carlos / platform Public

host: fix review findings on per-box snapshot keys (#158)

Finding 1: TestTwoBoxesSnapshotWithoutColliding didn't prove the
generation prune is scoped per-box — with one generation per box it
never crossed snapshotKeep, so a globally-scoped prune mutation stayed
green. Extend the test with four more interleaved fingerprint-changing
passes per box (aggregate 10 generations, each box's own count 5,
under snapshotKeep=8 individually but over it combined) and assert
both prefixes still list exactly 5. Verified by temporarily
re-collapsing the prune's list call to the global
"control/registry-snapshots/" prefix: the extended test fails; reverted
and confirmed green again.

Finding 2: the OVERWRITING guard's comment still claimed it "makes the
moment loud... where a rehearsal will see it" for the same-name
standby-restore case. Under per-box keys that's false in every
reachable case — readSnapshotMeta reads only this box's own key, whose
sole writer stamps meta.Box = box, so prior.Box != box cannot occur,
including when a rebuilt standby reuses the dead box's name. Comment
now says so plainly and points at the DR runbook's Step 0 ordering
rule as the actual owner of that hazard. Comparison logic unchanged,
per the reviewer's explicit instruction.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 02f449a20973ffd5eaa7d6b56077e739dbd8fca9 parent b466af4
2 files changed, +51 −8
  • internal/host/snapshot_loop.go +14 −8
  • internal/host/snapshot_loop_test.go +37 −0
diff --git a/internal/host/snapshot_loop.go b/internal/host/snapshot_loop.go
index 247cd541b..e75bb1569 100644
--- a/internal/host/snapshot_loop.go
+++ b/internal/host/snapshot_loop.go
@@ -105,14 +105,20 @@ func registrySnapshotPass(ctx context.Context, s release.Store, reg *registry.Re
if _, err := s.Put(ctx, genKey, body, ""); err != nil && !errors.Is(err, release.ErrCASConflict) {
return fmt.Errorf("snapshot: put %s: %w", genKey, err)
}
- // Overwriting a sidecar another box wrote is the cold-standby clobber
- // (runbook step 0): a freshly bootstrapped standby whose agent starts
- // before the operator restores will replace the dead box's snapshot with
- // its own empty registry, and every self-consistency check downstream
- // (bytes, created_at) still passes. Nothing here can safely decide whose
- // registry is authoritative — a legitimate promotion looks identical —
- // so this does not refuse the write; it makes the moment loud, in the
- // agent log, where a rehearsal will see it.
+ // This guard predates per-box keys and is retained for the shared-key
+ // era and for hand-placed sidecars only (a sidecar copied or edited by
+ // an operator with a Box field that does not match this box). Under
+ // per-box keys it reads ONLY this box's own key, and the only writer
+ // that ever lands there stamps meta.Box = box — so prior.Box != box
+ // cannot occur through normal operation, INCLUDING the same-name
+ // standby-restore case the runbook worries about (a rebuilt standby
+ // reusing the dead box's machine-id before restoring: same name means
+ // prior.Box already equals box, so this stays silent). That clobber —
+ // the freshly bootstrapped standby's agent starting and writing its own
+ // empty registry over the dead box's last-good snapshot before the
+ // operator restores it — is NOT detected here; the DR runbook's Step 0
+ // ordering rule (restore before the agent is allowed to snapshot) owns
+ // that hazard instead.
if prior := readSnapshotMeta(ctx, s, box); prior != nil && prior.Box != "" && prior.Box != box {
log.Printf("agent: registry snapshot: OVERWRITING a snapshot written by box %q (this box is %q, prior created_at %s) — if this box is a standby that has not been restored yet, STOP and preserve the prior snapshot",
prior.Box, box, prior.CreatedAt.Format(time.RFC3339))
diff --git a/internal/host/snapshot_loop_test.go b/internal/host/snapshot_loop_test.go
index a762be914..cce0fdae7 100644
--- a/internal/host/snapshot_loop_test.go
+++ b/internal/host/snapshot_loop_test.go
@@ -3,6 +3,7 @@ package host
import (
"context"
"encoding/json"
+ "fmt"
"path/filepath"
"testing"
"time"
@@ -280,6 +281,42 @@ func TestTwoBoxesSnapshotWithoutColliding(t *testing.T) {
if err != nil || len(gens) != 1 {
t.Fatalf("annie generations = %v, %v; want exactly 1", gens, err)
}
+
+ // Prove the prune is scoped per box, not global. Interleave four more
+ // fingerprint-changing passes for each box (registry content varies
+ // each round so the fingerprint gate always writes). After this, each
+ // box has 5 generations of its own (1 above + 4 here) — under
+ // snapshotKeep (8), so a correctly per-box-scoped prune leaves both at
+ // exactly 5. But the AGGREGATE across both boxes is 10, which crosses
+ // snapshotKeep — so a prune that (wrongly) lists the GLOBAL
+ // "control/registry-snapshots/" prefix would prune down to 8 total,
+ // eating into one or both boxes' tails. This is exactly the mutation
+ // the reviewer flagged: snapshotGenPrefix(box) collapsed back to a
+ // shared "control/registry-snapshots/" would pass every other test in
+ // this file (each of which uses only one box) but must fail here.
+ for i := 0; i < 4; i++ {
+ at := now.Add(time.Duration(i+1) * time.Minute)
+ if err := regA.Upsert(registry.Route{Host: fmt.Sprintf("annie-%d.example.com", i), Kind: "service", Addr: "10.0.1.1:3000", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := registrySnapshotPass(context.Background(), s, regA, "annie", at, &fpA); err != nil {
+ t.Fatal(err)
+ }
+ if err := regB.Upsert(registry.Route{Host: fmt.Sprintf("betty-%d.example.com", i), Kind: "service", Addr: "10.0.2.1:3000", Status: "ready"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := registrySnapshotPass(context.Background(), s, regB, "betty", at, &fpB); err != nil {
+ t.Fatal(err)
+ }
+ }
+ gensA, err := s.List(context.Background(), "control/registry-snapshots/annie/")
+ if err != nil || len(gensA) != 5 {
+ t.Fatalf("annie generations after interleaving = %v, %v; want exactly 5 (per-box prune must not see betty's writes)", gensA, err)
+ }
+ gensB, err := s.List(context.Background(), "control/registry-snapshots/betty/")
+ if err != nil || len(gensB) != 5 {
+ t.Fatalf("betty generations after interleaving = %v, %v; want exactly 5 (per-box prune must not see annie's writes)", gensB, err)
+ }
}
func TestSeedSnapshotFingerprintEmptyWhenNoSidecar(t *testing.T) {