2910672b5a
BackupService tracked last-success/last-error as plain in-process fields and scheduled the daily backup via setInterval measured from process start — so any server restart (deploy/crash/OOM/reboot, routine under `restart: always`) silently reset the admin UI to "last successful backup: Never" and drifted the actual cadence, independent of whether backups were writing correctly to disk (they were — a real field incident at park-buzi showed 7 valid rotating backups on disk with the status stuck on "Never"). Persist last-success/error to new site_config columns (migration 0025) and add BackupService.isDue(), computed from the persisted timestamp instead of process uptime; server.ts now polls every 15 min and lets isDue() gate the actual run. No API/UI contract change. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
223 lines
9.0 KiB
TypeScript
223 lines
9.0 KiB
TypeScript
import { constants } from "node:fs";
|
|
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, 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
|
|
// takes effect with no restart). The ENCRYPTION KEY stays an env/Komodo secret (BACKUP_KEY) —
|
|
// a key must never live in the DB it backs up. Remembers the last outcome so the route + UI can
|
|
// show last-success / last-error, and serializes concurrent runs (manual + timer). See
|
|
// wiki/concepts/backup-recovery.md.
|
|
//
|
|
// Last-success/last-error are PERSISTED to site_config (backup_last_*), not just held in
|
|
// memory — an earlier version tracked these as plain in-process fields only, so every server
|
|
// restart (deploy, crash, OOM, host reboot — all routine under `restart: always`) silently
|
|
// reset the admin UI to "last successful backup: Never", even with valid, correctly-rotating
|
|
// backups already on disk (2026-08-30 field incident, park-buzi). See wiki/concepts/backup-recovery.md.
|
|
|
|
/** The dedicated backup-encryption key, from env (NOT the DB). Separate from EVENT_SIGNING_KEY. */
|
|
export function backupKeyFromEnv(): string {
|
|
return process.env.BACKUP_KEY ?? "";
|
|
}
|
|
|
|
export interface TargetCheck {
|
|
readonly ok: boolean;
|
|
/** Machine-readable reason when !ok: "empty" | "missing" | "not_a_dir" | "not_writable". */
|
|
readonly reason?: string;
|
|
}
|
|
|
|
export interface BackupStatus {
|
|
/** True once a target dir is set AND a usable key is present (else backups are a no-op). */
|
|
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;
|
|
readonly lastSuccessAt: string | null;
|
|
readonly lastResult: { path: string; bytes: number; prunedFiles: number } | null;
|
|
readonly lastErrorAt: string | null;
|
|
readonly lastError: string | null;
|
|
}
|
|
|
|
/** Probe a candidate target path server-side: exists, is a directory, is writable. */
|
|
export async function checkTargetDir(dir: string): Promise<TargetCheck> {
|
|
const trimmed = dir.trim();
|
|
if (!trimmed) return { ok: false, reason: "empty" };
|
|
const path = resolve(trimmed);
|
|
let st: Awaited<ReturnType<typeof stat>>;
|
|
try {
|
|
st = await stat(path);
|
|
} catch {
|
|
return { ok: false, reason: "missing" };
|
|
}
|
|
if (!st.isDirectory()) return { ok: false, reason: "not_a_dir" };
|
|
try {
|
|
await access(path, constants.W_OK);
|
|
} catch {
|
|
return { ok: false, reason: "not_writable" };
|
|
}
|
|
return { ok: true };
|
|
}
|
|
|
|
export class BackupService {
|
|
readonly #db: Db;
|
|
readonly #logger?: FastifyBaseLogger;
|
|
|
|
#running = false;
|
|
|
|
constructor(db: Db, logger?: FastifyBaseLogger) {
|
|
this.#db = db;
|
|
this.#logger = logger;
|
|
}
|
|
|
|
/** Fresh read of the persisted row (single source of truth — no in-memory cache to go stale
|
|
* or reset on restart). */
|
|
#row(): { backupLastSuccessAt: string | null; backupLastResultJson: string | null; backupLastErrorAt: string | null; backupLastError: string | null } | undefined {
|
|
return this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
|
}
|
|
|
|
#persist(patch: {
|
|
backupLastSuccessAt?: string | null;
|
|
backupLastResultJson?: string | null;
|
|
backupLastErrorAt?: string | null;
|
|
backupLastError?: string | null;
|
|
}): void {
|
|
const updatedAt = new Date().toISOString();
|
|
const existing = this.#row();
|
|
if (existing) {
|
|
this.#db.update(siteConfig).set({ ...patch, updatedAt }).where(eq(siteConfig.id, 1)).run();
|
|
} else {
|
|
this.#db.insert(siteConfig).values({ id: 1, ...patch, updatedAt }).run();
|
|
}
|
|
}
|
|
|
|
/** The admin-chosen target dir from site_config (null/empty = unset). Read fresh each call. */
|
|
targetDir(): string | null {
|
|
const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
|
const dir = row?.backupTargetDir?.trim();
|
|
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;
|
|
}
|
|
|
|
get configured(): boolean {
|
|
return this.targetDir() !== null && this.keyPresent;
|
|
}
|
|
|
|
status(): BackupStatus {
|
|
const r = this.retention();
|
|
const row = this.#row();
|
|
let lastResult: BackupStatus["lastResult"] = null;
|
|
if (row?.backupLastResultJson) {
|
|
try {
|
|
lastResult = JSON.parse(row.backupLastResultJson) as BackupStatus["lastResult"];
|
|
} catch {
|
|
lastResult = null; // corrupt/foreign value in the column — don't let it crash status()
|
|
}
|
|
}
|
|
return {
|
|
configured: this.configured,
|
|
targetDir: this.targetDir(),
|
|
keepLast: r.keepLast,
|
|
keepDailyDays: r.keepDailyDays,
|
|
keyPresent: this.keyPresent,
|
|
running: this.#running,
|
|
lastSuccessAt: row?.backupLastSuccessAt ?? null,
|
|
lastResult,
|
|
lastErrorAt: row?.backupLastErrorAt ?? null,
|
|
lastError: row?.backupLastError ?? null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Run one backup. `trigger` is just for the log line. Serialized: if one is already in
|
|
* flight, resolves to that same promise. Reads the target dir + key at run time. Records
|
|
* last-success/last-error. Re-throws on failure so a manual caller (the route) can surface
|
|
* it; the scheduled timer wraps + swallows.
|
|
*/
|
|
#inflight: Promise<BackupResult> | null = null;
|
|
async run(trigger: "manual" | "scheduled"): Promise<BackupResult> {
|
|
if (this.#inflight) return this.#inflight;
|
|
const targetDir = this.targetDir();
|
|
const key = backupKeyFromEnv();
|
|
if (!targetDir) throw new Error("backup: no target directory configured");
|
|
if (key.length < 16) throw new Error("backup: BACKUP_KEY missing or too short (need ≥16 chars)");
|
|
|
|
this.#running = true;
|
|
this.#inflight = (async () => {
|
|
try {
|
|
this.#logger?.info(`backup: starting (${trigger}) → ${targetDir}`);
|
|
const res = await runBackup(this.#db, { targetDir, key, retention: this.retention() }, this.#logger);
|
|
this.#persist({
|
|
backupLastSuccessAt: new Date().toISOString(),
|
|
backupLastResultJson: JSON.stringify({ path: res.path, bytes: res.bytes, prunedFiles: res.prunedFiles }),
|
|
backupLastErrorAt: null,
|
|
backupLastError: null,
|
|
});
|
|
return res;
|
|
} catch (err) {
|
|
const message = (err as Error).message;
|
|
this.#persist({ backupLastErrorAt: new Date().toISOString(), backupLastError: message });
|
|
this.#logger?.error(`backup: failed (${trigger}): ${message}`);
|
|
throw err;
|
|
} finally {
|
|
this.#running = false;
|
|
this.#inflight = null;
|
|
}
|
|
})();
|
|
return this.#inflight;
|
|
}
|
|
|
|
/**
|
|
* Scheduled-run wrapper: never throws (a timer must not crash the process). Safe to call on
|
|
* a short, frequent poll (see server.ts) — it's a no-op unless `isDue()` says a full interval
|
|
* has actually elapsed since the last recorded success, so frequent polling doesn't cause
|
|
* frequent backups.
|
|
*/
|
|
async runScheduled(): Promise<void> {
|
|
if (!this.configured) return; // silent no-op when backups aren't set up
|
|
if (!this.isDue()) return;
|
|
try {
|
|
await this.run("scheduled");
|
|
} catch {
|
|
/* recorded in last-error; already logged */
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wall-clock check: has enough time elapsed since the last successful backup for a new one
|
|
* to be due? Deliberately based on the PERSISTED last-success instant, not "time since this
|
|
* process started" — a `setInterval(..., 24h)` measured from process start silently drifts
|
|
* (or skips a whole day) across every restart, since the countdown restarts from zero each
|
|
* time regardless of when the last real backup happened. See wiki/concepts/backup-recovery.md.
|
|
*/
|
|
isDue(now: Date = new Date(), intervalMs = 24 * 60 * 60 * 1000): boolean {
|
|
const lastSuccessAt = this.#row()?.backupLastSuccessAt;
|
|
if (!lastSuccessAt) return true; // never recorded a success → due immediately once configured
|
|
const last = new Date(lastSuccessAt).getTime();
|
|
if (Number.isNaN(last)) return true;
|
|
return now.getTime() - last >= intervalMs;
|
|
}
|
|
}
|