| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "net/http" |
| 8 | "os" |
| 9 | "strconv" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/registry" |
| 15 | "github.com/carlosframework/platform/internal/release" |
| 16 | "github.com/carlosframework/platform/internal/seeds" |
| 17 | "github.com/carlosframework/platform/internal/status" |
| 18 | ) |
| 19 | |
| 20 | func seedReadyBox(t *testing.T, fx *consoleFixture, label, placement string, protocol int, floor, prevAllowed, mode string, claim bool) { |
| 21 | t.Helper() |
| 22 | putSeedEdges(t, fx, label, `{"v":1,"box":"`+label+`","eip":"1.2.3.4","placement":"`+placement+ |
| 23 | `","hibernation_configured":true,"hosts":{},"seeds_protocol":`+strconv.Itoa(protocol)+ |
| 24 | `,"seeds_floor_min_release":"`+floor+`","prev_release":"v0.27.1","prev_seeds_allowed_for":"`+prevAllowed+`"}`) |
| 25 | putSeedHeartbeat(t, fx, label, mode, claim, time.Now()) |
| 26 | } |
| 27 | |
| 28 | // putSeedEdges writes a box's edges file, rewriting it in place: a test |
| 29 | // moves one box through several states, and putEdges only creates. |
| 30 | func putSeedEdges(t *testing.T, fx *consoleFixture, label, body string) { |
| 31 | t.Helper() |
| 32 | key := "control/edges/" + label + ".json" |
| 33 | _, etag, err := fx.rel.Get(context.Background(), key) |
| 34 | if err != nil && !errors.Is(err, os.ErrNotExist) { |
| 35 | t.Fatal(err) |
| 36 | } |
| 37 | if _, err := fx.rel.Put(context.Background(), key, []byte(body), etag); err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | } |
| 41 | |
| 42 | // putSeedHeartbeat writes the box's status heartbeat as of at; mode "" is |
| 43 | // a box with no layout report. |
| 44 | func putSeedHeartbeat(t *testing.T, fx *consoleFixture, label, mode string, claim bool, at time.Time) { |
| 45 | t.Helper() |
| 46 | putSeedHeartbeatIn(t, fx.rel, label, mode, claim, at) |
| 47 | } |
| 48 | |
| 49 | func putSeedHeartbeatIn(t *testing.T, store release.Store, label, mode string, claim bool, at time.Time) { |
| 50 | t.Helper() |
| 51 | var layout *status.LayoutReport |
| 52 | if mode != "" { |
| 53 | layout = &status.LayoutReport{Mode: mode} |
| 54 | } |
| 55 | // A release, as every real heartbeat carries: without one a box with no |
| 56 | // layout report would publish no metrics at all. |
| 57 | b := status.CollectBox(nil, status.Probes{Release: "v0.27.1", Layout: layout, HostClaimEnabled: claim}, at) |
| 58 | b.Label = label |
| 59 | if err := status.WriteBox(context.Background(), store, b); err != nil { |
| 60 | t.Fatal(err) |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | func publishSeedsFloorFx(t *testing.T, fx *consoleFixture, rel string) { |
| 65 | t.Helper() |
| 66 | if err := seeds.PublishFloor(context.Background(), fx.rel, seeds.Floor{Min: seeds.RequiredProtocol, MinRelease: rel}); err != nil { |
| 67 | t.Fatal(err) |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | func TestSeedRegionReady(t *testing.T) { |
| 72 | fx := newConsoleFixture(t) |
| 73 | ctx := context.Background() |
| 74 | if ref := fx.srv.seedRegionReady(ctx, "", false); ref == nil || ref.Status != http.StatusConflict || !strings.Contains(ref.Msg, "no seeds floor yet") { |
| 75 | t.Fatalf("no floor: %+v", ref) |
| 76 | } |
| 77 | publishSeedsFloorFx(t, fx, "v0.27.0") |
| 78 | seedReadyBox(t, fx, "kass-1", "", 1, "v0.27.0", "v0.27.0", "nested", false) |
| 79 | if ref := fx.srv.seedRegionReady(ctx, "", false); ref != nil { |
| 80 | t.Fatalf("a ready region: %+v", ref) |
| 81 | } |
| 82 | seedReadyBox(t, fx, "kass-2", "", 0, "v0.27.0", "v0.27.0", "nested", false) |
| 83 | if ref := fx.srv.seedRegionReady(ctx, "", false); ref == nil || ref.Status != http.StatusConflict || !strings.Contains(ref.Msg, "kass-2") || !strings.Contains(ref.Msg, "below the seeds floor") { |
| 84 | t.Fatalf("a box on protocol 0: %+v", ref) |
| 85 | } |
| 86 | seedReadyBox(t, fx, "kass-2", "", 1, "v0.27.0", "", "nested", false) |
| 87 | if ref := fx.srv.seedRegionReady(ctx, "", false); ref == nil || !strings.Contains(ref.Msg, "below the seeds floor") { |
| 88 | t.Fatalf("a carlos.prev without a seeds verdict: %+v", ref) |
| 89 | } |
| 90 | seedReadyBox(t, fx, "kass-2", "", 1, "v0.27.0", "v0.27.0", "flat", false) |
| 91 | if ref := fx.srv.seedRegionReady(ctx, "", false); ref == nil || !strings.Contains(ref.Msg, "nested") { |
| 92 | t.Fatalf("a flat box: %+v", ref) |
| 93 | } |
| 94 | seedReadyBox(t, fx, "kass-2", "", 1, "v0.27.0", "v0.27.0", "nested", true) |
| 95 | if ref := fx.srv.seedRegionReady(ctx, "", false); ref == nil || !strings.Contains(ref.Msg, "claim control") { |
| 96 | t.Fatalf("a claim-armed box: %+v", ref) |
| 97 | } |
| 98 | fx.srv.cfg.HostClaim = true |
| 99 | if ref := fx.srv.seedRegionReady(ctx, "", false); ref == nil || !strings.Contains(ref.Msg, "This console runs with claim control on") { |
| 100 | t.Fatalf("a claim console: %+v", ref) |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | func TestSeedRegionReadyOneBoxForImports(t *testing.T) { |
| 105 | fx := newConsoleFixture(t) |
| 106 | publishSeedsFloorFx(t, fx, "v0.27.0") |
| 107 | seedReadyBox(t, fx, "kass-1", "", 1, "v0.27.0", "v0.27.0", "nested", false) |
| 108 | seedReadyBox(t, fx, "kass-2", "", 1, "v0.27.0", "v0.27.0", "nested", false) |
| 109 | if ref := fx.srv.seedRegionReady(context.Background(), "", false); ref != nil { |
| 110 | t.Fatalf("two ready boxes, --from: %+v", ref) |
| 111 | } |
| 112 | if ref := fx.srv.seedRegionReady(context.Background(), "", true); ref == nil || ref.Status != http.StatusConflict || !strings.Contains(ref.Msg, "more than one box") { |
| 113 | t.Fatalf("two ready boxes, import: %+v", ref) |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | func TestSeedRegionReadyRefusesAnUnconfirmedBox(t *testing.T) { |
| 118 | fx := newConsoleFixture(t) |
| 119 | publishSeedsFloorFx(t, fx, "v0.27.0") |
| 120 | seedReadyBox(t, fx, "kass-1", "", 1, "v0.27.0", "v0.27.0", "", false) // no layout report |
| 121 | if ref := fx.srv.seedRegionReady(context.Background(), "", false); ref == nil || ref.Status != http.StatusServiceUnavailable { |
| 122 | t.Fatalf("a box with no layout report: %+v", ref) |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | func TestSeedRegionReadyIgnoresOtherRegions(t *testing.T) { |
| 127 | fx := newConsoleFixture(t) |
| 128 | publishSeedsFloorFx(t, fx, "v0.27.0") |
| 129 | seedReadyBox(t, fx, "kass-1", "", 1, "v0.27.0", "v0.27.0", "nested", false) |
| 130 | seedReadyBox(t, fx, "uk-1", "uk", 0, "", "", "flat", true) |
| 131 | if ref := fx.srv.seedRegionReady(context.Background(), "", false); ref != nil { |
| 132 | t.Fatalf("a bad box in uk blocked the home region: %+v", ref) |
| 133 | } |
| 134 | if ref := fx.srv.seedRegionReady(context.Background(), "uk", false); ref == nil { |
| 135 | t.Fatal("uk's own bad box did not block uk") |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | // Every missing, zero or unparseable field is not ready. An old box's edges |
| 140 | // file reads as zeros, and a box that could not read its own local floor |
| 141 | // publishes the protocol but an empty floor: neither may pass, whatever the |
| 142 | // rest of the file says. |
| 143 | func TestSeedRegionReadyFailsClosedOnTheEdgesFile(t *testing.T) { |
| 144 | fx := newConsoleFixture(t) |
| 145 | ctx := context.Background() |
| 146 | publishSeedsFloorFx(t, fx, "v0.27.0") |
| 147 | seedReadyBox(t, fx, "kass-1", "", 1, "v0.27.0", "v0.27.0", "nested", false) |
| 148 | for _, tc := range []struct{ name, body string }{ |
| 149 | {"an old box", `{"v":1,"box":"kass-2","eip":"1.2.3.4","hosts":{}}`}, |
| 150 | {"an old box keeping no carlos.prev", `{"v":1,"box":"kass-2","eip":"1.2.3.4","hosts":{},"prev_release":"none","seeds_floor_min_release":"v0.27.0"}`}, |
| 151 | {"an unreadable local floor", `{"v":1,"box":"kass-2","eip":"1.2.3.4","hosts":{},"seeds_protocol":1,"prev_release":"none"}`}, |
| 152 | {"a stale local floor", `{"v":1,"box":"kass-2","eip":"1.2.3.4","hosts":{},"seeds_protocol":1,"seeds_floor_min_release":"v0.26.0","prev_release":"none"}`}, |
| 153 | {"a carlos.prev vouched for an older floor", `{"v":1,"box":"kass-2","eip":"1.2.3.4","hosts":{},"seeds_protocol":1,"seeds_floor_min_release":"v0.27.0","prev_release":"v0.27.1","prev_seeds_allowed_for":"v0.26.0"}`}, |
| 154 | {"no carlos.prev answer at all", `{"v":1,"box":"kass-2","eip":"1.2.3.4","hosts":{},"seeds_protocol":1,"seeds_floor_min_release":"v0.27.0"}`}, |
| 155 | } { |
| 156 | putSeedEdges(t, fx, "kass-2", tc.body) |
| 157 | putSeedHeartbeat(t, fx, "kass-2", "nested", false, time.Now()) |
| 158 | ref := fx.srv.seedRegionReady(ctx, "", false) |
| 159 | want := "kass-2 in the home region are below the seeds floor. Roll the platform there, then publish the seeds floor again." |
| 160 | if ref == nil || ref.Status != http.StatusConflict || ref.Msg != want { |
| 161 | t.Errorf("%s: %+v", tc.name, ref) |
| 162 | } |
| 163 | } |
| 164 | putSeedEdges(t, fx, "kass-2", `{"v":1,"box":"kass-2","eip":"1.2.3.4","hosts":{},"seeds_protocol":1,"seeds_floor_min_release":"v0.27.0","prev_release":"none"}`) |
| 165 | if ref := fx.srv.seedRegionReady(ctx, "", false); ref != nil { |
| 166 | t.Fatalf("a box keeping no carlos.prev: %+v", ref) |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | // A box in the edges files with no heartbeat at all, or a stale one, is |
| 171 | // unconfirmed: never nested, never ready. |
| 172 | func TestSeedRegionReadyRefusesAMissingOrStaleHeartbeat(t *testing.T) { |
| 173 | fx := newConsoleFixture(t) |
| 174 | ctx := context.Background() |
| 175 | publishSeedsFloorFx(t, fx, "v0.27.0") |
| 176 | seedReadyBox(t, fx, "kass-1", "", 1, "v0.27.0", "v0.27.0", "nested", false) |
| 177 | putSeedEdges(t, fx, "kass-2", `{"v":1,"box":"kass-2","eip":"1.2.3.4","hosts":{},"seeds_protocol":1,"seeds_floor_min_release":"v0.27.0","prev_release":"none"}`) |
| 178 | want := "could not confirm every box in the home region. Try again in a minute." |
| 179 | if ref := fx.srv.seedRegionReady(ctx, "", false); ref == nil || ref.Status != http.StatusServiceUnavailable || ref.Msg != want { |
| 180 | t.Fatalf("a box with no heartbeat: %+v", ref) |
| 181 | } |
| 182 | putSeedHeartbeat(t, fx, "kass-2", "nested", false, time.Now().Add(-time.Hour)) |
| 183 | if ref := fx.srv.seedRegionReady(ctx, "", false); ref == nil || ref.Status != http.StatusServiceUnavailable || ref.Msg != want { |
| 184 | t.Fatalf("a box with a stale heartbeat: %+v", ref) |
| 185 | } |
| 186 | putSeedHeartbeat(t, fx, "kass-2", "nested", false, time.Now()) |
| 187 | if ref := fx.srv.seedRegionReady(ctx, "", false); ref != nil { |
| 188 | t.Fatalf("both boxes fresh: %+v", ref) |
| 189 | } |
| 190 | // A region with no edges file at all is unconfirmed too, named. |
| 191 | if ref := fx.srv.seedRegionReady(ctx, "uk", false); ref == nil || ref.Status != http.StatusServiceUnavailable || ref.Msg != "could not confirm every box in uk. Try again in a minute." { |
| 192 | t.Fatalf("a region with no box: %+v", ref) |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | // A published floor that PublishFloor would never write is unreadable, not |
| 197 | // a floor: an empty min_release would match every box that could not read |
| 198 | // its own. |
| 199 | func TestSeedRegionReadyRefusesAnUnusableFloor(t *testing.T) { |
| 200 | fx := newConsoleFixture(t) |
| 201 | ctx := context.Background() |
| 202 | seedReadyBox(t, fx, "kass-1", "", 1, "", "", "nested", false) |
| 203 | for _, body := range []string{`{"min":1,"min_release":""}`, `{"min":1}`, `{"min":1,"min_release":"next"}`, `not json`} { |
| 204 | _, etag, err := fx.rel.Get(ctx, seeds.FloorKey) |
| 205 | if err != nil && !errors.Is(err, os.ErrNotExist) { |
| 206 | t.Fatal(err) |
| 207 | } |
| 208 | if _, err := fx.rel.Put(ctx, seeds.FloorKey, []byte(body), etag); err != nil { |
| 209 | t.Fatal(err) |
| 210 | } |
| 211 | if ref := fx.srv.seedRegionReady(ctx, "", false); ref == nil || ref.Status != http.StatusServiceUnavailable || ref.Msg != "could not read the seeds floor. Try again." { |
| 212 | t.Errorf("floor %s: %+v", body, ref) |
| 213 | } |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | // The refusals name every box they found, sorted, and the region by its |
| 218 | // name: the configured home region's, not the record's "". |
| 219 | func TestSeedRegionReadyNamesBoxesAndRegion(t *testing.T) { |
| 220 | fx := newConsoleFixture(t) |
| 221 | fx.srv.cfg.HomeRegion = "eu" |
| 222 | ctx := context.Background() |
| 223 | publishSeedsFloorFx(t, fx, "v0.27.0") |
| 224 | seedReadyBox(t, fx, "kass-3", "eu", 1, "v0.27.0", "v0.27.0", "flat", false) |
| 225 | seedReadyBox(t, fx, "kass-1", "eu", 1, "v0.27.0", "v0.27.0", "flat", false) |
| 226 | seedReadyBox(t, fx, "kass-2", "", 1, "v0.27.0", "v0.27.0", "nested", true) |
| 227 | want := "kass-1, kass-3 in eu do not run instances nested yet. A seeded instance needs every box there nested." |
| 228 | if ref := fx.srv.seedRegionReady(ctx, "", false); ref == nil || ref.Status != http.StatusConflict || ref.Msg != want { |
| 229 | t.Fatalf("two flat boxes: %+v", ref) |
| 230 | } |
| 231 | seedReadyBox(t, fx, "kass-3", "eu", 1, "v0.27.0", "v0.27.0", "nested", true) |
| 232 | seedReadyBox(t, fx, "kass-1", "eu", 1, "v0.27.0", "v0.27.0", "nested", false) |
| 233 | want = "kass-2, kass-3 in eu run with claim control on. Seeded instances are not supported there yet." |
| 234 | if ref := fx.srv.seedRegionReady(ctx, "", false); ref == nil || ref.Status != http.StatusConflict || ref.Msg != want { |
| 235 | t.Fatalf("two claim boxes: %+v", ref) |
| 236 | } |
| 237 | if ref := fx.srv.seedRegionReady(ctx, "", true); ref == nil || ref.Status != http.StatusConflict || ref.Msg != "eu has more than one box. An import needs a region with one box for now." { |
| 238 | t.Fatalf("an import into three boxes: %+v", ref) |
| 239 | } |
| 240 | } |
| 241 | |
| 242 | // A customer fleet's heartbeat is not a platform box's: a fresh, nested, |
| 243 | // claim-free fleet heartbeat under the same label cannot stand in for a |
| 244 | // stale platform box. |
| 245 | func TestSeedRegionReadyIgnoresFleetHeartbeats(t *testing.T) { |
| 246 | fx := newConsoleFixture(t) |
| 247 | ctx := context.Background() |
| 248 | publishSeedsFloorFx(t, fx, "v0.27.0") |
| 249 | seedReadyBox(t, fx, "kass-1", "", 1, "v0.27.0", "v0.27.0", "nested", false) |
| 250 | putSeedHeartbeat(t, fx, "kass-1", "nested", false, time.Now().Add(-time.Hour)) |
| 251 | reg, err := registry.Open(fx.regPath) |
| 252 | if err != nil { |
| 253 | t.Fatal(err) |
| 254 | } |
| 255 | t.Cleanup(func() { reg.Close() }) |
| 256 | if err := reg.UpsertFleet(registry.Fleet{Name: "pi", Account: fx.sqid, Bucket: "pi-bucket"}); err != nil { |
| 257 | t.Fatal(err) |
| 258 | } |
| 259 | putSeedHeartbeatIn(t, release.NewPrefixStore(fx.rel, "fleets/pi/"), "kass-1", "nested", false, time.Now()) |
| 260 | if ref := fx.srv.seedRegionReady(ctx, "", false); ref == nil || ref.Status != http.StatusServiceUnavailable { |
| 261 | t.Fatalf("a fleet heartbeat covered a stale platform box: %+v", ref) |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | // Two platform heartbeats claiming one label are ambiguous: unconfirmed, |
| 266 | // whichever the read happens to see last. |
| 267 | func TestSeedRegionReadyRefusesADuplicateLabel(t *testing.T) { |
| 268 | fx := newConsoleFixture(t) |
| 269 | ctx := context.Background() |
| 270 | publishSeedsFloorFx(t, fx, "v0.27.0") |
| 271 | seedReadyBox(t, fx, "kass-1", "", 1, "v0.27.0", "v0.27.0", "nested", false) |
| 272 | b := status.CollectBox(nil, status.Probes{Release: "v0.27.1", Layout: &status.LayoutReport{Mode: "flat"}}, time.Now()) |
| 273 | b.Label = "kass-1" |
| 274 | body, err := json.Marshal(b) |
| 275 | if err != nil { |
| 276 | t.Fatal(err) |
| 277 | } |
| 278 | if _, err := fx.rel.Put(ctx, "status/boxes/kass-1-old.json", body, ""); err != nil { |
| 279 | t.Fatal(err) |
| 280 | } |
| 281 | if ref := fx.srv.seedRegionReady(ctx, "", false); ref == nil || ref.Status != http.StatusServiceUnavailable { |
| 282 | t.Fatalf("two heartbeats for kass-1: %+v", ref) |
| 283 | } |
| 284 | } |
| 285 | |
| 286 | // The floor's own min protocol binds too, when it is above the one this |
| 287 | // console requires. |
| 288 | func TestSeedRegionReadyHonoursTheFloorsProtocol(t *testing.T) { |
| 289 | fx := newConsoleFixture(t) |
| 290 | ctx := context.Background() |
| 291 | if err := seeds.PublishFloor(ctx, fx.rel, seeds.Floor{Min: seeds.RequiredProtocol + 1, MinRelease: "v0.27.0"}); err != nil { |
| 292 | t.Fatal(err) |
| 293 | } |
| 294 | seedReadyBox(t, fx, "kass-1", "", seeds.RequiredProtocol, "v0.27.0", "v0.27.0", "nested", false) |
| 295 | want := "kass-1 in the home region are below the seeds floor. Roll the platform there, then publish the seeds floor again." |
| 296 | if ref := fx.srv.seedRegionReady(ctx, "", false); ref == nil || ref.Status != http.StatusConflict || ref.Msg != want { |
| 297 | t.Fatalf("a box below the floor's protocol: %+v", ref) |
| 298 | } |
| 299 | seedReadyBox(t, fx, "kass-1", "", seeds.RequiredProtocol+1, "v0.27.0", "v0.27.0", "nested", false) |
| 300 | if ref := fx.srv.seedRegionReady(ctx, "", false); ref != nil { |
| 301 | t.Fatalf("a box at the floor's protocol: %+v", ref) |
| 302 | } |
| 303 | } |
| 304 | |
| 305 | // A caller may name the home region rather than pass the record's "". |
| 306 | func TestSeedRegionReadyAcceptsTheHomeRegionsName(t *testing.T) { |
| 307 | fx := newConsoleFixture(t) |
| 308 | fx.srv.cfg.HomeRegion = "eu" |
| 309 | ctx := context.Background() |
| 310 | publishSeedsFloorFx(t, fx, "v0.27.0") |
| 311 | seedReadyBox(t, fx, "kass-1", "", 1, "v0.27.0", "v0.27.0", "nested", false) |
| 312 | for _, region := range []string{"", "eu"} { |
| 313 | if ref := fx.srv.seedRegionReady(ctx, region, false); ref != nil { |
| 314 | t.Errorf("region %q: %+v", region, ref) |
| 315 | } |
| 316 | } |
| 317 | seedReadyBox(t, fx, "kass-1", "", 1, "v0.27.0", "v0.27.0", "flat", false) |
| 318 | want := "kass-1 in eu do not run instances nested yet. A seeded instance needs every box there nested." |
| 319 | if ref := fx.srv.seedRegionReady(ctx, "eu", false); ref == nil || ref.Msg != want { |
| 320 | t.Fatalf("region by name, a flat box: %+v", ref) |
| 321 | } |
| 322 | } |
| 323 | |
| 324 | func TestSeedsFloorDoorIsForSystemOperators(t *testing.T) { |
| 325 | fx := newConsoleFixture(t) |
| 326 | if err := fx.st.AddMember(OpsAccountID, "member@keymail.dev", RoleMember, time.Now()); err != nil { |
| 327 | t.Fatal(err) |
| 328 | } |
| 329 | member := cliToken(t, fx, "member@keymail.dev", "code-seeds-member") |
| 330 | _, want := getRaw(t, fx.ts.URL+"/api/cli/system/no-such-door", member) |
| 331 | if st, body := postRaw(t, fx.ts.URL+"/api/cli/system/seeds-floor", map[string]string{"min_release": "v0.27.0"}, member); st != http.StatusNotFound || body != want { |
| 332 | t.Fatalf("a member = %d %q, want the bare 404", st, body) |
| 333 | } |
| 334 | owner := cliToken(t, fx, "paul@keymail.dev", "code-seeds-owner") |
| 335 | for _, bad := range []string{"latest", "", "0.27.0"} { |
| 336 | if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/seeds-floor", map[string]any{"min_release": bad}, owner); st != http.StatusBadRequest { |
| 337 | t.Fatalf("a bad release %q = %d %v, want 400", bad, st, out) |
| 338 | } |
| 339 | } |
| 340 | if f, err := seeds.ReadFloor(context.Background(), fx.rel); err != nil || f != nil { |
| 341 | t.Fatalf("a refused publish wrote a floor: %+v, %v", f, err) |
| 342 | } |
| 343 | if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/seeds-floor", map[string]any{"min_release": "v0.27.0"}, owner); st != http.StatusOK || out["min_release"] != "v0.27.0" { |
| 344 | t.Fatalf("publish = %d %v", st, out) |
| 345 | } |
| 346 | st, out := postJSON(t, fx.ts.URL+"/api/cli/system/seeds-floor", map[string]any{"min_release": "v0.26.0"}, owner) |
| 347 | if st != http.StatusConflict || !strings.Contains(str(t, out, "error"), "already v0.27.0") { |
| 348 | t.Fatalf("a lower floor = %d %v, want 409 naming v0.27.0", st, out) |
| 349 | } |
| 350 | f, err := seeds.ReadFloor(context.Background(), fx.rel) |
| 351 | if err != nil || f == nil || f.MinRelease != "v0.27.0" || f.Min != seeds.RequiredProtocol { |
| 352 | t.Fatalf("floor = %+v, %v", f, err) |
| 353 | } |
| 354 | } |
| 355 | |
| 356 | func TestPlan14SeedsFloorMixedFleet(t *testing.T) { |
| 357 | fx := newConsoleFixture(t) |
| 358 | publishSeedsFloorFx(t, fx, "v0.27.0") |
| 359 | seedReadyBox(t, fx, "kass-1", "", 1, "v0.27.0", "v0.27.0", "nested", false) |
| 360 | for _, extra := range []string{``, `,"moves_floor_state":"accepted"`, `,"moves_floor_state":"accepted","seeds_floor_state":"quarantined","seeds_floor_generation":1`, `,"moves_floor_state":"accepted","seeds_floor_state":"accepted","seeds_floor_generation":1`} { |
| 361 | putSeedEdges(t, fx, "kass-2", `{"v":1,"box":"kass-2","eip":"1.2.3.4","hosts":{},"seeds_protocol":1,"seeds_floor_min_release":"v0.27.0","prev_release":"none"`+extra+`}`) |
| 362 | putSeedHeartbeat(t, fx, "kass-2", "nested", false, time.Now()) |
| 363 | ref := fx.srv.seedRegionReady(t.Context(), "", false) |
| 364 | accepted := extra == "" || strings.Contains(extra, `"seeds_floor_state":"accepted"`) |
| 365 | if (ref == nil) != accepted { |
| 366 | t.Fatal("mixed fleet", extra, ref) |
| 367 | } |
| 368 | } |
| 369 | } |
| 370 | |