fix(backup): capture capstan.db in every run and rewrite the DR runbook (agent-os-36o) - #34
Merged
Merged
Conversation
capstan.db was in no snapshot at all. The backup engine's scope was each
stack's compose directory, tagged by stack ID; the database — user accounts,
the encrypted git tokens and restic password, every setting, backup and
auto-update policies, the stacks registry, update history and the audit log —
was simply not backed up.
Two failure modes compounded. The restic repository defaults to
<DataDir>/restic-repo, the same volume the database lives on, so losing that
one volume lost the database and the local snapshots together; the only
surviving copy was whatever rclone had synced offsite, which contained stack
directories and nothing else. And the runbook did not mention any of it, so an
operator following it exactly got their compose files back and a Capstan with
no users, no settings, no credentials, no policies and no history — discovered
during an actual outage.
WHAT CHANGES
Every backup run, scheduled or manual, now snapshots capstan.db before the
per-stack loop, under its own restic tag "capstan-database". Taking it first
means a stack failure part-way through still leaves the database in the
repository.
The snapshot uses SQLite's VACUUM INTO (database.DB.VacuumInto), not a file
copy. capstan.db runs in WAL mode, so a raw copy taken while writes are in
flight can be torn or missing recent commits. VACUUM INTO observes one
consistent snapshot and emits a standalone database with no -wal/-shm sidecars,
which is what makes it safe to hand to restic as a single file.
The staged copy is a full plaintext database and is removed in a defer, whatever
happens next.
A run that saves every stack but loses the database is reported "partial", never
"success". Reporting green while the most important artifact is missing is the
silent failure this issue exists to remove.
The staging path is fixed at <DataDir>/backup-staging/capstan.db rather than a
random temp dir, because it becomes the path recorded inside the snapshot and
therefore the path an operator types during a restore. A random path would make
the runbook unwritable. Exported as DatabaseSnapshotPath() and pinned by a test,
since README names it literally.
RUNBOOK
README's disaster-recovery section is rewritten: database before stacks, stop
the container before overwriting a live database, what STORAGE_KEY and
JWT_SECRET are for, a four-point verification, and what is still recoverable
when STORAGE_KEY is lost.
Three corrections came out of actually executing it rather than writing it:
- Steps 2-4 now run inside a throwaway container built from the image. restic
and rclone ship in the image at pinned versions and a fresh recovery host
usually has neither; the rclone remote path in Capstan's settings is a path
as seen from inside the container, so running the documented `rclone sync`
on the host fails with "directory not found" (observed).
- The restore must delete capstan.db-wal / capstan.db-shm, which belong to the
database being replaced.
- The restored file must be chowned to appuser. The restore runs as root and
Capstan does not, so a root-owned capstan.db leaves the server unable to
write to its own database.
Gates: go build, go vet clean; 674 tests in 9 packages (667 baseline + 7 added).
Refs: agent-os-36o
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
capstan.dbwas in no snapshot at all. The backup engine's scope was each stack's compose directory; the database — accounts, the encrypted git tokens and restic password, every setting, backup and auto-update policies, the stacks registry, history and the audit log — was simply not backed up.Two failure modes compounded: the restic repository defaults to
<DataDir>/restic-repo, the same volume the database lives on, so losing that one volume lost both; and the runbook never mentioned it, so following it exactly gave you your compose files back and a Capstan with no users, no settings, no credentials, no policies and no history — discovered during an actual outage.What changes
Every run, scheduled or manual, snapshots
capstan.dbbefore the per-stack loop, under its own tagcapstan-database. Taking it first means a stack failure part-way through still leaves the database in the repository.It uses SQLite's
VACUUM INTO, not a file copy —capstan.dbruns in WAL mode, so a raw copy taken while writes are in flight can be torn or missing recent commits. The staged plaintext copy is removed in adefer, whatever happens next.A run that saves every stack but loses the database reports
partial, neversuccess. Green while the most important artifact is missing is the silent failure this issue exists to remove.The staging path is fixed at
<DataDir>/backup-staging/capstan.dbrather than a random temp dir, because it becomes the path recorded inside the snapshot and therefore the path an operator types during a restore. Exported asDatabaseSnapshotPath()and pinned by a test, since README names it literally.The drill
Full destroy-and-recover, following only the written runbook.
Setup — auth enabled, real user, a git-backed stack pointing at a private GitHub remote with an HTTPS token stored, deliberately non-default retention (
keepDaily 3,keepWeekly 9,keepMonthly 11), a 1440-minute schedule, and an rclone remote on a separate bind mount from./data.Backup:
Staging directory empty afterwards. Token confirmed encrypted at rest — 0 plaintext occurrences in
capstan.db.Destroy:
rm -rf ./data— database and local repository, together. Offsite copy (10 files) untouched.Recover: fresh container, different hostname, stopped;
rclone syncfrom offsite;restic snapshots --tag capstan-database; restore; start.Verify:
keepDaily 3, keepWeekly 9, keepMonthly 11, schedule 1440, rcloneRemote offsite, syncAfterBackup true— scheduler restarted at 24h[('stacks~drstack:default', True, 'hot')], 1 runhasHttpsToken: true,hasPassword: trueThe decrypt claim is proved rather than assumed, by re-running the identical restored database under a wrong
STORAGE_KEY:keepDaily/keepWeeklyhasPasswordhasHttpsTokenThose flags track successful decryption, not row presence — so the correct-key run demonstrates the restored secrets genuinely decrypt, and the wrong-key run is direct evidence for the runbook's
STORAGE_KEYwarning and its step 6.Three runbook corrections that only executing it produced
resticandrcloneship in the image at pinned versions and a fresh recovery host usually has neither. More importantly the rclone remote path in Capstan's settings is a path as seen from inside the container — running the documentedrclone syncon the host fails withdirectory not found(observed).capstan.db-wal/capstan.db-shm— they belong to the database being replaced.chown appuser:appuserthe restored file. The restore runs as root, Capstan does not; a root-ownedcapstan.dbleaves the server unable to write to its own database.Still outstanding
The one acceptance item not yet demonstrated is an authenticated
git pullwith the restored token. The token decrypts (proved above) but the drill PAT currently carriesMetadata: ReadwithoutContents: Read, so GitHub returns 403 togit-upload-packregardless of Capstan. Re-runnable in seconds once the token is rescoped; nothing in this PR depends on it.Also filed from the drill:
agent-os-pid— a database captured mid-run restores with that run stuck atrunning. Cosmetic, and the same shape appears when the process is killed mid-run, which nothing currently handles.Gates
go buildandgo vetclean; 674 tests in 9 packages (667 baseline + 7 added).Refs
agent-os-36o.