Back up Watchtower itself, and restore an instance from a bundle (ADR-0027) - #64
Merged
Merged
Conversation
…m a bundle
Since ADR-0024 every fact Watchtower owns lives in its PostgreSQL, but the backup
feature covered only stacks. An operator with a year of archives could restore every
stack's volumes onto a new box and still have nothing that knew how to deploy them.
ADR-0024 noted that backing up Watchtower's own state "becomes a PostgreSQL concern";
nothing was built, and docs/backups.md told operators to pg_dump by hand — next to a
stale paragraph telling them to keep a volume that has held nothing since.
ADR-0027 records the design. Four parts:
1. Instance self-backup. A pg_dumpall of Watchtower's own database, through the same
archive format, encryption and storage the stack backups use, written to
{instance}/_watchtower/ beside them — so one storage folder per instance holds the
whole picture. Nothing is stopped: the dump is consistent by construction, which it
has to be, since Watchtower is what runs it. Encryption is mandatory here rather
than optional — the dump carries every role's password hash, the data-protection
key ring and every certificate's private key.
2. An exportable bundle. One plain tar with a fresh instance dump, the newest archive
of every stack, a manifest (app version, last migration id, per-archive SHA-256) and
the out-of-database secrets. Admin-only and audited on both request and download,
because the file is the instance.
3. Restore. An uploaded bundle is validated before anything is touched, and refused on
a schema this build does not know, a mismatched KeyProtectionSecret, a bad checksum,
or an archive its own passphrase cannot open. A --restore-self sibling container —
modelled on the existing self-update coordinator — takes a safety dump, stops
Watchtower, replays, and always restarts it. A nonce written into the doomed database
is what proves afterwards whether the replay committed.
4. Recovery. A checklist deploys each stack from git and then restores its newest
archive, in that order: only the deploy creates the volumes the restore needs.
Along the way, shared rather than duplicated: BackupRetentionRunner and
BackupArchiveReader now serve both the stack and instance paths, and
PostgresDumpService gained ReplayRemoteAsync (replay a file already in the container)
so the coordinator runs the shipped implementation rather than a second one. Session
termination moved to after the SQL is staged, which shortens the window in which
something can reconnect before the DROP.
BackupEvent.StackId becomes nullable — an instance run has no stack — rather than
growing a parallel table the queue, sweep, retention and both history views would each
have needed a second case for. The wire DTO gains a `kind` derived from the null.
`_watchtower` is refused as a stack name, since a stack sanitizing onto it would share
the instance directory, and retention prunes a directory.
2137 tests pass, ~60 of them new. The frontend typechecks against a client regenerated
from the updated rpc-schema.json.
Two conflicts, both where main and this branch changed the same code for different reasons: - SelfUpdateService: main extracted GetCurrentGroupIds/ParseGroupsLine into HostSupplementaryGroups so the CI runner containers could share them (47afede). This branch had made the same helpers internal for the restore coordinator — the same need, a third consumer. Took main's extraction and pointed InstanceRestoreService at HostSupplementaryGroups.Current(). - PostgresDumpServiceTests: main moved the archive PUT onto the untimed client (4730ff1), so it is recorded by estate.LongRunning rather than estate.Default; this branch had reordered the replay so sessions are terminated after the SQL is staged. Both kept — the PUT is asserted on the long-running recorder, and the exec order is asserted by finding execs by what they run rather than by position, which is what made the two changes collide in the first place. The "SQL staged before sessions close" assertion is dropped: the two steps now ride different recorders, so their indices are not comparable. The invariant that matters — sessions closed before psql runs — is still pinned. rpc-schema.json auto-merged; regenerating from the merged code produces an identical file (150 methods = main's 139 + this branch's 11).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Watchtower has had mature per-stack backups since ADR-0016, but nothing that backed up Watchtower. Since ADR-0024 every fact it owns — the stacks and their environment variables, templates, products and releases, routes, accounts, certificates and keys — lives in its PostgreSQL. So an operator with a nightly schedule and a year of archives could restore every stack's volumes onto a new box and still have nothing that knew how to deploy them.
ADR-0024 noted in passing that backing up Watchtower's own state "becomes a PostgreSQL concern". Nothing was built, and
docs/backups.mdtold operators to runpg_dumpby hand — next to a stale paragraph telling them to also keep thewatchtower-datavolume, which by then held nothing.Design and rationale: ADR-0027.
What this adds
1. Instance self-backup. A
pg_dumpallof Watchtower's own database, wrapped in the same archive format, encryption and storage the stack backups use, written to{instance}/_watchtower/beside them — so one storage folder per instance holds the whole picture. On the same schedule, governed byBackup:IncludeSelf(default on).Backup:SelfPostgresContaineris the override.2. An exportable bundle. One plain (uncompressed) tar — its members are already compressed and encrypted — holding a fresh instance dump, the newest archive of every stack, a manifest and
secrets.json:Each stack archive keeps its storage-relative path, so an import can put it back exactly where the restored database's
BackupDirectoryalready points, instead of rewriting paths it cannot verify.3. Restore. An uploaded bundle is validated before anything is touched — an instance that cannot read the bundle it was handed must still be the instance it was — and refused on:
KeyProtectionSecretmismatch — the sharpest edge in the feature. The stored certificates and keys are AES-GCM under an env-only secret that cannot be changed at runtime, so the message names the variable and says a restart is part of the fix;The replay itself cannot be done by this process (
pg_dumpall --cleanterminates every session and drops every database; Watchtower's pool would reconnect into the middle of it). A--restore-selfsibling container — modelled on the existing self-update coordinator — takes a safety dump, stops Watchtower, replays, and always restarts it in afinally. It stops and starts rather than recreating, deliberately: the container's filesystem survives, and with it the marker the restarted process reads. A nonce written into the doomed database is what proves on the way back up whether the replay committed — nothing else knows that value, so nothing else could have removed it.4. Recovery. A checklist deploys each stack from git and then restores its newest archive, in that order — only the deploy creates the volumes the restore needs, and a deploy alone leaves the stack running on empty ones.
Notes for review
BackupEvent.StackIdbecomes nullable rather than growing a parallel table: the queue, the startup sweep, retention and both history views already speakBackupEvent, and a second table would have duplicated every one of them. The wire DTO gains akind(stack/instance) derived from the null, andbackups.eventsan optional filter. Unfiltered history is unchanged.BackupRetentionRunnerandBackupArchiveReaderwere extracted so the stack and instance paths prune and read archives identically.PostgresDumpServicegainedReplayRemoteAsync(replay a file already inside the container) so the coordinator runs the shipped implementation rather than a second copy of it — which moved session termination to after the SQL is staged, shortening the window in which something can reconnect before theDROP. One existing test asserted the old order; it now locates calls instead of assuming offsets._watchtoweris refused as a stack name, since a stack sanitizing onto it would write its archives into the instance directory, and retention prunes a directory.secrets.jsoncarries the key-protection secret, the backup passphrase and the storage credentials in plain text, so one artifact plus its passphrase is a complete instance. The trade is against the alternative: an operator who restores onto a new box and finds their certificates unreadable because a secret they never knew about stayed behind. Export and download are admin-only (a newSystemAdminPolicy, not merely operator-only) and audited, and the UI says what the file is.docker inspect— i.e. to anyone who already owns the Docker socket, and therefore the host.Verification
rpc-schema.jsonis regenerated; the frontend typechecks clean (tsc --noEmit, exit 0) against a client regenerated from it.Not yet verified: the end-to-end manual test — two compose instances, a real coordinator container — could not run on the author's machine, since the coordinator needs Watchtower itself containerised. That is the riskiest path in the change and is worth exercising before merge: build a bundle on one instance, restore it into a second with the same
KEYPROTECTIONSECRET, and check the negative cases (wrong secret, newer bundle, coordinator killed mid-replay).