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:
Kev
2026-07-19 22:27:58 -04:00
parent 13ca070096
commit ef7f17610f
4 changed files with 175 additions and 12 deletions
+32
View File
@@ -271,6 +271,38 @@ router.post('/backup/run', async (req, res) => {
}
});
/**
* GET /api/internal/backup/verify (Session 64) — prove the newest dump on the
* persistent volume actually CONTAINS the data, by counting `ledger_entries`
* rows out of the archive with pg_restore. A backup nobody has read back is a
* hope, not a backup.
*/
router.get('/backup/verify', async (req, res) => {
const { latestDump, countRowsInDump } = require('../backupScheduler');
try {
const dir = process.env.BACKUP_DIR || '/var/backups/vyndr';
const dump = latestDump(dir);
if (!dump) {
return res.json({ ok: false, backup_dir: dir, error: 'no dump found in BACKUP_DIR' });
}
const table = String(req.query.table || 'ledger_entries');
const counted = await countRowsInDump(dump.path, table);
return res.json({
ok: counted.ok,
backup_dir: dir,
dump: dump.file,
dump_bytes: dump.size,
table,
rows_in_dump: counted.rows,
error: counted.error || null,
});
} catch (err) {
const message = err && err.message ? err.message : String(err);
console.error('[internal/backup/verify] failed:', message);
return res.status(500).json({ ok: false, error: message });
}
});
/**
* POST /api/internal/ledger/settle (Session 58, Phase 1) — settle the
* persistent ledger (outcome + actual_value + CLV) across every sport.