region HA 2a: home-region capacity and forwarding; role doors only with region HA
The final review's three findings (Astra 2a final review): - The eligible-capacity pass counts a home box's "" placement under the home's roster name, as the chooser maps park.HomeRegion to HomeName, so the home region's count is published to the AWS region the signed roster gives it instead of never being published. - Forwarding treats two home boxes' equal "" placement as one placement: same AWS region, an address inside the reader's own network and the reader's region HA on still gate the private dial, and a home box and a named-placement box never dial each other privately. - The box and group role doors are registered only with CARLOS_REGION_HA on as well as host claims, so with it off they answer as an unknown route, as on main. The base claim route census is main's again; the role doors' fixtures and probes move to a region-HA console census (claim-routes-region-ha.txt). Census: internal/console and internal/edge envelopes re-pinned; B8 re-pins Server.registerCLIRoutes. No legacy or authority digest moved.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
14 files changed,
+373
−95
internal/console/apiboxroles_test.go+41 −19internal/console/authcli.go+6 −3internal/console/claim_invariant_fixtures_test.go+0 −2internal/console/claim_invariant_probes_nonapp_test.go+0 −48internal/console/claim_invariant_regionha_test.go+162 −0internal/console/regioncapacity.go+23 −4internal/console/regioncapacity_test.go+46 −2internal/console/svcroutes_test.go+28 −7internal/console/testdata/claim-routes-region-ha.txt+2 −0internal/console/testdata/claim-routes.txt+0 −2internal/edge/homemap.go+7 −5internal/edge/homemap_private_test.go+53 −0internal/provision/plan9_b8_census_test.go+2 −1internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv+3 −2
diff --git a/internal/console/apiboxroles_test.go b/internal/console/apiboxroles_test.go| index c655c2eb0..abac19bd6 100644 |
| --- a/internal/console/apiboxroles_test.go |
| +++ b/internal/console/apiboxroles_test.go |
| @@ -141,30 +141,52 @@ func TestRoleWriteErrors(t *testing.T) { |
| } |
| } |
| -// The doors exist only on a host-claim console, operator-only and 404 |
| -// with region HA off. |
| -func TestRoleDoorsAreRegisteredWithHostClaims(t *testing.T) { |
| +// The doors exist only on a host-claim console with region HA on, and are |
| +// operator-only there. With either switch off they are not registered: an |
| +// unauthenticated request, or an operator's, answers exactly as an unknown |
| +// route does, as on main (Astra 2a final review, P3). |
| +func TestRoleDoorsAreRegisteredWithHostClaimsAndRegionHA(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) |
| - } |
| + paths := []string{"/api/cli/system/boxes/ie-old/role", "/api/cli/system/groups/ie/role"} |
| 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) |
| + server := func(hostClaim, regionHA bool) *Server { |
| + s, err := NewServer(Config{Origin: "https://console.example", Keymail: "https://keymail.example", Releases: rel, |
| + HostClaim: hostClaim, RegionHA: regionHA, SystemOperators: []string{"paul@keymail.dev"}}, st) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return s |
| } |
| - if status, _ := serveHostClaim(t, on, path, ""); status != http.StatusUnauthorized { |
| - t.Fatalf("unsigned = %d", status) |
| + for _, c := range []struct { |
| + name string |
| + hostClaim, regionHA bool |
| + }{ |
| + {"both off", false, false}, |
| + {"claims on, region HA off", true, false}, |
| + {"claims off, region HA on", false, true}, |
| + } { |
| + s := server(c.hostClaim, c.regionHA) |
| + for _, bearer := range []string{"", token} { |
| + ghostStatus, ghostBody := serveHostClaim(t, s, "/api/cli/system/unknown/ie-old/role", bearer) |
| + if ghostStatus != http.StatusNotFound { |
| + t.Fatalf("%s: the unknown route answers %d", c.name, ghostStatus) |
| + } |
| + for _, path := range paths { |
| + if status, body := serveHostClaim(t, s, path, bearer); status != ghostStatus || !bytes.Equal(body, ghostBody) { |
| + t.Errorf("%s: %s (signed %v) = %d %q, want the unknown route's %d %q", c.name, path, bearer != "", status, body, ghostStatus, ghostBody) |
| + } |
| + } |
| + } |
| } |
| - if status, _ := serveHostClaim(t, on, path, token); status != http.StatusNotFound { |
| - t.Fatalf("region HA off = %d, want 404", status) |
| + on := server(true, true) |
| + for _, path := range paths { |
| + if status, _ := serveHostClaim(t, on, path, ""); status != http.StatusUnauthorized { |
| + t.Errorf("both on: %s unsigned = %d, want 401", path, status) |
| + } |
| + if status, _ := serveHostClaim(t, on, path, token); status == http.StatusNotFound || status == http.StatusUnauthorized { |
| + t.Errorf("both on: %s as an operator = %d, want the door's own answer", path, status) |
| + } |
| } |
| } |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index fb22494bb..65d9e32a1 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -513,9 +513,12 @@ func (s *Server) registerCLIRoutes() { |
| 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) |
| + // operator-only, and registered only with CARLOS_REGION_HA on too, |
| + // so with it off they answer as any unknown route, as on main. |
| + if s.cfg.RegionHA { |
| + 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 53770c0b8..215960ec9 100644 |
| --- a/internal/console/claim_invariant_fixtures_test.go |
| +++ b/internal/console/claim_invariant_fixtures_test.go |
| @@ -1129,8 +1129,6 @@ 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 6a8ffad45..d9aa1c8ae 100644 |
| --- a/internal/console/claim_invariant_probes_nonapp_test.go |
| +++ b/internal/console/claim_invariant_probes_nonapp_test.go |
| @@ -674,54 +674,6 @@ 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/claim_invariant_regionha_test.go b/internal/console/claim_invariant_regionha_test.go| new file mode 100644 |
| index 000000000..a8e5dcfc9 |
| --- /dev/null |
| +++ b/internal/console/claim_invariant_regionha_test.go |
| @@ -0,0 +1,162 @@ |
| +package console |
| + |
| +// claim_invariant_regionha_test.go covers what region HA (CARLOS_REGION_HA, |
| +// Config.RegionHA) adds to a host-claim console's routes, on a console built |
| +// with it on: the operator's box and group role doors (region HA spec 2 §7, |
| +// apiboxroles.go). With region HA off they are not registered, so the base |
| +// console's census (testdata/claim-routes.txt) is main's (Astra 2a final |
| +// review, P3). Each is a nonApp entry, probed here on a region-HA console. |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "os" |
| + "slices" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/hostactors" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// claimRegionHAOn builds the console the way cmd/console builds it with |
| +// CARLOS_REGION_HA on (the switch is read before NewServer). |
| +func claimRegionHAOn(c *Config) { c.RegionHA = true } |
| + |
| +// claimRegionHAFixtures are the entries that exist only on a region-HA |
| +// console. |
| +var claimRegionHAFixtures = map[string]claimFixture{ |
| + "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"), |
| +} |
| + |
| +// claimRegionHAProbes drive the entries above: one signed roster write each. |
| +var claimRegionHAProbes = map[string]claimProbe{ |
| + "route POST /api/cli/system/boxes/{box}/role": { |
| + 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) { |
| + 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 "" |
| + }, |
| + }, |
| +} |
| + |
| +// claimRegionHAMerged is the fixture table a region-HA console must match |
| +// exactly, and claimRegionHAMergedProbes its probe table. |
| +func claimRegionHAMerged() map[string]claimFixture { |
| + merged := map[string]claimFixture{} |
| + for k, v := range claimFixtures { |
| + merged[k] = v |
| + } |
| + for k, v := range claimRegionHAFixtures { |
| + merged[k] = v |
| + } |
| + return merged |
| +} |
| + |
| +func claimRegionHAMergedProbes() map[string]claimProbe { |
| + merged := map[string]claimProbe{} |
| + for k, v := range claimProbes { |
| + merged[k] = v |
| + } |
| + for k, v := range claimRegionHAProbes { |
| + merged[k] = v |
| + } |
| + return merged |
| +} |
| + |
| +// TestClaimRegionHAConsoleRouteCensus: a region-HA console serves exactly |
| +// what an enabled console serves plus the entries in |
| +// testdata/claim-routes-region-ha.txt, and every one has a fixture and a |
| +// probe. The base console registers none of them. |
| +func TestClaimRegionHAConsoleRouteCensus(t *testing.T) { |
| + b, err := os.ReadFile("testdata/claim-routes-region-ha.txt") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + extra := strings.Split(strings.TrimSpace(string(b)), "\n") |
| + base := claimInventoryKeys(newClaimCase(t).srv.claimEntries.snapshot()) |
| + for _, k := range extra { |
| + if slices.Contains(base, k) { |
| + t.Fatalf("%s is registered with region HA off", k) |
| + } |
| + if _, ok := claimRegionHAFixtures[k]; !ok { |
| + t.Errorf("%s has no region HA fixture", k) |
| + } |
| + } |
| + cx := newClaimCaseWith(t, claimRegionHAOn) |
| + if !cx.srv.cfg.RegionHA || !cx.srv.cfg.HostClaim { |
| + t.Fatal("the region HA console is not built with both switches on") |
| + } |
| + want := append(slices.Clone(base), extra...) |
| + slices.Sort(want) |
| + entries := cx.srv.claimEntries.snapshot() |
| + got := claimInventoryKeys(entries) |
| + if !slices.Equal(got, want) { |
| + for _, k := range got { |
| + if !slices.Contains(want, k) { |
| + t.Errorf("new with region HA: %s", k) |
| + } |
| + } |
| + for _, k := range want { |
| + if !slices.Contains(got, k) { |
| + t.Errorf("missing with region HA: %s", k) |
| + } |
| + } |
| + t.FailNow() |
| + } |
| + for _, p := range claimFixtureCensus(entries, claimRegionHAMerged()) { |
| + t.Error(p) |
| + } |
| + for _, p := range claimProbeCensus(claimRegionHAMerged(), claimRegionHAMergedProbes()) { |
| + t.Error(p) |
| + } |
| + for key := range claimRegionHAProbes { |
| + if _, ok := claimProbes[key]; ok { |
| + t.Errorf("%s is probed on the base console too", key) |
| + } |
| + } |
| +} |
| + |
| +// TestClaimRegionHAEntriesAreProbed drives every region-HA-only entry's |
| +// probe on a console built with region HA on. |
| +func TestClaimRegionHAEntriesAreProbed(t *testing.T) { |
| + runClaimProbes(t, claimRegionHAFixtures, claimRegionHAProbes, claimRegionHAOn) |
| +} |
diff --git a/internal/console/regioncapacity.go b/internal/console/regioncapacity.go| index 4076fd586..7b0ae977b 100644 |
| --- a/internal/console/regioncapacity.go |
| +++ b/internal/console/regioncapacity.go |
| @@ -10,6 +10,7 @@ import ( |
| "github.com/carlosframework/platform/internal/boxchoice" |
| "github.com/carlosframework/platform/internal/hostops" |
| + "github.com/carlosframework/platform/internal/park" |
| ) |
| // The eligible-capacity metric (region HA spec 2 §4, A1-11): once a |
| @@ -48,11 +49,18 @@ type capacityPublisher interface { |
| // eligible-capacity alarm on a transient read failure, whereas a missing |
| // data point is the alarm's own signal if the failure lasts (spec §4, |
| // A2-8). Only a complete read that finds no placeable box is a 0. |
| -func regionCapacities(ctx context.Context, snap boxchoice.Snapshot, capacity func(context.Context, string) (int, string, error)) ([]regionCapacity, error) { |
| +// |
| +// A home box publishes the home's placement code, "". It is counted under |
| +// home, the home's roster name, as the chooser reads it (Capacity's and |
| +// eligible's mapping of park.HomeRegion to HomeName), so the home region's |
| +// count goes to the AWS region the signed roster gives home (Astra 2a |
| +// final review, P2). With no home name the chooser places nothing in the |
| +// home, and its boxes are left out. |
| +func regionCapacities(ctx context.Context, snap boxchoice.Snapshot, home string, capacity func(context.Context, string) (int, string, error)) ([]regionCapacity, error) { |
| named := map[string]bool{} |
| for _, b := range snap.Boxes { |
| - if b.Placement != "" { |
| - named[b.Placement] = true |
| + if p := capacityPlacement(home, b.Placement); p != "" { |
| + named[p] = true |
| } |
| } |
| placements := make([]string, 0, len(named)) |
| @@ -76,6 +84,17 @@ func regionCapacities(ctx context.Context, snap boxchoice.Snapshot, capacity fun |
| return out, errors.Join(errs...) |
| } |
| +// capacityPlacement is the region a box's placement code is counted under: |
| +// the code itself, or for the home's "" the home's name, exactly when the |
| +// chooser maps park.HomeRegion to it (home is a placement code). "" is |
| +// counted nowhere. |
| +func capacityPlacement(home, code string) string { |
| + if region := park.PlacementRegion(code); region == park.HomeRegion && home != "" && park.PlacementRegion(home) == home { |
| + return home |
| + } |
| + return code |
| +} |
| + |
| // publishCapacity counts and publishes once. |
| func (s *Server) publishCapacity(ctx context.Context, pub capacityPublisher) error { |
| x := s.placementChooser("") |
| @@ -86,7 +105,7 @@ func (s *Server) publishCapacity(ctx context.Context, pub capacityPublisher) err |
| if err != nil { |
| return err |
| } |
| - counts, countErr := regionCapacities(ctx, boxSnapshot(snap), x.Capacity) |
| + counts, countErr := regionCapacities(ctx, boxSnapshot(snap), x.HomeName, x.Capacity) |
| if len(counts) == 0 { |
| return countErr |
| } |
diff --git a/internal/console/regioncapacity_test.go b/internal/console/regioncapacity_test.go| index d4c0b214d..239363a72 100644 |
| --- a/internal/console/regioncapacity_test.go |
| +++ b/internal/console/regioncapacity_test.go |
| @@ -37,7 +37,7 @@ func TestRegionCapacitiesCountsEachPlacement(t *testing.T) { |
| } |
| return 0, "", hostops.ErrContract // no roster region: left out |
| } |
| - got, err := regionCapacities(context.Background(), snap, capacity) |
| + got, err := regionCapacities(context.Background(), snap, "", capacity) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -66,7 +66,7 @@ func TestRegionCapacitiesOmitsARegionWhoseReadFailed(t *testing.T) { |
| } |
| return 1, "eu-" + p, nil |
| } |
| - got, err := regionCapacities(context.Background(), snap, capacity) |
| + got, err := regionCapacities(context.Background(), snap, "", capacity) |
| want := []regionCapacity{{Placement: "ie", AWSRegion: "eu-ie", Eligible: 1}, {Placement: "us", AWSRegion: "eu-us", Eligible: 1}} |
| if !errors.Is(err, failure) || !reflect.DeepEqual(got, want) { |
| t.Fatalf("%s: got %+v, %v; want %+v and the failure", name, got, err, want) |
| @@ -74,6 +74,50 @@ func TestRegionCapacitiesOmitsARegionWhoseReadFailed(t *testing.T) { |
| } |
| } |
| +// Astra 2a final review, P2: a home box publishes the home's placement |
| +// code "". It is counted under the home's roster name, as the chooser |
| +// reads it, once whether the home's boxes publish "" or the name; with no |
| +// home name (or one that is not a placement code) the chooser places |
| +// nothing in the home, and it is not asked. |
| +func TestRegionCapacitiesCountsTheHomeUnderItsName(t *testing.T) { |
| + snap := boxchoice.Snapshot{Complete: true, Boxes: []boxchoice.Box{ |
| + {BoxID: "a", Placement: ""}, {BoxID: "b", Placement: ""}, {BoxID: "c", Placement: "ie"}, {BoxID: "d", Placement: "uk"}, |
| + }} |
| + for home, wantAsked := range map[string][]string{ |
| + "ie": {"ie", "uk"}, |
| + "": {"ie", "uk"}, |
| + "Not/Code": {"ie", "uk"}, |
| + "de": {"de", "ie", "uk"}, |
| + } { |
| + asked := []string{} |
| + capacity := func(_ context.Context, p string) (int, string, error) { |
| + asked = append(asked, p) |
| + return 2, "aws-" + p, nil |
| + } |
| + got, err := regionCapacities(context.Background(), snap, home, capacity) |
| + if err != nil || !reflect.DeepEqual(asked, wantAsked) || len(got) != len(wantAsked) { |
| + t.Fatalf("home %q: got %+v, %v, asked %v; want %v asked", home, got, err, asked, wantAsked) |
| + } |
| + } |
| +} |
| + |
| +// The same end to end, with only home boxes: each publishes placement "", |
| +// and the pass publishes the home's placeable count, non-zero, to the AWS |
| +// region the signed roster gives the home (ie, eu-west-1). |
| +func TestPublishCapacityCountsTheHomeRegion(t *testing.T) { |
| + cx := haClaimCase(t, "ie") |
| + home := func(m map[string]any) { m["placement"] = "" } |
| + publishHABox(t, cx, "box-ie-default", false, home) |
| + publishHABox(t, cx, "box-ie-tito", false, home) |
| + var pub capacityRecorder |
| + if err := cx.srv.publishCapacity(context.Background(), &pub); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(pub.got) != 1 || pub.got[0].Placement != "ie" || pub.got[0].AWSRegion != "eu-west-1" || pub.got[0].Eligible == 0 { |
| + t.Fatalf("published %+v (%d calls), want the home ie with a non-zero count in eu-west-1", pub.got, pub.calls) |
| + } |
| +} |
| + |
| // capacityRecorder keeps what one pass would publish. |
| type capacityRecorder struct { |
| got []regionCapacity |
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go| index 262a15ad8..1777ac98b 100644 |
| --- a/internal/console/svcroutes_test.go |
| +++ b/internal/console/svcroutes_test.go |
| @@ -286,8 +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. |
| + } |
| + // Box and group roles (region HA spec 2 §7): closed doors (operator |
| + // only), registered only with host claims and CARLOS_REGION_HA both on. |
| + regionHADoors := []string{ |
| "POST /api/cli/system/boxes/{box}/role", |
| "POST /api/cli/system/groups/{region}/role", |
| } |
| @@ -299,11 +301,14 @@ func TestEveryCLIDoorNamesItsRoles(t *testing.T) { |
| name string |
| hostClaim bool |
| moves bool |
| + regionHA bool |
| }{ |
| - {"flag-off", false, false}, |
| - {"flag-on", true, false}, |
| - {"moves-on", false, true}, |
| - {"flag-on-moves-on", true, true}, |
| + {"flag-off", false, false, false}, |
| + {"flag-on", true, false, false}, |
| + {"moves-on", false, true, false}, |
| + {"flag-on-moves-on", true, true, false}, |
| + {"region-ha-on", false, false, true}, |
| + {"flag-on-region-ha-on", true, false, true}, |
| } { |
| t.Run(tc.name, func(t *testing.T) { |
| st := openTestStore(t) |
| @@ -315,7 +320,7 @@ func TestEveryCLIDoorNamesItsRoles(t *testing.T) { |
| } |
| t.Cleanup(func() { st.Close() }) |
| } |
| - s, err := NewServer(Config{Origin: "https://console.example", Keymail: "https://keymail.example", HostClaim: tc.hostClaim, MovesEnabled: tc.moves}, st) |
| + s, err := NewServer(Config{Origin: "https://console.example", Keymail: "https://keymail.example", HostClaim: tc.hostClaim, MovesEnabled: tc.moves, RegionHA: tc.regionHA}, st) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -326,9 +331,13 @@ func TestEveryCLIDoorNamesItsRoles(t *testing.T) { |
| } |
| } |
| wantClosed := append([]string(nil), closedDoors...) |
| + roles := tc.hostClaim && tc.regionHA |
| if tc.hostClaim { |
| wantClosed = append(wantClosed, hostClaimDoors...) |
| } |
| + if roles { |
| + wantClosed = append(wantClosed, regionHADoors...) |
| + } |
| sort.Strings(empty) |
| sort.Strings(wantClosed) |
| if !reflect.DeepEqual(empty, wantClosed) { |
| @@ -354,6 +363,18 @@ func TestEveryCLIDoorNamesItsRoles(t *testing.T) { |
| t.Errorf("%s: present=%v empty=%v, want present=%v and no service roles", pattern, present, set.Empty(), tc.hostClaim) |
| } |
| } |
| + for _, pattern := range regionHADoors { |
| + if !wantRegistered[pattern] { |
| + t.Errorf("region HA route missing from source: %s", pattern) |
| + } |
| + if !roles { |
| + delete(wantRegistered, pattern) |
| + } |
| + set, present := s.cliRoles[pattern] |
| + if present != roles || (present && !set.Empty()) { |
| + t.Errorf("%s: present=%v empty=%v, want present=%v and no service roles", pattern, present, set.Empty(), roles) |
| + } |
| + } |
| for _, pattern := range movesDoors { |
| if !wantRegistered[pattern] { |
| t.Errorf("moves route missing from source: %s", pattern) |
diff --git a/internal/console/testdata/claim-routes-region-ha.txt b/internal/console/testdata/claim-routes-region-ha.txt| new file mode 100644 |
| index 000000000..c798dc8aa |
| --- /dev/null |
| +++ b/internal/console/testdata/claim-routes-region-ha.txt |
| @@ -0,0 +1,2 @@ |
| +route POST /api/cli/system/boxes/{box}/role |
| +route POST /api/cli/system/groups/{region}/role |
diff --git a/internal/console/testdata/claim-routes.txt b/internal/console/testdata/claim-routes.txt| index 7e71c4dc8..fc667b937 100644 |
| --- a/internal/console/testdata/claim-routes.txt |
| +++ b/internal/console/testdata/claim-routes.txt |
| @@ -237,8 +237,6 @@ 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/edge/homemap.go b/internal/edge/homemap.go| index acee87bc0..f91901da4 100644 |
| --- a/internal/edge/homemap.go |
| +++ b/internal/edge/homemap.go |
| @@ -149,12 +149,14 @@ type edgesFileView struct { |
| // the same AWS region and placement as this one is dialled at its private |
| // address, so the dial stays inside the region's network, but only when |
| // that address passes edgesfile.PrivateAddress against the network this |
| -// box's own file publishes (census ruling region-ha-2a). Every other box, |
| -// and every box with privateDial off, is dialled at its public address, |
| -// as before. self is this box's own file, which selfKnown says was found. |
| -// "" is nothing to dial. |
| +// box's own file publishes (census ruling region-ha-2a). The home's |
| +// placement code is "", so two home boxes share a placement as two boxes |
| +// of one named region do; a home box and a named one never do (Astra 2a |
| +// final review, P2). Every other box, and every box with privateDial off, |
| +// is dialled at its public address, as before. self is this box's own |
| +// file, which selfKnown says was found. "" is nothing to dial. |
| func dialAddress(f, self edgesFileView, selfKnown, privateDial bool) string { |
| - if privateDial && selfKnown && f.Region != "" && f.Placement != "" && |
| + if privateDial && selfKnown && f.Region != "" && |
| f.Region == self.Region && f.Placement == self.Placement { |
| if addr, _ := edgesfile.PrivateAddress(f.PrivateIP, []string{self.PrivateCIDR}); addr != "" { |
| return addr |
diff --git a/internal/edge/homemap_private_test.go b/internal/edge/homemap_private_test.go| index ec2c9c018..474e9f8f3 100644 |
| --- a/internal/edge/homemap_private_test.go |
| +++ b/internal/edge/homemap_private_test.go |
| @@ -82,3 +82,56 @@ func TestEdgeHomeMapFollowsRegionHA(t *testing.T) { |
| t.Fatal("region HA on does not dial private") |
| } |
| } |
| + |
| +// Astra 2a final review, P2: the home's placement code is "". Two home |
| +// boxes in one AWS region share a placement, so a home edge dials a home |
| +// peer at its private address, under the same guards as a named region: |
| +// the same AWS region, an address inside this edge's own network, and this |
| +// edge's region HA on. A home edge never dials a named-placement box |
| +// privately, nor a named edge a home box, though they share the AWS region |
| +// and the network. |
| +func TestHomeMapDialsHomeBoxesPrivately(t *testing.T) { |
| + m := newHomeMap(meshTestStore(t), "hilda") |
| + privateFleet(t, m) |
| + putJSON(t, m.s, "control/edges/hilda.json", `{"v":1,"box":"hilda","eip":"11.11.11.11","private_ip":"10.0.2.1","private_cidr":"10.0.0.0/16","region":"eu-west-1","placement":"","hosts":{}}`) |
| + putJSON(t, m.s, "control/edges/ivy.json", `{"v":1,"box":"ivy","eip":"12.12.12.12","private_ip":"10.0.2.2","private_cidr":"10.0.0.0/16","region":"eu-west-1","placement":"","hosts":{"ivy.test":{"role":"home"}}}`) |
| + putJSON(t, m.s, "control/edges/jill.json", `{"v":1,"box":"jill","eip":"","private_ip":"10.0.2.3","region":"eu-west-1","hosts":{"jill.test":{"role":"home"}}}`) |
| + putJSON(t, m.s, "control/edges/kim.json", `{"v":1,"box":"kim","eip":"14.14.14.14","private_ip":"10.0.2.4","region":"eu-west-2","placement":"","hosts":{"kim.test":{"role":"home"}}}`) |
| + putJSON(t, m.s, "control/edges/lou.json", `{"v":1,"box":"lou","eip":"15.15.15.15","private_ip":"10.9.2.5","region":"eu-west-1","placement":"","hosts":{"lou.test":{"role":"home"}}}`) |
| + homeWant := map[string]string{ |
| + "ivy.test": "10.0.2.2", // a home peer in the same region: private |
| + "jill.test": "10.0.2.3", // no public address, but private within the home |
| + "kim.test": "14.14.14.14", // a home box in another AWS region: public |
| + "lou.test": "15.15.15.15", // outside this edge's network: refused |
| + "b.test": "2.2.2.2", // a named placement (ie) in the same region: public |
| + } |
| + m.privateDial = true |
| + refreshHomeMap(t, m) |
| + for host, want := range homeWant { |
| + if addr, _, ok := m.Home(host); !ok || addr != want { |
| + t.Errorf("home edge: %s dials %q (ok=%v), want %q", host, addr, ok, want) |
| + } |
| + } |
| + // A named edge (annie, in ie, the same AWS region and network) dials |
| + // every home box at its public address. |
| + named := newHomeMap(m.s, "annie") |
| + named.privateDial = true |
| + refreshHomeMap(t, named) |
| + for host, want := range map[string]string{"ivy.test": "12.12.12.12", "b.test": "10.0.1.2"} { |
| + if addr, _, ok := named.Home(host); !ok || addr != want { |
| + t.Errorf("named edge: %s dials %q (ok=%v), want %q", host, addr, ok, want) |
| + } |
| + } |
| + if _, _, ok := named.Home("jill.test"); ok { |
| + t.Error("named edge: a home box with only a private address became a target") |
| + } |
| + // With this edge's region HA off, the home dials are public, as on main. |
| + off := newHomeMap(m.s, "hilda") |
| + refreshHomeMap(t, off) |
| + if addr, _, _ := off.Home("ivy.test"); addr != "12.12.12.12" { |
| + t.Errorf("region HA off: ivy.test dials %q, want 12.12.12.12", addr) |
| + } |
| + if _, _, ok := off.Home("jill.test"); ok { |
| + t.Error("region HA off: a home box with only a private address became a target") |
| + } |
| +} |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index a146be989..61075a9d6 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -695,7 +695,8 @@ 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", "b55987735c833b952a7ef835302dba728c803379ecb474c3d465bb268cf4c781"}, |
| + // Re-reviewed for region HA 2a (Task 10 and the final review's P3): the box and group role doors, registered only with host claims and CARLOS_REGION_HA both on. Neither names instances or sidecars; the key sites and record handlers are unchanged. |
| + {"internal/console/authcli.go", "Server.registerCLIRoutes", "key-site", "lit:instances,lit:sidecars", "d212ac148a9f61066fde0429eaa25f4a5490115dbe48be84346f3640e80ed07c"}, |
| {"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 ae8566fbf..16471b480 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv |
| @@ -13,6 +13,7 @@ |
| # Task 11 fix round 1: an incomplete placement read is not "no room" (controller ruling). hostops.ExecutorChooser's Choose, Permits and PlaceableBoxes ask judged, which wraps the chooser's own Edges read and answers ErrEvidenceIncomplete when that snapshot is incomplete; ExecutorChooser.eligible (accepted-legacy, merge ruling 2) is unchanged. The no-capacity alert is recorded only once sent (noCapacityAlerts.failed gives a failed send's slot back), and the no-room 503 carries Retry-After: 60 (setNoCapacityRetryAfter in writeClaimResponseError, renderDoorRefusal and handleAPIDomainAttach's refusal write). Envelope-only: no root-read unit changed or was added, and the legacy seal stands. |
| # Task 12: H27 on the move door's destination, through hostops.ExecutorChooser.PlaceableBoxes and only with region HA on: roomIn refuses a destination with no box that has room before the placement checks (no edges file, blank public addresses), and moveRoomCheck (internal/console boxselect.go) refuses when none of selectMoveBoxes' destination candidates is placeable. Both only refuse: the candidates and the destination are main's. An incomplete placement read (hostops.ErrEvidenceIncomplete) is the temporarily unavailable 503, never no room. Census ruling region-ha-2a is asked to re-freeze one accepted-legacy unit, Server.handleAPIInstanceMoveRequest: two refusals added before any write; its read sites unchanged, offsets moved. No write site is added or changed. The seal moves. |
| # Task 13: the eligible-capacity metric (internal/console regioncapacity.go, counted through hostops.ExecutorChooser.Capacity, which now also answers the AWS region the signed roster names for the placement; awsregionmetric.go, the CloudWatch adapter), started beside claim recovery (StartClaimRecoveryPass). No root-read unit changed or was added: Capacity reads through judged and eligible, both unchanged by this task. A region whose read is incomplete or fails publishes no data point, never 0. Envelope-only, and the seal stands. |
| +# Final review fixes (Astra 2a final review): the eligible-capacity pass counts a home box's "" placement under the home's roster name, as the chooser maps it (regionCapacities, capacityPlacement; internal/console regioncapacity.go); forwarding treats two home boxes' equal "" placement as one placement (dialAddress, internal/edge homemap.go), with every other guard unchanged; the box and group role doors are registered only with CARLOS_REGION_HA on as well as host claims (registerCLIRoutes, internal/console authcli.go). Envelope-only: no root-read unit changed or was added, no legacy or authority digest moved and the seal is unchanged; the internal/console and internal/edge envelopes are re-pinned. B8 re-pins Server.registerCLIRoutes. |
| 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 |
| @@ -20,10 +21,10 @@ internal/activator/activator.go type Service authority B,C,D,F,J4 rev3=PROPOSED- |
| @coverage internal/edgesfile f62c3973bb48fa36f561d26f496c0ba9a14fe882fe5843867e9f7a9eaf0abbdf |
| @coverage internal/host 4ad9bdcd8b94516a6ccdad3bc900b166833faf1f0334ac83478be5b04de47069 |
| @coverage internal/hostops 0f8a9f4787c0438eb93bde1cb6369c40b89115fed460fd3be959b6128fce02a0 |
| -@coverage internal/edge 20177c9e7dc961937677bbf2763c73004d7c95f68512591800f6fe841c36152f |
| +@coverage internal/edge 41ac7a83ca6d571814cf3963dc196d289ba80c08f053e64d9d41d60103d45d39 |
| 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 33b2399507ea5e8860614c42b25ad5d4976c259b392eb5923bc88722cc72daa8 |
| +@coverage internal/console 35fab015007834f33f26bb1d321c5ac6bdc500de0889955a56d483cc885f9976 |
| @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 |