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>
16 files changed,
+825
−9
cmd/console/main.go+9 −0cmd/console/regionha_test.go+21 −0internal/console/hostclaimexecutor.go+33 −0internal/console/hostclaimexecutor_test.go+109 −0internal/console/server.go+5 −0internal/hostclaim/create.go+26 −0internal/hostclaim/create_selection.go+1 −3internal/hostclaim/create_test.go+24 −0internal/hostops/backfill.go+17 −0internal/hostops/backfill_controller.go+3 −0internal/hostops/executor.go+119 −0internal/hostops/executor_test.go+310 −0internal/hostops/loads.go+43 −0internal/hostops/operations.go+25 −3internal/hostops/operations_test.go+64 −1internal/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 |
| } |