| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "testing" |
| 7 | ) |
| 8 | |
| 9 | // Hosting is answered PER BOX, from that box's own hibernation_configured |
| 10 | // field, and is never inferred from what another box publishes. An earlier |
| 11 | // version keyed hosting on data_bucket alone, and because an absent |
| 12 | // data_bucket is what an OLD WRITER and a bucket-less new box both produce, |
| 13 | // it decided when silence meant "no" by asking whether any OTHER box had |
| 14 | // spoken. One box adopting a bucket-publishing build then withdrew every |
| 15 | // region that had not yet rolled, all of them healthy. These tests pin the |
| 16 | // three states apart. |
| 17 | func TestPlacementsAnswersHostingPerBox(t *testing.T) { |
| 18 | fx, _ := instanceFixture(t, "code-inst-placementcaps") |
| 19 | ctx := context.Background() |
| 20 | put := func(key, body string) { |
| 21 | t.Helper() |
| 22 | if _, err := fx.rel.Put(ctx, key, []byte(body), ""); err != nil { |
| 23 | t.Fatal(err) |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | // uk: an old writer — no hibernation_configured, no data_bucket. UNKNOWN, |
| 28 | // and unknown keeps the eligibility it had before the field existed. |
| 29 | put("control/edges/uk-box.json", `{"v":1,"box":"uk-box","eip":"1.2.3.4","placement":"uk","hosts":{}}`) |
| 30 | // ie: a new writer that can host. |
| 31 | put("control/edges/ie-box.json", `{"v":1,"box":"ie-box","eip":"5.6.7.8","placement":"ie","data_bucket":"carlos-ie","hibernation_configured":true,"hosts":{}}`) |
| 32 | // de: a new writer that CANNOT host, and says so explicitly. This is the |
| 33 | // state no earlier version could express. |
| 34 | put("control/edges/de-box.json", `{"v":1,"box":"de-box","eip":"9.9.9.9","placement":"de","hibernation_configured":false,"hosts":{}}`) |
| 35 | // se: reachable by nothing. |
| 36 | put("control/edges/se-box.json", `{"v":1,"box":"se-box","eip":"","placement":"se","data_bucket":"carlos-se","hibernation_configured":true,"hosts":{}}`) |
| 37 | |
| 38 | caps, published := placements(ctx, fx.rel) |
| 39 | if !published { |
| 40 | t.Fatal("published = false, want true") |
| 41 | } |
| 42 | |
| 43 | // The whole point: ie publishing a bucket must not make uk's silence |
| 44 | // mean "no". uk stays eligible until uk itself says otherwise. |
| 45 | if !caps["uk"].Hosting { |
| 46 | t.Error("uk not hosting: it is an OLD WRITER with no opinion, and one box's upgrade must not withdraw another box's region") |
| 47 | } |
| 48 | if !caps["ie"].Hosting || !caps["ie"].Staging { |
| 49 | t.Errorf("ie = %+v, want hosting and staging", caps["ie"]) |
| 50 | } |
| 51 | if caps["de"].Hosting { |
| 52 | t.Error("de hosting: it published hibernation_configured false, which is an explicit no") |
| 53 | } |
| 54 | if !caps["de"].Reachable { |
| 55 | t.Error("de unreachable: it has an EIP — a box that cannot host is still an edge") |
| 56 | } |
| 57 | if caps["se"].Reachable || caps["se"].Hosting || caps["se"].Staging { |
| 58 | t.Errorf("se = %+v, want nothing: no EIP gates every capability", caps["se"]) |
| 59 | } |
| 60 | |
| 61 | // Staging is strictly stronger than Hosting, and uk is where they part: |
| 62 | // eligible to host (unknown), but publishing no bucket coordinates, so |
| 63 | // no move can name it — internal/host's moveTargets would drop it and |
| 64 | // the worker would wait forever. |
| 65 | if caps["uk"].Staging { |
| 66 | t.Error("uk staging: it publishes no data_bucket, so a move there could never be prepared") |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | // An explicit false is believed even when a SIBLING box in the same region |
| 71 | // can host: the fold is an OR across boxes, so one capable box arms the |
| 72 | // region, and that is correct — the record only needs one box to land on. |
| 73 | func TestPlacementsFoldsAcrossBoxesInARegion(t *testing.T) { |
| 74 | fx, _ := instanceFixture(t, "code-inst-placementfold") |
| 75 | ctx := context.Background() |
| 76 | put := func(key, body string) { |
| 77 | t.Helper() |
| 78 | if _, err := fx.rel.Put(ctx, key, []byte(body), ""); err != nil { |
| 79 | t.Fatal(err) |
| 80 | } |
| 81 | } |
| 82 | put("control/edges/uk-a.json", `{"v":1,"box":"uk-a","eip":"1.1.1.1","placement":"uk","hibernation_configured":false,"hosts":{}}`) |
| 83 | put("control/edges/uk-b.json", `{"v":1,"box":"uk-b","eip":"2.2.2.2","placement":"uk","data_bucket":"carlos-uk","hibernation_configured":true,"hosts":{}}`) |
| 84 | |
| 85 | caps, _ := placements(ctx, fx.rel) |
| 86 | if !caps["uk"].Hosting || !caps["uk"].Staging { |
| 87 | t.Errorf("uk = %+v, want hosting and staging: one capable box in the region is enough", caps["uk"]) |
| 88 | } |
| 89 | } |
| 90 | |
| 91 | // The dormancy that remains is the one the placement field itself shipped |
| 92 | // with, and it is about placement codes, not about hosting: a fleet where no |
| 93 | // box publishes a code at all leaves the create gate entirely dormant. |
| 94 | func TestPlacementsDormantWithoutAnyPlacementCode(t *testing.T) { |
| 95 | fx, _ := instanceFixture(t, "code-inst-placementdormant") |
| 96 | ctx := context.Background() |
| 97 | if _, err := fx.rel.Put(ctx, "control/edges/solo.json", |
| 98 | []byte(`{"v":1,"box":"solo","eip":"1.2.3.4","hosts":{}}`), ""); err != nil { |
| 99 | t.Fatal(err) |
| 100 | } |
| 101 | if _, published := placements(ctx, fx.rel); published { |
| 102 | t.Error("published = true with no placement code anywhere — the gate must stay dormant") |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | // The seeds fields are additive: an edges file from a box that predates |
| 107 | // them parses, and reads as no protocol, no floor and no verdict. |
| 108 | func TestEdgesFilePlacementViewSeedsFields(t *testing.T) { |
| 109 | var old edgesFilePlacementView |
| 110 | if err := json.Unmarshal([]byte(`{"box":"kass-1","moves_protocol":1}`), &old); err != nil { |
| 111 | t.Fatal(err) |
| 112 | } |
| 113 | if old.SeedsProtocol != 0 || old.SeedsFloorMinRelease != "" || old.PrevSeedsAllowedFor != "" { |
| 114 | t.Fatalf("old writer = %+v", old) |
| 115 | } |
| 116 | var cur edgesFilePlacementView |
| 117 | body := `{"box":"kass-1","seeds_protocol":2,"seeds_floor_min_release":"v0.27.0","prev_seeds_allowed_for":"v0.27.0"}` |
| 118 | if err := json.Unmarshal([]byte(body), &cur); err != nil { |
| 119 | t.Fatal(err) |
| 120 | } |
| 121 | if cur.SeedsProtocol != 2 || cur.SeedsFloorMinRelease != "v0.27.0" || cur.PrevSeedsAllowedFor != "v0.27.0" { |
| 122 | t.Fatalf("current writer = %+v", cur) |
| 123 | } |
| 124 | } |
| 125 | |