diff --git a/config/platform-db/postgres-pk01.yaml b/config/platform-db/postgres-pk01.yaml index 0369e7e1..8d22b3cc 100644 --- a/config/platform-db/postgres-pk01.yaml +++ b/config/platform-db/postgres-pk01.yaml @@ -9,6 +9,7 @@ metadata: - 280 - 281 - 297 + - 300 cluster: role: primary @@ -88,7 +89,7 @@ postgres: purpose: platform-infra-standby-app - id: G14-public cidr: 202.98.17.68/32 - purpose: platform-infra-langbot-runtime + purpose: platform-infra-runtime tuning: maxConnections: 50 sharedBuffers: 512MB @@ -169,6 +170,36 @@ postgres: user: langbot address: 202.98.17.68/32 method: scram-sha-256 + - type: hostssl + database: n8n + user: n8n + address: 10.0.8.0/22 + method: scram-sha-256 + - type: hostssl + database: postgres + user: n8n + address: 10.0.8.0/22 + method: scram-sha-256 + - type: hostssl + database: n8n + user: n8n + address: 74.48.78.17/32 + method: scram-sha-256 + - type: hostssl + database: postgres + user: n8n + address: 74.48.78.17/32 + method: scram-sha-256 + - type: hostssl + database: n8n + user: n8n + address: 202.98.17.68/32 + method: scram-sha-256 + - type: hostssl + database: postgres + user: n8n + address: 202.98.17.68/32 + method: scram-sha-256 secrets: source: master-local @@ -202,6 +233,20 @@ secrets: LANGBOT_DB_NAME: langbot randomHex: LANGBOT_DB_PASSWORD: 32 + - name: n8n-db-credentials + sourceRef: platform-db/n8n-db.env + type: env + requiredKeys: + - N8N_DB_USER + - N8N_DB_PASSWORD + - N8N_DB_NAME + createIfMissing: + enabled: true + values: + N8N_DB_USER: n8n + N8N_DB_NAME: n8n + randomHex: + N8N_DB_PASSWORD: 32 objects: roles: @@ -223,6 +268,15 @@ objects: createdb: false createrole: false superuser: false + - name: n8n + passwordRef: + sourceRef: platform-db/n8n-db.env + key: N8N_DB_PASSWORD + login: true + attributes: + createdb: false + createrole: false + superuser: false databases: - name: sub2api owner: sub2api @@ -234,6 +288,11 @@ objects: encoding: UTF8 locale: C.UTF-8 extensions: [] + - name: n8n + owner: n8n + encoding: UTF8 + locale: C.UTF-8 + extensions: [] exports: connectionStrings: @@ -267,6 +326,21 @@ exports: - scope: platform-infra secret: langbot-secrets key: DATABASE_URL + - name: n8n-database-url + sourceSecretRef: platform-db/n8n-db.env + render: + envKey: DATABASE_URL + format: postgresql://$(N8N_DB_USER):$(N8N_DB_PASSWORD)@$(PGHOST):5432/$(N8N_DB_NAME)?sslmode=require + variables: + PGHOST: 82.156.23.220 + writeToSecretSource: + sourceRef: platform-infra/n8n.env + key: DATABASE_URL + mode: update-or-insert + consumers: + - scope: platform-infra + secret: n8n-secrets + key: DATABASE_URL backup: phase: minimum-restoreable @@ -300,6 +374,9 @@ observability: - kind: psql-app-role database: langbot user: langbot + - kind: psql-app-role + database: n8n + user: n8n - kind: disk-free path: /var/lib/postgresql/16/main minFreeGiB: 10 diff --git a/config/platform-infra/n8n.yaml b/config/platform-infra/n8n.yaml new file mode 100644 index 00000000..6337ab3f --- /dev/null +++ b/config/platform-infra/n8n.yaml @@ -0,0 +1,83 @@ +version: 1 +kind: platform-infra-n8n + +metadata: + id: n8n-public + owner: unidesk + relatedIssues: + - 300 + +image: + repository: n8nio/n8n + tag: "1.123.55" + pullPolicy: IfNotPresent + +dependencyImages: + postgresClient: docker.io/library/postgres:16-alpine + +targets: + - id: G14 + route: G14:k3s + namespace: platform-infra + enabled: true + replicas: 1 + publicExposure: + enabled: true + publicBaseUrl: https://n8n.pikapython.com + dns: + hostname: n8n.pikapython.com + expectedA: 82.156.23.220 + resolvers: [1.1.1.1, 8.8.8.8, 223.5.5.5, 114.114.114.114] + frpc: + deploymentName: n8n-frpc + secretName: n8n-frpc-secrets + secretKey: frpc.toml + image: fatedier/frpc:v0.68.1 + serverAddr: 82.156.23.220 + serverPort: 22000 + proxyName: platform-infra-n8n-g14-web + remotePort: 22100 + localIP: n8n.platform-infra.svc.cluster.local + localPort: 5678 + tokenSourceRef: platform-infra/pk01-frp.env + tokenSourceKey: FRP_TOKEN + pk01: + route: PK01 + caddyConfigPath: /etc/caddy/Caddyfile + caddyServiceName: caddy + responseHeaderTimeoutSeconds: 600 + +runtime: + timezone: Asia/Shanghai + database: + mode: external + sourceRef: platform-db/n8n-db.env + sourceKeys: + user: N8N_DB_USER + password: N8N_DB_PASSWORD + dbName: N8N_DB_NAME + secretName: n8n-secrets + host: 82.156.23.220 + port: 5432 + user: n8n + dbName: n8n + sslMode: require + sslRejectUnauthorized: false + secrets: + root: /root/unidesk/.state/secrets + appSourceRef: platform-infra/n8n.env + encryptionKey: N8N_ENCRYPTION_KEY + storage: + data: + name: n8n-data + size: 10Gi + public: + host: n8n.pikapython.com + protocol: https + editorBaseUrl: https://n8n.pikapython.com + webhookUrl: https://n8n.pikapython.com + proxyHops: 1 + security: + diagnosticsEnabled: false + personalisationEnabled: false + secureCookie: true diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 0aa4ed74..e79fabf3 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -73,7 +73,7 @@ CI/CD、GitOps、rollout、artifact 发布、PR 合并后的 runtime lane 滚动 - `hwlab g14 git-mirror status|apply|sync|flush [--dry-run|--confirm]` 是 `devops-infra` git mirror/relay 的受控维护入口:`apply` 渲染并 server-side apply `devops-infra/git-mirror.yaml`,同时删除遗留 `git-mirror-hwlab-sync` CronJob;`sync` 创建一次性 manual Job,把 GitHub allowlist refs 拉入本地 mirror;`flush` 创建一次性 manual Job,把本地 `v0.2-gitops` 快进推回 GitHub。 `status` 返回 read/write URL、last sync/write/flush、本地 ref、GitHub staging ref 和 pending flush 状态,并在 `cache.summary` 给出 `localV02`、`localGitops`、`githubGitops`、`pendingFlush`、`flushNeeded`、`githubInSync` 和下一条受控 `flushCommand`。confirmed `sync` 和 `flush` 默认创建 `.state/jobs/` 异步 job 并立刻返回可查询状态,只有现场同步调试才显式加 `--wait`;mirror 不设置 CronJob。 如果 PipelineRun 的 `gitops-promote` 阶段报 git mirror 控制面漂移或 refs 不一致,先执行 `hwlab g14 git-mirror apply --confirm` 重新应用当前 `devops-infra/git-mirror.yaml` hook/ConfigMap,再执行 `hwlab g14 git-mirror sync --confirm --wait` 复核 refs;失败的同名 PipelineRun 只能通过 `hwlab g14 control-plane cleanup-runs --lane --pipeline-run --confirm` 受控清理后重试,不要用原生 `kubectl delete` 或手工改 mirror hook。修复后仍必须用 `control-plane status --pipeline-run ` 和 `git-mirror status` 分别确认 runtime closeout 与 GitHub flush。 -- `platform-infra sub2api plan|apply|status|validate|codex-pool` 是 `platform-infra` namespace 内 Sub2API 的受控入口;`--target` 选择运行目标,默认 `G14` 为 active runtime,`D601` 为同一 YAML 控制的 standby predeploy target。镜像版本和 target 边界由 `config/platform-infra/sub2api.yaml` 控制,Codex 上游池、统一 API key Secret、FRP 公网端口和 master `~/.codex` 消费端由 `config/platform-infra/sub2api-codex-pool.yaml` 控制;完整日常部署、上游增删、FRP 暴露、local Codex 配置、验收和排障步骤统一见 `$unidesk-sub2api`(`.agents/skills/unidesk-sub2api/SKILL.md`)。`docs/reference/platform-infra.md` 只保留 namespace、YAML-first、路由、Secret 脱敏和探针开发边界。 +- `platform-infra sub2api|langbot|n8n ...` 是 `platform-infra` namespace 内平台公共服务的受控入口;`sub2api` 支持 `plan|apply|status|validate|codex-pool`,`langbot` 和 `n8n` 支持各自 YAML-controlled `plan|apply|status|logs|validate` 等公共服务操作。`--target` 选择运行目标,默认 `G14` 为 active runtime,`D601` 为同一 YAML 控制的 standby predeploy target。镜像版本和 target 边界由 `config/platform-infra/*.yaml` 控制,Codex 上游池、统一 API key Secret、FRP 公网端口和 master `~/.codex` 消费端由 `config/platform-infra/sub2api-codex-pool.yaml` 控制;完整 Sub2API 日常部署、上游增删、FRP 暴露、local Codex 配置、验收和排障步骤统一见 `$unidesk-sub2api`(`.agents/skills/unidesk-sub2api/SKILL.md`)。`docs/reference/platform-infra.md` 保留 namespace、YAML-first、路由、Secret 脱敏、PK01 Caddy+FRP 和探针开发边界。 - `hwlab g14 observability status|apply|query|targets|boundary|closeout [--lane v02] [--promql ] [--expect-count N] [--expect-value V] [--dry-run|--confirm]` 是 G14 `devops-infra` 共享监控基础设施和 HWLAB v0.2 监控 closeout 的受控入口。`apply` 固定安装 Prometheus Operator `v0.91.0`、Prometheus `v3.12.0`、Prometheus 发现 RBAC、`devops-infra` 内 Prometheus 实例和 ClusterIP query Service,并给被允许发现的 workload namespace 打低风险 label;它不把 Prometheus、Grafana 或 Alertmanager 部署到 `hwlab-v02`,也不接管 HWLAB runtime Deployment/Service。`status` 只读汇总 CRD、operator Deployment、Prometheus CR/pod/service、`hwlab-v02` ServiceMonitor/PrometheusRule 和 bounded `up` 查询;`query` 只通过 Kubernetes service proxy 查询 Prometheus,支持 `--expect-count` / `--expect-value` 输出 `assertion`、bad values 和 missing/extra series;`targets` 汇总 ServiceMonitor/PrometheusRule、metrics sidecar readiness/restart、三层指标值和 `metrics.k8s.io` 当前 CPU/内存资源快照;`boundary` 验证 workload namespace 没有 Prometheus/Alertmanager,并对 `19666/19667` 公网 `/metrics` 做负向验证;`closeout` 聚合平台 ready、scrape reachable、sidecar serving、business health probe、resource snapshot、namespace boundary 和 public metrics exposure 语义结论。长期边界见 `docs/reference/g14-observability-infra.md`。 - `hwlab g14 tools-image status|build --name ci-node-tools --tag [--dockerfile deploy/ci/hwlab-ci-node-tools.Dockerfile] [--dry-run|--confirm]` 是 G14 固定 HWLAB CI tools image 的受控 host build/push 入口;构建和 push 只发生在 G14 host 与本地 registry,不在 master server 构建,也不把 `apk add`/runtime install 塞进 Tekton PipelineRun。 - `trans gh:/owner/repo ...` 把 GitHub issue/PR 映射成只读/受控写入的虚拟文本目录,适合日报、PR 正文和 issue 正文的小补丁维护:`trans gh:/pikasTech/HWLAB ls` 展示 `pr/` 与 `issue/`,`trans gh:/pikasTech/HWLAB/pr ls [--limit N] [--full]` 和 `trans gh:/pikasTech/HWLAB/issue ls [--limit N] [--full]` 展示条目状态、楼层数、正文长度和标题,`trans gh:/pikasTech/HWLAB/pr/507 ls` 展示单个 PR 的一楼正文文件,`trans gh:/pikasTech/HWLAB/505/1 cat|rg|patch-apply` 兼容旧式 issue/PR number route。`patch-apply` 使用 UniDesk 默认 apply-patch v2 的虚拟文件 executor,把正文一楼映射为 `body.md`,写回仍走 `bun scripts/cli.ts gh issue/pr update` 的 guard/concurrency 规则;`rm` 对正文一楼结构化拒绝,避免误删 issue/PR 正文。大正文读取必须展开 UniDesk gh dump 文件,否则 `cat/rg/patch-apply` 会误读为空,这是 `gh:` 虚拟文件接口的 P0 可见性契约。 diff --git a/docs/reference/platform-infra.md b/docs/reference/platform-infra.md index 8776c2bf..f633dd8e 100644 --- a/docs/reference/platform-infra.md +++ b/docs/reference/platform-infra.md @@ -36,6 +36,15 @@ - LangBot Box is disabled by default for the public service because the official Box deployment needs Docker socket access. Enabling Box requires a separate explicit platform decision and YAML-controlled security boundary. - Official WeChat support is through LangBot's official platform adapters such as `officialaccount`, `wecom`, and `wecomcs`; real AppID, token, EncodingAESKey and channel credentials are bound in LangBot after deployment. Personal WeChat or OpenClaw-style adapters are not part of the default public-service boundary. +## n8n Workflow Boundary + +- n8n is the UniDesk-operated workflow/automation layer for LangBot and platform service integration. It is a workflow bridge for webhook orchestration, service calls, manual approval flows and external integrations; it does not replace LangBot or become the chat runtime. +- The canonical entrypoint is `bun scripts/cli.ts platform-infra n8n plan|apply|status|logs|validate`; G14 is the default runtime target and `config/platform-infra/n8n.yaml` is the YAML source of truth. +- n8n uses the existing Pika01/PK01 host-native PostgreSQL instance through `config/platform-db/postgres-pk01.yaml` and `platform-db postgres`. Adding n8n state means adding a dedicated `n8n` database and role inside that single external PostgreSQL instance; do not deploy an in-cluster PostgreSQL StatefulSet, a second PostgreSQL instance, or long-term SQLite state for n8n. +- Public exposure uses PK01 Caddy plus FRP to the G14 ClusterIP service at `https://n8n.pikapython.com`. Do not add Kubernetes Ingress, NodePort, LoadBalancer, host networking, or host ports for n8n unless a later YAML-controlled platform decision changes the exposure model. +- n8n reverse-proxy and webhook settings such as public base URL, `WEBHOOK_URL`, proxy hop trust and PostgreSQL connection fields must be rendered from YAML. Secret output may show key names, presence and fingerprints only; it must not print the database password, `N8N_ENCRYPTION_KEY`, or full `DATABASE_URL`. +- Closeout for public n8n changes requires `platform-infra n8n status` and `platform-infra n8n validate --full`, proving both in-cluster HTTP and public HTTPS. Actual LangBot workflows, credentials and business automations are separate follow-up scope after the base n8n service is healthy. + ## Codex Pool Routing `config/platform-infra/sub2api-codex-pool.yaml` controls the Codex-facing OpenAI-compatible pool: diff --git a/scripts/src/help.ts b/scripts/src/help.ts index bac47623..3f6a68d9 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -60,7 +60,7 @@ export function rootHelp(): unknown { { command: "hwlab nodes control-plane|git-mirror|secret --node --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.", }; } diff --git a/scripts/src/platform-infra-n8n.ts b/scripts/src/platform-infra-n8n.ts new file mode 100644 index 00000000..fc7e6f0e --- /dev/null +++ b/scripts/src/platform-infra-n8n.ts @@ -0,0 +1,1207 @@ +import { createHash, randomBytes } from "node:crypto"; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, join } from "node:path"; +import type { UniDeskConfig } from "./config"; +import { rootPath } from "./config"; +import { startJob } from "./jobs"; +import { + applyPk01CaddyBlock, + capture, + compactCapture, + dryRunManifestScript, + fingerprintValues, + parseJsonOutput, + prepareFrpcSecret, + publicHttpProbe, + publicServicePolicyChecks, + redactText, + renderFrpcManifest, + shQuote, + type FrpcSecretMaterial, + type PublicServiceExposure, + type PublicServiceTarget, +} from "./platform-infra-public-service"; + +const configFile = rootPath("config", "platform-infra", "n8n.yaml"); +const configLabel = "config/platform-infra/n8n.yaml"; +const serviceName = "n8n"; +const fieldManager = "unidesk-platform-n8n"; +const caddyManagedStart = "# BEGIN unidesk managed n8n"; +const caddyManagedEnd = "# END unidesk managed n8n"; + +interface N8nConfig { + version: number; + kind: "platform-infra-n8n"; + metadata: { id: string; owner: string; relatedIssues: number[] }; + 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; + 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: "create" | "update" | "none"; + 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 = "G14"; + 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 (!/^[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", + 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, ["script"], 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, ["script"], applyScript(confirmedYaml, n8n, target, secretMaterial, frpcSecret)); + const parsed = parseJsonOutput(result.stdout); + const caddy = await applyPk01CaddyBlock(config, serviceName, target.publicExposure, { start: caddyManagedStart, end: caddyManagedEnd }); + 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: { + 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, ["script"], 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, ["script"], 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, ["script"], 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 dbSourcePath = join(root, n8n.runtime.database.sourceRef); + if (!existsSync(dbSourcePath)) throw new Error(`n8n database source ${redactRepoPath(dbSourcePath)} is missing; run platform-db postgres apply --config config/platform-db/postgres-pk01.yaml --confirm first`); + const dbValues = parseEnvFile(readTextFile(dbSourcePath)); + 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 appSourcePath = join(root, n8n.runtime.secrets.appSourceRef); + const existedBefore = existsSync(appSourcePath); + const existing = existedBefore ? parseEnvFile(readTextFile(appSourcePath)) : {}; + const next = { ...existing }; + const encryptionKeyName = n8n.runtime.secrets.encryptionKey; + if (next[encryptionKeyName] === undefined || next[encryptionKeyName].length === 0) next[encryptionKeyName] = randomBytes(32).toString("base64url"); + const action = !existedBefore ? "create" : existing[encryptionKeyName] !== next[encryptionKeyName] ? "update" : "none"; + if (action !== "none") writeEnvFile(appSourcePath, next); + const values = { dbUser, dbPassword, dbName, encryptionKey: next[encryptionKeyName] }; + return { + dbSourceRef: n8n.runtime.database.sourceRef, + dbSourcePath: redactRepoPath(dbSourcePath), + appSourceRef: n8n.runtime.secrets.appSourceRef, + appSourcePath: redactRepoPath(appSourcePath), + action, + values, + fingerprint: fingerprintValues({ 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 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"), + }, + 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`); + 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 resolveTarget(n8n: N8nConfig, targetId: string): N8nTarget { + const target = n8n.targets.find((item) => item.id.toLowerCase() === targetId.toLowerCase()); + if (target === undefined) throw new Error(`unknown n8n target ${targetId}; 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 readTextFile(path: string): string { + if (!existsSync(path)) throw new Error(`required secret source ${redactRepoPath(path)} is missing`); + return readFileSync(path, "utf8"); +} + +function parseEnvFile(text: string): Record { + const result: Record = {}; + for (const rawLine of text.split(/\r?\n/u)) { + const line = rawLine.trim(); + if (line.length === 0 || line.startsWith("#")) continue; + const eq = line.indexOf("="); + if (eq <= 0) continue; + const key = line.slice(0, eq).trim(); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) continue; + result[key] = unquoteEnvValue(line.slice(eq + 1).trim()); + } + return result; +} + +function unquoteEnvValue(value: string): string { + if ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith("\"") && value.endsWith("\""))) return value.slice(1, -1); + return value; +} + +function writeEnvFile(path: string, values: Record): void { + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + const lines = Object.keys(values).sort().map((key) => `${key}=${quoteEnv(values[key])}`); + writeFileSync(path, `${lines.join("\n")}\n`, { encoding: "utf8", mode: 0o600 }); + chmodSync(path, 0o600); +} + +function quoteEnv(value: string): string { + if (/^[A-Za-z0-9_./:@%?&=+-]+$/u.test(value)) return value; + return `'${value.replaceAll("'", "'\"'\"'")}'`; +} + +function requiredEnvValue(values: Record, key: string, sourceRef: string): string { + const value = values[key]; + if (value === undefined || value.length === 0) throw new Error(`${sourceRef} is missing required key ${key}`); + return value; +} + +function redactRepoPath(path: string): string { + const root = rootPath(); + return path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path; +} + +function boolField(value: Record | null, key: string, fallback: boolean): boolean { + return typeof value?.[key] === "boolean" ? value[key] : fallback; +} + +function asRecord(value: unknown, path: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be a YAML object`); + return value as Record; +} + +function objectField(obj: Record, key: string, path: string): Record { + const prefix = path.length > 0 ? `${path}.` : ""; + return asRecord(obj[key], `${configLabel}.${prefix}${key}`); +} + +function arrayOfRecords(value: unknown, path: string): Record[] { + if (!Array.isArray(value)) throw new Error(`${configLabel}.${path} must be an array`); + return value.map((item, index) => asRecord(item, `${configLabel}.${path}[${index}]`)); +} + +function stringField(obj: Record, key: string, path: string): string { + const value = obj[key]; + const prefix = path.length > 0 ? `${path}.` : ""; + if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${configLabel}.${prefix}${key} must be a non-empty string`); + return value.trim(); +} + +function integerField(obj: Record, key: string, path: string): number { + const value = obj[key]; + const prefix = path.length > 0 ? `${path}.` : ""; + if (typeof value !== "number" || !Number.isInteger(value)) throw new Error(`${configLabel}.${prefix}${key} must be an integer`); + return value; +} + +function booleanField(obj: Record, key: string, path: string): boolean { + const value = obj[key]; + const prefix = path.length > 0 ? `${path}.` : ""; + if (typeof value !== "boolean") throw new Error(`${configLabel}.${prefix}${key} must be a boolean`); + return value; +} + +function stringArrayField(obj: Record, key: string, path: string): string[] { + const value = obj[key]; + const prefix = path.length > 0 ? `${path}.` : ""; + if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim().length === 0)) throw new Error(`${configLabel}.${prefix}${key} must be a string array`); + return value.map((item) => (item as string).trim()); +} + +function numberArrayField(obj: Record, key: string, path: string): number[] { + const value = obj[key]; + const prefix = path.length > 0 ? `${path}.` : ""; + if (!Array.isArray(value) || value.some((item) => typeof item !== "number" || !Number.isInteger(item))) throw new Error(`${configLabel}.${prefix}${key} must be an integer array`); + return value as number[]; +} + +function enumField(obj: Record, key: string, path: string, values: T): T[number] { + const value = stringField(obj, key, path); + if (!(values as readonly string[]).includes(value)) throw new Error(`${configLabel}.${path}.${key} must be one of ${values.join(", ")}`); + return value as T[number]; +} + +function kubernetesNameField(obj: Record, key: string, path: string): string { + const value = stringField(obj, key, path); + if (!/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/u.test(value)) throw new Error(`${configLabel}.${path}.${key} must be a Kubernetes name`); + return value; +} + +function sourceRefField(obj: Record, key: string, path: string): string { + const value = stringField(obj, key, path); + if (value.startsWith("/") || value.includes("..") || !/^[A-Za-z0-9_./-]+$/u.test(value)) throw new Error(`${configLabel}.${path}.${key} must be a relative source ref without ..`); + return value; +} + +function envKeyField(obj: Record, key: string, path: string): string { + const value = stringField(obj, key, path); + if (!/^[A-Z_][A-Z0-9_]*$/u.test(value)) throw new Error(`${configLabel}.${path}.${key} must be an env key`); + return value; +} + +function pgIdentifierField(obj: Record, key: string, path: string): string { + const value = stringField(obj, key, path); + if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(value)) throw new Error(`${configLabel}.${path}.${key} must be a PostgreSQL identifier`); + return value; +} + +function hostField(obj: Record, key: string, path: string): string { + const value = stringField(obj, key, path); + if (!/^[A-Za-z0-9._:-]+$/u.test(value)) throw new Error(`${configLabel}.${path}.${key} has an unsupported host format`); + return value; +} + +function portField(obj: Record, key: string, path: string): number { + const value = integerField(obj, key, path); + if (value < 1 || value > 65535) throw new Error(`${configLabel}.${path}.${key} must be a TCP port`); + return value; +} + +function absolutePathField(obj: Record, key: string, path: string): string { + const value = stringField(obj, key, path); + if (!value.startsWith("/")) throw new Error(`${configLabel}.${path}.${key} must be absolute`); + return value; +} + +function httpsUrlField(obj: Record, key: string, path: string): string { + const value = stringField(obj, key, path); + const url = new URL(value); + if (url.protocol !== "https:" || url.search || url.hash) throw new Error(`${configLabel}.${path}.${key} must be an https URL without query or hash`); + return value.replace(/\/+$/u, ""); +} + +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(".."); +} diff --git a/scripts/src/platform-infra-public-service.ts b/scripts/src/platform-infra-public-service.ts new file mode 100644 index 00000000..ccf05fb6 --- /dev/null +++ b/scripts/src/platform-infra-public-service.ts @@ -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 { + return await runSshCommandCapture(config, route, args, stdin); +} + +export async function applyPk01CaddyBlock( + config: UniDeskConfig, + serviceId: string, + exposure: PublicServiceExposure, + markers: { start: string; end: string }, +): Promise> { + 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; + requiredEnvValue: (values: Record, 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> { + 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 | 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 : null; + } catch { + return null; + } +} + +export function compactCapture(result: SshCaptureResult, options: { full?: boolean } = {}): Record { + 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 { + 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://@") + .replace(/(N8N_ENCRYPTION_KEY|PASSWORD|SECRET|TOKEN|API_KEY|DATABASE_URL)([=:]\s*)[^\s,;]+/giu, "$1$2"); +} + +export function fingerprintValues(values: Record, 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, "\\$&"); +} diff --git a/scripts/src/platform-infra.ts b/scripts/src/platform-infra.ts index 6a4938cf..93147e55 100644 --- a/scripts/src/platform-infra.ts +++ b/scripts/src/platform-infra.ts @@ -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)));