diff --git a/messages/de.json b/messages/de.json index eaf1be7fc..d29da7322 100644 --- a/messages/de.json +++ b/messages/de.json @@ -8322,7 +8322,10 @@ "failedDescription": "{when}: {reason}", "disabled": "Datenbank-Dumps sind abgeschaltet (DB_BACKUP_ENABLED=false). Sichere die Datenbank auf anderem Weg.", "noDirectoryOffhost": "Dieser Server kann nicht nach {dir} schreiben. Dumps gehen deshalb nur extern, hier wird keiner aufbewahrt. Binde das Backup-Volume ein.", - "noDirectory": "Dieser Server kann nicht nach {dir} schreiben und es ist kein externer Speicher eingerichtet. Deshalb entsteht kein Datenbank-Dump. Binde das Backup-Volume ein." + "noDirectory": "Dieser Server kann nicht nach {dir} schreiben und es ist kein externer Speicher eingerichtet. Deshalb entsteht kein Datenbank-Dump. Binde das Backup-Volume ein.", + "retentionLocal": "Gelöschte Konten bleiben in den Dumps, bis diese aussortiert werden: Auf diesem Server bleiben die neuesten {nights}.", + "retentionOffhost": "Externe Kopien entfernt die Lebenszyklus-Regel des Buckets nach {days} Tagen.", + "retentionOffhostMissing": "Keine Lebenszyklus-Regel im Bucket gilt für database/. Dumps dort und die gelöschten Konten darin werden deshalb nie entfernt. Lege eine Regel für dieses Präfix an." } }, "danger-zone": { diff --git a/messages/en.json b/messages/en.json index b5656728e..0d22de2aa 100644 --- a/messages/en.json +++ b/messages/en.json @@ -8322,7 +8322,10 @@ "failedDescription": "{when}: {reason}", "disabled": "Database dumps are switched off (DB_BACKUP_ENABLED=false). Back the database up another way.", "noDirectoryOffhost": "This server cannot write to {dir}, so dumps go off-host only and none is kept here. Mount the backups volume.", - "noDirectory": "This server cannot write to {dir} and no off-host bucket is set, so no database dump can be taken. Mount the backups volume." + "noDirectory": "This server cannot write to {dir} and no off-host bucket is set, so no database dump can be taken. Mount the backups volume.", + "retentionLocal": "Deleted accounts stay in the dumps until those age out: the newest {nights} are kept on this server.", + "retentionOffhost": "Off-host copies are removed after {days} days by the bucket's lifecycle rule.", + "retentionOffhostMissing": "No lifecycle rule in the bucket covers database/, so dumps there, and the deleted accounts inside them, are never removed. Add a rule for that prefix." } }, "danger-zone": { diff --git a/messages/es.json b/messages/es.json index a9c2b3c55..535cccd86 100644 --- a/messages/es.json +++ b/messages/es.json @@ -8322,7 +8322,10 @@ "failedDescription": "{when}: {reason}", "disabled": "Los volcados de la base de datos están desactivados (DB_BACKUP_ENABLED=false). Haz la copia de la base de datos de otra forma.", "noDirectoryOffhost": "Este servidor no puede escribir en {dir}, así que los volcados solo se guardan fuera y aquí no queda ninguno. Monta el volumen de copias.", - "noDirectory": "Este servidor no puede escribir en {dir} y no hay almacenamiento externo configurado, así que no se puede hacer ningún volcado. Monta el volumen de copias." + "noDirectory": "Este servidor no puede escribir en {dir} y no hay almacenamiento externo configurado, así que no se puede hacer ningún volcado. Monta el volumen de copias.", + "retentionLocal": "Las cuentas eliminadas siguen en los volcados hasta que estos caducan: en este servidor se guardan los {nights} más recientes.", + "retentionOffhost": "La regla de ciclo de vida del bucket elimina las copias externas tras {days} días.", + "retentionOffhostMissing": "Ninguna regla de ciclo de vida del bucket cubre database/, así que los volcados de ahí, y las cuentas eliminadas que contienen, nunca se borran. Añade una regla para ese prefijo." } }, "danger-zone": { diff --git a/messages/fr.json b/messages/fr.json index 2c38c64f1..f3d2c0519 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -8322,7 +8322,10 @@ "failedDescription": "{when} : {reason}", "disabled": "Les dumps de la base de données sont désactivés (DB_BACKUP_ENABLED=false). Sauvegardez la base de données autrement.", "noDirectoryOffhost": "Ce serveur ne peut pas écrire dans {dir} : les dumps partent uniquement hors site et aucun n'est conservé ici. Montez le volume de sauvegarde.", - "noDirectory": "Ce serveur ne peut pas écrire dans {dir} et aucun stockage hors site n'est configuré : aucun dump ne peut être fait. Montez le volume de sauvegarde." + "noDirectory": "Ce serveur ne peut pas écrire dans {dir} et aucun stockage hors site n'est configuré : aucun dump ne peut être fait. Montez le volume de sauvegarde.", + "retentionLocal": "Les comptes supprimés restent dans les dumps jusqu'à leur expiration : ce serveur conserve les {nights} plus récents.", + "retentionOffhost": "La règle de cycle de vie du bucket supprime les copies hors site après {days} jours.", + "retentionOffhostMissing": "Aucune règle de cycle de vie du bucket ne couvre database/ : les dumps qui s'y trouvent, et les comptes supprimés qu'ils contiennent, ne sont jamais effacés. Ajoutez une règle pour ce préfixe." } }, "danger-zone": { diff --git a/messages/it.json b/messages/it.json index 86af90b9d..fe7e6b4f9 100644 --- a/messages/it.json +++ b/messages/it.json @@ -8322,7 +8322,10 @@ "failedDescription": "{when}: {reason}", "disabled": "I dump del database sono disattivati (DB_BACKUP_ENABLED=false). Esegui il backup del database in un altro modo.", "noDirectoryOffhost": "Questo server non può scrivere in {dir}, quindi i dump vanno solo fuori sede e qui non ne resta nessuno. Monta il volume dei backup.", - "noDirectory": "Questo server non può scrivere in {dir} e non è configurato alcuno storage esterno, quindi non si può fare alcun dump. Monta il volume dei backup." + "noDirectory": "Questo server non può scrivere in {dir} e non è configurato alcuno storage esterno, quindi non si può fare alcun dump. Monta il volume dei backup.", + "retentionLocal": "Gli account eliminati restano nei dump finché questi non scadono: su questo server restano i {nights} più recenti.", + "retentionOffhost": "La regola del ciclo di vita del bucket elimina le copie esterne dopo {days} giorni.", + "retentionOffhostMissing": "Nessuna regola del ciclo di vita del bucket copre database/, quindi i dump lì, e gli account eliminati che contengono, non vengono mai rimossi. Aggiungi una regola per quel prefisso." } }, "danger-zone": { diff --git a/messages/ko.json b/messages/ko.json index 8325cf886..4300dfc7b 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -8297,7 +8297,10 @@ "failedDescription": "{when}: {reason}", "disabled": "데이터베이스 덤프가 꺼져 있습니다(DB_BACKUP_ENABLED=false). 다른 방법으로 데이터베이스를 백업하세요.", "noDirectoryOffhost": "이 서버는 {dir}에 쓸 수 없어 덤프가 외부 저장소로만 가고 여기에는 보관되지 않습니다. 백업 볼륨을 마운트하세요.", - "noDirectory": "이 서버는 {dir}에 쓸 수 없고 외부 저장소도 설정되지 않아 데이터베이스 덤프를 만들 수 없습니다. 백업 볼륨을 마운트하세요." + "noDirectory": "이 서버는 {dir}에 쓸 수 없고 외부 저장소도 설정되지 않아 데이터베이스 덤프를 만들 수 없습니다. 백업 볼륨을 마운트하세요.", + "retentionLocal": "삭제된 계정은 덤프가 만료될 때까지 덤프에 남습니다. 이 서버에는 최신 덤프 {nights}개가 보관됩니다.", + "retentionOffhost": "버킷의 수명 주기 규칙이 외부 사본을 {days}일 후에 삭제합니다.", + "retentionOffhostMissing": "버킷의 어떤 수명 주기 규칙도 database/를 다루지 않아 그곳의 덤프와 그 안의 삭제된 계정이 절대 삭제되지 않습니다. 이 접두사에 대한 규칙을 추가하세요." } }, "danger-zone": { diff --git a/messages/nl.json b/messages/nl.json index 9026deedf..63d6863ce 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -8322,7 +8322,10 @@ "failedDescription": "{when}: {reason}", "disabled": "Databasedumps staan uit (DB_BACKUP_ENABLED=false). Maak op een andere manier een back-up van de database.", "noDirectoryOffhost": "Deze server kan niet schrijven naar {dir}, dus dumps gaan alleen extern en hier wordt er geen bewaard. Koppel het back-upvolume.", - "noDirectory": "Deze server kan niet schrijven naar {dir} en er is geen externe opslag ingesteld, dus er kan geen databasedump worden gemaakt. Koppel het back-upvolume." + "noDirectory": "Deze server kan niet schrijven naar {dir} en er is geen externe opslag ingesteld, dus er kan geen databasedump worden gemaakt. Koppel het back-upvolume.", + "retentionLocal": "Verwijderde accounts blijven in de dumps tot die verlopen: op deze server blijven de nieuwste {nights} bewaard.", + "retentionOffhost": "De levenscyclusregel van de bucket verwijdert externe kopieën na {days} dagen.", + "retentionOffhostMissing": "Geen levenscyclusregel in de bucket geldt voor database/, dus dumps daar, en de verwijderde accounts erin, worden nooit verwijderd. Voeg een regel voor dat voorvoegsel toe." } }, "danger-zone": { diff --git a/messages/pl.json b/messages/pl.json index 850a5f449..e117927df 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -8322,7 +8322,10 @@ "failedDescription": "{when}: {reason}", "disabled": "Zrzuty bazy danych są wyłączone (DB_BACKUP_ENABLED=false). Zrób kopię bazy danych w inny sposób.", "noDirectoryOffhost": "Ten serwer nie może zapisywać w {dir}, więc zrzuty trafiają tylko poza serwer i żaden nie zostaje tutaj. Podłącz wolumin kopii.", - "noDirectory": "Ten serwer nie może zapisywać w {dir} i nie ustawiono magazynu zewnętrznego, więc nie da się wykonać zrzutu bazy. Podłącz wolumin kopii." + "noDirectory": "Ten serwer nie może zapisywać w {dir} i nie ustawiono magazynu zewnętrznego, więc nie da się wykonać zrzutu bazy. Podłącz wolumin kopii.", + "retentionLocal": "Usunięte konta pozostają w zrzutach, dopóki te nie wygasną: na tym serwerze zostaje {nights} najnowszych.", + "retentionOffhost": "Reguła cyklu życia bucketu usuwa kopie zewnętrzne po {days} dniach.", + "retentionOffhostMissing": "Żadna reguła cyklu życia bucketu nie obejmuje database/, więc zrzuty tam, wraz z usuniętymi kontami w nich, nigdy nie są usuwane. Dodaj regułę dla tego prefiksu." } }, "danger-zone": { diff --git a/messages/pt-BR.json b/messages/pt-BR.json index 7343679c5..b4df76db7 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -8322,7 +8322,10 @@ "failedDescription": "{when}: {reason}", "disabled": "Os dumps do banco de dados estão desligados (DB_BACKUP_ENABLED=false). Faça o backup do banco de outra forma.", "noDirectoryOffhost": "Este servidor não consegue gravar em {dir}, então os dumps vão só para fora e nenhum fica aqui. Monte o volume de backups.", - "noDirectory": "Este servidor não consegue gravar em {dir} e não há armazenamento externo configurado, então nenhum dump pode ser feito. Monte o volume de backups." + "noDirectory": "Este servidor não consegue gravar em {dir} e não há armazenamento externo configurado, então nenhum dump pode ser feito. Monte o volume de backups.", + "retentionLocal": "Contas excluídas continuam nos dumps até que eles expirem: este servidor guarda os {nights} mais recentes.", + "retentionOffhost": "A regra de ciclo de vida do bucket remove as cópias externas após {days} dias.", + "retentionOffhostMissing": "Nenhuma regra de ciclo de vida do bucket cobre database/, então os dumps ali, e as contas excluídas dentro deles, nunca são removidos. Adicione uma regra para esse prefixo." } }, "danger-zone": { diff --git a/src/app/api/admin/backups/__tests__/route.test.ts b/src/app/api/admin/backups/__tests__/route.test.ts index ec407f99f..b52c31a8c 100644 --- a/src/app/api/admin/backups/__tests__/route.test.ts +++ b/src/app/api/admin/backups/__tests__/route.test.ts @@ -54,12 +54,14 @@ vi.mock("@/lib/jobs/offhost-backup", () => ({ probeOffhostLifecycle: async () => ({ state: "missing", expirationDays: null, + database: { state: "missing", expirationDays: null }, }), })); const databaseHealth = { enabled: true, directory: "/var/lib/healthlog/backups", + keepNightly: 7, localDirWritable: true, offhostConfigured: true, lastGood: { @@ -184,7 +186,11 @@ describe("GET /api/admin/backups — off-host freshness", () => { periodHours: 24, rows: [], // No bucket to ask on a host that is not configured. - lifecycle: { state: "unknown", expirationDays: null }, + lifecycle: { + state: "unknown", + expirationDays: null, + database: { state: "unknown", expirationDays: null }, + }, }); }); @@ -247,6 +253,7 @@ describe("GET /api/admin/backups — off-host freshness", () => { expect(body.offhost.lifecycle).toEqual({ state: "missing", expirationDays: null, + database: { state: "missing", expirationDays: null }, }); expect(body.offhost.pendingDeletions).toEqual({ count: 2, diff --git a/src/app/api/admin/backups/route.ts b/src/app/api/admin/backups/route.ts index c9bd278cd..f4854239f 100644 --- a/src/app/api/admin/backups/route.ts +++ b/src/app/api/admin/backups/route.ts @@ -41,6 +41,7 @@ import { probeOffhostLifecycle, } from "@/lib/jobs/offhost-backup"; import { OFFHOST_BACKUP_QUEUE } from "@/lib/jobs/offhost-backup-queue"; +import { LIFECYCLE_UNKNOWN } from "@/lib/jobs/offhost-lifecycle"; import { classifyOffhostBackup, OFFHOST_BACKUP_PERIOD_HOURS, @@ -202,9 +203,7 @@ export const GET = apiHandler(async () => { orderBy: { requestedAt: "asc" }, select: { requestedAt: true, lastFailure: true }, }), - configured - ? probeOffhostLifecycle() - : Promise.resolve({ state: "unknown" as const, expirationDays: null }), + configured ? probeOffhostLifecycle() : Promise.resolve(LIFECYCLE_UNKNOWN), getDatabaseBackupHealth(now), ]); diff --git a/src/components/admin/__tests__/offhost-backups-section.test.tsx b/src/components/admin/__tests__/offhost-backups-section.test.tsx index 3631fe121..0ca93728b 100644 --- a/src/components/admin/__tests__/offhost-backups-section.test.tsx +++ b/src/components/admin/__tests__/offhost-backups-section.test.tsx @@ -26,7 +26,11 @@ import { OffhostBackupsSection } from "../offhost-backups-section"; const QUIET = { activity: { runningSince: null, interrupted: [] }, pendingDeletions: { count: 0, oldestRequestedAt: null, lastFailure: null }, - lifecycle: { state: "configured" as const, expirationDays: 30 }, + lifecycle: { + state: "configured" as const, + expirationDays: 30, + database: { state: "configured" as const, expirationDays: 30 }, + }, }; function render(data: BackupsList["offhost"] | null): string { @@ -133,7 +137,11 @@ describe("", () => { oldestRequestedAt: "2026-09-20T10:00:00.000Z", lastFailure: "AccessDenied", }, - lifecycle: { state: "missing", expirationDays: null }, + lifecycle: { + state: "missing", + expirationDays: null, + database: { state: "missing", expirationDays: null }, + }, }); expect(html).toContain('data-lifecycle-state="missing"'); expect(html).toContain("no lifecycle rule"); diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index 12355b7e2..568e652d6 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -111,7 +111,14 @@ export function BackupsSection() { } /> - {data?.database ? : null} + {data?.database ? ( + + ) : null} {data?.schedule ? ( ) : null} diff --git a/src/components/admin/backups/__tests__/database-dump-row.test.tsx b/src/components/admin/backups/__tests__/database-dump-row.test.tsx new file mode 100644 index 000000000..0a0721661 --- /dev/null +++ b/src/components/admin/backups/__tests__/database-dump-row.test.tsx @@ -0,0 +1,93 @@ +/** + * The database row on the backups card says where the newest good dump is, + * how long a deleted account stays in the dumps, and each thing that keeps a + * dump from being taken or from ever ageing out of the bucket. + */ +import { describe, expect, it } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { DatabaseBackupHealth } from "@/lib/database-backup/health"; +import type { LifecycleVerdict } from "@/lib/jobs/offhost-lifecycle"; + +import { DatabaseDumpRow } from "../database-dump-row"; + +const HEALTHY: DatabaseBackupHealth = { + enabled: true, + directory: "/var/lib/healthlog/backups", + keepNightly: 7, + localDirWritable: true, + offhostConfigured: true, + lastGood: { + at: "2026-10-24T23:31:00.000Z", + fileName: "healthlog-20261024T233000Z-daily.dump", + sizeBytes: 52_428_800, + local: true, + offhost: true, + }, + lastFailure: null, +}; + +function render( + health: DatabaseBackupHealth, + bucketLifecycle: LifecycleVerdict | null = { + state: "configured", + expirationDays: 30, + }, +): string { + return renderToStaticMarkup( + + + , + ); +} + +describe("", () => { + it("names the newest good dump, where it is, and how long dumps keep deleted accounts", () => { + const html = render(HEALTHY); + expect(html).toContain("kept on this server and off-host"); + expect(html).toContain("the newest 7 are kept on this server"); + expect(html).toContain("removed after 30 days"); + expect(html).not.toContain('data-slot="database-dump-failed"'); + expect(html).not.toContain('data-slot="database-dump-setup"'); + expect(html).not.toContain('data-slot="database-dump-no-lifecycle"'); + }); + + it("warns when no bucket rule ever removes the dumps", () => { + const html = render(HEALTHY, { state: "missing", expirationDays: null }); + expect(html).toContain('data-slot="database-dump-no-lifecycle"'); + expect(html).toContain("never removed"); + }); + + it("shows a failure newer than the last good dump, and not an older one", () => { + const failed = render({ + ...HEALTHY, + lastFailure: { + at: "2026-10-25T23:31:00.000Z", + error: "pg_dump exited 1: No space left on device", + }, + }); + expect(failed).toContain('data-slot="database-dump-failed"'); + expect(failed).toContain("No space left on device"); + const recovered = render({ + ...HEALTHY, + lastFailure: { at: "2026-10-20T23:31:00.000Z", error: "old" }, + }); + expect(recovered).not.toContain('data-slot="database-dump-failed"'); + }); + + it("says when dumps go off-host only, or cannot be taken at all", () => { + expect(render({ ...HEALTHY, localDirWritable: false }, null)).toContain( + "dumps go off-host only", + ); + expect( + render( + { ...HEALTHY, localDirWritable: false, offhostConfigured: false }, + null, + ), + ).toContain("no database dump can be taken"); + expect(render({ ...HEALTHY, lastGood: null }, null)).toContain( + "No database dump yet", + ); + }); +}); diff --git a/src/components/admin/backups/database-dump-row.tsx b/src/components/admin/backups/database-dump-row.tsx index eb1711c43..49ebc8960 100644 --- a/src/components/admin/backups/database-dump-row.tsx +++ b/src/components/admin/backups/database-dump-row.tsx @@ -14,6 +14,7 @@ import { SettingsInfoTile } from "@/components/settings/_info-tile"; import { formatBytes } from "@/lib/format/bytes"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import type { DatabaseBackupHealth } from "@/lib/database-backup/health"; +import type { LifecycleVerdict } from "@/lib/jobs/offhost-lifecycle"; function whereKept( good: NonNullable, @@ -26,7 +27,14 @@ function whereKept( return t("admin.section.backups.database.whereLocal"); } -export function DatabaseDumpRow({ health }: { health: DatabaseBackupHealth }) { +export function DatabaseDumpRow({ + health, + bucketLifecycle, +}: { + health: DatabaseBackupHealth; + /** The bucket's rule for `database/`; null when nothing goes off-host. */ + bucketLifecycle: LifecycleVerdict | null; +}) { const { t, locale } = useTranslations(); const fmt = useFormatters(); const good = health.lastGood; @@ -70,6 +78,21 @@ export function DatabaseDumpRow({ health }: { health: DatabaseBackupHealth }) { }) : t("admin.section.backups.database.none")}

+ {/* How long a deleted account stays in the dumps: the operator's + side of the deletion promise (GDPR retention of backups). */} +

+ {t("admin.section.backups.database.retentionLocal", { + nights: health.keepNightly, + })} + {bucketLifecycle?.state === "configured" + ? ` ${t("admin.section.backups.database.retentionOffhost", { + days: bucketLifecycle.expirationDays ?? 0, + })}` + : null} +

@@ -99,6 +122,17 @@ export function DatabaseDumpRow({ health }: { health: DatabaseBackupHealth }) { ) : null} + {bucketLifecycle?.state === "missing" ? ( + + {t("admin.section.backups.database.retentionOffhostMissing")} + + ) : null} + {setupProblem ? ( 0, lastGood: lastGood diff --git a/src/lib/jobs/__tests__/offhost-lifecycle.test.ts b/src/lib/jobs/__tests__/offhost-lifecycle.test.ts new file mode 100644 index 000000000..a85278266 --- /dev/null +++ b/src/lib/jobs/__tests__/offhost-lifecycle.test.ts @@ -0,0 +1,61 @@ +/** + * Which lifecycle rules retire the database dumps under `database/`, apart + * from the account copies: a rule scoped to another prefix, or to a tag, does + * not touch them. + */ +import { describe, expect, it } from "vitest"; +import type { LifecycleRule } from "@aws-sdk/client-s3"; + +import { lifecycleFromRules, ruleAppliesTo } from "../offhost-lifecycle"; + +const DUMP = "database/healthlog-20261025T013000Z-daily.dump.enc"; + +function rule(days: number, extra: Partial = {}): LifecycleRule { + return { Status: "Enabled", Expiration: { Days: days }, ...extra }; +} + +describe("lifecycle rules and the database dumps", () => { + it("counts a rule for the whole bucket, or for database/, for both", () => { + expect(lifecycleFromRules([rule(30)])).toEqual({ + state: "configured", + expirationDays: 30, + database: { state: "configured", expirationDays: 30 }, + }); + expect( + lifecycleFromRules([ + rule(30, { Filter: { Prefix: "2026-" } }), + rule(14, { Filter: { Prefix: "database/" } }), + ]).database, + ).toEqual({ state: "configured", expirationDays: 14 }); + }); + + it("says database/ is not covered when the only rule is scoped elsewhere", () => { + const result = lifecycleFromRules([ + rule(30, { Filter: { Prefix: "2026-" } }), + ]); + expect(result.state).toBe("configured"); + expect(result.database).toEqual({ state: "missing", expirationDays: null }); + }); + + it("reads the legacy prefix, the And filter, tags and disabled rules", () => { + expect(ruleAppliesTo(rule(5, { Prefix: "database/" }), DUMP)).toBe(true); + expect(ruleAppliesTo(rule(5, { Prefix: "other/" }), DUMP)).toBe(false); + expect( + ruleAppliesTo( + rule(5, { + Filter: { And: { Prefix: "database/", ObjectSizeGreaterThan: 1 } }, + }), + DUMP, + ), + ).toBe(true); + expect( + ruleAppliesTo( + rule(5, { Filter: { Tag: { Key: "k", Value: "v" } } }), + DUMP, + ), + ).toBe(false); + expect( + lifecycleFromRules([rule(7, { Status: "Disabled" })]).database.state, + ).toBe("missing"); + }); +}); diff --git a/src/lib/jobs/offhost-backup.ts b/src/lib/jobs/offhost-backup.ts index f34ded204..d04ae4aaf 100644 --- a/src/lib/jobs/offhost-backup.ts +++ b/src/lib/jobs/offhost-backup.ts @@ -44,6 +44,12 @@ import { import { annotate, getEvent } from "@/lib/logging/context"; import { envValue } from "@/lib/env"; import { OFFHOST_BACKUP_QUEUE } from "./offhost-backup-queue"; +import { + LIFECYCLE_MISSING, + LIFECYCLE_UNKNOWN, + lifecycleFromRules, + type OffhostLifecycle, +} from "./offhost-lifecycle"; const ALGORITHM = "aes-256-gcm"; const IV_LENGTH = 12; @@ -634,18 +640,7 @@ export interface S3Like { getLifecycle?(): Promise; } -/** - * Whether the bucket retires old copies by itself. - * - * `configured` names the shortest expiry of an enabled rule; `missing` means - * the bucket answered that it has no rule; `unknown` means it would not say - * (a credential without `s3:GetLifecycleConfiguration`, a target that does not - * implement the call, a network failure) and says nothing either way. - */ -export interface OffhostLifecycle { - state: "configured" | "missing" | "unknown"; - expirationDays: number | null; -} +export type { OffhostLifecycle } from "./offhost-lifecycle"; export async function getS3Client(cfg: OffhostBackupConfig): Promise { // Dynamic import so unit tests + dev environments without the SDK don't fail. @@ -765,13 +760,7 @@ export async function getS3Client(cfg: OffhostBackupConfig): Promise { }), { abortSignal: AbortSignal.timeout(5_000) }, ); - const days = (out.Rules ?? []) - .filter((rule) => rule.Status === "Enabled") - .map((rule) => rule.Expiration?.Days) - .filter((d): d is number => typeof d === "number" && d > 0); - return days.length > 0 - ? { state: "configured", expirationDays: Math.min(...days) } - : { state: "missing", expirationDays: null }; + return lifecycleFromRules(out.Rules ?? []); } catch (err) { const name = (err as { name?: string; Code?: string }).name; const code = (err as { Code?: string }).Code; @@ -779,9 +768,9 @@ export async function getS3Client(cfg: OffhostBackupConfig): Promise { name === "NoSuchLifecycleConfiguration" || code === "NoSuchLifecycleConfiguration" ) { - return { state: "missing", expirationDays: null }; + return LIFECYCLE_MISSING; } - return { state: "unknown", expirationDays: null }; + return LIFECYCLE_UNKNOWN; } }, }; @@ -806,15 +795,13 @@ export async function probeOffhostLifecycle( return lifecycleCache.value; } const cfg = loadOffhostConfigSafe(); - if (!cfg) return { state: "unknown", expirationDays: null }; + if (!cfg) return LIFECYCLE_UNKNOWN; let value: OffhostLifecycle; try { const s3 = s3Override ?? (await getS3Client(cfg)); - value = s3.getLifecycle - ? await s3.getLifecycle() - : { state: "unknown", expirationDays: null }; + value = s3.getLifecycle ? await s3.getLifecycle() : LIFECYCLE_UNKNOWN; } catch { - value = { state: "unknown", expirationDays: null }; + value = LIFECYCLE_UNKNOWN; } if (!s3Override) lifecycleCache = { at: now, value }; return value; diff --git a/src/lib/jobs/offhost-lifecycle.ts b/src/lib/jobs/offhost-lifecycle.ts new file mode 100644 index 000000000..4638c59d5 --- /dev/null +++ b/src/lib/jobs/offhost-lifecycle.ts @@ -0,0 +1,79 @@ +/** + * Whether the operator's bucket retires old copies by itself, read from its + * lifecycle rules (`GetBucketLifecycleConfiguration`). + * + * Two answers, because the bucket holds two kinds of object with different + * keys: the per-account copies (`/user-.json.enc`) and the database + * dumps (`database/…`). A rule scoped to a prefix covers only the keys under + * it, so a bucket whose one rule expires `2026-` keys ages the account copies + * out and keeps every database dump forever, and a database dump keeps every + * deleted account's data for as long as it exists. The account answer keeps + * its earlier reading (the shortest expiry of any enabled rule); the database + * answer counts only the rules that apply to a key under `database/`. + * + * `configured` names the shortest expiry; `missing` means the bucket answered + * that no rule applies; `unknown` means it would not say (a credential + * without `s3:GetLifecycleConfiguration`, a target that does not implement the + * call, a network failure) and says nothing either way. + */ +import type { LifecycleRule } from "@aws-sdk/client-s3"; + +export interface LifecycleVerdict { + state: "configured" | "missing" | "unknown"; + expirationDays: number | null; +} + +export interface OffhostLifecycle extends LifecycleVerdict { + /** The same question for the database dumps under `database/`. */ + database: LifecycleVerdict; +} + +export const LIFECYCLE_UNKNOWN: OffhostLifecycle = { + state: "unknown", + expirationDays: null, + database: { state: "unknown", expirationDays: null }, +}; + +export const LIFECYCLE_MISSING: OffhostLifecycle = { + state: "missing", + expirationDays: null, + database: { state: "missing", expirationDays: null }, +}; + +/** A key a database dump is stored under, to test the rules against. */ +const DATABASE_SAMPLE_KEY = + "database/healthlog-20260101T013000Z-daily.dump.enc"; + +/** + * Whether a rule's filter takes in `key`. Our objects carry no tags, so a + * rule that filters on a tag never applies to them; a size bound is not a + * reason to call a rule inapplicable, so it is read as applying. + */ +export function ruleAppliesTo(rule: LifecycleRule, key: string): boolean { + const filter = rule.Filter; + if (filter?.Tag || filter?.And?.Tags?.length) return false; + const prefix = filter?.Prefix ?? filter?.And?.Prefix ?? rule.Prefix ?? ""; + return key.startsWith(prefix); +} + +function verdict(rules: readonly LifecycleRule[]): LifecycleVerdict { + const days = rules + .filter((rule) => rule.Status === "Enabled") + .map((rule) => rule.Expiration?.Days) + .filter((d): d is number => typeof d === "number" && d > 0); + return days.length > 0 + ? { state: "configured", expirationDays: Math.min(...days) } + : { state: "missing", expirationDays: null }; +} + +/** Both answers from the rules a bucket returned. */ +export function lifecycleFromRules( + rules: readonly LifecycleRule[], +): OffhostLifecycle { + return { + ...verdict(rules), + database: verdict( + rules.filter((rule) => ruleAppliesTo(rule, DATABASE_SAMPLE_KEY)), + ), + }; +} diff --git a/src/types/backups.ts b/src/types/backups.ts index 5f2a28e2c..49e9a8f98 100644 --- a/src/types/backups.ts +++ b/src/types/backups.ts @@ -10,6 +10,7 @@ * keeps owning the HTTP contract, the component keeps owning the UI. */ import type { DatabaseBackupHealth } from "@/lib/database-backup/health"; +import type { OffhostLifecycle } from "@/lib/jobs/offhost-lifecycle"; import type { BackupPassActivity } from "@/lib/jobs/backup-pass-attempts"; import type { BackupScheduleStatus } from "@/lib/jobs/backup-schedule-status"; import type { OffhostBackupFreshness } from "@/lib/jobs/offhost-backup-freshness"; @@ -77,10 +78,7 @@ export interface OffhostBackupOverview { * (`GetBucketLifecycleConfiguration`), cached for ten minutes; `unknown` * when the credential may not read it. */ - lifecycle: { - state: "configured" | "missing" | "unknown"; - expirationDays: number | null; - }; + lifecycle: OffhostLifecycle; } export interface BackupsList {