dorfteich/apps/backup/src/mirror.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

131 lines
4.8 KiB
TypeScript

import { execFile } from 'node:child_process';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { promisify } from 'node:util';
import type { BackupEnv, BackupMirrorStatus } from '@dorfteich/shared';
import { sendMirrorFailureMail } from './mail.js';
import type { RemoteLogger } from './remote.js';
const execFileAsync = promisify(execFile);
/**
* The rsync mirror to a private host (issue #84, ADR 0015) — the operator
* extra beside the admin-configured Nextcloud target (#103). After every
* successful local run the set artifacts are rsynced to
* `BACKUP_MIRROR_TARGET`; `--delete` keeps the remote retention aligned
* with the local prune (the newest-set guarantee therefore carries over).
* Only set files travel — status files and staging artifacts stay local.
* rsync's delta transfer makes re-runs idempotent (0 files transferred).
*/
export interface MirrorConfig {
target: string;
sshKeyFile: string;
sshPort: number;
}
/** The mirror configuration, or null when the env does not enable it. */
export function resolveMirrorConfig(env: BackupEnv): MirrorConfig | null {
if (!env.BACKUP_MIRROR_TARGET || !env.BACKUP_MIRROR_SSH_KEY) return null;
return {
target: env.BACKUP_MIRROR_TARGET,
sshKeyFile: env.BACKUP_MIRROR_SSH_KEY,
sshPort: env.BACKUP_MIRROR_SSH_PORT,
};
}
/**
* The rsync invocation: only complete-set artifacts (and the remote bundle
* naming is local-only, so just dumps + archives), `--delete` inside that
* filter for retention alignment. The known-hosts file lives on the
* backups volume so the host key pins across container recreations;
* `accept-new` covers the very first contact (inside the WireGuard tunnel).
*/
export function buildRsyncArgs(config: MirrorConfig, backupsDir: string): string[] {
const ssh = [
'ssh',
`-i ${config.sshKeyFile}`,
`-p ${config.sshPort}`,
'-o StrictHostKeyChecking=accept-new',
`-o UserKnownHostsFile=${join(backupsDir, '.mirror_known_hosts')}`,
'-o BatchMode=yes',
].join(' ');
return [
'--archive',
// Fixed modes on the mirror host (dirs 750, files 640) — `--archive`
// would otherwise copy the container-side modes onto the target.
// Symbolic form: octal `--chmod` needs rsync ≥ 3, which not every
// dev machine has (macOS ships 2.6.9); the symbolic one works on both.
'--chmod=Du=rwx,Dg=rx,Do-rwx,Fu=rw,Fg=r,Fo-rwx',
'--delete',
'--stats',
'--include=db-*.dump',
'--include=files-*.tar.gz',
'--exclude=*',
'-e',
ssh,
`${backupsDir}/`,
config.target,
];
}
/** The transferred-file count from `rsync --stats` output. rsync 3 prints
* "Number of regular files transferred", the ancient 2.6 (macOS) drops
* "regular" — accept both. */
export function parseTransferredFiles(stats: string): number | undefined {
const match = /Number of (?:regular )?files transferred:\s*([\d,.]+)/.exec(stats);
if (!match) return undefined;
return Number(match[1]!.replace(/[,.]/g, ''));
}
/**
* Runs one mirror pass and returns the new mirror status. Failures are
* reported in the status and alert by mail — never thrown: the local
* backup succeeded and must count (issue #84 acceptance criteria).
*/
export async function mirrorSets(deps: {
env: BackupEnv;
config: MirrorConfig;
backupsDir: string;
previous: BackupMirrorStatus | undefined;
now(): Date;
log: RemoteLogger;
}): Promise<BackupMirrorStatus> {
const previousSuccessAt = deps.previous?.lastSuccessAt ?? null;
try {
if (!existsSync(deps.config.sshKeyFile)) {
throw new Error(`mirror ssh key not found: ${deps.config.sshKeyFile}`);
}
const { stdout } = await execFileAsync('rsync', buildRsyncArgs(deps.config, deps.backupsDir), {
maxBuffer: 16 * 1024 * 1024,
});
const finishedAt = deps.now().toISOString();
const transferredFiles = parseTransferredFiles(stdout);
deps.log.info({ target: deps.config.target, transferredFiles }, 'mirror run succeeded');
return {
lastRun: { finishedAt, outcome: 'succeeded', transferredFiles },
lastSuccessAt: finishedAt,
};
} catch (error) {
const stderr = (error as { stderr?: string }).stderr?.trim();
const message = stderr || (error instanceof Error ? error.message : String(error));
deps.log.error({ target: deps.config.target, error: message }, 'mirror run failed');
try {
const sent = await sendMirrorFailureMail(deps.env, {
backupId: 'mirror',
error: message,
lastSuccessAt: previousSuccessAt,
});
if (!sent) deps.log.warn({}, 'no BACKUP_MAIL_TO configured, mirror alert not sent');
} catch (mailError) {
deps.log.error({ error: String(mailError) }, 'mirror alert could not be sent');
}
return {
lastRun: { finishedAt: deps.now().toISOString(), outcome: 'failed', error: message },
lastSuccessAt: previousSuccessAt,
};
}
}