All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m9s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m47s
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m10s
CD / Promote to Int (push) Successful in 9s
CI / Auth e2e pack (push) Successful in 5m25s
CI / Import/export fidelity gate (push) Successful in 45s
New apps/backup service (ADR 0015): nightly pg_dump -Fc plus one tar of the uploads/plugins volumes as a consistent restore set on a new backups volume, retention prune that never removes the newest complete set, atomic status.json for the readiness/admin consumers (#85/#86), and a failure mail sent directly via nodemailer (the api may be the broken part) with de/en texts in the shared mails catalog. BACKUP_RUN_ONCE=1 gives the on-demand path; deploy/backup/restore.sh automates the documented restore runbook. The pure secret-store helpers moved to @dorfteich/shared so the sidecar resolves the wizard-written SMTP relay exactly like the api. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
140 lines
4.7 KiB
TypeScript
140 lines
4.7 KiB
TypeScript
import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
import { archiveFileName, dumpFileName } from './backup-set.js';
|
|
import { runBackup, type RunnerDeps } from './runner.js';
|
|
import { readStatus } from './status.js';
|
|
|
|
/**
|
|
* Time-accelerated nightly runs (issue #83 acceptance criteria): the clock
|
|
* is injected, so "a month of nights" is a loop, with real files in a temp
|
|
* backups directory and dump/archive doubles standing in for pg_dump/tar
|
|
* (their real counterparts are covered by pg.ts/archive.ts on the stage).
|
|
*/
|
|
|
|
const DAY = 24 * 60 * 60 * 1000;
|
|
const START = new Date('2026-07-01T03:00:00Z').getTime();
|
|
|
|
let dir: string;
|
|
let clock: Date;
|
|
let failures: Array<{ backupId: string; error: string; lastSuccessAt: string | null }>;
|
|
|
|
function makeDeps(overrides: Partial<RunnerDeps> = {}): RunnerDeps {
|
|
return {
|
|
backupsDir: dir,
|
|
retentionDays: 7,
|
|
now: () => new Date(clock),
|
|
dump: (outFile) => writeFile(outFile, 'dump-bytes'),
|
|
archive: (outFile) => writeFile(outFile, 'archive-bytes!'),
|
|
onFailure: async (run) => {
|
|
failures.push(run);
|
|
},
|
|
log: { info: () => {}, error: () => {} },
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
dir = await mkdtemp(join(tmpdir(), 'dorfteich-backup-'));
|
|
clock = new Date(START);
|
|
failures = [];
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('runBackup', () => {
|
|
it('produces a dump, a matching archive, and status.json', async () => {
|
|
const status = await runBackup(makeDeps());
|
|
|
|
const names = await readdir(dir);
|
|
expect(names).toContain(dumpFileName('20260701-030000'));
|
|
expect(names).toContain(archiveFileName('20260701-030000'));
|
|
expect(status.lastRun.outcome).toBe('succeeded');
|
|
expect(status.lastRun.sizes).toEqual({ dumpBytes: 10, archiveBytes: 14 });
|
|
expect(status.lastSuccess?.backupId).toBe('20260701-030000');
|
|
expect(readStatus(dir)).toEqual(status);
|
|
});
|
|
|
|
it('keeps exactly the retention window across simulated nights', async () => {
|
|
for (let night = 0; night < 30; night += 1) {
|
|
clock = new Date(START + night * DAY);
|
|
await runBackup(makeDeps());
|
|
}
|
|
|
|
const names = (await readdir(dir)).filter((name) => name.startsWith('db-'));
|
|
// Retention 7 days: the last 7 nights survive plus the current night's set.
|
|
expect(names.length).toBe(8);
|
|
expect(names).toContain(dumpFileName('20260730-030000'));
|
|
expect(names).not.toContain(dumpFileName('20260722-030000'));
|
|
expect(names).toContain(dumpFileName('20260723-030000'));
|
|
});
|
|
|
|
it('records a failed run, keeps lastSuccess, alerts, and cleans partials', async () => {
|
|
await runBackup(makeDeps());
|
|
|
|
clock = new Date(START + DAY);
|
|
const status = await runBackup(
|
|
makeDeps({
|
|
dump: async () => {
|
|
throw new Error('connection refused');
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(status.lastRun.outcome).toBe('failed');
|
|
expect(status.lastRun.error).toContain('connection refused');
|
|
expect(status.lastSuccess?.backupId).toBe('20260701-030000');
|
|
expect(failures).toHaveLength(1);
|
|
expect(failures[0]?.lastSuccessAt).toBe(new Date(START).toISOString());
|
|
|
|
const names = await readdir(dir);
|
|
expect(names.filter((name) => name.endsWith('.partial'))).toHaveLength(0);
|
|
// The last good set survives a failed night.
|
|
expect(names).toContain(dumpFileName('20260701-030000'));
|
|
});
|
|
|
|
it('a failure alert that itself fails never breaks the run', async () => {
|
|
const status = await runBackup(
|
|
makeDeps({
|
|
dump: async () => {
|
|
throw new Error('disk full');
|
|
},
|
|
onFailure: async () => {
|
|
throw new Error('smtp down');
|
|
},
|
|
}),
|
|
);
|
|
expect(status.lastRun.outcome).toBe('failed');
|
|
expect(readStatus(dir)?.lastRun.error).toContain('disk full');
|
|
});
|
|
|
|
it('after long downtime the newest complete set survives pruning', async () => {
|
|
await runBackup(makeDeps());
|
|
|
|
// 60 days later (retention 7): the old set is expired but must survive
|
|
// as the newest complete one until a new success replaces it.
|
|
clock = new Date(START + 60 * DAY);
|
|
const status = await runBackup(
|
|
makeDeps({
|
|
dump: async () => {
|
|
throw new Error('db gone');
|
|
},
|
|
}),
|
|
);
|
|
expect(status.lastRun.outcome).toBe('failed');
|
|
expect(await readdir(dir)).toContain(dumpFileName('20260701-030000'));
|
|
|
|
// The next success prunes it.
|
|
clock = new Date(START + 61 * DAY);
|
|
await runBackup(makeDeps());
|
|
const names = await readdir(dir);
|
|
expect(names).not.toContain(dumpFileName('20260701-030000'));
|
|
expect(names).toContain(dumpFileName('20260831-030000'));
|
|
});
|
|
});
|