carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package registry
2
3import (
4 "database/sql"
5 "errors"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10)
11
12// List must observe rows committed through a DIFFERENT Registry handle — the
13// in-process stand-in for `carlos add` writing while the edge holds its own
14// long-lived handle. Pins titogo's live failure: a route added while the
15// router ran never routed (through SIGHUP reloads), because List read a stale
16// WAL snapshot off the long-lived pool and the fingerprint watcher, already
17// matching, never retriggered.
18func TestListSeesOtherHandlesCommits(t *testing.T) {
19 path := filepath.Join(t.TempDir(), "registry.db")
20 router, err := Open(path)
21 if err != nil {
22 t.Fatal(err)
23 }
24
25 adder, err := Open(path)
26 if err != nil {
27 t.Fatal(err)
28 }
29 if err := adder.Upsert(Route{
30 Host: "a.example.com", Slug: "a", Kind: "instance",
31 Socket: "/tmp/a.sock", Status: "ready",
32 }); err != nil {
33 t.Fatal(err)
34 }
35
36 routes, err := router.List()
37 if err != nil {
38 t.Fatal(err)
39 }
40 if len(routes) != 1 || routes[0].Slug != "a" {
41 t.Fatalf("List through the first handle missed the second handle's commit: %+v", routes)
42 }
43
44 r := routes[0]
45 r.Status = "failed"
46 if err := adder.Upsert(r); err != nil {
47 t.Fatal(err)
48 }
49 routes, err = router.List()
50 if err != nil {
51 t.Fatal(err)
52 }
53 if len(routes) != 1 || routes[0].Status != "failed" {
54 t.Fatalf("List missed a cross-handle status flip: %+v", routes)
55 }
56}
57
58// TestUpsertRoundTripsAllColumns is the column-shift canary: routeCols,
59// scanRoute's destination list, Upsert's placeholder row and Upsert's
60// argument list are four hand-maintained lists that must agree, and a
61// miscount among them is a silent shift (a grant landing in created_at)
62// rather than an error. Every column carries a DISTINCT value here on
63// purpose — two columns sharing a value would let a swap pass.
64func TestUpsertRoundTripsAllColumns(t *testing.T) {
65 reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
66 if err != nil {
67 t.Fatal(err)
68 }
69 want := Route{
70 Host: "b.example.com", Slug: "b", Kind: "instance",
71 Socket: "", Addr: "127.0.0.1:8080",
72 Addrs: "10.0.0.1:3000,10.0.0.2:3000",
73 Health: `{"path":"/up","status":200}`,
74 Status: "ready", LastError: "prior fail",
75 DBPath: "/data/b.db", Version: "abc1234", PrevVersion: "def5678",
76 App: "kass", Pool: "shared", Region: "ie", Channel: "stable",
77 Environment: "demo", Caps: "host-role,port:25",
78 Hibernate: true, Canary: true, Replication: "batched",
79 NextDue: 4200, Backing: BackingUnit, Unit: "console.service",
80 Steering: "latency",
81 Replicas: "ie,uk,us",
82 Gate: "$2a$10$distinct",
83 CreatedAt: 1700000000,
84 GoodVersion: "good789", GoodDigest: "sha256:good", GoodDemand: "swap-demand",
85 ServingVersion: "serve012",
86 UpdateSettings: `{"updates":"manual","channel":"stable"}`,
87 BuildState: `{"known":true,"state":{"schema":1,"last_executed":"bs345"}}`,
88 SeedSource: `{"id":"sd_aaaaaaaaaaaaaaaaaaaaaaaaaa","kind":"replica","state":"ready"}`,
89 ImportGate: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
90 }
91 if err := reg.Upsert(want); err != nil {
92 t.Fatal(err)
93 }
94 got, err := reg.Get("b.example.com")
95 if err != nil {
96 t.Fatal(err)
97 }
98 if got == nil {
99 t.Fatal("Get returned nil for an upserted route")
100 }
101 if *got != want {
102 t.Fatalf("round trip mismatch:\n got %+v\n want %+v", *got, want)
103 }
104
105 // The replica mark and the replica set are the #162 pair, and Upsert's
106 // invariant blanks the SET on a row that IS a replica (a replica of a
107 // replica is not a shape the platform carries) — so the mark needs its
108 // own round trip, on a row whose set is empty.
109 wantReplica := Route{
110 Host: "r.example.com", Slug: "r", Kind: "instance",
111 Addr: "127.0.0.1:8081", Status: "ready",
112 App: "kass", Pool: "shared", Region: "uk", Channel: "stable",
113 Replica: true, CreatedAt: 1700000001,
114 }
115 if err := reg.Upsert(wantReplica); err != nil {
116 t.Fatal(err)
117 }
118 gotReplica, err := reg.Get("r.example.com")
119 if err != nil {
120 t.Fatal(err)
121 }
122 if gotReplica == nil {
123 t.Fatal("Get returned nil for the replica row")
124 }
125 if *gotReplica != wantReplica {
126 t.Fatalf("replica row round trip mismatch:\n got %+v\n want %+v", *gotReplica, wantReplica)
127 }
128}
129
130// An addrs or health change must move the edge's Fingerprint — the edge only
131// reloads on a fingerprint move, so a column it can't see means serving a
132// stale upstream set (or a stale/absent probe) forever.
133func TestFingerprintSeesAddrsAndHealthChanges(t *testing.T) {
134 reg := openTestRegistry(t)
135 if err := reg.Upsert(Route{Host: "m.example.com", Kind: "service", Addrs: "10.0.0.1:3000,10.0.0.2:3000", Status: "ready"}); err != nil {
136 t.Fatal(err)
137 }
138 fp1, err := reg.Fingerprint()
139 if err != nil {
140 t.Fatal(err)
141 }
142 if err := reg.SetUpstreams("m.example.com", "", "10.0.0.1:3000,10.0.0.3:3000", ""); err != nil {
143 t.Fatal(err)
144 }
145 fp2, err := reg.Fingerprint()
146 if err != nil {
147 t.Fatal(err)
148 }
149 if fp1 == fp2 {
150 t.Fatal("addrs change invisible to Fingerprint — edge would serve a stale upstream set forever")
151 }
152 if err := reg.SetUpstreams("m.example.com", "", "10.0.0.1:3000,10.0.0.3:3000", `{"path":"/up","status":200}`); err != nil {
153 t.Fatal(err)
154 }
155 fp3, err := reg.Fingerprint()
156 if err != nil {
157 t.Fatal(err)
158 }
159 if fp2 == fp3 {
160 t.Fatal("health change invisible to Fingerprint")
161 }
162 // The fingerprint moves on ANY column change, so the two moves above don't
163 // prove SetUpstreams wrote the right value into the right column — an
164 // addrs<->health argument swap inside SetUpstreams would move the
165 // fingerprint too. Check the values landed where asked.
166 got, err := reg.Get("m.example.com")
167 if err != nil {
168 t.Fatal(err)
169 }
170 if got.Addrs != "10.0.0.1:3000,10.0.0.3:3000" {
171 t.Fatalf("Addrs = %q, want the second SetUpstreams' addrs arg", got.Addrs)
172 }
173 if got.Health != `{"path":"/up","status":200}` {
174 t.Fatalf("Health = %q, want the second SetUpstreams' health arg", got.Health)
175 }
176}
177
178// AddrList must fold the legacy single Addr into a one-element list when
179// Addrs is empty, split Addrs on the comma when it isn't, and return nil
180// (not an empty slice) when the route carries no upstream at all.
181func TestAddrListFoldsLegacyAddr(t *testing.T) {
182 if got := (Route{Addr: "a:1"}).AddrList(); len(got) != 1 || got[0] != "a:1" {
183 t.Fatalf("fold = %v", got)
184 }
185 if got := (Route{Addrs: "a:1,b:2"}).AddrList(); len(got) != 2 || got[1] != "b:2" {
186 t.Fatalf("split = %v", got)
187 }
188 if got := (Route{}).AddrList(); got != nil {
189 t.Fatalf("empty = %v", got)
190 }
191}
192
193// TestSetUpstreamsRetiresLegacyAddr: the setter owns ALL THREE upstream
194// columns, in both directions. A route migrating from the legacy single addr
195// to a set must come out of the write with addr EMPTY — AddrList folds addr
196// in whenever addrs is empty, so an addr left standing is a decommissioned
197// upstream lying in wait for the next time the set is cleared, and the
198// provision pass's shape comparison would call the row permanently divergent
199// in the meantime.
200func TestSetUpstreamsRetiresLegacyAddr(t *testing.T) {
201 reg := openTestRegistry(t)
202 if err := reg.Upsert(Route{Host: "m.example.com", Kind: "service", Addr: "10.0.0.9:9443", Status: "ready"}); err != nil {
203 t.Fatal(err)
204 }
205 if err := reg.SetUpstreams("m.example.com", "", "10.0.0.1:3000,10.0.0.2:3000", `{"path":"/up"}`); err != nil {
206 t.Fatal(err)
207 }
208 got, err := reg.Get("m.example.com")
209 if err != nil {
210 t.Fatal(err)
211 }
212 if got.Addr != "" {
213 t.Errorf("Addr = %q after migrating to an addrs set, want it retired", got.Addr)
214 }
215 if list := got.AddrList(); len(list) != 2 || list[0] != "10.0.0.1:3000" {
216 t.Errorf("AddrList = %v, want exactly the new set", list)
217 }
218
219 // And back the other way: a route returning to a single upstream must not
220 // keep the set beside it.
221 if err := reg.SetUpstreams("m.example.com", "10.0.0.9:9443", "", ""); err != nil {
222 t.Fatal(err)
223 }
224 if got, err = reg.Get("m.example.com"); err != nil {
225 t.Fatal(err)
226 }
227 if got.Addrs != "" || got.Health != "" {
228 t.Errorf("Addrs/Health = %q/%q, want both retired", got.Addrs, got.Health)
229 }
230 if list := got.AddrList(); len(list) != 1 || list[0] != "10.0.0.9:9443" {
231 t.Errorf("AddrList = %v, want the single upstream", list)
232 }
233}
234
235func TestSetUpstreamsErrorsOnUnmatchedHost(t *testing.T) {
236 reg := openTestRegistry(t)
237 if err := reg.SetUpstreams("nope.example.com", "", "a:1", ""); err == nil {
238 t.Fatal("SetUpstreams on an unmatched host: want error, got nil")
239 }
240}
241
242// TestSetSteeringRoundTrips: the setter writes exactly the value given and
243// nothing else — a single-column UPDATE, unlike SetUpstreams' three.
244func TestSetSteeringRoundTrips(t *testing.T) {
245 reg := openTestRegistry(t)
246 if err := reg.Upsert(Route{Host: "m.example.com", Kind: "instance", Status: "ready"}); err != nil {
247 t.Fatal(err)
248 }
249 if err := reg.SetSteering("m.example.com", "latency"); err != nil {
250 t.Fatal(err)
251 }
252 got, err := reg.Get("m.example.com")
253 if err != nil {
254 t.Fatal(err)
255 }
256 if got.Steering != "latency" {
257 t.Fatalf("Steering = %q, want latency", got.Steering)
258 }
259 // And clearing: "" is a legitimate write, the column's only "off" value.
260 if err := reg.SetSteering("m.example.com", ""); err != nil {
261 t.Fatal(err)
262 }
263 if got, err = reg.Get("m.example.com"); err != nil {
264 t.Fatal(err)
265 }
266 if got.Steering != "" {
267 t.Fatalf("Steering = %q after clearing, want empty", got.Steering)
268 }
269}
270
271func TestSetSteeringErrorsOnUnmatchedHost(t *testing.T) {
272 reg := openTestRegistry(t)
273 if err := reg.SetSteering("nope.example.com", "latency"); err == nil {
274 t.Fatal("SetSteering on an unmatched host: want error, got nil")
275 }
276}
277
278// TestFingerprintIgnoresSteering is the re-ruled pin (plan amendment
279// b485ec5, review finding 3): steering is deliberately OUT of Fingerprint,
280// the same idiom as caps/unit (Route.Steering's own doc comment) — it
281// changes what DNS answers, not what the edge serves, and the converger
282// that acts on it is a host housekeeper duty reading the registry directly,
283// never gated on the edge's reload poll. This task's own first pass hashed
284// it here; that was wrong, and this test is what would have caught it.
285func TestFingerprintIgnoresSteering(t *testing.T) {
286 reg := openTestRegistry(t)
287 if err := reg.Upsert(Route{Host: "m.example.com", Kind: "instance", Status: "ready"}); err != nil {
288 t.Fatal(err)
289 }
290 before, err := reg.Fingerprint()
291 if err != nil {
292 t.Fatal(err)
293 }
294 if err := reg.SetSteering("m.example.com", "latency"); err != nil {
295 t.Fatal(err)
296 }
297 after, err := reg.Fingerprint()
298 if err != nil {
299 t.Fatal(err)
300 }
301 if after != before {
302 t.Fatalf("Fingerprint moved on a steering change (%s -> %s) — the edge would reload for a change that does not touch what it serves", before, after)
303 }
304}
305
306// TestSnapshotFingerprintSeesSteeringChange is UNCHANGED by the re-ruling:
307// SnapshotFingerprint hashes routeCols generically (its own doc comment: "a
308// column added to routeCols is covered here automatically"), and the
309// restore surface must reproduce steering even though the reload surface
310// (Fingerprint, above) deliberately ignores it — the two answer different
311// questions, per SnapshotFingerprint's own doc comment.
312func TestSnapshotFingerprintSeesSteeringChange(t *testing.T) {
313 reg := openTestRegistry(t)
314 if err := reg.Upsert(Route{Host: "m.example.com", Kind: "instance", Status: "ready"}); err != nil {
315 t.Fatal(err)
316 }
317 fp1, err := reg.SnapshotFingerprint()
318 if err != nil {
319 t.Fatal(err)
320 }
321 if err := reg.SetSteering("m.example.com", "latency"); err != nil {
322 t.Fatal(err)
323 }
324 fp2, err := reg.SnapshotFingerprint()
325 if err != nil {
326 t.Fatal(err)
327 }
328 if fp1 == fp2 {
329 t.Fatal("steering change invisible to SnapshotFingerprint — a cold-standby restore would silently drop it")
330 }
331}
332
333func TestGetMissingIsNilNil(t *testing.T) {
334 reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
335 if err != nil {
336 t.Fatal(err)
337 }
338 got, err := reg.Get("nope.example.com")
339 if err != nil || got != nil {
340 t.Fatalf("Get(missing) = %v, %v; want nil, nil", got, err)
341 }
342}
343
344func TestDueInstances(t *testing.T) {
345 reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
346 if err != nil {
347 t.Fatal(err)
348 }
349 for _, r := range []Route{
350 {Host: "due.example.com", Kind: "instance", NextDue: 100},
351 {Host: "future.example.com", Kind: "instance", NextDue: 900},
352 {Host: "never.example.com", Kind: "instance", NextDue: 0},
353 } {
354 if err := reg.Upsert(r); err != nil {
355 t.Fatal(err)
356 }
357 }
358 due, err := reg.DueInstances(500)
359 if err != nil {
360 t.Fatal(err)
361 }
362 if len(due) != 1 || due[0].Host != "due.example.com" {
363 t.Fatalf("DueInstances(500) = %+v; want exactly due.example.com", due)
364 }
365}
366
367func TestFingerprintChangesOnWrite(t *testing.T) {
368 reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
369 if err != nil {
370 t.Fatal(err)
371 }
372 before, err := reg.Fingerprint()
373 if err != nil {
374 t.Fatal(err)
375 }
376 if err := reg.Upsert(Route{Host: "c.example.com", Kind: "instance", Addr: "127.0.0.1:9"}); 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 write: %q", after)
385 }
386 // A cross-handle write must change the fingerprint read here too (fresh
387 // connection semantics — same quirk as List).
388 other, err := Open(reg.Path())
389 if err != nil {
390 t.Fatal(err)
391 }
392 if err := other.SetHibernate("c.example.com", true); err != nil {
393 t.Fatal(err)
394 }
395 final, err := reg.Fingerprint()
396 if err != nil {
397 t.Fatal(err)
398 }
399 if final == after {
400 t.Fatal("fingerprint blind to a cross-handle column write")
401 }
402 // Backing flips must reload the edge too: a woken host's backend choice
403 // (exec child vs carlos-app@ unit) rides the route the reload captured.
404 rt, err := reg.Get("c.example.com")
405 if err != nil {
406 t.Fatal(err)
407 }
408 rt.Backing = BackingUnit
409 if err := reg.Upsert(*rt); err != nil {
410 t.Fatal(err)
411 }
412 withBacking, err := reg.Fingerprint()
413 if err != nil {
414 t.Fatal(err)
415 }
416 if withBacking == final {
417 t.Fatal("fingerprint blind to a backing flip")
418 }
419 // Tunnel bindings must reload the edge too: this poll is the ONLY thing
420 // that notices a `carlos route -tunnel` written by another process, and a
421 // route the edge never reloads stays pointed at its old backend.
422 if err := reg.SetTunnel("c.example.com", "pi-box"); err != nil {
423 t.Fatal(err)
424 }
425 withTunnel, err := reg.Fingerprint()
426 if err != nil {
427 t.Fatal(err)
428 }
429 if withTunnel == withBacking {
430 t.Fatal("fingerprint blind to a tunnel binding")
431 }
432 // db_path rewrites must reload the edge too: a re-`carlos add` that only
433 // fixes -db otherwise leaves the running activator waking the host with
434 // the stale cached path forever — the exact vitogo incident (issue #69):
435 // `add` reported success, the row was updated, and wakes still died on
436 // the old path minutes later because this poll never moved.
437 rt, err = reg.Get("c.example.com")
438 if err != nil {
439 t.Fatal(err)
440 }
441 rt.DBPath = "/data/somewhere-else.db"
442 if err := reg.Upsert(*rt); err != nil {
443 t.Fatal(err)
444 }
445 withDBPath, err := reg.Fingerprint()
446 if err != nil {
447 t.Fatal(err)
448 }
449 if withDBPath == withTunnel {
450 t.Fatal("fingerprint blind to a db_path rewrite")
451 }
452}
453
454func TestSetVersionsAndHibernateAreNarrow(t *testing.T) {
455 reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
456 if err != nil {
457 t.Fatal(err)
458 }
459 if err := reg.Upsert(Route{Host: "d.example.com", Kind: "instance", Slug: "d", Channel: "stable"}); err != nil {
460 t.Fatal(err)
461 }
462 if err := reg.SetVersions("d.example.com", "v2", "v1"); err != nil {
463 t.Fatal(err)
464 }
465 if err := reg.SetHibernate("d.example.com", true); err != nil {
466 t.Fatal(err)
467 }
468 if err := reg.SetNextDue("d.example.com", 777); err != nil {
469 t.Fatal(err)
470 }
471 got, err := reg.Get("d.example.com")
472 if err != nil {
473 t.Fatal(err)
474 }
475 if got.Version != "v2" || got.PrevVersion != "v1" || !got.Hibernate || got.NextDue != 777 {
476 t.Fatalf("narrow updates lost: %+v", got)
477 }
478 if got.Slug != "d" || got.Channel != "stable" {
479 t.Fatalf("narrow update clobbered unrelated columns: %+v", got)
480 }
481}
482
483// Backing must survive an Upsert/List round trip — the #14 incident was
484// a route whose backing stamp went missing, and everything that branches
485// on rt.Backing (activator dispatch, config sync scope) reasons from it.
486func TestUpsertListRoundTripsBacking(t *testing.T) {
487 r := openTestRegistry(t)
488 if err := r.Upsert(Route{Host: "u.example.com", Kind: "instance", App: "a", Backing: BackingUnit}); err != nil {
489 t.Fatalf("Upsert: %v", err)
490 }
491 routes, err := r.List()
492 if err != nil {
493 t.Fatalf("List: %v", err)
494 }
495 if len(routes) != 1 || routes[0].Backing != BackingUnit {
496 t.Fatalf("routes = %+v, want one route with Backing=unit", routes)
497 }
498}
499
500// openTestRegistry opens a fresh registry under a per-test temp dir — the
501// shared open helper for tests that don't care about the path.
502func openTestRegistry(t *testing.T) *Registry {
503 t.Helper()
504 reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
505 if err != nil {
506 t.Fatal(err)
507 }
508 return reg
509}
510
511// The durability property this whole multi-account spec exists for: the id
512// is minted OUTSIDE the registry (by the console, into the bucket), so a
513// registry rebuilt from scratch re-materializes the SAME id — and therefore
514// the same sqid, and therefore the same platform domains. The old
515// AUTOINCREMENT+EnsureAccount shape failed exactly here: ids were assigned in
516// bucket-listing order, so a rebuild silently re-homed every platform domain.
517func TestAccountsMaterializeIdenticallyAfterRebuild(t *testing.T) {
518 dir := t.TempDir()
519 r1, err := Open(filepath.Join(dir, "registry.db"))
520 if err != nil {
521 t.Fatal(err)
522 }
523 if err := r1.PutAccount(793481920157, "kqmv", "Kass"); err != nil {
524 t.Fatal(err)
525 }
526 r1.Close()
527 // Rebuild from scratch — the id comes from the bucket, so it must survive.
528 os.Remove(filepath.Join(dir, "registry.db"))
529 r2, err := Open(filepath.Join(dir, "registry.db"))
530 if err != nil {
531 t.Fatal(err)
532 }
533 defer r2.Close()
534 if err := r2.PutAccount(793481920157, "kqmv", "Kass"); err != nil {
535 t.Fatal(err)
536 }
537 id, name, err := r2.AccountBySqid("kqmv")
538 if err != nil || id != 793481920157 || name != "Kass" {
539 t.Fatalf("rebuild lost identity: id=%d name=%q err=%v", id, name, err)
540 }
541}
542
543// The ops account is the migration's own row — id 1, sqid "bab", always, on
544// every deployment — so platform-domain sqids for CLI-added apps are stable
545// without any console ever existing.
546func TestOpsAccountIsAlwaysOne(t *testing.T) {
547 reg := openTestRegistry(t)
548 id, name, err := reg.AccountBySqid("bab")
549 if err != nil || id != 1 || name != "ops" {
550 t.Fatalf("ops: id=%d name=%q err=%v, want 1/ops", id, name, err)
551 }
552 // Absent is 0, nil — not an error: "no such account" is an answer.
553 if id, _, err := reg.AccountBySqid("nosuch"); err != nil || id != 0 {
554 t.Fatalf("AccountBySqid(absent) = %d, %v; want 0, nil", id, err)
555 }
556}
557
558// PutAccount is an upsert BY ID: re-materializing the same manifest is a
559// no-op, and a rename in the bucket lands without minting a second row.
560func TestPutAccountIdempotentAndRenames(t *testing.T) {
561 reg := openTestRegistry(t)
562 if err := reg.PutAccount(42, "cdef", "Old"); err != nil {
563 t.Fatal(err)
564 }
565 if err := reg.PutAccount(42, "cdef", "New"); err != nil {
566 t.Fatal(err)
567 }
568 _, name, _ := reg.AccountBySqid("cdef")
569 if name != "New" {
570 t.Errorf("rename not applied: %q", name)
571 }
572 accts, _ := reg.Accounts()
573 if len(accts) != 2 { // ops + this one, no duplicate row
574 t.Errorf("want ops + one, got %d: %+v", len(accts), accts)
575 }
576}
577
578// Account names are deliberately NOT unique (spec §1): identity is the sqid,
579// so there is nothing to squat. A leftover UNIQUE(name) from the pre-sqid
580// accounts table would turn two teams both calling their account "Blog" into
581// a materialization failure.
582func TestAccountNamesNeedNotBeUnique(t *testing.T) {
583 reg := openTestRegistry(t)
584 if err := reg.PutAccount(42, "cdef", "Blog"); err != nil {
585 t.Fatal(err)
586 }
587 if err := reg.PutAccount(43, "cdeg", "Blog"); err != nil {
588 t.Fatalf("two accounts named Blog must both materialize: %v", err)
589 }
590 if id, _, _ := reg.AccountBySqid("cdeg"); id != 43 {
591 t.Errorf("second Blog lost: id=%d", id)
592 }
593}
594
595// The accounts table is DROPPED and rebuilt on upgrade — the one deliberate
596// exception to additive-only migrations, because its legacy rows carry the
597// assigned ids this whole change exists to stop trusting, and its
598// UNIQUE(name) would refuse two accounts both called "Blog". A drop is where
599// data quietly dies, so this pins exactly what survives and what does not:
600// the ROUTES (the registry's actual data) are untouched, ops comes back as
601// id 1 / sqid bab, and re-opening is clean and idempotent.
602func TestAccountsRebuildKeepsRoutesAndRestoresOps(t *testing.T) {
603 path := filepath.Join(t.TempDir(), "registry.db")
604
605 // A registry in the pre-multi-account shape: the legacy accounts table
606 // (AUTOINCREMENT id, UNIQUE name) with rows, plus real route data.
607 first, err := Open(path)
608 if err != nil {
609 t.Fatal(err)
610 }
611 if _, err := first.sql.Exec(`DROP TABLE accounts`); err != nil {
612 t.Fatal(err)
613 }
614 if _, err := first.sql.Exec(`CREATE TABLE accounts (
615 id INTEGER PRIMARY KEY AUTOINCREMENT,
616 name TEXT UNIQUE NOT NULL
617);`); err != nil {
618 t.Fatal(err)
619 }
620 for _, name := range []string{"ops", "paul@keymail.dev"} {
621 if _, err := first.sql.Exec(`INSERT INTO accounts (name) VALUES (?)`, name); err != nil {
622 t.Fatal(err)
623 }
624 }
625 routes := []Route{
626 {Host: "kass.training", Kind: "instance", App: "kass", Channel: "stable",
627 Version: "v1", Addr: "127.0.0.1:9000", DBPath: "/data/kass.db", Hibernate: true},
628 {Host: "site.example.com", Kind: "static", App: "site", Channel: "canary/x", Status: "ready"},
629 }
630 for _, rt := range routes {
631 if err := first.Upsert(rt); err != nil {
632 t.Fatal(err)
633 }
634 }
635 first.Close()
636
637 // Two opens: the rebuild must be idempotent, so restarting twice neither
638 // rebuilds twice nor loses anything the second time.
639 for pass := 1; pass <= 2; pass++ {
640 r, err := Open(path)
641 if err != nil {
642 t.Fatalf("pass %d: open over the legacy shape: %v", pass, err)
643 }
644 id, name, err := r.AccountBySqid("bab")
645 if err != nil || id != 1 || name != "ops" {
646 t.Errorf("pass %d: ops = %d/%q (%v), want 1/ops", pass, id, name, err)
647 }
648 accts, err := r.Accounts()
649 if err != nil {
650 t.Fatal(err)
651 }
652 // The legacy assigned-id rows are deliberately NOT carried across:
653 // they are the bug. Everything but ops re-materializes from the
654 // bucket's own manifests on the next reconcile pass.
655 for _, a := range accts {
656 if a.Name == "paul@keymail.dev" {
657 t.Errorf("pass %d: a legacy assigned-id row survived: %+v", pass, a)
658 }
659 }
660 // The routes — the registry's actual data — are untouched.
661 got, err := r.List()
662 if err != nil {
663 t.Fatal(err)
664 }
665 if len(got) != len(routes) {
666 t.Fatalf("pass %d: %d routes survived, want %d", pass, len(got), len(routes))
667 }
668 byHost := map[string]Route{}
669 for _, rt := range got {
670 byHost[rt.Host] = rt
671 }
672 for _, want := range routes {
673 g, ok := byHost[want.Host]
674 if !ok {
675 t.Errorf("pass %d: route %s vanished", pass, want.Host)
676 continue
677 }
678 if g.App != want.App || g.Channel != want.Channel || g.Version != want.Version ||
679 g.Addr != want.Addr || g.DBPath != want.DBPath || g.Hibernate != want.Hibernate ||
680 g.Kind != want.Kind {
681 t.Errorf("pass %d: route %s lost fields: %+v, want %+v", pass, want.Host, g, want)
682 }
683 }
684 // A materialized account survives the SECOND open: only the legacy
685 // shape is ever dropped, and it is gone after pass 1.
686 if pass == 1 {
687 if err := r.PutAccount(793481920157, "kqmvexample", "Kass"); err != nil {
688 t.Fatal(err)
689 }
690 } else if id, _, _ := r.AccountBySqid("kqmvexample"); id != 793481920157 {
691 t.Errorf("pass 2 dropped an account materialized after the rebuild (id %d)", id)
692 }
693 r.Close()
694 }
695}
696
697func TestDomainColumnsRoundTrip(t *testing.T) {
698 reg := openTestRegistry(t)
699 r := Route{Host: "kass.abs.oncarlos.com", Kind: "instance", App: "kass",
700 Platform: true, DNSStatus: "pointing", DNSCheckedAt: 123, CertExpiresAt: 456, Account: "ops"}
701 if err := reg.Upsert(r); err != nil {
702 t.Fatal(err)
703 }
704 got, err := reg.Get(r.Host)
705 if err != nil {
706 t.Fatal(err)
707 }
708 if !got.Platform || got.DNSStatus != "pointing" || got.DNSCheckedAt != 123 ||
709 got.CertExpiresAt != 456 || got.Account != "ops" {
710 t.Errorf("round-trip lost fields: %+v", got)
711 }
712}
713
714// The domain-health stamps land every few minutes on every route, so they go
715// through narrow UPDATEs (the SetVersions rationale: a read-modify-write
716// Upsert racing the activator's SetNextDue or a deploy's SetVersions would
717// clobber them).
718func TestDomainSettersAreNarrow(t *testing.T) {
719 reg := openTestRegistry(t)
720 if err := reg.Upsert(Route{Host: "kass.training", Kind: "instance", App: "kass", Channel: "stable"}); err != nil {
721 t.Fatal(err)
722 }
723 if err := reg.SetAccount("kass.training", "paul"); err != nil {
724 t.Fatal(err)
725 }
726 if err := reg.SetDNSStatus("kass.training", "elsewhere", 111); err != nil {
727 t.Fatal(err)
728 }
729 if err := reg.SetCertExpiresAt("kass.training", 222); err != nil {
730 t.Fatal(err)
731 }
732 got, err := reg.Get("kass.training")
733 if err != nil {
734 t.Fatal(err)
735 }
736 if got.Account != "paul" || got.DNSStatus != "elsewhere" || got.DNSCheckedAt != 111 || got.CertExpiresAt != 222 {
737 t.Fatalf("narrow domain updates lost: %+v", got)
738 }
739 if got.App != "kass" || got.Channel != "stable" {
740 t.Fatalf("narrow domain update clobbered unrelated columns: %+v", got)
741 }
742}
743
744func TestStatusNamedRoundTrip(t *testing.T) {
745 reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
746 if err != nil {
747 t.Fatal(err)
748 }
749 r := Route{Host: "kass.training", Kind: "instance", App: "kass", StatusNamed: true}
750 if err := reg.Upsert(r); err != nil {
751 t.Fatal(err)
752 }
753 got, err := reg.Get("kass.training")
754 if err != nil {
755 t.Fatal(err)
756 }
757 if got == nil {
758 t.Fatal("Get returned nil for an upserted route")
759 }
760 if !got.StatusNamed {
761 t.Error("status_named did not round-trip")
762 }
763}
764
765// SetChannel is the narrow UPDATE `carlos route` writes through: same
766// rationale as SetAccount (a read-modify-write Upsert racing a deploy's
767// SetVersions or the activator's SetNextDue would clobber those columns), and
768// it must fail loudly on an unknown host so the CLI can name it rather than
769// silently doing nothing.
770func TestSetChannelNarrow(t *testing.T) {
771 reg := openTestRegistry(t)
772 if err := reg.Upsert(Route{Host: "keymail.dev", Kind: "instance", App: "keymail",
773 Channel: "canary/keymail", Version: "5457379"}); err != nil {
774 t.Fatal(err)
775 }
776 if err := reg.SetChannel("keymail.dev", "stable"); err != nil {
777 t.Fatal(err)
778 }
779 got, err := reg.Get("keymail.dev")
780 if err != nil {
781 t.Fatal(err)
782 }
783 if got.Channel != "stable" {
784 t.Fatalf("channel = %q, want stable", got.Channel)
785 }
786 if got.App != "keymail" || got.Version != "5457379" {
787 t.Fatalf("SetChannel clobbered unrelated columns: %+v", got)
788 }
789}
790
791func TestSetChannelUnknownHostErrors(t *testing.T) {
792 reg := openTestRegistry(t)
793 if err := reg.SetChannel("nope.example.com", "stable"); err == nil {
794 t.Fatal("SetChannel on an unknown host: got nil error, want one")
795 }
796}
797
798// SetChannels is `carlos route -app`'s write path: one transaction across
799// every matched route, so a bad host mid-batch (or a crash mid-write) can
800// never leave an app split across two channels — the CRITICAL 1 finding in
801// the route-verb review. A check-then-write-per-route loop had already
802// proven this fails: with a caller doing the checking up front and this
803// doing the writing, either every row moves or none do.
804func TestSetChannelsAllOrNothing(t *testing.T) {
805 reg := openTestRegistry(t)
806 for _, h := range []string{"a.example.com", "b.example.com"} {
807 if err := reg.Upsert(Route{Host: h, Kind: "instance", App: "demo", Channel: "canary/x"}); err != nil {
808 t.Fatal(err)
809 }
810 }
811 err := reg.SetChannels([]string{"a.example.com", "nosuch.example.com", "b.example.com"}, "stable")
812 if err == nil {
813 t.Fatal("SetChannels with an unknown host in the batch: got nil error, want one")
814 }
815 for _, h := range []string{"a.example.com", "b.example.com"} {
816 got, gerr := reg.Get(h)
817 if gerr != nil {
818 t.Fatal(gerr)
819 }
820 if got.Channel != "canary/x" {
821 t.Fatalf("%s channel = %q after a failed batch, want untouched canary/x — the batch was not atomic", h, got.Channel)
822 }
823 }
824}
825
826func TestSetChannelsUpdatesEveryHost(t *testing.T) {
827 reg := openTestRegistry(t)
828 for _, h := range []string{"a.example.com", "b.example.com", "c.example.com"} {
829 if err := reg.Upsert(Route{Host: h, Kind: "instance", App: "demo", Channel: "canary/x"}); err != nil {
830 t.Fatal(err)
831 }
832 }
833 if err := reg.SetChannels([]string{"a.example.com", "b.example.com", "c.example.com"}, "stable"); err != nil {
834 t.Fatal(err)
835 }
836 for _, h := range []string{"a.example.com", "b.example.com", "c.example.com"} {
837 got, err := reg.Get(h)
838 if err != nil {
839 t.Fatal(err)
840 }
841 if got.Channel != "stable" {
842 t.Fatalf("%s channel = %q, want stable", h, got.Channel)
843 }
844 }
845}
846
847// A no-op call must not open a transaction it never uses (harmless either
848// way, but it should not error).
849func TestSetChannelsEmptyIsNoop(t *testing.T) {
850 reg := openTestRegistry(t)
851 if err := reg.SetChannels(nil, "stable"); err != nil {
852 t.Fatalf("SetChannels(nil, ...): %v, want no error", err)
853 }
854}
855
856// SetEnvironment mirrors SetChannel exactly: a narrow UPDATE (so a
857// read-modify-write Upsert racing SetVersions/SetNextDue/SetChannel can
858// never clobber this column or be clobbered by it), erroring on an unknown
859// host so `carlos route -environment` can name it.
860func TestSetEnvironmentNarrow(t *testing.T) {
861 reg := openTestRegistry(t)
862 if err := reg.Upsert(Route{Host: "demo.example.com", Kind: "instance", App: "tito",
863 Channel: "stable", Version: "v1"}); err != nil {
864 t.Fatal(err)
865 }
866 if err := reg.SetEnvironment("demo.example.com", "demo"); err != nil {
867 t.Fatal(err)
868 }
869 got, err := reg.Get("demo.example.com")
870 if err != nil {
871 t.Fatal(err)
872 }
873 if got.Environment != "demo" {
874 t.Fatalf("environment = %q, want demo", got.Environment)
875 }
876 if got.App != "tito" || got.Version != "v1" || got.Channel != "stable" {
877 t.Fatalf("SetEnvironment clobbered unrelated columns: %+v", got)
878 }
879}
880
881func TestSetEnvironmentUnknownHostErrors(t *testing.T) {
882 reg := openTestRegistry(t)
883 if err := reg.SetEnvironment("nope.example.com", "demo"); err == nil {
884 t.Fatal("SetEnvironment on an unknown host: got nil error, want one")
885 }
886}
887
888// SetEnvironment("", ...) clears back to default — a plain UPDATE to "" is
889// exactly that; no separate "clear" verb is needed at the registry layer,
890// only at the CLI layer (`-environment -`).
891func TestSetEnvironmentEmptyClearsToDefault(t *testing.T) {
892 reg := openTestRegistry(t)
893 if err := reg.Upsert(Route{Host: "demo.example.com", Kind: "instance", App: "tito"}); err != nil {
894 t.Fatal(err)
895 }
896 if err := reg.SetEnvironment("demo.example.com", "demo"); err != nil {
897 t.Fatal(err)
898 }
899 if err := reg.SetEnvironment("demo.example.com", ""); err != nil {
900 t.Fatal(err)
901 }
902 got, err := reg.Get("demo.example.com")
903 if err != nil {
904 t.Fatal(err)
905 }
906 if got.Environment != "" {
907 t.Fatalf("environment = %q, want empty (default) after clearing", got.Environment)
908 }
909}
910
911// SetEnvironments is the bulk sibling of SetEnvironment — one transaction
912// across every matched route, same all-or-nothing contract as SetChannels.
913func TestSetEnvironmentsAllOrNothing(t *testing.T) {
914 reg := openTestRegistry(t)
915 for _, h := range []string{"a.example.com", "b.example.com"} {
916 if err := reg.Upsert(Route{Host: h, Kind: "instance", App: "tito"}); err != nil {
917 t.Fatal(err)
918 }
919 }
920 err := reg.SetEnvironments([]string{"a.example.com", "nosuch.example.com", "b.example.com"}, "demo")
921 if err == nil {
922 t.Fatal("SetEnvironments with an unknown host in the batch: got nil error, want one")
923 }
924 for _, h := range []string{"a.example.com", "b.example.com"} {
925 got, gerr := reg.Get(h)
926 if gerr != nil {
927 t.Fatal(gerr)
928 }
929 if got.Environment != "" {
930 t.Fatalf("%s environment = %q after a failed batch, want untouched \"\" — the batch was not atomic", h, got.Environment)
931 }
932 }
933}
934
935func TestSetEnvironmentsUpdatesEveryHost(t *testing.T) {
936 reg := openTestRegistry(t)
937 for _, h := range []string{"a.example.com", "b.example.com", "c.example.com"} {
938 if err := reg.Upsert(Route{Host: h, Kind: "instance", App: "tito"}); err != nil {
939 t.Fatal(err)
940 }
941 }
942 if err := reg.SetEnvironments([]string{"a.example.com", "b.example.com", "c.example.com"}, "demo"); err != nil {
943 t.Fatal(err)
944 }
945 for _, h := range []string{"a.example.com", "b.example.com", "c.example.com"} {
946 got, err := reg.Get(h)
947 if err != nil {
948 t.Fatal(err)
949 }
950 if got.Environment != "demo" {
951 t.Fatalf("%s environment = %q, want demo", h, got.Environment)
952 }
953 }
954}
955
956func TestSetEnvironmentsEmptyIsNoop(t *testing.T) {
957 reg := openTestRegistry(t)
958 if err := reg.SetEnvironments(nil, "demo"); err != nil {
959 t.Fatalf("SetEnvironments(nil, ...): %v, want no error", err)
960 }
961}
962
963// SetChannelsAndEnvironments is the combined write path `carlos route
964// -channel X -environment Y` uses: one transaction sets BOTH columns for
965// every host, so a channel-and-environment move can never leave a route
966// repointed on one axis but not the other — the same all-or-nothing
967// contract as SetChannels/SetEnvironments, but spanning two columns instead
968// of one.
969func TestSetChannelsAndEnvironmentsWritesBothColumnsAtomically(t *testing.T) {
970 reg := openTestRegistry(t)
971 for _, h := range []string{"a.example.com", "b.example.com"} {
972 if err := reg.Upsert(Route{Host: h, Kind: "instance", App: "tito", Channel: "canary/x"}); err != nil {
973 t.Fatal(err)
974 }
975 }
976 if err := reg.SetChannelsAndEnvironments([]string{"a.example.com", "b.example.com"}, "stable", "demo"); err != nil {
977 t.Fatal(err)
978 }
979 for _, h := range []string{"a.example.com", "b.example.com"} {
980 got, err := reg.Get(h)
981 if err != nil {
982 t.Fatal(err)
983 }
984 if got.Channel != "stable" || got.Environment != "demo" {
985 t.Fatalf("%s = channel %q environment %q, want stable/demo", h, got.Channel, got.Environment)
986 }
987 }
988}
989
990func TestSetChannelsAndEnvironmentsAllOrNothing(t *testing.T) {
991 reg := openTestRegistry(t)
992 if err := reg.Upsert(Route{Host: "a.example.com", Kind: "instance", App: "tito", Channel: "canary/x"}); err != nil {
993 t.Fatal(err)
994 }
995 err := reg.SetChannelsAndEnvironments([]string{"a.example.com", "nosuch.example.com"}, "stable", "demo")
996 if err == nil {
997 t.Fatal("SetChannelsAndEnvironments with an unknown host in the batch: got nil error, want one")
998 }
999 got, err := reg.Get("a.example.com")
1000 if err != nil {
1001 t.Fatal(err)
1002 }
1003 if got.Channel != "canary/x" || got.Environment != "" {
1004 t.Fatalf("a.example.com = channel %q environment %q after a failed batch, want both untouched", got.Channel, got.Environment)
1005 }
1006}
1007
1008// Provisioned + Seed round-trip through Upsert/Get, and SetSeed is the
1009// reconciler's narrow-update door (same contract as SetChannel: errors on an
1010// unmatched host so a vanished row is noticed, not silently no-opped).
1011func TestProvisionedSeedRoundTrip(t *testing.T) {
1012 r := openTestRegistry(t)
1013 if err := r.Upsert(Route{Host: "a.go.tito.io", Kind: "instance", Provisioned: true, Seed: `["seed"]`}); err != nil {
1014 t.Fatal(err)
1015 }
1016 got, err := r.Get("a.go.tito.io")
1017 if err != nil {
1018 t.Fatal(err)
1019 }
1020 if got == nil {
1021 t.Fatal("Get returned nil for an upserted route")
1022 }
1023 if !got.Provisioned || got.Seed != `["seed"]` {
1024 t.Fatalf("provisioned/seed did not round-trip: %+v", got)
1025 }
1026
1027 if err := r.SetSeed("a.go.tito.io", `["seed","-v"]`); err != nil {
1028 t.Fatal(err)
1029 }
1030 got, err = r.Get("a.go.tito.io")
1031 if err != nil {
1032 t.Fatal(err)
1033 }
1034 if got.Seed != `["seed","-v"]` {
1035 t.Fatalf("SetSeed did not update: %+v", got)
1036 }
1037 if !got.Provisioned {
1038 t.Fatalf("SetSeed clobbered an unrelated column: %+v", got)
1039 }
1040
1041 err = r.SetSeed("nope.example.com", `["x"]`)
1042 if err == nil {
1043 t.Fatal("SetSeed on an unknown host: got nil error, want one naming the host")
1044 }
1045 if !strings.Contains(err.Error(), "nope.example.com") {
1046 t.Fatalf("SetSeed unknown-host error = %q, want it to name the host", err.Error())
1047 }
1048}
1049
1050// Upsert must not let a caller that knows nothing about provisioning revoke
1051// the reconcile pass's removal authority. `carlos add` builds a full Route
1052// with provisioned=false and seed="" — run against a host an instance
1053// record already minted, a plain overwrite would strip the flag the pass
1054// removes by (leaving a route no bucket deletion can ever retire) and erase
1055// the first-boot argv. The zero value preserves, a real value wins, and
1056// clearing keeps exactly one door each: row deletion, and SetSeed.
1057func TestUpsertPreservesProvisionedAndSeed(t *testing.T) {
1058 r := openTestRegistry(t)
1059 const host = "acme.go.tito.io"
1060 if err := r.Upsert(Route{Host: host, Kind: "instance", App: "titogo",
1061 Provisioned: true, Seed: `["seed"]`}); err != nil {
1062 t.Fatal(err)
1063 }
1064 // The runAdd shape: every column set from flags, nothing about
1065 // provisioning in it at all.
1066 if err := r.Upsert(Route{Host: host, Kind: "instance", App: "titogo",
1067 Socket: "/run/carlos/" + host + ".sock", DBPath: "/data/" + host + ".db"}); err != nil {
1068 t.Fatal(err)
1069 }
1070 got, err := r.Get(host)
1071 if err != nil {
1072 t.Fatal(err)
1073 }
1074 if !got.Provisioned {
1075 t.Errorf("a re-add revoked the pass's removal authority: %+v", got)
1076 }
1077 if got.Seed != `["seed"]` {
1078 t.Errorf("a re-add erased the first-boot argv: seed = %q", got.Seed)
1079 }
1080 if got.Socket != "/run/carlos/"+host+".sock" {
1081 t.Errorf("the re-add's own columns did not land: %+v", got)
1082 }
1083
1084 // A non-zero incoming value still wins — this is preservation, not a
1085 // column that can never be written.
1086 if err := r.Upsert(Route{Host: host, Kind: "instance", App: "titogo",
1087 Provisioned: true, Seed: `["seed","-v"]`}); err != nil {
1088 t.Fatal(err)
1089 }
1090 if got, _ = r.Get(host); got.Seed != `["seed","-v"]` {
1091 t.Errorf("an explicit seed lost to the existing value: %q", got.Seed)
1092 }
1093
1094 // The narrow door still clears: the reconcile pass converging a
1095 // manifest whose seed was removed must be able to empty it.
1096 if err := r.SetSeed(host, ""); err != nil {
1097 t.Fatal(err)
1098 }
1099 if got, _ = r.Get(host); got.Seed != "" {
1100 t.Errorf("SetSeed(\"\") did not clear the seed: %q", got.Seed)
1101 }
1102 if !got.Provisioned {
1103 t.Errorf("SetSeed touched provisioned: %+v", got)
1104 }
1105
1106 // And provisioned's only clear is the row going away — which is what
1107 // the removal phase does.
1108 if err := r.Delete(host); err != nil {
1109 t.Fatal(err)
1110 }
1111 if err := r.Upsert(Route{Host: host, Kind: "instance", App: "titogo"}); err != nil {
1112 t.Fatal(err)
1113 }
1114 if got, _ = r.Get(host); got.Provisioned {
1115 t.Errorf("provisioned survived the row's deletion: %+v", got)
1116 }
1117}
1118
1119// Opening a DB created before this change (no provisioned/seed columns) must
1120// migrate cleanly — the ALTER list is additive, duplicate-column errors are
1121// swallowed, and pre-existing rows default provisioned=false seed="" rather
1122// than failing to open.
1123func TestProvisionedSeedMigratesFromPreChangeDB(t *testing.T) {
1124 path := filepath.Join(t.TempDir(), "registry.db")
1125 db, err := sql.Open("sqlite", path)
1126 if err != nil {
1127 t.Fatal(err)
1128 }
1129 if _, err := db.Exec(`
1130CREATE TABLE routes (
1131 host TEXT PRIMARY KEY,
1132 kind TEXT NOT NULL,
1133 socket TEXT NOT NULL DEFAULT '',
1134 status TEXT NOT NULL DEFAULT 'ready',
1135 last_error TEXT NOT NULL DEFAULT '',
1136 db_path TEXT NOT NULL DEFAULT '',
1137 version TEXT NOT NULL DEFAULT '',
1138 prev_version TEXT NOT NULL DEFAULT '',
1139 created_at INTEGER NOT NULL
1140);`); err != nil {
1141 t.Fatal(err)
1142 }
1143 if _, err := db.Exec(`INSERT INTO routes (host, kind, created_at) VALUES (?, ?, ?)`,
1144 "old.example.com", "instance", 1234); err != nil {
1145 t.Fatal(err)
1146 }
1147 if err := db.Close(); err != nil {
1148 t.Fatal(err)
1149 }
1150
1151 r, err := Open(path)
1152 if err != nil {
1153 t.Fatalf("Open over a pre-change DB: %v", err)
1154 }
1155 defer r.Close()
1156 got, err := r.Get("old.example.com")
1157 if err != nil {
1158 t.Fatal(err)
1159 }
1160 if got == nil {
1161 t.Fatal("pre-existing row vanished on migrate")
1162 }
1163 if got.Provisioned || got.Seed != "" {
1164 t.Fatalf("pre-existing row should default provisioned=false seed=\"\": %+v", got)
1165 }
1166
1167 // Reopening again (the ALTER already applied) must stay idempotent.
1168 r2, err := Open(path)
1169 if err != nil {
1170 t.Fatalf("second Open: %v", err)
1171 }
1172 defer r2.Close()
1173 if got2, err := r2.Get("old.example.com"); err != nil || got2 == nil {
1174 t.Fatalf("second Open lost the row: %+v, %v", got2, err)
1175 }
1176}
1177
1178// Review IMPORTANT 3: Upsert must not clear an existing row's Environment.
1179// `carlos add` (and any other full-row Upsert caller) does not know about
1180// environment bindings and passes the zero value, so a re-run on a bound host
1181// used to silently unbind it — and the next config sync then re-rendered the
1182// default bundle over that route's env file and restarted the unit.
1183func TestUpsertPreservesAnExistingEnvironmentBinding(t *testing.T) {
1184 reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
1185 if err != nil {
1186 t.Fatal(err)
1187 }
1188 base := Route{Host: "demo.example.com", Kind: "instance", App: "tito", Status: "ready"}
1189 if err := reg.Upsert(base); err != nil {
1190 t.Fatal(err)
1191 }
1192 if err := reg.SetEnvironment("demo.example.com", "demo"); err != nil {
1193 t.Fatal(err)
1194 }
1195 // The re-add: same row, no Environment in hand.
1196 if err := reg.Upsert(base); err != nil {
1197 t.Fatal(err)
1198 }
1199 got, err := reg.Get("demo.example.com")
1200 if err != nil {
1201 t.Fatal(err)
1202 }
1203 if got.Environment != "demo" {
1204 t.Fatalf("Environment = %q after a re-Upsert, want the binding preserved", got.Environment)
1205 }
1206 // An explicit non-empty value still wins — preservation is a floor, not a
1207 // freeze, so a caller that genuinely knows the binding can still set it.
1208 base.Environment = "staging"
1209 if err := reg.Upsert(base); err != nil {
1210 t.Fatal(err)
1211 }
1212 got, err = reg.Get("demo.example.com")
1213 if err != nil {
1214 t.Fatal(err)
1215 }
1216 if got.Environment != "staging" {
1217 t.Fatalf("Environment = %q, want an explicit Upsert value to win", got.Environment)
1218 }
1219 // Clearing stays possible, through the narrow setter the clear verb uses
1220 // (`carlos route -environment -`), which is the only intentional clear.
1221 if err := reg.SetEnvironment("demo.example.com", ""); err != nil {
1222 t.Fatal(err)
1223 }
1224 got, err = reg.Get("demo.example.com")
1225 if err != nil {
1226 t.Fatal(err)
1227 }
1228 if got.Environment != "" {
1229 t.Fatalf("Environment = %q after an explicit clear, want it cleared", got.Environment)
1230 }
1231}
1232
1233// A registry file written BEFORE the caps column existed must still open, and
1234// its rows must read as "nothing granted" — the additive-migration contract
1235// applied to the column that decides whether a unit gets box authority. The
1236// pre-change file is simulated by dropping the column from a fresh registry
1237// and writing a row through raw SQL, which is exactly the shape an older
1238// binary left behind.
1239func TestPreCapsRegistryOpensAndReadsUngranted(t *testing.T) {
1240 path := filepath.Join(t.TempDir(), "registry.db")
1241 reg, err := Open(path)
1242 if err != nil {
1243 t.Fatal(err)
1244 }
1245 if err := reg.Close(); err != nil {
1246 t.Fatal(err)
1247 }
1248
1249 old, err := sql.Open("sqlite", path)
1250 if err != nil {
1251 t.Fatal(err)
1252 }
1253 if _, err := old.Exec(`ALTER TABLE routes DROP COLUMN caps`); err != nil {
1254 t.Fatalf("simulate a pre-caps registry: %v", err)
1255 }
1256 if _, err := old.Exec(`INSERT INTO routes (host, kind, status, created_at)
1257VALUES ('legacy.example.com', 'instance', 'ready', 1700000000)`); err != nil {
1258 t.Fatal(err)
1259 }
1260 if err := old.Close(); err != nil {
1261 t.Fatal(err)
1262 }
1263
1264 reg, err = Open(path)
1265 if err != nil {
1266 t.Fatalf("a pre-caps registry must still open: %v", err)
1267 }
1268 got, err := reg.Get("legacy.example.com")
1269 if err != nil {
1270 t.Fatal(err)
1271 }
1272 if got == nil {
1273 t.Fatal("the pre-existing row vanished across the migration")
1274 }
1275 if got.Caps != "" {
1276 t.Fatalf("Caps = %q on a row written before the column existed, want %q — a migrated row must mean 'nothing granted'", got.Caps, "")
1277 }
1278 if got.Host != "legacy.example.com" || got.Kind != "instance" || got.CreatedAt != 1700000000 {
1279 t.Fatalf("the migrated row shifted columns: %+v", *got)
1280 }
1281 // And the new column is writable on that same row.
1282 if err := reg.SetCaps("legacy.example.com", "", "port:25"); err != nil {
1283 t.Fatal(err)
1284 }
1285 got, err = reg.Get("legacy.example.com")
1286 if err != nil {
1287 t.Fatal(err)
1288 }
1289 if got.Caps != "port:25" {
1290 t.Fatalf("Caps = %q after SetCaps on a migrated row", got.Caps)
1291 }
1292}
1293
1294// SetCaps is narrow (it touches no other column) and refuses an unknown host
1295// rather than silently doing nothing — `carlos route -grant` is a hostname a
1296// human types, and a typo that reports success while granting nothing is the
1297// worst possible outcome for this particular column.
1298func TestSetCapsIsNarrowAndErrorsOnAnUnknownHost(t *testing.T) {
1299 reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
1300 if err != nil {
1301 t.Fatal(err)
1302 }
1303 base := Route{
1304 Host: "keymail.dev", Kind: "instance", App: "keymail", Backing: BackingUnit,
1305 Channel: "canary/keymail", Environment: "prod", Version: "abc1234",
1306 Status: "ready", NextDue: 99, DBPath: "/var/lib/carlos-app/keymail.dev/registry.db",
1307 }
1308 if err := reg.Upsert(base); err != nil {
1309 t.Fatal(err)
1310 }
1311 if err := reg.SetCaps("keymail.dev", "", "port:25"); err != nil {
1312 t.Fatal(err)
1313 }
1314 got, err := reg.Get("keymail.dev")
1315 if err != nil {
1316 t.Fatal(err)
1317 }
1318 want := base
1319 want.Caps = "port:25"
1320 want.CreatedAt = got.CreatedAt
1321 if *got != want {
1322 t.Fatalf("SetCaps was not narrow:\n got %+v\n want %+v", *got, want)
1323 }
1324 if err := reg.SetCaps("nope.example.com", "", "port:25"); err == nil {
1325 t.Fatal("SetCaps on an unknown host must error, not silently write nothing")
1326 }
1327 // The compare-and-swap: a write built on a stale read refuses, naming the
1328 // current value, instead of merging over a concurrent grant/revoke.
1329 if err := reg.SetCaps("keymail.dev", "stale-read", "host-role"); err == nil {
1330 t.Fatal("SetCaps with a stale prior value must refuse, not overwrite")
1331 } else if !strings.Contains(err.Error(), "changed underneath") || !strings.Contains(err.Error(), "port:25") {
1332 t.Fatalf("a lost race should name the condition and the current value, got: %v", err)
1333 }
1334}
1335
1336// SetDBPath is the converge pass's narrow stamp (it teaches ops.FleetDBs
1337// where a platform-replicated app's databases live), with the same
1338// unknown-host contract.
1339func TestSetDBPathIsNarrowAndErrorsOnAnUnknownHost(t *testing.T) {
1340 reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
1341 if err != nil {
1342 t.Fatal(err)
1343 }
1344 base := Route{
1345 Host: "console.example.com", Kind: "instance", App: "console",
1346 Backing: BackingUnit, Channel: "canary/console", Status: "ready",
1347 }
1348 if err := reg.Upsert(base); err != nil {
1349 t.Fatal(err)
1350 }
1351 if err := reg.SetCaps("console.example.com", "", "host-role"); err != nil {
1352 t.Fatal(err)
1353 }
1354 if err := reg.SetDBPath("console.example.com", "", "/var/lib/carlos-app/console.example.com/console.db"); err != nil {
1355 t.Fatal(err)
1356 }
1357 got, err := reg.Get("console.example.com")
1358 if err != nil {
1359 t.Fatal(err)
1360 }
1361 want := base
1362 want.Caps = "host-role"
1363 want.DBPath = "/var/lib/carlos-app/console.example.com/console.db"
1364 want.CreatedAt = got.CreatedAt
1365 if *got != want {
1366 t.Fatalf("SetDBPath was not narrow:\n got %+v\n want %+v", *got, want)
1367 }
1368 if err := reg.SetDBPath("nope.example.com", "", "/x"); err == nil {
1369 t.Fatal("SetDBPath on an unknown host must error")
1370 }
1371 // Compare-and-swap, like SetCaps: the hourly pass decided to write
1372 // BECAUSE of the value it read, so a value that moved underneath it is a
1373 // refusal naming the current one — never a blind overwrite of a path
1374 // somebody else just set, which decides which file gets replicated.
1375 if err := reg.SetDBPath("console.example.com", "/data/stale.db", "/var/lib/carlos-app/console.example.com/other.db"); err == nil {
1376 t.Fatal("SetDBPath with a stale prior value must refuse, not overwrite")
1377 } else if !strings.Contains(err.Error(), "changed underneath") || !strings.Contains(err.Error(), "console.db") {
1378 t.Fatalf("a lost race should name the condition and the current value, got: %v", err)
1379 }
1380}
1381
1382// SetBacking is the migration door (app-capabilities design §5b/§5c): it
1383// moves a route between an exec child of the agent and a carlos-app@<host>
1384// unit, in both directions, narrowly — every other column of the row is
1385// untouched, so a stamp mid-migration cannot lose a grant or a version.
1386func TestSetBackingIsNarrowAndReversible(t *testing.T) {
1387 reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
1388 if err != nil {
1389 t.Fatal(err)
1390 }
1391 base := Route{Host: "console.example.com", Kind: "instance", App: "console", Channel: "canary/console", Status: "ready", DBPath: "/data/console.db"}
1392 if err := reg.Upsert(base); err != nil {
1393 t.Fatal(err)
1394 }
1395 if err := reg.SetBacking("console.example.com", BackingUnit); err != nil {
1396 t.Fatal(err)
1397 }
1398 got, err := reg.Get("console.example.com")
1399 if err != nil {
1400 t.Fatal(err)
1401 }
1402 want := base
1403 want.Backing = BackingUnit
1404 want.CreatedAt = got.CreatedAt
1405 if *got != want {
1406 t.Fatalf("SetBacking was not narrow:\n got %+v\n want %+v", *got, want)
1407 }
1408 // Reversible: the migration's rollback step is the same verb, back.
1409 if err := reg.SetBacking("console.example.com", ""); err != nil {
1410 t.Fatal(err)
1411 }
1412 if got, err := reg.Get("console.example.com"); err != nil {
1413 t.Fatal(err)
1414 } else if got.Backing != "" {
1415 t.Fatalf("backing = %q, want it back to the exec child", got.Backing)
1416 }
1417 if err := reg.SetBacking("nope.example.com", BackingUnit); err == nil {
1418 t.Fatal("SetBacking on an unknown host must error")
1419 }
1420}
1421
1422// The named finding, as a test: `carlos add` re-runs and the domains
1423// reconciler re-mints, and neither knows anything about grants. A plain
1424// excluded.caps would silently REVOKE port:25 on a re-add — a capability
1425// disappearing hours later, on the next converge pass, with nothing in the
1426// operator's shell history connecting the two.
1427func TestUpsertPreservesAnExistingGrant(t *testing.T) {
1428 reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
1429 if err != nil {
1430 t.Fatal(err)
1431 }
1432 base := Route{Host: "keymail.dev", Kind: "instance", App: "keymail", Backing: BackingUnit, Status: "ready"}
1433 if err := reg.Upsert(base); err != nil {
1434 t.Fatal(err)
1435 }
1436 if err := reg.SetCaps("keymail.dev", "", "host-role,port:25"); err != nil {
1437 t.Fatal(err)
1438 }
1439 // The re-add / the re-mint: same row, no grants in hand.
1440 if err := reg.Upsert(base); err != nil {
1441 t.Fatal(err)
1442 }
1443 got, err := reg.Get("keymail.dev")
1444 if err != nil {
1445 t.Fatal(err)
1446 }
1447 if got.Caps != "host-role,port:25" {
1448 t.Fatalf("Caps = %q after a re-Upsert, want the grant preserved", got.Caps)
1449 }
1450 // Preservation is a floor, not a freeze: an explicit non-empty value from
1451 // a caller that genuinely knows the grants still wins.
1452 base.Caps = "port:25"
1453 if err := reg.Upsert(base); err != nil {
1454 t.Fatal(err)
1455 }
1456 got, err = reg.Get("keymail.dev")
1457 if err != nil {
1458 t.Fatal(err)
1459 }
1460 if got.Caps != "port:25" {
1461 t.Fatalf("Caps = %q, want an explicit Upsert value to win", got.Caps)
1462 }
1463 // Revoking to nothing has exactly one door, and it is not Upsert.
1464 if err := reg.SetCaps("keymail.dev", "port:25", ""); err != nil {
1465 t.Fatal(err)
1466 }
1467 got, err = reg.Get("keymail.dev")
1468 if err != nil {
1469 t.Fatal(err)
1470 }
1471 if got.Caps != "" {
1472 t.Fatalf("Caps = %q after an explicit revoke-to-empty, want it cleared", got.Caps)
1473 }
1474}
1475
1476// A grant changes unit shape, not routing: the edge has no reason to reload
1477// for one, so the fingerprint must NOT move when caps does. (The inverse is
1478// pinned by TestFingerprintChangesOnWrite for the columns that do matter.)
1479func TestFingerprintIgnoresAGrant(t *testing.T) {
1480 path := filepath.Join(t.TempDir(), "registry.db")
1481 reg, err := Open(path)
1482 if err != nil {
1483 t.Fatal(err)
1484 }
1485 if err := reg.Upsert(Route{Host: "keymail.dev", Kind: "instance", App: "keymail", Backing: BackingUnit}); err != nil {
1486 t.Fatal(err)
1487 }
1488 before, err := reg.Fingerprint()
1489 if err != nil {
1490 t.Fatal(err)
1491 }
1492 if err := reg.SetCaps("keymail.dev", "", "host-role,port:25"); err != nil {
1493 t.Fatal(err)
1494 }
1495 after, err := reg.Fingerprint()
1496 if err != nil {
1497 t.Fatal(err)
1498 }
1499 if after != before {
1500 t.Fatalf("Fingerprint moved on a grant (%s -> %s) — the edge would reload for a change that does not touch routing", before, after)
1501 }
1502}
1503
1504// TestFingerprintSeesSameLengthVersionSwap pins issue #15: a same-length
1505// version rewrite (git short shas are always the same length) must change
1506// the fingerprint, or the edge's X-Carlos-Version header goes stale.
1507func TestFingerprintSeesSameLengthVersionSwap(t *testing.T) {
1508 reg := openTestRegistry(t)
1509 rt := Route{Host: "fp.example.test", Kind: "instance", App: "echo", Status: "ready", Version: "abc1234"}
1510 if err := reg.Upsert(rt); err != nil {
1511 t.Fatal(err)
1512 }
1513 before, err := reg.Fingerprint()
1514 if err != nil {
1515 t.Fatal(err)
1516 }
1517 if err := reg.SetVersions("fp.example.test", "def5678", "abc1234"); err != nil {
1518 t.Fatal(err)
1519 }
1520 after, err := reg.Fingerprint()
1521 if err != nil {
1522 t.Fatal(err)
1523 }
1524 if before == after {
1525 t.Fatal("same-length version swap invisible to Fingerprint (issue #15)")
1526 }
1527}
1528
1529func TestFingerprintSeesSameLengthChannelSwap(t *testing.T) {
1530 reg := openTestRegistry(t)
1531 rt := Route{Host: "fp2.example.test", Kind: "instance", App: "echo", Status: "ready", Channel: "canary/1"}
1532 if err := reg.Upsert(rt); err != nil {
1533 t.Fatal(err)
1534 }
1535 before, _ := reg.Fingerprint()
1536 rt.Channel = "canary/2"
1537 if err := reg.Upsert(rt); err != nil {
1538 t.Fatal(err)
1539 }
1540 after, _ := reg.Fingerprint()
1541 if before == after {
1542 t.Fatal("same-length channel swap invisible to Fingerprint (issue #15)")
1543 }
1544}
1545
1546func TestTunnelColumnRoundTrips(t *testing.T) {
1547 reg := openTestRegistry(t)
1548 if err := reg.Upsert(Route{Host: "demo.example", Kind: "instance", Status: "ready"}); err != nil {
1549 t.Fatal(err)
1550 }
1551 if err := reg.SetTunnel("demo.example", "pi-box"); err != nil {
1552 t.Fatal(err)
1553 }
1554 got, err := reg.Get("demo.example")
1555 if err != nil {
1556 t.Fatal(err)
1557 }
1558 if got.Tunnel != "pi-box" {
1559 t.Fatalf("Tunnel = %q, want pi-box", got.Tunnel)
1560 }
1561}
1562
1563// Same rationale as TestUpsertPreservesAnExistingEnvironmentBinding: a
1564// full-row Upsert caller that does not know about tunnel bindings must not
1565// silently unbind one by re-writing the zero value.
1566func TestUpsertPreservesTunnel(t *testing.T) {
1567 reg := openTestRegistry(t)
1568 base := Route{Host: "demo.example", Kind: "instance", Status: "ready"}
1569 if err := reg.Upsert(base); err != nil {
1570 t.Fatal(err)
1571 }
1572 if err := reg.SetTunnel("demo.example", "pi-box"); err != nil {
1573 t.Fatal(err)
1574 }
1575 // A full-row re-Upsert with zero-value Tunnel must not unbind.
1576 if err := reg.Upsert(base); err != nil {
1577 t.Fatal(err)
1578 }
1579 got, err := reg.Get("demo.example")
1580 if err != nil {
1581 t.Fatal(err)
1582 }
1583 if got.Tunnel != "pi-box" {
1584 t.Fatalf("Upsert clobbered Tunnel: %q", got.Tunnel)
1585 }
1586 // Clearing has exactly one door: SetTunnel(host, "").
1587 if err := reg.SetTunnel("demo.example", ""); err != nil {
1588 t.Fatal(err)
1589 }
1590 got, err = reg.Get("demo.example")
1591 if err != nil {
1592 t.Fatal(err)
1593 }
1594 if got.Tunnel != "" {
1595 t.Fatalf("SetTunnel clear failed: %q", got.Tunnel)
1596 }
1597}
1598
1599// Same contract as TestSetEnvironmentUnknownHostErrors: a human types a
1600// hostname into `carlos route -tunnel` directly, and a silent no-op on a
1601// typo would be worse than a refusal.
1602func TestSetTunnelUnknownHostErrors(t *testing.T) {
1603 reg := openTestRegistry(t)
1604 if err := reg.SetTunnel("nope.example.com", "pi-box"); err == nil {
1605 t.Fatal("SetTunnel on an unknown host: got nil error, want one")
1606 }
1607}
1608
1609// Mirrors appconfig.TestValidEnvironmentName: ValidTunnelName gates what
1610// reaches the `tunnel` column before it does — a name later matched against
1611// a dialed-in tunnel identity and used to build keys (remote-host-agent
1612// feature, issue #53), so path separators, spaces, and "" must never reach
1613// SetTunnel/Upsert unvalidated. Unlike ValidEnvironmentName there is no
1614// reserved name (no "default" tunnel), so "" is invalid only because it
1615// fails the character class, not a separate reservation.
1616func TestValidTunnelName(t *testing.T) {
1617 valid := []string{"demo", "a", "pi-box", "connect-timeslots", "0-9"}
1618 invalid := []string{
1619 "", // empty
1620 "Demo", // uppercase
1621 "has space", // space
1622 "has_us", // underscore not allowed
1623 "../../x", // path traversal
1624 "UP",
1625 string(make([]byte, 33)), // too long (33 NUL bytes, also fails the char class)
1626 }
1627 for _, n := range valid {
1628 if !ValidTunnelName(n) {
1629 t.Errorf("ValidTunnelName(%q) = false, want true", n)
1630 }
1631 }
1632 for _, n := range invalid {
1633 if ValidTunnelName(n) {
1634 t.Errorf("ValidTunnelName(%q) = true, want false", n)
1635 }
1636 }
1637}
1638
1639// A registry file written BEFORE the unit column existed must still open, its
1640// rows must read as "no unit" (which is what every route on every box is
1641// today), and the new column must be writable on that same migrated row. The
1642// pre-change file is simulated the way TestPreCapsRegistryOpensAndReadsUngranted
1643// simulates its own: drop the column from a fresh registry and write a row
1644// through raw SQL, which is the shape an older binary left behind.
1645func TestPreUnitRegistryOpensAndReadsNoUnit(t *testing.T) {
1646 path := filepath.Join(t.TempDir(), "registry.db")
1647 reg, err := Open(path)
1648 if err != nil {
1649 t.Fatal(err)
1650 }
1651 if err := reg.Close(); err != nil {
1652 t.Fatal(err)
1653 }
1654
1655 old, err := sql.Open("sqlite", path)
1656 if err != nil {
1657 t.Fatal(err)
1658 }
1659 if _, err := old.Exec(`ALTER TABLE routes DROP COLUMN unit`); err != nil {
1660 t.Fatalf("simulate a pre-unit registry: %v", err)
1661 }
1662 if _, err := old.Exec(`INSERT INTO routes (host, kind, addr, status, created_at)
1663VALUES ('console.carloku.com', 'instance', '127.0.0.1:8090', 'ready', 1700000000)`); err != nil {
1664 t.Fatal(err)
1665 }
1666 if err := old.Close(); err != nil {
1667 t.Fatal(err)
1668 }
1669
1670 reg, err = Open(path)
1671 if err != nil {
1672 t.Fatalf("a pre-unit registry must still open: %v", err)
1673 }
1674 defer reg.Close()
1675 got, err := reg.Get("console.carloku.com")
1676 if err != nil {
1677 t.Fatal(err)
1678 }
1679 if got == nil {
1680 t.Fatal("the pre-existing row vanished across the migration")
1681 }
1682 if got.Unit != "" {
1683 t.Fatalf("Unit = %q on a row written before the column existed, want %q", got.Unit, "")
1684 }
1685 if got.Host != "console.carloku.com" || got.Kind != "instance" ||
1686 got.Addr != "127.0.0.1:8090" || got.CreatedAt != 1700000000 {
1687 t.Fatalf("the migrated row shifted columns: %+v", *got)
1688 }
1689 // The stamp the live consoles need, on exactly that migrated row.
1690 if err := reg.SetUnit("console.carloku.com", "console.service"); err != nil {
1691 t.Fatal(err)
1692 }
1693 got, err = reg.Get("console.carloku.com")
1694 if err != nil {
1695 t.Fatal(err)
1696 }
1697 if got.Unit != "console.service" {
1698 t.Fatalf("Unit = %q after SetUnit on a migrated row", got.Unit)
1699 }
1700 // And clearing it again has a door, so a mis-stamp is reversible.
1701 if err := reg.SetUnit("console.carloku.com", ""); err != nil {
1702 t.Fatal(err)
1703 }
1704 got, err = reg.Get("console.carloku.com")
1705 if err != nil {
1706 t.Fatal(err)
1707 }
1708 if got.Unit != "" {
1709 t.Fatalf("SetUnit clear failed: %q", got.Unit)
1710 }
1711}
1712
1713// SetUnit is narrow (it touches no other column) and refuses an unknown host
1714// — the SetBacking contract, for the same reason: a human types the hostname
1715// in directly.
1716func TestSetUnitIsNarrowAndErrorsOnAnUnknownHost(t *testing.T) {
1717 reg := openTestRegistry(t)
1718 base := Route{
1719 Host: "console.example.com", Kind: "instance", App: "console",
1720 Addr: "127.0.0.1:8090", Status: "ready", Version: "abc1234", Channel: "canary/console",
1721 }
1722 if err := reg.Upsert(base); err != nil {
1723 t.Fatal(err)
1724 }
1725 if err := reg.SetUnit("console.example.com", "console.service"); err != nil {
1726 t.Fatal(err)
1727 }
1728 got, err := reg.Get("console.example.com")
1729 if err != nil {
1730 t.Fatal(err)
1731 }
1732 if got.Unit != "console.service" {
1733 t.Fatalf("Unit = %q, want console.service", got.Unit)
1734 }
1735 if got.Version != "abc1234" || got.Channel != "canary/console" || got.Addr != "127.0.0.1:8090" {
1736 t.Fatalf("SetUnit touched another column: %+v", *got)
1737 }
1738 if err := reg.SetUnit("nope.example.com", "console.service"); err == nil {
1739 t.Fatal("SetUnit on an unknown host: got nil error, want one")
1740 }
1741}
1742
1743// A typo'd unit must fail at WRITE time, loudly, rather than at restart time
1744// silently — the whole reason the column exists is that a restart which
1745// reaches nothing looks exactly like a restart that worked.
1746func TestSetUnitRefusesAMalformedName(t *testing.T) {
1747 reg := openTestRegistry(t)
1748 if err := reg.Upsert(Route{Host: "console.example.com", Kind: "instance", Status: "ready"}); err != nil {
1749 t.Fatal(err)
1750 }
1751 for _, bad := range []string{"console.servcie", "console", "console.timer", "console service", "console.service extra"} {
1752 if err := reg.SetUnit("console.example.com", bad); err == nil {
1753 t.Fatalf("SetUnit(%q) accepted; want a refusal", bad)
1754 }
1755 }
1756 got, err := reg.Get("console.example.com")
1757 if err != nil {
1758 t.Fatal(err)
1759 }
1760 if got.Unit != "" {
1761 t.Fatalf("a refused SetUnit still wrote %q", got.Unit)
1762 }
1763}
1764
1765func TestValidUnitName(t *testing.T) {
1766 valid := []string{
1767 "", // the column's default: no unit
1768 "console.service", //
1769 "keymail-relay.service", // hyphens, just not leading
1770 "titogo-canary@demo.service", // template instance
1771 "carlos_app.service", // underscore
1772 "a.b.c.service", // dots, just not leading
1773 "a.service", // one-character stem
1774 strings.Repeat("a", 64) + ".service", // the length bound itself
1775 }
1776 invalid := []string{
1777 "console", // no suffix
1778 "console.servcie", // the typo this rail is for
1779 "carlos-adopt.timer", // a unit, but not one that owns a process
1780 "console.socket", //
1781 "has space.service", // space
1782 "../../etc/x.service", // path traversal
1783 "console.service; reboot", // shell-shaped, and refused as a shape
1784 ".service", // suffix alone
1785 "-x.service", // leading dash: reads as a flag in any argv built from this column
1786 "..service", // dot-only stem
1787 "...service", //
1788 ".x.service", // leading dot
1789 strings.Repeat("a", 65) + ".service", // one over the bound
1790 }
1791 for _, n := range valid {
1792 if !ValidUnitName(n) {
1793 t.Errorf("ValidUnitName(%q) = false, want true", n)
1794 }
1795 }
1796 for _, n := range invalid {
1797 if ValidUnitName(n) {
1798 t.Errorf("ValidUnitName(%q) = true, want false", n)
1799 }
1800 }
1801}
1802
1803// Same rationale as TestUpsertPreservesTunnel, with the stakes of the caps
1804// column: a full-row Upsert caller (`carlos add`, a reconciler's mint) knows
1805// nothing about which unit owns a route's process, and dropping the stamp
1806// would silently restore issue #110 on the one row it matters for.
1807func TestUpsertPreservesUnit(t *testing.T) {
1808 reg := openTestRegistry(t)
1809 base := Route{Host: "console.example.com", Kind: "instance", App: "console", Status: "ready"}
1810 if err := reg.Upsert(base); err != nil {
1811 t.Fatal(err)
1812 }
1813 if err := reg.SetUnit("console.example.com", "console.service"); err != nil {
1814 t.Fatal(err)
1815 }
1816 if err := reg.Upsert(base); err != nil {
1817 t.Fatal(err)
1818 }
1819 got, err := reg.Get("console.example.com")
1820 if err != nil {
1821 t.Fatal(err)
1822 }
1823 if got.Unit != "console.service" {
1824 t.Fatalf("Upsert clobbered Unit: %q", got.Unit)
1825 }
1826 // Preservation is a floor, not a freeze: an explicit value still wins.
1827 base.Unit = "console-next.service"
1828 if err := reg.Upsert(base); err != nil {
1829 t.Fatal(err)
1830 }
1831 got, err = reg.Get("console.example.com")
1832 if err != nil {
1833 t.Fatal(err)
1834 }
1835 if got.Unit != "console-next.service" {
1836 t.Fatalf("Unit = %q, want an explicit Upsert value to win", got.Unit)
1837 }
1838}
1839
1840// unit changes who RESTARTS the process, not what the edge serves — so the
1841// edge must not reload for one. Same rule and same test shape as
1842// TestFingerprintIgnoresAGrant, and this pins the exclusion so a later
1843// "hash every column" tidy-up has to argue with a test.
1844func TestFingerprintIgnoresAUnitStamp(t *testing.T) {
1845 reg := openTestRegistry(t)
1846 if err := reg.Upsert(Route{
1847 Host: "console.example.com", Kind: "instance", App: "console",
1848 Addr: "127.0.0.1:8090", Status: "ready",
1849 }); err != nil {
1850 t.Fatal(err)
1851 }
1852 before, err := reg.Fingerprint()
1853 if err != nil {
1854 t.Fatal(err)
1855 }
1856 if err := reg.SetUnit("console.example.com", "console.service"); err != nil {
1857 t.Fatal(err)
1858 }
1859 after, err := reg.Fingerprint()
1860 if err != nil {
1861 t.Fatal(err)
1862 }
1863 if after != before {
1864 t.Fatalf("Fingerprint moved on a unit stamp (%s -> %s) — the edge would reload for a change that does not touch routing", before, after)
1865 }
1866}
1867
1868// The other half of TestFingerprintIgnoresAGrant and
1869// TestFingerprintIgnoresAUnitStamp: what the edge is right to ignore, the
1870// cold-standby snapshot must NOT ignore. caps and unit are box-scoped rows
1871// nothing else in the bucket can rebuild, so if SnapshotFingerprint sat
1872// still for them the snapshot pass would never fire and a restore would come
1873// back without them. These two tests pin the divergence in both directions
1874// at once — Fingerprint still still, SnapshotFingerprint moved.
1875func TestSnapshotFingerprintSeesACapsGrant(t *testing.T) {
1876 reg := openTestRegistry(t)
1877 if err := reg.Upsert(Route{
1878 Host: "mail.example.com", Kind: "instance", App: "mail",
1879 Addr: "127.0.0.1:8091", Status: "ready",
1880 }); err != nil {
1881 t.Fatal(err)
1882 }
1883 beforeSnap, err := reg.SnapshotFingerprint()
1884 if err != nil {
1885 t.Fatal(err)
1886 }
1887 beforeEdge, err := reg.Fingerprint()
1888 if err != nil {
1889 t.Fatal(err)
1890 }
1891 if err := reg.SetCaps("mail.example.com", "", "net_bind_service"); err != nil {
1892 t.Fatal(err)
1893 }
1894 afterSnap, err := reg.SnapshotFingerprint()
1895 if err != nil {
1896 t.Fatal(err)
1897 }
1898 afterEdge, err := reg.Fingerprint()
1899 if err != nil {
1900 t.Fatal(err)
1901 }
1902 if afterSnap == beforeSnap {
1903 t.Fatalf("SnapshotFingerprint unchanged across a caps grant (%s) — the snapshot pass would never fire, and a cold-standby restore would silently drop the grant", beforeSnap)
1904 }
1905 if afterEdge != beforeEdge {
1906 t.Fatalf("Fingerprint moved on a caps grant (%s -> %s) — the edge-reload digest must stay indifferent to it", beforeEdge, afterEdge)
1907 }
1908}
1909
1910func TestSnapshotFingerprintSeesAUnitStamp(t *testing.T) {
1911 reg := openTestRegistry(t)
1912 if err := reg.Upsert(Route{
1913 Host: "console.example.com", Kind: "instance", App: "console",
1914 Addr: "127.0.0.1:8090", Status: "ready",
1915 }); err != nil {
1916 t.Fatal(err)
1917 }
1918 beforeSnap, err := reg.SnapshotFingerprint()
1919 if err != nil {
1920 t.Fatal(err)
1921 }
1922 beforeEdge, err := reg.Fingerprint()
1923 if err != nil {
1924 t.Fatal(err)
1925 }
1926 if err := reg.SetUnit("console.example.com", "console.service"); err != nil {
1927 t.Fatal(err)
1928 }
1929 afterSnap, err := reg.SnapshotFingerprint()
1930 if err != nil {
1931 t.Fatal(err)
1932 }
1933 afterEdge, err := reg.Fingerprint()
1934 if err != nil {
1935 t.Fatal(err)
1936 }
1937 if afterSnap == beforeSnap {
1938 t.Fatalf("SnapshotFingerprint unchanged across a unit stamp (%s) — a restored console route would come back unstamped and stop auto-cycling", beforeSnap)
1939 }
1940 if afterEdge != beforeEdge {
1941 t.Fatalf("Fingerprint moved on a unit stamp (%s -> %s)", beforeEdge, afterEdge)
1942 }
1943}
1944
1945// Determinism: repeated calls over an unchanged registry must agree, or the
1946// snapshot pass writes a generation every 5 minutes forever. (This is the
1947// property that ruled out hashing the VACUUM INTO page image.)
1948func TestSnapshotFingerprintStableAcrossCalls(t *testing.T) {
1949 reg := openTestRegistry(t)
1950 if err := reg.Upsert(Route{
1951 Host: "a.example.com", Kind: "service", Addr: "10.0.0.1:3000", Status: "ready",
1952 }); err != nil {
1953 t.Fatal(err)
1954 }
1955 first, err := reg.SnapshotFingerprint()
1956 if err != nil {
1957 t.Fatal(err)
1958 }
1959 for i := 0; i < 3; i++ {
1960 again, err := reg.SnapshotFingerprint()
1961 if err != nil {
1962 t.Fatal(err)
1963 }
1964 if again != first {
1965 t.Fatalf("SnapshotFingerprint call %d = %s, want %s — an unstable digest snapshots on every pass", i+2, again, first)
1966 }
1967 }
1968}
1969
1970// An account rename has no route column behind it, and only the snapshot can
1971// restore the accounts table — so it has to move the snapshot digest even
1972// though no route changed at all.
1973func TestSnapshotFingerprintSeesAnAccountWrite(t *testing.T) {
1974 reg := openTestRegistry(t)
1975 before, err := reg.SnapshotFingerprint()
1976 if err != nil {
1977 t.Fatal(err)
1978 }
1979 if _, err := reg.sql.Exec(`INSERT INTO accounts (id, sqid, name) VALUES (?, ?, ?)`, 4242, "zzz", "later"); err != nil {
1980 t.Fatal(err)
1981 }
1982 after, err := reg.SnapshotFingerprint()
1983 if err != nil {
1984 t.Fatal(err)
1985 }
1986 if after == before {
1987 t.Fatalf("SnapshotFingerprint unchanged across an accounts write (%s)", before)
1988 }
1989}
1990
1991func TestValidTunnelNameLengthBoundary(t *testing.T) {
1992 ok32 := "01234567890123456789012345678901" // 32 chars
1993 if len(ok32) != 32 {
1994 t.Fatalf("test fixture is %d chars, want 32", len(ok32))
1995 }
1996 if !ValidTunnelName(ok32) {
1997 t.Errorf("ValidTunnelName(32 chars) = false, want true")
1998 }
1999 tooLong := ok32 + "x"
2000 if ValidTunnelName(tooLong) {
2001 t.Errorf("ValidTunnelName(33 chars) = true, want false")
2002 }
2003}
2004
2005func TestSetProvisioned(t *testing.T) {
2006 r := openTestRegistry(t)
2007 if err := r.Upsert(Route{Host: "a.example.com", Kind: "instance", App: "x", Status: "ready"}); err != nil {
2008 t.Fatal(err)
2009 }
2010 if err := r.SetProvisioned("a.example.com", true); err != nil {
2011 t.Fatal(err)
2012 }
2013 rt, err := r.Get("a.example.com")
2014 if err != nil {
2015 t.Fatal(err)
2016 }
2017 if !rt.Provisioned {
2018 t.Error("SetProvisioned(true) did not stamp the column")
2019 }
2020 if err := r.SetProvisioned("missing.example.com", true); err == nil {
2021 t.Error("SetProvisioned on an unknown host returned nil; want an error (the SetChannel contract)")
2022 }
2023}
2024
2025func TestSetStatusNamed(t *testing.T) {
2026 r := openTestRegistry(t)
2027 if err := r.Upsert(Route{Host: "a.example.com", Kind: "instance", App: "x", Status: "ready"}); err != nil {
2028 t.Fatal(err)
2029 }
2030 if err := r.SetStatusNamed("a.example.com", true); err != nil {
2031 t.Fatal(err)
2032 }
2033 rt, err := r.Get("a.example.com")
2034 if err != nil {
2035 t.Fatal(err)
2036 }
2037 if !rt.StatusNamed {
2038 t.Error("SetStatusNamed(true) did not stamp the column")
2039 }
2040 if err := r.SetStatusNamed("a.example.com", false); err != nil {
2041 t.Fatal(err)
2042 }
2043 rt, err = r.Get("a.example.com")
2044 if err != nil {
2045 t.Fatal(err)
2046 }
2047 if rt.StatusNamed {
2048 t.Error("SetStatusNamed(false) did not clear the column")
2049 }
2050 if err := r.SetStatusNamed("missing.example.com", true); err == nil {
2051 t.Error("SetStatusNamed on an unknown host returned nil; want an error (the SetChannel contract)")
2052 }
2053}
2054
2055// TestSetFleetBoxRoundTrips mirrors TestSetTunnel's shape: SetFleetBox is a
2056// narrow UPDATE on the new fleet_box column, which records which fleet box
2057// is CURRENTLY serving a placed route (distinct from Region/placement
2058// target, which says where the route WANTS to run).
2059func TestSetFleetBoxRoundTrips(t *testing.T) {
2060 reg := openTestRegistry(t)
2061 if err := reg.Upsert(Route{Host: "demo.example", Kind: "instance", Status: "ready"}); err != nil {
2062 t.Fatal(err)
2063 }
2064 if err := reg.SetFleetBox("demo.example", "pi-1"); err != nil {
2065 t.Fatal(err)
2066 }
2067 got, err := reg.Get("demo.example")
2068 if err != nil {
2069 t.Fatal(err)
2070 }
2071 if got.FleetBox != "pi-1" {
2072 t.Fatalf("FleetBox = %q, want pi-1", got.FleetBox)
2073 }
2074 if err := reg.SetFleetBox("demo.example", ""); err != nil {
2075 t.Fatal(err)
2076 }
2077 got, err = reg.Get("demo.example")
2078 if err != nil {
2079 t.Fatal(err)
2080 }
2081 if got.FleetBox != "" {
2082 t.Fatalf("SetFleetBox clear failed: %q", got.FleetBox)
2083 }
2084}
2085
2086// Same contract as TestSetTunnelUnknownHostErrors: a human/reconciler typo'd
2087// host must get a refusal, not a silent no-op.
2088func TestSetFleetBoxUnknownHostErrors(t *testing.T) {
2089 reg := openTestRegistry(t)
2090 if err := reg.SetFleetBox("nope.example.com", "pi-1"); err == nil {
2091 t.Fatal("SetFleetBox on an unknown host: got nil error, want one")
2092 }
2093}
2094
2095// Same rationale as TestUpsertPreservesTunnel: a full-row Upsert caller that
2096// does not know about fleet placement must not silently unbind a box another
2097// pass stamped.
2098func TestUpsertPreservesFleetBox(t *testing.T) {
2099 reg := openTestRegistry(t)
2100 base := Route{Host: "demo.example", Kind: "instance", Status: "ready"}
2101 if err := reg.Upsert(base); err != nil {
2102 t.Fatal(err)
2103 }
2104 if err := reg.SetFleetBox("demo.example", "pi-1"); err != nil {
2105 t.Fatal(err)
2106 }
2107 // A full-row re-Upsert with zero-value FleetBox must not unbind.
2108 if err := reg.Upsert(base); err != nil {
2109 t.Fatal(err)
2110 }
2111 got, err := reg.Get("demo.example")
2112 if err != nil {
2113 t.Fatal(err)
2114 }
2115 if got.FleetBox != "pi-1" {
2116 t.Fatalf("Upsert clobbered FleetBox: %q", got.FleetBox)
2117 }
2118 // An explicit non-empty value still wins.
2119 base.FleetBox = "pi-2"
2120 if err := reg.Upsert(base); err != nil {
2121 t.Fatal(err)
2122 }
2123 got, err = reg.Get("demo.example")
2124 if err != nil {
2125 t.Fatal(err)
2126 }
2127 if got.FleetBox != "pi-2" {
2128 t.Fatalf("FleetBox = %q, want an explicit Upsert value to win", got.FleetBox)
2129 }
2130 // Clearing has exactly one door: SetFleetBox(host, "").
2131 if err := reg.SetFleetBox("demo.example", ""); err != nil {
2132 t.Fatal(err)
2133 }
2134 got, err = reg.Get("demo.example")
2135 if err != nil {
2136 t.Fatal(err)
2137 }
2138 if got.FleetBox != "" {
2139 t.Fatalf("SetFleetBox clear failed: %q", got.FleetBox)
2140 }
2141}
2142
2143// A registry file written BEFORE the fleet_box column existed must still
2144// open, and its rows must read as "no box bound" — the same additive-
2145// migration contract as TestProvisionedSeedMigratesFromPreChangeDB.
2146func TestFleetBoxMigratesFromPreChangeDB(t *testing.T) {
2147 path := filepath.Join(t.TempDir(), "registry.db")
2148 db, err := sql.Open("sqlite", path)
2149 if err != nil {
2150 t.Fatal(err)
2151 }
2152 if _, err := db.Exec(`
2153CREATE TABLE routes (
2154 host TEXT PRIMARY KEY,
2155 kind TEXT NOT NULL,
2156 socket TEXT NOT NULL DEFAULT '',
2157 status TEXT NOT NULL DEFAULT 'ready',
2158 last_error TEXT NOT NULL DEFAULT '',
2159 db_path TEXT NOT NULL DEFAULT '',
2160 version TEXT NOT NULL DEFAULT '',
2161 prev_version TEXT NOT NULL DEFAULT '',
2162 created_at INTEGER NOT NULL
2163);`); err != nil {
2164 t.Fatal(err)
2165 }
2166 if _, err := db.Exec(`INSERT INTO routes (host, kind, created_at) VALUES (?, ?, ?)`,
2167 "old.example.com", "instance", 1234); err != nil {
2168 t.Fatal(err)
2169 }
2170 if err := db.Close(); err != nil {
2171 t.Fatal(err)
2172 }
2173
2174 r, err := Open(path)
2175 if err != nil {
2176 t.Fatalf("Open over a pre-change DB: %v", err)
2177 }
2178 defer r.Close()
2179 got, err := r.Get("old.example.com")
2180 if err != nil {
2181 t.Fatal(err)
2182 }
2183 if got == nil {
2184 t.Fatal("pre-existing row vanished on migrate")
2185 }
2186 if got.FleetBox != "" {
2187 t.Fatalf("pre-existing row should default fleet_box=\"\": %+v", got)
2188 }
2189}
2190
2191// TestSetPlacementRoundTrips pins SetPlacement's grammar: "" clears,
2192// "fleet/<name>" sets Region to the fleet placement target, and anything
2193// else — a bare region code, an invalid fleet name — is refused rather than
2194// silently written. Bare region codes are a FUTURE placement form; v1 only
2195// accepts "" or "fleet/<name>" here (FleetPlacement, fleets.go, is what
2196// distinguishes the two forms on read).
2197func TestSetPlacementRoundTrips(t *testing.T) {
2198 reg := openTestRegistry(t)
2199 if err := reg.Upsert(Route{Host: "demo.example", Kind: "instance", Status: "ready"}); err != nil {
2200 t.Fatal(err)
2201 }
2202 if err := reg.SetPlacement("demo.example", "fleet/pi"); err != nil {
2203 t.Fatal(err)
2204 }
2205 got, err := reg.Get("demo.example")
2206 if err != nil {
2207 t.Fatal(err)
2208 }
2209 if got.Region != "fleet/pi" {
2210 t.Fatalf("Region = %q, want fleet/pi", got.Region)
2211 }
2212 if err := reg.SetPlacement("demo.example", ""); err != nil {
2213 t.Fatal(err)
2214 }
2215 got, err = reg.Get("demo.example")
2216 if err != nil {
2217 t.Fatal(err)
2218 }
2219 if got.Region != "" {
2220 t.Fatalf("SetPlacement(\"\") did not clear Region: %q", got.Region)
2221 }
2222}
2223
2224func TestSetPlacementRejectsBadGrammar(t *testing.T) {
2225 reg := openTestRegistry(t)
2226 if err := reg.Upsert(Route{Host: "demo.example", Kind: "instance", Status: "ready"}); err != nil {
2227 t.Fatal(err)
2228 }
2229 for _, bad := range []string{"bogus", "eu", "fleet/UPPER", "fleet/", "fleet"} {
2230 if err := reg.SetPlacement("demo.example", bad); err == nil {
2231 t.Errorf("SetPlacement(%q): got nil error, want one", bad)
2232 }
2233 }
2234 got, err := reg.Get("demo.example")
2235 if err != nil {
2236 t.Fatal(err)
2237 }
2238 if got.Region != "" {
2239 t.Fatalf("a rejected SetPlacement wrote Region anyway: %q", got.Region)
2240 }
2241}
2242
2243func TestSetPlacementUnknownHostErrors(t *testing.T) {
2244 reg := openTestRegistry(t)
2245 if err := reg.SetPlacement("nope.example.com", "fleet/pi"); err == nil {
2246 t.Fatal("SetPlacement on an unknown host: got nil error, want one")
2247 }
2248}
2249
2250// TestRouteAliasCRUD pins UpsertAlias/ListAliases/DeleteAlias's contract: a
2251// re-upsert repoints host/account/app but must NOT reset the DNS/cert stamps
2252// or created_at — those are reconcile-pass-owned and box-stamped state, the
2253// same shape as Upsert's preserved columns on routes.
2254func TestRouteAliasCRUD(t *testing.T) {
2255 reg := openTestRegistry(t)
2256 if err := reg.UpsertAlias(RouteAlias{
2257 Alias: "extra.example.com", Host: "app.example.com",
2258 Account: "bab", App: "myapp", CreatedAt: 1000,
2259 }); err != nil {
2260 t.Fatalf("UpsertAlias: %v", err)
2261 }
2262 got, err := reg.ListAliases()
2263 if err != nil {
2264 t.Fatalf("ListAliases: %v", err)
2265 }
2266 if len(got) != 1 {
2267 t.Fatalf("ListAliases = %+v, want exactly one row", got)
2268 }
2269 if got[0].Alias != "extra.example.com" || got[0].Host != "app.example.com" ||
2270 got[0].Account != "bab" || got[0].App != "myapp" || got[0].CreatedAt != 1000 {
2271 t.Fatalf("round-tripped alias = %+v, want the upserted values", got[0])
2272 }
2273
2274 // Stamp the row the way a box-side pass would, THEN re-upsert a repoint —
2275 // the stamps and created_at must survive.
2276 if err := reg.SetAliasDNSStatus("extra.example.com", "pointing", 2000); err != nil {
2277 t.Fatalf("SetAliasDNSStatus: %v", err)
2278 }
2279 if err := reg.SetAliasCertExpiresAt("extra.example.com", 3000); err != nil {
2280 t.Fatalf("SetAliasCertExpiresAt: %v", err)
2281 }
2282 if err := reg.UpsertAlias(RouteAlias{
2283 Alias: "extra.example.com", Host: "other.example.com",
2284 Account: "bad", App: "otherapp", CreatedAt: 9999,
2285 }); err != nil {
2286 t.Fatalf("re-UpsertAlias: %v", err)
2287 }
2288 got, err = reg.ListAliases()
2289 if err != nil {
2290 t.Fatalf("ListAliases: %v", err)
2291 }
2292 if len(got) != 1 {
2293 t.Fatalf("ListAliases after re-upsert = %+v, want exactly one row", got)
2294 }
2295 a := got[0]
2296 if a.Host != "other.example.com" || a.Account != "bad" || a.App != "otherapp" {
2297 t.Fatalf("re-upsert did not repoint host/account/app: %+v", a)
2298 }
2299 if a.DNSStatus != "pointing" || a.DNSCheckedAt != 2000 || a.CertExpiresAt != 3000 {
2300 t.Fatalf("re-upsert clobbered stamps: %+v", a)
2301 }
2302 if a.CreatedAt != 1000 {
2303 t.Fatalf("re-upsert clobbered created_at: got %d, want the FIRST insert's 1000", a.CreatedAt)
2304 }
2305
2306 // Delete is idempotent: gone is success, called twice is still success.
2307 if err := reg.DeleteAlias("extra.example.com"); err != nil {
2308 t.Fatalf("DeleteAlias: %v", err)
2309 }
2310 if err := reg.DeleteAlias("extra.example.com"); err != nil {
2311 t.Fatalf("DeleteAlias (already gone): %v", err)
2312 }
2313 got, err = reg.ListAliases()
2314 if err != nil {
2315 t.Fatalf("ListAliases: %v", err)
2316 }
2317 if len(got) != 0 {
2318 t.Fatalf("ListAliases after delete = %+v, want none", got)
2319 }
2320}
2321
2322// TestRouteAliasStamps pins SetAliasDNSStatus/SetAliasCertExpiresAt as narrow
2323// UPDATEs that persist independently of UpsertAlias — same rationale as
2324// SetDNSStatus/SetCertExpiresAt on routes.
2325func TestRouteAliasStamps(t *testing.T) {
2326 reg := openTestRegistry(t)
2327 if err := reg.UpsertAlias(RouteAlias{Alias: "extra.example.com", Host: "app.example.com"}); err != nil {
2328 t.Fatalf("UpsertAlias: %v", err)
2329 }
2330 if err := reg.SetAliasDNSStatus("extra.example.com", "elsewhere", 111); err != nil {
2331 t.Fatalf("SetAliasDNSStatus: %v", err)
2332 }
2333 if err := reg.SetAliasCertExpiresAt("extra.example.com", 222); err != nil {
2334 t.Fatalf("SetAliasCertExpiresAt: %v", err)
2335 }
2336 got, err := reg.ListAliases()
2337 if err != nil {
2338 t.Fatalf("ListAliases: %v", err)
2339 }
2340 if len(got) != 1 {
2341 t.Fatalf("ListAliases = %+v, want exactly one row", got)
2342 }
2343 if got[0].DNSStatus != "elsewhere" || got[0].DNSCheckedAt != 111 || got[0].CertExpiresAt != 222 {
2344 t.Fatalf("stamps did not persist: %+v", got[0])
2345 }
2346
2347 // A same-host UpsertAlias (no repoint) must not disturb the stamps either.
2348 if err := reg.UpsertAlias(RouteAlias{Alias: "extra.example.com", Host: "app.example.com"}); err != nil {
2349 t.Fatalf("UpsertAlias: %v", err)
2350 }
2351 got, err = reg.ListAliases()
2352 if err != nil {
2353 t.Fatalf("ListAliases: %v", err)
2354 }
2355 if got[0].DNSStatus != "elsewhere" || got[0].DNSCheckedAt != 111 || got[0].CertExpiresAt != 222 {
2356 t.Fatalf("UpsertAlias disturbed stamps that survived a plain re-upsert: %+v", got[0])
2357 }
2358}
2359
2360// TestFingerprintCoversAliases pins the fingerprint contract from the task
2361// brief: an alias write moves the fingerprint (so the edge reloads and
2362// starts routing it), but a STAMP write never does — stamping runs on a
2363// 5-minute cadence and hashing it would force a pointless fleet-wide edge
2364// reload every pass (same reasoning as the routes section's own omission of
2365// dns_status/cert_expires_at).
2366func TestFingerprintCoversAliases(t *testing.T) {
2367 reg := openTestRegistry(t)
2368 fp1, err := reg.Fingerprint()
2369 if err != nil {
2370 t.Fatal(err)
2371 }
2372 if err := reg.UpsertAlias(RouteAlias{Alias: "extra.example.com", Host: "app.example.com", Account: "bab", App: "myapp"}); err != nil {
2373 t.Fatalf("UpsertAlias: %v", err)
2374 }
2375 fp2, err := reg.Fingerprint()
2376 if err != nil {
2377 t.Fatal(err)
2378 }
2379 if fp2 == fp1 {
2380 t.Fatal("Fingerprint blind to an alias UpsertAlias")
2381 }
2382
2383 if err := reg.SetAliasDNSStatus("extra.example.com", "pointing", 12345); err != nil {
2384 t.Fatalf("SetAliasDNSStatus: %v", err)
2385 }
2386 fp3, err := reg.Fingerprint()
2387 if err != nil {
2388 t.Fatal(err)
2389 }
2390 if fp3 != fp2 {
2391 t.Fatalf("Fingerprint moved on an alias stamp (%s -> %s) — the edge would reload for a change that does not touch routing", fp2, fp3)
2392 }
2393
2394 if err := reg.DeleteAlias("extra.example.com"); err != nil {
2395 t.Fatalf("DeleteAlias: %v", err)
2396 }
2397 fp4, err := reg.Fingerprint()
2398 if err != nil {
2399 t.Fatal(err)
2400 }
2401 if fp4 == fp2 {
2402 t.Fatal("Fingerprint blind to a DeleteAlias")
2403 }
2404}
2405
2406// TestFingerprintCoversWildcardDomains is Task 4's carry-over from Task 2's
2407// review: wildcard_domains rows (Task 3's reconcile-derived delegated
2408// wildcard parents) were entirely absent from Fingerprint, so a new
2409// delegated parent landing (or leaving) the registry would not move the
2410// digest the edge's 2s Watch poll keys off — it would sit unrouted until the
2411// wildcard manager's own 12h tick, rather than reaching wildcard.go's
2412// refresh() (registered via Edge.OnReload) within the usual 2s bound. This
2413// pins the fix the same way TestFingerprintCoversAliases pins route_aliases:
2414// an Upsert and a Delete of a wildcard_domains row must both move the
2415// fingerprint.
2416func TestFingerprintCoversWildcardDomains(t *testing.T) {
2417 reg := openTestRegistry(t)
2418 fp1, err := reg.Fingerprint()
2419 if err != nil {
2420 t.Fatal(err)
2421 }
2422 if err := reg.UpsertWildcardDomain(WildcardDomain{Host: "woodstar.app", Account: "bab", App: "myapp", CreatedAt: 1}); err != nil {
2423 t.Fatalf("UpsertWildcardDomain: %v", err)
2424 }
2425 fp2, err := reg.Fingerprint()
2426 if err != nil {
2427 t.Fatal(err)
2428 }
2429 if fp2 == fp1 {
2430 t.Fatal("Fingerprint blind to a wildcard_domains UpsertWildcardDomain")
2431 }
2432
2433 if err := reg.DeleteWildcardDomain("woodstar.app"); err != nil {
2434 t.Fatalf("DeleteWildcardDomain: %v", err)
2435 }
2436 fp3, err := reg.Fingerprint()
2437 if err != nil {
2438 t.Fatal(err)
2439 }
2440 if fp3 == fp2 {
2441 t.Fatal("Fingerprint blind to a DeleteWildcardDomain")
2442 }
2443 if fp3 != fp1 {
2444 t.Fatalf("Fingerprint after add+remove = %s, want back to the original %s", fp3, fp1)
2445 }
2446}
2447
2448// TestMigrateIdempotentWithAliases pins migrate()'s additive contract for the
2449// new table: opening an already-migrated registry a second time (CREATE
2450// TABLE IF NOT EXISTS route_aliases) must not error.
2451func TestMigrateIdempotentWithAliases(t *testing.T) {
2452 path := filepath.Join(t.TempDir(), "registry.db")
2453 r1, err := Open(path)
2454 if err != nil {
2455 t.Fatalf("first Open: %v", err)
2456 }
2457 if err := r1.UpsertAlias(RouteAlias{Alias: "extra.example.com", Host: "app.example.com"}); err != nil {
2458 t.Fatalf("UpsertAlias: %v", err)
2459 }
2460 if err := r1.Close(); err != nil {
2461 t.Fatal(err)
2462 }
2463
2464 r2, err := Open(path)
2465 if err != nil {
2466 t.Fatalf("second Open over the same path: %v", err)
2467 }
2468 defer r2.Close()
2469 got, err := r2.ListAliases()
2470 if err != nil {
2471 t.Fatalf("ListAliases: %v", err)
2472 }
2473 if len(got) != 1 || got[0].Alias != "extra.example.com" {
2474 t.Fatalf("route_aliases row lost across a second Open: %+v", got)
2475 }
2476}
2477
2478func TestWildcardDomains(t *testing.T) {
2479 r := openTestRegistry(t)
2480
2481 if err := r.UpsertWildcardDomain(WildcardDomain{Host: "woodstar.app", Account: "bab", App: "woodstar-relay", CreatedAt: 100}); err != nil {
2482 t.Fatal(err)
2483 }
2484 if err := r.UpsertWildcardDomain(WildcardDomain{Host: "example.org", Account: "bad", App: "other", CreatedAt: 200}); err != nil {
2485 t.Fatal(err)
2486 }
2487 // Upsert by host: same host, new owner fields win.
2488 if err := r.UpsertWildcardDomain(WildcardDomain{Host: "woodstar.app", Account: "bab", App: "woodstar-relay2", CreatedAt: 300}); err != nil {
2489 t.Fatal(err)
2490 }
2491 got, err := r.ListWildcardDomains()
2492 if err != nil {
2493 t.Fatal(err)
2494 }
2495 if len(got) != 2 || got[0].Host != "example.org" || got[1].Host != "woodstar.app" || got[1].App != "woodstar-relay2" {
2496 t.Fatalf("list = %+v", got)
2497 }
2498 if err := r.DeleteWildcardDomain("example.org"); err != nil {
2499 t.Fatal(err)
2500 }
2501 got, _ = r.ListWildcardDomains()
2502 if len(got) != 1 || got[0].Host != "woodstar.app" {
2503 t.Fatalf("after delete: %+v", got)
2504 }
2505}
2506
2507// TestDueInstancesIncludesSidecars pins the reason this task exists: a
2508// sidecar cannot be dialed, so its due-time wake is its ONLY schedule wake
2509// source. Left filtering kind='instance', DueInstances would store a
2510// sidecar's next_due and never once act on it.
2511func TestDueInstancesIncludesSidecars(t *testing.T) {
2512 r := openTestRegistry(t)
2513 if err := r.Upsert(Route{
2514 Host: "bot.eleven.bdm.sidecar.internal", Kind: "sidecar",
2515 DBPath: "/tmp/bot.db", Hibernate: true, Status: "ready",
2516 }); err != nil {
2517 t.Fatalf("upsert sidecar: %v", err)
2518 }
2519 if err := r.SetNextDue("bot.eleven.bdm.sidecar.internal", 100); err != nil {
2520 t.Fatalf("SetNextDue: %v", err)
2521 }
2522
2523 due, err := r.DueInstances(200)
2524 if err != nil {
2525 t.Fatalf("DueInstances: %v", err)
2526 }
2527 if len(due) != 1 || due[0].Host != "bot.eleven.bdm.sidecar.internal" {
2528 t.Fatalf("DueInstances = %+v, want the due sidecar — a sidecar's timer wake is its ONLY schedule wake source", due)
2529 }
2530}
2531
2532// TestClearNextDueIfConsumesOnlyTheValueThatFired pins both halves of the
2533// one-shot rule a sidecar's timer depends on: the fired value is consumed,
2534// and a value written since (the child's own /v1/next-due, landing while its
2535// wake was still resolving) is left standing.
2536func TestClearNextDueIfConsumesOnlyTheValueThatFired(t *testing.T) {
2537 r := openTestRegistry(t)
2538 host := "bot.eleven.bdm.sidecar.internal"
2539 if err := r.Upsert(Route{Host: host, Kind: "sidecar", DBPath: "/tmp/bot.db", Hibernate: true, Status: "ready"}); err != nil {
2540 t.Fatalf("upsert sidecar: %v", err)
2541 }
2542 if err := r.SetNextDue(host, 100); err != nil {
2543 t.Fatalf("SetNextDue: %v", err)
2544 }
2545 // A different value: not the one that fired, so it must survive.
2546 if err := r.ClearNextDueIf(host, 99); err != nil {
2547 t.Fatalf("ClearNextDueIf: %v", err)
2548 }
2549 if rt, err := r.Get(host); err != nil || rt.NextDue != 100 {
2550 t.Fatalf("next_due = %+v (%v), want 100 — a row that moved since the wake fired must be left alone", rt, err)
2551 }
2552 if err := r.ClearNextDueIf(host, 100); err != nil {
2553 t.Fatalf("ClearNextDueIf: %v", err)
2554 }
2555 if rt, err := r.Get(host); err != nil || rt.NextDue != 0 {
2556 t.Fatalf("next_due = %+v (%v), want 0 — a fired due time must be consumed by the wake it caused", rt, err)
2557 }
2558}
2559
2560// TestArgsRoundTrips checks Args survives Upsert and can be repointed
2561// narrowly by SetArgs, the door the sidecar reconcile pass uses when the
2562// app's manifest argv changes.
2563func TestArgsRoundTrips(t *testing.T) {
2564 r := openTestRegistry(t)
2565 if err := r.Upsert(Route{Host: "bot.x.internal", Kind: "sidecar", Args: `["sidecar","run"]`}); err != nil {
2566 t.Fatalf("upsert: %v", err)
2567 }
2568 got, err := r.Get("bot.x.internal")
2569 if err != nil {
2570 t.Fatalf("get: %v", err)
2571 }
2572 if got.Args != `["sidecar","run"]` {
2573 t.Fatalf("Args = %q, want the argv back", got.Args)
2574 }
2575
2576 if err := r.SetArgs("bot.x.internal", `["worker","run"]`); err != nil {
2577 t.Fatalf("SetArgs: %v", err)
2578 }
2579 got, _ = r.Get("bot.x.internal")
2580 if got.Args != `["worker","run"]` {
2581 t.Fatalf("Args after SetArgs = %q", got.Args)
2582 }
2583}
2584
2585// TestWorkerLimitsRoundTrip checks IdleSeconds/MaxRunSeconds survive Upsert
2586// and can be repointed narrowly by SetWorkerLimits — the sweeper reads these
2587// two columns locally on every tick, so a bucket read must never be in that
2588// hot path.
2589func TestWorkerLimitsRoundTrip(t *testing.T) {
2590 r := openTestRegistry(t)
2591 if err := r.Upsert(Route{Host: "bot.x.internal", Kind: "sidecar", IdleSeconds: 300, MaxRunSeconds: 3600}); err != nil {
2592 t.Fatalf("upsert: %v", err)
2593 }
2594 got, _ := r.Get("bot.x.internal")
2595 if got.IdleSeconds != 300 || got.MaxRunSeconds != 3600 {
2596 t.Fatalf("limits = %d/%d, want 300/3600 — the sweeper reads these locally on every tick", got.IdleSeconds, got.MaxRunSeconds)
2597 }
2598 if err := r.SetWorkerLimits("bot.x.internal", 60, 120); err != nil {
2599 t.Fatalf("SetWorkerLimits: %v", err)
2600 }
2601 got, _ = r.Get("bot.x.internal")
2602 if got.IdleSeconds != 60 || got.MaxRunSeconds != 120 {
2603 t.Fatalf("limits after set = %d/%d", got.IdleSeconds, got.MaxRunSeconds)
2604 }
2605}
2606
2607// TestRouteWrittenBeforeArgsStillParses pins the additive rule: a row
2608// written by code that predates this column has no args/idle/max-run value,
2609// and must still read back as the zero value rather than fail to parse.
2610func TestRouteWrittenBeforeArgsStillParses(t *testing.T) {
2611 r := openTestRegistry(t)
2612 if err := r.Upsert(Route{Host: "old.example.com", Kind: "instance"}); err != nil {
2613 t.Fatalf("upsert: %v", err)
2614 }
2615 got, err := r.Get("old.example.com")
2616 if err != nil {
2617 t.Fatalf("get: %v", err)
2618 }
2619 if got.Args != "" || got.IdleSeconds != 0 || got.MaxRunSeconds != 0 {
2620 t.Fatalf("row = %+v, want zero values for a pre-existing row", got)
2621 }
2622}
2623
2624// TestUpsertAppliesNewArgsAndLimits (fix round 1, 2026-08-17): a re-Upsert
2625// carrying a NEW non-empty args/idle/max_run over an existing row must land
2626// the new values — the sidecar reconcile pass re-Upserts on every pass when
2627// the app's manifest changes, and omission from the ON CONFLICT clause
2628// would silently ignore even a genuine new value, not merely preserve an
2629// old one.
2630func TestUpsertAppliesNewArgsAndLimits(t *testing.T) {
2631 r := openTestRegistry(t)
2632 if err := r.Upsert(Route{
2633 Host: "bot.y.internal", Kind: "sidecar",
2634 Args: `["sidecar","run"]`, IdleSeconds: 300, MaxRunSeconds: 3600,
2635 }); err != nil {
2636 t.Fatalf("first upsert: %v", err)
2637 }
2638 if err := r.Upsert(Route{
2639 Host: "bot.y.internal", Kind: "sidecar",
2640 Args: `["sidecar","run","-v"]`, IdleSeconds: 60, MaxRunSeconds: 120,
2641 }); err != nil {
2642 t.Fatalf("second upsert: %v", err)
2643 }
2644 got, err := r.Get("bot.y.internal")
2645 if err != nil {
2646 t.Fatalf("get: %v", err)
2647 }
2648 if got.Args != `["sidecar","run","-v"]` || got.IdleSeconds != 60 || got.MaxRunSeconds != 120 {
2649 t.Fatalf("row = %+v, want the second Upsert's new args/limits to have landed", got)
2650 }
2651}
2652
2653// TestUpsertPreservesArgsAndLimitsWhenCallerKnowsNothingOfThem (fix round 1,
2654// 2026-08-17): a re-Upsert carrying EMPTY args and ZERO ceilings — the
2655// `carlos add` shape, which knows nothing about sidecars — over a row that
2656// already has them must PRESERVE the existing values, not blank them. Same
2657// preserve-if-empty precedent as `seed` in the same ON CONFLICT clause.
2658func TestUpsertPreservesArgsAndLimitsWhenCallerKnowsNothingOfThem(t *testing.T) {
2659 r := openTestRegistry(t)
2660 if err := r.Upsert(Route{
2661 Host: "bot.z.internal", Kind: "sidecar",
2662 Args: `["sidecar","run"]`, IdleSeconds: 300, MaxRunSeconds: 3600,
2663 }); err != nil {
2664 t.Fatalf("first upsert: %v", err)
2665 }
2666 // The carlos-add shape: a full-row Upsert that names none of these
2667 // three columns at all.
2668 if err := r.Upsert(Route{Host: "bot.z.internal", Kind: "sidecar"}); err != nil {
2669 t.Fatalf("second upsert: %v", err)
2670 }
2671 got, err := r.Get("bot.z.internal")
2672 if err != nil {
2673 t.Fatalf("get: %v", err)
2674 }
2675 if got.Args != `["sidecar","run"]` || got.IdleSeconds != 300 || got.MaxRunSeconds != 3600 {
2676 t.Fatalf("row = %+v, want the original args/limits preserved, not blanked", got)
2677 }
2678}
2679
2680// #207: the snapshot gate must hold across the stamps the box rewrites on
2681// its own clock — otherwise every 5-minute tick is a "change" and the
2682// 8-generation history is 40 minutes deep instead of 8 changes deep. A
2683// real DNS status flip, by contrast, still moves it.
2684func TestSnapshotFingerprintIgnoresSchedulingChurn(t *testing.T) {
2685 reg := openTestRegistry(t)
2686 if err := reg.Upsert(Route{
2687 Host: "app.example.com", Kind: "instance", App: "app",
2688 Addr: "127.0.0.1:8091", Status: "ready",
2689 }); err != nil {
2690 t.Fatal(err)
2691 }
2692 if err := reg.SetDNSStatus("app.example.com", "ok", 1000); err != nil {
2693 t.Fatal(err)
2694 }
2695 before, err := reg.SnapshotFingerprint()
2696 if err != nil {
2697 t.Fatal(err)
2698 }
2699 if err := reg.SetNextDue("app.example.com", 4242); err != nil {
2700 t.Fatal(err)
2701 }
2702 if err := reg.SetDNSStatus("app.example.com", "ok", 2000); err != nil {
2703 t.Fatal(err)
2704 }
2705 after, err := reg.SnapshotFingerprint()
2706 if err != nil {
2707 t.Fatal(err)
2708 }
2709 if after != before {
2710 t.Fatalf("SnapshotFingerprint moved on next_due/dns_checked_at churn (%s -> %s) — the snapshot pass would write every tick", before, after)
2711 }
2712 if err := reg.SetDNSStatus("app.example.com", "mismatch", 3000); err != nil {
2713 t.Fatal(err)
2714 }
2715 flipped, err := reg.SnapshotFingerprint()
2716 if err != nil {
2717 t.Fatal(err)
2718 }
2719 if flipped == before {
2720 t.Fatal("a dns_status change is invisible to SnapshotFingerprint")
2721 }
2722}
2723
2724// The skip list is by column NAME, so a churn column must actually exist in
2725// routeCols — a rename would silently stop excluding it.
2726func TestSnapshotChurnColsExist(t *testing.T) {
2727 for col := range snapshotChurnCols {
2728 if !strings.Contains(routeCols, col) {
2729 t.Errorf("snapshotChurnCols names %q, which is not in routeCols", col)
2730 }
2731 }
2732}
2733
2734// Gate round-trips through Upsert (TestUpsertRoundTripsAllColumns is the
2735// column-shift canary; this pins the column's own semantics), a full-row
2736// Upsert with an empty gate PRESERVES a set one, and SetGate is the one
2737// door that clears it.
2738func TestGateColumnPreservedByUpsertClearedBySetGate(t *testing.T) {
2739 reg := openTestRegistry(t)
2740 if err := reg.Upsert(Route{Host: "g.example.com", Kind: "static", Status: "ready", Gate: "$2a$10$hash"}); err != nil {
2741 t.Fatal(err)
2742 }
2743 if got := mustRoute(t, reg, "g.example.com").Gate; got != "$2a$10$hash" {
2744 t.Fatalf("Gate after upsert = %q", got)
2745 }
2746 // A writer that knows nothing about the column must not open the host.
2747 if err := reg.Upsert(Route{Host: "g.example.com", Kind: "static", Status: "ready"}); err != nil {
2748 t.Fatal(err)
2749 }
2750 if got := mustRoute(t, reg, "g.example.com").Gate; got != "$2a$10$hash" {
2751 t.Fatalf("Gate after a gate-less upsert = %q, want it preserved", got)
2752 }
2753 if err := reg.SetGate("g.example.com", ""); err != nil {
2754 t.Fatal(err)
2755 }
2756 if got := mustRoute(t, reg, "g.example.com").Gate; got != "" {
2757 t.Fatalf("Gate after SetGate(\"\") = %q, want cleared", got)
2758 }
2759 if err := reg.SetGate("g.example.com", "$2a$10$other"); err != nil {
2760 t.Fatal(err)
2761 }
2762 if got := mustRoute(t, reg, "g.example.com").Gate; got != "$2a$10$other" {
2763 t.Fatalf("Gate after SetGate = %q", got)
2764 }
2765}
2766
2767func TestSetGateErrorsOnUnmatchedHost(t *testing.T) {
2768 reg := openTestRegistry(t)
2769 if err := reg.SetGate("nope.example.com", "$2a$10$hash"); err == nil {
2770 t.Fatal("SetGate on an unmatched host: want error, got nil")
2771 }
2772}
2773
2774// The edge reloads only when Fingerprint moves; a gate set or cleared with
2775// the column left out would take effect on the next unrelated reload — an
2776// operator turning the password off and watching the page keep asking.
2777func TestFingerprintSeesGateChanges(t *testing.T) {
2778 reg := openTestRegistry(t)
2779 if err := reg.Upsert(Route{Host: "g.example.com", Kind: "static", Status: "ready"}); err != nil {
2780 t.Fatal(err)
2781 }
2782 fp1, err := reg.Fingerprint()
2783 if err != nil {
2784 t.Fatal(err)
2785 }
2786 if err := reg.SetGate("g.example.com", "$2a$10$hash"); err != nil {
2787 t.Fatal(err)
2788 }
2789 fp2, err := reg.Fingerprint()
2790 if err != nil {
2791 t.Fatal(err)
2792 }
2793 if fp1 == fp2 {
2794 t.Fatal("gate change invisible to Fingerprint — the edge would keep serving the old answer")
2795 }
2796}
2797
2798func mustRoute(t *testing.T, reg *Registry, host string) Route {
2799 t.Helper()
2800 got, err := reg.Get(host)
2801 if err != nil {
2802 t.Fatal(err)
2803 }
2804 if got == nil {
2805 t.Fatalf("no route %s", host)
2806 }
2807 return *got
2808}
2809
2810// TestSetInstancePathsIsACompareAndSwapOnBothColumns: SetInstancePaths moves
2811// a row between the flat and the nested layout by one UPDATE guarded by BOTH
2812// path columns' current values, so a row a concurrent pass already moved,
2813// reverted or re-added is left exactly as it is rather than overwritten.
2814func TestSetInstancePathsIsACompareAndSwapOnBothColumns(t *testing.T) {
2815 r := openTestRegistry(t)
2816 if err := r.Upsert(Route{Host: "h.example.com", Kind: "instance", Socket: "/run/carlos/h.example.com.sock", DBPath: "/data/h.example.com.db"}); err != nil {
2817 t.Fatal(err)
2818 }
2819 if err := r.SetInstancePaths("h.example.com", "/data/h.example.com.db", "/run/carlos/h.example.com.sock", "/data/instances/h.example.com/h.example.com.db", "/run/carlos/instances/h.example.com/app.sock"); err != nil {
2820 t.Fatalf("CAS from the current values: %v", err)
2821 }
2822 rt := mustRoute(t, r, "h.example.com")
2823 if rt.DBPath != "/data/instances/h.example.com/h.example.com.db" || rt.Socket != "/run/carlos/instances/h.example.com/app.sock" {
2824 t.Fatalf("row after CAS = %q %q", rt.DBPath, rt.Socket)
2825 }
2826 // A second CAS from the OLD values misses on both columns and names the current ones.
2827 err := r.SetInstancePaths("h.example.com", "/data/h.example.com.db", "/run/carlos/h.example.com.sock", "/x", "/y")
2828 if !errors.Is(err, ErrPathsChanged) || !strings.Contains(err.Error(), "/data/instances/h.example.com/h.example.com.db") {
2829 t.Fatalf("stale CAS = %v", err)
2830 }
2831 // A CAS where only ONE column matches also misses: both must agree.
2832 err = r.SetInstancePaths("h.example.com", "/data/instances/h.example.com/h.example.com.db", "/run/carlos/h.example.com.sock", "/x", "/y")
2833 if !errors.Is(err, ErrPathsChanged) {
2834 t.Fatalf("half-matching CAS = %v", err)
2835 }
2836 if err := r.SetInstancePaths("absent.example.com", "a", "b", "c", "d"); err == nil || strings.Contains(err.Error(), "changed") {
2837 t.Fatalf("absent host = %v, want a no-route error", err)
2838 }
2839}
2840
2841// The good build (zero-downtime spec §8.5) has exactly one writer, the agent,
2842// and every write states what it expects to replace. A full-row Upsert from a
2843// caller that knows nothing about rollouts (carlos add, a re-mint) must never
2844// clear it: a lost good build is an instance with nothing to fall back to.
2845func TestGoodBuildIsWrittenOnlyByItsOwnDoor(t *testing.T) {
2846 reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
2847 if err != nil {
2848 t.Fatal(err)
2849 }
2850 const host = "a.example.com"
2851 if err := reg.Upsert(Route{Host: host, Kind: "instance", App: "blog", Version: "v1"}); err != nil {
2852 t.Fatal(err)
2853 }
2854 g1 := GoodBuild{Version: "v1", Digest: "aa11"}
2855 if err := reg.SetGoodBuild(host, GoodBuild{}, g1); err != nil {
2856 t.Fatalf("first write: %v", err)
2857 }
2858 err = reg.SetGoodBuild(host, GoodBuild{}, GoodBuild{Version: "v2", Digest: "bb22"})
2859 if !errors.Is(err, ErrGoodBuildChanged) {
2860 t.Fatalf("stale expectation: err = %v, want ErrGoodBuildChanged", err)
2861 }
2862 if err := reg.Upsert(Route{Host: host, Kind: "instance", App: "blog", Version: "v2"}); err != nil {
2863 t.Fatal(err)
2864 }
2865 got, err := reg.Get(host)
2866 if err != nil || got == nil {
2867 t.Fatalf("Get: %v, %v", got, err)
2868 }
2869 if got.Good() != g1 {
2870 t.Fatalf("Upsert changed the good build to %+v, want %+v", got.Good(), g1)
2871 }
2872 if got.Version != "v2" {
2873 t.Fatalf("Version = %q, want v2", got.Version)
2874 }
2875 g2 := GoodBuild{Version: "v2", Digest: "bb22", Demand: "hold"}
2876 if err := reg.SetGoodBuild(host, g1, g2); err != nil {
2877 t.Fatal(err)
2878 }
2879 routes, err := reg.List()
2880 if err != nil {
2881 t.Fatal(err)
2882 }
2883 if routes[0].Good() != g2 {
2884 t.Fatalf("List reads %+v, want %+v", routes[0].Good(), g2)
2885 }
2886 err = reg.SetGoodBuild("nope.example.com", GoodBuild{}, g1)
2887 if err == nil {
2888 t.Fatal("a missing row must be an error, not a silent no-op")
2889 }
2890 if errors.Is(err, ErrGoodBuildChanged) {
2891 t.Fatalf("a missing row is not a stale expectation: %v", err)
2892 }
2893}
2894
2895// serving_version moves on every wake and swap, with no operator change
2896// behind it, so it must not trigger a registry snapshot (#207's rule).
2897func TestServingVersionIsChurnForTheSnapshotGate(t *testing.T) {
2898 reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
2899 if err != nil {
2900 t.Fatal(err)
2901 }
2902 const host = "a.example.com"
2903 if err := reg.Upsert(Route{Host: host, Kind: "instance", App: "blog"}); err != nil {
2904 t.Fatal(err)
2905 }
2906 before, err := reg.SnapshotFingerprint()
2907 if err != nil {
2908 t.Fatal(err)
2909 }
2910 if err := reg.SetServingVersion(host, "v9"); err != nil {
2911 t.Fatal(err)
2912 }
2913 after, err := reg.SnapshotFingerprint()
2914 if err != nil {
2915 t.Fatal(err)
2916 }
2917 if before != after {
2918 t.Fatal("SetServingVersion moved the snapshot fingerprint")
2919 }
2920 got, _ := reg.Get(host)
2921 if got.ServingVersion != "v9" {
2922 t.Fatalf("ServingVersion = %q, want v9", got.ServingVersion)
2923 }
2924}
2925
2926// The good build is restore state, so a change to it must move the snapshot
2927// fingerprint, even though a registry that never set one hashes as before.
2928func TestGoodBuildMovesTheSnapshotFingerprint(t *testing.T) {
2929 reg, err := Open(filepath.Join(t.TempDir(), "registry.db"))
2930 if err != nil {
2931 t.Fatal(err)
2932 }
2933 const host = "a.example.com"
2934 if err := reg.Upsert(Route{Host: host, Kind: "instance", App: "blog"}); err != nil {
2935 t.Fatal(err)
2936 }
2937 before, err := reg.SnapshotFingerprint()
2938 if err != nil {
2939 t.Fatal(err)
2940 }
2941 if err := reg.SetGoodBuild(host, GoodBuild{}, GoodBuild{Version: "v1", Digest: "aa11"}); err != nil {
2942 t.Fatal(err)
2943 }
2944 after, err := reg.SnapshotFingerprint()
2945 if err != nil {
2946 t.Fatal(err)
2947 }
2948 if before == after {
2949 t.Fatal("a new good build did not move the snapshot fingerprint")
2950 }
2951}
2952