carlos / platform Public

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>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 8e3e867a822b783c961f8f6c761d591dad41a069 parent 64c1557
4 files changed, +503 −236
  • internal/boxchoice/boxchoice.go +186 −0
  • internal/boxchoice/boxchoice_test.go +261 −0
  • internal/console/boxchoice.go +30 −106
  • internal/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)
}
}