edges file: the seeds protocol, floor and verdict (Plan 3 Task 4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+76
−4
internal/console/instancesplacement.go+5 −0internal/console/instancesplacement_test.go+21 −0internal/host/edgesfile.go+10 −0internal/host/edgesfile_test.go+4 −4internal/host/seedsfloor.go+12 −0internal/host/seedsfloor_linux_test.go+24 −0
CI passed — run details
diff --git a/internal/console/instancesplacement.go b/internal/console/instancesplacement.go| index 711005ee9..20d400cce 100644 |
| --- a/internal/console/instancesplacement.go |
| +++ b/internal/console/instancesplacement.go |
| @@ -84,6 +84,11 @@ type edgesFilePlacementView struct { |
| MovesFloorMinRelease string `json:"moves_floor_min_release"` |
| PrevRelease string `json:"prev_release"` |
| PrevAllowedFor string `json:"prev_allowed_for"` |
| + // The seeds floor (seeded-instances spec §3.3), read only by |
| + // seedRegionReady. |
| + SeedsProtocol int `json:"seeds_protocol"` |
| + SeedsFloorMinRelease string `json:"seeds_floor_min_release"` |
| + PrevSeedsAllowedFor string `json:"prev_seeds_allowed_for"` |
| // Fresh is not on the wire: readLiveEdgesSnapshot sets it when the |
| // box's own status snapshot is still fresh (status.BoxStale). An edges |
| // file is not liveness, see readLiveEdgesSnapshot. |
diff --git a/internal/console/instancesplacement_test.go b/internal/console/instancesplacement_test.go| index 748c91100..2a75c6bc6 100644 |
| --- a/internal/console/instancesplacement_test.go |
| +++ b/internal/console/instancesplacement_test.go |
| @@ -2,6 +2,7 @@ package console |
| import ( |
| "context" |
| + "encoding/json" |
| "testing" |
| ) |
| @@ -101,3 +102,23 @@ func TestPlacementsDormantWithoutAnyPlacementCode(t *testing.T) { |
| t.Error("published = true with no placement code anywhere — the gate must stay dormant") |
| } |
| } |
| + |
| +// The seeds fields are additive: an edges file from a box that predates |
| +// them parses, and reads as no protocol, no floor and no verdict. |
| +func TestEdgesFilePlacementViewSeedsFields(t *testing.T) { |
| + var old edgesFilePlacementView |
| + if err := json.Unmarshal([]byte(`{"box":"kass-1","moves_protocol":1}`), &old); err != nil { |
| + t.Fatal(err) |
| + } |
| + if old.SeedsProtocol != 0 || old.SeedsFloorMinRelease != "" || old.PrevSeedsAllowedFor != "" { |
| + t.Fatalf("old writer = %+v", old) |
| + } |
| + var cur edgesFilePlacementView |
| + body := `{"box":"kass-1","seeds_protocol":2,"seeds_floor_min_release":"v0.27.0","prev_seeds_allowed_for":"v0.27.0"}` |
| + if err := json.Unmarshal([]byte(body), &cur); err != nil { |
| + t.Fatal(err) |
| + } |
| + if cur.SeedsProtocol != 2 || cur.SeedsFloorMinRelease != "v0.27.0" || cur.PrevSeedsAllowedFor != "v0.27.0" { |
| + t.Fatalf("current writer = %+v", cur) |
| + } |
| +} |
diff --git a/internal/host/edgesfile.go b/internal/host/edgesfile.go| index 337e146fd..bc848fa75 100644 |
| --- a/internal/host/edgesfile.go |
| +++ b/internal/host/edgesfile.go |
| @@ -303,6 +303,15 @@ type edgesFile struct { |
| // carlos.prev was judged at or above. Absent: no verdict. |
| PrevAllowedFor string `json:"prev_allowed_for,omitempty"` |
| + // The seeds protocol (seeded-instances spec §3.3), read by the console's |
| + // seedRegionReady. SeedsProtocol is not omitempty: 0 is a real answer |
| + // (a build that cannot place a seed). SeedsFloorMinRelease is the |
| + // armed seeds floor's min_release (absent: not armed), and |
| + // PrevSeedsAllowedFor the floor carlos.prev was judged at or above. |
| + SeedsProtocol int `json:"seeds_protocol"` |
| + SeedsFloorMinRelease string `json:"seeds_floor_min_release,omitempty"` |
| + PrevSeedsAllowedFor string `json:"prev_seeds_allowed_for,omitempty"` |
| + |
| Generation string `json:"generation"` |
| Hosts map[string]edgeHost `json:"hosts"` |
| } |
| @@ -815,6 +824,7 @@ func edgesFileLoop(reg *registry.Registry) { |
| } |
| ident.Headroom = headroomFor(ident.Capabilities, dataDir, reg) |
| ident = withMovesFields(ident, dataDir, optDir) |
| + ident = withSeedsFields(ident, dataDir, optDir) |
| err = edgesFileWriteClaims(ctx, store, reg, ident, eip, &last, state.timed(), claims) |
| if err != nil { |
| // Throttled: a bucket that refuses every write (an IAM Deny, an |
diff --git a/internal/host/edgesfile_test.go b/internal/host/edgesfile_test.go| index 1faa27ac3..8e771b148 100644 |
| --- a/internal/host/edgesfile_test.go |
| +++ b/internal/host/edgesfile_test.go |
| @@ -317,7 +317,7 @@ func TestEdgesFileRawWireBodyMatchesSchema(t *testing.T) { |
| t.Fatalf("generation %q is not RFC3339: %v", probe.Generation, err) |
| } |
| - want := `{"v":1,"box":"quiet-harbour","eip":"203.0.113.10","hibernation_configured":false,"serves_shared_pool":false,"accepts_inbound":false,"moves_protocol":0,"generation":"` + |
| + want := `{"v":1,"box":"quiet-harbour","eip":"203.0.113.10","hibernation_configured":false,"serves_shared_pool":false,"accepts_inbound":false,"moves_protocol":0,"seeds_protocol":0,"generation":"` + |
| probe.Generation + `","hosts":{"app.example.com":{"role":"home","platform":false}}}` |
| if string(got) != want { |
| t.Fatalf("raw wire body =\n%s\nwant\n%s", got, want) |
| @@ -359,7 +359,7 @@ func TestEdgesFileRawWireBodyRegionField(t *testing.T) { |
| t.Fatal(err) |
| } |
| gen := genOf(t, got) |
| - want := `{"v":1,"box":"quiet-harbour","eip":"203.0.113.10","hibernation_configured":false,"serves_shared_pool":false,"accepts_inbound":false,"moves_protocol":0,"generation":"` + |
| + want := `{"v":1,"box":"quiet-harbour","eip":"203.0.113.10","hibernation_configured":false,"serves_shared_pool":false,"accepts_inbound":false,"moves_protocol":0,"seeds_protocol":0,"generation":"` + |
| gen + `","hosts":{"app.example.com":{"role":"home","platform":false}}}` |
| if string(got) != want { |
| t.Fatalf("raw wire body (region unset) =\n%s\nwant\n%s", got, want) |
| @@ -378,7 +378,7 @@ func TestEdgesFileRawWireBodyRegionField(t *testing.T) { |
| t.Fatal(err) |
| } |
| gen := genOf(t, got) |
| - want := `{"v":1,"box":"quiet-harbour","eip":"203.0.113.10","region":"eu-west-1","hibernation_configured":false,"serves_shared_pool":false,"accepts_inbound":false,"moves_protocol":0,"generation":"` + |
| + want := `{"v":1,"box":"quiet-harbour","eip":"203.0.113.10","region":"eu-west-1","hibernation_configured":false,"serves_shared_pool":false,"accepts_inbound":false,"moves_protocol":0,"seeds_protocol":0,"generation":"` + |
| gen + `","hosts":{"app.example.com":{"role":"home","platform":false}}}` |
| if string(got) != want { |
| t.Fatalf("raw wire body (region set) =\n%s\nwant\n%s", got, want) |
| @@ -931,7 +931,7 @@ func TestEdgesFileRawWireBodyPlacementField(t *testing.T) { |
| if err := json.Unmarshal(got, &probe); err != nil { |
| t.Fatal(err) |
| } |
| - want := `{"v":1,"box":"quiet-harbour","eip":"203.0.113.10","region":"eu-west-2","placement":"uk","hibernation_configured":false,"serves_shared_pool":false,"accepts_inbound":false,"moves_protocol":0,"generation":"` + |
| + want := `{"v":1,"box":"quiet-harbour","eip":"203.0.113.10","region":"eu-west-2","placement":"uk","hibernation_configured":false,"serves_shared_pool":false,"accepts_inbound":false,"moves_protocol":0,"seeds_protocol":0,"generation":"` + |
| probe.Generation + `","hosts":{"app.example.com":{"role":"home","platform":false}}}` |
| if string(got) != want { |
| t.Fatalf("raw wire body (placement set) =\n%s\nwant\n%s", got, want) |
diff --git a/internal/host/seedsfloor.go b/internal/host/seedsfloor.go| index 00edaac8f..4ef17e39a 100644 |
| --- a/internal/host/seedsfloor.go |
| +++ b/internal/host/seedsfloor.go |
| @@ -52,6 +52,18 @@ func readLocalSeedsFloor(dataDir string) (*seeds.Floor, error) { |
| return &fl, nil |
| } |
| +// withSeedsFields sets the edges file's seeds fields from this box's own |
| +// files, read at each write, as withMovesFields does for the moves floor. |
| +func withSeedsFields(f edgesFile, dataDir, optDir string) edgesFile { |
| + f.SeedsProtocol = seeds.ProtocolVersion |
| + f.SeedsFloorMinRelease = "" |
| + if fl, err := readLocalSeedsFloor(dataDir); err == nil && fl != nil { |
| + f.SeedsFloorMinRelease = fl.MinRelease |
| + } |
| + f.PrevSeedsAllowedFor, _ = readLine(seedsPrevAllowedPath(optDir)) |
| + return f |
| +} |
| + |
| // seedsFloorArmed: a local seeds floor that reads, or one that is there and |
| // cannot be read. |
| func seedsFloorArmed(dataDir string) bool { |
diff --git a/internal/host/seedsfloor_linux_test.go b/internal/host/seedsfloor_linux_test.go| index a1ba5a22d..a85a068c8 100644 |
| --- a/internal/host/seedsfloor_linux_test.go |
| +++ b/internal/host/seedsfloor_linux_test.go |
| @@ -4,8 +4,10 @@ package host |
| import ( |
| "context" |
| + "encoding/json" |
| "os" |
| "path/filepath" |
| + "strings" |
| "testing" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -118,3 +120,25 @@ func TestJudgeSeedsPrev(t *testing.T) { |
| t.Fatal("a verdict for a carlos.prev below the floor") |
| } |
| } |
| + |
| +func TestWithSeedsFields(t *testing.T) { |
| + dd, opt := t.TempDir(), t.TempDir() |
| + f := withSeedsFields(edgesFile{Box: "kass-1"}, dd, opt) |
| + if f.SeedsProtocol != seeds.ProtocolVersion || f.SeedsFloorMinRelease != "" || f.PrevSeedsAllowedFor != "" { |
| + t.Fatalf("unarmed = %+v", f) |
| + } |
| + s := release.NewDirStore(t.TempDir()) |
| + publishSeedsFloor(t, s, "v0.27.0") |
| + if _, err := syncLocalSeedsFloor(context.Background(), s, dd); err != nil { |
| + t.Fatal(err) |
| + } |
| + os.WriteFile(seedsPrevAllowedPath(opt), []byte("v0.27.0\n"), 0o644) |
| + f = withSeedsFields(f, dd, opt) |
| + if f.SeedsFloorMinRelease != "v0.27.0" || f.PrevSeedsAllowedFor != "v0.27.0" { |
| + t.Fatalf("armed = %+v", f) |
| + } |
| + body, _ := json.Marshal(f) |
| + if !strings.Contains(string(body), `"seeds_protocol":0`) { |
| + t.Fatalf("seeds_protocol must be on the wire even at 0: %s", body) |
| + } |
| +} |