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
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
163 lines
5.6 KiB
TypeScript
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';
|
|
}
|