carlos system seeds-floor publish (Plan 3 Task 6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
13 files changed,
+155
−5
approved_seeded_copy_test.go+3 −2docs/site/cli.md+8 −0internal/console/authcli.go+2 −0internal/console/claim_invariant_fixtures_test.go+1 −0internal/console/claim_invariant_probes_nonapp_test.go+12 −0internal/console/seedfloor.go+37 −0internal/console/seedfloor_test.go+32 −0internal/console/svcroutes_test.go+5 −2internal/console/testdata/claim-routes.txt+1 −0internal/hostops/mutation_classifier.go+1 −0system.go+6 −1system_seeds.go+37 −0system_seeds_test.go+10 −0
CI passed — run details
diff --git a/approved_seeded_copy_test.go b/approved_seeded_copy_test.go| index 521a40e10..d97e6d7c9 100644 |
| --- a/approved_seeded_copy_test.go |
| +++ b/approved_seeded_copy_test.go |
| @@ -32,8 +32,9 @@ var seededCopyIDs = func() []string { |
| }() |
| // seededWiredIDs grows as the plans wire rows. Plan 1 wires none; Plan 3 |
| -// Task 3 wires SD-N1, Task 5 SD-N2 to SD-N8 and SD-N113. |
| -var seededWiredIDs = []string{"SD-N1", "SD-N2", "SD-N3", "SD-N4", "SD-N5", "SD-N6", "SD-N7", "SD-N8", "SD-N113"} |
| +// Task 3 wires SD-N1, Task 5 SD-N2 to SD-N8 and SD-N113, Task 6 SD-N9 to SD-N13, |
| +// SD-N111 and SD-N112. |
| +var seededWiredIDs = []string{"SD-N1", "SD-N2", "SD-N3", "SD-N4", "SD-N5", "SD-N6", "SD-N7", "SD-N8", "SD-N113", "SD-N9", "SD-N10", "SD-N11", "SD-N12", "SD-N13", "SD-N111", "SD-N112"} |
| func mustParseSeededRecord(t *testing.T) []copyRow { |
| t.Helper() |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index 2b69a00f6..21c9988cb 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -2032,6 +2032,14 @@ would linger. |
| Ops account owners and `CARLOS_SYSTEM_OPERATORS`. |
| +#### The seeds floor |
| + |
| +carlos system seeds-floor publish sets the first release whose boxes can place a seed. Publish it once every box runs that release. Each box arms it within the hour, and from then on a box never recovers to an older build. Until every box in a region has armed it, carlos instances create --from and carlos instances import refuse there. |
| + |
| +```sh |
| +carlos system seeds-floor publish --min-release v0.27.0 |
| +``` |
| + |
| ## On the host |
| These are what systemd starts. You will rarely type them, but knowing what |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 1015b357c..725d96432 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -943,6 +943,8 @@ func (s *Server) registerCLIRoutes() { |
| s.cliRoute("PUT /api/cli/system/geoip", svcrole.Set{}, s.handleAPISystemGeoipPut) |
| s.cliRoute("GET /api/cli/system/geoip", svcrole.Set{}, s.handleAPISystemGeoipGet) |
| s.cliRoute("DELETE /api/cli/system/geoip", svcrole.Set{}, s.handleAPISystemGeoipDelete) |
| + // The seeds floor (seeded-instances spec §3.3): apiSystemRoller. |
| + s.cliRoute("POST /api/cli/system/seeds-floor", svcrole.Set{}, s.handleAPISystemSeedsFloor) |
| } |
| // clientIP is who the rate limiter counts against. |
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go| index fdba18f99..a0572ad3c 100644 |
| --- a/internal/console/claim_invariant_fixtures_test.go |
| +++ b/internal/console/claim_invariant_fixtures_test.go |
| @@ -993,6 +993,7 @@ var claimFixtures = map[string]claimFixture{ |
| "route POST /api/cli/system/antivirus": claimNonApp("the deployment scanning switch (system/antivirus.json)"), |
| "route POST /api/cli/system/layout": claimNonApp("the deployment layout intent (control/layout.json)"), |
| "route POST /api/cli/system/rollback": claimNonApp("rolls the platform system pointer back"), |
| + "route POST /api/cli/system/seeds-floor": claimNonApp("operator seeds floor publish: control/seeds-protocol.json, classified exactly by the mutation boundary"), |
| "route POST /api/cli/system/update": claimNonApp("moves the platform system pointer to a signed release"), |
| "route PUT /api/cli/system/upload": claimNonApp("uploads and signs a platform system release"), |
| "route PUT /api/cli/system/geoip": claimNonApp("operator GeoIP publish: the platform database blob and pointer under control/geoip/, each classified exactly by the mutation boundary"), |
diff --git a/internal/console/claim_invariant_probes_nonapp_test.go b/internal/console/claim_invariant_probes_nonapp_test.go| index 0b5facdd0..90ec29991 100644 |
| --- a/internal/console/claim_invariant_probes_nonapp_test.go |
| +++ b/internal/console/claim_invariant_probes_nonapp_test.go |
| @@ -18,6 +18,7 @@ import ( |
| "github.com/carlosframework/platform/internal/geoip" |
| "github.com/carlosframework/platform/internal/infraview" |
| "github.com/carlosframework/platform/internal/release" |
| + "github.com/carlosframework/platform/internal/seeds" |
| "github.com/carlosframework/platform/internal/telemetry" |
| ) |
| @@ -660,6 +661,17 @@ var claimNonAppProbes = map[string]claimProbe{ |
| return "" |
| }, |
| }, |
| + "route POST /api/cli/system/seeds-floor": { |
| + do: claimAPIReply(http.MethodPost, "/api/cli/system/seeds-floor", `{"min_release":"v0.27.0"}`), |
| + body: []string{`"min_release":"v0.27.0"`}, |
| + effect: func(t *testing.T, cx *claimCase, r claimReply) string { |
| + f, err := seeds.ReadFloor(context.Background(), cx.rel) |
| + if err != nil || f == nil || f.MinRelease != "v0.27.0" { |
| + return "no seeds floor at v0.27.0 was published" |
| + } |
| + return "" |
| + }, |
| + }, |
| "route PUT /api/cli/system/upload": { |
| do: func(t *testing.T, cx *claimCase) claimReply { return cx.systemUploadReply(t, "66587fa") }, |
| body: []string{`"upload"`, `"size"`}, |
diff --git a/internal/console/seedfloor.go b/internal/console/seedfloor.go| index 80ecf200e..2cb09b527 100644 |
| --- a/internal/console/seedfloor.go |
| +++ b/internal/console/seedfloor.go |
| @@ -2,6 +2,7 @@ package console |
| import ( |
| "context" |
| + "errors" |
| "fmt" |
| "log" |
| "net/http" |
| @@ -152,3 +153,39 @@ func boxList(boxes []string) string { |
| sort.Strings(boxes) |
| return strings.Join(boxes, ", ") |
| } |
| + |
| +// handleAPISystemSeedsFloor publishes the seeds floor (spec §3.3, §10 step |
| +// 2). The update doors' authority: an ops account owner or an address on |
| +// CARLOS_SYSTEM_OPERATORS (apiSystemRoller). |
| +func (s *Server) handleAPISystemSeedsFloor(w http.ResponseWriter, r *http.Request) { |
| + addr, _, ok := s.apiSystemRoller(w, r) |
| + if !ok { |
| + return |
| + } |
| + var req struct { |
| + MinRelease string `json:"min_release"` |
| + } |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + // The v prefix is required here: ReleaseAtOrAbove tolerates its absence, |
| + // but the floor is stored and compared as typed, so "0.27.0" would sit in |
| + // the record beside every "v0.27.0" the boxes report. |
| + if ok, err := seeds.ReleaseAtOrAbove(req.MinRelease, req.MinRelease); !strings.HasPrefix(req.MinRelease, "v") || !ok || err != nil { |
| + jsonError(w, http.StatusBadRequest, "min_release must be a release such as v0.27.0") |
| + return |
| + } |
| + err := seeds.PublishFloor(r.Context(), s.cfg.Releases, seeds.Floor{Min: seeds.RequiredProtocol, MinRelease: req.MinRelease}) |
| + var lower *seeds.FloorLowerError |
| + switch { |
| + case errors.As(err, &lower): |
| + jsonError(w, http.StatusConflict, fmt.Sprintf("the seeds floor is already %s. A floor only rises.", lower.Current)) |
| + return |
| + case err != nil: |
| + log.Printf("console: publish seeds floor: %v", err) |
| + jsonError(w, http.StatusBadGateway, "could not publish the seeds floor. Try again.") |
| + return |
| + } |
| + s.auditSystem(addr, "seeds-floor-publish", fmt.Sprintf("published the seeds floor at %s", req.MinRelease)) |
| + writeJSON(w, http.StatusOK, map[string]string{"min_release": req.MinRelease}) |
| +} |
diff --git a/internal/console/seedfloor_test.go b/internal/console/seedfloor_test.go| index 81a472228..fa06a4e18 100644 |
| --- a/internal/console/seedfloor_test.go |
| +++ b/internal/console/seedfloor_test.go |
| @@ -320,3 +320,35 @@ func TestSeedRegionReadyAcceptsTheHomeRegionsName(t *testing.T) { |
| t.Fatalf("region by name, a flat box: %+v", ref) |
| } |
| } |
| + |
| +func TestSeedsFloorDoorIsForSystemOperators(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if err := fx.st.AddMember(OpsAccountID, "member@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + member := cliToken(t, fx, "member@keymail.dev", "code-seeds-member") |
| + _, want := getRaw(t, fx.ts.URL+"/api/cli/system/no-such-door", member) |
| + 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 { |
| + t.Fatalf("a member = %d %q, want the bare 404", st, body) |
| + } |
| + owner := cliToken(t, fx, "paul@keymail.dev", "code-seeds-owner") |
| + for _, bad := range []string{"latest", "", "0.27.0"} { |
| + if st, out := postJSON(t, fx.ts.URL+"/api/cli/system/seeds-floor", map[string]any{"min_release": bad}, owner); st != http.StatusBadRequest { |
| + t.Fatalf("a bad release %q = %d %v, want 400", bad, st, out) |
| + } |
| + } |
| + if f, err := seeds.ReadFloor(context.Background(), fx.rel); err != nil || f != nil { |
| + t.Fatalf("a refused publish wrote a floor: %+v, %v", f, err) |
| + } |
| + 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" { |
| + t.Fatalf("publish = %d %v", st, out) |
| + } |
| + st, out := postJSON(t, fx.ts.URL+"/api/cli/system/seeds-floor", map[string]any{"min_release": "v0.26.0"}, owner) |
| + if st != http.StatusConflict || !strings.Contains(str(t, out, "error"), "already v0.27.0") { |
| + t.Fatalf("a lower floor = %d %v, want 409 naming v0.27.0", st, out) |
| + } |
| + f, err := seeds.ReadFloor(context.Background(), fx.rel) |
| + if err != nil || f == nil || f.MinRelease != "v0.27.0" || f.Min != seeds.RequiredProtocol { |
| + t.Fatalf("floor = %+v, %v", f, err) |
| + } |
| +} |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| index 3bda8da4a..6b536e572 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -101,6 +101,9 @@ var closedDoors = []string{ |
| "PUT /api/cli/system/geoip", |
| "GET /api/cli/system/geoip", |
| "DELETE /api/cli/system/geoip", |
| + // The seeds floor (seeded-instances spec §3.3): deployment-wide and |
| + // operator-only. Publishing arms a floor every box enforces. |
| + "POST /api/cli/system/seeds-floor", |
| // A credential may never mint a credential, including itself. This is |
| // the escalation cut of spec §3. |
| "POST /api/cli/apps/{account}/{app}/instance-token", |
| @@ -906,8 +909,8 @@ func TestEveryClosedDoorRefusesAServiceCredential(t *testing.T) { |
| } |
| seen++ |
| } |
| - if seen != len(closedDoors) || seen != 47 { |
| - t.Errorf("walked %d closed doors, want all %d (and 47 today: instance-token, three door-key doors, the routes door, instances-manifest, sidecars-manifest, the route-channel move, two db replica doors, six fleets, four services, two email credentials, email pause, the build probe, the live tenancy read, the layout read, seven system doors, two deployment-wide antivirus, three platform geoip doors, four store-entitlement doors, and the five run doors)", |
| + if seen != len(closedDoors) || seen != 48 { |
| + t.Errorf("walked %d closed doors, want all %d (and 48 today: instance-token, three door-key doors, the routes door, instances-manifest, sidecars-manifest, the route-channel move, two db replica doors, six fleets, four services, two email credentials, email pause, the build probe, the live tenancy read, the layout read, eight system doors, two deployment-wide antivirus, three platform geoip doors, four store-entitlement doors, and the five run doors)", |
| seen, len(closedDoors)) |
| } |
| } |
diff --git a/internal/console/testdata/claim-routes.txt b/internal/console/testdata/claim-routes.txt| index af2a76894..a42796eaa 100644 |
| --- a/internal/console/testdata/claim-routes.txt |
| +++ b/internal/console/testdata/claim-routes.txt |
| @@ -223,6 +223,7 @@ route POST /api/cli/services/{account}/{name}/rotate |
| route POST /api/cli/system/antivirus |
| route POST /api/cli/system/layout |
| route POST /api/cli/system/rollback |
| +route POST /api/cli/system/seeds-floor |
| route POST /api/cli/system/update |
| route POST /api/ses/events |
| route POST /auth/cli/accounts |
diff --git a/internal/hostops/mutation_classifier.go b/internal/hostops/mutation_classifier.go| index 6a80ba912..1139b2765 100644 |
| --- a/internal/hostops/mutation_classifier.go |
| +++ b/internal/hostops/mutation_classifier.go |
| @@ -47,6 +47,7 @@ type nonAppFamily struct { |
| var nonAppFamilies = []nonAppFamily{ |
| {exact: "control/serial", reason: "deployment convergence notification counter; contains no app state"}, |
| {exact: "control/relay-writer.json", reason: "deployment relay writer lease; app effects remain separately admitted"}, |
| + {exact: "control/seeds-protocol.json", reason: "operator-published seeds floor; a protocol number and a release, no app state"}, |
| {prefix: "status/boxes/", deletes: true, reason: "per-box heartbeats; no app state"}, |
| {exact: "status/fleet.json", reason: "deployment fleet status summary"}, |
| {exact: "status/index.html", reason: "deployment status page"}, |
diff --git a/system.go b/system.go| index 4a1d600d0..e9faf5c7a 100644 |
| --- a/system.go |
| +++ b/system.go |
| @@ -49,7 +49,7 @@ func systemVerb(args []string) (verb string, rest []string, component string) { |
| // untouched in rest for runSystemAntivirusVerb to read. |
| // layout is a family too: migrate|revert|status stay in rest for |
| // runSystemLayout. |
| - case "guard-arm", "guard-exec", "update", "status", "rollback", "apply", "antivirus", "layout", "geoip", "help", "-h", "--help": |
| + case "guard-arm", "guard-exec", "update", "status", "rollback", "apply", "antivirus", "layout", "geoip", "seeds-floor", "help", "-h", "--help": |
| return args[0], args[1:], component |
| } |
| return "", args, component |
| @@ -77,6 +77,8 @@ func runSystem(args []string) { |
| runSystemLayout(rest) |
| case "geoip": |
| runSystemGeoipVerb(rest) |
| + case "seeds-floor": |
| + runSystemSeedsFloorVerb(rest) |
| case "help", "-h", "--help": |
| systemUsage() |
| default: |
| @@ -109,6 +111,7 @@ func systemUsage() { |
| carlos system router <verb> [flags] |
| carlos system antivirus <status|enable|disable> [flags] |
| carlos system geoip <publish|status|off> [flags] |
| + carlos system seeds-floor publish --min-release <release> |
| The platform's own binary (ops account owners and CARLOS_SYSTEM_OPERATORS): |
| @@ -118,6 +121,8 @@ The platform's own binary (ops account owners and CARLOS_SYSTEM_OPERATORS): |
| apply (root oneshot behind carlos-system-update.service; not for operators) |
| layout migrate|revert|status: move every instance into its own directory |
| and namespace, or back; see carlos system layout help |
| + seeds-floor publish --min-release <release> |
| + the first release whose boxes can place a seed, such as v0.27.0 |
| Deployment-wide settings (CARLOS_STORE_OPERATORS): |
diff --git a/system_seeds.go b/system_seeds.go| new file mode 100644 |
| index 000000000..cf096161b |
| --- /dev/null |
| +++ b/system_seeds.go |
| @@ -0,0 +1,37 @@ |
| +package main |
| + |
| +import ( |
| + "context" |
| + "flag" |
| + "fmt" |
| + "log" |
| + "net/http" |
| + "os" |
| +) |
| + |
| +// runSystemSeedsFloorVerb is `carlos system seeds-floor publish` |
| +// (seeded-instances spec §3.3, §10 step 2). |
| +func runSystemSeedsFloorVerb(rest []string) { |
| + if len(rest) == 0 || rest[0] != "publish" { |
| + systemUsage() |
| + os.Exit(2) |
| + } |
| + fs := flag.NewFlagSet("system seeds-floor publish", flag.ExitOnError) |
| + consoleFlag := consoleOption(fs, "console origin (default: the only logged-in one)") |
| + minRelease := fs.String("min-release", "", "the first release whose boxes can place a seed, such as v0.27.0") |
| + fs.Parse(rest[1:]) |
| + if *minRelease == "" || fs.NArg() != 0 { |
| + fs.Usage() |
| + os.Exit(2) |
| + } |
| + sess := requireDeployAPISession(*consoleFlag) |
| + printUsing(stdout, sess) |
| + status, body, err := sess.postJSON(context.Background(), "/api/cli/system/seeds-floor", map[string]string{"min_release": *minRelease}) |
| + if err != nil { |
| + log.Fatalf("carlos system seeds-floor publish: %v", err) |
| + } |
| + if status != http.StatusOK { |
| + log.Fatalf("carlos system seeds-floor publish: %v", apiErrorFromBody(status, body)) |
| + } |
| + fmt.Fprintln(stdout, fmt.Sprintf("published the seeds floor at %s. Every box arms it within the hour.", *minRelease)) |
| +} |
diff --git a/system_seeds_test.go b/system_seeds_test.go| new file mode 100644 |
| index 000000000..8033b90fe |
| --- /dev/null |
| +++ b/system_seeds_test.go |
| @@ -0,0 +1,10 @@ |
| +package main |
| + |
| +import "testing" |
| + |
| +func TestSystemVerbKnowsSeedsFloor(t *testing.T) { |
| + verb, rest, _ := systemVerb([]string{"seeds-floor", "publish", "--min-release", "v0.27.0"}) |
| + if verb != "seeds-floor" || len(rest) != 3 || rest[0] != "publish" { |
| + t.Fatalf("systemVerb = %q %q", verb, rest) |
| + } |
| +} |