import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { isAbsolute } from "node:path"; import type { UniDeskConfig } from "./config"; import { rootPath } from "./config"; import { startJob } from "./jobs"; import { applyPk01CaddyBlock, capture, compactCapture, dryRunManifestScript, parseJsonOutput, prepareFrpcSecret, publicHttpProbe, publicServicePolicyChecks, redactText, renderFrpcManifest, shQuote, type FrpcSecretMaterial, } from "./platform-infra-public-service"; import { createYamlFieldReader, } from "./platform-infra-ops-library"; import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile, redactRepoPath, requiredEnvValue } from "./secrets"; const configFile = rootPath("config", "platform-infra", "langbot.yaml"); const configLabel = "config/platform-infra/langbot.yaml"; const serviceName = "langbot"; const pluginRuntimeServiceName = "langbot-plugin-runtime"; const fieldManager = "unidesk-platform-langbot"; const { asRecord, objectField, arrayOfRecords, stringField, integerField, booleanField, stringArrayField, numberArrayField, enumField, kubernetesNameField, sourceRefField, envKeyField, pgIdentifierField, hostField, portField, absolutePathField, apiPathField, httpsUrlField, } = createYamlFieldReader(configLabel); interface LangBotConfig { version: number; kind: "platform-infra-langbot"; metadata: { id: string; owner: string; relatedIssues: number[] }; defaults: { targetId: string }; 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 | null; 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: "read"; values: { dbUser: string; dbPassword: string; dbName: string; jwtSecret: string; apiKey: 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: string | null = null; let full = false; let raw = false; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--target") { const value = args[index + 1]; if (value === undefined || value.startsWith("--")) throw new Error("--target requires a value"); targetId = value; index += 1; } else if (arg === "--full") { full = true; } else if (arg === "--raw") { raw = true; full = true; } else { throw new Error(`unsupported langbot option: ${arg}`); } } if (targetId !== null && !/^[A-Za-z0-9._-]+$/u.test(targetId)) throw new Error("--target must be a simple target id"); return { targetId, full, raw }; } function parseApplyOptions(args: string[]): ApplyOptions { const commonArgs = 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); const allowedArgs = ["--target", "--confirm", "--dry-run", "--wait", ...(common.targetId === null ? [] : [common.targetId])]; if (args.some((arg) => !allowedArgs.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 defaults = objectField(root, "defaults", ""); const image = objectField(root, "image", ""); const dependencyImages = objectField(root, "dependencyImages", ""); const runtime = objectField(root, "runtime", ""); const database = objectField(runtime, "database", "runtime"); const sourceKeys = objectField(database, "sourceKeys", "runtime.database"); const secrets = objectField(runtime, "secrets", "runtime"); const storage = objectField(runtime, "storage", "runtime"); const 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"), }, defaults: { targetId: stringField(defaults, "targetId", "defaults"), }, image: { repository: stringField(image, "repository", "image"), tag: stringField(image, "tag", "image"), pullPolicy: enumField(image, "pullPolicy", "image", ["Always", "IfNotPresent", "Never"] as const), }, dependencyImages: { postgresClient: stringField(dependencyImages, "postgresClient", "dependencyImages"), }, targets: arrayOfRecords(root.targets, "targets").map(parseTarget), runtime: { timezone: stringField(runtime, "timezone", "runtime"), database: { sourceRef: sourceRefField(database, "sourceRef", "runtime.database"), sourceKeys: { user: envKeyField(sourceKeys, "user", "runtime.database.sourceKeys"), password: envKeyField(sourceKeys, "password", "runtime.database.sourceKeys"), dbName: envKeyField(sourceKeys, "dbName", "runtime.database.sourceKeys"), }, secretName: kubernetesNameField(database, "secretName", "runtime.database"), host: hostField(database, "host", "runtime.database"), port: portField(database, "port", "runtime.database"), user: pgIdentifierField(database, "user", "runtime.database"), dbName: pgIdentifierField(database, "dbName", "runtime.database"), sslMode: 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"); assertKnownEnabledTarget(config.targets, config.defaults.targetId, "defaults.targetId"); 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 assertKnownEnabledTarget(targets: LangBotTarget[], targetId: string, path: string): void { const target = targets.find((item) => item.id.toLowerCase() === targetId.toLowerCase()); if (target === undefined) throw new Error(`config/platform-infra/langbot.yaml.${path} references unknown target ${targetId}; known targets: ${targets.map((item) => item.id).join(", ")}`); if (!target.enabled) throw new Error(`config/platform-infra/langbot.yaml.${path} references disabled target ${target.id}`); } function resolveTarget(langbot: LangBotConfig, targetId: string | null): LangBotTarget { const resolvedTargetId = targetId ?? langbot.defaults.targetId; const target = langbot.targets.find((item) => item.id.toLowerCase() === resolvedTargetId.toLowerCase()); if (target === undefined) throw new Error(`unknown LangBot target ${resolvedTargetId}; 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: { secrets: "bun scripts/cli.ts secrets sync --config config/secrets-distribution.yaml --scope platform-infra --confirm", 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, ["sh"], dryRunManifestScript({ yaml, target, fieldManager, manifestName: serviceName })); 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({ secretRoot: secretRoot(langbot), exposure: target.publicExposure, sourcePathRedactor: redactRepoPath, parseEnvFile, requiredEnvValue, readTextFile, }); const confirmedYaml = renderManifest(langbot, target, secretMaterial.fingerprint); const result = await capture(config, target.route, ["sh"], applyScript(confirmedYaml, langbot, target, secretMaterial, frpcSecret)); const parsed = parseJsonOutput(result.stdout); const caddy = await applyPk01CaddyBlock(config, serviceName, target.publicExposure); return { ok: result.exitCode === 0 && boolField(parsed, "ok", false) && caddy.ok === true, action: "platform-infra-langbot-apply", mode: "confirmed", mutation: true, target: targetSummary(target), policy, secrets: secretSummary(secretMaterial, frpcSecret), remote: parsed ?? compactCapture(result, { full: true }), pk01Caddy: caddy, next: { secrets: "bun scripts/cli.ts secrets sync --config config/secrets-distribution.yaml --scope platform-infra --confirm", status: `bun scripts/cli.ts platform-infra 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, ["sh"], 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, ["sh"], 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, ["sh"], validateScript(target)); const k8sParsed = parseJsonOutput(k8s.stdout); const publicProbe = publicHttpProbe(target.publicExposure.publicBaseUrl, "/api/v1/system/info"); 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 (${sqlLiteral(langbot.apiKey.name)}, ${sqlLiteral(secret.values.apiKey)}, ${sqlLiteral(langbot.apiKey.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, "-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: fingerprintSecretValues({ [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, { headers: [`X-API-Key: ${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: fingerprintSecretValues({ [langbot.apiKey.key]: secret.values.apiKey }, [langbot.apiKey.key]), valuesPrinted: false, }, response: probe, }; } function renderManifest(langbot: LangBotConfig, target: LangBotTarget, secretFingerprint = "not-prepared"): 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/langbot-secret-fingerprint: "${secretFingerprint}" 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 policyChecks(yaml: string, target: LangBotTarget): Array> { return [ ...publicServicePolicyChecks(yaml, target, "LangBot"), { 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." }, ]; } export function prepareLangBotSecretMaterial(): SecretMaterial { return prepareSecretMaterial(readLangBotConfig()); } export function readLangBotPostgresConninfo(): string { const langbot = readLangBotConfig(); return postgresConninfo(langbot, prepareSecretMaterial(langbot)); } function prepareSecretMaterial(langbot: LangBotConfig): SecretMaterial { const root = secretRoot(langbot); const dbSource = readEnvSourceFile({ root, sourceRef: langbot.runtime.database.sourceRef, missingMessage: (sourcePath) => `LangBot database source ${redactRepoPath(sourcePath)} is missing; run platform-db postgres apply --config config/platform-db/postgres-pk01.yaml --confirm first`, }); const dbValues = dbSource.values; const dbUser = requiredEnvValue(dbValues, 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 appSource = readEnvSourceFile({ root, sourceRef: langbot.runtime.secrets.appSourceRef, missingMessage: (sourcePath) => `LangBot app secret source ${redactRepoPath(sourcePath)} is missing; run bun scripts/cli.ts secrets sync --config config/secrets-distribution.yaml --scope platform-infra --confirm first`, }); const appValues = appSource.values; const jwtSecret = requiredEnvValue(appValues, "LANGBOT_JWT_SECRET", langbot.runtime.secrets.appSourceRef); const apiKey = requiredEnvValue(appValues, langbot.apiKey.key, langbot.apiKey.sourceRef); const values = { dbUser, dbPassword, dbName, jwtSecret, apiKey, }; return { dbSourceRef: langbot.runtime.database.sourceRef, dbSourcePath: dbSource.sourcePathRedacted, appSourceRef: langbot.runtime.secrets.appSourceRef, appSourcePath: appSource.sourcePathRedacted, action: "read", values, fingerprint: fingerprintSecretValues({ dbPassword, jwtSecret: values.jwtSecret, apiKey: values.apiKey }, ["dbPassword", "jwtSecret", "apiKey"]), valuesPrinted: false, }; } export function readLangBotRuntimeConfig(): Record { const langbot = readLangBotConfig(); const target = resolveTarget(langbot, null); return { publicBaseUrl: target.publicExposure.publicBaseUrl, expectedNamespace: target.namespace, apiKeyName: langbot.apiKey.key, valuesPrinted: false, }; } export function readLangBotSecretMaterial(): Record { const langbot = readLangBotConfig(); const secret = prepareSecretMaterial(langbot); return { apiKey: secret.values.apiKey, apiKeyFingerprint: fingerprintSecretValues({ [langbot.apiKey.key]: secret.values.apiKey }, [langbot.apiKey.key]), sourceRef: langbot.apiKey.sourceRef, keyName: langbot.apiKey.key, valuesPrinted: false, }; } 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 `; } 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 capture events kubectl -n ${target.namespace} get events --sort-by=.lastTimestamp python3 - "$tmp" <<'PY' import json, os, sys tmp = sys.argv[1] def rc(name): try: return int(open(os.path.join(tmp, f"{name}.rc"), encoding="utf-8").read() or "1") except FileNotFoundError: return 1 def load(name): try: return json.load(open(os.path.join(tmp, f"{name}.json"), encoding="utf-8")) except Exception: return None deployments = load("deployments") or {"items": []} pods = load("pods") or {"items": []} services = load("services") or {"items": []} pvc = load("pvc") or {"items": []} events = load("events") or {"items": []} def deployment_summary(name): for item in deployments.get("items", []): if item.get("metadata", {}).get("name") == name: status = item.get("status", {}) spec = item.get("spec", {}) return { "name": name, "replicas": spec.get("replicas", 0), "readyReplicas": status.get("readyReplicas", 0), "updatedReplicas": status.get("updatedReplicas", 0), "availableReplicas": status.get("availableReplicas", 0), } return {"name": name, "missing": True, "readyReplicas": 0, "replicas": 0} 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 ) watched_names = {"${serviceName}", "${pluginRuntimeServiceName}", "${target.publicExposure.frpc.deploymentName}"} watched_pod_names = { item.get("metadata", {}).get("name") for item in pods.get("items", []) if item.get("metadata", {}).get("labels", {}).get("app.kubernetes.io/name") in watched_names } watched_pvc_names = { item.get("metadata", {}).get("name") for item in pvc.get("items", []) if item.get("metadata", {}).get("labels", {}).get("app.kubernetes.io/part-of") == "platform-infra" and str(item.get("metadata", {}).get("name", "")).startswith("langbot-") } def compact_message(value, limit=500): if not value: return None value = str(value).replace("\\n", " ") return value if len(value) <= limit else value[:limit] + "...(truncated)" def state_summary(status): state = status.get("state") or {} if not state: return {"type": "unknown"} state_type = next(iter(state.keys())) detail = state.get(state_type) or {} return { "type": state_type, "reason": detail.get("reason"), "message": compact_message(detail.get("message")), } def condition_summary(condition): return { "type": condition.get("type"), "status": condition.get("status"), "reason": condition.get("reason"), "message": compact_message(condition.get("message")), } def event_summary(event): involved = event.get("involvedObject", {}) return { "type": event.get("type"), "reason": event.get("reason"), "message": compact_message(event.get("message"), 700), "count": event.get("count"), "lastTimestamp": event.get("lastTimestamp") or event.get("eventTime"), "involvedObject": { "kind": involved.get("kind"), "name": involved.get("name"), }, } relevant_events = [] for event in events.get("items", []): involved = event.get("involvedObject", {}) kind = involved.get("kind") name = involved.get("name") if ( name in watched_pod_names or name in watched_pvc_names or name in watched_names or (kind == "ReplicaSet" and any(str(name or "").startswith(prefix + "-") for prefix in watched_names)) ): relevant_events.append(event_summary(event)) payload = { "ok": rc("ns") == 0 and rc("deployments") == 0 and rc("pods") == 0 and deployment_ready, "namespace": "${target.namespace}", "publicBaseUrl": "${target.publicExposure.publicBaseUrl}", "database": { "host": "${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"), "conditions": [ condition_summary(condition) for condition in item.get("status", {}).get("conditions", []) if condition.get("status") != "True" or condition.get("type") in ("Ready", "ContainersReady", "PodScheduled") ], "initContainers": [ { "name": c.get("name"), "ready": c.get("ready"), "restartCount": c.get("restartCount"), "state": state_summary(c), } for c in item.get("status", {}).get("initContainerStatuses", []) ], "containers": [ { "name": c.get("name"), "ready": c.get("ready"), "restartCount": c.get("restartCount"), "state": state_summary(c), } for c in item.get("status", {}).get("containerStatuses", []) ], } for item in pods.get("items", []) if item.get("metadata", {}).get("name") in watched_pod_names ], "events": relevant_events[-20:], "services": [item.get("metadata", {}).get("name") for item in services.get("items", [])], "pvcs": [item.get("metadata", {}).get("name") for item in pvc.get("items", [])], "secrets": { "app": {"name": "${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, re, sys tmp = sys.argv[1] components = sys.argv[2:] payload = {"ok": True, "components": {}, "valuesPrinted": False} secret_markers = ("PASSWORD", "SECRET", "TOKEN", "API_KEY", "DATABASE_URL") generic_secret = re.compile(r"(?i)((?:password|secret|token|api[_-]?key|database_url)\\s*[=:]\\s*)[^\\s,;]+") def redact(value): value = re.sub(r"lbk_[A-Za-z0-9_-]+", "lbk_", value) value = re.sub(r"(postgresql://)[^@\\s]+@", r"\\1@", value) redacted_lines = [] for line in value.splitlines(): if "env_key:" in line and "env_value:" in line: key = line.split("env_key:", 1)[1].split(",", 1)[0].strip().upper() if any(marker in key for marker in secret_markers): line = line.split("env_value:", 1)[0] + "env_value: " line = generic_secret.sub(r"\\1", line) redacted_lines.append(line) return "\\n".join(redacted_lines) for component in components: def text(suffix): try: return redact(open(os.path.join(tmp, f"{component}.{suffix}"), encoding="utf-8", errors="replace").read())[-12000:] except FileNotFoundError: return "" rc_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 -c 'import json, 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' >"$tmp/http.out" 2>"$tmp/http.err" http_rc=$? python3 - "$deploy_rc" "$frpc_rc" "$http_rc" "$tmp/deploy.json" "$tmp/deploy.err" "$tmp/frpc.json" "$tmp/frpc.err" "$tmp/http.out" "$tmp/http.err" <<'PY' import json, sys deploy_rc, frpc_rc, http_rc = [int(value) for value in sys.argv[1:4]] def load(path): try: return json.load(open(path, encoding="utf-8")) except Exception: return None def text(path): try: return open(path, encoding="utf-8", errors="replace").read()[-3000:] except FileNotFoundError: return "" deploy = load(sys.argv[4]) or {} frpc = load(sys.argv[6]) or {} http = load(sys.argv[8]) or {} payload = { "ok": deploy_rc == 0 and frpc_rc == 0 and http_rc == 0 and http.get("ok") is True, "deployments": { "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 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, }; } function secretRoot(langbot: LangBotConfig): string { const root = langbot.runtime.secrets.root; return isAbsolute(root) ? root : rootPath(root); } function sqlLiteral(value: string): string { return `'${value.replaceAll("'", "''")}'`; } function boolField(value: Record | null, key: string, fallback: boolean): boolean { return typeof value?.[key] === "boolean" ? value[key] : fallback; } 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(".."); }