region HA 2a: box and group role doors and the carlos system verbs
POST /api/cli/system/boxes/{box}/role and /groups/{region}/role (operator-only,
host claims on, 404 unless CARLOS_REGION_HA), Server.boxRoster, and carlos
system box role / group role. Copy rows RHA-C20 to RHA-C22 added (pending);
census: new accepted-legacy unit Server.boxRoster, seal and coverage re-pinned.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
17 files changed,
+634
−8
approved_region_ha_copy_test.go+6 −5docs/site/cli.md+13 −0docs/superpowers/plans/2026-10-16-region-ha-2a-copy.md+14 −0internal/console/apiboxroles.go+176 −0internal/console/apiboxroles_test.go+155 −0internal/console/authcli.go+4 −0internal/console/claim_invariant_fixtures_test.go+2 −0internal/console/claim_invariant_probes_nonapp_test.go+48 −0internal/console/svcroutes_test.go+4 −0internal/console/testdata/claim-routes.txt+2 −0internal/provision/plan14_root_reads_test.go+1 −1internal/provision/plan9_b8_census_test.go+1 −1internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv+8 −0region_ha_boundary_test.go+2 −0system.go+13 −1system_roles.go+118 −0system_roles_test.go+67 −0
diff --git a/approved_region_ha_copy_test.go b/approved_region_ha_copy_test.go| index 744d17db7..5210315a6 100644 |
| --- a/approved_region_ha_copy_test.go |
| +++ b/approved_region_ha_copy_test.go |
| @@ -14,7 +14,7 @@ import ( |
| // mean what they mean there: |
| // |
| // - TestRegionHACopyRecordIsComplete: the record names exactly RHA-C1 to |
| -// RHA-C19; |
| +// RHA-C22; |
| // - TestRegionHACopyHasNoEmDash; |
| // - TestRegionHABuiltCopyIsApproved: an id in regionHACopyBuilt is an |
| // approved row. No pending string reaches code, CLI help or docs; |
| @@ -24,10 +24,11 @@ const regionHACopyRecordPath = "docs/superpowers/plans/2026-10-16-region-ha-2a-c |
| var regionHACopyIDPattern = regexp.MustCompile(`^RHA-C[0-9]+$`) |
| -// regionHACopyIDs is the record's inventory: RHA-C1 to RHA-C19, pinned. |
| +// regionHACopyIDs is the record's inventory: RHA-C1 to RHA-C22, pinned |
| +// (RHA-C20 to RHA-C22 are Task 10's further rows). |
| var regionHACopyIDs = func() []string { |
| - ids := make([]string, 0, 19) |
| - for i := 1; i <= 19; i++ { |
| + ids := make([]string, 0, 22) |
| + for i := 1; i <= 22; i++ { |
| ids = append(ids, "RHA-C"+strconv.Itoa(i)) |
| } |
| return ids |
| @@ -36,7 +37,7 @@ var regionHACopyIDs = func() []string { |
| // regionHACopyBuilt is every id whose text the code or docs carry. The |
| // task that writes a row's text adds its id here in the same commit, once |
| // Paul has approved the row. |
| -var regionHACopyBuilt = []string{} |
| +var regionHACopyBuilt = []string{"RHA-C1", "RHA-C2", "RHA-C3", "RHA-C4", "RHA-C5", "RHA-C6", "RHA-C7", "RHA-C8", "RHA-C9", "RHA-C10", "RHA-C11", "RHA-C12", "RHA-C13", "RHA-C14", "RHA-C15", "RHA-C16", "RHA-C20", "RHA-C21", "RHA-C22"} |
| func mustParseRegionHARecord(t *testing.T) []copyRow { |
| t.Helper() |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index 07c4d2939..5c09b4231 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -2200,6 +2200,19 @@ carlos system legacy-routes assign says which account and app the routes an oper |
| carlos system legacy-routes assign --name shop --to bac/shop |
| ``` |
| +#### Box and group roles |
| + |
| +carlos system box role says whether new instances may be placed on a box. A box with the default role can take them. A legacy or platform box keeps the instances it has and takes no new ones: no new instance, relocation or move lands on it. carlos system group role sets the role of every box a region's group has enrolled, and of every box it enrolls later. Both work only on a console with region HA on. |
| + |
| +Do not set a role until every box and console in the deployment runs a release that knows about roles. A box on an older release cannot read a roster that carries one. |
| + |
| +```sh |
| +carlos system box role i-0123456789abcdef0 legacy |
| +carlos system group role ie default |
| +``` |
| + |
| +Ops account owners and `CARLOS_SYSTEM_OPERATORS`. |
| + |
| ### carlos move |
| Moves one instance to another region. The instance keeps serving while its |
diff --git a/docs/superpowers/plans/2026-10-16-region-ha-2a-copy.md b/docs/superpowers/plans/2026-10-16-region-ha-2a-copy.md| index cb94e5c3c..0eb368371 100644 |
| --- a/docs/superpowers/plans/2026-10-16-region-ha-2a-copy.md |
| +++ b/docs/superpowers/plans/2026-10-16-region-ha-2a-copy.md |
| @@ -60,3 +60,17 @@ domain attach, standalone sidecar create and the move door's destination. |
| | RHA-C17 | internal/console/nocapacity.go | No box in <region> has room right now. Try again in a few minutes. | pending | |
| | RHA-C18 | internal/console/nocapacity.go (the operator alert's subject) | No box in <region> has room | pending | |
| | RHA-C19 | internal/console/nocapacity.go (the operator alert's body) | A request in <region> was refused because no box there can take it. Every box is full, stale or excluded by its role. Add a box, or free memory or disk on one, and check the eligible-capacity alarm for <region>. | pending | |
| + |
| +## More Task 10 rows |
| + |
| +Added at Task 10's build (controller ruling): every sentence a person reads |
| +that the role doors, the CLI and the docs section carry and the rows above |
| +do not. Command syntax (the usage synopses and the docs example's commands) |
| +is not copy. The numbering follows Task 11's, which is why these start at |
| +RHA-C20. |
| + |
| +| id | file | text | status | |
| +|---|---|---|---| |
| +| RHA-C20 | system_roles.go | unreadable response: <error> | pending | |
| +| RHA-C21 | docs/site/cli.md | Ops account owners and `CARLOS_SYSTEM_OPERATORS`. | pending | |
| +| RHA-C22 | docs/site/cli.md | Do not set a role until every box and console in the deployment runs a release that knows about roles. A box on an older release cannot read a roster that carries one. | pending | |
diff --git a/internal/console/apiboxroles.go b/internal/console/apiboxroles.go| new file mode 100644 |
| index 000000000..32a829ba3 |
| --- /dev/null |
| +++ b/internal/console/apiboxroles.go |
| @@ -0,0 +1,176 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "fmt" |
| + "log" |
| + "net/http" |
| + |
| + "github.com/carlosframework/platform/internal/hostactors" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// Box and group roles (region HA spec 2 §7, A11-1): `carlos system box role |
| +// <box> <role>` and `carlos system group role <region> <role>`. A legacy or |
| +// platform box takes no new instance, relocation or move; it keeps the |
| +// ones it has (H25). A group's role is every box it enrolled, now and |
| +// later. The roles live in the signed roster, so the console, the only |
| +// holder of the root key, is the only writer. |
| +// |
| +// Operator-only (apiSystemRoller, the update doors' authority), registered |
| +// only with host claims on, and answering 404 unless CARLOS_REGION_HA is on |
| +// too: with region HA off nothing reads a role. |
| + |
| +const ( |
| + boxRoleRefusal = "role must be default, legacy or platform" |
| + groupRoleRefusal = "a group's role must be default or legacy" |
| + boxRosterUnread = "could not read the box roster. Try again." |
| + boxRosterChanged = "the box roster changed while the role was being saved, and nothing was changed. Try again." |
| + boxRoleWriteFailure = "could not save the role. Try again." |
| +) |
| + |
| +func noEnrolledBox(name string) string { return fmt.Sprintf("no enrolled box is named %s", name) } |
| + |
| +func noGroupIn(region string) string { return fmt.Sprintf("%s has no group", region) } |
| + |
| +func retiredBoxRole(box string) string { return fmt.Sprintf("%s is retired, so it takes no role", box) } |
| + |
| +// boxRoster reads the signed roster with the root signer that writes it, |
| +// for the role doors alone, which have already let only an operator in |
| +// (roleOperator). It is a read, of the one roster every reader reads |
| +// (control/host-actors.json through hostactors.Directory). Census ruling |
| +// region-ha-2a's conditions hold it to that: operator-only, read-only, the |
| +// same roster, and no role it reads feeds root's claim authority (roles |
| +// are read by the placement chooser alone). |
| +func (s *Server) boxRoster(ctx context.Context) (hostactors.Directory, release.Signer, hostactors.Observed, error) { |
| + if s.cfg.Releases == nil || s.cfg.Bucket == "" || len(s.cfg.Key) == 0 { |
| + return hostactors.Directory{}, release.Signer{}, hostactors.Observed{}, errors.New("console: no deployment bucket or root key") |
| + } |
| + signer := release.RootSigner(s.cfg.Key) |
| + dir := hostactors.Directory{Store: s.cfg.Releases, PublicKey: signer.RootPublicKey(), Bucket: s.cfg.Bucket} |
| + o, err := dir.Read(ctx) |
| + return dir, signer, o, err |
| +} |
| + |
| +// rosterRef finds a box by reference, or by the one label no retired box |
| +// shares (hostactors.Directory.Resolve's rule, over a roster in hand). |
| +func rosterRef(r hostactors.Roster, name string) (string, bool) { |
| + if _, ok := r.Boxes[name]; ok { |
| + return name, true |
| + } |
| + found := "" |
| + for ref, b := range r.Boxes { |
| + if b.Label == name && b.State != "retired" { |
| + if found != "" { |
| + return "", false |
| + } |
| + found = ref |
| + } |
| + } |
| + return found, found != "" |
| +} |
| + |
| +// roleOperator is the role doors' gate: an operator, with region HA on. |
| +func (s *Server) roleOperator(w http.ResponseWriter, r *http.Request) (string, bool) { |
| + addr, _, ok := s.apiSystemRoller(w, r) |
| + if !ok { |
| + return "", false |
| + } |
| + if !s.cfg.RegionHA { |
| + http.NotFound(w, r) |
| + return "", false |
| + } |
| + return addr, true |
| +} |
| + |
| +// roleWriteError answers a roster write that did not land. |
| +func roleWriteError(w http.ResponseWriter, what string, err error) { |
| + if errors.Is(err, release.ErrCASConflict) || errors.Is(err, hostactors.ErrRoster) { |
| + jsonError(w, http.StatusConflict, boxRosterChanged) |
| + return |
| + } |
| + log.Printf("console: %s: %v", what, err) |
| + jsonError(w, http.StatusServiceUnavailable, boxRoleWriteFailure) |
| +} |
| + |
| +func (s *Server) handleAPISystemBoxRole(w http.ResponseWriter, r *http.Request) { |
| + by, ok := s.roleOperator(w, r) |
| + if !ok { |
| + return |
| + } |
| + var req struct { |
| + Role string `json:"role"` |
| + } |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + role, err := hostactors.ParseBoxRole(req.Role) |
| + if err != nil { |
| + jsonError(w, http.StatusBadRequest, boxRoleRefusal) |
| + return |
| + } |
| + dir, signer, o, err := s.boxRoster(r.Context()) |
| + if err != nil { |
| + log.Printf("console: box role: roster: %v", err) |
| + jsonError(w, http.StatusServiceUnavailable, boxRosterUnread) |
| + return |
| + } |
| + name := r.PathValue("box") |
| + ref, ok := rosterRef(o.Roster, name) |
| + if !ok { |
| + jsonError(w, http.StatusNotFound, noEnrolledBox(name)) |
| + return |
| + } |
| + if o.Roster.Boxes[ref].State == "retired" { |
| + jsonError(w, http.StatusConflict, retiredBoxRole(ref)) |
| + return |
| + } |
| + next, err := dir.SetBoxRole(r.Context(), signer, o, ref, role) |
| + if err != nil { |
| + roleWriteError(w, "box role "+ref, err) |
| + return |
| + } |
| + log.Printf("console: box %s has the %s role (by %s, roster revision %d)", ref, hostactors.RoleName(role), by, next.Roster.Revision) |
| + writeJSON(w, http.StatusOK, map[string]string{"box": ref, "role": hostactors.RoleName(role)}) |
| +} |
| + |
| +func (s *Server) handleAPISystemGroupRole(w http.ResponseWriter, r *http.Request) { |
| + by, ok := s.roleOperator(w, r) |
| + if !ok { |
| + return |
| + } |
| + var req struct { |
| + Role string `json:"role"` |
| + } |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + role, err := hostactors.ParseGroupRole(req.Role) |
| + if err != nil { |
| + jsonError(w, http.StatusBadRequest, groupRoleRefusal) |
| + return |
| + } |
| + dir, signer, o, err := s.boxRoster(r.Context()) |
| + if err != nil { |
| + log.Printf("console: group role: roster: %v", err) |
| + jsonError(w, http.StatusServiceUnavailable, boxRosterUnread) |
| + return |
| + } |
| + region := r.PathValue("region") |
| + g, ok := o.Roster.Regions[region] |
| + if !ok || g.GroupName == "" { |
| + jsonError(w, http.StatusNotFound, noGroupIn(region)) |
| + return |
| + } |
| + next, boxes, err := dir.SetGroupRole(r.Context(), signer, o, region, role) |
| + if err != nil { |
| + roleWriteError(w, "group role "+region, err) |
| + return |
| + } |
| + log.Printf("console: the group in %s (%s) and its boxes %v have the %s role (by %s, roster revision %d)", region, g.GroupName, boxes, hostactors.RoleName(role), by, next.Roster.Revision) |
| + if boxes == nil { |
| + boxes = []string{} |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"region": region, "group": g.GroupName, "role": hostactors.RoleName(role), "boxes": boxes}) |
| +} |
diff --git a/internal/console/apiboxroles_test.go b/internal/console/apiboxroles_test.go| new file mode 100644 |
| index 000000000..da9d498dc |
| --- /dev/null |
| +++ b/internal/console/apiboxroles_test.go |
| @@ -0,0 +1,155 @@ |
| +package console |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "net/http" |
| + "net/http/httptest" |
| + "reflect" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostactors" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// roleFixture is a console with region HA on and a signed roster: in ie, |
| +// an operator-enrolled box ie-old and two boxes the group "routers" |
| +// enrolled; uk has no group. |
| +func roleFixture(t *testing.T) *consoleFixture { |
| + t.Helper() |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.RegionHA = true |
| + old := moveRosterBox(ieBox("ie-old")) |
| + g1, g2 := moveRosterBox(ieBox("ie-g1")), moveRosterBox(ieBox("ie-g2")) |
| + g1.Group, g2.Group = "routers", "routers" |
| + putMoveRoster(t, fx, hostactors.Roster{ |
| + Boxes: map[string]hostactors.Box{"ie-old": old, "ie-g1": g1, "ie-g2": g2}, |
| + Regions: map[string]hostactors.Region{ |
| + "ie": {Account: moveTestAccount, AWSRegion: "eu-west-1", GroupName: "routers", GroupARN: "arn:group"}, |
| + "uk": {Account: moveTestAccount, AWSRegion: "eu-west-2"}, |
| + }, |
| + }) |
| + return fx |
| +} |
| + |
| +// roleCall drives one role door as addr, past cliRoute. |
| +func roleCall(t *testing.T, h http.HandlerFunc, addr, pathKey, pathValue, body string) (int, map[string]any) { |
| + t.Helper() |
| + r := httptest.NewRequest(http.MethodPost, "/api/cli/system/x/y/role", bytes.NewBufferString(body)) |
| + r.Header.Set("Content-Type", "application/json") |
| + r.SetPathValue(pathKey, pathValue) |
| + r = r.WithContext(context.WithValue(r.Context(), cliRouteKey{}, Principal{Kind: PrincipalUser, Address: addr})) |
| + w := httptest.NewRecorder() |
| + h(w, r) |
| + var out map[string]any |
| + _ = json.Unmarshal(w.Body.Bytes(), &out) |
| + return w.Code, out |
| +} |
| + |
| +func rosterNow(t *testing.T, fx *consoleFixture) hostactors.Roster { |
| + t.Helper() |
| + _, _, o, err := fx.srv.boxRoster(context.Background()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return o.Roster |
| +} |
| + |
| +func TestBoxRoleDoor(t *testing.T) { |
| + fx := roleFixture(t) |
| + h := fx.srv.handleAPISystemBoxRole |
| + if st, _ := roleCall(t, h, "someone@keymail.dev", "box", "ie-old", `{"role":"legacy"}`); st != http.StatusNotFound { |
| + t.Fatalf("a non-operator = %d, want 404", st) |
| + } |
| + fx.srv.cfg.RegionHA = false |
| + if st, _ := roleCall(t, h, "paul@keymail.dev", "box", "ie-old", `{"role":"legacy"}`); st != http.StatusNotFound { |
| + t.Fatalf("region HA off = %d, want 404", st) |
| + } |
| + fx.srv.cfg.RegionHA = true |
| + if st, out := roleCall(t, h, "paul@keymail.dev", "box", "ie-old", `{"role":"draining"}`); st != http.StatusBadRequest || out["error"] != boxRoleRefusal { |
| + t.Fatalf("unknown role = %d %v", st, out) |
| + } |
| + if st, out := roleCall(t, h, "paul@keymail.dev", "box", "nobody", `{"role":"legacy"}`); st != http.StatusNotFound || out["error"] != noEnrolledBox("nobody") { |
| + t.Fatalf("unknown box = %d %v", st, out) |
| + } |
| + // By label, as an operator names it. |
| + st, out := roleCall(t, h, "paul@keymail.dev", "box", "ie-old-label", `{"role":"legacy"}`) |
| + if st != http.StatusOK || out["box"] != "ie-old" || out["role"] != "legacy" { |
| + t.Fatalf("set = %d %v", st, out) |
| + } |
| + if got := rosterNow(t, fx).Boxes["ie-old"].Role; got != hostactors.RoleLegacy { |
| + t.Fatalf("the roster says %q", got) |
| + } |
| + st, out = roleCall(t, h, "paul@keymail.dev", "box", "ie-old", `{"role":"default"}`) |
| + if st != http.StatusOK || out["role"] != "default" || rosterNow(t, fx).Boxes["ie-old"].Role != hostactors.RoleDefault { |
| + t.Fatalf("back to default = %d %v", st, out) |
| + } |
| +} |
| + |
| +func TestGroupRoleDoor(t *testing.T) { |
| + fx := roleFixture(t) |
| + h := fx.srv.handleAPISystemGroupRole |
| + if st, out := roleCall(t, h, "paul@keymail.dev", "region", "ie", `{"role":"platform"}`); st != http.StatusBadRequest || out["error"] != groupRoleRefusal { |
| + t.Fatalf("platform group = %d %v", st, out) |
| + } |
| + if st, out := roleCall(t, h, "paul@keymail.dev", "region", "uk", `{"role":"legacy"}`); st != http.StatusNotFound || out["error"] != noGroupIn("uk") { |
| + t.Fatalf("no group = %d %v", st, out) |
| + } |
| + st, out := roleCall(t, h, "paul@keymail.dev", "region", "ie", `{"role":"legacy"}`) |
| + if st != http.StatusOK || out["group"] != "routers" || out["role"] != "legacy" || !reflect.DeepEqual(out["boxes"], []any{"ie-g1", "ie-g2"}) { |
| + t.Fatalf("set = %d %v", st, out) |
| + } |
| + r := rosterNow(t, fx) |
| + if r.Regions["ie"].Role != hostactors.RoleLegacy || r.Boxes["ie-g1"].Role != hostactors.RoleLegacy || r.Boxes["ie-g2"].Role != hostactors.RoleLegacy || r.Boxes["ie-old"].Role != hostactors.RoleDefault { |
| + t.Fatalf("roster after the group role: %+v", r.Boxes) |
| + } |
| +} |
| + |
| +func TestRoleWriteErrors(t *testing.T) { |
| + for _, c := range []struct { |
| + err error |
| + code int |
| + msg string |
| + }{ |
| + {release.ErrCASConflict, http.StatusConflict, boxRosterChanged}, |
| + {hostactors.ErrRoster, http.StatusConflict, boxRosterChanged}, |
| + {errors.New("SlowDown"), http.StatusServiceUnavailable, boxRoleWriteFailure}, |
| + } { |
| + w := httptest.NewRecorder() |
| + roleWriteError(w, "test", c.err) |
| + var out map[string]string |
| + _ = json.Unmarshal(w.Body.Bytes(), &out) |
| + if w.Code != c.code || out["error"] != c.msg { |
| + t.Fatalf("%v = %d %v", c.err, w.Code, out) |
| + } |
| + } |
| +} |
| + |
| +// The doors exist only on a host-claim console, operator-only and 404 |
| +// with region HA off. |
| +func TestRoleDoorsAreRegisteredWithHostClaims(t *testing.T) { |
| + st := openGatedStore(t) |
| + rel := release.NewDirStore(t.TempDir()) |
| + path := "/api/cli/system/boxes/ie-old/role" |
| + off, err := NewServer(Config{Origin: "https://console.example", Keymail: "https://keymail.example", Releases: rel}, st) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ghostStatus, ghostBody := serveHostClaim(t, off, "/unknown-role-route", "") |
| + if status, body := serveHostClaim(t, off, path, ""); status != ghostStatus || !bytes.Equal(body, ghostBody) { |
| + t.Fatalf("claims off = %d %q, want the unknown route's %d %q", status, body, ghostStatus, ghostBody) |
| + } |
| + token := mintHostClaimToken(t, st, "paul@keymail.dev") |
| + on, err := NewServer(Config{Origin: "https://console.example", Keymail: "https://keymail.example", Releases: rel, HostClaim: true, SystemOperators: []string{"paul@keymail.dev"}}, st) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if status, _ := serveHostClaim(t, on, path, ""); status != http.StatusUnauthorized { |
| + t.Fatalf("unsigned = %d", status) |
| + } |
| + if status, _ := serveHostClaim(t, on, path, token); status != http.StatusNotFound { |
| + t.Fatalf("region HA off = %d, want 404", status) |
| + } |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 9a0b20ed7..fb22494bb 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -512,6 +512,10 @@ func (s *Server) registerCLIRoutes() { |
| // ActorDirectory both routes stop at the same inert refusal. |
| s.cliRoute("POST /api/cli/ops/hosts/{host}/resolve/inspect", svcrole.Set{}, s.handleAPIHostResolveInspect) |
| s.cliRoute("POST /api/cli/ops/hosts/{host}/resolve", svcrole.Set{}, s.handleAPIHostResolve) |
| + // Box and group roles (region HA spec 2 §7, apiboxroles.go): |
| + // operator-only, and 404 unless CARLOS_REGION_HA is on too. |
| + s.cliRoute("POST /api/cli/system/boxes/{box}/role", svcrole.Set{}, s.handleAPISystemBoxRole) |
| + s.cliRoute("POST /api/cli/system/groups/{region}/role", svcrole.Set{}, s.handleAPISystemGroupRole) |
| } |
| // carlos status --layout (plan 4, task 5): which layout each instance is |
| // on, box by box. Same posture as the door above — an empty role set, the |
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go| index 215960ec9..53770c0b8 100644 |
| --- a/internal/console/claim_invariant_fixtures_test.go |
| +++ b/internal/console/claim_invariant_fixtures_test.go |
| @@ -1129,6 +1129,8 @@ var claimFixtures = map[string]claimFixture{ |
| }}, |
| "route POST /api/cli/ops/hosts/{host}/resolve/inspect": claimRead("operator host-loss inspection: reads the claim, the record and the roster and signs a challenge; writes nothing"), |
| "route POST /api/cli/system/antivirus": claimNonApp("the deployment scanning switch (system/antivirus.json)"), |
| + "route POST /api/cli/system/boxes/{box}/role": claimNonApp("operator box role (region HA spec 2 §7): one signed roster write to control/host-actors.json; names no app"), |
| + "route POST /api/cli/system/groups/{region}/role": claimNonApp("operator group role (region HA spec 2 §7): one signed roster write to control/host-actors.json, the group and the boxes it enrolled; names no app"), |
| "route POST /api/cli/system/layout": claimNonApp("the deployment layout intent (control/layout.json)"), |
| "route POST /api/cli/system/activation/cancel": claimNonApp("cancels an unpublished system activation"), |
| "route POST /api/cli/system/pointer-digests": claimNonApp("operator switch: writes or removes each account's root-signed pointer digest marker (releases/<acct>/pointer-digests), 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 d9aa1c8ae..6a8ffad45 100644 |
| --- a/internal/console/claim_invariant_probes_nonapp_test.go |
| +++ b/internal/console/claim_invariant_probes_nonapp_test.go |
| @@ -674,6 +674,54 @@ var claimNonAppProbes = map[string]claimProbe{ |
| return "" |
| }, |
| }, |
| + // Box and group roles (region HA spec 2 §7): one signed roster write, |
| + // only with region HA on. |
| + "route POST /api/cli/system/boxes/{box}/role": { |
| + setup: func(t *testing.T, cx *claimCase) { cx.srv.cfg.RegionHA = true }, |
| + do: claimAPIReply(http.MethodPost, "/api/cli/system/boxes/box-ie-default/role", `{"role":"legacy"}`), |
| + body: []string{`"role":"legacy"`}, |
| + effect: func(t *testing.T, cx *claimCase, r claimReply) string { |
| + o, err := cx.claimFixtureDirectory().Read(context.Background()) |
| + if err != nil || o.Roster.Boxes["box-ie-default"].Role != hostactors.RoleLegacy { |
| + return "box-ie-default does not have the legacy role in the signed roster" |
| + } |
| + return "" |
| + }, |
| + }, |
| + "route POST /api/cli/system/groups/{region}/role": { |
| + setup: func(t *testing.T, cx *claimCase) { |
| + cx.srv.cfg.RegionHA = true |
| + ctx := context.Background() |
| + o, err := cx.claimFixtureDirectory().Read(ctx) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + next := hostactors.Clone(o.Roster) |
| + g := next.Regions["ie"] |
| + g.GroupName, g.GroupARN = "routers", "arn:aws:autoscaling:eu-west-1:123456789012:autoScalingGroup:routers" |
| + next.Regions["ie"] = g |
| + b := next.Boxes["box-ie-default"] |
| + b.Group = "routers" |
| + next.Boxes["box-ie-default"] = b |
| + next.Revision++ |
| + raw, err := release.SignRootEnvelope(release.RootSigner(cx.priv), next) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = cx.rel.Put(ctx, hostactors.Key, raw, o.ETag); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, |
| + do: claimAPIReply(http.MethodPost, "/api/cli/system/groups/ie/role", `{"role":"legacy"}`), |
| + body: []string{`"boxes":["box-ie-default"]`}, |
| + effect: func(t *testing.T, cx *claimCase, r claimReply) string { |
| + o, err := cx.claimFixtureDirectory().Read(context.Background()) |
| + if err != nil || o.Roster.Regions["ie"].Role != hostactors.RoleLegacy || o.Roster.Boxes["box-ie-default"].Role != hostactors.RoleLegacy { |
| + return "the group in ie and its box do not have the legacy role in the signed roster" |
| + } |
| + 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"`}, |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| index 89c5b7b93..262a15ad8 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -286,6 +286,10 @@ func TestEveryCLIDoorNamesItsRoles(t *testing.T) { |
| hostClaimDoors := []string{ |
| "POST /api/cli/ops/hosts/{host}/resolve/inspect", |
| "POST /api/cli/ops/hosts/{host}/resolve", |
| + // Box and group roles (region HA spec 2 §7): operator-only, and 404 |
| + // unless CARLOS_REGION_HA is on as well. |
| + "POST /api/cli/system/boxes/{box}/role", |
| + "POST /api/cli/system/groups/{region}/role", |
| } |
| movesDoors := []string{ |
| "POST /api/cli/apps/{account}/{app}/instances/{host}/move/resolve", |
diff --git a/internal/console/testdata/claim-routes.txt b/internal/console/testdata/claim-routes.txt| index fc667b937..7e71c4dc8 100644 |
| --- a/internal/console/testdata/claim-routes.txt |
| +++ b/internal/console/testdata/claim-routes.txt |
| @@ -237,6 +237,8 @@ route POST /api/cli/services/{account}/{name}/revoke |
| route POST /api/cli/services/{account}/{name}/rotate |
| route POST /api/cli/system/activation/cancel |
| route POST /api/cli/system/antivirus |
| +route POST /api/cli/system/boxes/{box}/role |
| +route POST /api/cli/system/groups/{region}/role |
| route POST /api/cli/system/layout |
| route POST /api/cli/system/legacy-routes |
| route POST /api/cli/system/pointer-digests |
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go| index 4c5bc4c89..cad6a66a5 100644 |
| --- a/internal/provision/plan14_root_reads_test.go |
| +++ b/internal/provision/plan14_root_reads_test.go |
| @@ -797,7 +797,7 @@ func TestPlan14RootReadCensus(t *testing.T) { |
| if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv"), &inv, true); err != nil { |
| t.Fatal(err) |
| } |
| - seal = "5d1b9b6addf6c9964ed18b417ee5b3d0d49fd43e1acabb775e054bba4b6e314e" |
| + seal = "4f1164be6f79275a71801f5309a77b6e773fe2b4343820106ce8e3ab8e6c2a2a" |
| errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...) |
| errs = append(errs, checkRootReadDeclarations(got, inv)...) |
| if len(errs) > 0 { |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 593038ff4..daff9cc12 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -692,7 +692,7 @@ var plan9B8CensusInventory = []censusEntry{ |
| // Registers instance and sidecar HTTP paths; record handlers use the reviewed provision writers. |
| // Re-reviewed at the merge of main into carlos-run (2026-10-13): main's routes (the retry door among them) with the five run doors registered beside them; record writes stay in the reviewed handlers. |
| // Re-reviewed at the merge of carlos-run into seeded-instances (2026-10-13): main's routes and the run doors with the four import doors, the seeds-floor door and the legacy-routes door registered beside them; record writes stay in the reviewed handlers. |
| - {"internal/console/authcli.go", "Server.registerCLIRoutes", "key-site", "lit:instances,lit:sidecars", "bb32a85461c3253b2b20f4191ae5fb1f2aa26e5e6c038bc338b6d16a14e7301a"}, |
| + {"internal/console/authcli.go", "Server.registerCLIRoutes", "key-site", "lit:instances,lit:sidecars", "b55987735c833b952a7ef835302dba728c803379ecb474c3d465bb268cf4c781"}, |
| {"internal/console/browserrestart.go", "Server.handleBrowserRestart", "key-site", "lit:instances", "955d71813504f2cfec1e1f64817d5aa5a921aa38e1044c0e903d2cfbe4d073f4"}, |
| // Builds the instance page path and picks the confirmation line (a declined box's own line); the only write is the reviewed retry door (requestHostRetry), no record write here. |
| {"internal/console/browserretry.go", "Server.handleInstanceRetry", "key-site", "lit:instances", "4d85d392f3660373f3c282c6391f58d8e25b6df738257ccc067b816e24dfe7ff"}, |
diff --git a/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv b/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv| index 98c76e40f..b5c86aa93 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv |
| @@ -7,6 +7,8 @@ |
| # Task 7: the box's own addresses (internal/host boxaddress.go, the provider-neutral source over CARLOS_PRIVATE_IP with CARLOS_PRIVATE_CIDR, and CARLOS_PUBLIC_IP, all from root-owned host.env; awsaddr.go, the IMDS adapter, which reads the VPC's CIDR blocks too) and the edges file's private_ip and private_cidr, validated before they are written by edgesfile.PrivateAddress (census ruling region-ha-2a, condition 1). edgesFileWriteClaims (authority, F,J1) is re-digested with its class, task, basis and sites unchanged: with region HA on it reads the address source before every publication and no longer holds a blank EIP. It adds no registry, /data, release or claim read, and nothing runs as root that did not before; the legacy seal stands. |
| # Task 8: with the reader's own region HA on, forwarding dials a same-region box's private address when it passes edgesfile.PrivateAddress against the reader's own published network, and every other box at its public address (internal/edge homemap.go: dialAddress, regionHAFromEnv, edgesFileView's private_ip, private_cidr, region and placement). The home map is not a root-read unit. Envelope-only, and the legacy seal stands. |
| # Task 9: the load balancer health answer (internal/edge lbhealth.go, answered in httpFallbackHandler; the home map and the certificate mirror latch their first loads, Edge.Watch beats, the activator's sweeper beats through Service.LastSweep, and ClaimWiring.startRegional marks the box hosting). The only root-read unit that moved is the activator's authority row type Service, which gains one atomic.Int64 field (lastSweep) written after each finished sweep and read by LastSweep: no read, write or decision of the seed rows' authority changes, so its row is re-pinned to the new digest with its class, owner and basis unchanged. The answer reads only in-memory state. Envelope-only, and the legacy seal stands. |
| +# Task 10: the operator's box and group role doors (internal/console apiboxroles.go, registered with host claims on in registerCLIRoutes) and the CLI (system_roles.go). Census ruling region-ha-2a (platform-1e, approved with conditions) covers one accepted-legacy unit, Server.boxRoster: the role doors' read of the signed roster (Directory.Read), operator-only (it is reached only past apiSystemRoller), read-only, the same roster every reader reads, and no role it reads feeds root's claim authority. Its basis is main's for a console root input (J4). The seal moves. |
| +# Task 10 (system.go): the box and group verbs add `box` and `group` to systemVerb's verb list and two cases to runSystem's dispatch, calling runSystemBoxVerb and runSystemGroupVerb (system_roles.go). The two seed-overlay authority declarations are re-digested, class, task, basis and (empty) kinds unchanged: no root read is added, and the verbs reach the console over the operator's CLI session. |
| internal/activator/activator.go type Service authority B,C,D,F,J4 rev3=PROPOSED-seed; existing activator lifecycle and due-wake state plus owned seed exclusion and incarnation-keyed diagnostics; normal refusal and preparation ordering remain authoritative be4ca3a3ea2bd768e7e52c1056513d4fef1c1702d2e04656c540cdcbb858865c |
| @coverage internal/lbhealth ad1211a01a4eb3d02cd8ed95cbff0f8b17b3976ce9c0279ebd2cae2f20c07124 |
| @coverage internal/hostactors 6736698380a247764ada687f2476f89382fc32a1183a50c06afe75967d716eab |
| @@ -17,3 +19,9 @@ internal/activator/activator.go type Service authority B,C,D,F,J4 rev3=PROPOSED- |
| @coverage internal/edge 20177c9e7dc961937677bbf2763c73004d7c95f68512591800f6fe841c36152f |
| internal/host/edgesfile.go edgesFileWriteClaims authority F,J1 root-route;kept=F;scope=protected-decision-only registry:Account@10835,registry:Alias@5297,registry:Alias@5332,registry:Alias@5754,registry:Alias@5911,registry:Alias@5981,registry:Alias@6013,registry:Alias@6482,registry:Alias@8772,registry:App@10847,registry:DNSStatus@5429,registry:DNSStatus@5920,registry:Host@10480,registry:Host@2878,registry:Host@5389,registry:Host@6234,registry:Host@6567,registry:Host@8819,registry:Host@8866,registry:Host@8916,registry:Host@8951,registry:Host@9000,registry:Platform@2921,registry:Provisioned@2935,registry:Replica@2959,registry:Status@877,whole:Route@3212,whole:Route@323,whole:Route@3505,whole:Route@351,whole:RouteAlias@5282,whole:RouteAlias@8725,whole:RoutePath@3520,whole:WildcardDomain@8905 8c7cdffba66886b92f9a2ac663a86dbdd5f80699afd2e6afb4571da916c727ad |
| @coverage internal/activator fd9cf14712f55895541bb8bfbf8137cfd446298bcaa8634b0aba7b8344c29ec3 |
| +@coverage internal/console ec1a99e1bcdc7c5e5ffc415f1ce2626adbc73494629c3288007cdc2c8843ee50 |
| +@coverage . 0e6e4542758248000de1882a01f13e3449c880b4ae23e8b2ff0d6dce076b614c |
| +@legacy b02b534b6617f57381ba12e9d9a2bf1bd63f737dc68005de926ff18b7db93dd3 internal/console/apiboxroles.go Server.boxRoster legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input;census-ruling-region-ha-2a file:Read@484 9be9925de14dbbd7d86123bf9faf1535c7b116575cadcb8ba983a665af0513f4 |
| +internal/console/apiboxroles.go Server.boxRoster legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input;census-ruling-region-ha-2a file:Read@484 9be9925de14dbbd7d86123bf9faf1535c7b116575cadcb8ba983a665af0513f4 |
| +system.go runSystem authority B,C,D,F,J4 rev3=PROPOSED-seed; command arguments, claim flags, service identity and root delegation capability before constructing agent-mode resources; one-shot skips Configure, Start, duties and listeners; close resources only after private runtime joins 529ab54816bd15161c4f57e2fcb5899581ee7098a465cd3d53b070bde8ddf415 |
| +system.go systemVerb authority B,C,D,F,J4 rev3=PROPOSED-seed; command arguments, claim flags, service identity and root delegation capability before constructing agent-mode resources; one-shot skips Configure, Start, duties and listeners; close resources only after private runtime joins 7067c6024d46c6da98f6db1276aff895b41c0d00939e101ccf70cff8b691fd27 |
diff --git a/region_ha_boundary_test.go b/region_ha_boundary_test.go| index 4336d209e..67f9946de 100644 |
| --- a/region_ha_boundary_test.go |
| +++ b/region_ha_boundary_test.go |
| @@ -25,6 +25,8 @@ var regionHACoreFiles = []string{ |
| "internal/host/boxaddress.go", |
| "internal/edgesfile/private.go", |
| "internal/edge/lbhealth.go", |
| + "internal/console/apiboxroles.go", |
| + "system_roles.go", |
| } |
| // regionHAAdapterFiles are spec 2's AWS adapters. Each is named for AWS so |
diff --git a/system.go b/system.go| index 761e5bc12..4a52915ca 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 "files", "guard-arm", "guard-exec", "journal-migrate", "floor-allows-prev", "update", "status", "rollback", "cancel-activation", "apply", "antivirus", "layout", "geoip", "seeds-floor", "legacy-routes", "help", "-h", "--help": |
| + case "files", "guard-arm", "guard-exec", "journal-migrate", "floor-allows-prev", "update", "status", "rollback", "cancel-activation", "apply", "antivirus", "layout", "geoip", "seeds-floor", "legacy-routes", "box", "group", "help", "-h", "--help": |
| return args[0], args[1:], component |
| } |
| return "", args, component |
| @@ -90,6 +90,10 @@ func runSystem(args []string) { |
| runSystemSeedsFloorVerb(rest) |
| case "legacy-routes": |
| runSystemLegacyRoutesVerb(rest) |
| + case "box": |
| + runSystemBoxVerb(rest) |
| + case "group": |
| + runSystemGroupVerb(rest) |
| case "help", "-h", "--help": |
| systemUsage() |
| default: |
| @@ -124,6 +128,8 @@ func systemUsage() { |
| carlos system geoip <publish|status|off> [flags] |
| carlos system seeds-floor publish --min-release <release> |
| carlos system legacy-routes assign --name <name> --to <account>/<app> |
| + carlos system box role <box> <default|legacy|platform> |
| + carlos system group role <region> <default|legacy> |
| The platform's own binary (ops account owners and CARLOS_SYSTEM_OPERATORS): |
| @@ -140,6 +146,12 @@ The platform's own binary (ops account owners and CARLOS_SYSTEM_OPERATORS): |
| the first release whose boxes can place a seed, such as v0.27.0 |
| legacy-routes assign --name <name> --to <account>/<app> |
| `+systemLegacyRoutesVerb+` |
| + box role <box> <default|legacy|platform> |
| + whether new instances may be placed on a box. A legacy or |
| + platform box keeps the instances it has and takes no new ones |
| + group role <region> <default|legacy> |
| + the same for every box a region's group has enrolled, and for |
| + every box it enrolls later |
| Deployment-wide settings (CARLOS_STORE_OPERATORS): |
diff --git a/system_roles.go b/system_roles.go| new file mode 100644 |
| index 000000000..487d52bc5 |
| --- /dev/null |
| +++ b/system_roles.go |
| @@ -0,0 +1,118 @@ |
| +package main |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "fmt" |
| + "log" |
| + "net/http" |
| + "net/url" |
| + "os" |
| +) |
| + |
| +// carlos system box role <box> <role> and carlos system group role |
| +// <region> <role> (region HA spec 2 §7, A11-1). A box's role says whether |
| +// placement may choose it: legacy and platform boxes take no new |
| +// instances and keep the ones they have. A group's role is every box it |
| +// has enrolled and every box it enrolls later. Operators only, and only on |
| +// a console with region HA on. |
| + |
| +// rolePost is the one console call a role change makes. |
| +type rolePost func(ctx context.Context, path string, in any) (int, []byte, error) |
| + |
| +// boxRoleLine is what the CLI says once a box has its role. |
| +func boxRoleLine(box, role string) string { return fmt.Sprintf("%s now has the %s role", box, role) } |
| + |
| +// groupRoleLine is what it says once a group has its role. |
| +func groupRoleLine(region, role string) string { |
| + return fmt.Sprintf("the group in %s now has the %s role", region, role) |
| +} |
| + |
| +// setBoxRole asks the console to give box the role, and returns the line |
| +// to print. |
| +func setBoxRole(ctx context.Context, post rolePost, box, role string) (string, error) { |
| + status, body, err := post(ctx, "/api/cli/system/boxes/"+url.PathEscape(box)+"/role", map[string]string{"role": role}) |
| + if err != nil { |
| + return "", err |
| + } |
| + if status != http.StatusOK { |
| + return "", apiErrorFromBody(status, body) |
| + } |
| + var out struct { |
| + Box string `json:"box"` |
| + Role string `json:"role"` |
| + } |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return "", fmt.Errorf("unreadable response: %w", err) |
| + } |
| + return boxRoleLine(out.Box, out.Role), nil |
| +} |
| + |
| +// setGroupRole asks the console to give region's group the role, and |
| +// returns the lines to print: the group's, then one per box it set. |
| +func setGroupRole(ctx context.Context, post rolePost, region, role string) ([]string, error) { |
| + status, body, err := post(ctx, "/api/cli/system/groups/"+url.PathEscape(region)+"/role", map[string]string{"role": role}) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if status != http.StatusOK { |
| + return nil, apiErrorFromBody(status, body) |
| + } |
| + var out struct { |
| + Region string `json:"region"` |
| + Role string `json:"role"` |
| + Boxes []string `json:"boxes"` |
| + } |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return nil, fmt.Errorf("unreadable response: %w", err) |
| + } |
| + lines := []string{groupRoleLine(out.Region, out.Role)} |
| + for _, b := range out.Boxes { |
| + lines = append(lines, boxRoleLine(b, out.Role)) |
| + } |
| + return lines, nil |
| +} |
| + |
| +// roleArgs reads `role <name> <role>` with an optional --console flag |
| +// after them, and exits with the usage on anything else. |
| +func roleArgs(rest []string) (name, role, console string) { |
| + if len(rest) < 3 || rest[0] != "role" { |
| + systemUsage() |
| + os.Exit(2) |
| + } |
| + name, role = rest[1], rest[2] |
| + tail := rest[3:] |
| + switch { |
| + case len(tail) == 0: |
| + case len(tail) == 2 && (tail[0] == "--console" || tail[0] == "-console"): |
| + console = tail[1] |
| + default: |
| + systemUsage() |
| + os.Exit(2) |
| + } |
| + return name, role, console |
| +} |
| + |
| +func runSystemBoxVerb(rest []string) { |
| + box, role, consoleFlag := roleArgs(rest) |
| + sess := requireDeployAPISession(consoleFlag) |
| + printUsing(stdout, sess) |
| + line, err := setBoxRole(context.Background(), sess.postJSON, box, role) |
| + if err != nil { |
| + log.Fatalf("carlos system box role: %v", err) |
| + } |
| + fmt.Fprintln(stdout, line) |
| +} |
| + |
| +func runSystemGroupVerb(rest []string) { |
| + region, role, consoleFlag := roleArgs(rest) |
| + sess := requireDeployAPISession(consoleFlag) |
| + printUsing(stdout, sess) |
| + lines, err := setGroupRole(context.Background(), sess.postJSON, region, role) |
| + if err != nil { |
| + log.Fatalf("carlos system group role: %v", err) |
| + } |
| + for _, l := range lines { |
| + fmt.Fprintln(stdout, l) |
| + } |
| +} |
diff --git a/system_roles_test.go b/system_roles_test.go| new file mode 100644 |
| index 000000000..f8fc0383f |
| --- /dev/null |
| +++ b/system_roles_test.go |
| @@ -0,0 +1,67 @@ |
| +package main |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "net/http" |
| + "reflect" |
| + "testing" |
| +) |
| + |
| +func TestSystemVerbKnowsBoxAndGroup(t *testing.T) { |
| + for _, verb := range []string{"box", "group"} { |
| + got, rest, _ := systemVerb([]string{verb, "role", "x", "legacy"}) |
| + if got != verb || len(rest) != 3 || rest[0] != "role" { |
| + t.Fatalf("systemVerb(%s) = %q %q", verb, got, rest) |
| + } |
| + } |
| +} |
| + |
| +// fakeRolePost answers one role call with status and body, recording what |
| +// was asked. |
| +type fakeRolePost struct { |
| + status int |
| + body string |
| + path string |
| + in any |
| +} |
| + |
| +func (f *fakeRolePost) post(_ context.Context, path string, in any) (int, []byte, error) { |
| + f.path, f.in = path, in |
| + return f.status, []byte(f.body), nil |
| +} |
| + |
| +func TestSetBoxRole(t *testing.T) { |
| + f := &fakeRolePost{status: http.StatusOK, body: `{"box":"i-0abc","role":"legacy"}`} |
| + line, err := setBoxRole(context.Background(), f.post, "old box", "legacy") |
| + if err != nil || line != "i-0abc now has the legacy role" { |
| + t.Fatalf("line %q, %v", line, err) |
| + } |
| + if f.path != "/api/cli/system/boxes/old%20box/role" || !reflect.DeepEqual(f.in, map[string]string{"role": "legacy"}) { |
| + t.Fatalf("asked %s %v", f.path, f.in) |
| + } |
| + f = &fakeRolePost{status: http.StatusBadRequest, body: `{"error":"role must be default, legacy or platform"}`} |
| + if _, err := setBoxRole(context.Background(), f.post, "x", "boss"); err == nil || err.Error() != "role must be default, legacy or platform" { |
| + t.Fatalf("refusal: %v", err) |
| + } |
| + failing := func(context.Context, string, any) (int, []byte, error) { return 0, nil, errors.New("offline") } |
| + if _, err := setBoxRole(context.Background(), failing, "x", "legacy"); err == nil { |
| + t.Fatal("a failed call printed a line") |
| + } |
| +} |
| + |
| +func TestSetGroupRole(t *testing.T) { |
| + f := &fakeRolePost{status: http.StatusOK, body: `{"region":"ie","group":"routers","role":"legacy","boxes":["i-1","i-2"]}`} |
| + lines, err := setGroupRole(context.Background(), f.post, "ie", "legacy") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + want := []string{"the group in ie now has the legacy role", "i-1 now has the legacy role", "i-2 now has the legacy role"} |
| + if !reflect.DeepEqual(lines, want) || f.path != "/api/cli/system/groups/ie/role" { |
| + t.Fatalf("lines %q path %s", lines, f.path) |
| + } |
| + f = &fakeRolePost{status: http.StatusOK, body: `{"region":"ie","group":"routers","role":"default","boxes":[]}`} |
| + if lines, _ := setGroupRole(context.Background(), f.post, "ie", "default"); len(lines) != 1 { |
| + t.Fatalf("an empty group: %q", lines) |
| + } |
| +} |