| 1 | package registry |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "path/filepath" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | 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"}` |
| 11 | |
| 12 | func TestBuildStateColumn(t *testing.T) { |
| 13 | reg, err := Open(filepath.Join(t.TempDir(), "r.db")) |
| 14 | if err != nil { |
| 15 | t.Fatal(err) |
| 16 | } |
| 17 | if err := reg.Upsert(Route{Host: "a.example.com", Kind: "instance", App: "km"}); err != nil { |
| 18 | t.Fatal(err) |
| 19 | } |
| 20 | rt, _ := reg.Get("a.example.com") |
| 21 | if rt.BuildState != "" { |
| 22 | t.Fatalf("a new row has build state %q", rt.BuildState) |
| 23 | } |
| 24 | before, err := reg.Fingerprint() |
| 25 | if err != nil { |
| 26 | t.Fatal(err) |
| 27 | } |
| 28 | col := buildStateCol |
| 29 | if err := reg.SetBuildState("a.example.com", col); err != nil { |
| 30 | t.Fatal(err) |
| 31 | } |
| 32 | if rt, _ = reg.Get("a.example.com"); rt.BuildState != col { |
| 33 | t.Fatalf("column = %q", rt.BuildState) |
| 34 | } |
| 35 | after, err := reg.Fingerprint() |
| 36 | if err != nil { |
| 37 | t.Fatal(err) |
| 38 | } |
| 39 | if after != before { |
| 40 | t.Fatal("build state changed the edge's fingerprint") |
| 41 | } |
| 42 | // A full-row Upsert that does not carry the column keeps it. |
| 43 | if err := reg.Upsert(Route{Host: "a.example.com", Kind: "instance", App: "km"}); err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | if rt, _ = reg.Get("a.example.com"); rt.BuildState != col { |
| 47 | t.Fatalf("Upsert cleared the column: %q", rt.BuildState) |
| 48 | } |
| 49 | if err := reg.SetBuildState("a.example.com", ""); err != nil { |
| 50 | t.Fatal(err) |
| 51 | } |
| 52 | if rt, _ = reg.Get("a.example.com"); rt.BuildState != "" { |
| 53 | t.Fatal("the narrow setter could not clear the column") |
| 54 | } |
| 55 | if err := reg.SetBuildState("nobody.example.com", col); err == nil { |
| 56 | t.Fatal("an unknown host was not an error") |
| 57 | } |
| 58 | } |
| 59 | |
| 60 | // build_state is rewritten (ReadAt) on every reconcile refresh with nothing an |
| 61 | // operator did behind it, so it must not trigger a registry snapshot (#207). |
| 62 | func TestBuildStateIsChurnForTheSnapshotGate(t *testing.T) { |
| 63 | reg, err := Open(filepath.Join(t.TempDir(), "r.db")) |
| 64 | if err != nil { |
| 65 | t.Fatal(err) |
| 66 | } |
| 67 | const host = "a.example.com" |
| 68 | if err := reg.Upsert(Route{Host: host, Kind: "instance", App: "km"}); err != nil { |
| 69 | t.Fatal(err) |
| 70 | } |
| 71 | before, err := reg.SnapshotFingerprint() |
| 72 | if err != nil { |
| 73 | t.Fatal(err) |
| 74 | } |
| 75 | if err := reg.SetBuildState(host, buildStateCol); err != nil { |
| 76 | t.Fatal(err) |
| 77 | } |
| 78 | after, err := reg.SnapshotFingerprint() |
| 79 | if err != nil { |
| 80 | t.Fatal(err) |
| 81 | } |
| 82 | if before != after { |
| 83 | t.Fatal("SetBuildState moved the snapshot fingerprint") |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | // A cold handover INSERTs the whole row positionally: the column must be in |
| 88 | // that list or every handover fails on column count, and its value must |
| 89 | // survive. |
| 90 | func TestBuildStateSurvivesColdHandover(t *testing.T) { |
| 91 | t.Setenv("CARLOS_HOST_CLAIM", "1") |
| 92 | ctx := context.Background() |
| 93 | reg, err := Open(filepath.Join(t.TempDir(), "registry.db")) |
| 94 | if err != nil { |
| 95 | t.Fatal(err) |
| 96 | } |
| 97 | t.Cleanup(func() { reg.Close() }) |
| 98 | oldOwner := ClaimOwnership{"inc", 7, "installation-7", "route-7"} |
| 99 | newOwner := ClaimOwnership{"inc", 8, "installation-8", "route-8"} |
| 100 | route := Route{Host: "shop.example", App: "app", ClaimOwnership: oldOwner} |
| 101 | if err = reg.Upsert(route); err != nil { |
| 102 | t.Fatal(err) |
| 103 | } |
| 104 | if err = reg.SetBuildState(route.Host, buildStateCol); err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | current, err := reg.Get(route.Host) |
| 108 | if err != nil || current == nil { |
| 109 | t.Fatalf("Get: %v", err) |
| 110 | } |
| 111 | old := ColdRows{Route: *current} |
| 112 | next := ColdRows{Route: *current} |
| 113 | next.Route.ClaimOwnership = newOwner |
| 114 | targets := []ClaimRow{{Kind: "route", Host: route.Host, Ownership: oldOwner}} |
| 115 | if err = reg.RetireClaimRows(ctx, "ret", targets, func([]ClaimRowArchive) error { return nil }); err != nil { |
| 116 | t.Fatal(err) |
| 117 | } |
| 118 | if err = reg.RestoreClaimRows(ctx, "h1", "ret", old, next); err != nil { |
| 119 | t.Fatalf("handover refused: %v", err) |
| 120 | } |
| 121 | got, err := reg.Get(route.Host) |
| 122 | if err != nil || got == nil { |
| 123 | t.Fatalf("Get after handover: %v", err) |
| 124 | } |
| 125 | if got.BuildState != buildStateCol { |
| 126 | t.Fatalf("build state after handover = %q", got.BuildState) |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | // buildStateColLater is buildStateCol after one more reconcile refresh: only |
| 131 | // read_at moved, as it does every reconcile even for a stopped host. |
| 132 | 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"}` |
| 133 | |
| 134 | // A cold handover replayed after its lost reply must not be refused because |
| 135 | // the agent refreshed the row's build-state copy in between: the column is |
| 136 | // the agent's cache of the bucket object (snapshotChurnCols), not part of |
| 137 | // the installation the replay checks. |
| 138 | func TestColdHandoverReplayIgnoresABuildStateRefresh(t *testing.T) { |
| 139 | t.Setenv("CARLOS_HOST_CLAIM", "1") |
| 140 | ctx := context.Background() |
| 141 | reg, err := Open(filepath.Join(t.TempDir(), "registry.db")) |
| 142 | if err != nil { |
| 143 | t.Fatal(err) |
| 144 | } |
| 145 | t.Cleanup(func() { reg.Close() }) |
| 146 | oldOwner := ClaimOwnership{"inc", 7, "installation-7", "route-7"} |
| 147 | newOwner := ClaimOwnership{"inc", 8, "installation-8", "route-8"} |
| 148 | route := Route{Host: "shop.example", App: "app", ClaimOwnership: oldOwner} |
| 149 | if err = reg.Upsert(route); err != nil { |
| 150 | t.Fatal(err) |
| 151 | } |
| 152 | if err = reg.SetBuildState(route.Host, buildStateCol); err != nil { |
| 153 | t.Fatal(err) |
| 154 | } |
| 155 | current, err := reg.Get(route.Host) |
| 156 | if err != nil || current == nil { |
| 157 | t.Fatalf("Get: %v", err) |
| 158 | } |
| 159 | old := ColdRows{Route: *current} |
| 160 | next := ColdRows{Route: *current} |
| 161 | next.Route.ClaimOwnership = newOwner |
| 162 | targets := []ClaimRow{{Kind: "route", Host: route.Host, Ownership: oldOwner}} |
| 163 | if err = reg.RetireClaimRows(ctx, "ret", targets, func([]ClaimRowArchive) error { return nil }); err != nil { |
| 164 | t.Fatal(err) |
| 165 | } |
| 166 | if err = reg.RestoreClaimRows(ctx, "h1", "ret", old, next); err != nil { |
| 167 | t.Fatalf("handover refused: %v", err) |
| 168 | } |
| 169 | if err = reg.SetBuildState(route.Host, buildStateColLater); err != nil { |
| 170 | t.Fatal(err) |
| 171 | } |
| 172 | if err = reg.RestoreClaimRows(ctx, "h1", "ret", old, next); err != nil { |
| 173 | t.Fatalf("replay after a build-state refresh refused: %v", err) |
| 174 | } |
| 175 | if got, _ := reg.Get(route.Host); got == nil || got.BuildState != buildStateColLater { |
| 176 | t.Fatalf("the replay rewrote the refreshed copy: %+v", got) |
| 177 | } |
| 178 | // Any other column still decides the replay. |
| 179 | if _, err = reg.sql.Exec(`UPDATE routes SET version='other' WHERE host=?`, route.Host); err != nil { |
| 180 | t.Fatal(err) |
| 181 | } |
| 182 | if err = reg.RestoreClaimRows(ctx, "h1", "ret", old, next); !errors.Is(err, ErrClaimOwnership) { |
| 183 | t.Fatalf("replay over a changed row: %v", err) |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | // A recovery handover's rebind (main's RebindClaimRows) reads the row, then |
| 188 | // rebinds it in a transaction, and replays from a fresh read after a lost |
| 189 | // reply or a crash. A build-state refresh between any two of those steps is |
| 190 | // not a concurrent replacement of the installation: it must neither refuse |
| 191 | // the rebind nor its replay, and the rebind must not write the column. |
| 192 | func TestRecoveryRebindIgnoresABuildStateRefresh(t *testing.T) { |
| 193 | for _, mode := range []string{"between-read-and-rebind", "replay-from-a-fresh-read", "replay-with-the-first-read"} { |
| 194 | t.Run(mode, func(t *testing.T) { |
| 195 | r, err := Open(filepath.Join(t.TempDir(), "registry.db")) |
| 196 | if err != nil { |
| 197 | t.Fatal(err) |
| 198 | } |
| 199 | defer r.Close() |
| 200 | const host = "app.example" |
| 201 | oldOwner := ClaimOwnership{"inc", 1, "old", "old-row"} |
| 202 | newOwner := ClaimOwnership{"inc", 2, "new", "new-row"} |
| 203 | if err = r.Upsert(Route{Host: host, Kind: "instance", ClaimOwnership: oldOwner}); err != nil { |
| 204 | t.Fatal(err) |
| 205 | } |
| 206 | if err = r.SetBuildState(host, buildStateCol); err != nil { |
| 207 | t.Fatal(err) |
| 208 | } |
| 209 | saved, err := r.Get(host) |
| 210 | if err != nil { |
| 211 | t.Fatal(err) |
| 212 | } |
| 213 | old := ColdRows{Route: *saved} |
| 214 | next := old |
| 215 | next.Route.ClaimOwnership = newOwner |
| 216 | if mode == "between-read-and-rebind" { |
| 217 | if err = r.SetBuildState(host, buildStateColLater); err != nil { |
| 218 | t.Fatal(err) |
| 219 | } |
| 220 | } |
| 221 | if err = r.RebindClaimRows(t.Context(), old, next); err != nil { |
| 222 | t.Fatalf("rebind: %v", err) |
| 223 | } |
| 224 | if mode != "between-read-and-rebind" { |
| 225 | if err = r.SetBuildState(host, buildStateColLater); err != nil { |
| 226 | t.Fatal(err) |
| 227 | } |
| 228 | replayOld, replayNext := old, next |
| 229 | if mode == "replay-from-a-fresh-read" { |
| 230 | // As claimCrossBoxHandover.rebindRecoveryRows replays: a |
| 231 | // fresh Get, the old ownership from the inventory. |
| 232 | fresh, err := r.Get(host) |
| 233 | if err != nil { |
| 234 | t.Fatal(err) |
| 235 | } |
| 236 | replayOld, replayNext = ColdRows{Route: *fresh}, ColdRows{Route: *fresh} |
| 237 | replayOld.Route.ClaimOwnership = oldOwner |
| 238 | } |
| 239 | if err = r.RebindClaimRows(t.Context(), replayOld, replayNext); err != nil { |
| 240 | t.Fatalf("replay after a build-state refresh refused: %v", err) |
| 241 | } |
| 242 | } |
| 243 | got, err := r.Get(host) |
| 244 | if err != nil || got.ClaimOwnership != newOwner || got.BuildState != buildStateColLater { |
| 245 | t.Fatalf("route = %+v, %v", got, err) |
| 246 | } |
| 247 | // Any other column still decides the replay. |
| 248 | if _, err = r.sql.Exec(`UPDATE routes SET version='other' WHERE host=?`, host); err != nil { |
| 249 | t.Fatal(err) |
| 250 | } |
| 251 | if err = r.RebindClaimRows(t.Context(), old, next); !errors.Is(err, ErrClaimOwnership) { |
| 252 | t.Fatalf("replay over a changed row: %v", err) |
| 253 | } |
| 254 | }) |
| 255 | } |
| 256 | } |
| 257 | |
| 258 | // The host's registry child carries the writer across the transport. |
| 259 | func TestRemoteSetBuildState(t *testing.T) { |
| 260 | r, _ := remoteTestRegistry(t) |
| 261 | const host = "remote.example.com" |
| 262 | if err := r.Upsert(Route{Host: host, Kind: "instance", Status: "ready"}); err != nil { |
| 263 | t.Fatal(err) |
| 264 | } |
| 265 | if err := r.SetBuildState(host, buildStateCol); err != nil { |
| 266 | t.Fatal(err) |
| 267 | } |
| 268 | got, err := r.Get(host) |
| 269 | if err != nil || got == nil || got.BuildState != buildStateCol { |
| 270 | t.Fatalf("route = %+v, %v", got, err) |
| 271 | } |
| 272 | if err := r.SetBuildState("nobody.example.com", buildStateCol); err == nil { |
| 273 | t.Fatal("an unknown host was not an error") |
| 274 | } |
| 275 | } |
| 276 | |