carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
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.
17type 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.
40var 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.
78func (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.
147func (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.
181type roomCheckError struct{ err error }
182
183func (e *roomCheckError) Error() string { return "move room check: " + e.err.Error() }
184func (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.
200func (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