M7-02: Backup service: schedules, manifest and metrics (ADR 0058)¶
Status: ready | Depends on: M3-02, M3-03, M7-01 | Milestone: M7
Goal¶
After this ticket bothy-backupd in the service backup backs up an instance on its own: full and differential pgBackRest backups per backup.pg.full_schedule and backup.pg.diff_schedule into repo1 (local) and repo2 (off-site), a logical dump pg_dump -Fc per backup.logical.schedule into a restic repository off-site, the object backup per backup.objects.schedule (restic for the file system driver, a mirror without deletion into a second bucket for the S3 driver), and expire on repo1 (16 8.1, 8.2, ADR 0058 items 1 to 5). Every run writes a manifest (backup_manifest.v1: row counts per table, head_seq, last audit seq and hash, schema and server version, sync_epoch, count and sum of the file sizes; never content) next to the backup and a row into backup_runs through the port BackupRuns of the module status, with a separate database role that may write only this table (03 4.12). It reports the metrics bothy.backup.* and bothy.storage.volume.* of signals.md. Off-site targets are append-only: the credentials of backup can write and read, never delete (R-OP-09). From this ticket on, bothy migrate up aborts on pending migrations of an existing schema when no successful backup is younger than migrate.recent_backup_max_age, as long as migrate.require_recent_backup holds (16 10 item 1, R-OP-13). Why: a backup that is not scheduled, not described and not reported is not a backup; the manifest is what the restore test (M7-03) and the epoch (M7-04) compare against.
Read first¶
docs/spec/16-operations.md: 2, 7.1 (roles;bothy-backupduses the superuser for pgBackRest andpg_dump), 8.1, 8.2, 10 item 1, 13 (formula,bothy.storage.volume.free), 14 (allbackup.*,migrate.require_recent_backup,migrate.recent_backup_max_age,dr.rpo_target), 15 (R-OP-06 to R-OP-10, R-OP-13).docs/spec/03-data-model.md(core contract, read only):backup_runs(4.12: columns, check, index, retentionops, the separate role),instance(sync_epoch,schema_version),audit_events(seq,hash),files(size_bytes,deleted_at).docs/adr/0058-backup-with-pgbackrest-object-backup-and-restore-test.md;docs/operations/backup-restore.md1 to 3 and 6;docs/operations/configuration.md(rowsbackup.*, the secret rowspgbackrest_cipher_pass,restic_password,offsite/,db_superuser_password, the variableBOTHY_BACKUP_OFFSITE_TARGET).docs/observability/signals.md(rowsbothy.backup.*,bothy.storage.volume.*and the paragraph on read-only volumes),docs/observability/alerting.md(BothyBackupStale,BothyWalArchiveStale,BothyVolumeFull),docs/spec/17-observability.md2 and 6.docs/spec/02-architecture.md: 4.5 (modulestatus, portBackupRuns), 12.3 (jobs), 13 (configuration schema).docs/DECISIONS.md: D-W-100 and its extension to M7 (D-W-125); section 10 (format ofD-M7-02-<n>rows).- Briefs (merged names win, D-W-100):
M7-01-postgres-image.md(image,cmd/bothy-backupd,postgres-image-test),M1-12-postgres-migrations.md(migrate up --bootstrap, roles, grants per table),M1-68-core-metrics.md(signal registry,make signal-check),M3-01-object-storage-fs.mdandM3-02-object-storage-s3.md(drivers, SeaweedFS test container),M3-03-upload-sessions.md(files). - Code on the base branch:
services/server/cmd/bothy-backupd,services/server/images/bothy-postgres,services/server/internal/status,services/server/cmd/bothy(migrate),services/server/migrations,infra/compose/compose.yaml.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Schedules, limits and periods only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the value. - Specification first: a change to behaviour, configuration, a role or a secret entry updates
docs/spec/16-operations.mdanddocs/operations/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md,actions.yaml) is not edited by this ticket;backup_runsis built exactly as 03 has it. Everything this ticket adds is English. - No cost (D-W-125): tests use local targets and throwaway containers only; no cloud account, no paid service.
Product requirements implemented here:
- Every kind of 16 8.1 runs on its schedule:
pg_full,pg_diff,logical,objects; each run is onebackup_runsrow fromrunningtosucceededorfailed, witherroras a code without content or secrets. - The manifest contains exactly the fields of 03 (
backup_manifest.v1) and the references ofbackup-restore.md3 (pgbackrest.label,restic.snapshot_id), never content; it is stored encrypted next to the backup and asbackup_runs.manifest. - Off-site writes are append-only: the credentials in the secret directory of
backupcannot delete or overwrite;expireruns only on repo1; off-site expiry needs the separate account of the owner (R-OP-09, Design note 4). - With an empty
backup.offsite.target, backups stay local,bothy config checkand the start log ofbothy-backupdwarn "local backup only". - The database role that writes
backup_runscan write only this table;pgBackRestandpg_dumpconnect asmigrate.bootstrap_user(16 7.1). migrate upchecks the most recent successful run of a kindpg_full,pg_difforlogicalagainstmigrate.recent_backup_max_agebefore it applies a pending migration to an existing schema; an empty database is exempt;migrate.require_recent_backup = falseturns the check off.- Metrics as in
signals.md, through the OpenTelemetry SDK ofbothy-backupd(service.name = bothy-backupd); no person, path or bucket name in an attribute.
Scope¶
Server module (paths under services/server/ unless they start with docs/ or infra/; every new Go file starts with // SPDX-License-Identifier: Apache-2.0):
- Module
status: migrationbackup_runs(03 4.12) with the grants of 16 7.1 rule 3; the portBackupRuns(start, finish, last successful run per kind);domainfor the manifest schema and its validation;appfor the backup use cases (schedule evaluation, one run per kind, manifest building through read-only queries, metrics);adaptersfor the executor ofpgbackrest,pg_dump,resticand the S3 mirror (Design note 2). The table list and README ofstatusfollow. cmd/bothy-backupd: the scheduler loop (cron expressions ofbackup.*, at most one run per kind at a time), the subcommandsbackup <kind>(run one kind now, for the runbook and tests) andexpire --offsite(owner only, Design note 4); configuration through the schema ofinternal/platform/config(the keysbackup.*become active).- Database role
bothy_backup(Design note 3):migrate up --bootstrapcreates it like the four roles of 16 7.1; secretdb_backup_passwordin the catalogue (generated, tomigrateandbackup); keymigrate.backup_password_ref. cmd/bothymigrate: the backup requirement of item 17; keysmigrate.require_recent_backupandmigrate.recent_backup_max_agebecome active.images/bothy-postgres: restic pinned with a SHA-256 per architecture; nothing else changes in the image.infra/compose/compose.yamland.env.example: whatbackupneeds (secret paths, the off-site target variable); the off-site test overlay goes underinfra/compose/test/if one is needed.- Docs:
docs/spec/16-operations.md7.1 (rowbothy_backup), 8.2, 14 (new keys),docs/operations/backup-restore.md(2, 3, the owner's off-site expiry),docs/operations/configuration.md(keys active, secret row),docs/observability/signals.mdonly if a name differs,services/server/CHANGELOG.mdunder[Unreleased],docs/DECISIONS.mdsection 10 rowsD-M7-02-<n>.
Out of scope¶
- The restore test and
backup.restore_test.*(M7-03);bothy restore finalize(M7-04); the deletion journal and the list of completed deletion requests in the manifest (M7-07, which builds on the privacy requests of M7-06). - Retention of old
backup_runsrows (jobretention.ops, M7-09) and the alert rules (M7-20 routes alerts; the rules ofalerting.mdalready exist). - A console view of
backup_runs:docs/api/has no operation for it; adding one would be a core change. - The path for a managed PostgreSQL service (16 3 rule 3,
backup-restore.md6); this ticket builds the Compose path with pgBackRest.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M7-02-backup-service; one writer per worktree. - Conventional Commits with the module or component as scope (for example
feat(status): ...,feat(backupd): ...,feat(compose): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag, never publish an image or a release, never create remote repositories or cloud resources; the only remote steps are the branch push and the pull request ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules). Tests bind only
127.0.0.1and port 0; PostgreSQL, SeaweedFS and every other service run only as throwaway containers on random ports (P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip. - Stop only processes and containers you started, by PID or container ID. Temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets; tests generate throwaway passwords and keys.
- Time only through
kernel/clock; the scheduler test moves a fake clock, it never sleeps for an interval. - Never lower a threshold, disable a gate or delete a test.
- If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100, extended to M7 by D-W-125). Where the merged code of an earlier ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>.
Design notes¶
-
Scheduler (16 8.1). Five-field cron expressions parsed by a small parser in
status/domainor a pinned library under the license gate (record which); missed times while the process was down are not caught up, the next due time runs; at most one run per kind; a full and a differential due at the same minute run only the full.backup <kind>runs one kind now and exits with0or1. -
Executor port (rule 2). The use cases call a port with one method per external step (
PgBackRestBackup,PgBackRestExpireLocal,PgDump,ResticBackup,S3Mirror,VolumeUsage); the adapter runs the binaries with arguments as a list, never through a shell, and maps exit codes to error codes without output text. Unit tests use a fake executor; the real binaries run in the image test. -
Role
bothy_backup(03 4.12; Ruling for docs/DECISIONS.md).LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS,CONNECT,USAGEonpublic,SELECT, INSERT, UPDATEonbackup_runsonly (a test against the expectation list of 16 7.1 rule 3). The manifest queries (row counts,head_seq, audit head, files) run asmigrate.bootstrap_userin aREAD ONLYtransaction withREPEATABLE READ, so the counts belong to one snapshot. Exact row counts per table come fromcount(*)per table of the domain schema (notpg_class.reltuples), because the restore test compares them. -
Append-only off-site (R-OP-09; Ruling for docs/DECISIONS.md). Supported targets: S3-compatible with versioning and Object Lock, and restic's REST server in append-only mode or SFTP without delete permission, as
backup-restore.md2 names.backupholds only the credentials inoffsite/; deletion after the retention period needsoffsite-admin/, which never enters the volumesecretsand which the owner passes tobothy-backupd expire --offsiteby hand (runbook). Test: with the credentials ofbackup, deleting or overwriting an existing object or snapshot fails against the local test target. -
Object backup (16 8.1 rows "Objects"). Driver
fs: restic over the read-only volumefiles. Drivers3: a mirror into the second bucket that copies new objects and never deletes (deleted objects stay until the off-site expiry); if the provider replicates with versioning,backup.objects.*only checks that the replica is present (record the decision). -
Metrics (signals.md). Exactly the instruments and attributes of the table, declared as
Specs in the registry of M1-68 somake signal-checkholds them;bothy.backup.wal.last_archived.timestampfrompgbackrest info(archive stop time). -
Rulings for
docs/DECISIONS.md(D-M7-02-<n>): cron parser, role and snapshot (note 3), off-site targets and the owner's expiry (note 4), object mirror (note 5), skipped catch-up of missed times (note 1).
Tests (write them first)¶
Unit:
status/domain:TestManifestSchema(exactly the fields of 03 andbackup-restore.md3; a field with message text fails validation),TestCronDue(due times, a full and a differential at the same minute).status/app:TestRunWritesRowAndManifest,TestRunFailureRecordsCode(an executor error givesfailedwith a code and no output text),TestOneRunPerKind,TestOffsiteEmptyWarns.cmd/bothy:TestMigrateRequiresRecentBackup(pending migrations and no run younger than the maximum age: abort with a hint; a fresh run: proceed; empty database: proceed; switch off: proceed).
Integration (pgtest and throwaway containers):
TestBackupRunsRole:bothy_backupcan insert and updatebackup_runsand nothing else (insert into another table fails).TestManifestCounts: after seeding messages, files and audit rows the manifest counts and heads equal direct queries.- Image test in
postgres-image-test(extend the suite of M7-01): every kind runs against the database container and local targets (a local directory for repo1, SeaweedFS or a restic REST server in append-only mode for off-site),backup_runshas onesucceededrow per kind, the manifest is present next to the backup, a delete with the credentials ofbackupfails, metrics reach an OTLP test receiver with the names ofsignals.md.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/status/... ./cmd/... ./internal/platform/... ./internal/wire/...make postgres-image postgres-image-teston the build hostmake migrations-lint,make migrations-lock,make codegen-check,make arch,make register-check,make config-doc-check,make signal-check,make docs,make linkcheckmake check
Acceptance criteria¶
- All kinds run on their schedule and by hand; each writes a
backup_runsrow and a complete manifest without content. - Off-site is append-only for the credentials of
backup;expireruns only on repo1; the owner's off-site expiry is documented. - Metrics of
signals.mdreach the collector path;make signal-checkis green. -
bothy_backupwrites onlybackup_runs;migrate upenforces a recent backup as in 16 10 item 1. - 16, the runbook, the configuration reference, READMEs, the CHANGELOG and the rulings are updated.
-
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
- READMEs of
statusandcmd/bothy-backupdupdated in English. - Rulings of Design note 7 recorded in
docs/DECISIONS.mdsection 10. services/server/CHANGELOG.mdentry under 'Unreleased'.- An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make checkand ofmake postgres-image-test. - Review and red team verdicts and the findings fixed.
- The off-site target kinds tested locally and how append-only was proven.
- What is not proven (a real cloud bucket with Object Lock).
- Follow-ups that could not be done in this scope, each with its reason, every difference to the names of this brief (D-W-100), and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.