Backup: durable on-box volume, off-box DEFERRED, and a real read-back check
BACKUP_DIR is now a persistent volume (/app/backups), so the dump already survives redeploys — the container-ephemeral risk that made this urgent is closed. Storage Box SSH auth is not sorted yet, so the off-box push is explicitly DEFERRED rather than failing: - gated on BACKUP_OFFBOX=1 (plus BACKUP_REMOTE and BACKUP_SSH_KEY); until then the script logs "off-box push DEFERRED" and exits clean. - if an enabled push DOES fail, it is a LOW-priority "deferred" notice, not a failure — the durable on-box dump succeeded, and calling that an incident would train us to ignore backup alerts. Adds the read-back check, because a backup nobody has read is a hope: countRowsInDump() runs `pg_restore --data-only --table=X -f -` and counts the rows between `FROM stdin;` and the terminating `\.`, proving the archive CONTAINS the data rather than merely parsing. Needs no Postgres server, so it runs inside the API container. Validated against a real pg_dump from a scratch Postgres: counted exactly 604 rows. GET /api/internal/backup/verify exposes it (newest dump in BACKUP_DIR, size, table, rows_in_dump). Unit tests inject spawn/fs so CI needs neither docker nor pg_restore. Suite 278/3310 green, build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
This commit is contained in:
@@ -97,3 +97,62 @@ describe('scheduling', () => {
|
||||
expect(sched.startBackupScheduler({ env: {}, notify: async () => {} })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('countRowsInDump — reading the backup back', () => {
|
||||
const { EventEmitter } = require('events');
|
||||
const { Readable } = require('stream');
|
||||
|
||||
function fakeSpawn(stdoutText, code = 0) {
|
||||
return () => {
|
||||
const child = new EventEmitter();
|
||||
child.stdout = Readable.from([stdoutText]);
|
||||
child.stderr = Readable.from([]);
|
||||
child.stdout.on('end', () => setImmediate(() => child.emit('close', code)));
|
||||
return child;
|
||||
};
|
||||
}
|
||||
|
||||
const COPY_BLOCK = [
|
||||
'--',
|
||||
'COPY public.ledger_entries (id, player_name) FROM stdin;',
|
||||
'1\tAlonso',
|
||||
'2\tHenderson',
|
||||
'3\tReese',
|
||||
'\\.',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
test('counts only the rows inside the COPY block', async () => {
|
||||
const r = await sched.countRowsInDump('/x.dump', 'ledger_entries', { spawn: fakeSpawn(COPY_BLOCK) });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.rows).toBe(3);
|
||||
});
|
||||
|
||||
test('an empty dump reports 0 rows, not a false success count', async () => {
|
||||
const empty = 'COPY public.ledger_entries (id) FROM stdin;\n\\.\n';
|
||||
const r = await sched.countRowsInDump('/x.dump', 'ledger_entries', { spawn: fakeSpawn(empty) });
|
||||
expect(r.rows).toBe(0);
|
||||
});
|
||||
|
||||
test('a failing pg_restore is reported, never counted as ok', async () => {
|
||||
const r = await sched.countRowsInDump('/x.dump', 'ledger_entries', { spawn: fakeSpawn('', 1) });
|
||||
expect(r.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('latestDump', () => {
|
||||
test('picks the newest dump and ignores unrelated files', () => {
|
||||
const fs = {
|
||||
readdirSync: () => ['vyndr-old.dump', 'notes.txt', 'vyndr-new.dump'],
|
||||
statSync: (p) => ({ size: p.includes('new') ? 999 : 111, mtimeMs: p.includes('new') ? 200 : 100 }),
|
||||
};
|
||||
const d = sched.latestDump('/app/backups', { fs });
|
||||
expect(d.file).toBe('vyndr-new.dump');
|
||||
expect(d.size).toBe(999);
|
||||
});
|
||||
|
||||
test('missing directory degrades to null, never throws', () => {
|
||||
const fs = { readdirSync: () => { throw new Error('ENOENT'); } };
|
||||
expect(sched.latestDump('/nope', { fs })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user