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
+78 -1
View File
@@ -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
+83
View File
@@ -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
+1 -1
View File
@@ -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 <lane> --pipeline-run <name> --confirm` 受控清理后重试,不要用原生 `kubectl delete` 或手工改 mirror hook。修复后仍必须用 `control-plane status --pipeline-run <name>``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 <expr>] [--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 <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 可见性契约。
+9
View File
@@ -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:
+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)));