diff --git a/apps/backup/src/pg.test.ts b/apps/backup/src/pg.test.ts new file mode 100644 index 0000000..1a2aee9 --- /dev/null +++ b/apps/backup/src/pg.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; + +import { pgEnvFromUrl, pgRestoreArgs, schemaResetArgs, schemaResetSql } from './pg.js'; + +describe('pgEnvFromUrl', () => { + it('maps the URL onto libpq env vars, never argv', () => { + expect(pgEnvFromUrl('postgresql://user:p%40ss@db:5433/dorfteich')).toEqual({ + PGHOST: 'db', + PGPORT: '5433', + PGUSER: 'user', + PGPASSWORD: 'p@ss', + PGDATABASE: 'dorfteich', + }); + }); +}); + +describe('restore command plan (issue #288)', () => { + it('resets the schema first — --clean alone cannot drop inherited partition PKs', () => { + expect(schemaResetSql).toBe('DROP SCHEMA IF EXISTS public CASCADE; CREATE SCHEMA public;'); + expect(schemaResetArgs('dorfteich')).toEqual([ + '--dbname', + 'dorfteich', + '--set', + 'ON_ERROR_STOP=1', + '--command', + schemaResetSql, + ]); + }); + + it('keeps --clean --if-exists as no-op belt and braces on the empty schema', () => { + expect(pgRestoreArgs('dorfteich', '/backups/x.dump')).toEqual([ + '--clean', + '--if-exists', + '--no-owner', + '--dbname', + 'dorfteich', + '/backups/x.dump', + ]); + }); +}); diff --git a/apps/backup/src/pg.ts b/apps/backup/src/pg.ts index 1ec49c7..cc50422 100644 --- a/apps/backup/src/pg.ts +++ b/apps/backup/src/pg.ts @@ -39,16 +39,32 @@ export async function pgDump(databaseUrl: string, outFile: string): Promise { const database = pgEnvFromUrl(databaseUrl).PGDATABASE ?? ''; - await runPgTool( - 'pg_restore', - ['--clean', '--if-exists', '--no-owner', '--dbname', database, dumpFile], - databaseUrl, - ); + await runPgTool('psql', schemaResetArgs(database), databaseUrl); + await runPgTool('pg_restore', pgRestoreArgs(database, dumpFile), databaseUrl); } diff --git a/docs/architecture/operations.md b/docs/architecture/operations.md index b84a217..086296a 100644 --- a/docs/architecture/operations.md +++ b/docs/architecture/operations.md @@ -79,7 +79,7 @@ monitoring, structured logs, backup alerting — no dedicated metrics stack. - **Restore runbook** (also the Prod-relocation procedure) — automated by `deploy/backup/restore.sh `, run from the stage directory: 1. stop the app services (`web`, `api`, `collab`; the db stays up), - 2. restore DB: `pg_restore --clean --if-exists` into the `db` container, + 2. restore DB: schema reset + `pg_restore` into the `db` container (#288), 3. restore volume: unpack the matching uploads/plugins archive, 4. `docker compose up -d`, verify `/readyz`, spot-check a page + a file. - **Drills** (issue #87): monthly automated restore of the latest backup diff --git a/docs/operations/restore-runbook.md b/docs/operations/restore-runbook.md index f785117..b761902 100644 --- a/docs/operations/restore-runbook.md +++ b/docs/operations/restore-runbook.md @@ -17,7 +17,8 @@ On the stage host, from the stage directory (`/srv/DOCKER/dorfteich-/`): id in `status.json` → `lastSuccess.backupId`. 2. **Run the automated runbook:** `./restore.sh ` (`deploy/backup/restore.sh`). It stops `web`/`api`/`collab` (the db stays - up), replays the dump with `pg_restore --clean --if-exists` and unpacks + up), resets the `public` schema and replays the dump with `pg_restore` + (#288 — objects created after the backup do not survive) and unpacks the volume archive through the backup sidecar image, starts the stack, and polls `/readyz`. 3. **Verify:** `/readyz` fully green, spot-check one page and one uploaded