| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "log" |
| 8 | "os" |
| 9 | "slices" |
| 10 | |
| 11 | "github.com/carlosframework/platform/internal/boxid" |
| 12 | "github.com/carlosframework/platform/internal/hostops" |
| 13 | "github.com/carlosframework/platform/internal/moves" |
| 14 | ) |
| 15 | |
| 16 | // moveSelection is the move door's answer about the two regions of a move. |
| 17 | type moveSelection struct { |
| 18 | Target string // the one eligible box in `to`; "" when none |
| 19 | Source string // the one eligible box in `from`; "" when none (the door requires it to equal the claim's Op.Exec) |
| 20 | // Many is set when a region (to, then from) has more than one eligible |
| 21 | // box, and ManyRegion names it as the caller does (the home's name, not |
| 22 | // the record's ""). A flag, because the home's record code is "". |
| 23 | Many bool |
| 24 | ManyRegion string |
| 25 | // FloorOK: both regions have edges files, and every file of either |
| 26 | // region publishes moves_protocol at or above this build's |
| 27 | // moves.ProtocolVersion (and the floor's min, when one is published) |
| 28 | // and, when a floor is published, moves_floor_min_release equal to its |
| 29 | // min_release and prev_release "none" or prev_allowed_for equal to it. |
| 30 | FloorOK bool |
| 31 | // ConsoleBelowFloor: a floor is published and its min is above |
| 32 | // moves.ProtocolVersion, the protocol this console speaks (§11: the |
| 33 | // console must meet the floor too, whatever the boxes publish). |
| 34 | ConsoleBelowFloor bool |
| 35 | } |
| 36 | |
| 37 | // errEdgesIncomplete is the snapshot (or a box's identity record) that |
| 38 | // could not all be read: a box whose evidence is missing may be below the |
| 39 | // floor or may be the second box of a region, so nothing is concluded. |
| 40 | var errEdgesIncomplete = errors.New("console: the fleet's edges files could not all be read") |
| 41 | |
| 42 | // selectMoveBoxes is the first production caller of boxchoice.go's |
| 43 | // eligibleBoxes and chooseBox (region HA's tests exercise them; no door |
| 44 | // calls them yet), so every input is composed here: |
| 45 | // |
| 46 | // - the snapshot is s.liveEdges(ctx) (readLiveEdgesSnapshot, liveness |
| 47 | // joined): eligibleBoxes refuses a snapshot whose complete() is false, |
| 48 | // and only the live read computes BoxesComplete; |
| 49 | // - the roster is synthesised by verifiedRoster from the boxes' own |
| 50 | // signed identity records (boxid.Verifier.Enroll), because the console |
| 51 | // has no reader of control/host-actors.json. Each entry's placement is |
| 52 | // the edges file's own, so eligibleBoxes' placement-agreement check is |
| 53 | // tautological here: what carries the safety is the AWS-signed identity |
| 54 | // (account, and the AWS region expected for the code) plus the one-box |
| 55 | // refusal below (D15). Enroll's roster check (one instance under two |
| 56 | // references) is not run; |
| 57 | // - awsRegion per code is regionCandidates' AWSRegion (the majority of |
| 58 | // the code's eligible boxes' AWS regions); a code with none has no |
| 59 | // eligible box; |
| 60 | // - homeName is s.moveHomeName() (the name homeRegionIn translates |
| 61 | // with, so the door and the selection agree on a claim-mode fallback |
| 62 | // home) and expectedFP is s.expectedSecretsFP(); lost is nil (Phase 1 |
| 63 | // consumes no loss records, contract C6); |
| 64 | // - running is nil, and chooseBox is unchanged: its spread, load and |
| 65 | // hash tie-break preferences are never reached, because a region with |
| 66 | // two eligible boxes is refused before chooseBox could choose between |
| 67 | // them (D15). Region HA owns that tie-break (L15, P5-D2). |
| 68 | // |
| 69 | // Both regions are selected (round-5 finding 7): §7.1 requires the source |
| 70 | // to be the one eligible box of its region, and the door then requires the |
| 71 | // claim's Op.Exec to be that box. |
| 72 | // |
| 73 | // The floor (§11, K10): see moveSelection.FloorOK. A box's prev verdict is |
| 74 | // its own (Task 13 writes prev_allowed_for after ReleaseAtOrAbove); the |
| 75 | // console does not re-derive it from prev_release, because the box's units |
| 76 | // select by that verdict. Every file of either region counts, fresh or not: |
| 77 | // a box that stopped reporting may come back. |
| 78 | func (s *Server) selectMoveBoxes(ctx context.Context, host, to, from, app string) (moveSelection, error) { |
| 79 | snap, err := s.liveEdges(ctx) |
| 80 | if err != nil || !snap.EvidenceComplete() { |
| 81 | return moveSelection{}, errEdgesIncomplete |
| 82 | } |
| 83 | floor, err := moves.ReadFloor(ctx, s.cfg.Releases) |
| 84 | if err != nil { |
| 85 | return moveSelection{}, err |
| 86 | } |
| 87 | home := s.moveHomeName() |
| 88 | aws := map[string]string{} |
| 89 | for _, c := range regionCandidates(snap, home, s.expectedSecretsFP()) { |
| 90 | aws[c.Code] = c.AWSRegion |
| 91 | } |
| 92 | roster, err := s.verifiedRoster(ctx, snap, aws, to, from) |
| 93 | if err != nil { |
| 94 | return moveSelection{}, err |
| 95 | } |
| 96 | sel := moveSelection{FloorOK: true, ConsoleBelowFloor: floor != nil && moves.ProtocolVersion < floor.Min} |
| 97 | seen := map[string]bool{} |
| 98 | for _, f := range snap.Files { |
| 99 | code := s.homeRegionIn(f.Placement) |
| 100 | if code != to && code != from { |
| 101 | continue |
| 102 | } |
| 103 | seen[code] = true |
| 104 | if f.MovesProtocol < moves.ProtocolVersion || (floor != nil && f.MovesProtocol < floor.Min) { |
| 105 | sel.FloorOK = false |
| 106 | } |
| 107 | if floor != nil { |
| 108 | prevOK := f.PrevRelease == "none" || f.PrevAllowedFor == floor.MinRelease |
| 109 | rootFloorRefused := (f.MovesFloorState != "" || f.MovesFloorGeneration != 0) && (f.MovesFloorState != "accepted" || f.MovesFloorGeneration == 0) |
| 110 | if rootFloorRefused || f.MovesFloorMinRelease != floor.MinRelease || !prevOK { |
| 111 | sel.FloorOK = false |
| 112 | } |
| 113 | } |
| 114 | } |
| 115 | if !seen[to] || !seen[from] { |
| 116 | sel.FloorOK = false |
| 117 | } |
| 118 | fp := s.expectedSecretsFP() |
| 119 | for _, code := range []string{to, from} { |
| 120 | cands := eligibleBoxes(snap, roster, code, aws[code], home, fp, nil) |
| 121 | if code == to { |
| 122 | if err := s.moveRoomCheck(ctx, to, cands); err != nil { |
| 123 | return moveSelection{}, err |
| 124 | } |
| 125 | } |
| 126 | if len(cands) > 1 { |
| 127 | sel.Many, sel.ManyRegion = true, s.homeRegionOut(code) |
| 128 | return sel, nil |
| 129 | } |
| 130 | if c, ok := chooseBox(host, cands, nil, app); ok { |
| 131 | if code == to { |
| 132 | sel.Target = c.BoxID |
| 133 | } else { |
| 134 | sel.Source = c.BoxID |
| 135 | } |
| 136 | } |
| 137 | } |
| 138 | return sel, nil |
| 139 | } |
| 140 | |
| 141 | // verifiedRoster proves each box of the codes from its boxid record: |
| 142 | // boxid.Load, then Verifier.Enroll against the deployment's account and the |
| 143 | // AWS region the code's boxes publish, with no roster to compare. A box |
| 144 | // with no record, or one that does not verify, is left out, which |
| 145 | // eligibleBoxes reads as not eligible. A record that exists but cannot be |
| 146 | // read is errEdgesIncomplete: that box may be a region's second box. |
| 147 | func (s *Server) verifiedRoster(ctx context.Context, snap edgesSnapshot, aws map[string]string, codes ...string) (map[string]rosterBox, error) { |
| 148 | want := map[string]bool{} |
| 149 | for _, c := range codes { |
| 150 | want[c] = true |
| 151 | } |
| 152 | v := boxid.Verifier{Account: s.cfg.AWSAccount, Certs: s.boxCerts} |
| 153 | out := map[string]rosterBox{} |
| 154 | for _, f := range snap.Files { |
| 155 | code := s.homeRegionIn(f.Placement) |
| 156 | if !want[code] || !boxid.ValidBox(f.BoxID) { |
| 157 | continue |
| 158 | } |
| 159 | raw, err := boxid.Load(ctx, s.cfg.Releases, f.BoxID) |
| 160 | if errors.Is(err, os.ErrNotExist) { |
| 161 | continue |
| 162 | } |
| 163 | if err != nil { |
| 164 | log.Printf("console: move door: read the identity record of %s: %v", f.BoxID, err) |
| 165 | return nil, fmt.Errorf("%w: %v", errEdgesIncomplete, err) |
| 166 | } |
| 167 | ev, err := v.Enroll(raw, boxid.Expect{Box: f.BoxID, AWSRegion: aws[code]}) |
| 168 | if err != nil { |
| 169 | continue |
| 170 | } |
| 171 | out[f.BoxID] = rosterBox{Placement: f.Placement, Cloud: ev.Cloud} |
| 172 | } |
| 173 | return out, nil |
| 174 | } |
| 175 | |
| 176 | // roomCheckError is a placement read moveRoomCheck could not complete (an |
| 177 | // incomplete snapshot, or an error reading it). It is not a refusal for |
| 178 | // want of room: the move door answers it as temporarily unavailable |
| 179 | // (writeClaimResponseError), never as the protocol floor's 503. It wraps |
| 180 | // the read's error, so hostops.ErrEvidenceIncomplete is still found. |
| 181 | type roomCheckError struct{ err error } |
| 182 | |
| 183 | func (e *roomCheckError) Error() string { return "move room check: " + e.err.Error() } |
| 184 | func (e *roomCheckError) Unwrap() error { return e.err } |
| 185 | |
| 186 | // moveRoomCheck is a move's destination under region HA (spec 2 §7, |
| 187 | // H27): the destination is a door that chooses a box, so when it has |
| 188 | // candidates and none of them is a box the create door's chooser would |
| 189 | // place on (roles, headroom, fresh edges files), the move is the no-room |
| 190 | // refusal. It only refuses (census ruling region-ha-2a rev 2): the |
| 191 | // candidates, and so the destination main's selection picks, are never |
| 192 | // narrowed or changed. It answers nil, and main's own checks answer, when |
| 193 | // region HA is off or there is no claim runtime (no roster is read), when |
| 194 | // the destination has no candidates (main's "no box publishes an edges |
| 195 | // file yet"), and when the roster does not place the region |
| 196 | // (hostops.ErrContract), matching roomIn: it refuses only where placement |
| 197 | // is known to have no room. The source is never checked, so an instance on |
| 198 | // an excluded box can still move out (A10-3). A read that could not be |
| 199 | // completed is a *roomCheckError, never no room. |
| 200 | func (s *Server) moveRoomCheck(ctx context.Context, to string, cands []boxCandidate) error { |
| 201 | x := s.placementChooser("") |
| 202 | if x == nil || len(cands) == 0 { |
| 203 | return nil |
| 204 | } |
| 205 | boxes, err := x.PlaceableBoxes(ctx, s.cfg.claimPlacementRegion(to)) |
| 206 | switch { |
| 207 | case errors.Is(err, hostops.ErrContract): |
| 208 | return nil |
| 209 | case err != nil: |
| 210 | return &roomCheckError{err} |
| 211 | } |
| 212 | for _, c := range cands { |
| 213 | if slices.Contains(boxes, c.BoxID) { |
| 214 | return nil |
| 215 | } |
| 216 | } |
| 217 | return s.noRoom(ctx, to) |
| 218 | } |
| 219 | |