feat(backup): admin-tunable retention + BACKUP_KEY as a Komodo secret
Build desktop / desktop (push) Successful in 4m17s
Build & push images / images (push) Failing after 39s
CI / check (push) Successful in 39s

Retention (keep-last / keep-daily-days) is operational policy the on-site admin
should tune, not a server env var requiring a redeploy -- same reasoning that moved
the target directory to the UI.

- Migration 0017: site_config.backup_keep_last + backup_keep_daily_days (nullable;
  null = code default 7 / 30 per field).
- BackupService reads retention fresh each run; status() exposes keepLast +
  keepDailyDays. DEFAULT_BACKUP_RETENTION is now a pure code default (env reads gone).
- PUT /api/backup/config accepts keepLast / keepDailyDays (non-negative int, or null
  to reset to default; 400 on negative).
- UI: two retention fields on the Backup config card; one Save covers target +
  retention. i18n sq + en.

BACKUP_KEY wired into Komodo:
- komodo/resources.toml: BACKUP_KEY=[[park_buzi_backup_key]] (per-booth secret,
  alongside JWT / signing keys).
- komodo/.env.komodo.example: documents it as the ONLY backup env var -- escrow it
  offsite alongside EVENT_SIGNING_KEY (recovery needs both); target + retention are
  admin-chosen in the UI / DB, not env. Server .env.example trimmed to just BACKUP_KEY.

Also carries the small in-progress setup-intro i18n copy trim.

Tests: 218 server tests green, incl. retention persist / reset-to-default / reject-
negative and the updated status shape. Migration applies cleanly (needed a
statement-breakpoint between the two ALTERs). Wiki backup-recovery updated.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-29 12:52:18 +02:00
parent d5e41500a8
commit 84f00db48b
16 changed files with 234 additions and 44 deletions
+20 -2
View File
@@ -3,7 +3,7 @@ import { access, stat } from "node:fs/promises";
import { resolve } from "node:path";
import { eq, siteConfig, type Db } from "@parking/db";
import type { FastifyBaseLogger } from "fastify";
import { DEFAULT_BACKUP_RETENTION, runBackup, type BackupResult } from "./backup.js";
import { DEFAULT_BACKUP_RETENTION, runBackup, type BackupResult, type BackupRetention } from "./backup.js";
// Thin coordinator around the backup engine (backup.ts). The TARGET DIRECTORY is admin-chosen
// and stored in site_config.backup_target_dir (read fresh each run, so changing it in the UI
@@ -28,6 +28,9 @@ export interface BackupStatus {
readonly configured: boolean;
/** The admin-chosen target dir (null if unset) — surfaced so the UI can show/edit it. */
readonly targetDir: string | null;
/** Admin-tuned retention (resolved: DB value or code default) — surfaced for the UI form. */
readonly keepLast: number;
readonly keepDailyDays: number;
/** Whether the env key is present + long enough (the UI flags a missing key distinctly). */
readonly keyPresent: boolean;
readonly running: boolean;
@@ -79,6 +82,18 @@ export class BackupService {
return dir ? dir : null;
}
/** Resolved retention from site_config, falling back to the code default per field. Read fresh. */
retention(): BackupRetention {
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
const keepLast = row?.backupKeepLast;
const keepDailyDays = row?.backupKeepDailyDays;
return {
keepLast: keepLast != null && keepLast >= 0 ? keepLast : DEFAULT_BACKUP_RETENTION.keepLast,
keepDailyDays:
keepDailyDays != null && keepDailyDays >= 0 ? keepDailyDays : DEFAULT_BACKUP_RETENTION.keepDailyDays,
};
}
get keyPresent(): boolean {
return backupKeyFromEnv().length >= 16;
}
@@ -88,9 +103,12 @@ export class BackupService {
}
status(): BackupStatus {
const r = this.retention();
return {
configured: this.configured,
targetDir: this.targetDir(),
keepLast: r.keepLast,
keepDailyDays: r.keepDailyDays,
keyPresent: this.keyPresent,
running: this.#running,
lastSuccessAt: this.#lastSuccessAt,
@@ -120,7 +138,7 @@ export class BackupService {
this.#inflight = (async () => {
try {
this.#logger?.info(`backup: starting (${trigger}) → ${targetDir}`);
const res = await runBackup(this.#db, { targetDir, key, retention: DEFAULT_BACKUP_RETENTION }, this.#logger);
const res = await runBackup(this.#db, { targetDir, key, retention: this.retention() }, this.#logger);
this.#lastResult = res;
this.#lastSuccessAt = new Date().toISOString();
this.#lastError = null;