|
1 | 1 | import { spawn } from "node:child_process"; |
| 2 | +import { mkdtemp, rm, writeFile } from "node:fs/promises"; |
| 3 | +import { tmpdir } from "node:os"; |
| 4 | +import { join } from "node:path"; |
2 | 5 | import { controlPool } from "./db"; |
3 | 6 | import { config } from "./config"; |
4 | 7 | import { |
@@ -52,61 +55,122 @@ function connOf(url: string): Conn { |
52 | 55 | }; |
53 | 56 | } |
54 | 57 |
|
55 | | -/** Pipe `pg_dump` (source) → `pg_restore` (target), resolving on clean exit. */ |
56 | | -function dumpRestore(src: Conn, dst: Conn, database: string): Promise<void> { |
| 58 | +/** Run a pg CLI to completion; resolves with stdout, rejects on non-zero exit. */ |
| 59 | +function runPg(cmd: string, args: string[], password: string): Promise<string> { |
57 | 60 | return new Promise((resolve, reject) => { |
| 61 | + const p = spawn(cmd, args, { env: { ...process.env, PGPASSWORD: password } }); |
| 62 | + let out = ""; |
| 63 | + let err = ""; |
| 64 | + p.stdout.on("data", (b) => (out += b.toString())); |
| 65 | + p.stderr.on("data", (b) => (err += b.toString())); |
| 66 | + p.on("error", (e) => reject(new Error(`${cmd} spawn failed: ${e.message}`))); |
| 67 | + p.on("close", (code) => |
| 68 | + code === 0 |
| 69 | + ? resolve(out) |
| 70 | + : reject(new Error(`${cmd} exited ${code}: ${err.slice(-2000)}`)), |
| 71 | + ); |
| 72 | + }); |
| 73 | +} |
| 74 | + |
| 75 | +/** |
| 76 | + * `pg_dump` (source) → `pg_restore` (target), through a temp file. |
| 77 | + * |
| 78 | + * Deliberately NOT a pipe: the restore runs from a filtered TOC (`-L`), and |
| 79 | + * pg_restore cannot seek a stream. The filter drops the DDL-audit event triggers |
| 80 | + * (migrations/0004), which cannot survive the crossing as dumped: |
| 81 | + * - pg_dump ALWAYS emits them. Event triggers are database-global, so |
| 82 | + * `-N hauldr_audit` excludes the schema's functions but not the triggers |
| 83 | + * that call them — excluding the schema alone makes it worse, not better. |
| 84 | + * - their functions arrive owned by `postgres`, and on the supabase image |
| 85 | + * `postgres` is not a superuser. A superuser-owned event trigger may not |
| 86 | + * execute a non-superuser-owned function, so the CREATE fails. |
| 87 | + * pg_restore is best-effort: it fails those four TOC entries, restores |
| 88 | + * everything else, and still exits non-zero — which used to abort a stageA whose |
| 89 | + * DATA had in fact landed intact. Skipping them here and rebuilding them in |
| 90 | + * `repairAudit` keeps the exit code meaningful: it now means a real failure. |
| 91 | + */ |
| 92 | +async function dumpRestore(src: Conn, dst: Conn, database: string): Promise<void> { |
| 93 | + const dir = await mkdtemp(join(tmpdir(), `cutover-${database}-`)); |
| 94 | + const archive = join(dir, "archive.dump"); |
| 95 | + const toc = join(dir, "restore.toc"); |
| 96 | + try { |
58 | 97 | // -Fc custom format; -N realtime EXCLUDES the service-managed realtime schema |
59 | 98 | // (Realtime rebuilds it on tenant register; carrying it over causes |
60 | 99 | // `SET log_min_messages` permission errors + supabase_realtime_admin grant |
61 | | - // failures on the v17 restore). Plain restore into the v17 db owned by postgres. |
62 | | - const dump = spawn( |
| 100 | + // failures on the v17 restore). |
| 101 | + await runPg( |
63 | 102 | "pg_dump", |
64 | 103 | [ |
65 | 104 | "-h", src.host, "-p", src.port, "-U", src.user, |
66 | | - "-Fc", "-N", "realtime", database, |
| 105 | + "-Fc", "-N", "realtime", "-f", archive, database, |
67 | 106 | ], |
68 | | - { env: { ...process.env, PGPASSWORD: src.password } }, |
| 107 | + src.password, |
69 | 108 | ); |
70 | | - const restore = spawn( |
| 109 | + |
| 110 | + // TOC line shape: `<dumpId>; <tableoid> <oid> <desc> <schema> <name> <owner>`. |
| 111 | + // Anchor on the `desc` field so a table whose NAME merely contains the words |
| 112 | + // is not silently dropped from the restore. |
| 113 | + const listed = await runPg("pg_restore", ["-l", archive], dst.password); |
| 114 | + const kept = listed |
| 115 | + .split("\n") |
| 116 | + .filter((l) => !/^\s*\d+;\s+\d+\s+\d+\s+EVENT TRIGGER\b/.test(l)); |
| 117 | + await writeFile(toc, kept.join("\n")); |
| 118 | + |
| 119 | + await runPg( |
71 | 120 | "pg_restore", |
72 | 121 | [ |
73 | 122 | "-h", dst.host, "-p", dst.port, "-U", dst.user, |
74 | | - "-d", database, |
| 123 | + "-d", database, "-L", toc, archive, |
75 | 124 | ], |
76 | | - { env: { ...process.env, PGPASSWORD: dst.password } }, |
| 125 | + dst.password, |
77 | 126 | ); |
| 127 | + } finally { |
| 128 | + await rm(dir, { recursive: true, force: true }); |
| 129 | + } |
| 130 | +} |
78 | 131 |
|
79 | | - let dumpErr = ""; |
80 | | - let restoreErr = ""; |
81 | | - dump.stderr.on("data", (b) => (dumpErr += b.toString())); |
82 | | - restore.stderr.on("data", (b) => (restoreErr += b.toString())); |
83 | | - |
84 | | - dump.stdout.pipe(restore.stdin); |
85 | | - |
86 | | - let dumpDone = false; |
87 | | - let restoreDone = false; |
88 | | - let failed = false; |
89 | | - const fail = (msg: string) => { |
90 | | - if (failed) return; |
91 | | - failed = true; |
92 | | - reject(new Error(msg)); |
93 | | - }; |
| 132 | +/** |
| 133 | + * Rebuild the DDL-audit event triggers on the target cluster (see dumpRestore for |
| 134 | + * why they are not restored), and leave the ledger writable by the app roles. |
| 135 | + * |
| 136 | + * Re-owning the functions is what legalises the triggers: created by this |
| 137 | + * cluster's admin (a superuser), they may only execute a superuser-owned |
| 138 | + * function, and the restore left them owned by the deprivileged `postgres`. |
| 139 | + * |
| 140 | + * The grants mirror migrations/0005 and are re-applied because they are a |
| 141 | + * PRECONDITION of the move, not a nicety: the trigger writes `ddl_log` as |
| 142 | + * whoever emitted the DDL, and on pg17 that is a `postgres` which no longer |
| 143 | + * bypasses the ACL — the first DDL-on-boot (GoTrue, storage-api) would die 42501 |
| 144 | + * and crashloop the sidecar. Applied here too so a project whose db predates |
| 145 | + * 0005 still lands on v17 disarmed. |
| 146 | + */ |
| 147 | +async function repairAudit(cluster: Cluster, database: string): Promise<void> { |
| 148 | + const c = dbClientForCluster(cluster, database); |
| 149 | + await c.connect(); |
| 150 | + try { |
| 151 | + const { rowCount } = await c.query( |
| 152 | + "select 1 from pg_namespace where nspname = 'hauldr_audit'", |
| 153 | + ); |
| 154 | + if (!rowCount) return; // db predates migrations/0004 — nothing to rebuild |
94 | 155 |
|
95 | | - dump.on("error", (e) => fail(`pg_dump spawn failed: ${e.message}`)); |
96 | | - restore.on("error", (e) => fail(`pg_restore spawn failed: ${e.message}`)); |
97 | | - dump.on("close", (code) => { |
98 | | - dumpDone = true; |
99 | | - if (code !== 0) return fail(`pg_dump exited ${code}: ${dumpErr.slice(-2000)}`); |
100 | | - if (restoreDone && !failed) resolve(); |
101 | | - }); |
102 | | - restore.on("close", (code) => { |
103 | | - restoreDone = true; |
104 | | - // pg_restore returns non-zero on ANY error; surface its stderr. (It emits |
105 | | - // warnings on stderr too, but a non-zero exit is a real failure here.) |
106 | | - if (code !== 0) return fail(`pg_restore exited ${code}: ${restoreErr.slice(-2000)}`); |
107 | | - if (dumpDone && !failed) resolve(); |
108 | | - }); |
109 | | - }); |
| 156 | + await c.query("alter function hauldr_audit.on_ddl_end() owner to current_user"); |
| 157 | + await c.query("alter function hauldr_audit.on_ddl_drop() owner to current_user"); |
| 158 | + await c.query("drop event trigger if exists hauldr_audit_ddl_end"); |
| 159 | + await c.query( |
| 160 | + `create event trigger hauldr_audit_ddl_end on ddl_command_end |
| 161 | + execute function hauldr_audit.on_ddl_end()`, |
| 162 | + ); |
| 163 | + await c.query("drop event trigger if exists hauldr_audit_ddl_drop"); |
| 164 | + await c.query( |
| 165 | + `create event trigger hauldr_audit_ddl_drop on sql_drop |
| 166 | + execute function hauldr_audit.on_ddl_drop()`, |
| 167 | + ); |
| 168 | + await c.query("grant usage on schema hauldr_audit to public"); |
| 169 | + await c.query("grant insert on hauldr_audit.ddl_log to public"); |
| 170 | + await c.query("grant usage on sequence hauldr_audit.ddl_log_id_seq to public"); |
| 171 | + } finally { |
| 172 | + await c.end(); |
| 173 | + } |
110 | 174 | } |
111 | 175 |
|
112 | 176 | /** Per-table row counts for schemas public + auth, keyed `schema.table`. */ |
@@ -211,10 +275,16 @@ export async function stageA(name: string): Promise<void> { |
211 | 275 | await v17admin.end(); |
212 | 276 | } |
213 | 277 |
|
214 | | - // 4. Data move: pg_dump (pg16) → pg_restore (pg17), excluding `realtime`. |
| 278 | + // 4. Data move: pg_dump (pg16) → pg_restore (pg17), excluding `realtime` and |
| 279 | + // the DDL-audit event triggers (rebuilt in 5 — see dumpRestore). |
215 | 280 | console.log(`[cutover ${name}] stageA 4/7 pg_dump → pg_restore (excluding realtime)`); |
216 | 281 | await dumpRestore(src, dst, database); |
217 | 282 |
|
| 283 | + // 4b. Rebuild the audit event triggers the restore had to skip, owned by this |
| 284 | + // cluster's superuser + granted, so DDL-on-boot does not 42501 on v17. |
| 285 | + console.log(`[cutover ${name}] stageA 4b/7 rebuild DDL-audit event triggers on pg17`); |
| 286 | + await repairAudit("pg17", database); |
| 287 | + |
218 | 288 | // 5. Apply the v17 compat baseline as supabase_admin: default extensions, the |
219 | 289 | // Supabase-dialect auth helpers (reused verbatim from gotrue.ts), and the |
220 | 290 | // GoTrue search_path crash-loop fix. |
|
0 commit comments