1564 lines
63 KiB
TypeScript
1564 lines
63 KiB
TypeScript
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 { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile, redactRepoPath, requiredEnvValue } from "./secrets";
|
|
|
|
const configFile = rootPath("config", "platform-infra", "langbot.yaml");
|
|
const serviceName = "langbot";
|
|
const pluginRuntimeServiceName = "langbot-plugin-runtime";
|
|
const fieldManager = "unidesk-platform-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: "read";
|
|
values: {
|
|
dbUser: string;
|
|
dbPassword: string;
|
|
dbName: string;
|
|
jwtSecret: string;
|
|
apiKey: string;
|
|
};
|
|
fingerprint: string;
|
|
valuesPrinted: false;
|
|
}
|
|
|
|
export function langBotHelp(): Record<string, unknown> {
|
|
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<Record<string, unknown>> {
|
|
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<string, unknown>, 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<string, unknown> {
|
|
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<Record<string, unknown>> {
|
|
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"], 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, ["script"], 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<Record<string, unknown>> {
|
|
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<Record<string, unknown>> {
|
|
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<Record<string, unknown>> {
|
|
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");
|
|
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<string, unknown> {
|
|
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<string, unknown> {
|
|
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<Record<string, unknown>> {
|
|
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<string, unknown> {
|
|
const langbot = readLangBotConfig();
|
|
const target = resolveTarget(langbot, "G14");
|
|
return {
|
|
publicBaseUrl: target.publicExposure.publicBaseUrl,
|
|
expectedNamespace: target.namespace,
|
|
apiKeyName: langbot.apiKey.key,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
export function readLangBotSecretMaterial(): Record<string, unknown> {
|
|
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_<redacted>", value)
|
|
value = re.sub(r"(postgresql://)[^@\\s]+@", r"\\1<redacted>@", 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: <redacted>"
|
|
line = generic_secret.sub(r"\\1<redacted>", 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<string, unknown> {
|
|
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<string, unknown> {
|
|
return { id: target.id, route: target.route, namespace: target.namespace, replicas: target.replicas };
|
|
}
|
|
|
|
function secretSummary(secret: SecretMaterial, frpc: FrpcSecretMaterial): Record<string, unknown> {
|
|
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<string, unknown> | null, key: string, fallback: boolean): boolean {
|
|
return typeof value?.[key] === "boolean" ? value[key] : fallback;
|
|
}
|
|
|
|
function asRecord(value: unknown, path: string): Record<string, unknown> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be a YAML object`);
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function objectField(obj: Record<string, unknown>, key: string, path: string): Record<string, unknown> {
|
|
const prefix = path.length > 0 ? `${path}.` : "";
|
|
return asRecord(obj[key], `config/platform-infra/langbot.yaml.${prefix}${key}`);
|
|
}
|
|
|
|
function arrayOfRecords(value: unknown, path: string): Record<string, unknown>[] {
|
|
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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<const T extends readonly string[]>(obj: Record<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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<string, unknown>, 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("..");
|
|
}
|