Merge remote-tracking branch 'origin/master' into feat/hwlab-1119-d601-external-db

# Conflicts:
#	config/platform-db/postgres-pk01.yaml
#	scripts/src/platform-db.ts
This commit is contained in:
Codex
2026-06-12 21:22:18 +00:00
28 changed files with 4467 additions and 237 deletions
+78
View File
@@ -100,6 +100,17 @@ export function unsupportedRebuildService(value: string | undefined): Record<str
};
}
export function unsupportedRestartService(value: string | undefined): Record<string, unknown> {
const base = unsupportedRebuildService(value);
return {
...base,
error: "unsupported-server-restart",
reason: typeof base.reason === "string" ? base.reason.replace(/server rebuild/g, "server restart") : base.reason,
replacement: typeof base.replacement === "string" ? base.replacement.replace(/server rebuild/g, "server restart") : base.replacement,
policy: "server restart is a no-build maintenance entrypoint and must not silently mutate upstream images, D601 services, database, or unknown objects",
};
}
export function resolveComposeCommand(config: UniDeskConfig, envFile: string): string[] {
const composeFile = rootPath(config.docker.composeFile);
if (commandOk(["docker", "compose", "version"], repoRoot)) {
@@ -431,6 +442,73 @@ export function rebuildService(config: UniDeskConfig, service: RebuildableServic
};
}
export function restartService(config: UniDeskConfig, service: RebuildableService): unknown {
const runtimeEnv = writeComposeEnv(config, false);
const compose = resolveComposeCommand(config, runtimeEnv.envFile);
const listServiceContainersCommand = [
"docker",
"ps",
"-q",
"--filter",
`label=com.docker.compose.project=${config.docker.projectName}`,
"--filter",
`label=com.docker.compose.service=${service}`,
"--filter",
"label=com.docker.compose.oneoff=False",
];
const upCommand = [...compose, "up", "-d", "--no-build", "--no-deps", "--force-recreate", service];
const listAllServiceContainersCommand = [...listServiceContainersCommand];
listAllServiceContainersCommand[2] = "-a";
const validateScript = [
"ready=0",
"for attempt in $(seq 1 60); do",
`cid=$(${shellJoin(listServiceContainersCommand)} || true)`,
"if [ -n \"$cid\" ]; then",
"health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' $cid 2>/dev/null | head -1 || true)",
`echo "service_container_probe service=${service} attempt=$attempt cid=$cid health=$health"`,
"if [ \"$health\" = \"healthy\" ] || [ \"$health\" = \"running\" ]; then ready=1; break; fi",
"else",
`echo "service_container_probe service=${service} attempt=$attempt cid=missing"`,
"fi",
"sleep 1",
"done",
"if [ \"$ready\" != \"1\" ]; then",
`echo "service_container_not_ready service=${service}" >&2`,
`${shellJoin(listAllServiceContainersCommand)} --format '{{.ID}} {{.Names}} {{.Status}}' >&2 || true`,
"exit 1",
"fi",
].join("\n");
const script = [
"set -euo pipefail",
`echo ${shellJoin(["restart_service", service, "no_build_force_recreate_with_validation"])}`,
restrictedHostAccessScript(config),
shellJoin(upCommand),
validateScript,
].join("\n");
const command = ["bash", "-lc", composeLockedScript(script)];
const job = startJob("server_restart", command, `Restart and validate UniDesk ${service} without building images`);
return {
job,
runtimeEnv,
service,
command,
strategy: {
buildBeforeReplace: false,
noBuild: true,
replaceScope: {
projectLabel: config.docker.projectName,
serviceLabel: service,
},
noDeps: true,
forceRecreate: true,
composeMutationLock: rootPath(".state", "locks", "server-compose.lock"),
jobRunner: "local",
postUpValidation: true,
namedVolumesPreserved: true,
},
};
}
function fixedPorts(config: UniDeskConfig): Array<{ name: string; port: number; listening: boolean }> {
return [
{ name: "frontend", port: config.network.frontend.port, listening: isPortListening(config.network.frontend.port) },
+15 -4
View File
@@ -19,6 +19,7 @@ export function rootHelp(): unknown {
{ command: "server cleanup plan [--min-age-hours N] [--limit N]", description: "Dry-run Docker image cleanup plan only: list active/protected images, stale candidates older than the default 24h threshold, risk, estimated reclaim, and manual review commands without deleting anything." },
{ command: "gc plan|run|db-trace|policy|remote [--confirm] [--logs-keep-days N] [--include-browser-cache]", description: "One-time main-server or remote provider disk relief and low-risk anti-bloat policy for logs, journald, allowlisted /tmp artifacts, scoped core dumps and explicit trace telemetry retention; plan is read-only and run requires --confirm." },
{ command: "server rebuild <backend-core|frontend|dev-frontend-proxy|provider-gateway|todo-note|code-queue-mgr|project-manager|baidu-netdisk|oa-event-flow>", description: "Maintenance-only local Compose rebuild for reviewed main-server services; frontend standard release must use CI artifact plus deploy apply dev/prod artifact consumers." },
{ command: "server restart <backend-core|frontend|dev-frontend-proxy|provider-gateway|todo-note|code-queue-mgr|project-manager|baidu-netdisk|oa-event-flow>", description: "No-build single-service Compose restart for reviewed main-server maintenance recovery; returns an async job and validates the recreated container." },
{ command: "provider attach <providerId> [--master-server URL] [--up] [--force] | provider triage <providerId> [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]", description: "Generate the minimal external provider-gateway env/compose bundle or run the low-noise read-only provider health triage contract." },
{ command: "trans <route> [operation args...] (alias of ssh <route> ...)", description: "Open a Host SSH / WSL SSH maintenance session; provider WebSocket carries control and host.ssh.tcp-pool carries stdin/stdout/stderr data." },
{ command: "trans gh:/owner/repo[/pr|/issue][/number[/1]] ls|cat|rg|patch-apply", description: "Treat GitHub PRs/issues as virtual text directories; `ls --full` shows state/floors/body length, and `patch-apply` updates first-floor `body.md` through UniDesk gh plus apply-patch v2." },
@@ -59,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|n8n ...", description: "Deploy platform-infra services such as Sub2API, LangBot and n8n, manage YAML-controlled public FRP/Caddy exposure, and inspect status/logs without printing secrets." },
{ 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." },
@@ -101,7 +102,7 @@ export function isHelpToken(value: string | undefined): boolean {
export function serverHelp(action: string | undefined = undefined): unknown {
return {
command: action === undefined || isHelpToken(action) ? "server start|stop|status|swap|logs|cleanup|rebuild" : `server ${action}`,
command: action === undefined || isHelpToken(action) ? "server start|stop|status|swap|logs|cleanup|rebuild|restart" : `server ${action}`,
output: "json",
description: "Manage the fixed main-server Docker Compose stack without exposing backend-core REST publicly.",
usage: {
@@ -112,6 +113,7 @@ export function serverHelp(action: string | undefined = undefined): unknown {
logs: "bun scripts/cli.ts server logs [--tail-bytes N]",
cleanup: "bun scripts/cli.ts server cleanup plan [--min-age-hours N] [--limit N]",
rebuild: "bun scripts/cli.ts server rebuild <backend-core|frontend|dev-frontend-proxy|provider-gateway|todo-note|code-queue-mgr|project-manager|baidu-netdisk|oa-event-flow>",
restart: "bun scripts/cli.ts server restart <backend-core|frontend|dev-frontend-proxy|provider-gateway|todo-note|code-queue-mgr|project-manager|baidu-netdisk|oa-event-flow>",
},
cleanupPlan: {
dryRunOnly: true,
@@ -138,6 +140,7 @@ export function serverHelp(action: string | undefined = undefined): unknown {
maintenanceOnly: {
frontend: "server rebuild frontend is not standard release evidence; publish 127.0.0.1:5000/unidesk/frontend:<commit> with CI, then consume it with deploy apply --env dev/prod --service frontend --commit <full-sha>",
userServices: "server rebuild for main-server user services is reserved for local maintenance, bootstrap or recovery and must not replace commit-pinned artifact CD",
restart: "server restart is for no-build single-service recovery when the existing image is already present and the goal is to refresh runtime state",
},
nonRebuildableBoundary: {
upstreamImages: ["filebrowser", "filebrowser-d601"],
@@ -602,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|n8n ...",
output: "json",
usage: [
"bun scripts/cli.ts platform-infra sub2api plan",
@@ -612,8 +615,16 @@ 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",
"bun scripts/cli.ts platform-infra n8n plan",
"bun scripts/cli.ts platform-infra n8n apply --confirm",
"bun scripts/cli.ts platform-infra n8n status",
"bun scripts/cli.ts platform-infra n8n validate --full",
],
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, n8n, and the YAML-controlled Codex pool.",
};
}
+191 -117
View File
@@ -242,8 +242,8 @@ interface RemoteFacts {
logDirExists: boolean;
roleExists: boolean;
databaseExists: boolean;
roleExistsByName?: Record<string, boolean>;
databaseExistsByName?: Record<string, boolean>;
roles: Array<{ name: string; exists: boolean }>;
databases: Array<{ name: string; owner: string; exists: boolean }>;
serverVersion: string | null;
sslOn: boolean;
sslCertFile: string | null;
@@ -252,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>;
}
@@ -260,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;
@@ -371,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 } : {}),
@@ -385,17 +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 rolesReady = facts !== null && pg.objects.roles.every((role, index) => facts.postgres.roleExistsByName?.[role.name] ?? (index === 0 ? facts.postgres.roleExists : false));
const databasesReady = facts !== null && pg.objects.databases.every((database, index) => facts.postgres.databaseExistsByName?.[database.name] ?? (index === 0 ? facts.postgres.databaseExists : false));
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
&& rolesReady
&& databasesReady
&& rolesHealthy
&& databasesHealthy
&& facts.network.port5432Listening
&& facts.postgres.appConnectionOk === true
&& facts.postgres.appConnectionSsl === true;
&& appConnectionsHealthy;
const cutoverReady = deploymentHealthy && secrets.ok && endpointHealthy;
const cutoverBlockers = cutoverReady
? []
@@ -425,14 +436,13 @@ async function status(config: UniDeskConfig, options: PlatformDbOptions): Promis
dnsAddresses: facts.network.dns.addresses,
roleExists: facts.postgres.roleExists,
databaseExists: facts.postgres.databaseExists,
roleExistsByName: facts.postgres.roleExistsByName ?? {},
databaseExistsByName: facts.postgres.databaseExistsByName ?? {},
rolesReady,
databasesReady,
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,
@@ -556,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 === 0 || databases.length === 0) throw new Error(`${configPath}.objects.roles/databases must each contain at least one item`);
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),
@@ -925,6 +946,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,
};
}
@@ -987,7 +1012,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,
@@ -1002,13 +1027,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 !== undefined && 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 !== undefined && 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> {
@@ -1106,8 +1135,10 @@ function secretSummary(secrets: SecretInspection): Record<string, unknown> {
}
function desiredSummary(pg: PostgresHostConfig, secrets: SecretInspection, facts: RemoteFacts | null): Record<string, unknown> {
const roleExistsByName = facts?.postgres.roleExistsByName ?? {};
const databaseExistsByName = facts?.postgres.databaseExistsByName ?? {};
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}`,
@@ -1144,14 +1175,17 @@ function desiredSummary(pg: PostgresHostConfig, secrets: SecretInspection, facts
},
pgHbaManagedBlock: { start: managedHbaStart, end: managedHbaEnd, rules: pg.postgres.auth.pgHba.length },
pgHbaRemoteTransport: "hostssl",
roles: pg.objects.roles.map((role, index) => ({
name: role.name,
action: (facts === null ? false : roleExistsByName[role.name] ?? (index === 0 ? facts.postgres.roleExists : false)) ? "none" : "create-or-update",
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((database, index) => ({
name: database.name,
owner: database.owner,
action: (facts === null ? false : databaseExistsByName[database.name] ?? (index === 0 ? facts.postgres.databaseExists : false)) ? "none" : "create",
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),
@@ -1188,29 +1222,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" };
}
@@ -1237,6 +1276,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,
@@ -1247,25 +1288,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 roleSqlList = pg.objects.roles.map((item) => `'${item.name.replace(/'/gu, "''")}'`).join(",");
const databaseSqlList = pg.objects.databases.map((item) => `'${item.name.replace(/'/gu, "''")}'`).join(",");
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
@@ -1305,16 +1355,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 (${roleSqlList})" >"$tmp/rolesExisting" 2>/dev/null
$root_prefix runuser -u postgres -- psql -Atqc "SELECT datname FROM pg_database WHERE datname IN (${databaseSqlList})" >"$tmp/databasesExisting" 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
@@ -1332,12 +1379,43 @@ def int_or_none(name):
return None
def yes_file(name, expected="0"):
return text(name) == expected
def set_lines(name):
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 = ${JSON.stringify(pg.objects.roles.map((item) => item.name))}
expected_databases = ${JSON.stringify(pg.objects.databases.map((item) => item.name))}
existing_roles = set_lines("rolesExisting")
existing_databases = set_lines("databasesExisting")
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,
@@ -1377,18 +1455,19 @@ payload = {
"dataDirExists": yes_file("dataDirRc"),
"configDirExists": yes_file("configDirRc"),
"logDirExists": yes_file("logDirRc"),
"roleExists": text("roleExists") == "1",
"databaseExists": text("databaseExists") == "1",
"roleExistsByName": {name: name in existing_roles for name in expected_roles},
"databaseExistsByName": {name: name in existing_databases for name in expected_databases},
"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))
@@ -1416,18 +1495,16 @@ 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 roles = pg.objects.roles.map((item) => {
const material = secrets.materials.get(item.passwordRef.sourceRef);
if (material === undefined) throw new Error(`missing material for ${item.passwordRef.sourceRef}`);
const password = material.values[item.passwordRef.key];
if (password === undefined || password.length === 0) throw new Error(`missing ${item.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: item.name,
name: role.name,
password,
login: item.login,
attributes: item.attributes,
login: role.login,
attributes: role.attributes,
};
});
return {
@@ -1442,15 +1519,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: roles[0].password,
login: role.login,
attributes: role.attributes,
},
roles,
database,
databases: pg.objects.databases,
role: roles[0],
database: pg.objects.databases[0],
backup: pg.backup.logicalDump,
hbaMarkers: { start: managedHbaStart, end: managedHbaEnd },
};
@@ -1598,7 +1670,7 @@ if name == "pg_hba":
print(f'{rule["type"]} {rule["database"]} {rule["user"]} {rule["address"]} {rule["method"]}')
print(markers["end"])
elif name == "sql":
for role in data.get("roles", [data["role"]]):
for role in data["roles"]:
name = role["name"]
password = role["password"].replace("'", "''")
attrs = []
@@ -1616,23 +1688,29 @@ elif name == "sql":
print(" END IF;")
print("END")
print("$unidesk$;")
elif name == "databases":
for db in data.get("databases", [data["database"]]):
print("\t".join([db["name"], db["owner"], db["encoding"], db["locale"]]))
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
}
@@ -1655,10 +1733,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)"
@@ -1748,13 +1822,13 @@ sql="$job_dir/role.sql"
render_file sql > "$sql"
runuser -u postgres -- psql -v ON_ERROR_STOP=1 < "$sql"
write_state running create-database
write_state running create-databases
databases_tsv="$job_dir/databases.tsv"
render_file databases > "$databases_tsv"
while IFS=' ' 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"
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"
@@ -1789,16 +1863,11 @@ fi
write_state running final-check
runuser -u postgres -- psql -Atqc "SHOW server_version;" >/dev/null
python3 - "$payload" <<'PY' > "$job_dir/final-check.sql"
import json, sys
data=json.load(open(sys.argv[1], encoding="utf-8"))
for role in data.get("roles", [data["role"]]):
print("SELECT 'role', %r, EXISTS (SELECT 1 FROM pg_roles WHERE rolname = %r);" % (role["name"], role["name"]))
for db in data.get("databases", [data["database"]]):
print("SELECT 'database', %r, EXISTS (SELECT 1 FROM pg_database WHERE datname = %r);" % (db["name"], db["name"]))
PY
chmod 0644 "$job_dir/final-check.sql"
runuser -u postgres -- psql -Atq < "$job_dir/final-check.sql" | awk -F'|' '{ if ($3 != "t") exit 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
@@ -1897,6 +1966,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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,372 @@
import { createHash } from "node:crypto";
import { Buffer } from "node:buffer";
import type { UniDeskConfig } from "./config";
import { runSshCommandCapture, type SshCaptureResult } from "./ssh";
export interface PublicServiceExposure {
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;
};
}
export interface PublicServiceTarget {
id: string;
route: string;
namespace: string;
replicas: number;
publicExposure: PublicServiceExposure;
}
export interface FrpcSecretMaterial {
sourceRef: string;
sourcePath: string;
secretName: string;
secretKey: string;
frpcToml: string;
fingerprint: string;
valuesPrinted: false;
}
export async function capture(config: UniDeskConfig, route: string, args: string[], stdin: string): Promise<SshCaptureResult> {
return await runSshCommandCapture(config, route, args, stdin);
}
export async function applyPk01CaddyBlock(
config: UniDeskConfig,
serviceId: string,
exposure: PublicServiceExposure,
markers: { start: string; end: string },
): Promise<Record<string, unknown>> {
if (!exposure.enabled) return { ok: true, action: "not-enabled" };
const block = `${markers.start}
${exposure.dns.hostname} {
reverse_proxy 127.0.0.1:${exposure.frpc.remotePort} {
transport http {
response_header_timeout ${exposure.pk01.responseHeaderTimeoutSeconds}s
}
}
}
${markers.end}
`;
const blockB64 = Buffer.from(block, "utf8").toString("base64");
const script = `
set -u
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
block="$tmp/${serviceId}.caddy"
printf '%s' '${blockB64}' | base64 -d >"$block"
sudo python3 - ${shQuote(exposure.pk01.caddyConfigPath)} "$block" ${shQuote(markers.start)} ${shQuote(markers.end)} >"$tmp/update.out" 2>"$tmp/update.err" <<'PY'
import pathlib
import sys
config_path = pathlib.Path(sys.argv[1])
block_path = pathlib.Path(sys.argv[2])
start = sys.argv[3]
end = sys.argv[4]
text = config_path.read_text(encoding="utf-8") if config_path.exists() else ""
block = block_path.read_text(encoding="utf-8").strip() + "\\n"
if start in text and end in text:
before = text.split(start, 1)[0].rstrip()
tail = text.split(end, 1)[1].lstrip()
next_text = before + "\\n\\n" + block + "\\n" + tail
action = "update"
else:
next_text = text.rstrip() + "\\n\\n" + block
action = "append"
tmp = config_path.with_suffix(config_path.suffix + ".unidesk-${serviceId}.tmp")
tmp.write_text(next_text, encoding="utf-8")
tmp.replace(config_path)
print(action)
PY
update_rc=$?
if [ "$update_rc" -eq 0 ]; then
sudo caddy fmt --overwrite ${shQuote(exposure.pk01.caddyConfigPath)} >"$tmp/fmt.out" 2>"$tmp/fmt.err"
fmt_rc=$?
else
: >"$tmp/fmt.out"; : >"$tmp/fmt.err"; fmt_rc=1
fi
if [ "$fmt_rc" -eq 0 ]; then
sudo caddy validate --config ${shQuote(exposure.pk01.caddyConfigPath)} >"$tmp/validate.out" 2>"$tmp/validate.err"
validate_rc=$?
else
: >"$tmp/validate.out"; : >"$tmp/validate.err"; validate_rc=1
fi
if [ "$validate_rc" -eq 0 ]; then
sudo systemctl reload ${shQuote(exposure.pk01.caddyServiceName)} >"$tmp/reload.out" 2>"$tmp/reload.err" || sudo systemctl restart ${shQuote(exposure.pk01.caddyServiceName)} >>"$tmp/reload.out" 2>>"$tmp/reload.err"
reload_rc=$?
else
: >"$tmp/reload.out"; : >"$tmp/reload.err"; reload_rc=1
fi
python3 - "$update_rc" "$fmt_rc" "$validate_rc" "$reload_rc" "$tmp/update.out" "$tmp/update.err" "$tmp/fmt.out" "$tmp/fmt.err" "$tmp/validate.out" "$tmp/validate.err" "$tmp/reload.out" "$tmp/reload.err" <<'PY'
import json, sys
rcs = [int(value) for value in sys.argv[1:5]]
def text(path):
try:
return open(path, encoding="utf-8", errors="replace").read()[-3000:]
except FileNotFoundError:
return ""
payload = {
"ok": all(rc == 0 for rc in rcs),
"serviceId": "${serviceId}",
"hostname": "${exposure.dns.hostname}",
"remotePort": ${exposure.frpc.remotePort},
"caddyConfigPath": "${exposure.pk01.caddyConfigPath}",
"service": "${exposure.pk01.caddyServiceName}",
"managedBlock": {"start": "${markers.start}", "end": "${markers.end}"},
"steps": {
"update": {"exitCode": rcs[0], "stdout": text(sys.argv[5]), "stderr": text(sys.argv[6])},
"fmt": {"exitCode": rcs[1], "stdout": text(sys.argv[7]), "stderr": text(sys.argv[8])},
"validate": {"exitCode": rcs[2], "stdout": text(sys.argv[9]), "stderr": text(sys.argv[10])},
"reload": {"exitCode": rcs[3], "stdout": text(sys.argv[11]), "stderr": text(sys.argv[12])},
},
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
sys.exit(0 if payload["ok"] else 1)
PY
`;
const result = await capture(config, exposure.pk01.route, ["script"], script);
const parsed = parseJsonOutput(result.stdout);
return parsed ?? { ok: false, capture: compactCapture(result, { full: true }) };
}
export function prepareFrpcSecret(params: {
secretRoot: string;
exposure: PublicServiceExposure;
sourcePathRedactor: (path: string) => string;
parseEnvFile: (text: string) => Record<string, string>;
requiredEnvValue: (values: Record<string, string>, key: string, sourceRef: string) => string;
readTextFile: (path: string) => string;
}): FrpcSecretMaterial {
const { exposure } = params;
const sourcePath = `${params.secretRoot.replace(/\/+$/u, "")}/${exposure.frpc.tokenSourceRef}`;
const values = params.parseEnvFile(params.readTextFile(sourcePath));
const token = params.requiredEnvValue(values, exposure.frpc.tokenSourceKey, exposure.frpc.tokenSourceRef);
const frpcToml = [
`serverAddr = "${exposure.frpc.serverAddr}"`,
`serverPort = ${exposure.frpc.serverPort}`,
"loginFailExit = true",
`auth.token = "${escapeTomlString(token)}"`,
"",
"[[proxies]]",
`name = "${exposure.frpc.proxyName}"`,
'type = "tcp"',
`localIP = "${exposure.frpc.localIP}"`,
`localPort = ${exposure.frpc.localPort}`,
`remotePort = ${exposure.frpc.remotePort}`,
"",
].join("\n");
return {
sourceRef: exposure.frpc.tokenSourceRef,
sourcePath: params.sourcePathRedactor(sourcePath),
secretName: exposure.frpc.secretName,
secretKey: exposure.frpc.secretKey,
frpcToml,
fingerprint: fingerprintValues({ token, frpcToml }, ["token", "frpcToml"]),
valuesPrinted: false,
};
}
export function renderFrpcManifest(target: PublicServiceTarget): string {
const exposure = target.publicExposure;
if (!exposure.enabled) return "";
return `---
apiVersion: apps/v1
kind: Deployment
metadata:
name: ${exposure.frpc.deploymentName}
namespace: ${target.namespace}
labels:
app.kubernetes.io/name: ${exposure.frpc.deploymentName}
app.kubernetes.io/component: tunnel
app.kubernetes.io/part-of: platform-infra
app.kubernetes.io/managed-by: unidesk
unidesk.ai/runtime-node: ${target.id}
unidesk.ai/public-hostname: ${exposure.dns.hostname}
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: ${exposure.frpc.deploymentName}
app.kubernetes.io/component: tunnel
template:
metadata:
labels:
app.kubernetes.io/name: ${exposure.frpc.deploymentName}
app.kubernetes.io/component: tunnel
app.kubernetes.io/part-of: platform-infra
annotations:
unidesk.ai/public-base-url: "${exposure.publicBaseUrl}"
unidesk.ai/frp-server: "${exposure.frpc.serverAddr}:${exposure.frpc.serverPort}"
unidesk.ai/frp-remote-port: "${exposure.frpc.remotePort}"
spec:
containers:
- name: frpc
image: ${exposure.frpc.image}
imagePullPolicy: IfNotPresent
args:
- -c
- /etc/frp/frpc.toml
volumeMounts:
- name: frpc-config
mountPath: /etc/frp/frpc.toml
subPath: ${exposure.frpc.secretKey}
readOnly: true
volumes:
- name: frpc-config
secret:
secretName: ${exposure.frpc.secretName}
`;
}
export function publicServicePolicyChecks(yaml: string, target: PublicServiceTarget, serviceName: string): Array<Record<string, unknown>> {
return [
{ name: "no-ingress", ok: !/^\s*kind:\s*Ingress\s*$/mu.test(yaml), detail: `${serviceName} public exposure must use PK01 Caddy+FRP, not Kubernetes Ingress.` },
{ name: "no-nodeport-or-loadbalancer", ok: !/^\s*type:\s*(NodePort|LoadBalancer)\s*$/mu.test(yaml), detail: "Services must stay ClusterIP." },
{ name: "no-host-network", ok: !/^\s*hostNetwork:\s*true\s*$/mu.test(yaml), detail: "Pods must not use host network." },
{ name: "no-host-port", ok: !/^\s*hostPort:\s*[0-9]+\s*$/mu.test(yaml), detail: "Pods must not expose host ports." },
{ name: "no-cpu-memory-resources", ok: !/^\s*(cpu|memory):\s*/mu.test(yaml), detail: "No CPU/memory request or limit objects are rendered." },
{ name: "allow-all-network-policy", ok: hasAllowAllNetworkPolicy(yaml, target.namespace), detail: `NetworkPolicy/allow-all exists in ${target.namespace}.` },
];
}
export function dryRunManifestScript(params: { yaml: string; target: PublicServiceTarget; fieldManager: string; manifestName: string }): string {
const encoded = Buffer.from(params.yaml, "utf8").toString("base64");
return `
set -u
tmp="$(mktemp -d)"
trap 'rm -rf "$tmp"' EXIT
manifest="$tmp/${params.manifestName}.k8s.yaml"
printf '%s' '${encoded}' | base64 -d > "$manifest"
kubectl apply --dry-run=client -f "$manifest" >"$tmp/client.out" 2>"$tmp/client.err"
client_rc=$?
if kubectl get namespace ${params.target.namespace} >/dev/null 2>&1; then
kubectl apply --server-side --dry-run=server --field-manager=${params.fieldManager} -f "$manifest" >"$tmp/server.out" 2>"$tmp/server.err"
server_rc=$?
server_disposition=executed
else
: >"$tmp/server.err"
printf '%s\\n' 'server dry-run skipped because namespace does not exist yet' >"$tmp/server.out"
server_rc=0
server_disposition=skipped-namespace-missing
fi
python3 - "$client_rc" "$server_rc" "$server_disposition" "$tmp/client.out" "$tmp/client.err" "$tmp/server.out" "$tmp/server.err" <<'PY'
import json, sys
client_rc, server_rc = int(sys.argv[1]), int(sys.argv[2])
def text(path):
try:
return open(path, encoding="utf-8", errors="replace").read()
except FileNotFoundError:
return ""
payload = {
"ok": client_rc == 0 and server_rc == 0,
"target": "${params.target.id}",
"namespace": "${params.target.namespace}",
"clientDryRun": {"exitCode": client_rc, "stdout": text(sys.argv[4])[-4000:], "stderr": text(sys.argv[5])[-4000:]},
"serverDryRun": {"exitCode": server_rc, "disposition": sys.argv[3], "stdout": text(sys.argv[6])[-4000:], "stderr": text(sys.argv[7])[-4000:]},
}
print(json.dumps(payload, ensure_ascii=False, indent=2))
sys.exit(0 if payload["ok"] else 1)
PY
`;
}
export function parseJsonOutput(stdout: string): Record<string, unknown> | null {
const trimmed = stdout.trim();
if (trimmed.length === 0) return null;
const start = trimmed.indexOf("{");
const end = trimmed.lastIndexOf("}");
if (start === -1 || end === -1 || end <= start) return null;
try {
const parsed = JSON.parse(trimmed.slice(start, end + 1)) as unknown;
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
} catch {
return null;
}
}
export function compactCapture(result: SshCaptureResult, options: { full?: boolean } = {}): Record<string, unknown> {
const full = options.full ?? false;
return {
exitCode: result.exitCode,
stdoutBytes: Buffer.byteLength(result.stdout, "utf8"),
stderrBytes: Buffer.byteLength(result.stderr, "utf8"),
stdoutTail: full || result.exitCode !== 0 ? redactText(result.stdout).slice(-8000) : "",
stderrTail: full || result.exitCode !== 0 ? redactText(result.stderr).slice(-4000) : "",
};
}
export function publicHttpProbe(baseUrl: string, path: string): Record<string, unknown> {
const url = `${baseUrl.replace(/\/+$/u, "")}${path}`;
const result = Bun.spawnSync(["curl", "-fsS", "--connect-timeout", "10", "--max-time", "30", "-o", "-", "-w", "\n%{http_code}", url], { stdout: "pipe", stderr: "pipe" });
const stdout = new TextDecoder().decode(result.stdout);
const stderr = new TextDecoder().decode(result.stderr);
const lines = stdout.split(/\r?\n/u);
const statusText = lines.pop() ?? "";
const status = Number(statusText);
const body = lines.join("\n");
return {
ok: result.exitCode === 0 && Number.isInteger(status) && status >= 200 && status < 500,
url,
status: Number.isInteger(status) ? status : null,
bodyBytes: Buffer.byteLength(body, "utf8"),
bodyPreview: body.slice(0, 2000),
stderrTail: redactText(stderr).slice(-2000),
valuesPrinted: false,
};
}
export function redactText(text: string): string {
return text
.replace(/postgres(?:ql)?:\/\/[^@\s]+@/giu, "postgresql://<redacted>@")
.replace(/(N8N_ENCRYPTION_KEY|PASSWORD|SECRET|TOKEN|API_KEY|DATABASE_URL)([=:]\s*)[^\s,;]+/giu, "$1$2<redacted>");
}
export function fingerprintValues(values: Record<string, string>, keys: string[]): string {
const hash = createHash("sha256");
for (const key of keys.slice().sort()) {
hash.update(key);
hash.update("\0");
hash.update(values[key] ?? "");
hash.update("\0");
}
return `sha256:${hash.digest("hex")}`;
}
export function shQuote(value: string): string {
return `'${value.replaceAll("'", "'\"'\"'")}'`;
}
export function escapeTomlString(value: string): string {
return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"");
}
function hasAllowAllNetworkPolicy(yaml: string, namespaceName: string): boolean {
return yaml.split(/^---\s*$/mu).some((document) => /^\s*kind:\s*NetworkPolicy\s*$/mu.test(document)
&& /^\s*name:\s*allow-all\s*$/mu.test(document)
&& new RegExp(`^\\s*namespace:\\s*${escapeRegExp(namespaceName)}\\s*$`, "mu").test(document)
&& /^\s*podSelector:\s*\{\}\s*$/mu.test(document));
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
}
@@ -1346,13 +1346,25 @@ def gateway_monitor_paths(config):
return {"/responses", "/v1/responses", "/responses/compact", "/v1/responses/compact"}
def gateway_failure_kind(message, payload, config):
if "openai.forward_failed" not in message or not isinstance(payload, dict):
if not isinstance(payload, dict):
return None
path = payload.get("path")
if path not in gateway_monitor_paths(config):
return None
if payload.get("account_id") is None:
return None
if "codex.remote_compact.failed" in message:
status = payload.get("status_code")
if isinstance(status, int) and status >= 500:
return "gateway-compact-final-failure"
return None
if "openai.upstream_failover_switching" in message and path in ("/responses/compact", "/v1/responses/compact"):
upstream_status = payload.get("upstream_status")
if isinstance(upstream_status, int) and upstream_status >= 500:
return "gateway-compact-upstream-failover"
return None
if "openai.forward_failed" not in message:
return None
error_text = str(payload.get("error") or "").lower()
fallback_written = payload.get("fallback_error_response_written") is True
upstream_already_written = payload.get("upstream_error_response_already_written") is True
@@ -1404,7 +1416,7 @@ def gateway_failure_kind(message, payload, config):
return None
def gateway_failure_is_observe_only(failure_kind):
return failure_kind in {"gateway-session-affinity-failure"}
return failure_kind in {"gateway-session-affinity-failure", "gateway-compact-final-failure", "gateway-compact-upstream-failover"}
def gateway_failure_item(ts, pod_name, payload, failure_kind):
request_id = payload.get("request_id") or sha(json.dumps(payload, sort_keys=True, ensure_ascii=False))
@@ -202,6 +202,8 @@ interface CodexPoolLocalCodexConfig {
backupSuffix: string;
providerName: string;
wireApi: string;
modelContextWindow: number;
modelAutoCompactTokenLimit: number;
supportsWebSockets: boolean;
responsesWebSocketsV2: boolean;
responsesSmokeModel: string;
@@ -211,6 +213,8 @@ interface CodexLocalConsumerTomlOptions {
providerName: string;
baseUrl: string;
wireApi: string;
modelContextWindow: number;
modelAutoCompactTokenLimit: number;
supportsWebSockets: boolean;
responsesWebSocketsV2: boolean;
}
@@ -1009,6 +1013,8 @@ async function codexPoolConfigureLocal(config: UniDeskConfig, options: ConfirmOp
baseUrl: codexConsumerBaseUrl(pool),
providerName: pool.localCodex.providerName,
wireApi: pool.localCodex.wireApi,
modelContextWindow: pool.localCodex.modelContextWindow,
modelAutoCompactTokenLimit: pool.localCodex.modelAutoCompactTokenLimit,
supportsWebSockets: pool.localCodex.supportsWebSockets,
responsesWebSocketsV2: pool.localCodex.responsesWebSocketsV2,
responsesSmokeModel: pool.localCodex.responsesSmokeModel,
@@ -1274,6 +1280,8 @@ function defaultCodexPoolConfig(): CodexPoolConfig {
backupSuffix: "pre-sub2api",
providerName: "OpenAI",
wireApi: "responses",
modelContextWindow: 272000,
modelAutoCompactTokenLimit: 240000,
supportsWebSockets: true,
responsesWebSocketsV2: true,
responsesSmokeModel: "gpt-5.5",
@@ -1444,6 +1452,11 @@ function readPositiveInteger(value: unknown, key: string): number {
return parsed;
}
function readPositiveIntegerConfig(value: unknown, key: string, fallback: number): number {
if (value === undefined || value === null) return fallback;
return readPositiveInteger(value, key);
}
function readCaddyRetryMethods(value: unknown, key: string): string[] {
if (value === undefined || value === null) return [];
if (!Array.isArray(value)) throw new Error(`${codexPoolConfigPath}.${key} must be a YAML array`);
@@ -1619,6 +1632,8 @@ function readLocalCodexConfig(value: unknown, defaults: CodexPoolLocalCodexConfi
backupSuffix: stringValue(value.backupSuffix) ?? defaults.backupSuffix,
providerName: stringValue(value.providerName) ?? defaults.providerName,
wireApi: stringValue(value.wireApi) ?? defaults.wireApi,
modelContextWindow: readPositiveIntegerConfig(value.modelContextWindow, "localCodex.modelContextWindow", defaults.modelContextWindow),
modelAutoCompactTokenLimit: readPositiveIntegerConfig(value.modelAutoCompactTokenLimit, "localCodex.modelAutoCompactTokenLimit", defaults.modelAutoCompactTokenLimit),
supportsWebSockets: readBooleanConfig(value.supportsWebSockets, "localCodex.supportsWebSockets", defaults.supportsWebSockets),
responsesWebSocketsV2: readBooleanConfig(value.responsesWebSocketsV2, "localCodex.responsesWebSocketsV2", defaults.responsesWebSocketsV2),
responsesSmokeModel: stringValue(value.responsesSmokeModel) ?? defaults.responsesSmokeModel,
@@ -3097,6 +3112,8 @@ function writeLocalCodexConfig(pool: CodexPoolConfig, apiKey: string): Record<st
name: pool.localCodex.providerName,
baseUrl: codexConsumerBaseUrl(pool),
wireApi: pool.localCodex.wireApi,
modelContextWindow: pool.localCodex.modelContextWindow,
modelAutoCompactTokenLimit: pool.localCodex.modelAutoCompactTokenLimit,
supportsWebSockets: pool.localCodex.supportsWebSockets,
responsesWebSocketsV2: pool.localCodex.responsesWebSocketsV2,
},
@@ -3116,6 +3133,8 @@ function updateCodexConfigToml(current: string, pool: CodexPoolConfig): string {
providerName: pool.localCodex.providerName,
baseUrl: codexConsumerBaseUrl(pool),
wireApi: pool.localCodex.wireApi,
modelContextWindow: pool.localCodex.modelContextWindow,
modelAutoCompactTokenLimit: pool.localCodex.modelAutoCompactTokenLimit,
supportsWebSockets: pool.localCodex.supportsWebSockets,
responsesWebSocketsV2: pool.localCodex.responsesWebSocketsV2,
});
@@ -3127,6 +3146,8 @@ function codexConsumerBaseUrl(pool: CodexPoolConfig): string {
export function renderCodexLocalConsumerToml(current: string, options: CodexLocalConsumerTomlOptions): string {
let next = upsertTopLevelTomlString(current, "model_provider", options.providerName);
next = upsertTopLevelTomlInteger(next, "model_context_window", options.modelContextWindow);
next = upsertTopLevelTomlInteger(next, "model_auto_compact_token_limit", options.modelAutoCompactTokenLimit);
next = upsertProviderSection(next, options);
next = upsertTomlSectionBoolean(next, "features", "responses_websockets_v2", options.responsesWebSocketsV2);
return next.endsWith("\n") ? next : `${next}\n`;
@@ -3146,6 +3167,21 @@ function upsertTopLevelTomlString(text: string, key: string, value: string): str
return lines.join("\n");
}
function upsertTopLevelTomlInteger(text: string, key: string, value: number): string {
const lines = text.split(/\r?\n/u);
const firstSection = lines.findIndex((line) => /^\s*\[/.test(line));
const end = firstSection === -1 ? lines.length : firstSection;
const assignment = `${key} = ${value}`;
for (let index = 0; index < end; index += 1) {
if (new RegExp(`^\\s*${escapeRegExp(key)}\\s*=`).test(lines[index])) {
lines[index] = assignment;
return lines.join("\n");
}
}
lines.splice(end, 0, assignment);
return lines.join("\n");
}
function upsertProviderSection(text: string, options: CodexLocalConsumerTomlOptions): string {
const { providerName, baseUrl, wireApi, supportsWebSockets } = options;
const header = `[model_providers.${providerName}]`;
+21 -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|n8n ...",
output: "json",
usage: [
"bun scripts/cli.ts platform-infra sub2api plan [--target G14|D601]",
@@ -205,8 +205,19 @@ 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",
"bun scripts/cli.ts platform-infra n8n plan [--target G14]",
"bun scripts/cli.ts platform-infra n8n apply [--target G14] --confirm",
"bun scripts/cli.ts platform-infra n8n status [--target G14] [--full|--raw]",
"bun scripts/cli.ts platform-infra n8n logs [--target G14] [--component app|frpc|all]",
"bun scripts/cli.ts platform-infra n8n validate [--target G14] [--full|--raw]",
],
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, LangBot and n8n. Public services use PK01 Caddy+FRP rather than Kubernetes Ingress, NodePort, or LoadBalancer.",
target: {
default: defaultTargetId,
namespace,
@@ -231,6 +242,14 @@ 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 === "n8n") {
const { runN8nCommand } = await import("./platform-infra-n8n");
return await runN8nCommand(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)));
+6 -2
View File
@@ -164,11 +164,15 @@ function systemStatusSignal(debug: unknown, providerId: string): ProviderTriageS
if (item === null) return signal("backend-core-system-status", "provider-gateway", "unknown", `no system status sample for ${providerId}`);
const current = asRecord(item.current);
const currentOk = current === null ? null : current.ok;
const status: ProviderSignalStatus = current === null ? "unknown" : currentOk === false ? "degraded" : "ok";
return signal("backend-core-system-status", "provider-gateway", status, `system status current.ok=${String(currentOk)} updatedAt=${item.updatedAt ?? "null"}`, {
const stale = item.stale === true;
const status: ProviderSignalStatus = current === null ? stale ? "degraded" : "unknown" : currentOk === false ? "degraded" : "ok";
return signal("backend-core-system-status", "provider-gateway", status, `system status current.ok=${String(currentOk)} stale=${String(stale)} updatedAt=${item.updatedAt ?? "null"}`, {
providerId: item.providerId,
nodeStatus: item.nodeStatus,
updatedAt: item.updatedAt,
currentCollectedAt: item.currentCollectedAt ?? null,
stale,
staleSeconds: item.staleSeconds ?? null,
current: current === null ? null : {
ok: current.ok,
collectedAt: current.collectedAt,