668 lines
25 KiB
TypeScript
668 lines
25 KiB
TypeScript
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);
|
|
}
|