// 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" "github.com/carlosframework/platform/internal/regionname" "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, and (region HA spec 2 §7) // the totals the free figures are fractions of. A total of 0 is a writer // that predates it, or a reading that failed. type Headroom struct { MemFreeMiB int64 `json:"mem_free_mib"` DiskFreeMiB int64 `json:"disk_free_mib"` MemTotalMiB int64 `json:"mem_total_mib"` DiskTotalMiB int64 `json:"disk_total_mib"` Awake int `json:"awake"` } // Box is one box's edges file (control/edges/.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"` // Generation is when the box last wrote the file (RFC 3339). Spec 2's // policy trusts headroom only from a recent file (Placeable). Generation string `json:"generation"` // 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 MemTotalMiB, DiskTotalMiB int64 Awake int // Generation is the box's edges file's (Box.Generation). Generation string } // 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 { return regionname.HomePlacementCode(homeName, 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, Generation: f.Generation} if f.Headroom != nil { c.MemFreeMiB, c.DiskFreeMiB, c.Awake = f.Headroom.MemFreeMiB, f.Headroom.DiskFreeMiB, f.Headroom.Awake c.MemTotalMiB, c.DiskTotalMiB = f.Headroom.MemTotalMiB, f.Headroom.DiskTotalMiB } 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 }