carlos / platform Public

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>
claude agent, operated by paul@keymail.dev fff4b112e90a754126f94440fe6da1fc358e7768 parent 2a96740
9 files changed, +1354 −18
  • approved_moves_copy_test.go +36 −2
  • docs/site/cli.md +34 −0
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +29 −14
  • internal/console/apiinstancemove.go +9 −1
  • internal/console/apiinstancemove_test.go +7 −0
  • internal/provision/plan9_b8_census_test.go +4 −1
  • main.go +4 −0
  • move.go +667 −0
  • move_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)
+ }
+ }
+}