Files
pikasTech-unidesk/scripts/src/platform-infra-n8n.ts
T
2026-06-15 04:01:04 +00:00

1094 lines
46 KiB
TypeScript

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<string, unknown> {
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<Record<string, unknown>> {
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<string, unknown> {
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<Record<string, unknown>> {
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<Record<string, unknown>> {
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<Record<string, unknown>> {
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<Record<string, unknown>> {
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<Record<string, unknown>> {
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<redacted>@", value)
return generic_secret.sub(r"\\1<redacted>", 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<string, unknown>, 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<string, unknown> {
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<string, unknown> {
return { id: target.id, route: target.route, namespace: target.namespace, replicas: target.replicas };
}
function secretSummary(secret: SecretMaterial, frpc: FrpcSecretMaterial): Record<string, unknown> {
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<string, unknown> | null, key: string, fallback: boolean): boolean {
return typeof value?.[key] === "boolean" ? value[key] : fallback;
}
function pvcSpec(record: Record<string, unknown>, 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("..");
}