package registry import ( "context" "errors" "path/filepath" "testing" ) const buildStateCol = `{"known":true,"replica":true,"state":{"schema":1,"host":"a.example.com","last_executed":"v1","at":"2026-10-07T12:00:00Z"},"read_at":"2026-10-07T12:00:00Z"}` func TestBuildStateColumn(t *testing.T) { reg, err := Open(filepath.Join(t.TempDir(), "r.db")) if err != nil { t.Fatal(err) } if err := reg.Upsert(Route{Host: "a.example.com", Kind: "instance", App: "km"}); err != nil { t.Fatal(err) } rt, _ := reg.Get("a.example.com") if rt.BuildState != "" { t.Fatalf("a new row has build state %q", rt.BuildState) } before, err := reg.Fingerprint() if err != nil { t.Fatal(err) } col := buildStateCol if err := reg.SetBuildState("a.example.com", col); err != nil { t.Fatal(err) } if rt, _ = reg.Get("a.example.com"); rt.BuildState != col { t.Fatalf("column = %q", rt.BuildState) } after, err := reg.Fingerprint() if err != nil { t.Fatal(err) } if after != before { t.Fatal("build state changed the edge's fingerprint") } // A full-row Upsert that does not carry the column keeps it. if err := reg.Upsert(Route{Host: "a.example.com", Kind: "instance", App: "km"}); err != nil { t.Fatal(err) } if rt, _ = reg.Get("a.example.com"); rt.BuildState != col { t.Fatalf("Upsert cleared the column: %q", rt.BuildState) } if err := reg.SetBuildState("a.example.com", ""); err != nil { t.Fatal(err) } if rt, _ = reg.Get("a.example.com"); rt.BuildState != "" { t.Fatal("the narrow setter could not clear the column") } if err := reg.SetBuildState("nobody.example.com", col); err == nil { t.Fatal("an unknown host was not an error") } } // build_state is rewritten (ReadAt) on every reconcile refresh with nothing an // operator did behind it, so it must not trigger a registry snapshot (#207). func TestBuildStateIsChurnForTheSnapshotGate(t *testing.T) { reg, err := Open(filepath.Join(t.TempDir(), "r.db")) if err != nil { t.Fatal(err) } const host = "a.example.com" if err := reg.Upsert(Route{Host: host, Kind: "instance", App: "km"}); err != nil { t.Fatal(err) } before, err := reg.SnapshotFingerprint() if err != nil { t.Fatal(err) } if err := reg.SetBuildState(host, buildStateCol); err != nil { t.Fatal(err) } after, err := reg.SnapshotFingerprint() if err != nil { t.Fatal(err) } if before != after { t.Fatal("SetBuildState moved the snapshot fingerprint") } } // A cold handover INSERTs the whole row positionally: the column must be in // that list or every handover fails on column count, and its value must // survive. func TestBuildStateSurvivesColdHandover(t *testing.T) { t.Setenv("CARLOS_HOST_CLAIM", "1") ctx := context.Background() reg, err := Open(filepath.Join(t.TempDir(), "registry.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { reg.Close() }) oldOwner := ClaimOwnership{"inc", 7, "installation-7", "route-7"} newOwner := ClaimOwnership{"inc", 8, "installation-8", "route-8"} route := Route{Host: "shop.example", App: "app", ClaimOwnership: oldOwner} if err = reg.Upsert(route); err != nil { t.Fatal(err) } if err = reg.SetBuildState(route.Host, buildStateCol); err != nil { t.Fatal(err) } current, err := reg.Get(route.Host) if err != nil || current == nil { t.Fatalf("Get: %v", err) } old := ColdRows{Route: *current} next := ColdRows{Route: *current} next.Route.ClaimOwnership = newOwner targets := []ClaimRow{{Kind: "route", Host: route.Host, Ownership: oldOwner}} if err = reg.RetireClaimRows(ctx, "ret", targets, func([]ClaimRowArchive) error { return nil }); err != nil { t.Fatal(err) } if err = reg.RestoreClaimRows(ctx, "h1", "ret", old, next); err != nil { t.Fatalf("handover refused: %v", err) } got, err := reg.Get(route.Host) if err != nil || got == nil { t.Fatalf("Get after handover: %v", err) } if got.BuildState != buildStateCol { t.Fatalf("build state after handover = %q", got.BuildState) } } // buildStateColLater is buildStateCol after one more reconcile refresh: only // read_at moved, as it does every reconcile even for a stopped host. const buildStateColLater = `{"known":true,"replica":true,"state":{"schema":1,"host":"a.example.com","last_executed":"v1","at":"2026-10-07T12:00:00Z"},"read_at":"2026-10-07T12:00:30Z"}` // A cold handover replayed after its lost reply must not be refused because // the agent refreshed the row's build-state copy in between: the column is // the agent's cache of the bucket object (snapshotChurnCols), not part of // the installation the replay checks. func TestColdHandoverReplayIgnoresABuildStateRefresh(t *testing.T) { t.Setenv("CARLOS_HOST_CLAIM", "1") ctx := context.Background() reg, err := Open(filepath.Join(t.TempDir(), "registry.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { reg.Close() }) oldOwner := ClaimOwnership{"inc", 7, "installation-7", "route-7"} newOwner := ClaimOwnership{"inc", 8, "installation-8", "route-8"} route := Route{Host: "shop.example", App: "app", ClaimOwnership: oldOwner} if err = reg.Upsert(route); err != nil { t.Fatal(err) } if err = reg.SetBuildState(route.Host, buildStateCol); err != nil { t.Fatal(err) } current, err := reg.Get(route.Host) if err != nil || current == nil { t.Fatalf("Get: %v", err) } old := ColdRows{Route: *current} next := ColdRows{Route: *current} next.Route.ClaimOwnership = newOwner targets := []ClaimRow{{Kind: "route", Host: route.Host, Ownership: oldOwner}} if err = reg.RetireClaimRows(ctx, "ret", targets, func([]ClaimRowArchive) error { return nil }); err != nil { t.Fatal(err) } if err = reg.RestoreClaimRows(ctx, "h1", "ret", old, next); err != nil { t.Fatalf("handover refused: %v", err) } if err = reg.SetBuildState(route.Host, buildStateColLater); err != nil { t.Fatal(err) } if err = reg.RestoreClaimRows(ctx, "h1", "ret", old, next); err != nil { t.Fatalf("replay after a build-state refresh refused: %v", err) } if got, _ := reg.Get(route.Host); got == nil || got.BuildState != buildStateColLater { t.Fatalf("the replay rewrote the refreshed copy: %+v", got) } // Any other column still decides the replay. if _, err = reg.sql.Exec(`UPDATE routes SET version='other' WHERE host=?`, route.Host); err != nil { t.Fatal(err) } if err = reg.RestoreClaimRows(ctx, "h1", "ret", old, next); !errors.Is(err, ErrClaimOwnership) { t.Fatalf("replay over a changed row: %v", err) } } // A recovery handover's rebind (main's RebindClaimRows) reads the row, then // rebinds it in a transaction, and replays from a fresh read after a lost // reply or a crash. A build-state refresh between any two of those steps is // not a concurrent replacement of the installation: it must neither refuse // the rebind nor its replay, and the rebind must not write the column. func TestRecoveryRebindIgnoresABuildStateRefresh(t *testing.T) { for _, mode := range []string{"between-read-and-rebind", "replay-from-a-fresh-read", "replay-with-the-first-read"} { t.Run(mode, func(t *testing.T) { r, err := Open(filepath.Join(t.TempDir(), "registry.db")) if err != nil { t.Fatal(err) } defer r.Close() const host = "app.example" oldOwner := ClaimOwnership{"inc", 1, "old", "old-row"} newOwner := ClaimOwnership{"inc", 2, "new", "new-row"} if err = r.Upsert(Route{Host: host, Kind: "instance", ClaimOwnership: oldOwner}); err != nil { t.Fatal(err) } if err = r.SetBuildState(host, buildStateCol); err != nil { t.Fatal(err) } saved, err := r.Get(host) if err != nil { t.Fatal(err) } old := ColdRows{Route: *saved} next := old next.Route.ClaimOwnership = newOwner if mode == "between-read-and-rebind" { if err = r.SetBuildState(host, buildStateColLater); err != nil { t.Fatal(err) } } if err = r.RebindClaimRows(t.Context(), old, next); err != nil { t.Fatalf("rebind: %v", err) } if mode != "between-read-and-rebind" { if err = r.SetBuildState(host, buildStateColLater); err != nil { t.Fatal(err) } replayOld, replayNext := old, next if mode == "replay-from-a-fresh-read" { // As claimCrossBoxHandover.rebindRecoveryRows replays: a // fresh Get, the old ownership from the inventory. fresh, err := r.Get(host) if err != nil { t.Fatal(err) } replayOld, replayNext = ColdRows{Route: *fresh}, ColdRows{Route: *fresh} replayOld.Route.ClaimOwnership = oldOwner } if err = r.RebindClaimRows(t.Context(), replayOld, replayNext); err != nil { t.Fatalf("replay after a build-state refresh refused: %v", err) } } got, err := r.Get(host) if err != nil || got.ClaimOwnership != newOwner || got.BuildState != buildStateColLater { t.Fatalf("route = %+v, %v", got, err) } // Any other column still decides the replay. if _, err = r.sql.Exec(`UPDATE routes SET version='other' WHERE host=?`, host); err != nil { t.Fatal(err) } if err = r.RebindClaimRows(t.Context(), old, next); !errors.Is(err, ErrClaimOwnership) { t.Fatalf("replay over a changed row: %v", err) } }) } } // The host's registry child carries the writer across the transport. func TestRemoteSetBuildState(t *testing.T) { r, _ := remoteTestRegistry(t) const host = "remote.example.com" if err := r.Upsert(Route{Host: host, Kind: "instance", Status: "ready"}); err != nil { t.Fatal(err) } if err := r.SetBuildState(host, buildStateCol); err != nil { t.Fatal(err) } got, err := r.Get(host) if err != nil || got == nil || got.BuildState != buildStateCol { t.Fatalf("route = %+v, %v", got, err) } if err := r.SetBuildState("nobody.example.com", buildStateCol); err == nil { t.Fatal("an unknown host was not an error") } }