region HA 2a: box and group roles in the signed roster
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8 files changed,
+427
−2
internal/hostactors/enroll.go+9 −1internal/hostactors/enrollment_confirmation.go+3 −0internal/hostactors/roles.go+143 −0internal/hostactors/roles_test.go+250 −0internal/hostactors/roster.go+18 −0internal/provision/plan9_b8_census_test.go+1 −1internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv+2 −0region_ha_boundary_test.go+1 −0
diff --git a/internal/hostactors/enroll.go b/internal/hostactors/enroll.go| index 6f5e401b9..7f5681fba 100644 |
| --- a/internal/hostactors/enroll.go |
| +++ b/internal/hostactors/enroll.go |
| @@ -68,7 +68,15 @@ func (d Directory) enroll(ctx context.Context, sg release.Signer, o Observed, v |
| a.Consumed = true |
| r.EnrollmentAttempts[in.Reference] = a |
| } |
| - r.Boxes[in.Reference] = Box{Enrollment: enrollment, Namespace: in.Namespace, BoxID: in.Reference, Label: in.Label, Region: in.Region, Pool: in.Pool, Cloud: cloud, EvidenceSHA256: evidence.RecordSHA256, State: "active", LossIDs: []string{}} |
| + box := Box{Enrollment: enrollment, Namespace: in.Namespace, BoxID: in.Reference, Label: in.Label, Region: in.Region, Pool: in.Pool, Cloud: cloud, EvidenceSHA256: evidence.RecordSHA256, State: "active", LossIDs: []string{}} |
| + if automatic { |
| + // A box the group launched takes the group's role in this same |
| + // write (region HA spec 2 §7, A11-1): a replacement for an |
| + // excluded group is never placeable, even for a moment. |
| + g := r.Regions[in.Region] |
| + box.Group, box.Role = g.GroupName, g.Role |
| + } |
| + r.Boxes[in.Reference] = box |
| return d.Replace(ctx, sg, o, r) |
| } |
diff --git a/internal/hostactors/enrollment_confirmation.go b/internal/hostactors/enrollment_confirmation.go| index 326d0bb66..52a5d57c3 100644 |
| --- a/internal/hostactors/enrollment_confirmation.go |
| +++ b/internal/hostactors/enrollment_confirmation.go |
| @@ -18,6 +18,9 @@ type Region struct { |
| AWSRegion string `json:"aws_region"` |
| GroupName string `json:"group_name"` |
| GroupARN string `json:"group_arn"` |
| + // Role is the role every box automatic enrollment admits from the |
| + // group takes (roles.go): "" (default) or legacy. |
| + Role string `json:"role,omitempty"` |
| } |
| type TerminatedEnrollment struct { |
| PassID string `json:"pass_id"` |
diff --git a/internal/hostactors/roles.go b/internal/hostactors/roles.go| new file mode 100644 |
| index 000000000..c457eaa41 |
| --- /dev/null |
| +++ b/internal/hostactors/roles.go |
| @@ -0,0 +1,143 @@ |
| +package hostactors |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "sort" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// Box and group roles (region HA spec 2 §7, A11-1). A box's role says |
| +// whether placement may choose it: the default role may be chosen, and |
| +// legacy and platform never are, for a create, a relocation target or a |
| +// move target. The box keeps the instances it has (H25). |
| +// |
| +// The region's group entry carries a role too. Every box automatic |
| +// enrollment admits from the group takes the group's role in the same |
| +// roster write, and changing the group's role changes the role of every |
| +// box enrolled from it, so a replacement launched after the group was |
| +// excluded is never placeable, even for a moment. |
| +// |
| +// The roster stores the default role as "" so a roster written before |
| +// roles existed reads as all-default. |
| +const ( |
| + RoleDefault = "" |
| + RoleLegacy = "legacy" |
| + RolePlatform = "platform" |
| +) |
| + |
| +// ErrRole is a role name a box or group cannot take. |
| +var ErrRole = errors.New("hostactors: not a role this box or group can take") |
| + |
| +// ParseBoxRole reads an operator's box role: default, legacy or platform. |
| +func ParseBoxRole(s string) (string, error) { |
| + switch strings.TrimSpace(s) { |
| + case "default": |
| + return RoleDefault, nil |
| + case RoleLegacy: |
| + return RoleLegacy, nil |
| + case RolePlatform: |
| + return RolePlatform, nil |
| + } |
| + return "", ErrRole |
| +} |
| + |
| +// ParseGroupRole reads an operator's group role: default or legacy. A |
| +// group is never the platform box. |
| +func ParseGroupRole(s string) (string, error) { |
| + switch strings.TrimSpace(s) { |
| + case "default": |
| + return RoleDefault, nil |
| + case RoleLegacy: |
| + return RoleLegacy, nil |
| + } |
| + return "", ErrRole |
| +} |
| + |
| +// RoleName is the operator's word for a stored role. |
| +func RoleName(role string) string { |
| + if role == RoleDefault { |
| + return "default" |
| + } |
| + return role |
| +} |
| + |
| +func validBoxRole(role string) bool { |
| + return role == RoleDefault || role == RoleLegacy || role == RolePlatform |
| +} |
| + |
| +func validGroupRole(role string) bool { return role == RoleDefault || role == RoleLegacy } |
| + |
| +// SetBoxRole sets one box's role in a fresh roster write over o. The box |
| +// must be enrolled and not retired. Setting the role it already has |
| +// changes nothing and returns o. |
| +func (d Directory) SetBoxRole(ctx context.Context, sg release.Signer, o Observed, ref, role string) (Observed, error) { |
| + if !validBoxRole(role) { |
| + return Observed{}, ErrRole |
| + } |
| + b, ok := o.Roster.Boxes[ref] |
| + if !ok || b.State == "retired" { |
| + return Observed{}, ErrRoster |
| + } |
| + if b.Role == role { |
| + return o, nil |
| + } |
| + r := Clone(o.Roster) |
| + b = r.Boxes[ref] |
| + b.Role = role |
| + r.Boxes[ref] = b |
| + return d.Replace(ctx, sg, o, r) |
| +} |
| + |
| +// GroupMembers returns, sorted, the boxes enrolled from region's group |
| +// that are not retired: the boxes a group role change applies to. |
| +func GroupMembers(r Roster, region string) []string { |
| + g, ok := r.Regions[region] |
| + if !ok || g.GroupName == "" { |
| + return nil |
| + } |
| + var out []string |
| + for ref, b := range r.Boxes { |
| + if b.Region == region && b.Group == g.GroupName && b.State != "retired" { |
| + out = append(out, ref) |
| + } |
| + } |
| + sort.Strings(out) |
| + return out |
| +} |
| + |
| +// SetGroupRole sets region's group role and, in the same roster write, the |
| +// role of every box enrolled from that group (GroupMembers). The region |
| +// must have a group. It returns the roster and the boxes it set. |
| +func (d Directory) SetGroupRole(ctx context.Context, sg release.Signer, o Observed, region, role string) (Observed, []string, error) { |
| + if !validGroupRole(role) { |
| + return Observed{}, nil, ErrRole |
| + } |
| + g, ok := o.Roster.Regions[region] |
| + if !ok || g.GroupName == "" { |
| + return Observed{}, nil, ErrRoster |
| + } |
| + members := GroupMembers(o.Roster, region) |
| + changed := g.Role != role |
| + for _, ref := range members { |
| + changed = changed || o.Roster.Boxes[ref].Role != role |
| + } |
| + if !changed { |
| + return o, members, nil |
| + } |
| + r := Clone(o.Roster) |
| + g.Role = role |
| + r.Regions[region] = g |
| + for _, ref := range members { |
| + b := r.Boxes[ref] |
| + b.Role = role |
| + r.Boxes[ref] = b |
| + } |
| + next, err := d.Replace(ctx, sg, o, r) |
| + if err != nil { |
| + return Observed{}, nil, err |
| + } |
| + return next, members, nil |
| +} |
diff --git a/internal/hostactors/roles_test.go b/internal/hostactors/roles_test.go| new file mode 100644 |
| index 000000000..2d0b0c507 |
| --- /dev/null |
| +++ b/internal/hostactors/roles_test.go |
| @@ -0,0 +1,250 @@ |
| +package hostactors |
| + |
| +import ( |
| + "context" |
| + "crypto" |
| + "crypto/rand" |
| + "crypto/rsa" |
| + "crypto/sha256" |
| + "crypto/x509" |
| + "encoding/base64" |
| + "encoding/json" |
| + "errors" |
| + "reflect" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/boxid" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestParseRoles(t *testing.T) { |
| + for in, want := range map[string]string{"default": RoleDefault, "legacy": RoleLegacy, "platform": RolePlatform, " legacy ": RoleLegacy} { |
| + if got, err := ParseBoxRole(in); err != nil || got != want { |
| + t.Fatalf("ParseBoxRole(%q) = %q, %v", in, got, err) |
| + } |
| + } |
| + for _, in := range []string{"", "Legacy", "draining", "none"} { |
| + if _, err := ParseBoxRole(in); !errors.Is(err, ErrRole) { |
| + t.Fatalf("ParseBoxRole(%q) accepted", in) |
| + } |
| + } |
| + if _, err := ParseGroupRole("platform"); !errors.Is(err, ErrRole) { |
| + t.Fatal("a group took the platform role") |
| + } |
| + if got, err := ParseGroupRole("legacy"); err != nil || got != RoleLegacy { |
| + t.Fatalf("ParseGroupRole(legacy) = %q, %v", got, err) |
| + } |
| + if RoleName(RoleDefault) != "default" || RoleName(RolePlatform) != "platform" { |
| + t.Fatal("RoleName") |
| + } |
| +} |
| + |
| +func TestBoxRoleIsSignedAndPersists(t *testing.T) { |
| + ctx := context.Background() |
| + d, sg, o := fixture(t) |
| + next, err := d.SetBoxRole(ctx, sg, o, "box", RoleLegacy) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + read, err := d.Read(ctx) |
| + if err != nil || read.Roster.Boxes["box"].Role != RoleLegacy || read.ETag != next.ETag { |
| + t.Fatalf("read back: %+v %v", read.Roster.Boxes["box"], err) |
| + } |
| + // The same role again writes nothing. |
| + same, err := d.SetBoxRole(ctx, sg, next, "box", RoleLegacy) |
| + if err != nil || same.ETag != next.ETag { |
| + t.Fatalf("idempotent set wrote: %v", err) |
| + } |
| + // A stale observation loses the CAS. |
| + if _, err = d.SetBoxRole(ctx, sg, o, "box", RolePlatform); err == nil { |
| + t.Fatal("stale roster accepted") |
| + } |
| + back, err := d.SetBoxRole(ctx, sg, next, "box", RoleDefault) |
| + if err != nil || back.Roster.Boxes["box"].Role != RoleDefault { |
| + t.Fatalf("back to default: %+v %v", back.Roster.Boxes["box"], err) |
| + } |
| + if _, err = d.SetBoxRole(ctx, sg, back, "nobody", RoleLegacy); !errors.Is(err, ErrRoster) { |
| + t.Fatalf("unknown box: %v", err) |
| + } |
| + if _, err = d.SetBoxRole(ctx, sg, back, "box", "boss"); !errors.Is(err, ErrRole) { |
| + t.Fatalf("unknown role: %v", err) |
| + } |
| + // A retired box keeps no role. |
| + retired := Clone(back.Roster) |
| + b := retired.Boxes["box"] |
| + b.State = "retired" |
| + retired.Boxes["box"] = b |
| + gone, err := d.Replace(ctx, sg, back, retired) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = d.SetBoxRole(ctx, sg, gone, "box", RoleLegacy); !errors.Is(err, ErrRoster) { |
| + t.Fatalf("retired box: %v", err) |
| + } |
| +} |
| + |
| +func TestRosterRefusesUnknownRolesAndAGroupChange(t *testing.T) { |
| + ctx := context.Background() |
| + d, sg, o := fixture(t) |
| + bad := Clone(o.Roster) |
| + b := bad.Boxes["box"] |
| + b.Role = "boss" |
| + bad.Boxes["box"] = b |
| + if _, err := d.Replace(ctx, sg, o, bad); !errors.Is(err, ErrRoster) { |
| + t.Fatalf("unknown box role written: %v", err) |
| + } |
| + moved := Clone(o.Roster) |
| + b = moved.Boxes["box"] |
| + b.Group = "routers" |
| + moved.Boxes["box"] = b |
| + if _, err := d.Replace(ctx, sg, o, moved); !errors.Is(err, ErrRoster) { |
| + t.Fatalf("group rewritten: %v", err) |
| + } |
| + noGroup := Clone(o.Roster) |
| + noGroup.Regions = map[string]Region{"ie": {Account: "123", AWSRegion: "eu-west-1", Role: RoleLegacy}} |
| + if _, err := d.Replace(ctx, sg, o, noGroup); !errors.Is(err, ErrRoster) { |
| + t.Fatalf("a role on a region with no group: %v", err) |
| + } |
| + platformGroup := Clone(o.Roster) |
| + platformGroup.Regions = map[string]Region{"ie": {Account: "123", AWSRegion: "eu-west-1", GroupName: "routers", Role: RolePlatform}} |
| + if _, err := d.Replace(ctx, sg, o, platformGroup); !errors.Is(err, ErrRoster) { |
| + t.Fatalf("a platform group: %v", err) |
| + } |
| +} |
| + |
| +// groupFixture is fixture plus a group in ie and two boxes it enrolled. |
| +func groupFixture(t *testing.T) (Directory, release.Signer, Observed) { |
| + t.Helper() |
| + ctx := context.Background() |
| + d, sg, o := fixture(t) |
| + next := Clone(o.Roster) |
| + next.Regions = map[string]Region{ |
| + "ie": {Account: "123", AWSRegion: "eu-west-1", GroupName: "routers", GroupARN: "group-arn", Namespaces: []string{"platform"}, Pools: []string{"default"}}, |
| + "uk": {Account: "123", AWSRegion: "eu-west-2"}, |
| + } |
| + for i, ref := range []string{"g1", "g2"} { |
| + next.Boxes[ref] = Box{Enrollment: strings.Repeat(string(rune('c'+i)), 32), Namespace: "platform", BoxID: ref, Label: ref, Region: "ie", Pool: "default", Cloud: Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-" + ref}, EvidenceSHA256: strings.Repeat("b", 64), State: "active", LossIDs: []string{}, Group: "routers"} |
| + } |
| + o, err := d.Replace(ctx, sg, o, next) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return d, sg, o |
| +} |
| + |
| +func TestGroupRoleSetsEveryMemberInOneWrite(t *testing.T) { |
| + ctx := context.Background() |
| + d, sg, o := groupFixture(t) |
| + next, set, err := d.SetGroupRole(ctx, sg, o, "ie", RoleLegacy) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !reflect.DeepEqual(set, []string{"g1", "g2"}) { |
| + t.Fatalf("set %v", set) |
| + } |
| + if next.Roster.Revision != o.Roster.Revision+1 { |
| + t.Fatalf("revision %d, want one write", next.Roster.Revision) |
| + } |
| + r := next.Roster |
| + if r.Regions["ie"].Role != RoleLegacy || r.Boxes["g1"].Role != RoleLegacy || r.Boxes["g2"].Role != RoleLegacy { |
| + t.Fatalf("roles: %+v %+v %+v", r.Regions["ie"], r.Boxes["g1"], r.Boxes["g2"]) |
| + } |
| + // The operator-enrolled box in the same region is not the group's. |
| + if r.Boxes["box"].Role != RoleDefault { |
| + t.Fatalf("operator box took the group's role: %+v", r.Boxes["box"]) |
| + } |
| + again, _, err := d.SetGroupRole(ctx, sg, next, "ie", RoleLegacy) |
| + if err != nil || again.ETag != next.ETag { |
| + t.Fatalf("idempotent group set wrote: %v", err) |
| + } |
| + back, _, err := d.SetGroupRole(ctx, sg, next, "ie", RoleDefault) |
| + if err != nil || back.Roster.Boxes["g1"].Role != RoleDefault || back.Roster.Regions["ie"].Role != RoleDefault { |
| + t.Fatalf("back to default: %v", err) |
| + } |
| + if _, _, err = d.SetGroupRole(ctx, sg, back, "uk", RoleLegacy); !errors.Is(err, ErrRoster) { |
| + t.Fatalf("a region with no group: %v", err) |
| + } |
| + if _, _, err = d.SetGroupRole(ctx, sg, back, "ie", RolePlatform); !errors.Is(err, ErrRole) { |
| + t.Fatalf("a platform group: %v", err) |
| + } |
| +} |
| + |
| +// Automatic enrollment gives the new box the group's role in the same |
| +// roster write (A11-1): a replacement for an excluded group is never |
| +// placeable, even for a moment. |
| +func TestAutomaticEnrollmentInheritsTheGroupRole(t *testing.T) { |
| + for _, role := range []string{RoleDefault, RoleLegacy} { |
| + t.Run(RoleName(role), func(t *testing.T) { |
| + ctx := context.Background() |
| + d, sg, o := fixture(t) |
| + key, err := rsa.GenerateKey(rand.Reader, 2048) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + doc := []byte(`{"accountId":"123","region":"eu-west-1","instanceId":"i-new"}`) |
| + sum := sha256.Sum256(doc) |
| + sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, sum[:]) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, _ := json.Marshal(boxid.Record{V: 1, Box: "new", IdentityDocument: base64.StdEncoding.EncodeToString(doc), IdentitySignature: base64.StdEncoding.EncodeToString(sig)}) |
| + v := boxid.Verifier{Account: "123", Certs: map[string]*x509.Certificate{"eu-west-1": {PublicKey: &key.PublicKey, PublicKeyAlgorithm: x509.RSA}}} |
| + in := EnrollmentRequest{Reference: "new", Namespace: "platform", Label: "new", Region: "ie", Pool: "default", AWSRegion: "eu-west-1"} |
| + next := Clone(o.Roster) |
| + next.Regions = map[string]Region{"ie": {Account: "123", AWSRegion: "eu-west-1", GroupName: "routers", GroupARN: "group-arn", Namespaces: []string{"platform"}, Pools: []string{"default"}, Role: role}} |
| + if o, err = d.Replace(ctx, sg, o, next); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err = d.Invite(ctx, sg, in.Reference, raw); err != nil { |
| + t.Fatal(err) |
| + } |
| + o, a, err := d.BeginEnrollmentAttempt(ctx, sg, o, in, raw, "i-new") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + now := time.Now().UTC() |
| + c := MembershipConfirmation{PassID: "pass-1", AttemptID: a.AttemptID, ConfirmationID: a.ConfirmationID, EvidenceSHA256: a.EvidenceSHA256, TargetPlacement: "ie", AWSRegion: "eu-west-1", GroupName: "routers", GroupARN: "group-arn", InstanceID: "i-new", Account: "123", AvailabilityZoneRegion: "eu-west-1", LifecycleState: "InService", InstanceState: "running", ObservedAt: now, RequestIDs: []string{"asg", "ec2"}} |
| + c.Confirmer.Box, c.Confirmer.Enrollment, c.Confirmer.Region = "box", o.Roster.Boxes["box"].Enrollment, "ie" |
| + wire, _ := json.Marshal(c) |
| + if err := release.CreateExact(ctx, d.Store, ConfirmationKey(in.Reference, a.AttemptID, a.ConfirmationID), wire); err != nil { |
| + t.Fatal(err) |
| + } |
| + in.AttemptID, in.ConfirmationID = a.AttemptID, a.ConfirmationID |
| + admitted, err := d.EnrollAutomatic(ctx, sg, o, v, in, raw, "i-new", now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b := admitted.Roster.Boxes["new"] |
| + if b.Group != "routers" || b.Role != role { |
| + t.Fatalf("admitted box group %q role %q, want routers %q", b.Group, b.Role, role) |
| + } |
| + if admitted.Roster.Revision != o.Roster.Revision+1 { |
| + t.Fatal("the role was not set in the enrolling write") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// Every other roster write keeps the role: a quarantine, for one, rewrites |
| +// the box's entry and must not reopen placement on it. |
| +func TestOtherRosterWritesKeepTheRole(t *testing.T) { |
| + ctx := context.Background() |
| + d, sg, o := fixture(t) |
| + o, err := d.SetBoxRole(ctx, sg, o, "box", RoleLegacy) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + q, err := d.Quarantine(ctx, sg, o, "box", o.Roster.Boxes["box"].Enrollment, "loss-1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if b := q.Roster.Boxes["box"]; b.Role != RoleLegacy || b.State != "quarantined" { |
| + t.Fatalf("after a quarantine: %+v", b) |
| + } |
| + read, err := d.Read(ctx) |
| + if err != nil || read.Roster.Boxes["box"].Role != RoleLegacy { |
| + t.Fatalf("read back: %+v %v", read.Roster.Boxes["box"], err) |
| + } |
| +} |
diff --git a/internal/hostactors/roster.go b/internal/hostactors/roster.go| index 3d6ff913c..38abe806c 100644 |
| --- a/internal/hostactors/roster.go |
| +++ b/internal/hostactors/roster.go |
| @@ -39,6 +39,14 @@ type Box struct { |
| State string `json:"state"` |
| QuarantineRevision uint64 `json:"quarantine_revision"` |
| LossIDs []string `json:"loss_ids"` |
| + // Group is the Auto Scaling group automatic enrollment admitted the box |
| + // from (the region's GroupName then); "" for a box an operator |
| + // enrolled. It never changes. |
| + Group string `json:"group,omitempty"` |
| + // Role is the box's placement role (roles.go): "" (default), legacy or |
| + // platform. It is the one field besides the state fields a roster |
| + // write may change on an enrolled box. |
| + Role string `json:"role,omitempty"` |
| } |
| type Console struct { |
| EnrolledAt time.Time `json:"enrolled_at,omitzero"` |
| @@ -114,12 +122,20 @@ func (r Roster) Validate(bucket string) error { |
| if b.State != "active" && b.State != "quarantined" && b.State != "retired" { |
| return ErrRoster |
| } |
| + if !validBoxRole(b.Role) { |
| + return ErrRoster |
| + } |
| if b.State == "quarantined" && (b.QuarantineRevision == 0 || len(b.LossIDs) == 0) { |
| return ErrRoster |
| } |
| ids[b.Enrollment] = true |
| clouds[b.Cloud] = true |
| } |
| + for _, g := range r.Regions { |
| + if !validGroupRole(g.Role) || (g.Role != RoleDefault && g.GroupName == "") { |
| + return ErrRoster |
| + } |
| + } |
| for id, c := range r.Consoles { |
| if !segment(id) || !token.MatchString(c.Enrollment) || ids[c.Enrollment] || c.InstanceID != id || c.DeploymentBucket != bucket || len(c.Namespaces) == 0 { |
| return ErrRoster |
| @@ -246,6 +262,8 @@ func (d Directory) Replace(ctx context.Context, sg release.Signer, before Observ |
| z.QuarantineRevision = 0 |
| a.LossIDs = nil |
| z.LossIDs = nil |
| + a.Role = "" |
| + z.Role = "" |
| if !reflect.DeepEqual(a, z) || b.QuarantineRevision < old.QuarantineRevision { |
| return Observed{}, ErrRoster |
| } |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 8af5ccb11..593038ff4 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -806,7 +806,7 @@ var plan9B8CensusInventory = []censusEntry{ |
| {"internal/host/snapshot_loop.go", "putReplacing", "nonrecord", "sel:Put", "11e8639effce142eab707b67cbdd425d0d7cea510ddf95d292bdba8f79c9b510"}, |
| {"internal/hostactors/censuskey.go", "Directory.BindCensusKey", "nonrecord", "sel:Put", "7d9d673435e8c8a6aac3105e3a754bde2adcbc5d603945dd599cef703f334a5b"}, |
| {"internal/hostactors/invitation.go", "Directory.Invite", "nonrecord", "sel:Put", "356618a75a23d0157d8e0402e20187a42956f67a4332d698bb22bf05133ac489"}, |
| - {"internal/hostactors/roster.go", "Directory.Replace", "nonrecord", "sel:Put", "677bd94746cc5d3a5cd966d221f80b509ae096083a8ff4a0ee817d4c12c4a75f"}, |
| + {"internal/hostactors/roster.go", "Directory.Replace", "nonrecord", "sel:Put", "e6e8ac0070650dfacc4a438b080cb69738530b3d9013de55c1bcf06431b0a847"}, |
| {"internal/hostbox/regionalworker.go", "RegionalWorker.Census", "key-site", "lit:instances", "477bcb896b5ae8ec80af592d68eceb7992ad8a23cd6cff6043ad03b3ef2a8085"}, |
| {"internal/hostbox/requeststore.go", "RequestStore.CopyVersion", "versioned-transport", "def:CopyVersion,sel:CopyVersion", "3a50049ce7ca7f2cbc770bef46218944cdc7000205a86c20e4932f0b510da28f"}, |
| {"internal/hostbox/requeststore.go", "RequestStore.PutIfAbsent", "versioned-transport", "def:PutIfAbsent,sel:PutIfAbsent", "3666cbe5d2b91c1c652ffb4459c10a374fbd92e325e4e5c574fa3525d0ce2ffe"}, |
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 d98906ccd..34d954cab 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv |
| @@ -1,3 +1,5 @@ |
| # Plan 14 census overlay region-ha-2a (region HA spec 2, plan 2a: docs/superpowers/plans/2026-10-16-region-ha-2a-core.md), loaded last. |
| # Task 2: the new internal/lbhealth package (the load balancer health state). Envelope-only: no root-read unit changed or was added, nothing runs as root that did not before, and no registry, /data, release or claim read was added, so the legacy seal stands. |
| +# Task 3: box and group roles in the signed roster (internal/hostactors roles.go; Box.Role and Box.Group, Region.Role, Replace lets the role change, automatic enrollment inherits the group's role). Envelope-only: Directory.Replace, Roster.Validate and Directory.enroll are not root-read units, no root-read unit changed or was added, and the legacy seal stands. |
| @coverage internal/lbhealth ad1211a01a4eb3d02cd8ed95cbff0f8b17b3976ce9c0279ebd2cae2f20c07124 |
| +@coverage internal/hostactors 6736698380a247764ada687f2476f89382fc32a1183a50c06afe75967d716eab |
diff --git a/region_ha_boundary_test.go b/region_ha_boundary_test.go| index 2499e2ef4..7f68ca3fc 100644 |
| --- a/region_ha_boundary_test.go |
| +++ b/region_ha_boundary_test.go |
| @@ -20,6 +20,7 @@ import ( |
| // an AWS package. |
| var regionHACoreFiles = []string{ |
| "internal/lbhealth/lbhealth.go", |
| + "internal/hostactors/roles.go", |
| } |
| // regionHAAdapterFiles are spec 2's AWS adapters. Each is named for AWS so |