backups go to another machine, and old ones are pruned

Three things.

The local directory defaults to <officerdev root>/dockers/postgres/dumps,
derived from OFFICER_ROOT rather than written down — the same reasoning as
rootPath: a path fixed at install freezes the layout of the machine it was
installed on, and a restore elsewhere then points somewhere that is not there
while looking deliberately chosen.

The destination is SSH, and it is now what "set up" MEANS: a dump sitting on the
machine it came from is a second copy on one disk, lost to the same failure that
would make anybody want it. So local is staging and the copy off the box is the
backup, and a schedule with nowhere to send it does not count as configured.

Testable, in two steps reported separately, because they fail for different
reasons and the fixes differ: the connection is keys and networking, the
directory is permissions on the far side. It tests what is in the FORM rather
than what is stored — testing a saved destination while somebody looks at a
different one is worse than not testing.

Everything goes out with BatchMode=yes. Nobody is at a terminal when this runs,
so a key that has lost access must FAIL rather than block forever on a prompt
nothing will answer. It is also why no credential is stored: there is none to
store and none to leak.

The remote path is the one value here a shell interprets — `ssh host mkdir -p
<path>` runs through it — so shell characters are refused rather than escaped.
Refusing is checkable by reading it; escaping is checkable by being right about
every shell.

And rotation keeps the newest N per database at both ends. Per database, because
two sharing one budget means a busy one evicts the other's history. Sorted by
NAME rather than mtime: the timestamp is in the filename and is sortable, while
mtime stops meaning "when this was taken" the moment a file is copied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 13:03:30 +01:00
co-authored by Claude Opus 5
parent ac3d8b76d9
commit 5fc710f072
4 changed files with 403 additions and 83 deletions
+33 -3
View File
@@ -9,7 +9,7 @@ import { readSync, startSync } from './sync';
import { readRuns } from './ledger';
import { reconcile } from './derive';
import { createDatabases, pushSchema, readDatabases } from './databases';
import { readBackups, runBackup, saveBackups, schedule } from './backups';
import { readBackups, runBackup, saveBackups, schedule, testDestination } from './backups';
import { join } from 'node:path';
import { fofosRequest, FofosError, probe, type FofosResponse } from './fofos-client';
@@ -330,9 +330,39 @@ router.post('/_config/databases/create', async (ctx) => {
router.get('/_config/backups', async (ctx) => ctx.json(await readBackups()));
router.put('/_config/backups', async (ctx) => {
const { cron, dir } = (ctx.get('body') ?? {}) as { cron?: string; dir?: string };
const body = (ctx.get('body') ?? {}) as {
cron?: string;
dir?: string;
sshHost?: string;
sshPath?: string;
keep?: number | null;
};
try {
return ctx.json(await saveBackups(cron ?? '', dir ?? ''));
return ctx.json(
await saveBackups({
cron: body.cron ?? '',
dir: body.dir ?? '',
sshHost: body.sshHost ?? '',
sshPath: body.sshPath ?? '',
keep: body.keep ?? null,
}),
);
} catch (error) {
return ctx.json({ error: (error as Error).message }, 400);
}
});
/**
* Prove the destination before anything is trusted to it.
*
* Takes the values from the FORM rather than from storage, so it tests what is on screen — testing a
* saved destination while somebody looks at a different one is worse than not testing at all.
*/
router.post('/_config/backups/test', async (ctx) => {
const { sshHost, sshPath } = (ctx.get('body') ?? {}) as { sshHost?: string; sshPath?: string };
try {
const result = await testDestination(sshHost ?? '', sshPath ?? '');
return ctx.json(result, result.ok ? 200 : 400);
} catch (error) {
return ctx.json({ error: (error as Error).message }, 400);
}