Skip to content

fix(backup): trust store, failed-enable revert, cluster-scoped repositories — with a server E2E tier - #94

Merged
vishr merged 8 commits into
mainfrom
test/issue-88-backup-e2e
Aug 22, 2026
Merged

fix(backup): trust store, failed-enable revert, cluster-scoped repositories — with a server E2E tier#94
vishr merged 8 commits into
mainfrom
test/issue-88-backup-e2e

Conversation

@vishr

@vishr vishr commented Aug 22, 2026

Copy link
Copy Markdown
Member

Three faults on the backup path, and the end-to-end tier that found them.

Why a second tier

Every engine in the Docker suite is built with transport.NewLocal(), and internal/transport/transport.go says so outright: local docker stands in for the deploy host. So ob's real transport had never run end to end, and neither had anything that needs a machine — bootstrap on a host with no container runtime, systemd timers, the host trust store, or what teardown leaves behind.

This adds a tier that makes the substitution nowhere. The test process runs on the workstation and reaches a disposable Lima guest over real SSH as root, driving the compiled binary rather than internal engine APIs, so the approval artifacts, the confirmations and the exit codes are part of what is tested. The guest arrives bare, so ob bootstrap is on the critical path rather than assumed.

Same two commands on an Apple Silicon Mac and on a GitHub runner; the image is pinned by digest per architecture, so neither emulates.

  • TestServerLifecycle — bootstrap, plan, approve, deploy, status, exec, logs, audit, second release, rollback, service apply, secrets list, job plan and run, doctor, the eight backup subcommands, destroy. One machine, in order. 17 of 28 commands, from none. ~200s warm.
  • TestServerProbes — what is left behind when things go wrong: a failed enablement, a repeated one, a redeployed database, teardown with and without volumes.

Two of the probes found bugs on their first run.

The fixes

The host trust store never reached the container (#88). wal-g runs inside the driver's image and postgres:18 carries no certificate authorities, so every upload to the HTTPS endpoint an s3-compatible target must declare failed with x509: certificate signed by unknown authority — after the base backup, a quarter of an hour in, with archiving already on. The bundle now travels the way the binary does, through the directory already mounted read-only into the container, and the wrapper exports it under the three names that can do the verifying. A host with no bundle is refused before archiving is turned on.

A failed enablement left archiving on (#88). PostgreSQL retains every segment it cannot ship, so a command that reported failure established an unbounded disk commitment. Enablement now walks the same pending-then-disabled transitions ob backup disable walks — but only when that run is what turned archiving on, so a service that was already protected is left as it was.

Repositories were shared across clusters (#91). WalgPrefix was <app>_<service> with no cluster identity, and every fresh cluster starts its WAL at 000000010000000000000001. A database redeployed against a target that still held the previous one's history wrote object names that already existed with different bytes; wal-g refuses the overwrite, PostgreSQL archives in order, and the chain stopped at the first collision while enable reported success. ob destroy --volumes removes the cluster and leaves the repository — that is the point of a backup — so the sequence is ordinary. The prefix now carries PostgreSQL's system identifier, recorded at enablement and used by rendering, status, backup and restore alike. An empty generation names the legacy layout and stays readable; a moved repository is reported rather than silently rebased.

Backup timers survived ob destroy (#92). RemoveSchedules matched ob-<app>- while backup timers are named ob-backup-<app>-…, so two timers and four unit files outlived the release directory they invoked. The namespace split stays — it exists because a deploy once deleted every scheduled backup — and teardown now sweeps both.

Enablement also quiesces the archiver before the base backup. Nothing at enable time can undo a collision written by a cluster that no longer exists, but the condition is detected and refused rather than establishing a chain that is already broken, and that failure routes through the revert above.

Verification

Red before each fix, green after, on a real machine — not a mock.

For #88 specifically, the unfixed build fails in 14m1s against the 14m25s in the original report, with pg_stat_archiver reading 0 archived | 1 failed where the report read 0 | 13.

Worth knowing before merging

Historical generations remain recoverable. Status discovers every cluster generation in the repository, and status, drill, and restore accept --generation to select one explicitly. A successful historical restore records that recovered binding. The recovery guide documents the loss-of-volume and loss-of-host sequence.

e2e/testdata/postgres/secrets/age.key is a committed private key. It decrypts only the fixture credentials for a MinIO the test starts and deletes on a throwaway guest, and the fixture README says so — but it is a real key in the repository and worth an explicit decision.

Automation executes both E2E tiers. Docker runs on PRs; real-server lifecycle and probe jobs run in parallel before release, nightly, and on demand.

Tier allocation is deliberate. Fast repository, native, and Docker checks stay on PRs. The Lima/SSH suite gates publication and keeps nightly/manual coverage without adding about sixteen minutes to review feedback.

Three findings about the environment came out of building this and are recorded in #44 rather than fixed here: Ubuntu's docker.io ships no buildx while plan.go requires it and preflight.go never checks for it; Ubuntu's packaged buildx accepts --format and ignores it, so ob blames the registry; and tag pinning walks every child manifest in the index, which throttles against public registries.

Closes #88
Closes #91
Closes #92

vishr and others added 8 commits August 21, 2026 20:33
The Docker suite is a good deterministic gate for deployment choreography,
but every engine in it is built with transport.NewLocal() — transport.go
records that local docker is standing in for the deploy host. So the
transport every operator uses had never run end to end, and neither had
anything that needs a machine: bootstrap on a host with no container
runtime, systemd timers, the host trust store, or what teardown leaves
behind.

This adds a second tier that makes the substitution nowhere. The test
process runs on the workstation and reaches a disposable Lima guest over
real SSH as root, driving the compiled binary rather than internal engine
APIs, so the approval artifacts, the confirmations and the exit codes are
part of what is tested. The guest arrives bare; `ob bootstrap` is on the
critical path rather than assumed.

Both gates keep their shape: OB_E2E=1 for the Docker suite, OB_SERVER_E2E=1
plus OB_E2E_SERVER for this one. The server cases compile on every PR and
skip when there is no machine, so they cannot rot unnoticed — a build tag
would have hidden them from the fast job entirely.

TestServerLifecycle walks bootstrap, plan, approve, deploy, status, exec,
logs, audit, a second release, rollback, service apply, secrets list, job
plan and run, doctor, the eight backup subcommands and destroy, on one
machine in order: 17 of 28 commands, from none.

TestServerProbes asks what is left behind when things go wrong — a failed
enablement, a repeated one, a redeployed database, teardown with and
without volumes. Two of those found bugs on their first run (#91, #92).

The guest is pinned by digest per architecture, so a Mac boots aarch64 and
CI boots x86_64 with no emulation and no floating image. There is no shared
mount: payloads travel ob's own tar-over-SSH upload, which is the code path
under test, and that removes the one place vz and qemu genuinely differ.

The object store runs as a systemd unit rather than a container, because ob
owns the container runtime on a host it manages and a foreign container
would be either flaky or a test that passes for the wrong reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DF9dqmoT7gsSk7TfzL2WQS
RemoveSchedules matched "ob-<app>-" and backup timers are named
"ob-backup-<app>-<env>-<service>-<op>", so teardown swept the job namespace
and left the backup one behind. Two timers and four unit files survived
`ob destroy --volumes`, loaded and firing on schedule against a release
directory the same command had just deleted.

The split is deliberate and stays: BackupTimerForEnvironment explains that
the job scheduler treats every "ob-<app>-*" unit as its own and removes what
is no longer declared, which once deleted every scheduled backup on the next
deploy. Teardown is the case that needs both namespaces, and its own comment
already claimed it took down every timer the app installed.

A later operation against the same application does sweep the orphans up,
which is why this was easy to miss: the units only persist when destroy is
the last thing you do — the normal case.

Closes #92

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DF9dqmoT7gsSk7TfzL2WQS
…cope repositories per cluster

Three faults on the same path, found against a real server.

The trust store never reached the container. wal-g executes inside the
driver's image and postgres:18 carries no certificate authorities, so every
upload to the HTTPS endpoint an s3-compatible target must declare failed
with "x509: certificate signed by unknown authority". It failed late — the
base backup ran first, so the error arrived a quarter of an hour in, with
archiving already on. The bundle now travels the way the binary does,
through the directory already mounted read-only into the container, and the
wrapper exports it under the three names that can do the verifying: wal-g's
own S3 setting, the AWS SDK beneath it, and crypto/tls beneath that. A host
with no bundle anywhere is refused before archiving is turned on rather than
discovered afterwards.

A failed enablement left archiving on. PostgreSQL retains every segment it
cannot ship, so a command that reported failure established an unbounded
disk commitment. Enablement now walks the same pending-then-disabled
transitions `ob backup disable` walks when the base backup fails — but only
when that run is what turned archiving on. A service that was already
protected is left as it was, because taking it to unprotected over a failed
re-enable would break a working setup to tidy a failed change.

Repositories were shared across clusters. WalgPrefix was <app>_<service>
with no cluster identity, and every fresh cluster starts its write-ahead log
at 000000010000000000000001 — so a database redeployed against a target that
still held the previous one's history wrote object names that already
existed with different bytes. wal-g refuses the overwrite, PostgreSQL
archives strictly in order, and the chain stopped at the first collision
while enable reported success. `ob destroy --volumes` removes the cluster
and leaves the repository, which is the point of a backup, so the sequence
that reaches this is ordinary. The prefix now carries PostgreSQL's system
identifier, recorded at enablement and used by rendering, status, backup and
restore alike. An empty generation names the legacy layout and stays
readable; a repository that moves is reported rather than silently rebased.

Enablement also quiesces the archiver before taking the base backup: a
forced WAL switch, then a wait for archive_status to empty. Nothing at
enable time can undo a collision written by a cluster that no longer exists,
but the condition is now detected and refused instead of establishing a
chain that is already broken — and that failure routes through the revert
above, so the service is left unprotected rather than falsely protected.

Verified on a disposable Linux guest over SSH: red before each fix, green
after. TestServerLifecycle covers the trust store and restore; TestServerProbes
covers the failed enablement, the repeated one and the redeployed database.

Closes #88
Closes #91

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DF9dqmoT7gsSk7TfzL2WQS
The repository path now carries the cluster's system identifier, so a
rebuilt database begins its own history instead of colliding with the old
one. That is a visible change in where backups live, and it makes the order
of a recovery matter: recovering first keeps the history, because a
recovered cluster keeps the identifier it was backed up with, while enabling
first binds the service to a new cluster and leaves the old backups in the
bucket but out of reach of `ob backup restore`.

The stopping section said re-enabling is how you reach a repository again,
which stays true for the same database and is the wrong instruction after
the volume has been replaced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DF9dqmoT7gsSk7TfzL2WQS
Discover generations in the backup target after local state loss. Reject stale live bindings and restore the exact prior binding when re-enable fails.

Also make archiver polling fail promptly and harden the server E2E harness.
Run lifecycle and failure probes on isolated guests in parallel before publishing. Keep the slow suite out of PR CI while retaining nightly and manual coverage.
@vishr
vishr merged commit 8ab15b6 into main Aug 22, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant