carlos / platform Public

hostrelocate: the worker chooses its target with create's chooser, scoped to its namespace and the record's pool, never the source or a replaced target

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev f9f254ac44dd40fd2ecc0cc1c4665d9b646662b6 parent 1553b7b
2 files changed, +160 −0
  • internal/hostrelocate/chooser.go +88 −0
  • internal/hostrelocate/chooser_test.go +72 −0
diff --git a/internal/hostrelocate/chooser.go b/internal/hostrelocate/chooser.go
new file mode 100644
index 000000000..4ea42110a
--- /dev/null
+++ b/internal/hostrelocate/chooser.go
@@ -0,0 +1,88 @@
+package hostrelocate
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "slices"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostops"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// RegionChooser gives the worker its Choose and Eligible from create's
+// chooser (hostops.ExecutorChooser), so a relocation's target comes from
+// the same complete eligible set a create's executor does (spec 1 §2,
+// §5.2). Like ProductionAuthority.SelectExecutor it chooses within claim
+// Plan 5's scope, set on every call: Namespace, and the pool of the host's
+// authoritative record. The placement is Region, the worker's: a
+// relocation never leaves it, and Worker.Step acts only on its own
+// region's operations.
+type RegionChooser struct {
+ Executors hostops.ExecutorChooser
+ // Records is the deployment (control) bucket holding the host's
+ // authoritative record at holder.key.
+ Records release.Store
+ // Namespace is the roster namespace of the box the worker runs on. An
+ // empty one refuses: the chooser would choose nothing.
+ Namespace string
+ Region string
+}
+
+// Choose names a target for obs's host, never one in exclude.
+// ExecutorChooser already leaves out the claim's lost boxes and every box
+// owing a park-local chore for the host; exclude adds the worker's own
+// (the source, the current target, the targets this operation replaced).
+// They are passed as the lost list of a copy of the observation: that list
+// is the only exclusion the chooser reads, and the copy is never written.
+func (r RegionChooser) Choose(ctx context.Context, obs hostclaim.Observed, exclude []string) (hostclaim.Target, error) {
+ x, rec, err := r.scoped(ctx, obs)
+ if err != nil {
+ return hostclaim.Target{}, err
+ }
+ c := *obs.Claim
+ c.Lost = append(slices.Clone(c.Lost), exclude...)
+ obs.Claim = &c
+ sel, err := x.Choose(ctx, obs, rec)
+ if err != nil {
+ return hostclaim.Target{}, err
+ }
+ return hostclaim.Target{Box: sel.Box, Enrollment: sel.Enrollment}, nil
+}
+
+// Eligible refuses box unless it is in the complete eligible set for obs's
+// host now (ExecutorChooser.Permits within the same scope).
+func (r RegionChooser) Eligible(ctx context.Context, obs hostclaim.Observed, box string) error {
+ x, rec, err := r.scoped(ctx, obs)
+ if err != nil {
+ return err
+ }
+ return x.Permits(ctx, obs, rec, box)
+}
+
+// scoped reads the host's authoritative record and returns the chooser
+// scoped to Namespace and the record's pool, with the record placed in
+// Region.
+func (r RegionChooser) scoped(ctx context.Context, obs hostclaim.Observed) (hostops.ExecutorChooser, hostops.Record, error) {
+ if r.Records == nil || r.Namespace == "" || r.Region == "" || obs.Absent || obs.Claim == nil || obs.Claim.Holder == nil {
+ return hostops.ExecutorChooser{}, hostops.Record{}, ErrUnsupported
+ }
+ rec, err := hostops.ReadRecord(ctx, r.Records, obs.Claim.Holder.Key)
+ if err != nil {
+ return hostops.ExecutorChooser{}, hostops.Record{}, err
+ }
+ if rec.Holder.Host != obs.Host || rec.Holder.Family != obs.Claim.Holder.Family {
+ return hostops.ExecutorChooser{}, hostops.Record{}, fmt.Errorf("%w: record %s is not %s's", ErrRefused, obs.Claim.Holder.Key, obs.Host)
+ }
+ var shape struct {
+ Pool string `json:"pool"`
+ }
+ if err := json.Unmarshal(rec.Body, &shape); err != nil {
+ return hostops.ExecutorChooser{}, hostops.Record{}, err
+ }
+ rec.Holder.Region = r.Region
+ x := r.Executors
+ x.Namespace, x.Pool = r.Namespace, shape.Pool
+ return x, rec, nil
+}
diff --git a/internal/hostrelocate/chooser_test.go b/internal/hostrelocate/chooser_test.go
new file mode 100644
index 000000000..09fd33cb2
--- /dev/null
+++ b/internal/hostrelocate/chooser_test.go
@@ -0,0 +1,72 @@
+package hostrelocate
+
+import (
+ "context"
+ "errors"
+ "slices"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/boxchoice"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostops"
+)
+
+// 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.
+func scopedChooser(f *fixture, namespace string) RegionChooser {
+ yes := true
+ snap := boxchoice.Snapshot{Complete: true}
+ 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}})
+ }
+ loads := map[string]boxchoice.Load{"spa": {Hosts: 9, Apps: map[string]int{"bab/jam": 3}}}
+ return RegionChooser{Records: f.s, Namespace: namespace, Region: "ie",
+ Executors: hostops.ExecutorChooser{Directory: f.w.Directory, SecretsKeyFP: "fp", Now: f.w.Now,
+ Edges: func(context.Context) (boxchoice.Snapshot, error) { return snap, nil },
+ Loads: func(context.Context) (map[string]boxchoice.Load, error) { return loads, nil }}}
+}
+
+// Task 2's fix: the chooser chooses only within claim Plan 5's scope, and
+// an unscoped one chooses nothing. With the scope set from the worker's
+// namespace and the record's pool, the worker picks a target: never the
+// source (the least loaded box, so the chooser alone would name it), never
+// the dead target, never a box of another namespace.
+func TestWorkerChoosesATargetWithinTheHostsScope(t *testing.T) {
+ f := newFixture(t)
+ ctx := context.Background()
+ body := `{"host":"` + host + `","account":"bab","app":"jam","region":"ie","created_at":"2026-10-04T01:00:00Z"}`
+ if _, err := f.s.Put(ctx, f.claim().Claim.Holder.Key, []byte(body), ""); err != nil {
+ t.Fatal(err)
+ }
+ b := f.roster.Boxes["sp2"]
+ b.Namespace = "other"
+ f.roster.Boxes["sp2"] = b
+ f.roster.Revision++
+ f.writeRoster()
+
+ unscoped := scopedChooser(f, "")
+ f.w.Choose, f.w.Eligible = unscoped.Choose, unscoped.Eligible
+ before := f.claim().ETag
+ if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); !errors.Is(err, ErrUnsupported) {
+ t.Fatalf("an unscoped chooser admitted a target: %v", err)
+ }
+
+ scoped := scopedChooser(f, "platform")
+ f.w.Choose, f.w.Eligible = scoped.Choose, scoped.Eligible
+ if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "sp2"}); !errors.Is(err, hostops.ErrNoEligibleBox) {
+ t.Fatalf("started on a box of another namespace: %v", err)
+ }
+ if f.claim().ETag != before {
+ t.Fatal("a refused start wrote the claim")
+ }
+ if _, err := f.w.Start(ctx, host, Request{Reason: hostclaim.ReasonDrain, Source: "src", Target: "dst"}); err != nil {
+ t.Fatal(err)
+ }
+ f.terminate("dst")
+ o := f.worker(nil)
+ if o.Claim.Op.Intent["target_box"] != "spa" || o.Claim.Op.Intent["target_enrollment"] != enrollment("spa") || !slices.Contains(o.Claim.Lost, "dst") {
+ t.Fatalf("replacement: %+v lost %v", o.Claim.Op.Intent, o.Claim.Lost)
+ }
+}