| 1 | // Package boxchoice decides which boxes in a placement may run a host as |
| 2 | // its executor, and which of them a new claim should name (region HA spec |
| 3 | // 1 §2 and §5.2). It is pure: callers read the edges files, the box status |
| 4 | // snapshots and the roster, and pass what they read. |
| 5 | // |
| 6 | // Console's placement door and moves' door both call it, so the two |
| 7 | // always agree on what "eligible" means. |
| 8 | package boxchoice |
| 9 | |
| 10 | import ( |
| 11 | "crypto/sha256" |
| 12 | "encoding/hex" |
| 13 | "github.com/carlosframework/platform/internal/regionname" |
| 14 | "slices" |
| 15 | "sort" |
| 16 | |
| 17 | "github.com/carlosframework/platform/internal/boxid" |
| 18 | ) |
| 19 | |
| 20 | // Capability is the capability an edges file lists when its box can be a |
| 21 | // region HA executor. |
| 22 | const Capability = "region-ha-1" |
| 23 | |
| 24 | // Headroom is what a box reports it has free, and (region HA spec 2 §7) |
| 25 | // the totals the free figures are fractions of. A total of 0 is a writer |
| 26 | // that predates it, or a reading that failed. |
| 27 | type Headroom struct { |
| 28 | MemFreeMiB int64 `json:"mem_free_mib"` |
| 29 | DiskFreeMiB int64 `json:"disk_free_mib"` |
| 30 | MemTotalMiB int64 `json:"mem_total_mib"` |
| 31 | DiskTotalMiB int64 `json:"disk_total_mib"` |
| 32 | Awake int `json:"awake"` |
| 33 | } |
| 34 | |
| 35 | // Box is one box's edges file (control/edges/<box>.json, written by |
| 36 | // internal/host/edgesfile.go), reduced to what eligibility reads, plus |
| 37 | // Fresh, which is not on the wire. The JSON tags are the edges file's, so |
| 38 | // a caller may unmarshal a file straight into a Box. |
| 39 | // |
| 40 | // The *bool fields are pointers because absent and false are different |
| 41 | // facts: absent is a writer that predates the field, and is not eligible. |
| 42 | type Box struct { |
| 43 | Box string `json:"box"` |
| 44 | BoxID string `json:"box_id"` |
| 45 | EIP string `json:"eip"` |
| 46 | Region string `json:"region"` |
| 47 | Placement string `json:"placement"` |
| 48 | HibernationConfigured *bool `json:"hibernation_configured"` |
| 49 | ServesSharedPool *bool `json:"serves_shared_pool"` |
| 50 | AcceptsInbound *bool `json:"accepts_inbound"` |
| 51 | SecretsKeyFP string `json:"secrets_key_fp"` |
| 52 | Capabilities []string `json:"capabilities"` |
| 53 | Draining bool `json:"draining"` |
| 54 | Headroom *Headroom `json:"headroom"` |
| 55 | // Generation is when the box last wrote the file (RFC 3339). Spec 2's |
| 56 | // policy trusts headroom only from a recent file (Placeable). |
| 57 | Generation string `json:"generation"` |
| 58 | // Fresh is the caller's verdict that the box's own status snapshot is |
| 59 | // still fresh. An edges file outlives its box, so the file alone is |
| 60 | // not liveness. |
| 61 | Fresh bool `json:"-"` |
| 62 | } |
| 63 | |
| 64 | // Snapshot is one read of every box. Complete is true only when every |
| 65 | // listed edges file was read and parsed and every box's liveness is known |
| 66 | // (each live box has a file and each file's Fresh is a known answer). An |
| 67 | // incomplete snapshot makes nothing eligible: an unread file could be the |
| 68 | // one box that would change the answer. |
| 69 | type Snapshot struct { |
| 70 | Boxes []Box |
| 71 | Complete bool |
| 72 | } |
| 73 | |
| 74 | // RosterBox is what the roster (control/host-actors.json, claim D7) says |
| 75 | // about one active box: the placement it is enrolled in and the cloud |
| 76 | // identity enrollment proved from its boxid record (spec §5.1, H11). The |
| 77 | // caller passes active entries only, keyed by box id. |
| 78 | type RosterBox struct { |
| 79 | Placement string |
| 80 | Cloud boxid.Cloud |
| 81 | } |
| 82 | |
| 83 | // Candidate is one box that may be named as a host's executor (region HA |
| 84 | // spec 1 §2, "eligible box"). |
| 85 | type Candidate struct { |
| 86 | BoxID string |
| 87 | Region string |
| 88 | MemFreeMiB, DiskFreeMiB int64 |
| 89 | MemTotalMiB, DiskTotalMiB int64 |
| 90 | Awake int |
| 91 | // Generation is the box's edges file's (Box.Generation). |
| 92 | Generation string |
| 93 | } |
| 94 | |
| 95 | // Load is what claims already place on a box. |
| 96 | type Load struct { |
| 97 | Hosts int |
| 98 | Apps map[string]int // "acct/app" -> hosts of that app |
| 99 | } |
| 100 | |
| 101 | func isTrue(b *bool) bool { return b != nil && *b } |
| 102 | |
| 103 | // Eligible returns the boxes that may run a host placed in placement, |
| 104 | // sorted by box id. A box qualifies on its own evidence alone: it is |
| 105 | // fresh, has a dial address and a box id, and has a roster entry for that |
| 106 | // box id with a complete cloud identity, enrolled in this placement, whose |
| 107 | // proven AWS region is awsRegion (the region the roster records for this |
| 108 | // placement), as its file's region also is. Its file says hibernation is |
| 109 | // configured, it serves the shared pool and accepts inbound, its secrets |
| 110 | // key fingerprint is expectedFP, and it has Capability. It is not |
| 111 | // draining and not in lost. Two boxes that each satisfy half of this never |
| 112 | // add up to one. Identity and region come from the roster; the edges file |
| 113 | // is evidence of liveness and configuration only (spec §5.1). |
| 114 | // |
| 115 | // homeName is the home placement's code: a box whose file or roster entry |
| 116 | // names it is the home placement, as is the empty code. |
| 117 | // |
| 118 | // An incomplete snapshot, an empty expectedFP or an empty awsRegion |
| 119 | // returns nothing. |
| 120 | func Eligible(snap Snapshot, roster map[string]RosterBox, placement, awsRegion, homeName, expectedFP string, lost map[string]bool) []Candidate { |
| 121 | if !snap.Complete || expectedFP == "" || awsRegion == "" { |
| 122 | return nil |
| 123 | } |
| 124 | home := func(code string) string { return regionname.HomePlacementCode(homeName, code) } |
| 125 | var out []Candidate |
| 126 | for _, f := range snap.Boxes { |
| 127 | want := home(placement) |
| 128 | rb, ok := roster[f.BoxID] |
| 129 | switch { |
| 130 | case home(f.Placement) != want: |
| 131 | case !f.Fresh, f.EIP == "", f.BoxID == "": |
| 132 | case !ok || !rb.Cloud.Complete(): |
| 133 | case home(rb.Placement) != want: |
| 134 | // The file's placement is the box's own word; the roster's is |
| 135 | // the enrollment's. Both must agree. |
| 136 | case rb.Cloud.Region != awsRegion, f.Region != awsRegion: |
| 137 | // The AWS region was proven at enrollment: a box enrolled in |
| 138 | // another region is not in this placement, whatever its file |
| 139 | // says. |
| 140 | case !isTrue(f.HibernationConfigured), !isTrue(f.ServesSharedPool), !isTrue(f.AcceptsInbound): |
| 141 | case f.SecretsKeyFP != expectedFP: |
| 142 | case !slices.Contains(f.Capabilities, Capability): |
| 143 | case f.Draining, lost[f.BoxID]: |
| 144 | default: |
| 145 | c := Candidate{BoxID: f.BoxID, Region: rb.Cloud.Region, Generation: f.Generation} |
| 146 | if f.Headroom != nil { |
| 147 | c.MemFreeMiB, c.DiskFreeMiB, c.Awake = f.Headroom.MemFreeMiB, f.Headroom.DiskFreeMiB, f.Headroom.Awake |
| 148 | c.MemTotalMiB, c.DiskTotalMiB = f.Headroom.MemTotalMiB, f.Headroom.DiskTotalMiB |
| 149 | } |
| 150 | out = append(out, c) |
| 151 | } |
| 152 | } |
| 153 | sort.Slice(out, func(i, j int) bool { return out[i].BoxID < out[j].BoxID }) |
| 154 | return out |
| 155 | } |
| 156 | |
| 157 | // Choose is spec 1 §5.2's choose() until spec 2 (H12): prefer a box that |
| 158 | // runs no instance of app (soft spread: it never refuses), then the fewest |
| 159 | // hosts overall, then the greatest hexadecimal SHA-256 of |
| 160 | // host + "\x00" + box id (claim P5-D2), then the smallest box id if two |
| 161 | // hashes are equal. The answer does not depend on the order of cands. The |
| 162 | // bool is false only when cands is empty. |
| 163 | func Choose(host string, cands []Candidate, running map[string]Load, app string) (Candidate, bool) { |
| 164 | if len(cands) == 0 { |
| 165 | return Candidate{}, false |
| 166 | } |
| 167 | tie := func(ref string) string { |
| 168 | sum := sha256.Sum256([]byte(host + "\x00" + ref)) |
| 169 | return hex.EncodeToString(sum[:]) |
| 170 | } |
| 171 | best := cands[0] |
| 172 | for _, c := range cands[1:] { |
| 173 | cl, bl := running[c.BoxID], running[best.BoxID] |
| 174 | cRuns, bRuns := cl.Apps[app] > 0, bl.Apps[app] > 0 |
| 175 | switch { |
| 176 | case cRuns != bRuns: |
| 177 | if !cRuns { |
| 178 | best = c |
| 179 | } |
| 180 | case cl.Hosts != bl.Hosts: |
| 181 | if cl.Hosts < bl.Hosts { |
| 182 | best = c |
| 183 | } |
| 184 | case tie(c.BoxID) != tie(best.BoxID): |
| 185 | if tie(c.BoxID) > tie(best.BoxID) { |
| 186 | best = c |
| 187 | } |
| 188 | case c.BoxID < best.BoxID: |
| 189 | best = c |
| 190 | } |
| 191 | } |
| 192 | return best, true |
| 193 | } |
| 194 | |