import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { isAbsolute } from "node:path"; import type { UniDeskConfig } from "./config"; import { rootPath } from "./config"; import { startJob } from "./jobs"; import { applyPk01CaddyBlock, capture, compactCapture, dryRunManifestScript, parseJsonOutput, prepareFrpcSecret, publicHttpProbe, publicServicePolicyChecks, redactText, renderFrpcManifest, shQuote, type FrpcSecretMaterial, type PublicServiceExposure, type PublicServiceTarget, } from "./platform-infra-public-service"; import { createYamlFieldReader, } from "./platform-infra-ops-library"; import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile, redactRepoPath, requiredEnvValue } from "./secrets"; const configFile = rootPath("config", "platform-infra", "n8n.yaml"); const configLabel = "config/platform-infra/n8n.yaml"; const serviceName = "n8n"; const fieldManager = "unidesk-platform-n8n"; const { asRecord, objectField, arrayOfRecords, stringField, integerField, booleanField, stringArrayField, numberArrayField, enumField, kubernetesNameField, sourceRefField, envKeyField, pgIdentifierField, hostField, portField, absolutePathField, httpsUrlField, } = createYamlFieldReader(configLabel); interface N8nConfig { version: number; kind: "platform-infra-n8n"; metadata: { id: string; owner: string; relatedIssues: number[] }; defaults: { targetId: string }; image: { repository: string; tag: string; pullPolicy: "Always" | "IfNotPresent" | "Never" }; dependencyImages: { postgresClient: string }; targets: N8nTarget[]; runtime: { timezone: string; database: { sourceRef: string; sourceKeys: { user: string; password: string; dbName: string }; secretName: string; host: string; port: number; user: string; dbName: string; sslMode: "require"; sslRejectUnauthorized: boolean; }; secrets: { root: string; appSourceRef: string; encryptionKey: string }; storage: { data: PvcSpec }; public: { host: string; protocol: "https"; editorBaseUrl: string; webhookUrl: string; proxyHops: number; }; security: { diagnosticsEnabled: boolean; personalisationEnabled: boolean; secureCookie: boolean; }; }; } interface PvcSpec { name: string; size: string; } interface N8nTarget extends PublicServiceTarget { enabled: boolean; } interface CommonOptions { targetId: string | null; full: boolean; raw: boolean; } interface ApplyOptions extends CommonOptions { confirm: boolean; dryRun: boolean; wait: boolean; } interface LogsOptions extends CommonOptions { component: "app" | "frpc" | "all"; lines: number; } interface SecretMaterial { dbSourceRef: string; dbSourcePath: string; appSourceRef: string; appSourcePath: string; action: "read"; values: { dbUser: string; dbPassword: string; dbName: string; encryptionKey: string; }; fingerprint: string; valuesPrinted: false; } export function n8nHelp(): Record { return { command: "platform-infra n8n plan|apply|status|logs|validate", output: "json", usage: [ "bun scripts/cli.ts platform-infra n8n plan [--target G14]", "bun scripts/cli.ts platform-infra n8n apply [--target G14] --dry-run", "bun scripts/cli.ts platform-infra n8n apply [--target G14] --confirm", "bun scripts/cli.ts platform-infra n8n status [--target G14] [--full|--raw]", "bun scripts/cli.ts platform-infra n8n logs [--target G14] [--component app|frpc|all] [--lines N]", "bun scripts/cli.ts platform-infra n8n validate [--target G14] [--full|--raw]", ], configTruth: "config/platform-infra/n8n.yaml", publicUrl: "https://n8n.pikapython.com", databasePolicy: "Uses the existing PK01/Pika01 host-native PostgreSQL instance with a dedicated n8n database and role; no in-cluster PostgreSQL or long-term SQLite.", secretPolicy: "Database passwords and N8N_ENCRYPTION_KEY are never printed; output uses presence, key names and fingerprints only.", }; } export async function runN8nCommand(config: UniDeskConfig, args: string[]): Promise> { const [action = "plan"] = args; if (action === "plan") return plan(parseCommonOptions(args.slice(1))); if (action === "apply") return await apply(config, parseApplyOptions(args.slice(1))); if (action === "status") return await status(config, parseCommonOptions(args.slice(1))); if (action === "logs") return await logs(config, parseLogsOptions(args.slice(1))); if (action === "validate") return await validate(config, parseCommonOptions(args.slice(1))); return { ok: false, error: "unsupported-platform-infra-n8n-command", args, help: n8nHelp() }; } function parseCommonOptions(args: string[]): CommonOptions { let targetId: string | null = null; let full = false; let raw = false; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--target") { const value = args[index + 1]; if (value === undefined || value.startsWith("--")) throw new Error("--target requires a value"); targetId = value; index += 1; } else if (arg === "--full") { full = true; } else if (arg === "--raw") { raw = true; full = true; } else { throw new Error(`unsupported n8n option: ${arg}`); } } if (targetId !== null && !/^[A-Za-z0-9._-]+$/u.test(targetId)) throw new Error("--target must be a simple target id"); return { targetId, full, raw }; } function parseApplyOptions(args: string[]): ApplyOptions { const commonArgs: string[] = []; let confirm = false; let dryRun = false; let wait = false; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--confirm") confirm = true; else if (arg === "--dry-run") dryRun = true; else if (arg === "--wait") wait = true; else { commonArgs.push(arg); if (arg === "--target") { commonArgs.push(args[index + 1] ?? ""); index += 1; } } } const common = parseCommonOptions(commonArgs); if (confirm && dryRun) throw new Error("n8n apply accepts only one of --confirm or --dry-run"); return { ...common, confirm, dryRun: dryRun || !confirm, wait }; } function parseLogsOptions(args: string[]): LogsOptions { const commonArgs: string[] = []; let component: LogsOptions["component"] = "all"; let lines = 120; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--component") { const value = args[index + 1]; if (value !== "app" && value !== "frpc" && value !== "all") throw new Error("--component must be app, frpc, or all"); component = value; index += 1; } else if (arg === "--lines") { const value = Number(args[index + 1]); if (!Number.isInteger(value) || value < 1 || value > 500) throw new Error("--lines must be an integer in 1..500"); lines = value; index += 1; } else { commonArgs.push(arg); if (arg === "--target") { commonArgs.push(args[index + 1] ?? ""); index += 1; } } } return { ...parseCommonOptions(commonArgs), component, lines }; } function plan(options: CommonOptions): Record { const n8n = readN8nConfig(); const target = resolveTarget(n8n, options.targetId); const yaml = renderManifest(n8n, target); const policy = policyChecks(yaml, target); return { ok: policy.every((check) => check.ok), action: "platform-infra-n8n-plan", mutation: false, config: configSummary(n8n, target), policy, decision: { namespace: target.namespace, publicExposure: "PK01 Caddy -> PK01 frps remotePort -> G14 frpc -> n8n ClusterIP", database: "PK01/Pika01 is the only external PostgreSQL instance; n8n uses its own database and role in that instance.", sqlite: "not used for durable state", resourcePolicy: "No Kubernetes CPU/memory requests or limits are rendered.", }, next: { postgres: "bun scripts/cli.ts platform-db postgres apply --config config/platform-db/postgres-pk01.yaml --confirm", secrets: "bun scripts/cli.ts secrets sync --config config/secrets-distribution.yaml --scope platform-infra --confirm", dryRun: `bun scripts/cli.ts platform-infra n8n apply --target ${target.id} --dry-run`, apply: `bun scripts/cli.ts platform-infra n8n apply --target ${target.id} --confirm`, status: `bun scripts/cli.ts platform-infra n8n status --target ${target.id}`, validate: `bun scripts/cli.ts platform-infra n8n validate --target ${target.id}`, }, }; } async function apply(config: UniDeskConfig, options: ApplyOptions): Promise> { const n8n = readN8nConfig(); const target = resolveTarget(n8n, options.targetId); const yaml = renderManifest(n8n, target); const policy = policyChecks(yaml, target); if (!policy.every((check) => check.ok)) return { ok: false, action: "platform-infra-n8n-apply", mode: "policy-blocked", policy }; if (options.confirm && !options.wait) { const job = startJob( `platform_infra_n8n_apply_${target.id.toLowerCase()}`, ["bun", "scripts/cli.ts", "platform-infra", "n8n", "apply", "--target", target.id, "--confirm", "--wait"], `Apply ${target.id} n8n platform-infra manifests, FRP and PK01 Caddy exposure through the controlled UniDesk CLI`, ); return { ok: true, action: "platform-infra-n8n-apply", mode: "async-job", mutation: true, target: targetSummary(target), job, statusCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`, next: { status: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`, rollout: `bun scripts/cli.ts platform-infra n8n status --target ${target.id}`, validate: `bun scripts/cli.ts platform-infra n8n validate --target ${target.id}`, }, }; } if (options.dryRun) { const result = await capture(config, target.route, ["sh"], dryRunManifestScript({ yaml, target, fieldManager, manifestName: serviceName })); const parsed = parseJsonOutput(result.stdout); return { ok: result.exitCode === 0 && boolField(parsed, "ok", false), action: "platform-infra-n8n-apply", mode: "dry-run", mutation: false, target: targetSummary(target), policy, remote: parsed ?? compactCapture(result, { full: true }), }; } const secretMaterial = prepareSecretMaterial(n8n); const frpcSecret = prepareFrpcSecret({ secretRoot: secretRoot(n8n), exposure: target.publicExposure, sourcePathRedactor: redactRepoPath, parseEnvFile, requiredEnvValue, readTextFile, }); const confirmedYaml = renderManifest(n8n, target, secretMaterial.fingerprint); const result = await capture(config, target.route, ["sh"], applyScript(confirmedYaml, n8n, target, secretMaterial, frpcSecret)); const parsed = parseJsonOutput(result.stdout); const caddy = await applyPk01CaddyBlock(config, serviceName, target.publicExposure); return { ok: result.exitCode === 0 && boolField(parsed, "ok", false) && caddy.ok === true, action: "platform-infra-n8n-apply", mode: "confirmed", mutation: true, target: targetSummary(target), policy, secrets: secretSummary(secretMaterial, frpcSecret), remote: parsed ?? compactCapture(result, { full: true }), pk01Caddy: caddy, next: { secrets: "bun scripts/cli.ts secrets sync --config config/secrets-distribution.yaml --scope platform-infra --confirm", status: `bun scripts/cli.ts platform-infra n8n status --target ${target.id}`, validate: `bun scripts/cli.ts platform-infra n8n validate --target ${target.id}`, }, }; } async function status(config: UniDeskConfig, options: CommonOptions): Promise> { const n8n = readN8nConfig(); const target = resolveTarget(n8n, options.targetId); const result = await capture(config, target.route, ["sh"], statusScript(n8n, target)); const parsed = parseJsonOutput(result.stdout); return { ok: result.exitCode === 0 && boolField(parsed, "ok", false), action: "platform-infra-n8n-status", target: targetSummary(target), summary: parsed, remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }), ...(options.raw ? { raw: result } : {}), }; } async function logs(config: UniDeskConfig, options: LogsOptions): Promise> { const n8n = readN8nConfig(); const target = resolveTarget(n8n, options.targetId); const result = await capture(config, target.route, ["sh"], logsScript(target, options)); const parsed = parseJsonOutput(result.stdout); return { ok: result.exitCode === 0 && boolField(parsed, "ok", false), action: "platform-infra-n8n-logs", target: targetSummary(target), component: options.component, lines: options.lines, logs: parsed, remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }), ...(options.raw ? { raw: result } : {}), }; } async function validate(config: UniDeskConfig, options: CommonOptions): Promise> { const n8n = readN8nConfig(); const target = resolveTarget(n8n, options.targetId); const k8s = await capture(config, target.route, ["sh"], validateScript(target)); const k8sParsed = parseJsonOutput(k8s.stdout); const publicProbe = publicHttpProbe(target.publicExposure.publicBaseUrl, "/"); return { ok: k8s.exitCode === 0 && boolField(k8sParsed, "ok", false) && publicProbe.ok, action: "platform-infra-n8n-validate", target: targetSummary(target), k8s: k8sParsed ?? compactCapture(k8s, { full: true }), publicHttps: publicProbe, remote: compactCapture(k8s, { full: options.full || k8s.exitCode !== 0 }), ...(options.raw ? { raw: k8s } : {}), }; } function renderManifest(n8n: N8nConfig, target: N8nTarget, secretFingerprint = "not-prepared"): string { const image = `${n8n.image.repository}:${n8n.image.tag}`; const configHash = createHash("sha256").update(JSON.stringify({ n8n, target })).digest("hex").slice(0, 16); const db = n8n.runtime.database; const storage = n8n.runtime.storage; const publicConfig = n8n.runtime.public; const security = n8n.runtime.security; return `apiVersion: v1 kind: Namespace metadata: name: ${target.namespace} labels: app.kubernetes.io/name: platform-infra app.kubernetes.io/managed-by: unidesk unidesk.ai/runtime-node: ${target.id} --- apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: allow-all namespace: ${target.namespace} labels: app.kubernetes.io/name: platform-infra app.kubernetes.io/part-of: platform-infra app.kubernetes.io/managed-by: unidesk spec: podSelector: {} policyTypes: - Ingress - Egress ingress: - {} egress: - {} --- apiVersion: v1 kind: PersistentVolumeClaim metadata: name: ${storage.data.name} namespace: ${target.namespace} labels: app.kubernetes.io/name: n8n app.kubernetes.io/part-of: platform-infra app.kubernetes.io/managed-by: unidesk spec: accessModes: - ReadWriteOnce resources: requests: storage: ${storage.data.size} --- apiVersion: v1 kind: ConfigMap metadata: name: n8n-config namespace: ${target.namespace} labels: app.kubernetes.io/name: n8n app.kubernetes.io/part-of: platform-infra app.kubernetes.io/managed-by: unidesk data: TZ: "${n8n.runtime.timezone}" N8N_HOST: "${publicConfig.host}" N8N_PORT: "5678" N8N_PROTOCOL: "${publicConfig.protocol}" N8N_EDITOR_BASE_URL: "${publicConfig.editorBaseUrl}" WEBHOOK_URL: "${publicConfig.webhookUrl}" N8N_PROXY_HOPS: "${publicConfig.proxyHops}" DB_TYPE: "postgresdb" DB_POSTGRESDB_HOST: "${db.host}" DB_POSTGRESDB_PORT: "${db.port}" DB_POSTGRESDB_DATABASE: "${db.dbName}" DB_POSTGRESDB_USER: "${db.user}" DB_POSTGRESDB_SSL_ENABLED: "${db.sslMode === "require"}" DB_POSTGRESDB_SSL_REJECT_UNAUTHORIZED: "${db.sslRejectUnauthorized}" N8N_DIAGNOSTICS_ENABLED: "${security.diagnosticsEnabled}" N8N_PERSONALIZATION_ENABLED: "${security.personalisationEnabled}" N8N_SECURE_COOKIE: "${security.secureCookie}" N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS: "true" --- apiVersion: v1 kind: Service metadata: name: ${serviceName} namespace: ${target.namespace} labels: app.kubernetes.io/name: n8n app.kubernetes.io/part-of: platform-infra app.kubernetes.io/managed-by: unidesk spec: selector: app.kubernetes.io/name: n8n ports: - name: http port: 5678 targetPort: http --- apiVersion: apps/v1 kind: Deployment metadata: name: ${serviceName} namespace: ${target.namespace} labels: app.kubernetes.io/name: n8n app.kubernetes.io/part-of: platform-infra app.kubernetes.io/managed-by: unidesk spec: replicas: ${target.replicas} strategy: type: Recreate selector: matchLabels: app.kubernetes.io/name: n8n template: metadata: labels: app.kubernetes.io/name: n8n app.kubernetes.io/part-of: platform-infra annotations: unidesk.ai/n8n-config-hash: "${configHash}" unidesk.ai/n8n-secret-fingerprint: "${secretFingerprint}" unidesk.ai/public-base-url: "${target.publicExposure.publicBaseUrl}" spec: securityContext: fsGroup: 1000 initContainers: - name: wait-postgres image: ${n8n.dependencyImages.postgresClient} imagePullPolicy: IfNotPresent command: - sh - -c - until pg_isready -h ${db.host} -p ${db.port} -U ${db.user} -d ${db.dbName}; do sleep 2; done containers: - name: n8n image: ${image} imagePullPolicy: ${n8n.image.pullPolicy} ports: - name: http containerPort: 5678 envFrom: - configMapRef: name: n8n-config env: - name: DB_POSTGRESDB_PASSWORD valueFrom: secretKeyRef: name: ${db.secretName} key: DB_POSTGRESDB_PASSWORD - name: N8N_ENCRYPTION_KEY valueFrom: secretKeyRef: name: ${db.secretName} key: N8N_ENCRYPTION_KEY volumeMounts: - name: n8n-data mountPath: /home/node/.n8n volumes: - name: n8n-data persistentVolumeClaim: claimName: ${storage.data.name} ${renderFrpcManifest(target)} `; } function policyChecks(yaml: string, target: N8nTarget): Array> { return [ ...publicServicePolicyChecks(yaml, target, "n8n"), { name: "no-postgres-statefulset", ok: !/^\s*kind:\s*StatefulSet\s*$/mu.test(yaml) && !/postgres/i.test(yaml.split(/^---\s*$/mu).filter((doc) => /^\s*kind:\s*StatefulSet\s*$/mu.test(doc)).join("\n")), detail: "n8n must use the PK01/Pika01 external PostgreSQL instance, not an in-cluster PostgreSQL StatefulSet." }, { name: "postgresdb-not-sqlite", ok: /^\s*DB_TYPE:\s*"postgresdb"\s*$/mu.test(yaml), detail: "n8n durable state must use PostgreSQL, not long-term SQLite." }, ]; } function applyScript(yaml: string, n8n: N8nConfig, target: N8nTarget, secret: SecretMaterial, frpc: FrpcSecretMaterial): string { const encodedManifest = Buffer.from(yaml, "utf8").toString("base64"); const dbPasswordB64 = Buffer.from(secret.values.dbPassword, "utf8").toString("base64"); const encryptionKeyB64 = Buffer.from(secret.values.encryptionKey, "utf8").toString("base64"); const frpcB64 = Buffer.from(frpc.frpcToml, "utf8").toString("base64"); return ` set -u tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT manifest="$tmp/n8n.k8s.yaml" printf '%s' '${encodedManifest}' | base64 -d > "$manifest" printf '%s' '${dbPasswordB64}' | base64 -d > "$tmp/db-password" printf '%s' '${encryptionKeyB64}' | base64 -d > "$tmp/encryption-key" printf '%s' '${frpcB64}' | base64 -d > "$tmp/frpc.toml" kubectl create namespace ${target.namespace} --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f - >"$tmp/ns.out" 2>"$tmp/ns.err" ns_rc=$? secret_rc=1 frpc_rc=1 apply_rc=1 if [ "$ns_rc" -eq 0 ]; then kubectl -n ${target.namespace} create secret generic ${n8n.runtime.database.secretName} \\ --from-file=DB_POSTGRESDB_PASSWORD="$tmp/db-password" \\ --from-file=N8N_ENCRYPTION_KEY="$tmp/encryption-key" \\ --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f - >"$tmp/secret.out" 2>"$tmp/secret.err" secret_rc=$? kubectl -n ${target.namespace} create secret generic ${frpc.secretName} \\ --from-file=${frpc.secretKey}="$tmp/frpc.toml" \\ --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f - >"$tmp/frpc-secret.out" 2>"$tmp/frpc-secret.err" frpc_rc=$? fi if [ "$ns_rc" -eq 0 ] && [ "$secret_rc" -eq 0 ] && [ "$frpc_rc" -eq 0 ]; then kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f "$manifest" >"$tmp/apply.out" 2>"$tmp/apply.err" apply_rc=$? else : >"$tmp/apply.out" printf '%s\\n' 'skipped because namespace or secret sync failed' >"$tmp/apply.err" fi python3 - "$ns_rc" "$secret_rc" "$frpc_rc" "$apply_rc" "$tmp/ns.out" "$tmp/ns.err" "$tmp/secret.out" "$tmp/secret.err" "$tmp/frpc-secret.out" "$tmp/frpc-secret.err" "$tmp/apply.out" "$tmp/apply.err" <<'PY' import json, sys ns_rc, secret_rc, frpc_rc, apply_rc = [int(value) for value in sys.argv[1:5]] def text(path, limit=6000): try: return open(path, encoding="utf-8", errors="replace").read()[-limit:] except FileNotFoundError: return "" payload = { "ok": ns_rc == 0 and secret_rc == 0 and frpc_rc == 0 and apply_rc == 0, "target": "${target.id}", "namespace": "${target.namespace}", "image": "${n8n.image.repository}:${n8n.image.tag}", "secret": {"name": "${n8n.runtime.database.secretName}", "keys": ["DB_POSTGRESDB_PASSWORD", "N8N_ENCRYPTION_KEY"], "valuesPrinted": False}, "frpcSecret": {"name": "${frpc.secretName}", "key": "${frpc.secretKey}", "fingerprint": "${frpc.fingerprint}", "valuesPrinted": False}, "steps": { "namespace": {"exitCode": ns_rc, "stdout": text(sys.argv[5]), "stderr": text(sys.argv[6])}, "secret": {"exitCode": secret_rc, "stdout": text(sys.argv[7]), "stderr": text(sys.argv[8])}, "frpcSecret": {"exitCode": frpc_rc, "stdout": text(sys.argv[9]), "stderr": text(sys.argv[10])}, "apply": {"exitCode": apply_rc, "stdout": text(sys.argv[11], 10000), "stderr": text(sys.argv[12])}, }, } print(json.dumps(payload, ensure_ascii=False, indent=2)) sys.exit(0 if payload["ok"] else 1) PY `; } function statusScript(n8n: N8nConfig, target: N8nTarget): string { return ` set -u tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT capture() { name="$1" shift "$@" -o json >"$tmp/$name.json" 2>"$tmp/$name.err" printf '%s' "$?" >"$tmp/$name.rc" } capture ns kubectl get namespace ${target.namespace} capture deployments kubectl -n ${target.namespace} get deployments -l app.kubernetes.io/part-of=platform-infra capture pods kubectl -n ${target.namespace} get pods -l app.kubernetes.io/part-of=platform-infra capture services kubectl -n ${target.namespace} get services -l app.kubernetes.io/part-of=platform-infra capture pvc kubectl -n ${target.namespace} get pvc -l app.kubernetes.io/part-of=platform-infra capture configmap kubectl -n ${target.namespace} get configmap n8n-config capture secret kubectl -n ${target.namespace} get secret ${n8n.runtime.database.secretName} capture frpcSecret kubectl -n ${target.namespace} get secret ${target.publicExposure.frpc.secretName} capture networkpolicy kubectl -n ${target.namespace} get networkpolicy allow-all capture ingress kubectl -n ${target.namespace} get ingress capture quota kubectl -n ${target.namespace} get resourcequota capture limitrange kubectl -n ${target.namespace} get limitrange capture events kubectl -n ${target.namespace} get events --sort-by=.lastTimestamp python3 - "$tmp" <<'PY' import json, os, sys tmp = sys.argv[1] def rc(name): try: return int(open(os.path.join(tmp, f"{name}.rc"), encoding="utf-8").read() or "1") except FileNotFoundError: return 1 def load(name): try: return json.load(open(os.path.join(tmp, f"{name}.json"), encoding="utf-8")) except Exception: return None deployments = load("deployments") or {"items": []} pods = load("pods") or {"items": []} services = load("services") or {"items": []} pvc = load("pvc") or {"items": []} events = load("events") or {"items": []} def deployment_summary(name): for item in deployments.get("items", []): if item.get("metadata", {}).get("name") == name: status = item.get("status", {}) spec = item.get("spec", {}) return { "name": name, "replicas": spec.get("replicas", 0), "readyReplicas": status.get("readyReplicas", 0), "updatedReplicas": status.get("updatedReplicas", 0), "availableReplicas": status.get("availableReplicas", 0), } return {"name": name, "missing": True, "readyReplicas": 0, "replicas": 0} app_deploy = deployment_summary("${serviceName}") frpc_deploy = deployment_summary("${target.publicExposure.frpc.deploymentName}") deployment_ready = ( not app_deploy.get("missing") and not frpc_deploy.get("missing") and app_deploy.get("readyReplicas", 0) >= 1 and frpc_deploy.get("readyReplicas", 0) >= 1 ) watched_names = {"${serviceName}", "${target.publicExposure.frpc.deploymentName}"} watched_pod_names = { item.get("metadata", {}).get("name") for item in pods.get("items", []) if item.get("metadata", {}).get("labels", {}).get("app.kubernetes.io/name") in watched_names } watched_pvc_names = { item.get("metadata", {}).get("name") for item in pvc.get("items", []) if item.get("metadata", {}).get("labels", {}).get("app.kubernetes.io/part-of") == "platform-infra" and str(item.get("metadata", {}).get("name", "")).startswith("n8n-") } def compact_message(value, limit=500): if not value: return None value = str(value).replace("\\n", " ") return value if len(value) <= limit else value[:limit] + "...(truncated)" def state_summary(status): state = status.get("state") or {} if not state: return {"type": "unknown"} state_type = next(iter(state.keys())) detail = state.get(state_type) or {} return {"type": state_type, "reason": detail.get("reason"), "message": compact_message(detail.get("message"))} def condition_summary(condition): return {"type": condition.get("type"), "status": condition.get("status"), "reason": condition.get("reason"), "message": compact_message(condition.get("message"))} def event_summary(event): involved = event.get("involvedObject", {}) return { "type": event.get("type"), "reason": event.get("reason"), "message": compact_message(event.get("message"), 700), "count": event.get("count"), "lastTimestamp": event.get("lastTimestamp") or event.get("eventTime"), "involvedObject": {"kind": involved.get("kind"), "name": involved.get("name")}, } relevant_events = [] for event in events.get("items", []): involved = event.get("involvedObject", {}) kind = involved.get("kind") name = involved.get("name") if name in watched_pod_names or name in watched_pvc_names or name in watched_names or (kind == "ReplicaSet" and any(str(name or "").startswith(prefix + "-") for prefix in watched_names)): relevant_events.append(event_summary(event)) payload = { "ok": rc("ns") == 0 and rc("deployments") == 0 and rc("pods") == 0 and deployment_ready, "namespace": "${target.namespace}", "publicBaseUrl": "${target.publicExposure.publicBaseUrl}", "database": {"host": "${n8n.runtime.database.host}", "database": "${n8n.runtime.database.dbName}", "user": "${n8n.runtime.database.user}", "sslMode": "${n8n.runtime.database.sslMode}"}, "deployments": {"n8n": app_deploy, "frpc": frpc_deploy}, "pods": [ { "name": item.get("metadata", {}).get("name"), "phase": item.get("status", {}).get("phase"), "ready": sum(1 for condition in item.get("status", {}).get("conditions", []) if condition.get("type") == "Ready" and condition.get("status") == "True"), "conditions": [condition_summary(condition) for condition in item.get("status", {}).get("conditions", []) if condition.get("status") != "True" or condition.get("type") in ("Ready", "ContainersReady", "PodScheduled")], "initContainers": [{"name": c.get("name"), "ready": c.get("ready"), "restartCount": c.get("restartCount"), "state": state_summary(c)} for c in item.get("status", {}).get("initContainerStatuses", [])], "containers": [{"name": c.get("name"), "ready": c.get("ready"), "restartCount": c.get("restartCount"), "state": state_summary(c)} for c in item.get("status", {}).get("containerStatuses", [])], } for item in pods.get("items", []) if item.get("metadata", {}).get("name") in watched_pod_names ], "events": relevant_events[-20:], "services": [item.get("metadata", {}).get("name") for item in services.get("items", [])], "pvcs": [item.get("metadata", {}).get("name") for item in pvc.get("items", [])], "secrets": { "app": {"name": "${n8n.runtime.database.secretName}", "exists": rc("secret") == 0, "valuesPrinted": False}, "frpc": {"name": "${target.publicExposure.frpc.secretName}", "exists": rc("frpcSecret") == 0, "valuesPrinted": False}, }, "policyObjects": { "allowAllNetworkPolicy": rc("networkpolicy") == 0, "ingressCount": len((load("ingress") or {}).get("items", [])), "resourceQuotaCount": len((load("quota") or {}).get("items", [])), "limitRangeCount": len((load("limitrange") or {}).get("items", [])), }, } print(json.dumps(payload, ensure_ascii=False, indent=2)) PY `; } function logsScript(target: N8nTarget, options: LogsOptions): string { const components = options.component === "all" ? ["app", "frpc"] : [options.component]; const commands = components.map((component) => { const deployment = component === "app" ? serviceName : target.publicExposure.frpc.deploymentName; return `kubectl -n ${target.namespace} logs deploy/${deployment} --tail=${options.lines} --all-containers=true >"$tmp/${component}.out" 2>"$tmp/${component}.err"; printf '%s' "$?" >"$tmp/${component}.rc"`; }).join("\n"); return ` set -u tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT ${commands} python3 - "$tmp" ${components.map((item) => shQuote(item)).join(" ")} <<'PY' import json, os, re, sys tmp = sys.argv[1] components = sys.argv[2:] payload = {"ok": True, "components": {}, "valuesPrinted": False} generic_secret = re.compile(r"(?i)((?:password|secret|token|api[_-]?key|database_url|encryption_key)\\s*[=:]\\s*)[^\\s,;]+") def redact(value): value = re.sub(r"(postgresql://)[^@\\s]+@", r"\\1@", value) return generic_secret.sub(r"\\1", value) for component in components: def text(suffix): try: return redact(open(os.path.join(tmp, f"{component}.{suffix}"), encoding="utf-8", errors="replace").read())[-12000:] except FileNotFoundError: return "" rc = int((text("rc").strip() or "1")) payload["components"][component] = {"exitCode": rc, "stdoutTail": text("out"), "stderrTail": text("err")} if rc != 0: payload["ok"] = False print(json.dumps(payload, ensure_ascii=False, indent=2)) PY `; } function validateScript(target: N8nTarget): string { return ` set -u tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT kubectl -n ${target.namespace} get deploy ${serviceName} -o json >"$tmp/deploy.json" 2>"$tmp/deploy.err" deploy_rc=$? kubectl -n ${target.namespace} get deploy ${target.publicExposure.frpc.deploymentName} -o json >"$tmp/frpc.json" 2>"$tmp/frpc.err" frpc_rc=$? kubectl -n ${target.namespace} exec deploy/${serviceName} -c n8n -- node -e 'const http=require("http"); const req=http.get("http://127.0.0.1:5678/", (res)=>{let n=0; res.on("data",(c)=>n+=c.length); res.on("end",()=>{console.log(JSON.stringify({ok:res.statusCode>=200 && res.statusCode<500,status:res.statusCode,bytes:n}));});}); req.setTimeout(10000,()=>{console.log(JSON.stringify({ok:false,error:"timeout"})); req.destroy(); process.exit(1);}); req.on("error",(e)=>{console.log(JSON.stringify({ok:false,error:e.message})); process.exit(1);});' >"$tmp/http.out" 2>"$tmp/http.err" http_rc=$? python3 - "$deploy_rc" "$frpc_rc" "$http_rc" "$tmp/deploy.json" "$tmp/deploy.err" "$tmp/frpc.json" "$tmp/frpc.err" "$tmp/http.out" "$tmp/http.err" <<'PY' import json, sys deploy_rc, frpc_rc, http_rc = [int(value) for value in sys.argv[1:4]] def load(path): try: return json.load(open(path, encoding="utf-8")) except Exception: return None def text(path): try: return open(path, encoding="utf-8", errors="replace").read()[-3000:] except FileNotFoundError: return "" deploy = load(sys.argv[4]) or {} frpc = load(sys.argv[6]) or {} http = load(sys.argv[8]) or {} payload = { "ok": deploy_rc == 0 and frpc_rc == 0 and http_rc == 0 and http.get("ok") is True, "deployments": {"n8nReady": deploy.get("status", {}).get("readyReplicas", 0), "frpcReady": frpc.get("status", {}).get("readyReplicas", 0)}, "internalHttp": http, "errors": {"deploy": text(sys.argv[5]), "frpc": text(sys.argv[7]), "http": text(sys.argv[9])}, } print(json.dumps(payload, ensure_ascii=False, indent=2)) sys.exit(0 if payload["ok"] else 1) PY `; } function prepareSecretMaterial(n8n: N8nConfig): SecretMaterial { const root = secretRoot(n8n); const dbSource = readEnvSourceFile({ root, sourceRef: n8n.runtime.database.sourceRef, missingMessage: (sourcePath) => `n8n database source ${redactRepoPath(sourcePath)} is missing; run platform-db postgres apply --config config/platform-db/postgres-pk01.yaml --confirm first`, }); const dbValues = dbSource.values; const dbUser = requiredEnvValue(dbValues, n8n.runtime.database.sourceKeys.user, n8n.runtime.database.sourceRef); const dbPassword = requiredEnvValue(dbValues, n8n.runtime.database.sourceKeys.password, n8n.runtime.database.sourceRef); const dbName = requiredEnvValue(dbValues, n8n.runtime.database.sourceKeys.dbName, n8n.runtime.database.sourceRef); if (dbUser !== n8n.runtime.database.user) throw new Error(`${n8n.runtime.database.sourceRef}.${n8n.runtime.database.sourceKeys.user} does not match n8n runtime.database.user`); if (dbName !== n8n.runtime.database.dbName) throw new Error(`${n8n.runtime.database.sourceRef}.${n8n.runtime.database.sourceKeys.dbName} does not match n8n runtime.database.dbName`); const appSource = readEnvSourceFile({ root, sourceRef: n8n.runtime.secrets.appSourceRef, missingMessage: (sourcePath) => `n8n app secret source ${redactRepoPath(sourcePath)} is missing; run bun scripts/cli.ts secrets sync --config config/secrets-distribution.yaml --scope platform-infra --confirm first`, }); const appValues = appSource.values; const encryptionKeyName = n8n.runtime.secrets.encryptionKey; const encryptionKey = requiredEnvValue(appValues, encryptionKeyName, n8n.runtime.secrets.appSourceRef); const values = { dbUser, dbPassword, dbName, encryptionKey }; return { dbSourceRef: n8n.runtime.database.sourceRef, dbSourcePath: dbSource.sourcePathRedacted, appSourceRef: n8n.runtime.secrets.appSourceRef, appSourcePath: appSource.sourcePathRedacted, action: "read", values, fingerprint: fingerprintSecretValues({ dbPassword, encryptionKey: values.encryptionKey }, ["dbPassword", "encryptionKey"]), valuesPrinted: false, }; } function readN8nConfig(): N8nConfig { const parsed = Bun.YAML.parse(readFileSync(configFile, "utf8")) as unknown; const root = asRecord(parsed, configLabel); const version = integerField(root, "version", ""); const kind = stringField(root, "kind", ""); if (kind !== "platform-infra-n8n") throw new Error(`${configLabel}.kind must be platform-infra-n8n`); const metadata = objectField(root, "metadata", ""); const defaults = objectField(root, "defaults", ""); const image = objectField(root, "image", ""); const dependencyImages = objectField(root, "dependencyImages", ""); const runtime = objectField(root, "runtime", ""); const database = objectField(runtime, "database", "runtime"); const sourceKeys = objectField(database, "sourceKeys", "runtime.database"); const secrets = objectField(runtime, "secrets", "runtime"); const storage = objectField(runtime, "storage", "runtime"); const publicConfig = objectField(runtime, "public", "runtime"); const security = objectField(runtime, "security", "runtime"); const config: N8nConfig = { version, kind, metadata: { id: stringField(metadata, "id", "metadata"), owner: stringField(metadata, "owner", "metadata"), relatedIssues: numberArrayField(metadata, "relatedIssues", "metadata"), }, defaults: { targetId: stringField(defaults, "targetId", "defaults"), }, image: { repository: stringField(image, "repository", "image"), tag: stringField(image, "tag", "image"), pullPolicy: enumField(image, "pullPolicy", "image", ["Always", "IfNotPresent", "Never"] as const), }, dependencyImages: { postgresClient: stringField(dependencyImages, "postgresClient", "dependencyImages"), }, targets: arrayOfRecords(root.targets, "targets").map(parseTarget), runtime: { timezone: stringField(runtime, "timezone", "runtime"), database: { sourceRef: sourceRefField(database, "sourceRef", "runtime.database"), sourceKeys: { user: envKeyField(sourceKeys, "user", "runtime.database.sourceKeys"), password: envKeyField(sourceKeys, "password", "runtime.database.sourceKeys"), dbName: envKeyField(sourceKeys, "dbName", "runtime.database.sourceKeys"), }, secretName: kubernetesNameField(database, "secretName", "runtime.database"), host: hostField(database, "host", "runtime.database"), port: portField(database, "port", "runtime.database"), user: pgIdentifierField(database, "user", "runtime.database"), dbName: pgIdentifierField(database, "dbName", "runtime.database"), sslMode: enumField(database, "sslMode", "runtime.database", ["require"] as const), sslRejectUnauthorized: booleanField(database, "sslRejectUnauthorized", "runtime.database"), }, secrets: { root: stringField(secrets, "root", "runtime.secrets"), appSourceRef: sourceRefField(secrets, "appSourceRef", "runtime.secrets"), encryptionKey: envKeyField(secrets, "encryptionKey", "runtime.secrets"), }, storage: { data: pvcSpec(objectField(storage, "data", "runtime.storage"), "runtime.storage.data"), }, public: { host: hostField(publicConfig, "host", "runtime.public"), protocol: enumField(publicConfig, "protocol", "runtime.public", ["https"] as const), editorBaseUrl: httpsUrlField(publicConfig, "editorBaseUrl", "runtime.public"), webhookUrl: httpsUrlField(publicConfig, "webhookUrl", "runtime.public"), proxyHops: integerField(publicConfig, "proxyHops", "runtime.public"), }, security: { diagnosticsEnabled: booleanField(security, "diagnosticsEnabled", "runtime.security"), personalisationEnabled: booleanField(security, "personalisationEnabled", "runtime.security"), secureCookie: booleanField(security, "secureCookie", "runtime.security"), }, }, }; if (!isImageReference(`${config.image.repository}:${config.image.tag}`)) throw new Error(`${configLabel}.image must render a valid image reference`); if (!isImageReference(config.dependencyImages.postgresClient)) throw new Error(`${configLabel}.dependencyImages.postgresClient must be an image reference`); if (config.targets.length === 0) throw new Error(`${configLabel}.targets must not be empty`); assertKnownEnabledTarget(config.targets, config.defaults.targetId, "defaults.targetId"); return config; } function parseTarget(record: Record, index: number): N8nTarget { const path = `targets[${index}]`; const exposure = objectField(record, "publicExposure", path); const dns = objectField(exposure, "dns", `${path}.publicExposure`); const frpc = objectField(exposure, "frpc", `${path}.publicExposure`); const pk01 = objectField(exposure, "pk01", `${path}.publicExposure`); const publicBaseUrl = httpsUrlField(exposure, "publicBaseUrl", `${path}.publicExposure`); const hostname = hostField(dns, "hostname", `${path}.publicExposure.dns`); if (new URL(publicBaseUrl).hostname !== hostname) throw new Error(`${path}.publicExposure hostname must match publicBaseUrl`); return { id: stringField(record, "id", path), route: stringField(record, "route", path), namespace: kubernetesNameField(record, "namespace", path), enabled: booleanField(record, "enabled", path), replicas: integerField(record, "replicas", path), publicExposure: { enabled: booleanField(exposure, "enabled", `${path}.publicExposure`), publicBaseUrl, dns: { hostname, expectedA: stringField(dns, "expectedA", `${path}.publicExposure.dns`), resolvers: stringArrayField(dns, "resolvers", `${path}.publicExposure.dns`), }, frpc: { deploymentName: kubernetesNameField(frpc, "deploymentName", `${path}.publicExposure.frpc`), secretName: kubernetesNameField(frpc, "secretName", `${path}.publicExposure.frpc`), secretKey: stringField(frpc, "secretKey", `${path}.publicExposure.frpc`), image: stringField(frpc, "image", `${path}.publicExposure.frpc`), serverAddr: hostField(frpc, "serverAddr", `${path}.publicExposure.frpc`), serverPort: portField(frpc, "serverPort", `${path}.publicExposure.frpc`), proxyName: stringField(frpc, "proxyName", `${path}.publicExposure.frpc`), remotePort: portField(frpc, "remotePort", `${path}.publicExposure.frpc`), localIP: hostField(frpc, "localIP", `${path}.publicExposure.frpc`), localPort: portField(frpc, "localPort", `${path}.publicExposure.frpc`), tokenSourceRef: sourceRefField(frpc, "tokenSourceRef", `${path}.publicExposure.frpc`), tokenSourceKey: envKeyField(frpc, "tokenSourceKey", `${path}.publicExposure.frpc`), }, pk01: { route: stringField(pk01, "route", `${path}.publicExposure.pk01`), caddyConfigPath: absolutePathField(pk01, "caddyConfigPath", `${path}.publicExposure.pk01`), caddyServiceName: stringField(pk01, "caddyServiceName", `${path}.publicExposure.pk01`), responseHeaderTimeoutSeconds: integerField(pk01, "responseHeaderTimeoutSeconds", `${path}.publicExposure.pk01`), }, } satisfies PublicServiceExposure, }; } function assertKnownEnabledTarget(targets: N8nTarget[], targetId: string, path: string): void { const target = targets.find((item) => item.id.toLowerCase() === targetId.toLowerCase()); if (target === undefined) throw new Error(`${configLabel}.${path} references unknown target ${targetId}; known targets: ${targets.map((item) => item.id).join(", ")}`); if (!target.enabled) throw new Error(`${configLabel}.${path} references disabled target ${target.id}`); } function resolveTarget(n8n: N8nConfig, targetId: string | null): N8nTarget { const resolvedTargetId = targetId ?? n8n.defaults.targetId; const target = n8n.targets.find((item) => item.id.toLowerCase() === resolvedTargetId.toLowerCase()); if (target === undefined) throw new Error(`unknown n8n target ${resolvedTargetId}; known targets: ${n8n.targets.map((item) => item.id).join(", ")}`); if (!target.enabled) throw new Error(`n8n target ${target.id} is disabled in ${configLabel}`); return target; } function configSummary(n8n: N8nConfig, target: N8nTarget): Record { return { path: "config/platform-infra/n8n.yaml", metadata: n8n.metadata, target: targetSummary(target), image: `${n8n.image.repository}:${n8n.image.tag}`, pullPolicy: n8n.image.pullPolicy, database: { host: n8n.runtime.database.host, port: n8n.runtime.database.port, user: n8n.runtime.database.user, dbName: n8n.runtime.database.dbName, sslMode: n8n.runtime.database.sslMode, sourceRef: n8n.runtime.database.sourceRef, secretName: n8n.runtime.database.secretName, valuesPrinted: false, }, storage: n8n.runtime.storage, public: n8n.runtime.public, publicExposure: { publicBaseUrl: target.publicExposure.publicBaseUrl, dns: target.publicExposure.dns, frpc: { deploymentName: target.publicExposure.frpc.deploymentName, remotePort: target.publicExposure.frpc.remotePort, localIP: target.publicExposure.frpc.localIP, localPort: target.publicExposure.frpc.localPort, tokenSourceRef: target.publicExposure.frpc.tokenSourceRef, valuesPrinted: false, }, pk01: target.publicExposure.pk01, }, }; } function targetSummary(target: N8nTarget): Record { return { id: target.id, route: target.route, namespace: target.namespace, replicas: target.replicas }; } function secretSummary(secret: SecretMaterial, frpc: FrpcSecretMaterial): Record { return { dbSourceRef: secret.dbSourceRef, dbSourcePath: secret.dbSourcePath, appSourceRef: secret.appSourceRef, appSourcePath: secret.appSourcePath, action: secret.action, fingerprint: secret.fingerprint, frpc: { sourceRef: frpc.sourceRef, sourcePath: frpc.sourcePath, secretName: frpc.secretName, secretKey: frpc.secretKey, fingerprint: frpc.fingerprint, }, valuesPrinted: false, }; } function secretRoot(n8n: N8nConfig): string { const root = n8n.runtime.secrets.root; return isAbsolute(root) ? root : rootPath(root); } function boolField(value: Record | null, key: string, fallback: boolean): boolean { return typeof value?.[key] === "boolean" ? value[key] : fallback; } function pvcSpec(record: Record, path: string): PvcSpec { const name = kubernetesNameField(record, "name", path); const size = stringField(record, "size", path); if (!/^[0-9]+(Gi|Mi)$/u.test(size)) throw new Error(`${configLabel}.${path}.size must use Mi or Gi`); return { name, size }; } function isImageReference(value: string): boolean { return /^[A-Za-z0-9._:/-]+$/u.test(value) && value.includes(":") && !value.includes(".."); }