region HA 2a: creates and relocations choose by the placement policy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+134
−13
internal/hostops/executor.go+45 −5internal/hostops/executor_test.go+81 −6internal/hostrelocate/chooser_test.go+6 −2internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv+2 −0
diff --git a/internal/hostops/executor.go b/internal/hostops/executor.go| index d34440d20..8291e53b0 100644 |
| --- a/internal/hostops/executor.go |
| +++ b/internal/hostops/executor.go |
| @@ -48,17 +48,19 @@ const sharedPool = "default" |
| // r's placement is created on. Identity, placement and AWS region come from |
| // the roster; a box with a valid termination fence, or not active, is never |
| // chosen; a box on the claim's lost list, or owing a park-local chore on |
| -// it, is never chosen. |
| +// it, is never chosen. Among the rest, region HA spec 2's policy (§7) |
| +// decides: placeable boxes only (placeable), then boxchoice.ChooseRoomiest. |
| func (x ExecutorChooser) Choose(ctx context.Context, obs hostclaim.Observed, r Record) (hostclaim.ExecutorSelection, error) { |
| o, cands, err := x.eligible(ctx, obs, r) |
| if err != nil { |
| return hostclaim.ExecutorSelection{}, err |
| } |
| + cands = x.placeable(o, cands) |
| loads, err := x.Loads(ctx) |
| if err != nil { |
| return hostclaim.ExecutorSelection{}, err |
| } |
| - best, ok := boxchoice.Choose(r.Holder.Host, cands, loads, r.Holder.Account+"/"+r.Holder.App) |
| + best, ok := boxchoice.ChooseRoomiest(r.Holder.Host, cands, loads, r.Holder.Account+"/"+r.Holder.App) |
| if !ok { |
| return hostclaim.ExecutorSelection{}, ErrNoEligibleBox |
| } |
| @@ -69,13 +71,15 @@ func (x ExecutorChooser) Choose(ctx context.Context, obs hostclaim.Observed, r R |
| // in r's placement now (spec 1 §2, §5.8 "The start CAS ... refused ... |
| // when `to` is not eligible for the host"): the same read Choose makes, |
| // without choosing. A stale edges file, a missing capability or secrets |
| -// key, a draining box, a fence, or an incomplete snapshot refuses. |
| +// key, a draining box, a fence, or an incomplete snapshot refuses, and so |
| +// does a box spec 2's policy does not place on (placeable): a legacy or |
| +// platform role, too little memory or disk, or stale headroom. |
| func (x ExecutorChooser) Permits(ctx context.Context, obs hostclaim.Observed, r Record, box string) error { |
| - _, cands, err := x.eligible(ctx, obs, r) |
| + o, cands, err := x.eligible(ctx, obs, r) |
| if err != nil { |
| return err |
| } |
| - for _, c := range cands { |
| + for _, c := range x.placeable(o, cands) { |
| if c.BoxID == box { |
| return nil |
| } |
| @@ -83,6 +87,42 @@ func (x ExecutorChooser) Permits(ctx context.Context, obs hostclaim.Observed, r |
| return fmt.Errorf("%w: %s", ErrNoEligibleBox, box) |
| } |
| +// PlaceableBoxes is every box in placement that could take a new |
| +// instance in the chooser's namespace and pool now, sorted: Choose's set, |
| +// before the choice. The console's move door takes a move's destination |
| +// from it (region HA spec 2 §7, H27). |
| +func (x ExecutorChooser) PlaceableBoxes(ctx context.Context, placement string) ([]string, error) { |
| + r := Record{Holder: hostclaim.NewHolder{Region: placement}} |
| + o, cands, err := x.eligible(ctx, hostclaim.Observed{Absent: true}, r) |
| + if err != nil { |
| + return nil, err |
| + } |
| + boxes := []string{} |
| + for _, c := range x.placeable(o, cands) { |
| + boxes = append(boxes, c.BoxID) |
| + } |
| + return boxes, nil |
| +} |
| + |
| +// Capacity is how many boxes PlaceableBoxes names. The console publishes |
| +// it per region as the eligible-capacity metric (region HA spec 2 §4, |
| +// A1-11). |
| +func (x ExecutorChooser) Capacity(ctx context.Context, placement string) (int, error) { |
| + boxes, err := x.PlaceableBoxes(ctx, placement) |
| + return len(boxes), err |
| +} |
| + |
| +// placeable is region HA spec 2's policy over the eligible set (§7): |
| +// each box's roster role, then boxchoice.Placeable's thresholds at |
| +// x.Now(). Identity was Eligible's; nothing here reads a provider. |
| +func (x ExecutorChooser) placeable(o hostactors.Observed, cands []boxchoice.Candidate) []boxchoice.Candidate { |
| + roles := make(map[string]string, len(cands)) |
| + for _, c := range cands { |
| + roles[c.BoxID] = o.Roster.Boxes[c.BoxID].Role |
| + } |
| + return boxchoice.Placeable(cands, roles, x.Now()) |
| +} |
| + |
| // eligible is one read of the roster, the fences and the edges files, |
| // reduced to boxchoice.Eligible's candidates. |
| func (x ExecutorChooser) eligible(ctx context.Context, obs hostclaim.Observed, r Record) (hostactors.Observed, []boxchoice.Candidate, error) { |
diff --git a/internal/hostops/executor_test.go b/internal/hostops/executor_test.go| index c874eac2f..6c8a58822 100644 |
| --- a/internal/hostops/executor_test.go |
| +++ b/internal/hostops/executor_test.go |
| @@ -43,7 +43,12 @@ func chooserFixtureWith(t *testing.T, edit func(*hostactors.Roster), boxes ...st |
| roster.Boxes[ref] = hostactors.Box{Enrollment: strings.Repeat(string("abcdef"[i]), 32), Namespace: "platform", BoxID: ref, Label: ref, Region: "ie", Pool: "default", |
| Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-" + ref}, EvidenceSHA256: strings.Repeat("c", 64), State: "active"} |
| snap.Boxes = append(snap.Boxes, boxchoice.Box{BoxID: ref, EIP: "203.0.113.1", Region: "eu-west-1", Placement: "ie", Fresh: true, |
| - HibernationConfigured: &yes, ServesSharedPool: &yes, AcceptsInbound: &yes, SecretsKeyFP: "fp", Capabilities: []string{boxchoice.Capability}}) |
| + HibernationConfigured: &yes, ServesSharedPool: &yes, AcceptsInbound: &yes, SecretsKeyFP: "fp", Capabilities: []string{boxchoice.Capability}, |
| + // Equal, roomy headroom from a file a minute old: spec 2's |
| + // policy (§7) admits every box, and the choice falls through |
| + // to the hash unless a test changes it. |
| + Headroom: &boxchoice.Headroom{MemFreeMiB: 4000, MemTotalMiB: 8000, DiskFreeMiB: 50000, DiskTotalMiB: 100000}, |
| + Generation: "2026-10-07T08:59:00Z"}) |
| } |
| if edit != nil { |
| edit(&roster) |
| @@ -68,9 +73,9 @@ func chooserRecord() Record { |
| Body: []byte(`{"host":"shop.bab.oncarlos.com","account":"bab","app":"jam","region":"ie"}`)} |
| } |
| -func TestExecutorChooserSpreadsThenBalancesThenHashes(t *testing.T) { |
| +func TestExecutorChooserSpreadsThenPrefersMemoryThenHashes(t *testing.T) { |
| ctx := context.Background() |
| - x, _, _, loads := chooserFixture(t, "box-a", "box-b") |
| + x, _, snap, loads := chooserFixture(t, "box-a", "box-b") |
| absent := hostclaim.Observed{Host: "shop.bab.oncarlos.com", Absent: true} |
| first, err := x.Choose(ctx, absent, chooserRecord()) |
| if err != nil { |
| @@ -85,10 +90,80 @@ func TestExecutorChooserSpreadsThenBalancesThenHashes(t *testing.T) { |
| if got, _ := x.Choose(ctx, absent, chooserRecord()); got.Box != other { |
| t.Fatalf("soft spread: got %s, want %s", got.Box, other) |
| } |
| - // Both run the app: the fewest hosts wins. |
| + // Both run the app: the most free memory wins (spec 2 §7), whatever |
| + // the host counts say. |
| loads[other] = boxchoice.Load{Hosts: 5, Apps: map[string]int{"bab/jam": 1}} |
| - if got, _ := x.Choose(ctx, absent, chooserRecord()); got.Box != first.Box { |
| - t.Fatalf("headroom: got %s, want %s", got.Box, first.Box) |
| + for i := range snap.Boxes { |
| + if snap.Boxes[i].BoxID == other { |
| + snap.Boxes[i].Headroom.MemFreeMiB = 6000 |
| + } |
| + } |
| + if got, _ := x.Choose(ctx, absent, chooserRecord()); got.Box != other { |
| + t.Fatalf("headroom: got %s, want %s", got.Box, other) |
| + } |
| +} |
| + |
| +// withRole sets one box's roster role before the roster is signed. |
| +func withRole(ref, role string) func(*hostactors.Roster) { |
| + return func(r *hostactors.Roster) { |
| + b := r.Boxes[ref] |
| + b.Role = role |
| + r.Boxes[ref] = b |
| + } |
| +} |
| + |
| +// Region HA spec 2 §7: the chooser, and so creates and relocation |
| +// targets, applies the roster's roles and the policy's thresholds after |
| +// Eligible. Permits answers the same set, and Capacity counts it. |
| +func TestExecutorChooserAppliesRolesAndHeadroom(t *testing.T) { |
| + ctx := context.Background() |
| + absent := hostclaim.Observed{Host: "shop.bab.oncarlos.com", Absent: true} |
| + cases := []struct { |
| + name string |
| + roster func(*hostactors.Roster) |
| + edges func(*boxchoice.Box) |
| + }{ |
| + {"legacy role", withRole("box-a", hostactors.RoleLegacy), nil}, |
| + {"platform role", withRole("box-a", hostactors.RolePlatform), nil}, |
| + {"memory under 15%", nil, func(b *boxchoice.Box) { b.Headroom.MemFreeMiB = 1199 }}, |
| + {"disk under 10%", nil, func(b *boxchoice.Box) { b.Headroom.DiskFreeMiB = 9999 }}, |
| + {"edges file 16 minutes old", nil, func(b *boxchoice.Box) { b.Generation = "2026-10-07T08:44:00Z" }}, |
| + {"no headroom", nil, func(b *boxchoice.Box) { b.Headroom = nil }}, |
| + } |
| + for _, tc := range cases { |
| + t.Run(tc.name, func(t *testing.T) { |
| + x, _, snap, _ := chooserFixtureWith(t, tc.roster, "box-a", "box-b") |
| + for i := range snap.Boxes { |
| + if snap.Boxes[i].BoxID == "box-a" && tc.edges != nil { |
| + tc.edges(&snap.Boxes[i]) |
| + } |
| + } |
| + for range 4 { |
| + got, err := x.Choose(ctx, absent, chooserRecord()) |
| + if err != nil || got.Box != "box-b" { |
| + t.Fatalf("chose %+v %v, want box-b", got, err) |
| + } |
| + } |
| + if err := x.Permits(ctx, absent, chooserRecord(), "box-a"); !errors.Is(err, ErrNoEligibleBox) { |
| + t.Fatalf("Permits(box-a) = %v, want ErrNoEligibleBox", err) |
| + } |
| + if err := x.Permits(ctx, absent, chooserRecord(), "box-b"); err != nil { |
| + t.Fatalf("Permits(box-b) = %v", err) |
| + } |
| + if n, err := x.Capacity(ctx, "ie"); err != nil || n != 1 { |
| + t.Fatalf("Capacity = %d, %v; want 1", n, err) |
| + } |
| + if boxes, err := x.PlaceableBoxes(ctx, "ie"); err != nil || len(boxes) != 1 || boxes[0] != "box-b" { |
| + t.Fatalf("PlaceableBoxes = %v, %v; want [box-b]", boxes, err) |
| + } |
| + }) |
| + } |
| + x, _, _, _ := chooserFixture(t, "box-a", "box-b") |
| + if n, err := x.Capacity(ctx, "ie"); err != nil || n != 2 { |
| + t.Fatalf("Capacity with both placeable = %d, %v; want 2", n, err) |
| + } |
| + if _, err := x.Capacity(ctx, "us"); !errors.Is(err, ErrContract) { |
| + t.Fatalf("Capacity of a region the roster lacks = %v, want ErrContract", err) |
| } |
| } |
diff --git a/internal/hostrelocate/chooser_test.go b/internal/hostrelocate/chooser_test.go| index 09fd33cb2..c6c66c283 100644 |
| --- a/internal/hostrelocate/chooser_test.go |
| +++ b/internal/hostrelocate/chooser_test.go |
| @@ -5,6 +5,7 @@ import ( |
| "errors" |
| "slices" |
| "testing" |
| + "time" |
| "github.com/carlosframework/platform/internal/boxchoice" |
| "github.com/carlosframework/platform/internal/hostclaim" |
| @@ -13,13 +14,16 @@ import ( |
| // scopedChooser plugs create's chooser into the fixture's worker, scoped |
| // to namespace, with every fixture box fresh and eligible on its edges |
| -// file and spa the most loaded. |
| +// file, equally roomy under spec 2's policy (§7), and spa the most loaded. |
| func scopedChooser(f *fixture, namespace string) RegionChooser { |
| yes := true |
| snap := boxchoice.Snapshot{Complete: true} |
| + generation := f.w.Now().Add(-time.Minute).UTC().Format(time.RFC3339) |
| for _, ref := range []string{"src", "dst", "spa", "sp2"} { |
| snap.Boxes = append(snap.Boxes, boxchoice.Box{BoxID: ref, EIP: "203.0.113.1", Region: "eu-west-1", Placement: "ie", Fresh: true, |
| - HibernationConfigured: &yes, ServesSharedPool: &yes, AcceptsInbound: &yes, SecretsKeyFP: "fp", Capabilities: []string{boxchoice.Capability}}) |
| + HibernationConfigured: &yes, ServesSharedPool: &yes, AcceptsInbound: &yes, SecretsKeyFP: "fp", Capabilities: []string{boxchoice.Capability}, |
| + Headroom: &boxchoice.Headroom{MemFreeMiB: 4000, MemTotalMiB: 8000, DiskFreeMiB: 50000, DiskTotalMiB: 100000}, |
| + Generation: generation}) |
| } |
| loads := map[string]boxchoice.Load{"spa": {Hosts: 9, Apps: map[string]int{"bab/jam": 3}}} |
| return RegionChooser{Records: f.s, Namespace: namespace, Region: "ie", |
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 f9d0fe7f9..a9f4b4b33 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-region-ha-2a.tsv |
| @@ -3,8 +3,10 @@ |
| # 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. |
| # Task 4: the placement policy (internal/boxchoice policy.go; headroom totals and the edges file's generation carried through Eligible and edgesfile.Snapshot.BoxChoice). Envelope-only: no root-read unit changed or was added, and the legacy seal stands. |
| # Task 5: the edges file's headroom carries the box's total memory and data volume size (headroomFor and capacityMiB, internal/host/headroom.go; edgeHeadroom's two additive fields). headroomFor calls the existing readMemBytes and diskBytes and adds no read of its own. Envelope-only: readHeadroom and readMemBytes are unchanged, no root-read unit changed or was added, and the legacy seal stands. |
| +# Task 6: hostops.ExecutorChooser applies spec 2's policy in Choose and Permits (creates and relocation targets) and names the placeable set in PlaceableBoxes and Capacity. ExecutorChooser.eligible, the accepted-legacy unit (merge ruling 2), is unchanged: the policy runs over its result. Envelope-only, and the legacy seal stands. |
| @coverage internal/lbhealth ad1211a01a4eb3d02cd8ed95cbff0f8b17b3976ce9c0279ebd2cae2f20c07124 |
| @coverage internal/hostactors 6736698380a247764ada687f2476f89382fc32a1183a50c06afe75967d716eab |
| @coverage internal/boxchoice 1bfd561bcb48b51e60e63f99e086fd44d3b1be2160e28b3ed0253a14bbb9ff66 |
| @coverage internal/edgesfile da42c799d62e065817b5cce4f9a3ead1308dbd379615a85a7b14533c16d7e804 |
| @coverage internal/host ac55f3fa73d0e0c6d5979e446b2accdd2863b06a93c54d2006f6b547f840abd0 |
| +@coverage internal/hostops 3dc23aaff7bdae0341a8c9a6fbac076a2f57ca1e4676d83543433ff9ad7d1c79 |