All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m45s
CD / Build and push images (push) Successful in 3m49s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m18s
CD / Promote to Int (push) Successful in 11s
CI / Auth e2e pack (push) Successful in 5m35s
CI / Import/export fidelity gate (push) Successful in 47s
Off-host backups for every self-hoster, configured entirely in the admin UI — supersedes the host-specific mirror plan behind #84. shared: - webdav.ts (new package entry like token-crypto): minimal WebDAV client with basic auth — PROPFIND (tolerant multistatus parser), MKCOL, PUT (streamed), GET, DELETE; Nextcloud DAV path derived from the plain server URL, explicit DAV bases pass through - backup-status.ts: additive remote-upload status in status.json, the restore-status.json contract (running/succeeded/failed + staleness bound), the backup_command/backup_maintenance NOTIFY channels, and the one-bundle-per-set naming (dorfteich-backup-<id>.tar.gz) - backup-set.ts moved here from apps/backup (api lists local sets) backup sidecar: - reads the backup.* instance settings directly from the database (admin changes apply next run; local retention row overrides the env) and the app password from the secret store - after each successful set: bundle dump + files archive + manifest into ONE self-contained tar.gz, upload via WebDAV per schedule (off/daily/weekly; manual runs always upload), prune remote bundles — never the newest — and record the outcome in status.json; upload failures alert via a new backupUploadFailed mail (de+en) - command listener on backup_command (run / restore) with a serial queue against the nightly timer - restore orchestrator: restore-status.json → maintenance NOTIFY → grace → (remote: download + manifest-verify bundle) → terminate other DB connections → shared perform-restore path (same code as restore.sh) → final status + maintenance exit api: - MaintenanceGuard (global, registered before the setup gate): 503 maintenance_mode while restore-status says running; health endpoints and the new public GET /backup/restore-status stay exempt; a stale running state (crashed sidecar) unblocks after 30 min - MaintenanceStateService watches the file and restarts the api after a successful restore (fresh caches, migrate-on-start for older dumps); main.ts refuses to touch the database while a restore runs — a container restarting mid-restore must not race pg_restore with migrate deploy - worker sweeps (conversion, mail outbox, scheduler) catch transient database failures instead of dying on an unhandled rejection — the restore's connection termination crashed the api in verification - backup admin endpoints under /admin/system/backup: settings (live connection test before save, password write-only into the secret store), nextcloud/test, sets (local via the ro backups mount + remote via WebDAV), run + restore (type-to-confirm backstop, source validation) — commands travel as NOTIFY payloads; audit actions backup.settings_changed/run_triggered/restore_requested - readyz: new warning-level backup_remote check while a target is configured (26 h daily / 170 h weekly bound) collab: - maintenance listener: on enter, persist + close every live session and refuse new connections until exit (failsafe timeout 30 min) — no in-memory document may write pre-restore content back afterwards web: - Admin → System backup section: status card with remote facts and a "Back up now" button, the Nextcloud settings form with test button, and the restore picker (local + remote sets, type-to-confirm) - global maintenance screen: any 503 maintenance_mode flips the SPA to a status page polling the exempt endpoint, reloading when the instance returns Verified end-to-end against a live stack (fresh DB, native api + sidecar, fake WebDAV server): configure → test → manual backup → bundle upload → readyz/sets/status surfaces → remote restore with maintenance gate, marker rollback and api restart; suites: shared 21, backup 9, collab 11, api 58 files green, lint + i18n:check + typecheck clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
253 lines
9.3 KiB
TypeScript
253 lines
9.3 KiB
TypeScript
import { createReadStream, createWriteStream } from 'node:fs';
|
|
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
import { Readable } from 'node:stream';
|
|
import { pipeline } from 'node:stream/promises';
|
|
|
|
import {
|
|
remoteBundleId,
|
|
remoteBundleName,
|
|
type BackupEnv,
|
|
type BackupRemoteStatus,
|
|
} from '@dorfteich/shared';
|
|
import {
|
|
webdavCheck,
|
|
webdavDelete,
|
|
webdavGet,
|
|
webdavList,
|
|
webdavPut,
|
|
type WebDavTarget,
|
|
} from '@dorfteich/shared/webdav';
|
|
|
|
import { createArchiveOfFiles, extractArchiveTo } from './archive.js';
|
|
import { archiveFileName, backupIdTime, dumpFileName } from './backup-set.js';
|
|
import { sendUploadFailureMail } from './mail.js';
|
|
import { NEXTCLOUD_PASSWORD_SECRET_KEY, type BackupDbSettings } from './settings.js';
|
|
|
|
/**
|
|
* Off-host half of a backup run (issue #103): bundle a complete local set
|
|
* into ONE self-contained archive, upload it to the admin-configured
|
|
* Nextcloud folder via WebDAV, and prune expired remote bundles — never the
|
|
* newest one, mirroring the local guarantee (#83).
|
|
*/
|
|
|
|
export interface RemoteLogger {
|
|
info(details: object, message: string): void;
|
|
warn(details: object, message: string): void;
|
|
error(details: object, message: string): void;
|
|
}
|
|
|
|
/**
|
|
* The effective WebDAV target, or null when the feature is off or not fully
|
|
* configured. The app password comes from the wizard-written secret store
|
|
* (never the database); base URL and username from instance settings.
|
|
*/
|
|
export function resolveRemoteTarget(
|
|
settings: BackupDbSettings,
|
|
secrets: Record<string, string>,
|
|
): WebDavTarget | null {
|
|
const { enabled, baseUrl, username, folder } = settings.nextcloud;
|
|
const password = secrets[NEXTCLOUD_PASSWORD_SECRET_KEY] ?? '';
|
|
if (!enabled || !baseUrl || !username || !password) return null;
|
|
return { baseUrl, username, password, folder };
|
|
}
|
|
|
|
/**
|
|
* Whether a scheduled run should upload: `off` never (manual trigger only),
|
|
* `daily` after every successful set, `weekly` when the last remote copy is
|
|
* at least ~a week old (half a day of slack so a slightly early nightly run
|
|
* does not skip its week).
|
|
*/
|
|
export function uploadDue(
|
|
schedule: BackupDbSettings['nextcloud']['uploadSchedule'],
|
|
lastSuccessfulUploadAt: string | null,
|
|
now: Date,
|
|
): boolean {
|
|
if (schedule === 'off') return false;
|
|
if (schedule === 'daily') return true;
|
|
if (!lastSuccessfulUploadAt) return true;
|
|
const ageMs = now.getTime() - new Date(lastSuccessfulUploadAt).getTime();
|
|
return ageMs >= 6.5 * 24 * 60 * 60 * 1000;
|
|
}
|
|
|
|
/** Contents of the bundle's manifest.json — the self-description a rebuild
|
|
* after total loss relies on (documented in the restore runbook). */
|
|
export interface BundleManifest {
|
|
schemaVersion: 1;
|
|
backupId: string;
|
|
createdAt: string;
|
|
files: string[];
|
|
}
|
|
|
|
/**
|
|
* Packs one complete local set into `dorfteich-backup-<id>.tar.gz` next to
|
|
* the set (staged as `.partial`, like every other artifact). Returns the
|
|
* bundle path; the caller uploads and then deletes it — bundles are derived
|
|
* data and would double the volume's footprint.
|
|
*/
|
|
export async function buildBundle(
|
|
backupsDir: string,
|
|
backupId: string,
|
|
now: Date,
|
|
): Promise<{ path: string; sizeBytes: number }> {
|
|
const files = [dumpFileName(backupId), archiveFileName(backupId)];
|
|
const manifest: BundleManifest = {
|
|
schemaVersion: 1,
|
|
backupId,
|
|
createdAt: now.toISOString(),
|
|
files,
|
|
};
|
|
const manifestName = 'manifest.json';
|
|
await writeFile(join(backupsDir, manifestName), JSON.stringify(manifest, null, 2) + '\n');
|
|
|
|
const bundlePath = join(backupsDir, remoteBundleName(backupId));
|
|
await createArchiveOfFiles(`${bundlePath}.partial`, backupsDir, [...files, manifestName]);
|
|
await rename(`${bundlePath}.partial`, bundlePath);
|
|
await rm(join(backupsDir, manifestName), { force: true });
|
|
return { path: bundlePath, sizeBytes: (await stat(bundlePath)).size };
|
|
}
|
|
|
|
/**
|
|
* Uploads the bundle for `backupId` and prunes expired remote bundles.
|
|
* Returns the new remote status; an upload failure alerts by mail (the
|
|
* local run stays succeeded — the admin card and readyz surface the gap).
|
|
*/
|
|
export async function uploadSet(deps: {
|
|
env: BackupEnv;
|
|
backupsDir: string;
|
|
target: WebDavTarget;
|
|
remoteRetentionDays: number;
|
|
backupId: string;
|
|
previous: BackupRemoteStatus | undefined;
|
|
now(): Date;
|
|
log: RemoteLogger;
|
|
}): Promise<BackupRemoteStatus> {
|
|
const { backupId, target } = deps;
|
|
const previousSuccess = deps.previous?.lastSuccessfulUpload ?? null;
|
|
let bundle: { path: string; sizeBytes: number } | null = null;
|
|
try {
|
|
const check = await webdavCheck(target);
|
|
if (!check.ok) throw new Error(check.error);
|
|
|
|
bundle = await buildBundle(deps.backupsDir, backupId, deps.now());
|
|
const put = await webdavPut(
|
|
target,
|
|
remoteBundleName(backupId),
|
|
Readable.toWeb(createReadStream(bundle.path)) as ReadableStream<Uint8Array>,
|
|
{ contentLength: bundle.sizeBytes },
|
|
);
|
|
if (!put.ok) throw new Error(put.error);
|
|
|
|
await pruneRemote(target, deps.remoteRetentionDays, deps.now(), deps.log);
|
|
const finishedAt = deps.now().toISOString();
|
|
deps.log.info({ backupId, sizeBytes: bundle.sizeBytes }, 'remote upload succeeded');
|
|
return {
|
|
lastUpload: { backupId, finishedAt, outcome: 'succeeded', sizeBytes: bundle.sizeBytes },
|
|
lastSuccessfulUpload: { backupId, finishedAt, sizeBytes: bundle.sizeBytes },
|
|
};
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
deps.log.error({ backupId, error: message }, 'remote upload failed');
|
|
try {
|
|
const sent = await sendUploadFailureMail(deps.env, {
|
|
backupId,
|
|
error: message,
|
|
lastSuccessAt: previousSuccess?.finishedAt ?? null,
|
|
});
|
|
if (!sent) deps.log.warn({ backupId }, 'no BACKUP_MAIL_TO configured, upload alert not sent');
|
|
} catch (mailError) {
|
|
deps.log.error({ backupId, error: String(mailError) }, 'upload alert could not be sent');
|
|
}
|
|
return {
|
|
lastUpload: {
|
|
backupId,
|
|
finishedAt: deps.now().toISOString(),
|
|
outcome: 'failed',
|
|
error: message,
|
|
},
|
|
lastSuccessfulUpload: previousSuccess,
|
|
};
|
|
} finally {
|
|
if (bundle) await rm(bundle.path, { force: true });
|
|
await rm(join(deps.backupsDir, 'manifest.json'), { force: true });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Deletes remote bundles older than the retention window — never the newest
|
|
* one, even when expired (same guarantee as the local prune, #83). Foreign
|
|
* files in the folder are never touched. Prune failures are logged, not
|
|
* thrown: the upload itself succeeded and must count.
|
|
*/
|
|
export async function pruneRemote(
|
|
target: WebDavTarget,
|
|
retentionDays: number,
|
|
now: Date,
|
|
log: RemoteLogger,
|
|
): Promise<void> {
|
|
const listed = await webdavList(target);
|
|
if (!listed.ok) {
|
|
log.warn({ error: listed.error }, 'remote prune skipped: listing failed');
|
|
return;
|
|
}
|
|
const bundles = listed.value
|
|
.filter((entry) => !entry.isCollection)
|
|
.map((entry) => ({ name: entry.name, id: remoteBundleId(entry.name) }))
|
|
.filter((entry): entry is { name: string; id: string } => entry.id !== null)
|
|
.sort((a, b) => a.id.localeCompare(b.id));
|
|
const cutoff = now.getTime() - retentionDays * 24 * 60 * 60 * 1000;
|
|
const newest = bundles.at(-1);
|
|
for (const bundle of bundles) {
|
|
if (bundle === newest) continue;
|
|
const time = backupIdTime(bundle.id);
|
|
if (!time || time.getTime() >= cutoff) continue;
|
|
const deleted = await webdavDelete(target, bundle.name);
|
|
if (deleted.ok) log.info({ name: bundle.name }, 'pruned expired remote bundle');
|
|
else log.warn({ name: bundle.name, error: deleted.error }, 'remote prune of bundle failed');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Downloads a remote bundle and unpacks its set artifacts into the backups
|
|
* directory (the in-app restore path, and documented for operators in the
|
|
* runbook). Verifies the manifest matches the requested id.
|
|
*/
|
|
export async function fetchRemoteSet(deps: {
|
|
backupsDir: string;
|
|
target: WebDavTarget;
|
|
backupId: string;
|
|
log: RemoteLogger;
|
|
}): Promise<void> {
|
|
const name = remoteBundleName(deps.backupId);
|
|
const response = await webdavGet(deps.target, name);
|
|
if (!response.ok) throw new Error(response.error);
|
|
if (!response.value.body) throw new Error(`download ${name}: empty response body`);
|
|
|
|
const downloadPath = join(deps.backupsDir, `${name}.download`);
|
|
const scratchDir = join(deps.backupsDir, `.restore-${deps.backupId}`);
|
|
try {
|
|
await pipeline(
|
|
Readable.fromWeb(response.value.body as import('node:stream/web').ReadableStream),
|
|
createWriteStream(downloadPath),
|
|
);
|
|
await mkdir(scratchDir, { recursive: true });
|
|
await extractArchiveTo(downloadPath, scratchDir);
|
|
|
|
const manifest = JSON.parse(
|
|
await readFile(join(scratchDir, 'manifest.json'), 'utf8'),
|
|
) as BundleManifest;
|
|
if (manifest.backupId !== deps.backupId) {
|
|
throw new Error(
|
|
`bundle manifest mismatch: requested ${deps.backupId}, bundle contains ${manifest.backupId}`,
|
|
);
|
|
}
|
|
for (const file of [dumpFileName(deps.backupId), archiveFileName(deps.backupId)]) {
|
|
await rename(join(scratchDir, file), join(deps.backupsDir, file));
|
|
}
|
|
deps.log.info({ backupId: deps.backupId }, 'remote set downloaded and unpacked');
|
|
} finally {
|
|
await rm(downloadPath, { force: true });
|
|
await rm(scratchDir, { recursive: true, force: true });
|
|
}
|
|
}
|