| 1 | package registry |
| 2 | |
| 3 | import ( |
| 4 | "database/sql" |
| 5 | "errors" |
| 6 | "path/filepath" |
| 7 | "regexp" |
| 8 | "strings" |
| 9 | "sync" |
| 10 | "testing" |
| 11 | ) |
| 12 | |
| 13 | // Distinct, realistic-shape (64 lowercase hex) token hashes for tests — |
| 14 | // AddFleetBox/SetFleetBoxToken now reject anything else. |
| 15 | var ( |
| 16 | tokenHashA = strings.Repeat("a", 64) |
| 17 | tokenHashB = strings.Repeat("b", 64) |
| 18 | tokenHashC = strings.Repeat("c", 64) |
| 19 | ) |
| 20 | |
| 21 | // TestFleetRoundTrips pins UpsertFleet/GetFleet/ListFleets against the exact |
| 22 | // shape later tasks depend on (task-1-brief.md's Fleet struct). |
| 23 | func TestFleetRoundTrips(t *testing.T) { |
| 24 | reg := openTestRegistry(t) |
| 25 | want := Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data", CreatedAt: 1700000000} |
| 26 | if err := reg.UpsertFleet(want); err != nil { |
| 27 | t.Fatal(err) |
| 28 | } |
| 29 | got, err := reg.GetFleet("acme") |
| 30 | if err != nil { |
| 31 | t.Fatal(err) |
| 32 | } |
| 33 | if got == nil { |
| 34 | t.Fatal("GetFleet returned nil for an upserted fleet") |
| 35 | } |
| 36 | if *got != want { |
| 37 | t.Fatalf("round trip mismatch:\n got %+v\n want %+v", *got, want) |
| 38 | } |
| 39 | list, err := reg.ListFleets() |
| 40 | if err != nil { |
| 41 | t.Fatal(err) |
| 42 | } |
| 43 | if len(list) != 1 || list[0] != want { |
| 44 | t.Fatalf("ListFleets = %+v, want [%+v]", list, want) |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | func TestGetFleetMissingIsNilNil(t *testing.T) { |
| 49 | reg := openTestRegistry(t) |
| 50 | got, err := reg.GetFleet("nope") |
| 51 | if err != nil || got != nil { |
| 52 | t.Fatalf("GetFleet(missing) = %v, %v; want nil, nil", got, err) |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | // TestCreateFleetInsertOnly: the ordinary sequential case — first create |
| 57 | // wins, a second with the same name is refused without touching the |
| 58 | // existing row. |
| 59 | func TestCreateFleetInsertOnly(t *testing.T) { |
| 60 | reg := openTestRegistry(t) |
| 61 | created, err := reg.CreateFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}) |
| 62 | if err != nil || !created { |
| 63 | t.Fatalf("first CreateFleet: created=%v err=%v, want true, nil", created, err) |
| 64 | } |
| 65 | created, err = reg.CreateFleet(Fleet{Name: "acme", Account: "intruder", Bucket: "stolen"}) |
| 66 | if err != nil || created { |
| 67 | t.Fatalf("second CreateFleet (same name): created=%v err=%v, want false, nil", created, err) |
| 68 | } |
| 69 | got, err := reg.GetFleet("acme") |
| 70 | if err != nil || got == nil || got.Account != "amadan" || got.Bucket != "acme-data" { |
| 71 | t.Fatalf("GetFleet after a refused create = %+v, %v, want the FIRST caller's row untouched", got, err) |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | // TestCreateFleetRaceInsertOnly is the property the console's |
| 76 | // handleAPIFleetNew now depends on entirely (no more read-then-write): two |
| 77 | // callers racing the SAME never-before-seen name — from two DIFFERENT |
| 78 | // accounts — must never both win, and the row that survives must belong to |
| 79 | // whichever one did. This is what makes UpsertFleet's ON CONFLICT DO UPDATE |
| 80 | // (which CreateFleet deliberately does not use) unreachable from a genuine |
| 81 | // race here. |
| 82 | func TestCreateFleetRaceInsertOnly(t *testing.T) { |
| 83 | reg := openTestRegistry(t) |
| 84 | var wg sync.WaitGroup |
| 85 | created := make([]bool, 2) |
| 86 | errs := make([]error, 2) |
| 87 | accounts := []string{"aaaa", "bbbb"} |
| 88 | wg.Add(2) |
| 89 | for i := 0; i < 2; i++ { |
| 90 | go func(i int) { |
| 91 | defer wg.Done() |
| 92 | created[i], errs[i] = reg.CreateFleet(Fleet{Name: "acme", Account: accounts[i], Bucket: accounts[i] + "-data"}) |
| 93 | }(i) |
| 94 | } |
| 95 | wg.Wait() |
| 96 | |
| 97 | for i, err := range errs { |
| 98 | if err != nil { |
| 99 | t.Fatalf("CreateFleet[%d]: %v", i, err) |
| 100 | } |
| 101 | } |
| 102 | if created[0] == created[1] { |
| 103 | t.Fatalf("created = %v, %v — want exactly one true", created[0], created[1]) |
| 104 | } |
| 105 | winner := accounts[0] |
| 106 | if created[1] { |
| 107 | winner = accounts[1] |
| 108 | } |
| 109 | got, err := reg.GetFleet("acme") |
| 110 | if err != nil || got == nil { |
| 111 | t.Fatalf("GetFleet after the race: %+v, %v", got, err) |
| 112 | } |
| 113 | if got.Account != winner { |
| 114 | t.Fatalf("surviving row account = %q, want the winner's %q", got.Account, winner) |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | // TestFleetBoxRoundTrips pins AddFleetBox/ListFleetBoxes against the exact |
| 119 | // FleetBox shape later tasks depend on. |
| 120 | func TestFleetBoxRoundTrips(t *testing.T) { |
| 121 | reg := openTestRegistry(t) |
| 122 | if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil { |
| 123 | t.Fatal(err) |
| 124 | } |
| 125 | want := FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active", AddedAt: 1700000001} |
| 126 | if err := reg.AddFleetBox(want); err != nil { |
| 127 | t.Fatal(err) |
| 128 | } |
| 129 | boxes, err := reg.ListFleetBoxes("acme") |
| 130 | if err != nil { |
| 131 | t.Fatal(err) |
| 132 | } |
| 133 | if len(boxes) != 1 || boxes[0] != want { |
| 134 | t.Fatalf("ListFleetBoxes = %+v, want [%+v]", boxes, want) |
| 135 | } |
| 136 | } |
| 137 | |
| 138 | // TestAddFleetBoxRejectsUnknownFleet: no FK, so this is the only rail |
| 139 | // against an orphan box — one authenticating a channel for a fleet with no |
| 140 | // account/bucket record at all. |
| 141 | func TestAddFleetBoxRejectsUnknownFleet(t *testing.T) { |
| 142 | reg := openTestRegistry(t) |
| 143 | if err := reg.AddFleetBox(FleetBox{Fleet: "ghost", Label: "pi-1", TokenHash: tokenHashA, Status: "active"}); err == nil { |
| 144 | t.Fatal("AddFleetBox against a never-upserted fleet: got nil error, want one") |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | // TestAddFleetBoxRejectsDuplicateActive is the brief's core invariant: two |
| 149 | // boxes cannot hold the same (fleet, label) channel identity while both are |
| 150 | // active. |
| 151 | func TestAddFleetBoxRejectsDuplicateActive(t *testing.T) { |
| 152 | reg := openTestRegistry(t) |
| 153 | if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil { |
| 154 | t.Fatal(err) |
| 155 | } |
| 156 | box := FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active"} |
| 157 | if err := reg.AddFleetBox(box); err != nil { |
| 158 | t.Fatal(err) |
| 159 | } |
| 160 | err := reg.AddFleetBox(box) |
| 161 | if err == nil { |
| 162 | t.Fatal("AddFleetBox on a duplicate active (fleet,label): got nil error, want one") |
| 163 | } |
| 164 | // Distinguishable from any other failure via errors.Is, not string |
| 165 | // matching — the property console/fleets.go's handler now relies on to |
| 166 | // tell an ordinary conflict apart from a genuine infrastructure error. |
| 167 | if !errors.Is(err, ErrFleetBoxActive) { |
| 168 | t.Fatalf("AddFleetBox on a duplicate active: err = %v, want it to wrap ErrFleetBoxActive", err) |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | // TestAddFleetBoxReactivatesADetachedLabel: the brief only forbids a |
| 173 | // duplicate ACTIVE box, implying a detached label can be re-added — the path |
| 174 | // a re-provisioned box takes. |
| 175 | func TestAddFleetBoxReactivatesADetachedLabel(t *testing.T) { |
| 176 | reg := openTestRegistry(t) |
| 177 | if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil { |
| 178 | t.Fatal(err) |
| 179 | } |
| 180 | box := FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active"} |
| 181 | if err := reg.AddFleetBox(box); err != nil { |
| 182 | t.Fatal(err) |
| 183 | } |
| 184 | if err := reg.SetFleetBoxStatus("acme", "pi-1", "detached"); err != nil { |
| 185 | t.Fatal(err) |
| 186 | } |
| 187 | reAdd := FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashB, Status: "active"} |
| 188 | if err := reg.AddFleetBox(reAdd); err != nil { |
| 189 | t.Fatalf("AddFleetBox over a detached label: %v", err) |
| 190 | } |
| 191 | boxes, err := reg.ListFleetBoxes("acme") |
| 192 | if err != nil { |
| 193 | t.Fatal(err) |
| 194 | } |
| 195 | if len(boxes) != 1 || boxes[0].TokenHash != tokenHashB || boxes[0].Status != "active" { |
| 196 | t.Fatalf("re-add over a detached label = %+v, want reactivated with the new token", boxes) |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | // TestFleetChannelAuthOmitsDetachedBoxes pins the exact contract later tasks |
| 201 | // rely on: channel name "<fleet>/<label>" -> TokenHash, ACTIVE boxes only. |
| 202 | func TestFleetChannelAuthOmitsDetachedBoxes(t *testing.T) { |
| 203 | reg := openTestRegistry(t) |
| 204 | if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil { |
| 205 | t.Fatal(err) |
| 206 | } |
| 207 | if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active"}); err != nil { |
| 208 | t.Fatal(err) |
| 209 | } |
| 210 | if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-2", TokenHash: tokenHashB, Status: "active"}); err != nil { |
| 211 | t.Fatal(err) |
| 212 | } |
| 213 | if err := reg.SetFleetBoxStatus("acme", "pi-2", "detached"); err != nil { |
| 214 | t.Fatal(err) |
| 215 | } |
| 216 | auth, err := reg.FleetChannelAuth() |
| 217 | if err != nil { |
| 218 | t.Fatal(err) |
| 219 | } |
| 220 | if _, ok := auth["acme/pi-2"]; ok { |
| 221 | t.Fatalf("FleetChannelAuth included a detached box: %+v", auth) |
| 222 | } |
| 223 | if got, ok := auth["acme/pi-1"]; !ok || got != tokenHashA { |
| 224 | t.Fatalf("FleetChannelAuth missing/wrong entry for the active box: %+v", auth) |
| 225 | } |
| 226 | } |
| 227 | |
| 228 | func TestSetFleetBoxTokenMissingRowErrors(t *testing.T) { |
| 229 | reg := openTestRegistry(t) |
| 230 | if err := reg.SetFleetBoxToken("acme", "nope", tokenHashA); err == nil { |
| 231 | t.Fatal("SetFleetBoxToken on a missing row: got nil error, want one") |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | func TestSetFleetBoxStatusMissingRowErrors(t *testing.T) { |
| 236 | reg := openTestRegistry(t) |
| 237 | if err := reg.SetFleetBoxStatus("acme", "nope", "detached"); err == nil { |
| 238 | t.Fatal("SetFleetBoxStatus on a missing row: got nil error, want one") |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | // TestFleetWritersRejectInvalidNames: every writer validates fleet/label |
| 243 | // names with ValidTunnelName before touching the DB. |
| 244 | func TestFleetWritersRejectInvalidNames(t *testing.T) { |
| 245 | reg := openTestRegistry(t) |
| 246 | if err := reg.UpsertFleet(Fleet{Name: "UPPER"}); err == nil { |
| 247 | t.Fatal("UpsertFleet({Name:UPPER}): got nil error, want one") |
| 248 | } |
| 249 | if err := reg.AddFleetBox(FleetBox{Fleet: "UPPER", Label: "pi-1", TokenHash: tokenHashA}); err == nil { |
| 250 | t.Fatal("AddFleetBox with an invalid fleet name: got nil error, want one") |
| 251 | } |
| 252 | if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "UPPER", TokenHash: tokenHashA}); err == nil { |
| 253 | t.Fatal("AddFleetBox with an invalid label: got nil error, want one") |
| 254 | } |
| 255 | if err := reg.SetFleetBoxToken("UPPER", "pi-1", tokenHashA); err == nil { |
| 256 | t.Fatal("SetFleetBoxToken with an invalid fleet name: got nil error, want one") |
| 257 | } |
| 258 | if err := reg.SetFleetBoxStatus("acme", "UPPER", "detached"); err == nil { |
| 259 | t.Fatal("SetFleetBoxStatus with an invalid label: got nil error, want one") |
| 260 | } |
| 261 | } |
| 262 | |
| 263 | // TestFleetBoxWritersRejectInvalidStatus: AddFleetBox and SetFleetBoxStatus |
| 264 | // both reject a status outside {"active","detached"} — a typo'd status |
| 265 | // would otherwise make a live box invisible to AddFleetBox's uniqueness |
| 266 | // guard, which keys off status = 'active'. |
| 267 | func TestFleetBoxWritersRejectInvalidStatus(t *testing.T) { |
| 268 | reg := openTestRegistry(t) |
| 269 | if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil { |
| 270 | t.Fatal(err) |
| 271 | } |
| 272 | if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "enabled"}); err == nil { |
| 273 | t.Fatal("AddFleetBox with status \"enabled\": got nil error, want one") |
| 274 | } |
| 275 | if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active"}); err != nil { |
| 276 | t.Fatal(err) |
| 277 | } |
| 278 | if err := reg.SetFleetBoxStatus("acme", "pi-1", "enabled"); err == nil { |
| 279 | t.Fatal("SetFleetBoxStatus(\"enabled\"): got nil error, want one") |
| 280 | } |
| 281 | } |
| 282 | |
| 283 | // TestFleetBoxWritersRejectInvalidTokenHash: AddFleetBox and |
| 284 | // SetFleetBoxToken both require exactly 64 lowercase hex characters — |
| 285 | // structurally enforcing "hashes only, never plaintext". |
| 286 | func TestFleetBoxWritersRejectInvalidTokenHash(t *testing.T) { |
| 287 | reg := openTestRegistry(t) |
| 288 | if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil { |
| 289 | t.Fatal(err) |
| 290 | } |
| 291 | for _, bad := range []string{"", "not-a-hash", strings.Repeat("a", 63), strings.Repeat("A", 64)} { |
| 292 | if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: bad, Status: "active"}); err == nil { |
| 293 | t.Fatalf("AddFleetBox with token hash %q: got nil error, want one", bad) |
| 294 | } |
| 295 | } |
| 296 | if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active"}); err != nil { |
| 297 | t.Fatal(err) |
| 298 | } |
| 299 | if err := reg.SetFleetBoxToken("acme", "pi-1", "plaintext-token"); err == nil { |
| 300 | t.Fatal("SetFleetBoxToken with a plaintext-shaped value: got nil error, want one") |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | // TestFingerprintChangesOnFleetMutation: Fingerprint must fold the fleets |
| 305 | // table into its aggregate — mutating a FLEET row (not a route) must still |
| 306 | // change it, which is what lets the edge notice a fleet change later. |
| 307 | func TestFingerprintChangesOnFleetMutation(t *testing.T) { |
| 308 | reg := openTestRegistry(t) |
| 309 | if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil { |
| 310 | t.Fatal(err) |
| 311 | } |
| 312 | before, err := reg.Fingerprint() |
| 313 | if err != nil { |
| 314 | t.Fatal(err) |
| 315 | } |
| 316 | if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan-longer-name", Bucket: "acme-data"}); err != nil { |
| 317 | t.Fatal(err) |
| 318 | } |
| 319 | after, err := reg.Fingerprint() |
| 320 | if err != nil { |
| 321 | t.Fatal(err) |
| 322 | } |
| 323 | if before == after { |
| 324 | t.Fatalf("fingerprint unchanged across a fleet mutation: %q", after) |
| 325 | } |
| 326 | } |
| 327 | |
| 328 | // TestFingerprintChangesOnFleetBoxMutation: same guarantee for fleet_boxes — |
| 329 | // a box status flip must change the fingerprint too (SetFleetBoxStatus is |
| 330 | // exactly how a detach reaches the edge in later tasks). |
| 331 | func TestFingerprintChangesOnFleetBoxMutation(t *testing.T) { |
| 332 | reg := openTestRegistry(t) |
| 333 | if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil { |
| 334 | t.Fatal(err) |
| 335 | } |
| 336 | if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active"}); err != nil { |
| 337 | t.Fatal(err) |
| 338 | } |
| 339 | before, err := reg.Fingerprint() |
| 340 | if err != nil { |
| 341 | t.Fatal(err) |
| 342 | } |
| 343 | if err := reg.SetFleetBoxStatus("acme", "pi-1", "detached"); err != nil { |
| 344 | t.Fatal(err) |
| 345 | } |
| 346 | after, err := reg.Fingerprint() |
| 347 | if err != nil { |
| 348 | t.Fatal(err) |
| 349 | } |
| 350 | if before == after { |
| 351 | t.Fatalf("fingerprint unchanged across a fleet_boxes status flip: %q", after) |
| 352 | } |
| 353 | } |
| 354 | |
| 355 | // TestFingerprintChangesOnTokenRotation is the Critical-1 regression pin: a |
| 356 | // sha256 hex token_hash is ALWAYS 64 characters, so a rotation between two |
| 357 | // same-length, different-content hashes changes no LENGTH, no COUNT, no |
| 358 | // MAX(rowid) — only the rev column moves. Without rev folded into |
| 359 | // Fingerprint, this rotation would be invisible to the edge's poll and a |
| 360 | // revoked token would keep authenticating. |
| 361 | func TestFingerprintChangesOnTokenRotation(t *testing.T) { |
| 362 | reg := openTestRegistry(t) |
| 363 | if err := reg.UpsertFleet(Fleet{Name: "acme", Account: "amadan", Bucket: "acme-data"}); err != nil { |
| 364 | t.Fatal(err) |
| 365 | } |
| 366 | if err := reg.AddFleetBox(FleetBox{Fleet: "acme", Label: "pi-1", TokenHash: tokenHashA, Status: "active"}); err != nil { |
| 367 | t.Fatal(err) |
| 368 | } |
| 369 | if len(tokenHashA) != len(tokenHashC) { |
| 370 | t.Fatalf("test fixture bug: rotation hashes must be equal length, got %d and %d", len(tokenHashA), len(tokenHashC)) |
| 371 | } |
| 372 | before, err := reg.Fingerprint() |
| 373 | if err != nil { |
| 374 | t.Fatal(err) |
| 375 | } |
| 376 | if err := reg.SetFleetBoxToken("acme", "pi-1", tokenHashC); err != nil { |
| 377 | t.Fatal(err) |
| 378 | } |
| 379 | after, err := reg.Fingerprint() |
| 380 | if err != nil { |
| 381 | t.Fatal(err) |
| 382 | } |
| 383 | if before == after { |
| 384 | t.Fatalf("fingerprint unchanged across a same-length token rotation: %q", after) |
| 385 | } |
| 386 | } |
| 387 | |
| 388 | // TestFingerprintChangesOnFleetBoxRestamp is the Task 10 regression pin — |
| 389 | // the defect the two-box e2e (internal/edge/fleet_e2e_test.go) surfaced. A |
| 390 | // failover restamps routes.fleet_box (ReconcileFleetBindings -> SetFleetBox) |
| 391 | // between two labels of the SAME length, which is the common case for fleet |
| 392 | // box labels ("box-a"/"box-b", "pi-1"/"pi-2"). Before the fleet-placement |
| 393 | // projection was added to Fingerprint, that write changed nothing the |
| 394 | // aggregate looked at — fleet_box is not in the routes weighted sum at all — |
| 395 | // so the edge never reloaded, kept the dead box's label captured in its |
| 396 | // fleet proxy, and dispatched straight back to it the moment it re-dialed. |
| 397 | // Unlike an operator's route edit, a restamp is unattended: there is nobody |
| 398 | // there to SIGHUP. |
| 399 | func TestFingerprintChangesOnFleetBoxRestamp(t *testing.T) { |
| 400 | reg := openTestRegistry(t) |
| 401 | if err := reg.Upsert(Route{Host: "app.example", Kind: "instance", Region: PlacementTarget("pi"), FleetBox: "box-a"}); err != nil { |
| 402 | t.Fatal(err) |
| 403 | } |
| 404 | before, err := reg.Fingerprint() |
| 405 | if err != nil { |
| 406 | t.Fatal(err) |
| 407 | } |
| 408 | if err := reg.SetFleetBox("app.example", "box-b"); err != nil { |
| 409 | t.Fatal(err) |
| 410 | } |
| 411 | after, err := reg.Fingerprint() |
| 412 | if err != nil { |
| 413 | t.Fatal(err) |
| 414 | } |
| 415 | if before == after { |
| 416 | t.Fatalf("fingerprint unchanged across a same-length fleet_box restamp: %q — the edge would keep routing to the box that died", after) |
| 417 | } |
| 418 | } |
| 419 | |
| 420 | // TestFingerprintChangesOnPlacement: the same projection covers the OTHER |
| 421 | // column fleet dispatch reads — Region. Placing an existing route on a fleet |
| 422 | // (`carlos apps -place`, a different PROCESS from the edge, so the |
| 423 | // fingerprint poll is the only path in) must be visible too. |
| 424 | func TestFingerprintChangesOnPlacement(t *testing.T) { |
| 425 | reg := openTestRegistry(t) |
| 426 | if err := reg.Upsert(Route{Host: "app.example", Kind: "instance"}); err != nil { |
| 427 | t.Fatal(err) |
| 428 | } |
| 429 | before, err := reg.Fingerprint() |
| 430 | if err != nil { |
| 431 | t.Fatal(err) |
| 432 | } |
| 433 | if err := reg.SetPlacement("app.example", PlacementTarget("pi")); err != nil { |
| 434 | t.Fatal(err) |
| 435 | } |
| 436 | mid, err := reg.Fingerprint() |
| 437 | if err != nil { |
| 438 | t.Fatal(err) |
| 439 | } |
| 440 | if before == mid { |
| 441 | t.Fatalf("fingerprint unchanged when a route was placed on a fleet: %q", mid) |
| 442 | } |
| 443 | // ...and clearing it again is equally visible (the row drops out of the |
| 444 | // projection rather than lingering as a stale entry). |
| 445 | if err := reg.SetPlacement("app.example", ""); err != nil { |
| 446 | t.Fatal(err) |
| 447 | } |
| 448 | after, err := reg.Fingerprint() |
| 449 | if err != nil { |
| 450 | t.Fatal(err) |
| 451 | } |
| 452 | if after == mid { |
| 453 | t.Fatalf("fingerprint unchanged when a route left fleet placement: %q", after) |
| 454 | } |
| 455 | } |
| 456 | |
| 457 | // A registry with no fleet-placed routes at all — every deployment in the |
| 458 | // fleet today — must produce a STABLE fingerprint across repeated polls: the |
| 459 | // projection is a GROUP_CONCAT, and one that flapped (or that grew with |
| 460 | // unrelated routes) would make the edge reload every 2s forever. |
| 461 | func TestFingerprintStableWithoutFleetRoutes(t *testing.T) { |
| 462 | reg := openTestRegistry(t) |
| 463 | for _, h := range []string{"one.example", "two.example", "three.example"} { |
| 464 | if err := reg.Upsert(Route{Host: h, Kind: "service", Addr: "127.0.0.1:1", Region: "eu"}); err != nil { |
| 465 | t.Fatal(err) |
| 466 | } |
| 467 | } |
| 468 | first, err := reg.Fingerprint() |
| 469 | if err != nil { |
| 470 | t.Fatal(err) |
| 471 | } |
| 472 | for i := 0; i < 5; i++ { |
| 473 | again, err := reg.Fingerprint() |
| 474 | if err != nil { |
| 475 | t.Fatal(err) |
| 476 | } |
| 477 | if again != first { |
| 478 | t.Fatalf("fingerprint flapped with no writes at all: %q then %q", first, again) |
| 479 | } |
| 480 | } |
| 481 | // The boundedness claim itself, pinned. This assertion used to read |
| 482 | // "the placement projection is empty, hence a trailing ':'", because the |
| 483 | // fingerprint was a concatenation whose last component was a |
| 484 | // GROUP_CONCAT of fleet-placed rows — a WHERE clause that ever widened |
| 485 | // would have put hostnames' worth of text through a 2s poll. The merge |
| 486 | // with main replaced that concatenation with a sha256 over every |
| 487 | // reload-relevant column (registry.go's Fingerprint), which closes the |
| 488 | // same hole structurally rather than by scoping: the result is |
| 489 | // "<route count>:<64 hex>" no matter how many routes are fleet-placed, |
| 490 | // so no projection can grow and the poll's payload is fixed-size |
| 491 | // forever. That is what is pinned here now. |
| 492 | if !fingerprintShape.MatchString(first) { |
| 493 | t.Fatalf("fingerprint = %q, want the fixed-size %q shape — a poll payload that grows with the route table is the thing this test exists to catch", first, fingerprintShape) |
| 494 | } |
| 495 | } |
| 496 | |
| 497 | // fingerprintShape is Fingerprint's whole contract as a string: a route |
| 498 | // count, a colon, and a sha256 in hex. Nothing about it varies with how many |
| 499 | // routes, fleets or fleet boxes the registry holds. |
| 500 | var fingerprintShape = regexp.MustCompile(`^[0-9]+:[0-9a-f]{64}$`) |
| 501 | |
| 502 | // The other half of the stability question, and the one that actually |
| 503 | // exercises the concat: with fleet-placed routes present the projection is |
| 504 | // non-empty and built by GROUP_CONCAT over an ORDER BY subquery. If that |
| 505 | // order were not stable across identical polls, the edge would see a |
| 506 | // "changed" fingerprint every 2 seconds and reload forever — on every box |
| 507 | // running a fleet, permanently. Mixed FleetBox values (one stamped, one |
| 508 | // stamped differently, one placed but unstamped) so a reordering has |
| 509 | // something to reorder. |
| 510 | func TestFingerprintStableWithFleetRoutes(t *testing.T) { |
| 511 | reg := openTestRegistry(t) |
| 512 | for _, rt := range []Route{ |
| 513 | {Host: "one.example", Kind: "instance", Region: PlacementTarget("pi"), FleetBox: "box-a"}, |
| 514 | {Host: "two.example", Kind: "instance", Region: PlacementTarget("pi"), FleetBox: "box-b"}, |
| 515 | {Host: "three.example", Kind: "instance", Region: PlacementTarget("eleven"), FleetBox: ""}, |
| 516 | {Host: "four.example", Kind: "service", Addr: "127.0.0.1:1"}, // not fleet-placed |
| 517 | } { |
| 518 | if err := reg.Upsert(rt); err != nil { |
| 519 | t.Fatal(err) |
| 520 | } |
| 521 | } |
| 522 | first, err := reg.Fingerprint() |
| 523 | if err != nil { |
| 524 | t.Fatal(err) |
| 525 | } |
| 526 | if strings.HasSuffix(first, ":") { |
| 527 | t.Fatalf("fingerprint = %q, want a NON-empty final component — with three fleet-placed routes the projection this test exists to exercise is empty, so the stability loop below proves nothing", first) |
| 528 | } |
| 529 | for i := 0; i < 5; i++ { |
| 530 | again, err := reg.Fingerprint() |
| 531 | if err != nil { |
| 532 | t.Fatal(err) |
| 533 | } |
| 534 | if again != first { |
| 535 | t.Fatalf("fingerprint flapped across identical polls with fleet routes present: %q then %q — every edge running a fleet would reload every 2s forever", first, again) |
| 536 | } |
| 537 | } |
| 538 | } |
| 539 | |
| 540 | // TestFleetPlacement pins the "fleet/<name>" grammar Route.Region carries |
| 541 | // when a route is bound to a customer fleet: the prefix alone is not enough, |
| 542 | // the name half must also pass ValidTunnelName, and a bare region code (a |
| 543 | // FUTURE placement form this v1 does not parse) must answer false rather |
| 544 | // than partially matching. |
| 545 | func TestFleetPlacement(t *testing.T) { |
| 546 | cases := []struct { |
| 547 | region string |
| 548 | wantFleet string |
| 549 | wantOK bool |
| 550 | }{ |
| 551 | {"fleet/pi", "pi", true}, |
| 552 | {"", "", false}, |
| 553 | {"eu", "", false}, |
| 554 | {"fleet/UPPER", "", false}, |
| 555 | {"fleet/", "", false}, |
| 556 | {"fleet", "", false}, |
| 557 | } |
| 558 | for _, c := range cases { |
| 559 | fleet, ok := FleetPlacement(c.region) |
| 560 | if fleet != c.wantFleet || ok != c.wantOK { |
| 561 | t.Errorf("FleetPlacement(%q) = (%q, %v), want (%q, %v)", c.region, fleet, ok, c.wantFleet, c.wantOK) |
| 562 | } |
| 563 | } |
| 564 | } |
| 565 | |
| 566 | // TestPlacementTarget pins the inverse rendering FleetPlacement parses back. |
| 567 | func TestPlacementTarget(t *testing.T) { |
| 568 | if got := PlacementTarget("pi"); got != "fleet/pi" { |
| 569 | t.Errorf("PlacementTarget(%q) = %q, want %q", "pi", got, "fleet/pi") |
| 570 | } |
| 571 | fleet, ok := FleetPlacement(PlacementTarget("pi-box")) |
| 572 | if !ok || fleet != "pi-box" { |
| 573 | t.Fatalf("FleetPlacement(PlacementTarget(%q)) = (%q, %v), want (%q, true)", "pi-box", fleet, ok, "pi-box") |
| 574 | } |
| 575 | } |
| 576 | |
| 577 | // mustCreateFleet is a tiny helper for tests that don't care about anything |
| 578 | // beyond fixed test values for account/bucket. |
| 579 | func mustCreateFleet(t *testing.T, r *Registry, fleet, account, bucket string) { |
| 580 | t.Helper() |
| 581 | if _, err := r.CreateFleet(Fleet{Name: fleet, Account: account, Bucket: bucket}); err != nil { |
| 582 | t.Fatalf("CreateFleet(%q): %v", fleet, err) |
| 583 | } |
| 584 | } |
| 585 | |
| 586 | // mustAddBox is a tiny helper for tests that don't care about anything |
| 587 | // beyond a fixed test token hash and an "active" status. |
| 588 | func mustAddBox(t *testing.T, r *Registry, fleet, label string) { |
| 589 | t.Helper() |
| 590 | if err := r.AddFleetBox(FleetBox{Fleet: fleet, Label: label, TokenHash: tokenHashA, Status: "active"}); err != nil { |
| 591 | t.Fatalf("AddFleetBox(%q, %q): %v", fleet, label, err) |
| 592 | } |
| 593 | } |
| 594 | |
| 595 | // TestFleetBoxTags pins ValidBoxTags's grammar and SetFleetBoxTags's |
| 596 | // round-trip through ListFleetBoxes, including the invalid-grammar and |
| 597 | // unknown-box refusals and clearing back to "". |
| 598 | func TestFleetBoxTags(t *testing.T) { |
| 599 | r := openTestRegistry(t) |
| 600 | mustCreateFleet(t, r, "pi", "acct1", "bkt") |
| 601 | mustAddBox(t, r, "pi", "box-a") |
| 602 | // grammar |
| 603 | for _, bad := range []string{"Office", "a b", "-lead", "toolongtoolongtoolongtoolo", "a,,b", |
| 604 | "a,b,c,d,e,f,g,h,i"} { |
| 605 | if ValidBoxTags(bad) { |
| 606 | t.Errorf("ValidBoxTags(%q) = true, want false", bad) |
| 607 | } |
| 608 | } |
| 609 | for _, good := range []string{"", "office", "office,free", "a-1"} { |
| 610 | if !ValidBoxTags(good) { |
| 611 | t.Errorf("ValidBoxTags(%q) = false, want true", good) |
| 612 | } |
| 613 | } |
| 614 | if err := r.SetFleetBoxTags("pi", "box-a", "Office"); err == nil { |
| 615 | t.Fatal("invalid grammar accepted") |
| 616 | } |
| 617 | if err := r.SetFleetBoxTags("pi", "nope", "office"); err == nil { |
| 618 | t.Fatal("unknown box accepted") |
| 619 | } |
| 620 | if err := r.SetFleetBoxTags("pi", "box-a", "office,free"); err != nil { |
| 621 | t.Fatal(err) |
| 622 | } |
| 623 | boxes, err := r.ListFleetBoxes("pi") |
| 624 | if err != nil || len(boxes) != 1 || boxes[0].Tags != "office,free" { |
| 625 | t.Fatalf("tags round-trip: %+v err=%v", boxes, err) |
| 626 | } |
| 627 | // clearing |
| 628 | if err := r.SetFleetBoxTags("pi", "box-a", ""); err != nil { |
| 629 | t.Fatal(err) |
| 630 | } |
| 631 | } |
| 632 | |
| 633 | // TestFleetBoxTagsFingerprintMoves mirrors |
| 634 | // TestFingerprintChangesOnTokenRotation for tags: a tags write must move |
| 635 | // Fingerprint the same way a token rotation does, or the edge would never |
| 636 | // notice a tag change land. |
| 637 | func TestFleetBoxTagsFingerprintMoves(t *testing.T) { |
| 638 | r := openTestRegistry(t) |
| 639 | mustCreateFleet(t, r, "pi", "acct1", "bkt") |
| 640 | mustAddBox(t, r, "pi", "box-a") |
| 641 | before, err := r.Fingerprint() |
| 642 | if err != nil { |
| 643 | t.Fatal(err) |
| 644 | } |
| 645 | if err := r.SetFleetBoxTags("pi", "box-a", "office"); err != nil { |
| 646 | t.Fatal(err) |
| 647 | } |
| 648 | after, err := r.Fingerprint() |
| 649 | if err != nil { |
| 650 | t.Fatal(err) |
| 651 | } |
| 652 | if before == after { |
| 653 | t.Fatalf("fingerprint unchanged across a fleet_boxes tags write: %q", after) |
| 654 | } |
| 655 | } |
| 656 | |
| 657 | // TestFleetBoxesPreTagsRowsParse mirrors TestFleetBoxMigratesFromPreChangeDB |
| 658 | // (registry_test.go): a fleet_boxes row written before the tags column |
| 659 | // existed is simulated by creating the table without it and inserting a raw |
| 660 | // row, then Open() (which runs migrateFleets) must still open the file and |
| 661 | // ListFleetBoxes must read the row back with Tags == "". |
| 662 | func TestFleetBoxesPreTagsRowsParse(t *testing.T) { |
| 663 | path := filepath.Join(t.TempDir(), "registry.db") |
| 664 | db, err := sql.Open("sqlite", path) |
| 665 | if err != nil { |
| 666 | t.Fatal(err) |
| 667 | } |
| 668 | if _, err := db.Exec(` |
| 669 | CREATE TABLE fleet_boxes ( |
| 670 | fleet TEXT NOT NULL, |
| 671 | label TEXT NOT NULL, |
| 672 | token_hash TEXT NOT NULL, |
| 673 | status TEXT NOT NULL, |
| 674 | added_at INTEGER NOT NULL, |
| 675 | rev INTEGER NOT NULL DEFAULT 0, |
| 676 | PRIMARY KEY (fleet, label) |
| 677 | );`); err != nil { |
| 678 | t.Fatal(err) |
| 679 | } |
| 680 | if _, err := db.Exec(`INSERT INTO fleet_boxes (fleet, label, token_hash, status, added_at) VALUES (?, ?, ?, ?, ?)`, |
| 681 | "pi", "box-a", tokenHashA, "active", 1700000000); err != nil { |
| 682 | t.Fatal(err) |
| 683 | } |
| 684 | if err := db.Close(); err != nil { |
| 685 | t.Fatal(err) |
| 686 | } |
| 687 | |
| 688 | r, err := Open(path) |
| 689 | if err != nil { |
| 690 | t.Fatalf("Open over a pre-tags DB: %v", err) |
| 691 | } |
| 692 | defer r.Close() |
| 693 | boxes, err := r.ListFleetBoxes("pi") |
| 694 | if err != nil { |
| 695 | t.Fatal(err) |
| 696 | } |
| 697 | if len(boxes) != 1 || boxes[0].Tags != "" { |
| 698 | t.Fatalf("pre-tags row should default tags=\"\": %+v", boxes) |
| 699 | } |
| 700 | } |
| 701 | |