feat(platform-infra): add YAML-controlled nginx deployment

This commit is contained in:
Codex
2026-07-13 14:04:47 +02:00
parent 21567bafbf
commit 635589d79e
6 changed files with 812 additions and 1 deletions
+86
View File
@@ -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
+15
View File
@@ -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` 必须报告测试后端活动数为零。
+18
View File
@@ -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.<id>.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 容器继续运行。
+667
View File
@@ -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<string, string>;
};
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<string, NginxTarget>;
}
interface SecretMaterial {
sourcePath: string;
value: string;
fingerprint: string;
}
export function nginxHelp(): Record<string, unknown> {
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<Record<string, unknown>> {
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<NginxConfig>(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<number>();
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<string, unknown> {
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<Record<string, unknown>> {
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<Record<string, unknown>> {
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<string, unknown> & { 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<string, unknown> {
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<string, unknown> {
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<string, unknown> {
return {
requestCount: target.validation.requestCount,
concurrency: 1,
backends: "host-python-systemd-temporary",
cleanupAfterSuccess: target.validation.cleanupAfterSuccess,
};
}
function secretSummary(target: NginxTarget, secret: SecretMaterial): Record<string, unknown> {
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<ReturnType<typeof capture>>,
secret: SecretMaterial,
): Record<string, unknown> {
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);
}
+22 -1
View File
@@ -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]",
+4
View File
@@ -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));