mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
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", () => {
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
Reference in new issue
Block a user