| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "slices" |
| 6 | "testing" |
| 7 | "time" |
| 8 | |
| 9 | "github.com/carlosframework/platform/internal/boxchoice" |
| 10 | "github.com/carlosframework/platform/internal/hostactors" |
| 11 | "github.com/carlosframework/platform/internal/hostclaim" |
| 12 | "github.com/carlosframework/platform/internal/release" |
| 13 | ) |
| 14 | |
| 15 | // The console's chooser reads the bucket through its two production |
| 16 | // readers: the live edges (each box's edges file joined with its status |
| 17 | // snapshot) and the serving claims' loads. The bucket holds real evidence, |
| 18 | // the assertions name boxes and counts, and a change to the stored |
| 19 | // evidence shows in the next read, so neither reader can be replaced by |
| 20 | // one returning nothing (round 10). |
| 21 | func TestCreateExecutorChooserReadsTheLiveEdgesAndTheClaims(t *testing.T) { |
| 22 | ctx := context.Background() |
| 23 | store := release.NewDirStore(t.TempDir()) |
| 24 | off := &Server{cfg: Config{Releases: store, HomeRegion: "ie"}} |
| 25 | if off.executorChooser(hostactors.Directory{Store: store, Bucket: "bucket"}) != nil { |
| 26 | t.Fatal("a chooser with CARLOS_REGION_HA off: create would change") |
| 27 | } |
| 28 | s := &Server{cfg: Config{Releases: store, HomeRegion: "ie", RegionHA: true}} |
| 29 | x := s.executorChooser(hostactors.Directory{Store: store, Bucket: "bucket"}) |
| 30 | if x == nil || x.Edges == nil || x.Loads == nil || x.Now == nil || x.HomeName != "ie" || x.SecretsKeyFP != s.expectedSecretsFP() { |
| 31 | t.Fatalf("chooser: %+v", x) |
| 32 | } |
| 33 | |
| 34 | // Two live boxes in ie, each with its edges file and a fresh status |
| 35 | // snapshot; box-b advertises the region-ha-1 capability. |
| 36 | edges := func(box, extra string) string { |
| 37 | return `{"v":1,"box":"` + box + `","box_id":"` + box + `","eip":"203.0.113.1","region":"eu-west-1","placement":"ie","hibernation_configured":true` + extra + `,"hosts":{}}` |
| 38 | } |
| 39 | for box, body := range map[string]string{"box-a": edges("box-a", ""), "box-b": edges("box-b", `,"capabilities":["`+boxchoice.Capability+`"]`)} { |
| 40 | setBoxSnapshot(t, store, box, time.Now()) |
| 41 | if _, err := store.Put(ctx, "control/edges/"+box+".json", []byte(body), ""); err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | } |
| 45 | // Two serving claims on box-a for bab/jam, one on box-b for bab/tea. |
| 46 | created := time.Date(2026, 10, 4, 1, 0, 0, 0, time.UTC) |
| 47 | serve := func(host, app, box string) { |
| 48 | t.Helper() |
| 49 | nh := hostclaim.NewHolder{Family: "instance", Key: "apps/bab/" + app + "/instances/" + host + ".json", Account: "bab", App: app, Host: host, Region: "ie", RecordRegion: "ie", CreatedAt: created} |
| 50 | body := []byte(`{"host":"` + host + `","account":"bab","app":"` + app + `","region":"ie","created_at":"2026-10-04T01:00:00Z"}`) |
| 51 | o, err := hostclaim.Read(ctx, store, host) |
| 52 | if err != nil { |
| 53 | t.Fatal(err) |
| 54 | } |
| 55 | if o, err = hostclaim.Update(ctx, store, o, hostclaim.BeginCreate(nh, body, "c", "console", created)); err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | if _, err = hostclaim.Update(ctx, store, o, hostclaim.CompleteCreate("c", box)); err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | } |
| 62 | serve("a.bab.oncarlos.com", "jam", "box-a") |
| 63 | serve("b.bab.oncarlos.com", "jam", "box-a") |
| 64 | serve("c.bab.oncarlos.com", "tea", "box-b") |
| 65 | |
| 66 | read := func() (map[string]boxchoice.Box, map[string]boxchoice.Load) { |
| 67 | t.Helper() |
| 68 | snap, err := x.Edges(ctx) |
| 69 | if err != nil || !snap.Complete { |
| 70 | t.Fatalf("edges: %+v %v", snap, err) |
| 71 | } |
| 72 | boxes := map[string]boxchoice.Box{} |
| 73 | for _, b := range snap.Boxes { |
| 74 | boxes[b.BoxID] = b |
| 75 | } |
| 76 | loads, err := x.Loads(ctx) |
| 77 | if err != nil { |
| 78 | t.Fatal(err) |
| 79 | } |
| 80 | return boxes, loads |
| 81 | } |
| 82 | boxes, loads := read() |
| 83 | if len(boxes) != 2 || !boxes["box-a"].Fresh || !boxes["box-b"].Fresh || boxes["box-a"].Placement != "ie" || |
| 84 | slices.Contains(boxes["box-a"].Capabilities, boxchoice.Capability) || !slices.Contains(boxes["box-b"].Capabilities, boxchoice.Capability) { |
| 85 | t.Fatalf("live edges: %+v", boxes) |
| 86 | } |
| 87 | if loads["box-a"].Hosts != 2 || loads["box-a"].Apps["bab/jam"] != 2 || loads["box-b"].Hosts != 1 || loads["box-b"].Apps["bab/tea"] != 1 { |
| 88 | t.Fatalf("loads: %+v", loads) |
| 89 | } |
| 90 | |
| 91 | // The evidence changes: box-a starts draining and stops reporting |
| 92 | // (stale), and a fourth host serves on box-b. The next read says so. |
| 93 | _, etag, err := store.Get(ctx, "control/edges/box-a.json") |
| 94 | if err != nil { |
| 95 | t.Fatal(err) |
| 96 | } |
| 97 | if _, err := store.Put(ctx, "control/edges/box-a.json", []byte(edges("box-a", `,"draining":true`)), etag); err != nil { |
| 98 | t.Fatal(err) |
| 99 | } |
| 100 | setBoxSnapshot(t, store, "box-a", time.Now().Add(-time.Hour)) |
| 101 | serve("d.bab.oncarlos.com", "tea", "box-b") |
| 102 | boxes, loads = read() |
| 103 | if !boxes["box-a"].Draining || boxes["box-a"].Fresh || !boxes["box-b"].Fresh { |
| 104 | t.Fatalf("live edges after the change: %+v", boxes) |
| 105 | } |
| 106 | if loads["box-b"].Hosts != 2 || loads["box-b"].Apps["bab/tea"] != 2 || loads["box-a"].Hosts != 2 { |
| 107 | t.Fatalf("loads after the change: %+v", loads) |
| 108 | } |
| 109 | } |
| 110 | |