Skip to content

Commit afbeeb5

Browse files
valvesssclaude
andcommitted
HAULDR-12: o storage-api atravessa o cutover junto com o projeto
O storage-api montava sua DATABASE_URL de config.adminUrl — pg16, sempre. A constante era invisível enquanto a frota inteira estava no pg16, e o cutover nunca tocou no storage (grep = zero ocorrências em cutover.ts). As duas somadas deixaram 5 projetos servindo do pg17 com o metadado de arquivo indo pro pg16: upload dá 200, os bytes chegam no Garage, e o app nunca vê o arquivo. Re-provisionar não consertava — re-provisionar era o que reaplicava a constante. - storageapi.ts: DATABASE_URL sai de adminDbUrlForCluster(clusterOf(name), db), a mesma lente que GoTrue/PostgREST/backups já usam. Para pg16 o resultado é byte-idêntico ao anterior. - cutover.ts: stageB e rollback repontam o storage junto com auth/REST. Fatal como o REST (e diferente do realtime, que é provisionado-mas-sem-uso): storage é load-bearing, então falha tem que ser alta, não virar meio-movimento calado. O schema storage já atravessa no stageA (o pg_dump só exclui `realtime`), então é repoint puro, não migração de dados. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4da954e commit afbeeb5

2 files changed

Lines changed: 39 additions & 16 deletions

File tree

control-plane/src/cutover.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { ensureGlobalRoles } from "./roles";
1515
import { poolerEnabled, registerTenant, terminateTenant } from "./supavisor";
1616
import { AUTH_HELPERS_SQL, provisionAuth } from "./gotrue";
1717
import { provisionRest } from "./postgrest";
18+
import { provisionStorageApi } from "./storageapi";
1819
import { provisionRealtime, realtimeEnabled } from "./realtime";
1920

2021
/**
@@ -35,8 +36,9 @@ import { provisionRealtime, realtimeEnabled } from "./realtime";
3536
* A — data move: roles, database, dump→restore (excluding `realtime`), compat
3637
* baseline, reindex, integrity check. Idempotent + verifiable; the app is
3738
* still serving from pg16 while this runs.
38-
* B — flip: `projects.cluster='pg17'`, then re-provision auth/REST/realtime
39-
* (now cluster-aware → they repoint at v17) + `notify pgrst` on the v17 db.
39+
* B — flip: `projects.cluster='pg17'`, then re-provision auth/REST/storage/
40+
* realtime (now cluster-aware → they repoint at v17) + `notify pgrst` on
41+
* the v17 db.
4042
*/
4143

4244
function ident(s: string) {
@@ -409,10 +411,22 @@ export async function stageB(name: string): Promise<void> {
409411
console.log(`[cutover ${name}] stageB 1/4 flip projects.cluster = pg17`);
410412
await controlPool.query("update projects set cluster = 'pg17' where name = $1", [name]);
411413

412-
console.log(`[cutover ${name}] stageB 2/4 repoint sidecars (auth, rest, realtime)`);
414+
console.log(`[cutover ${name}] stageB 2/4 repoint sidecars (auth, rest, storage, realtime)`);
413415
await provisionAuth(name);
414416
// REST is à-la-carte — only repoint it if the project has it.
415417
if (await hasRest(name)) await provisionRest(name);
418+
// Storage is à-la-carte too, and it is repointed for the same reason as REST:
419+
// its metadata (storage.objects / storage.buckets, and the RLS on them) lives
420+
// in the project db, so a storage-api left on pg16 writes file rows into a
421+
// database the app no longer reads. That is silent — uploads succeed, the
422+
// bytes land in Garage, and the app simply never sees the file. The schema
423+
// itself crosses in stageA (pg_dump excludes only `realtime`), so this is a
424+
// pure repoint, not a data move.
425+
//
426+
// Fatal, like REST and unlike realtime: realtime is provisioned-but-unused
427+
// fleet-wide, storage is load-bearing. A failure here must be loud rather than
428+
// leave the project half-moved and quietly writing to the wrong cluster.
429+
if (await hasStorageApi(name)) await provisionStorageApi(name);
416430
// Realtime is à-la-carte + service-configured — only if enabled and registered.
417431
//
418432
// Non-fatal, deliberately. stageA excludes the service-managed `realtime`
@@ -466,6 +480,7 @@ export async function rollback(name: string): Promise<void> {
466480
console.log(`[cutover ${name}] rollback 2/3 repoint sidecars back to pg16`);
467481
await provisionAuth(name);
468482
if (await hasRest(name)) await provisionRest(name);
483+
if (await hasStorageApi(name)) await provisionStorageApi(name);
469484
if (realtimeEnabled() && (await hasRealtime(name))) await provisionRealtime(name);
470485

471486
// Symmetric with stageB: a tenant left on v17 would keep pooled traffic there
@@ -483,6 +498,13 @@ async function hasRest(name: string): Promise<boolean> {
483498
);
484499
return !!(rows[0]?.postgrest_url || rows[0]?.rest_requested);
485500
}
501+
async function hasStorageApi(name: string): Promise<boolean> {
502+
const { rows } = await controlPool.query(
503+
"select storage_api_url, storage_api_container from projects where name = $1",
504+
[name],
505+
);
506+
return !!(rows[0]?.storage_api_url || rows[0]?.storage_api_container);
507+
}
486508
async function hasRealtime(name: string): Promise<boolean> {
487509
const { rows } = await controlPool.query(
488510
"select realtime_external_id from projects where name = $1",

control-plane/src/storageapi.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { execFile } from "node:child_process";
22
import { promisify } from "node:util";
33
import { controlPool } from "./db";
44
import { config, projectHostLabel } from "./config";
5+
import { clusterOf, adminDbUrlForCluster } from "./clusters";
56
import { coolifyProvisionStorageApi, coolifyDestroyStorageApi } from "./coolify";
67
import { projectIdentity } from "./gotrue";
78
import { provisionStorage } from "./storage";
@@ -16,16 +17,6 @@ export type ProjectStorageApi = {
1617
handle: string;
1718
};
1819

19-
/** The Postgres URL storage-api uses to reach its database: the project's owner
20-
* (postgres) — it installs the `storage` schema + roles and runs metadata
21-
* queries, so it needs DDL. Per-request authorization still keys off the JWT.
22-
* Mirrors GoTrue's internal admin connection. */
23-
function adminDbUrl(database: string): string {
24-
const u = new URL(config.adminUrl);
25-
const user = u.username || "postgres";
26-
return `postgres://${user}:${u.password}@${config.authDbHost}:${config.authDbPort}/${database}?sslmode=disable`;
27-
}
28-
2920
/**
3021
* Assemble what a per-project storage-api needs: its database (as the owner, for
3122
* the storage schema + metadata), the GoTrue JWT secret (so GoTrue tokens
@@ -36,6 +27,7 @@ function adminDbUrl(database: string): string {
3627
*/
3728
async function prepareStorageApi(name: string): Promise<Record<string, string>> {
3829
const store = await provisionStorage(name);
30+
const cluster = await clusterOf(name);
3931
const { rows } = await controlPool.query(
4032
"select database, jwt_secret from projects where name = $1",
4133
[name],
@@ -55,9 +47,18 @@ async function prepareStorageApi(name: string): Promise<Record<string, string>>
5547
// Supabase-shaped keys, minted from the secret (the `apikey` header + admin).
5648
ANON_KEY: mintApiKey("anon", secret),
5749
SERVICE_KEY: mintApiKey("service_role", secret),
58-
// Metadata + schema live in the project DB; storage-api installs its schema
59-
// and the storage roles on boot (idempotent against the existing base roles).
60-
DATABASE_URL: adminDbUrl(row.database),
50+
// Metadata + schema live in the project DB, ON THE PROJECT'S CLUSTER —
51+
// storage-api installs its schema and the storage roles on boot (idempotent
52+
// against the existing base roles), so it connects as that cluster's admin
53+
// (`postgres` on pg16, `supabase_admin` on pg17). Per-request authorization
54+
// still keys off the JWT.
55+
//
56+
// This used to build the URL from `config.adminUrl` — pg16, always. It was
57+
// invisible while the whole fleet was on pg16, and it is what put five
58+
// cut-over projects in split-brain: the project served from pg17 while its
59+
// file metadata was written to the pg16 copy nobody reads. Re-provisioning
60+
// could not fix it, because re-provisioning is what re-applied the constant.
61+
DATABASE_URL: adminDbUrlForCluster(cluster, row.database),
6162
DB_INSTALL_ROLES: "true",
6263
// Bytes go to the project's Garage bucket via the S3 backend (path-style —
6364
// Garage requires it). The key is scoped to just this project's bucket.

0 commit comments

Comments
 (0)