boxchoice: box eligibility and the executor choice are an exported package that console and moves share, with console's behaviour unchanged
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+503
−236
internal/boxchoice/boxchoice.go+186 −0internal/boxchoice/boxchoice_test.go+261 −0internal/console/boxchoice.go+30 −106internal/console/boxchoice_test.go+26 −130
diff --git a/internal/boxchoice/boxchoice.go b/internal/boxchoice/boxchoice.go| new file mode 100644 |
| index 000000000..7308877e7 |
| --- /dev/null |
| +++ b/internal/boxchoice/boxchoice.go |
| @@ -0,0 +1,186 @@ |
| +// Package boxchoice decides which boxes in a placement may run a host as |
| +// its executor, and which of them a new claim should name (region HA spec |
| +// 1 §2 and §5.2). It is pure: callers read the edges files, the box status |
| +// snapshots and the roster, and pass what they read. |
| +// |
| +// Console's placement door and moves' door both call it, so the two |
| +// always agree on what "eligible" means. |
| +package boxchoice |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "slices" |
| + "sort" |
| + |
| + "github.com/carlosframework/platform/internal/boxid" |
| +) |
| + |
| +// Capability is the capability an edges file lists when its box can be a |
| +// region HA executor. |
| +const Capability = "region-ha-1" |
| + |
| +// Headroom is what a box reports it has free. |
| +type Headroom struct { |
| + MemFreeMiB int64 `json:"mem_free_mib"` |
| + DiskFreeMiB int64 `json:"disk_free_mib"` |
| + Awake int `json:"awake"` |
| +} |
| + |
| +// Box is one box's edges file (control/edges/<box>.json, written by |
| +// internal/host/edgesfile.go), reduced to what eligibility reads, plus |
| +// Fresh, which is not on the wire. The JSON tags are the edges file's, so |
| +// a caller may unmarshal a file straight into a Box. |
| +// |
| +// The *bool fields are pointers because absent and false are different |
| +// facts: absent is a writer that predates the field, and is not eligible. |
| +type Box struct { |
| + Box string `json:"box"` |
| + BoxID string `json:"box_id"` |
| + EIP string `json:"eip"` |
| + Region string `json:"region"` |
| + Placement string `json:"placement"` |
| + HibernationConfigured *bool `json:"hibernation_configured"` |
| + ServesSharedPool *bool `json:"serves_shared_pool"` |
| + AcceptsInbound *bool `json:"accepts_inbound"` |
| + SecretsKeyFP string `json:"secrets_key_fp"` |
| + Capabilities []string `json:"capabilities"` |
| + Draining bool `json:"draining"` |
| + Headroom *Headroom `json:"headroom"` |
| + // Fresh is the caller's verdict that the box's own status snapshot is |
| + // still fresh. An edges file outlives its box, so the file alone is |
| + // not liveness. |
| + Fresh bool `json:"-"` |
| +} |
| + |
| +// Snapshot is one read of every box. Complete is true only when every |
| +// listed edges file was read and parsed and every box's liveness is known |
| +// (each live box has a file and each file's Fresh is a known answer). An |
| +// incomplete snapshot makes nothing eligible: an unread file could be the |
| +// one box that would change the answer. |
| +type Snapshot struct { |
| + Boxes []Box |
| + Complete bool |
| +} |
| + |
| +// RosterBox is what the roster (control/host-actors.json, claim D7) says |
| +// about one active box: the placement it is enrolled in and the cloud |
| +// identity enrollment proved from its boxid record (spec §5.1, H11). The |
| +// caller passes active entries only, keyed by box id. |
| +type RosterBox struct { |
| + Placement string |
| + Cloud boxid.Cloud |
| +} |
| + |
| +// Candidate is one box that may be named as a host's executor (region HA |
| +// spec 1 §2, "eligible box"). |
| +type Candidate struct { |
| + BoxID string |
| + Region string |
| + MemFreeMiB, DiskFreeMiB int64 |
| + Awake int |
| +} |
| + |
| +// Load is what claims already place on a box. |
| +type Load struct { |
| + Hosts int |
| + Apps map[string]int // "acct/app" -> hosts of that app |
| +} |
| + |
| +func isTrue(b *bool) bool { return b != nil && *b } |
| + |
| +// Eligible returns the boxes that may run a host placed in placement, |
| +// sorted by box id. A box qualifies on its own evidence alone: it is |
| +// fresh, has a dial address and a box id, and has a roster entry for that |
| +// box id with a complete cloud identity, enrolled in this placement, whose |
| +// proven AWS region is awsRegion (the region the roster records for this |
| +// placement), as its file's region also is. Its file says hibernation is |
| +// configured, it serves the shared pool and accepts inbound, its secrets |
| +// key fingerprint is expectedFP, and it has Capability. It is not |
| +// draining and not in lost. Two boxes that each satisfy half of this never |
| +// add up to one. Identity and region come from the roster; the edges file |
| +// is evidence of liveness and configuration only (spec §5.1). |
| +// |
| +// homeName is the home placement's code: a box whose file or roster entry |
| +// names it is the home placement, as is the empty code. |
| +// |
| +// An incomplete snapshot, an empty expectedFP or an empty awsRegion |
| +// returns nothing. |
| +func Eligible(snap Snapshot, roster map[string]RosterBox, placement, awsRegion, homeName, expectedFP string, lost map[string]bool) []Candidate { |
| + if !snap.Complete || expectedFP == "" || awsRegion == "" { |
| + return nil |
| + } |
| + home := func(code string) string { |
| + if homeName != "" && code == homeName { |
| + return "" |
| + } |
| + return code |
| + } |
| + var out []Candidate |
| + for _, f := range snap.Boxes { |
| + want := home(placement) |
| + rb, ok := roster[f.BoxID] |
| + switch { |
| + case home(f.Placement) != want: |
| + case !f.Fresh, f.EIP == "", f.BoxID == "": |
| + case !ok || !rb.Cloud.Complete(): |
| + case home(rb.Placement) != want: |
| + // The file's placement is the box's own word; the roster's is |
| + // the enrollment's. Both must agree. |
| + case rb.Cloud.Region != awsRegion, f.Region != awsRegion: |
| + // The AWS region was proven at enrollment: a box enrolled in |
| + // another region is not in this placement, whatever its file |
| + // says. |
| + case !isTrue(f.HibernationConfigured), !isTrue(f.ServesSharedPool), !isTrue(f.AcceptsInbound): |
| + case f.SecretsKeyFP != expectedFP: |
| + case !slices.Contains(f.Capabilities, Capability): |
| + case f.Draining, lost[f.BoxID]: |
| + default: |
| + c := Candidate{BoxID: f.BoxID, Region: rb.Cloud.Region} |
| + if f.Headroom != nil { |
| + c.MemFreeMiB, c.DiskFreeMiB, c.Awake = f.Headroom.MemFreeMiB, f.Headroom.DiskFreeMiB, f.Headroom.Awake |
| + } |
| + out = append(out, c) |
| + } |
| + } |
| + sort.Slice(out, func(i, j int) bool { return out[i].BoxID < out[j].BoxID }) |
| + return out |
| +} |
| + |
| +// Choose is spec 1 §5.2's choose() until spec 2 (H12): prefer a box that |
| +// runs no instance of app (soft spread: it never refuses), then the fewest |
| +// hosts overall, then the greatest hexadecimal SHA-256 of |
| +// host + "\x00" + box id (claim P5-D2), then the smallest box id if two |
| +// hashes are equal. The answer does not depend on the order of cands. The |
| +// bool is false only when cands is empty. |
| +func Choose(host string, cands []Candidate, running map[string]Load, app string) (Candidate, bool) { |
| + if len(cands) == 0 { |
| + return Candidate{}, false |
| + } |
| + tie := func(ref string) string { |
| + sum := sha256.Sum256([]byte(host + "\x00" + ref)) |
| + return hex.EncodeToString(sum[:]) |
| + } |
| + best := cands[0] |
| + for _, c := range cands[1:] { |
| + cl, bl := running[c.BoxID], running[best.BoxID] |
| + cRuns, bRuns := cl.Apps[app] > 0, bl.Apps[app] > 0 |
| + switch { |
| + case cRuns != bRuns: |
| + if !cRuns { |
| + best = c |
| + } |
| + case cl.Hosts != bl.Hosts: |
| + if cl.Hosts < bl.Hosts { |
| + best = c |
| + } |
| + case tie(c.BoxID) != tie(best.BoxID): |
| + if tie(c.BoxID) > tie(best.BoxID) { |
| + best = c |
| + } |
| + case c.BoxID < best.BoxID: |
| + best = c |
| + } |
| + } |
| + return best, true |
| +} |
diff --git a/internal/boxchoice/boxchoice_test.go b/internal/boxchoice/boxchoice_test.go| new file mode 100644 |
| index 000000000..7041ee6ff |
| --- /dev/null |
| +++ b/internal/boxchoice/boxchoice_test.go |
| @@ -0,0 +1,261 @@ |
| +package boxchoice |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/boxid" |
| +) |
| + |
| +const testKeyFP = "sha256:test-key" |
| + |
| +// haBox is an eligible box's edges file in placement "uk", as JSON, so the |
| +// tests also hold Box's tags to the edges file's. |
| +func haBox(id string, mut func(m map[string]any)) string { |
| + m := map[string]any{ |
| + "v": 1, "box": id, "box_id": id, "eip": "1.2.3.4", "region": "eu-west-2", "placement": "uk", |
| + "hosts": map[string]any{}, "hibernation_configured": true, "serves_shared_pool": true, |
| + "accepts_inbound": true, "secrets_key_fp": testKeyFP, "capabilities": []string{"region-ha-1"}, |
| + "headroom": map[string]any{"mem_free_mib": 1000, "disk_free_mib": 1000, "awake": 1}, |
| + } |
| + if mut != nil { |
| + mut(m) |
| + } |
| + b, _ := json.Marshal(m) |
| + return string(b) |
| +} |
| + |
| +// views is a complete snapshot of fresh boxes. |
| +func views(t *testing.T, bodies ...string) Snapshot { |
| + t.Helper() |
| + snap := Snapshot{Complete: true} |
| + for _, body := range bodies { |
| + var b Box |
| + if err := json.Unmarshal([]byte(body), &b); err != nil { |
| + t.Fatal(err) |
| + } |
| + b.Fresh = true |
| + snap.Boxes = append(snap.Boxes, b) |
| + } |
| + return snap |
| +} |
| + |
| +func markStale(snap Snapshot, box string) Snapshot { |
| + for i := range snap.Boxes { |
| + if snap.Boxes[i].Box == box { |
| + snap.Boxes[i].Fresh = false |
| + } |
| + } |
| + return snap |
| +} |
| + |
| +func ids(cs []Candidate) []string { |
| + out := make([]string, len(cs)) |
| + for i, c := range cs { |
| + out[i] = c.BoxID |
| + } |
| + return out |
| +} |
| + |
| +// rosterFor enrolls each box in placement "uk" as an instance in eu-west-2. |
| +func rosterFor(boxes ...string) map[string]RosterBox { |
| + out := map[string]RosterBox{} |
| + for _, b := range boxes { |
| + out[b] = RosterBox{Placement: "uk", Cloud: boxid.Cloud{Provider: "aws", Account: "1", Region: "eu-west-2", InstanceID: "i-" + b}} |
| + } |
| + return out |
| +} |
| + |
| +func TestEligibleAppliesThePredicateOneBoxAtATime(t *testing.T) { |
| + del := func(k string) func(map[string]any) { return func(m map[string]any) { delete(m, k) } } |
| + set := func(k string, v any) func(map[string]any) { return func(m map[string]any) { m[k] = v } } |
| + cases := []struct { |
| + name string |
| + mut func(map[string]any) |
| + want bool |
| + }{ |
| + {"all present", nil, true}, |
| + {"no dial address", set("eip", ""), false}, |
| + {"no box id", set("box_id", ""), false}, |
| + {"hibernation unconfigured", set("hibernation_configured", false), false}, |
| + {"hibernation unknown", del("hibernation_configured"), false}, |
| + {"refuses the shared pool", set("serves_shared_pool", false), false}, |
| + {"shared pool unknown", del("serves_shared_pool"), false}, |
| + {"refuses inbound", set("accepts_inbound", false), false}, |
| + {"inbound unknown", del("accepts_inbound"), false}, |
| + {"wrong secrets key", set("secrets_key_fp", "sha256:other"), false}, |
| + {"no capability", del("capabilities"), false}, |
| + {"draining", set("draining", true), false}, |
| + {"another placement", set("placement", "us"), false}, |
| + {"box_id names no roster entry", set("box_id", "i-0zzz"), false}, |
| + } |
| + for _, tc := range cases { |
| + t.Run(tc.name, func(t *testing.T) { |
| + snap := views(t, haBox("i-0aaa", tc.mut)) |
| + got := Eligible(snap, rosterFor("i-0aaa"), "uk", "eu-west-2", "ie", testKeyFP, nil) |
| + if (len(got) == 1) != tc.want { |
| + t.Fatalf("eligible = %v, want %v", ids(got), tc.want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestEligibleCarriesTheHeadroomAndTheRostersRegion(t *testing.T) { |
| + got := Eligible(views(t, haBox("i-0aaa", nil)), rosterFor("i-0aaa"), "uk", "eu-west-2", "ie", testKeyFP, nil) |
| + want := Candidate{BoxID: "i-0aaa", Region: "eu-west-2", MemFreeMiB: 1000, DiskFreeMiB: 1000, Awake: 1} |
| + if len(got) != 1 || got[0] != want { |
| + t.Fatalf("got %+v, want %+v", got, want) |
| + } |
| +} |
| + |
| +func TestEligibleNeedsARosterEntryFreshnessAndNotLost(t *testing.T) { |
| + snap := views(t, haBox("i-0aaa", nil), haBox("i-0bbb", nil), haBox("i-0ccc", nil)) |
| + snap = markStale(snap, "i-0bbb") |
| + got := Eligible(snap, rosterFor("i-0aaa", "i-0bbb"), "uk", "eu-west-2", "ie", testKeyFP, nil) |
| + if len(got) != 1 || got[0].BoxID != "i-0aaa" { |
| + t.Fatalf("got %v, want only i-0aaa (i-0bbb is stale, i-0ccc has no roster entry)", ids(got)) |
| + } |
| + got = Eligible(snap, rosterFor("i-0aaa"), "uk", "eu-west-2", "ie", testKeyFP, map[string]bool{"i-0aaa": true}) |
| + if len(got) != 0 { |
| + t.Fatalf("a lost box is eligible: %v", ids(got)) |
| + } |
| +} |
| + |
| +// Nearest-region's rule for boxes: one box holding the key and another |
| +// accepting inbound do not add up to one. |
| +func TestEligibleNeverCombinesTwoHalfBoxes(t *testing.T) { |
| + snap := views(t, |
| + haBox("i-0aaa", func(m map[string]any) { m["secrets_key_fp"] = "" }), |
| + haBox("i-0bbb", func(m map[string]any) { m["accepts_inbound"] = false })) |
| + if got := Eligible(snap, rosterFor("i-0aaa", "i-0bbb"), "uk", "eu-west-2", "ie", testKeyFP, nil); len(got) != 0 { |
| + t.Fatalf("got %v, want none", ids(got)) |
| + } |
| +} |
| + |
| +func TestEligibleFromAnIncompleteSnapshotOrWithoutAKeyOrRegionIsNothing(t *testing.T) { |
| + snap := views(t, haBox("i-0aaa", nil)) |
| + snap.Complete = false |
| + if got := Eligible(snap, rosterFor("i-0aaa"), "uk", "eu-west-2", "ie", testKeyFP, nil); len(got) != 0 { |
| + t.Fatalf("got %v from an incomplete snapshot", ids(got)) |
| + } |
| + if got := Eligible(views(t, haBox("i-0aaa", nil)), rosterFor("i-0aaa"), "uk", "eu-west-2", "ie", "", nil); len(got) != 0 { |
| + t.Fatalf("got %v with no expected key", ids(got)) |
| + } |
| + if got := Eligible(views(t, haBox("i-0aaa", nil)), rosterFor("i-0aaa"), "uk", "", "ie", testKeyFP, nil); len(got) != 0 { |
| + t.Fatalf("got %v with no expected region", ids(got)) |
| + } |
| +} |
| + |
| +func TestEligibleHomePlacementMatchesTheEmptyCode(t *testing.T) { |
| + home := haBox("i-0aaa", func(m map[string]any) { m["placement"] = "" }) |
| + named := haBox("i-0bbb", func(m map[string]any) { m["placement"] = "ie" }) |
| + snap := views(t, home, named) |
| + roster := rosterFor("i-0aaa", "i-0bbb") |
| + for _, b := range []string{"i-0aaa", "i-0bbb"} { |
| + rb := roster[b] |
| + rb.Placement = "ie" |
| + roster[b] = rb |
| + } |
| + if got := Eligible(snap, roster, "", "eu-west-2", "ie", testKeyFP, nil); len(got) != 2 { |
| + t.Fatalf("got %v, want both home boxes", ids(got)) |
| + } |
| + if got := Eligible(snap, roster, "ie", "eu-west-2", "ie", testKeyFP, nil); len(got) != 2 { |
| + t.Fatalf("got %v, want both home boxes by the home's code", ids(got)) |
| + } |
| +} |
| + |
| +// A box enrolled in another AWS region than the placement's is not in that |
| +// placement, even when its own file says it is. |
| +func TestEligibleRefusesABoxEnrolledInAnotherRegion(t *testing.T) { |
| + snap := views(t, haBox("i-0aaa", nil), haBox("i-0bbb", nil)) |
| + roster := rosterFor("i-0aaa", "i-0bbb") |
| + roster["i-0bbb"] = RosterBox{Placement: "uk", Cloud: boxid.Cloud{Provider: "aws", Account: "1", Region: "us-east-1", InstanceID: "i-0bbb"}} |
| + got := Eligible(snap, roster, "uk", "eu-west-2", "ie", testKeyFP, nil) |
| + if len(got) != 1 || got[0].BoxID != "i-0aaa" { |
| + t.Fatalf("got %v, want only i-0aaa", ids(got)) |
| + } |
| + // And a file that advertises another AWS region than the one expected. |
| + snap = views(t, haBox("i-0aaa", func(m map[string]any) { m["region"] = "us-east-1" })) |
| + if got := Eligible(snap, rosterFor("i-0aaa"), "uk", "eu-west-2", "ie", testKeyFP, nil); len(got) != 0 { |
| + t.Fatalf("got %v, want none", ids(got)) |
| + } |
| +} |
| + |
| +// The roster's enrolled placement is the enrollment's word and must agree |
| +// with the file's; an incomplete cloud is no identity at all. |
| +func TestEligibleNeedsTheRosterToAgree(t *testing.T) { |
| + snap := views(t, haBox("i-0aaa", nil), haBox("i-0bbb", nil)) |
| + roster := rosterFor("i-0aaa", "i-0bbb") |
| + roster["i-0aaa"] = RosterBox{Placement: "us", Cloud: roster["i-0aaa"].Cloud} |
| + roster["i-0bbb"] = RosterBox{Placement: "uk", Cloud: boxid.Cloud{Provider: "aws", Region: "eu-west-2", InstanceID: "i-0bbb"}} |
| + if got := Eligible(snap, roster, "uk", "eu-west-2", "ie", testKeyFP, nil); len(got) != 0 { |
| + t.Fatalf("got %v, want none", ids(got)) |
| + } |
| +} |
| + |
| +func TestEligibleSortsByBoxID(t *testing.T) { |
| + snap := views(t, haBox("i-0ccc", nil), haBox("i-0aaa", nil), haBox("i-0bbb", nil)) |
| + got := ids(Eligible(snap, rosterFor("i-0aaa", "i-0bbb", "i-0ccc"), "uk", "eu-west-2", "ie", testKeyFP, nil)) |
| + if len(got) != 3 || got[0] != "i-0aaa" || got[1] != "i-0bbb" || got[2] != "i-0ccc" { |
| + t.Fatalf("got %v", got) |
| + } |
| +} |
| + |
| +func TestChoose(t *testing.T) { |
| + a, b := Candidate{BoxID: "i-0aaa"}, Candidate{BoxID: "i-0bbb"} |
| + load := func(hosts int, apps ...string) Load { |
| + l := Load{Hosts: hosts, Apps: map[string]int{}} |
| + for _, x := range apps { |
| + l.Apps[x]++ |
| + } |
| + return l |
| + } |
| + const host = "shop.bab.oncarlos.com" |
| + if _, ok := Choose(host, nil, nil, "bab/jam"); ok { |
| + t.Fatal("chose from nothing") |
| + } |
| + got, _ := Choose(host, []Candidate{a, b}, map[string]Load{"i-0aaa": load(5), "i-0bbb": load(2)}, "bab/jam") |
| + if got.BoxID != "i-0bbb" { |
| + t.Fatalf("fewest hosts: got %s", got.BoxID) |
| + } |
| + got, _ = Choose(host, []Candidate{a, b}, map[string]Load{"i-0aaa": load(5), "i-0bbb": load(2, "bab/jam")}, "bab/jam") |
| + if got.BoxID != "i-0aaa" { |
| + t.Fatalf("spread beats load: got %s", got.BoxID) |
| + } |
| + got, ok := Choose(host, []Candidate{a, b}, map[string]Load{"i-0aaa": load(5, "bab/jam"), "i-0bbb": load(2, "bab/jam", "bab/jam")}, "bab/jam") |
| + if !ok || got.BoxID != "i-0bbb" { |
| + t.Fatalf("every box runs the app: fewest hosts overall, never a refusal; got %s ok=%v", got.BoxID, ok) |
| + } |
| +} |
| + |
| +// H12, claim P5-D2: an equal load breaks by the greatest SHA-256 of |
| +// host + "\x00" + reference, so the choice differs host by host instead |
| +// of always landing on the lowest name, and does not depend on the order |
| +// the candidates arrive in. |
| +func TestChooseBreaksATieByTheHostsHash(t *testing.T) { |
| + cands := []Candidate{{BoxID: "annie-welding"}, {BoxID: "bert-sawing"}, {BoxID: "cleo-mixing"}} |
| + want := func(host string) string { |
| + best, bestSum := "", "" |
| + for _, c := range cands { |
| + sum := sha256.Sum256([]byte(host + "\x00" + c.BoxID)) |
| + if h := hex.EncodeToString(sum[:]); h > bestSum { |
| + best, bestSum = c.BoxID, h |
| + } |
| + } |
| + return best |
| + } |
| + seen := map[string]bool{} |
| + for _, host := range []string{"a.bab.oncarlos.com", "b.bab.oncarlos.com", "c.bab.oncarlos.com", "d.bab.oncarlos.com", "e.bab.oncarlos.com", "f.bab.oncarlos.com"} { |
| + forward, _ := Choose(host, cands, nil, "bab/jam") |
| + backward, _ := Choose(host, []Candidate{cands[2], cands[1], cands[0]}, nil, "bab/jam") |
| + if forward.BoxID != want(host) || backward.BoxID != want(host) { |
| + t.Fatalf("%s: chose %s and %s, want %s", host, forward.BoxID, backward.BoxID, want(host)) |
| + } |
| + seen[forward.BoxID] = true |
| + } |
| + if len(seen) < 2 { |
| + t.Fatalf("six hosts all chose %v: the tie-break is not per host", seen) |
| + } |
| +} |
diff --git a/internal/console/boxchoice.go b/internal/console/boxchoice.go| index 32d8dea64..5c15f0771 100644 |
| --- a/internal/console/boxchoice.go |
| +++ b/internal/console/boxchoice.go |
| @@ -1,122 +1,46 @@ |
| package console |
| import ( |
| - "crypto/sha256" |
| - "encoding/hex" |
| - "slices" |
| - "sort" |
| - |
| - "github.com/carlosframework/platform/internal/boxid" |
| + "github.com/carlosframework/platform/internal/boxchoice" |
| ) |
| -// boxCandidate is one box that may be named as a host's executor (region HA |
| -// spec 1 §2, "eligible box"). |
| -type boxCandidate struct { |
| - BoxID string |
| - Region string |
| - MemFreeMiB, DiskFreeMiB int64 |
| - Awake int |
| -} |
| +// The box eligibility predicate and the choice live in internal/boxchoice, |
| +// the contract console and moves share. These names keep console's own |
| +// vocabulary. |
| +type ( |
| + boxCandidate = boxchoice.Candidate |
| + rosterBox = boxchoice.RosterBox |
| + boxLoad = boxchoice.Load |
| +) |
| -// rosterBox is what the roster (control/host-actors.json, claim D7) says |
| -// about one active box: the placement it is enrolled in and the cloud |
| -// identity enrollment proved from its boxid record (spec §5.1, H11). The |
| -// caller passes active entries only. |
| -type rosterBox struct { |
| - Placement string |
| - Cloud boxid.Cloud |
| +// eligibleBoxes is boxchoice.Eligible on console's edges snapshot. The |
| +// snapshot is complete only when both its files and its liveness are |
| +// (edgesSnapshot.complete). |
| +func eligibleBoxes(snap edgesSnapshot, roster map[string]rosterBox, placement, awsRegion, homeName, expectedFP string, lost map[string]bool) []boxCandidate { |
| + return boxchoice.Eligible(boxSnapshot(snap), roster, placement, awsRegion, homeName, expectedFP, lost) |
| } |
| -// eligibleBoxes is regionCandidates' predicate applied to one box at a time, |
| -// plus what a multi-box region adds: a roster entry for the file's box_id, |
| -// enrolled in this placement, whose proven cloud is in awsRegion (the AWS |
| -// region the roster records for this placement), the HA capability, not |
| -// draining, not lost. Two boxes that each satisfy half of it never add up |
| -// to one. The edges file is evidence of liveness and configuration only: |
| -// identity and region come from the roster (spec §5.1). |
| -// |
| -// An incomplete snapshot offers nothing: an unread file could be the one |
| -// box that would change the answer. |
| -func eligibleBoxes(snap edgesSnapshot, roster map[string]rosterBox, placement, awsRegion, homeName, expectedFP string, lost map[string]bool) []boxCandidate { |
| - if !snap.complete() || expectedFP == "" || awsRegion == "" { |
| - return nil |
| - } |
| - home := func(code string) string { |
| - if homeName != "" && code == homeName { |
| - return "" |
| - } |
| - return code |
| - } |
| - var out []boxCandidate |
| +// boxSnapshot carries every field the predicate reads. A field dropped here |
| +// would silently read as absent, so console's eligibility tests run through |
| +// this conversion. |
| +func boxSnapshot(snap edgesSnapshot) boxchoice.Snapshot { |
| + out := boxchoice.Snapshot{Complete: snap.complete(), Boxes: make([]boxchoice.Box, 0, len(snap.Files))} |
| for _, f := range snap.Files { |
| - want := home(placement) |
| - rb, ok := roster[f.BoxID] |
| - switch { |
| - case home(f.Placement) != want: |
| - case !f.Fresh, f.EIP == "", f.BoxID == "": |
| - case !ok || !rb.Cloud.Complete(): |
| - case home(rb.Placement) != want: |
| - // The file's placement is the box's own word; the roster's is |
| - // the enrollment's. Both must agree. |
| - case rb.Cloud.Region != awsRegion, f.Region != awsRegion: |
| - // The AWS region was proven at enrollment: a box enrolled in |
| - // another region is not in this placement, whatever its file |
| - // says. |
| - case !isTrue(f.HibernationConfigured), !isTrue(f.ServesSharedPool), !isTrue(f.AcceptsInbound): |
| - case f.SecretsKeyFP != expectedFP: |
| - case !slices.Contains(f.Capabilities, "region-ha-1"): |
| - case f.Draining, lost[f.BoxID]: |
| - default: |
| - c := boxCandidate{BoxID: f.BoxID, Region: rb.Cloud.Region} |
| - if f.Headroom != nil { |
| - c.MemFreeMiB, c.DiskFreeMiB, c.Awake = f.Headroom.MemFreeMiB, f.Headroom.DiskFreeMiB, f.Headroom.Awake |
| - } |
| - out = append(out, c) |
| + b := boxchoice.Box{ |
| + Box: f.Box, BoxID: f.BoxID, EIP: f.EIP, Region: f.Region, Placement: f.Placement, |
| + HibernationConfigured: f.HibernationConfigured, ServesSharedPool: f.ServesSharedPool, |
| + AcceptsInbound: f.AcceptsInbound, SecretsKeyFP: f.SecretsKeyFP, Capabilities: f.Capabilities, |
| + Draining: f.Draining, Fresh: f.Fresh, |
| } |
| + if f.Headroom != nil { |
| + b.Headroom = &boxchoice.Headroom{MemFreeMiB: f.Headroom.MemFreeMiB, DiskFreeMiB: f.Headroom.DiskFreeMiB, Awake: f.Headroom.Awake} |
| + } |
| + out.Boxes = append(out.Boxes, b) |
| } |
| - sort.Slice(out, func(i, j int) bool { return out[i].BoxID < out[j].BoxID }) |
| return out |
| } |
| -// boxLoad is what claims already place on a box. |
| -type boxLoad struct { |
| - Hosts int |
| - Apps map[string]int // "acct/app" -> hosts of that app |
| -} |
| - |
| -// chooseBox is spec 1 §5.2's choose() until spec 2 (H12): prefer a box |
| -// that runs no instance of this app (soft spread: it never refuses), then |
| -// the fewest hosts overall, then the greatest hexadecimal SHA-256 of |
| -// host + "\x00" + box reference (claim P5-D2), then the smallest reference |
| -// if two hashes are equal. |
| +// chooseBox is boxchoice.Choose. |
| func chooseBox(host string, cands []boxCandidate, running map[string]boxLoad, app string) (boxCandidate, bool) { |
| - if len(cands) == 0 { |
| - return boxCandidate{}, false |
| - } |
| - tie := func(ref string) string { |
| - sum := sha256.Sum256([]byte(host + "\x00" + ref)) |
| - return hex.EncodeToString(sum[:]) |
| - } |
| - best := cands[0] |
| - for _, c := range cands[1:] { |
| - cl, bl := running[c.BoxID], running[best.BoxID] |
| - cRuns, bRuns := cl.Apps[app] > 0, bl.Apps[app] > 0 |
| - switch { |
| - case cRuns != bRuns: |
| - if !cRuns { |
| - best = c |
| - } |
| - case cl.Hosts != bl.Hosts: |
| - if cl.Hosts < bl.Hosts { |
| - best = c |
| - } |
| - case tie(c.BoxID) != tie(best.BoxID): |
| - if tie(c.BoxID) > tie(best.BoxID) { |
| - best = c |
| - } |
| - case c.BoxID < best.BoxID: |
| - best = c |
| - } |
| - } |
| - return best, true |
| + return boxchoice.Choose(host, cands, running, app) |
| } |
diff --git a/internal/console/boxchoice_test.go b/internal/console/boxchoice_test.go| index b2aa98c6d..be3e1a485 100644 |
| --- a/internal/console/boxchoice_test.go |
| +++ b/internal/console/boxchoice_test.go |
| @@ -1,21 +1,23 @@ |
| package console |
| import ( |
| - "crypto/sha256" |
| - "encoding/hex" |
| "encoding/json" |
| "testing" |
| "github.com/carlosframework/platform/internal/boxid" |
| ) |
| +// The predicate and the choice are tested in internal/boxchoice. These |
| +// tests hold console's conversion to it: every field the predicate reads |
| +// must survive boxSnapshot, or a box would silently read as missing it. |
| + |
| // haBox is an eligible box's edges file in placement "uk". |
| func haBox(id string, mut func(m map[string]any)) string { |
| m := map[string]any{ |
| "v": 1, "box": id, "box_id": id, "eip": "1.2.3.4", "region": "eu-west-2", "placement": "uk", |
| "hosts": map[string]any{}, "hibernation_configured": true, "serves_shared_pool": true, |
| "accepts_inbound": true, "secrets_key_fp": testKeyFP, "capabilities": []string{"region-ha-1"}, |
| - "headroom": map[string]any{"mem_free_mib": 1000, "disk_free_mib": 1000, "awake": 1}, |
| + "headroom": map[string]any{"mem_free_mib": 1000, "disk_free_mib": 2000, "awake": 3}, |
| } |
| if mut != nil { |
| mut(m) |
| @@ -41,7 +43,7 @@ func rosterFor(boxes ...string) map[string]rosterBox { |
| return out |
| } |
| -func TestEligibleBoxesAppliesThePredicateOneBoxAtATime(t *testing.T) { |
| +func TestEligibleBoxesCarriesEveryFieldThePredicateReads(t *testing.T) { |
| del := func(k string) func(map[string]any) { return func(m map[string]any) { delete(m, k) } } |
| set := func(k string, v any) func(map[string]any) { return func(m map[string]any) { m[k] = v } } |
| cases := []struct { |
| @@ -59,6 +61,7 @@ func TestEligibleBoxesAppliesThePredicateOneBoxAtATime(t *testing.T) { |
| {"no capability", del("capabilities"), false}, |
| {"draining", set("draining", true), false}, |
| {"another placement", set("placement", "us"), false}, |
| + {"another region", set("region", "us-east-1"), false}, |
| {"box_id names no roster entry", set("box_id", "i-0zzz"), false}, |
| } |
| for _, tc := range cases { |
| @@ -70,141 +73,34 @@ func TestEligibleBoxesAppliesThePredicateOneBoxAtATime(t *testing.T) { |
| } |
| }) |
| } |
| -} |
| - |
| -func TestEligibleBoxesNeedsARosterEntryFreshnessAndNotLost(t *testing.T) { |
| - snap := views(t, haBox("i-0aaa", nil), haBox("i-0bbb", nil), haBox("i-0ccc", nil)) |
| - snap = markStale(snap, "i-0bbb") |
| - got := eligibleBoxes(snap, rosterFor("i-0aaa", "i-0bbb"), "uk", "eu-west-2", "ie", testKeyFP, nil) |
| - if len(got) != 1 || got[0].BoxID != "i-0aaa" { |
| - t.Fatalf("got %v, want only i-0aaa (i-0bbb is stale, i-0ccc has no roster entry)", ids(got)) |
| - } |
| - got = eligibleBoxes(snap, rosterFor("i-0aaa"), "uk", "eu-west-2", "ie", testKeyFP, map[string]bool{"i-0aaa": true}) |
| - if len(got) != 0 { |
| - t.Fatalf("a lost box is eligible: %v", ids(got)) |
| + got := eligibleBoxes(views(t, haBox("i-0aaa", nil)), rosterFor("i-0aaa"), "uk", "eu-west-2", "ie", testKeyFP, nil) |
| + if want := (boxCandidate{BoxID: "i-0aaa", Region: "eu-west-2", MemFreeMiB: 1000, DiskFreeMiB: 2000, Awake: 3}); len(got) != 1 || got[0] != want { |
| + t.Fatalf("got %+v, want %+v", got, want) |
| } |
| -} |
| - |
| -// TestEligibilityNeverCombinesBoxes is nearest-region's rule for boxes: one |
| -// box holding the key and another accepting inbound do not add up to one. |
| -func TestEligibleBoxesNeverCombinesTwoHalfBoxes(t *testing.T) { |
| - snap := views(t, |
| - haBox("i-0aaa", func(m map[string]any) { m["secrets_key_fp"] = "" }), |
| - haBox("i-0bbb", func(m map[string]any) { m["accepts_inbound"] = false })) |
| - if got := eligibleBoxes(snap, rosterFor("i-0aaa", "i-0bbb"), "uk", "eu-west-2", "ie", testKeyFP, nil); len(got) != 0 { |
| - t.Fatalf("got %v, want none", ids(got)) |
| + if got := eligibleBoxes(markStale(views(t, haBox("i-0aaa", nil)), "i-0aaa"), rosterFor("i-0aaa"), "uk", "eu-west-2", "ie", testKeyFP, nil); len(got) != 0 { |
| + t.Fatalf("a stale box is eligible: %v", ids(got)) |
| } |
| } |
| +// Both halves of console's completeness carry over: an unread edges file, |
| +// and unknown liveness. |
| func TestEligibleBoxesAnIncompleteSnapshotOffersNothing(t *testing.T) { |
| - snap := views(t, haBox("i-0aaa", nil)) |
| - snap.Complete = false |
| - if got := eligibleBoxes(snap, rosterFor("i-0aaa"), "uk", "eu-west-2", "ie", testKeyFP, nil); len(got) != 0 { |
| - t.Fatalf("got %v from an incomplete snapshot", ids(got)) |
| - } |
| -} |
| - |
| -func TestEligibleBoxesHomePlacementMatchesTheEmptyCode(t *testing.T) { |
| - home := haBox("i-0aaa", func(m map[string]any) { m["placement"] = "" }) |
| - named := haBox("i-0bbb", func(m map[string]any) { m["placement"] = "ie" }) |
| - snap := views(t, home, named) |
| - roster := rosterFor("i-0aaa", "i-0bbb") |
| - for _, b := range []string{"i-0aaa", "i-0bbb"} { |
| - rb := roster[b] |
| - rb.Placement = "ie" |
| - roster[b] = rb |
| - } |
| - got := eligibleBoxes(snap, roster, "", "eu-west-2", "ie", testKeyFP, nil) |
| - if len(got) != 2 { |
| - t.Fatalf("got %v, want both home boxes", ids(got)) |
| + for _, bend := range []func(*edgesSnapshot){ |
| + func(s *edgesSnapshot) { s.Complete = false }, |
| + func(s *edgesSnapshot) { s.BoxesComplete = false }, |
| + } { |
| + snap := views(t, haBox("i-0aaa", nil)) |
| + bend(&snap) |
| + if got := eligibleBoxes(snap, rosterFor("i-0aaa"), "uk", "eu-west-2", "ie", testKeyFP, nil); len(got) != 0 { |
| + t.Fatalf("got %v from an incomplete snapshot", ids(got)) |
| + } |
| } |
| } |
| -func TestChooseBox(t *testing.T) { |
| +func TestChooseBoxIsBoxchoiceChoose(t *testing.T) { |
| a, b := boxCandidate{BoxID: "i-0aaa"}, boxCandidate{BoxID: "i-0bbb"} |
| - load := func(hosts int, apps ...string) boxLoad { |
| - l := boxLoad{Hosts: hosts, Apps: map[string]int{}} |
| - for _, x := range apps { |
| - l.Apps[x]++ |
| - } |
| - return l |
| - } |
| - const host = "shop.bab.oncarlos.com" |
| - if _, ok := chooseBox(host, nil, nil, "bab/jam"); ok { |
| - t.Fatal("chose from nothing") |
| - } |
| - got, _ := chooseBox(host, []boxCandidate{a, b}, map[string]boxLoad{"i-0aaa": load(5), "i-0bbb": load(2)}, "bab/jam") |
| - if got.BoxID != "i-0bbb" { |
| - t.Fatalf("fewest hosts: got %s", got.BoxID) |
| - } |
| - got, _ = chooseBox(host, []boxCandidate{a, b}, map[string]boxLoad{"i-0aaa": load(5), "i-0bbb": load(2, "bab/jam")}, "bab/jam") |
| - if got.BoxID != "i-0aaa" { |
| - t.Fatalf("spread beats load: got %s", got.BoxID) |
| - } |
| - got, ok := chooseBox(host, []boxCandidate{a, b}, map[string]boxLoad{"i-0aaa": load(5, "bab/jam"), "i-0bbb": load(2, "bab/jam", "bab/jam")}, "bab/jam") |
| + got, ok := chooseBox("shop.bab.oncarlos.com", []boxCandidate{a, b}, map[string]boxLoad{"i-0aaa": {Hosts: 5}, "i-0bbb": {Hosts: 2}}, "bab/jam") |
| if !ok || got.BoxID != "i-0bbb" { |
| - t.Fatalf("every box runs the app: fewest hosts overall, never a refusal; got %s ok=%v", got.BoxID, ok) |
| - } |
| -} |
| - |
| -// H12, claim P5-D2: an equal load breaks by the greatest SHA-256 of |
| -// host + "\x00" + reference, so the choice differs host by host instead |
| -// of always landing on the lowest name, and does not depend on the order |
| -// the candidates arrive in. |
| -func TestChooseBoxBreaksATieByTheHostsHash(t *testing.T) { |
| - cands := []boxCandidate{{BoxID: "annie-welding"}, {BoxID: "bert-sawing"}, {BoxID: "cleo-mixing"}} |
| - want := func(host string) string { |
| - best, bestSum := "", "" |
| - for _, c := range cands { |
| - sum := sha256.Sum256([]byte(host + "\x00" + c.BoxID)) |
| - if h := hex.EncodeToString(sum[:]); h > bestSum { |
| - best, bestSum = c.BoxID, h |
| - } |
| - } |
| - return best |
| - } |
| - seen := map[string]bool{} |
| - for _, host := range []string{"a.bab.oncarlos.com", "b.bab.oncarlos.com", "c.bab.oncarlos.com", "d.bab.oncarlos.com", "e.bab.oncarlos.com", "f.bab.oncarlos.com"} { |
| - forward, _ := chooseBox(host, cands, nil, "bab/jam") |
| - backward, _ := chooseBox(host, []boxCandidate{cands[2], cands[1], cands[0]}, nil, "bab/jam") |
| - if forward.BoxID != want(host) || backward.BoxID != want(host) { |
| - t.Fatalf("%s: chose %s and %s, want %s", host, forward.BoxID, backward.BoxID, want(host)) |
| - } |
| - seen[forward.BoxID] = true |
| - } |
| - if len(seen) < 2 { |
| - t.Fatalf("six hosts all chose %v: the tie-break is not per host", seen) |
| - } |
| -} |
| - |
| -// A box enrolled in another AWS region than the placement's is not in that |
| -// placement, even when its own file says it is. |
| -func TestEligibleBoxesRefusesABoxEnrolledInAnotherRegion(t *testing.T) { |
| - snap := views(t, haBox("i-0aaa", nil), haBox("i-0bbb", nil)) |
| - roster := rosterFor("i-0aaa", "i-0bbb") |
| - roster["i-0bbb"] = rosterBox{Placement: "uk", Cloud: boxid.Cloud{Provider: "aws", Account: "1", Region: "us-east-1", InstanceID: "i-0bbb"}} |
| - got := eligibleBoxes(snap, roster, "uk", "eu-west-2", "ie", testKeyFP, nil) |
| - if len(got) != 1 || got[0].BoxID != "i-0aaa" { |
| - t.Fatalf("got %v, want only i-0aaa", ids(got)) |
| - } |
| - // And a file that advertises another AWS region than the one expected. |
| - snap = views(t, haBox("i-0aaa", func(m map[string]any) { m["region"] = "us-east-1" })) |
| - if got := eligibleBoxes(snap, rosterFor("i-0aaa"), "uk", "eu-west-2", "ie", testKeyFP, nil); len(got) != 0 { |
| - t.Fatalf("got %v, want none", ids(got)) |
| - } |
| - if got := eligibleBoxes(views(t, haBox("i-0aaa", nil)), rosterFor("i-0aaa"), "uk", "", "ie", testKeyFP, nil); len(got) != 0 { |
| - t.Fatalf("got %v with no expected region", ids(got)) |
| - } |
| -} |
| - |
| -// The roster's enrolled placement is the enrollment's word and must agree |
| -// with the file's; an incomplete cloud is no identity at all. |
| -func TestEligibleBoxesNeedsTheRosterToAgree(t *testing.T) { |
| - snap := views(t, haBox("i-0aaa", nil), haBox("i-0bbb", nil)) |
| - roster := rosterFor("i-0aaa", "i-0bbb") |
| - roster["i-0aaa"] = rosterBox{Placement: "us", Cloud: roster["i-0aaa"].Cloud} |
| - roster["i-0bbb"] = rosterBox{Placement: "uk", Cloud: boxid.Cloud{Provider: "aws", Region: "eu-west-2", InstanceID: "i-0bbb"}} |
| - if got := eligibleBoxes(snap, roster, "uk", "eu-west-2", "ie", testKeyFP, nil); len(got) != 0 { |
| - t.Fatalf("got %v, want none", ids(got)) |
| + t.Fatalf("got %s ok=%v, want i-0bbb", got.BoxID, ok) |
| } |
| } |