package console import ( "context" "errors" "fmt" "log" "os" "slices" "github.com/carlosframework/platform/internal/boxid" "github.com/carlosframework/platform/internal/hostops" "github.com/carlosframework/platform/internal/moves" ) // moveSelection is the move door's answer about the two regions of a move. type moveSelection struct { Target string // the one eligible box in `to`; "" when none Source string // the one eligible box in `from`; "" when none (the door requires it to equal the claim's Op.Exec) // Many is set when a region (to, then from) has more than one eligible // box, and ManyRegion names it as the caller does (the home's name, not // the record's ""). A flag, because the home's record code is "". Many bool ManyRegion string // FloorOK: both regions have edges files, and every file of either // region publishes moves_protocol at or above this build's // moves.ProtocolVersion (and the floor's min, when one is published) // and, when a floor is published, moves_floor_min_release equal to its // min_release and prev_release "none" or prev_allowed_for equal to it. FloorOK bool // ConsoleBelowFloor: a floor is published and its min is above // moves.ProtocolVersion, the protocol this console speaks (§11: the // console must meet the floor too, whatever the boxes publish). ConsoleBelowFloor bool } // errEdgesIncomplete is the snapshot (or a box's identity record) that // could not all be read: a box whose evidence is missing may be below the // floor or may be the second box of a region, so nothing is concluded. var errEdgesIncomplete = errors.New("console: the fleet's edges files could not all be read") // selectMoveBoxes is the first production caller of boxchoice.go's // eligibleBoxes and chooseBox (region HA's tests exercise them; no door // calls them yet), so every input is composed here: // // - the snapshot is s.liveEdges(ctx) (readLiveEdgesSnapshot, liveness // joined): eligibleBoxes refuses a snapshot whose complete() is false, // and only the live read computes BoxesComplete; // - the roster is synthesised by verifiedRoster from the boxes' own // signed identity records (boxid.Verifier.Enroll), because the console // has no reader of control/host-actors.json. Each entry's placement is // the edges file's own, so eligibleBoxes' placement-agreement check is // tautological here: what carries the safety is the AWS-signed identity // (account, and the AWS region expected for the code) plus the one-box // refusal below (D15). Enroll's roster check (one instance under two // references) is not run; // - awsRegion per code is regionCandidates' AWSRegion (the majority of // the code's eligible boxes' AWS regions); a code with none has no // eligible box; // - homeName is s.moveHomeName() (the name homeRegionIn translates // with, so the door and the selection agree on a claim-mode fallback // home) and expectedFP is s.expectedSecretsFP(); lost is nil (Phase 1 // consumes no loss records, contract C6); // - running is nil, and chooseBox is unchanged: its spread, load and // hash tie-break preferences are never reached, because a region with // two eligible boxes is refused before chooseBox could choose between // them (D15). Region HA owns that tie-break (L15, P5-D2). // // Both regions are selected (round-5 finding 7): §7.1 requires the source // to be the one eligible box of its region, and the door then requires the // claim's Op.Exec to be that box. // // The floor (§11, K10): see moveSelection.FloorOK. A box's prev verdict is // its own (Task 13 writes prev_allowed_for after ReleaseAtOrAbove); the // console does not re-derive it from prev_release, because the box's units // select by that verdict. Every file of either region counts, fresh or not: // a box that stopped reporting may come back. func (s *Server) selectMoveBoxes(ctx context.Context, host, to, from, app string) (moveSelection, error) { snap, err := s.liveEdges(ctx) if err != nil || !snap.EvidenceComplete() { return moveSelection{}, errEdgesIncomplete } floor, err := moves.ReadFloor(ctx, s.cfg.Releases) if err != nil { return moveSelection{}, err } home := s.moveHomeName() aws := map[string]string{} for _, c := range regionCandidates(snap, home, s.expectedSecretsFP()) { aws[c.Code] = c.AWSRegion } roster, err := s.verifiedRoster(ctx, snap, aws, to, from) if err != nil { return moveSelection{}, err } sel := moveSelection{FloorOK: true, ConsoleBelowFloor: floor != nil && moves.ProtocolVersion < floor.Min} seen := map[string]bool{} for _, f := range snap.Files { code := s.homeRegionIn(f.Placement) if code != to && code != from { continue } seen[code] = true if f.MovesProtocol < moves.ProtocolVersion || (floor != nil && f.MovesProtocol < floor.Min) { sel.FloorOK = false } if floor != nil { prevOK := f.PrevRelease == "none" || f.PrevAllowedFor == floor.MinRelease rootFloorRefused := (f.MovesFloorState != "" || f.MovesFloorGeneration != 0) && (f.MovesFloorState != "accepted" || f.MovesFloorGeneration == 0) if rootFloorRefused || f.MovesFloorMinRelease != floor.MinRelease || !prevOK { sel.FloorOK = false } } } if !seen[to] || !seen[from] { sel.FloorOK = false } fp := s.expectedSecretsFP() for _, code := range []string{to, from} { cands := eligibleBoxes(snap, roster, code, aws[code], home, fp, nil) if code == to { if err := s.moveRoomCheck(ctx, to, cands); err != nil { return moveSelection{}, err } } if len(cands) > 1 { sel.Many, sel.ManyRegion = true, s.homeRegionOut(code) return sel, nil } if c, ok := chooseBox(host, cands, nil, app); ok { if code == to { sel.Target = c.BoxID } else { sel.Source = c.BoxID } } } return sel, nil } // verifiedRoster proves each box of the codes from its boxid record: // boxid.Load, then Verifier.Enroll against the deployment's account and the // AWS region the code's boxes publish, with no roster to compare. A box // with no record, or one that does not verify, is left out, which // eligibleBoxes reads as not eligible. A record that exists but cannot be // read is errEdgesIncomplete: that box may be a region's second box. func (s *Server) verifiedRoster(ctx context.Context, snap edgesSnapshot, aws map[string]string, codes ...string) (map[string]rosterBox, error) { want := map[string]bool{} for _, c := range codes { want[c] = true } v := boxid.Verifier{Account: s.cfg.AWSAccount, Certs: s.boxCerts} out := map[string]rosterBox{} for _, f := range snap.Files { code := s.homeRegionIn(f.Placement) if !want[code] || !boxid.ValidBox(f.BoxID) { continue } raw, err := boxid.Load(ctx, s.cfg.Releases, f.BoxID) if errors.Is(err, os.ErrNotExist) { continue } if err != nil { log.Printf("console: move door: read the identity record of %s: %v", f.BoxID, err) return nil, fmt.Errorf("%w: %v", errEdgesIncomplete, err) } ev, err := v.Enroll(raw, boxid.Expect{Box: f.BoxID, AWSRegion: aws[code]}) if err != nil { continue } out[f.BoxID] = rosterBox{Placement: f.Placement, Cloud: ev.Cloud} } return out, nil } // roomCheckError is a placement read moveRoomCheck could not complete (an // incomplete snapshot, or an error reading it). It is not a refusal for // want of room: the move door answers it as temporarily unavailable // (writeClaimResponseError), never as the protocol floor's 503. It wraps // the read's error, so hostops.ErrEvidenceIncomplete is still found. type roomCheckError struct{ err error } func (e *roomCheckError) Error() string { return "move room check: " + e.err.Error() } func (e *roomCheckError) Unwrap() error { return e.err } // moveRoomCheck is a move's destination under region HA (spec 2 §7, // H27): the destination is a door that chooses a box, so when it has // candidates and none of them is a box the create door's chooser would // place on (roles, headroom, fresh edges files), the move is the no-room // refusal. It only refuses (census ruling region-ha-2a rev 2): the // candidates, and so the destination main's selection picks, are never // narrowed or changed. It answers nil, and main's own checks answer, when // region HA is off or there is no claim runtime (no roster is read), when // the destination has no candidates (main's "no box publishes an edges // file yet"), and when the roster does not place the region // (hostops.ErrContract), matching roomIn: it refuses only where placement // is known to have no room. The source is never checked, so an instance on // an excluded box can still move out (A10-3). A read that could not be // completed is a *roomCheckError, never no room. func (s *Server) moveRoomCheck(ctx context.Context, to string, cands []boxCandidate) error { x := s.placementChooser("") if x == nil || len(cands) == 0 { return nil } boxes, err := x.PlaceableBoxes(ctx, s.cfg.claimPlacementRegion(to)) switch { case errors.Is(err, hostops.ErrContract): return nil case err != nil: return &roomCheckError{err} } for _, c := range cands { if slices.Contains(boxes, c.BoxID) { return nil } } return s.noRoom(ctx, to) }