dorfteich/packages/shared/src/backup-status.ts
Claude Fable 5 52192eb05f
All checks were successful
CD / Build and push images (push) Successful in 3m51s
CI / Lint, typecheck, test (push) Successful in 4m5s
CD / Deploy to Test (push) Successful in 11s
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m11s
CD / Promote to Int (push) Successful in 12s
CI / Auth e2e pack (push) Successful in 5m52s
CI / Import/export fidelity gate (push) Successful in 47s
Backup mirror to BASEL: rsync of the sets after every successful run (#84)
The operator-level extra beside the admin-configured Nextcloud target
(#103), unblocked now that the ONE→BASEL tunnel is stable again.

- sidecar: optional mirror step (mirror.ts) driven purely by env —
  BACKUP_MIRROR_TARGET (rsync-over-ssh), BACKUP_MIRROR_SSH_KEY (private
  key on the secrets volume, never in image or repo),
  BACKUP_MIRROR_SSH_PORT. Runs after the prune of every successful run,
  so --delete aligns the remote retention with the local one (the
  newest-complete-set guarantee carries over). Only set files travel
  (db-*.dump, files-*.tar.gz); status files and bundles stay local.
  Host key pinned via accept-new into .mirror_known_hosts on the backups
  volume; fixed remote modes (dirs 750, files 640, symbolic --chmod —
  octal needs rsync ≥ 3, macOS dev machines ship 2.6.9). rsync +
  openssh-client added to the sidecar image.
- status: additive `mirror` block in status.json (outcome, transferred
  count, lastSuccessAt carried across failures) — shown on the admin
  backup card; failures alert via a new backupMirrorFailed mail (de+en)
  while the local run still counts as succeeded.
- deploy/backup-basel.md: complete BASEL-side walkthrough — dedicated
  user dorfteich-backup with a /home/ home and a bash login shell,
  explicitly avoiding the Debian backup-user (UID 34) pitfalls
  (nologin shell rejects rsync sessions, /var/backups home), key
  placement through the api container onto the secrets volume, .env
  values, on-demand verification.
- tests: rsync-arg/stats-parsing units plus an integration suite against
  the real rsync binary (local target; skips where rsync is absent) —
  transfer, idempotent re-run (0 files), retention alignment, failure
  path carrying lastSuccessAt.

Verified live against the real BASEL host from a native sidecar run:
initial transfer, host-key pinning, retention alignment after a local
prune, idempotency, and the failure path (surfaced in status.json while
the local run stayed green). BASEL side provisioned per the doc.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-12 12:20:32 +02:00

163 lines
5.6 KiB
TypeScript

/**
* The contract of the backup sidecar's `status.json` (ADR 0015, issue #83):
* the sidecar writes it after every run, the api reads it for the readyz
* backup-freshness check (issue #85) and the admin panel's backup card
* (issue #86). Keep the shape additive — bump `schemaVersion` on breaking
* changes.
*/
export const BACKUP_STATUS_FILE = 'status.json';
/**
* Freshness bound for readiness (operations.md §Health): the nightly cadence
* plus a two-hour grace window. An older (or missing) last success degrades
* readyz — it never hard-fails it.
*/
export const BACKUP_FRESH_MAX_AGE_HOURS = 26;
/**
* Freshness bound of the off-host copy (issue #103) when the upload
* schedule is `weekly`: seven nightly cadences plus the same grace window.
* `daily` uploads share {@link BACKUP_FRESH_MAX_AGE_HOURS}.
*/
export const BACKUP_REMOTE_WEEKLY_MAX_AGE_HOURS = 7 * 24 + 2;
export interface BackupSizes {
dumpBytes: number;
archiveBytes: number;
}
export interface BackupRun {
backupId: string;
startedAt: string;
finishedAt: string;
durationMs: number;
outcome: 'succeeded' | 'failed';
/** Present on failure: the first error the run hit, as a plain string. */
error?: string;
/** Present on success. */
sizes?: BackupSizes;
}
export interface BackupStatus {
schemaVersion: 1;
updatedAt: string;
retentionDays: number;
lastRun: BackupRun;
/** Carried across failed runs so freshness checks see the real gap. */
lastSuccess: { backupId: string; finishedAt: string; sizes: BackupSizes } | null;
/**
* Off-host upload state (issue #103). Absent until a Nextcloud target was
* configured and the sidecar attempted its first upload; carried across
* runs like `lastSuccess` so a broken target keeps its last good copy
* visible.
*/
remote?: BackupRemoteStatus;
/** rsync mirror state (issue #84); absent until a target is configured. */
mirror?: BackupMirrorStatus;
}
/** One WebDAV upload attempt of a complete restore set (issue #103). */
export interface BackupRemoteUpload {
backupId: string;
finishedAt: string;
outcome: 'succeeded' | 'failed';
/** Present on failure: the first error the upload hit, as a plain string. */
error?: string;
/** Present on success: size of the uploaded bundle. */
sizeBytes?: number;
}
export interface BackupRemoteStatus {
lastUpload: BackupRemoteUpload;
/** Carried across failed uploads — the newest copy known to exist remotely. */
lastSuccessfulUpload: { backupId: string; finishedAt: string; sizeBytes: number } | null;
}
/**
* The rsync mirror to a private host (issue #84, ADR 0015) — an
* operator-level extra beside the admin-configured Nextcloud target
* (#103). Present once a mirror target is configured; carried across
* failed runs like the other blocks.
*/
export interface BackupMirrorStatus {
lastRun: {
finishedAt: string;
outcome: 'succeeded' | 'failed';
/** Present on failure: the first error the mirror hit. */
error?: string;
/** Present on success: files rsync actually transferred (0 = idempotent re-run). */
transferredFiles?: number;
};
/** Carried across failed runs — when the mirror last matched the local sets. */
lastSuccessAt: string | null;
}
/**
* The remote bundle naming scheme (issue #103): one self-contained archive
* per restore set, everything needed to rebuild an instance after total
* loss (`db-<id>.dump`, `files-<id>.tar.gz`, `manifest.json`).
*/
export function remoteBundleName(backupId: string): string {
return `dorfteich-backup-${backupId}.tar.gz`;
}
const REMOTE_BUNDLE_PATTERN = /^dorfteich-backup-(\d{8}-\d{6})\.tar\.gz$/;
/** The backup id encoded in a remote bundle name, or null for foreign files. */
export function remoteBundleId(name: string): string | null {
return REMOTE_BUNDLE_PATTERN.exec(name)?.[1] ?? null;
}
/**
* The in-app restore contract (issue #103): the sidecar orchestrates a
* restore and mirrors its progress into `restore-status.json` next to
* `status.json`. The api's maintenance gate answers 503 while `state` is
* `running`, and restarts itself once it flips to `succeeded`.
*/
export const RESTORE_STATUS_FILE = 'restore-status.json';
/**
* Safety valve: a `running` restore older than this is treated as crashed
* (sidecar died before writing a final state), so the api leaves maintenance
* mode instead of serving 503 forever.
*/
export const RESTORE_STALE_MAX_AGE_MINUTES = 30;
export interface RestoreStatus {
schemaVersion: 1;
state: 'running' | 'succeeded' | 'failed';
backupId: string;
source: 'local' | 'remote';
/** Username of the requesting Site Admin (display only). */
requestedBy: string | null;
startedAt: string;
finishedAt: string | null;
/** Present when `state` is `failed`. */
error?: string;
}
/**
* PostgreSQL `NOTIFY` channel over which the api sends commands to the
* backup sidecar (issue #103) — the same primitive as the api↔collab bus
* (collab-token.ts). Payload is a JSON {@link BackupCommand}.
*/
export const BACKUP_COMMAND_CHANNEL = 'backup_command';
export type BackupCommand =
| { kind: 'run'; requestedBy: string | null }
| { kind: 'restore'; source: 'local' | 'remote'; backupId: string; requestedBy: string | null };
/**
* PostgreSQL `NOTIFY` channel over which the backup sidecar announces
* maintenance-mode transitions during an in-app restore (issue #103). The
* collab server listens and closes/refuses live sessions so no in-memory
* document persists pre-restore content back over the restored database.
* Payload is a JSON {@link MaintenanceEvent}.
*/
export const BACKUP_MAINTENANCE_CHANNEL = 'backup_maintenance';
export interface MaintenanceEvent {
phase: 'enter' | 'exit';
}