carlos move: request, status, cut, abort, resolve; --wait and the exit codes (§12)
Plan 6 Task 1a, with Task 6's docs folded in (rulings L2, L4, L13, L16, L17, L18, L19, L24, L25): - carlos move [status|cut|abort|resolve] --host <h> ..., through the console, as an operator. Every verb reads the move status first and sends nothing unless the answer carries "protocol":"phase1"; it refuses to run as root. - The wait follows the move id from the mutation's answer, polls the flat view every 2 s, reads detail=1 on a change (and every 30 s), backs off to 10 s on a 429 without exiting, and reads the outcome only from last, bound to that id. Exit codes: 0 done, 3 rolled back or aborted, 4 stuck, waiting, relocating, ready for a manual cut or not recorded, 5 refused, 1 transport or 5xx, 2 usage. - Sentences: M-C1 to M-C12 and M-C22 spoken verbatim from move.go through one sink (moveSay) and approved in the record; MV-N325 to MV-N339 added pending. The copy gate scans move.go and main.go; an allow-listed function's underivable value no longer asks for a row (tested). - docs/site/cli.md: the carlos move section, with TestMoveDocsNameEveryVerb parsing its examples through parseMoveArgs. - The move detail carries from and region (homeRegionOut) for the CLI's sentences. - B8 census: main's digest (the dispatch line) and moveMain's key-site row.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9 files changed,
+1354
−18
approved_moves_copy_test.go+36 −2docs/site/cli.md+34 −0docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+29 −14internal/console/apiinstancemove.go+9 −1internal/console/apiinstancemove_test.go+7 −0internal/provision/plan9_b8_census_test.go+4 −1main.go+4 −0move.go+667 −0move_test.go+564 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go| index 771474c20..b614dc39a 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -76,6 +76,8 @@ var movesCopyIDs = []string{ |
| "MV-N313", "MV-N314", |
| "MV-N315", "MV-N316", "MV-N317", "MV-N318", |
| "MV-N319", |
| + "MV-N325", "MV-N326", "MV-N327", "MV-N328", "MV-N329", "MV-N330", "MV-N331", |
| + "MV-N332", "MV-N333", "MV-N334", "MV-N335", "MV-N336", "MV-N337", "MV-N338", "MV-N339", |
| } |
| // copyRow is one row of the record. The File cell is the file the sentence is |
| @@ -673,6 +675,8 @@ var movesScanFiles = []string{ |
| "internal/stopevidence/reconstruct.go", |
| "internal/stopevidence/reconstruct_linux.go", |
| "internal/stopevidence/reconstruct_other.go", |
| + "main.go", |
| + "move.go", |
| } |
| // The scan's base is the main commit moves-phase1 last merged: the diff from |
| @@ -731,6 +735,10 @@ var movesLocalSinks = map[string]map[string]int{ |
| "internal/moves/readiness.go": {"fail": 0, "reasonOr": 1}, |
| "internal/host/movesreport.go": {"fail": 0}, |
| "internal/console/apiinstancemove.go": {"moveTransitionSaying": 8}, |
| + // Every operator sentence of `carlos move` goes through moveSay, L13's |
| + // say (named so because the scan resolves helpers by name, and |
| + // internal/release has a say of its own). |
| + "move.go": {"moveSay": 1}, |
| } |
| var movesCopyTables = map[string][]string{ |
| @@ -809,7 +817,13 @@ var movesFuncSinks = map[string]map[string]int{ |
| // An entry is only for a value that never reaches an operator, and says why. |
| // A value that does reach an operator is mapped to a recorded sentence in the |
| // code instead. TestMovesSinkAllowListIsHonest holds the list to this. |
| -var movesSinkAllow = map[string]map[string]string{} |
| +var movesSinkAllow = map[string]map[string]string{ |
| + "move.go": { |
| + "sayStuck": "prints, after M-C11, the record's stuck reason, a sentence the provision transitions record (MV-N300 to MV-N312 and the rows before them), on its own line (preflight L28)", |
| + "sayRefusal": "passes on the console's refusal, a sentence recorded with the door that speaks it (M-C13, M-C17, MV-N1 and the rest of the console rows)", |
| + "sayError": "reports a transport or decoding error from net/http or encoding/json, Go's text and not a sentence of this file", |
| + }, |
| +} |
| // movesMappingHelpers are functions that map a refusal from another package |
| // onto the sentence an operator sees, echoing the refusal's own text only in a |
| @@ -886,13 +900,19 @@ func movesLegacy(e movesEmission) bool { |
| return ok |
| } |
| -// findUnrecorded returns the emissions no row covers. |
| +// findUnrecorded returns the emissions no row covers. A value the gate cannot |
| +// derive, inside a function movesSinkAllow lists, is not this file's sentence: |
| +// the entry's reason says whose it is, so no row could carry it. A sentence |
| +// the gate can derive in such a function still needs its row. |
| func findUnrecorded(ems []movesEmission, rows []copyRow) []movesEmission { |
| var out []movesEmission |
| for _, e := range ems { |
| if movesLegacy(e) { |
| continue |
| } |
| + if _, allowed := movesSinkAllow[e.file][e.fn]; allowed && e.underived { |
| + continue |
| + } |
| found := false |
| for _, r := range rows { |
| match := movesRowMatches |
| @@ -1507,6 +1527,20 @@ func TestMovesSinkAllowListIsHonest(t *testing.T) { |
| if got := checkSinkAllow(map[string]map[string]string{"a.go": {"f": "this value is only ever logged"}}, vios); len(got) != 0 { |
| t.Errorf("a good entry: %q", got) |
| } |
| + |
| + // An allow-listed function's underivable value needs no row; its |
| + // derivable sentences still do, and another function's value does. |
| + saved := movesSinkAllow |
| + defer func() { movesSinkAllow = saved }() |
| + movesSinkAllow = map[string]map[string]string{"a.go": {"f": "passes on a sentence recorded elsewhere"}} |
| + ems := []movesEmission{ |
| + {file: "a.go", fn: "f", kind: "say", underived: true}, |
| + {file: "a.go", fn: "f", kind: "say", pattern: "an unrecorded sentence of f"}, |
| + {file: "a.go", fn: "g", kind: "say", underived: true}, |
| + } |
| + if un := findUnrecorded(ems, nil); len(un) != 2 || un[0].fn != "f" || un[0].underived || un[1].fn != "g" { |
| + t.Errorf("unrecorded = %v, want f's derived sentence and g's value", un) |
| + } |
| } |
| // "valid region" is not "invalid region", though the record's text contains it. |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index aa73211c8..f3928b8f5 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -2047,6 +2047,40 @@ would linger. |
| Ops account owners and `CARLOS_SYSTEM_OPERATORS`. |
| +### carlos move |
| + |
| +Moves one instance to another region. The instance keeps serving while its |
| +data is copied, then goes offline for the cut. If the new region cannot start |
| +it, the move takes it back to where it was, on its own. |
| + |
| +Moves are off unless the console runs with `CARLOS_MOVES_PHASE1` on. Against |
| +any other console, `carlos move` sends nothing. Run it as yourself, not as |
| +root. |
| + |
| +```sh |
| +carlos move --host <host> --to <region> [--manual-cut] [--no-wait] |
| +carlos move status --host <host> [--wait] |
| +carlos move cut --host <host> |
| +carlos move abort --host <host> |
| +carlos move resolve --host <host> --retry |
| +``` |
| + |
| +Each command waits until the move ends unless you pass `--no-wait`. |
| +`--manual-cut` stops once the move is ready, and `carlos move cut` starts the |
| +cut. `carlos move status --wait` picks a wait back up after a disconnect. |
| +`--app` and `--account` name the instance's app and account, as for |
| +`carlos instances`. |
| + |
| +| exit code | meaning | |
| +|---|---| |
| +| `0` | the move finished | |
| +| `3` | the move rolled back or was aborted | |
| +| `4` | the move needs an operator: it is stuck, waiting for a box, or ready for a manual cut, or its outcome was not recorded | |
| +| `5` | refused, by the console or before anything was sent | |
| +| `1` | the console could not be reached, or answered with an error | |
| + |
| +Operators who are members of the instance's account. |
| + |
| ## On the host |
| These are what systemd starts. You will rarely type them, but knowing what |
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 39ea2cff7..c7ce8f74a 100644 |
| --- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| +++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md |
| @@ -13,18 +13,18 @@ Columns: id, file, text, status. |
| | id | file | text | status | |
| |---|---|---|---| |
| -| M-C1 | move.go | Moving <host> from <from> to <to>. | pending | |
| -| M-C2 | move.go | Copying data. <host> keeps serving while this runs. | pending | |
| -| M-C3 | move.go | Checking that <to> can run <host>. | pending | |
| -| M-C4 | move.go | <to> cannot take this move yet: <check>. | pending | |
| -| M-C5 | move.go | <to> is ready. Starting the cut. <host> will be offline for about <estimate>. | pending | |
| -| M-C6 | move.go | Stopped in <from>. Starting in <to>. | pending | |
| -| M-C7 | move.go | Moved. <host> is served from <to>. It was offline for <seconds> seconds. | pending | |
| -| M-C8 | move.go | <to> could not start <host>: <reason>. Moving it back to <from>. | pending | |
| -| M-C9 | move.go | <host> is back in <from> and serving. Nothing was lost. | pending | |
| -| 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-C1 | move.go | Moving <host> from <from> to <to>. | approved | |
| +| M-C2 | move.go | Copying data. <host> keeps serving while this runs. | approved | |
| +| M-C3 | move.go | Checking that <to> can run <host>. | approved | |
| +| M-C4 | move.go | <to> cannot take this move yet: <check>. | approved | |
| +| M-C5 | move.go | <to> is ready. Starting the cut. <host> will be offline for about <estimate>. | approved | |
| +| M-C6 | move.go | Stopped in <from>. Starting in <to>. | approved | |
| +| M-C7 | move.go | Moved. <host> is served from <to>. It was offline for <seconds> seconds. | approved | |
| +| M-C8 | move.go | <to> could not start <host>: <reason>. Moving it back to <from>. | approved | |
| +| M-C9 | move.go | <host> is back in <from> and serving. Nothing was lost. | approved | |
| +| M-C10 | move.go | Waiting for <box> in <to> to confirm it has stopped <host>. <host> is offline. | approved | |
| +| 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. | approved | |
| +| M-C12 | move.go | Ready to cut. Run carlos move cut --host <host> when you are ready. | approved | |
| | 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 | |
| @@ -34,7 +34,7 @@ Columns: id, file, text, status. |
| | M-C19 | internal/console/apiinstancemove.go | <host> is being moved. Delete it after the move finishes, or abort the move first. | approved | |
| | M-C20 | internal/host/movespaging.go | <box> is running a process for <host> that this move cannot account for. An operator has been paged. | pending | |
| | M-C21 | internal/console/apiinstancemove.go | <region> has more than one box. Moves between regions with several boxes are not supported yet. | approved | |
| -| M-C22 | move.go | <host> is waiting for a box in <region>. Its last box was terminated before the move finished. Its data up to the last completed upload is kept in <region>. It will start when a box joins the region, and any writes after that upload will be reported then. | pending | |
| +| M-C22 | move.go | <host> is waiting for a box in <region>. Its last box was terminated before the move finished. Its data up to the last completed upload is kept in <region>. It will start when a box joins the region, and any writes after that upload will be reported then. | approved | |
| ## Plan 1 |
| @@ -284,11 +284,26 @@ Columns: id, file, text, status. |
| ## Plan 6 |
| -(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. Task 2b made the delete door speak M-C19 through moveDeleteRefusal in internal/console/apiinstancemove.go, so its File cell moves there (preflight L6) and it is approved verbatim) |
| +(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. Task 2b made the delete door speak M-C19 through moveDeleteRefusal in internal/console/apiinstancemove.go, so its File cell moves there (preflight L6) and it is approved verbatim. Task 1a takes MV-N325 to MV-N339 for `carlos move` (move.go, and its usage line in main.go), and speaks M-C1 to M-C12 and M-C22 verbatim from move.go, so those rows are approved) |
| | id | file | text | status | |
| |---|---|---|---| |
| | MV-N319 | internal/console/apiinstancemove.go | could not read the move's reports. try again | pending | |
| +| MV-N325 | move.go | Moves are not enabled on this console. | pending | |
| +| MV-N326 | move.go | carlos move needs --app: name the app the instance belongs to. | pending | |
| +| MV-N327 | move.go | Run carlos move as yourself, not as root. It uses your own credentials. | pending | |
| +| MV-N328 | move.go | carlos move works through a console. Unset CARLOS_DEPLOYMENT_BUCKET and CARLOS_DEPLOYMENT_DIR and run carlos auth login. | pending | |
| +| MV-N329 | move.go | This move's outcome is not recorded. | pending | |
| +| MV-N330 | move.go | The console is limiting requests. Checking every 10 seconds. | pending | |
| +| MV-N331 | move.go | carlos move needs --host <host>. | pending | |
| +| MV-N332 | move.go | carlos move needs --to <region>. | pending | |
| +| MV-N333 | move.go | carlos move resolve needs --retry. | pending | |
| +| MV-N334 | move.go | No move is in flight for <host>. | pending | |
| +| MV-N335 | move.go | Move <id> of <host> is <state>. | pending | |
| +| MV-N336 | move.go | The move was aborted. <host> stays in <from>. | pending | |
| +| MV-N337 | move.go | <host> is relocating after its box was terminated. This move will not finish. | pending | |
| +| MV-N338 | main.go | move an instance to another region: status | cut | abort | resolve --retry (operators; off unless the console enables it) | pending | |
| +| MV-N339 | move.go (M-C10's <box> when the claim names none) | the target box | pending | |
| ## Review of 2026-10-09 |
diff --git a/internal/console/apiinstancemove.go b/internal/console/apiinstancemove.go| index e7dd25147..7c0837864 100644 |
| --- a/internal/console/apiinstancemove.go |
| +++ b/internal/console/apiinstancemove.go |
| @@ -1265,6 +1265,11 @@ type moveDetailBody struct { |
| // 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"` |
| + // From is the move's origin and Region the record's region now, each |
| + // named as the caller names it (homeRegionOut), so the CLI's sentences |
| + // (M-C1, M-C7, M-C9) never print the record's "" for the home. |
| + From string `json:"from,omitempty"` |
| + Region string `json:"region"` |
| } |
| // moveSourceReport is the source report with its environment replaced by |
| @@ -1355,7 +1360,10 @@ func (s *Server) writeMoveDetail(w http.ResponseWriter, r *http.Request, acct *A |
| jsonError(w, http.StatusBadGateway, "could not read the host claim") |
| return |
| } |
| - out := moveDetail{moveView: view, Detail: moveDetailBody{Move: rec.Move, Log: []string{}}} |
| + out := moveDetail{moveView: view, Detail: moveDetailBody{Move: rec.Move, Log: []string{}, Region: s.homeRegionOut(rec.Region)}} |
| + if rec.Move != nil { |
| + out.Detail.From = s.homeRegionOut(rec.Move.From) |
| + } |
| 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"]} |
diff --git a/internal/console/apiinstancemove_test.go b/internal/console/apiinstancemove_test.go| index bd80efd44..a6626ca6e 100644 |
| --- a/internal/console/apiinstancemove_test.go |
| +++ b/internal/console/apiinstancemove_test.go |
| @@ -1701,6 +1701,8 @@ type moveAnswer struct { |
| } `json:"evidence"` |
| Log []string `json:"log"` |
| WaitingForBox string `json:"waiting_for_box"` |
| + From string `json:"from"` |
| + Region string `json:"region"` |
| } `json:"detail"` |
| } |
| @@ -1818,6 +1820,11 @@ func TestMoveDetailIsOperatorOnly(t *testing.T) { |
| 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) |
| } |
| + // The CLI's sentences name the regions as the caller does (Task 1a): |
| + // the move's origin and the record's region now. |
| + if a.Detail.From != "ie" || a.Detail.Region != "ie" { |
| + t.Fatalf("detail from %q region %q, want ie and ie: %s", a.Detail.From, a.Detail.Region, out) |
| + } |
| flat, _ := readMoveAnswer(t, http.MethodGet, moveURL, token, "", http.StatusOK) |
| if err := fx.st.SetOperator("alice@keymail.dev", false); err != nil { |
| t.Fatal(err) |
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go| index 258957971..2d77a0491 100644 |
| --- a/internal/provision/plan9_b8_census_test.go |
| +++ b/internal/provision/plan9_b8_census_test.go |
| @@ -561,7 +561,10 @@ var plan9B8CensusInventory = []censusEntry{ |
| // Re-signs only the validated channel pointer; the digest-marker helper only reads. |
| {"internal/release/resign.go", "Signer.claimRawResign", "nonrecord", "sel:Put", "30b9a49890bde1ef0f26886c3dd3eb385de4502bc191e1e9a96cd1b75d4cc8e5"}, |
| // Dispatches CLI verb names including instances and sidecars; record writes remain in reviewed handlers. |
| - {"main.go", "main", "key-site", "lit:instances,lit:sidecars", "7aff68c454fa65dc19fb8c0cbb08250066aa4aeca629b43df3325b8e7932694d"}, |
| + {"main.go", "main", "key-site", "lit:instances,lit:sidecars", "3eb1b1a9b8a7fd9cc2820b5a4348046b4f6bac3496d5ac6c4e2c32722233bd15"}, |
| + // Plan 6 Task 1a: `carlos move` builds the console's move door path |
| + // (/api/cli/apps/.../instances/<host>/move); it writes no record. |
| + {"move.go", "moveMain", "key-site", "lit:instances", "16fb7f2e98a020b9bc69ce0de4af807aee690f6baec51b4e11ff12aa7e705358"}, |
| {"schedule.go", "renderScheduleList", "key-site", "lit:instances", "af6d666083fbbc753eecc98809d3d307344cc15110362a7e93afab573749ea6e"}, |
| {"internal/activator/layout.go", "LayoutInputs.sockets", "key-site", "lit:instances", "1d11ac5ac5be6a42e676e064771141fe8f4367c78cbb7e5c5cca072f40c27c42"}, |
| {"internal/activator/paths.go", "ExecBackend.pathsFor", "key-site", "lit:instances", "cbc01865e27934cb38baab9329a0867b899149531b4706a533a6956afc7c71b3"}, |
diff --git a/main.go b/main.go| index 21bff32f4..d366e39da 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -161,6 +161,8 @@ func main() { |
| runOps(args) |
| case "system": |
| runSystem(args) |
| + case "move": |
| + runMove(args) |
| case "status": |
| RunStatus(args) |
| case "agent": |
| @@ -293,6 +295,8 @@ Your deployment — stand up and administer boxes, routes and accounts |
| economics bill: record the month's AWS bill; backhaul: what region |
| edges cost us this period |
| status --live: what every box last reported; --dry-run: leak check |
| + move move an instance to another region: status | cut | abort | |
| + resolve --retry (operators; off unless the console enables it) |
| On the host — what systemd runs; rarely typed by hand |
| edge the mesh front door (router) |
diff --git a/move.go b/move.go| new file mode 100644 |
| index 000000000..88100e4bb |
| --- /dev/null |
| +++ b/move.go |
| @@ -0,0 +1,667 @@ |
| +package main |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "math" |
| + "net/http" |
| + "net/url" |
| + "os" |
| + "slices" |
| + "strings" |
| + "time" |
| +) |
| + |
| +// `carlos move` (spec §12, moves Phase 1 Plan 6 Task 1a): request a move of |
| +// one instance to another region, read its state, cut, abort, and retry a |
| +// stuck one. Operators only, through the console, with |
| +// CARLOS_MOVES_PHASE1 on there. The verb refuses to send anything to a |
| +// console whose move answer lacks the Phase 1 marker (preflight L4), so it |
| +// can never start a legacy move. |
| +// |
| +// Waiting is the default. The wait polls the flat view every two seconds, |
| +// reads detail=1 when the state changes (and now and then while it does |
| +// not), backs off to ten seconds on a 429 and keeps going (L19), and reads |
| +// the outcome only from `last`, bound to the id the move was requested |
| +// under (L2, Review Focus 1). Exit codes (L24): 0 done; 3 rolled back or |
| +// aborted; 4 stuck, rolling back past its release deadline, relocating, |
| +// ready for a manual cut, or an outcome not recorded; 5 refused (by the |
| +// console, or before anything was sent); 1 a transport error or a 5xx; 2 a |
| +// usage error. |
| +// |
| +// Every operator sentence goes through say, the copy gate's sink for this |
| +// file (movesLocalSinks). The functions that pass on a sentence recorded |
| +// elsewhere (the console's refusals, the record's stuck reason, transport |
| +// errors) are listed in movesSinkAllow with the reason. |
| + |
| +const ( |
| + moveExitDone = 0 |
| + moveExitError = 1 |
| + moveExitUsage = 2 |
| + moveExitRolled = 3 |
| + moveExitWaiting = 4 |
| + moveExitRefused = 5 |
| + moveProtocolMark = "phase1" |
| +) |
| + |
| +// The sentences this file adds (copy record MV-N325 to MV-N339, pending). |
| +const ( |
| + moveNotEnabled = "Moves are not enabled on this console." |
| + moveNoApp = "carlos move needs --app: name the app the instance belongs to." |
| + moveAsRoot = "Run carlos move as yourself, not as root. It uses your own credentials." |
| + moveDirectMode = "carlos move works through a console. Unset CARLOS_DEPLOYMENT_BUCKET and CARLOS_DEPLOYMENT_DIR and run carlos auth login." |
| + moveNotRecorded = "This move's outcome is not recorded." |
| + moveLimited = "The console is limiting requests. Checking every 10 seconds." |
| + moveNeedsHost = "carlos move needs --host <host>." |
| + moveNeedsTo = "carlos move needs --to <region>." |
| + moveNeedsRetry = "carlos move resolve needs --retry." |
| +) |
| + |
| +func moveSay(out io.Writer, line string) { |
| + fmt.Fprintln(out, line) |
| +} |
| + |
| +func runMove(args []string) { |
| + deps := moveDeps{session: resolveMode, euid: os.Geteuid, poll: 2 * time.Second} |
| + os.Exit(moveMain(context.Background(), args, os.Stdout, deps)) |
| +} |
| + |
| +// moveDeps are what moveMain reaches outside itself, so a test can run it |
| +// against an httptest console. |
| +type moveDeps struct { |
| + session func(consoleFlag string) (*apiSession, error) |
| + euid func() int |
| + poll time.Duration |
| +} |
| + |
| +type moveArgs struct { |
| + verb string // request | status | cut | abort | resolve |
| + host, to, app string |
| + account, accountID string |
| + console string |
| + manualCut, noWait, wait bool |
| + retry bool |
| +} |
| + |
| +// parseMoveArgs reads `carlos move [status|cut|abort|resolve] <flags>`. Each |
| +// verb takes only its own flags; a flag another verb owns is a usage error. |
| +// Usage problems are said on out and returned as an error. |
| +func parseMoveArgs(args []string, out io.Writer) (moveArgs, error) { |
| + a := moveArgs{verb: "request"} |
| + if len(args) > 0 { |
| + switch args[0] { |
| + case "status", "cut", "abort", "resolve": |
| + a.verb, args = args[0], args[1:] |
| + } |
| + } |
| + fs := flag.NewFlagSet("move "+a.verb, flag.ContinueOnError) |
| + fs.SetOutput(out) |
| + fs.StringVar(&a.host, "host", "", "the instance's host name") |
| + fs.StringVar(&a.app, "app", "", "the app the instance belongs to") |
| + fs.StringVar(&a.app, "a", "", "shorthand for --app") |
| + fs.StringVar(&a.account, "account", "", "the account the app belongs to (default: your default account)") |
| + fs.StringVar(&a.accountID, "account-id", "", "the account, by its id") |
| + fs.StringVar(&a.console, "console", "", "the console to work through (default: the only logged-in one)") |
| + switch a.verb { |
| + case "request": |
| + fs.StringVar(&a.to, "to", "", "the region to move the instance to") |
| + fs.BoolVar(&a.manualCut, "manual-cut", false, "stop when the move is ready, and cut with carlos move cut") |
| + fs.BoolVar(&a.noWait, "no-wait", false, "return once the console has accepted the request") |
| + case "status": |
| + fs.BoolVar(&a.wait, "wait", false, "wait for the move in flight, or the last one, to finish") |
| + case "cut", "abort": |
| + fs.BoolVar(&a.noWait, "no-wait", false, "return once the console has accepted it") |
| + case "resolve": |
| + fs.BoolVar(&a.retry, "retry", false, "retry a stuck move once its cause is fixed") |
| + fs.BoolVar(&a.noWait, "no-wait", false, "return once the console has accepted it") |
| + } |
| + if err := fs.Parse(args); err != nil { |
| + return moveArgs{}, err |
| + } |
| + if fs.NArg() > 0 { |
| + moveSay(out, moveNeedsHost) |
| + return moveArgs{}, errors.New("unexpected arguments") |
| + } |
| + switch { |
| + case a.host == "": |
| + moveSay(out, moveNeedsHost) |
| + return moveArgs{}, errors.New("no --host") |
| + case a.verb == "request" && a.to == "": |
| + moveSay(out, moveNeedsTo) |
| + return moveArgs{}, errors.New("no --to") |
| + case a.verb == "resolve" && !a.retry: |
| + moveSay(out, moveNeedsRetry) |
| + return moveArgs{}, errors.New("no --retry") |
| + } |
| + if a.verb != "status" { |
| + a.wait = !a.noWait |
| + } |
| + return a, nil |
| +} |
| + |
| +// moveMain runs one `carlos move` and returns its exit code. |
| +func moveMain(ctx context.Context, args []string, out io.Writer, d moveDeps) int { |
| + a, err := parseMoveArgs(args, out) |
| + if errors.Is(err, flag.ErrHelp) { |
| + return moveExitDone |
| + } |
| + if err != nil { |
| + return moveExitUsage |
| + } |
| + // L25: the operator's own credentials are what count (declare-lost reads |
| + // the cloud with them), and run as root rootaws replaces them. One rule |
| + // for the whole verb. |
| + if d.euid() == 0 { |
| + moveSay(out, moveAsRoot) |
| + return moveExitRefused |
| + } |
| + sess, err := d.session(a.console) |
| + if err != nil { |
| + sayError(out, err) |
| + return moveExitError |
| + } |
| + if sess == nil { |
| + moveSay(out, moveDirectMode) |
| + return moveExitRefused |
| + } |
| + acct := resolvedAccount(sess, &a.account, &a.accountID) |
| + app := resolvedApp(sess, acct, a.app, moveNoApp) |
| + m := &mover{sess: sess, out: out, host: a.host, poll: d.poll, |
| + path: apiPath(acct, app, "/instances/"+url.PathEscape(a.host)+"/move")} |
| + |
| + // L4: read the status first, and send nothing to a console that does |
| + // not answer as Phase 1. |
| + view, code := m.readFlat(ctx) |
| + if code >= 0 { |
| + return code |
| + } |
| + if view.Protocol != moveProtocolMark { |
| + moveSay(out, moveNotEnabled) |
| + return moveExitRefused |
| + } |
| + |
| + switch a.verb { |
| + case "status": |
| + return m.status(ctx, view, a.wait) |
| + case "request": |
| + cut := "auto" |
| + if a.manualCut { |
| + cut = "manual" |
| + } |
| + return m.mutate(ctx, http.MethodPost, "", map[string]string{"region": a.to, "cut": cut}, a.wait, true) |
| + case "cut": |
| + return m.mutate(ctx, http.MethodPost, "/cut", struct{}{}, a.wait, false) |
| + case "abort": |
| + return m.mutate(ctx, http.MethodDelete, "", nil, a.wait, false) |
| + default: // resolve --retry |
| + return m.mutate(ctx, http.MethodPost, "/resolve", map[string]bool{"retry": true}, a.wait, false) |
| + } |
| +} |
| + |
| +// The console's answers, as this CLI reads them (Task 2's shapes, copied |
| +// here as the CLI convention is). |
| +type moveFlat struct { |
| + State string `json:"state"` |
| + Target string `json:"target"` |
| + UpdatedAt int64 `json:"updated_at"` |
| + ID string `json:"id"` |
| + Last *moveOutcome `json:"last"` |
| + Protocol string `json:"protocol"` |
| +} |
| + |
| +type moveOutcome struct { |
| + ID string `json:"id"` |
| + Outcome string `json:"outcome"` |
| + At time.Time `json:"at"` |
| + OfflineS float64 `json:"offline_s"` |
| +} |
| + |
| +type moveAnswer struct { |
| + moveFlat |
| + Detail *moveDetailView `json:"detail"` |
| +} |
| + |
| +type moveDetailView struct { |
| + From string `json:"from"` |
| + Region string `json:"region"` |
| + Move *moveJournal `json:"move"` |
| + Claim *moveClaim `json:"claim"` |
| + TargetReport *moveReport `json:"target_report"` |
| + WaitingForBox string `json:"waiting_for_box"` |
| +} |
| + |
| +type moveJournal struct { |
| + ID string `json:"id"` |
| + To string `json:"to"` |
| + Cut string `json:"cut"` |
| + State string `json:"state"` |
| + CutPhase string `json:"cut_phase"` |
| + CutBudgetS int `json:"cut_budget_s"` |
| + Reason string `json:"reason"` |
| + StuckReason string `json:"stuck_reason"` |
| + Rollback *moveRollback `json:"rollback"` |
| +} |
| + |
| +type moveRollback struct { |
| + Reason string `json:"reason"` |
| + TargetPhase string `json:"target_phase"` |
| + ReleaseDeadline *time.Time `json:"release_deadline"` |
| +} |
| + |
| +type moveClaim struct { |
| + Kind string `json:"kind"` |
| + MvBox string `json:"target"` |
| +} |
| + |
| +type moveReport struct { |
| + Checks map[string]struct { |
| + OK bool `json:"ok"` |
| + Reason string `json:"reason"` |
| + } `json:"checks"` |
| +} |
| + |
| +// mover is one invocation's conversation with the move door. |
| +type mover struct { |
| + sess *apiSession |
| + out io.Writer |
| + host string |
| + path string |
| + poll time.Duration |
| + // limited: a 429 was answered, so the wait polls every 10 seconds now |
| + // and has said so (once). |
| + limited bool |
| +} |
| + |
| +func (m *mover) withDetail() string { |
| + if strings.Contains(m.path, "?") { |
| + return m.path + "&detail=1" |
| + } |
| + return m.path + "?detail=1" |
| +} |
| + |
| +// moveTransient is how many failed reads in a row the wait rides out before |
| +// it gives up with exit 1: a console restart is not a failed move. |
| +const moveTransient = 5 |
| + |
| +// get reads one answer, riding out 429s (backing off, said once) and a few |
| +// transient failures. code is -1 on success, else the exit code to return. |
| +func (m *mover) get(ctx context.Context, path string, into any) (code int) { |
| + failures := 0 |
| + for { |
| + status, body, err := m.sess.getJSON(ctx, path) |
| + switch { |
| + case err == nil && status == http.StatusOK: |
| + if err := json.Unmarshal(body, into); err != nil { |
| + sayError(m.out, err) |
| + return moveExitError |
| + } |
| + return -1 |
| + case err == nil && status == http.StatusTooManyRequests: |
| + if !m.limited { |
| + m.limited = true |
| + moveSay(m.out, moveLimited) |
| + } |
| + case err == nil && status >= 400 && status < 500: |
| + sayRefusal(m.out, status, body) |
| + return moveExitRefused |
| + default: |
| + failures++ |
| + if failures >= moveTransient { |
| + if err == nil { |
| + err = apiErrorFromBody(status, body) |
| + } |
| + sayError(m.out, err) |
| + return moveExitError |
| + } |
| + } |
| + if err := m.sleep(ctx); err != nil { |
| + sayError(m.out, err) |
| + return moveExitError |
| + } |
| + } |
| +} |
| + |
| +func (m *mover) interval() time.Duration { |
| + if m.limited { |
| + return 5 * m.poll // 10 s at the production 2 s poll |
| + } |
| + return m.poll |
| +} |
| + |
| +func (m *mover) sleep(ctx context.Context) error { |
| + t := time.NewTimer(m.interval()) |
| + defer t.Stop() |
| + select { |
| + case <-ctx.Done(): |
| + return ctx.Err() |
| + case <-t.C: |
| + return nil |
| + } |
| +} |
| + |
| +func (m *mover) readFlat(ctx context.Context) (moveFlat, int) { |
| + var v moveFlat |
| + return v, m.get(ctx, m.path, &v) |
| +} |
| + |
| +func (m *mover) readDetail(ctx context.Context) (moveAnswer, int) { |
| + var a moveAnswer |
| + code := m.get(ctx, m.withDetail(), &a) |
| + if code < 0 && a.Detail == nil { |
| + a.Detail = &moveDetailView{} |
| + } |
| + return a, code |
| +} |
| + |
| +// mutate sends one move mutation. A refusal is the console's own sentence |
| +// and exit 5. On acceptance the answer names the move (Task 2); a request |
| +// prints M-C1, and then the wait (unless --no-wait) follows that id. |
| +func (m *mover) mutate(ctx context.Context, method, tail string, body any, wait, request bool) int { |
| + var status int |
| + var raw []byte |
| + var err error |
| + if method == http.MethodDelete { |
| + status, raw, err = m.sess.deleteJSON(ctx, m.path) |
| + } else { |
| + status, raw, err = m.sess.postJSON(ctx, m.path+tail, body) |
| + } |
| + if err != nil { |
| + sayError(m.out, err) |
| + return moveExitError |
| + } |
| + if status >= 400 && status < 500 { |
| + sayRefusal(m.out, status, raw) |
| + return moveExitRefused |
| + } |
| + if status < 200 || status > 299 { |
| + sayError(m.out, apiErrorFromBody(status, raw)) |
| + return moveExitError |
| + } |
| + var view moveFlat |
| + if err := json.Unmarshal(raw, &view); err != nil { |
| + sayError(m.out, err) |
| + return moveExitError |
| + } |
| + if view.ID == "" || view.Protocol != moveProtocolMark { |
| + moveSay(m.out, moveNotEnabled) |
| + return moveExitRefused |
| + } |
| + w := newMoveWatch(m, view.ID) |
| + w.to = view.Target |
| + if request { |
| + a, code := m.readDetail(ctx) |
| + if code >= 0 { |
| + return code |
| + } |
| + w.learn(a) |
| + if w.first("start") { |
| + moveSay(w.m.out, fmt.Sprintf("Moving %s from %s to %s.", m.host, w.from, w.to)) |
| + } |
| + } |
| + if !wait { |
| + return moveExitDone |
| + } |
| + return w.run(ctx) |
| +} |
| + |
| +// status answers `carlos move status`: without --wait, one line for the |
| +// move in flight (and what the wait would say of its state), or the last |
| +// move's outcome; with --wait, the wait for the move in flight, or the |
| +// outcome of the last one when nothing is in flight. |
| +func (m *mover) status(ctx context.Context, view moveFlat, wait bool) int { |
| + id := view.ID |
| + if id == "" && view.Last != nil { |
| + id = view.Last.ID |
| + } |
| + if id == "" { |
| + moveSay(m.out, fmt.Sprintf("No move is in flight for %s.", m.host)) |
| + return moveExitDone |
| + } |
| + w := newMoveWatch(m, id) |
| + if wait { |
| + return w.run(ctx) |
| + } |
| + a, code := m.readDetail(ctx) |
| + if code >= 0 { |
| + return code |
| + } |
| + if a.ID == id { |
| + moveSay(m.out, fmt.Sprintf("Move %s of %s is %s.", id, m.host, a.State)) |
| + } |
| + // The state's own sentence, and its exit code when it is one the wait |
| + // would end on (an outcome, stuck, waiting); else 0. |
| + if code := w.observe(a); code >= 0 { |
| + return code |
| + } |
| + return moveExitDone |
| +} |
| + |
| +// moveWatch is the plan's moveWait: it follows one move, by id, and prints |
| +// each sentence once. |
| +type moveWatch struct { |
| + m *mover |
| + id string |
| + from, to string |
| + said map[string]bool |
| + lastKey string |
| + sinceRead int |
| +} |
| + |
| +func newMoveWatch(m *mover, id string) *moveWatch { |
| + return &moveWatch{m: m, id: id, said: map[string]bool{}} |
| +} |
| + |
| +// moveDetailEvery is how many flat polls may pass with no change before the |
| +// wait reads detail=1 anyway (a target report appears without a record |
| +// change, M-C3 and M-C4): every 30 s at the 2 s poll. |
| +const moveDetailEvery = 15 |
| + |
| +// first reports whether key's sentence is due: true the first time only. |
| +func (w *moveWatch) first(key string) bool { |
| + if w.said[key] { |
| + return false |
| + } |
| + w.said[key] = true |
| + return true |
| +} |
| + |
| +// learn keeps the regions the sentences name. |
| +func (w *moveWatch) learn(a moveAnswer) { |
| + d := a.Detail |
| + if d.From != "" { |
| + w.from = d.From |
| + } |
| + if a.Target != "" && a.ID == w.id { |
| + w.to = a.Target |
| + } |
| + if w.from == "" && a.State != "none" && d.Region != "" { |
| + w.from = d.Region |
| + } |
| +} |
| + |
| +// run polls until this move ends or needs an operator, and returns the exit |
| +// code. It never infers the outcome from a state it saw: the outcome is |
| +// last, bound to this move's id. |
| +func (w *moveWatch) run(ctx context.Context) int { |
| + for first := true; ; first = false { |
| + if !first { |
| + if err := w.m.sleep(ctx); err != nil { |
| + sayError(w.m.out, err) |
| + return moveExitError |
| + } |
| + } |
| + view, code := w.m.readFlat(ctx) |
| + if code >= 0 { |
| + return code |
| + } |
| + key := fmt.Sprintf("%s|%s|%d", view.State, view.ID, view.UpdatedAt) |
| + w.sinceRead++ |
| + if key == w.lastKey && w.sinceRead < moveDetailEvery && view.ID == w.id { |
| + continue |
| + } |
| + w.lastKey, w.sinceRead = key, 0 |
| + a, code := w.m.readDetail(ctx) |
| + if code >= 0 { |
| + return code |
| + } |
| + if code := w.observe(a); code >= 0 { |
| + return code |
| + } |
| + } |
| +} |
| + |
| +// observe prints what one detail answer says of this move, and returns an |
| +// exit code when the wait ends here (else -1). |
| +func (w *moveWatch) observe(a moveAnswer) int { |
| + w.learn(a) |
| + host, from, to := w.m.host, w.from, w.to |
| + if a.ID != w.id { |
| + // This move is not in flight: its outcome is last, or it is not |
| + // recorded (Review Focus 1). |
| + return w.outcome(a) |
| + } |
| + j := a.Detail.Move |
| + if j == nil { |
| + j = &moveJournal{} |
| + } |
| + if w.first("start") { |
| + moveSay(w.m.out, fmt.Sprintf("Moving %s from %s to %s.", host, from, to)) |
| + } |
| + switch a.State { |
| + case "preparing": |
| + if w.first("preparing") { |
| + moveSay(w.m.out, fmt.Sprintf("Copying data. %s keeps serving while this runs.", host)) |
| + } |
| + if r := a.Detail.TargetReport; r != nil { |
| + if w.first("checking") { |
| + moveSay(w.m.out, fmt.Sprintf("Checking that %s can run %s.", to, host)) |
| + } |
| + ids := make([]string, 0, len(r.Checks)) |
| + for id := range r.Checks { |
| + ids = append(ids, id) |
| + } |
| + slices.Sort(ids) |
| + for _, id := range ids { |
| + if c := r.Checks[id]; !c.OK && c.Reason != "" { |
| + if w.first("check:" + c.Reason) { |
| + moveSay(w.m.out, fmt.Sprintf("%s cannot take this move yet: %s.", to, c.Reason)) |
| + } |
| + } |
| + } |
| + } |
| + case "ready": |
| + if j.Cut == "manual" { |
| + moveSay(w.m.out, fmt.Sprintf("Ready to cut. Run carlos move cut --host %s when you are ready.", host)) |
| + return moveExitWaiting |
| + } |
| + case "cutting": |
| + if j.CutPhase != "" && j.CutPhase != "requested" && j.CutBudgetS > 0 { |
| + if w.first("cut") { |
| + moveSay(w.m.out, fmt.Sprintf("%s is ready. Starting the cut. %s will be offline for about %s.", to, host, moveEstimate(j.CutBudgetS))) |
| + } |
| + } |
| + case "landing", "committed": |
| + if w.first("landing") { |
| + moveSay(w.m.out, fmt.Sprintf("Stopped in %s. Starting in %s.", from, to)) |
| + } |
| + case "rolling_back", "reverting": |
| + reason := j.Reason |
| + if j.Rollback != nil && j.Rollback.Reason != "" { |
| + reason = j.Rollback.Reason |
| + } |
| + if reason != "" { |
| + if w.first("rollback") { |
| + moveSay(w.m.out, fmt.Sprintf("%s could not start %s: %s. Moving it back to %s.", to, host, reason, from)) |
| + } |
| + } |
| + if rb := j.Rollback; rb != nil && rb.ReleaseDeadline != nil && time.Now().After(*rb.ReleaseDeadline) && rb.TargetPhase == "releasing" { |
| + box := "the target box" |
| + if c := a.Detail.Claim; c != nil && c.MvBox != "" { |
| + box = c.MvBox |
| + } |
| + moveSay(w.m.out, fmt.Sprintf("Waiting for %s in %s to confirm it has stopped %s. %s is offline.", box, to, host, host)) |
| + return moveExitWaiting |
| + } |
| + case "stuck": |
| + sayStuck(w.m.out, host, from, to, j.StuckReason) |
| + return moveExitWaiting |
| + case "relocating": |
| + if region := a.Detail.WaitingForBox; region != "" { |
| + moveSay(w.m.out, fmt.Sprintf("%s is waiting for a box in %s. Its last box was terminated before the move finished. Its data up to the last completed upload is kept in %s. It will start when a box joins the region, and any writes after that upload will be reported then.", host, region, region)) |
| + return moveExitWaiting |
| + } |
| + moveSay(w.m.out, fmt.Sprintf("%s is relocating after its box was terminated. This move will not finish.", host)) |
| + return moveExitWaiting |
| + } |
| + return -1 |
| +} |
| + |
| +// outcome is the end of a move that is no longer in flight: last, if it is |
| +// this move's, else not recorded. |
| +func (w *moveWatch) outcome(a moveAnswer) int { |
| + host := w.m.host |
| + l := a.Last |
| + if l == nil || l.ID != w.id { |
| + moveSay(w.m.out, moveNotRecorded) |
| + return moveExitWaiting |
| + } |
| + region := a.Detail.Region |
| + switch l.Outcome { |
| + case "done": |
| + if region == "" { |
| + region = w.to |
| + } |
| + moveSay(w.m.out, fmt.Sprintf("Moved. %s is served from %s. It was offline for %d seconds.", host, region, int(math.Round(l.OfflineS)))) |
| + return moveExitDone |
| + case "rolled_back": |
| + // M-C8 is said only from a rollback seen with its reason: the |
| + // outcome carries none, and the line is in the present tense. |
| + if region == "" { |
| + region = w.from |
| + } |
| + moveSay(w.m.out, fmt.Sprintf("%s is back in %s and serving. Nothing was lost.", host, region)) |
| + return moveExitRolled |
| + case "aborted": |
| + if region == "" { |
| + region = w.from |
| + } |
| + moveSay(w.m.out, fmt.Sprintf("The move was aborted. %s stays in %s.", host, region)) |
| + return moveExitRolled |
| + } |
| + moveSay(w.m.out, moveNotRecorded) |
| + return moveExitWaiting |
| +} |
| + |
| +// moveEstimate renders a cut budget for M-C5. |
| +func moveEstimate(seconds int) string { |
| + if seconds < 90 { |
| + return fmt.Sprintf("%d seconds", seconds) |
| + } |
| + return fmt.Sprintf("%d minutes", (seconds+59)/60) |
| +} |
| + |
| +// sayStuck is M-C11 and then the record's stuck reason on its own line |
| +// (preflight L28). The reason is a sentence the provision transitions |
| +// record (MV-N300 to MV-N312 and the rows before them), passed on as it is. |
| +func sayStuck(out io.Writer, host, from, to, reason string) { |
| + moveSay(out, fmt.Sprintf("%s could not start in %s or in %s. It is offline. Run carlos move resolve --host %s --retry once the cause is fixed.", host, to, from, host)) |
| + if reason != "" { |
| + moveSay(out, reason) |
| + } |
| +} |
| + |
| +// sayRefusal passes on the console's refusal, a sentence recorded with the |
| +// door that speaks it (M-C13, M-C17, MV-N1 and the rest). |
| +func sayRefusal(out io.Writer, status int, body []byte) { |
| + moveSay(out, apiErrorFromBody(status, body).Error()) |
| +} |
| + |
| +// sayError reports a transport or decoding error: Go's text, not a sentence |
| +// of this file. |
| +func sayError(out io.Writer, err error) { |
| + moveSay(out, err.Error()) |
| +} |
diff --git a/move_test.go b/move_test.go| new file mode 100644 |
| index 000000000..ccb267660 |
| --- /dev/null |
| +++ b/move_test.go |
| @@ -0,0 +1,564 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// The fake console below answers the move doors in Task 2's shapes: the flat |
| +// view (state, target, updated_at, id, last, protocol) and, for detail=1, |
| +// the same plus the detail object. Each flat GET returns the next scripted |
| +// frame; a detail GET returns the frame the last flat GET returned. |
| + |
| +const ( |
| + tMoveID = "mv_abcdefghijklmnopqrstuvwxyz" |
| + tHost = "acme.go.tito.io" |
| + tMovePth = "/api/cli/apps/bab/titogo/instances/acme.go.tito.io/move" |
| +) |
| + |
| +type frame map[string]any |
| + |
| +func flatOf(f frame) frame { |
| + out := frame{} |
| + for k, v := range f { |
| + if k != "detail" { |
| + out[k] = v |
| + } |
| + } |
| + return out |
| +} |
| + |
| +type fakeMoveConsole struct { |
| + mu sync.Mutex |
| + frames []frame |
| + i int |
| + last frame |
| + tooMany int // the next flat GETs answered 429 |
| + flatGets int |
| + details int |
| + posts []string // method path body |
| + mutated bool // a mutation since the last flat GET |
| + answer func(method, path, body string) (int, any) |
| +} |
| + |
| +func (c *fakeMoveConsole) ServeHTTP(w http.ResponseWriter, r *http.Request) { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + path := r.URL.Path |
| + if !strings.HasPrefix(path, tMovePth) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + write := func(code int, v any) { |
| + w.Header().Set("Content-Type", "application/json") |
| + w.WriteHeader(code) |
| + _ = json.NewEncoder(w).Encode(v) |
| + } |
| + if r.Method == http.MethodGet && path == tMovePth { |
| + if r.URL.Query().Get("detail") == "1" { |
| + c.details++ |
| + cur := c.last |
| + if cur == nil || c.mutated { |
| + // Right after a mutation the console shows its result: the |
| + // next scripted frame. |
| + n := c.i |
| + if n >= len(c.frames) { |
| + n = len(c.frames) - 1 |
| + } |
| + cur = c.frames[n] |
| + } |
| + write(http.StatusOK, cur) |
| + return |
| + } |
| + c.flatGets++ |
| + if c.tooMany > 0 { |
| + c.tooMany-- |
| + write(http.StatusTooManyRequests, map[string]string{"error": "too many instance requests. wait a minute and try again"}) |
| + return |
| + } |
| + n := c.i |
| + if n >= len(c.frames) { |
| + n = len(c.frames) - 1 |
| + } |
| + c.last = c.frames[n] |
| + c.mutated = false |
| + c.i++ |
| + write(http.StatusOK, flatOf(c.last)) |
| + return |
| + } |
| + body, _ := io.ReadAll(r.Body) |
| + c.posts = append(c.posts, r.Method+" "+path+" "+strings.TrimSpace(string(body))) |
| + c.mutated = true |
| + if c.answer == nil { |
| + write(http.StatusAccepted, frame{"state": "preparing", "target": "uk", "id": tMoveID, "protocol": "phase1"}) |
| + return |
| + } |
| + code, v := c.answer(r.Method, path, string(body)) |
| + write(code, v) |
| +} |
| + |
| +func (c *fakeMoveConsole) mutations() []string { |
| + c.mu.Lock() |
| + defer c.mu.Unlock() |
| + return append([]string(nil), c.posts...) |
| +} |
| + |
| +// runMoveAgainst runs the verb against the fake, as an operator who is not |
| +// root, polling every millisecond. It returns the exit code and the output. |
| +func runMoveAgainst(t *testing.T, c *fakeMoveConsole, args ...string) (int, string) { |
| + t.Helper() |
| + ts := httptest.NewServer(c) |
| + t.Cleanup(ts.Close) |
| + var out bytes.Buffer |
| + deps := moveDeps{ |
| + session: func(string) (*apiSession, error) { |
| + return &apiSession{c: newConsoleClient(ts.URL, "cli-token"), console: ts.URL, address: "op@keymail.dev"}, nil |
| + }, |
| + euid: func() int { return 1000 }, |
| + poll: time.Millisecond, |
| + } |
| + args = append(args, "--account", "bab", "--app", "titogo") |
| + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) |
| + defer cancel() |
| + code := moveMain(ctx, args, &out, deps) |
| + return code, out.String() |
| +} |
| + |
| +// The frames a move goes through, as the console shows them. |
| +func none(last frame) frame { |
| + f := frame{"state": "none", "protocol": "phase1"} |
| + if last != nil { |
| + f["last"] = last |
| + } |
| + f["detail"] = frame{"region": "ie", "log": []string{}, "evidence": frame{}} |
| + return f |
| +} |
| + |
| +func inFlight(state string, move frame, extra frame) frame { |
| + m := frame{"id": tMoveID, "from": "ie", "to": "uk", "cut": "auto", "state": state} |
| + for k, v := range move { |
| + m[k] = v |
| + } |
| + d := frame{"move": m, "from": "ie", "region": "ie", "log": []string{}, "evidence": frame{}, |
| + "claim": frame{"kind": "moving", "exec": "ie-box", "target": "uk-box"}} |
| + for k, v := range extra { |
| + d[k] = v |
| + } |
| + return frame{"state": state, "target": "uk", "updated_at": time.Now().Unix(), "id": tMoveID, "protocol": "phase1", "detail": d} |
| +} |
| + |
| +func outcome(id, what string, offline float64) frame { |
| + return frame{"id": id, "outcome": what, "at": time.Now().UTC().Format(time.RFC3339), "offline_s": offline} |
| +} |
| + |
| +const ( |
| + mc1 = "Moving acme.go.tito.io from ie to uk." |
| + mc2 = "Copying data. acme.go.tito.io keeps serving while this runs." |
| + mc3 = "Checking that uk can run acme.go.tito.io." |
| + mc5 = "uk is ready. Starting the cut. acme.go.tito.io will be offline for about 45 seconds." |
| + mc6 = "Stopped in ie. Starting in uk." |
| + mc7 = "Moved. acme.go.tito.io is served from uk. It was offline for 41 seconds." |
| + mc8 = "uk could not start acme.go.tito.io: the landing ran past its deadline. Moving it back to ie." |
| + mc9 = "acme.go.tito.io is back in ie and serving. Nothing was lost." |
| + mc11 = "acme.go.tito.io could not start in uk or in ie. It is offline. Run carlos move resolve --host acme.go.tito.io --retry once the cause is fixed." |
| + mc12 = "Ready to cut. Run carlos move cut --host acme.go.tito.io when you are ready." |
| +) |
| + |
| +func wantLines(t *testing.T, out string, lines ...string) { |
| + t.Helper() |
| + for _, l := range lines { |
| + if !strings.Contains(out, l+"\n") { |
| + t.Errorf("output lacks %q:\n%s", l, out) |
| + } |
| + } |
| +} |
| + |
| +func wantNoLine(t *testing.T, out string, lines ...string) { |
| + t.Helper() |
| + for _, l := range lines { |
| + if strings.Contains(out, l) { |
| + t.Errorf("output holds %q:\n%s", l, out) |
| + } |
| + } |
| +} |
| + |
| +// Exit codes (preflight L24): 0 done, 3 rolled back or aborted, 4 stuck or |
| +// waiting, 5 refused. Each sentence comes from the state it describes, and |
| +// the outcome only from last, bound to this move's id. |
| +func TestMoveWaitExitCodes(t *testing.T) { |
| + rb := frame{"id": "rb_x", "reason": "the landing ran past its deadline", "target_phase": "cleaning"} |
| + report := frame{"checks": frame{"t1_protocol": frame{"ok": true, "reason": ""}}} |
| + t.Run("rolled back", func(t *testing.T) { |
| + frames := []frame{none(nil), inFlight("preparing", nil, nil)} |
| + // A report appears with no record change: only the wait's periodic |
| + // detail read sees it, so it stays up for that many polls. |
| + for i := 0; i < moveDetailEvery+1; i++ { |
| + frames = append(frames, inFlight("preparing", nil, frame{"target_report": report})) |
| + } |
| + c := &fakeMoveConsole{frames: append(frames, |
| + inFlight("ready", nil, nil), |
| + inFlight("cutting", frame{"cut_phase": "admitted", "cut_budget_s": 45}, nil), |
| + inFlight("landing", frame{"cut_phase": "flipped", "landing_phase": "probing"}, nil), |
| + inFlight("rolling_back", frame{"rollback": rb}, nil), |
| + none(outcome(tMoveID, "rolled_back", 0)), |
| + )} |
| + code, out := runMoveAgainst(t, c, "--host", tHost, "--to", "uk") |
| + if code != 3 { |
| + t.Fatalf("exit %d, want 3:\n%s", code, out) |
| + } |
| + wantLines(t, out, mc1, mc2, mc3, mc5, mc6, mc8, mc9) |
| + if m := c.mutations(); len(m) != 1 || m[0] != "POST "+tMovePth+` {"cut":"auto","region":"uk"}` { |
| + t.Fatalf("mutations %q", m) |
| + } |
| + }) |
| + t.Run("done", func(t *testing.T) { |
| + // After the close the record's region is the target's: M-C7 names it. |
| + done := none(outcome(tMoveID, "done", 41.2)) |
| + done["detail"].(frame)["region"] = "uk" |
| + c := &fakeMoveConsole{frames: []frame{ |
| + none(nil), |
| + inFlight("preparing", nil, nil), |
| + inFlight("cutting", frame{"cut_phase": "stopping", "cut_budget_s": 45}, nil), |
| + inFlight("committed", frame{"cut_phase": "flipped"}, nil), |
| + done, |
| + }} |
| + code, out := runMoveAgainst(t, c, "--host", tHost, "--to", "uk") |
| + if code != 0 { |
| + t.Fatalf("exit %d, want 0:\n%s", code, out) |
| + } |
| + wantLines(t, out, mc1, mc5, mc6, mc7) |
| + }) |
| + t.Run("stuck", func(t *testing.T) { |
| + c := &fakeMoveConsole{frames: []frame{ |
| + none(nil), |
| + inFlight("stuck", frame{"stuck_reason": "the recovery probe ran out of attempts"}, nil), |
| + }} |
| + code, out := runMoveAgainst(t, c, "--host", tHost, "--to", "uk") |
| + if code != 4 { |
| + t.Fatalf("exit %d, want 4:\n%s", code, out) |
| + } |
| + wantLines(t, out, mc11, "the recovery probe ran out of attempts") |
| + }) |
| + t.Run("rolling back past the release deadline", func(t *testing.T) { |
| + past := time.Now().Add(-time.Minute).UTC().Format(time.RFC3339) |
| + late := frame{"id": "rb_x", "reason": "the landing ran past its deadline", "target_phase": "releasing", "release_deadline": past} |
| + c := &fakeMoveConsole{frames: []frame{none(nil), inFlight("rolling_back", frame{"rollback": late}, nil)}} |
| + code, out := runMoveAgainst(t, c, "--host", tHost, "--to", "uk") |
| + if code != 4 { |
| + t.Fatalf("exit %d, want 4:\n%s", code, out) |
| + } |
| + wantLines(t, out, mc8, "Waiting for uk-box in uk to confirm it has stopped acme.go.tito.io. acme.go.tito.io is offline.") |
| + }) |
| + t.Run("refused at the request", func(t *testing.T) { |
| + c := &fakeMoveConsole{frames: []frame{none(nil)}, answer: func(string, string, string) (int, any) { |
| + return http.StatusConflict, map[string]string{"error": "the instance is already in uk"} |
| + }} |
| + code, out := runMoveAgainst(t, c, "--host", tHost, "--to", "uk") |
| + if code != 5 { |
| + t.Fatalf("exit %d, want 5:\n%s", code, out) |
| + } |
| + wantLines(t, out, "the instance is already in uk") |
| + }) |
| + t.Run("abort", func(t *testing.T) { |
| + c := &fakeMoveConsole{frames: []frame{ |
| + inFlight("preparing", nil, nil), |
| + inFlight("aborting", nil, nil), |
| + none(outcome(tMoveID, "aborted", 0)), |
| + }, answer: func(string, string, string) (int, any) { |
| + return http.StatusAccepted, frame{"state": "aborting", "target": "uk", "id": tMoveID, "protocol": "phase1"} |
| + }} |
| + code, out := runMoveAgainst(t, c, "abort", "--host", tHost) |
| + if code != 3 { |
| + t.Fatalf("exit %d, want 3:\n%s", code, out) |
| + } |
| + wantLines(t, out, "The move was aborted. acme.go.tito.io stays in ie.") |
| + if m := c.mutations(); len(m) != 1 || !strings.HasPrefix(m[0], "DELETE "+tMovePth+" ") { |
| + t.Fatalf("mutations %q", m) |
| + } |
| + }) |
| + t.Run("abort after the cut started", func(t *testing.T) { |
| + const mc13 = "This move has started its cut. It will finish or move back on its own." |
| + c := &fakeMoveConsole{frames: []frame{inFlight("landing", nil, nil)}, answer: func(string, string, string) (int, any) { |
| + return http.StatusConflict, map[string]string{"error": mc13} |
| + }} |
| + code, out := runMoveAgainst(t, c, "abort", "--host", tHost) |
| + if code != 5 { |
| + t.Fatalf("exit %d, want 5:\n%s", code, out) |
| + } |
| + wantLines(t, out, mc13) |
| + }) |
| + t.Run("cut and resolve post to their doors", func(t *testing.T) { |
| + for _, v := range []struct { |
| + args []string |
| + want string |
| + }{ |
| + {[]string{"cut", "--host", tHost, "--no-wait"}, "POST " + tMovePth + "/cut {}"}, |
| + {[]string{"resolve", "--host", tHost, "--retry", "--no-wait"}, "POST " + tMovePth + `/resolve {"retry":true}`}, |
| + } { |
| + c := &fakeMoveConsole{frames: []frame{inFlight("ready", nil, nil)}} |
| + code, out := runMoveAgainst(t, c, v.args...) |
| + if code != 0 { |
| + t.Fatalf("%v: exit %d:\n%s", v.args, code, out) |
| + } |
| + if m := c.mutations(); len(m) != 1 || m[0] != v.want { |
| + t.Fatalf("%v: mutations %q, want %q", v.args, m, v.want) |
| + } |
| + } |
| + }) |
| +} |
| + |
| +// Review Focus 1: the outcome is never inferred from the last state seen. A |
| +// wait that saw only preparing still reads rolled_back from last, bound to |
| +// this move's id; a last of another move is "not recorded". |
| +func TestMoveWaitSurvivesMissedStates(t *testing.T) { |
| + c := &fakeMoveConsole{frames: []frame{none(nil), inFlight("preparing", nil, nil), none(outcome(tMoveID, "rolled_back", 0))}} |
| + code, out := runMoveAgainst(t, c, "--host", tHost, "--to", "uk") |
| + if code != 3 { |
| + t.Fatalf("exit %d, want 3:\n%s", code, out) |
| + } |
| + wantLines(t, out, mc9) |
| + // The rollback's reason was never seen, so M-C8 (a present-tense line |
| + // with a reason) is not invented after the fact. |
| + wantNoLine(t, out, "could not start") |
| + |
| + for _, last := range []frame{outcome("mv_another", "done", 3), nil} { |
| + c := &fakeMoveConsole{frames: []frame{none(nil), inFlight("preparing", nil, nil), none(last)}} |
| + code, out := runMoveAgainst(t, c, "--host", tHost, "--to", "uk") |
| + if code != 4 { |
| + t.Fatalf("last %v: exit %d, want 4:\n%s", last, code, out) |
| + } |
| + wantLines(t, out, "This move's outcome is not recorded.") |
| + wantNoLine(t, out, "Moved.", "back in") |
| + } |
| + // Another move in flight, and ours closed: ours is read from last. |
| + other := inFlight("preparing", nil, nil) |
| + other["id"] = "mv_another" |
| + other["last"] = outcome(tMoveID, "done", 41.2) |
| + other["detail"].(frame)["region"] = "uk" |
| + c = &fakeMoveConsole{frames: []frame{none(nil), other}} |
| + code, out = runMoveAgainst(t, c, "--host", tHost, "--to", "uk") |
| + if code != 0 { |
| + t.Fatalf("exit %d, want 0:\n%s", code, out) |
| + } |
| + wantLines(t, out, mc7) |
| +} |
| + |
| +func TestMoveManualCutPrintsM12AndStops(t *testing.T) { |
| + c := &fakeMoveConsole{frames: []frame{none(nil), inFlight("preparing", frame{"cut": "manual"}, nil), inFlight("ready", frame{"cut": "manual"}, nil)}} |
| + code, out := runMoveAgainst(t, c, "--host", tHost, "--to", "uk", "--manual-cut") |
| + if code != 4 { |
| + t.Fatalf("exit %d, want 4:\n%s", code, out) |
| + } |
| + wantLines(t, out, mc12) |
| + if m := c.mutations(); len(m) != 1 || m[0] != "POST "+tMovePth+` {"cut":"manual","region":"uk"}` { |
| + t.Fatalf("mutations %q: want the request and no cut", m) |
| + } |
| +} |
| + |
| +// L4: a console whose answer lacks the Phase 1 marker gets no mutation. |
| +func TestMoveRefusesAConsoleWithoutPhase1(t *testing.T) { |
| + legacy := frame{"state": "none"} |
| + for _, args := range [][]string{ |
| + {"--host", tHost, "--to", "uk"}, {"cut", "--host", tHost}, {"abort", "--host", tHost}, |
| + {"resolve", "--host", tHost, "--retry"}, {"status", "--host", tHost}, |
| + } { |
| + c := &fakeMoveConsole{frames: []frame{legacy}} |
| + code, out := runMoveAgainst(t, c, args...) |
| + if code != 5 { |
| + t.Fatalf("%v: exit %d, want 5:\n%s", args, code, out) |
| + } |
| + wantLines(t, out, "Moves are not enabled on this console.") |
| + if m := c.mutations(); len(m) != 0 { |
| + t.Fatalf("%v: mutations %q, want none", args, m) |
| + } |
| + } |
| +} |
| + |
| +// L18: relocating with waiting_for_box prints M-C22 and exits 4, never |
| +// waiting for the box. |
| +func TestMoveWaitReportsWaitingForABox(t *testing.T) { |
| + c := &fakeMoveConsole{frames: []frame{none(nil), inFlight("relocating", nil, frame{"waiting_for_box": "ie"})}} |
| + code, out := runMoveAgainst(t, c, "--host", tHost, "--to", "uk") |
| + if code != 4 { |
| + t.Fatalf("exit %d, want 4:\n%s", code, out) |
| + } |
| + wantLines(t, out, "acme.go.tito.io is waiting for a box in ie. Its last box was terminated before the move finished. Its data up to the last completed upload is kept in ie. It will start when a box joins the region, and any writes after that upload will be reported then.") |
| + c = &fakeMoveConsole{frames: []frame{none(nil), inFlight("relocating", nil, nil)}} |
| + code, out = runMoveAgainst(t, c, "--host", tHost, "--to", "uk") |
| + if code != 4 { |
| + t.Fatalf("relocating with a box: exit %d, want 4:\n%s", code, out) |
| + } |
| + wantLines(t, out, "acme.go.tito.io is relocating after its box was terminated. This move will not finish.") |
| +} |
| + |
| +// L19: the wait polls the flat view and reads detail only when the state |
| +// changes (or now and then). A 429 backs off, says so once, and never ends |
| +// the wait. |
| +func TestMoveWaitBacksOffAndReadsDetailOnChange(t *testing.T) { |
| + frames := []frame{none(nil)} |
| + for i := 0; i < 10; i++ { |
| + frames = append(frames, inFlight("preparing", nil, nil)) |
| + } |
| + done := none(outcome(tMoveID, "done", 41.2)) |
| + done["detail"].(frame)["region"] = "uk" |
| + frames = append(frames, done) |
| + c := &fakeMoveConsole{frames: frames, tooMany: 2} |
| + code, out := runMoveAgainst(t, c, "--host", tHost, "--to", "uk") |
| + if code != 0 { |
| + t.Fatalf("exit %d, want 0:\n%s", code, out) |
| + } |
| + if n := strings.Count(out, "The console is limiting requests. Checking every 10 seconds."); n != 1 { |
| + t.Fatalf("the back-off line appears %d times, want once:\n%s", n, out) |
| + } |
| + if c.details > 4 { |
| + t.Fatalf("%d detail reads over %d flat polls; detail is read on a change", c.details, c.flatGets) |
| + } |
| +} |
| + |
| +// L25: carlos move refuses to run as root, before any request. |
| +func TestMoveRefusesRoot(t *testing.T) { |
| + c := &fakeMoveConsole{frames: []frame{none(nil)}} |
| + ts := httptest.NewServer(c) |
| + defer ts.Close() |
| + var out bytes.Buffer |
| + called := false |
| + deps := moveDeps{session: func(string) (*apiSession, error) { called = true; return nil, nil }, euid: func() int { return 0 }, poll: time.Millisecond} |
| + if code := moveMain(context.Background(), []string{"--host", tHost, "--to", "uk", "--app", "titogo"}, &out, deps); code != 5 { |
| + t.Fatalf("exit %d, want 5:\n%s", code, out.String()) |
| + } |
| + if called || c.flatGets != 0 { |
| + t.Fatal("a refused root run reached the console") |
| + } |
| + wantLines(t, out.String(), "Run carlos move as yourself, not as root. It uses your own credentials.") |
| +} |
| + |
| +// --no-wait answers after the request: M-C1, exit 0. |
| +func TestMoveNoWait(t *testing.T) { |
| + c := &fakeMoveConsole{frames: []frame{none(nil), inFlight("preparing", nil, nil)}} |
| + code, out := runMoveAgainst(t, c, "--host", tHost, "--to", "uk", "--no-wait") |
| + if code != 0 { |
| + t.Fatalf("exit %d:\n%s", code, out) |
| + } |
| + wantLines(t, out, mc1) |
| + wantNoLine(t, out, mc2) |
| +} |
| + |
| +// status: one read, the state in a line, exit 0; --wait waits for this |
| +// move's outcome; with nothing in flight and nothing closed, it says so. |
| +func TestMoveStatus(t *testing.T) { |
| + c := &fakeMoveConsole{frames: []frame{inFlight("preparing", nil, nil)}} |
| + code, out := runMoveAgainst(t, c, "status", "--host", tHost) |
| + if code != 0 { |
| + t.Fatalf("exit %d:\n%s", code, out) |
| + } |
| + wantLines(t, out, "Move "+tMoveID+" of acme.go.tito.io is preparing.", mc2) |
| + if m := c.mutations(); len(m) != 0 { |
| + t.Fatalf("status mutated: %q", m) |
| + } |
| + c = &fakeMoveConsole{frames: []frame{none(nil)}} |
| + if code, out := runMoveAgainst(t, c, "status", "--host", tHost, "--wait"); code != 0 || !strings.Contains(out, "No move is in flight for acme.go.tito.io.\n") { |
| + t.Fatalf("status --wait with nothing = %d:\n%s", code, out) |
| + } |
| + // Resumed after a disconnect: the first read is none, and last is the |
| + // move this operator was waiting on. |
| + closed := none(outcome(tMoveID, "rolled_back", 0)) |
| + c = &fakeMoveConsole{frames: []frame{closed}} |
| + if code, out := runMoveAgainst(t, c, "status", "--host", tHost, "--wait"); code != 3 || !strings.Contains(out, mc9+"\n") { |
| + t.Fatalf("status --wait after a rollback = %d:\n%s", code, out) |
| + } |
| +} |
| + |
| +func TestParseMoveArgs(t *testing.T) { |
| + for _, c := range []struct { |
| + args []string |
| + ok bool |
| + verb string |
| + }{ |
| + {[]string{"--host", tHost, "--to", "uk"}, true, "request"}, |
| + {[]string{"--host", tHost, "--to", "uk", "--manual-cut", "--no-wait"}, true, "request"}, |
| + {[]string{"status", "--host", tHost}, true, "status"}, |
| + {[]string{"status", "--host", tHost, "--wait"}, true, "status"}, |
| + {[]string{"cut", "--host", tHost}, true, "cut"}, |
| + {[]string{"abort", "--host", tHost}, true, "abort"}, |
| + {[]string{"resolve", "--host", tHost, "--retry"}, true, "resolve"}, |
| + {[]string{"--to", "uk"}, false, ""}, |
| + {[]string{"--host", tHost}, false, ""}, |
| + {[]string{"resolve", "--host", tHost}, false, ""}, |
| + {[]string{"cut", "--host", tHost, "--to", "uk"}, false, ""}, |
| + {[]string{"resolve", "--host", tHost, "--declare-lost", "uk-box"}, false, ""}, |
| + } { |
| + got, err := parseMoveArgs(c.args, io.Discard) |
| + if (err == nil) != c.ok || (c.ok && got.verb != c.verb) { |
| + t.Errorf("parseMoveArgs(%q) = %+v, %v; want ok %v verb %q", c.args, got, err, c.ok, c.verb) |
| + } |
| + } |
| +} |
| + |
| +// L16 (Task 6 folded in): every fenced `carlos move` line in the CLI page's |
| +// section parses through the CLI's own parser, the section names every verb, |
| +// and it states the four exit codes. |
| +func TestMoveDocsNameEveryVerb(t *testing.T) { |
| + raw, err := os.ReadFile("docs/site/cli.md") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + page := string(raw) |
| + start := strings.Index(page, "\n### carlos move\n") |
| + if start < 0 { |
| + t.Fatal("docs/site/cli.md has no ### carlos move section") |
| + } |
| + section := page[start+1:] |
| + if end := strings.Index(section[4:], "\n## "); end >= 0 { |
| + section = section[:end+4] |
| + } |
| + if end := strings.Index(section[4:], "\n### "); end >= 0 { |
| + section = section[:end+4] |
| + } |
| + verbs := map[string]bool{} |
| + inFence := false |
| + for _, line := range strings.Split(section, "\n") { |
| + if strings.HasPrefix(line, "```") { |
| + inFence = !inFence |
| + continue |
| + } |
| + fields := strings.Fields(line) |
| + if !inFence || len(fields) < 2 || fields[0] != "carlos" || fields[1] != "move" { |
| + continue |
| + } |
| + args := fields[2:] |
| + for i, a := range args { |
| + // [--flag] is an optional flag, and a placeholder stands for a |
| + // value. |
| + a = strings.TrimSuffix(strings.TrimPrefix(a, "["), "]") |
| + if strings.HasPrefix(a, "<") { |
| + a = "x" |
| + } |
| + args[i] = a |
| + } |
| + got, err := parseMoveArgs(args, io.Discard) |
| + if err != nil { |
| + t.Errorf("the page's example %q does not parse: %v", line, err) |
| + continue |
| + } |
| + verbs[got.verb] = true |
| + } |
| + for _, v := range []string{"request", "status", "cut", "abort", "resolve"} { |
| + if !verbs[v] { |
| + t.Errorf("the section has no example of %s", v) |
| + } |
| + } |
| + for _, code := range []string{"`0`", "`3`", "`4`", "`5`"} { |
| + if !strings.Contains(section, code) { |
| + t.Errorf("the section does not state exit code %s", code) |
| + } |
| + } |
| +} |