fix(backup): trust store, failed-enable revert, cluster-scoped repositories — with a server E2E tier - #94
Merged
Merged
Conversation
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.
# Conflicts: # TRADEMARK.md
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.
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.
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(), andinternal/transport/transport.gosays 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 bootstrapis 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:18carries no certificate authorities, so every upload to the HTTPS endpoint an s3-compatible target must declare failed withx509: 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 disablewalks — 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).
WalgPrefixwas<app>_<service>with no cluster identity, and every fresh cluster starts its WAL at000000010000000000000001. 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 whileenablereported success.ob destroy --volumesremoves 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).RemoveSchedulesmatchedob-<app>-while backup timers are namedob-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.
TestServerLifecycle22/22 includingbackup restore returns the dataTestServerProbes7/7 including the redeploy regression that defines backup: a redeployed database collides with the previous cluster's WAL history #91golangci-lint0 issues,actionlintclean, generated docs currentFor #88 specifically, the unfixed build fails in 14m1s against the 14m25s in the original report, with
pg_stat_archiverreading0 archived | 1 failedwhere the report read0 | 13.Worth knowing before merging
Historical generations remain recoverable. Status discovers every cluster generation in the repository, and status, drill, and restore accept
--generationto 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.keyis 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.ioships no buildx whileplan.gorequires it andpreflight.gonever checks for it; Ubuntu's packaged buildx accepts--formatand 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