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
131 lines
4.8 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|