138 lines
12 KiB
TypeScript
138 lines
12 KiB
TypeScript
/** Test-only adopted-writer capture regression fixture. Never imported by
|
|
* startup, restore, dispatch or READY. Raw/unadopted SQL is NOT fenced. */
|
|
import { createHash } from "node:crypto"
|
|
import { resolve } from "node:path"
|
|
import { pathToFileURL } from "node:url"
|
|
if (process.env.MMAPP_OWN_SOURCE_CAPTURE !== "true" || !process.env.MMAPP_OWN_SOURCE_CAPTURE_ROOT || process.env.DATABASE_URL) throw Error("Owned capture runner required")
|
|
const source = (path: string) => pathToFileURL(resolve(process.env.MMAPP_OWN_SOURCE_CAPTURE_ROOT!, "backend/src", path)).href
|
|
const { PARTITION_WRITER_ADVISORY_KEY } = await import(source("services/partition-write-transaction.ts")) as typeof import("../services/partition-write-transaction.js")
|
|
const { captureMigrationData } = await import(source("db/migration-data-inventory.ts")) as typeof import("./migration-data-inventory.js")
|
|
export interface CaptureClient {
|
|
query(sql: string, values?: unknown[]): Promise<{ rows: Record<string, unknown>[]; command?: string }>
|
|
release(destroy?: boolean): void
|
|
}
|
|
export interface CapturePool { connect(): Promise<CaptureClient> }
|
|
export interface CapturePolicy { databaseName: string; captureRole: string; runtimeRole: string; timeoutMs: number }
|
|
export class AdoptedSourceCaptureError extends Error {
|
|
readonly code = "ADOPTED_SOURCE_CAPTURE_REFUSED"
|
|
constructor(message: string, readonly outcomeUnknown = false, options?: ErrorOptions) { super(message, options) }
|
|
}
|
|
const fail = (message: string): never => { throw new AdoptedSourceCaptureError(message) }
|
|
const digest = (value: unknown) => createHash("sha256").update(JSON.stringify(value)).digest("hex")
|
|
export const CAPTURE_SQL = Object.freeze({
|
|
roleClosure: `WITH RECURSIVE roles(oid) AS (
|
|
SELECT oid FROM pg_catalog.pg_roles WHERE rolname=$1
|
|
UNION SELECT m.roleid FROM pg_catalog.pg_auth_members m JOIN roles r ON m.member=r.oid)
|
|
SELECT r.oid::text AS oid,r.rolname,r.rolsuper,r.rolcreaterole,r.rolcreatedb,r.rolreplication,r.rolbypassrls,r.rolcanlogin
|
|
FROM pg_catalog.pg_roles r JOIN roles c ON c.oid=r.oid ORDER BY r.oid LIMIT 129`,
|
|
privilege: `SELECT
|
|
EXISTS(SELECT 1 FROM pg_catalog.pg_database d WHERE d.datname=pg_catalog.current_database() AND (d.datdba=ANY($1::oid[]) OR EXISTS(SELECT 1 FROM unnest($1::oid[]) r WHERE pg_catalog.has_database_privilege(r,d.oid,'CREATE')))) AS database_owner_or_create,
|
|
EXISTS(SELECT 1 FROM pg_catalog.pg_namespace n WHERE n.nspname='public' AND (n.nspowner=ANY($1::oid[]) OR EXISTS(SELECT 1 FROM unnest($1::oid[]) r WHERE pg_catalog.has_schema_privilege(r,n.oid,'CREATE')))) AS schema_owner_or_create,
|
|
EXISTS(SELECT 1 FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid=c.relnamespace WHERE n.nspname='public' AND c.relowner=ANY($1::oid[])) AS relation_owner,
|
|
EXISTS(SELECT 1 FROM pg_catalog.pg_proc p JOIN pg_catalog.pg_namespace n ON n.oid=p.pronamespace WHERE n.nspname='public' AND p.proowner=ANY($1::oid[])) AS routine_owner,
|
|
EXISTS(SELECT 1 FROM pg_catalog.pg_proc p JOIN pg_catalog.pg_namespace n ON n.oid=p.pronamespace WHERE p.prosecdef AND n.nspname NOT IN ('pg_catalog','information_schema') AND EXISTS(SELECT 1 FROM unnest($1::oid[]) r WHERE pg_catalog.has_function_privilege(r,p.oid,'EXECUTE'))) AS callable_security_definer`,
|
|
ownLock: `SELECT count(*)::int AS n FROM pg_catalog.pg_locks WHERE locktype='advisory' AND pid=pg_catalog.pg_backend_pid() AND classid=0 AND objid=$1::oid AND objsubid=1 AND mode='ExclusiveLock' AND granted`,
|
|
context: `SELECT d.oid::text AS database_oid,d.datname,SESSION_USER::text AS session_user,CURRENT_USER::text AS current_user,
|
|
pg_catalog.pg_backend_pid() AS backend_pid,c.system_identifier::text AS system_identifier,
|
|
pg_catalog.pg_current_snapshot()::text AS snapshot,pg_catalog.transaction_timestamp()::text AS transaction_started,
|
|
pg_catalog.current_setting('transaction_isolation') AS isolation,pg_catalog.current_setting('transaction_read_only') AS read_only,
|
|
pg_catalog.pg_is_in_recovery() AS in_recovery
|
|
FROM pg_catalog.pg_database d CROSS JOIN pg_catalog.pg_control_system() c WHERE d.datname=pg_catalog.current_database()`,
|
|
})
|
|
function policy(raw: CapturePolicy) {
|
|
if (!raw || Object.getPrototypeOf(raw) !== Object.prototype || Object.keys(raw).sort().join(" ") !== "captureRole databaseName runtimeRole timeoutMs" ||
|
|
Object.values(Object.getOwnPropertyDescriptors(raw)).some(d => !("value" in d) || !d.enumerable) || Reflect.ownKeys(raw).length !== 4 ||
|
|
![raw.databaseName, raw.captureRole, raw.runtimeRole].every(v => typeof v === "string" && /^[a-z][a-z0-9_]{0,62}$/.test(v)) ||
|
|
raw.captureRole === raw.runtimeRole || !Number.isSafeInteger(raw.timeoutMs) || raw.timeoutMs < 100 || raw.timeoutMs > 30000) fail("Exact bounded trusted capture policy required")
|
|
return Object.freeze(structuredClone(raw))
|
|
}
|
|
/** Owns the actual exclusive lock on the SAME checked-out backend used for
|
|
* capture. It waits for existing adopted transactions and blocks later adopted
|
|
* transactions/DDL before their first relation access. No lock callback seam. */
|
|
export async function openAdoptedSourceCapture(pool: CapturePool, raw: CapturePolicy, options: { signal?: AbortSignal } = {}) {
|
|
const cfg = policy(raw)
|
|
if (options.signal?.aborted) fail("Capture aborted before checkout")
|
|
const client = await pool.connect()
|
|
const deadlineAt = Date.now() + cfg.timeoutMs
|
|
let closed = false, busy = false, used = false, transaction = false, commitSent = false
|
|
const release = (discard: boolean) => {
|
|
if (!closed) { closed = true; clearTimeout(timer); options.signal?.removeEventListener("abort", onAbort); client.release(discard) }
|
|
}
|
|
const destroy = () => release(true)
|
|
const onAbort = () => destroy()
|
|
const timer = setTimeout(destroy, cfg.timeoutMs); timer.unref()
|
|
options.signal?.addEventListener("abort", onAbort, { once: true })
|
|
const fence = () => {
|
|
if (closed || options.signal?.aborted || Date.now() >= deadlineAt) { destroy(); fail("Capture closed, aborted or expired") }
|
|
}
|
|
const query: CaptureClient["query"] = async (sql, values) => {
|
|
fence(); const result = await client.query(sql, values); fence(); return result
|
|
}
|
|
async function rolePolicy() {
|
|
const roles = (await query(CAPTURE_SQL.roleClosure, [cfg.runtimeRole])).rows
|
|
if (!roles.length || roles.length > 128 || !roles.some(r => r.rolname === cfg.runtimeRole && r.rolcanlogin === true) || roles.some(r =>
|
|
typeof r.oid !== "string" || !/^[1-9][0-9]*$/.test(r.oid) || typeof r.rolname !== "string" || r.rolname.startsWith("pg_") ||
|
|
[r.rolsuper,r.rolcreaterole,r.rolcreatedb,r.rolreplication,r.rolbypassrls].some(v => v !== false))) fail("Runtime role/membership has privileged bypass")
|
|
const rows = (await query(CAPTURE_SQL.privilege, [roles.map(r => r.oid)])).rows
|
|
if (rows.length !== 1 || Object.values(rows[0]).some(v => v !== false)) fail("Runtime role can own/create application schema or execute security-definer bypass")
|
|
return digest({ roles, privileges: rows[0] })
|
|
}
|
|
async function ownedLock() { const rows = (await query(CAPTURE_SQL.ownLock, [PARTITION_WRITER_ADVISORY_KEY])).rows; if (rows.length !== 1 || rows[0].n !== 1) fail("Same-backend exclusive adopted-writer lease missing") }
|
|
try {
|
|
await query("SET lock_timeout='" + cfg.timeoutMs + "ms'")
|
|
await query("SET statement_timeout='" + cfg.timeoutMs + "ms'")
|
|
const identity = (await query("SELECT pg_catalog.current_database() AS database_name,CURRENT_USER::text AS current_user,SESSION_USER::text AS session_user")).rows
|
|
if (identity.length !== 1 || identity[0].database_name !== cfg.databaseName || identity[0].current_user !== cfg.captureRole || identity[0].session_user !== cfg.captureRole) fail("Capture connection is not the explicitly provisioned source/operator")
|
|
// Authenticate privileges before waiting: a superuser/owner application
|
|
// role must not be mislabeled as an enforceable limited writer.
|
|
const rolePolicySha256 = await rolePolicy()
|
|
// SESSION lease in autocommit first: its waiting SELECT must finish BEFORE
|
|
// BEGIN RR. Taking an xact lock inside RR could pin an old snapshot while
|
|
// waiting and miss the writer's COMMIT even after the lock is granted.
|
|
await query("SELECT pg_catalog.pg_advisory_lock($1::bigint)", [PARTITION_WRITER_ADVISORY_KEY]); await ownedLock()
|
|
return Object.freeze({
|
|
async capture() {
|
|
if (closed || busy || used) fail("Capture handle closed/concurrent/reused")
|
|
used = true; busy = true
|
|
try {
|
|
await ownedLock()
|
|
if (await rolePolicy() !== rolePolicySha256) fail("Role policy changed while acquiring writer drain")
|
|
await query("BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY"); transaction = true
|
|
for (const statement of ["SET LOCAL search_path=pg_catalog", "SET LOCAL TimeZone='UTC'", "SET LOCAL DateStyle='ISO, YMD'", "SET LOCAL standard_conforming_strings=on", "SET LOCAL statement_timeout='5s'",
|
|
"SET LOCAL row_security=off", "SET LOCAL extra_float_digits=3", "SET LOCAL bytea_output=hex", "SET LOCAL IntervalStyle=postgres", "SET LOCAL client_encoding='UTF8'", "SET LOCAL lc_monetary='C'", "SET LOCAL lc_numeric='C'"]) await query(statement)
|
|
const first = (await query(CAPTURE_SQL.context)).rows
|
|
if (first.length !== 1 || first[0].datname !== cfg.databaseName || first[0].current_user !== cfg.captureRole || first[0].session_user !== cfg.captureRole || first[0].isolation !== "repeatable read" || first[0].read_only !== "on" || first[0].in_recovery !== false) fail("Owned source snapshot context unavailable")
|
|
const captured = await captureMigrationData({ query }, { scope: { applicationNamespaces: ["public"], excludedControlNamespaces: ["mmapp_control"] }, deadlineAt, maxRows: 100000, maxBytes: 16777216 })
|
|
const last = (await query(CAPTURE_SQL.context)).rows
|
|
if (JSON.stringify(first) !== JSON.stringify(last)) fail("Source/backend/snapshot changed across schema/data capture")
|
|
await ownedLock(); commitSent = true
|
|
const committed = await query("COMMIT")
|
|
if (committed.command !== "COMMIT") fail("Owned capture COMMIT acknowledgement unavailable")
|
|
transaction = false
|
|
// Role catalogs are re-read OUTSIDE the old RR snapshot so membership
|
|
// changes during capture are not hidden by snapshot consistency.
|
|
if (await rolePolicy() !== rolePolicySha256) fail("Role policy changed during capture")
|
|
await ownedLock()
|
|
return Object.freeze({ format: "adopted-source-capture-v1" as const, source: Object.freeze(structuredClone(first[0])),
|
|
rolePolicySha256, schemaSha256: captured.schemaSha256, dataSha256: captured.sha256, rowCount: captured.rowCount, sequenceCount: captured.sequences.length,
|
|
adoptedWriterDrainAuthenticated: true as const, ownedRepeatableReadCaptureAuthenticated: true as const,
|
|
allWriterDrainAuthenticated: false as const, sourceGenerationAuthenticated: false as const, readyAuthorized: false as const })
|
|
} catch (cause) {
|
|
destroy()
|
|
throw new AdoptedSourceCaptureError("Owned adopted-writer source capture refused; no replay", transaction || commitSent, { cause })
|
|
} finally { busy = false }
|
|
},
|
|
async close() {
|
|
if (closed) return
|
|
if (busy) fail("Capture still active")
|
|
try {
|
|
const released = (await query("SELECT pg_catalog.pg_advisory_unlock($1::bigint) AS unlocked", [PARTITION_WRITER_ADVISORY_KEY])).rows
|
|
if (released.length !== 1 || released[0].unlocked !== true) fail("Adopted-writer lease release unknown")
|
|
await query("RESET lock_timeout"); await query("RESET statement_timeout")
|
|
release(false)
|
|
} catch (cause) { destroy(); throw new AdoptedSourceCaptureError("Capture owner release unconfirmed", true, { cause }) }
|
|
},
|
|
})
|
|
} catch (cause) { destroy(); throw cause }
|
|
}
|