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>
2 files changed,
+160
−0
internal/hostrelocate/chooser.go+88 −0internal/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) |
| + } |
| +} |