| 1 | package provision |
| 2 | |
| 3 | import ( |
| 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. |
| 23 | func 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. |
| 67 | func 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. |
| 90 | func 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 | |
| 123 | func 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 | |
| 149 | func 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 | |
| 169 | func 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 | |
| 187 | func 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 | |
| 205 | func 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 | |
| 235 | func 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. |
| 261 | func 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. |
| 311 | func 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 | |
| 357 | func 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. |
| 410 | func 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 | |
| 431 | func 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 | |
| 506 | func 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 | |
| 536 | func 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 | |
| 555 | func 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. |
| 580 | func 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 | |
| 612 | func 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 | |
| 626 | func 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 | |
| 639 | func 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. |
| 699 | func 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 | |
| 722 | func 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 | |
| 736 | func 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 | |
| 751 | func 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. |
| 791 | func 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. |
| 823 | func 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. |
| 861 | func 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 | |
| 881 | func 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. |
| 901 | func 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. |
| 918 | func 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). |
| 1012 | func 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). |
| 1032 | func 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. |
| 1041 | func 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 | |
| 1059 | func 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 | |
| 1070 | func 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 | |
| 1078 | func 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 | |
| 1103 | func 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 | |
| 1112 | func 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. |
| 1136 | func 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 | |
| 1164 | func 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 | |
| 1175 | func 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 | |
| 1189 | func 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 | |
| 1205 | func 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. |
| 1217 | func 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. |
| 1238 | func 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. |
| 1256 | func 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. |
| 1281 | func 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). |
| 1304 | func 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 | |
| 1347 | func 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 | |
| 1400 | func 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 | |