carlos / platform Public

carlos system seeds-floor publish (Plan 3 Task 6)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 7cb5fc2349c9338540c235d467826287a7337604 parent 5fb3dca
13 files changed, +155 −5
  • approved_seeded_copy_test.go +3 −2
  • docs/site/cli.md +8 −0
  • internal/console/authcli.go +2 −0
  • internal/console/claim_invariant_fixtures_test.go +1 −0
  • internal/console/claim_invariant_probes_nonapp_test.go +12 −0
  • internal/console/seedfloor.go +37 −0
  • internal/console/seedfloor_test.go +32 −0
  • internal/console/svcroutes_test.go +5 −2
  • internal/console/testdata/claim-routes.txt +1 −0
  • internal/hostops/mutation_classifier.go +1 −0
  • system.go +6 −1
  • system_seeds.go +37 −0
  • system_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)
+ }
+}