Merge pull request #298 from pikasTech/feat/langbot-platform-infra

feat: add platform-infra LangBot service
This commit is contained in:
Lyon
2026-06-13 00:12:07 +08:00
committed by GitHub
7 changed files with 2190 additions and 94 deletions
+67
View File
@@ -8,6 +8,7 @@ metadata:
relatedIssues:
- 280
- 281
- 297
cluster:
role: primary
@@ -135,6 +136,26 @@ postgres:
user: sub2api
address: 36.49.29.73/32
method: scram-sha-256
- type: hostssl
database: langbot
user: langbot
address: 10.0.8.0/22
method: scram-sha-256
- type: hostssl
database: postgres
user: langbot
address: 10.0.8.0/22
method: scram-sha-256
- type: hostssl
database: langbot
user: langbot
address: 74.48.78.17/32
method: scram-sha-256
- type: hostssl
database: postgres
user: langbot
address: 74.48.78.17/32
method: scram-sha-256
secrets:
source: master-local
@@ -154,6 +175,20 @@ secrets:
SUB2API_DB_NAME: sub2api
randomHex:
SUB2API_DB_PASSWORD: 32
- name: langbot-db-credentials
sourceRef: platform-db/langbot-db.env
type: env
requiredKeys:
- LANGBOT_DB_USER
- LANGBOT_DB_PASSWORD
- LANGBOT_DB_NAME
createIfMissing:
enabled: true
values:
LANGBOT_DB_USER: langbot
LANGBOT_DB_NAME: langbot
randomHex:
LANGBOT_DB_PASSWORD: 32
objects:
roles:
@@ -166,12 +201,26 @@ objects:
createdb: false
createrole: false
superuser: false
- name: langbot
passwordRef:
sourceRef: platform-db/langbot-db.env
key: LANGBOT_DB_PASSWORD
login: true
attributes:
createdb: false
createrole: false
superuser: false
databases:
- name: sub2api
owner: sub2api
encoding: UTF8
locale: C.UTF-8
extensions: []
- name: langbot
owner: langbot
encoding: UTF8
locale: C.UTF-8
extensions: []
exports:
connectionStrings:
@@ -190,6 +239,21 @@ exports:
- scope: platform-infra
secret: sub2api-secrets
key: DATABASE_URL
- name: langbot-database-url
sourceSecretRef: platform-db/langbot-db.env
render:
envKey: DATABASE_URL
format: postgresql://$(LANGBOT_DB_USER):$(LANGBOT_DB_PASSWORD)@$(PGHOST):5432/$(LANGBOT_DB_NAME)?sslmode=require
variables:
PGHOST: 82.156.23.220
writeToSecretSource:
sourceRef: platform-infra/langbot.env
key: DATABASE_URL
mode: update-or-insert
consumers:
- scope: platform-infra
secret: langbot-secrets
key: DATABASE_URL
backup:
phase: minimum-restoreable
@@ -220,6 +284,9 @@ observability:
- kind: psql-app-role
database: sub2api
user: sub2api
- kind: psql-app-role
database: langbot
user: langbot
- kind: disk-free
path: /var/lib/postgresql/16/main
minFreeGiB: 10
+98
View File
@@ -0,0 +1,98 @@
version: 1
kind: platform-infra-langbot
metadata:
id: langbot-public
owner: unidesk
relatedIssues:
- 297
image:
repository: rockchin/langbot
tag: v4.10.1
pullPolicy: IfNotPresent
dependencyImages:
postgresClient: docker.m.daocloud.io/library/postgres:16-alpine
targets:
- id: G14
route: G14:k3s
namespace: platform-infra
enabled: true
replicas: 1
publicExposure:
enabled: true
publicBaseUrl: https://langbot.pikapython.com
dns:
hostname: langbot.pikapython.com
expectedA: 82.156.23.220
resolvers: [1.1.1.1, 8.8.8.8, 223.5.5.5, 114.114.114.114]
frpc:
deploymentName: langbot-frpc
secretName: langbot-frpc-secrets
secretKey: frpc.toml
image: 127.0.0.1:5000/hwlab/frpc:v0.68.1
serverAddr: 82.156.23.220
serverPort: 22000
proxyName: platform-infra-langbot-g14-web
remotePort: 22097
localIP: langbot.platform-infra.svc.cluster.local
localPort: 5300
tokenSourceRef: platform-infra/pk01-frp.env
tokenSourceKey: FRP_TOKEN
pk01:
route: PK01
caddyConfigPath: /etc/caddy/Caddyfile
caddyServiceName: caddy
responseHeaderTimeoutSeconds: 600
runtime:
timezone: Asia/Shanghai
database:
mode: external
sourceRef: platform-db/langbot-db.env
sourceKeys:
user: LANGBOT_DB_USER
password: LANGBOT_DB_PASSWORD
dbName: LANGBOT_DB_NAME
secretName: langbot-secrets
host: 82.156.23.220
port: 5432
user: langbot
dbName: langbot
sslMode: require
appDatabaseValue: langbot?ssl=require
secrets:
root: /root/unidesk/.state/secrets
appSourceRef: platform-infra/langbot.env
storage:
data:
name: langbot-data
size: 10Gi
plugins:
name: langbot-plugins
size: 5Gi
pluginRuntime:
name: langbot-plugin-runtime-data
size: 5Gi
box:
enabled: false
reason: LangBot Box mounts Docker socket in the official manifest; public platform service keeps it disabled by default.
apiKey:
sourceRef: platform-infra/langbot.env
key: LANGBOT_API_KEY
name: unidesk-langbot-cli
description: UniDesk controlled CLI API key for langbot.pikapython.com
officialWechat:
supportedAdapters:
- officialaccount
- wecom
- wecomcs
webhookBaseUrl: https://langbot.pikapython.com
defaultCallbackPath: /callback/command
notes:
- Official account, WeCom, and WeCom customer service adapters are supported through LangBot official platform sources.
- Personal WeChat/OpenClaw is not enabled by default for this public service.
+10
View File
@@ -22,6 +22,16 @@
- External platform PostgreSQL endpoints for Sub2API are produced by the platform DB YAML and its `platform-db postgres` CLI. Cross-node Sub2API consumers connect directly to that endpoint; the master server is not a PostgreSQL data-plane relay. DNS aliases are optional when the exported `DATABASE_URL` uses a reachable IP with `sslmode=require`; current PK01-specific rules live in `docs/reference/pk01.md`.
- Sub2API account sentinel and public exposure are target-scoped YAML decisions. Do not create a second sentinel, FRP client, public management surface, or edge proxy by hand; enable or move those resources only through the target YAML and the `platform-infra sub2api` / `codex-pool --target` CLI paths.
## LangBot Deployment Boundary
- LangBot is a UniDesk-operated public platform service in namespace `platform-infra`. The canonical entrypoint is `bun scripts/cli.ts platform-infra langbot plan|apply|status|logs|validate|bootstrap-api-key|query`; G14 is the default runtime target.
- LangBot configuration is YAML-first in `config/platform-infra/langbot.yaml`. Image tag, target namespace, PVCs, PK01 Caddy/FRP exposure, API key seed source, and official WeChat adapter metadata must stay in YAML rather than helper constants or manual runtime patches.
- LangBot uses the existing PK01 host-native PostgreSQL instance through `config/platform-db/postgres-pk01.yaml` and `platform-db postgres`. Adding LangBot state means adding a dedicated database and role inside that existing instance; do not deploy a second PostgreSQL StatefulSet, container, or external DB instance for LangBot.
- Public exposure uses PK01 Caddy plus FRP to the G14 ClusterIP service. Do not add Kubernetes Ingress, NodePort, LoadBalancer, host networking, or host ports for LangBot unless a later YAML-controlled platform decision changes the exposure model.
- LangBot's built-in Web frontend and API share the same public HTTPS origin. CLI queries must use the YAML-declared API key source and must report key names/fingerprints only, never the API key value.
- LangBot Box is disabled by default for the public service because the official Box deployment needs Docker socket access. Enabling Box requires a separate explicit platform decision and YAML-controlled security boundary.
- Official WeChat support is through LangBot's official platform adapters such as `officialaccount`, `wecom`, and `wecomcs`. Personal WeChat or OpenClaw-style adapters are not part of the default public-service boundary.
## Codex Pool Routing
`config/platform-infra/sub2api-codex-pool.yaml` controls the Codex-facing OpenAI-compatible pool:
+7 -3
View File
@@ -60,7 +60,7 @@ export function rootHelp(): unknown {
{ command: "hwlab nodes control-plane|git-mirror|secret --node <node> --lane <lane>", description: "Manage HWLAB node/lane runtime prerequisites, including D601 YAML-declared infra/tools-image/Argo bootstrap and G14 v0.3+ runtime lanes, with the node identity passed as data." },
{ command: "hwlab g14 monitor-prs | hwlab g14 control-plane status|apply|trigger-current|runtime-migration|cleanup-runs|cleanup-released-pvs | hwlab g14 git-mirror status|apply|sync|flush | hwlab g14 tools-image status|build", description: "Start the legacy G14 PR monitor, run bounded v0.2 Tekton/Argo control-plane, manual PipelineRun trigger, runtime migration, CI workspace retention, manual devops-infra git mirror/relay maintenance, or fixed HWLAB CI tools image actions; long confirmed trigger/sync/flush actions return async jobs by default." },
{ command: "agentrun get|describe|events|logs|result|ack|cancel|dispatch|create|apply|send|control-plane|git-mirror", description: "Use AgentRun v0.1 resource primitives with low-noise human output by default; session follow-up uses send only and the server decides internal steer vs turn." },
{ command: "platform-infra sub2api plan|apply|status|validate|codex-pool", description: "Deploy Sub2API in G14 platform-infra, manage the YAML-controlled Codex upstream pool, expose the unified API, and inspect marker sentinel state with low-noise reports without printing API keys." },
{ command: "platform-infra sub2api|langbot ...", description: "Deploy platform-infra services such as Sub2API and LangBot, manage YAML-controlled public FRP/Caddy exposure, and inspect status/logs without printing API keys." },
{ command: "platform-db postgres plan|status|apply", description: "Manage YAML-declared host-native PostgreSQL 16 on PK01 for Sub2API/platform state, with PostgreSQL native TLS and redacted secret exports." },
{ command: "hwlab cd audit --env dev | hwlab cd status --env dev | hwlab cd apply --env dev --dry-run", description: "Legacy D601 HWLAB DEV CD wrapper kept for explicit old-path diagnostics; current HWLAB rollout uses G14 GitOps." },
{ command: "code-agent-sandbox", description: "Independent Code Agent Sandbox service skeleton for adapter, mode, and credential-boundary diagnostics." },
@@ -605,7 +605,7 @@ function agentRunHelpSummary(): unknown {
function platformInfraHelpSummary(): unknown {
return {
command: "platform-infra sub2api plan|apply|status|validate|codex-pool",
command: "platform-infra sub2api|langbot ...",
output: "json",
usage: [
"bun scripts/cli.ts platform-infra sub2api plan",
@@ -615,8 +615,12 @@ function platformInfraHelpSummary(): unknown {
"bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image status",
"bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-probe --account unidesk-codex-hy --confirm",
"bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-report",
"bun scripts/cli.ts platform-infra langbot plan",
"bun scripts/cli.ts platform-infra langbot apply --confirm",
"bun scripts/cli.ts platform-infra langbot status",
"bun scripts/cli.ts platform-infra langbot query --path /api/v1/platform/bots",
],
description: "Operate G14 platform-infra services such as Sub2API and the YAML-controlled Codex pool.",
description: "Operate G14 platform-infra services such as Sub2API, LangBot, and the YAML-controlled Codex pool.",
};
}
+216 -89
View File
@@ -242,6 +242,8 @@ interface RemoteFacts {
logDirExists: boolean;
roleExists: boolean;
databaseExists: boolean;
roles: Array<{ name: string; exists: boolean }>;
databases: Array<{ name: string; owner: string; exists: boolean }>;
serverVersion: string | null;
sslOn: boolean;
sslCertFile: string | null;
@@ -250,6 +252,14 @@ interface RemoteFacts {
appConnectionSsl: boolean | null;
appConnectionHost: string | null;
appConnectionError: string | null;
appConnections: Array<{
user: string;
database: string;
ok: boolean | null;
ssl: boolean | null;
host: string | null;
error: string | null;
}>;
};
raw?: Record<string, unknown>;
}
@@ -258,6 +268,8 @@ interface ControllerConnectionProbe {
attempted: boolean;
ok: boolean | null;
ssl: boolean | null;
user: string | null;
database: string | null;
host: string;
port: number;
clientAddr: string | null;
@@ -369,7 +381,7 @@ async function plan(config: UniDeskConfig, options: PlatformDbOptions): Promise<
next: {
apply: `bun scripts/cli.ts platform-db postgres apply --config ${pg.configPath} --confirm`,
status: `bun scripts/cli.ts platform-db postgres status --config ${pg.configPath}`,
issue: "https://github.com/pikasTech/unidesk/issues/281",
issues: pg.metadata.relatedIssues.map((issue) => `https://github.com/pikasTech/unidesk/issues/${issue}`),
},
remote: compactCapture(remote.capture, { full: options.full || remote.capture.exitCode !== 0 }),
...(options.raw ? { raw: remote.capture } : {}),
@@ -383,15 +395,18 @@ async function status(config: UniDeskConfig, options: PlatformDbOptions): Promis
const facts = remote.parsed;
const controllerConnection = controllerConnectionProbe(pg, secrets);
const endpointHealthy = controllerConnection.ok === true && controllerConnection.ssl === true;
const rolesHealthy = facts !== null && pg.objects.roles.every((role) => facts.postgres.roles.some((item) => item.name === role.name && item.exists));
const databasesHealthy = facts !== null && pg.objects.databases.every((database) => facts.postgres.databases.some((item) => item.name === database.name && item.exists));
const appConnectionsHealthy = facts !== null
&& appProbes(pg, secrets).every((probe) => facts.postgres.appConnections.some((item) => item.user === probe.user && item.database === probe.database && item.ok === true && item.ssl === true));
const deploymentHealthy = facts !== null
&& facts.postgres.packageInstalled
&& facts.postgres.serviceActive
&& facts.postgres.sslOn
&& facts.postgres.roleExists
&& facts.postgres.databaseExists
&& rolesHealthy
&& databasesHealthy
&& facts.network.port5432Listening
&& facts.postgres.appConnectionOk === true
&& facts.postgres.appConnectionSsl === true;
&& appConnectionsHealthy;
const cutoverReady = deploymentHealthy && secrets.ok && endpointHealthy;
const cutoverBlockers = cutoverReady
? []
@@ -421,10 +436,13 @@ async function status(config: UniDeskConfig, options: PlatformDbOptions): Promis
dnsAddresses: facts.network.dns.addresses,
roleExists: facts.postgres.roleExists,
databaseExists: facts.postgres.databaseExists,
roles: facts.postgres.roles,
databases: facts.postgres.databases,
appConnectionOk: facts.postgres.appConnectionOk,
appConnectionSsl: facts.postgres.appConnectionSsl,
appConnectionHost: facts.postgres.appConnectionHost,
appConnectionError: facts.postgres.appConnectionError,
appConnections: facts.postgres.appConnections,
connectionHostProbe: controllerConnection,
port5432Listening: facts.network.port5432Listening,
serverVersion: facts.postgres.serverVersion,
@@ -548,7 +566,18 @@ function readPostgresHostConfig(pathArg: string): PostgresHostConfig {
const entries = arrayOfRecords(secrets.entries, `${configPath}.secrets.entries`).map((entry, index) => secretEntry(entry, `${configPath}.secrets.entries[${index}]`));
const roles = arrayOfRecords(objects.roles, `${configPath}.objects.roles`).map((role, index) => roleConfig(role, `${configPath}.objects.roles[${index}]`));
const databases = arrayOfRecords(objects.databases, `${configPath}.objects.databases`).map((database, index) => databaseConfig(database, `${configPath}.objects.databases[${index}]`));
if (roles.length !== 1 || databases.length !== 1) throw new Error(`${configPath}.objects must declare exactly one role and one database for the first PK01 Sub2API rollout`);
if (roles.length === 0 || databases.length === 0) throw new Error(`${configPath}.objects must declare at least one role and one database`);
const roleNames = new Set<string>();
for (const role of roles) {
if (roleNames.has(role.name)) throw new Error(`${configPath}.objects.roles contains duplicate role ${role.name}`);
roleNames.add(role.name);
}
const databaseNames = new Set<string>();
for (const database of databases) {
if (databaseNames.has(database.name)) throw new Error(`${configPath}.objects.databases contains duplicate database ${database.name}`);
if (!roleNames.has(database.owner)) throw new Error(`${configPath}.objects.databases.${database.name}.owner must reference a declared role`);
databaseNames.add(database.name);
}
const connectionStrings = arrayOfRecords(exportsConfig.connectionStrings, `${configPath}.exports.connectionStrings`).map((item, index) => connectionStringExport(item, `${configPath}.exports.connectionStrings[${index}]`));
return {
configPath: relativeConfigPath(configPath),
@@ -912,6 +941,10 @@ function configSummary(pg: PostgresHostConfig): Record<string, unknown> {
futureClientSslmode: pg.postgres.network.tls.futureClientSslmode,
},
haPhase: pg.cluster.haPhase,
objects: {
roles: pg.objects.roles.map((role) => ({ name: role.name, login: role.login })),
databases: pg.objects.databases.map((database) => ({ name: database.name, owner: database.owner })),
},
noK3s: true,
};
}
@@ -974,7 +1007,7 @@ function inspectSecrets(pg: PostgresHostConfig, materialize: boolean): SecretIns
function ensureLocalSecretState(pg: PostgresHostConfig): { inspection: SecretInspection; summary: Record<string, unknown>; exports: Array<Record<string, unknown>> } {
const inspection = inspectSecrets(pg, true);
if (!inspection.ok) throw new Error("required secret source keys are missing and cannot be generated from YAML");
validateSub2ApiSecretConsistency(pg, inspection);
validateObjectSecretConsistency(pg, inspection);
const exports = pg.exports.connectionStrings.map((item) => writeConnectionStringExport(pg, inspection, item));
return {
inspection,
@@ -989,13 +1022,17 @@ function ensureLocalSecretState(pg: PostgresHostConfig): { inspection: SecretIns
};
}
function validateSub2ApiSecretConsistency(pg: PostgresHostConfig, inspection: SecretInspection): void {
const role = pg.objects.roles[0];
const database = pg.objects.databases[0];
const material = inspection.materials.get(role.passwordRef.sourceRef);
if (material === undefined) throw new Error(`secret source not found for role passwordRef: ${role.passwordRef.sourceRef}`);
if (material.values.SUB2API_DB_USER !== role.name) throw new Error("SUB2API_DB_USER must match YAML objects.roles[0].name");
if (material.values.SUB2API_DB_NAME !== database.name) throw new Error("SUB2API_DB_NAME must match YAML objects.databases[0].name");
function validateObjectSecretConsistency(pg: PostgresHostConfig, inspection: SecretInspection): void {
const roleNames = new Set(pg.objects.roles.map((role) => role.name));
for (const role of pg.objects.roles) {
const material = inspection.materials.get(role.passwordRef.sourceRef);
if (material === undefined) throw new Error(`secret source not found for role passwordRef: ${role.passwordRef.sourceRef}`);
const password = material.values[role.passwordRef.key];
if (password === undefined || password.length === 0) throw new Error(`secret source ${role.passwordRef.sourceRef} is missing ${role.passwordRef.key}`);
}
for (const database of pg.objects.databases) {
if (!roleNames.has(database.owner)) throw new Error(`database ${database.name} owner ${database.owner} is not declared in objects.roles`);
}
}
function writeConnectionStringExport(pg: PostgresHostConfig, inspection: SecretInspection, item: ConnectionStringExportConfig): Record<string, unknown> {
@@ -1095,6 +1132,8 @@ function secretSummary(secrets: SecretInspection): Record<string, unknown> {
function desiredSummary(pg: PostgresHostConfig, secrets: SecretInspection, facts: RemoteFacts | null): Record<string, unknown> {
const role = pg.objects.roles[0];
const database = pg.objects.databases[0];
const observedRoles = facts?.postgres.roles ?? [];
const observedDatabases = facts?.postgres.databases ?? [];
return {
package: {
name: `postgresql-${pg.postgres.package.version}`,
@@ -1133,6 +1172,16 @@ function desiredSummary(pg: PostgresHostConfig, secrets: SecretInspection, facts
pgHbaRemoteTransport: "hostssl",
role: { name: role.name, action: facts?.postgres.roleExists ? "none" : "create-or-update" },
database: { name: database.name, owner: database.owner, action: facts?.postgres.databaseExists ? "none" : "create" },
roles: pg.objects.roles.map((item) => ({
name: item.name,
login: item.login,
action: observedRoles.some((observed) => observed.name === item.name && observed.exists) ? "none" : "create-or-update",
})),
databases: pg.objects.databases.map((item) => ({
name: item.name,
owner: item.owner,
action: observedDatabases.some((observed) => observed.name === item.name && observed.exists) ? "none" : "create",
})),
},
secrets: secretSummary(secrets),
exports: pg.exports.connectionStrings.map((item) => ({
@@ -1168,29 +1217,34 @@ function desiredChecks(pg: PostgresHostConfig, facts: RemoteFacts, secrets: Secr
}
async function remoteFacts(config: UniDeskConfig, pg: PostgresHostConfig, secrets: SecretInspection | null): Promise<{ capture: SshCaptureResult; parsed: RemoteFacts | null }> {
const capture = await runSshCommandCapture(config, pg.node.route, ["script"], factsScript(pg, appProbe(pg, secrets)));
const capture = await runSshCommandCapture(config, pg.node.route, ["script"], factsScript(pg, appProbes(pg, secrets)));
const parsed = parseJsonOutput(capture.stdout) as RemoteFacts | null;
return { capture, parsed };
}
function appProbe(pg: PostgresHostConfig, secrets: SecretInspection | null): { user: string; password: string; database: string } | null {
if (secrets === null || !secrets.ok) return null;
const role = pg.objects.roles[0];
const database = pg.objects.databases[0];
const material = secrets.materials.get(role.passwordRef.sourceRef);
const password = material?.values[role.passwordRef.key];
if (password === undefined || password.length === 0) return null;
return { user: role.name, password, database: database.name };
function appProbes(pg: PostgresHostConfig, secrets: SecretInspection | null): Array<{ user: string; password: string; database: string }> {
if (secrets === null || !secrets.ok) return [];
const rolesByName = new Map(pg.objects.roles.map((role) => [role.name, role]));
const probes: Array<{ user: string; password: string; database: string }> = [];
for (const database of pg.objects.databases) {
const role = rolesByName.get(database.owner);
if (role === undefined) continue;
const material = secrets.materials.get(role.passwordRef.sourceRef);
const password = material?.values[role.passwordRef.key];
if (password === undefined || password.length === 0) continue;
probes.push({ user: role.name, password, database: database.name });
}
return probes;
}
function controllerConnectionProbe(pg: PostgresHostConfig, secrets: SecretInspection): ControllerConnectionProbe {
const host = pg.postgres.network.connectionHost;
const port = pg.postgres.network.port;
const base = { host, port, clientAddr: null, serverAddr: null };
const base = { host, port, user: null, database: null, clientAddr: null, serverAddr: null };
if (!secrets.ok) {
return { ...base, attempted: false, ok: null, ssl: null, psqlVersion: null, error: "secrets-unhealthy" };
}
const probe = appProbe(pg, secrets);
const probe = appProbes(pg, secrets)[0] ?? null;
if (probe === null) {
return { ...base, attempted: false, ok: null, ssl: null, psqlVersion: null, error: "probe-secret-material-unavailable" };
}
@@ -1217,6 +1271,8 @@ function controllerConnectionProbe(pg: PostgresHostConfig, secrets: SecretInspec
const ssl = ["t", "true", "1", "on"].includes(sslText) ? true : ["f", "false", "0", "off"].includes(sslText) ? false : null;
return {
...base,
user: probe.user,
database: probe.database,
attempted: true,
ok: result.status === 0 && ssl === true,
ssl,
@@ -1227,23 +1283,34 @@ function controllerConnectionProbe(pg: PostgresHostConfig, secrets: SecretInspec
};
}
function factsScript(pg: PostgresHostConfig, probe: { user: string; password: string; database: string } | null): string {
function factsScript(pg: PostgresHostConfig, probes: Array<{ user: string; password: string; database: string }>): string {
const release = releaseUrl(pg);
const dataDir = pg.postgres.paths.dataDir;
const configDir = pg.postgres.paths.configDir;
const logDir = pg.postgres.paths.logDir;
const role = pg.objects.roles[0].name;
const database = pg.objects.databases[0].name;
const roleNames = pg.objects.roles.map((role) => role.name);
const databaseItems = pg.objects.databases.map((database) => ({ name: database.name, owner: database.owner }));
const roleListSql = sqlStringList(roleNames);
const databaseListSql = sqlStringList(databaseItems.map((database) => database.name));
const rolesJsonB64 = Buffer.from(JSON.stringify(roleNames), "utf8").toString("base64");
const databasesJsonB64 = Buffer.from(JSON.stringify(databaseItems), "utf8").toString("base64");
const dnsHost = pg.postgres.network.publicDns;
const appHost = pg.postgres.network.listenAddresses.find((item) => item !== "127.0.0.1" && item !== "0.0.0.0") ?? "127.0.0.1";
const probeEnv = probe === null
? ""
: `APP_PROBE_USER=${shellQuote(probe.user)}\nAPP_PROBE_DATABASE=${shellQuote(probe.database)}\nAPP_PROBE_PASSWORD=${shellQuote(probe.password)}\nAPP_PROBE_HOST=${shellQuote(appHost)}\nAPP_PROBE_PORT=${shellQuote(String(pg.postgres.network.port))}\n`;
const probeCommands = probes.map((probe, index) => {
const encoded = Buffer.from(JSON.stringify({ user: probe.user, database: probe.database }), "utf8").toString("base64");
return [
`printf '%s' '${encoded}' | base64 -d >"$tmp/appProbe.${index}.json"`,
`PGPASSWORD=${shellQuote(probe.password)} psql "host=${appHost} port=${pg.postgres.network.port} user=${probe.user} dbname=${probe.database} sslmode=require" -Atqc "SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid();" >"$tmp/appSsl.${index}" 2>"$tmp/appConnErr.${index}"`,
`printf '%s' "$?" >"$tmp/appConnRc.${index}"`,
`printf '%s' ${shellQuote(appHost)} >"$tmp/appConnHost.${index}"`,
].join("\n");
}).join("\n");
return `
set +e
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
${probeEnv}
printf '%s' '${rolesJsonB64}' | base64 -d >"$tmp/expectedRoles.json"
printf '%s' '${databasesJsonB64}' | base64 -d >"$tmp/expectedDatabases.json"
date -Is >"$tmp/observedAt" 2>/dev/null
hostname >"$tmp/hostname" 2>/dev/null
if [ -r /etc/os-release ]; then . /etc/os-release; printf '%s' "$PRETTY_NAME" >"$tmp/osPrettyName"; printf '%s' "$VERSION_CODENAME" >"$tmp/osCodename"; fi
@@ -1283,14 +1350,13 @@ if command -v runuser >/dev/null 2>&1 && command -v psql >/dev/null 2>&1 && id p
$root_prefix runuser -u postgres -- psql -Atqc "SHOW ssl;" >"$tmp/sslOn" 2>/dev/null
$root_prefix runuser -u postgres -- psql -Atqc "SHOW ssl_cert_file;" >"$tmp/sslCertFile" 2>/dev/null
$root_prefix runuser -u postgres -- psql -Atqc "SHOW ssl_key_file;" >"$tmp/sslKeyFile" 2>/dev/null
$root_prefix runuser -u postgres -- psql -Atqc "SELECT 1 FROM pg_roles WHERE rolname='${role}'" >"$tmp/roleExists" 2>/dev/null
$root_prefix runuser -u postgres -- psql -Atqc "SELECT 1 FROM pg_database WHERE datname='${database}'" >"$tmp/databaseExists" 2>/dev/null
$root_prefix runuser -u postgres -- psql -Atqc "SELECT rolname FROM pg_roles WHERE rolname IN (${roleListSql}) ORDER BY rolname;" >"$tmp/rolesExisting" 2>/dev/null
$root_prefix runuser -u postgres -- psql -Atqc "SELECT datname FROM pg_database WHERE datname IN (${databaseListSql}) ORDER BY datname;" >"$tmp/databasesExisting" 2>/dev/null
fi
fi
if [ -n "\${APP_PROBE_PASSWORD:-}" ] && command -v psql >/dev/null 2>&1; then
PGPASSWORD="$APP_PROBE_PASSWORD" psql "host=$APP_PROBE_HOST port=$APP_PROBE_PORT user=$APP_PROBE_USER dbname=$APP_PROBE_DATABASE sslmode=require" -Atqc "SELECT ssl FROM pg_stat_ssl WHERE pid = pg_backend_pid();" >"$tmp/appSsl" 2>"$tmp/appConnErr"
printf '%s' "$?" >"$tmp/appConnRc"
printf '%s' "$APP_PROBE_HOST" >"$tmp/appConnHost"
if command -v psql >/dev/null 2>&1; then
:
${probeCommands}
fi
python3 - "$tmp" "${release}" <<'PY'
import json, os, sys
@@ -1308,6 +1374,43 @@ def int_or_none(name):
return None
def yes_file(name, expected="0"):
return text(name) == expected
def load_json(name, fallback):
try:
return json.load(open(os.path.join(tmp, name), encoding="utf-8"))
except Exception:
return fallback
def set_from_lines(name):
return {line.strip() for line in text(name).splitlines() if line.strip()}
expected_roles = load_json("expectedRoles.json", [])
expected_databases = load_json("expectedDatabases.json", [])
existing_roles = set_from_lines("rolesExisting")
existing_databases = set_from_lines("databasesExisting")
roles = [{"name": name, "exists": name in existing_roles} for name in expected_roles]
databases = [{"name": item["name"], "owner": item["owner"], "exists": item["name"] in existing_databases} for item in expected_databases]
app_connections = []
index = 0
while True:
meta = load_json(f"appProbe.{index}.json", None)
if meta is None:
break
ssl_raw = text(f"appSsl.{index}").lower()
if ssl_raw in {"t", "true", "1", "on"}:
ssl = True
elif ssl_raw in {"f", "false", "0", "off"}:
ssl = False
else:
ssl = None
rc_raw = text(f"appConnRc.{index}")
app_connections.append({
"user": meta.get("user"),
"database": meta.get("database"),
"ok": None if rc_raw == "" else rc_raw == "0",
"ssl": ssl,
"host": text(f"appConnHost.{index}") or None,
"error": text(f"appConnErr.{index}") or None,
})
index += 1
primary_conn = app_connections[0] if app_connections else {}
release_rc = text("releaserc")
payload = {
"ok": True,
@@ -1347,16 +1450,19 @@ payload = {
"dataDirExists": yes_file("dataDirRc"),
"configDirExists": yes_file("configDirRc"),
"logDirExists": yes_file("logDirRc"),
"roleExists": text("roleExists") == "1",
"databaseExists": text("databaseExists") == "1",
"roleExists": bool(roles and roles[0]["exists"]),
"databaseExists": bool(databases and databases[0]["exists"]),
"roles": roles,
"databases": databases,
"serverVersion": text("serverVersion") or None,
"sslOn": text("sslOn").lower() in {"on", "true", "1"},
"sslCertFile": text("sslCertFile") or None,
"sslKeyFile": text("sslKeyFile") or None,
"appConnectionOk": None if text("appConnRc") == "" else text("appConnRc") == "0",
"appConnectionSsl": None if text("appSsl") == "" else text("appSsl").lower() in {"t", "true", "1", "on"},
"appConnectionHost": text("appConnHost") or None,
"appConnectionError": text("appConnErr") or None,
"appConnectionOk": primary_conn.get("ok"),
"appConnectionSsl": primary_conn.get("ssl"),
"appConnectionHost": primary_conn.get("host"),
"appConnectionError": primary_conn.get("error"),
"appConnections": app_connections,
},
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
@@ -1384,12 +1490,18 @@ async function startRemoteApplyJob(config: UniDeskConfig, pg: PostgresHostConfig
}
function remoteApplyPayload(pg: PostgresHostConfig, secrets: SecretInspection): Record<string, unknown> {
const role = pg.objects.roles[0];
const database = pg.objects.databases[0];
const material = secrets.materials.get(role.passwordRef.sourceRef);
if (material === undefined) throw new Error(`missing material for ${role.passwordRef.sourceRef}`);
const dbPassword = material.values[role.passwordRef.key];
if (dbPassword === undefined || dbPassword.length === 0) throw new Error(`missing ${role.passwordRef.key}`);
const roles = pg.objects.roles.map((role) => {
const material = secrets.materials.get(role.passwordRef.sourceRef);
if (material === undefined) throw new Error(`missing material for ${role.passwordRef.sourceRef}`);
const password = material.values[role.passwordRef.key];
if (password === undefined || password.length === 0) throw new Error(`missing ${role.passwordRef.key}`);
return {
name: role.name,
password,
login: role.login,
attributes: role.attributes,
};
});
return {
clusterId: pg.metadata.id,
pgVersion: pg.postgres.package.version,
@@ -1402,13 +1514,10 @@ function remoteApplyPayload(pg: PostgresHostConfig, secrets: SecretInspection):
tuning: pg.postgres.tuning,
pgHba: pg.postgres.auth.pgHba,
passwordEncryption: pg.postgres.auth.passwordEncryption,
role: {
name: role.name,
password: dbPassword,
login: role.login,
attributes: role.attributes,
},
database,
roles,
databases: pg.objects.databases,
role: roles[0],
database: pg.objects.databases[0],
backup: pg.backup.logicalDump,
hbaMarkers: { start: managedHbaStart, end: managedHbaEnd },
};
@@ -1556,38 +1665,47 @@ if name == "pg_hba":
print(f'{rule["type"]} {rule["database"]} {rule["user"]} {rule["address"]} {rule["method"]}')
print(markers["end"])
elif name == "sql":
role = data["role"]
name = role["name"]
password = role["password"].replace("'", "''")
attrs = []
attrs.append("LOGIN" if role.get("login") else "NOLOGIN")
attrs.append("CREATEDB" if role["attributes"].get("createdb") else "NOCREATEDB")
attrs.append("CREATEROLE" if role["attributes"].get("createrole") else "NOCREATEROLE")
attrs.append("SUPERUSER" if role["attributes"].get("superuser") else "NOSUPERUSER")
attr_sql = " ".join(attrs)
print("DO $unidesk$")
print("BEGIN")
print(f" IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{name}') THEN")
print(f" CREATE ROLE {name} {attr_sql} PASSWORD '{password}';")
print(" ELSE")
print(f" ALTER ROLE {name} WITH {attr_sql} PASSWORD '{password}';")
print(" END IF;")
print("END")
print("$unidesk$;")
for role in data["roles"]:
name = role["name"]
password = role["password"].replace("'", "''")
attrs = []
attrs.append("LOGIN" if role.get("login") else "NOLOGIN")
attrs.append("CREATEDB" if role["attributes"].get("createdb") else "NOCREATEDB")
attrs.append("CREATEROLE" if role["attributes"].get("createrole") else "NOCREATEROLE")
attrs.append("SUPERUSER" if role["attributes"].get("superuser") else "NOSUPERUSER")
attr_sql = " ".join(attrs)
print("DO $unidesk$")
print("BEGIN")
print(f" IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{name}') THEN")
print(f" CREATE ROLE {name} {attr_sql} PASSWORD '{password}';")
print(" ELSE")
print(f" ALTER ROLE {name} WITH {attr_sql} PASSWORD '{password}';")
print(" END IF;")
print("END")
print("$unidesk$;")
elif name == "databases_tsv":
for database in data["databases"]:
print("\t".join([database["name"], database["owner"], database["encoding"], database["locale"]]))
elif name == "backup_script":
backup = data["backup"]
role = data["role"]["name"]
db = data["database"]["name"]
db = backup["database"]
database = next((item for item in data["databases"] if item["name"] == db), None)
if database is None:
raise SystemExit(f"backup database {db} is not declared")
role = next((item for item in data["roles"] if item["name"] == database["owner"]), None)
if role is None:
raise SystemExit(f"backup owner {database['owner']} has no declared role")
role_name = role["name"]
port = data["network"]["port"]
dest = backup["destination"]["path"]
retention = backup["retentionDays"]
password = data["role"]["password"].replace("'", "'\"'\"'")
password = role["password"].replace("'", "'\"'\"'")
print("#!/bin/sh")
print("set -eu")
print(f"dest='{dest}'")
print("mkdir -p \"$dest\"")
print("ts=$(date +%Y%m%dT%H%M%S)")
print(f"PGPASSWORD='{password}' /usr/lib/postgresql/{data['pgVersion']}/bin/pg_dump -h 127.0.0.1 -p {port} -U {role} -d {db} --format=custom --file \"$dest/{db}-$ts.dump\"")
print(f"PGPASSWORD='{password}' /usr/lib/postgresql/{data['pgVersion']}/bin/pg_dump -h 127.0.0.1 -p {port} -U {role_name} -d {db} --format=custom --file \"$dest/{db}-$ts.dump\"")
print(f"find \"$dest\" -type f -name '{db}-*.dump' -mtime +{retention} -delete")
PY
}
@@ -1610,10 +1728,6 @@ CONFIG_DIR="$(json_get paths.configDir)"
DATA_DIR="$(json_get paths.dataDir)"
BACKUP_ENABLED="$(json_get backup.enabled)"
BACKUP_PATH="$(json_get backup.destination.path)"
DB_NAME="$(json_get database.name)"
DB_OWNER="$(json_get database.owner)"
DB_ENCODING="$(json_get database.encoding)"
DB_LOCALE="$(json_get database.locale)"
TLS_ENABLED="$(json_get tls.enabled)"
TLS_COMMON_NAME="$(json_get tls.commonName)"
TLS_CERT_FILE="$(json_get tls.certFile)"
@@ -1703,10 +1817,15 @@ sql="$job_dir/role.sql"
render_file sql > "$sql"
runuser -u postgres -- psql -v ON_ERROR_STOP=1 < "$sql"
write_state running create-database
if ! runuser -u postgres -- psql -Atqc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'" | grep -q 1; then
runuser -u postgres -- createdb -O "$DB_OWNER" -E "$DB_ENCODING" --locale="$DB_LOCALE" --template=template0 "$DB_NAME"
fi
write_state running create-databases
databases_tsv="$job_dir/databases.tsv"
render_file databases_tsv > "$databases_tsv"
while IFS="$(printf '\t')" read -r DB_NAME DB_OWNER DB_ENCODING DB_LOCALE; do
[ -n "$DB_NAME" ] || continue
if ! runuser -u postgres -- psql -Atqc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'" | grep -q 1; then
runuser -u postgres -- createdb -O "$DB_OWNER" -E "$DB_ENCODING" --locale="$DB_LOCALE" --template=template0 "$DB_NAME"
fi
done < "$databases_tsv"
if [ "$BACKUP_ENABLED" = "true" ]; then
write_state running configure-backup
@@ -1739,8 +1858,11 @@ fi
write_state running final-check
runuser -u postgres -- psql -Atqc "SHOW server_version;" >/dev/null
runuser -u postgres -- psql -Atqc "SELECT 1 FROM pg_roles WHERE rolname='$DB_OWNER'" | grep -q 1
runuser -u postgres -- psql -Atqc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'" | grep -q 1
while IFS="$(printf '\t')" read -r DB_NAME DB_OWNER DB_ENCODING DB_LOCALE; do
[ -n "$DB_NAME" ] || continue
runuser -u postgres -- psql -Atqc "SELECT 1 FROM pg_roles WHERE rolname='$DB_OWNER'" | grep -q 1
runuser -u postgres -- psql -Atqc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'" | grep -q 1
done < "$databases_tsv"
write_state succeeded complete 0
trap - EXIT
exit 0
@@ -1839,6 +1961,11 @@ function shellQuote(value: string): string {
return `'${value.replaceAll("'", "'\"'\"'")}'`;
}
function sqlStringList(values: string[]): string {
if (values.length === 0) return "''";
return values.map((value) => `'${value.replaceAll("'", "''")}'`).join(", ");
}
function parseJsonOutput(stdout: string): Record<string, unknown> | null {
const trimmed = stdout.trim();
if (trimmed.length === 0) return null;
File diff suppressed because it is too large Load Diff
+12 -2
View File
@@ -191,7 +191,7 @@ interface EgressProxySecretMaterial {
export function platformInfraHelp(): unknown {
return {
command: "platform-infra sub2api plan|apply|status|validate|codex-pool",
command: "platform-infra sub2api|langbot ...",
output: "json",
usage: [
"bun scripts/cli.ts platform-infra sub2api plan [--target G14|D601]",
@@ -205,8 +205,14 @@ export function platformInfraHelp(): unknown {
"bun scripts/cli.ts platform-infra sub2api codex-pool trace --request-id <requestId>",
"bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image status",
"bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-probe --account unidesk-codex-hy --confirm",
"bun scripts/cli.ts platform-infra langbot plan [--target G14]",
"bun scripts/cli.ts platform-infra langbot apply [--target G14] --confirm",
"bun scripts/cli.ts platform-infra langbot status [--target G14] [--full|--raw]",
"bun scripts/cli.ts platform-infra langbot logs [--target G14] [--component app|plugin-runtime|frpc|all]",
"bun scripts/cli.ts platform-infra langbot bootstrap-api-key --confirm",
"bun scripts/cli.ts platform-infra langbot query --path /api/v1/platform/bots",
],
description: "Operate YAML-controlled Sub2API platform-infra targets. G14 remains the active bundled deployment; D601 can be held at external-pending predeployment or activated against the external PK01 PostgreSQL runtime.",
description: "Operate YAML-controlled platform-infra services such as Sub2API and LangBot. Public services use PK01 Caddy+FRP rather than Kubernetes Ingress, NodePort, or LoadBalancer.",
target: {
default: defaultTargetId,
namespace,
@@ -231,6 +237,10 @@ export function platformInfraHelp(): unknown {
export async function runPlatformInfraCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown> | RenderedCliResult> {
const [target, action] = args;
if (target === "langbot") {
const { runLangBotCommand } = await import("./platform-infra-langbot");
return await runLangBotCommand(config, args.slice(1));
}
if (target !== "sub2api") return unsupported(args);
if (action === "plan" || action === undefined) return plan(parseTargetOptions(args.slice(2)));
if (action === "apply") return await apply(config, parseApplyOptions(args.slice(2)));