feat: deploy n8n as platform infra service

This commit is contained in:
Codex
2026-06-12 17:20:49 +00:00
parent cd025b6c84
commit 91b6c10d3d
8 changed files with 1768 additions and 7 deletions
+7 -3
View File
@@ -60,7 +60,7 @@ export function rootHelp(): unknown {
{ command: "hwlab nodes control-plane|git-mirror|secret --node <node> --lane <lane>", description: "Manage HWLAB node/lane runtime prerequisites, including D601 YAML-declared infra/tools-image/Argo bootstrap and G14 v0.3+ runtime lanes, with the node identity passed as data." },
{ command: "hwlab g14 monitor-prs | hwlab g14 control-plane status|apply|trigger-current|runtime-migration|cleanup-runs|cleanup-released-pvs | hwlab g14 git-mirror status|apply|sync|flush | hwlab g14 tools-image status|build", description: "Start the legacy G14 PR monitor, run bounded v0.2 Tekton/Argo control-plane, manual PipelineRun trigger, runtime migration, CI workspace retention, manual devops-infra git mirror/relay maintenance, or fixed HWLAB CI tools image actions; long confirmed trigger/sync/flush actions return async jobs by default." },
{ command: "agentrun get|describe|events|logs|result|ack|cancel|dispatch|create|apply|send|control-plane|git-mirror", description: "Use AgentRun v0.1 resource primitives with low-noise human output by default; session follow-up uses send only and the server decides internal steer vs turn." },
{ command: "platform-infra sub2api|langbot ...", description: "Deploy platform-infra services such as Sub2API and LangBot, manage YAML-controlled public FRP/Caddy exposure, and inspect status/logs without printing API keys." },
{ command: "platform-infra sub2api|langbot|n8n ...", description: "Deploy platform-infra services such as Sub2API, LangBot and n8n, manage YAML-controlled public FRP/Caddy exposure, and inspect status/logs without printing secrets." },
{ command: "platform-db postgres plan|status|apply", description: "Manage YAML-declared host-native PostgreSQL 16 on PK01 for Sub2API/platform state, with PostgreSQL native TLS and redacted secret exports." },
{ command: "hwlab cd audit --env dev | hwlab cd status --env dev | hwlab cd apply --env dev --dry-run", description: "Legacy D601 HWLAB DEV CD wrapper kept for explicit old-path diagnostics; current HWLAB rollout uses G14 GitOps." },
{ command: "code-agent-sandbox", description: "Independent Code Agent Sandbox service skeleton for adapter, mode, and credential-boundary diagnostics." },
@@ -605,7 +605,7 @@ function agentRunHelpSummary(): unknown {
function platformInfraHelpSummary(): unknown {
return {
command: "platform-infra sub2api|langbot ...",
command: "platform-infra sub2api|langbot|n8n ...",
output: "json",
usage: [
"bun scripts/cli.ts platform-infra sub2api plan",
@@ -619,8 +619,12 @@ function platformInfraHelpSummary(): unknown {
"bun scripts/cli.ts platform-infra langbot apply --confirm",
"bun scripts/cli.ts platform-infra langbot status",
"bun scripts/cli.ts platform-infra langbot query --path /api/v1/platform/bots",
"bun scripts/cli.ts platform-infra n8n plan",
"bun scripts/cli.ts platform-infra n8n apply --confirm",
"bun scripts/cli.ts platform-infra n8n status",
"bun scripts/cli.ts platform-infra n8n validate --full",
],
description: "Operate G14 platform-infra services such as Sub2API, LangBot, and the YAML-controlled Codex pool.",
description: "Operate G14 platform-infra services such as Sub2API, LangBot, n8n, and the YAML-controlled Codex pool.",
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,372 @@
import { createHash } from "node:crypto";
import { Buffer } from "node:buffer";
import type { UniDeskConfig } from "./config";
import { runSshCommandCapture, type SshCaptureResult } from "./ssh";
export interface PublicServiceExposure {
enabled: boolean;
publicBaseUrl: string;
dns: { hostname: string; expectedA: string; resolvers: string[] };
frpc: {
deploymentName: string;
secretName: string;
secretKey: string;
image: string;
serverAddr: string;
serverPort: number;
proxyName: string;
remotePort: number;
localIP: string;
localPort: number;
tokenSourceRef: string;
tokenSourceKey: string;
};
pk01: {
route: string;
caddyConfigPath: string;
caddyServiceName: string;
responseHeaderTimeoutSeconds: number;
};
}
export interface PublicServiceTarget {
id: string;
route: string;
namespace: string;
replicas: number;
publicExposure: PublicServiceExposure;
}
export interface FrpcSecretMaterial {
sourceRef: string;
sourcePath: string;
secretName: string;
secretKey: string;
frpcToml: string;
fingerprint: string;
valuesPrinted: false;
}
export async function capture(config: UniDeskConfig, route: string, args: string[], stdin: string): Promise<SshCaptureResult> {
return await runSshCommandCapture(config, route, args, stdin);
}
export async function applyPk01CaddyBlock(
config: UniDeskConfig,
serviceId: string,
exposure: PublicServiceExposure,
markers: { start: string; end: string },
): Promise<Record<string, unknown>> {
if (!exposure.enabled) return { ok: true, action: "not-enabled" };
const block = `${markers.start}
${exposure.dns.hostname} {
reverse_proxy 127.0.0.1:${exposure.frpc.remotePort} {
transport http {
response_header_timeout ${exposure.pk01.responseHeaderTimeoutSeconds}s
}
}
}
${markers.end}
`;
const blockB64 = Buffer.from(block, "utf8").toString("base64");
const script = `
set -u
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
block="$tmp/${serviceId}.caddy"
printf '%s' '${blockB64}' | base64 -d >"$block"
sudo python3 - ${shQuote(exposure.pk01.caddyConfigPath)} "$block" ${shQuote(markers.start)} ${shQuote(markers.end)} >"$tmp/update.out" 2>"$tmp/update.err" <<'PY'
import pathlib
import sys
config_path = pathlib.Path(sys.argv[1])
block_path = pathlib.Path(sys.argv[2])
start = sys.argv[3]
end = sys.argv[4]
text = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
block = block_path.read_text(encoding="utf-8").strip() + "\\n"
if start in text and end in text:
before = text.split(start, 1)[0].rstrip()
tail = text.split(end, 1)[1].lstrip()
next_text = before + "\\n\\n" + block + "\\n" + tail
action = "update"
else:
next_text = text.rstrip() + "\\n\\n" + block
action = "append"
tmp = config_path.with_suffix(config_path.suffix + ".unidesk-${serviceId}.tmp")
tmp.write_text(next_text, encoding="utf-8")
tmp.replace(config_path)
print(action)
PY
update_rc=$?
if [ "$update_rc" -eq 0 ]; then
sudo caddy fmt --overwrite ${shQuote(exposure.pk01.caddyConfigPath)} >"$tmp/fmt.out" 2>"$tmp/fmt.err"
fmt_rc=$?
else
: >"$tmp/fmt.out"; : >"$tmp/fmt.err"; fmt_rc=1
fi
if [ "$fmt_rc" -eq 0 ]; then
sudo caddy validate --config ${shQuote(exposure.pk01.caddyConfigPath)} >"$tmp/validate.out" 2>"$tmp/validate.err"
validate_rc=$?
else
: >"$tmp/validate.out"; : >"$tmp/validate.err"; validate_rc=1
fi
if [ "$validate_rc" -eq 0 ]; then
sudo systemctl reload ${shQuote(exposure.pk01.caddyServiceName)} >"$tmp/reload.out" 2>"$tmp/reload.err" || sudo systemctl restart ${shQuote(exposure.pk01.caddyServiceName)} >>"$tmp/reload.out" 2>>"$tmp/reload.err"
reload_rc=$?
else
: >"$tmp/reload.out"; : >"$tmp/reload.err"; reload_rc=1
fi
python3 - "$update_rc" "$fmt_rc" "$validate_rc" "$reload_rc" "$tmp/update.out" "$tmp/update.err" "$tmp/fmt.out" "$tmp/fmt.err" "$tmp/validate.out" "$tmp/validate.err" "$tmp/reload.out" "$tmp/reload.err" <<'PY'
import json, sys
rcs = [int(value) for value in sys.argv[1:5]]
def text(path):
try:
return open(path, encoding="utf-8", errors="replace").read()[-3000:]
except FileNotFoundError:
return ""
payload = {
"ok": all(rc == 0 for rc in rcs),
"serviceId": "${serviceId}",
"hostname": "${exposure.dns.hostname}",
"remotePort": ${exposure.frpc.remotePort},
"caddyConfigPath": "${exposure.pk01.caddyConfigPath}",
"service": "${exposure.pk01.caddyServiceName}",
"managedBlock": {"start": "${markers.start}", "end": "${markers.end}"},
"steps": {
"update": {"exitCode": rcs[0], "stdout": text(sys.argv[5]), "stderr": text(sys.argv[6])},
"fmt": {"exitCode": rcs[1], "stdout": text(sys.argv[7]), "stderr": text(sys.argv[8])},
"validate": {"exitCode": rcs[2], "stdout": text(sys.argv[9]), "stderr": text(sys.argv[10])},
"reload": {"exitCode": rcs[3], "stdout": text(sys.argv[11]), "stderr": text(sys.argv[12])},
},
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
sys.exit(0 if payload["ok"] else 1)
PY
`;
const result = await capture(config, exposure.pk01.route, ["script"], script);
const parsed = parseJsonOutput(result.stdout);
return parsed ?? { ok: false, capture: compactCapture(result, { full: true }) };
}
export function prepareFrpcSecret(params: {
secretRoot: string;
exposure: PublicServiceExposure;
sourcePathRedactor: (path: string) => string;
parseEnvFile: (text: string) => Record<string, string>;
requiredEnvValue: (values: Record<string, string>, key: string, sourceRef: string) => string;
readTextFile: (path: string) => string;
}): FrpcSecretMaterial {
const { exposure } = params;
const sourcePath = `${params.secretRoot.replace(/\/+$/u, "")}/${exposure.frpc.tokenSourceRef}`;
const values = params.parseEnvFile(params.readTextFile(sourcePath));
const token = params.requiredEnvValue(values, exposure.frpc.tokenSourceKey, exposure.frpc.tokenSourceRef);
const frpcToml = [
`serverAddr = "${exposure.frpc.serverAddr}"`,
`serverPort = ${exposure.frpc.serverPort}`,
"loginFailExit = true",
`auth.token = "${escapeTomlString(token)}"`,
"",
"[[proxies]]",
`name = "${exposure.frpc.proxyName}"`,
'type = "tcp"',
`localIP = "${exposure.frpc.localIP}"`,
`localPort = ${exposure.frpc.localPort}`,
`remotePort = ${exposure.frpc.remotePort}`,
"",
].join("\n");
return {
sourceRef: exposure.frpc.tokenSourceRef,
sourcePath: params.sourcePathRedactor(sourcePath),
secretName: exposure.frpc.secretName,
secretKey: exposure.frpc.secretKey,
frpcToml,
fingerprint: fingerprintValues({ token, frpcToml }, ["token", "frpcToml"]),
valuesPrinted: false,
};
}
export function renderFrpcManifest(target: PublicServiceTarget): string {
const exposure = target.publicExposure;
if (!exposure.enabled) return "";
return `---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ${exposure.frpc.deploymentName}
namespace: ${target.namespace}
labels:
app.kubernetes.io/name: ${exposure.frpc.deploymentName}
app.kubernetes.io/component: tunnel
app.kubernetes.io/part-of: platform-infra
app.kubernetes.io/managed-by: unidesk
unidesk.ai/runtime-node: ${target.id}
unidesk.ai/public-hostname: ${exposure.dns.hostname}
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: ${exposure.frpc.deploymentName}
app.kubernetes.io/component: tunnel
template:
metadata:
labels:
app.kubernetes.io/name: ${exposure.frpc.deploymentName}
app.kubernetes.io/component: tunnel
app.kubernetes.io/part-of: platform-infra
annotations:
unidesk.ai/public-base-url: "${exposure.publicBaseUrl}"
unidesk.ai/frp-server: "${exposure.frpc.serverAddr}:${exposure.frpc.serverPort}"
unidesk.ai/frp-remote-port: "${exposure.frpc.remotePort}"
spec:
containers:
- name: frpc
image: ${exposure.frpc.image}
imagePullPolicy: IfNotPresent
args:
- -c
- /etc/frp/frpc.toml
volumeMounts:
- name: frpc-config
mountPath: /etc/frp/frpc.toml
subPath: ${exposure.frpc.secretKey}
readOnly: true
volumes:
- name: frpc-config
secret:
secretName: ${exposure.frpc.secretName}
`;
}
export function publicServicePolicyChecks(yaml: string, target: PublicServiceTarget, serviceName: string): Array<Record<string, unknown>> {
return [
{ name: "no-ingress", ok: !/^\s*kind:\s*Ingress\s*$/mu.test(yaml), detail: `${serviceName} public exposure must use PK01 Caddy+FRP, not Kubernetes Ingress.` },
{ name: "no-nodeport-or-loadbalancer", ok: !/^\s*type:\s*(NodePort|LoadBalancer)\s*$/mu.test(yaml), detail: "Services must stay ClusterIP." },
{ name: "no-host-network", ok: !/^\s*hostNetwork:\s*true\s*$/mu.test(yaml), detail: "Pods must not use host network." },
{ name: "no-host-port", ok: !/^\s*hostPort:\s*[0-9]+\s*$/mu.test(yaml), detail: "Pods must not expose host ports." },
{ name: "no-cpu-memory-resources", ok: !/^\s*(cpu|memory):\s*/mu.test(yaml), detail: "No CPU/memory request or limit objects are rendered." },
{ name: "allow-all-network-policy", ok: hasAllowAllNetworkPolicy(yaml, target.namespace), detail: `NetworkPolicy/allow-all exists in ${target.namespace}.` },
];
}
export function dryRunManifestScript(params: { yaml: string; target: PublicServiceTarget; fieldManager: string; manifestName: string }): string {
const encoded = Buffer.from(params.yaml, "utf8").toString("base64");
return `
set -u
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
manifest="$tmp/${params.manifestName}.k8s.yaml"
printf '%s' '${encoded}' | base64 -d > "$manifest"
kubectl apply --dry-run=client -f "$manifest" >"$tmp/client.out" 2>"$tmp/client.err"
client_rc=$?
if kubectl get namespace ${params.target.namespace} >/dev/null 2>&1; then
kubectl apply --server-side --dry-run=server --field-manager=${params.fieldManager} -f "$manifest" >"$tmp/server.out" 2>"$tmp/server.err"
server_rc=$?
server_disposition=executed
else
: >"$tmp/server.err"
printf '%s\\n' 'server dry-run skipped because namespace does not exist yet' >"$tmp/server.out"
server_rc=0
server_disposition=skipped-namespace-missing
fi
python3 - "$client_rc" "$server_rc" "$server_disposition" "$tmp/client.out" "$tmp/client.err" "$tmp/server.out" "$tmp/server.err" <<'PY'
import json, sys
client_rc, server_rc = int(sys.argv[1]), int(sys.argv[2])
def text(path):
try:
return open(path, encoding="utf-8", errors="replace").read()
except FileNotFoundError:
return ""
payload = {
"ok": client_rc == 0 and server_rc == 0,
"target": "${params.target.id}",
"namespace": "${params.target.namespace}",
"clientDryRun": {"exitCode": client_rc, "stdout": text(sys.argv[4])[-4000:], "stderr": text(sys.argv[5])[-4000:]},
"serverDryRun": {"exitCode": server_rc, "disposition": sys.argv[3], "stdout": text(sys.argv[6])[-4000:], "stderr": text(sys.argv[7])[-4000:]},
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
sys.exit(0 if payload["ok"] else 1)
PY
`;
}
export function parseJsonOutput(stdout: string): Record<string, unknown> | null {
const trimmed = stdout.trim();
if (trimmed.length === 0) return null;
const start = trimmed.indexOf("{");
const end = trimmed.lastIndexOf("}");
if (start === -1 || end === -1 || end <= start) return null;
try {
const parsed = JSON.parse(trimmed.slice(start, end + 1)) as unknown;
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
} catch {
return null;
}
}
export function compactCapture(result: SshCaptureResult, options: { full?: boolean } = {}): Record<string, unknown> {
const full = options.full ?? false;
return {
exitCode: result.exitCode,
stdoutBytes: Buffer.byteLength(result.stdout, "utf8"),
stderrBytes: Buffer.byteLength(result.stderr, "utf8"),
stdoutTail: full || result.exitCode !== 0 ? redactText(result.stdout).slice(-8000) : "",
stderrTail: full || result.exitCode !== 0 ? redactText(result.stderr).slice(-4000) : "",
};
}
export function publicHttpProbe(baseUrl: string, path: string): Record<string, unknown> {
const url = `${baseUrl.replace(/\/+$/u, "")}${path}`;
const result = Bun.spawnSync(["curl", "-fsS", "--connect-timeout", "10", "--max-time", "30", "-o", "-", "-w", "\n%{http_code}", url], { stdout: "pipe", stderr: "pipe" });
const stdout = new TextDecoder().decode(result.stdout);
const stderr = new TextDecoder().decode(result.stderr);
const lines = stdout.split(/\r?\n/u);
const statusText = lines.pop() ?? "";
const status = Number(statusText);
const body = lines.join("\n");
return {
ok: result.exitCode === 0 && Number.isInteger(status) && status >= 200 && status < 500,
url,
status: Number.isInteger(status) ? status : null,
bodyBytes: Buffer.byteLength(body, "utf8"),
bodyPreview: body.slice(0, 2000),
stderrTail: redactText(stderr).slice(-2000),
valuesPrinted: false,
};
}
export function redactText(text: string): string {
return text
.replace(/postgres(?:ql)?:\/\/[^@\s]+@/giu, "postgresql://<redacted>@")
.replace(/(N8N_ENCRYPTION_KEY|PASSWORD|SECRET|TOKEN|API_KEY|DATABASE_URL)([=:]\s*)[^\s,;]+/giu, "$1$2<redacted>");
}
export function fingerprintValues(values: Record<string, string>, keys: string[]): string {
const hash = createHash("sha256");
for (const key of keys.slice().sort()) {
hash.update(key);
hash.update("\0");
hash.update(values[key] ?? "");
hash.update("\0");
}
return `sha256:${hash.digest("hex")}`;
}
export function shQuote(value: string): string {
return `'${value.replaceAll("'", "'\"'\"'")}'`;
}
export function escapeTomlString(value: string): string {
return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
}
function hasAllowAllNetworkPolicy(yaml: string, namespaceName: string): boolean {
return yaml.split(/^---\s*$/mu).some((document) => /^\s*kind:\s*NetworkPolicy\s*$/mu.test(document)
&& /^\s*name:\s*allow-all\s*$/mu.test(document)
&& new RegExp(`^\\s*namespace:\\s*${escapeRegExp(namespaceName)}\\s*$`, "mu").test(document)
&& /^\s*podSelector:\s*\{\}\s*$/mu.test(document));
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}
+11 -2
View File
@@ -191,7 +191,7 @@ interface EgressProxySecretMaterial {
export function platformInfraHelp(): unknown {
return {
command: "platform-infra sub2api|langbot ...",
command: "platform-infra sub2api|langbot|n8n ...",
output: "json",
usage: [
"bun scripts/cli.ts platform-infra sub2api plan [--target G14|D601]",
@@ -211,8 +211,13 @@ export function platformInfraHelp(): unknown {
"bun scripts/cli.ts platform-infra langbot logs [--target G14] [--component app|plugin-runtime|frpc|all]",
"bun scripts/cli.ts platform-infra langbot bootstrap-api-key --confirm",
"bun scripts/cli.ts platform-infra langbot query --path /api/v1/platform/bots",
"bun scripts/cli.ts platform-infra n8n plan [--target G14]",
"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]",
"bun scripts/cli.ts platform-infra n8n validate [--target G14] [--full|--raw]",
],
description: "Operate YAML-controlled platform-infra services such as Sub2API and LangBot. Public services use PK01 Caddy+FRP rather than Kubernetes Ingress, NodePort, or LoadBalancer.",
description: "Operate YAML-controlled platform-infra services such as Sub2API, LangBot and n8n. Public services use PK01 Caddy+FRP rather than Kubernetes Ingress, NodePort, or LoadBalancer.",
target: {
default: defaultTargetId,
namespace,
@@ -241,6 +246,10 @@ export async function runPlatformInfraCommand(config: UniDeskConfig, args: strin
const { runLangBotCommand } = await import("./platform-infra-langbot");
return await runLangBotCommand(config, args.slice(1));
}
if (target === "n8n") {
const { runN8nCommand } = await import("./platform-infra-n8n");
return await runN8nCommand(config, args.slice(1));
}
if (target !== "sub2api") return unsupported(args);
if (action === "plan" || action === undefined) return plan(parseTargetOptions(args.slice(2)));
if (action === "apply") return await apply(config, parseApplyOptions(args.slice(2)));