Merge pull request #298 from pikasTech/feat/langbot-platform-infra
feat: add platform-infra LangBot service
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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)));
|
||||
|
||||
Reference in New Issue
Block a user