From 021e1f6ba9da4a61b5c6d580116ad9e6995bc986 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 12 Jun 2026 16:05:27 +0000 Subject: [PATCH] feat: add platform-infra langbot service --- config/platform-db/postgres-pk01.yaml | 67 + config/platform-infra/langbot.yaml | 98 ++ docs/reference/platform-infra.md | 10 + scripts/src/help.ts | 10 +- scripts/src/platform-db.ts | 305 +++-- scripts/src/platform-infra-langbot.ts | 1780 +++++++++++++++++++++++++ scripts/src/platform-infra.ts | 14 +- 7 files changed, 2190 insertions(+), 94 deletions(-) create mode 100644 config/platform-infra/langbot.yaml create mode 100644 scripts/src/platform-infra-langbot.ts diff --git a/config/platform-db/postgres-pk01.yaml b/config/platform-db/postgres-pk01.yaml index b6711e4b..4e238e04 100644 --- a/config/platform-db/postgres-pk01.yaml +++ b/config/platform-db/postgres-pk01.yaml @@ -8,6 +8,7 @@ metadata: relatedIssues: - 280 - 281 + - 297 cluster: role: primary @@ -135,6 +136,26 @@ postgres: user: sub2api address: 36.49.29.73/32 method: scram-sha-256 + - type: hostssl + database: langbot + user: langbot + address: 10.0.8.0/22 + method: scram-sha-256 + - type: hostssl + database: postgres + user: langbot + address: 10.0.8.0/22 + method: scram-sha-256 + - type: hostssl + database: langbot + user: langbot + address: 74.48.78.17/32 + method: scram-sha-256 + - type: hostssl + database: postgres + user: langbot + address: 74.48.78.17/32 + method: scram-sha-256 secrets: source: master-local @@ -154,6 +175,20 @@ secrets: SUB2API_DB_NAME: sub2api randomHex: SUB2API_DB_PASSWORD: 32 + - name: langbot-db-credentials + sourceRef: platform-db/langbot-db.env + type: env + requiredKeys: + - LANGBOT_DB_USER + - LANGBOT_DB_PASSWORD + - LANGBOT_DB_NAME + createIfMissing: + enabled: true + values: + LANGBOT_DB_USER: langbot + LANGBOT_DB_NAME: langbot + randomHex: + LANGBOT_DB_PASSWORD: 32 objects: roles: @@ -166,12 +201,26 @@ objects: createdb: false createrole: false superuser: false + - name: langbot + passwordRef: + sourceRef: platform-db/langbot-db.env + key: LANGBOT_DB_PASSWORD + login: true + attributes: + createdb: false + createrole: false + superuser: false databases: - name: sub2api owner: sub2api encoding: UTF8 locale: C.UTF-8 extensions: [] + - name: langbot + owner: langbot + encoding: UTF8 + locale: C.UTF-8 + extensions: [] exports: connectionStrings: @@ -190,6 +239,21 @@ exports: - scope: platform-infra secret: sub2api-secrets key: DATABASE_URL + - name: langbot-database-url + sourceSecretRef: platform-db/langbot-db.env + render: + envKey: DATABASE_URL + format: postgresql://$(LANGBOT_DB_USER):$(LANGBOT_DB_PASSWORD)@$(PGHOST):5432/$(LANGBOT_DB_NAME)?sslmode=require + variables: + PGHOST: 82.156.23.220 + writeToSecretSource: + sourceRef: platform-infra/langbot.env + key: DATABASE_URL + mode: update-or-insert + consumers: + - scope: platform-infra + secret: langbot-secrets + key: DATABASE_URL backup: phase: minimum-restoreable @@ -220,6 +284,9 @@ observability: - kind: psql-app-role database: sub2api user: sub2api + - kind: psql-app-role + database: langbot + user: langbot - kind: disk-free path: /var/lib/postgresql/16/main minFreeGiB: 10 diff --git a/config/platform-infra/langbot.yaml b/config/platform-infra/langbot.yaml new file mode 100644 index 00000000..e7a532d1 --- /dev/null +++ b/config/platform-infra/langbot.yaml @@ -0,0 +1,98 @@ +version: 1 +kind: platform-infra-langbot + +metadata: + id: langbot-public + owner: unidesk + relatedIssues: + - 297 + +image: + repository: rockchin/langbot + tag: v4.10.1 + pullPolicy: IfNotPresent + +dependencyImages: + postgresClient: docker.m.daocloud.io/library/postgres:16-alpine + +targets: + - id: G14 + route: G14:k3s + namespace: platform-infra + enabled: true + replicas: 1 + publicExposure: + enabled: true + publicBaseUrl: https://langbot.pikapython.com + dns: + hostname: langbot.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: langbot-frpc + secretName: langbot-frpc-secrets + secretKey: frpc.toml + image: 127.0.0.1:5000/hwlab/frpc:v0.68.1 + serverAddr: 82.156.23.220 + serverPort: 22000 + proxyName: platform-infra-langbot-g14-web + remotePort: 22097 + localIP: langbot.platform-infra.svc.cluster.local + localPort: 5300 + 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/langbot-db.env + sourceKeys: + user: LANGBOT_DB_USER + password: LANGBOT_DB_PASSWORD + dbName: LANGBOT_DB_NAME + secretName: langbot-secrets + host: 82.156.23.220 + port: 5432 + user: langbot + dbName: langbot + sslMode: require + appDatabaseValue: langbot?ssl=require + secrets: + root: /root/unidesk/.state/secrets + appSourceRef: platform-infra/langbot.env + storage: + data: + name: langbot-data + size: 10Gi + plugins: + name: langbot-plugins + size: 5Gi + pluginRuntime: + name: langbot-plugin-runtime-data + size: 5Gi + box: + enabled: false + reason: LangBot Box mounts Docker socket in the official manifest; public platform service keeps it disabled by default. + +apiKey: + sourceRef: platform-infra/langbot.env + key: LANGBOT_API_KEY + name: unidesk-langbot-cli + description: UniDesk controlled CLI API key for langbot.pikapython.com + +officialWechat: + supportedAdapters: + - officialaccount + - wecom + - wecomcs + webhookBaseUrl: https://langbot.pikapython.com + defaultCallbackPath: /callback/command + notes: + - Official account, WeCom, and WeCom customer service adapters are supported through LangBot official platform sources. + - Personal WeChat/OpenClaw is not enabled by default for this public service. diff --git a/docs/reference/platform-infra.md b/docs/reference/platform-infra.md index 18cacbd1..5a5bebd9 100644 --- a/docs/reference/platform-infra.md +++ b/docs/reference/platform-infra.md @@ -22,6 +22,16 @@ - External platform PostgreSQL endpoints for Sub2API are produced by the platform DB YAML and its `platform-db postgres` CLI. Cross-node Sub2API consumers connect directly to that endpoint; the master server is not a PostgreSQL data-plane relay. DNS aliases are optional when the exported `DATABASE_URL` uses a reachable IP with `sslmode=require`; current PK01-specific rules live in `docs/reference/pk01.md`. - Sub2API account sentinel and public exposure are target-scoped YAML decisions. Do not create a second sentinel, FRP client, public management surface, or edge proxy by hand; enable or move those resources only through the target YAML and the `platform-infra sub2api` / `codex-pool --target` CLI paths. +## LangBot Deployment Boundary + +- LangBot is a UniDesk-operated public platform service in namespace `platform-infra`. The canonical entrypoint is `bun scripts/cli.ts platform-infra langbot plan|apply|status|logs|validate|bootstrap-api-key|query`; G14 is the default runtime target. +- LangBot configuration is YAML-first in `config/platform-infra/langbot.yaml`. Image tag, target namespace, PVCs, PK01 Caddy/FRP exposure, API key seed source, and official WeChat adapter metadata must stay in YAML rather than helper constants or manual runtime patches. +- LangBot uses the existing PK01 host-native PostgreSQL instance through `config/platform-db/postgres-pk01.yaml` and `platform-db postgres`. Adding LangBot state means adding a dedicated database and role inside that existing instance; do not deploy a second PostgreSQL StatefulSet, container, or external DB instance for LangBot. +- Public exposure uses PK01 Caddy plus FRP to the G14 ClusterIP service. Do not add Kubernetes Ingress, NodePort, LoadBalancer, host networking, or host ports for LangBot unless a later YAML-controlled platform decision changes the exposure model. +- LangBot's built-in Web frontend and API share the same public HTTPS origin. CLI queries must use the YAML-declared API key source and must report key names/fingerprints only, never the API key value. +- 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`. Personal WeChat or OpenClaw-style adapters are not part of the default public-service boundary. + ## 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 ae416693..bac47623 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 plan|apply|status|validate|codex-pool", description: "Deploy Sub2API in G14 platform-infra, manage the YAML-controlled Codex upstream pool, expose the unified API, and inspect marker sentinel state with low-noise reports without printing API keys." }, + { 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-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 plan|apply|status|validate|codex-pool", + command: "platform-infra sub2api|langbot ...", output: "json", usage: [ "bun scripts/cli.ts platform-infra sub2api plan", @@ -615,8 +615,12 @@ function platformInfraHelpSummary(): unknown { "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image status", "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-probe --account unidesk-codex-hy --confirm", "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-report", + "bun scripts/cli.ts platform-infra langbot plan", + "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", ], - description: "Operate G14 platform-infra services such as Sub2API and the YAML-controlled Codex pool.", + description: "Operate G14 platform-infra services such as Sub2API, LangBot, and the YAML-controlled Codex pool.", }; } diff --git a/scripts/src/platform-db.ts b/scripts/src/platform-db.ts index bf7a8a30..96b0249d 100644 --- a/scripts/src/platform-db.ts +++ b/scripts/src/platform-db.ts @@ -242,6 +242,8 @@ interface RemoteFacts { logDirExists: boolean; roleExists: boolean; databaseExists: boolean; + roles: Array<{ name: string; exists: boolean }>; + databases: Array<{ name: string; owner: string; exists: boolean }>; serverVersion: string | null; sslOn: boolean; sslCertFile: string | null; @@ -250,6 +252,14 @@ interface RemoteFacts { appConnectionSsl: boolean | null; appConnectionHost: string | null; appConnectionError: string | null; + appConnections: Array<{ + user: string; + database: string; + ok: boolean | null; + ssl: boolean | null; + host: string | null; + error: string | null; + }>; }; raw?: Record; } @@ -258,6 +268,8 @@ interface ControllerConnectionProbe { attempted: boolean; ok: boolean | null; ssl: boolean | null; + user: string | null; + database: string | null; host: string; port: number; clientAddr: string | null; @@ -369,7 +381,7 @@ async function plan(config: UniDeskConfig, options: PlatformDbOptions): Promise< next: { apply: `bun scripts/cli.ts platform-db postgres apply --config ${pg.configPath} --confirm`, status: `bun scripts/cli.ts platform-db postgres status --config ${pg.configPath}`, - issue: "https://github.com/pikasTech/unidesk/issues/281", + issues: pg.metadata.relatedIssues.map((issue) => `https://github.com/pikasTech/unidesk/issues/${issue}`), }, remote: compactCapture(remote.capture, { full: options.full || remote.capture.exitCode !== 0 }), ...(options.raw ? { raw: remote.capture } : {}), @@ -383,15 +395,18 @@ async function status(config: UniDeskConfig, options: PlatformDbOptions): Promis const facts = remote.parsed; const controllerConnection = controllerConnectionProbe(pg, secrets); const endpointHealthy = controllerConnection.ok === true && controllerConnection.ssl === true; + const rolesHealthy = facts !== null && pg.objects.roles.every((role) => facts.postgres.roles.some((item) => item.name === role.name && item.exists)); + const databasesHealthy = facts !== null && pg.objects.databases.every((database) => facts.postgres.databases.some((item) => item.name === database.name && item.exists)); + const appConnectionsHealthy = facts !== null + && appProbes(pg, secrets).every((probe) => facts.postgres.appConnections.some((item) => item.user === probe.user && item.database === probe.database && item.ok === true && item.ssl === true)); const deploymentHealthy = facts !== null && facts.postgres.packageInstalled && facts.postgres.serviceActive && facts.postgres.sslOn - && facts.postgres.roleExists - && facts.postgres.databaseExists + && rolesHealthy + && databasesHealthy && facts.network.port5432Listening - && facts.postgres.appConnectionOk === true - && facts.postgres.appConnectionSsl === true; + && appConnectionsHealthy; const cutoverReady = deploymentHealthy && secrets.ok && endpointHealthy; const cutoverBlockers = cutoverReady ? [] @@ -421,10 +436,13 @@ async function status(config: UniDeskConfig, options: PlatformDbOptions): Promis dnsAddresses: facts.network.dns.addresses, roleExists: facts.postgres.roleExists, databaseExists: facts.postgres.databaseExists, + roles: facts.postgres.roles, + databases: facts.postgres.databases, appConnectionOk: facts.postgres.appConnectionOk, appConnectionSsl: facts.postgres.appConnectionSsl, appConnectionHost: facts.postgres.appConnectionHost, appConnectionError: facts.postgres.appConnectionError, + appConnections: facts.postgres.appConnections, connectionHostProbe: controllerConnection, port5432Listening: facts.network.port5432Listening, serverVersion: facts.postgres.serverVersion, @@ -548,7 +566,18 @@ function readPostgresHostConfig(pathArg: string): PostgresHostConfig { const entries = arrayOfRecords(secrets.entries, `${configPath}.secrets.entries`).map((entry, index) => secretEntry(entry, `${configPath}.secrets.entries[${index}]`)); const roles = arrayOfRecords(objects.roles, `${configPath}.objects.roles`).map((role, index) => roleConfig(role, `${configPath}.objects.roles[${index}]`)); const databases = arrayOfRecords(objects.databases, `${configPath}.objects.databases`).map((database, index) => databaseConfig(database, `${configPath}.objects.databases[${index}]`)); - if (roles.length !== 1 || databases.length !== 1) throw new Error(`${configPath}.objects must declare exactly one role and one database for the first PK01 Sub2API rollout`); + if (roles.length === 0 || databases.length === 0) throw new Error(`${configPath}.objects must declare at least one role and one database`); + const roleNames = new Set(); + for (const role of roles) { + if (roleNames.has(role.name)) throw new Error(`${configPath}.objects.roles contains duplicate role ${role.name}`); + roleNames.add(role.name); + } + const databaseNames = new Set(); + for (const database of databases) { + if (databaseNames.has(database.name)) throw new Error(`${configPath}.objects.databases contains duplicate database ${database.name}`); + if (!roleNames.has(database.owner)) throw new Error(`${configPath}.objects.databases.${database.name}.owner must reference a declared role`); + databaseNames.add(database.name); + } const connectionStrings = arrayOfRecords(exportsConfig.connectionStrings, `${configPath}.exports.connectionStrings`).map((item, index) => connectionStringExport(item, `${configPath}.exports.connectionStrings[${index}]`)); return { configPath: relativeConfigPath(configPath), @@ -912,6 +941,10 @@ function configSummary(pg: PostgresHostConfig): Record { futureClientSslmode: pg.postgres.network.tls.futureClientSslmode, }, haPhase: pg.cluster.haPhase, + objects: { + roles: pg.objects.roles.map((role) => ({ name: role.name, login: role.login })), + databases: pg.objects.databases.map((database) => ({ name: database.name, owner: database.owner })), + }, noK3s: true, }; } @@ -974,7 +1007,7 @@ function inspectSecrets(pg: PostgresHostConfig, materialize: boolean): SecretIns function ensureLocalSecretState(pg: PostgresHostConfig): { inspection: SecretInspection; summary: Record; exports: Array> } { const inspection = inspectSecrets(pg, true); if (!inspection.ok) throw new Error("required secret source keys are missing and cannot be generated from YAML"); - validateSub2ApiSecretConsistency(pg, inspection); + validateObjectSecretConsistency(pg, inspection); const exports = pg.exports.connectionStrings.map((item) => writeConnectionStringExport(pg, inspection, item)); return { inspection, @@ -989,13 +1022,17 @@ function ensureLocalSecretState(pg: PostgresHostConfig): { inspection: SecretIns }; } -function validateSub2ApiSecretConsistency(pg: PostgresHostConfig, inspection: SecretInspection): void { - const role = pg.objects.roles[0]; - const database = pg.objects.databases[0]; - const material = inspection.materials.get(role.passwordRef.sourceRef); - if (material === undefined) throw new Error(`secret source not found for role passwordRef: ${role.passwordRef.sourceRef}`); - if (material.values.SUB2API_DB_USER !== role.name) throw new Error("SUB2API_DB_USER must match YAML objects.roles[0].name"); - if (material.values.SUB2API_DB_NAME !== database.name) throw new Error("SUB2API_DB_NAME must match YAML objects.databases[0].name"); +function validateObjectSecretConsistency(pg: PostgresHostConfig, inspection: SecretInspection): void { + const roleNames = new Set(pg.objects.roles.map((role) => role.name)); + for (const role of pg.objects.roles) { + const material = inspection.materials.get(role.passwordRef.sourceRef); + if (material === undefined) throw new Error(`secret source not found for role passwordRef: ${role.passwordRef.sourceRef}`); + const password = material.values[role.passwordRef.key]; + if (password === undefined || password.length === 0) throw new Error(`secret source ${role.passwordRef.sourceRef} is missing ${role.passwordRef.key}`); + } + for (const database of pg.objects.databases) { + if (!roleNames.has(database.owner)) throw new Error(`database ${database.name} owner ${database.owner} is not declared in objects.roles`); + } } function writeConnectionStringExport(pg: PostgresHostConfig, inspection: SecretInspection, item: ConnectionStringExportConfig): Record { @@ -1095,6 +1132,8 @@ function secretSummary(secrets: SecretInspection): Record { function desiredSummary(pg: PostgresHostConfig, secrets: SecretInspection, facts: RemoteFacts | null): Record { const role = pg.objects.roles[0]; const database = pg.objects.databases[0]; + const observedRoles = facts?.postgres.roles ?? []; + const observedDatabases = facts?.postgres.databases ?? []; return { package: { name: `postgresql-${pg.postgres.package.version}`, @@ -1133,6 +1172,16 @@ function desiredSummary(pg: PostgresHostConfig, secrets: SecretInspection, facts pgHbaRemoteTransport: "hostssl", role: { name: role.name, action: facts?.postgres.roleExists ? "none" : "create-or-update" }, database: { name: database.name, owner: database.owner, action: facts?.postgres.databaseExists ? "none" : "create" }, + roles: pg.objects.roles.map((item) => ({ + name: item.name, + login: item.login, + action: observedRoles.some((observed) => observed.name === item.name && observed.exists) ? "none" : "create-or-update", + })), + databases: pg.objects.databases.map((item) => ({ + name: item.name, + owner: item.owner, + action: observedDatabases.some((observed) => observed.name === item.name && observed.exists) ? "none" : "create", + })), }, secrets: secretSummary(secrets), exports: pg.exports.connectionStrings.map((item) => ({ @@ -1168,29 +1217,34 @@ function desiredChecks(pg: PostgresHostConfig, facts: RemoteFacts, secrets: Secr } async function remoteFacts(config: UniDeskConfig, pg: PostgresHostConfig, secrets: SecretInspection | null): Promise<{ capture: SshCaptureResult; parsed: RemoteFacts | null }> { - const capture = await runSshCommandCapture(config, pg.node.route, ["script"], factsScript(pg, appProbe(pg, secrets))); + const capture = await runSshCommandCapture(config, pg.node.route, ["script"], factsScript(pg, appProbes(pg, secrets))); const parsed = parseJsonOutput(capture.stdout) as RemoteFacts | null; return { capture, parsed }; } -function appProbe(pg: PostgresHostConfig, secrets: SecretInspection | null): { user: string; password: string; database: string } | null { - if (secrets === null || !secrets.ok) return null; - const role = pg.objects.roles[0]; - const database = pg.objects.databases[0]; - const material = secrets.materials.get(role.passwordRef.sourceRef); - const password = material?.values[role.passwordRef.key]; - if (password === undefined || password.length === 0) return null; - return { user: role.name, password, database: database.name }; +function appProbes(pg: PostgresHostConfig, secrets: SecretInspection | null): Array<{ user: string; password: string; database: string }> { + if (secrets === null || !secrets.ok) return []; + const rolesByName = new Map(pg.objects.roles.map((role) => [role.name, role])); + const probes: Array<{ user: string; password: string; database: string }> = []; + for (const database of pg.objects.databases) { + const role = rolesByName.get(database.owner); + if (role === undefined) continue; + const material = secrets.materials.get(role.passwordRef.sourceRef); + const password = material?.values[role.passwordRef.key]; + if (password === undefined || password.length === 0) continue; + probes.push({ user: role.name, password, database: database.name }); + } + return probes; } function controllerConnectionProbe(pg: PostgresHostConfig, secrets: SecretInspection): ControllerConnectionProbe { const host = pg.postgres.network.connectionHost; const port = pg.postgres.network.port; - const base = { host, port, clientAddr: null, serverAddr: null }; + const base = { host, port, user: null, database: null, clientAddr: null, serverAddr: null }; if (!secrets.ok) { return { ...base, attempted: false, ok: null, ssl: null, psqlVersion: null, error: "secrets-unhealthy" }; } - const probe = appProbe(pg, secrets); + const probe = appProbes(pg, secrets)[0] ?? null; if (probe === null) { return { ...base, attempted: false, ok: null, ssl: null, psqlVersion: null, error: "probe-secret-material-unavailable" }; } @@ -1217,6 +1271,8 @@ function controllerConnectionProbe(pg: PostgresHostConfig, secrets: SecretInspec const ssl = ["t", "true", "1", "on"].includes(sslText) ? true : ["f", "false", "0", "off"].includes(sslText) ? false : null; return { ...base, + user: probe.user, + database: probe.database, attempted: true, ok: result.status === 0 && ssl === true, ssl, @@ -1227,23 +1283,34 @@ function controllerConnectionProbe(pg: PostgresHostConfig, secrets: SecretInspec }; } -function factsScript(pg: PostgresHostConfig, probe: { user: string; password: string; database: string } | null): string { +function factsScript(pg: PostgresHostConfig, probes: Array<{ user: string; password: string; database: string }>): string { const release = releaseUrl(pg); const dataDir = pg.postgres.paths.dataDir; const configDir = pg.postgres.paths.configDir; const logDir = pg.postgres.paths.logDir; - const role = pg.objects.roles[0].name; - const database = pg.objects.databases[0].name; + const roleNames = pg.objects.roles.map((role) => role.name); + const databaseItems = pg.objects.databases.map((database) => ({ name: database.name, owner: database.owner })); + const roleListSql = sqlStringList(roleNames); + const databaseListSql = sqlStringList(databaseItems.map((database) => database.name)); + const rolesJsonB64 = Buffer.from(JSON.stringify(roleNames), "utf8").toString("base64"); + const databasesJsonB64 = Buffer.from(JSON.stringify(databaseItems), "utf8").toString("base64"); const dnsHost = pg.postgres.network.publicDns; const appHost = pg.postgres.network.listenAddresses.find((item) => item !== "127.0.0.1" && item !== "0.0.0.0") ?? "127.0.0.1"; - const probeEnv = probe === null - ? "" - : `APP_PROBE_USER=${shellQuote(probe.user)}\nAPP_PROBE_DATABASE=${shellQuote(probe.database)}\nAPP_PROBE_PASSWORD=${shellQuote(probe.password)}\nAPP_PROBE_HOST=${shellQuote(appHost)}\nAPP_PROBE_PORT=${shellQuote(String(pg.postgres.network.port))}\n`; + const probeCommands = probes.map((probe, index) => { + const encoded = Buffer.from(JSON.stringify({ user: probe.user, database: probe.database }), "utf8").toString("base64"); + return [ + `printf '%s' '${encoded}' | base64 -d >"$tmp/appProbe.${index}.json"`, + `PGPASSWORD=${shellQuote(probe.password)} psql "host=${appHost} port=${pg.postgres.network.port} user=${probe.user} dbname=${probe.database} sslmode=require" -Atqc "SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid();" >"$tmp/appSsl.${index}" 2>"$tmp/appConnErr.${index}"`, + `printf '%s' "$?" >"$tmp/appConnRc.${index}"`, + `printf '%s' ${shellQuote(appHost)} >"$tmp/appConnHost.${index}"`, + ].join("\n"); + }).join("\n"); return ` set +e tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT -${probeEnv} +printf '%s' '${rolesJsonB64}' | base64 -d >"$tmp/expectedRoles.json" +printf '%s' '${databasesJsonB64}' | base64 -d >"$tmp/expectedDatabases.json" date -Is >"$tmp/observedAt" 2>/dev/null hostname >"$tmp/hostname" 2>/dev/null if [ -r /etc/os-release ]; then . /etc/os-release; printf '%s' "$PRETTY_NAME" >"$tmp/osPrettyName"; printf '%s' "$VERSION_CODENAME" >"$tmp/osCodename"; fi @@ -1283,14 +1350,13 @@ if command -v runuser >/dev/null 2>&1 && command -v psql >/dev/null 2>&1 && id p $root_prefix runuser -u postgres -- psql -Atqc "SHOW ssl;" >"$tmp/sslOn" 2>/dev/null $root_prefix runuser -u postgres -- psql -Atqc "SHOW ssl_cert_file;" >"$tmp/sslCertFile" 2>/dev/null $root_prefix runuser -u postgres -- psql -Atqc "SHOW ssl_key_file;" >"$tmp/sslKeyFile" 2>/dev/null - $root_prefix runuser -u postgres -- psql -Atqc "SELECT 1 FROM pg_roles WHERE rolname='${role}'" >"$tmp/roleExists" 2>/dev/null - $root_prefix runuser -u postgres -- psql -Atqc "SELECT 1 FROM pg_database WHERE datname='${database}'" >"$tmp/databaseExists" 2>/dev/null + $root_prefix runuser -u postgres -- psql -Atqc "SELECT rolname FROM pg_roles WHERE rolname IN (${roleListSql}) ORDER BY rolname;" >"$tmp/rolesExisting" 2>/dev/null + $root_prefix runuser -u postgres -- psql -Atqc "SELECT datname FROM pg_database WHERE datname IN (${databaseListSql}) ORDER BY datname;" >"$tmp/databasesExisting" 2>/dev/null fi fi -if [ -n "\${APP_PROBE_PASSWORD:-}" ] && command -v psql >/dev/null 2>&1; then - PGPASSWORD="$APP_PROBE_PASSWORD" psql "host=$APP_PROBE_HOST port=$APP_PROBE_PORT user=$APP_PROBE_USER dbname=$APP_PROBE_DATABASE sslmode=require" -Atqc "SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid();" >"$tmp/appSsl" 2>"$tmp/appConnErr" - printf '%s' "$?" >"$tmp/appConnRc" - printf '%s' "$APP_PROBE_HOST" >"$tmp/appConnHost" +if command -v psql >/dev/null 2>&1; then +: +${probeCommands} fi python3 - "$tmp" "${release}" <<'PY' import json, os, sys @@ -1308,6 +1374,43 @@ def int_or_none(name): return None def yes_file(name, expected="0"): return text(name) == expected +def load_json(name, fallback): + try: + return json.load(open(os.path.join(tmp, name), encoding="utf-8")) + except Exception: + return fallback +def set_from_lines(name): + return {line.strip() for line in text(name).splitlines() if line.strip()} +expected_roles = load_json("expectedRoles.json", []) +expected_databases = load_json("expectedDatabases.json", []) +existing_roles = set_from_lines("rolesExisting") +existing_databases = set_from_lines("databasesExisting") +roles = [{"name": name, "exists": name in existing_roles} for name in expected_roles] +databases = [{"name": item["name"], "owner": item["owner"], "exists": item["name"] in existing_databases} for item in expected_databases] +app_connections = [] +index = 0 +while True: + meta = load_json(f"appProbe.{index}.json", None) + if meta is None: + break + ssl_raw = text(f"appSsl.{index}").lower() + if ssl_raw in {"t", "true", "1", "on"}: + ssl = True + elif ssl_raw in {"f", "false", "0", "off"}: + ssl = False + else: + ssl = None + rc_raw = text(f"appConnRc.{index}") + app_connections.append({ + "user": meta.get("user"), + "database": meta.get("database"), + "ok": None if rc_raw == "" else rc_raw == "0", + "ssl": ssl, + "host": text(f"appConnHost.{index}") or None, + "error": text(f"appConnErr.{index}") or None, + }) + index += 1 +primary_conn = app_connections[0] if app_connections else {} release_rc = text("releaserc") payload = { "ok": True, @@ -1347,16 +1450,19 @@ payload = { "dataDirExists": yes_file("dataDirRc"), "configDirExists": yes_file("configDirRc"), "logDirExists": yes_file("logDirRc"), - "roleExists": text("roleExists") == "1", - "databaseExists": text("databaseExists") == "1", + "roleExists": bool(roles and roles[0]["exists"]), + "databaseExists": bool(databases and databases[0]["exists"]), + "roles": roles, + "databases": databases, "serverVersion": text("serverVersion") or None, "sslOn": text("sslOn").lower() in {"on", "true", "1"}, "sslCertFile": text("sslCertFile") or None, "sslKeyFile": text("sslKeyFile") or None, - "appConnectionOk": None if text("appConnRc") == "" else text("appConnRc") == "0", - "appConnectionSsl": None if text("appSsl") == "" else text("appSsl").lower() in {"t", "true", "1", "on"}, - "appConnectionHost": text("appConnHost") or None, - "appConnectionError": text("appConnErr") or None, + "appConnectionOk": primary_conn.get("ok"), + "appConnectionSsl": primary_conn.get("ssl"), + "appConnectionHost": primary_conn.get("host"), + "appConnectionError": primary_conn.get("error"), + "appConnections": app_connections, }, } print(json.dumps(payload, ensure_ascii=False, indent=2)) @@ -1384,12 +1490,18 @@ async function startRemoteApplyJob(config: UniDeskConfig, pg: PostgresHostConfig } function remoteApplyPayload(pg: PostgresHostConfig, secrets: SecretInspection): Record { - const role = pg.objects.roles[0]; - const database = pg.objects.databases[0]; - const material = secrets.materials.get(role.passwordRef.sourceRef); - if (material === undefined) throw new Error(`missing material for ${role.passwordRef.sourceRef}`); - const dbPassword = material.values[role.passwordRef.key]; - if (dbPassword === undefined || dbPassword.length === 0) throw new Error(`missing ${role.passwordRef.key}`); + const roles = pg.objects.roles.map((role) => { + const material = secrets.materials.get(role.passwordRef.sourceRef); + if (material === undefined) throw new Error(`missing material for ${role.passwordRef.sourceRef}`); + const password = material.values[role.passwordRef.key]; + if (password === undefined || password.length === 0) throw new Error(`missing ${role.passwordRef.key}`); + return { + name: role.name, + password, + login: role.login, + attributes: role.attributes, + }; + }); return { clusterId: pg.metadata.id, pgVersion: pg.postgres.package.version, @@ -1402,13 +1514,10 @@ function remoteApplyPayload(pg: PostgresHostConfig, secrets: SecretInspection): tuning: pg.postgres.tuning, pgHba: pg.postgres.auth.pgHba, passwordEncryption: pg.postgres.auth.passwordEncryption, - role: { - name: role.name, - password: dbPassword, - login: role.login, - attributes: role.attributes, - }, - database, + roles, + databases: pg.objects.databases, + role: roles[0], + database: pg.objects.databases[0], backup: pg.backup.logicalDump, hbaMarkers: { start: managedHbaStart, end: managedHbaEnd }, }; @@ -1556,38 +1665,47 @@ if name == "pg_hba": print(f'{rule["type"]} {rule["database"]} {rule["user"]} {rule["address"]} {rule["method"]}') print(markers["end"]) elif name == "sql": - role = data["role"] - name = role["name"] - password = role["password"].replace("'", "''") - attrs = [] - attrs.append("LOGIN" if role.get("login") else "NOLOGIN") - attrs.append("CREATEDB" if role["attributes"].get("createdb") else "NOCREATEDB") - attrs.append("CREATEROLE" if role["attributes"].get("createrole") else "NOCREATEROLE") - attrs.append("SUPERUSER" if role["attributes"].get("superuser") else "NOSUPERUSER") - attr_sql = " ".join(attrs) - print("DO $unidesk$") - print("BEGIN") - print(f" IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{name}') THEN") - print(f" CREATE ROLE {name} {attr_sql} PASSWORD '{password}';") - print(" ELSE") - print(f" ALTER ROLE {name} WITH {attr_sql} PASSWORD '{password}';") - print(" END IF;") - print("END") - print("$unidesk$;") + for role in data["roles"]: + name = role["name"] + password = role["password"].replace("'", "''") + attrs = [] + attrs.append("LOGIN" if role.get("login") else "NOLOGIN") + attrs.append("CREATEDB" if role["attributes"].get("createdb") else "NOCREATEDB") + attrs.append("CREATEROLE" if role["attributes"].get("createrole") else "NOCREATEROLE") + attrs.append("SUPERUSER" if role["attributes"].get("superuser") else "NOSUPERUSER") + attr_sql = " ".join(attrs) + print("DO $unidesk$") + print("BEGIN") + print(f" IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{name}') THEN") + print(f" CREATE ROLE {name} {attr_sql} PASSWORD '{password}';") + print(" ELSE") + print(f" ALTER ROLE {name} WITH {attr_sql} PASSWORD '{password}';") + print(" END IF;") + print("END") + print("$unidesk$;") +elif name == "databases_tsv": + for database in data["databases"]: + print("\t".join([database["name"], database["owner"], database["encoding"], database["locale"]])) elif name == "backup_script": backup = data["backup"] - role = data["role"]["name"] - db = data["database"]["name"] + db = backup["database"] + database = next((item for item in data["databases"] if item["name"] == db), None) + if database is None: + raise SystemExit(f"backup database {db} is not declared") + role = next((item for item in data["roles"] if item["name"] == database["owner"]), None) + if role is None: + raise SystemExit(f"backup owner {database['owner']} has no declared role") + role_name = role["name"] port = data["network"]["port"] dest = backup["destination"]["path"] retention = backup["retentionDays"] - password = data["role"]["password"].replace("'", "'\"'\"'") + password = role["password"].replace("'", "'\"'\"'") print("#!/bin/sh") print("set -eu") print(f"dest='{dest}'") print("mkdir -p \"$dest\"") print("ts=$(date +%Y%m%dT%H%M%S)") - print(f"PGPASSWORD='{password}' /usr/lib/postgresql/{data['pgVersion']}/bin/pg_dump -h 127.0.0.1 -p {port} -U {role} -d {db} --format=custom --file \"$dest/{db}-$ts.dump\"") + print(f"PGPASSWORD='{password}' /usr/lib/postgresql/{data['pgVersion']}/bin/pg_dump -h 127.0.0.1 -p {port} -U {role_name} -d {db} --format=custom --file \"$dest/{db}-$ts.dump\"") print(f"find \"$dest\" -type f -name '{db}-*.dump' -mtime +{retention} -delete") PY } @@ -1610,10 +1728,6 @@ CONFIG_DIR="$(json_get paths.configDir)" DATA_DIR="$(json_get paths.dataDir)" BACKUP_ENABLED="$(json_get backup.enabled)" BACKUP_PATH="$(json_get backup.destination.path)" -DB_NAME="$(json_get database.name)" -DB_OWNER="$(json_get database.owner)" -DB_ENCODING="$(json_get database.encoding)" -DB_LOCALE="$(json_get database.locale)" TLS_ENABLED="$(json_get tls.enabled)" TLS_COMMON_NAME="$(json_get tls.commonName)" TLS_CERT_FILE="$(json_get tls.certFile)" @@ -1703,10 +1817,15 @@ sql="$job_dir/role.sql" render_file sql > "$sql" runuser -u postgres -- psql -v ON_ERROR_STOP=1 < "$sql" -write_state running create-database -if ! runuser -u postgres -- psql -Atqc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'" | grep -q 1; then - runuser -u postgres -- createdb -O "$DB_OWNER" -E "$DB_ENCODING" --locale="$DB_LOCALE" --template=template0 "$DB_NAME" -fi +write_state running create-databases +databases_tsv="$job_dir/databases.tsv" +render_file databases_tsv > "$databases_tsv" +while IFS="$(printf '\t')" read -r DB_NAME DB_OWNER DB_ENCODING DB_LOCALE; do + [ -n "$DB_NAME" ] || continue + if ! runuser -u postgres -- psql -Atqc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'" | grep -q 1; then + runuser -u postgres -- createdb -O "$DB_OWNER" -E "$DB_ENCODING" --locale="$DB_LOCALE" --template=template0 "$DB_NAME" + fi +done < "$databases_tsv" if [ "$BACKUP_ENABLED" = "true" ]; then write_state running configure-backup @@ -1739,8 +1858,11 @@ fi write_state running final-check runuser -u postgres -- psql -Atqc "SHOW server_version;" >/dev/null -runuser -u postgres -- psql -Atqc "SELECT 1 FROM pg_roles WHERE rolname='$DB_OWNER'" | grep -q 1 -runuser -u postgres -- psql -Atqc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'" | grep -q 1 +while IFS="$(printf '\t')" read -r DB_NAME DB_OWNER DB_ENCODING DB_LOCALE; do + [ -n "$DB_NAME" ] || continue + runuser -u postgres -- psql -Atqc "SELECT 1 FROM pg_roles WHERE rolname='$DB_OWNER'" | grep -q 1 + runuser -u postgres -- psql -Atqc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'" | grep -q 1 +done < "$databases_tsv" write_state succeeded complete 0 trap - EXIT exit 0 @@ -1839,6 +1961,11 @@ function shellQuote(value: string): string { return `'${value.replaceAll("'", "'\"'\"'")}'`; } +function sqlStringList(values: string[]): string { + if (values.length === 0) return "''"; + return values.map((value) => `'${value.replaceAll("'", "''")}'`).join(", "); +} + function parseJsonOutput(stdout: string): Record | null { const trimmed = stdout.trim(); if (trimmed.length === 0) return null; diff --git a/scripts/src/platform-infra-langbot.ts b/scripts/src/platform-infra-langbot.ts new file mode 100644 index 00000000..18d7d7ff --- /dev/null +++ b/scripts/src/platform-infra-langbot.ts @@ -0,0 +1,1780 @@ +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 { runSshCommandCapture, type SshCaptureResult } from "./ssh"; + +const configFile = rootPath("config", "platform-infra", "langbot.yaml"); +const serviceName = "langbot"; +const pluginRuntimeServiceName = "langbot-plugin-runtime"; +const fieldManager = "unidesk-platform-langbot"; +const caddyManagedStart = "# BEGIN unidesk managed langbot"; +const caddyManagedEnd = "# END unidesk managed langbot"; + +interface LangBotConfig { + version: number; + kind: "platform-infra-langbot"; + metadata: { id: string; owner: string; relatedIssues: number[] }; + image: { repository: string; tag: string; pullPolicy: "Always" | "IfNotPresent" | "Never" }; + dependencyImages: { postgresClient: string }; + targets: LangBotTarget[]; + runtime: { + timezone: string; + database: { + sourceRef: string; + sourceKeys: { user: string; password: string; dbName: string }; + secretName: string; + host: string; + port: number; + user: string; + dbName: string; + sslMode: string; + appDatabaseValue: string; + }; + secrets: { root: string; appSourceRef: string }; + storage: { + data: PvcSpec; + plugins: PvcSpec; + pluginRuntime: PvcSpec; + }; + box: { enabled: boolean; reason: string }; + }; + apiKey: { sourceRef: string; key: string; name: string; description: string }; + officialWechat: { + supportedAdapters: string[]; + webhookBaseUrl: string; + defaultCallbackPath: string; + notes: string[]; + }; +} + +interface PvcSpec { + name: string; + size: string; +} + +interface LangBotTarget { + id: string; + route: string; + namespace: string; + enabled: boolean; + replicas: number; + publicExposure: PublicExposure; +} + +interface PublicExposure { + 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; + }; +} + +interface CommonOptions { + targetId: string; + full: boolean; + raw: boolean; +} + +interface ApplyOptions extends CommonOptions { + confirm: boolean; + dryRun: boolean; + wait: boolean; +} + +interface QueryOptions extends CommonOptions { + path: string; +} + +interface LogsOptions extends CommonOptions { + component: "app" | "plugin-runtime" | "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; + jwtSecret: string; + apiKey: string; + }; + fingerprint: string; + valuesPrinted: false; +} + +interface FrpcSecretMaterial { + sourceRef: string; + sourcePath: string; + secretName: string; + secretKey: string; + frpcToml: string; + fingerprint: string; + valuesPrinted: false; +} + +export function langBotHelp(): Record { + return { + command: "platform-infra langbot plan|apply|status|logs|validate|bootstrap-api-key|query", + output: "json", + usage: [ + "bun scripts/cli.ts platform-infra langbot plan [--target G14]", + "bun scripts/cli.ts platform-infra langbot apply [--target G14] --dry-run", + "bun scripts/cli.ts platform-infra langbot apply [--target G14] --confirm", + "bun scripts/cli.ts platform-infra langbot status [--target G14] [--full|--raw]", + "bun scripts/cli.ts platform-infra langbot logs [--target G14] [--component app|plugin-runtime|frpc|all] [--lines N]", + "bun scripts/cli.ts platform-infra langbot validate [--target G14] [--full|--raw]", + "bun scripts/cli.ts platform-infra langbot bootstrap-api-key --confirm", + "bun scripts/cli.ts platform-infra langbot query --path /api/v1/platform/bots", + ], + configTruth: "config/platform-infra/langbot.yaml", + publicUrl: "https://langbot.pikapython.com", + secretPolicy: "API keys, database passwords and JWT secrets are never printed; output uses presence, key names and fingerprints only.", + }; +} + +export async function runLangBotCommand(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))); + if (action === "bootstrap-api-key") return bootstrapApiKey(parseApplyOptions(args.slice(1))); + if (action === "query") return query(parseQueryOptions(args.slice(1))); + return { ok: false, error: "unsupported-platform-infra-langbot-command", args, help: langBotHelp() }; +} + +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 langbot 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 = args.filter((arg, index) => { + const previous = args[index - 1]; + return arg !== "--confirm" && arg !== "--dry-run" && arg !== "--wait" && previous !== "--component" && previous !== "--lines" && previous !== "--path"; + }); + const common = parseCommonOptions(commonArgs); + if (args.some((arg) => !["--target", "--confirm", "--dry-run", "--wait", common.targetId].includes(arg) && !arg.startsWith("--target="))) { + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--target") { + index += 1; + continue; + } + if (arg === "--confirm" || arg === "--dry-run" || arg === "--wait") continue; + throw new Error(`unsupported langbot apply option: ${arg}`); + } + } + const confirm = args.includes("--confirm"); + const dryRun = args.includes("--dry-run") || !confirm; + if (confirm && args.includes("--dry-run")) throw new Error("langbot apply accepts only one of --confirm or --dry-run"); + return { ...common, confirm, dryRun, wait: args.includes("--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 !== "plugin-runtime" && value !== "frpc" && value !== "all") throw new Error("--component must be app, plugin-runtime, 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 parseQueryOptions(args: string[]): QueryOptions { + const commonArgs: string[] = []; + let path = "/api/v1/platform/bots"; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--path") { + const value = args[index + 1]; + if (value === undefined || !value.startsWith("/") || value.includes("..")) throw new Error("--path must be an absolute API path without .."); + path = value; + index += 1; + } else { + commonArgs.push(arg); + if (arg === "--target") { + commonArgs.push(args[index + 1] ?? ""); + index += 1; + } + } + } + return { ...parseCommonOptions(commonArgs), path }; +} + +function readLangBotConfig(): LangBotConfig { + const parsed = Bun.YAML.parse(readFileSync(configFile, "utf8")) as unknown; + const root = asRecord(parsed, "config/platform-infra/langbot.yaml"); + const version = integerField(root, "version", ""); + const kind = stringField(root, "kind", ""); + if (kind !== "platform-infra-langbot") throw new Error("config/platform-infra/langbot.yaml.kind must be platform-infra-langbot"); + 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 box = objectField(runtime, "box", "runtime"); + const apiKey = objectField(root, "apiKey", ""); + const officialWechat = objectField(root, "officialWechat", ""); + const config: LangBotConfig = { + 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: stringField(database, "sslMode", "runtime.database"), + appDatabaseValue: stringField(database, "appDatabaseValue", "runtime.database"), + }, + secrets: { + root: stringField(secrets, "root", "runtime.secrets"), + appSourceRef: sourceRefField(secrets, "appSourceRef", "runtime.secrets"), + }, + storage: { + data: pvcSpec(objectField(storage, "data", "runtime.storage"), "runtime.storage.data"), + plugins: pvcSpec(objectField(storage, "plugins", "runtime.storage"), "runtime.storage.plugins"), + pluginRuntime: pvcSpec(objectField(storage, "pluginRuntime", "runtime.storage"), "runtime.storage.pluginRuntime"), + }, + box: { + enabled: booleanField(box, "enabled", "runtime.box"), + reason: stringField(box, "reason", "runtime.box"), + }, + }, + apiKey: { + sourceRef: sourceRefField(apiKey, "sourceRef", "apiKey"), + key: envKeyField(apiKey, "key", "apiKey"), + name: stringField(apiKey, "name", "apiKey"), + description: stringField(apiKey, "description", "apiKey"), + }, + officialWechat: { + supportedAdapters: stringArrayField(officialWechat, "supportedAdapters", "officialWechat"), + webhookBaseUrl: httpsUrlField(officialWechat, "webhookBaseUrl", "officialWechat"), + defaultCallbackPath: apiPathField(officialWechat, "defaultCallbackPath", "officialWechat"), + notes: stringArrayField(officialWechat, "notes", "officialWechat"), + }, + }; + if (!isImageReference(`${config.image.repository}:${config.image.tag}`)) throw new Error("config/platform-infra/langbot.yaml.image must render a valid image reference"); + if (!isImageReference(config.dependencyImages.postgresClient)) throw new Error("config/platform-infra/langbot.yaml.dependencyImages.postgresClient must be an image reference"); + if (config.targets.length === 0) throw new Error("config/platform-infra/langbot.yaml.targets must not be empty"); + return config; +} + +function parseTarget(record: Record, index: number): LangBotTarget { + 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`), + }, + }, + }; +} + +function resolveTarget(langbot: LangBotConfig, targetId: string): LangBotTarget { + const target = langbot.targets.find((item) => item.id.toLowerCase() === targetId.toLowerCase()); + if (target === undefined) throw new Error(`unknown LangBot target ${targetId}; known targets: ${langbot.targets.map((item) => item.id).join(", ")}`); + if (!target.enabled) throw new Error(`LangBot target ${target.id} is disabled in config/platform-infra/langbot.yaml`); + return target; +} + +function plan(options: CommonOptions): Record { + const langbot = readLangBotConfig(); + const target = resolveTarget(langbot, options.targetId); + const yaml = renderManifest(langbot, target); + const policy = policyChecks(yaml, target); + return { + ok: policy.every((check) => check.ok), + action: "platform-infra-langbot-plan", + mutation: false, + config: configSummary(langbot, target), + policy, + decision: { + namespace: target.namespace, + publicExposure: "PK01 Caddy -> PK01 frps remotePort -> G14 frpc -> LangBot ClusterIP", + database: "PK01 existing host-native PostgreSQL instance; LangBot uses its own database and role in the same instance.", + box: langbot.runtime.box.enabled ? "enabled" : `disabled: ${langbot.runtime.box.reason}`, + officialWechat: langbot.officialWechat, + resourcePolicy: "No Kubernetes CPU/memory requests or limits are rendered.", + }, + next: { + dryRun: `bun scripts/cli.ts platform-infra langbot apply --target ${target.id} --dry-run`, + apply: `bun scripts/cli.ts platform-infra langbot apply --target ${target.id} --confirm`, + status: `bun scripts/cli.ts platform-infra langbot status --target ${target.id}`, + validate: `bun scripts/cli.ts platform-infra langbot validate --target ${target.id}`, + }, + }; +} + +async function apply(config: UniDeskConfig, options: ApplyOptions): Promise> { + const langbot = readLangBotConfig(); + const target = resolveTarget(langbot, options.targetId); + const yaml = renderManifest(langbot, target); + const policy = policyChecks(yaml, target); + if (!policy.every((check) => check.ok)) return { ok: false, action: "platform-infra-langbot-apply", mode: "policy-blocked", policy }; + if (options.confirm && !options.wait) { + const job = startJob( + `platform_infra_langbot_apply_${target.id.toLowerCase()}`, + ["bun", "scripts/cli.ts", "platform-infra", "langbot", "apply", "--target", target.id, "--confirm", "--wait"], + `Apply ${target.id} LangBot platform-infra manifests, FRP and PK01 Caddy exposure through the controlled UniDesk CLI`, + ); + return { + ok: true, + action: "platform-infra-langbot-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 langbot status --target ${target.id}`, + validate: `bun scripts/cli.ts platform-infra langbot validate --target ${target.id}`, + }, + }; + } + if (options.dryRun) { + const result = await capture(config, target.route, ["script"], dryRunScript(yaml, target)); + const parsed = parseJsonOutput(result.stdout); + return { + ok: result.exitCode === 0 && boolField(parsed, "ok", false), + action: "platform-infra-langbot-apply", + mode: "dry-run", + mutation: false, + target: targetSummary(target), + policy, + remote: parsed ?? compactCapture(result, { full: true }), + }; + } + const secretMaterial = prepareSecretMaterial(langbot); + const frpcSecret = prepareFrpcSecret(langbot, target); + const result = await capture(config, target.route, ["script"], applyScript(yaml, langbot, target, secretMaterial, frpcSecret)); + const parsed = parseJsonOutput(result.stdout); + const caddy = await applyPk01Caddy(config, target); + return { + ok: result.exitCode === 0 && boolField(parsed, "ok", false) && caddy.ok === true, + action: "platform-infra-langbot-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 langbot status --target ${target.id}`, + validate: `bun scripts/cli.ts platform-infra langbot validate --target ${target.id}`, + bootstrapApiKey: "bun scripts/cli.ts platform-infra langbot bootstrap-api-key --confirm", + }, + }; +} + +async function status(config: UniDeskConfig, options: CommonOptions): Promise> { + const langbot = readLangBotConfig(); + const target = resolveTarget(langbot, options.targetId); + const result = await capture(config, target.route, ["script"], statusScript(langbot, target)); + const parsed = parseJsonOutput(result.stdout); + return { + ok: result.exitCode === 0 && boolField(parsed, "ok", false), + action: "platform-infra-langbot-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 langbot = readLangBotConfig(); + const target = resolveTarget(langbot, 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-langbot-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 langbot = readLangBotConfig(); + const target = resolveTarget(langbot, options.targetId); + const k8s = await capture(config, target.route, ["script"], validateScript(target)); + const k8sParsed = parseJsonOutput(k8s.stdout); + const publicProbe = publicHttpProbe(target.publicExposure.publicBaseUrl, "/api/v1/system/info", null); + return { + ok: k8s.exitCode === 0 && boolField(k8sParsed, "ok", false) && publicProbe.ok, + action: "platform-infra-langbot-validate", + target: targetSummary(target), + k8s: k8sParsed ?? compactCapture(k8s, { full: true }), + publicHttps: publicProbe, + officialWechat: { + webhookBaseUrl: langbot.officialWechat.webhookBaseUrl, + callbackPath: langbot.officialWechat.defaultCallbackPath, + supportedAdapters: langbot.officialWechat.supportedAdapters, + }, + remote: compactCapture(k8s, { full: options.full || k8s.exitCode !== 0 }), + ...(options.raw ? { raw: k8s } : {}), + }; +} + +function bootstrapApiKey(options: ApplyOptions): Record { + const langbot = readLangBotConfig(); + resolveTarget(langbot, options.targetId); + if (!options.confirm || options.dryRun) { + return { + ok: true, + action: "platform-infra-langbot-bootstrap-api-key", + mode: "dry-run", + mutation: false, + targetTable: "api_keys", + next: { confirm: "bun scripts/cli.ts platform-infra langbot bootstrap-api-key --confirm" }, + }; + } + const secret = prepareSecretMaterial(langbot); + const conn = postgresConninfo(langbot, secret); + const sql = [ + "INSERT INTO api_keys (name, key, description)", + "VALUES (:'name', :'api_key', :'description')", + "ON CONFLICT (key) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, updated_at = now()", + "RETURNING id, name, created_at, updated_at;", + ].join("\n"); + const result = Bun.spawnSync([ + "psql", + "-Atq", + conn, + "-v", + `name=${langbot.apiKey.name}`, + "-v", + `api_key=${secret.values.apiKey}`, + "-v", + `description=${langbot.apiKey.description}`, + "-c", + sql, + ], { + stdout: "pipe", + stderr: "pipe", + env: { ...process.env, PGPASSWORD: secret.values.dbPassword }, + }); + const stdout = new TextDecoder().decode(result.stdout).trim(); + const stderr = new TextDecoder().decode(result.stderr).trim(); + const tableMissing = /relation "api_keys" does not exist/u.test(stderr); + return { + ok: result.exitCode === 0, + action: "platform-infra-langbot-bootstrap-api-key", + mode: "confirmed", + mutation: true, + apiKey: { + name: langbot.apiKey.name, + keySourceRef: langbot.apiKey.sourceRef, + keyName: langbot.apiKey.key, + fingerprint: fingerprintValues({ [langbot.apiKey.key]: secret.values.apiKey }, [langbot.apiKey.key]), + valuesPrinted: false, + }, + database: { + host: langbot.runtime.database.host, + port: langbot.runtime.database.port, + database: langbot.runtime.database.dbName, + user: langbot.runtime.database.user, + sslmode: langbot.runtime.database.sslMode, + }, + result: { + exitCode: result.exitCode, + row: result.exitCode === 0 ? stdout : "", + stderrTail: redactText(stderr).slice(-2000), + blocker: tableMissing ? "api_keys-table-missing-run-langbot-first" : null, + }, + next: tableMissing + ? { waitForAppMigration: "bun scripts/cli.ts platform-infra langbot status --target G14", retry: "bun scripts/cli.ts platform-infra langbot bootstrap-api-key --confirm" } + : { query: "bun scripts/cli.ts platform-infra langbot query --path /api/v1/platform/bots" }, + }; +} + +function query(options: QueryOptions): Record { + const langbot = readLangBotConfig(); + const target = resolveTarget(langbot, options.targetId); + const secret = prepareSecretMaterial(langbot); + const probe = publicHttpProbe(target.publicExposure.publicBaseUrl, options.path, secret.values.apiKey); + return { + ok: probe.ok, + action: "platform-infra-langbot-query", + mutation: false, + target: targetSummary(target), + request: { + method: "GET", + baseUrl: target.publicExposure.publicBaseUrl, + path: options.path, + auth: "X-API-Key", + apiKeyFingerprint: fingerprintValues({ [langbot.apiKey.key]: secret.values.apiKey }, [langbot.apiKey.key]), + valuesPrinted: false, + }, + response: probe, + }; +} + +function renderManifest(langbot: LangBotConfig, target: LangBotTarget): string { + const image = `${langbot.image.repository}:${langbot.image.tag}`; + const configHash = createHash("sha256").update(JSON.stringify({ langbot, target })).digest("hex").slice(0, 16); + const db = langbot.runtime.database; + const storage = langbot.runtime.storage; + const exposure = target.publicExposure; + 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: langbot + 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: PersistentVolumeClaim +metadata: + name: ${storage.plugins.name} + namespace: ${target.namespace} + labels: + app.kubernetes.io/name: langbot + app.kubernetes.io/component: plugins + app.kubernetes.io/part-of: platform-infra + app.kubernetes.io/managed-by: unidesk +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: ${storage.plugins.size} +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: ${storage.pluginRuntime.name} + namespace: ${target.namespace} + labels: + app.kubernetes.io/name: langbot-plugin-runtime + app.kubernetes.io/part-of: platform-infra + app.kubernetes.io/managed-by: unidesk +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: ${storage.pluginRuntime.size} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: langbot-config + namespace: ${target.namespace} + labels: + app.kubernetes.io/name: langbot + app.kubernetes.io/part-of: platform-infra + app.kubernetes.io/managed-by: unidesk +data: + TZ: "${langbot.runtime.timezone}" + API__HOST: "0.0.0.0" + API__PORT: "5300" + API__WEBHOOK_PREFIX: "${exposure.publicBaseUrl}" + API__EXTRA_WEBHOOK_PREFIX: "" + DATABASE__USE: "postgresql" + DATABASE__POSTGRESQL__HOST: "${db.host}" + DATABASE__POSTGRESQL__PORT: "${db.port}" + DATABASE__POSTGRESQL__USER: "${db.user}" + DATABASE__POSTGRESQL__DATABASE: "${db.appDatabaseValue}" + PLUGIN__RUNTIME_WS_URL: "ws://${pluginRuntimeServiceName}:5400/control/ws" + BOX__ENABLED: "${langbot.runtime.box.enabled}" + SPACE__DISABLE_TELEMETRY: "true" + SYSTEM__OUTBOUND_IPS: "" +--- +apiVersion: v1 +kind: Service +metadata: + name: ${pluginRuntimeServiceName} + namespace: ${target.namespace} + labels: + app.kubernetes.io/name: langbot-plugin-runtime + app.kubernetes.io/part-of: platform-infra + app.kubernetes.io/managed-by: unidesk +spec: + selector: + app.kubernetes.io/name: langbot-plugin-runtime + ports: + - name: websocket + port: 5400 + targetPort: websocket +--- +apiVersion: v1 +kind: Service +metadata: + name: ${serviceName} + namespace: ${target.namespace} + labels: + app.kubernetes.io/name: langbot + app.kubernetes.io/part-of: platform-infra + app.kubernetes.io/managed-by: unidesk +spec: + selector: + app.kubernetes.io/name: langbot + ports: + - name: http + port: 5300 + targetPort: http + - name: callback-2280 + port: 2280 + targetPort: callback-2280 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ${pluginRuntimeServiceName} + namespace: ${target.namespace} + labels: + app.kubernetes.io/name: langbot-plugin-runtime + app.kubernetes.io/part-of: platform-infra + app.kubernetes.io/managed-by: unidesk +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: langbot-plugin-runtime + template: + metadata: + labels: + app.kubernetes.io/name: langbot-plugin-runtime + app.kubernetes.io/part-of: platform-infra + annotations: + unidesk.ai/langbot-config-hash: "${configHash}" + spec: + securityContext: + fsGroup: 1000 + containers: + - name: plugin-runtime + image: ${image} + imagePullPolicy: ${langbot.image.pullPolicy} + command: + - uv + - run + - --no-sync + - -m + - langbot_plugin.cli.__init__ + - rt + ports: + - name: websocket + containerPort: 5400 + envFrom: + - configMapRef: + name: langbot-config + volumeMounts: + - name: plugin-runtime-data + mountPath: /app/data/plugins + volumes: + - name: plugin-runtime-data + persistentVolumeClaim: + claimName: ${storage.pluginRuntime.name} +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ${serviceName} + namespace: ${target.namespace} + labels: + app.kubernetes.io/name: langbot + 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: langbot + template: + metadata: + labels: + app.kubernetes.io/name: langbot + app.kubernetes.io/part-of: platform-infra + annotations: + unidesk.ai/langbot-config-hash: "${configHash}" + unidesk.ai/public-base-url: "${exposure.publicBaseUrl}" + unidesk.ai/box-enabled: "${langbot.runtime.box.enabled}" + spec: + securityContext: + fsGroup: 1000 + initContainers: + - name: wait-postgres + image: ${langbot.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: langbot + image: ${image} + imagePullPolicy: ${langbot.image.pullPolicy} + ports: + - name: http + containerPort: 5300 + - name: callback-2280 + containerPort: 2280 + envFrom: + - configMapRef: + name: langbot-config + env: + - name: DATABASE__POSTGRESQL__PASSWORD + valueFrom: + secretKeyRef: + name: ${db.secretName} + key: DATABASE_PASSWORD + - name: SYSTEM__JWT__SECRET + valueFrom: + secretKeyRef: + name: ${db.secretName} + key: SYSTEM_JWT_SECRET + volumeMounts: + - name: langbot-data + mountPath: /app/data + - name: langbot-plugins + mountPath: /app/plugins + volumes: + - name: langbot-data + persistentVolumeClaim: + claimName: ${storage.data.name} + - name: langbot-plugins + persistentVolumeClaim: + claimName: ${storage.plugins.name} +${renderFrpcManifest(target)} +`; +} + +function renderFrpcManifest(target: LangBotTarget): 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} +`; +} + +function policyChecks(yaml: string, target: LangBotTarget): Array> { + return [ + { name: "no-ingress", ok: !/^\s*kind:\s*Ingress\s*$/mu.test(yaml), detail: "LangBot 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: "no-box-docker-socket", ok: !/docker\.sock|langbot-box/u.test(yaml), detail: "LangBot Box is disabled by default and Docker socket is not mounted." }, + { name: "allow-all-network-policy", ok: hasAllowAllNetworkPolicy(yaml, target.namespace), detail: `NetworkPolicy/allow-all exists in ${target.namespace}.` }, + ]; +} + +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 prepareSecretMaterial(langbot: LangBotConfig): SecretMaterial { + const root = secretRoot(langbot); + const dbSourcePath = join(root, langbot.runtime.database.sourceRef); + if (!existsSync(dbSourcePath)) throw new Error(`LangBot database source ${redactRepoPath(dbSourcePath)} is missing; run platform-db postgres apply --config config/platform-db/postgres-pk01.yaml --confirm first`); + const dbValues = parseEnvFile(readFileSync(dbSourcePath, "utf8")); + const dbUser = requiredEnvValue(dbValues, langbot.runtime.database.sourceKeys.user, langbot.runtime.database.sourceRef); + const dbPassword = requiredEnvValue(dbValues, langbot.runtime.database.sourceKeys.password, langbot.runtime.database.sourceRef); + const dbName = requiredEnvValue(dbValues, langbot.runtime.database.sourceKeys.dbName, langbot.runtime.database.sourceRef); + if (dbUser !== langbot.runtime.database.user) throw new Error(`${langbot.runtime.database.sourceRef}.${langbot.runtime.database.sourceKeys.user} does not match langbot runtime.database.user`); + if (dbName !== langbot.runtime.database.dbName) throw new Error(`${langbot.runtime.database.sourceRef}.${langbot.runtime.database.sourceKeys.dbName} does not match langbot runtime.database.dbName`); + const appSourcePath = join(root, langbot.runtime.secrets.appSourceRef); + const existedBefore = existsSync(appSourcePath); + const existing = existedBefore ? parseEnvFile(readFileSync(appSourcePath, "utf8")) : {}; + const next = { ...existing }; + if (next.LANGBOT_JWT_SECRET === undefined || next.LANGBOT_JWT_SECRET.length === 0) next.LANGBOT_JWT_SECRET = randomBytes(32).toString("hex"); + if (next[langbot.apiKey.key] === undefined || next[langbot.apiKey.key].length === 0) next[langbot.apiKey.key] = `lbk_${randomBytes(32).toString("base64url")}`; + const required = ["LANGBOT_JWT_SECRET", langbot.apiKey.key]; + const action = !existedBefore ? "create" : required.some((key) => existing[key] !== next[key]) ? "update" : "none"; + if (action !== "none") writeEnvFile(appSourcePath, next); + const values = { + dbUser, + dbPassword, + dbName, + jwtSecret: next.LANGBOT_JWT_SECRET, + apiKey: next[langbot.apiKey.key], + }; + return { + dbSourceRef: langbot.runtime.database.sourceRef, + dbSourcePath: redactRepoPath(dbSourcePath), + appSourceRef: langbot.runtime.secrets.appSourceRef, + appSourcePath: redactRepoPath(appSourcePath), + action, + values, + fingerprint: fingerprintValues({ dbPassword, jwtSecret: values.jwtSecret, apiKey: values.apiKey }, ["dbPassword", "jwtSecret", "apiKey"]), + valuesPrinted: false, + }; +} + +function prepareFrpcSecret(langbot: LangBotConfig, target: LangBotTarget): FrpcSecretMaterial { + const exposure = target.publicExposure; + const sourcePath = join(secretRoot(langbot), exposure.frpc.tokenSourceRef); + if (!existsSync(sourcePath)) throw new Error(`LangBot public exposure requires ${redactRepoPath(sourcePath)} with ${exposure.frpc.tokenSourceKey}`); + const values = parseEnvFile(readFileSync(sourcePath, "utf8")); + const token = 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: redactRepoPath(sourcePath), + secretName: exposure.frpc.secretName, + secretKey: exposure.frpc.secretKey, + frpcToml, + fingerprint: fingerprintValues({ token, frpcToml }, ["token", "frpcToml"]), + valuesPrinted: false, + }; +} + +function dryRunScript(yaml: string, target: LangBotTarget): string { + const encoded = Buffer.from(yaml, "utf8").toString("base64"); + return ` +set -u +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +manifest="$tmp/langbot.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 ${target.namespace} >/dev/null 2>&1; then + kubectl apply --server-side --dry-run=server --field-manager=${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": "${target.id}", + "namespace": "${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 +`; +} + +function applyScript(yaml: string, langbot: LangBotConfig, target: LangBotTarget, secret: SecretMaterial, frpc: FrpcSecretMaterial): string { + const encodedManifest = Buffer.from(yaml, "utf8").toString("base64"); + const dbPasswordB64 = Buffer.from(secret.values.dbPassword, "utf8").toString("base64"); + const jwtB64 = Buffer.from(secret.values.jwtSecret, "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/langbot.k8s.yaml" +printf '%s' '${encodedManifest}' | base64 -d > "$manifest" +printf '%s' '${dbPasswordB64}' | base64 -d > "$tmp/db-password" +printf '%s' '${jwtB64}' | base64 -d > "$tmp/jwt-secret" +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 ${langbot.runtime.database.secretName} \\ + --from-file=DATABASE_PASSWORD="$tmp/db-password" \\ + --from-file=SYSTEM_JWT_SECRET="$tmp/jwt-secret" \\ + --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": "${langbot.image.repository}:${langbot.image.tag}", + "secret": {"name": "${langbot.runtime.database.secretName}", "keys": ["DATABASE_PASSWORD", "SYSTEM_JWT_SECRET"], "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 +`; +} + +async function applyPk01Caddy(config: UniDeskConfig, target: LangBotTarget): Promise> { + const exposure = target.publicExposure; + if (!exposure.enabled) return { ok: true, action: "not-enabled" }; + const block = `${caddyManagedStart} +${exposure.dns.hostname} { + reverse_proxy 127.0.0.1:${exposure.frpc.remotePort} { + transport http { + response_header_timeout ${exposure.pk01.responseHeaderTimeoutSeconds}s + } + } +} +${caddyManagedEnd} +`; + const blockB64 = Buffer.from(block, "utf8").toString("base64"); + const script = ` +set -u +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +block="$tmp/langbot.caddy" +printf '%s' '${blockB64}' | base64 -d >"$block" +sudo python3 - ${shQuote(exposure.pk01.caddyConfigPath)} "$block" ${shQuote(caddyManagedStart)} ${shQuote(caddyManagedEnd)} >"$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-langbot.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), + "hostname": "${exposure.dns.hostname}", + "remotePort": ${exposure.frpc.remotePort}, + "caddyConfigPath": "${exposure.pk01.caddyConfigPath}", + "service": "${exposure.pk01.caddyServiceName}", + "managedBlock": {"start": "${caddyManagedStart}", "end": "${caddyManagedEnd}"}, + "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 }) }; +} + +function statusScript(langbot: LangBotConfig, target: LangBotTarget): 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 langbot-config +capture secret kubectl -n ${target.namespace} get secret ${langbot.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 +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": []} +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} +langbot_deploy = deployment_summary("${serviceName}") +plugin_deploy = deployment_summary("${pluginRuntimeServiceName}") +frpc_deploy = deployment_summary("${target.publicExposure.frpc.deploymentName}") +deployment_ready = ( + not langbot_deploy.get("missing") + and not plugin_deploy.get("missing") + and not frpc_deploy.get("missing") + and langbot_deploy.get("readyReplicas", 0) >= 1 + and plugin_deploy.get("readyReplicas", 0) >= 1 + and frpc_deploy.get("readyReplicas", 0) >= 1 +) +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": "${langbot.runtime.database.host}", + "database": "${langbot.runtime.database.dbName}", + "user": "${langbot.runtime.database.user}", + "sslMode": "${langbot.runtime.database.sslMode}", + }, + "deployments": { + "langbot": langbot_deploy, + "pluginRuntime": plugin_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"), + "containers": [ + { + "name": c.get("name"), + "ready": c.get("ready"), + "restartCount": c.get("restartCount"), + "state": list((c.get("state") or {}).keys()), + } + for c in item.get("status", {}).get("containerStatuses", []) + ], + } + for item in pods.get("items", []) + ], + "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": "${langbot.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", [])), + }, + "officialWechat": { + "webhookBaseUrl": "${langbot.officialWechat.webhookBaseUrl}", + "defaultCallbackPath": "${langbot.officialWechat.defaultCallbackPath}", + "supportedAdapters": ${JSON.stringify(langbot.officialWechat.supportedAdapters)}, + }, +} +print(json.dumps(payload, ensure_ascii=False, indent=2)) +PY +`; +} + +function logsScript(target: LangBotTarget, options: LogsOptions): string { + const components = options.component === "all" ? ["app", "plugin-runtime", "frpc"] : [options.component]; + const commands = components.map((component) => { + const deployment = component === "app" ? serviceName : component === "plugin-runtime" ? pluginRuntimeServiceName : 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, sys +tmp = sys.argv[1] +components = sys.argv[2:] +payload = {"ok": True, "components": {}, "valuesPrinted": False} +for component in components: + def text(suffix): + try: + return open(os.path.join(tmp, f"{component}.{suffix}"), encoding="utf-8", errors="replace").read()[-12000:] + except FileNotFoundError: + return "" + rc_text = text("rc").strip() + rc = int(rc_text 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: LangBotTarget): 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 langbot -- python - <<'PY2' >"$tmp/http.out" 2>"$tmp/http.err" +import json +import urllib.request +try: + with urllib.request.urlopen("http://127.0.0.1:5300/api/v1/system/info", timeout=10) as resp: + print(json.dumps({"ok": 200 <= resp.status < 500, "status": resp.status, "bytes": len(resp.read())})) +except Exception as exc: + print(json.dumps({"ok": False, "error": str(exc)})) + raise +PY2 +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": { + "langbotReady": 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 publicHttpProbe(baseUrl: string, path: string, apiKey: string | null): Record { + const url = `${baseUrl.replace(/\/+$/u, "")}${path}`; + const args = ["-fsS", "--connect-timeout", "10", "--max-time", "30", "-o", "-", "-w", "\n%{http_code}"]; + if (apiKey !== null) args.unshift("-H", `X-API-Key: ${apiKey}`); + args.push(url); + const result = Bun.spawnSync(["curl", ...args], { 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), + apiKeyUsed: apiKey !== null, + valuesPrinted: false, + }; +} + +function postgresConninfo(langbot: LangBotConfig, secret: SecretMaterial): string { + const db = langbot.runtime.database; + return `host=${db.host} port=${db.port} user=${db.user} dbname=${db.dbName} sslmode=${db.sslMode} connect_timeout=10`; +} + +function configSummary(langbot: LangBotConfig, target: LangBotTarget): Record { + return { + path: "config/platform-infra/langbot.yaml", + metadata: langbot.metadata, + target: targetSummary(target), + image: `${langbot.image.repository}:${langbot.image.tag}`, + pullPolicy: langbot.image.pullPolicy, + database: { + host: langbot.runtime.database.host, + port: langbot.runtime.database.port, + user: langbot.runtime.database.user, + dbName: langbot.runtime.database.dbName, + appDatabaseValue: langbot.runtime.database.appDatabaseValue, + sslMode: langbot.runtime.database.sslMode, + sourceRef: langbot.runtime.database.sourceRef, + secretName: langbot.runtime.database.secretName, + valuesPrinted: false, + }, + storage: langbot.runtime.storage, + 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, + }, + apiKey: { + sourceRef: langbot.apiKey.sourceRef, + key: langbot.apiKey.key, + name: langbot.apiKey.name, + valuesPrinted: false, + }, + officialWechat: langbot.officialWechat, + }; +} + +function targetSummary(target: LangBotTarget): 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, + }; +} + +async function capture(config: UniDeskConfig, route: string, args: string[], stdin: string): Promise { + return await runSshCommandCapture(config, route, args, stdin); +} + +function secretRoot(langbot: LangBotConfig): string { + const root = langbot.runtime.secrets.root; + return isAbsolute(root) ? root : rootPath(root); +} + +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 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")}`; +} + +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; + } +} + +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) : "", + }; +} + +function redactText(text: string): string { + return text.replace(/lbk_[A-Za-z0-9_-]+/gu, "lbk_").replace(/postgresql:\/\/[^@\s]+@/gu, "postgresql://@"); +} + +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 escapeTomlString(value: string): string { + return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\""); +} + +function shQuote(value: string): string { + return `'${value.replaceAll("'", "'\"'\"'")}'`; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} + +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], `config/platform-infra/langbot.yaml.${prefix}${key}`); +} + +function arrayOfRecords(value: unknown, path: string): Record[] { + if (!Array.isArray(value)) throw new Error(`config/platform-infra/langbot.yaml.${path} must be an array`); + return value.map((item, index) => asRecord(item, `config/platform-infra/langbot.yaml.${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(`config/platform-infra/langbot.yaml.${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(`config/platform-infra/langbot.yaml.${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(`config/platform-infra/langbot.yaml.${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(`config/platform-infra/langbot.yaml.${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(`config/platform-infra/langbot.yaml.${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(`config/platform-infra/langbot.yaml.${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(`config/platform-infra/langbot.yaml.${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(`config/platform-infra/langbot.yaml.${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(`config/platform-infra/langbot.yaml.${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(`config/platform-infra/langbot.yaml.${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(`config/platform-infra/langbot.yaml.${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(`config/platform-infra/langbot.yaml.${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(`config/platform-infra/langbot.yaml.${path}.${key} must be absolute`); + return value; +} + +function apiPathField(obj: Record, key: string, path: string): string { + const value = stringField(obj, key, path); + if (!value.startsWith("/") || value.includes("..")) throw new Error(`config/platform-infra/langbot.yaml.${path}.${key} must be an absolute path without ..`); + 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(`config/platform-infra/langbot.yaml.${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(`config/platform-infra/langbot.yaml.${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.ts b/scripts/src/platform-infra.ts index b894dc70..6a4938cf 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 plan|apply|status|validate|codex-pool", + command: "platform-infra sub2api|langbot ...", output: "json", usage: [ "bun scripts/cli.ts platform-infra sub2api plan [--target G14|D601]", @@ -205,8 +205,14 @@ export function platformInfraHelp(): unknown { "bun scripts/cli.ts platform-infra sub2api codex-pool trace --request-id ", "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image status", "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-probe --account unidesk-codex-hy --confirm", + "bun scripts/cli.ts platform-infra langbot plan [--target G14]", + "bun scripts/cli.ts platform-infra langbot apply [--target G14] --confirm", + "bun scripts/cli.ts platform-infra langbot status [--target G14] [--full|--raw]", + "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", ], - description: "Operate YAML-controlled Sub2API platform-infra targets. G14 remains the active bundled deployment; D601 can be held at external-pending predeployment or activated against the external PK01 PostgreSQL runtime.", + 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.", target: { default: defaultTargetId, namespace, @@ -231,6 +237,10 @@ export function platformInfraHelp(): unknown { export async function runPlatformInfraCommand(config: UniDeskConfig, args: string[]): Promise | RenderedCliResult> { const [target, action] = args; + if (target === "langbot") { + const { runLangBotCommand } = await import("./platform-infra-langbot"); + return await runLangBotCommand(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)));