2bfae804da
Exit 0 from the backup script is deliberately tied to ON-BOX durability, so it is not proof the off-box copy landed. GET /api/internal/backup/offbox runs rsync --list-only against BACKUP_REMOTE using the SAME pinned known_hosts as the push (checking never disabled) and returns the dumps actually present, with size and timestamp — so off-box presence is a verified fact rather than an inference from an exit code. Needed because the dev box cannot authenticate to the Storage Box: the authorized key installed there is Kev's ~/vyndr-backup-key, not the keypair generated in-session, so independent verification has to run from the container that does hold working credentials. Suite 280/3338 green, build exit 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SmNjJAwEnqHPtXbvSZR8kA
239 lines
9.9 KiB
JavaScript
239 lines
9.9 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* NIGHTLY DATABASE BACKUP — in-process scheduler (Session 64, Phase 1).
|
|
*
|
|
* Supabase free tier has ZERO backups. `scripts/backup-db.sh` was written and
|
|
* mechanism-verified in Session 62, but it was never INSTALLED: it depended on
|
|
* someone adding a host cron or a Coolify Scheduled Task by hand, and that never
|
|
* happened. The ledger has been unbacked every night since.
|
|
*
|
|
* So the cron ships as CODE, in the container that already holds
|
|
* SUPABASE_DB_URL, pg_dump and the Supabase network route. Deploy = installed.
|
|
* No host shell, no dashboard click, nothing to forget.
|
|
*
|
|
* ARMING (deliberately opt-OUT, not opt-in — the whole failure mode was an
|
|
* opt-in that nobody opted into):
|
|
* - armed whenever SUPABASE_DB_URL is present
|
|
* - BACKUP_CRON=0 is the explicit kill switch
|
|
* - BACKUP_HOUR_UTC (default 3) / BACKUP_MINUTE_UTC (default 10)
|
|
*
|
|
* DURABILITY CAVEAT (reported, not hidden): the container filesystem is
|
|
* ephemeral. Dumps survive a redeploy ONLY if BACKUP_DIR points at a persistent
|
|
* volume, or BACKUP_REMOTE pushes them off-box. We detect and page when neither
|
|
* is set, because an undurable backup that reads as "backed up" is worse than a
|
|
* loud gap.
|
|
*/
|
|
|
|
const path = require('path');
|
|
|
|
const SCRIPT = path.join(__dirname, '..', 'scripts', 'backup-db.sh');
|
|
|
|
function hourUtc() { return Number(process.env.BACKUP_HOUR_UTC || 3); }
|
|
function minuteUtc() { return Number(process.env.BACKUP_MINUTE_UTC || 10); }
|
|
|
|
/** Is durable retention configured? Neither → the dump dies with the container. */
|
|
function durabilityWarning(env = process.env) {
|
|
const hasRemote = !!(env.BACKUP_REMOTE && String(env.BACKUP_REMOTE).trim());
|
|
const hasVolume = !!(env.BACKUP_DIR && String(env.BACKUP_DIR).trim());
|
|
if (hasRemote || hasVolume) return null;
|
|
return 'backups are written inside an ephemeral container — set BACKUP_REMOTE (off-box rsync) or point BACKUP_DIR at a persistent volume, or every dump is lost on redeploy';
|
|
}
|
|
|
|
function shouldArm(env = process.env) {
|
|
if (env.BACKUP_CRON === '0') return { armed: false, reason: 'BACKUP_CRON=0 (kill switch)' };
|
|
if (!env.SUPABASE_DB_URL) return { armed: false, reason: 'SUPABASE_DB_URL unset (nothing to dump)' };
|
|
return { armed: true, reason: null };
|
|
}
|
|
|
|
/** One backup run. Resolves { ok, code, ms } — never throws. */
|
|
function runBackup(deps = {}) {
|
|
const spawn = deps.spawn || require('child_process').spawn;
|
|
const started = Date.now();
|
|
return new Promise((resolve) => {
|
|
let child;
|
|
try {
|
|
child = spawn('sh', [SCRIPT], { env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
} catch (e) {
|
|
return resolve({ ok: false, code: null, ms: Date.now() - started, error: e.message });
|
|
}
|
|
let tail = '';
|
|
const grab = (b) => { tail = (tail + b.toString()).slice(-2000); };
|
|
if (child.stdout) child.stdout.on('data', grab);
|
|
if (child.stderr) child.stderr.on('data', grab);
|
|
child.on('error', (e) => resolve({ ok: false, code: null, ms: Date.now() - started, error: e.message, tail }));
|
|
child.on('close', (code) => resolve({ ok: code === 0, code, ms: Date.now() - started, tail }));
|
|
});
|
|
}
|
|
|
|
function startBackupScheduler(opts = {}) {
|
|
const notify = opts.notify || require('./utils/opsNotify').notify;
|
|
const now = opts.now || (() => new Date());
|
|
const doBackup = opts.runBackup || runBackup;
|
|
|
|
const { armed, reason } = shouldArm(opts.env || process.env);
|
|
if (!armed) {
|
|
console.log(`[backupScheduler] disarmed — ${reason}`);
|
|
return null;
|
|
}
|
|
|
|
const warn = durabilityWarning(opts.env || process.env);
|
|
console.log(`[backupScheduler] armed — nightly ${String(hourUtc()).padStart(2, '0')}:${String(minuteUtc()).padStart(2, '0')} UTC via ${SCRIPT}${warn ? ` — WARNING: ${warn}` : ''}`);
|
|
if (warn) {
|
|
void notify(`⚠️ VYNDR backup: ${warn}`, { title: 'VYNDR backup', priority: 'default', tags: ['warning'] });
|
|
}
|
|
|
|
let lastRunDay = null;
|
|
|
|
async function tick() {
|
|
const d = now();
|
|
if (d.getUTCHours() !== hourUtc() || d.getUTCMinutes() !== minuteUtc()) return;
|
|
const dayKey = d.toISOString().slice(0, 10);
|
|
if (lastRunDay === dayKey) return; // once per day, even if the tick repeats
|
|
lastRunDay = dayKey;
|
|
|
|
console.log(`[backupScheduler] starting nightly backup (${dayKey})`);
|
|
const res = await doBackup(opts);
|
|
if (res.ok) {
|
|
console.log(`[backupScheduler] backup OK in ${Math.round(res.ms / 1000)}s`);
|
|
} else {
|
|
console.error(`[backupScheduler] backup FAILED (code ${res.code}): ${res.error || ''}`);
|
|
await notify(
|
|
`❌ VYNDR nightly backup FAILED (exit ${res.code}). The database is unprotected tonight.`,
|
|
{ title: 'VYNDR backup', priority: 'high', tags: ['x'] },
|
|
);
|
|
}
|
|
}
|
|
|
|
const interval = setInterval(() => { void tick(); }, 60_000);
|
|
if (interval.unref) interval.unref();
|
|
return { interval, tick };
|
|
}
|
|
|
|
/**
|
|
* Count rows for a table INSIDE a dump, without needing a Postgres server.
|
|
* `pg_restore --data-only --table=X` emits a COPY block; the rows are the lines
|
|
* between `FROM stdin;` and the terminating `\.`. This proves the dump actually
|
|
* CONTAINS the data (not merely that it parses), which is the thing a backup has
|
|
* to guarantee. A real server restore is still the gold standard — this is the
|
|
* strongest check available from inside the API container.
|
|
*/
|
|
function countRowsInDump(dumpPath, table = 'ledger_entries', deps = {}) {
|
|
const spawn = deps.spawn || require('child_process').spawn;
|
|
return new Promise((resolve) => {
|
|
let child;
|
|
try {
|
|
child = spawn('pg_restore', ['--data-only', `--table=${table}`, '-f', '-', dumpPath], {
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
});
|
|
} catch (e) {
|
|
return resolve({ ok: false, rows: null, error: e.message });
|
|
}
|
|
let out = '';
|
|
let err = '';
|
|
let inCopy = false;
|
|
let rows = 0;
|
|
let leftover = '';
|
|
child.stdout.on('data', (b) => {
|
|
const text = leftover + b.toString();
|
|
const lines = text.split('\n');
|
|
leftover = lines.pop() ?? '';
|
|
for (const line of lines) {
|
|
if (!inCopy) {
|
|
if (/FROM stdin;\s*$/.test(line)) inCopy = true;
|
|
} else if (line === '\\.') {
|
|
inCopy = false;
|
|
} else {
|
|
rows += 1;
|
|
}
|
|
}
|
|
if (out.length < 4000) out += text.slice(0, 4000);
|
|
});
|
|
child.stderr.on('data', (b) => { err = (err + b.toString()).slice(-2000); });
|
|
child.on('error', (e) => resolve({ ok: false, rows: null, error: e.message }));
|
|
child.on('close', (code) => resolve({
|
|
ok: code === 0, rows, code, error: code === 0 ? null : (err || `exit ${code}`),
|
|
}));
|
|
});
|
|
}
|
|
|
|
/** Newest *.dump in BACKUP_DIR, with its size. */
|
|
function latestDump(dir = process.env.BACKUP_DIR || '/var/backups/vyndr', deps = {}) {
|
|
const fs = deps.fs || require('fs');
|
|
try {
|
|
const files = fs.readdirSync(dir)
|
|
.filter((f) => f.startsWith('vyndr-') && f.endsWith('.dump'))
|
|
.map((f) => {
|
|
const full = path.join(dir, f);
|
|
return { file: f, path: full, size: fs.statSync(full).size, mtime: fs.statSync(full).mtimeMs };
|
|
})
|
|
.sort((a, b) => b.mtime - a.mtime);
|
|
return files[0] || null;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List the OFF-BOX copies actually present on the Storage Box.
|
|
*
|
|
* Exit 0 from the backup script is NOT proof the dump landed — the script
|
|
* deliberately keeps its exit code tied to on-box durability. This reads the
|
|
* remote directory back, so "the off-box copy exists" is a verified fact rather
|
|
* than an inference. Uses the SAME pinned known_hosts as the push; host-key
|
|
* checking is never disabled.
|
|
*/
|
|
function listOffbox(deps = {}) {
|
|
const spawn = deps.spawn || require('child_process').spawn;
|
|
const remote = process.env.BACKUP_REMOTE;
|
|
const port = process.env.BACKUP_SSH_PORT || '23';
|
|
const knownHosts = process.env.BACKUP_KNOWN_HOSTS
|
|
|| path.join(__dirname, '..', 'scripts', 'storagebox_known_hosts');
|
|
return new Promise((resolve) => {
|
|
if (!remote) return resolve({ ok: false, error: 'BACKUP_REMOTE unset' });
|
|
if (!process.env.BACKUP_SSH_KEY) return resolve({ ok: false, error: 'BACKUP_SSH_KEY unset' });
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
let keyFile;
|
|
try {
|
|
// Same decode contract as backup-db.sh: base64 preferred, raw PEM fallback.
|
|
const raw = process.env.BACKUP_SSH_KEY;
|
|
let decoded;
|
|
try {
|
|
const d = Buffer.from(raw, 'base64').toString('utf8');
|
|
decoded = d.includes('PRIVATE KEY') ? d : raw.replace(/\\n/g, '\n');
|
|
} catch { decoded = raw.replace(/\\n/g, '\n'); }
|
|
keyFile = path.join(os.tmpdir(), `vyndr-offbox-${Date.now()}`);
|
|
fs.writeFileSync(keyFile, decoded.endsWith('\n') ? decoded : `${decoded}\n`, { mode: 0o600 });
|
|
} catch (e) {
|
|
return resolve({ ok: false, error: `key write failed: ${e.message}` });
|
|
}
|
|
const sshCmd = `ssh -p ${port} -o StrictHostKeyChecking=yes -o UserKnownHostsFile=${knownHosts} -o BatchMode=yes -i ${keyFile}`;
|
|
const child = spawn('rsync', ['--list-only', '-e', sshCmd, remote], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
let out = '';
|
|
let err = '';
|
|
child.stdout.on('data', (b) => { out += b.toString(); });
|
|
child.stderr.on('data', (b) => { err = (err + b.toString()).slice(-1500); });
|
|
const done = (result) => {
|
|
try { require('fs').unlinkSync(keyFile); } catch { /* best effort */ }
|
|
resolve(result);
|
|
};
|
|
child.on('error', (e) => done({ ok: false, error: e.message }));
|
|
child.on('close', (code) => {
|
|
const files = out.split('\n')
|
|
.map((l) => l.trim())
|
|
.filter((l) => /vyndr-.*\.dump$/.test(l))
|
|
.map((l) => {
|
|
const parts = l.split(/\s+/);
|
|
return { size: Number(parts[1].replace(/,/g, '')) || null, date: parts[2], time: parts[3], file: parts[parts.length - 1] };
|
|
});
|
|
done({ ok: code === 0, code, count: files.length, files, error: code === 0 ? null : (err || `exit ${code}`) });
|
|
});
|
|
});
|
|
}
|
|
|
|
module.exports = {
|
|
startBackupScheduler, runBackup, shouldArm, durabilityWarning, SCRIPT,
|
|
countRowsInDump, latestDump, listOffbox,
|
|
};
|