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
149 lines
5.2 KiB
TypeScript
149 lines
5.2 KiB
TypeScript
import { execFileSync } from 'node:child_process';
|
|
import { existsSync } from 'node:fs';
|
|
import { mkdtemp, readdir, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
import type { BackupEnv } from '@dorfteich/shared';
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
buildRsyncArgs,
|
|
mirrorSets,
|
|
parseTransferredFiles,
|
|
resolveMirrorConfig,
|
|
} from './mirror.js';
|
|
|
|
const silentLog = { info: () => {}, warn: () => {}, error: () => {} };
|
|
const noMailEnv = { BACKUP_MAIL_TO: undefined } as unknown as BackupEnv;
|
|
|
|
function hasRsync(): boolean {
|
|
try {
|
|
execFileSync('rsync', ['--version'], { stdio: 'ignore' });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
describe('resolveMirrorConfig', () => {
|
|
it('requires both target and key; port defaults to 22', () => {
|
|
const base = { BACKUP_MIRROR_SSH_PORT: 22 } as unknown as BackupEnv;
|
|
expect(resolveMirrorConfig(base)).toBeNull();
|
|
expect(
|
|
resolveMirrorConfig({ ...base, BACKUP_MIRROR_TARGET: 'u@h:/x/' } as BackupEnv),
|
|
).toBeNull();
|
|
expect(
|
|
resolveMirrorConfig({
|
|
...base,
|
|
BACKUP_MIRROR_TARGET: 'u@h:/x/',
|
|
BACKUP_MIRROR_SSH_KEY: '/data/secrets/key',
|
|
} as BackupEnv),
|
|
).toEqual({ target: 'u@h:/x/', sshKeyFile: '/data/secrets/key', sshPort: 22 });
|
|
});
|
|
});
|
|
|
|
describe('buildRsyncArgs', () => {
|
|
it('transfers only set files, deletes within the filter, uses the pinned ssh', () => {
|
|
const args = buildRsyncArgs(
|
|
{ target: 'u@h:/backups/', sshKeyFile: '/k', sshPort: 2222 },
|
|
'/backups',
|
|
);
|
|
expect(args).toContain('--delete');
|
|
expect(args).toContain('--include=db-*.dump');
|
|
expect(args).toContain('--include=files-*.tar.gz');
|
|
expect(args).toContain('--exclude=*');
|
|
const ssh = args[args.indexOf('-e') + 1]!;
|
|
expect(ssh).toContain('-i /k');
|
|
expect(ssh).toContain('-p 2222');
|
|
expect(ssh).toContain('BatchMode=yes');
|
|
expect(args.at(-2)).toBe('/backups/');
|
|
expect(args.at(-1)).toBe('u@h:/backups/');
|
|
});
|
|
});
|
|
|
|
describe('parseTransferredFiles', () => {
|
|
it('reads the rsync stats line, tolerating thousands separators', () => {
|
|
expect(parseTransferredFiles('Number of regular files transferred: 4\n')).toBe(4);
|
|
expect(parseTransferredFiles('Number of regular files transferred: 1,234\n')).toBe(1234);
|
|
expect(parseTransferredFiles('no stats here')).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe.skipIf(!hasRsync())('mirrorSets (real rsync, local target)', () => {
|
|
let source: string;
|
|
let target: string;
|
|
let keyFile: string;
|
|
|
|
beforeEach(async () => {
|
|
source = await mkdtemp(join(tmpdir(), 'dorfteich-mirror-src-'));
|
|
target = await mkdtemp(join(tmpdir(), 'dorfteich-mirror-dst-'));
|
|
// rsync to a local path ignores -e ssh; a dummy key satisfies the check.
|
|
keyFile = join(source, '.dummy-key');
|
|
await writeFile(keyFile, 'dummy');
|
|
await writeFile(join(source, 'db-20260712-030000.dump'), 'dump-1');
|
|
await writeFile(join(source, 'files-20260712-030000.tar.gz'), 'files-1');
|
|
await writeFile(join(source, 'db-20260711-030000.dump'), 'dump-0');
|
|
await writeFile(join(source, 'files-20260711-030000.tar.gz'), 'files-0');
|
|
await writeFile(join(source, 'status.json'), '{}');
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await rm(source, { recursive: true, force: true });
|
|
await rm(target, { recursive: true, force: true });
|
|
});
|
|
|
|
const run = () =>
|
|
mirrorSets({
|
|
env: noMailEnv,
|
|
config: { target: `${target}/`, sshKeyFile: keyFile, sshPort: 22 },
|
|
backupsDir: source,
|
|
previous: undefined,
|
|
now: () => new Date('2026-07-12T03:05:00Z'),
|
|
log: silentLog,
|
|
});
|
|
|
|
it('transfers set files only, is idempotent, and aligns retention', async () => {
|
|
const first = await run();
|
|
expect(first.lastRun.outcome).toBe('succeeded');
|
|
expect(first.lastRun.transferredFiles).toBe(4);
|
|
expect(first.lastSuccessAt).not.toBeNull();
|
|
expect((await readdir(target)).sort()).toEqual([
|
|
'db-20260711-030000.dump',
|
|
'db-20260712-030000.dump',
|
|
'files-20260711-030000.tar.gz',
|
|
'files-20260712-030000.tar.gz',
|
|
]);
|
|
|
|
// Idempotent re-run: nothing travels.
|
|
const second = await run();
|
|
expect(second.lastRun.outcome).toBe('succeeded');
|
|
expect(second.lastRun.transferredFiles).toBe(0);
|
|
|
|
// A locally pruned set disappears remotely too (retention alignment).
|
|
await rm(join(source, 'db-20260711-030000.dump'));
|
|
await rm(join(source, 'files-20260711-030000.tar.gz'));
|
|
await run();
|
|
expect((await readdir(target)).sort()).toEqual([
|
|
'db-20260712-030000.dump',
|
|
'files-20260712-030000.tar.gz',
|
|
]);
|
|
});
|
|
|
|
it('reports a failure without throwing and carries the last success', async () => {
|
|
const good = await run();
|
|
const failed = await mirrorSets({
|
|
env: noMailEnv,
|
|
config: { target: `${target}/`, sshKeyFile: '/nonexistent-key', sshPort: 22 },
|
|
backupsDir: source,
|
|
previous: good,
|
|
now: () => new Date('2026-07-12T03:10:00Z'),
|
|
log: silentLog,
|
|
});
|
|
expect(failed.lastRun.outcome).toBe('failed');
|
|
expect(failed.lastRun.error).toContain('not found');
|
|
expect(failed.lastSuccessAt).toBe(good.lastSuccessAt);
|
|
expect(existsSync(join(target, 'db-20260712-030000.dump'))).toBe(true);
|
|
});
|
|
});
|