feat(admin): say how long dumps keep deleted accounts, and check the bucket rule for database/

A database dump is the whole database at one moment, so an account deleted
since stays in the dumps taken before until they age out. The database row
on the backups card now says so with the numbers that decide it: how many
nightly dumps this server keeps, and after how many days the bucket's
lifecycle rule removes the off-host copies.

The lifecycle probe used to answer with the shortest expiry of any enabled
rule, whatever its prefix, so a rule scoped to the account copies read as
covering the dumps too. It now also answers for `database/` alone, counting
only rules whose filter takes in a dump's key (prefix, legacy prefix, an
And filter; a tag filter never matches untagged objects), and the card
warns when no rule covers that prefix: dumps there, and the deleted accounts
in them, would never be removed. The reading moved into its own module.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 13:43:23 +01:00
1 parent 82db3de30a
commit e825a4c4d3
20 files changed
+355 -47

No files matched your search

+4 -1
View File
@@ -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": {
+4 -1
View File
@@ -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": {
+4 -1
View File
@@ -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": {
+4 -1
View File
@@ -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": {
+4 -1
View File
@@ -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": {
+4 -1
View File
@@ -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": {
+4 -1
View File
@@ -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": {
+4 -1
View File
@@ -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": {
+4 -1
View File
@@ -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": {
@@ -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,
+2 -3
View File
@@ -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),
]);
@@ -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("<OffhostBackupsSection>", () => {
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");
+8 -1
View File
@@ -111,7 +111,14 @@ export function BackupsSection() {
}
/>
{data?.database ? <DatabaseDumpRow health={data.database} /> : null}
{data?.database ? (
<DatabaseDumpRow
health={data.database}
bucketLifecycle={
data.offhost.configured ? data.offhost.lifecycle.database : null
}
/>
) : null}
{data?.schedule ? (
<ScheduleHealthNotice schedule={data.schedule} />
) : null}
@@ -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(
<I18nProvider initialLocale="en">
<DatabaseDumpRow health={health} bucketLifecycle={bucketLifecycle} />
</I18nProvider>,
);
}
describe("<DatabaseDumpRow>", () => {
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",
);
});
});
@@ -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<DatabaseBackupHealth["lastGood"]>,
@@ -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")}
</p>
{/* How long a deleted account stays in the dumps: the operator's
side of the deletion promise (GDPR retention of backups). */}
<p
className="text-muted-foreground text-xs"
data-slot="database-dump-retention"
>
{t("admin.section.backups.database.retentionLocal", {
nights: health.keepNightly,
})}
{bucketLifecycle?.state === "configured"
? ` ${t("admin.section.backups.database.retentionOffhost", {
days: bucketLifecycle.expirationDays ?? 0,
})}`
: null}
</p>
</div>
</div>
@@ -99,6 +122,17 @@ export function DatabaseDumpRow({ health }: { health: DatabaseBackupHealth }) {
</div>
) : null}
{bucketLifecycle?.state === "missing" ? (
<SettingsInfoTile
icon={AlertTriangle}
tone="warning"
role="alert"
data-slot="database-dump-no-lifecycle"
>
{t("admin.section.backups.database.retentionOffhostMissing")}
</SettingsInfoTile>
) : null}
{setupProblem ? (
<SettingsInfoTile
icon={AlertTriangle}
+8
View File
@@ -16,6 +16,7 @@ import {
databaseBackupEnabled,
isWritableDirectory,
resolveBackupDir,
resolveDailyKeep,
} from "./dump-core.mjs";
export interface DatabaseBackupHealth {
@@ -23,6 +24,12 @@ export interface DatabaseBackupHealth {
enabled: boolean;
/** The directory dumps are kept in (`DB_BACKUP_DIR`). */
directory: string;
/**
* Nightly dumps kept in that directory (`DB_BACKUP_KEEP`). Also how long a
* deleted account stays in the dumps on this server, which the backups
* page says.
*/
keepNightly: number;
/** This process can write there. False means dumps only go off-host. */
localDirWritable: boolean;
/** The off-host bucket is configured, or a dump has been uploaded to it. */
@@ -100,6 +107,7 @@ export async function getDatabaseBackupHealth(
return {
enabled: databaseBackupEnabled(env),
directory,
keepNightly: resolveDailyKeep(env),
localDirWritable: isWritableDirectory(directory),
offhostConfigured: offhostBackupConfigured() || everUploaded > 0,
lastGood: lastGood
@@ -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> = {}): 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");
});
});
+13 -26
View File
@@ -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<OffhostLifecycle>;
}
/**
* 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<S3Like> {
// Dynamic import so unit tests + dev environments without the SDK don't fail.
@@ -765,13 +760,7 @@ export async function getS3Client(cfg: OffhostBackupConfig): Promise<S3Like> {
}),
{ 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<S3Like> {
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;
+79
View File
@@ -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 (`<date>/user-<id>.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)),
),
};
}
+2 -4
View File
@@ -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 {