carlos / platform Public

region HA 2a: creates and relocations choose by the placement policy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 2792f1fb9811b7362444019b7a8de33f38f9f102 parent 3d35bbf
4 files changed, +134 −13
  • internal/hostops/executor.go +45 −5
  • internal/hostops/executor_test.go +81 −6
  • internal/hostrelocate/chooser_test.go +6 −2
  • internal/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