carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package provision
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "os"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/carlosframework/platform/internal/registry"
14 "github.com/carlosframework/platform/internal/release"
15)
16
17// A schema-1 record exactly as the flagship and tito buckets hold it today:
18// none of the shape fields exist. Every one must default to what reconcile
19// hardcoded before they were introduced — a non-nil Hibernate here, or a
20// Kind that does not fall back to "instance", rewrites the fleet on the
21// first pass after deploy. This is the additive wire-shape rule stated as a
22// test; do not relax it.
23func TestRecordWireShapeIsAdditive(t *testing.T) {
24 const legacy = `{"schema":1,"host":"acme.go.tito.io","slug":"acme","app":"tito",` +
25 `"account":"bab","pool":"tito","region":"ie","channel":"stable",` +
26 `"created_at":"2026-08-04T09:00:00Z"}`
27 var rec Record
28 if err := json.Unmarshal([]byte(legacy), &rec); err != nil {
29 t.Fatal(err)
30 }
31 if rec.Kind != "" {
32 t.Errorf("Kind = %q, want empty", rec.Kind)
33 }
34 if rec.Addr != "" {
35 t.Errorf("Addr = %q, want empty", rec.Addr)
36 }
37 if rec.Unit != "" {
38 t.Errorf("Unit = %q, want empty", rec.Unit)
39 }
40 if rec.Environment != "" {
41 t.Errorf("Environment = %q, want empty", rec.Environment)
42 }
43 if rec.StatusNamed != nil {
44 t.Errorf("StatusNamed = %v, want nil — absent and false are different facts here too", rec.StatusNamed)
45 }
46 if rec.StatusNamedOrDefault() {
47 t.Error("StatusNamedOrDefault() = true, want false — reconcile minted StatusNamed:false for these")
48 }
49 if rec.Hibernate != nil {
50 t.Errorf("Hibernate = %v, want nil — absent and false are different facts here", rec.Hibernate)
51 }
52 if got := rec.KindOrDefault(); got != "instance" {
53 t.Errorf("KindOrDefault() = %q, want instance", got)
54 }
55 if !rec.DerivesPaths() {
56 t.Error("DerivesPaths() = false, want true — a legacy record is a socket-backed instance")
57 }
58 if !rec.HibernateOrDefault() {
59 t.Error("HibernateOrDefault() = false, want true — reconcile mints Hibernate:true for these today")
60 }
61}
62
63// The console rewrites records (plan 2's PUT). A rewrite must not sprout
64// keys the reader before it did not know, so every added field is
65// omitempty and a record carrying none of them marshals to exactly what it
66// marshalled to before.
67func TestRecordMarshalOmitsAbsentShapeFields(t *testing.T) {
68 body, err := json.Marshal(Record{
69 Schema: 1, Host: "acme.go.tito.io", Slug: "acme", App: "tito",
70 Account: "bab", Pool: "tito", Region: "ie", Channel: "stable",
71 })
72 if err != nil {
73 t.Fatal(err)
74 }
75 for _, key := range []string{"kind", "addr", "unit", "hibernate", "environment", "status_named", "steering"} {
76 if strings.Contains(string(body), `"`+key+`"`) {
77 t.Errorf("marshalled record carries %q when it was never set: %s", key, body)
78 }
79 }
80}
81
82// TestRecordSteeringPrePlanFixtureParses is the plan's own wire-additivity
83// demand: a record HAND-TYPED as a binary that predates the steering field
84// would have written it — a current production-shaped record (schema-1 plus
85// the shape and multi-upstream fields, the vocabulary provision's own test
86// fixtures already exercise), verbatim bytes, never round-tripped through
87// this build's Record first. It must still parse, with Steering == "" — the
88// only value that reproduces what routeFor already mints for every record
89// that predates this plan: unsteered, today's static per-host answer.
90func TestRecordSteeringPrePlanFixtureParses(t *testing.T) {
91 const preplan = `{"schema":1,"host":"ha.go.tito.io","slug":"ha","app":"tito",` +
92 `"account":"bab","pool":"tito","region":"ie","channel":"stable",` +
93 `"created_at":"2026-08-12T09:00:00Z","kind":"service",` +
94 `"addrs":["127.0.0.1:7001","127.0.0.1:7002"],` +
95 `"probe":{"path":"/up","status":200},"environment":"demo",` +
96 `"status_named":true}`
97 var rec Record
98 if err := json.Unmarshal([]byte(preplan), &rec); err != nil {
99 t.Fatal(err)
100 }
101 if rec.Steering != "" {
102 t.Errorf("Steering = %q, want empty — a pre-plan record names no opinion", rec.Steering)
103 }
104 if !ValidSteering(rec.Steering) {
105 t.Error("the zero value must itself be valid — every record ever written must pass this door unchanged")
106 }
107 // Confirm nothing ELSE about the record's own established shape was
108 // disturbed by adding the new field.
109 if rec.Host != "ha.go.tito.io" || rec.Pool != "tito" || len(rec.Addrs) != 2 || rec.Environment != "demo" {
110 t.Fatalf("pre-plan fixture's own fields mis-parsed: %+v", rec)
111 }
112 // Round trip: re-marshaling must not sprout a "steering" key for a
113 // record that never carried one.
114 body, err := json.Marshal(rec)
115 if err != nil {
116 t.Fatal(err)
117 }
118 if strings.Contains(string(body), `"steering"`) {
119 t.Errorf("re-marshaled pre-plan record carries steering: %s", body)
120 }
121}
122
123func TestValidHost(t *testing.T) {
124 cases := []struct {
125 host, domain string
126 want bool
127 }{
128 {"acme.go.tito.io", "go.tito.io", true},
129 {"a.go.tito.io", "go.tito.io", true},
130 {"acme.go.tito.io", "other.io", false}, // wrong suffix
131 {"go.tito.io", "go.tito.io", false}, // no label
132 {"a.b.go.tito.io", "go.tito.io", false}, // two labels
133 {"Acme.go.tito.io", "go.tito.io", false}, // uppercase refused, not folded
134 {"-a.go.tito.io", "go.tito.io", false}, // leading hyphen
135 {"a-.go.tito.io", "go.tito.io", false}, // trailing hyphen
136 {"../x.go.tito.io", "go.tito.io", false}, // traversal shapes
137 {"a/b.go.tito.io", "go.tito.io", false},
138 {"acme.go.tito.io", "", false}, // empty domain never validates
139 {strings.Repeat("a", 64) + ".go.tito.io", "go.tito.io", false}, // label > 63
140 {strings.Repeat("a", 250) + ".go.tito.io", "go.tito.io", false}, // total > 253
141 }
142 for _, c := range cases {
143 if got := ValidHost(c.host, c.domain); got != c.want {
144 t.Errorf("ValidHost(%q, %q) = %v, want %v", c.host, c.domain, got, c.want)
145 }
146 }
147}
148
149func TestValidHostShape(t *testing.T) {
150 cases := []struct {
151 host string
152 want bool
153 }{
154 {"acme.go.tito.io", true},
155 {"a", true},
156 {"a..b", false},
157 {"a/b", false},
158 {".a", false},
159 {strings.Repeat("a", 254), false},
160 {"Acme", false},
161 }
162 for _, c := range cases {
163 if got := ValidHostShape(c.host); got != c.want {
164 t.Errorf("ValidHostShape(%q) = %v, want %v", c.host, got, c.want)
165 }
166 }
167}
168
169func TestValidRegion(t *testing.T) {
170 cases := []struct {
171 region string
172 want bool
173 }{
174 {"ie", true},
175 {"", true},
176 {"IE", false},
177 {"x/y", false},
178 {strings.Repeat("a", 17), false},
179 }
180 for _, c := range cases {
181 if got := ValidRegion(c.region); got != c.want {
182 t.Errorf("ValidRegion(%q) = %v, want %v", c.region, got, c.want)
183 }
184 }
185}
186
187func TestValidPool(t *testing.T) {
188 cases := []struct {
189 pool string
190 want bool
191 }{
192 {"ie", true},
193 {"", true},
194 {"IE", false},
195 {"x/y", false},
196 {strings.Repeat("a", 17), false},
197 }
198 for _, c := range cases {
199 if got := ValidPool(c.pool); got != c.want {
200 t.Errorf("ValidPool(%q) = %v, want %v", c.pool, got, c.want)
201 }
202 }
203}
204
205func TestRecordKeyRoundTrip(t *testing.T) {
206 key := RecordKey("bad", "tito", "acme.go.tito.io")
207 want := "apps/bad/tito/instances/acme.go.tito.io.json"
208 if key != want {
209 t.Fatalf("RecordKey = %q, want %q", key, want)
210 }
211
212 known := map[string]bool{"bad": true}
213 acct, app, host, ok := ParseRecordKey(key, known)
214 if !ok || acct != "bad" || app != "tito" || host != "acme.go.tito.io" {
215 t.Fatalf("ParseRecordKey(%q) = (%q, %q, %q, %v), want (bad, tito, acme.go.tito.io, true)", key, acct, app, host, ok)
216 }
217
218 if _, _, _, ok := ParseRecordKey(key, map[string]bool{}); ok {
219 t.Errorf("ParseRecordKey with unknown account should refuse, got ok=true")
220 }
221
222 if _, _, _, ok := ParseRecordKey(ManifestKey("bad", "tito"), known); ok {
223 t.Errorf("ParseRecordKey on the manifest key should refuse, got ok=true")
224 }
225
226 if _, _, _, ok := ParseRecordKey("apps/bad/tito/channels/stable.json", known); ok {
227 t.Errorf("ParseRecordKey on a channel pointer should refuse, got ok=true")
228 }
229
230 if _, _, _, ok := ParseRecordKey("apps/bad/tito/instances/sub/host.json", known); ok {
231 t.Errorf("ParseRecordKey with a nested slash after instances/ should refuse, got ok=true")
232 }
233}
234
235func TestCreateRecordCreateOnly(t *testing.T) {
236 ctx := context.Background()
237 s := release.NewDirStore(t.TempDir())
238 rec := Record{
239 Schema: 1, Host: "acme.go.tito.io", Slug: "acme", App: "tito", Account: "bad",
240 Channel: "stable", CreatedAt: time.Now().UTC(),
241 }
242
243 if err := CreateRecord(ctx, s, rec); err != nil {
244 t.Fatalf("first CreateRecord: %v", err)
245 }
246
247 err := CreateRecord(ctx, s, rec)
248 if err == nil {
249 t.Fatal("second CreateRecord: want error, got nil")
250 }
251 if !errors.Is(err, release.ErrCASConflict) {
252 t.Errorf("second CreateRecord error = %v, want wrapping release.ErrCASConflict", err)
253 }
254}
255
256// TestSetRecordRegion: an existing record's Region repoints in place — the
257// rest of the record (identity, CreatedAt/CreatedBy, Pool, Channel) is
258// untouched — and it can be called again to repoint a second time, unlike
259// CreateRecord's create-only contract. A host with no record answers
260// os.ErrNotExist verbatim.
261func TestSetRecordRegion(t *testing.T) {
262 ctx := context.Background()
263 s := release.NewDirStore(t.TempDir())
264 rec := Record{
265 Schema: 1, Host: "acme.go.tito.io", Slug: "acme", App: "tito", Account: "bad",
266 Pool: "tito", Region: "ie", Channel: "stable", CreatedAt: time.Now().UTC(), CreatedBy: "alice",
267 }
268 if err := CreateRecord(ctx, s, rec); err != nil {
269 t.Fatalf("CreateRecord: %v", err)
270 }
271
272 if err := SetRecordRegion(ctx, s, "bad", "tito", "acme.go.tito.io", "fleet/pi"); err != nil {
273 t.Fatalf("SetRecordRegion: %v", err)
274 }
275 got, err := GetRecord(ctx, s, "bad", "tito", "acme.go.tito.io")
276 if err != nil {
277 t.Fatalf("GetRecord: %v", err)
278 }
279 if got.Region != "fleet/pi" {
280 t.Fatalf("Region = %q, want fleet/pi", got.Region)
281 }
282 if got.Pool != rec.Pool || got.Channel != rec.Channel || got.CreatedBy != rec.CreatedBy || !got.CreatedAt.Equal(rec.CreatedAt) {
283 t.Fatalf("SetRecordRegion touched more than Region: got %+v, want the rest unchanged from %+v", got, rec)
284 }
285
286 // A second repoint (e.g. clearing placement back to unconstrained) is not
287 // a create-only conflict.
288 if err := SetRecordRegion(ctx, s, "bad", "tito", "acme.go.tito.io", ""); err != nil {
289 t.Fatalf("second SetRecordRegion: %v", err)
290 }
291 got, err = GetRecord(ctx, s, "bad", "tito", "acme.go.tito.io")
292 if err != nil {
293 t.Fatal(err)
294 }
295 if got.Region != "" {
296 t.Fatalf("Region after clearing = %q, want empty", got.Region)
297 }
298
299 if err := SetRecordRegion(ctx, s, "bad", "tito", "missing.go.tito.io", "fleet/pi"); !errors.Is(err, os.ErrNotExist) {
300 t.Fatalf("SetRecordRegion on a missing record: err = %v, want os.ErrNotExist", err)
301 }
302}
303
304// TestUpdateRecordGuardedByETag: the write-back half of the console's PATCH
305// door. Unlike CreateRecord (create-only, a lost race is a permanent 409)
306// and unlike SetRecordRegion (an internal read-modify-write that retries
307// once), this write is guarded by the etag the CALLER read the record under
308// and a lost race SURFACES — the member's request read a record, decided
309// what the new upstream set should be from it, and a silent retry would
310// re-apply that decision on top of a body it never saw.
311func TestUpdateRecordGuardedByETag(t *testing.T) {
312 ctx := context.Background()
313 s := release.NewDirStore(t.TempDir())
314 rec := Record{
315 Schema: 1, Host: "acme.go.tito.io", Slug: "acme", App: "tito", Account: "bad",
316 Kind: "service", Addr: "127.0.0.1:7000", Channel: "stable",
317 CreatedAt: time.Now().UTC(), CreatedBy: "alice",
318 }
319 if err := CreateRecord(ctx, s, rec); err != nil {
320 t.Fatalf("CreateRecord: %v", err)
321 }
322
323 got, etag, err := GetRecordETag(ctx, s, "bad", "tito", "acme.go.tito.io")
324 if err != nil {
325 t.Fatalf("GetRecordETag: %v", err)
326 }
327 if etag == "" {
328 t.Fatal("GetRecordETag returned no etag — a guarded write-back has nothing to guard on")
329 }
330 if got.Addr != "127.0.0.1:7000" {
331 t.Fatalf("GetRecordETag body = %+v", got)
332 }
333
334 got.Addr = ""
335 got.Addrs = []string{"127.0.0.1:7001", "127.0.0.1:7002"}
336 if err := UpdateRecord(ctx, s, *got, etag); err != nil {
337 t.Fatalf("UpdateRecord: %v", err)
338 }
339 after, err := GetRecord(ctx, s, "bad", "tito", "acme.go.tito.io")
340 if err != nil {
341 t.Fatal(err)
342 }
343 if len(after.Addrs) != 2 || after.Addrs[0] != "127.0.0.1:7001" || after.Addrs[1] != "127.0.0.1:7002" {
344 t.Fatalf("Addrs = %v, want the pair in order", after.Addrs)
345 }
346 if after.CreatedBy != rec.CreatedBy || !after.CreatedAt.Equal(rec.CreatedAt) || after.Channel != rec.Channel {
347 t.Fatalf("UpdateRecord touched the record's identity/provenance: %+v", after)
348 }
349
350 // The same (now stale) etag again is a lost race, surfaced — never a
351 // silent overwrite of whatever landed in between.
352 if err := UpdateRecord(ctx, s, *got, etag); !errors.Is(err, release.ErrCASConflict) {
353 t.Fatalf("UpdateRecord with a stale etag = %v, want release.ErrCASConflict", err)
354 }
355}
356
357func TestManifestRoundTrip(t *testing.T) {
358 ctx := context.Background()
359 s := release.NewDirStore(t.TempDir())
360 m := Manifest{
361 Schema: 1, App: "tito", Domain: "go.tito.io", Pool: "tito", Channel: "stable",
362 Regions: []string{"ie"}, Seed: []string{"titogo", "seed"},
363 }
364
365 if err := PutManifest(ctx, s, "bad", "tito", m); err != nil {
366 t.Fatalf("PutManifest: %v", err)
367 }
368
369 got, err := GetManifest(ctx, s, "bad", "tito")
370 if err != nil {
371 t.Fatalf("GetManifest: %v", err)
372 }
373 if got.App != m.App || got.Domain != m.Domain || got.Pool != m.Pool ||
374 got.Channel != m.Channel || len(got.Regions) != 1 || got.Regions[0] != "ie" ||
375 len(got.Seed) != 2 {
376 t.Errorf("GetManifest round trip mismatch: got %+v, want %+v", got, m)
377 }
378
379 // Update: PutManifest twice must succeed (CAS upsert, not create-only).
380 m.Channel = "canary/x"
381 if err := PutManifest(ctx, s, "bad", "tito", m); err != nil {
382 t.Fatalf("second PutManifest (update): %v", err)
383 }
384 got, err = GetManifest(ctx, s, "bad", "tito")
385 if err != nil {
386 t.Fatalf("GetManifest after update: %v", err)
387 }
388 if got.Channel != "canary/x" {
389 t.Errorf("GetManifest after update: Channel = %q, want canary/x", got.Channel)
390 }
391
392 // Absence.
393 if _, err := GetManifest(ctx, s, "bad", "missing"); !errors.Is(err, os.ErrNotExist) {
394 t.Errorf("GetManifest on absence: err = %v, want os.ErrNotExist", err)
395 }
396
397 // A newer-schema body is refused on Get.
398 newer := []byte(`{"schema":2,"app":"tito","domain":"go.tito.io"}`)
399 if _, err := s.Put(ctx, ManifestKey("bad", "future"), newer, ""); err != nil {
400 t.Fatalf("seeding schema-2 manifest: %v", err)
401 }
402 if _, err := GetManifest(ctx, s, "bad", "future"); err == nil {
403 t.Error("GetManifest on schema 2: want error, got nil")
404 }
405}
406
407// Astra plan 6 R3: the deploy watch must know when the list it was handed is
408// not the whole app, so a record that cannot be read is named, not dropped
409// silently.
410func TestListRecordsCompleteNamesTheSkippedHost(t *testing.T) {
411 ctx := context.Background()
412 s := release.NewDirStore(t.TempDir())
413 rec := Record{Schema: 1, Host: "a.example.com", Slug: "a", App: "app", Account: "acct", Channel: "stable", CreatedAt: time.Now().UTC()}
414 if err := CreateRecord(ctx, s, rec); err != nil {
415 t.Fatalf("CreateRecord: %v", err)
416 }
417 if _, err := s.Put(ctx, RecordKey("acct", "app", "b.example.com"), []byte("not json"), ""); err != nil {
418 t.Fatal(err)
419 }
420 recs, truncated, skipped, err := ListRecordsComplete(ctx, s, "acct", "app", 10)
421 if err != nil || truncated || len(recs) != 1 || len(skipped) != 1 || skipped[0] != "b.example.com" {
422 t.Fatalf("recs %d truncated %v skipped %v err %v", len(recs), truncated, skipped, err)
423 }
424 // ListRecords is the same list without the skipped names.
425 recs, truncated, err = ListRecords(ctx, s, "acct", "app", 10)
426 if err != nil || truncated || len(recs) != 1 {
427 t.Fatalf("ListRecords: recs %d truncated %v err %v", len(recs), truncated, err)
428 }
429}
430
431func TestListRecords(t *testing.T) {
432 ctx := context.Background()
433 s := release.NewDirStore(t.TempDir())
434
435 hosts := []string{"c.go.tito.io", "a.go.tito.io", "b.go.tito.io"}
436 for _, h := range hosts {
437 rec := Record{Schema: 1, Host: h, Slug: h[:1], App: "tito", Account: "bad", Channel: "stable", CreatedAt: time.Now().UTC()}
438 if err := CreateRecord(ctx, s, rec); err != nil {
439 t.Fatalf("CreateRecord(%s): %v", h, err)
440 }
441 }
442 // The manifest sits at the app prefix, not under instances/ — must never
443 // be mistaken for a record.
444 m := Manifest{Schema: 1, App: "tito", Domain: "go.tito.io"}
445 if err := PutManifest(ctx, s, "bad", "tito", m); err != nil {
446 t.Fatalf("PutManifest: %v", err)
447 }
448
449 recs, truncated, err := ListRecords(ctx, s, "bad", "tito", 10)
450 if err != nil {
451 t.Fatalf("ListRecords: %v", err)
452 }
453 if truncated {
454 t.Error("ListRecords: truncated = true, want false")
455 }
456 if len(recs) != 3 {
457 t.Fatalf("ListRecords: got %d records, want 3", len(recs))
458 }
459 for i, want := range []string{"a.go.tito.io", "b.go.tito.io", "c.go.tito.io"} {
460 if recs[i].Host != want {
461 t.Errorf("ListRecords[%d].Host = %q, want %q (not sorted)", i, recs[i].Host, want)
462 }
463 }
464
465 recs, truncated, err = ListRecords(ctx, s, "bad", "tito", 2)
466 if err != nil {
467 t.Fatalf("ListRecords with max=2: %v", err)
468 }
469 if !truncated {
470 t.Error("ListRecords with max=2: truncated = false, want true")
471 }
472 if len(recs) != 2 {
473 t.Fatalf("ListRecords with max=2: got %d records, want 2", len(recs))
474 }
475
476 // A malformed record body is skipped, not fatal.
477 badKey := RecordKey("bad", "tito", "z.go.tito.io")
478 if _, err := s.Put(ctx, badKey, []byte("not json"), ""); err != nil {
479 t.Fatalf("seeding malformed record: %v", err)
480 }
481 recs, _, err = ListRecords(ctx, s, "bad", "tito", 10)
482 if err != nil {
483 t.Fatalf("ListRecords with a malformed body: %v", err)
484 }
485 if len(recs) != 3 {
486 t.Fatalf("ListRecords with a malformed body: got %d records, want 3 (malformed skipped)", len(recs))
487 }
488
489 // max=0 and a negative max must both degrade to "list nothing", never
490 // panic on the slice bound — a console handler hands this an
491 // unvalidated int.
492 for _, max := range []int{0, -1} {
493 recs, truncated, err := ListRecords(ctx, s, "bad", "tito", max)
494 if err != nil {
495 t.Fatalf("ListRecords with max=%d: %v", max, err)
496 }
497 if len(recs) != 0 {
498 t.Errorf("ListRecords with max=%d: got %d records, want 0", max, len(recs))
499 }
500 if !truncated {
501 t.Errorf("ListRecords with max=%d: truncated = false, want true (keys exist)", max)
502 }
503 }
504}
505
506func TestGetRecord(t *testing.T) {
507 ctx := context.Background()
508 s := release.NewDirStore(t.TempDir())
509 rec := Record{Schema: 1, Host: "acme.go.tito.io", Slug: "acme", App: "tito", Account: "bad", Channel: "stable", CreatedAt: time.Now().UTC()}
510 if err := CreateRecord(ctx, s, rec); err != nil {
511 t.Fatalf("CreateRecord: %v", err)
512 }
513
514 got, err := GetRecord(ctx, s, "bad", "tito", "acme.go.tito.io")
515 if err != nil {
516 t.Fatalf("GetRecord: %v", err)
517 }
518 if got.Host != rec.Host || got.App != rec.App || got.Account != rec.Account {
519 t.Errorf("GetRecord = %+v, want %+v", got, rec)
520 }
521
522 if _, err := GetRecord(ctx, s, "bad", "tito", "missing.go.tito.io"); !errors.Is(err, os.ErrNotExist) {
523 t.Errorf("GetRecord on absence: err = %v, want os.ErrNotExist", err)
524 }
525
526 // A body that disagrees with its own key path is refused, not guessed at.
527 body, _ := json.Marshal(Record{Schema: 1, Host: "other.go.tito.io", App: "tito", Account: "bad"})
528 if _, err := s.Put(ctx, RecordKey("bad", "tito", "liar.go.tito.io"), body, ""); err != nil {
529 t.Fatalf("seeding disagreeing record: %v", err)
530 }
531 if _, err := GetRecord(ctx, s, "bad", "tito", "liar.go.tito.io"); err == nil {
532 t.Error("GetRecord on a disagreeing body: want error, got nil")
533 }
534}
535
536func TestDeleteRecordIdempotent(t *testing.T) {
537 ctx := context.Background()
538 s := release.NewDirStore(t.TempDir())
539 rec := Record{Schema: 1, Host: "acme.go.tito.io", App: "tito", Account: "bad", Channel: "stable"}
540 if err := CreateRecord(ctx, s, rec); err != nil {
541 t.Fatalf("CreateRecord: %v", err)
542 }
543 if err := DeleteRecord(ctx, s, "bad", "tito", "acme.go.tito.io"); err != nil {
544 t.Fatalf("DeleteRecord: %v", err)
545 }
546 if _, err := GetRecord(ctx, s, "bad", "tito", "acme.go.tito.io"); !errors.Is(err, os.ErrNotExist) {
547 t.Errorf("GetRecord after delete: err = %v, want os.ErrNotExist", err)
548 }
549 // Deleting again is not an error (Store's idempotent-delete contract).
550 if err := DeleteRecord(ctx, s, "bad", "tito", "acme.go.tito.io"); err != nil {
551 t.Errorf("second DeleteRecord: %v, want nil (idempotent)", err)
552 }
553}
554
555func TestDefaults(t *testing.T) {
556 if got := DefaultSocket("h"); got != "/run/carlos/h.sock" {
557 t.Errorf("DefaultSocket(%q) = %q, want /run/carlos/h.sock", "h", got)
558 }
559 if got := DefaultDBPath("/data", "h"); got != "/data/h.db" {
560 t.Errorf("DefaultDBPath(%q, %q) = %q, want /data/h.db", "/data", "h", got)
561 }
562 // CARLOS_DATA_DIR comes from an env var an operator types; a trailing
563 // slash is the ordinary way to type a directory and must not produce
564 // "/data//h.db" — a second path for the same database, which is how a
565 // route and litestream's box-wide config come to disagree about which
566 // file they are replicating.
567 if got := DefaultDBPath("/data/", "h"); got != "/data/h.db" {
568 t.Errorf("DefaultDBPath(%q, %q) = %q, want /data/h.db", "/data/", "h", got)
569 }
570}
571
572// TestPutManifestIfMatch pins the guarded write's two properties: etag ""
573// is CREATE-ONLY (a manifest landing between a caller's read-nothing and
574// its write surfaces as the conflict it is, never an overwrite), and a
575// stale etag — the caller's read racing somebody else's update — is refused
576// the same way. This is what makes the console enable door's domain check
577// honest: PutManifest's upsert re-reads inside and the last writer wins,
578// which is exactly the wrong property when the write was authorized by
579// what the FIRST read said.
580func TestPutManifestIfMatch(t *testing.T) {
581 ctx := context.Background()
582 s := release.NewDirStore(t.TempDir())
583 m := Manifest{Schema: 1, App: "tito", Domain: "go.tito.io"}
584
585 if err := PutManifestIfMatch(ctx, s, "bad", "tito", m, ""); err != nil {
586 t.Fatalf("create-only put: %v", err)
587 }
588 if err := PutManifestIfMatch(ctx, s, "bad", "tito", m, ""); !errors.Is(err, release.ErrCASConflict) {
589 t.Fatalf("second create-only put: err = %v, want ErrCASConflict", err)
590 }
591
592 got, etag, err := GetManifestETag(ctx, s, "bad", "tito")
593 if err != nil || etag == "" {
594 t.Fatalf("GetManifestETag: %+v %q %v, want a manifest with an etag", got, etag, err)
595 }
596 got.Channel = "beta"
597 if err := PutManifestIfMatch(ctx, s, "bad", "tito", *got, etag); err != nil {
598 t.Fatalf("guarded update: %v", err)
599 }
600 // The first read's etag is now stale; a write still guarded by it must
601 // lose, not overwrite the update that beat it.
602 got.Channel = "edge"
603 if err := PutManifestIfMatch(ctx, s, "bad", "tito", *got, etag); !errors.Is(err, release.ErrCASConflict) {
604 t.Fatalf("stale-etag put: err = %v, want ErrCASConflict", err)
605 }
606 final, err := GetManifest(ctx, s, "bad", "tito")
607 if err != nil || final.Channel != "beta" {
608 t.Fatalf("manifest after stale write = %+v %v, want the guarded update's beta", final, err)
609 }
610}
611
612func TestValidKind(t *testing.T) {
613 for _, k := range []string{"", "instance", "service", "static"} {
614 if !ValidKind(k) {
615 t.Errorf("ValidKind(%q) = false, want true", k)
616 }
617 }
618 // "status" is the platform's own status-page class, not an app's.
619 for _, k := range []string{"status", "Instance", "unti", "instance "} {
620 if ValidKind(k) {
621 t.Errorf("ValidKind(%q) = true, want false", k)
622 }
623 }
624}
625
626func TestValidSteering(t *testing.T) {
627 for _, s := range []string{"", "latency"} {
628 if !ValidSteering(s) {
629 t.Errorf("ValidSteering(%q) = false, want true", s)
630 }
631 }
632 for _, s := range []string{"Latency", "geo", "LATENCY", " latency", "latency "} {
633 if ValidSteering(s) {
634 t.Errorf("ValidSteering(%q) = true, want false", s)
635 }
636 }
637}
638
639func TestValidateShape(t *testing.T) {
640 yes := true
641 base := Record{Schema: 1, Host: "acme.go.tito.io", Slug: "acme", App: "tito",
642 Account: "bab", Channel: "stable"}
643 with := func(f func(*Record)) Record {
644 r := base
645 f(&r)
646 return r
647 }
648 ok := []struct {
649 name string
650 rec Record
651 }{
652 {"legacy instance", base},
653 {"addr tenant", with(func(r *Record) { r.Addr = "127.0.0.1:8090" })},
654 {"unit-owned tenant", with(func(r *Record) { r.Addr = "127.0.0.1:8090"; r.Unit = "console.service" })},
655 {"service with addr", with(func(r *Record) { r.Kind = "service"; r.Addr = "127.0.0.1:8090" })},
656 {"static", with(func(r *Record) { r.Kind = "static" })},
657 {"explicitly non-hibernating instance", with(func(r *Record) { r.Hibernate = new(bool) })},
658 {"steered instance", with(func(r *Record) { r.Steering = "latency" })},
659 }
660 for _, c := range ok {
661 if err := ValidateShape(c.rec); err != nil {
662 t.Errorf("%s: ValidateShape = %v, want nil", c.name, err)
663 }
664 }
665 bad := []struct {
666 name string
667 rec Record
668 }{
669 {"bogus kind", with(func(r *Record) { r.Kind = "unti" })},
670 {"status kind", with(func(r *Record) { r.Kind = "status" })},
671 {"addr without port", with(func(r *Record) { r.Addr = "127.0.0.1" })},
672 {"addr with garbage port", with(func(r *Record) { r.Addr = "127.0.0.1:abc" })},
673 {"unit typo", with(func(r *Record) { r.Unit = "console" })},
674 {"bad environment name", with(func(r *Record) { r.Environment = "Demo" })},
675 {"bad steering value", with(func(r *Record) { r.Steering = "geo" })},
676 {"service with no addr", with(func(r *Record) { r.Kind = "service" })},
677 {"static with addr", with(func(r *Record) { r.Kind = "static"; r.Addr = "127.0.0.1:8090" })},
678 {"static with unit", with(func(r *Record) { r.Kind = "static"; r.Unit = "console.service" })},
679 {"static hibernating", with(func(r *Record) { r.Kind = "static"; r.Hibernate = &yes })},
680 // The same wedge from the other side: edge's activator gate is
681 // `Kind == "instance" && Hibernate`, which Addr does not exclude, so
682 // this route would be armed for a wake it has no socket for.
683 {"addr hibernating", with(func(r *Record) { r.Addr = "127.0.0.1:8090"; r.Hibernate = &yes })},
684 {"addr service hibernating", with(func(r *Record) {
685 r.Kind = "service"
686 r.Addr = "127.0.0.1:8090"
687 r.Hibernate = &yes
688 })},
689 }
690 for _, c := range bad {
691 if err := ValidateShape(c.rec); err == nil {
692 t.Errorf("%s: ValidateShape = nil, want a refusal", c.name)
693 }
694 }
695}
696
697// Design §3.2: the suffix pin stays exactly where the traversal danger is,
698// and nowhere else.
699func TestValidRecordHostFollowsPathDerivation(t *testing.T) {
700 socketBacked := Record{Host: "acme.go.tito.io"}
701 if !ValidRecordHost(socketBacked, "go.tito.io") {
702 t.Error("a socket-backed instance under its own domain was refused")
703 }
704 offDomain := Record{Host: "carloku.com"}
705 if ValidRecordHost(offDomain, "go.tito.io") {
706 t.Error("a socket-backed instance escaped its domain suffix — that is the traversal rail")
707 }
708 static := Record{Host: "carloku.com", Kind: "static"}
709 if !ValidRecordHost(static, "go.tito.io") {
710 t.Error("a static apex was refused; it derives no path and needs shape validation only")
711 }
712 addrBacked := Record{Host: "console.platform.tito.io", Addr: "127.0.0.1:8090"}
713 if !ValidRecordHost(addrBacked, "go.tito.io") {
714 t.Error("an addr-backed tenant off-domain was refused; it derives no path")
715 }
716 hostile := Record{Host: "../../etc/passwd", Kind: "static"}
717 if ValidRecordHost(hostile, "go.tito.io") {
718 t.Error("a hostile host passed the shape check")
719 }
720}
721
722func TestAddrsOrDefaultFoldsLegacyAddr(t *testing.T) {
723 one := Record{Addr: "10.0.0.1:3000"}
724 if got := one.AddrsOrDefault(); len(got) != 1 || got[0] != "10.0.0.1:3000" {
725 t.Fatalf("legacy fold = %v", got)
726 }
727 many := Record{Addrs: []string{"10.0.0.1:3000", "10.0.0.2:3000"}}
728 if got := many.AddrsOrDefault(); len(got) != 2 {
729 t.Fatalf("addrs = %v", got)
730 }
731 if got := (Record{}).AddrsOrDefault(); got != nil {
732 t.Fatalf("empty = %v, want nil", got)
733 }
734}
735
736func TestHealthDefaults(t *testing.T) {
737 h := Health{}.WithDefaults()
738 if h.Path != "/" || h.Status != 200 || h.IntervalMS != 2000 || h.FailAfter != 3 || h.RiseAfter != 2 {
739 t.Fatalf("defaults = %+v", h)
740 }
741 // Multi-addr with no explicit health implies default probing.
742 if (Record{Addrs: []string{"a:1", "b:1"}}).HealthOrDefault() == nil {
743 t.Fatal("multi-addr record must default to probed")
744 }
745 // Single legacy addr with no health stays unprobed (today's behavior).
746 if (Record{Addr: "a:1"}).HealthOrDefault() != nil {
747 t.Fatal("legacy single-addr record must stay unprobed")
748 }
749}
750
751func TestValidateShapeMultiAddr(t *testing.T) {
752 base := Record{Host: "x", Kind: "service"}
753 ok := base
754 ok.Addrs = []string{"10.0.0.1:3000", "10.0.0.2:3000"}
755 if err := ValidateShape(ok); err != nil {
756 t.Fatalf("valid multi-addr rejected: %v", err)
757 }
758 bad := base
759 bad.Addrs = []string{"10.0.0.1:3000", "not-an-addr"}
760 if err := ValidateShape(bad); err == nil {
761 t.Fatal("bad element accepted")
762 }
763 both := base
764 both.Addr, both.Addrs = "10.0.0.1:3000", []string{"10.0.0.2:3000"}
765 if err := ValidateShape(both); err == nil {
766 t.Fatal("addr AND addrs together must be rejected — one shape per record")
767 }
768 hib := true
769 sleepy := base
770 sleepy.Addrs, sleepy.Hibernate = []string{"10.0.0.1:3000"}, &hib
771 if err := ValidateShape(sleepy); err == nil {
772 t.Fatal("addrs with hibernate must be rejected (wake-safety invariant)")
773 }
774 dup := base
775 dup.Addrs = []string{"10.0.0.1:3000", "10.0.0.1:3000"}
776 if err := ValidateShape(dup); err == nil {
777 t.Fatal("duplicate upstream must be rejected")
778 }
779}
780
781// TestValidateShapeRefusesLinkLocalUpstream (ruling 2026-08-12): an upstream
782// is an address the EDGE connects to on a caller's behalf, so 169.254.0.0/16
783// — the cloud instance metadata service and its neighbours — is refused at
784// the same door the port range is, in both the legacy Addr and the Addrs
785// spelling.
786//
787// The rail is deliberately MINIMAL: loopback and RFC1918 stay allowed,
788// because that is where every legitimate addr-backed route on the fleet
789// points today (127.0.0.1:8090 consoles, private-subnet tenants). Widening it
790// is a separate ruling, not a tidy-up.
791func TestValidateShapeRefusesLinkLocalUpstream(t *testing.T) {
792 for _, addr := range []string{"169.254.169.254:80", "169.254.0.1:8080"} {
793 if err := ValidateShape(Record{Host: "x", Kind: "service", Addr: addr}); err == nil {
794 t.Errorf("legacy addr %s accepted, want it refused as link-local", addr)
795 } else if !strings.Contains(err.Error(), "link-local") {
796 t.Errorf("refusal for %s = %v, want it to say link-local", addr, err)
797 }
798 if err := ValidateShape(Record{Host: "x", Kind: "service",
799 Addrs: []string{"10.0.0.1:3000", addr}}); err == nil {
800 t.Errorf("addrs element %s accepted, want it refused as link-local", addr)
801 }
802 }
803 // The allowed neighbours, stated so a later widening has to break a test
804 // rather than slip through: these are live production shapes.
805 for _, addr := range []string{"127.0.0.1:8090", "10.0.0.1:3000", "192.168.1.5:8080", "172.16.0.9:443",
806 "169.254.169.254.example.com:80", "backend.internal:8080"} {
807 if err := ValidateShape(Record{Host: "x", Kind: "service", Addr: addr}); err != nil {
808 t.Errorf("addr %s refused (%v), want it allowed — this rail is metadata-service only", addr, err)
809 }
810 }
811}
812
813// TestAddrsElementsMustBeIPLiterals closes the hole the link-local rail had
814// while it only ran on a parsed IP: a NAME skipped the check entirely, so
815// "md.attacker.example:80" resolving to the metadata service walked through
816// the door the rail exists to guard. The new Addrs set therefore takes IP
817// literals only — the platform dials these addresses itself, and a name is
818// free to resolve somewhere else after the door said yes.
819//
820// The rule is scoped to Addrs BY DESIGN (wire-compat): no record in any
821// bucket carries Addrs yet, while legacy Addr records naming a hostname do
822// exist, and those must keep validating. That asymmetry is pinned below.
823func TestAddrsElementsMustBeIPLiterals(t *testing.T) {
824 svc := func(addrs ...string) Record {
825 return Record{Host: "x", Kind: "service", Addrs: addrs}
826 }
827 for _, addr := range []string{"md.attacker.example:80", "backend.internal:8080", "localhost:8090"} {
828 if err := ValidateShape(svc("10.0.0.1:3000", addr)); err == nil {
829 t.Errorf("addrs element %s accepted, want it refused as a name", addr)
830 } else if !strings.Contains(err.Error(), "names a host") {
831 t.Errorf("refusal for %s = %v, want it to say the element names a host", addr, err)
832 }
833 }
834 // Link-local in the OTHER family: the same SSRF, IPv6 clothes. v4
835 // link-local is covered by TestValidateShapeRefusesLinkLocalUpstream.
836 for _, addr := range []string{"[fe80::1]:80", "[fe80::a00:27ff:fe4e:66a1]:8080", "[ff02::1]:80"} {
837 if err := ValidateShape(svc(addr)); err == nil {
838 t.Errorf("addrs element %s accepted, want it refused as link-local", addr)
839 } else if !strings.Contains(err.Error(), "link-local") {
840 t.Errorf("refusal for %s = %v, want it to say link-local", addr, err)
841 }
842 }
843 // Loopback and RFC1918 literals stay allowed in the Addrs spelling too —
844 // the rail closed the name hole, it did not narrow what an upstream may be.
845 for _, addr := range []string{"127.0.0.1:8090", "10.0.0.1:3000", "192.168.1.5:8080", "172.16.0.9:443", "[::1]:8090"} {
846 if err := ValidateShape(svc(addr)); err != nil {
847 t.Errorf("addrs element %s refused (%v), want it allowed", addr, err)
848 }
849 }
850 // The wire-compat half, stated explicitly: a legacy single Addr naming a
851 // hostname keeps validating. Records like this are in production buckets;
852 // tightening this line stops them materializing.
853 if err := ValidateShape(Record{Host: "x", Kind: "service", Addr: "backend.internal:8080"}); err != nil {
854 t.Errorf("legacy addr with a hostname refused (%v) — records in live buckets carry that shape", err)
855 }
856}
857
858// TestValidateShapeCapsUpstreams: the prober walks a host's upstreams
859// sequentially per tick and the design is sized for a handful of backends per
860// route, so the set has an upper bound at the door.
861func TestValidateShapeCapsUpstreams(t *testing.T) {
862 addrs := func(n int) []string {
863 out := make([]string, n)
864 for i := range out {
865 out[i] = fmt.Sprintf("10.0.%d.%d:3000", i/250, i%250+1)
866 }
867 return out
868 }
869 if err := ValidateShape(Record{Host: "x", Kind: "service", Addrs: addrs(MaxUpstreams)}); err != nil {
870 t.Errorf("%d upstreams refused (%v), want the cap itself allowed", MaxUpstreams, err)
871 }
872 err := ValidateShape(Record{Host: "x", Kind: "service", Addrs: addrs(MaxUpstreams + 1)})
873 if err == nil {
874 t.Fatalf("%d upstreams accepted, want a refusal", MaxUpstreams+1)
875 }
876 if !strings.Contains(err.Error(), fmt.Sprint(MaxUpstreams)) {
877 t.Errorf("refusal = %v, want it to name the limit (%d)", err, MaxUpstreams)
878 }
879}
880
881func TestLegacyRecordStillParses(t *testing.T) {
882 // A record written before addrs/health existed — byte-for-byte old wire shape.
883 raw := []byte(`{"schema":1,"host":"a.example.com","slug":"a","app":"web","account":"bab","channel":"stable","created_at":"2026-08-01T00:00:00Z","kind":"service","addr":"10.0.0.1:3000"}`)
884 var rec Record
885 if err := json.Unmarshal(raw, &rec); err != nil {
886 t.Fatal(err)
887 }
888 if rec.Addrs != nil || rec.Health != nil {
889 t.Fatalf("legacy record grew fields: %+v", rec)
890 }
891 if got := rec.AddrsOrDefault(); len(got) != 1 || got[0] != "10.0.0.1:3000" {
892 t.Fatalf("legacy fold = %v", got)
893 }
894}
895
896// TestHealthWireTagIsProbeNotHealth pins Record.Health's wire key to
897// "probe": the console's instance-list API already ships "health" for a
898// computed one-word status string (instanceListRecord.Health, parsed by a
899// deployed CLI), so this field must never collide with it. A well-meaning
900// rename back to "health" would silently break that shipped surface.
901func TestHealthWireTagIsProbeNotHealth(t *testing.T) {
902 rec := Record{Host: "x", App: "web", Account: "bab", Channel: "stable", Health: &Health{Path: "/up"}}
903 b, err := json.Marshal(rec)
904 if err != nil {
905 t.Fatal(err)
906 }
907 if !strings.Contains(string(b), `"probe":`) {
908 t.Fatalf("marshal dropped the probe key: %s", b)
909 }
910 if strings.Contains(string(b), `"health":`) {
911 t.Fatalf("marshal used the collided health key: %s", b)
912 }
913}
914
915// The move transitions (issue #331): SetRecordMove is the one writer of
916// Record.Move, and each transition validates the state it leaves from. The
917// terminal MoveComplete flips Region and clears Move in ONE write.
918func TestSetRecordMoveTransitions(t *testing.T) {
919 ctx := context.Background()
920 s := release.NewDirStore(t.TempDir())
921 rec := Record{Schema: 1, Host: "a.go.example.com", Slug: "a", App: "app", Account: "bab",
922 Region: "ie", Channel: "stable", CreatedAt: time.Now().UTC()}
923 if err := CreateRecord(ctx, s, rec); err != nil {
924 t.Fatal(err)
925 }
926 mv := func(mut func(*Record) error) error {
927 _, err := SetRecordMove(ctx, s, "bab", "app", "a.go.example.com", mut)
928 return err
929 }
930 read := func() Record {
931 t.Helper()
932 got, err := GetRecord(ctx, s, "bab", "app", "a.go.example.com")
933 if err != nil {
934 t.Fatal(err)
935 }
936 return *got
937 }
938 now := time.Now()
939
940 if err := mv(MoveRequest("ie", now)); !errors.Is(err, ErrMoveConflict) {
941 t.Fatalf("move to the current region = %v, want ErrMoveConflict", err)
942 }
943 if err := mv(MoveMarkCutting()); !errors.Is(err, ErrMoveConflict) {
944 t.Fatalf("cut with no move in flight = %v, want ErrMoveConflict", err)
945 }
946 if err := mv(MoveRequest("uk", now)); err != nil {
947 t.Fatal(err)
948 }
949 if got := read(); got.Move == nil || got.Move.State != MovePreparing || got.Move.To != "uk" || got.Region != "ie" {
950 t.Fatalf("after request: %+v", got.Move)
951 }
952 if err := mv(MoveRequest("se", now)); !errors.Is(err, ErrMoveConflict) {
953 t.Fatalf("second request while in flight = %v, want ErrMoveConflict", err)
954 }
955 if err := mv(MoveMarkCutting()); !errors.Is(err, ErrMoveConflict) {
956 t.Fatalf("cut from preparing = %v, want ErrMoveConflict", err)
957 }
958 if err := mv(MovePromoteReady(now)); err != nil {
959 t.Fatal(err)
960 }
961 if got := read(); got.Move.State != MoveReady || got.Move.ReadyAt == nil {
962 t.Fatalf("after promote: %+v", got.Move)
963 }
964 if err := mv(MoveMarkCutting()); err != nil {
965 t.Fatal(err)
966 }
967 if err := mv(MoveAbort()); !errors.Is(err, ErrMoveConflict) {
968 t.Fatalf("abort from cutting = %v, want ErrMoveConflict (completes forward)", err)
969 }
970 if err := mv(MoveComplete()); err != nil {
971 t.Fatal(err)
972 }
973 if got := read(); got.Region != "uk" || got.Move != nil {
974 t.Fatalf("after complete: region=%q move=%+v, want uk/nil in one write", got.Region, got.Move)
975 }
976 // Abort is two-phase: ready → aborting (cut and promote both refuse
977 // there) → cleared only by MoveAbortComplete.
978 if err := mv(MoveRequest("ie", now)); err != nil {
979 t.Fatal(err)
980 }
981 if err := mv(MovePromoteReady(now)); err != nil {
982 t.Fatal(err)
983 }
984 if err := mv(MoveAbort()); err != nil {
985 t.Fatal(err)
986 }
987 if got := read(); got.Move == nil || got.Move.State != MoveAborting {
988 t.Fatalf("after abort: %+v, want aborting", got.Move)
989 }
990 if err := mv(MoveMarkCutting()); !errors.Is(err, ErrMoveConflict) {
991 t.Fatalf("cut from aborting = %v, want ErrMoveConflict", err)
992 }
993 if err := mv(MoveAbort()); !errors.Is(err, ErrMoveConflict) {
994 t.Fatalf("double abort = %v, want ErrMoveConflict", err)
995 }
996 if err := mv(MoveAbortComplete()); err != nil {
997 t.Fatal(err)
998 }
999 if got := read(); got.Move != nil || got.Region != "uk" {
1000 t.Fatalf("after abort-complete: %+v region=%q", got.Move, got.Region)
1001 }
1002}
1003
1004// Abort names the states it may leave, so a state a newer box writes is
1005// refused here rather than aborted (G16): "landing" is the target restoring
1006// from the staging an abort would delete. The Phase 1 states past the cut
1007// (committed, rolling_back, reverting, stuck) are refused the same way. This
1008// is the real function, not a copy: v0.22.2's MoveAbort
1009// (internal/provision/provision.go:1022-1039 at that tag) allows exactly the
1010// same cases, preparing and ready, so a v0.22 console refuses all of them too
1011// (§14.3).
1012func TestMoveAbortRefusesStatesItDoesNotKnow(t *testing.T) {
1013 for _, state := range []string{MoveLanding, MoveCommitted, MoveRollingBack, MoveReverting, MoveStuck, "freezing", "done", ""} {
1014 rec := &Record{Move: &MoveState{To: "uk", State: state}}
1015 if err := MoveAbort()(rec); !errors.Is(err, ErrMoveConflict) {
1016 t.Errorf("abort from %q = %v, want ErrMoveConflict", state, err)
1017 }
1018 if rec.Move.State != state {
1019 t.Errorf("abort from %q rewrote the state to %q", state, rec.Move.State)
1020 }
1021 }
1022 for _, state := range []string{MovePreparing, MoveReady} {
1023 rec := &Record{Move: &MoveState{To: "uk", State: state}}
1024 if err := MoveAbort()(rec); err != nil || rec.Move.State != MoveAborting {
1025 t.Errorf("abort from %q = %v, state %q; want aborting", state, err, rec.Move.State)
1026 }
1027 }
1028}
1029
1030// A replica never gets a route on a platform box, so a move of one would
1031// wedge in preparing with no box to drive it (G14).
1032func TestMoveRequestRefusesAReplica(t *testing.T) {
1033 rec := &Record{Region: "ie", Role: "replica"}
1034 if err := MoveRequest("uk", time.Now())(rec); !errors.Is(err, ErrMoveConflict) || rec.Move != nil {
1035 t.Fatalf("move of a replica = %v, move %+v; want ErrMoveConflict and no move", err, rec.Move)
1036 }
1037}
1038
1039// A record written before the move field existed round-trips byte-identical
1040// through the CAS helper's read-marshal cycle — the wire-shape rule.
1041func TestMoveFieldAbsentOnPreMoveRecords(t *testing.T) {
1042 raw := []byte(`{"schema":1,"host":"a.go.example.com","slug":"a","app":"app","account":"bab","region":"ie","channel":"stable","created_at":"2026-08-01T00:00:00Z"}`)
1043 var rec Record
1044 if err := json.Unmarshal(raw, &rec); err != nil {
1045 t.Fatal(err)
1046 }
1047 if rec.Move != nil {
1048 t.Fatalf("pre-move record decoded a Move: %+v", rec.Move)
1049 }
1050 out, err := json.Marshal(rec)
1051 if err != nil {
1052 t.Fatal(err)
1053 }
1054 if strings.Contains(string(out), "move") {
1055 t.Fatalf("re-marshal grew a move key: %s", out)
1056 }
1057}
1058
1059func TestValidateShapeGate(t *testing.T) {
1060 ok := Record{Kind: "static", Gate: "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"}
1061 if err := ValidateShape(ok); err != nil {
1062 t.Fatalf("bcrypt gate refused: %v", err)
1063 }
1064 bad := Record{Kind: "static", Gate: "hunter2"}
1065 if err := ValidateShape(bad); err == nil || !strings.Contains(err.Error(), "gate") {
1066 t.Fatalf("plaintext gate accepted: %v", err)
1067 }
1068}
1069
1070func TestInstanceIDShortHostsAreThemselves(t *testing.T) {
1071 for _, h := range []string{"h", "bill.go.tito.io", strings.Repeat("a", 40)} {
1072 if got := InstanceID(h); got != h {
1073 t.Errorf("InstanceID(%q) = %q, want the host itself", h, got)
1074 }
1075 }
1076}
1077
1078func TestInstanceIDLongHostsAreBoundedAndPrefixed(t *testing.T) {
1079 long := strings.Repeat("a", 60) + ".example.com"
1080 got := InstanceID(long)
1081 if len(got) != MaxInstanceID {
1082 t.Fatalf("InstanceID(long) = %q (%d bytes), want %d", got, len(got), MaxInstanceID)
1083 }
1084 if got[0] != '_' {
1085 t.Errorf("InstanceID(long) = %q, want a leading underscore: no valid host has one", got)
1086 }
1087 if got[1:23] != long[:22] {
1088 t.Errorf("InstanceID(long) = %q, want the host's first 22 bytes after the underscore", got)
1089 }
1090 if got[23] != '-' {
1091 t.Errorf("InstanceID(long) = %q, want a dash before the hash", got)
1092 }
1093 if got != InstanceID(long) {
1094 t.Error("InstanceID is not stable")
1095 }
1096 // Two long hosts sharing a 22-byte prefix get different identifiers.
1097 other := strings.Repeat("a", 60) + ".example.org"
1098 if InstanceID(other) == got {
1099 t.Errorf("InstanceID(%q) == InstanceID(%q); the hash must separate them", long, other)
1100 }
1101}
1102
1103func TestInstanceIDDomainsNeverMeet(t *testing.T) {
1104 // codex R6-1: the hashed form of one host must never equal any valid
1105 // short host. A leading underscore is not a valid host label.
1106 long := strings.Repeat("a", 31) + ".example.com"
1107 if ValidHostShape(InstanceID(long)) {
1108 t.Errorf("InstanceID(%q) = %q is a valid host shape; the domains overlap", long, InstanceID(long))
1109 }
1110}
1111
1112func TestNestedHelpersAreKeyedByInstanceID(t *testing.T) {
1113 long := strings.Repeat("b", 50) + ".example.com"
1114 iid := InstanceID(long)
1115 if got, want := InstanceDir("/data", long), "/data/instances/"+iid; got != want {
1116 t.Errorf("InstanceDir = %q, want %q", got, want)
1117 }
1118 if got, want := NestedDBPath("/data/", long), "/data/instances/"+iid+"/"+long+".db"; got != want {
1119 t.Errorf("NestedDBPath = %q, want %q (file names keep the full host)", got, want)
1120 }
1121 if got, want := InstanceRunDir(long), "/run/carlos/instances/"+iid; got != want {
1122 t.Errorf("InstanceRunDir = %q, want %q", got, want)
1123 }
1124 if got, want := NestedSocket(long), "/run/carlos/instances/"+iid+"/app.sock"; got != want {
1125 t.Errorf("NestedSocket = %q, want %q", got, want)
1126 }
1127 if got, want := LogSocket(long, "sidecar"), "/run/carlos/logs/"+iid+".sidecar.sock"; got != want {
1128 t.Errorf("LogSocket = %q, want %q", got, want)
1129 }
1130}
1131
1132// NestedPaths is a path-shape test and nothing more: it must answer true
1133// for a row that left management (unflagged, unit-backed) and for a row
1134// whose paths are nested but not the exact defaults, because the downgrade
1135// guards have to see every row a pre-layout release would mishandle.
1136func TestNestedPathsIsShapeOnly(t *testing.T) {
1137 host := "acme.example.com"
1138 for _, tc := range []struct {
1139 name string
1140 rt registry.Route
1141 want bool
1142 }{
1143 {"flat defaults", registry.Route{Host: host, Hibernate: true, DBPath: DefaultDBPath("/data", host), Socket: DefaultSocket(host)}, false},
1144 {"nested defaults", registry.Route{Host: host, Hibernate: true, DBPath: NestedDBPath("/data", host), Socket: NestedSocket(host)}, true},
1145 {"nested and unflagged", registry.Route{Host: host, DBPath: NestedDBPath("/data", host), Socket: NestedSocket(host)}, true},
1146 {"nested db only, off the default name", registry.Route{Host: host, Backing: registry.BackingUnit, DBPath: "/data/instances/other/x.db"}, true},
1147 {"nested socket only", registry.Route{Host: host, Socket: "/run/carlos/instances/zz/app.sock"}, true},
1148 {"the instances directory itself", registry.Route{Host: host, DBPath: "/data/instances", Socket: "/run/carlos/instances"}, false},
1149 {"a sibling that shares the prefix", registry.Route{Host: host, DBPath: "/data/instances-old/x.db", Socket: "/run/carlos/instancesx/app.sock"}, false},
1150 {"another data dir", registry.Route{Host: host, DBPath: "/srv/instances/a/a.db"}, false},
1151 // A hand-edited row is read as the kernel would resolve it.
1152 {"a doubled slash in the db", registry.Route{Host: host, DBPath: "/data//instances/a/a.db"}, true},
1153 {"a doubled slash in the socket", registry.Route{Host: host, Socket: "/run/carlos//instances/zz/app.sock"}, true},
1154 {"a dot segment in the db", registry.Route{Host: host, DBPath: "/data/./instances/a/a.db"}, true},
1155 {"a dot segment in the socket", registry.Route{Host: host, Socket: "/run/carlos/./instances/zz/app.sock"}, true},
1156 {"a dot-dot segment that leaves the tree", registry.Route{Host: host, DBPath: "/data/instances/../a.db"}, false},
1157 } {
1158 if got := NestedPaths("/data/", tc.rt); got != tc.want {
1159 t.Errorf("%s: NestedPaths = %v, want %v", tc.name, got, tc.want)
1160 }
1161 }
1162}
1163
1164func TestFlatDefaultsUnchangedThisRelease(t *testing.T) {
1165 // Spec §8.1: nothing mints nested in this release. The flat helpers
1166 // are what routeFor and `carlos add` still use.
1167 if got := DefaultSocket("h"); got != "/run/carlos/h.sock" {
1168 t.Errorf("DefaultSocket = %q", got)
1169 }
1170 if got := DefaultDBPath("/data", "h"); got != "/data/h.db" {
1171 t.Errorf("DefaultDBPath = %q", got)
1172 }
1173}
1174
1175func TestLongestSocketAddressFitsSockaddrUn(t *testing.T) {
1176 // codex R5-3: an 80-byte host overflowed the first draft's paths. The
1177 // bound is computed, not assumed, over a 253-byte host.
1178 if got := LongestSocketAddress(); got > MaxUnixPath {
1179 t.Fatalf("longest socket address is %d bytes, over the %d-byte limit", got, MaxUnixPath)
1180 }
1181 host := strings.Repeat("c", 253)
1182 for _, p := range []string{NestedSocket(host), NestedSocket(host) + ".control", LogSocket(host, "sidecar")} {
1183 if len(p) > MaxUnixPath {
1184 t.Errorf("%q is %d bytes", p, len(p))
1185 }
1186 }
1187}
1188
1189func TestIsControlEndpoint(t *testing.T) {
1190 for path, want := range map[string]bool{
1191 ControlSocketPath: true,
1192 "/run/carlos-control/anything": true,
1193 "/run/carlos-control": true,
1194 "/run/carlos/h.sock": false,
1195 "/run/carlos/instances/h/app.sock": false,
1196 "/run/carlos-controlx/control.sock": false,
1197 "/run/titogo/example.sock": false,
1198 } {
1199 if got := IsControlEndpoint(path); got != want {
1200 t.Errorf("IsControlEndpoint(%q) = %v, want %v", path, got, want)
1201 }
1202 }
1203}
1204
1205func TestLayoutJournalPath(t *testing.T) {
1206 if got := LayoutJournalPath("/data/", "h.example.com"); got != "/data/layout/h.example.com.json" {
1207 t.Errorf("LayoutJournalPath = %q", got)
1208 }
1209}
1210
1211// --- #162: replica role and placement set ---------------------------------
1212
1213// TestRecordReplicaFieldsAreAdditive extends TestRecordWireShapeIsAdditive
1214// to the replica pair: a record written before the fields existed reads as
1215// "the serving home with no replicas anywhere", byte for byte the routing
1216// and placement truth every box in the fleet held before #162.
1217func TestRecordReplicaFieldsAreAdditive(t *testing.T) {
1218 const legacy = `{"schema":1,"host":"acme.go.tito.io","slug":"acme","app":"tito",` +
1219 `"account":"bab","pool":"tito","region":"ie","channel":"stable",` +
1220 `"created_at":"2026-08-04T09:00:00Z"}`
1221 var rec Record
1222 if err := json.Unmarshal([]byte(legacy), &rec); err != nil {
1223 t.Fatal(err)
1224 }
1225 if rec.Role != "" {
1226 t.Errorf("Role = %q, want empty", rec.Role)
1227 }
1228 if len(rec.Replicas) != 0 {
1229 t.Errorf("Replicas = %v, want none", rec.Replicas)
1230 }
1231 if got := rec.Replicas; got != nil {
1232 t.Errorf("Replicas = %v on a legacy record, want nil", got)
1233 }
1234}
1235
1236// TestValidRolePinsTheVocabulary: closed and case-sensitive, ValidSteering's
1237// discipline — a value no reader has heard of is refused where it is typed.
1238func TestValidRolePinsTheVocabulary(t *testing.T) {
1239 for _, role := range []string{"", "replica"} {
1240 if !ValidRole(role) {
1241 t.Errorf("ValidRole(%q) = false, want true", role)
1242 }
1243 }
1244 for _, role := range []string{"Replica", "REPLICA", "read", "mirror", "home"} {
1245 if ValidRole(role) {
1246 t.Errorf("ValidRole(%q) = true, want false", role)
1247 }
1248 }
1249}
1250
1251// TestValidReplicasCanonicalizesAndRejects: the set is canonical (sorted,
1252// deduplicated) so the wire column and the reconciler's converge compare
1253// equal regardless of the order the writer typed; a placement outside the
1254// Region vocabulary is refused at the door rather than discovered as a
1255// placement no box ever announces.
1256func TestValidReplicasCanonicalizesAndRejects(t *testing.T) {
1257 got, err := ValidReplicas([]string{"uk", " ie ", "", "uk", "us"})
1258 if err != nil {
1259 t.Fatalf("ValidReplicas: %v", err)
1260 }
1261 if strings.Join(got, ",") != "ie,uk,us" {
1262 t.Errorf("ValidReplicas = %v, want [ie uk us] — sorted and deduplicated", got)
1263 }
1264 if _, err := ValidReplicas([]string{"fleet/pi"}); err == nil {
1265 t.Error("ValidReplicas(fleet/pi) accepted — a fleet is a tenancy boundary, not a replica placement")
1266 }
1267 if _, err := ValidReplicas([]string{"not a place"}); err == nil {
1268 t.Error("ValidReplicas(garbage) accepted")
1269 }
1270 var many []string
1271 for i := 0; i < MaxReplicas+1; i++ {
1272 many = append(many, fmt.Sprintf("r%d", i))
1273 }
1274 if _, err := ValidReplicas(many); err == nil {
1275 t.Errorf("ValidReplicas(%d placements) accepted, want the residence cap refusal", MaxReplicas+1)
1276 }
1277}
1278
1279// TestValidateShapeReplicaRails pins the shape rules the door enforces for
1280// the replica role: instance-only, and the placement set's vocabulary.
1281func TestValidateShapeReplicaRails(t *testing.T) {
1282 replica := Record{Role: "replica", Region: "uk"}
1283 if err := ValidateShape(replica); err != nil {
1284 t.Fatalf("ValidateShape(bare replica): %v", err)
1285 }
1286 if err := ValidateShape(Record{Role: "replica", Kind: "static"}); err == nil {
1287 t.Error("ValidateShape(replica static) accepted — a replica is a process holding a mirror, never a file tree")
1288 }
1289 if err := ValidateShape(Record{Role: "replica", Kind: "service", Addr: "10.0.0.1:8090"}); err == nil {
1290 t.Error("ValidateShape(replica service) accepted — the platform spawns the mirror's process")
1291 }
1292 if err := ValidateShape(Record{Replicas: []string{"fleet/pi"}}); err == nil {
1293 t.Error("ValidateShape(fleet replica placement) accepted")
1294 }
1295 if err := ValidateShape(Record{Replicas: []string{"ie", "uk", "ie"}}); err != nil {
1296 t.Errorf("ValidateShape(duplicate placement): %v — deduplication is the door's job, not a refusal", err)
1297 }
1298}
1299
1300// PrivateStorage is additive and its ZERO VALUE is the legacy behaviour: a
1301// manifest written before the field existed — every manifest in a
1302// production bucket — must read as an app whose instances share the app's
1303// storage, which is what those apps do (issue #412, spec §5).
1304func TestManifestPrivateStorageIsAdditive(t *testing.T) {
1305 ctx := context.Background()
1306 s := release.NewDirStore(t.TempDir())
1307
1308 // The bytes an older console wrote: no such key at all.
1309 legacy := []byte(`{"schema":1,"app":"tito","domain":"go.tito.io","channel":"stable"}`)
1310 if _, err := s.Put(ctx, ManifestKey("bad", "tito"), legacy, ""); err != nil {
1311 t.Fatal(err)
1312 }
1313 got, err := GetManifest(ctx, s, "bad", "tito")
1314 if err != nil {
1315 t.Fatal(err)
1316 }
1317 if got.PrivateStorage {
1318 t.Fatal("a manifest written before the field reads as private-storage — every app in production would start minting buckets")
1319 }
1320
1321 got.PrivateStorage = true
1322 if err := PutManifest(ctx, s, "bad", "tito", *got); err != nil {
1323 t.Fatal(err)
1324 }
1325 back, err := GetManifest(ctx, s, "bad", "tito")
1326 if err != nil {
1327 t.Fatal(err)
1328 }
1329 if !back.PrivateStorage {
1330 t.Fatalf("policy did not survive a round trip: %+v", back)
1331 }
1332 // omitempty: an app without the policy writes no key, so nothing an old
1333 // reader parses changes shape.
1334 back.PrivateStorage = false
1335 if err := PutManifest(ctx, s, "bad", "tito", *back); err != nil {
1336 t.Fatal(err)
1337 }
1338 raw, _, err := s.Get(ctx, ManifestKey("bad", "tito"))
1339 if err != nil {
1340 t.Fatal(err)
1341 }
1342 if strings.Contains(string(raw), "private_storage") {
1343 t.Errorf("an app without the policy still writes the key: %s", raw)
1344 }
1345}
1346
1347func TestListRecordsStrict(t *testing.T) {
1348 s, sqid := shopSetup(t)
1349 for _, h := range []string{"c.example.com", "a.example.com", "b.example.com"} {
1350 rec := Record{Schema: 1, Host: h, Slug: h[:1], App: "shop", Account: sqid, Channel: "stable", CreatedAt: time.Now().UTC()}
1351 if err := CreateRecord(ctx, s, rec); err != nil {
1352 t.Fatal(err)
1353 }
1354 }
1355
1356 recs, unreadable, err := ListRecordsStrict(ctx, s, sqid, "shop", 3)
1357 if err != nil || len(unreadable) != 0 || unreadable == nil || len(recs) != 3 {
1358 t.Fatalf("clean listing = %+v, %#v, %v", recs, unreadable, err)
1359 }
1360 for i, want := range []string{"a.example.com", "b.example.com", "c.example.com"} {
1361 if recs[i].Host != want {
1362 t.Errorf("recs[%d].Host = %q, want %q", i, recs[i].Host, want)
1363 }
1364 }
1365
1366 // The cap is an error, not a flag: one key over, and at 0 or below.
1367 for _, max := range []int{2, 0, -1} {
1368 recs, unreadable, err := ListRecordsStrict(ctx, s, sqid, "shop", max)
1369 if !errors.Is(err, ErrRecordsTruncated) || recs != nil || unreadable != nil {
1370 t.Fatalf("max %d = %+v, %v, %v; want ErrRecordsTruncated and nothing else", max, recs, unreadable, err)
1371 }
1372 }
1373 // An app with no records is complete at any cap.
1374 if recs, unreadable, err := ListRecordsStrict(ctx, s, sqid, "empty", 0); err != nil || len(recs) != 0 || len(unreadable) != 0 {
1375 t.Fatalf("empty app = %+v, %v, %v", recs, unreadable, err)
1376 }
1377
1378 // A body that does not decode, and one that names another host, are
1379 // reported by the host in their key. ListRecords skips them.
1380 if _, err := s.Put(ctx, RecordKey(sqid, "shop", "z.example.com"), []byte("not json"), ""); err != nil {
1381 t.Fatal(err)
1382 }
1383 liar := Record{Schema: 1, Host: "elsewhere.example.com", Slug: "y", App: "shop", Account: sqid, Channel: "stable", CreatedAt: time.Now().UTC()}
1384 raw, err := json.Marshal(liar)
1385 if err != nil {
1386 t.Fatal(err)
1387 }
1388 if _, err := s.Put(ctx, RecordKey(sqid, "shop", "y.example.com"), raw, ""); err != nil {
1389 t.Fatal(err)
1390 }
1391 recs, unreadable, err = ListRecordsStrict(ctx, s, sqid, "shop", 10)
1392 if err != nil || len(recs) != 3 || len(unreadable) != 2 || !unreadable["z.example.com"] || !unreadable["y.example.com"] {
1393 t.Fatalf("with two bad bodies = %d recs, %v, %v", len(recs), unreadable, err)
1394 }
1395 if plain, _, err := ListRecords(ctx, s, sqid, "shop", 10); err != nil || len(plain) != 3 {
1396 t.Fatalf("ListRecords must keep skipping them: %d, %v", len(plain), err)
1397 }
1398}
1399
1400func TestListRecordsStrictFailsOnAnyReadFailure(t *testing.T) {
1401 inner, sqid := shopSetup(t)
1402 for _, h := range []string{"a.example.com", "b.example.com"} {
1403 rec := Record{Schema: 1, Host: h, Slug: h[:1], App: "shop", Account: sqid, Channel: "stable", CreatedAt: time.Now().UTC()}
1404 if err := CreateRecord(ctx, inner, rec); err != nil {
1405 t.Fatal(err)
1406 }
1407 }
1408 // ListRecords drops the record it cannot Get; the strict listing fails.
1409 fs := &failStore{Store: inner}
1410 fs.failReadsFor(RecordKey(sqid, "shop", "b.example.com"))
1411 if plain, _, err := ListRecords(ctx, fs, sqid, "shop", 10); err != nil || len(plain) != 1 {
1412 t.Fatalf("ListRecords over an unreadable record = %d, %v", len(plain), err)
1413 }
1414 if recs, unreadable, err := ListRecordsStrict(ctx, fs, sqid, "shop", 10); err == nil || recs != nil || unreadable != nil {
1415 t.Fatalf("ListRecordsStrict over an unreadable record = %+v, %v, %v; want an error", recs, unreadable, err)
1416 }
1417 // A listing that fails is an error too.
1418 ls := &bucketShaped{Store: inner, failList: RecordsPrefix(sqid, "shop")}
1419 if recs, unreadable, err := ListRecordsStrict(ctx, ls, sqid, "shop", 10); err == nil || recs != nil || unreadable != nil {
1420 t.Fatalf("ListRecordsStrict over a failed listing = %+v, %v, %v; want an error", recs, unreadable, err)
1421 }
1422}
1423