594 lines
22 KiB
TypeScript
594 lines
22 KiB
TypeScript
import { shQuote } from "../platform-infra-public-service";
|
|
|
|
import type { ExternalActiveSecretMaterial, Sub2ApiConfig, Sub2ApiHostDockerConfig, Sub2ApiTargetConfig } from "./entry";
|
|
import { requiredSecretKeys } from "./entry";
|
|
import { imageRef, targetDatabase, targetDependencyImages, targetImage } from "./manifest";
|
|
|
|
export function requireHostDockerConfig(target: Sub2ApiTargetConfig): Sub2ApiHostDockerConfig {
|
|
if (target.hostDocker === null) throw new Error(`target ${target.id} requires hostDocker config when runtimeMode=host-docker`);
|
|
return target.hostDocker;
|
|
}
|
|
|
|
export function hostDockerPlanSummary(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): Record<string, unknown> {
|
|
const host = requireHostDockerConfig(target);
|
|
const database = targetDatabase(sub2api, target);
|
|
return {
|
|
runtimeMode: "host-docker",
|
|
route: target.route,
|
|
projectName: host.projectName,
|
|
workDir: host.workDir,
|
|
composePath: host.composePath,
|
|
envPath: host.envPath,
|
|
appDataDir: host.appDataDir,
|
|
appPort: host.appPort,
|
|
redisPort: host.redisPort,
|
|
databaseHost: database.host,
|
|
databasePort: database.port,
|
|
databaseSslMode: database.sslMode,
|
|
noProxy: host.noProxy,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
export function hostDockerComposeYaml(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
|
|
const host = requireHostDockerConfig(target);
|
|
const dependencyImages = targetDependencyImages(sub2api, target);
|
|
return `services:
|
|
redis:
|
|
image: ${yamlString(dependencyImages.redis)}
|
|
container_name: ${yamlString(`${host.projectName}-redis`)}
|
|
network_mode: host
|
|
restart: unless-stopped
|
|
command:
|
|
- redis-server
|
|
- --save
|
|
- ""
|
|
- --appendonly
|
|
- "no"
|
|
- --bind
|
|
- "127.0.0.1"
|
|
- --port
|
|
- ${yamlString(String(host.redisPort))}
|
|
app:
|
|
image: ${yamlString(imageRef(sub2api, target))}
|
|
container_name: ${yamlString(`${host.projectName}-app`)}
|
|
network_mode: host
|
|
restart: unless-stopped
|
|
env_file:
|
|
- ${yamlString(host.envPath)}
|
|
volumes:
|
|
- ${yamlString(`${host.appDataDir}:/app/data`)}
|
|
depends_on:
|
|
- redis
|
|
`;
|
|
}
|
|
|
|
export function hostDockerEnvFile(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig, secretMaterial: ExternalActiveSecretMaterial): string {
|
|
const host = requireHostDockerConfig(target);
|
|
const database = targetDatabase(sub2api, target);
|
|
const urlAllowlist = sub2api.security.urlAllowlist;
|
|
const values: Record<string, string> = {
|
|
AUTO_SETUP: "true",
|
|
SERVER_HOST: "127.0.0.1",
|
|
SERVER_PORT: String(host.appPort),
|
|
SERVER_MODE: "release",
|
|
RUN_MODE: "standard",
|
|
DATABASE_HOST: database.host,
|
|
DATABASE_PORT: String(database.port),
|
|
DATABASE_USER: database.user,
|
|
DATABASE_DBNAME: database.dbName,
|
|
DATABASE_SSLMODE: database.sslMode,
|
|
DATABASE_PASSWORD: secretMaterial.values.POSTGRES_PASSWORD,
|
|
DATABASE_MAX_OPEN_CONNS: "10",
|
|
DATABASE_MAX_IDLE_CONNS: "2",
|
|
DATABASE_CONN_MAX_LIFETIME_MINUTES: "30",
|
|
DATABASE_CONN_MAX_IDLE_TIME_MINUTES: "5",
|
|
REDIS_HOST: "127.0.0.1",
|
|
REDIS_PORT: String(host.redisPort),
|
|
REDIS_PASSWORD: "",
|
|
REDIS_DB: "0",
|
|
REDIS_POOL_SIZE: "32",
|
|
REDIS_MIN_IDLE_CONNS: "2",
|
|
REDIS_ENABLE_TLS: "false",
|
|
ADMIN_EMAIL: "admin@sub2api.platform-infra.local",
|
|
ADMIN_PASSWORD: secretMaterial.values.ADMIN_PASSWORD,
|
|
JWT_SECRET: secretMaterial.values.JWT_SECRET,
|
|
TOTP_ENCRYPTION_KEY: secretMaterial.values.TOTP_ENCRYPTION_KEY,
|
|
JWT_EXPIRE_HOUR: "24",
|
|
TZ: "Asia/Shanghai",
|
|
SECURITY_URL_ALLOWLIST_ENABLED: String(urlAllowlist.enabled),
|
|
SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP: String(urlAllowlist.allowInsecureHttp),
|
|
SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS: String(urlAllowlist.allowPrivateHosts),
|
|
SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS: urlAllowlist.upstreamHosts.join(","),
|
|
UPDATE_PROXY_URL: "",
|
|
HTTP_PROXY: "",
|
|
HTTPS_PROXY: "",
|
|
ALL_PROXY: "",
|
|
http_proxy: "",
|
|
https_proxy: "",
|
|
all_proxy: "",
|
|
NO_PROXY: host.noProxy.join(","),
|
|
no_proxy: host.noProxy.join(","),
|
|
GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT: "0",
|
|
GATEWAY_OPENAI_HTTP2_ENABLED: "true",
|
|
GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1: "true",
|
|
GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD: "2",
|
|
GATEWAY_OPENAI_HTTP2_FALLBACK_WINDOW_SECONDS: "60",
|
|
GATEWAY_OPENAI_HTTP2_FALLBACK_TTL_SECONDS: "600",
|
|
GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT: "900",
|
|
GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL: "10",
|
|
GATEWAY_IMAGE_CONCURRENCY_ENABLED: "false",
|
|
GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS: "0",
|
|
GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE: "reject",
|
|
GATEWAY_IMAGE_CONCURRENCY_WAIT_TIMEOUT_SECONDS: "30",
|
|
GATEWAY_IMAGE_CONCURRENCY_MAX_WAITING_REQUESTS: "100",
|
|
};
|
|
return `${Object.entries(values).map(([key, value]) => `${key}=${envFileValue(value)}`).join("\n")}\n`;
|
|
}
|
|
|
|
export function hostDockerDryRunScript(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
|
|
const host = requireHostDockerConfig(target);
|
|
const compose = hostDockerComposeYaml(sub2api, target);
|
|
const composeB64 = Buffer.from(compose, "utf8").toString("base64");
|
|
return `
|
|
set -u
|
|
tmp="$(mktemp -d)"
|
|
trap 'rm -rf "$tmp"' EXIT
|
|
compose="$tmp/docker-compose.yml"
|
|
printf '%s' '${composeB64}' | base64 -d >"$compose"
|
|
docker_out="$tmp/docker.out"
|
|
docker_err="$tmp/docker.err"
|
|
compose_out="$tmp/compose.out"
|
|
compose_err="$tmp/compose.err"
|
|
ports_out="$tmp/ports.out"
|
|
ports_err="$tmp/ports.err"
|
|
docker_bin=""
|
|
if docker ps >/dev/null 2>&1; then
|
|
docker_bin="docker"
|
|
elif sudo docker ps >/dev/null 2>&1; then
|
|
docker_bin="sudo docker"
|
|
fi
|
|
if [ -n "$docker_bin" ]; then
|
|
$docker_bin ps --format '{{json .}}' >"$docker_out" 2>"$docker_err"
|
|
docker_rc=$?
|
|
if $docker_bin compose version >"$compose_out" 2>"$compose_err"; then
|
|
compose_rc=0
|
|
elif command -v docker-compose >/dev/null 2>&1 && docker-compose version >"$compose_out" 2>"$compose_err"; then
|
|
compose_rc=0
|
|
else
|
|
compose_rc=0
|
|
printf '%s\\n' 'docker compose is absent; apply will use raw docker run fallback' >"$compose_out"
|
|
fi
|
|
else
|
|
docker_rc=1
|
|
compose_rc=1
|
|
: >"$docker_out"
|
|
printf '%s\\n' 'docker daemon is not accessible' >"$docker_err"
|
|
: >"$compose_out"
|
|
printf '%s\\n' 'docker compose is not accessible' >"$compose_err"
|
|
fi
|
|
{
|
|
for port in ${host.appPort} ${host.redisPort}; do
|
|
state="free"
|
|
if command -v ss >/dev/null 2>&1 && ss -ltn | awk '{print $4}' | grep -E "(:|\\])$port$" >/dev/null 2>&1; then
|
|
state="listening"
|
|
fi
|
|
printf 'port=%s state=%s\\n' "$port" "$state"
|
|
done
|
|
} >"$ports_out" 2>"$ports_err"
|
|
ports_rc=$?
|
|
python3 - "$docker_rc" "$compose_rc" "$ports_rc" "$docker_out" "$docker_err" "$compose_out" "$compose_err" "$ports_out" "$ports_err" <<'PY'
|
|
import json
|
|
import sys
|
|
|
|
docker_rc, compose_rc, ports_rc = [int(value) for value in sys.argv[1:4]]
|
|
paths = sys.argv[4:]
|
|
|
|
def text(path, limit=4000):
|
|
try:
|
|
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
|
|
except FileNotFoundError:
|
|
return ""
|
|
|
|
payload = {
|
|
"ok": docker_rc == 0 and compose_rc == 0 and ports_rc == 0,
|
|
"target": "${target.id}",
|
|
"runtimeMode": "host-docker",
|
|
"route": "${target.route}",
|
|
"projectName": "${host.projectName}",
|
|
"composePath": "${host.composePath}",
|
|
"envPath": "${host.envPath}",
|
|
"appDataDir": "${host.appDataDir}",
|
|
"image": "${imageRef(sub2api, target)}",
|
|
"dependencyImages": ${JSON.stringify(targetDependencyImages(sub2api, target))},
|
|
"ports": {
|
|
"app": ${host.appPort},
|
|
"redis": ${host.redisPort},
|
|
"observed": text(paths[4], 2000),
|
|
},
|
|
"steps": {
|
|
"docker": {"exitCode": docker_rc, "stdout": text(paths[0]), "stderr": text(paths[1])},
|
|
"dockerCompose": {"exitCode": compose_rc, "stdout": text(paths[2]), "stderr": text(paths[3])},
|
|
"portObservation": {"exitCode": ports_rc, "stdout": text(paths[4]), "stderr": text(paths[5])},
|
|
},
|
|
"valuesPrinted": False,
|
|
}
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
sys.exit(0 if payload["ok"] else 1)
|
|
PY
|
|
`;
|
|
}
|
|
|
|
export function hostDockerApplyScript(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig, secretMaterial: ExternalActiveSecretMaterial | null): string {
|
|
if (secretMaterial === null) throw new Error("host-docker apply requires external-active secret material");
|
|
const host = requireHostDockerConfig(target);
|
|
const compose = hostDockerComposeYaml(sub2api, target);
|
|
const env = hostDockerEnvFile(sub2api, target, secretMaterial);
|
|
const composeB64 = Buffer.from(compose, "utf8").toString("base64");
|
|
const envB64 = Buffer.from(env, "utf8").toString("base64");
|
|
const image = targetImage(sub2api, target);
|
|
const dependencyImages = targetDependencyImages(sub2api, target);
|
|
const pullCommand = image.pullPolicy === "Always"
|
|
? `$docker_bin pull ${shQuote(imageRef(sub2api, target))} >"$pull_out" 2>"$pull_err"\napp_pull_rc=$?\n$docker_bin pull ${shQuote(dependencyImages.redis)} >>"$pull_out" 2>>"$pull_err"\nredis_pull_rc=$?\nif [ "$app_pull_rc" -eq 0 ] && [ "$redis_pull_rc" -eq 0 ]; then pull_rc=0; else pull_rc=1; fi`
|
|
: `: >"$pull_out"\n: >"$pull_err"\npull_rc=0`;
|
|
const secretSourceSummary = {
|
|
sourceRef: secretMaterial.sourceRef,
|
|
sourcePath: secretMaterial.sourcePath,
|
|
appSourceRef: secretMaterial.appSourceRef,
|
|
appSourcePath: secretMaterial.appSourcePath,
|
|
sourceAction: secretMaterial.action,
|
|
fingerprint: secretMaterial.fingerprint,
|
|
valuesPrinted: false,
|
|
};
|
|
return `
|
|
set -u
|
|
tmp="$(mktemp -d)"
|
|
trap 'rm -rf "$tmp"' EXIT
|
|
compose_tmp="$tmp/docker-compose.yml"
|
|
env_tmp="$tmp/sub2api.env"
|
|
printf '%s' '${composeB64}' | base64 -d >"$compose_tmp"
|
|
printf '%s' '${envB64}' | base64 -d >"$env_tmp"
|
|
docker_out="$tmp/docker.out"
|
|
docker_err="$tmp/docker.err"
|
|
install_out="$tmp/install.out"
|
|
install_err="$tmp/install.err"
|
|
pull_out="$tmp/pull.out"
|
|
pull_err="$tmp/pull.err"
|
|
up_out="$tmp/up.out"
|
|
up_err="$tmp/up.err"
|
|
health_out="$tmp/health.out"
|
|
health_err="$tmp/health.err"
|
|
docker_bin=""
|
|
if docker ps >/dev/null 2>&1; then
|
|
docker_bin="docker"
|
|
elif sudo docker ps >/dev/null 2>&1; then
|
|
docker_bin="sudo docker"
|
|
fi
|
|
if [ -n "$docker_bin" ]; then
|
|
if $docker_bin compose version >"$docker_out" 2>"$docker_err"; then
|
|
compose_cmd="$docker_bin compose"
|
|
compose_mode="plugin"
|
|
docker_rc=0
|
|
elif command -v docker-compose >/dev/null 2>&1 && docker-compose version >"$docker_out" 2>"$docker_err"; then
|
|
compose_cmd="docker-compose"
|
|
compose_mode="standalone"
|
|
docker_rc=0
|
|
else
|
|
compose_cmd=""
|
|
compose_mode="raw-docker"
|
|
$docker_bin version >"$docker_out" 2>"$docker_err"
|
|
docker_rc=$?
|
|
fi
|
|
else
|
|
compose_cmd=""
|
|
compose_mode="none"
|
|
docker_rc=1
|
|
: >"$docker_out"
|
|
printf '%s\\n' 'docker daemon or docker compose is not accessible' >"$docker_err"
|
|
fi
|
|
install_rc=1
|
|
if [ "$docker_rc" -eq 0 ]; then
|
|
sudo mkdir -p ${shQuote(host.workDir)} ${shQuote(host.appDataDir)} "$(dirname ${shQuote(host.composePath)})" "$(dirname ${shQuote(host.envPath)})" >"$install_out" 2>"$install_err"
|
|
install_rc=$?
|
|
if [ "$install_rc" -eq 0 ]; then
|
|
sudo install -m 0644 "$compose_tmp" ${shQuote(host.composePath)} >>"$install_out" 2>>"$install_err"
|
|
install_rc=$?
|
|
fi
|
|
if [ "$install_rc" -eq 0 ]; then
|
|
current_uid="$(id -u)"
|
|
current_gid="$(id -g)"
|
|
sudo install -m 0600 -o "$current_uid" -g "$current_gid" "$env_tmp" ${shQuote(host.envPath)} >>"$install_out" 2>>"$install_err"
|
|
install_rc=$?
|
|
fi
|
|
else
|
|
: >"$install_out"
|
|
: >"$install_err"
|
|
fi
|
|
if [ "$install_rc" -eq 0 ]; then
|
|
${pullCommand}
|
|
else
|
|
: >"$pull_out"
|
|
printf '%s\\n' 'skipped because install failed' >"$pull_err"
|
|
pull_rc=1
|
|
fi
|
|
if [ "$pull_rc" -eq 0 ]; then
|
|
if [ -n "$compose_cmd" ]; then
|
|
$compose_cmd -p ${shQuote(host.projectName)} -f ${shQuote(host.composePath)} up -d --remove-orphans >"$up_out" 2>"$up_err"
|
|
up_rc=$?
|
|
else
|
|
$docker_bin rm -f ${shQuote(`${host.projectName}-app`)} ${shQuote(`${host.projectName}-redis`)} >"$up_out" 2>"$up_err" || true
|
|
$docker_bin run -d --name ${shQuote(`${host.projectName}-redis`)} \\
|
|
--restart unless-stopped \\
|
|
--network host \\
|
|
${shQuote(dependencyImages.redis)} \\
|
|
redis-server --save "" --appendonly no --bind 127.0.0.1 --port ${host.redisPort} >>"$up_out" 2>>"$up_err"
|
|
redis_run_rc=$?
|
|
if [ "$redis_run_rc" -eq 0 ]; then
|
|
$docker_bin run -d --name ${shQuote(`${host.projectName}-app`)} \\
|
|
--restart unless-stopped \\
|
|
--network host \\
|
|
--env-file ${shQuote(host.envPath)} \\
|
|
-v ${shQuote(`${host.appDataDir}:/app/data`)} \\
|
|
${shQuote(imageRef(sub2api, target))} >>"$up_out" 2>>"$up_err"
|
|
app_run_rc=$?
|
|
else
|
|
app_run_rc=1
|
|
fi
|
|
if [ "$redis_run_rc" -eq 0 ] && [ "$app_run_rc" -eq 0 ]; then up_rc=0; else up_rc=1; fi
|
|
fi
|
|
else
|
|
: >"$up_out"
|
|
printf '%s\\n' 'skipped because pull failed' >"$up_err"
|
|
up_rc=1
|
|
fi
|
|
health_rc=1
|
|
if [ "$up_rc" -eq 0 ]; then
|
|
deadline=$(( $(date +%s) + 180 ))
|
|
while [ "$(date +%s)" -lt "$deadline" ]; do
|
|
if curl -fsS --max-time 5 http://127.0.0.1:${host.appPort}/health >"$health_out" 2>"$health_err"; then
|
|
health_rc=0
|
|
break
|
|
fi
|
|
sleep 3
|
|
done
|
|
else
|
|
: >"$health_out"
|
|
printf '%s\\n' 'skipped because compose up failed' >"$health_err"
|
|
fi
|
|
python3 - "$docker_rc" "$install_rc" "$pull_rc" "$up_rc" "$health_rc" "$compose_mode" "$docker_out" "$docker_err" "$install_out" "$install_err" "$pull_out" "$pull_err" "$up_out" "$up_err" "$health_out" "$health_err" <<'PY'
|
|
import json
|
|
import sys
|
|
|
|
docker_rc, install_rc, pull_rc, up_rc, health_rc = [int(value) for value in sys.argv[1:6]]
|
|
docker_mode = sys.argv[6]
|
|
paths = sys.argv[7:]
|
|
|
|
def text(path, limit=4000):
|
|
try:
|
|
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
|
|
except FileNotFoundError:
|
|
return ""
|
|
|
|
payload = {
|
|
"ok": docker_rc == 0 and install_rc == 0 and pull_rc == 0 and up_rc == 0 and health_rc == 0,
|
|
"target": "${target.id}",
|
|
"runtimeMode": "host-docker",
|
|
"route": "${target.route}",
|
|
"projectName": "${host.projectName}",
|
|
"dockerMode": docker_mode,
|
|
"composePath": "${host.composePath}",
|
|
"envPath": "${host.envPath}",
|
|
"appDataDir": "${host.appDataDir}",
|
|
"image": "${imageRef(sub2api, target)}",
|
|
"secret": {
|
|
"requiredKeys": ${JSON.stringify(requiredSecretKeys)},
|
|
"externalActiveSource": json.loads(${JSON.stringify(JSON.stringify(secretSourceSummary))}),
|
|
"valuesPrinted": False,
|
|
},
|
|
"steps": {
|
|
"docker": {"exitCode": docker_rc, "stdout": text(paths[0]), "stderr": text(paths[1])},
|
|
"installFiles": {"exitCode": install_rc, "stdout": text(paths[2]), "stderr": text(paths[3])},
|
|
"pull": {"exitCode": pull_rc, "stdout": text(paths[4]), "stderr": text(paths[5])},
|
|
"composeUp": {"exitCode": up_rc, "stdout": text(paths[6]), "stderr": text(paths[7])},
|
|
"localHealth": {"exitCode": health_rc, "stdout": text(paths[8]), "stderr": text(paths[9])},
|
|
},
|
|
"valuesPrinted": False,
|
|
}
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
sys.exit(0 if payload["ok"] else 1)
|
|
PY
|
|
`;
|
|
}
|
|
|
|
export function hostDockerStatusScript(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
|
|
const host = requireHostDockerConfig(target);
|
|
return `
|
|
set -u
|
|
tmp="$(mktemp -d)"
|
|
trap 'rm -rf "$tmp"' EXIT
|
|
docker_out="$tmp/docker.out"
|
|
docker_err="$tmp/docker.err"
|
|
ps_out="$tmp/ps.out"
|
|
ps_err="$tmp/ps.err"
|
|
health_out="$tmp/health.out"
|
|
health_err="$tmp/health.err"
|
|
caddy_out="$tmp/caddy.out"
|
|
caddy_err="$tmp/caddy.err"
|
|
docker_bin=""
|
|
if docker ps >/dev/null 2>&1; then
|
|
docker_bin="docker"
|
|
elif sudo docker ps >/dev/null 2>&1; then
|
|
docker_bin="sudo docker"
|
|
fi
|
|
if [ -n "$docker_bin" ]; then
|
|
$docker_bin version --format '{{json .}}' >"$docker_out" 2>"$docker_err"
|
|
docker_rc=$?
|
|
$docker_bin ps -a --filter label=com.docker.compose.project=${shQuote(host.projectName)} --format '{{json .}}' >"$ps_out" 2>"$ps_err"
|
|
$docker_bin ps -a --filter name=${shQuote(`${host.projectName}-`)} --format '{{json .}}' >>"$ps_out" 2>>"$ps_err"
|
|
ps_rc=$?
|
|
else
|
|
docker_rc=1
|
|
ps_rc=1
|
|
: >"$docker_out"
|
|
printf '%s\\n' 'docker daemon is not accessible' >"$docker_err"
|
|
: >"$ps_out"
|
|
: >"$ps_err"
|
|
fi
|
|
curl -fsS --max-time 5 http://127.0.0.1:${host.appPort}/health >"$health_out" 2>"$health_err"
|
|
health_rc=$?
|
|
systemctl is-active caddy >"$caddy_out" 2>"$caddy_err"
|
|
caddy_rc=$?
|
|
python3 - "$docker_rc" "$ps_rc" "$health_rc" "$caddy_rc" "$docker_out" "$docker_err" "$ps_out" "$ps_err" "$health_out" "$health_err" "$caddy_out" "$caddy_err" <<'PY'
|
|
import json
|
|
import sys
|
|
|
|
docker_rc, ps_rc, health_rc, caddy_rc = [int(value) for value in sys.argv[1:5]]
|
|
paths = sys.argv[5:]
|
|
|
|
def text(path, limit=4000):
|
|
try:
|
|
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
|
|
except FileNotFoundError:
|
|
return ""
|
|
|
|
containers = []
|
|
for line in text(paths[2], 20000).splitlines():
|
|
try:
|
|
containers.append(json.loads(line))
|
|
except json.JSONDecodeError:
|
|
pass
|
|
|
|
payload = {
|
|
"ok": docker_rc == 0 and ps_rc == 0 and health_rc == 0,
|
|
"target": "${target.id}",
|
|
"runtimeMode": "host-docker",
|
|
"route": "${target.route}",
|
|
"projectName": "${host.projectName}",
|
|
"serviceDns": "host-docker:127.0.0.1:${host.appPort}",
|
|
"containers": containers,
|
|
"secret": {"managedByThisApply": True, "valuesPrinted": False},
|
|
"networkPolicy": {"ok": True, "mode": "not-applicable-host-docker"},
|
|
"egressProxy": {"enabled": False},
|
|
"sentinel": {"enabled": False, "mode": "not-deployed-host-docker"},
|
|
"checks": {
|
|
"docker": {"exitCode": docker_rc, "stdout": text(paths[0]), "stderr": text(paths[1])},
|
|
"composeContainers": {"exitCode": ps_rc, "stdout": text(paths[2], 8000), "stderr": text(paths[3])},
|
|
"localHealth": {"exitCode": health_rc, "stdout": text(paths[4]), "stderr": text(paths[5])},
|
|
"caddyService": {"exitCode": caddy_rc, "stdout": text(paths[6]), "stderr": text(paths[7])},
|
|
},
|
|
"valuesPrinted": False,
|
|
}
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
sys.exit(0 if payload["ok"] else 1)
|
|
PY
|
|
`;
|
|
}
|
|
|
|
export function hostDockerValidateScript(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
|
|
const host = requireHostDockerConfig(target);
|
|
const database = targetDatabase(sub2api, target);
|
|
const exposure = target.publicExposure?.enabled ? target.publicExposure : null;
|
|
const publicProbe = exposure === null ? `
|
|
public_rc=0
|
|
: >"$tmp/public.out"
|
|
: >"$tmp/public.err"
|
|
` : `
|
|
curl -kfsS --max-time 20 --resolve ${shQuote(`${exposure.dns.hostname}:443:127.0.0.1`)} ${shQuote(`${exposure.publicBaseUrl}/health`)} >"$tmp/public.out" 2>"$tmp/public.err"
|
|
public_rc=$?
|
|
`;
|
|
return `
|
|
set -u
|
|
tmp="$(mktemp -d)"
|
|
trap 'rm -rf "$tmp"' EXIT
|
|
docker_out="$tmp/docker.out"
|
|
docker_err="$tmp/docker.err"
|
|
app_out="$tmp/app.out"
|
|
app_err="$tmp/app.err"
|
|
redis_out="$tmp/redis.out"
|
|
redis_err="$tmp/redis.err"
|
|
pg_out="$tmp/pg.out"
|
|
pg_err="$tmp/pg.err"
|
|
health_out="$tmp/health.out"
|
|
health_err="$tmp/health.err"
|
|
docker_bin=""
|
|
if docker ps >/dev/null 2>&1; then
|
|
docker_bin="docker"
|
|
elif sudo docker ps >/dev/null 2>&1; then
|
|
docker_bin="sudo docker"
|
|
fi
|
|
if [ -n "$docker_bin" ]; then
|
|
$docker_bin inspect -f '{{.State.Running}}' ${shQuote(`${host.projectName}-app`)} >"$app_out" 2>"$app_err"
|
|
app_rc=$?
|
|
$docker_bin exec ${shQuote(`${host.projectName}-redis`)} redis-cli -h 127.0.0.1 -p ${host.redisPort} ping >"$redis_out" 2>"$redis_err"
|
|
redis_rc=$?
|
|
$docker_bin ps --filter label=com.docker.compose.project=${shQuote(host.projectName)} --format '{{json .}}' >"$docker_out" 2>"$docker_err"
|
|
$docker_bin ps --filter name=${shQuote(`${host.projectName}-`)} --format '{{json .}}' >>"$docker_out" 2>>"$docker_err"
|
|
docker_rc=$?
|
|
else
|
|
app_rc=1
|
|
redis_rc=1
|
|
docker_rc=1
|
|
: >"$app_out"
|
|
: >"$redis_out"
|
|
: >"$docker_out"
|
|
printf '%s\\n' 'docker daemon is not accessible' >"$app_err"
|
|
printf '%s\\n' 'docker daemon is not accessible' >"$redis_err"
|
|
printf '%s\\n' 'docker daemon is not accessible' >"$docker_err"
|
|
fi
|
|
if command -v pg_isready >/dev/null 2>&1; then
|
|
pg_isready -h ${shQuote(database.host)} -p ${database.port} -U ${shQuote(database.user)} -d ${shQuote(database.dbName)} >"$pg_out" 2>"$pg_err"
|
|
pg_rc=$?
|
|
else
|
|
pg_rc=127
|
|
: >"$pg_out"
|
|
printf '%s\\n' 'pg_isready is not installed on PK01 host' >"$pg_err"
|
|
fi
|
|
curl -fsS --max-time 10 http://127.0.0.1:${host.appPort}/health >"$health_out" 2>"$health_err"
|
|
health_rc=$?
|
|
${publicProbe}
|
|
python3 - "$docker_rc" "$app_rc" "$redis_rc" "$pg_rc" "$health_rc" "$public_rc" "$docker_out" "$docker_err" "$app_out" "$app_err" "$redis_out" "$redis_err" "$pg_out" "$pg_err" "$health_out" "$health_err" "$tmp/public.out" "$tmp/public.err" <<'PY'
|
|
import json
|
|
import sys
|
|
|
|
docker_rc, app_rc, redis_rc, pg_rc, health_rc, public_rc = [int(value) for value in sys.argv[1:7]]
|
|
paths = sys.argv[7:]
|
|
|
|
def text(path, limit=4000):
|
|
try:
|
|
return open(path, encoding="utf-8", errors="replace").read()[-limit:]
|
|
except FileNotFoundError:
|
|
return ""
|
|
|
|
payload = {
|
|
"ok": docker_rc == 0 and app_rc == 0 and redis_rc == 0 and pg_rc == 0 and health_rc == 0 and public_rc == 0,
|
|
"target": "${target.id}",
|
|
"runtimeMode": "host-docker",
|
|
"route": "${target.route}",
|
|
"serviceDns": "host-docker:127.0.0.1:${host.appPort}",
|
|
"checks": {
|
|
"dockerComposeProject": {"exitCode": docker_rc, "stdout": text(paths[0], 8000), "stderr": text(paths[1])},
|
|
"appContainerRunning": {"exitCode": app_rc, "stdout": text(paths[2]), "stderr": text(paths[3])},
|
|
"redisPing": {"exitCode": redis_rc, "stdout": text(paths[4]), "stderr": text(paths[5])},
|
|
"postgresLocalPgIsReady": {"exitCode": pg_rc, "host": "${database.host}", "port": ${database.port}, "user": "${database.user}", "dbName": "${database.dbName}", "stdout": text(paths[6]), "stderr": text(paths[7])},
|
|
"sub2apiLocalHealth": {"exitCode": health_rc, "stdout": text(paths[8]), "stderr": text(paths[9])},
|
|
"sub2apiPublicHealthViaPk01Caddy": {"exitCode": public_rc, "stdout": text(paths[10]), "stderr": text(paths[11])},
|
|
"sentinel": {"ok": True, "mode": "not-deployed-host-docker"},
|
|
"egressProxy": {"ok": True, "mode": "not-deployed-host-docker"},
|
|
},
|
|
"valuesPrinted": False,
|
|
}
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
sys.exit(0 if payload["ok"] else 1)
|
|
PY
|
|
`;
|
|
}
|
|
|
|
function yamlString(value: string): string {
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
function envFileValue(value: string): string {
|
|
if (/^[A-Za-z0-9_./:@,+-]*$/u.test(value)) return value;
|
|
return JSON.stringify(value);
|
|
}
|