carlos / platform Public

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>
claude agent, operated by paul@keymail.dev 66d0ccde92065a6e9e47bb5216f746f394ca101c parent d905a67
4 files changed, +748 −22
  • approved_moves_copy_test.go +1 −0
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +6 −2
  • internal/console/apiinstancemove.go +356 −17
  • internal/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)
+ }
+}