carlos / platform Public

hostops: with region HA on, create chooses its executor once with boxchoice over the roster and records it in the create intent

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev d1ce3f72b0751465cb766d740d4c629880467a1e parent 03da930
16 files changed, +825 −9
  • cmd/console/main.go +9 −0
  • cmd/console/regionha_test.go +21 −0
  • internal/console/hostclaimexecutor.go +33 −0
  • internal/console/hostclaimexecutor_test.go +109 −0
  • internal/console/server.go +5 −0
  • internal/hostclaim/create.go +26 −0
  • internal/hostclaim/create_selection.go +1 −3
  • internal/hostclaim/create_test.go +24 −0
  • internal/hostops/backfill.go +17 −0
  • internal/hostops/backfill_controller.go +3 −0
  • internal/hostops/executor.go +119 −0
  • internal/hostops/executor_test.go +310 −0
  • internal/hostops/loads.go +43 −0
  • internal/hostops/operations.go +25 −3
  • internal/hostops/operations_test.go +64 −1
  • internal/hostops/production_create.go +16 −2
diff --git a/cmd/console/main.go b/cmd/console/main.go
index 9ef54f0e2..f40107972 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -313,6 +313,7 @@ func serve() {
// correctly draws no bucket node.
Bucket: os.Getenv("CARLOS_DEPLOYMENT_BUCKET"),
HomeRegion: strings.TrimSpace(os.Getenv("CONSOLE_HOME_REGION")),
+ RegionHA: regionHAEnabled(os.Getenv),
// The deployment's AWS account id, the trust anchor of the Phase 1
// move door's box identity check (unset: no box verifies).
AWSAccount: strings.TrimSpace(os.Getenv("CARLOS_AWS_ACCOUNT")),
@@ -1398,3 +1399,11 @@ func sweepLegacyMail(claims bool, rel release.Store, st *console.Store) {
log.Printf("console: mail legacy sweep: dropping mail_counts -> %v", err)
}
}
+
+// regionHAEnabled is CARLOS_REGION_HA as strconv.ParseBool reads it: on
+// for "1", "t", "true" in any case, off for "", "0", "false" and anything
+// unparseable. The same rule as the box's (internal/host/boxrecord.go).
+func regionHAEnabled(getenv func(string) string) bool {
+ on, err := strconv.ParseBool(strings.TrimSpace(getenv("CARLOS_REGION_HA")))
+ return err == nil && on
+}
diff --git a/cmd/console/regionha_test.go b/cmd/console/regionha_test.go
new file mode 100644
index 000000000..8bed8b65f
--- /dev/null
+++ b/cmd/console/regionha_test.go
@@ -0,0 +1,21 @@
+package main
+
+import "testing"
+
+// CARLOS_REGION_HA reaches Config.RegionHA through regionHAEnabled
+// (strconv.ParseBool): only a true value turns the console's create-time
+// chooser on; unset, false and garbage leave create as today.
+func TestRegionHAEnabledParsesTheSwitch(t *testing.T) {
+ for v, want := range map[string]bool{"": false, "0": false, "false": false, "no": false, "garbage": false,
+ "1": true, "t": true, "true": true, "TRUE": true, " true ": true} {
+ got := regionHAEnabled(func(k string) string {
+ if k != "CARLOS_REGION_HA" {
+ t.Fatalf("read %s", k)
+ }
+ return v
+ })
+ if got != want {
+ t.Errorf("CARLOS_REGION_HA=%q: %v, want %v", v, got, want)
+ }
+ }
+}
diff --git a/internal/console/hostclaimexecutor.go b/internal/console/hostclaimexecutor.go
new file mode 100644
index 000000000..b0bf172a0
--- /dev/null
+++ b/internal/console/hostclaimexecutor.go
@@ -0,0 +1,33 @@
+package console
+
+import (
+ "context"
+ "time"
+
+ "github.com/carlosframework/platform/internal/boxchoice"
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostops"
+)
+
+// executorChooser is create's executor choice (region HA §5.2, L8) over
+// console's live edges read and the claim listing's loads. The create
+// door's hostops.ProductionAuthority uses it as Executors once that door
+// lands; nothing calls it before then. With Config.RegionHA off it is nil,
+// and the create path is today's (spec §8 step 2: dormant).
+func (s *Server) executorChooser(d hostactors.Directory) *hostops.ExecutorChooser {
+ if !s.cfg.RegionHA {
+ return nil
+ }
+ return &hostops.ExecutorChooser{Directory: d,
+ Edges: func(ctx context.Context) (boxchoice.Snapshot, error) {
+ snap, err := s.liveEdges(ctx)
+ if err != nil {
+ return boxchoice.Snapshot{}, err
+ }
+ return boxSnapshot(snap), nil
+ },
+ Loads: func(ctx context.Context) (map[string]boxchoice.Load, error) {
+ return hostops.ClaimLoads(ctx, s.cfg.Releases)
+ },
+ SecretsKeyFP: s.expectedSecretsFP(), HomeName: s.cfg.HomeRegion, Now: time.Now}
+}
diff --git a/internal/console/hostclaimexecutor_test.go b/internal/console/hostclaimexecutor_test.go
new file mode 100644
index 000000000..95442df6a
--- /dev/null
+++ b/internal/console/hostclaimexecutor_test.go
@@ -0,0 +1,109 @@
+package console
+
+import (
+ "context"
+ "slices"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/boxchoice"
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The console's chooser reads the bucket through its two production
+// readers: the live edges (each box's edges file joined with its status
+// snapshot) and the serving claims' loads. The bucket holds real evidence,
+// the assertions name boxes and counts, and a change to the stored
+// evidence shows in the next read, so neither reader can be replaced by
+// one returning nothing (round 10).
+func TestCreateExecutorChooserReadsTheLiveEdgesAndTheClaims(t *testing.T) {
+ ctx := context.Background()
+ store := release.NewDirStore(t.TempDir())
+ off := &Server{cfg: Config{Releases: store, HomeRegion: "ie"}}
+ if off.executorChooser(hostactors.Directory{Store: store, Bucket: "bucket"}) != nil {
+ t.Fatal("a chooser with CARLOS_REGION_HA off: create would change")
+ }
+ s := &Server{cfg: Config{Releases: store, HomeRegion: "ie", RegionHA: true}}
+ x := s.executorChooser(hostactors.Directory{Store: store, Bucket: "bucket"})
+ if x == nil || x.Edges == nil || x.Loads == nil || x.Now == nil || x.HomeName != "ie" || x.SecretsKeyFP != s.expectedSecretsFP() {
+ t.Fatalf("chooser: %+v", x)
+ }
+
+ // Two live boxes in ie, each with its edges file and a fresh status
+ // snapshot; box-b advertises the region-ha-1 capability.
+ edges := func(box, extra string) string {
+ return `{"v":1,"box":"` + box + `","box_id":"` + box + `","eip":"203.0.113.1","region":"eu-west-1","placement":"ie","hibernation_configured":true` + extra + `,"hosts":{}}`
+ }
+ for box, body := range map[string]string{"box-a": edges("box-a", ""), "box-b": edges("box-b", `,"capabilities":["`+boxchoice.Capability+`"]`)} {
+ setBoxSnapshot(t, store, box, time.Now())
+ if _, err := store.Put(ctx, "control/edges/"+box+".json", []byte(body), ""); err != nil {
+ t.Fatal(err)
+ }
+ }
+ // Two serving claims on box-a for bab/jam, one on box-b for bab/tea.
+ created := time.Date(2026, 10, 4, 1, 0, 0, 0, time.UTC)
+ serve := func(host, app, box string) {
+ t.Helper()
+ nh := hostclaim.NewHolder{Family: "instance", Key: "apps/bab/" + app + "/instances/" + host + ".json", Account: "bab", App: app, Host: host, Region: "ie", RecordRegion: "ie", CreatedAt: created}
+ body := []byte(`{"host":"` + host + `","account":"bab","app":"` + app + `","region":"ie","created_at":"2026-10-04T01:00:00Z"}`)
+ o, err := hostclaim.Read(ctx, store, host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if o, err = hostclaim.Update(ctx, store, o, hostclaim.BeginCreate(nh, body, "c", "console", created)); err != nil {
+ t.Fatal(err)
+ }
+ if _, err = hostclaim.Update(ctx, store, o, hostclaim.CompleteCreate("c", box)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ serve("a.bab.oncarlos.com", "jam", "box-a")
+ serve("b.bab.oncarlos.com", "jam", "box-a")
+ serve("c.bab.oncarlos.com", "tea", "box-b")
+
+ read := func() (map[string]boxchoice.Box, map[string]boxchoice.Load) {
+ t.Helper()
+ snap, err := x.Edges(ctx)
+ if err != nil || !snap.Complete {
+ t.Fatalf("edges: %+v %v", snap, err)
+ }
+ boxes := map[string]boxchoice.Box{}
+ for _, b := range snap.Boxes {
+ boxes[b.BoxID] = b
+ }
+ loads, err := x.Loads(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return boxes, loads
+ }
+ boxes, loads := read()
+ if len(boxes) != 2 || !boxes["box-a"].Fresh || !boxes["box-b"].Fresh || boxes["box-a"].Placement != "ie" ||
+ slices.Contains(boxes["box-a"].Capabilities, boxchoice.Capability) || !slices.Contains(boxes["box-b"].Capabilities, boxchoice.Capability) {
+ t.Fatalf("live edges: %+v", boxes)
+ }
+ if loads["box-a"].Hosts != 2 || loads["box-a"].Apps["bab/jam"] != 2 || loads["box-b"].Hosts != 1 || loads["box-b"].Apps["bab/tea"] != 1 {
+ t.Fatalf("loads: %+v", loads)
+ }
+
+ // The evidence changes: box-a starts draining and stops reporting
+ // (stale), and a fourth host serves on box-b. The next read says so.
+ _, etag, err := store.Get(ctx, "control/edges/box-a.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := store.Put(ctx, "control/edges/box-a.json", []byte(edges("box-a", `,"draining":true`)), etag); err != nil {
+ t.Fatal(err)
+ }
+ setBoxSnapshot(t, store, "box-a", time.Now().Add(-time.Hour))
+ serve("d.bab.oncarlos.com", "tea", "box-b")
+ boxes, loads = read()
+ if !boxes["box-a"].Draining || boxes["box-a"].Fresh || !boxes["box-b"].Fresh {
+ t.Fatalf("live edges after the change: %+v", boxes)
+ }
+ if loads["box-b"].Hosts != 2 || loads["box-b"].Apps["bab/tea"] != 2 || loads["box-a"].Hosts != 2 {
+ t.Fatalf("loads after the change: %+v", loads)
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index a0827041b..d3627ba26 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -500,6 +500,11 @@ type Config struct {
// struct. "" is legal — a local CARLOS_DEPLOYMENT_DIR deployment has no
// bucket name to draw — and simply draws no bucket node.
Bucket string
+ // RegionHA is CARLOS_REGION_HA as strconv.ParseBool reads it (region HA
+ // spec 1 §8 step 2): with it off the console chooses no executor at
+ // create and relocation has no console half. A deployment fact, on
+ // Config for the same reason Bucket is.
+ RegionHA bool
// HomeRegion NAMES the deployment's home placement (CONSOLE_HOME_REGION,
// e.g. "ie") for consumer pickers (issue #346). It is a NAME, not a
// scope: records for the home keep region "" (the unconstrained value
diff --git a/internal/hostclaim/create.go b/internal/hostclaim/create.go
index 8079e8629..bde10f5c3 100644
--- a/internal/hostclaim/create.go
+++ b/internal/hostclaim/create.go
@@ -80,6 +80,32 @@ func BeginCreate(nh NewHolder, recordBody []byte, opID, by string, now time.Time
}
}
+// ExecutorSelection is the box create selects once, with
+// boxchoice.Choose over the eligible set (claim P5-D2, region HA §5.2,
+// H12), and its roster enrollment.
+type ExecutorSelection struct {
+ Box, Enrollment string
+}
+
+// BeginCreateOn is BeginCreate recording the selected executor in the
+// create intent, in the same step 2 CAS, before any effect (claim §5.1,
+// P5-D6 `selected_executor`, `executor_enrollment`). CompleteCreate then
+// sets op.exec to exactly that box; recovery never substitutes another.
+func BeginCreateOn(nh NewHolder, recordBody []byte, opID, by string, now time.Time, sel ExecutorSelection) func(*Claim) error {
+ begin := BeginCreate(nh, recordBody, opID, by, now)
+ return func(c *Claim) error {
+ if !validBoxReference(sel.Box) || sel.Box == "none" || sel.Enrollment == "" {
+ return fmt.Errorf("%w: create needs a selected executor", ErrMalformed)
+ }
+ if err := begin(c); err != nil {
+ return err
+ }
+ c.Op.Intent["selected_executor"] = sel.Box
+ c.Op.Intent["executor_enrollment"] = sel.Enrollment
+ return nil
+ }
+}
+
func CompleteCreate(opID, execBox string) func(*Claim) error {
return func(c *Claim) error {
if err := RequireOp(opID)(c); err != nil {
diff --git a/internal/hostclaim/create_selection.go b/internal/hostclaim/create_selection.go
index 6627ec26d..05b840c7f 100644
--- a/internal/hostclaim/create_selection.go
+++ b/internal/hostclaim/create_selection.go
@@ -10,12 +10,10 @@ func BeginSelectedCreate(nh NewHolder, body []byte, id, by string, now time.Time
if !validBoxReference(box) || enrollment == "" || appID == "" || epoch == 0 || ownershipEffect == "" || recordEffect == "" || ownershipEffect == recordEffect {
return ErrMalformed
}
- if err := BeginCreate(nh, body, id, by, now)(c); err != nil {
+ if err := BeginCreateOn(nh, body, id, by, now, ExecutorSelection{Box: box, Enrollment: enrollment})(c); err != nil {
return err
}
c.Op.Participants = []string{box}
- c.Op.Intent["selected_executor"] = box
- c.Op.Intent["executor_enrollment"] = enrollment
c.Op.Intent["app_id"] = appID
c.Op.Intent["admission_epoch"] = fmt.Sprint(epoch)
c.Op.Intent["ownership_effect"] = ownershipEffect
diff --git a/internal/hostclaim/create_test.go b/internal/hostclaim/create_test.go
index 3c1ca091f..e12a3bdaf 100644
--- a/internal/hostclaim/create_test.go
+++ b/internal/hostclaim/create_test.go
@@ -276,3 +276,27 @@ func TestCompleteCreateRefusesExecutionAuthority(t *testing.T) {
})
}
}
+
+func TestCreateCompletesOnlyOnTheSelectedExecutor(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ now := time.Date(2026, 10, 4, 1, 0, 1, 0, time.UTC)
+ reg, _ := Read(ctx, s, testHost)
+ sel := ExecutorSelection{Box: "bri-knitting", Enrollment: "enrollment-b"}
+ if _, err := Update(ctx, s, reg, BeginCreateOn(newHolder(), record, "op_c1", "console", now, ExecutorSelection{})); !errors.Is(err, ErrMalformed) {
+ t.Fatalf("create with no selection: %v", err)
+ }
+ o, err := Update(ctx, s, reg, BeginCreateOn(newHolder(), record, "op_c1", "console", now, sel))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if o.Claim.Op.Intent["selected_executor"] != sel.Box || o.Claim.Op.Intent["executor_enrollment"] != sel.Enrollment || o.Claim.Op.Exec != "" {
+ t.Fatalf("intent: %+v", o.Claim.Op)
+ }
+ if _, err := Update(ctx, s, o, CompleteCreate("op_c1", "adi-knitting")); !errors.Is(err, ErrWrongState) {
+ t.Fatalf("completion substituted a box: %v", err)
+ }
+ if o, err = Update(ctx, s, o, CompleteCreate("op_c1", sel.Box)); err != nil || o.Claim.Op.Exec != sel.Box {
+ t.Fatalf("complete: %v %+v", err, o.Claim.Op)
+ }
+}
diff --git a/internal/hostops/backfill.go b/internal/hostops/backfill.go
index 31bc6363c..3cb7989ae 100644
--- a/internal/hostops/backfill.go
+++ b/internal/hostops/backfill.go
@@ -90,6 +90,23 @@ type ProductionAuthority struct {
Signer release.Signer
Evidence hostactors.Evidence
Backfill *Backfill
+ // Executors is create's executor selection (region HA §5.2, L8). The
+ // create door sets it only with CARLOS_REGION_HA on; nil keeps today's
+ // create path (claim Plan 5's selectExecutor), so a claim-enabled
+ // deployment with HA off (every one-box region included) creates
+ // exactly as before.
+ Executors *ExecutorChooser
+}
+
+// SelectExecutor is Authority's create-time selection: choose() once,
+// before the create CAS, when a chooser is configured; otherwise no
+// selection, and the create is today's.
+func (a ProductionAuthority) SelectExecutor(ctx context.Context, obs hostclaim.Observed, r Record) (hostclaim.ExecutorSelection, bool, error) {
+ if a.Executors == nil {
+ return hostclaim.ExecutorSelection{}, false, nil
+ }
+ sel, err := a.Executors.Choose(ctx, obs, r)
+ return sel, err == nil, err
}
func reportKey(reservation, box string) (string, error) {
diff --git a/internal/hostops/backfill_controller.go b/internal/hostops/backfill_controller.go
index 08ebd8567..dcbf750e6 100644
--- a/internal/hostops/backfill_controller.go
+++ b/internal/hostops/backfill_controller.go
@@ -23,6 +23,9 @@ func (a BackfillAuthority) Adoption(ctx context.Context, r Record) (string, stri
}
func (a BackfillAuthority) ClaimBackfill() *Backfill { return a.p.Backfill }
+func (a BackfillAuthority) SelectExecutor(context.Context, hostclaim.Observed, Record) (hostclaim.ExecutorSelection, bool, error) {
+ return hostclaim.ExecutorSelection{}, false, nil // the backfill grants no create (Create refuses)
+}
func (a BackfillAuthority) Create(context.Context, hostclaim.Observed, Record, string) error {
return ErrContract
}
diff --git a/internal/hostops/executor.go b/internal/hostops/executor.go
new file mode 100644
index 000000000..84e22285e
--- /dev/null
+++ b/internal/hostops/executor.go
@@ -0,0 +1,119 @@
+package hostops
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/carlosframework/platform/internal/boxchoice"
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostclaim"
+)
+
+// ErrNoEligibleBox is a create refused because no box in the placement is
+// eligible (region HA §5.2). The console maps it to today's refusal.
+var ErrNoEligibleBox = errors.New("hostops: region has no box that can take this")
+
+// ExecutorChooser is create's executor selection: claim P5-D2's choose()
+// over the complete eligible set (region HA spec 1 §5.2, H12, L8). It is
+// made once, before the create CAS, and recorded in the create intent.
+type ExecutorChooser struct {
+ Directory hostactors.Directory
+ // Edges is one read of every box's edges file with the caller's
+ // liveness verdict per box. An incomplete snapshot selects nothing.
+ Edges func(context.Context) (boxchoice.Snapshot, error)
+ // Loads counts claimed hosts per box and per "acct/app", from the
+ // claim-recovery listing (claim §5.1 step 5). A stale count only
+ // affects balance, never safety.
+ Loads func(context.Context) (map[string]boxchoice.Load, error)
+ SecretsKeyFP string
+ HomeName string
+ Now func() time.Time
+}
+
+// Choose returns the box, and its roster enrollment, that a new host in
+// 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.
+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
+ }
+ 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)
+ if !ok {
+ return hostclaim.ExecutorSelection{}, ErrNoEligibleBox
+ }
+ return hostclaim.ExecutorSelection{Box: best.BoxID, Enrollment: o.Roster.Boxes[best.BoxID].Enrollment}, nil
+}
+
+// Permits reports whether box is in the complete eligible set for a host
+// 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.
+func (x ExecutorChooser) Permits(ctx context.Context, obs hostclaim.Observed, r Record, box string) error {
+ _, cands, err := x.eligible(ctx, obs, r)
+ if err != nil {
+ return err
+ }
+ for _, c := range cands {
+ if c.BoxID == box {
+ return nil
+ }
+ }
+ return fmt.Errorf("%w: %s", ErrNoEligibleBox, box)
+}
+
+// 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) {
+ placement := r.Holder.Region
+ if x.Edges == nil || x.Loads == nil || x.Now == nil || placement == "" {
+ return hostactors.Observed{}, nil, ErrContract
+ }
+ o, err := x.Directory.Read(ctx)
+ if err != nil {
+ return hostactors.Observed{}, nil, err
+ }
+ region, ok := o.Roster.Regions[placement]
+ if !ok || region.AWSRegion == "" {
+ return hostactors.Observed{}, nil, ErrContract
+ }
+ roster := map[string]boxchoice.RosterBox{}
+ for ref, b := range o.Roster.Boxes {
+ if b.State != "active" || b.Region != placement {
+ continue
+ }
+ fenced, err := x.Directory.TerminationFence(ctx, o, ref, x.Now())
+ if err != nil {
+ return hostactors.Observed{}, nil, err // C6: a read error never means "not fenced"
+ }
+ if !fenced {
+ roster[ref] = boxchoice.RosterBox{Placement: b.Region, Cloud: b.Cloud}
+ }
+ }
+ snap, err := x.Edges(ctx)
+ if err != nil {
+ return hostactors.Observed{}, nil, err
+ }
+ // Never a box on the claim's lost list, nor one that still owes a
+ // park-local chore for this host (spec 1 §2: its old installation is
+ // not yet retired).
+ excluded := map[string]bool{}
+ if !obs.Absent && obs.Claim != nil {
+ for _, box := range obs.Claim.Lost {
+ excluded[box] = true
+ }
+ for _, box := range hostclaim.LocalChoreBoxes(obs.Claim) {
+ excluded[box] = true
+ }
+ }
+ return o, boxchoice.Eligible(snap, roster, placement, region.AWSRegion, x.HomeName, x.SecretsKeyFP, excluded), nil
+}
diff --git a/internal/hostops/executor_test.go b/internal/hostops/executor_test.go
new file mode 100644
index 000000000..ad5243aa8
--- /dev/null
+++ b/internal/hostops/executor_test.go
@@ -0,0 +1,310 @@
+package hostops
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/boxchoice"
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/termfence"
+)
+
+func chooserFixture(t *testing.T, boxes ...string) (ExecutorChooser, release.Store, *boxchoice.Snapshot, map[string]boxchoice.Load) {
+ t.Helper()
+ s := release.NewDirStore(t.TempDir())
+ pub, key, err := ed25519.GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ roster := hostactors.Roster{V: 1, DeploymentBucket: "bucket", Revision: 1,
+ Regions: map[string]hostactors.Region{"ie": {Account: "123", AWSRegion: "eu-west-1"}},
+ Boxes: map[string]hostactors.Box{}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}}
+ snap := &boxchoice.Snapshot{Complete: true}
+ yes := true
+ for i, ref := range boxes {
+ 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}})
+ }
+ wire, err := release.SignRootEnvelope(release.RootSigner(key), roster)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Put(context.Background(), hostactors.Key, wire, ""); err != nil {
+ t.Fatal(err)
+ }
+ loads := map[string]boxchoice.Load{}
+ x := ExecutorChooser{Directory: hostactors.Directory{Store: s, PublicKey: pub, Bucket: "bucket"}, SecretsKeyFP: "fp",
+ Edges: func(context.Context) (boxchoice.Snapshot, error) { return *snap, nil },
+ Loads: func(context.Context) (map[string]boxchoice.Load, error) { return loads, nil },
+ Now: func() time.Time { return time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC) }}
+ return x, s, snap, loads
+}
+
+func chooserRecord() Record {
+ return Record{Holder: hostclaim.NewHolder{Family: "instance", Account: "bab", App: "jam", Host: "shop.bab.oncarlos.com", Region: "ie"}}
+}
+
+func TestExecutorChooserSpreadsThenBalancesThenHashes(t *testing.T) {
+ ctx := context.Background()
+ x, _, _, 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 {
+ t.Fatal(err)
+ }
+ other := map[string]string{"box-a": "box-b", "box-b": "box-a"}[first.Box]
+ if first.Enrollment != map[string]string{"box-a": strings.Repeat("a", 32), "box-b": strings.Repeat("b", 32)}[first.Box] {
+ t.Fatalf("enrollment not the roster's: %+v", first)
+ }
+ // The hash winner already runs this app: spread prefers the other box.
+ loads[first.Box] = boxchoice.Load{Hosts: 1, Apps: map[string]int{"bab/jam": 1}}
+ 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.
+ 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)
+ }
+}
+
+// Round 18 finding 2: with equal loads the winner is the greatest
+// SHA-256 of host + "\x00" + box id (claim P5-D2), computed here
+// independently of boxchoice. The two hostnames are chosen so that their
+// winners differ: a chooser that hashed anything but the record's host
+// would get at least one of them wrong.
+func TestExecutorChooserHashesTheRecordsHostOnEqualLoads(t *testing.T) {
+ ctx := context.Background()
+ winner := func(host string) string {
+ a := sha256.Sum256([]byte(host + "\x00box-a"))
+ b := sha256.Sum256([]byte(host + "\x00box-b"))
+ if hex.EncodeToString(a[:]) > hex.EncodeToString(b[:]) {
+ return "box-a"
+ }
+ return "box-b"
+ }
+ hosts := []string{"shop.bab.oncarlos.com", "cafe.bab.oncarlos.com"}
+ if winner(hosts[0]) == winner(hosts[1]) {
+ t.Fatal("the fixture's hostnames must choose opposite boxes")
+ }
+ for _, host := range hosts {
+ x, _, _, _ := chooserFixture(t, "box-a", "box-b")
+ r := chooserRecord()
+ r.Holder.Host = host
+ got, err := x.Choose(ctx, hostclaim.Observed{Host: host, Absent: true}, r)
+ if err != nil || got.Box != winner(host) {
+ t.Fatalf("%s: chose %+v %v, want %s", host, got, err, winner(host))
+ }
+ }
+}
+
+func TestExecutorChooserNeverNamesAFencedLostOrUnknownBox(t *testing.T) {
+ ctx := context.Background()
+ absent := hostclaim.Observed{Host: "shop.bab.oncarlos.com", Absent: true}
+ t.Run("lost", func(t *testing.T) {
+ x, _, _, _ := chooserFixture(t, "box-a")
+ obs := hostclaim.Observed{Host: "shop.bab.oncarlos.com", Claim: &hostclaim.Claim{Lost: []string{"box-a"}}}
+ if _, err := x.Choose(ctx, obs, chooserRecord()); !errors.Is(err, ErrNoEligibleBox) {
+ t.Fatalf("chose a lost box: %v", err)
+ }
+ })
+ t.Run("owing a local retirement", func(t *testing.T) {
+ // Spec 1 §2: a box whose old installation of the host is not yet
+ // retired (park-local) is never chosen; the clean one is.
+ x, _, _, _ := chooserFixture(t, "box-a", "box-b")
+ obs := hostclaim.Observed{Host: "shop.bab.oncarlos.com", Claim: &hostclaim.Claim{Obligations: []hostclaim.Obligation{{ID: "old", Op: "old-op", Kind: "park-local", Box: "box-a", Retirement: "r", Owner: "box:box-a"}}}}
+ for range 4 {
+ if got, err := x.Choose(ctx, obs, chooserRecord()); err != nil || got.Box != "box-b" {
+ t.Fatalf("got %+v %v, want the box with no chore", got, err)
+ }
+ }
+ if err := x.Permits(ctx, obs, chooserRecord(), "box-a"); !errors.Is(err, ErrNoEligibleBox) {
+ t.Fatalf("permitted a box owing park-local: %v", err)
+ }
+ })
+ t.Run("fenced", func(t *testing.T) {
+ x, s, _, _ := chooserFixture(t, "box-a", "box-w")
+ f := termfence.Record{V: 1, Box: "box-a", Enrollment: strings.Repeat("a", 32), State: "terminated",
+ Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-box-a"},
+ ObservedAt: "2026-10-07T08:00:00Z", Via: termfence.ViaDescribe, RequestID: "req",
+ WrittenBy: termfence.WrittenBy{Box: "box-w", Enrollment: strings.Repeat("b", 32)}}
+ raw, _ := json.Marshal(f)
+ if _, err := s.Put(ctx, "control/box-fences/"+strings.Repeat("a", 32)+".json", raw, ""); err != nil {
+ t.Fatal(err)
+ }
+ got, err := x.Choose(ctx, absent, chooserRecord())
+ if err != nil || got.Box != "box-w" {
+ t.Fatalf("got %+v %v, want the unfenced box", got, err)
+ }
+ })
+ t.Run("an edges file for a box the roster does not hold", func(t *testing.T) {
+ x, _, snap, _ := chooserFixture(t)
+ yes := true
+ snap.Boxes = append(snap.Boxes, boxchoice.Box{BoxID: "stray", EIP: "203.0.113.9", Region: "eu-west-1", Placement: "ie", Fresh: true,
+ HibernationConfigured: &yes, ServesSharedPool: &yes, AcceptsInbound: &yes, SecretsKeyFP: "fp", Capabilities: []string{boxchoice.Capability}})
+ if _, err := x.Choose(ctx, absent, chooserRecord()); !errors.Is(err, ErrNoEligibleBox) {
+ t.Fatalf("chose an unenrolled box: %v", err)
+ }
+ })
+ t.Run("an incomplete snapshot", func(t *testing.T) {
+ x, _, snap, _ := chooserFixture(t, "box-a")
+ snap.Complete = false
+ if _, err := x.Choose(ctx, absent, chooserRecord()); !errors.Is(err, ErrNoEligibleBox) {
+ t.Fatalf("chose from a partial read: %v", err)
+ }
+ })
+ t.Run("an unreadable fence", func(t *testing.T) {
+ x, s, _, _ := chooserFixture(t, "box-a")
+ if _, err := s.Put(ctx, "control/box-fences/"+strings.Repeat("a", 32)+".json", []byte("{"), ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := x.Choose(ctx, absent, chooserRecord()); err == nil || errors.Is(err, ErrNoEligibleBox) {
+ t.Fatalf("an unreadable fence must refuse, not skip: %v", err)
+ }
+ })
+}
+
+func TestExecutorChooserPermitsOnlyTheEligibleSet(t *testing.T) {
+ ctx := context.Background()
+ absent := hostclaim.Observed{Host: "shop.bab.oncarlos.com", Absent: true}
+ for _, tc := range []struct {
+ name string
+ change func(*boxchoice.Box)
+ }{
+ {"a stale edges file", func(b *boxchoice.Box) { b.Fresh = false }},
+ {"no region-ha-1 capability", func(b *boxchoice.Box) { b.Capabilities = nil }},
+ {"another secrets key", func(b *boxchoice.Box) { b.SecretsKeyFP = "other" }},
+ {"a draining box", func(b *boxchoice.Box) { b.Draining = true }},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ x, _, snap, _ := chooserFixture(t, "box-a", "box-b")
+ if err := x.Permits(ctx, absent, chooserRecord(), "box-a"); err != nil {
+ t.Fatal(err)
+ }
+ tc.change(&snap.Boxes[0])
+ if err := x.Permits(ctx, absent, chooserRecord(), "box-a"); !errors.Is(err, ErrNoEligibleBox) {
+ t.Fatalf("permitted: %v", err)
+ }
+ if err := x.Permits(ctx, absent, chooserRecord(), "box-b"); err != nil {
+ t.Fatalf("the other box: %v", err)
+ }
+ })
+ }
+ t.Run("an unknown box", func(t *testing.T) {
+ x, _, _, _ := chooserFixture(t, "box-a")
+ if err := x.Permits(ctx, absent, chooserRecord(), "box-z"); !errors.Is(err, ErrNoEligibleBox) {
+ t.Fatalf("permitted: %v", err)
+ }
+ })
+}
+
+func TestClaimLoadsCountsExecutorsPerBoxAndApp(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ created := time.Date(2026, 10, 4, 1, 0, 0, 0, time.UTC)
+ for i, tc := range []struct{ host, app, box string }{
+ {"a.bab.oncarlos.com", "jam", "box-a"}, {"b.bab.oncarlos.com", "jam", "box-a"}, {"c.bab.oncarlos.com", "tea", "box-b"},
+ } {
+ nh := hostclaim.NewHolder{Family: "instance", Key: "apps/bab/" + tc.app + "/instances/" + tc.host + ".json", Account: "bab", App: tc.app, Host: tc.host, Region: "ie", RecordRegion: "ie", CreatedAt: created}
+ body := []byte(`{"host":"` + tc.host + `","account":"bab","app":"` + tc.app + `","region":"ie","created_at":"2026-10-04T01:00:00Z"}`)
+ o, _ := hostclaim.Read(ctx, s, tc.host)
+ o, err := hostclaim.Update(ctx, s, o, hostclaim.BeginCreateOn(nh, body, "c", "console", created, hostclaim.ExecutorSelection{Box: tc.box, Enrollment: "e"}))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if i < 2 { // the third is still creating: it has no executor yet
+ if _, err := hostclaim.Update(ctx, s, o, hostclaim.CompleteCreate("c", tc.box)); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ if _, err := s.Put(ctx, "control/hosts/broken.bab.oncarlos.com.json", []byte("{"), ""); err != nil {
+ t.Fatal(err)
+ }
+ loads, err := ClaimLoads(ctx, s)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if loads["box-a"].Hosts != 2 || loads["box-a"].Apps["bab/jam"] != 2 || loads["box-b"].Hosts != 0 {
+ t.Fatalf("loads: %+v", loads)
+ }
+}
+
+func TestProductionAuthoritySelectsOnlyWithAChooser(t *testing.T) {
+ // No chooser (CARLOS_REGION_HA off): no selection and no error, so the
+ // create is today's.
+ sel, selected, err := (ProductionAuthority{}).SelectExecutor(context.Background(), hostclaim.Observed{}, Record{})
+ if err != nil || selected || sel != (hostclaim.ExecutorSelection{}) {
+ t.Fatalf("no chooser: %+v %v %v", sel, selected, err)
+ }
+ // With a chooser the selection is the chooser's, read from its
+ // evidence: box-a is draining, so box-b with its roster enrollment;
+ // with both draining, no box and an error, never a selection.
+ x, _, snap, _ := chooserFixture(t, "box-a", "box-b")
+ snap.Boxes[0].Draining = true
+ absent := hostclaim.Observed{Host: "shop.bab.oncarlos.com", Absent: true}
+ sel, selected, err = (ProductionAuthority{Executors: &x}).SelectExecutor(context.Background(), absent, chooserRecord())
+ if err != nil || !selected || sel.Box != "box-b" || sel.Enrollment != strings.Repeat("b", 32) {
+ t.Fatalf("with a chooser: %+v %v %v", sel, selected, err)
+ }
+ snap.Boxes[1].Draining = true
+ if sel, selected, err = (ProductionAuthority{Executors: &x}).SelectExecutor(context.Background(), absent, chooserRecord()); !errors.Is(err, ErrNoEligibleBox) || selected {
+ t.Fatalf("no eligible box: %+v %v %v", sel, selected, err)
+ }
+}
+
+// Main's production create (claim Plan 5's StartCreate) selects through
+// selectExecutor. Without a chooser it is Plan 5's choice over the roster
+// alone; with one the choice is the chooser's, read from the edges
+// evidence, and Plan 5's scope (this console's namespace, the record's
+// pool) still binds it: a chosen box outside that scope refuses.
+func TestProductionCreateSelectsThroughTheChooser(t *testing.T) {
+ ctx := context.Background()
+ x, s, snap, _ := chooserFixture(t, "box-a", "box-b")
+ r := chooserRecord()
+ r.Body = []byte(`{"host":"shop.bab.oncarlos.com","account":"bab","app":"jam","region":"ie"}`)
+ absent := hostclaim.Observed{Host: r.Holder.Host, Absent: true}
+ a := ProductionAuthority{Store: s, Directory: x.Directory, Namespace: "platform", Now: x.Now}
+ off, _, pool, err := a.selectExecutor(ctx, absent, r)
+ if err != nil || pool != "default" {
+ t.Fatalf("no chooser: %s %s %v", off, pool, err)
+ }
+ // The roster-only choice is draining: Plan 5 does not read the edges,
+ // so it still names it; the chooser names the other box.
+ other := map[string]string{"box-a": "box-b", "box-b": "box-a"}[off]
+ for i := range snap.Boxes {
+ snap.Boxes[i].Draining = snap.Boxes[i].BoxID == off
+ }
+ if again, _, _, err := a.selectExecutor(ctx, absent, r); err != nil || again != off {
+ t.Fatalf("no chooser, after a drain: %s %v, want %s", again, err, off)
+ }
+ a.Executors = &x
+ box, enrollment, pool, err := a.selectExecutor(ctx, absent, r)
+ if err != nil || box != other || enrollment != strings.Repeat(map[string]string{"box-a": "a", "box-b": "b"}[other], 32) || pool != "default" {
+ t.Fatalf("with a chooser: %s %s %s %v, want %s", box, enrollment, pool, err, other)
+ }
+ // Another console namespace: the chooser still names a box, but not
+ // one Plan 5's scope holds.
+ a.Namespace = "elsewhere"
+ if box, _, _, err := a.selectExecutor(ctx, absent, r); !errors.Is(err, ErrNoEligibleBox) {
+ t.Fatalf("a box outside the namespace: %s %v", box, err)
+ }
+ // Every box draining: the chooser's refusal, never Plan 5's fallback.
+ a.Namespace = "platform"
+ snap.Boxes[0].Draining, snap.Boxes[1].Draining = true, true
+ if box, _, _, err := a.selectExecutor(ctx, absent, r); !errors.Is(err, ErrNoEligibleBox) {
+ t.Fatalf("no eligible box: %s %v", box, err)
+ }
+}
diff --git a/internal/hostops/loads.go b/internal/hostops/loads.go
new file mode 100644
index 000000000..d8eace47d
--- /dev/null
+++ b/internal/hostops/loads.go
@@ -0,0 +1,43 @@
+package hostops
+
+import (
+ "context"
+ "log"
+
+ "github.com/carlosframework/platform/internal/boxchoice"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// ClaimLoads counts, from one listing of control/hosts/ (ListClaims), the
+// hosts whose claim names each box as op.exec, in total and per
+// "acct/app": choose()'s second rule until spec 2 (region HA §5.2, claim
+// P5-D2). The count only balances, so a claim that cannot be read is
+// logged and left out; a listing that fails is an error, because a count
+// of nothing would send every create to one box.
+func ClaimLoads(ctx context.Context, s release.Store) (map[string]boxchoice.Load, error) {
+ hosts, err := ListClaims(ctx, s)
+ if err != nil {
+ return nil, err
+ }
+ out := map[string]boxchoice.Load{}
+ for _, host := range hosts {
+ obs, err := hostclaim.Read(ctx, s, host)
+ if err != nil {
+ log.Printf("hostops: claim loads: %s: %v", host, err)
+ continue
+ }
+ if obs.Absent || obs.Claim.Holder == nil || obs.Claim.Op.Exec == "" || obs.Claim.Op.Exec == "none" {
+ continue
+ }
+ box, h := obs.Claim.Op.Exec, obs.Claim.Holder
+ l := out[box]
+ if l.Apps == nil {
+ l.Apps = map[string]int{}
+ }
+ l.Hosts++
+ l.Apps[h.Account+"/"+h.App]++
+ out[box] = l
+ }
+ return out, nil
+}
diff --git a/internal/hostops/operations.go b/internal/hostops/operations.go
index e6bbf103a..e7ea1c824 100644
--- a/internal/hostops/operations.go
+++ b/internal/hostops/operations.go
@@ -14,6 +14,13 @@ import (
// These callbacks are authority providers, not optional best-effort hints.
// Production construction waits for the decisions named in this plan.
type Authority interface {
+ // SelectExecutor chooses the new host's executor once, before the
+ // create CAS (claim P5-D2, region HA §5.2). selected false means this
+ // deployment does not choose at create (CARLOS_REGION_HA off): the
+ // create proceeds exactly as today, and Executor names the box at
+ // completion. Production is ExecutorChooser.Choose behind that switch,
+ // consulted inside StartCreate's own selection.
+ SelectExecutor(context.Context, hostclaim.Observed, Record) (sel hostclaim.ExecutorSelection, selected bool, err error)
Create(context.Context, hostclaim.Observed, Record, string) error
Executor(context.Context, hostclaim.Observed, Record) (string, error)
StartDelete(context.Context, hostclaim.Observed, Record) error
@@ -49,21 +56,36 @@ func (c Controller) Attempt(ctx context.Context, before hostclaim.Observed, r Re
if before.Host != r.Holder.Host || r.Holder.CreatedAt.IsZero() || id == "" {
return hostclaim.Observed{}, ErrIdentity
}
+ // With region HA on, the executor is selected once, before the CAS,
+ // and recorded in it; off, the create is today's. The production
+ // authority makes that one selection inside StartCreate.
+ production, isProduction := c.Authority.(productionCreateAuthority)
+ var sel hostclaim.ExecutorSelection
+ var selected bool
+ if !isProduction {
+ var err error
+ if sel, selected, err = c.Authority.SelectExecutor(ctx, before, r); err != nil {
+ return hostclaim.Observed{}, err
+ }
+ }
// Last pre-CAS read of registration, open admission and purge reservation.
if err := c.Authority.Create(ctx, before, r, id); err != nil {
return hostclaim.Observed{}, err
}
var obs hostclaim.Observed
var err error
- if production, ok := c.Authority.(productionCreateAuthority); ok {
+ switch {
+ case isProduction:
obs, err = production.StartCreate(ctx, before, r, id, by)
- } else {
+ case selected:
+ obs, err = hostclaim.Update(ctx, c.Store, before, hostclaim.BeginCreateOn(r.Holder, r.Body, id, by, c.Now(), sel))
+ default:
obs, err = hostclaim.Update(ctx, c.Store, before, hostclaim.BeginCreate(r.Holder, r.Body, id, by, c.Now()))
}
if err != nil {
return hostclaim.Observed{}, err
}
- if production, ok := c.Authority.(productionCreateAuthority); ok {
+ if isProduction {
if err := production.RecordEffects(ctx, obs, r); err != nil {
return obs, err
}
diff --git a/internal/hostops/operations_test.go b/internal/hostops/operations_test.go
index 284704d9f..b1ee10380 100644
--- a/internal/hostops/operations_test.go
+++ b/internal/hostops/operations_test.go
@@ -15,10 +15,32 @@ import (
"github.com/carlosframework/platform/internal/release"
)
-type authority struct{ err error }
+type authority struct {
+ err error
+ // selects is region HA's create-time choice; false is today's create.
+ // selected is the box it chooses (box-a when empty); executor is the
+ // box completion offers (box-a when empty); selections counts calls.
+ selects bool
+ selected, executor string
+ selections int
+}
+func (a *authority) SelectExecutor(context.Context, hostclaim.Observed, Record) (hostclaim.ExecutorSelection, bool, error) {
+ a.selections++
+ if !a.selects {
+ return hostclaim.ExecutorSelection{}, false, a.err
+ }
+ box := a.selected
+ if box == "" {
+ box = "box-a"
+ }
+ return hostclaim.ExecutorSelection{Box: box, Enrollment: "enrollment-" + box[len("box-"):]}, true, a.err
+}
func (a *authority) Create(context.Context, hostclaim.Observed, Record, string) error { return a.err }
func (a *authority) Executor(context.Context, hostclaim.Observed, Record) (string, error) {
+ if a.executor != "" {
+ return a.executor, a.err
+ }
return "box-a", a.err
}
func (a *authority) StartDelete(context.Context, hostclaim.Observed, Record) error { return a.err }
@@ -138,6 +160,47 @@ func TestAttemptUsesPreRegistrationObservation(t *testing.T) {
t.Fatal("deleted claim")
}
}
+
+// TestCreateIsTodaysWithoutASelection pins dormancy (region HA §8 step 2):
+// with no create-time choice the claim carries no selected executor and
+// completion takes the box Executor names. With one, Attempt itself
+// records the selection in the create CAS (round 9 finding 2): selection
+// chooses box-b while completion offers box-a, so the completion refuses,
+// the persisted intent names box-b and its enrollment, and a completion
+// with box-b serves without selecting again.
+func TestCreateIsTodaysWithoutASelection(t *testing.T) {
+ f := newFixture(t)
+ obs := f.serving(t, "off.bab.example.com")
+ if obs.Claim.Op.Exec != "box-a" || obs.Claim.Op.Intent["selected_executor"] != "" || obs.Claim.Op.Intent["executor_enrollment"] != "" {
+ t.Fatalf("HA off: %+v", obs.Claim.Op)
+ }
+ f.auth.selects, f.auth.selected, f.auth.selections = true, "box-b", 0
+ const host = "on.bab.example.com"
+ before, err := hostclaim.Read(f.ctx, f.store, host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ id := f.c.NewID()
+ if _, err := f.c.Attempt(f.ctx, before, f.record(t, host), id, "test"); !errors.Is(err, hostclaim.ErrWrongState) {
+ t.Fatalf("completion substituted box-a for the selected box-b: %v", err)
+ }
+ pending, err := hostclaim.Read(f.ctx, f.store, host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if pending.Claim.Op.Kind != hostclaim.KindCreating || pending.Claim.Op.Intent["selected_executor"] != "box-b" || pending.Claim.Op.Intent["executor_enrollment"] != "enrollment-b" {
+ t.Fatalf("HA on, the persisted intent: %+v", pending.Claim.Op)
+ }
+ f.auth.executor = "box-b"
+ done, err := f.c.Complete(f.ctx, host, id)
+ if err != nil || done.Claim.Op.Kind != hostclaim.KindServing || done.Claim.Op.Exec != "box-b" {
+ t.Fatalf("completion with the selected box: %+v %v", done.Claim, err)
+ }
+ if f.auth.selections != 1 {
+ t.Fatalf("selection ran %d times, want once", f.auth.selections)
+ }
+}
+
func TestRecoverCreateAbsentAndLateOrphan(t *testing.T) {
f := newFixture(t)
host := "shop.bab.example.com"
diff --git a/internal/hostops/production_create.go b/internal/hostops/production_create.go
index 39631761c..1b56841b8 100644
--- a/internal/hostops/production_create.go
+++ b/internal/hostops/production_create.go
@@ -7,6 +7,7 @@ import (
"encoding/hex"
"encoding/json"
"errors"
+ "fmt"
"os"
"sort"
"strconv"
@@ -94,7 +95,12 @@ type executorCandidate struct {
hosts int
}
-func (a ProductionAuthority) selectExecutor(ctx context.Context, r Record) (string, string, string, error) {
+// selectExecutor is claim Plan 5's create-time selection over this
+// console's namespace, the record's region and pool, active and unfenced.
+// With a chooser (ProductionAuthority.Executors, CARLOS_REGION_HA on) the
+// one choice is the chooser's, over its edges evidence; this scope still
+// binds it, so a chosen box outside it refuses the create.
+func (a ProductionAuthority) selectExecutor(ctx context.Context, before hostclaim.Observed, r Record) (string, string, string, error) {
var shape struct {
Pool string `json:"pool"`
Replicas []string `json:"replicas"`
@@ -143,6 +149,14 @@ func (a ProductionAuthority) selectExecutor(ctx context.Context, r Record) (stri
if everywhere && servingScope != 1 {
return "", "", "", ErrContract
}
+ if sel, selected, err := a.SelectExecutor(ctx, before, r); err != nil {
+ return "", "", "", err
+ } else if selected {
+ if c := candidates[sel.Box]; c == nil || c.enrollment != sel.Enrollment {
+ return "", "", "", fmt.Errorf("%w: %s is not in this console's namespace and the record's pool", ErrNoEligibleBox, sel.Box)
+ }
+ return sel.Box, sel.Enrollment, pool, nil
+ }
hosts, err := ListClaims(ctx, a.Store)
if err != nil {
return "", "", "", err
@@ -193,7 +207,7 @@ func (a ProductionAuthority) selectExecutor(ctx context.Context, r Record) (stri
}
func (a ProductionAuthority) StartCreate(ctx context.Context, before hostclaim.Observed, r Record, id, by string) (hostclaim.Observed, error) {
- box, enrollment, pool, err := a.selectExecutor(ctx, r)
+ box, enrollment, pool, err := a.selectExecutor(ctx, before, r)
if err != nil {
return hostclaim.Observed{}, err
}