feat(platform-infra): add YAML-controlled nginx deployment
This commit is contained in:
@@ -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);
|
||||
}
|
||||
@@ -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]",
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user