Files
pikasTech-unidesk/scripts/src/platform-db.ts
T
Codex c5c1ff7f58 chore: sync hwlab node control-plane state
Capture D601 v03 control-plane YAML/CLI changes, update CI/CD skill notes, and document the hwlab.pikapython.com admin password reset boundary with follow-up issue #319.
2026-06-13 02:52:09 +00:00

1997 lines
83 KiB
TypeScript

import { createHash, randomBytes } from "node:crypto";
import { spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, isAbsolute, join } from "node:path";
import type { UniDeskConfig } from "./config";
import { repoRoot, rootPath } from "./config";
import { startJob } from "./jobs";
import { runSshCommandCapture, type SshCaptureResult } from "./ssh";
const defaultConfigPath = "config/platform-db/postgres-pk01.yaml";
const managedHbaStart = "# BEGIN unidesk managed pk01-platform-postgres";
const managedHbaEnd = "# END unidesk managed pk01-platform-postgres";
interface PlatformDbOptions {
configPath: string;
confirm: boolean;
wait: boolean;
dryRun: boolean;
full: boolean;
raw: boolean;
}
interface PostgresHostConfig {
configPath: string;
version: number;
kind: "postgres-host-cluster";
metadata: {
id: string;
relatedIssues: number[];
};
cluster: {
role: string;
haPhase: string;
};
node: {
id: string;
route: string;
mode: "host-systemd";
requiredHostFacts: {
minCpu: number;
minMemoryGiB: number;
minDiskFreeGiB: number;
requireSwap: boolean;
};
swap: {
ensure: boolean;
size: string;
path: string;
};
};
postgres: {
package: {
manager: "apt";
version: string;
repo: string;
repoUrl: string;
suite: string;
component: string;
signingKeyUrl: string;
signedBy: string;
sourceList: string;
};
service: {
name: string;
enabled: boolean;
state: string;
};
paths: {
dataDir: string;
configDir: string;
logDir: string;
};
network: {
port: number;
listenAddresses: string[];
connectionHost: string;
publicDns: string;
transport: "postgres-native-tls";
sslmode: "require";
tls: {
enabled: boolean;
mode: string;
commonName: string;
certFile: string;
keyFile: string;
futureClientSslmode: string;
};
firewall: {
mode: string;
defaultDeny: boolean;
allowSources: Array<{ id: string; cidr: string; purpose: string }>;
};
};
tuning: {
maxConnections: number;
sharedBuffers: string;
effectiveCacheSize: string;
workMem: string;
maintenanceWorkMem: string;
walCompression: boolean;
checkpointCompletionTarget: number;
};
auth: {
passwordEncryption: "scram-sha-256";
pgHba: PgHbaRule[];
};
};
secrets: {
source: "master-local";
root: string;
entries: SecretEntryConfig[];
};
objects: {
roles: Array<{
name: string;
passwordRef: { sourceRef: string; key: string };
login: boolean;
attributes: { createdb: boolean; createrole: boolean; superuser: boolean };
}>;
databases: Array<{
name: string;
owner: string;
encoding: string;
locale: string;
extensions: string[];
}>;
};
exports: {
connectionStrings: ConnectionStringExportConfig[];
};
backup: {
logicalDump: {
enabled: boolean;
schedule: string;
database: string;
retentionDays: number;
destination: { type: string; path: string };
encryption: { enabled: boolean };
};
};
}
interface PgHbaRule {
type: "local" | "host" | "hostssl";
database: string;
user: string;
address?: string;
method: string;
}
interface SecretEntryConfig {
name: string;
sourceRef: string;
type: "env";
requiredKeys: string[];
createIfMissing?: {
enabled: boolean;
values?: Record<string, string>;
randomHex?: Record<string, number>;
};
}
interface ConnectionStringExportConfig {
name: string;
sourceSecretRef: string;
render: {
envKey: string;
format: string;
variables?: Record<string, string>;
};
writeToSecretSource: {
sourceRef: string;
key: string;
mode: "update-or-insert";
};
consumers: Array<{ scope: string; secret: string; key: string }>;
}
interface SecretMaterial {
values: Record<string, string>;
sourcePath: string;
existedBefore: boolean;
missingBefore: string[];
action: "none" | "create" | "update" | "blocked";
fingerprint: string | null;
}
interface SecretInspection {
ok: boolean;
root: string;
entries: Array<{
name: string;
sourceRef: string;
sourcePath: string;
exists: boolean;
requiredKeys: string[];
presentKeys: string[];
missingKeys: string[];
action: string;
fingerprint: string | null;
}>;
materials: Map<string, SecretMaterial>;
}
interface RemoteFacts {
ok: boolean;
observedAt: string | null;
host: {
hostname: string | null;
osPrettyName: string | null;
osCodename: string | null;
arch: string | null;
cpuCount: number | null;
memoryTotalMiB: number | null;
memoryAvailableMiB: number | null;
swapTotalMiB: number | null;
rootAvailGiB: number | null;
targetDataAvailGiB: number | null;
};
network: {
port5432Listening: boolean;
port5432Line: string | null;
dns: {
hostname: string;
resolves: boolean;
addresses: string[];
};
};
repo: {
releaseUrl: string;
reachable: boolean;
firstLine: string | null;
};
postgres: {
psqlVersion: string | null;
postgresVersion: string | null;
packageInstalled: boolean;
serviceActive: boolean;
serviceEnabled: boolean;
dataDirExists: boolean;
configDirExists: boolean;
logDirExists: boolean;
roleExists: boolean;
databaseExists: boolean;
roles: Array<{ name: string; exists: boolean }>;
databases: Array<{ name: string; owner: string; exists: boolean }>;
serverVersion: string | null;
sslOn: boolean;
sslCertFile: string | null;
sslKeyFile: string | null;
appConnectionOk: boolean | null;
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>;
}
interface ControllerConnectionProbe {
attempted: boolean;
ok: boolean | null;
ssl: boolean | null;
user: string | null;
database: string | null;
host: string;
port: number;
clientAddr: string | null;
serverAddr: string | null;
psqlVersion: string | null;
error: string | null;
}
interface RemoteJobStart {
ok: boolean;
remoteJobId: string | null;
statePath: string | null;
logPath: string | null;
parsed: Record<string, unknown> | null;
capture: Record<string, unknown>;
}
export function platformDbHelp(): unknown {
return {
command: "platform-db postgres plan|status|apply",
output: "json",
usage: [
`bun scripts/cli.ts platform-db postgres plan --config ${defaultConfigPath}`,
`bun scripts/cli.ts platform-db postgres status --config ${defaultConfigPath} [--full|--raw]`,
`bun scripts/cli.ts platform-db postgres apply --config ${defaultConfigPath} --dry-run`,
`bun scripts/cli.ts platform-db postgres apply --config ${defaultConfigPath} --confirm`,
`bun scripts/cli.ts platform-db postgres apply --config ${defaultConfigPath} --confirm --wait`,
],
description: "Manage YAML-declared host-native PostgreSQL for UniDesk platform state. The PK01 declaration uses PostgreSQL 16 for Sub2API and does not deploy k3s.",
safety: {
configTruth: defaultConfigPath,
defaultMutation: false,
apply: "apply --confirm returns an async UniDesk job; the job uses short trans calls and a remote PK01 job instead of keeping one long SSH session open.",
redaction: "Passwords and full DATABASE_URL values are never printed; output is limited to key names, presence, fingerprints and state.",
noK3s: "PK01 PostgreSQL is host-systemd, not Docker or k3s.",
},
};
}
export async function runPlatformDbCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown>> {
const [target, action = "plan"] = args;
if (target !== "postgres") return unsupported(args);
const options = parseOptions(args.slice(2));
if (action === "plan") return await plan(config, options);
if (action === "status") return await status(config, options);
if (action === "apply") return await apply(config, options);
return unsupported(args);
}
function unsupported(args: string[]): Record<string, unknown> {
return {
ok: false,
error: "unsupported-platform-db-command",
args,
help: platformDbHelp(),
};
}
function parseOptions(args: string[]): PlatformDbOptions {
const options: PlatformDbOptions = {
configPath: defaultConfigPath,
confirm: false,
wait: false,
dryRun: false,
full: false,
raw: false,
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--config") {
const raw = args[index + 1];
if (raw === undefined || raw.trim().length === 0) throw new Error("--config requires a non-empty path");
options.configPath = raw;
index += 1;
} else if (arg === "--confirm") {
options.confirm = true;
} else if (arg === "--wait") {
options.wait = true;
} else if (arg === "--dry-run") {
options.dryRun = true;
} else if (arg === "--full") {
options.full = true;
} else if (arg === "--raw") {
options.raw = true;
options.full = true;
} else {
throw new Error(`unsupported platform-db option: ${arg}`);
}
}
if (options.confirm && options.dryRun) throw new Error("apply accepts only one of --confirm or --dry-run");
return options;
}
async function plan(config: UniDeskConfig, options: PlatformDbOptions): Promise<Record<string, unknown>> {
const pg = readPostgresHostConfig(options.configPath);
const secrets = inspectSecrets(pg, false);
const remote = await remoteFacts(config, pg, null);
const facts = remote.parsed;
const checks = facts === null ? [] : desiredChecks(pg, facts, secrets);
return {
ok: remote.capture.exitCode === 0 && facts !== null && secrets.ok,
action: "platform-db-postgres-plan",
mutation: false,
config: configSummary(pg),
secrets: secretSummary(secrets),
remoteFacts: facts,
desired: desiredSummary(pg, secrets, facts),
checks,
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}`,
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 } : {}),
};
}
async function status(config: UniDeskConfig, options: PlatformDbOptions): Promise<Record<string, unknown>> {
const pg = readPostgresHostConfig(options.configPath);
const secrets = inspectSecrets(pg, false);
const remote = await remoteFacts(config, pg, secrets);
const facts = remote.parsed;
const controllerConnection = controllerConnectionProbe(pg, secrets);
const endpointHealthy = controllerConnection.ok === true && controllerConnection.ssl === true;
const rolesHealthy = facts !== null && pg.objects.roles.every((role) => facts.postgres.roles.some((item) => item.name === role.name && item.exists));
const databasesHealthy = facts !== null && pg.objects.databases.every((database) => facts.postgres.databases.some((item) => item.name === database.name && item.exists));
const appConnectionsHealthy = facts !== null
&& appProbes(pg, secrets).every((probe) => facts.postgres.appConnections.some((item) => item.user === probe.user && item.database === probe.database && item.ok === true && item.ssl === true));
const deploymentHealthy = facts !== null
&& facts.postgres.packageInstalled
&& facts.postgres.serviceActive
&& facts.postgres.sslOn
&& rolesHealthy
&& databasesHealthy
&& facts.network.port5432Listening
&& appConnectionsHealthy;
const cutoverReady = deploymentHealthy && secrets.ok && endpointHealthy;
const cutoverBlockers = cutoverReady
? []
: [
...(deploymentHealthy ? [] : ["deployment-unhealthy"]),
...(secrets.ok ? [] : ["secrets-unhealthy"]),
...(endpointHealthy ? [] : [controllerConnection.attempted ? "connection-host-probe-failed" : "connection-host-probe-unavailable"]),
];
return {
ok: remote.capture.exitCode === 0 && facts !== null && deploymentHealthy && secrets.ok,
action: "platform-db-postgres-status",
mutation: false,
config: configSummary(pg),
summary: facts === null ? null : {
healthy: deploymentHealthy,
deploymentHealthy,
cutoverReady,
cutoverBlockers,
connectionHost: pg.postgres.network.connectionHost,
publicDns: pg.postgres.network.publicDns,
pgVersion: pg.postgres.package.version,
packageInstalled: facts.postgres.packageInstalled,
serviceActive: facts.postgres.serviceActive,
serviceEnabled: facts.postgres.serviceEnabled,
sslOn: facts.postgres.sslOn,
dnsResolves: facts.network.dns.resolves,
dnsAddresses: facts.network.dns.addresses,
roleExists: facts.postgres.roleExists,
databaseExists: facts.postgres.databaseExists,
roles: facts.postgres.roles,
databases: facts.postgres.databases,
appConnectionOk: facts.postgres.appConnectionOk,
appConnectionSsl: facts.postgres.appConnectionSsl,
appConnectionHost: facts.postgres.appConnectionHost,
appConnectionError: facts.postgres.appConnectionError,
appConnections: facts.postgres.appConnections,
connectionHostProbe: controllerConnection,
port5432Listening: facts.network.port5432Listening,
serverVersion: facts.postgres.serverVersion,
secretsOk: secrets.ok,
dnsRequiredBeforeSub2ApiCutover: false,
dnsDisposition: facts.network.dns.resolves ? "optional-alias-resolves" : "optional-alias-unresolved",
},
remoteFacts: facts,
secrets: secretSummary(secrets),
remote: compactCapture(remote.capture, { full: options.full || remote.capture.exitCode !== 0 }),
...(options.raw ? { raw: remote.capture } : {}),
};
}
async function apply(config: UniDeskConfig, options: PlatformDbOptions): Promise<Record<string, unknown>> {
const pg = readPostgresHostConfig(options.configPath);
if (!options.confirm || options.dryRun) {
const planned = await plan(config, { ...options, dryRun: true, confirm: false });
return {
ok: planned.ok,
action: "platform-db-postgres-apply",
mode: "dry-run",
mutation: false,
plan: planned,
next: {
confirm: `bun scripts/cli.ts platform-db postgres apply --config ${pg.configPath} --confirm`,
},
};
}
if (!options.wait) {
const job = startJob(
"platform_db_postgres_apply",
["bun", "scripts/cli.ts", "platform-db", "postgres", "apply", "--config", pg.configPath, "--confirm", "--wait"],
"Apply YAML-declared PK01 host-native PostgreSQL 16 through the controlled UniDesk platform-db CLI",
);
return {
ok: true,
action: "platform-db-postgres-apply",
mode: "async-job",
mutation: true,
job,
statusCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`,
next: {
status: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`,
postgresStatus: `bun scripts/cli.ts platform-db postgres status --config ${pg.configPath}`,
},
};
}
const localState = ensureLocalSecretState(pg);
const start = await startRemoteApplyJob(config, pg, localState);
if (!start.ok || start.remoteJobId === null) {
return {
ok: false,
action: "platform-db-postgres-apply",
mode: "remote-job-start-failed",
mutation: true,
config: configSummary(pg),
localSecrets: localState.summary,
remoteJob: start,
};
}
const waited = await waitRemoteApplyJob(config, pg, start.remoteJobId);
const finalStatus = await status(config, { ...options, raw: false, full: false });
return {
ok: waited.ok && finalStatus.ok === true,
action: "platform-db-postgres-apply",
mode: "confirmed-wait",
mutation: true,
config: configSummary(pg),
localSecrets: localState.summary,
remoteJob: {
start,
waited,
},
finalStatus,
next: {
status: `bun scripts/cli.ts platform-db postgres status --config ${pg.configPath}`,
syncSecrets: "bun scripts/cli.ts secrets sync --config config/secrets-distribution.yaml --scope platform-infra --confirm",
},
};
}
function readPostgresHostConfig(pathArg: string): PostgresHostConfig {
const configPath = resolveConfigPath(pathArg);
const parsed = asRecord(Bun.YAML.parse(readFileSync(configPath, "utf8")) as unknown, configPath);
const version = numberField(parsed, "version", configPath);
const kind = stringField(parsed, "kind", configPath);
if (kind !== "postgres-host-cluster") throw new Error(`${configPath}.kind must be postgres-host-cluster`);
const metadata = objectField(parsed, "metadata", configPath);
const cluster = objectField(parsed, "cluster", configPath);
const node = objectField(parsed, "node", configPath);
const requiredHostFacts = objectField(node, "requiredHostFacts", `${configPath}.node`);
const swap = objectField(node, "swap", `${configPath}.node`);
const postgres = objectField(parsed, "postgres", configPath);
const postgresPackage = objectField(postgres, "package", `${configPath}.postgres`);
const service = objectField(postgres, "service", `${configPath}.postgres`);
const paths = objectField(postgres, "paths", `${configPath}.postgres`);
const network = objectField(postgres, "network", `${configPath}.postgres`);
const tls = objectField(network, "tls", `${configPath}.postgres.network`);
const firewall = objectField(network, "firewall", `${configPath}.postgres.network`);
const tuning = objectField(postgres, "tuning", `${configPath}.postgres`);
const auth = objectField(postgres, "auth", `${configPath}.postgres`);
const secrets = objectField(parsed, "secrets", configPath);
const objects = objectField(parsed, "objects", configPath);
const exportsConfig = objectField(parsed, "exports", configPath);
const backup = objectField(parsed, "backup", configPath);
const logicalDump = objectField(backup, "logicalDump", `${configPath}.backup`);
const destination = objectField(logicalDump, "destination", `${configPath}.backup.logicalDump`);
const encryption = objectField(logicalDump, "encryption", `${configPath}.backup.logicalDump`);
const manager = stringField(postgresPackage, "manager", `${configPath}.postgres.package`);
if (manager !== "apt") throw new Error(`${configPath}.postgres.package.manager must be apt`);
const packageVersion = stringField(postgresPackage, "version", `${configPath}.postgres.package`);
if (packageVersion !== "16") throw new Error(`${configPath}.postgres.package.version must be \"16\" for Sub2API PG16 deployment`);
const passwordEncryption = stringField(auth, "passwordEncryption", `${configPath}.postgres.auth`);
if (passwordEncryption !== "scram-sha-256") throw new Error(`${configPath}.postgres.auth.passwordEncryption must be scram-sha-256`);
const nodeMode = stringField(node, "mode", `${configPath}.node`);
if (nodeMode !== "host-systemd") throw new Error(`${configPath}.node.mode must be host-systemd`);
const secretSource = stringField(secrets, "source", `${configPath}.secrets`);
if (secretSource !== "master-local") throw new Error(`${configPath}.secrets.source must be master-local`);
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 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),
version,
kind,
metadata: {
id: stringField(metadata, "id", `${configPath}.metadata`),
relatedIssues: numberArrayField(metadata, "relatedIssues", `${configPath}.metadata`),
},
cluster: {
role: stringField(cluster, "role", `${configPath}.cluster`),
haPhase: stringField(cluster, "haPhase", `${configPath}.cluster`),
},
node: {
id: stringField(node, "id", `${configPath}.node`),
route: stringField(node, "route", `${configPath}.node`),
mode: nodeMode,
requiredHostFacts: {
minCpu: numberField(requiredHostFacts, "minCpu", `${configPath}.node.requiredHostFacts`),
minMemoryGiB: numberField(requiredHostFacts, "minMemoryGiB", `${configPath}.node.requiredHostFacts`),
minDiskFreeGiB: numberField(requiredHostFacts, "minDiskFreeGiB", `${configPath}.node.requiredHostFacts`),
requireSwap: booleanField(requiredHostFacts, "requireSwap", `${configPath}.node.requiredHostFacts`),
},
swap: {
ensure: booleanField(swap, "ensure", `${configPath}.node.swap`),
size: stringField(swap, "size", `${configPath}.node.swap`),
path: absolutePathField(swap, "path", `${configPath}.node.swap`),
},
},
postgres: {
package: {
manager,
version: packageVersion,
repo: stringField(postgresPackage, "repo", `${configPath}.postgres.package`),
repoUrl: stringField(postgresPackage, "repoUrl", `${configPath}.postgres.package`),
suite: stringField(postgresPackage, "suite", `${configPath}.postgres.package`),
component: stringField(postgresPackage, "component", `${configPath}.postgres.package`),
signingKeyUrl: stringField(postgresPackage, "signingKeyUrl", `${configPath}.postgres.package`),
signedBy: absolutePathField(postgresPackage, "signedBy", `${configPath}.postgres.package`),
sourceList: absolutePathField(postgresPackage, "sourceList", `${configPath}.postgres.package`),
},
service: {
name: stringField(service, "name", `${configPath}.postgres.service`),
enabled: booleanField(service, "enabled", `${configPath}.postgres.service`),
state: stringField(service, "state", `${configPath}.postgres.service`),
},
paths: {
dataDir: absolutePathField(paths, "dataDir", `${configPath}.postgres.paths`),
configDir: absolutePathField(paths, "configDir", `${configPath}.postgres.paths`),
logDir: absolutePathField(paths, "logDir", `${configPath}.postgres.paths`),
},
network: {
port: integerField(network, "port", `${configPath}.postgres.network`),
listenAddresses: stringArrayField(network, "listenAddresses", `${configPath}.postgres.network`),
connectionHost: stringField(network, "connectionHost", `${configPath}.postgres.network`),
publicDns: stringField(network, "publicDns", `${configPath}.postgres.network`),
transport: postgresTransportField(network, "transport", `${configPath}.postgres.network`),
sslmode: postgresSslModeField(network, "sslmode", `${configPath}.postgres.network`),
tls: {
enabled: booleanField(tls, "enabled", `${configPath}.postgres.network.tls`),
mode: stringField(tls, "mode", `${configPath}.postgres.network.tls`),
commonName: stringField(tls, "commonName", `${configPath}.postgres.network.tls`),
certFile: absolutePathField(tls, "certFile", `${configPath}.postgres.network.tls`),
keyFile: absolutePathField(tls, "keyFile", `${configPath}.postgres.network.tls`),
futureClientSslmode: stringField(tls, "futureClientSslmode", `${configPath}.postgres.network.tls`),
},
firewall: {
mode: stringField(firewall, "mode", `${configPath}.postgres.network.firewall`),
defaultDeny: booleanField(firewall, "defaultDeny", `${configPath}.postgres.network.firewall`),
allowSources: arrayOfRecords(firewall.allowSources, `${configPath}.postgres.network.firewall.allowSources`).map((item, index) => ({
id: stringField(item, "id", `${configPath}.postgres.network.firewall.allowSources[${index}]`),
cidr: stringField(item, "cidr", `${configPath}.postgres.network.firewall.allowSources[${index}]`),
purpose: stringField(item, "purpose", `${configPath}.postgres.network.firewall.allowSources[${index}]`),
})),
},
},
tuning: {
maxConnections: integerField(tuning, "maxConnections", `${configPath}.postgres.tuning`),
sharedBuffers: stringField(tuning, "sharedBuffers", `${configPath}.postgres.tuning`),
effectiveCacheSize: stringField(tuning, "effectiveCacheSize", `${configPath}.postgres.tuning`),
workMem: stringField(tuning, "workMem", `${configPath}.postgres.tuning`),
maintenanceWorkMem: stringField(tuning, "maintenanceWorkMem", `${configPath}.postgres.tuning`),
walCompression: booleanField(tuning, "walCompression", `${configPath}.postgres.tuning`),
checkpointCompletionTarget: numberField(tuning, "checkpointCompletionTarget", `${configPath}.postgres.tuning`),
},
auth: {
passwordEncryption,
pgHba: arrayOfRecords(auth.pgHba, `${configPath}.postgres.auth.pgHba`).map((item, index) => pgHbaRule(item, `${configPath}.postgres.auth.pgHba[${index}]`)),
},
},
secrets: {
source: secretSource,
root: stringField(secrets, "root", `${configPath}.secrets`),
entries,
},
objects: { roles, databases },
exports: { connectionStrings },
backup: {
logicalDump: {
enabled: booleanField(logicalDump, "enabled", `${configPath}.backup.logicalDump`),
schedule: stringField(logicalDump, "schedule", `${configPath}.backup.logicalDump`),
database: stringField(logicalDump, "database", `${configPath}.backup.logicalDump`),
retentionDays: integerField(logicalDump, "retentionDays", `${configPath}.backup.logicalDump`),
destination: {
type: stringField(destination, "type", `${configPath}.backup.logicalDump.destination`),
path: absolutePathField(destination, "path", `${configPath}.backup.logicalDump.destination`),
},
encryption: {
enabled: booleanField(encryption, "enabled", `${configPath}.backup.logicalDump.encryption`),
},
},
},
};
}
function resolveConfigPath(pathArg: string): string {
return isAbsolute(pathArg) ? pathArg : rootPath(pathArg);
}
function relativeConfigPath(path: string): string {
return path.startsWith(`${repoRoot}/`) ? path.slice(repoRoot.length + 1) : path;
}
function asRecord(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be a YAML object`);
return value as Record<string, unknown>;
}
function objectField(obj: Record<string, unknown>, key: string, path: string): Record<string, unknown> {
return asRecord(obj[key], `${path}.${key}`);
}
function stringField(obj: Record<string, unknown>, key: string, path: string): string {
const value = obj[key];
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
return value.trim();
}
function absolutePathField(obj: Record<string, unknown>, key: string, path: string): string {
const value = stringField(obj, key, path);
if (!value.startsWith("/")) throw new Error(`${path}.${key} must be an absolute path`);
return value;
}
function numberField(obj: Record<string, unknown>, key: string, path: string): number {
const value = obj[key];
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) throw new Error(`${path}.${key} must be a positive number`);
return value;
}
function integerField(obj: Record<string, unknown>, key: string, path: string): number {
const value = numberField(obj, key, path);
if (!Number.isInteger(value)) throw new Error(`${path}.${key} must be an integer`);
return value;
}
function booleanField(obj: Record<string, unknown>, key: string, path: string): boolean {
const value = obj[key];
if (typeof value !== "boolean") throw new Error(`${path}.${key} must be a boolean`);
return value;
}
function postgresTransportField(obj: Record<string, unknown>, key: string, path: string): "postgres-native-tls" {
const value = stringField(obj, key, path);
if (value !== "postgres-native-tls") throw new Error(`${path}.${key} must be postgres-native-tls`);
return value;
}
function postgresSslModeField(obj: Record<string, unknown>, key: string, path: string): "require" {
const value = stringField(obj, key, path);
if (value !== "require") throw new Error(`${path}.${key} must be require`);
return value;
}
function stringArrayField(obj: Record<string, unknown>, key: string, path: string): string[] {
const value = obj[key];
if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== "string" || item.trim().length === 0)) {
throw new Error(`${path}.${key} must be a non-empty string array`);
}
return value.map((item) => (item as string).trim());
}
function numberArrayField(obj: Record<string, unknown>, key: string, path: string): number[] {
const value = obj[key];
if (!Array.isArray(value) || value.some((item) => typeof item !== "number" || !Number.isInteger(item))) {
throw new Error(`${path}.${key} must be an integer array`);
}
return value as number[];
}
function arrayOfRecords(value: unknown, path: string): Record<string, unknown>[] {
if (!Array.isArray(value)) throw new Error(`${path} must be an array`);
return value.map((item, index) => asRecord(item, `${path}[${index}]`));
}
function secretEntry(entry: Record<string, unknown>, path: string): SecretEntryConfig {
const type = stringField(entry, "type", path);
if (type !== "env") throw new Error(`${path}.type must be env`);
const createRaw = entry.createIfMissing === undefined ? undefined : objectField(entry, "createIfMissing", path);
const valuesRaw = createRaw?.values === undefined ? undefined : asStringMap(createRaw.values, `${path}.createIfMissing.values`);
const randomHexRaw = createRaw?.randomHex === undefined ? undefined : asNumberMap(createRaw.randomHex, `${path}.createIfMissing.randomHex`);
return {
name: stringField(entry, "name", path),
sourceRef: safeSourceRef(stringField(entry, "sourceRef", path), `${path}.sourceRef`),
type,
requiredKeys: stringArrayField(entry, "requiredKeys", path),
...(createRaw === undefined ? {} : {
createIfMissing: {
enabled: booleanField(createRaw, "enabled", `${path}.createIfMissing`),
...(valuesRaw === undefined ? {} : { values: valuesRaw }),
...(randomHexRaw === undefined ? {} : { randomHex: randomHexRaw }),
},
}),
};
}
function asStringMap(value: unknown, path: string): Record<string, string> {
const record = asRecord(value, path);
const result: Record<string, string> = {};
for (const [key, item] of Object.entries(record)) {
if (typeof item !== "string") throw new Error(`${path}.${key} must be a string`);
result[key] = item;
}
return result;
}
function asNumberMap(value: unknown, path: string): Record<string, number> {
const record = asRecord(value, path);
const result: Record<string, number> = {};
for (const [key, item] of Object.entries(record)) {
if (typeof item !== "number" || !Number.isInteger(item) || item <= 0 || item > 128) throw new Error(`${path}.${key} must be an integer in 1..128`);
result[key] = item;
}
return result;
}
function safeSourceRef(value: string, path: string): string {
if (value.startsWith("/") || value.includes("..") || value.includes("\0")) throw new Error(`${path} must be a relative secret source ref without ..`);
return value;
}
function roleConfig(role: Record<string, unknown>, path: string): PostgresHostConfig["objects"]["roles"][number] {
const passwordRef = objectField(role, "passwordRef", path);
const attributes = objectField(role, "attributes", path);
const name = pgIdentifier(stringField(role, "name", path), `${path}.name`);
return {
name,
passwordRef: {
sourceRef: safeSourceRef(stringField(passwordRef, "sourceRef", `${path}.passwordRef`), `${path}.passwordRef.sourceRef`),
key: envKey(stringField(passwordRef, "key", `${path}.passwordRef`), `${path}.passwordRef.key`),
},
login: booleanField(role, "login", path),
attributes: {
createdb: booleanField(attributes, "createdb", `${path}.attributes`),
createrole: booleanField(attributes, "createrole", `${path}.attributes`),
superuser: booleanField(attributes, "superuser", `${path}.attributes`),
},
};
}
function databaseConfig(database: Record<string, unknown>, path: string): PostgresHostConfig["objects"]["databases"][number] {
return {
name: pgIdentifier(stringField(database, "name", path), `${path}.name`),
owner: pgIdentifier(stringField(database, "owner", path), `${path}.owner`),
encoding: stringField(database, "encoding", path),
locale: stringField(database, "locale", path),
extensions: stringArrayFieldAllowEmpty(database, "extensions", path).map((item, index) => pgIdentifier(item, `${path}.extensions[${index}]`)),
};
}
function stringArrayFieldAllowEmpty(obj: Record<string, unknown>, key: string, path: string): string[] {
const value = obj[key];
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim().length === 0)) {
throw new Error(`${path}.${key} must be a string array`);
}
return value.map((item) => (item as string).trim());
}
function connectionStringExport(item: Record<string, unknown>, path: string): ConnectionStringExportConfig {
const render = objectField(item, "render", path);
const writeToSecretSource = objectField(item, "writeToSecretSource", path);
const variables = render.variables === undefined ? undefined : asStringMap(render.variables, `${path}.render.variables`);
const mode = stringField(writeToSecretSource, "mode", `${path}.writeToSecretSource`);
if (mode !== "update-or-insert") throw new Error(`${path}.writeToSecretSource.mode must be update-or-insert`);
return {
name: stringField(item, "name", path),
sourceSecretRef: safeSourceRef(stringField(item, "sourceSecretRef", path), `${path}.sourceSecretRef`),
render: {
envKey: envKey(stringField(render, "envKey", `${path}.render`), `${path}.render.envKey`),
format: stringField(render, "format", `${path}.render`),
...(variables === undefined ? {} : { variables }),
},
writeToSecretSource: {
sourceRef: safeSourceRef(stringField(writeToSecretSource, "sourceRef", `${path}.writeToSecretSource`), `${path}.writeToSecretSource.sourceRef`),
key: envKey(stringField(writeToSecretSource, "key", `${path}.writeToSecretSource`), `${path}.writeToSecretSource.key`),
mode,
},
consumers: arrayOfRecords(item.consumers, `${path}.consumers`).map((consumer, index) => ({
scope: stringField(consumer, "scope", `${path}.consumers[${index}]`),
secret: stringField(consumer, "secret", `${path}.consumers[${index}]`),
key: envKey(stringField(consumer, "key", `${path}.consumers[${index}]`), `${path}.consumers[${index}].key`),
})),
};
}
function pgHbaRule(item: Record<string, unknown>, path: string): PgHbaRule {
const type = stringField(item, "type", path);
if (type !== "local" && type !== "host" && type !== "hostssl") throw new Error(`${path}.type must be local, host, or hostssl`);
const method = stringField(item, "method", path);
if (method !== "peer" && method !== "scram-sha-256") throw new Error(`${path}.method must be peer or scram-sha-256`);
const address = item.address === undefined ? undefined : stringField(item, "address", path);
if ((type === "host" || type === "hostssl") && address === undefined) throw new Error(`${path}.address is required for host rules`);
return {
type,
database: stringField(item, "database", path),
user: stringField(item, "user", path),
...(address === undefined ? {} : { address }),
method,
};
}
function pgIdentifier(value: string, path: string): string {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(value)) throw new Error(`${path} must be a safe PostgreSQL identifier`);
return value;
}
function envKey(value: string, path: string): string {
if (!/^[A-Z_][A-Z0-9_]*$/u.test(value)) throw new Error(`${path} must be an uppercase env key`);
return value;
}
function configSummary(pg: PostgresHostConfig): Record<string, unknown> {
return {
path: pg.configPath,
id: pg.metadata.id,
relatedIssues: pg.metadata.relatedIssues,
node: pg.node.id,
route: pg.node.route,
mode: pg.node.mode,
pgVersion: pg.postgres.package.version,
packageRepo: {
repo: pg.postgres.package.repo,
repoUrl: pg.postgres.package.repoUrl,
suite: pg.postgres.package.suite,
component: pg.postgres.package.component,
sourceList: pg.postgres.package.sourceList,
},
service: pg.postgres.service.name,
port: pg.postgres.network.port,
listenAddresses: pg.postgres.network.listenAddresses,
connectionHost: pg.postgres.network.connectionHost,
publicDns: pg.postgres.network.publicDns,
transport: pg.postgres.network.transport,
sslmode: pg.postgres.network.sslmode,
tls: {
enabled: pg.postgres.network.tls.enabled,
mode: pg.postgres.network.tls.mode,
commonName: pg.postgres.network.tls.commonName,
certFile: pg.postgres.network.tls.certFile,
keyFile: pg.postgres.network.tls.keyFile,
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,
};
}
function inspectSecrets(pg: PostgresHostConfig, materialize: boolean): SecretInspection {
const root = secretRoot(pg);
const materials = new Map<string, SecretMaterial>();
const entries = pg.secrets.entries.map((entry) => {
const sourcePath = join(root, entry.sourceRef);
const existedBefore = existsSync(sourcePath);
const existingValues = existedBefore ? parseEnvFile(readFileSync(sourcePath, "utf8")) : {};
const nextValues = { ...existingValues };
for (const [key, value] of Object.entries(entry.createIfMissing?.values ?? {})) {
if (nextValues[key] === undefined) nextValues[key] = value;
}
for (const [key, bytes] of Object.entries(entry.createIfMissing?.randomHex ?? {})) {
if (nextValues[key] === undefined) nextValues[key] = randomBytes(bytes).toString("hex");
}
const missingBefore = entry.requiredKeys.filter((key) => existingValues[key] === undefined || existingValues[key].length === 0);
const missingAfter = entry.requiredKeys.filter((key) => nextValues[key] === undefined || nextValues[key].length === 0);
const action = missingAfter.length > 0
? "blocked"
: !existedBefore
? "create"
: missingBefore.length > 0
? "update"
: "none";
if (materialize && action !== "blocked" && (action === "create" || action === "update")) {
writeEnvFile(sourcePath, nextValues);
}
const material: SecretMaterial = {
values: nextValues,
sourcePath,
existedBefore,
missingBefore,
action,
fingerprint: missingAfter.length === 0 ? fingerprintValues(nextValues, entry.requiredKeys) : null,
};
materials.set(entry.sourceRef, material);
return {
name: entry.name,
sourceRef: entry.sourceRef,
sourcePath: redactRepoPath(sourcePath),
exists: existedBefore,
requiredKeys: entry.requiredKeys,
presentKeys: entry.requiredKeys.filter((key) => nextValues[key] !== undefined && nextValues[key].length > 0),
missingKeys: missingAfter,
action,
fingerprint: material.fingerprint,
};
});
return {
ok: entries.every((entry) => entry.missingKeys.length === 0),
root: redactRepoPath(root),
entries,
materials,
};
}
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");
validateObjectSecretConsistency(pg, inspection);
const exports = pg.exports.connectionStrings.map((item) => writeConnectionStringExport(pg, inspection, item));
return {
inspection,
exports,
summary: {
ok: inspection.ok,
root: inspection.root,
entries: inspection.entries,
exports,
valuesPrinted: false,
},
};
}
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> {
const source = inspection.materials.get(item.sourceSecretRef);
if (source === undefined) throw new Error(`export source secret not found: ${item.sourceSecretRef}`);
const rendered = renderConnectionString(item, source.values);
const root = secretRoot(pg);
const targetPath = join(root, item.writeToSecretSource.sourceRef);
const existing = existsSync(targetPath) ? parseEnvFile(readFileSync(targetPath, "utf8")) : {};
const before = existing[item.writeToSecretSource.key];
const next = { ...existing, [item.writeToSecretSource.key]: rendered };
writeEnvFile(targetPath, next);
return {
name: item.name,
sourceRef: item.sourceSecretRef,
targetRef: item.writeToSecretSource.sourceRef,
key: item.writeToSecretSource.key,
action: before === rendered ? "none" : before === undefined ? "create" : "update",
fingerprint: fingerprintValues({ [item.writeToSecretSource.key]: rendered }, [item.writeToSecretSource.key]),
consumers: item.consumers,
valuesPrinted: false,
};
}
function renderConnectionString(item: ConnectionStringExportConfig, values: Record<string, string>): string {
let output = item.render.format;
const merged = { ...values, ...(item.render.variables ?? {}) };
for (const [key, value] of Object.entries(merged)) {
const rendered = key === "PGHOST" ? value : encodeURIComponent(value);
output = output.replaceAll(`$(${key})`, rendered);
}
if (/\$\([A-Z0-9_]+\)/u.test(output)) throw new Error(`unresolved variable in connection string export ${item.name}`);
return output;
}
function secretRoot(pg: PostgresHostConfig): string {
return isAbsolute(pg.secrets.root) ? pg.secrets.root : rootPath(pg.secrets.root);
}
function parseEnvFile(text: string): Record<string, string> {
const result: Record<string, string> = {};
for (const rawLine of text.split(/\r?\n/u)) {
const line = rawLine.trim();
if (line.length === 0 || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq <= 0) continue;
const key = line.slice(0, eq).trim();
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) continue;
result[key] = unquoteEnvValue(line.slice(eq + 1).trim());
}
return result;
}
function unquoteEnvValue(value: string): string {
if ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith("\"") && value.endsWith("\""))) {
return value.slice(1, -1);
}
return value;
}
function writeEnvFile(path: string, values: Record<string, string>): void {
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
const lines = Object.keys(values)
.sort()
.map((key) => `${key}=${quoteEnv(values[key])}`);
writeFileSync(path, `${lines.join("\n")}\n`, { encoding: "utf8", mode: 0o600 });
chmodSync(path, 0o600);
}
function quoteEnv(value: string): string {
if (/^[A-Za-z0-9_./:@%?&=+-]+$/u.test(value)) return value;
return `'${value.replaceAll("'", "'\"'\"'")}'`;
}
function compactText(value: string): string {
return value.replace(/\s+/gu, " ").trim().slice(0, 500);
}
function fingerprintValues(values: Record<string, string>, keys: string[]): string {
const material = keys
.slice()
.sort()
.map((key) => `${key}=${values[key] ?? ""}`)
.join("\n");
return `sha256:${createHash("sha256").update(material).digest("hex")}`;
}
function secretSummary(secrets: SecretInspection): Record<string, unknown> {
return {
ok: secrets.ok,
root: secrets.root,
entries: secrets.entries,
valuesPrinted: false,
};
}
function desiredSummary(pg: PostgresHostConfig, secrets: SecretInspection, facts: RemoteFacts | null): Record<string, unknown> {
const role = pg.objects.roles[0];
const database = pg.objects.databases[0];
const observedRoles = facts?.postgres.roles ?? [];
const observedDatabases = facts?.postgres.databases ?? [];
return {
package: {
name: `postgresql-${pg.postgres.package.version}`,
repo: pg.postgres.package.repo,
releaseUrl: releaseUrl(pg),
action: facts?.postgres.packageInstalled ? "none" : "install",
},
host: {
swap: {
ensure: pg.node.swap.ensure,
size: pg.node.swap.size,
path: pg.node.swap.path,
action: facts === null ? "unknown" : (facts.host.swapTotalMiB ?? 0) > 0 ? "none" : "ensure",
},
minCpu: pg.node.requiredHostFacts.minCpu,
minMemoryGiB: pg.node.requiredHostFacts.minMemoryGiB,
minDiskFreeGiB: pg.node.requiredHostFacts.minDiskFreeGiB,
},
postgres: {
service: pg.postgres.service.name,
port: pg.postgres.network.port,
listenAddresses: pg.postgres.network.listenAddresses,
connectionHost: pg.postgres.network.connectionHost,
publicDns: pg.postgres.network.publicDns,
transport: pg.postgres.network.transport,
sslmode: pg.postgres.network.sslmode,
tls: {
enabled: pg.postgres.network.tls.enabled,
mode: pg.postgres.network.tls.mode,
commonName: pg.postgres.network.tls.commonName,
certFile: pg.postgres.network.tls.certFile,
keyFile: pg.postgres.network.tls.keyFile,
futureClientSslmode: pg.postgres.network.tls.futureClientSslmode,
},
pgHbaManagedBlock: { start: managedHbaStart, end: managedHbaEnd, rules: pg.postgres.auth.pgHba.length },
pgHbaRemoteTransport: "hostssl",
role: { name: role.name, action: facts?.postgres.roleExists ? "none" : "create-or-update" },
database: { name: database.name, owner: database.owner, action: facts?.postgres.databaseExists ? "none" : "create" },
roles: pg.objects.roles.map((item) => ({
name: item.name,
login: item.login,
action: observedRoles.some((observed) => observed.name === item.name && observed.exists) ? "none" : "create-or-update",
})),
databases: pg.objects.databases.map((item) => ({
name: item.name,
owner: item.owner,
action: observedDatabases.some((observed) => observed.name === item.name && observed.exists) ? "none" : "create",
})),
},
secrets: secretSummary(secrets),
exports: pg.exports.connectionStrings.map((item) => ({
name: item.name,
sourceRef: item.sourceSecretRef,
targetRef: item.writeToSecretSource.sourceRef,
key: item.writeToSecretSource.key,
consumers: item.consumers,
valuesPrinted: false,
})),
backup: pg.backup.logicalDump,
};
}
function desiredChecks(pg: PostgresHostConfig, facts: RemoteFacts, secrets: SecretInspection): Array<Record<string, unknown>> {
const memoryGiB = (facts.host.memoryTotalMiB ?? 0) / 1024;
const checks = [
{ name: "provider-online", ok: facts.ok, detail: "PK01 remote facts returned JSON" },
{ name: "cpu", ok: (facts.host.cpuCount ?? 0) >= pg.node.requiredHostFacts.minCpu, observed: facts.host.cpuCount, required: pg.node.requiredHostFacts.minCpu },
{ name: "memory", ok: memoryGiB >= pg.node.requiredHostFacts.minMemoryGiB, observedGiB: Number(memoryGiB.toFixed(2)), requiredGiB: pg.node.requiredHostFacts.minMemoryGiB },
{ name: "disk-free", ok: (facts.host.rootAvailGiB ?? 0) >= pg.node.requiredHostFacts.minDiskFreeGiB, observedGiB: facts.host.rootAvailGiB, requiredGiB: pg.node.requiredHostFacts.minDiskFreeGiB },
{ name: "swap", ok: !pg.node.requiredHostFacts.requireSwap || (facts.host.swapTotalMiB ?? 0) > 0 || pg.node.swap.ensure, observedMiB: facts.host.swapTotalMiB, disposition: (facts.host.swapTotalMiB ?? 0) > 0 ? "already-present" : "will-ensure" },
{ name: "port-5432-free-or-postgres", ok: !facts.network.port5432Listening || facts.postgres.packageInstalled, detail: facts.network.port5432Line },
{ name: "pgdg-repo-release", ok: facts.repo.reachable, releaseUrl: facts.repo.releaseUrl, firstLine: facts.repo.firstLine },
{ name: "secrets", ok: secrets.ok, entries: secrets.entries.map((entry) => ({ sourceRef: entry.sourceRef, action: entry.action, missingKeys: entry.missingKeys })) },
{ name: "pg16-required", ok: pg.postgres.package.version === "16", detail: "Sub2API deployment requires PostgreSQL 16 per issue #281 update." },
{ name: "dns-db-pikapython", ok: true, host: facts.network.dns.hostname, addresses: facts.network.dns.addresses, requiredBeforeSub2ApiCutover: false, disposition: facts.network.dns.resolves ? "optional-alias-resolves" : "optional-alias-unresolved" },
{ name: "postgres-tls-required", ok: pg.postgres.network.tls.enabled && pg.postgres.network.sslmode === "require", transport: pg.postgres.network.transport, sslmode: pg.postgres.network.sslmode },
{ name: "remote-pg-hba-hostssl", ok: pg.postgres.auth.pgHba.filter((rule) => rule.type !== "local" && rule.address !== "127.0.0.1/32").every((rule) => rule.type === "hostssl"), detail: "remote PostgreSQL access must use hostssl so plaintext remote connections are refused" },
{ name: "postgres-ssl-runtime", ok: !facts.postgres.packageInstalled || facts.postgres.sslOn, observed: facts.postgres.sslOn, disposition: facts.postgres.packageInstalled ? "must-be-on" : "will-enable-on-apply" },
];
return checks;
}
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, appProbes(pg, secrets)));
const parsed = parseJsonOutput(capture.stdout) as RemoteFacts | null;
return { capture, parsed };
}
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, 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 = appProbes(pg, secrets)[0] ?? null;
if (probe === null) {
return { ...base, attempted: false, ok: null, ssl: null, psqlVersion: null, error: "probe-secret-material-unavailable" };
}
const version = spawnSync("psql", ["--version"], { encoding: "utf8", timeout: 5_000 });
if (version.error !== undefined || version.status !== 0) {
return {
...base,
attempted: false,
ok: null,
ssl: null,
psqlVersion: null,
error: compactText(version.error?.message ?? version.stderr ?? "psql-unavailable"),
};
}
const conn = `host=${host} port=${port} user=${probe.user} dbname=${probe.database} sslmode=require connect_timeout=8`;
const result = spawnSync("psql", ["-Atq", "-F", "\t", conn, "-c", "SELECT inet_client_addr()::text, inet_server_addr()::text, ssl::text FROM pg_stat_ssl WHERE pid = pg_backend_pid();"], {
encoding: "utf8",
timeout: 12_000,
env: { ...process.env, PGPASSWORD: probe.password },
});
const line = result.stdout.trim().split(/\r?\n/u).find((item) => item.trim().length > 0);
const fields = line?.split("\t") ?? [];
const sslText = (fields[2] ?? "").toLowerCase();
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,
clientAddr: fields[0] ?? null,
serverAddr: fields[1] ?? null,
psqlVersion: version.stdout.trim() || null,
error: result.status === 0 ? null : compactText(result.stderr || result.error?.message || "psql-connection-failed"),
};
}
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 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 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
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
dpkg --print-architecture >"$tmp/arch" 2>/dev/null
nproc --all >"$tmp/cpu" 2>/dev/null
awk '/MemTotal:/ {print int($2/1024)}' /proc/meminfo >"$tmp/memTotal" 2>/dev/null
awk '/MemAvailable:/ {print int($2/1024)}' /proc/meminfo >"$tmp/memAvailable" 2>/dev/null
awk 'NR>1 {sum += $3} END {print int(sum/1024)}' /proc/swaps >"$tmp/swapTotal" 2>/dev/null
df -BG / | awk 'NR==2 {gsub("G","",$4); print $4}' >"$tmp/rootAvail" 2>/dev/null
df -BG "${dataDir}" 2>/dev/null | awk 'NR==2 {gsub("G","",$4); print $4}' >"$tmp/dataAvail" 2>/dev/null
ss -lntup 2>/dev/null | awk '/:5432 / {print; found=1} END {exit found?0:1}' >"$tmp/port5432" 2>/dev/null
printf '%s' "$?" >"$tmp/port5432rc"
getent ahostsv4 "${dnsHost}" >"$tmp/dns" 2>/dev/null
printf '%s' "$?" >"$tmp/dnsrc"
curl -fsSI --max-time 10 "${release}" >"$tmp/release" 2>"$tmp/releaseErr"
printf '%s' "$?" >"$tmp/releaserc"
command -v psql >"$tmp/psqlPath" 2>/dev/null
if [ "$?" -eq 0 ]; then psql --version >"$tmp/psqlVersion" 2>/dev/null; fi
command -v postgres >"$tmp/postgresPath" 2>/dev/null
if [ "$?" -eq 0 ]; then postgres --version >"$tmp/postgresVersion" 2>/dev/null; fi
dpkg-query -W postgresql-${pg.postgres.package.version} >/dev/null 2>&1 && printf 'install ok installed' >"$tmp/packageStatus" || :
systemctl is-active ${shellQuote(pg.postgres.service.name)} >"$tmp/serviceActive" 2>/dev/null
systemctl is-enabled ${shellQuote(pg.postgres.service.name)} >"$tmp/serviceEnabled" 2>/dev/null
[ -d "${dataDir}" ]; printf '%s' "$?" >"$tmp/dataDirRc"
[ -d "${configDir}" ]; printf '%s' "$?" >"$tmp/configDirRc"
[ -d "${logDir}" ]; printf '%s' "$?" >"$tmp/logDirRc"
if command -v runuser >/dev/null 2>&1 && command -v psql >/dev/null 2>&1 && id postgres >/dev/null 2>&1; then
if [ "$(id -u)" -eq 0 ]; then
root_prefix=""
elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
root_prefix="sudo -n"
else
root_prefix=""
fi
if [ -n "$root_prefix" ] || [ "$(id -u)" -eq 0 ]; then
$root_prefix runuser -u postgres -- psql -Atqc "SHOW server_version;" >"$tmp/serverVersion" 2>"$tmp/serverVersionErr"
$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 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 command -v psql >/dev/null 2>&1; then
:
${probeCommands}
fi
python3 - "$tmp" "${release}" <<'PY'
import json, os, sys
tmp, release = sys.argv[1], sys.argv[2]
def text(name):
try:
return open(os.path.join(tmp, name), encoding="utf-8", errors="replace").read().strip()
except FileNotFoundError:
return ""
def int_or_none(name):
raw = text(name)
try:
return int(raw)
except ValueError:
return None
def yes_file(name, expected="0"):
return text(name) == expected
def load_json(name, fallback):
try:
return json.load(open(os.path.join(tmp, name), encoding="utf-8"))
except Exception:
return fallback
def set_from_lines(name):
return {line.strip() for line in text(name).splitlines() if line.strip()}
expected_roles = load_json("expectedRoles.json", [])
expected_databases = load_json("expectedDatabases.json", [])
existing_roles = set_from_lines("rolesExisting")
existing_databases = set_from_lines("databasesExisting")
roles = [{"name": name, "exists": name in existing_roles} for name in expected_roles]
databases = [{"name": item["name"], "owner": item["owner"], "exists": item["name"] in existing_databases} for item in expected_databases]
app_connections = []
index = 0
while True:
meta = load_json(f"appProbe.{index}.json", None)
if meta is None:
break
ssl_raw = text(f"appSsl.{index}").lower()
if ssl_raw in {"t", "true", "1", "on"}:
ssl = True
elif ssl_raw in {"f", "false", "0", "off"}:
ssl = False
else:
ssl = None
rc_raw = text(f"appConnRc.{index}")
app_connections.append({
"user": meta.get("user"),
"database": meta.get("database"),
"ok": None if rc_raw == "" else rc_raw == "0",
"ssl": ssl,
"host": text(f"appConnHost.{index}") or None,
"error": text(f"appConnErr.{index}") or None,
})
index += 1
primary_conn = app_connections[0] if app_connections else {}
release_rc = text("releaserc")
payload = {
"ok": True,
"observedAt": text("observedAt") or None,
"host": {
"hostname": text("hostname") or None,
"osPrettyName": text("osPrettyName") or None,
"osCodename": text("osCodename") or None,
"arch": text("arch") or None,
"cpuCount": int_or_none("cpu"),
"memoryTotalMiB": int_or_none("memTotal"),
"memoryAvailableMiB": int_or_none("memAvailable"),
"swapTotalMiB": int_or_none("swapTotal") or 0,
"rootAvailGiB": int_or_none("rootAvail"),
"targetDataAvailGiB": int_or_none("dataAvail"),
},
"network": {
"port5432Listening": text("port5432rc") == "0",
"port5432Line": text("port5432") or None,
"dns": {
"hostname": "${dnsHost}",
"resolves": text("dnsrc") == "0",
"addresses": sorted({line.split()[0] for line in text("dns").splitlines() if line.strip()}),
},
},
"repo": {
"releaseUrl": release,
"reachable": release_rc == "0",
"firstLine": (text("release").splitlines() or [None])[0],
},
"postgres": {
"psqlVersion": text("psqlVersion") or None,
"postgresVersion": text("postgresVersion") or None,
"packageInstalled": "install ok installed" in text("packageStatus"),
"serviceActive": text("serviceActive") == "active",
"serviceEnabled": text("serviceEnabled") == "enabled",
"dataDirExists": yes_file("dataDirRc"),
"configDirExists": yes_file("configDirRc"),
"logDirExists": yes_file("logDirRc"),
"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": 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))
PY
`;
}
function releaseUrl(pg: PostgresHostConfig): string {
return `${pg.postgres.package.repoUrl.replace(/\/+$/u, "")}/dists/${pg.postgres.package.suite}/Release`;
}
async function startRemoteApplyJob(config: UniDeskConfig, pg: PostgresHostConfig, localState: { inspection: SecretInspection }): Promise<RemoteJobStart> {
const payload = remoteApplyPayload(pg, localState.inspection);
const script = startRemoteApplyJobScript(pg, payload);
const capture = await runSshCommandCapture(config, pg.node.route, ["script"], script);
const parsed = parseJsonOutput(capture.stdout);
return {
ok: capture.exitCode === 0 && parsed !== null && parsed.ok === true,
remoteJobId: typeof parsed?.remoteJobId === "string" ? parsed.remoteJobId : null,
statePath: typeof parsed?.statePath === "string" ? parsed.statePath : null,
logPath: typeof parsed?.logPath === "string" ? parsed.logPath : null,
parsed,
capture: compactCapture(capture, { full: capture.exitCode !== 0 }),
};
}
function remoteApplyPayload(pg: PostgresHostConfig, secrets: SecretInspection): Record<string, unknown> {
const roles = pg.objects.roles.map((role) => {
const material = secrets.materials.get(role.passwordRef.sourceRef);
if (material === undefined) throw new Error(`missing material for ${role.passwordRef.sourceRef}`);
const password = material.values[role.passwordRef.key];
if (password === undefined || password.length === 0) throw new Error(`missing ${role.passwordRef.key}`);
return {
name: role.name,
password,
login: role.login,
attributes: role.attributes,
};
});
return {
clusterId: pg.metadata.id,
pgVersion: pg.postgres.package.version,
node: pg.node,
package: pg.postgres.package,
service: pg.postgres.service,
paths: pg.postgres.paths,
network: pg.postgres.network,
tls: pg.postgres.network.tls,
tuning: pg.postgres.tuning,
pgHba: pg.postgres.auth.pgHba,
passwordEncryption: pg.postgres.auth.passwordEncryption,
roles,
databases: pg.objects.databases,
role: roles[0],
database: pg.objects.databases[0],
backup: pg.backup.logicalDump,
hbaMarkers: { start: managedHbaStart, end: managedHbaEnd },
};
}
function startRemoteApplyJobScript(pg: PostgresHostConfig, payload: Record<string, unknown>): string {
const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
const runner = remoteRunnerScript();
return `
set -eu
if ! command -v sudo >/dev/null 2>&1 || ! sudo -n true >/dev/null 2>&1; then
printf '{"ok":false,"error":"sudo-noninteractive-unavailable","message":"PK01 host PostgreSQL apply requires passwordless sudo for root-owned systemd, apt, PostgreSQL config and /root/.unidesk job state.","valuesPrinted":false}\\n'
exit 1
fi
sudo -n sh <<'ROOT'
set -eu
umask 077
base="/root/.unidesk/platform-db/jobs"
mkdir -p "$base"
job_id="pk01-pg16-$(date +%Y%m%d%H%M%S)-$$"
job_dir="$base/$job_id"
mkdir -p "$job_dir"
payload="$job_dir/payload.json"
state="$job_dir/state.json"
log="$job_dir/apply.log"
runner="$job_dir/run.sh"
printf '%s' '${encoded}' | base64 -d > "$payload"
cat > "$runner" <<'RUNNER'
${runner}
RUNNER
chmod 700 "$runner"
python3 - "$state" <<'PY'
import json, sys, datetime
path = sys.argv[1]
json.dump({"status":"queued","stage":"created","startedAt":datetime.datetime.utcnow().isoformat()+"Z","finishedAt":None,"exitCode":None}, open(path, "w"), ensure_ascii=False, indent=2)
PY
nohup "$runner" "$payload" "$state" >"$log" 2>&1 &
printf '{"ok":true,"remoteJobId":"%s","statePath":"%s","logPath":"%s","valuesPrinted":false}\\n' "$job_id" "$state" "$log"
ROOT
`;
}
function remoteRunnerScript(): string {
return String.raw`#!/bin/sh
set -eu
payload="$1"
state="$2"
job_dir="$(dirname "$payload")"
log="$job_dir/apply.log"
write_state() {
status="$1"
stage="$2"
exit_code=""
if [ "$#" -ge 3 ]; then
exit_code="$3"
fi
python3 - "$state" "$status" "$stage" "$exit_code" <<'PY'
import datetime, json, os, sys
path, status, stage, exit_code = sys.argv[1:5]
try:
current = json.load(open(path, encoding="utf-8"))
except Exception:
current = {}
current["status"] = status
current["stage"] = stage
current["updatedAt"] = datetime.datetime.utcnow().isoformat() + "Z"
if status == "running" and not current.get("startedAt"):
current["startedAt"] = current["updatedAt"]
if status in {"succeeded", "failed"}:
current["finishedAt"] = current["updatedAt"]
current["exitCode"] = int(exit_code or ("0" if status == "succeeded" else "1"))
json.dump(current, open(path, "w"), ensure_ascii=False, indent=2)
PY
}
fail() {
rc="$1"
stage="$2"
write_state failed "$stage" "$rc"
exit "$rc"
}
run_stage() {
stage="$1"
shift
write_state running "$stage"
printf '\n### %s\n' "$stage"
"$@" || fail "$?" "$stage"
}
prepare_apt_sourceparts() {
apt_sourceparts="$job_dir/apt-sourceparts"
rm -rf "$apt_sourceparts"
mkdir -p "$apt_sourceparts"
if [ -f "$SOURCE_LIST" ]; then
cp "$SOURCE_LIST" "$apt_sourceparts/$(basename "$SOURCE_LIST")"
fi
printf '%s' "$apt_sourceparts"
}
apt_get_update_safe() {
apt_sourceparts="$(prepare_apt_sourceparts)"
env DEBIAN_FRONTEND=noninteractive apt-get \
-o Dir::Etc::sourceparts="$apt_sourceparts" \
-o APT::Get::List-Cleanup=0 \
update
}
apt_get_install_safe() {
apt_sourceparts="$(prepare_apt_sourceparts)"
env DEBIAN_FRONTEND=noninteractive apt-get \
-o Dir::Etc::sourceparts="$apt_sourceparts" \
-o APT::Get::List-Cleanup=0 \
install -y "$@"
}
json_get() {
python3 - "$payload" "$1" <<'PY'
import json, sys
data = json.load(open(sys.argv[1], encoding="utf-8"))
value = data
for part in sys.argv[2].split("."):
value = value[part]
if isinstance(value, bool):
print("true" if value else "false")
else:
print(value)
PY
}
render_file() {
name="$1"
python3 - "$payload" "$name" <<'PY'
import json, sys
data = json.load(open(sys.argv[1], encoding="utf-8"))
name = sys.argv[2]
if name == "pg_hba":
markers = data["hbaMarkers"]
print(markers["start"])
for rule in data["pgHba"]:
if rule["type"] == "local":
print(f'local {rule["database"]} {rule["user"]} {rule["method"]}')
else:
print(f'{rule["type"]} {rule["database"]} {rule["user"]} {rule["address"]} {rule["method"]}')
print(markers["end"])
elif name == "sql":
for role in data["roles"]:
name = role["name"]
password = role["password"].replace("'", "''")
attrs = []
attrs.append("LOGIN" if role.get("login") else "NOLOGIN")
attrs.append("CREATEDB" if role["attributes"].get("createdb") else "NOCREATEDB")
attrs.append("CREATEROLE" if role["attributes"].get("createrole") else "NOCREATEROLE")
attrs.append("SUPERUSER" if role["attributes"].get("superuser") else "NOSUPERUSER")
attr_sql = " ".join(attrs)
print("DO $unidesk$")
print("BEGIN")
print(f" IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = '{name}') THEN")
print(f" CREATE ROLE {name} {attr_sql} PASSWORD '{password}';")
print(" ELSE")
print(f" ALTER ROLE {name} WITH {attr_sql} PASSWORD '{password}';")
print(" END IF;")
print("END")
print("$unidesk$;")
elif name == "databases_tsv":
for database in data["databases"]:
print("\t".join([database["name"], database["owner"], database["encoding"], database["locale"]]))
elif name == "backup_script":
backup = data["backup"]
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 = 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_name} -d {db} --format=custom --file \"$dest/{db}-$ts.dump\"")
print(f"find \"$dest\" -type f -name '{db}-*.dump' -mtime +{retention} -delete")
PY
}
write_state running started
trap 'rc=$?; if [ "$rc" -ne 0 ]; then write_state failed trap "$rc"; fi' EXIT
PG_VERSION="$(json_get pgVersion)"
SERVICE="$(json_get service.name)"
SIGNED_BY="$(json_get package.signedBy)"
SOURCE_LIST="$(json_get package.sourceList)"
REPO_URL="$(json_get package.repoUrl)"
SUITE="$(json_get package.suite)"
COMPONENT="$(json_get package.component)"
KEY_URL="$(json_get package.signingKeyUrl)"
SWAP_ENSURE="$(json_get node.swap.ensure)"
SWAP_PATH="$(json_get node.swap.path)"
SWAP_SIZE="$(json_get node.swap.size)"
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)"
TLS_ENABLED="$(json_get tls.enabled)"
TLS_COMMON_NAME="$(json_get tls.commonName)"
TLS_CERT_FILE="$(json_get tls.certFile)"
TLS_KEY_FILE="$(json_get tls.keyFile)"
LISTEN_ADDRESSES="$(python3 - "$payload" <<'PY'
import json, sys
data=json.load(open(sys.argv[1], encoding="utf-8"))
print(",".join(data["network"]["listenAddresses"]))
PY
)"
if [ "$SWAP_ENSURE" = "true" ] && ! awk 'NR>1 {found=1} END {exit found?0:1}' /proc/swaps; then
run_stage ensure-swap fallocate -l "$SWAP_SIZE" "$SWAP_PATH"
chmod 600 "$SWAP_PATH"
run_stage mkswap mkswap "$SWAP_PATH"
run_stage swapon swapon "$SWAP_PATH"
grep -F "$SWAP_PATH none swap sw 0 0" /etc/fstab >/dev/null 2>&1 || printf '%s none swap sw 0 0\n' "$SWAP_PATH" >> /etc/fstab
fi
run_stage apt-prerequisites apt_get_update_safe
run_stage apt-install-prerequisites apt_get_install_safe ca-certificates curl gnupg openssl
write_state running configure-pgdg-repo
install -d -m 0755 "$(dirname "$SIGNED_BY")"
curl -fsSL "$KEY_URL" | gpg --dearmor > "$SIGNED_BY.tmp"
mv "$SIGNED_BY.tmp" "$SIGNED_BY"
chmod 0644 "$SIGNED_BY"
printf 'deb [signed-by=%s] %s %s %s\n' "$SIGNED_BY" "$REPO_URL" "$SUITE" "$COMPONENT" > "$SOURCE_LIST"
run_stage apt-update-pgdg apt_get_update_safe
run_stage install-postgresql apt_get_install_safe "postgresql-$PG_VERSION" "postgresql-client-$PG_VERSION"
run_stage enable-service systemctl enable "$SERVICE"
run_stage start-service systemctl start "$SERVICE"
if [ "$TLS_ENABLED" = "true" ]; then
write_state running configure-tls
if [ ! -s "$TLS_CERT_FILE" ] || [ ! -s "$TLS_KEY_FILE" ]; then
tmp_key="$job_dir/server.key"
tmp_cert="$job_dir/server.crt"
openssl req -new -x509 -days 825 -nodes -text -subj "/CN=$TLS_COMMON_NAME" -keyout "$tmp_key" -out "$tmp_cert"
install -o postgres -g postgres -m 0600 "$tmp_key" "$TLS_KEY_FILE"
install -o postgres -g postgres -m 0644 "$tmp_cert" "$TLS_CERT_FILE"
else
chown postgres:postgres "$TLS_KEY_FILE" "$TLS_CERT_FILE"
chmod 0600 "$TLS_KEY_FILE"
chmod 0644 "$TLS_CERT_FILE"
fi
fi
write_state running configure-postgresql
runuser -u postgres -- psql -v ON_ERROR_STOP=1 <<SQL
ALTER SYSTEM SET listen_addresses TO '$LISTEN_ADDRESSES';
ALTER SYSTEM SET ssl TO 'on';
ALTER SYSTEM SET ssl_cert_file TO '$TLS_CERT_FILE';
ALTER SYSTEM SET ssl_key_file TO '$TLS_KEY_FILE';
ALTER SYSTEM SET max_connections TO '$(json_get tuning.maxConnections)';
ALTER SYSTEM SET shared_buffers TO '$(json_get tuning.sharedBuffers)';
ALTER SYSTEM SET effective_cache_size TO '$(json_get tuning.effectiveCacheSize)';
ALTER SYSTEM SET work_mem TO '$(json_get tuning.workMem)';
ALTER SYSTEM SET maintenance_work_mem TO '$(json_get tuning.maintenanceWorkMem)';
ALTER SYSTEM SET wal_compression TO '$(json_get tuning.walCompression)';
ALTER SYSTEM SET checkpoint_completion_target TO '$(json_get tuning.checkpointCompletionTarget)';
ALTER SYSTEM SET password_encryption TO '$(json_get passwordEncryption)';
SQL
write_state running render-pg-hba
hba="$CONFIG_DIR/pg_hba.conf"
tmp_hba="$job_dir/pg_hba.conf"
if [ -f "$hba" ]; then
awk -v start="$(
printf '%s' "# BEGIN unidesk managed pk01-platform-postgres"
)" -v end="$(
printf '%s' "# END unidesk managed pk01-platform-postgres"
)" '
$0 == start {skip=1; next}
$0 == end {skip=0; next}
skip != 1 {print}
' "$hba" > "$tmp_hba"
else
: > "$tmp_hba"
fi
render_file pg_hba >> "$tmp_hba"
install -o postgres -g postgres -m 0640 "$tmp_hba" "$hba"
run_stage restart-postgresql systemctl restart "$SERVICE"
write_state running create-role
sql="$job_dir/role.sql"
render_file sql > "$sql"
runuser -u postgres -- psql -v ON_ERROR_STOP=1 < "$sql"
write_state running create-databases
databases_tsv="$job_dir/databases.tsv"
render_file databases_tsv > "$databases_tsv"
while IFS="$(printf '\t')" read -r DB_NAME DB_OWNER DB_ENCODING DB_LOCALE; do
[ -n "$DB_NAME" ] || continue
if ! runuser -u postgres -- psql -Atqc "SELECT 1 FROM pg_database WHERE datname='$DB_NAME'" | grep -q 1; then
runuser -u postgres -- createdb -O "$DB_OWNER" -E "$DB_ENCODING" --locale="$DB_LOCALE" --template=template0 "$DB_NAME"
fi
done < "$databases_tsv"
if [ "$BACKUP_ENABLED" = "true" ]; then
write_state running configure-backup
mkdir -p "$BACKUP_PATH"
backup_script="/usr/local/sbin/unidesk-pk01-sub2api-pgdump.sh"
render_file backup_script > "$backup_script"
chmod 700 "$backup_script"
cat > /etc/systemd/system/unidesk-pk01-sub2api-pgdump.service <<SERVICE
[Unit]
Description=UniDesk PK01 Sub2API PostgreSQL logical backup
[Service]
Type=oneshot
ExecStart=$backup_script
SERVICE
cat > /etc/systemd/system/unidesk-pk01-sub2api-pgdump.timer <<TIMER
[Unit]
Description=Daily UniDesk PK01 Sub2API PostgreSQL logical backup
[Timer]
OnCalendar=*-*-* 03:17:00
Persistent=true
[Install]
WantedBy=timers.target
TIMER
systemctl daemon-reload
systemctl enable --now unidesk-pk01-sub2api-pgdump.timer
fi
write_state running final-check
runuser -u postgres -- psql -Atqc "SHOW server_version;" >/dev/null
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
`;
}
async function waitRemoteApplyJob(config: UniDeskConfig, pg: PostgresHostConfig, remoteJobId: string): Promise<Record<string, unknown>> {
const observations: Array<Record<string, unknown>> = [];
const maxPolls = 240;
let lastProgressKey: string | null = null;
for (let attempt = 0; attempt < maxPolls; attempt += 1) {
const observation = await readRemoteJob(config, pg, remoteJobId);
observations.push(observation.summary);
const statusValue = observation.status;
const stage = typeof observation.summary.stage === "string" ? observation.summary.stage : null;
const progressKey = `${statusValue ?? "unknown"}:${stage ?? "unknown"}`;
if (attempt === 0 || progressKey !== lastProgressKey || attempt % 6 === 0 || statusValue === "succeeded" || statusValue === "failed") {
lastProgressKey = progressKey;
console.error(JSON.stringify({
event: "platform-db.postgres.apply.progress",
at: new Date().toISOString(),
remoteJobId,
attempt: attempt + 1,
status: statusValue,
stage,
updatedAt: observation.summary.updatedAt ?? null,
exitCode: observation.summary.exitCode ?? null,
}));
}
if (statusValue === "succeeded" || statusValue === "failed") {
return {
ok: statusValue === "succeeded",
attempts: attempt + 1,
terminal: observation.summary,
recent: observations.slice(-10),
};
}
await Bun.sleep(5000);
}
return {
ok: false,
attempts: maxPolls,
terminal: null,
recent: observations.slice(-10),
error: "remote apply job did not finish before local polling budget",
};
}
async function readRemoteJob(config: UniDeskConfig, pg: PostgresHostConfig, remoteJobId: string): Promise<{ status: string | null; summary: Record<string, unknown> }> {
const safeId = remoteJobId.replace(/[^A-Za-z0-9_.-]/gu, "");
const script = `
set +e
if ! command -v sudo >/dev/null 2>&1 || ! sudo -n true >/dev/null 2>&1; then
printf '{"status":"unknown","stage":"sudo-unavailable","error":"sudo-noninteractive-unavailable"}'
printf '\\n---LOG---\\n'
exit 1
fi
sudo -n sh <<'ROOT'
job_dir="/root/.unidesk/platform-db/jobs/${safeId}"
state="$job_dir/state.json"
log="$job_dir/apply.log"
if [ -f "$state" ]; then cat "$state"; else printf '{"status":"missing","stage":"missing"}'; fi
printf '\\n---LOG---\\n'
if [ -f "$log" ]; then tail -80 "$log"; fi
ROOT
`;
const capture = await runSshCommandCapture(config, pg.node.route, ["script"], script);
const [stateText, logTail = ""] = capture.stdout.split("\n---LOG---\n");
const parsed = parseJsonOutput(stateText) ?? {};
const statusValue = typeof parsed.status === "string" ? parsed.status : null;
return {
status: statusValue,
summary: {
ok: capture.exitCode === 0,
remoteJobId,
status: statusValue,
stage: parsed.stage ?? null,
updatedAt: parsed.updatedAt ?? null,
finishedAt: parsed.finishedAt ?? null,
exitCode: parsed.exitCode ?? null,
logTail: redactLogTail(logTail),
capture: compactCapture(capture, { full: capture.exitCode !== 0 }),
},
};
}
function redactLogTail(text: string): string {
return text
.split(/\r?\n/u)
.filter((line) => !line.includes("PASSWORD") && !line.includes("postgresql://"))
.slice(-80)
.join("\n");
}
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;
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;
}
}
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 ? result.stdout.slice(-8000) : "",
stderrTail: full || result.exitCode !== 0 ? result.stderr.slice(-4000) : "",
};
}
function redactRepoPath(path: string): string {
return path.startsWith(`${repoRoot}/`) ? path.slice(repoRoot.length + 1) : path;
}