diff --git a/docs/solutions/test-failures/pg-test-server-libdir-plpgsql-and-missing-login-role.md b/docs/solutions/test-failures/pg-test-server-libdir-plpgsql-and-missing-login-role.md new file mode 100644 index 0000000000..43f17afc69 --- /dev/null +++ b/docs/solutions/test-failures/pg-test-server-libdir-plpgsql-and-missing-login-role.md @@ -0,0 +1,15 @@ +--- +category: test-failures +module: testing +problem_type: environment-provisioning +applies_when: Local PostgreSQL tests can reach TCP but schema setup or configured login fails. +tags: [postgresql, plpgsql, embedded-postgres, worktree, provisioning, fn-152] +--- + +# Repair a stale local PostgreSQL test server + +A postmaster may remain alive after the worktree holding its embedded PostgreSQL payload is deleted. PostgreSQL derives `$libdir` from that executable, so `LANGUAGE plpgsql` then fails with `58P01` even though TCP accepts connections. The same cluster can also lack the role/database resolved by the test URL. + +Use `pnpm pg:test:up` to stage the pinned payload in a durable home and verify configured login, PL/pgSQL, maintenance access, and admin DDL. The script derives role, password, and database using `postgres@3.4.9` URL/environment fallback rules; the database falls back to the role name. It only gates health checks whose independently resolved identities are guaranteed by provisioning. Bare and CI-shaped probes that resolve elsewhere are diagnostic notes, not blockers. + +Do not tune `dynamic_library_path`, weaken `pgDescribe`, hardcode `postgres` plus the OS user, or gate success on a bare connection. A foreign server is never mutated. `--replace` requires a readable data directory, matching PID metadata, a live PostgreSQL process, and never-touch checks before a graceful stop; an identity-only foreign defect is refused with SQL for its operator instead. diff --git a/docs/testing.md b/docs/testing.md index da4cafb029..fb84a2c4eb 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -31,6 +31,16 @@ FN-9131 retains `pg-connection-budget.ts` as a tested cluster-shared advisory-lo The initial harness wiring made a 27-worker PostgreSQL directory run worse, even after registry contention was changed from bounded rejection to queueing. Do not restore the wiring, alter test timeouts, add retries, or cap workers as a workaround. FN-9139 owns selecting a per-worker lifecycle point that is inert for non-PostgreSQL lanes and can charge admission wait outside individual test budgets. See [the terminal-negative record](solutions/test-failures/pg-harness-connection-budget.md). +### Local PostgreSQL test server provisioning + + + +Use `pnpm pg:test:up`, `pnpm pg:test:status`, and `pnpm pg:test:down`; forward flags as `pnpm pg:test:up -- --replace` and `pnpm pg:test:down -- --purge`. The script stages the pinned embedded PostgreSQL binaries under `~/.fusion/pg-test-server` (or `FUSION_PG_TEST_SERVER_HOME`) and never starts a relative or worktree-native executable. `dynamic_library_path` cannot repair `$libdir/plpgsql` because PostgreSQL resolves that path from its executable. + +The configured identity follows `postgres@3.4.9`: role is URL username, then `PGUSERNAME`, `PGUSER`, and the OS user; password is URL password then `PGPASSWORD`; database is URL path, then `PGDATABASE`, then the role name. A role-only repair therefore still fails when its role-named database is absent. The readiness gate runs configured login, PL/pgSQL, maintenance, and admin-DDL probes. A bare or CI-shaped probe is diagnostic-only unless its independently resolved role/database is one the script provisions; no harness connect dials a path-less base URL. + +`FUSION_PG_TEST_URL_BASE` is shared by `up`, `status`, and `down`; export the same non-default URL for all three. A path-bearing URL is provisionable but reports `harness-url-concat`, since `${PG_TEST_URL_BASE}/${dbName}` corrupts the 24 harness URL constructions. The script refuses URL/`--port` conflicts and `PGHOST`/`PGPORT` endpoint divergence. It reuses healthy servers, never mutates a foreign server's roles or databases, and requires a proven data-directory/PID ownership chain before `--replace` can stop a broken PostgreSQL server. `pg_ctl` daemonizes the postmaster, avoiding detached process spawning. A skipped `pgDescribe` block is not PostgreSQL verification evidence. + ### PostgreSQL setup-boundary participation and measurement diff --git a/package.json b/package.json index a864abe6ef..2738bbfec7 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,9 @@ "check:mock-completeness": "node scripts/check-mock-completeness.mjs", "test:gate:static": "node scripts/check-no-nohup.mjs && node scripts/check-no-cwd-relative-dashboard-test-reads.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-getdatabase.mjs && node scripts/check-prerebase-inert.mjs && node scripts/check-capacity-pool-id.mjs && node scripts/check-cli-runtime-routing.mjs && node scripts/check-no-node-only-core-imports-in-dashboard.mjs && node scripts/check-pi-versions-pinned.mjs && node scripts/check-workspace-package-graph.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs && node scripts/check-mock-completeness.mjs && node scripts/check-inert-sync-lane-conversions.mjs && node scripts/check-runtime-skill-loader-drift.mjs", "test:gate": "node scripts/run-static-gate-checks.mjs && sh -c 'pnpm --filter @fusion/engine test:core & engine_pid=$!; pnpm --filter @fusion/core test:pg-gate & pg_pid=$!; pnpm --filter @fusion/core test:unit-gate & unit_pid=$!; status=0; wait $engine_pid || status=1; wait $pg_pid || status=1; wait $unit_pid || status=1; exit $status' && pnpm --filter @runfusion/fusion test:ci-shape", + "pg:test:up": "node scripts/pg-test-server.mjs up", + "pg:test:status": "node scripts/pg-test-server.mjs status", + "pg:test:down": "node scripts/pg-test-server.mjs down", "smoke:boot": "node scripts/boot-smoke.mjs", "local": "node scripts/start-local.mjs", "dev": "node scripts/dev-with-memory.mjs", diff --git a/packages/core/src/__test-utils__/pg-provisioning-diagnostics.ts b/packages/core/src/__test-utils__/pg-provisioning-diagnostics.ts new file mode 100644 index 0000000000..53223a8a2f --- /dev/null +++ b/packages/core/src/__test-utils__/pg-provisioning-diagnostics.ts @@ -0,0 +1,25 @@ +/* +FNXC:PgTestProvisioning 2026-08-22-16:55: +A TCP-open PostgreSQL listener can still reject the harness identity or lack PL/pgSQL. +Keep the remediation at the raising seams so failed test setup names the local provisioning command. +*/ +export function decoratePgProvisioningError(error: unknown, _urlBase: string): unknown { + if (!(error instanceof Error)) return error; + + const code = (error as Error & { code?: string }).code; + const message = error.message; + let remediation: string | undefined; + if (code === "58P01" && message.includes("$libdir/plpgsql")) { + remediation = " PostgreSQL cannot load PL/pgSQL. Run pnpm pg:test:up -- --replace to replace a broken local test server."; + } else if (code === "28000" && /role\s+["'][^"']+["']\s+does not exist/i.test(message)) { + remediation = " PostgreSQL is reachable but lacks the configured login role. Run pnpm pg:test:up."; + } else if (code === "3D000" && /database\s+["'][^"']+["']\s+does not exist/i.test(message)) { + remediation = " PostgreSQL lacks the configured database. Run pnpm pg:test:up; postgres@3.4.9 resolves the database from the URL path, then PGDATABASE, then the role name."; + } else if (code === "ECONNREFUSED" || /ECONNREFUSED/.test(message)) { + remediation = " PostgreSQL refused the connection. Run pnpm pg:test:up."; + } + if (!remediation) return error; + const decorated = new Error(`${message}${remediation}`); + Object.assign(decorated, error); + return decorated; +} diff --git a/packages/core/src/__test-utils__/pg-test-harness.ts b/packages/core/src/__test-utils__/pg-test-harness.ts index fea7f72dd8..625803a113 100644 --- a/packages/core/src/__test-utils__/pg-test-harness.ts +++ b/packages/core/src/__test-utils__/pg-test-harness.ts @@ -64,6 +64,7 @@ import { sql } from "drizzle-orm"; import type { ResolvedBackend } from "../postgres/backend-resolver.js"; import { createConnectionSetFromUrl } from "../postgres/connection.js"; import { applySchemaBaseline } from "../postgres/schema-applier.js"; +import { decoratePgProvisioningError } from "./pg-provisioning-diagnostics.js"; import { createAsyncDataLayer, type AsyncDataLayer, @@ -572,6 +573,8 @@ async function withMaintenanceSql( }); try { return await fn(client); + } catch (error) { + throw decoratePgProvisioningError(error, PG_TEST_URL_BASE); } finally { await client.end({ timeout: 5 }).catch(() => {}); } @@ -734,6 +737,8 @@ function ensureGoldenTemplate(): Promise { }); try { await applySchemaBaseline(schemaConnections.migration); + } catch (error) { + throw decoratePgProvisioningError(error, PG_TEST_URL_BASE); } finally { await schemaConnections.close(); } diff --git a/packages/core/src/__tests__/pg-provisioning-diagnostics.test.ts b/packages/core/src/__tests__/pg-provisioning-diagnostics.test.ts new file mode 100644 index 0000000000..3b252448c5 --- /dev/null +++ b/packages/core/src/__tests__/pg-provisioning-diagnostics.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { decoratePgProvisioningError } from "../__test-utils__/pg-provisioning-diagnostics.js"; + +function pgError(code: string, message: string): Error & { code: string } { + return Object.assign(new Error(message), { code }); +} + +describe("decoratePgProvisioningError", () => { + it.each([ + [pgError("58P01", 'could not access file "$libdir/plpgsql"'), "pnpm pg:test:up -- --replace"], + [pgError("28000", 'role "node" does not exist'), "pnpm pg:test:up"], + [pgError("3D000", 'database "node" does not exist'), "role name"], + [pgError("ECONNREFUSED", "connect ECONNREFUSED 127.0.0.1:5432"), "pnpm pg:test:up"], + ])("decorates supported PostgreSQL failures", (error, expected) => { + const decorated = decoratePgProvisioningError(error, "postgresql://localhost:5432"); + expect(decorated).toBeInstanceOf(Error); + expect(decorated).not.toBe(error); + expect((decorated as Error).message).toContain(error.message); + expect((decorated as Error).message).toContain(expected); + }); + + it("preserves unrelated and non-Error throwables by identity", () => { + const ordinary = new Error("ordinary failure"); + const nonError = { code: "28000" }; + const unrelatedPg = pgError("58P01", "some other library is missing"); + expect(decoratePgProvisioningError(ordinary, "postgresql://localhost:5432")).toBe(ordinary); + expect(decoratePgProvisioningError(nonError, "postgresql://localhost:5432")).toBe(nonError); + expect(decoratePgProvisioningError(unrelatedPg, "postgresql://localhost:5432")).toBe(unrelatedPg); + }); +}); diff --git a/packages/engine/src/__tests__/reliability-interactions/_helpers.ts b/packages/engine/src/__tests__/reliability-interactions/_helpers.ts index f1c3afb313..6c5486b8dc 100644 --- a/packages/engine/src/__tests__/reliability-interactions/_helpers.ts +++ b/packages/engine/src/__tests__/reliability-interactions/_helpers.ts @@ -11,6 +11,7 @@ import { drizzleEq, postgresSchema, } from "@fusion/core"; import { aiMergeTask } from "../../merger.js"; +import { decoratePgProvisioningError } from "../../../../core/src/__test-utils__/pg-provisioning-diagnostics.js"; import { SelfHealingManager } from "../../self-healing.js"; export const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0; @@ -40,8 +41,8 @@ FNXC:SqliteRemoval 2026-07-14-00:00: The SQLite Database class was removed (VAL-REMOVAL-005). Reliability fixtures now require a PG-backed TaskStore. The engine-slow CI job and test-shards both provision a PG service container. Tests skip locally when PG is not reachable. -The TCP probe is duplicated from packages/core/src/__test-utils__/pg-test-harness.ts -because that module is not exported from @fusion/core's public API. +The TCP probe remains duplicated because pg-test-harness is not exported from @fusion/core's +public API, while provisioning diagnostics are shared from its test-only sibling module. */ const PG_TEST_URL_BASE = process.env.FUSION_PG_TEST_URL_BASE ?? "postgresql://localhost:5432"; @@ -135,7 +136,8 @@ async function adminExecAsync(statement: string, timeoutMs = 15_000): Promise {}); @@ -177,8 +179,13 @@ export async function createPgLayer(): Promise { directSessionProvenance: "migration-override", }; const schemaConn = await createConnectionSetFromUrl(backend, { poolMax: 1, connectTimeoutSeconds: 5 }); - await applySchemaBaseline(schemaConn.migration); - await schemaConn.close(); + try { + await applySchemaBaseline(schemaConn.migration); + } catch (error) { + throw decoratePgProvisioningError(error, PG_TEST_URL_BASE); + } finally { + await schemaConn.close(); + } /* FN-8764 built-in workflow-owner provisioning during AgentStore.init() requires a bound asyncLayer.projectId (the project-scoped advisory lock hashes it), so the reliability layer diff --git a/scripts/__tests__/pg-test-server.test.mjs b/scripts/__tests__/pg-test-server.test.mjs new file mode 100644 index 0000000000..1d4ee0cbeb --- /dev/null +++ b/scripts/__tests__/pg-test-server.test.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + classifyClusterHealth, + classifyOwnership, + classifyProbeGating, + deriveIdentityForUrl, + isEphemeralNativeRoot, + parseArgs, + planReplacement, + resolveTarget, + stagedRootFor, +} from "../pg-test-server.mjs"; + +const osUser = "node"; +const env = {}; + +test("rejects ephemeral roots and parses supported commands", () => { + assert.equal(isEphemeralNativeRoot(".worktrees/task/native"), true); + assert.equal(isEphemeralNativeRoot("/repo/.worktrees/task/native"), true); + assert.equal(isEphemeralNativeRoot("/opt/fusion/native"), false); + assert.deepEqual(parseArgs(["up", "--", "--port", "55432", "--replace", "--json"]), { command: "up", port: 55432, replace: true, purge: false, json: true, help: false }); + assert.throws(() => parseArgs(["bogus"]), /unknown argument/); + assert.match(stagedRootFor({ home: "/home/node/.fusion/pg-test-server", platform: "linux", arch: "arm64" }), /15\.18\.0-beta\.17-linux-arm64$/); +}); + +test("derives identity exactly as postgres URL and environment fallbacks", () => { + assert.deepEqual(deriveIdentityForUrl({ url: "postgresql://localhost:5432", env, osUsername: osUser }), { role: osUser, password: "", database: osUser }); + assert.deepEqual(deriveIdentityForUrl({ url: "postgresql://alice:p%40ss@localhost:5432", env, osUsername: osUser }), { role: "alice", password: "p@ss", database: "alice" }); + assert.deepEqual(deriveIdentityForUrl({ url: "postgresql://localhost:5432/fusion_test", env: { PGUSER: "fallback" }, osUsername: osUser }), { role: "fallback", password: "", database: "fusion_test" }); + assert.deepEqual(deriveIdentityForUrl({ url: "postgresql://localhost:5432", env: { PGUSERNAME: "preferred", PGUSER: "other", PGDATABASE: "db", PGPASSWORD: "pw" }, osUsername: osUser }), { role: "preferred", password: "pw", database: "db" }); +}); + +test("closes health gates over identities provisioning guarantees", () => { + const configured = "postgresql://alice:pw@localhost:55432/db"; + const bare = "postgresql://localhost:55432"; + assert.equal(classifyProbeGating({ probeUrl: configured, env, osUsername: osUser, ownership: "script-owned", roleCfg: "alice", databaseCfg: "db" }), "gating"); + assert.equal(classifyProbeGating({ probeUrl: bare, env, osUsername: osUser, ownership: "script-owned", roleCfg: "alice", databaseCfg: "db" }), "diagnostic"); + const health = classifyClusterHealth([ + { kind: "configured", ok: true, gating: true }, + { kind: "plpgsql", ok: true, gating: true }, + { kind: "bare", ok: false, gating: false, code: "28000", message: 'role "node" does not exist' }, + ]); + assert.equal(health.status, "healthy"); + assert.equal(health.defects.length, 0); + assert.equal(health.notes.length, 1); +}); + +test("classifies all blocking PostgreSQL readiness defects", () => { + assert.equal(classifyClusterHealth([{ ok: false, code: "58P01", message: 'could not access file "$libdir/plpgsql"' }]).status, "missing-plpgsql"); + assert.equal(classifyClusterHealth([{ ok: false, code: "28000", message: 'role "node" does not exist' }]).status, "missing-login-role"); + assert.equal(classifyClusterHealth([{ ok: false, code: "3D000", message: 'database "node" does not exist' }]).status, "missing-database"); + assert.equal(classifyClusterHealth([{ ok: false, code: "ECONNREFUSED" }]).status, "unreachable"); + const both = classifyClusterHealth([{ ok: false, code: "58P01", message: "$libdir/plpgsql" }, { ok: false, code: "28000" }]); + assert.equal(both.status, "missing-plpgsql"); + assert.deepEqual(both.defects, ["missing-plpgsql", "missing-login-role"]); +}); + +test("resolves target safeguards and replacement ownership", () => { + assert.equal(resolveTarget({ argv: { port: 55432 }, env: {}, osUsername: osUser }).port, 55432); + assert.equal(resolveTarget({ argv: {}, env: { FUSION_PG_TEST_URL_BASE: "not a url" }, osUsername: osUser }).refusal, "malformed-url"); + assert.equal(resolveTarget({ argv: {}, env: { FUSION_PG_TEST_URL_BASE: "postgresql://example.com:55432" }, osUsername: osUser }).refusal, "non-local-host"); + assert.equal(resolveTarget({ argv: {}, env: { FUSION_PG_TEST_URL_BASE: "postgresql://localhost:55432/path" }, osUsername: osUser }).warnings[0].code, "harness-url-concat"); + assert.equal(classifyOwnership({ dataDir: "/runtime", runtimeDataDir: "/runtime" }), "runtime-app-cluster"); + assert.equal(classifyOwnership({ dataDir: "/foreign", ownerMatches: false, executableIsPostgres: true }), "foreign-other-user"); + assert.equal(classifyOwnership({ dataDir: "/foreign", ownerMatches: true, executableIsPostgres: false }), "not-postgres"); + assert.equal(classifyOwnership({}), "foreign-unprovable"); + assert.equal(planReplacement({ ownership: "foreign-proven", health: { status: "healthy", defects: [] }, replaceRequested: true }), "reuse"); + assert.equal(planReplacement({ ownership: "foreign-proven", health: { status: "missing-login-role", defects: ["missing-login-role"] }, replaceRequested: true }), "refuse-foreign-identity-missing"); + assert.equal(planReplacement({ ownership: "foreign-proven", health: { status: "missing-plpgsql", defects: ["missing-plpgsql"] }, replaceRequested: true }), "stop-then-provision"); + assert.equal(planReplacement({ ownership: "foreign-other-user", health: { status: "missing-plpgsql", defects: ["missing-plpgsql"] }, replaceRequested: true }), "refuse-never-touch"); + assert.equal(planReplacement({ ownership: "not-postgres", health: { status: "missing-plpgsql", defects: ["missing-plpgsql"] }, replaceRequested: true }), "refuse-never-touch"); +}); diff --git a/scripts/pg-test-server.mjs b/scripts/pg-test-server.mjs new file mode 100644 index 0000000000..84ad876734 --- /dev/null +++ b/scripts/pg-test-server.mjs @@ -0,0 +1,332 @@ +#!/usr/bin/env node + +import { cpSync, chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, readlinkSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { homedir, userInfo } from "node:os"; +import { basename, dirname, isAbsolute, join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { URL, fileURLToPath } from "node:url"; + +const VERSION = "15.18.0-beta.17"; +const MARKER = ".fusion-pg-test-native.json"; +const OWNER_MARKER = "fusion-pg-test-server.json"; +const DEFAULT_URL = "postgresql://localhost:5432"; +const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]); +const RESERVED_PORTS = new Set((process.env.FUSION_RESERVED_PORTS ?? "4040").split(",").map(Number)); +const rootDir = dirname(fileURLToPath(import.meta.url)); + +/* +FNXC:PgTestProvisioning 2026-08-22-16:49: +FN-152 stages the pinned embedded-postgres payload before starting it, following +materializeEmbeddedPostgresRuntimeBinaries() in embedded-lifecycle.ts. A postmaster +computes $libdir from its executable, so a worktree payload can disappear underneath it. +*/ +export function isEphemeralNativeRoot(path) { + return !isAbsolute(path) || /(^|[\\/])\.worktrees([\\/]|$)|(^|[\\/])worktrees([\\/]|$)/.test(path); +} + +export function stagedRootFor({ home, version = VERSION, platform, arch }) { + return join(home, "native", `${version}-${platform}-${arch}`); +} + +export function parseArgs(argv) { + const out = { command: "up", port: undefined, replace: false, purge: false, json: false, help: false }; + const args = [...argv].filter((arg) => arg !== "--"); + if (["up", "status", "down"].includes(args[0])) out.command = args.shift(); + while (args.length) { + const arg = args.shift(); + if (arg === "--port") { const value = args.shift(); if (!value || !/^\d+$/.test(value)) throw new Error("--port requires an integer"); out.port = Number(value); } + else if (arg === "--replace") out.replace = true; + else if (arg === "--purge") out.purge = true; + else if (arg === "--json") out.json = true; + else if (arg === "--help" || arg === "-h") out.help = true; + else throw new Error(`unknown argument: ${arg}`); + } + return out; +} + +function platformPackage(platform, arch) { + if (platform !== "linux" || !["arm64", "x64"].includes(arch)) return null; + return `@embedded-postgres/${platform}-${arch}`; +} + +export function resolveNativeRootCandidates({ platform = process.platform, arch = process.arch, requireResolve, env = process.env }) { + const candidates = []; + if (env.FUSION_PG_TEST_NATIVE_ROOT) candidates.push(resolve(env.FUSION_PG_TEST_NATIVE_ROOT)); + const pkg = platformPackage(platform, arch); + if (!pkg) return candidates; + try { candidates.push(join(dirname(requireResolve(pkg)), "..", "native")); } catch { /* pnpm may hide optional platform packages from direct resolution. */ } + // pnpm's platform dependency is reachable through the embedded-postgres virtual store, not normal resolution. + const scanRoots = [process.cwd(), dirname(process.cwd()), env.FUSION_PG_TEST_MAIN_CHECKOUT].filter(Boolean); + for (const scanRoot of scanRoots) { + const store = join(scanRoot, "node_modules", ".pnpm"); + try { + const prefix = pkg.replace("/", "+") + "@"; + const entry = readdirSync(store).find((name) => name.startsWith(prefix)); + if (entry) candidates.push(join(store, entry, "node_modules", ...pkg.split("/"), "native")); + } catch { /* Continue to the next optional pnpm store location. */ } + } + return [...new Set(candidates)].filter((candidate) => existsSync(join(candidate, "bin", "postgres"))); +} + +function resolveNativeRoot(opts = {}) { + const requireResolve = createRequire(join(process.cwd(), "package.json")).resolve; + const candidates = resolveNativeRootCandidates({ requireResolve, ...opts }); + const durable = candidates.find((candidate) => !isEphemeralNativeRoot(candidate)); + return { source: durable ?? candidates[0] ?? null, candidates }; +} + +export function deriveIdentityForUrl({ url, env = process.env, osUsername = userInfo().username }) { + const parsed = typeof url === "string" ? new URL(url) : url; + const role = decodeURIComponent(parsed.username || "") || env.PGUSERNAME || env.PGUSER || osUsername; + const password = decodeURIComponent(parsed.password || "") || env.PGPASSWORD || ""; + const pathDatabase = parsed.pathname === "/" ? "" : parsed.pathname.slice(1); + return { role, password, database: pathDatabase || env.PGDATABASE || role }; +} + +export function resolveTarget({ argv = {}, env = process.env, osUsername = userInfo().username }) { + let url; + try { url = new URL(env.FUSION_PG_TEST_URL_BASE ?? DEFAULT_URL); } catch { return { refusal: "malformed-url", message: "FUSION_PG_TEST_URL_BASE must be a valid PostgreSQL URL." }; } + const envPort = url.port ? Number(url.port) : Number(env.PGPORT || 5432); + const host = url.hostname || env.PGHOST || "localhost"; + const port = argv.port ?? envPort; + if (!Number.isInteger(port) || port < 1024 || port > 65535) return { refusal: "invalid-port", message: "--port must be an integer in 1024..65535." }; + if (argv.port && env.FUSION_PG_TEST_URL_BASE && url.port && argv.port !== Number(url.port)) return { refusal: "port-conflict", message: `--port ${argv.port} conflicts with URL port ${url.port}. export FUSION_PG_TEST_URL_BASE=${url.toString()}` }; + if (!LOCAL_HOSTS.has(host)) return { refusal: "non-local-host", message: `Cannot provision remote host ${host}.` }; + // port-4040-allowlist: this is a refusal guard, not a process-management operation. + if (RESERVED_PORTS.has(port)) return { refusal: "reserved-port", message: `Port ${port} is reserved and cannot host test PostgreSQL.` }; + const probeHost = url.hostname || "localhost"; + const probePort = url.port ? Number(url.port) : 5432; + if ((!url.port && env.PGPORT && Number(env.PGPORT) !== probePort) || (!url.hostname && env.PGHOST && env.PGHOST !== probeHost)) return { refusal: "env-target-divergence", message: `PGHOST/PGPORT diverge from harness probe ${probeHost}:${probePort}; export FUSION_PG_TEST_URL_BASE=postgresql://${host}:${port}` }; + const identity = deriveIdentityForUrl({ url, env, osUsername }); + const bareUrl = new URL(`postgresql://${host}:${port}`); + const bareIdentity = deriveIdentityForUrl({ url: bareUrl, env, osUsername }); + const warnings = []; + if (url.pathname !== "") warnings.push({ code: "harness-url-concat", message: `Harnesses concatenate \${PG_TEST_URL_BASE}/\${dbName} at 24 sites; use export FUSION_PG_TEST_URL_BASE=postgresql://${host}:${port}` }); + return { host, port, urlBase: url.toString(), role: identity.role, password: identity.password, database: identity.database, bareIdentity, warnings }; +} + +export function classifyProbeGating({ probeUrl, env = process.env, osUsername, ownership, roleCfg, databaseCfg, probeDb }) { + const identity = deriveIdentityForUrl({ url: probeUrl, env, osUsername }); + const allowedUser = identity.role === roleCfg || (ownership === "script-owned" && identity.role === "postgres"); + const allowedDatabase = [databaseCfg, "postgres", probeDb].filter(Boolean).includes(identity.database); + return allowedUser && allowedDatabase ? "gating" : "diagnostic"; +} + +function defectFor(result) { + if (result?.ok) return null; + const code = result?.code ?? result?.error?.code; + const message = result?.message ?? result?.error?.message ?? ""; + if (code === "ECONNREFUSED") return "unreachable"; + if (result?.notPostgres) return "not-postgres"; + if (code === "58P01" && message.includes("$libdir/plpgsql")) return "missing-plpgsql"; + if (code === "28000") return "missing-login-role"; + if (code === "3D000") return "missing-database"; + if (result?.kind === "admin-ddl") return "admin-ddl-denied"; + return "admin-ddl-denied"; +} + +export function classifyClusterHealth(results = []) { + const gating = results.filter((result) => result.gating !== false); + const notes = results.filter((result) => result.gating === false); + const defects = gating.map(defectFor).filter(Boolean); + const order = ["unreachable", "not-postgres", "missing-plpgsql", "missing-login-role", "missing-database", "admin-ddl-denied"]; + return { status: order.find((status) => defects.includes(status)) ?? "healthy", defects: [...new Set(defects)], notes }; +} + +export function classifyOwnership(facts = {}) { + if (facts.notPostgres || facts.executableIsPostgres === false) return "not-postgres"; + if (facts.pidAlive === false) return "stale-pid"; + if (!facts.dataDir) return "foreign-unprovable"; + if (facts.runtimeDataDir && resolve(facts.dataDir) === resolve(facts.runtimeDataDir)) return "runtime-app-cluster"; + if (facts.ownerMatches === false) return "foreign-other-user"; + if (facts.serverHome && facts.markerMatches && resolve(facts.dataDir).startsWith(resolve(facts.serverHome) + "/")) return "script-owned"; + return "foreign-proven"; +} + +export function planReplacement({ ownership, health, replaceRequested }) { + if (health.status === "healthy") return "reuse"; + const identityOnly = ["missing-login-role", "missing-database"].includes(health.status) && !health.defects.includes("missing-plpgsql"); + if (["runtime-app-cluster", "foreign-other-user", "not-postgres"].includes(ownership)) return "refuse-never-touch"; + if (ownership === "foreign-unprovable") return "refuse-unprovable"; + if (ownership === "script-owned" && identityOnly) return "repair-identity-in-place"; + if (ownership !== "script-owned" && identityOnly) return "refuse-foreign-identity-missing"; + if (!replaceRequested) return "refuse-needs-replace"; + return ownership === "foreign-proven" ? "stop-then-provision" : "provision-fresh"; +} + +export function describeRemediation(status, ctx = {}) { + const command = status === "missing-plpgsql" ? "pnpm pg:test:up -- --replace" : "pnpm pg:test:up"; + if (status === "missing-login-role" || status === "missing-database") { + if (ctx.ownership?.startsWith("foreign")) return `${command}\nCREATE ROLE "${ctx.role}" WITH SUPERUSER LOGIN${ctx.password ? ` PASSWORD '${ctx.password.replace(/'/g, "''")}'` : ""};\nCREATE DATABASE "${ctx.database}" OWNER "${ctx.role}";`; + return `${command}; PostgreSQL also needs the configured database.`; + } + return `${command}`; +} + +function serverHome(env) { return env.FUSION_PG_TEST_SERVER_HOME || join(homedir(), ".fusion", "pg-test-server"); } +function clusterRoot(target, env) { return join(serverHome(env), "clusters", `${VERSION}-${process.platform}-${process.arch}-${target.port}`); } +function ownerMarker(dataDir) { return join(dirname(dataDir), OWNER_MARKER); } +function markerMatches(dataDir, home) { try { const marker = JSON.parse(readFileSync(ownerMarker(dataDir), "utf8")); return marker.dataDir === dataDir && marker.owner === "fusion-pg-test-server" && resolve(dataDir).startsWith(resolve(home) + "/"); } catch { return false; } } +function run(bin, args, options = {}) { const result = spawnSync(bin, args, { encoding: "utf8", stdio: "pipe", ...options }); if (result.status !== 0) throw new Error(`${basename(bin)} failed: ${result.stderr || result.stdout}`); return result; } +function stageNative(source, target) { + const marker = join(target, MARKER); const expected = JSON.stringify({ source: resolve(source), version: VERSION }); + if (existsSync(marker) && readFileSync(marker, "utf8") === expected && existsSync(join(target, "bin", "postgres"))) return target; + rmSync(target, { recursive: true, force: true }); mkdirSync(target, { recursive: true }); + for (const entry of readdirSync(source)) cpSync(join(source, entry), join(target, entry), { recursive: true, force: true, verbatimSymlinks: true }); + for (const entry of readdirSync(join(target, "bin"))) chmodSync(join(target, "bin", entry), 0o755); + writeFileSync(marker, expected); return target; +} +function postgresClient() { + const loaded = createRequire(join(process.cwd(), "packages/core/package.json"))("postgres"); + return loaded.default ?? loaded; +} +async function probe(target, ownership = "foreign-proven", { includeDdl = true } = {}) { + const postgres = postgresClient(); const results = []; + const configured = new URL(target.urlBase); + const maintenance = new URL(target.urlBase); maintenance.pathname = "/postgres"; + const probeDb = `fusion_pg_test_probe_${process.pid}_${Math.random().toString(36).slice(2, 10)}`; + const gatingFor = (url) => classifyProbeGating({ probeUrl: url, env: process.env, osUsername: userInfo().username, ownership, roleCfg: target.role, databaseCfg: target.database, probeDb }) === "gating"; + const execute = async (kind, url, sql, extra = {}) => { + const client = postgres(url.toString(), { max: 1, prepare: false, connect_timeout: 3, onnotice: () => {} }); + try { await client.unsafe(sql); results.push({ kind, gating: gatingFor(url), ok: true, ...extra }); } + catch (error) { results.push({ kind, gating: gatingFor(url), ok: false, code: error?.code, message: error?.message, ...extra }); } + finally { await client.end({ timeout: 2 }).catch(() => {}); } + }; + await execute("configured", configured, "SELECT 1"); + await execute("plpgsql", configured, "DO $$ BEGIN PERFORM 1; END $$;"); + await execute("maintenance", maintenance, "SELECT 1"); + await execute("bare", new URL(`postgresql://${target.host}:${target.port}`), "SELECT 1"); + await execute("ci", new URL(`postgresql://postgres:postgres@${target.host}:${target.port}`), "SELECT 1"); + + if (includeDdl) { + // FNXC:PgTestProvisioning 2026-08-22-17:07: FN-152 requires the same create/drop capability the harness uses, and cleanup must run even when creation or dropping fails. + const ddl = postgres(maintenance.toString(), { max: 1, prepare: false, connect_timeout: 3, onnotice: () => {} }); + const ddlGating = gatingFor(maintenance); + let ddlCreated = false; + try { + await ddl.unsafe(`CREATE DATABASE ${quote(probeDb)}`); + ddlCreated = true; + results.push({ kind: "admin-ddl", gating: ddlGating, ok: true }); + } catch (error) { + results.push({ kind: "admin-ddl", gating: ddlGating, ok: false, code: error?.code, message: error?.message }); + } finally { + // FNXC:PgTestProvisioning 2026-08-22-17:07: Keep the scratch database alive through the concat probe; otherwise FN-152 would only prove cleanup, not harness URL readiness. + if (ddlCreated) { + const concatUrl = new URL(`${target.urlBase}/${probeDb}`); + const concatGating = target.warnings.some((warning) => warning.code === "harness-url-concat") ? false : gatingFor(concatUrl); + const concat = postgres(concatUrl.toString(), { max: 1, prepare: false, connect_timeout: 3, onnotice: () => {} }); + try { await concat.unsafe("SELECT 1"); results.push({ kind: "harness-concat", gating: concatGating, ok: true }); } + catch (error) { results.push({ kind: "harness-concat", gating: concatGating, ok: false, code: error?.code, message: error?.message, warningEvidence: !concatGating }); } + finally { await concat.end({ timeout: 2 }).catch(() => {}); } + try { await ddl.unsafe(`DROP DATABASE ${quote(probeDb)}`); } + catch (error) { results.push({ kind: "admin-ddl", gating: ddlGating, ok: false, code: error?.code, message: error?.message }); } + } + await ddl.end({ timeout: 2 }).catch(() => {}); + } + } + + // A configured-role failure must not hide the independent PL/pgSQL diagnosis. + if (results.some((result) => result.kind === "configured" && !result.ok)) { + const fallback = postgres(`postgresql://postgres:postgres@${target.host}:${target.port}/postgres`, { max: 1, prepare: false, connect_timeout: 3, onnotice: () => {} }); + try { await fallback.unsafe("DO $$ BEGIN PERFORM 1; END $$;"); results.push({ kind: "fallback-plpgsql", gating: true, ok: true }); } + catch (error) { results.push({ kind: "fallback-plpgsql", gating: true, ok: false, code: error?.code, message: error?.message }); } + finally { await fallback.end({ timeout: 2 }).catch(() => {}); } + } + return classifyClusterHealth(results); +} + +async function inspectServer(target, home) { + const postgres = postgresClient(); + for (const url of [target.urlBase, `postgresql://postgres:postgres@${target.host}:${target.port}/postgres`]) { + const client = postgres(url, { max: 1, prepare: false, connect_timeout: 3, onnotice: () => {} }); + try { + const rows = await client.unsafe("SELECT current_setting('data_directory') AS data_directory, current_setting('port') AS port"); + const dataDir = rows[0]?.data_directory; + const pidLines = dataDir ? readFileSync(join(dataDir, "postmaster.pid"), "utf8").split("\n") : []; + const pid = Number(pidLines[0]); const pidPort = Number(pidLines[3]); + let pidAlive = Number.isFinite(pid) && pid > 0; + let ownerMatches = false; + let executableIsPostgres = false; + let executablePath = null; + if (pidAlive) { + try { + process.kill(pid, 0); + ownerMatches = typeof process.getuid !== "function" || statSync(`/proc/${pid}`).uid === process.getuid(); + executablePath = readlinkSync(`/proc/${pid}/exe`); + executableIsPostgres = basename(executablePath.replace(/ \(deleted\)$/, "")) === "postgres"; + } catch { pidAlive = false; } + } + return { dataDir, reportedPort: Number(rows[0]?.port), pid, pidPort, pidAlive, ownerMatches, executableIsPostgres, executablePath, serverHome: home, markerMatches: dataDir ? markerMatches(dataDir, home) : false, runtimeDataDir: join(homedir(), ".fusion", "embedded-postgres", "default") }; + } catch { /* Try the diagnostic admin identity before declaring ownership unprovable. */ } finally { await client.end({ timeout: 2 }).catch(() => {}); } + } + return { dataDir: null, serverHome: home }; +} +async function provisionIdentity(target) { + const postgres = postgresClient(); const maintenance = new URL(`postgresql://postgres@${target.host}:${target.port}/postgres`); + const client = postgres(maintenance.toString(), { max: 1, prepare: false, onnotice: () => {} }); + try { + const roleExists = await client.unsafe("SELECT 1 FROM pg_roles WHERE rolname = $1", [target.role]); + if (!roleExists.length) await client.unsafe(`CREATE ROLE ${quote(target.role)} WITH SUPERUSER LOGIN${target.password ? ` PASSWORD ${quoteLiteral(target.password)}` : ""}`); + else if (target.password) await client.unsafe(`ALTER ROLE ${quote(target.role)} PASSWORD ${quoteLiteral(target.password)}`); + const dbExists = await client.unsafe("SELECT 1 FROM pg_database WHERE datname = $1", [target.database]); + if (!dbExists.length) await client.unsafe(`CREATE DATABASE ${quote(target.database)} OWNER ${quote(target.role)}`); + } finally { await client.end({ timeout: 2 }).catch(() => {}); } +} +function quote(value) { return `"${String(value).replace(/"/g, '""')}"`; } +function quoteLiteral(value) { return `'${String(value).replace(/'/g, "''")}'`; } +function print(value, json) { console.log(json ? JSON.stringify(value) : typeof value === "string" ? value : JSON.stringify(value, null, 2)); } +function help() { console.log("Usage: pnpm pg:test:up [-- --port N|--replace|--json]\n pnpm pg:test:status [-- --port N|--json]\n pnpm pg:test:down [-- --purge|--port N]\nUse the same FUSION_PG_TEST_URL_BASE for up, status, and down."); } + +async function main() { + const args = parseArgs(process.argv.slice(2)); if (args.help) return help(); + if (["darwin", "win32"].includes(process.platform)) throw new Error(`Unsupported platform ${process.platform}; set FUSION_PG_TEST_URL_BASE to a manually provisioned server.`); + const target = resolveTarget({ argv: args }); if (target.refusal) throw new Error(`${target.refusal}: ${target.message}`); + const home = serverHome(process.env); const dataDir = join(clusterRoot(target, process.env), "data"); + let health; try { health = await probe(target, markerMatches(dataDir, home) ? "script-owned" : "foreign-proven", { includeDdl: args.command === "up" }); } catch { health = { status: "unreachable", defects: ["unreachable"], notes: [] }; } + if (args.command === "status") { print({ target, health }, args.json); if (health.status !== "healthy") process.exitCode = 1; return; } + if (args.command === "down") { + if (!markerMatches(dataDir, home)) throw new Error("Refusing to stop foreign PostgreSQL cluster."); + const staged = stagedRootFor({ home, version: VERSION, platform: process.platform, arch: process.arch }); run(join(staged, "bin", "pg_ctl"), ["-D", dataDir, "-m", "fast", "stop"]); + if (args.purge) rmSync(dirname(dataDir), { recursive: true, force: true }); return; + } + const facts = health.status === "unreachable" ? { dataDir: null, serverHome: home } : await inspectServer(target, home); + let ownership = classifyOwnership(facts); + if (facts.dataDir && facts.pidPort && (facts.pidPort !== target.port || facts.reportedPort !== target.port)) ownership = "foreign-unprovable"; + let plan = planReplacement({ ownership, health, replaceRequested: args.replace }); + if (health.status === "unreachable") plan = "provision-fresh"; + if (plan === "reuse") { print({ status: "healthy", target, health, export: `export FUSION_PG_TEST_URL_BASE=${target.urlBase}` }, args.json); return; } + if (plan === "refuse-foreign-identity-missing" || plan.startsWith("refuse")) throw new Error(`${plan}: ${describeRemediation(health.status, { ownership, ...target })}`); + const { source } = resolveNativeRoot(); if (!source) throw new Error("No embedded PostgreSQL payload found. Install dependencies or set FUSION_PG_TEST_NATIVE_ROOT."); + const staged = stageNative(source, stagedRootFor({ home, version: VERSION, platform: process.platform, arch: process.arch })); + if (plan === "stop-then-provision") { + if (!facts.dataDir || !facts.pidAlive || !facts.ownerMatches || !facts.executableIsPostgres || facts.pidPort !== target.port || facts.reportedPort !== target.port) throw new Error("Replacement ownership proof chain failed; refusing to signal PostgreSQL."); + console.log(`Reclaiming broken PostgreSQL PID ${facts.pid} at ${facts.dataDir} (${facts.executablePath}).`); + try { run(join(staged, "bin", "pg_ctl"), ["-D", facts.dataDir, "-m", "fast", "stop"]); } + catch { process.kill(facts.pid, "SIGINT"); } + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { await new Promise((done) => setTimeout(done, 250)); const next = await probe(target, ownership, { includeDdl: false }); if (next.status === "unreachable") break; } + const stopped = await probe(target, ownership, { includeDdl: false }); + if (stopped.status !== "unreachable") { process.kill(facts.pid, "SIGQUIT"); await new Promise((done) => setTimeout(done, 1_000)); } + const final = await probe(target, ownership, { includeDdl: false }); + if (final.status !== "unreachable") throw new Error("PostgreSQL did not stop after bounded graceful shutdown; refusing replacement."); + ownership = "script-owned"; + } + const initialized = existsSync(join(dataDir, "PG_VERSION")); + if (!initialized) { + mkdirSync(dataDir, { recursive: true }); const passwordFile = join(dirname(dataDir), `.pw-${process.pid}`); writeFileSync(passwordFile, "postgres\n", { mode: 0o600 }); + try { run(join(staged, "bin", "initdb"), ["-D", dataDir, "-U", "postgres", `--pwfile=${passwordFile}`, "--auth-local=trust", "--auth-host=trust", "--encoding=UTF8", "--locale=C"]); } + finally { rmSync(passwordFile, { force: true }); } + } + if (!initialized || health.status === "unreachable") { + run(join(staged, "bin", "pg_ctl"), ["-D", dataDir, "-l", join(dirname(dataDir), "postgres.log"), "-o", `-p ${target.port} -c listen_addresses=localhost`, "start"]); + } + if (!existsSync(ownerMarker(dataDir))) { + writeFileSync(ownerMarker(dataDir), JSON.stringify({ owner: "fusion-pg-test-server", version: VERSION, platform: process.platform, arch: process.arch, port: target.port, dataDir, role: target.role, database: target.database, createdAt: new Date().toISOString() }, null, 2)); + } + await provisionIdentity(target); health = await probe(target, "script-owned"); + if (health.status !== "healthy") throw new Error(`Provisioned server failed health checks: ${health.status}; ${describeRemediation(health.status, { ownership: "script-owned", ...target })}`); + print({ status: target.warnings.length ? "provisioned-with-warnings" : "healthy", target, health, export: `export FUSION_PG_TEST_URL_BASE=${target.urlBase}` }, args.json); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main().catch((error) => { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; });