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
+28
View File
@@ -115,8 +115,36 @@ export const fofosAdminSettings = pgTable('fofos_admin_settings', {
* half-configured state to interpret — it is off.
*/
backupCron: text('backup_cron'),
/**
* Where dumps are written on THIS machine, before they are sent anywhere.
*
* Null means the default, computed at read time from `OFFICER_ROOT` — the same reasoning as
* `rootPath` above: a path written in at install freezes the layout of the machine it was installed
* on, and a restore elsewhere then points at a directory that is not there while looking chosen.
*/
backupDir: text('backup_dir'),
/**
* Where they are COPIED to. `user@host`, and an absolute path on it.
*
* A backup that only exists on the machine it came from is not a backup — it is a second copy on the
* same disk, lost to the same failure. So the local directory is staging and this is the destination.
*
* Authenticated by the owner's existing SSH key, never a password: there is nobody at a terminal when
* this runs, and a credential stored here would be one this plugin had to hold and could leak.
*/
backupSshHost: text('backup_ssh_host'),
backupSshPath: text('backup_ssh_path'),
/**
* How many dumps to keep, per database, in each place.
*
* Null means keep everything, which is a real choice and a bad default — a nightly dump of a small
* site fills a disk slowly enough that nobody notices until something else fails.
*/
backupKeep: integer('backup_keep'),
/**
* When the last one finished, which is what makes the schedule survive a restart.
*