console: GET …/move?detail=1 with phases, reports, evidence keys and the log (§12)
Plan 6 Task 2, with the controller's rulings (preflight L2, L4, L5 claim-off, L18, L26, L28, L30, M18): - With CARLOS_MOVES_PHASE1 on, every move answer's flat view carries the move id, the last close's outcome (D11 last) and the Phase 1 marker "protocol":"phase1", for every caller including the Instance-role token. With the switch off the bytes are main's and detail=1 is not read. - An operator's GET …/move?detail=1 adds a "detail" object: the record's move journal, the claim op (kind, phase, exec, participants, target box), both readiness reports (the source's environment HMACs only; any other shape is dropped), the stop evidence keys and every log/ key. Members and service tokens get the flat view. - A relocating move (record relocating, or a committed landing whose claim is relocating in the target region, J3) answers state relocating; when the claim is relocating/pinning with exec "none" and the region has no live enrolled box (boxchoice.Eligible over the live edges, roster through hostactors.Directory, fenced boxes excluded), detail.waiting_for_box names the region. - The abort door answers M-C13 once the cut has started (decided under the record CAS), on consoles without the host claim; M-C13 is now approved verbatim. New pending row MV-N319 for the reports read failure.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+748
−22
approved_moves_copy_test.go+1 −0docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+6 −2internal/console/apiinstancemove.go+356 −17internal/console/apiinstancemove_test.go+385 −3
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index ccd011d44..663d87fff 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -75,6 +75,7 @@ var movesCopyIDs = []string{ |
| "MV-N307", "MV-N308", "MV-N309", "MV-N310", "MV-N311", "MV-N312", |
| "MV-N313", "MV-N314", |
| "MV-N315", "MV-N316", "MV-N317", "MV-N318", |
| + "MV-N319", |
| } |
| // copyRow is one row of the record. The File cell is the file the sentence is |
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md| index 94f979f72..898939b30 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -25,7 +25,7 @@ Columns: id, file, text, status. |
| | M-C10 | move.go | Waiting for <box> in <to> to confirm it has stopped <host>. <host> is offline. | pending | |
| | M-C11 | move.go | <host> could not start in <to> or in <from>. It is offline. Run carlos move resolve --host <host> --retry once the cause is fixed. | pending | |
| | M-C12 | move.go | Ready to cut. Run carlos move cut --host <host> when you are ready. | pending | |
| -| M-C13 | internal/console/apiinstancemove.go | This move has started its cut. It will finish or move back on its own. | pending | |
| +| M-C13 | internal/console/apiinstancemove.go | This move has started its cut. It will finish or move back on its own. | approved | |
| | M-C14 | internal/host/movesphase1.go | <host> keeps a file this move cannot copy: <path>. Only regular files and folders move. | approved | |
| | M-C15 | internal/host/movesphase1.go | <host> keeps more than 256 MiB of files beside its database. Keep large files in object storage. | approved | |
| | M-C16 | internal/host/movesphase1.go | <host> does not answer its readiness path. Declare moves.ready_path for this app. | approved | |
| @@ -284,7 +284,11 @@ Columns: id, file, text, status. |
| ## Plan 6 |
| -(completed by Plan 6 Task 7) |
| +(completed by Plan 6 Task 7. Task 2 takes MV-N319 to MV-N324, and made the abort door speak M-C13 on a console without the host claim, so M-C13 is now approved verbatim) |
| + |
| +| id | file | text | status | |
| +|---|---|---|---| |
| +| MV-N319 | internal/console/apiinstancemove.go | could not read the move's reports. try again | pending | |
| ## Review of 2026-10-09 |
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go| index 6ff8122ee..ed220c1da 100644 |
| --- a/internal/console/apiinstancemove.go |
| +++ b/internal/console/apiinstancemove.go |
| @@ -4,6 +4,7 @@ import ( |
| "context" |
| "crypto/rand" |
| "database/sql" |
| + "encoding/json" |
| "errors" |
| "fmt" |
| "log" |
| @@ -144,21 +145,48 @@ const moveScopedHolder = "the instance keeps its data under a per-incarnation pa |
| // move in flight answers 200 {"state":"none"} so a poller needs no 404 |
| // branch. Target carries the same NAME the caller used — the home code is |
| // un-normalized on the way out (see homeRegionOut). |
| +// |
| +// With CARLOS_MOVES_PHASE1 on (Plan 6 Task 2, preflight L2 and L4) the view |
| +// also carries the in-flight move's id, the outcome the last close left on |
| +// the record (D11's last), and the Phase 1 marker, so a CLI can bind its |
| +// wait to the move it requested and can tell a Phase 1 console from a |
| +// legacy one before it sends anything. None of the three reveals anything |
| +// the state did not, so every caller of the flat view sees them, the |
| +// Instance-role token included (M18). With the switch off they are absent |
| +// and the bytes are main's. |
| type moveView struct { |
| - State string `json:"state"` |
| - Target string `json:"target,omitempty"` |
| - UpdatedAt int64 `json:"updated_at,omitempty"` |
| + State string `json:"state"` |
| + Target string `json:"target,omitempty"` |
| + UpdatedAt int64 `json:"updated_at,omitempty"` |
| + ID string `json:"id,omitempty"` |
| + Last *provision.MoveOutcome `json:"last,omitempty"` |
| + Protocol string `json:"protocol,omitempty"` |
| } |
| +// moveProtocolPhase1 is the Phase 1 marker every move answer carries with |
| +// the switch on (preflight L4). The CLI refuses to send a mutation to a |
| +// console whose answer lacks it. |
| +const moveProtocolPhase1 = "phase1" |
| + |
| func (s *Server) moveViewOf(rec *provision.Record) moveView { |
| - if rec == nil || rec.Move == nil { |
| - return moveView{State: "none"} |
| + v := moveView{State: "none"} |
| + if rec != nil && rec.Move != nil { |
| + v = moveView{ |
| + State: rec.Move.State, |
| + Target: s.homeRegionOut(rec.Move.To), |
| + UpdatedAt: rec.Move.UpdatedAt.Unix(), |
| + } |
| } |
| - return moveView{ |
| - State: rec.Move.State, |
| - Target: s.homeRegionOut(rec.Move.To), |
| - UpdatedAt: rec.Move.UpdatedAt.Unix(), |
| + if s.cfg.MovesEnabled { |
| + v.Protocol = moveProtocolPhase1 |
| + if rec != nil { |
| + if rec.Move != nil { |
| + v.ID = rec.Move.ID |
| + } |
| + v.Last = rec.LastMove |
| + } |
| } |
| + return v |
| } |
| // armedCodes renders the armed half of an armedPlacements answer as the |
| @@ -251,12 +279,38 @@ func (s *Server) moveTransitionSaying(w http.ResponseWriter, r *http.Request, ac |
| // handleAPIInstanceMoveStatus answers the poll: the flat view, "none" when |
| // quiet — the tenant's freeze loop lives on this door. |
| +// |
| +// With the switch on (Plan 6 Task 2), an operator's GET with detail=1 gets |
| +// the detail answer (moveDetail); anyone else's gets the flat view, with no |
| +// error (M18). A committed landing whose claim has been converted to a |
| +// relocation (Paul's J3) answers state relocating in both, so the flat view |
| +// reads the claim while the record says committed, and only then. If that |
| +// read fails the flat view says what the record says; the poll goes on. |
| func (s *Server) handleAPIInstanceMoveStatus(w http.ResponseWriter, r *http.Request) { |
| - _, _, _, rec, ok := s.moveGate(w, r) |
| + acct, app, host, rec, ok := s.moveGate(w, r) |
| if !ok { |
| return |
| } |
| - writeJSON(w, http.StatusOK, s.moveViewOf(rec)) |
| + view := s.moveViewOf(rec) |
| + if !s.cfg.MovesEnabled { |
| + writeJSON(w, http.StatusOK, view) |
| + return |
| + } |
| + if r.URL.Query().Get("detail") == "1" { |
| + if _, isOp := s.moveOperator(r); isOp { |
| + s.writeMoveDetail(w, r, acct, app, host, rec, view) |
| + return |
| + } |
| + } |
| + if rec.Move != nil && rec.Move.State == provision.MoveCommitted { |
| + obs, err := hostclaim.Read(r.Context(), s.cfg.Releases, host) |
| + if err != nil { |
| + log.Printf("console: api instance move %s/%s/%s: status: claim read: %v", acct.Sqid, app, host, err) |
| + } else if moveConvertedAfterCommit(rec, obs) { |
| + view.State = provision.MoveRelocating |
| + } |
| + } |
| + writeJSON(w, http.StatusOK, view) |
| } |
| func (s *Server) legacyHandleAPIInstanceMoveRequest(w http.ResponseWriter, r *http.Request) { |
| @@ -361,20 +415,30 @@ func (s *Server) moveOperatorGate(w http.ResponseWriter, r *http.Request) (acct |
| if !gok { |
| return nil, "", "", nil, "", false |
| } |
| - addr, ok = s.store.CLITokenAddress(bearer(r), time.Now()) |
| + addr, ok = s.moveOperator(r) |
| if !ok { |
| http.NotFound(w, r) |
| return nil, "", "", nil, "", false |
| } |
| + return acct, app, host, rec, addr, true |
| +} |
| + |
| +// moveOperator reports whether the request presents an operator's own CLI |
| +// token, and whose. A service token, a member's token and an error reading |
| +// the operator flag are all "no" (the error is logged). |
| +func (s *Server) moveOperator(r *http.Request) (addr string, ok bool) { |
| + addr, ok = s.store.CLITokenAddress(bearer(r), time.Now()) |
| + if !ok { |
| + return "", false |
| + } |
| isOp, err := s.store.IsOperator(addr) |
| if err != nil { |
| log.Printf("console: api instance move: is-operator %s: %v", addr, err) |
| } |
| if err != nil || !isOp { |
| - http.NotFound(w, r) |
| - return nil, "", "", nil, "", false |
| + return "", false |
| } |
| - return acct, app, host, rec, addr, true |
| + return addr, true |
| } |
| // moveGuardOf is the guard a door derives from the record it read. A record |
| @@ -828,7 +892,8 @@ func (s *Server) handleAPIInstanceMoveCut(w http.ResponseWriter, r *http.Request |
| // claim-side effect: the claim stays moving until the source's interim |
| // abort deletes staging and releases it (hostclaim.ReleaseMove, Task 14). |
| // A Legacy() record takes the legacy MoveAbort (F7(a)), the one transition |
| -// that accepts it. |
| +// that accepts it. Once the cut has started the answer is M-C13 |
| +// (abortBeforeTheCut, preflight L5). |
| func (s *Server) handleAPIInstanceMoveAbort(w http.ResponseWriter, r *http.Request) { |
| if !s.cfg.MovesEnabled { |
| s.legacyHandleAPIInstanceMoveAbort(w, r) |
| @@ -842,7 +907,41 @@ func (s *Server) handleAPIInstanceMoveAbort(w http.ResponseWriter, r *http.Reque |
| s.moveTransitionP1(w, r, acct, app, host, legacyAbortOf(*rec.Move), http.StatusAccepted) |
| return |
| } |
| - s.moveTransitionP1(w, r, acct, app, host, provision.MoveGuardedAbort(moveGuardOf(rec)), http.StatusAccepted) |
| + s.moveTransitionP1(w, r, acct, app, host, abortBeforeTheCut(moveGuardOf(rec)), http.StatusAccepted) |
| +} |
| + |
| +// moveCutStartedSentence is M-C13, the abort door's answer once the cut has |
| +// started (preflight L5): the move then finishes forward or moves back by |
| +// itself, and an abort could only confuse the operator about which. |
| +const moveCutStartedSentence = "This move has started its cut. It will finish or move back on its own." |
| + |
| +// moveCutStarted is "past cutting/requested and not stuck": a cut past its |
| +// request (MoveGuardedAbort's own bar), a landing, a committed move, a |
| +// rollback or a revert. A stuck move keeps provision's refusal, and |
| +// relocating and aborting keep theirs. |
| +func moveCutStarted(m *provision.MoveState) bool { |
| + switch m.State { |
| + case provision.MoveCutting: |
| + return m.CutPhase != provision.CutRequested |
| + case provision.MoveLanding, provision.MoveCommitted, provision.MoveRollingBack, provision.MoveReverting: |
| + return true |
| + } |
| + return false |
| +} |
| + |
| +// abortBeforeTheCut is MoveGuardedAbort that refuses with M-C13 when the |
| +// journal it is about to abort, this guard's move, has started its cut. It |
| +// decides on the record SetRecordMove reads under its CAS, not on the |
| +// door's earlier read, so a cut that advances in between is still M-C13. |
| +// Anything else is MoveGuardedAbort's own answer. |
| +func abortBeforeTheCut(g provision.MoveGuard) func(*provision.Record) error { |
| + abort := provision.MoveGuardedAbort(g) |
| + return func(rec *provision.Record) error { |
| + if m := rec.Move; m != nil && m.ID == g.ID && m.Incarnation == g.Incarnation && moveCutStarted(m) { |
| + return fmt.Errorf("%w: %s", provision.ErrMoveConflict, moveCutStartedSentence) |
| + } |
| + return abort(rec) |
| + } |
| } |
| // handleAPIInstanceMoveResolve is the operator's door out of a stuck move |
| @@ -932,6 +1031,7 @@ func phase1Conflict(err error) string { |
| msg == "already aborting", |
| msg == "no move in flight", |
| msg == moveChangedSentence, |
| + msg == moveCutStartedSentence, |
| // provision.RefusedCommitted, RefusedNotStuck and |
| // RefusedNothingToDeclare (TestPhase1ConflictPassesPlan4Refusals |
| // holds the literals to the constants). |
| @@ -987,3 +1087,242 @@ func sourceEnrollment(r hostactors.Roster, box string) (string, bool) { |
| } |
| return b.Enrollment, true |
| } |
| + |
| +// ---- GET …/move?detail=1 (Plan 6 Task 2, spec §12) ---- |
| + |
| +// moveDetail is the operator's detail answer: the flat view, unchanged, and |
| +// beside it the detail object. Everything in it is a record field, a claim |
| +// field, a report the boxes published for the CLI, or an object key: never |
| +// an evidence or log body, and never a launch environment value (the |
| +// source report's environment is HMACs only, sourceReportHMACsOnly). |
| +type moveDetail struct { |
| + moveView |
| + Detail moveDetailBody `json:"detail"` |
| +} |
| + |
| +type moveDetailBody struct { |
| + // Move is the record's journal as read; nil when no move is in flight. |
| + Move *provision.MoveState `json:"move,omitempty"` |
| + // Claim is the host claim's operation; nil when the host has no claim. |
| + Claim *moveClaimView `json:"claim,omitempty"` |
| + // TargetReport and SourceReport are the reports under the move's report |
| + // prefix, nil when absent or unreadable as a report. |
| + TargetReport *moves.TargetReport `json:"target_report,omitempty"` |
| + SourceReport *moves.SourceReport `json:"source_report,omitempty"` |
| + // Evidence names the stop evidence the record points at, by key. |
| + Evidence moveEvidenceKeys `json:"evidence"` |
| + // Log is every key under the move's log/ prefix, sorted (preflight L26: |
| + // a move writes a few dozen; the CLI reads bodies only on demand). |
| + Log []string `json:"log"` |
| + // WaitingForBox is the region a relocating move waits on for a live |
| + // enrolled box (M-C22), empty otherwise and when it cannot be told. |
| + WaitingForBox string `json:"waiting_for_box,omitempty"` |
| +} |
| + |
| +// moveClaimView is the claim operation's shape and nothing else (no journal, |
| +// no holder). Target is the move's target box, Intent["target"], which M-C10 |
| +// names (preflight L28). |
| +type moveClaimView struct { |
| + Kind string `json:"kind"` |
| + Phase string `json:"phase,omitempty"` |
| + Exec string `json:"exec,omitempty"` |
| + Participants []string `json:"participants,omitempty"` |
| + Target string `json:"target,omitempty"` |
| +} |
| + |
| +// moveEvidenceKeys are the stop evidence keys the record carries: the |
| +// source's (Move.Source.Evidence, written at stopped) and the target's |
| +// (Move.Rollback.Evidence, written when a rollback released it). |
| +type moveEvidenceKeys struct { |
| + Source string `json:"source,omitempty"` |
| + Target string `json:"target,omitempty"` |
| +} |
| + |
| +// claimPhasePinning is the relocating claim's first phase, which runs on no |
| +// box (exec "none") until one is chosen. hostclaim does not export it; the |
| +// literal is hostclaim/relocating.go's (validateRelocating and |
| +// ConvertMovingToRelocating), preflight L30. |
| +const claimPhasePinning = "pinning" |
| + |
| +// moveRelocationRegion is the region of a relocating claim operation that |
| +// belongs to this record's move (the same holder incarnation), and whether |
| +// there is one. |
| +func moveRelocationRegion(rec *provision.Record, obs hostclaim.Observed) (string, bool) { |
| + if rec == nil || rec.Move == nil || obs.Absent || obs.Claim == nil || obs.Claim.Holder == nil { |
| + return "", false |
| + } |
| + c := obs.Claim |
| + if c.Op.Kind != hostclaim.KindRelocating || c.Holder.Incarnation != rec.Move.Incarnation || c.Op.Intent["region"] == "" { |
| + return "", false |
| + } |
| + return c.Op.Intent["region"], true |
| +} |
| + |
| +// moveConvertedAfterCommit is Paul's J3 as the status door sees it: the |
| +// record says committed and the claim is relocating this incarnation in the |
| +// move's target region, because the target was terminated after the commit. |
| +// The door answers relocating for it. |
| +func moveConvertedAfterCommit(rec *provision.Record, obs hostclaim.Observed) bool { |
| + region, ok := moveRelocationRegion(rec, obs) |
| + return ok && rec.Move.State == provision.MoveCommitted && region == rec.Move.To |
| +} |
| + |
| +// writeMoveDetail answers an operator's detail=1. The claim read and the |
| +// report and log reads fail closed with a fixed sentence; a report that does |
| +// not parse is left out, as the boxes treat it (no report). The region |
| +// check behind waiting_for_box never fails the answer: when it cannot tell, |
| +// the field is left out and the state still says relocating. |
| +func (s *Server) writeMoveDetail(w http.ResponseWriter, r *http.Request, acct *Account, app, host string, rec *provision.Record, view moveView) { |
| + ctx := r.Context() |
| + obs, err := hostclaim.Read(ctx, s.cfg.Releases, host) |
| + if err != nil { |
| + log.Printf("console: api instance move %s/%s/%s: detail: claim read: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusBadGateway, "could not read the host claim") |
| + return |
| + } |
| + out := moveDetail{moveView: view, Detail: moveDetailBody{Move: rec.Move, Log: []string{}}} |
| + if !obs.Absent && obs.Claim != nil { |
| + op := obs.Claim.Op |
| + out.Detail.Claim = &moveClaimView{Kind: op.Kind, Phase: op.Phase, Exec: op.Exec, Participants: op.Participants, Target: op.Intent["target"]} |
| + } |
| + if m := rec.Move; m != nil { |
| + if m.Source != nil { |
| + out.Detail.Evidence.Source = m.Source.Evidence |
| + } |
| + if m.Rollback != nil { |
| + out.Detail.Evidence.Target = m.Rollback.Evidence |
| + } |
| + } |
| + // The reports and the log of the move in flight, or of the last one to |
| + // close. A legacy journal has no id and so no report prefix. |
| + opID := "" |
| + if rec.Move != nil { |
| + opID = rec.Move.ID |
| + } else if rec.LastMove != nil { |
| + opID = rec.LastMove.ID |
| + } |
| + if provision.ValidMoveID(opID) { |
| + prefix := moves.ReportPrefix(rec.Account, rec.App, rec.Host, opID) |
| + var target moves.TargetReport |
| + var source moves.SourceReport |
| + tok, terr := s.readMoveReport(ctx, moves.TargetReportKey(prefix), &target) |
| + sok, serr := s.readMoveReport(ctx, moves.SourceReportKey(prefix), &source) |
| + keys, lerr := s.cfg.Releases.List(ctx, prefix+"log/") |
| + if err := errors.Join(terr, serr, lerr); err != nil { |
| + log.Printf("console: api instance move %s/%s/%s: detail: reports of %s: %v", acct.Sqid, app, host, opID, err) |
| + jsonError(w, http.StatusBadGateway, "could not read the move's reports. try again") |
| + return |
| + } |
| + if tok { |
| + out.Detail.TargetReport = &target |
| + } |
| + if sok { |
| + sourceReportHMACsOnly(&source) |
| + out.Detail.SourceReport = &source |
| + } |
| + slices.Sort(keys) |
| + out.Detail.Log = append(out.Detail.Log, keys...) |
| + } |
| + if (rec.Move != nil && rec.Move.State == provision.MoveRelocating) || moveConvertedAfterCommit(rec, obs) { |
| + out.State = provision.MoveRelocating |
| + if region, ok := moveRelocationRegion(rec, obs); ok && obs.Claim.Op.Phase == claimPhasePinning && obs.Claim.Op.Exec == "none" { |
| + live, known := s.regionHasLiveBox(ctx, region) |
| + if known && !live { |
| + out.Detail.WaitingForBox = s.homeRegionOut(region) |
| + } |
| + } |
| + } |
| + writeJSON(w, http.StatusOK, out) |
| +} |
| + |
| +// readMoveReport reads one report: (false, nil) when it is absent or does |
| +// not parse (logged), an error when the read failed. |
| +func (s *Server) readMoveReport(ctx context.Context, key string, into any) (bool, error) { |
| + body, _, err := s.cfg.Releases.Get(ctx, key) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return false, nil |
| + } |
| + if err != nil { |
| + return false, fmt.Errorf("read %s: %w", key, err) |
| + } |
| + if err := json.Unmarshal(body, into); err != nil { |
| + log.Printf("console: move report %s does not parse: %v", key, err) |
| + return false, nil |
| + } |
| + return true, nil |
| +} |
| + |
| +// sourceReportHMACsOnly keeps the report's environment to what ReportEnv |
| +// writes: per-name HMACs, 64 hex characters each, and a digest of them, |
| +// "sha256:" and 64 hex characters. A value of any other shape is not an |
| +// HMAC and may be the value itself, so it is dropped rather than passed to |
| +// the operator (Review Focus 2). |
| +func sourceReportHMACsOnly(r *moves.SourceReport) { |
| + vars := make(map[string]string, len(r.Env.Vars)) |
| + for k, v := range r.Env.Vars { |
| + if isHex64(v) { |
| + vars[k] = v |
| + } |
| + } |
| + r.Env.Vars = vars |
| + if d, ok := strings.CutPrefix(r.Env.Digest, "sha256:"); !ok || !isHex64(d) { |
| + r.Env.Digest = "" |
| + } |
| +} |
| + |
| +func isHex64(s string) bool { |
| + if len(s) != 64 { |
| + return false |
| + } |
| + for _, c := range s { |
| + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +// regionHasLiveBox reports whether the region (record vocabulary) has a live |
| +// enrolled box, through the region HA contracts (Task 2 rulings, L18): |
| +// boxchoice.Eligible (eligibleBoxes) over the live edges snapshot, with the |
| +// roster read through hostactors.Directory, never directly. A roster entry |
| +// counts when it is active, has an enrollment, is in the region, and has no |
| +// termination fence; its placement and cloud are the roster's. The AWS |
| +// region is the one the code's eligible boxes publish (regionCands), as the |
| +// move door selects. known is false when the snapshot is incomplete or the |
| +// roster or a fence cannot be read or trusted: then nothing is concluded. |
| +func (s *Server) regionHasLiveBox(ctx context.Context, region string) (live, known bool) { |
| + snap, err := s.liveEdges(ctx) |
| + if err != nil || !snap.complete() { |
| + log.Printf("console: move detail: the edges snapshot of %s is incomplete (%v)", region, err) |
| + return false, false |
| + } |
| + dir := hostactors.Directory{Store: s.cfg.Releases, PublicKey: s.cfg.ReleasePub, Bucket: s.cfg.Bucket} |
| + o, err := dir.Read(ctx) |
| + if err != nil { |
| + log.Printf("console: move detail: host roster: %v", err) |
| + return false, false |
| + } |
| + now := time.Now() |
| + roster := map[string]rosterBox{} |
| + for ref, b := range o.Roster.Boxes { |
| + if b.State != "active" || b.Enrollment == "" || s.homeRegionIn(b.Region) != region { |
| + continue |
| + } |
| + fenced, err := dir.TerminationFence(ctx, o, ref, now) |
| + if err != nil { |
| + log.Printf("console: move detail: termination fence of %s: %v", ref, err) |
| + return false, false |
| + } |
| + if !fenced { |
| + roster[ref] = rosterBox{Placement: b.Region, Cloud: b.Cloud} |
| + } |
| + } |
| + aws := "" |
| + for _, c := range s.regionCands(snap) { |
| + if c.Code == region { |
| + aws = c.AWSRegion |
| + } |
| + } |
| + return len(eligibleBoxes(snap, roster, region, aws, s.cfg.HomeRegion, s.expectedSecretsFP(), nil)) > 0, true |
| +} |
diff --git a/internal/console/apiinstancemove_test.go b/internal/console/apiinstancemove_test.go| index 58a334979..52f958407 100644 |
| --- a/internal/console/apiinstancemove_test.go |
| +++ b/internal/console/apiinstancemove_test.go |
| @@ -1051,7 +1051,8 @@ func TestAPIInstanceMoveVerbsPhase1(t *testing.T) { |
| t.Fatalf("abort from cutting/requested = %d %s, want 202", code, out) |
| } |
| releaseMove(g2) |
| - // Past the admission: abort is refused, the cut completes forward. |
| + // Past the admission: abort is refused with M-C13 (preflight L5), the |
| + // cut completes forward or moves back on its own. |
| if code, _, out := rawReq(t, http.MethodPost, moveURL, token, `{"region":"uk"}`); code != http.StatusAccepted { |
| t.Fatalf("third request = %d %s", code, out) |
| } |
| @@ -1062,7 +1063,7 @@ func TestAPIInstanceMoveVerbsPhase1(t *testing.T) { |
| }); err != nil { |
| t.Fatal(err) |
| } |
| - if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusConflict || errorText(out) != "past the point of no return. the cut completes forward or moves back on its own" { |
| + if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusConflict || errorText(out) != "This move has started its cut. It will finish or move back on its own." { |
| t.Fatalf("abort after admission = %d %s, want 409", code, out) |
| } |
| _ = g3 |
| @@ -1623,7 +1624,7 @@ func TestResolveRetryFromStuckOnly(t *testing.T) { |
| if err := json.Unmarshal([]byte(out), &view); err != nil { |
| t.Fatal(err) |
| } |
| - if len(view) != 3 || view["state"] != provision.MoveRollingBack || view["target"] != "uk" || view["updated_at"] == nil { |
| + if len(view) != 5 || view["state"] != provision.MoveRollingBack || view["target"] != "uk" || view["updated_at"] == nil || view["id"] != "mv_abcdefghijklmnopqrstuvwxyz" || view["protocol"] != "phase1" { |
| t.Fatalf("retry after %s answered %s, want the flat view in rolling_back", c.name, out) |
| } |
| got := read().Move |
| @@ -1661,3 +1662,384 @@ func TestResolveRetryFromStuckOnly(t *testing.T) { |
| t.Fatalf("a retried revert %+v", got) |
| } |
| } |
| + |
| +// ---- Plan 6 Task 2: the status door's shape and GET …/move?detail=1 ---- |
| + |
| +// moveAnswer is a move door's answer as a CLI reads it: the flat view |
| +// (preflight L2: id and last; L4: the Phase 1 marker) and, for an operator's |
| +// detail=1, the detail object. |
| +type moveAnswer struct { |
| + State string `json:"state"` |
| + Target string `json:"target"` |
| + UpdatedAt int64 `json:"updated_at"` |
| + ID string `json:"id"` |
| + Last *provision.MoveOutcome `json:"last"` |
| + Protocol string `json:"protocol"` |
| + Detail *struct { |
| + Move *provision.MoveState `json:"move"` |
| + Claim *struct { |
| + Kind string `json:"kind"` |
| + Phase string `json:"phase"` |
| + Exec string `json:"exec"` |
| + Participants []string `json:"participants"` |
| + Target string `json:"target"` |
| + } `json:"claim"` |
| + TargetReport *moves.TargetReport `json:"target_report"` |
| + SourceReport *moves.SourceReport `json:"source_report"` |
| + Evidence struct { |
| + Source string `json:"source"` |
| + Target string `json:"target"` |
| + } `json:"evidence"` |
| + Log []string `json:"log"` |
| + WaitingForBox string `json:"waiting_for_box"` |
| + } `json:"detail"` |
| +} |
| + |
| +func readMoveAnswer(t *testing.T, method, u, token, body string, wantCode int) (moveAnswer, string) { |
| + t.Helper() |
| + code, _, out := rawReq(t, method, u, token, body) |
| + if code != wantCode { |
| + t.Fatalf("%s %s = %d %s, want %d", method, u, code, out, wantCode) |
| + } |
| + var a moveAnswer |
| + if err := json.Unmarshal([]byte(out), &a); err != nil { |
| + t.Fatalf("%s %s: %v in %s", method, u, err, out) |
| + } |
| + return a, out |
| +} |
| + |
| +// setMove writes the record's move through the record writer, as a test |
| +// fixture (never a direct Put of the record). |
| +func setMove(t *testing.T, fx *consoleFixture, mut func(*provision.Record)) { |
| + t.Helper() |
| + if _, err := provision.SetRecordMove(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io", func(rec *provision.Record) error { |
| + mut(rec) |
| + return nil |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func recordMove(t *testing.T, fx *consoleFixture) *provision.MoveState { |
| + t.Helper() |
| + rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io") |
| + if err != nil || rec.Move == nil { |
| + t.Fatalf("record move: %v", err) |
| + } |
| + return rec.Move |
| +} |
| + |
| +// Preflight L2 and L4: with the switch on every move answer carries the |
| +// move id and the Phase 1 marker, and after a close the flat view carries |
| +// the outcome (D11's last), for every caller of the flat view, the |
| +// Instance-role token included. |
| +func TestMoveViewCarriesTheIDAndTheOutcome(t *testing.T) { |
| + fx, token, moveURL := moveDoorWithClaim(t, "code-inst-move-view") |
| + router := serviceToken(t, fx.st, OpsAccountID, "router", "titogo", svcrole.Instance) |
| + a, out := readMoveAnswer(t, http.MethodGet, moveURL, token, "", http.StatusOK) |
| + if a.State != "none" || a.Protocol != "phase1" || a.ID != "" || a.Last != nil || strings.Contains(out, `"detail"`) { |
| + t.Fatalf("status before any move = %s", out) |
| + } |
| + a, out = readMoveAnswer(t, http.MethodPost, moveURL, token, `{"region":"uk","cut":"manual"}`, http.StatusAccepted) |
| + id := recordMove(t, fx).ID |
| + if a.ID != id || !provision.ValidMoveID(id) || a.Protocol != "phase1" || a.State != provision.MovePreparing { |
| + t.Fatalf("request answered %s, want id %s and the marker", out, id) |
| + } |
| + for _, who := range []string{token, router} { |
| + if a, out := readMoveAnswer(t, http.MethodGet, moveURL, who, "", http.StatusOK); a.ID != id || a.Protocol != "phase1" || a.Last != nil { |
| + t.Fatalf("status in flight = %s", out) |
| + } |
| + } |
| + if a, out := readMoveAnswer(t, http.MethodDelete, moveURL, token, "", http.StatusAccepted); a.ID != id || a.State != provision.MoveAborting || a.Protocol != "phase1" { |
| + t.Fatalf("abort answered %s", out) |
| + } |
| + at := time.Now().UTC().Truncate(time.Second) |
| + if _, err := provision.SetRecordMove(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io", |
| + provision.MoveGuardedAbortComplete(provision.MoveGuard{ID: id, Incarnation: recordMove(t, fx).Incarnation}, at)); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, who := range []string{token, router} { |
| + a, out := readMoveAnswer(t, http.MethodGet, moveURL, who, "", http.StatusOK) |
| + if a.State != "none" || a.ID != "" || a.Last == nil || a.Last.ID != id || a.Last.Outcome != "aborted" || !a.Last.At.Equal(at) || a.Protocol != "phase1" { |
| + t.Fatalf("status after the close = %s", out) |
| + } |
| + } |
| +} |
| + |
| +// With the switch off the status door is main's, byte for byte: no id, no |
| +// last, no marker, and detail=1 is not read. |
| +func TestMoveViewSwitchOffIsMainsShape(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-inst-move-view-off") |
| + if err := fx.st.SetOperator("alice@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + m := titoManifest() |
| + m.Regions = nil |
| + m.Pool = "" |
| + enableInstances(t, fx, "titogo", m) |
| + u := instancesURL(fx, "bab", "titogo") |
| + if c, _, out := rawReq(t, http.MethodPost, u, token, `{"host":"acme.go.tito.io","region":"ie"}`); c != http.StatusCreated { |
| + t.Fatalf("create = %d %s", c, out) |
| + } |
| + moveURL := u + "/acme.go.tito.io/move" |
| + g := provision.MoveGuard{ID: "mv_abcdefghijklmnopqrstuvwxyz", Incarnation: strings.Repeat("ab", 32)} |
| + setMove(t, fx, func(rec *provision.Record) { |
| + rec.Move = &provision.MoveState{ID: g.ID, Incarnation: g.Incarnation, From: "ie", To: "uk", State: provision.MoveAborting, RequestedAt: time.Now().UTC()} |
| + }) |
| + if _, err := provision.SetRecordMove(context.Background(), fx.rel, "bab", "titogo", "acme.go.tito.io", provision.MoveGuardedAbortComplete(g, time.Now())); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, q := range []string{"", "?detail=1"} { |
| + code, _, out := rawReq(t, http.MethodGet, moveURL+q, token, "") |
| + if code != http.StatusOK || strings.TrimSpace(out) != `{"state":"none"}` { |
| + t.Fatalf("switch-off status%s = %d %q, want main's bytes", q, code, out) |
| + } |
| + } |
| +} |
| + |
| +// M18 (Task 2 rulings): detail=1 is the operator's. A member's token and |
| +// the Instance-role token get the flat view, with no detail and no error. |
| +func TestMoveDetailIsOperatorOnly(t *testing.T) { |
| + fx, token, moveURL := moveDoorWithClaim(t, "code-inst-move-dop") |
| + router := serviceToken(t, fx.st, OpsAccountID, "router", "titogo", svcrole.Instance) |
| + readMoveAnswer(t, http.MethodPost, moveURL, token, `{"region":"uk","cut":"manual"}`, http.StatusAccepted) |
| + id := recordMove(t, fx).ID |
| + a, out := readMoveAnswer(t, http.MethodGet, moveURL+"?detail=1", token, "", http.StatusOK) |
| + if a.Detail == nil || a.Detail.Move == nil || a.Detail.Move.ID != id || a.Detail.Claim == nil || a.Detail.Claim.Kind != hostclaim.KindMoving || |
| + a.Detail.Claim.Exec != "ie-box" || a.Detail.Claim.Target != "uk-box" || a.Protocol != "phase1" || a.ID != id { |
| + t.Fatalf("an operator's detail = %s", out) |
| + } |
| + flat, _ := readMoveAnswer(t, http.MethodGet, moveURL, token, "", http.StatusOK) |
| + if err := fx.st.SetOperator("alice@keymail.dev", false); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, who := range []struct{ name, token string }{{"a member", token}, {"an instance token", router}} { |
| + a, out := readMoveAnswer(t, http.MethodGet, moveURL+"?detail=1", who.token, "", http.StatusOK) |
| + if a.Detail != nil || strings.Contains(out, `"detail"`) { |
| + t.Fatalf("detail=1 as %s = %s, want the flat view", who.name, out) |
| + } |
| + if a.State != flat.State || a.ID != flat.ID || a.Target != flat.Target || a.Protocol != flat.Protocol || a.UpdatedAt != flat.UpdatedAt { |
| + t.Fatalf("detail=1 as %s = %s, want the flat view %+v", who.name, out, flat) |
| + } |
| + } |
| +} |
| + |
| +// Review Focus 2: the detail answer carries the reports' check ids, the |
| +// evidence keys and the log keys, and never a launch environment value, an |
| +// evidence or log body, a CARLOS_ value or a secret. |
| +func TestMoveDetailLeaksNothing(t *testing.T) { |
| + fx, token, moveURL := moveDoorWithClaim(t, "code-inst-move-leak") |
| + ctx := context.Background() |
| + readMoveAnswer(t, http.MethodPost, moveURL, token, `{"region":"uk","cut":"manual"}`, http.StatusAccepted) |
| + mv := recordMove(t, fx) |
| + prefix := moves.ReportPrefix("bab", "titogo", "acme.go.tito.io", mv.ID) |
| + srcKey, dstKey := moves.StoppedKey(prefix, "source"), moves.StoppedKey(prefix, "target") |
| + const rb = "rb_abcdefghijklmnopqrstuvwxyz" |
| + setMove(t, fx, func(rec *provision.Record) { |
| + rec.Move.State, rec.Move.CutPhase = provision.MoveRollingBack, provision.CutArchived |
| + rec.Move.Source = &provision.MoveSource{FinalTXID: 7, DBSHA256: strings.Repeat("d", 64), Evidence: srcKey} |
| + rec.Move.Rollback = &provision.MoveRollback{ID: rb, Reason: provision.RollbackLandingDeadline, TargetPhase: provision.RbTargetReleased, Evidence: dstKey} |
| + }) |
| + |
| + secretsKey := strings.Repeat("A", 43) + "=" |
| + env := map[string]string{ |
| + "DATABASE_URL": "postgres://app:hunter2-db-canary@db.internal/app", |
| + "STRIPE_SECRET_KEY": "sk_live_stripe_canary_value", |
| + "CARLOS_SECRETS_KEY": secretsKey, |
| + "CARLOS_APP_TOKEN": "carlos-app-token-canary", |
| + } |
| + hk, err := moves.ReportKeyFromSecrets(secretsKey) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + src := moves.SourceReport{V: 1, Box: "ie-box", MoveID: mv.ID, At: time.Now().UTC(), Env: moves.ReportEnv(hk, env), |
| + S1: moves.Check{OK: true}, S2: moves.Check{OK: true}, Release: "v0.25.0"} |
| + // A report that broke the HMAC rule: a raw value under a name. The |
| + // console passes HMACs only. |
| + src.Env.Vars["PLAIN_LEAK"] = "raw-env-value-canary" |
| + dst := moves.TargetReport{V: 1, Box: "uk-box", Region: "uk", MoveID: mv.ID, At: time.Now().UTC(), Checks: map[string]moves.Check{ |
| + moves.CheckProtocol: {OK: true}, moves.CheckEnv: {OK: false, Reason: "the launch environment differs"}, |
| + }} |
| + putJSON := func(key string, v any) { |
| + t.Helper() |
| + b, err := json.Marshal(v) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.rel.Put(ctx, key, b, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + putJSON(moves.SourceReportKey(prefix), src) |
| + putJSON(moves.TargetReportKey(prefix), dst) |
| + putJSON(srcKey, map[string]any{"v": 1, "role": "source", "note": "evidence-body-canary-source", "env": env["DATABASE_URL"]}) |
| + putJSON(dstKey, map[string]any{"v": 1, "role": "target", "note": "evidence-body-canary-target"}) |
| + logKeys := []string{moves.LogKey(prefix, 1, "requested"), moves.LogKey(prefix, 2, "rollback")} |
| + for _, k := range logKeys { |
| + putJSON(k, map[string]any{"event": "x", "note": "log-body-canary", "actor": "alice@keymail.dev"}) |
| + } |
| + |
| + a, out := readMoveAnswer(t, http.MethodGet, moveURL+"?detail=1", token, "", http.StatusOK) |
| + if a.Detail == nil || a.Detail.TargetReport == nil || a.Detail.SourceReport == nil { |
| + t.Fatalf("detail = %s, want both reports", out) |
| + } |
| + for _, leak := range []string{ |
| + env["DATABASE_URL"], "hunter2-db-canary", env["STRIPE_SECRET_KEY"], secretsKey, env["CARLOS_APP_TOKEN"], |
| + "raw-env-value-canary", "evidence-body-canary", "log-body-canary", token, |
| + } { |
| + if strings.Contains(out, leak) { |
| + t.Errorf("the detail answer holds %q:\n%s", leak, out) |
| + } |
| + } |
| + for _, want := range []string{moves.CheckProtocol, moves.CheckEnv, `"s1"`, `"s2"`, srcKey, dstKey, src.Env.Digest, src.Env.Vars["DATABASE_URL"]} { |
| + if !strings.Contains(out, want) { |
| + t.Errorf("the detail answer lacks %q:\n%s", want, out) |
| + } |
| + } |
| + if _, ok := a.Detail.SourceReport.Env.Vars["PLAIN_LEAK"]; ok { |
| + t.Errorf("a non-HMAC environment value was passed through: %+v", a.Detail.SourceReport.Env.Vars) |
| + } |
| + if a.Detail.Evidence.Source != srcKey || a.Detail.Evidence.Target != dstKey { |
| + t.Errorf("evidence = %+v, want the two keys", a.Detail.Evidence) |
| + } |
| + if !reflect.DeepEqual(a.Detail.Log, logKeys) { |
| + t.Errorf("log = %v, want %v", a.Detail.Log, logKeys) |
| + } |
| +} |
| + |
| +// Relocating (spec rev 7.20 §7.10, D16) and Paul's J3 (preflight L18): a |
| +// move whose claim is relocating/pinning with exec "none" in a region with |
| +// no live enrolled box reports waiting_for_box = that region: the source's |
| +// before the flip, the target's after a committed landing was converted. A |
| +// converted committed landing answers state relocating, in the flat view |
| +// too. "Live enrolled" is boxchoice.Eligible over the live edges snapshot, |
| +// with the roster read through hostactors.Directory: an active entry with |
| +// an enrollment and no termination fence. |
| +// waitingFor is the detail's waiting_for_box, or a marker when the answer |
| +// has no detail at all. |
| +func waitingFor(a moveAnswer) string { |
| + if a.Detail == nil { |
| + return "<no detail>" |
| + } |
| + return a.Detail.WaitingForBox |
| +} |
| + |
| +func TestMoveDetailShowsWaitingForBox(t *testing.T) { |
| + relocate := func(region, box string) func(*hostclaim.Claim) { |
| + return func(c *hostclaim.Claim) { |
| + c.Op.Kind, c.Op.Phase, c.Op.Exec, c.Op.Participants = hostclaim.KindRelocating, "pinning", "none", nil |
| + c.Op.Intent = map[string]string{"region": region, "source_box": box} |
| + } |
| + } |
| + t.Run("before the flip", func(t *testing.T) { |
| + fx, token, moveURL := moveDoorWithClaim(t, "code-inst-move-wait-src") |
| + readMoveAnswer(t, http.MethodPost, moveURL, token, `{"region":"uk","cut":"manual"}`, http.StatusAccepted) |
| + mv := recordMove(t, fx) |
| + setMove(t, fx, func(rec *provision.Record) { |
| + rec.Move.Relocation = &provision.MoveRelocationDecision{SourceBox: "ie-box", Region: "ie", PreviousState: rec.Move.State, DecidedAt: time.Now().UTC()} |
| + rec.Move.State = provision.MoveRelocating |
| + }) |
| + rewriteClaim(t, fx, "acme.go.tito.io", relocate("ie", "ie-box")) |
| + // ie-box was terminated: its fence is verified, so ie has no live |
| + // enrolled box even though its edges file is fresh. |
| + writeMoveFence(t, fx, moveRosterBox(ieBox("ie-box")), nil) |
| + router := serviceToken(t, fx.st, OpsAccountID, "router", "titogo", svcrole.Instance) |
| + if a, out := readMoveAnswer(t, http.MethodGet, moveURL, router, "", http.StatusOK); a.State != provision.MoveRelocating || a.ID != mv.ID { |
| + t.Fatalf("flat view = %s", out) |
| + } |
| + a, out := readMoveAnswer(t, http.MethodGet, moveURL+"?detail=1", token, "", http.StatusOK) |
| + if a.State != provision.MoveRelocating || waitingFor(a) != "ie" { |
| + t.Fatalf("detail with no box in ie = %s, want waiting_for_box ie", out) |
| + } |
| + // A live enrolled box joins ie: nothing is waiting. |
| + armMoveFleet(t, fx, ieBox("ie-box2")) |
| + putMoveRoster(t, fx, hostactors.Roster{Boxes: map[string]hostactors.Box{"ie-box2": moveRosterBox(ieBox("ie-box2")), "uk-box": moveRosterBox(ukBox("uk-box"))}}) |
| + if a, out := readMoveAnswer(t, http.MethodGet, moveURL+"?detail=1", token, "", http.StatusOK); a.State != provision.MoveRelocating || waitingFor(a) != "" { |
| + t.Fatalf("detail with a live box in ie = %s", out) |
| + } |
| + // The same box, live but not enrolled in the roster, is not one. |
| + putMoveRoster(t, fx, hostactors.Roster{Boxes: map[string]hostactors.Box{"uk-box": moveRosterBox(ukBox("uk-box"))}}) |
| + if a, out := readMoveAnswer(t, http.MethodGet, moveURL+"?detail=1", token, "", http.StatusOK); waitingFor(a) != "ie" { |
| + t.Fatalf("detail with an unenrolled box in ie = %s, want waiting_for_box ie", out) |
| + } |
| + }) |
| + t.Run("after the commit (J3)", func(t *testing.T) { |
| + fx, token, moveURL := moveDoorWithClaim(t, "code-inst-move-wait-dst") |
| + readMoveAnswer(t, http.MethodPost, moveURL, token, `{"region":"uk","cut":"manual"}`, http.StatusAccepted) |
| + now := time.Now().UTC() |
| + setMove(t, fx, func(rec *provision.Record) { |
| + rec.Region = "uk" |
| + rec.Move.State, rec.Move.CutPhase, rec.Move.FlippedAt, rec.Move.CommittedAt = provision.MoveCommitted, provision.CutFlipped, &now, &now |
| + }) |
| + // Not converted: the record's own state. |
| + if a, out := readMoveAnswer(t, http.MethodGet, moveURL+"?detail=1", token, "", http.StatusOK); a.State != provision.MoveCommitted || waitingFor(a) != "" { |
| + t.Fatalf("a committed landing = %s", out) |
| + } |
| + rewriteClaim(t, fx, "acme.go.tito.io", relocate("uk", "uk-box")) |
| + writeMoveFence(t, fx, moveRosterBox(ukBox("uk-box")), nil) |
| + router := serviceToken(t, fx.st, OpsAccountID, "router", "titogo", svcrole.Instance) |
| + if a, out := readMoveAnswer(t, http.MethodGet, moveURL, router, "", http.StatusOK); a.State != provision.MoveRelocating { |
| + t.Fatalf("flat view of a converted landing = %s, want relocating", out) |
| + } |
| + a, out := readMoveAnswer(t, http.MethodGet, moveURL+"?detail=1", token, "", http.StatusOK) |
| + if a.State != provision.MoveRelocating || waitingFor(a) != "uk" || (a.Detail != nil && a.Detail.Move.State != provision.MoveCommitted) { |
| + t.Fatalf("detail of a converted landing with no box in uk = %s, want waiting_for_box uk", out) |
| + } |
| + // A live enrolled box joins uk: still relocating, nothing waiting. |
| + armMoveFleet(t, fx, ukBox("uk-box2")) |
| + putMoveRoster(t, fx, hostactors.Roster{Boxes: map[string]hostactors.Box{"ie-box": moveRosterBox(ieBox("ie-box")), "uk-box2": moveRosterBox(ukBox("uk-box2"))}}) |
| + if a, out := readMoveAnswer(t, http.MethodGet, moveURL+"?detail=1", token, "", http.StatusOK); a.State != provision.MoveRelocating || waitingFor(a) != "" { |
| + t.Fatalf("detail with a live box in uk = %s", out) |
| + } |
| + // Past pinning the relocation has its box: nothing waits. |
| + putMoveRoster(t, fx, hostactors.Roster{Boxes: map[string]hostactors.Box{"ie-box": moveRosterBox(ieBox("ie-box"))}}) |
| + rewriteClaim(t, fx, "acme.go.tito.io", func(c *hostclaim.Claim) { |
| + c.Op.Phase, c.Op.Exec, c.Op.Intent["target_box"] = "installing", "uk-box2", "uk-box2" |
| + }) |
| + if a, out := readMoveAnswer(t, http.MethodGet, moveURL+"?detail=1", token, "", http.StatusOK); a.State != provision.MoveRelocating || waitingFor(a) != "" { |
| + t.Fatalf("detail of a relocation that is installing = %s", out) |
| + } |
| + }) |
| +} |
| + |
| +// Preflight L5 (claim-off half): once the cut has started (cutting past |
| +// requested, landing, committed, rolling back, reverting) the abort door |
| +// answers M-C13 and writes nothing. Before it, abort is the guarded |
| +// transition; a stuck move keeps provision's own refusal. |
| +func TestMoveAbortAfterTheCutStartedSaysSo(t *testing.T) { |
| + const mc13 = "This move has started its cut. It will finish or move back on its own." |
| + fx, token, moveURL := moveDoorWithClaim(t, "code-inst-move-mc13") |
| + readMoveAnswer(t, http.MethodPost, moveURL, token, `{"region":"uk","cut":"manual"}`, http.StatusAccepted) |
| + for _, c := range []struct { |
| + name string |
| + mut func(*provision.MoveState) |
| + }{ |
| + {"cutting/admitted", func(m *provision.MoveState) { m.State, m.CutPhase = provision.MoveCutting, provision.CutAdmitted }}, |
| + {"cutting/flipped", func(m *provision.MoveState) { m.State, m.CutPhase = provision.MoveCutting, provision.CutFlipped }}, |
| + {"landing", func(m *provision.MoveState) { m.State = provision.MoveLanding }}, |
| + {"committed", func(m *provision.MoveState) { m.State = provision.MoveCommitted }}, |
| + {"rolling back", func(m *provision.MoveState) { m.State = provision.MoveRollingBack }}, |
| + {"reverting", func(m *provision.MoveState) { m.State = provision.MoveReverting }}, |
| + } { |
| + setMove(t, fx, func(rec *provision.Record) { c.mut(rec.Move) }) |
| + before, _ := json.Marshal(recordMove(t, fx)) |
| + if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusConflict || errorText(out) != mc13 { |
| + t.Fatalf("abort in %s = %d %s, want M-C13", c.name, code, out) |
| + } |
| + if after, _ := json.Marshal(recordMove(t, fx)); string(after) != string(before) { |
| + t.Fatalf("a refused abort in %s wrote", c.name) |
| + } |
| + } |
| + setMove(t, fx, func(rec *provision.Record) { |
| + rec.Move.State, rec.Move.CutPhase, rec.Move.StuckReason = provision.MoveStuck, provision.CutArchived, provision.StuckBaselineMismatch |
| + }) |
| + if code, _, out := rawReq(t, http.MethodDelete, moveURL, token, ""); code != http.StatusConflict || errorText(out) == mc13 { |
| + t.Fatalf("abort of a stuck move = %d %s, want provision's refusal", code, out) |
| + } |
| + setMove(t, fx, func(rec *provision.Record) { |
| + rec.Move.State, rec.Move.CutPhase, rec.Move.StuckReason = provision.MoveCutting, provision.CutRequested, "" |
| + }) |
| + if a, out := readMoveAnswer(t, http.MethodDelete, moveURL, token, "", http.StatusAccepted); a.State != provision.MoveAborting { |
| + t.Fatalf("abort from cutting/requested = %s", out) |
| + } |
| +} |