feat(recovery): judge the bucket's rule for database dumps in readiness

Only the backups page said when the bucket's lifecycle rules left
database/ out, so every dump, and every deleted account inside it, stayed
in the bucket forever without anyone being told. Readiness gains a
dump_retention signal that reads the same cached lifecycle probe: attention
when dumps go off-host and no rule covers database/, ok with the expiry
when one does, quiet when the bucket will not say, inactive without a
bucket or without off-host dumps. The hourly evaluation pushes it to the
admins like every other signal.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 16:00:35 +01:00
1 parent 351e7d1fe0
commit 66e92f36fe
16 files changed
+401 -36

No files matched your search

@@ -4,7 +4,8 @@
* Every backup signal with its verdict, its time and where to fix it: the
* database dump, the weekly copy, the off-host copies, the last restore drill
* (no time window: a failed drill stays failed until a later one passes), the
* off-host setup and the recovery kit. The same judgement the hourly
* off-host setup, whether the bucket ever removes the database dumps, and the
* recovery kit. The same judgement the hourly
* evaluation pushes to the admins (`src/lib/recovery/readiness-model.ts`).
* Cookie-only admin.
*/
@@ -3,7 +3,8 @@
/**
* `<RecoveryReadinessCard>` — every backup signal on one card: the database
* dump, the weekly account copies, the off-host copies, the last restore
* drill, the off-host setup and the recovery kit. Each row says its verdict,
* drill, the off-host setup, the bucket's rule for the dumps and the recovery
* kit. Each row says its verdict,
* the time it is about and where to fix it.
*
* No time window anywhere: a failed drill stays on this card until a later
@@ -12,6 +12,7 @@ import type { DatabaseBackupHealth } from "@/lib/recovery/database-dump-health";
import {
DUMP_FAILING_AFTER_HOURS,
evaluateDatabaseDump,
evaluateDumpRetention,
evaluateOffhostCopies,
evaluateOffhostSetup,
evaluateRecoveryKit,
@@ -83,6 +84,7 @@ function inputs(over: Partial<ReadinessInputs> = {}): ReadinessInputs {
weekly: { accounts: [{ createdAt: ago(60 * DAY), lastCopyAt: ago(DAY) }] },
offhost: {
configured: true,
databaseLifecycle: { state: "configured", expirationDays: 30 },
accounts: [
{ lastAttemptAt: ago(10 * HOUR), lastSuccessAt: ago(10 * HOUR) },
],
@@ -227,6 +229,76 @@ describe("database dump", () => {
});
});
describe("dump retention in the bucket", () => {
const goingOffhost = dump({ offhostConfigured: true });
const evalRule = (
databaseLifecycle: ReadinessInputs["offhost"]["databaseLifecycle"],
d: ReadinessInputs["dump"] = goingOffhost,
configured = true,
) =>
evaluateDumpRetention({
dump: d,
offhost: { configured, databaseLifecycle, accounts: [] },
});
it("asks for a rule when dumps go to a bucket that never removes them", () => {
expect(evalRule({ state: "missing", expirationDays: null })).toMatchObject({
state: "attention",
reason: "missing",
fixPath: "/admin/backups",
});
});
it("names the expiry when a rule covers database/, and stays quiet when the bucket will not say", () => {
expect(evalRule({ state: "configured", expirationDays: 30 })).toMatchObject(
{ state: "ok", reason: "configured", params: { days: 30 } },
);
expect(evalRule({ state: "unknown", expirationDays: null })).toMatchObject({
state: "ok",
reason: "unknown",
});
expect(evalRule(null)).toMatchObject({ state: "ok", reason: "unknown" });
});
it("does not apply without a bucket, or while no dump goes to it", () => {
const missing = { state: "missing", expirationDays: null } as const;
expect(evalRule(missing, goingOffhost, false)).toMatchObject({
state: "inactive",
reason: "not_configured",
});
for (const d of [
null,
"unreadable" as const,
dump({ offhostConfigured: false }),
dump({ offhostConfigured: true, enabled: false }),
]) {
expect(evalRule(missing, d)).toMatchObject({
state: "inactive",
reason: "no_offhost_dumps",
});
}
});
it("moves the overall verdict to attention on its own", () => {
const r = evaluateRecoveryReadiness(
inputs({
dump: goingOffhost,
offhost: {
configured: true,
databaseLifecycle: { state: "missing", expirationDays: null },
accounts: [
{ lastAttemptAt: ago(10 * HOUR), lastSuccessAt: ago(10 * HOUR) },
],
},
}),
);
expect(
r.signals.filter((x) => x.state !== "ok" && x.state !== "inactive"),
).toEqual([expect.objectContaining({ id: "dump_retention" })]);
expect(r.state).toBe("attention");
});
});
describe("weekly copy", () => {
const weekly = (
...accounts: Array<{ createdAt: Date; lastCopyAt: Date | null }>
@@ -289,7 +361,11 @@ describe("off-host copies", () => {
lastAttemptAt: Date | null;
lastSuccessAt: Date | null;
}>
) => evaluateOffhostCopies({ now: NOW, offhost: { configured, accounts } });
) =>
evaluateOffhostCopies({
now: NOW,
offhost: { configured, databaseLifecycle: null, accounts },
});
it("does not apply while no bucket is configured", () => {
expect(offhost(false).state).toBe("inactive");
@@ -337,7 +413,10 @@ describe("off-host copies", () => {
describe("restore drill", () => {
const drill = (d: ReadinessInputs["drill"], configured = true) =>
evaluateRestoreDrill({ offhost: { configured, accounts: [] }, drill: d });
evaluateRestoreDrill({
offhost: { configured, databaseLifecycle: null, accounts: [] },
drill: d,
});
const run = {
lastRunAt: ago(5 * DAY),
lastSuccessAt: null as Date | null,
@@ -386,7 +465,7 @@ describe("off-host setup", () => {
evaluateOffhostSetup({
now: NOW,
firstAdminAt,
offhost: { configured, accounts: [] },
offhost: { configured, databaseLifecycle: null, accounts: [] },
});
it("asks for a bucket 7 days after the first admin exists", () => {
+51 -2
View File
@@ -7,8 +7,11 @@
* ordinary row. A failed monthly drill was on the failing-jobs card three days
* in thirty. An off-host bucket that was never configured showed only on the
* backups page, and the key step confirmed one key of the several a restore
* needs. This module turns them into six signals with one verdict each, which
* the readiness tile shows and the hourly evaluation pushes to every admin.
* needs. A bucket whose lifecycle rule left `database/` out kept every dump,
* and every deleted account inside it, forever, and only the backups page
* said so. This module turns them into seven signals with one verdict each,
* which the readiness tile shows and the hourly evaluation pushes to every
* admin.
*
* Pure: the inputs are read elsewhere (`readiness.ts`), so each threshold is a
* unit test away and the tile and the push cannot disagree about a verdict.
@@ -26,6 +29,7 @@ import {
classifyOffhostBackup,
type OffhostBackupFreshness,
} from "@/lib/jobs/offhost-backup-freshness";
import type { LifecycleVerdict } from "@/lib/jobs/offhost-lifecycle";
import type { DatabaseBackupHealth } from "@/lib/recovery/database-dump-health";
import type { KitEntry } from "@/lib/recovery/kit-rules";
@@ -50,6 +54,7 @@ export const READINESS_SIGNALS = [
"offhost_copies",
"restore_drill",
"offhost_setup",
"dump_retention",
"recovery_kit",
] as const;
export type ReadinessSignalId = (typeof READINESS_SIGNALS)[number];
@@ -69,6 +74,7 @@ export const READINESS_FIX_PATHS: Record<ReadinessSignalId, string> = {
offhost_copies: "/admin/backups",
restore_drill: "/admin/backups",
offhost_setup: "/admin/backups",
dump_retention: "/admin/backups",
recovery_kit: "/admin/encryption",
};
@@ -121,6 +127,12 @@ export interface ReadinessInputs {
};
offhost: {
configured: boolean;
/**
* The bucket's lifecycle rules for keys under `database/`, as the backups
* page reads them (`probeOffhostLifecycle`). Null when no bucket is
* configured.
*/
databaseLifecycle: LifecycleVerdict | null;
accounts: ReadonlyArray<{
lastAttemptAt: Date | null;
lastSuccessAt: Date | null;
@@ -385,6 +397,42 @@ export function evaluateOffhostSetup(
: signal(id, "ok", "not_configured_grace", graceEnds);
}
/**
* Whether the bucket ever removes the database dumps. Each one holds every
* account that existed when it was taken, so a bucket with no rule for
* `database/` keeps a deleted account's data for as long as the bucket
* exists. Attention, not failing: every restore still works. A bucket that
* will not say (a credential without the permission, a target without the
* call) is not news either way.
*/
export function evaluateDumpRetention(
input: Pick<ReadinessInputs, "dump" | "offhost">,
): ReadinessSignal {
const id = "dump_retention";
if (!input.offhost.configured) {
return signal(id, "inactive", "not_configured", null);
}
const { dump } = input;
const dumpsGoOffhost =
dump !== null &&
dump !== "unreadable" &&
dump.enabled &&
dump.offhostConfigured;
if (!dumpsGoOffhost) {
return signal(id, "inactive", "no_offhost_dumps", null);
}
const rule = input.offhost.databaseLifecycle;
if (rule?.state === "missing") {
return signal(id, "attention", "missing", null);
}
if (rule?.state === "configured" && rule.expirationDays !== null) {
return signal(id, "ok", "configured", null, {
days: rule.expirationDays,
});
}
return signal(id, "ok", "unknown", null);
}
export function evaluateRecoveryKit(
input: Pick<ReadinessInputs, "now" | "firstAdminAt" | "kit">,
): ReadinessSignal {
@@ -431,6 +479,7 @@ export function evaluateRecoveryReadiness(
evaluateOffhostCopies(inputs),
evaluateRestoreDrill(inputs),
evaluateOffhostSetup(inputs),
evaluateDumpRetention(inputs),
evaluateRecoveryKit(inputs),
];
const state = signals.reduce<Exclude<ReadinessState, "inactive">>(
+11 -1
View File
@@ -11,12 +11,17 @@
* - the off-host copies: the per-account ledger the nightly run writes, and
* the same "configured" rule the backups page uses (the environment, or a
* ledger row as proof that an upload happened);
* - the bucket's lifecycle rule for `database/`: the same cached probe the
* backups page shows, asked only when a bucket is configured;
* - the drill: its persisted last outcome, with no time window;
* - the kit: the same assessment the kit card shows;
* - the dump: whatever the dump module reports, through a provider.
*/
import { prisma } from "@/lib/db";
import { offhostBackupConfigured } from "@/lib/jobs/offhost-backup";
import {
offhostBackupConfigured,
probeOffhostLifecycle,
} from "@/lib/jobs/offhost-backup";
import {
readDatabaseBackupHealth,
type DatabaseBackupHealthProvider,
@@ -74,6 +79,10 @@ export async function readReadinessInputs(
const configured =
offhostBackupConfigured() ||
accounts.some((account) => account.offhostBackupState !== null);
// Never throws: a bucket that cannot be asked answers `unknown`.
const databaseLifecycle = configured
? (await probeOffhostLifecycle()).database
: null;
return {
now,
@@ -88,6 +97,7 @@ export async function readReadinessInputs(
},
offhost: {
configured,
databaseLifecycle,
accounts: accounts.map((account) => ({
lastAttemptAt: account.offhostBackupState?.lastAttemptAt ?? null,
lastSuccessAt: account.offhostBackupState?.lastSuccessAt ?? null,