diff --git a/config/platform-infra/nginx.yaml b/config/platform-infra/nginx.yaml new file mode 100644 index 00000000..794462c2 --- /dev/null +++ b/config/platform-infra/nginx.yaml @@ -0,0 +1,86 @@ +version: 1 +kind: platform-infra-nginx + +metadata: + name: nginx + +defaults: + target: PK01 + +artifacts: + image: + buildTarget: NC01 + image: nginx-lb-logger:1.0.0 + imageId: sha256:a0d4cef22b9e9e602f853da17bf965408d4cbb5c0a06e272bff1342038ef9343 + localPath: /root/demo/nginx-lb-logger/artifacts/nginx-lb-logger-1.0.0.tar.gz + remotePath: /opt/unidesk/nginx/artifacts/nginx-lb-logger-1.0.0.tar.gz + sha256: af4bb6a5ad4ae9c49e63133be6249c1b3401aa6e9f2c4cfa92b56e8f66660471 + bytes: 65078179 + composePlugin: + localPath: /usr/libexec/docker/cli-plugins/docker-compose + remotePath: /usr/local/lib/docker/cli-plugins/docker-compose + sha256: c0ef2e286ba1c45bf1868b70a1c71d30a802bb3996888f2f986c3166f9e45f57 + +targets: + PK01: + route: PK01 + runtime: + workDir: /opt/unidesk/nginx + composePath: /opt/unidesk/nginx/compose.yaml + envPath: /opt/unidesk/nginx/runtime.env + projectName: nginx + containerName: nginx + networkMode: host + restart: unless-stopped + resources: + cpus: "0.25" + memory: 64m + pidsLimit: 64 + logDir: /opt/unidesk/nginx/logs + routes: + - listenPort: 19080 + upstreams: + - host: 127.0.0.1 + port: 19081 + - host: 127.0.0.1 + port: 19082 + environment: + NGINX_ADMIN_PORT: "19089" + NGINX_LOAD_BALANCE_METHOD: round_robin + NGINX_UPSTREAM_KEEPALIVE: "4" + NGINX_UPSTREAM_MAX_FAILS: "1" + NGINX_UPSTREAM_FAIL_TIMEOUT: 5s + NGINX_CLIENT_MAX_BODY_SIZE: 1m + NGINX_CLIENT_BODY_BUFFER_SIZE: 1m + NGINX_PROXY_CONNECT_TIMEOUT: 2s + NGINX_PROXY_READ_TIMEOUT: 10s + NGINX_PROXY_SEND_TIMEOUT: 10s + NGINX_PROXY_HOST: $host + NGINX_BACKEND_ID_HEADER: X-Backend + LOGGER_PATH: /var/log/logger/requests.jsonl + LOGGER_MAX_BYTES: "10485760" + LOGGER_CHECK_INTERVAL_SECONDS: "60" + LOGGER_ADMIN_PREFIX: /_logger + LOGGER_TAIL_DEFAULT_LINES: "100" + LOGGER_TAIL_MAX_LINES: "1000" + LOGGER_TAIL_MAX_BYTES: "1048576" + LOGGER_DOWNLOAD_FILENAME: requests.jsonl + NGINX_WORKER_PROCESSES: "1" + apiKey: + sourceRef: .state/secrets/platform-infra/nginx.env + sourceKey: LOGGER_API_KEY + targetKey: LOGGER_API_KEY + requestHeader: X-API-Key + validation: + requestCount: 4 + cleanupAfterSuccess: true + backendScriptPath: /opt/unidesk/nginx/test-backend.py + backends: + - id: backend-1 + bindHost: 127.0.0.1 + port: 19081 + unit: nginx-test-backend-1.service + - id: backend-2 + bindHost: 127.0.0.1 + port: 19082 + unit: nginx-test-backend-2.service diff --git a/docs/reference/pk01.md b/docs/reference/pk01.md index 9ecd436b..c11fefb4 100644 --- a/docs/reference/pk01.md +++ b/docs/reference/pk01.md @@ -191,3 +191,18 @@ Interpretation guide: | `/home/ubuntu/.vscode-server` | VS Code remote server, extensions, and cache | Do not delete installed servers/extensions by default; cached VSIX cleanup needs an explicit policy. | | `/var/lib/docker` | Docker overlay/image/container state for PK01 workloads | Do not prune generically; inspect running containers first. | | `/var/log/journal` | systemd journal | Managed by journald cap; use sudo when vacuuming manually. | + +## Nginx Host-Docker 受控部署 + +- PK01 的 Nginx 负载均衡器由 `config/platform-infra/nginx.yaml` 管理。 +- 部署形态与 PK01 Sub2API 一致,使用 UniDesk YAML-first 的 host-Docker Compose 入口。 +- 正式命令是 `bun scripts/cli.ts platform-infra nginx apply --target PK01 --confirm`。 +- apply 默认提交异步 job;长传输、镜像加载和验证不能占用单次 SSH 长连接。 +- 容器使用 host network,并限制为 `64m` 内存、`0.25` CPU 和 64 个 PID。 +- 业务监听端口、上游列表、管理端口、日志上限和 logger API key 来源均来自 owning YAML。 +- PK01 不构建镜像;镜像在 NC01 构建后以 SHA-256 和镜像 ID 校验的产物下发。 +- 验证期间临时创建两个 loopback host Python/systemd 后端。 +- 验证固定发送四个串行请求,并核对响应后端与 Nginx JSONL 记录顺序。 +- 验证 logger API 的未认证拒绝、查询、tail、下载和清理能力。 +- 验证结束后删除两个后端及其 unit,保留 Nginx 容器和已清空的日志文件。 +- `platform-infra nginx status --target PK01` 必须报告测试后端活动数为零。 diff --git a/docs/reference/platform-infra.md b/docs/reference/platform-infra.md index 175de5b3..04fc59cc 100644 --- a/docs/reference/platform-infra.md +++ b/docs/reference/platform-infra.md @@ -323,3 +323,21 @@ spec: This policy must be included in the `sub2api plan` / `apply` manifest rendering so that it is created as part of the normal deployment flow, not maintained as a manual one-off. `platform-infra sub2api status` must report whether `NetworkPolicy/allow-all` exists and still has `podSelector: {}`, `policyTypes: [Ingress, Egress]`, `ingress: [{}]`, and `egress: [{}]`. For active bundled targets, `platform-infra sub2api validate` must also run temporary in-namespace probe pods that connect to `sub2api-postgres:5432` and `sub2api-redis:6379`; local `pg_isready` inside the PostgreSQL pod alone is insufficient because it does not exercise kube-router cross-pod policy evaluation. For external-DB standby targets, `validate --target` checks the predeployment shape: no local PostgreSQL, app replicas zero, ClusterIP services, allow-all NetworkPolicy, local Redis declared as ephemeral cache with readiness required only when Redis replicas are above zero, and no standby-disabled public FRP, egress proxy, or sentinel CronJob remains. For external-DB active targets, `validate --target` checks that the app uses the external database endpoint, local Redis is ephemeral, no local PostgreSQL StatefulSet exists, and any YAML-declared egress proxy and public exposure resources are present and probed through their configured paths. + +## Nginx 负载均衡与 Logger 边界 + +- Nginx host-Docker 服务的配置真相是 `config/platform-infra/nginx.yaml`。 +- 正式入口是 `platform-infra nginx plan|apply|status`,不得用 Demo 脚本直接修改 PK01。 +- 镜像由 owning YAML 固定产物路径、SHA-256 和镜像 ID,并在 NC01 构建。 +- `targets..runtime.routes` 是业务监听端口与上游的唯一真相。 +- 一个 target 可以声明一个或多个监听端口,每个端口可以声明一个或多个 HTTP 上游。 +- CLI 将路由数组渲染为容器的 `NGINX_ROUTES`,不在源码中保存应用专用端口。 +- Nginx 配置使用 `NGINX_*`,日志收集和管理配置使用 `LOGGER_*`。 +- logger API key 只允许由 YAML `sourceRef`、`sourceKey` 和 `targetKey` 下发。 +- API key 输出只披露 presence 与 fingerprint,禁止输出或嵌入受控 job 命令。 +- 除健康检查外,logger 查询、tail、下载和清理接口均要求 `X-API-Key`。 +- JSONL 文件由 Nginx `access_log` 写入,验证器不得生成或补写日志。 +- `LOGGER_MAX_BYTES` 与检查周期必须由 YAML 明确设置,避免日志无限增长。 +- PK01 验证只发送四个串行请求,不执行压力测试。 +- 两个验证后端必须是 PK01 host Python/systemd 服务,不能作为容器部署。 +- 验证退出时必须停止并删除后端脚本和 unit;正式 Nginx 容器继续运行。 diff --git a/scripts/src/platform-infra-nginx.ts b/scripts/src/platform-infra-nginx.ts new file mode 100644 index 00000000..c9dca3dc --- /dev/null +++ b/scripts/src/platform-infra-nginx.ts @@ -0,0 +1,667 @@ +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, statSync } from "node:fs"; +import { isAbsolute, join } from "node:path"; +import type { UniDeskConfig } from "./config"; +import { repoRoot, rootPath } from "./config"; +import { startJob } from "./jobs"; +import { + capture, + compactCapture, + fingerprintValues, + parseEnvFile, + parseJsonOutput, + parseOpsApplyOptions, + parseOpsCommonOptions, + readYamlRecord, + shQuote, + type OpsApplyOptions, + type OpsCommonOptions, +} from "./platform-infra-ops-library"; + +const configPath = rootPath("config", "platform-infra", "nginx.yaml"); +const configLabel = "config/platform-infra/nginx.yaml"; + +interface Artifact { + localPath: string; + remotePath: string; + sha256: string; +} + +interface ImageArtifact extends Artifact { + buildTarget: string; + image: string; + imageId: string; + bytes: number; +} + +interface Route { + listenPort: number; + upstreams: Array<{ host: string; port: number }>; +} + +interface Backend { + id: string; + bindHost: string; + port: number; + unit: string; +} + +interface NginxTarget { + route: string; + runtime: { + workDir: string; + composePath: string; + envPath: string; + projectName: string; + containerName: string; + networkMode: "host"; + restart: string; + resources: { cpus: string; memory: string; pidsLimit: number }; + logDir: string; + routes: Route[]; + environment: Record; + }; + apiKey: { + sourceRef: string; + sourceKey: string; + targetKey: string; + requestHeader: string; + }; + validation: { + requestCount: number; + cleanupAfterSuccess: boolean; + backendScriptPath: string; + backends: Backend[]; + }; +} + +interface NginxConfig { + version: number; + kind: "platform-infra-nginx"; + metadata: { name: string }; + defaults: { target: string }; + artifacts: { image: ImageArtifact; composePlugin: Artifact }; + targets: Record; +} + +interface SecretMaterial { + sourcePath: string; + value: string; + fingerprint: string; +} + +export function nginxHelp(): Record { + return { + command: "platform-infra nginx plan|apply|status", + output: "json", + usage: [ + "bun scripts/cli.ts platform-infra nginx plan [--target PK01]", + "bun scripts/cli.ts platform-infra nginx apply [--target PK01] --dry-run", + "bun scripts/cli.ts platform-infra nginx apply [--target PK01] --confirm", + "bun scripts/cli.ts platform-infra nginx status [--target PK01] [--full|--raw]", + ], + configTruth: configLabel, + secretPolicy: "API key values are never printed; output contains presence and fingerprint only.", + }; +} + +export async function runPlatformInfraNginxCommand( + config: UniDeskConfig, + args: string[], +): Promise> { + const [action = "plan"] = args; + if (action === "plan") return plan(parseOpsCommonOptions(args.slice(1))); + if (action === "apply") return await apply(config, parseOpsApplyOptions(args.slice(1))); + if (action === "status") return await status(config, parseOpsCommonOptions(args.slice(1))); + return { ok: false, error: "unsupported-platform-infra-nginx-command", args, help: nginxHelp() }; +} + +function readConfig(): NginxConfig { + const config = readYamlRecord(configPath, "platform-infra-nginx"); + assert(config.version === 1, `${configLabel}.version must be 1`); + assert(config.metadata.name === "nginx", `${configLabel}.metadata.name must be nginx`); + assert(Object.keys(config.targets).length > 0, `${configLabel}.targets must not be empty`); + validateArtifact(config.artifacts.image, "artifacts.image"); + validateArtifact(config.artifacts.composePlugin, "artifacts.composePlugin"); + assert(Number.isInteger(config.artifacts.image.bytes), `${configLabel}.artifacts.image.bytes must be an integer`); + assert(config.artifacts.image.bytes > 0, `${configLabel}.artifacts.image.bytes must be positive`); + assert(/^sha256:[a-f0-9]{64}$/u.test(config.artifacts.image.imageId), "artifacts.image.imageId is invalid"); + for (const [targetId, target] of Object.entries(config.targets)) validateTarget(targetId, target); + return config; +} + +function validateArtifact(artifact: Artifact, path: string): void { + absolutePath(artifact.localPath, `${path}.localPath`); + absolutePath(artifact.remotePath, `${path}.remotePath`); + assert(/^[a-f0-9]{64}$/u.test(artifact.sha256), `${configLabel}.${path}.sha256 is invalid`); +} + +function validateTarget(id: string, target: NginxTarget): void { + simpleName(id, `targets.${id}`); + simpleName(target.route, `targets.${id}.route`); + const runtime = target.runtime; + for (const [key, value] of [ + ["workDir", runtime.workDir], + ["composePath", runtime.composePath], + ["envPath", runtime.envPath], + ["logDir", runtime.logDir], + ["backendScriptPath", target.validation.backendScriptPath], + ]) absolutePath(value, `targets.${id}.${key}`); + assert(runtime.networkMode === "host", `targets.${id}.runtime.networkMode must be host`); + simpleName(runtime.projectName, `targets.${id}.runtime.projectName`); + simpleName(runtime.containerName, `targets.${id}.runtime.containerName`); + assert(runtime.routes.length > 0, `targets.${id}.runtime.routes must not be empty`); + const listenPorts = new Set(); + for (const route of runtime.routes) { + port(route.listenPort, `targets.${id}.runtime.routes.listenPort`); + assert(!listenPorts.has(route.listenPort), `targets.${id}.runtime.routes contains a duplicate listenPort`); + listenPorts.add(route.listenPort); + assert(route.upstreams.length > 0, `targets.${id}.runtime.routes.upstreams must not be empty`); + for (const upstream of route.upstreams) { + assert(/^[A-Za-z0-9_.-]+$/u.test(upstream.host), `targets.${id} upstream host is invalid`); + port(upstream.port, `targets.${id} upstream port`); + } + } + assert(target.validation.requestCount === 4, `targets.${id}.validation.requestCount must be 4`); + assert(target.validation.cleanupAfterSuccess, `targets.${id}.validation.cleanupAfterSuccess must be true`); + assert(target.validation.backends.length === 2, `targets.${id}.validation.backends must contain two backends`); + for (const backend of target.validation.backends) { + simpleName(backend.id, `targets.${id}.validation.backends.id`); + assert(backend.bindHost === "127.0.0.1", `targets.${id} validation backend must bind to 127.0.0.1`); + port(backend.port, `targets.${id} validation backend port`); + assert(/^[A-Za-z0-9_.@-]+\.service$/u.test(backend.unit), `targets.${id} backend unit is invalid`); + } + for (const key of Object.keys(runtime.environment)) { + assert(/^(NGINX|LOGGER)_[A-Z0-9_]+$/u.test(key), `targets.${id}.runtime.environment key ${key} is invalid`); + } + assert(target.apiKey.sourceKey === "LOGGER_API_KEY", `targets.${id}.apiKey.sourceKey must be LOGGER_API_KEY`); + assert(target.apiKey.targetKey === "LOGGER_API_KEY", `targets.${id}.apiKey.targetKey must be LOGGER_API_KEY`); + assert(target.apiKey.requestHeader === "X-API-Key", `targets.${id}.apiKey.requestHeader must be X-API-Key`); +} + +function resolveTarget(config: NginxConfig, requested: string | null): { id: string; target: NginxTarget } { + const id = requested ?? config.defaults.target; + const target = config.targets[id]; + if (target === undefined) throw new Error(`unknown nginx target: ${id}`); + return { id, target }; +} + +function plan(options: OpsCommonOptions): Record { + const config = readConfig(); + const { id, target } = resolveTarget(config, options.targetId); + const secret = readSecret(target); + const image = localArtifactSummary(config.artifacts.image); + const composePlugin = localArtifactSummary(config.artifacts.composePlugin); + const compose = renderCompose(config, target); + return { + ok: image.valid && composePlugin.valid, + action: "platform-infra-nginx-plan", + mutation: false, + target: targetSummary(id, target), + artifacts: { image, composePlugin }, + compose: { + sha256: sha256(compose), + bytes: Buffer.byteLength(compose), + routes: renderRoutes(target.runtime.routes), + }, + apiKey: secretSummary(target, secret), + validation: validationSummary(target), + next: { + dryRun: `bun scripts/cli.ts platform-infra nginx apply --target ${id} --dry-run`, + apply: `bun scripts/cli.ts platform-infra nginx apply --target ${id} --confirm`, + status: `bun scripts/cli.ts platform-infra nginx status --target ${id}`, + }, + }; +} + +async function apply(config: UniDeskConfig, options: OpsApplyOptions): Promise> { + const nginx = readConfig(); + const { id, target } = resolveTarget(nginx, options.targetId); + const secret = readSecret(target); + const image = localArtifactSummary(nginx.artifacts.image); + const composePlugin = localArtifactSummary(nginx.artifacts.composePlugin); + if (!image.valid || !composePlugin.valid) { + return { ok: false, action: "platform-infra-nginx-apply", mode: "artifact-invalid", image, composePlugin }; + } + if (options.confirm && !options.wait) { + const job = startJob( + `platform_infra_nginx_apply_${id.toLowerCase()}`, + ["bun", "scripts/cli.ts", "platform-infra", "nginx", "apply", "--target", id, "--confirm", "--wait"], + `Deploy YAML-controlled Nginx to ${id}, run four serial requests, and remove host test backends`, + ); + return { + ok: true, + action: "platform-infra-nginx-apply", + mode: "async-job", + mutation: true, + target: targetSummary(id, target), + job, + statusCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`, + }; + } + const compose = renderCompose(nginx, target); + if (options.dryRun) { + return { + ok: true, + action: "platform-infra-nginx-apply", + mode: "dry-run", + mutation: false, + target: targetSummary(id, target), + compose: { + sha256: sha256(compose), + bytes: Buffer.byteLength(compose), + routes: renderRoutes(target.runtime.routes), + }, + apiKey: secretSummary(target, secret), + artifacts: { image, composePlugin }, + validation: validationSummary(target), + }; + } + + const prepare = await capture(config, target.route, ["sh"], prepareRemoteScript(target, nginx)); + if (prepare.exitCode !== 0) return failedApply(id, target, "prepare", prepare, secret); + const uploads = [ + uploadArtifact(target.route, nginx.artifacts.image.localPath, nginx.artifacts.image.remotePath), + uploadArtifact(target.route, nginx.artifacts.composePlugin.localPath, nginx.artifacts.composePlugin.remotePath), + uploadArtifact(target.route, secret.sourcePath, target.runtime.envPath), + ]; + const failedUpload = uploads.find((upload) => !upload.ok); + if (failedUpload !== undefined) { + return { + ok: false, + action: "platform-infra-nginx-apply", + mode: "confirmed", + stage: "upload", + target: targetSummary(id, target), + uploads, + apiKey: secretSummary(target, secret), + }; + } + const remote = await capture(config, target.route, ["sh"], applyRemoteScript(nginx, target, compose)); + const parsed = parseJsonOutput(remote.stdout); + return { + ok: remote.exitCode === 0 && parsed?.ok === true, + action: "platform-infra-nginx-apply", + mode: "confirmed", + mutation: true, + target: targetSummary(id, target), + uploads, + apiKey: secretSummary(target, secret), + validation: parsed ?? compactCapture(remote, { full: true }), + remote: compactCapture(remote, { full: options.full || remote.exitCode !== 0 }), + next: { status: `bun scripts/cli.ts platform-infra nginx status --target ${id}` }, + }; +} + +async function status(config: UniDeskConfig, options: OpsCommonOptions): Promise> { + const nginx = readConfig(); + const { id, target } = resolveTarget(nginx, options.targetId); + const secret = readSecret(target); + const remote = await capture(config, target.route, ["sh"], statusRemoteScript(nginx, target)); + const parsed = parseJsonOutput(remote.stdout); + return { + ok: remote.exitCode === 0 && parsed?.ok === true, + action: "platform-infra-nginx-status", + target: targetSummary(id, target), + apiKey: secretSummary(target, secret), + summary: parsed, + remote: compactCapture(remote, { full: options.full || remote.exitCode !== 0 }), + ...(options.raw ? { raw: remote } : {}), + }; +} + +function prepareRemoteScript(target: NginxTarget, config: NginxConfig): string { + return `set -eu +install -d -m 0755 ${shQuote(target.runtime.workDir)} ${shQuote(target.runtime.logDir)} +install -d -m 0755 ${shQuote(parentPath(config.artifacts.image.remotePath))} +install -d -m 0755 ${shQuote(parentPath(config.artifacts.composePlugin.remotePath))} +`; +} + +function applyRemoteScript(config: NginxConfig, target: NginxTarget, compose: string): string { + const backendScript = renderBackendScript(); + const unitFiles = target.validation.backends.map((backend) => ({ + path: `/etc/systemd/system/${backend.unit}`, + content: renderBackendUnit(target.validation.backendScriptPath, backend), + })); + const firstRoute = target.runtime.routes[0]!; + const expected = target.validation.backends.map((backend) => backend.id).join(","); + const actualExpected = Array.from( + { length: target.validation.requestCount }, + (_, index) => target.validation.backends[index % target.validation.backends.length]!.id, + ).join(","); + const unitNames = target.validation.backends.map((backend) => shQuote(backend.unit)).join(" "); + const unitPaths = target.validation.backends + .map((backend) => shQuote(`/etc/systemd/system/${backend.unit}`)) + .join(" "); + const unitWrites = unitFiles + .map((unit) => { + const encoded = shQuote(Buffer.from(unit.content).toString("base64")); + return `printf '%s' ${encoded} | base64 -d > ${shQuote(unit.path)}`; + }) + .join("\n"); + const adminBase = [ + "http://127.0.0.1:", + target.runtime.environment.NGINX_ADMIN_PORT, + target.runtime.environment.LOGGER_ADMIN_PREFIX, + ].join(""); + const validationFormat = [ + '{"ok":true,"requests":', + String(target.validation.requestCount), + ',"backendSequence":"%s","loggedSequence":"%s",', + '"unauthorizedStatus":%s,"activeTestBackends":%s,"logCleared":true}\\n', + ].join(""); + assert(expected.length > 0, "validation backends are missing"); + return `set -eu +image_artifact=${shQuote(config.artifacts.image.remotePath)} +plugin=${shQuote(config.artifacts.composePlugin.remotePath)} +env_file=${shQuote(target.runtime.envPath)} +compose_file=${shQuote(target.runtime.composePath)} +log_file=${shQuote(`${target.runtime.logDir}/requests.jsonl`)} +backend_script=${shQuote(target.validation.backendScriptPath)} +cleanup_backends() { + systemctl stop ${unitNames} >/dev/null 2>&1 || true + systemctl disable ${unitNames} >/dev/null 2>&1 || true + rm -f ${unitPaths} "$backend_script" + systemctl daemon-reload >/dev/null 2>&1 || true +} +trap cleanup_backends EXIT +[ "$(sha256sum "$image_artifact" | awk '{print $1}')" = ${shQuote(config.artifacts.image.sha256)} ] +[ "$(sha256sum "$plugin" | awk '{print $1}')" = ${shQuote(config.artifacts.composePlugin.sha256)} ] +chmod 0755 "$plugin" +docker compose version >/dev/null +chmod 0600 "$env_file" +printf '%s' ${shQuote(Buffer.from(compose).toString("base64"))} | base64 -d > "$compose_file" +printf '%s' ${shQuote(Buffer.from(backendScript).toString("base64"))} | base64 -d > "$backend_script" +chmod 0755 "$backend_script" +${unitWrites} +systemctl daemon-reload +systemctl enable --now ${unitNames} >/dev/null +for backend_port in ${target.validation.backends.map((backend) => backend.port).join(" ")}; do + for attempt in $(seq 1 10); do + if curl -fsS http://127.0.0.1:"$backend_port"/ >/dev/null; then break; fi + [ "$attempt" -lt 10 ] || exit 1 + sleep 1 + done +done +docker load -i "$image_artifact" >/dev/null +loaded_image_id=$(docker image inspect ${shQuote(config.artifacts.image.image)} --format '{{.Id}}') +[ "$loaded_image_id" = ${shQuote(config.artifacts.image.imageId)} ] +docker compose -f "$compose_file" -p ${shQuote(target.runtime.projectName)} up -d --force-recreate +for attempt in $(seq 1 20); do + if wget -qO- ${adminBase}/health >/dev/null; then break; fi + [ "$attempt" -lt 20 ] || exit 1 + sleep 1 +done +actual= +for sequence in $(seq 1 ${target.validation.requestCount}); do + backend=$(curl -fsS -D - -o /dev/null http://127.0.0.1:${firstRoute.listenPort}/validation?sequence="$sequence" \ + | awk 'BEGIN { IGNORECASE=1 } /^X-Backend:/ { gsub("\\r", "", $2); print $2; exit }') + actual="\${actual}\${actual:+,}\${backend}" +done +[ "$actual" = ${shQuote(actualExpected)} ] +set -a +. "$env_file" +set +a +logger_curl() { + printf 'header = "${target.apiKey.requestHeader}: %s"\n' "$LOGGER_API_KEY" | curl --config - "$@" +} +unauthorized=$(curl -sS -o /dev/null -w '%{http_code}' \ + ${adminBase}/logs/info) +[ "$unauthorized" = 401 ] +logger_curl -fsS -o /tmp/nginx-logger-info.json \ + ${adminBase}/logs/info +logger_curl -fsS -o /tmp/nginx-logger-tail.jsonl \ + '${adminBase}/logs/tail?lines=${target.validation.requestCount}' +logger_curl -fsS -o /tmp/nginx-logger-download.jsonl \ + ${adminBase}/logs/download +[ -s /tmp/nginx-logger-download.jsonl ] +logged=$(python3 -c 'import json,sys; print(",".join(json.loads(x)["backend"] for x in open(sys.argv[1])))' \ + /tmp/nginx-logger-tail.jsonl) +[ "$logged" = ${shQuote(actualExpected)} ] +logger_curl -fsS -X POST -o /tmp/nginx-logger-clear.json \ + ${adminBase}/logs/clear +[ ! -s "$log_file" ] +cleanup_backends +trap - EXIT +active_backends=0 +for unit in ${unitNames}; do + systemctl is-active --quiet "$unit" && active_backends=$((active_backends + 1)) || true +done +printf ${shQuote(validationFormat)} \ + "$actual" "$logged" "$unauthorized" "$active_backends" +`; +} + +function statusRemoteScript(config: NginxConfig, target: NginxTarget): string { + const adminPort = target.runtime.environment.NGINX_ADMIN_PORT; + const prefix = target.runtime.environment.LOGGER_ADMIN_PREFIX; + const statusFormat = [ + '{"ok":%s,"running":%s,"imageId":"%s","expectedImageId":"%s",', + '"healthStatus":%s,"logBytes":%s,"activeTestBackends":%s}\\n', + ].join(""); + return `set -eu +container=${shQuote(target.runtime.containerName)} +running=false +if [ "$(docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null || true)" = true ]; then running=true; fi +image_id=$(docker inspect -f '{{.Image}}' "$container" 2>/dev/null || true) +health_code=$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:${adminPort}${prefix}/health 2>/dev/null || true) +log_bytes=$(wc -c < ${shQuote(`${target.runtime.logDir}/requests.jsonl`)} 2>/dev/null || printf 0) +active_backends=0 +for unit in ${target.validation.backends.map((backend) => shQuote(backend.unit)).join(" ")}; do + systemctl is-active --quiet "$unit" && active_backends=$((active_backends + 1)) || true +done +ok=false +[ "$running" = true ] && [ "$image_id" = ${shQuote(config.artifacts.image.imageId)} ] && \ + [ "$health_code" = 200 ] && [ "$active_backends" -eq 0 ] && ok=true +printf ${shQuote(statusFormat)} \ + "$ok" "$running" "$image_id" ${shQuote(config.artifacts.image.imageId)} \ + "\${health_code:-0}" "\${log_bytes:-0}" "$active_backends" +`; +} + +function renderCompose(config: NginxConfig, target: NginxTarget): string { + const environment = { + ...target.runtime.environment, + NGINX_ROUTES: renderRoutes(target.runtime.routes), + }; + const envLines = Object.entries(environment) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, value]) => ` ${key}: ${yamlString(value.replaceAll("$", () => "$$"))}`) + .join("\n"); + return `name: ${target.runtime.projectName} + +services: + nginx: + image: ${config.artifacts.image.image} + container_name: ${target.runtime.containerName} + restart: ${target.runtime.restart} + network_mode: ${target.runtime.networkMode} + mem_limit: ${target.runtime.resources.memory} + cpus: ${yamlString(target.runtime.resources.cpus)} + pids_limit: ${target.runtime.resources.pidsLimit} + env_file: + - ${target.runtime.envPath} + environment: +${envLines} + volumes: + - ${target.runtime.logDir}:/var/log/logger + logging: + driver: json-file + options: + max-size: 5m + max-file: "2" +`; +} + +function renderBackendScript(): string { + return `#!/usr/bin/env python3 +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer + +host, port, backend = sys.argv[1], int(sys.argv[2]), sys.argv[3] + +class Handler(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + def do_GET(self): + body = ('{"backend":"%s"}\\n' % backend).encode("ascii") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("X-Backend", backend) + self.end_headers() + self.wfile.write(body) + def log_message(self, format, *args): + return + +HTTPServer((host, port), Handler).serve_forever() +`; +} + +function renderBackendUnit(scriptPath: string, backend: Backend): string { + return `[Unit] +Description=Nginx validation backend ${backend.id} +After=network.target + +[Service] +Type=simple +ExecStart=/usr/bin/python3 ${scriptPath} ${backend.bindHost} ${backend.port} ${backend.id} +Restart=on-failure +MemoryMax=32M +CPUQuota=10% + +[Install] +WantedBy=multi-user.target +`; +} + +function readSecret(target: NginxTarget): SecretMaterial { + const sourcePath = isAbsolute(target.apiKey.sourceRef) + ? target.apiKey.sourceRef + : join(repoRoot, target.apiKey.sourceRef); + assert(existsSync(sourcePath), `${target.apiKey.sourceRef} is missing`); + const values = parseEnvFile(readFileSync(sourcePath, "utf8")); + const value = values[target.apiKey.sourceKey]; + assert( + typeof value === "string" && value.length >= 16, + `${target.apiKey.sourceRef} is missing ${target.apiKey.sourceKey}`, + ); + return { + sourcePath, + value, + fingerprint: fingerprintValues({ [target.apiKey.sourceKey]: value }, [target.apiKey.sourceKey]), + }; +} + +function localArtifactSummary(artifact: Artifact & { bytes?: number }): Record & { valid: boolean } { + const present = existsSync(artifact.localPath); + const bytes = present ? statSync(artifact.localPath).size : 0; + const digest = present ? sha256(readFileSync(artifact.localPath)) : null; + const valid = present && digest === artifact.sha256 && (artifact.bytes === undefined || bytes === artifact.bytes); + return { present, bytes, sha256: digest, expectedSha256: artifact.sha256, valid }; +} + +function uploadArtifact(route: string, localPath: string, remotePath: string): Record { + const result = Bun.spawnSync(["trans", route, "upload", localPath, remotePath], { + stdout: "pipe", + stderr: "pipe", + }); + const stdout = new TextDecoder().decode(result.stdout); + const parsed = parseJsonOutput(stdout); + return { + ok: result.exitCode === 0 && parsed?.ok === true, + localPath, + remotePath, + bytes: parsed?.bytes ?? 0, + sha256: parsed?.sha256 ?? null, + verified: parsed?.verified === true, + stderrBytes: result.stderr.length, + }; +} + +function targetSummary(id: string, target: NginxTarget): Record { + return { + id, + route: target.route, + runtime: "host-docker-compose", + container: target.runtime.containerName, + resources: target.runtime.resources, + routes: target.runtime.routes, + adminPort: target.runtime.environment.NGINX_ADMIN_PORT, + }; +} + +function validationSummary(target: NginxTarget): Record { + return { + requestCount: target.validation.requestCount, + concurrency: 1, + backends: "host-python-systemd-temporary", + cleanupAfterSuccess: target.validation.cleanupAfterSuccess, + }; +} + +function secretSummary(target: NginxTarget, secret: SecretMaterial): Record { + return { + sourceRef: target.apiKey.sourceRef, + sourceKey: target.apiKey.sourceKey, + targetKey: target.apiKey.targetKey, + present: true, + fingerprint: secret.fingerprint, + valuesPrinted: false, + }; +} + +function failedApply( + id: string, + target: NginxTarget, + stage: string, + remote: Awaited>, + secret: SecretMaterial, +): Record { + return { + ok: false, + action: "platform-infra-nginx-apply", + mode: "confirmed", + stage, + target: targetSummary(id, target), + apiKey: secretSummary(target, secret), + remote: compactCapture(remote, { full: true }), + }; +} + +function renderRoutes(routes: Route[]): string { + return routes + .map((route) => { + const upstreams = route.upstreams.map((upstream) => `${upstream.host}:${upstream.port}`).join(","); + return `${route.listenPort}=${upstreams}`; + }) + .join(";"); +} + +function sha256(value: string | Buffer): string { + return createHash("sha256").update(value).digest("hex"); +} + +function yamlString(value: string): string { + return JSON.stringify(value); +} + +function parentPath(path: string): string { + return path.slice(0, path.lastIndexOf("/")) || "/"; +} + +function absolutePath(value: string, path: string): void { + assert(typeof value === "string" && isAbsolute(value), `${configLabel}.${path} must be an absolute path`); +} + +function simpleName(value: string, path: string): void { + assert(typeof value === "string" && /^[A-Za-z0-9._-]+$/u.test(value), `${configLabel}.${path} is invalid`); +} + +function port(value: number, path: string): void { + assert(Number.isInteger(value) && value >= 1 && value <= 65535, `${configLabel}.${path} must be a valid port`); +} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} diff --git a/scripts/src/platform-infra/entry.ts b/scripts/src/platform-infra/entry.ts index a31e195c..d9116132 100644 --- a/scripts/src/platform-infra/entry.ts +++ b/scripts/src/platform-infra/entry.ts @@ -391,7 +391,24 @@ export interface ManagedResourceCleanupPlan { export function platformInfraHelp(): unknown { const target = sub2ApiHelpTargetSummary(); return { - command: "platform-infra sub2api|sub2rank|langbot|n8n|webterm|wechat-archive|observability|secret-plane|kafka|gitea|pipelines-as-code ...", + command: [ + "platform-infra", + [ + "sub2api", + "sub2rank", + "nginx", + "langbot", + "n8n", + "webterm", + "wechat-archive", + "observability", + "secret-plane", + "kafka", + "gitea", + "pipelines-as-code", + ].join("|"), + "...", + ].join(" "), output: "json", usage: [ "bun scripts/cli.ts platform-infra sub2api plan [--target G14|D601]", @@ -413,6 +430,10 @@ export function platformInfraHelp(): unknown { "bun scripts/cli.ts platform-infra sub2rank validate [--target NC01] [--full|--raw]", "bun scripts/cli.ts platform-infra egress-proxy traffic --target D601 --sample-seconds 15", "bun scripts/cli.ts platform-infra egress-proxy k3s-build-benchmark --targets D601,D518 --profile no-mirror-600m --dry-run", + "bun scripts/cli.ts platform-infra nginx plan [--target PK01]", + "bun scripts/cli.ts platform-infra nginx apply [--target PK01] --dry-run", + "bun scripts/cli.ts platform-infra nginx apply [--target PK01] --confirm", + "bun scripts/cli.ts platform-infra nginx status [--target PK01] [--full|--raw]", "bun scripts/cli.ts platform-infra langbot plan [--target G14]", "bun scripts/cli.ts platform-infra langbot apply [--target G14] --confirm", "bun scripts/cli.ts platform-infra langbot status [--target G14] [--full|--raw]", diff --git a/scripts/src/platform-infra/options.ts b/scripts/src/platform-infra/options.ts index db3923da..24482b49 100644 --- a/scripts/src/platform-infra/options.ts +++ b/scripts/src/platform-infra/options.ts @@ -50,6 +50,10 @@ export async function runPlatformInfraCommand(config: UniDeskConfig, args: strin const { runLangBotCommand } = await import("../platform-infra-langbot"); return await runLangBotCommand(config, args.slice(1)); } + if (target === "nginx") { + const { runPlatformInfraNginxCommand } = await import("../platform-infra-nginx"); + return await runPlatformInfraNginxCommand(config, args.slice(1)); + } if (target === "n8n") { const { runN8nCommand } = await import("../platform-infra-n8n"); return await runN8nCommand(config, args.slice(1));