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:
@@ -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
@@ -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
@@ -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}]`;
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user