carlos / platform Public

offline-move: carlos move offline

The seven verbs (start, seal, flip, open, finish, abort, status) against
the offline doors. Each mutating verb posts, then polls status until its
step is done or failed; finish posts again once the tombstone is lifted;
abort leaves finish to the operator. Door refusals print as sent. Docs
paragraph is copy rows MV-O32/33; new pending rows MV-O52 to MV-O57.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 168028657d4b80a99f6b7fc6d7c3229f68b742b1 parent e242e49
5 files changed, +1225 −10
  • approved_moves_copy_test.go +1 −1
  • docs/site/cli.md +14 −0
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +11 −0
  • move.go +597 −9
  • move_test.go +602 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index 4716b4dfe..ab2f3e7ef 100644
--- a/approved_moves_copy_test.go
+++ b/approved_moves_copy_test.go
@@ -96,7 +96,7 @@ var movesCopyIDs = []string{
"MV-O20", "MV-O21", "MV-O22", "MV-O23", "MV-O24", "MV-O25", "MV-O26", "MV-O27", "MV-O28", "MV-O29", "MV-O30", "MV-O31",
"MV-O32", "MV-O33",
"MV-O34", "MV-O35", "MV-O36", "MV-O37", "MV-O38", "MV-O39", "MV-O40", "MV-O41", "MV-O42", "MV-O43",
- "MV-O50", "MV-O51",
+ "MV-O50", "MV-O51", "MV-O52", "MV-O53", "MV-O54", "MV-O55", "MV-O56", "MV-O57",
}
// copyRow is one row of the record. The File cell is the file the sentence is
diff --git a/docs/site/cli.md b/docs/site/cli.md
index 22601a4d6..194ae0780 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -2347,6 +2347,20 @@ to confirm.
Operators who are members of the instance's account.
+`carlos move offline` moves one instance with planned downtime, one step at a time. It is for operators, works with host claims and Phase 1 moves off, and never acts on its own: each verb does one step and says which verb comes next.
+
+```sh
+carlos move offline start --host <host> --to <region>
+carlos move offline seal --host <host>
+carlos move offline flip --host <host>
+carlos move offline open --host <host>
+carlos move offline finish --host <host>
+carlos move offline abort --host <host>
+carlos move offline status --host <host>
+```
+
+The instance is offline from `start` until `open`. Until `open`, `abort` puts it back where it was with nothing lost. After `open`, moving it back is a new move.
+
## 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 6715204bc..401ef893d 100644
--- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
@@ -542,3 +542,14 @@ Two more refusals of the console doors (Task 6), for cases MV-O20 to MV-O31 do n
|---|---|---|---|
| MV-O50 | internal/console/apiinstancemove_offline.go | <host> is steered or runs under a unit. offline moves cannot carry either yet | pending |
| MV-O51 | internal/console/apiinstancemove_offline.go | <box> already serves <host>. an offline move needs the region it moves to to be empty of it | pending |
+
+Six more lines of `carlos move offline` (`move.go`, Task 7), for places MV-O1 to MV-O10 do not reach: what `abort` says once every box has put the instance back (the plan's runbook has the operator run `finish` next, and no row said so), the lines `status` prints for each box and for each manifest item (`<status>` is ok, waiting, failed or unchecked; `<detail>` and `<phase>` are box diagnostics, not copy; an item with no detail uses the shorter line), and the usage line. A box whose report is not current says so: `flip` and `open` refuse on a stale report, so `status` has to show it. MV-O56 follows MV-O57 in the table only by number.
+
+| id | file | text | status |
+|---|---|---|---|
+| MV-O52 | move.go | Every box has put <host> back in <from>. Run carlos move offline finish --host <host> to end the move. | pending |
+| MV-O53 | move.go | <box> is <phase> as the <role>. | pending |
+| MV-O54 | move.go | <box> is <phase> as the <role>. Its report is not current. | pending |
+| MV-O55 | move.go | Item <id> is <status>: <detail>. | pending |
+| MV-O57 | move.go | Item <id> is <status>. | pending |
+| MV-O56 | move.go | carlos move offline needs start, seal, flip, open, finish, abort or status. | pending |
diff --git a/move.go b/move.go
index 6f4c67a7d..c0080f313 100644
--- a/move.go
+++ b/move.go
@@ -9,6 +9,7 @@ import (
"flag"
"fmt"
"io"
+ "maps"
"math"
"net/http"
"net/url"
@@ -21,6 +22,7 @@ import (
"github.com/carlosframework/platform/internal/hostfence"
"github.com/carlosframework/platform/internal/hostresolve"
"github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/provision"
)
// `carlos move` (spec §12, moves Phase 1 Plan 6 Task 1a): request a move of
@@ -81,6 +83,8 @@ const (
moveNoFlipTime = "The move's flip time could not be read, so its target cannot be declared lost from here."
moveOtherLoss = "The console asks to confirm a loss carlos move does not know. Use carlos ops host resolve."
moveLossChanged = "The move changed since it was read. Nothing was declared. Run the command again."
+ // The offline subcommand named no verb, or one it does not have (MV-O56).
+ moveOfflineUsage = "carlos move offline needs start, seal, flip, open, finish, abort or status."
)
func moveSay(out io.Writer, line string) {
@@ -117,7 +121,8 @@ type moveDeps struct {
}
type moveArgs struct {
- verb string // request | status | cut | abort | resolve
+ verb string // request | status | cut | abort | resolve | offline
+ offline string // the offline verb: start | seal | flip | open | finish | abort | status
host, to, app string
account, accountID string
console string
@@ -135,9 +140,19 @@ func parseMoveArgs(args []string, out io.Writer) (moveArgs, error) {
switch args[0] {
case "status", "cut", "abort", "resolve":
a.verb, args = args[0], args[1:]
+ case "offline":
+ if len(args) < 2 || !slices.Contains(offlineVerbs, args[1]) {
+ moveSay(out, moveOfflineUsage)
+ return moveArgs{}, errors.New("no offline verb")
+ }
+ a.verb, a.offline, args = "offline", args[1], args[2:]
}
}
- fs := flag.NewFlagSet("move "+a.verb, flag.ContinueOnError)
+ name := "move " + a.verb
+ if a.offline != "" {
+ name += " " + a.offline
+ }
+ fs := flag.NewFlagSet(name, flag.ContinueOnError)
var parsed strings.Builder
fs.SetOutput(&parsed)
fs.Usage = func() {} // the help is said by moveFlagHelp
@@ -156,6 +171,10 @@ func parseMoveArgs(args []string, out io.Writer) (moveArgs, error) {
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 "offline":
+ if a.offline == "start" {
+ fs.StringVar(&a.to, "to", "", "the region to move the instance to")
+ }
case "resolve":
fs.BoolVar(&a.retry, "retry", false, "retry a stuck move once its cause is fixed")
fs.StringVar(&a.lostBox, "declare-lost", "", "declare the move's target box lost, once AWS shows it stopped or terminated")
@@ -173,7 +192,7 @@ func parseMoveArgs(args []string, out io.Writer) (moveArgs, error) {
case a.host == "":
moveSay(out, moveNeedsHost)
return moveArgs{}, errors.New("no --host")
- case a.verb == "request" && a.to == "":
+ case (a.verb == "request" || a.offline == "start") && a.to == "":
moveSay(out, moveNeedsTo)
return moveArgs{}, errors.New("no --to")
case a.verb == "resolve" && a.retry && a.lostBox != "":
@@ -183,7 +202,7 @@ func parseMoveArgs(args []string, out io.Writer) (moveArgs, error) {
moveSay(out, moveNeedsRetry)
return moveArgs{}, errors.New("no --retry or --declare-lost")
}
- if a.verb != "status" {
+ if a.verb != "status" && a.verb != "offline" {
a.wait = !a.noWait
}
return a, nil
@@ -231,6 +250,13 @@ func moveMain(ctx context.Context, args []string, out io.Writer, d moveDeps) int
m := &mover{sess: sess, out: out, host: a.host, poll: d.poll,
path: apiPath(acct, app, "/instances/"+url.PathEscape(a.host)+"/move")}
+ // The offline move is the operator's tool for a console with Phase 1 off.
+ // It has no Phase 1 marker to check: its doors refuse for themselves.
+ if a.verb == "offline" {
+ m.path += "/offline"
+ return m.offline(ctx, a)
+ }
+
// L4: read the status first, and send nothing to a console that does
// not answer as Phase 1.
view, code := m.readFlat(ctx)
@@ -293,6 +319,11 @@ type moveAnswer struct {
// (hasStuck when there is one).
stuck serverText
hasStuck bool
+ // waiting is the offline status view's "waiting": the sentence the next
+ // verb would be refused with, as the console wrote it (set by roundTrip).
+ WireWaiting string `json:"waiting"` // read only by roundTrip
+ waiting serverText
+ hasWaiting bool
}
type moveDetailView struct {
@@ -419,6 +450,9 @@ func (m *mover) roundTrip(ctx context.Context, method, path string, in any) move
if d := r.answer.Detail; d != nil && d.Move != nil && d.Move.WireStuck != "" {
r.answer.stuck, r.answer.hasStuck = serverText{spoken: d.Move.WireStuck}, true
}
+ if r.answer.WireWaiting != "" {
+ r.answer.waiting, r.answer.hasWaiting = serverText{spoken: r.answer.WireWaiting}, true
+ }
return r
}
var e struct {
@@ -468,31 +502,37 @@ func (m *mover) limit() {
// 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) (moveAnswer, int) {
+ r, code := m.getReply(ctx, path)
+ return r.answer, code
+}
+
+// getReply is get with the whole reply, for a reader of the raw body.
+func (m *mover) getReply(ctx context.Context, path string) (moveReply, int) {
failures := 0
for {
r := m.roundTrip(ctx, http.MethodGet, path, nil)
ok := !r.failed && r.moved == ""
switch {
case ok && r.status == http.StatusOK:
- return r.answer, -1
+ return r, -1
case r.moved != "" || r.stopped:
m.sayReply(r)
- return moveAnswer{}, moveExitError
+ return moveReply{}, moveExitError
case ok && r.status == http.StatusTooManyRequests:
m.limit()
case ok && r.status >= 400 && r.status < 500:
m.sayReply(r)
- return moveAnswer{}, moveExitRefused
+ return moveReply{}, moveExitRefused
default:
failures++
if failures >= moveTransient {
m.sayReply(r)
- return moveAnswer{}, moveExitError
+ return moveReply{}, moveExitError
}
}
if !m.sleep(ctx) {
moveSay(m.out, moveStopped)
- return moveAnswer{}, moveExitError
+ return moveReply{}, moveExitError
}
}
}
@@ -937,3 +977,551 @@ func (m *mover) refusal(r moveReply) int {
}
return -1
}
+
+// ---- carlos move offline ----
+//
+// An operator moves one instance with planned downtime, one step at a time:
+// start, seal, flip, open, finish, abort, and status (offline-move plan,
+// Task 7). Each verb posts to its door, then polls the status door every
+// interval until the step it started is done or has failed, and says which
+// verb comes next. It never runs the next verb by itself. The exit codes are
+// the family's: 0 the step is done, 3 the move was aborted, 4 the step failed
+// or the move waits for the operator, 5 the console refused, 1 an error.
+// Refusals are the console's own sentences (serverText); the CLI invents none.
+
+// offlineVerbs are the seven verbs after `carlos move offline`.
+var offlineVerbs = []string{"start", "seal", "flip", "open", "finish", "abort", "status"}
+
+// offlineView is the offline status view (GET) and the 202 every mutating
+// door answers with.
+type offlineView struct {
+ Host string `json:"host"`
+ State string `json:"state"`
+ ID string `json:"id"`
+ From string `json:"from"`
+ To string `json:"to"`
+ Sealer string `json:"sealer"`
+ Boxes []string `json:"boxes"`
+ Next string `json:"next"`
+ CanAbort bool `json:"can_abort"`
+ // Failed: a box's report says the step cannot finish (the status view's
+ // own flag; the CLI says MV-O6 or MV-O9 for it).
+ Failed bool `json:"failed"`
+ Reports []offlineReportView `json:"reports"`
+ Items []offlineItemView `json:"items"`
+ Last *moveOutcome `json:"last"`
+}
+
+type offlineReportView struct {
+ Box string `json:"box"`
+ Role string `json:"role"`
+ Phase string `json:"phase"`
+ Fresh bool `json:"fresh"`
+ Verdicts map[string]offlineVerdict `json:"items"`
+}
+
+// offlineVerdict is one check of a report; its names are apart from the
+// tree's for the copy gate (see offlineItemView).
+type offlineVerdict struct {
+ Passed bool `json:"ok"`
+ Why string `json:"reason"`
+}
+
+// The fields are named apart from the rest of the tree's (Detail, ID): the
+// copy gate reads a field by its name across the scanned files.
+type offlineItemView struct {
+ Slug string `json:"id"`
+ Owner string `json:"owner"`
+ Check string `json:"status"`
+ Diagnostic string `json:"detail"`
+}
+
+// offlineReply is one answer of the offline doors: the view, and the sentence
+// the console says the next verb waits for (server text).
+type offlineReply struct {
+ v offlineView
+ waiting serverText
+ hasWaiting bool
+}
+
+// report is the box's report of a role, if it wrote one.
+func (v offlineView) report(role, box string) *offlineReportView {
+ for i := range v.Reports {
+ if v.Reports[i].Role == role && v.Reports[i].Box == box {
+ return &v.Reports[i]
+ }
+ }
+ return nil
+}
+
+// failed names the items an owner's report failed, by the box's own
+// diagnostic (or the item's name when it gave none), joined. Empty when none
+// failed.
+func (v offlineView) failed(owner string) string {
+ var parts []string
+ for _, it := range v.Items {
+ if it.Owner != owner || it.Check != "failed" {
+ continue
+ }
+ if it.Diagnostic == "" {
+ parts = append(parts, it.Slug)
+ continue
+ }
+ parts = append(parts, it.Diagnostic)
+ }
+ return strings.Join(parts, "; ")
+}
+
+// why is the reason a step failed, for the one sentence that names it: the
+// items its owner's report failed, else what a failed report of the role
+// says, else the report's phase. Empty when nothing failed.
+func (v offlineView) why(owner, role string) string {
+ if reason := v.failed(owner); reason != "" {
+ return reason
+ }
+ for _, r := range v.Reports {
+ if r.Role != role || r.Phase != moves.OfflinePhaseFailed {
+ continue
+ }
+ var parts []string
+ for _, id := range slices.Sorted(maps.Keys(r.Verdicts)) {
+ c := r.Verdicts[id]
+ switch {
+ case c.Passed:
+ case c.Why != "":
+ parts = append(parts, c.Why)
+ default:
+ parts = append(parts, id)
+ }
+ }
+ if len(parts) > 0 {
+ return strings.Join(parts, "; ")
+ }
+ return r.Phase
+ }
+ if v.Failed {
+ return moves.OfflinePhaseFailed
+ }
+ return ""
+}
+
+// offlineRun is one `carlos move offline` verb's conversation: the move it
+// follows, as the console's answers named it, and what has been said.
+type offlineRun struct {
+ m *mover
+ id, from, to string
+ said map[string]bool
+ lastWaiting serverText
+}
+
+func (w *offlineRun) learn(v offlineView) {
+ if v.ID != "" && (w.id == "" || w.id == v.ID) {
+ w.id, w.from, w.to = v.ID, v.From, v.To
+ }
+}
+
+func (w *offlineRun) once(key string) bool {
+ if w.said[key] {
+ return false
+ }
+ w.said[key] = true
+ return true
+}
+
+// read decodes a 2xx reply from an offline door.
+func (w *offlineRun) read(r moveReply) (offlineReply, int) {
+ var v offlineView
+ if json.Unmarshal(r.body, &v) != nil {
+ moveSay(w.m.out, moveUnreadable)
+ return offlineReply{}, moveExitError
+ }
+ w.learn(v)
+ return offlineReply{v: v, waiting: r.answer.waiting, hasWaiting: r.answer.hasWaiting}, -1
+}
+
+// poll reads the status door.
+func (w *offlineRun) poll(ctx context.Context) (offlineReply, int) {
+ r, code := w.m.getReply(ctx, w.m.path)
+ if code >= 0 {
+ return offlineReply{}, code
+ }
+ return w.read(r)
+}
+
+// post sends a verb's door. A refusal is said as the console wrote it.
+func (w *offlineRun) post(ctx context.Context, tail string, body any) (offlineReply, int) {
+ r := w.m.send(ctx, http.MethodPost, tail, body)
+ if code := w.m.refusal(r); code >= 0 {
+ return offlineReply{}, code
+ }
+ return w.read(r)
+}
+
+// follow polls until step says the verb's step has ended, and returns the
+// exit code it gave.
+func (w *offlineRun) follow(ctx context.Context, cur offlineReply, step func(offlineReply) (int, bool)) int {
+ for {
+ if code, done := step(cur); done {
+ return code
+ }
+ if !w.m.sleep(ctx) {
+ moveSay(w.m.out, moveStopped)
+ return moveExitError
+ }
+ var code int
+ if cur, code = w.poll(ctx); code >= 0 {
+ return code
+ }
+ }
+}
+
+// sayWaiting says the console's sentence for what the next verb waits for,
+// each time it changes.
+func (w *offlineRun) sayWaiting(r offlineReply) {
+ if r.hasWaiting && r.waiting != w.lastWaiting {
+ saySpoken(w.m.out, r.waiting)
+ w.lastWaiting = r.waiting
+ }
+}
+
+// sayCleaning names, once each, the boxes the finish waits for.
+func (w *offlineRun) sayCleaning(v offlineView) {
+ var source, sealer string
+ switch v.State {
+ case provision.OfflineOpen:
+ source = moves.OfflinePhaseParked
+ case provision.OfflineFinishing:
+ source, sealer = moves.OfflinePhaseGone, moves.OfflinePhaseLifted
+ case provision.OfflineReverting:
+ source, sealer = moves.OfflinePhaseReverted, moves.OfflinePhaseReverted
+ default:
+ return
+ }
+ var boxes []string
+ for _, b := range v.Boxes {
+ if r := v.report(moves.OfflineRoleSource, b); r == nil || r.Phase != source {
+ boxes = append(boxes, b)
+ }
+ }
+ if sealer != "" && v.Sealer != "" {
+ if r := v.report(moves.OfflineRoleSealer, v.Sealer); r == nil || r.Phase != sealer {
+ boxes = append(boxes, v.Sealer)
+ }
+ }
+ for _, b := range boxes {
+ if w.once("clean:" + v.State + ":" + b) {
+ moveSay(w.m.out, fmt.Sprintf("Waiting for %s to clean up %s.", b, w.m.host))
+ }
+ }
+}
+
+// ended says the end of the move this run followed, from the console's
+// `last`, which counts only when it is that move's own.
+func (w *offlineRun) ended(v offlineView) int {
+ host := w.m.host
+ l := v.Last
+ if w.id == "" || l == nil || l.ID != w.id {
+ moveSay(w.m.out, moveNotRecorded)
+ return moveExitWaiting
+ }
+ switch l.Outcome {
+ case "done":
+ if w.to == "" {
+ offlineOutcomeLine(w.m, l)
+ return moveExitDone
+ }
+ moveSay(w.m.out, moves.MovedLine(host, w.to, int(math.Round(l.OfflineS))))
+ return moveExitDone
+ case "aborted":
+ if w.from == "" {
+ offlineOutcomeLine(w.m, l)
+ return moveExitRolled
+ }
+ moveSay(w.m.out, moves.MovedBackLine(host, w.from))
+ return moveExitRolled
+ }
+ moveSay(w.m.out, moveNotRecorded)
+ return moveExitWaiting
+}
+
+// offlineOutcomeLine says an outcome without the regions: the move, the host
+// and how it ended.
+func offlineOutcomeLine(m *mover, l *moveOutcome) {
+ moveSay(m.out, fmt.Sprintf("Move %s of %s is %s.", l.ID, m.host, l.Outcome))
+}
+
+// headline says where a move in flight stands, in the verbs' own sentences.
+func (w *offlineRun) headline(r offlineReply) {
+ v, host := r.v, w.m.host
+ moveSay(w.m.out, moves.MoveStartingLine(host, v.From, v.To))
+ switch v.State {
+ case provision.OfflineStopping:
+ if v.Next == "seal" {
+ moveSay(w.m.out, fmt.Sprintf("Every box in %s has stopped %s. Run carlos move offline seal --host %s to copy it.", v.From, host, host))
+ return
+ }
+ w.sayWaiting(r)
+ case provision.OfflineSealing:
+ moveSay(w.m.out, fmt.Sprintf("Copying %s to %s.", host, v.To))
+ case provision.OfflineSealed:
+ if v.Next == "flip" {
+ moveSay(w.m.out, fmt.Sprintf("Copied and checked. Run carlos move offline flip --host %s to start it in %s.", host, v.To))
+ return
+ }
+ w.sayWaiting(r)
+ case provision.OfflineLanding:
+ if reason := v.why(moves.OfflineOwnerLanding, moves.OfflineRoleTarget); reason != "" {
+ moveSay(w.m.out, fmt.Sprintf("%s could not take %s: %s. Run carlos move offline abort --host %s to move it back.", v.To, host, reason, host))
+ return
+ }
+ moveSay(w.m.out, moves.FlippedLine(v.From, v.To))
+ case provision.OfflineLanded:
+ if v.Next == "open" {
+ moveSay(w.m.out, fmt.Sprintf("%s holds everything %s needs. Nothing is served yet. Run carlos move offline open --host %s to serve it from %s.", v.To, host, host, v.To))
+ return
+ }
+ w.sayWaiting(r)
+ case provision.OfflineOpen, provision.OfflineFinishing:
+ moveSay(w.m.out, fmt.Sprintf("%s is served from %s. Run carlos move offline finish --host %s once %s has cleaned up.", host, v.To, host, v.From))
+ w.sayCleaning(v)
+ case provision.OfflineReverting:
+ moveSay(w.m.out, fmt.Sprintf("Moving %s back to %s.", host, v.From))
+ w.sayCleaning(v)
+ if v.Next == "finish" {
+ moveSay(w.m.out, fmt.Sprintf("Every box has put %s back in %s. Run carlos move offline finish --host %s to end the move.", host, v.From, host))
+ }
+ }
+}
+
+// offline runs one `carlos move offline` verb.
+func (m *mover) offline(ctx context.Context, a moveArgs) int {
+ w := &offlineRun{m: m, said: map[string]bool{}}
+ switch a.offline {
+ case "status":
+ return w.runStatus(ctx)
+ case "start":
+ return w.runStart(ctx, a.to)
+ case "seal":
+ return w.runSeal(ctx)
+ case "flip":
+ return w.runFlip(ctx)
+ case "open":
+ return w.runOpen(ctx)
+ case "finish":
+ return w.runFinish(ctx)
+ default: // abort
+ return w.runAbort(ctx)
+ }
+}
+
+// stray is a view the verb's step did not expect: a move that is no longer
+// in flight says how it ended; any other state is said where it stands, and
+// the operator decides.
+func (w *offlineRun) stray(r offlineReply) int {
+ if r.v.State == "none" {
+ return w.ended(r.v)
+ }
+ w.headline(r)
+ return moveExitWaiting
+}
+
+func (w *offlineRun) runStatus(ctx context.Context) int {
+ r, code := w.poll(ctx)
+ if code >= 0 {
+ return code
+ }
+ v, host := r.v, w.m.host
+ if v.State == "none" {
+ if v.Last != nil {
+ offlineOutcomeLine(w.m, v.Last)
+ } else {
+ moveSay(w.m.out, fmt.Sprintf("No move is in flight for %s.", host))
+ }
+ return moveExitDone
+ }
+ w.headline(r)
+ failed := false
+ for _, it := range v.Items {
+ if it.Diagnostic == "" {
+ moveSay(w.m.out, fmt.Sprintf("Item %s is %s.", it.Slug, it.Check))
+ } else {
+ moveSay(w.m.out, fmt.Sprintf("Item %s is %s: %s.", it.Slug, it.Check, it.Diagnostic))
+ }
+ failed = failed || it.Check == "failed"
+ }
+ for _, rep := range v.Reports {
+ if rep.Fresh {
+ moveSay(w.m.out, fmt.Sprintf("%s is %s as the %s.", rep.Box, rep.Phase, rep.Role))
+ } else {
+ moveSay(w.m.out, fmt.Sprintf("%s is %s as the %s. Its report is not current.", rep.Box, rep.Phase, rep.Role))
+ }
+ }
+ if failed {
+ return moveExitWaiting
+ }
+ return moveExitDone
+}
+
+func (w *offlineRun) runStart(ctx context.Context, to string) int {
+ r, code := w.post(ctx, "", map[string]string{"region": to})
+ if code >= 0 {
+ return code
+ }
+ host := w.m.host
+ moveSay(w.m.out, moves.MoveStartingLine(host, r.v.From, r.v.To))
+ moveSay(w.m.out, fmt.Sprintf("%s is offline from now until the move opens or is aborted.", host))
+ return w.follow(ctx, r, func(r offlineReply) (int, bool) {
+ switch r.v.State {
+ case provision.OfflineStopping:
+ if r.v.Next == "seal" {
+ moveSay(w.m.out, fmt.Sprintf("Every box in %s has stopped %s. Run carlos move offline seal --host %s to copy it.", r.v.From, host, host))
+ return moveExitDone, true
+ }
+ w.sayWaiting(r)
+ return 0, false
+ }
+ return w.stray(r), true
+ })
+}
+
+func (w *offlineRun) runSeal(ctx context.Context) int {
+ r, code := w.post(ctx, "/seal", struct{}{})
+ if code >= 0 {
+ return code
+ }
+ host := w.m.host
+ moveSay(w.m.out, fmt.Sprintf("Copying %s to %s.", host, r.v.To))
+ return w.follow(ctx, r, func(r offlineReply) (int, bool) {
+ switch r.v.State {
+ case provision.OfflineSealing, provision.OfflineSealed:
+ if reason := r.v.why(moves.OfflineOwnerSeal, moves.OfflineRoleSealer); reason != "" {
+ moveSay(w.m.out, fmt.Sprintf("The move could not finish a step: %s. Run carlos move offline status --host %s for each item.", reason, host))
+ return moveExitWaiting, true
+ }
+ if r.v.State == provision.OfflineSealed {
+ if r.v.Next == "flip" {
+ moveSay(w.m.out, fmt.Sprintf("Copied and checked. Run carlos move offline flip --host %s to start it in %s.", host, r.v.To))
+ return moveExitDone, true
+ }
+ w.sayWaiting(r)
+ }
+ return 0, false
+ }
+ return w.stray(r), true
+ })
+}
+
+func (w *offlineRun) runFlip(ctx context.Context) int {
+ r, code := w.post(ctx, "/flip", struct{}{})
+ if code >= 0 {
+ return code
+ }
+ host := w.m.host
+ moveSay(w.m.out, moves.FlippedLine(r.v.From, r.v.To))
+ return w.follow(ctx, r, func(r offlineReply) (int, bool) {
+ switch r.v.State {
+ case provision.OfflineLanding, provision.OfflineLanded:
+ if reason := r.v.why(moves.OfflineOwnerLanding, moves.OfflineRoleTarget); reason != "" {
+ moveSay(w.m.out, fmt.Sprintf("%s could not take %s: %s. Run carlos move offline abort --host %s to move it back.", r.v.To, host, reason, host))
+ return moveExitWaiting, true
+ }
+ if r.v.State == provision.OfflineLanded {
+ if r.v.Next == "open" {
+ moveSay(w.m.out, fmt.Sprintf("%s holds everything %s needs. Nothing is served yet. Run carlos move offline open --host %s to serve it from %s.", r.v.To, host, host, r.v.To))
+ return moveExitDone, true
+ }
+ w.sayWaiting(r)
+ }
+ return 0, false
+ }
+ return w.stray(r), true
+ })
+}
+
+func (w *offlineRun) runOpen(ctx context.Context) int {
+ r, code := w.post(ctx, "/open", struct{}{})
+ if code >= 0 {
+ return code
+ }
+ host := w.m.host
+ moveSay(w.m.out, fmt.Sprintf("%s is served from %s. Run carlos move offline finish --host %s once %s has cleaned up.", host, r.v.To, host, r.v.From))
+ return w.follow(ctx, r, func(r offlineReply) (int, bool) {
+ if r.v.State == provision.OfflineOpen {
+ if r.v.Next == "finish" {
+ return moveExitDone, true
+ }
+ w.sayCleaning(r.v)
+ return 0, false
+ }
+ return w.stray(r), true
+ })
+}
+
+// finish posts until the move is cleared. The first post (from open) starts
+// the clean-up and the console answers offline-finishing; once the boxes have
+// cleaned up and the tombstone is lifted, the status shows the next verb is
+// finish again and the second post ends the move. A move reverting from an
+// abort needs only the one.
+func (w *offlineRun) runFinish(ctx context.Context) int {
+ cur, code := w.poll(ctx)
+ if code >= 0 {
+ return code
+ }
+ for {
+ // Wait until the door would accept: the boxes' clean-up shows in the
+ // status before the post, so the door is never asked early.
+ switch cur.v.State {
+ case provision.OfflineOpen, provision.OfflineFinishing, provision.OfflineReverting:
+ if cur.v.Next != "finish" {
+ code := w.follow(ctx, cur, func(r offlineReply) (int, bool) {
+ switch r.v.State {
+ case provision.OfflineOpen, provision.OfflineFinishing, provision.OfflineReverting:
+ if r.v.Next == "finish" {
+ cur = r
+ return -1, true
+ }
+ w.sayCleaning(r.v)
+ return 0, false
+ }
+ cur = r
+ return -1, true
+ })
+ if code >= 0 {
+ return code
+ }
+ }
+ }
+ prev := cur.v.State
+ r, code := w.post(ctx, "/finish", struct{}{})
+ if code >= 0 {
+ return code
+ }
+ if r.v.State == "none" {
+ return w.ended(r.v)
+ }
+ if r.v.State == prev {
+ return w.stray(r)
+ }
+ cur = r
+ }
+}
+
+func (w *offlineRun) runAbort(ctx context.Context) int {
+ r, code := w.post(ctx, "/abort", struct{}{})
+ if code >= 0 {
+ return code
+ }
+ host := w.m.host
+ moveSay(w.m.out, fmt.Sprintf("Moving %s back to %s.", host, r.v.From))
+ return w.follow(ctx, r, func(r offlineReply) (int, bool) {
+ if r.v.State == provision.OfflineReverting {
+ if r.v.Next == "finish" {
+ moveSay(w.m.out, fmt.Sprintf("Every box has put %s back in %s. Run carlos move offline finish --host %s to end the move.", host, r.v.From, host))
+ return moveExitRolled, true
+ }
+ w.sayCleaning(r.v)
+ return 0, false
+ }
+ return w.stray(r), true
+ })
+}
diff --git a/move_test.go b/move_test.go
index e2f3a7390..caeb52fa0 100644
--- a/move_test.go
+++ b/move_test.go
@@ -1094,3 +1094,605 @@ func TestMoveStatusSaysWhyTheCutDidNotStart(t *testing.T) {
}
wantLines(t, out, mc12)
}
+
+// ---- carlos move offline ----
+
+const tOfflinePth = tMovePth + "/offline"
+
+// offPost is one scripted answer of a mutating door; the last one repeats.
+type offPost struct {
+ code int
+ body any
+}
+
+// fakeOfflineConsole answers the offline doors in T6's shapes. Each GET of the
+// status door returns the next scripted view (the last repeats); each POST
+// returns the next scripted answer of its door. Anything outside the offline
+// doors is a 404, so a verb that read the Phase 1 door would fail.
+type fakeOfflineConsole struct {
+ mu sync.Mutex
+ gets []frame
+ i int
+ posts map[string][]offPost // keyed by the door's tail: "", "/seal", ...
+ n map[string]int
+ log []string // method path body
+}
+
+func (c *fakeOfflineConsole) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ write := func(code int, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(code)
+ _ = json.NewEncoder(w).Encode(v)
+ }
+ body, _ := io.ReadAll(r.Body)
+ c.log = append(c.log, r.Method+" "+strings.TrimPrefix(r.URL.Path, tMovePth)+" "+strings.TrimSpace(string(body)))
+ if !strings.HasPrefix(r.URL.Path, tOfflinePth) {
+ http.NotFound(w, r)
+ return
+ }
+ if r.Method == http.MethodGet && r.URL.Path == tOfflinePth {
+ n := c.i
+ if n >= len(c.gets) {
+ n = len(c.gets) - 1
+ }
+ c.i++
+ write(http.StatusOK, c.gets[n])
+ return
+ }
+ tail := strings.TrimPrefix(r.URL.Path, tOfflinePth)
+ list := c.posts[tail]
+ if r.Method != http.MethodPost || len(list) == 0 {
+ http.NotFound(w, r)
+ return
+ }
+ if c.n == nil {
+ c.n = map[string]int{}
+ }
+ k := c.n[tail]
+ if k >= len(list) {
+ k = len(list) - 1
+ }
+ c.n[tail]++
+ write(list[k].code, list[k].body)
+}
+
+func (c *fakeOfflineConsole) requests() []string {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return append([]string(nil), c.log...)
+}
+
+// posted counts the POSTs a door got.
+func (c *fakeOfflineConsole) posted(tail string) int {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.n[tail]
+}
+
+func runOfflineAgainst(t *testing.T, c *fakeOfflineConsole, 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,
+ stdin: strings.NewReader(""),
+ }
+ args = append([]string{"offline"}, args...)
+ args = append(args, "--host", tHost, "--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()
+}
+
+// offView is the offline status view of a move in state, as T6 builds it.
+func offView(state string, extra frame) frame {
+ f := frame{"host": tHost, "state": state, "id": tMoveID, "from": "ie", "to": "uk",
+ "sealer": "ie-box", "boxes": []string{"ie-box"}}
+ for k, v := range extra {
+ f[k] = v
+ }
+ return f
+}
+
+func offDone(outcome string, offlineS float64) frame {
+ return frame{"host": tHost, "state": "none",
+ "last": frame{"id": tMoveID, "outcome": outcome, "at": "2026-10-20T10:00:00Z", "offline_s": offlineS}}
+}
+
+func offReport(box, role, phase string, fresh bool) frame {
+ return frame{"box": box, "role": role, "phase": phase, "fresh": fresh}
+}
+
+const (
+ oHost = "acme.go.tito.io"
+
+ oStarting = "Moving acme.go.tito.io from ie to uk."
+ oOffline = "acme.go.tito.io is offline from now until the move opens or is aborted."
+ oStopped = "Every box in ie has stopped acme.go.tito.io. Run carlos move offline seal --host acme.go.tito.io to copy it."
+ oCopying = "Copying acme.go.tito.io to uk."
+ oCopied = "Copied and checked. Run carlos move offline flip --host acme.go.tito.io to start it in uk."
+ oFlipped = "Stopped in ie. Starting in uk."
+ oLanded = "uk holds everything acme.go.tito.io needs. Nothing is served yet. Run carlos move offline open --host acme.go.tito.io to serve it from uk."
+ oServed = "acme.go.tito.io is served from uk. Run carlos move offline finish --host acme.go.tito.io once ie has cleaned up."
+ oBack = "Moving acme.go.tito.io back to ie."
+ oPutBack = "Every box has put acme.go.tito.io back in ie. Run carlos move offline finish --host acme.go.tito.io to end the move."
+ oWaitBox = "Waiting for ie-box in ie to confirm it has stopped acme.go.tito.io. acme.go.tito.io is offline."
+)
+
+func TestParseMoveArgsOffline(t *testing.T) {
+ for _, c := range []struct {
+ args []string
+ ok bool
+ sub string
+ }{
+ {[]string{"offline", "start", "--host", tHost, "--to", "uk"}, true, "start"},
+ {[]string{"offline", "seal", "--host", tHost}, true, "seal"},
+ {[]string{"offline", "flip", "--host", tHost}, true, "flip"},
+ {[]string{"offline", "open", "--host", tHost}, true, "open"},
+ {[]string{"offline", "finish", "--host", tHost}, true, "finish"},
+ {[]string{"offline", "abort", "--host", tHost}, true, "abort"},
+ {[]string{"offline", "status", "--host", tHost, "--app", "x", "--account", "y"}, true, "status"},
+ {[]string{"offline", "start", "--host", tHost}, false, ""},
+ {[]string{"offline", "start", "--to", "uk"}, false, ""},
+ {[]string{"offline", "seal", "--host", tHost, "--to", "uk"}, false, ""},
+ {[]string{"offline", "seal", "--host", tHost, "--no-wait"}, false, ""},
+ {[]string{"offline", "seal", "--host", tHost, "extra"}, false, ""},
+ {[]string{"offline"}, false, ""},
+ {[]string{"offline", "cut", "--host", tHost}, false, ""},
+ {[]string{"offline", "--host", tHost}, false, ""},
+ } {
+ got, err := parseMoveArgs(c.args, io.Discard)
+ if (err == nil) != c.ok || (c.ok && (got.verb != "offline" || got.offline != c.sub)) {
+ t.Errorf("parseMoveArgs(%q) = %+v, %v; want ok %v offline %q", c.args, got, err, c.ok, c.sub)
+ }
+ }
+ var out bytes.Buffer
+ if _, err := parseMoveArgs([]string{"offline", "cut", "--host", tHost}, &out); err == nil {
+ t.Fatal("an unknown offline verb parsed")
+ }
+ wantLines(t, out.String(), "carlos move offline needs start, seal, flip, open, finish, abort or status.")
+ out.Reset()
+ _, _ = parseMoveArgs([]string{"offline", "start", "--host", tHost}, &out)
+ wantLines(t, out.String(), "carlos move needs --to <region>.")
+ // The Phase 1 verbs are untouched.
+ if got, err := parseMoveArgs([]string{"abort", "--host", tHost}, io.Discard); err != nil || got.verb != "abort" || got.offline != "" {
+ t.Errorf("carlos move abort = %+v, %v", got, err)
+ }
+}
+
+func TestMoveOfflineStart(t *testing.T) {
+ c := &fakeOfflineConsole{
+ posts: map[string][]offPost{"": {{202, offView("offline-stopping", frame{"waiting": oWaitBox})}}},
+ gets: []frame{
+ offView("offline-stopping", frame{"waiting": oWaitBox}),
+ offView("offline-stopping", frame{"waiting": oWaitBox}),
+ offView("offline-stopping", frame{"next": "seal"}),
+ },
+ }
+ code, out := runOfflineAgainst(t, c, "start", "--to", "uk")
+ if code != 0 {
+ t.Fatalf("exit %d:\n%s", code, out)
+ }
+ want := oStarting + "\n" + oOffline + "\n" + oWaitBox + "\n" + oStopped + "\n"
+ if out != want {
+ t.Fatalf("output:\n%s\nwant:\n%s", out, want)
+ }
+ reqs := c.requests()
+ if reqs[0] != `POST {"region":"uk"}` && reqs[0] != `POST /offline {"region":"uk"}` {
+ t.Fatalf("first request %q", reqs[0])
+ }
+ for _, r := range reqs {
+ if !strings.Contains(r, "/offline") {
+ t.Errorf("a request left the offline doors: %q", r)
+ }
+ }
+}
+
+func TestMoveOfflineRefusals(t *testing.T) {
+ for _, c := range []struct {
+ name string
+ code int
+ body any
+ exit int
+ say string
+ }{
+ {"claim console", 409, frame{"error": "offline moves are not available while host claims or Phase 1 moves are on"}, 5, "offline moves are not available while host claims or Phase 1 moves are on"},
+ {"not found", 404, "404 page not found", 5, "Not found. Check --account and --app, and that you are a member of the instance's account."},
+ {"console error", 502, frame{"error": "could not write the move. try again"}, 1, "could not write the move. try again"},
+ {"no reason", 500, frame{}, 1, "The console answered 500 and gave no reason."},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ f := &fakeOfflineConsole{posts: map[string][]offPost{"": {{c.code, c.body}}, "/seal": {{c.code, c.body}}}, gets: []frame{offView("offline-stopping", nil)}}
+ for _, args := range [][]string{{"start", "--to", "uk"}, {"seal"}} {
+ code, out := runOfflineAgainst(t, f, args...)
+ if code != c.exit || out != c.say+"\n" {
+ t.Errorf("%v: exit %d, output %q; want %d, %q", args, code, out, c.exit, c.say)
+ }
+ }
+ for _, r := range f.requests() {
+ if strings.HasPrefix(r, "GET") {
+ t.Errorf("a refused verb polled: %q", r)
+ }
+ }
+ })
+ }
+}
+
+func TestMoveOfflineSeal(t *testing.T) {
+ proc := "ie-box still runs a process for acme.go.tito.io. wait for it to stop, then flip"
+ c := &fakeOfflineConsole{
+ posts: map[string][]offPost{"/seal": {{202, offView("offline-sealing", nil)}}},
+ gets: []frame{
+ offView("offline-sealing", nil),
+ offView("offline-sealing", nil),
+ offView("offline-sealed", frame{"waiting": proc}),
+ offView("offline-sealed", frame{"waiting": proc}),
+ offView("offline-sealed", frame{"next": "flip"}),
+ },
+ }
+ code, out := runOfflineAgainst(t, c, "seal")
+ if want := oCopying + "\n" + proc + "\n" + oCopied + "\n"; code != 0 || out != want {
+ t.Fatalf("exit %d, output:\n%s\nwant:\n%s", code, out, want)
+ }
+ if got := c.requests()[0]; !strings.HasPrefix(got, "POST /offline/seal") {
+ t.Errorf("first request %q", got)
+ }
+}
+
+func TestMoveOfflineSealFails(t *testing.T) {
+ item := func(status, detail string) frame {
+ return frame{"id": "files", "owner": "seal", "status": status, "detail": detail}
+ }
+ for _, c := range []struct {
+ name string
+ extra frame
+ say string
+ }{
+ {"item", frame{"items": []frame{item("ok", ""), item("failed", "a file over 256 MiB")}}, "The move could not finish a step: a file over 256 MiB. Run carlos move offline status --host acme.go.tito.io for each item."},
+ {"item without detail", frame{"items": []frame{item("failed", "")}}, "The move could not finish a step: files. Run carlos move offline status --host acme.go.tito.io for each item."},
+ {"failed flag and report", frame{"failed": true, "reports": []frame{{"box": "ie-box", "role": "sealer", "phase": "failed", "fresh": true,
+ "items": frame{"db": frame{"ok": true, "reason": ""}, "files": frame{"ok": false, "reason": "special file in the archive"}}}}},
+ "The move could not finish a step: special file in the archive. Run carlos move offline status --host acme.go.tito.io for each item."},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ f := &fakeOfflineConsole{
+ posts: map[string][]offPost{"/seal": {{202, offView("offline-sealing", nil)}}},
+ gets: []frame{offView("offline-sealing", c.extra)},
+ }
+ code, out := runOfflineAgainst(t, f, "seal")
+ if want := oCopying + "\n" + c.say + "\n"; code != 4 || out != want {
+ t.Fatalf("exit %d, output:\n%s\nwant 4:\n%s", code, out, want)
+ }
+ })
+ }
+}
+
+func TestMoveOfflineFlip(t *testing.T) {
+ c := &fakeOfflineConsole{
+ posts: map[string][]offPost{"/flip": {{202, offView("offline-landing", nil)}}},
+ gets: []frame{
+ offView("offline-landing", nil),
+ offView("offline-landed", frame{"waiting": "this move is offline-landed. run carlos move offline status --host acme.go.tito.io to see what it waits for"}),
+ offView("offline-landed", frame{"next": "open"}),
+ },
+ }
+ code, out := runOfflineAgainst(t, c, "flip")
+ want := oFlipped + "\nthis move is offline-landed. run carlos move offline status --host acme.go.tito.io to see what it waits for\n" + oLanded + "\n"
+ if code != 0 || out != want {
+ t.Fatalf("exit %d, output:\n%s\nwant:\n%s", code, out, want)
+ }
+}
+
+func TestMoveOfflineFlipFails(t *testing.T) {
+ failedItem := frame{"id": "db", "owner": "landing", "status": "failed", "detail": "the listing differs"}
+ for _, c := range []struct {
+ name string
+ extra frame
+ say string
+ }{
+ {"item", frame{"items": []frame{failedItem}}, "uk could not take acme.go.tito.io: the listing differs. Run carlos move offline abort --host acme.go.tito.io to move it back."},
+ {"failed flag, check in the report", frame{"failed": true, "reports": []frame{{"box": "uk-box", "role": "target", "phase": "failed", "fresh": true,
+ "items": frame{"env-pin": frame{"ok": false, "reason": "the app settings differ"}}}}},
+ "uk could not take acme.go.tito.io: the app settings differ. Run carlos move offline abort --host acme.go.tito.io to move it back."},
+ {"failed flag only", frame{"failed": true}, "uk could not take acme.go.tito.io: failed. Run carlos move offline abort --host acme.go.tito.io to move it back."},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ f := &fakeOfflineConsole{
+ posts: map[string][]offPost{"/flip": {{202, offView("offline-landing", nil)}}},
+ gets: []frame{offView("offline-landing", nil), offView("offline-landing", c.extra)},
+ }
+ code, out := runOfflineAgainst(t, f, "flip")
+ if want := oFlipped + "\n" + c.say + "\n"; code != 4 || out != want {
+ t.Fatalf("exit %d, output:\n%s\nwant 4:\n%s", code, out, want)
+ }
+ if f.posted("/flip") != 1 {
+ t.Error("flip posted more than once")
+ }
+ })
+ }
+}
+
+func TestMoveOfflineOpen(t *testing.T) {
+ c := &fakeOfflineConsole{
+ posts: map[string][]offPost{"/open": {{202, offView("offline-open", nil)}}},
+ gets: []frame{
+ offView("offline-open", frame{"reports": []frame{offReport("ie-box", "source", "stopped", true)}}),
+ offView("offline-open", frame{"reports": []frame{offReport("ie-box", "source", "stopped", true)}}),
+ offView("offline-open", frame{"next": "finish", "reports": []frame{offReport("ie-box", "source", "parked", true)}}),
+ },
+ }
+ code, out := runOfflineAgainst(t, c, "open")
+ want := oServed + "\nWaiting for ie-box to clean up acme.go.tito.io.\n"
+ if code != 0 || out != want {
+ t.Fatalf("exit %d, output:\n%s\nwant:\n%s", code, out, want)
+ }
+}
+
+// finish posts twice for a move that was open: the first starts the clean-up
+// and answers offline-finishing; once the boxes are gone and the tombstone is
+// lifted, the status shows finish again and the second post ends the move.
+func TestMoveOfflineFinishPostsTwice(t *testing.T) {
+ c := &fakeOfflineConsole{
+ posts: map[string][]offPost{"/finish": {
+ {202, offView("offline-finishing", frame{"reports": []frame{offReport("ie-box", "source", "parked", true)}})},
+ {202, offDone("done", 1800)},
+ }},
+ gets: []frame{
+ offView("offline-open", frame{"next": "finish"}),
+ offView("offline-finishing", frame{"reports": []frame{offReport("ie-box", "source", "parked", true)}}),
+ offView("offline-finishing", frame{"reports": []frame{offReport("ie-box", "source", "gone", true), offReport("ie-box", "sealer", "sealed", true)}}),
+ offView("offline-finishing", frame{"next": "finish", "reports": []frame{offReport("ie-box", "source", "gone", true), offReport("ie-box", "sealer", "lifted", true)}}),
+ },
+ }
+ code, out := runOfflineAgainst(t, c, "finish")
+ want := "Waiting for ie-box to clean up acme.go.tito.io.\nMoved. acme.go.tito.io is served from uk. It was offline for 1800 seconds.\n"
+ if code != 0 || out != want {
+ t.Fatalf("exit %d, output:\n%s\nwant:\n%s", code, out, want)
+ }
+ if n := c.posted("/finish"); n != 2 {
+ t.Fatalf("finish posted %d times, want 2:\n%v", n, c.requests())
+ }
+}
+
+// A finish before the boxes have parked does not ask the door early.
+func TestMoveOfflineFinishWaitsBeforeThePost(t *testing.T) {
+ c := &fakeOfflineConsole{
+ posts: map[string][]offPost{"/finish": {{202, offDone("done", 12)}}},
+ gets: []frame{
+ offView("offline-open", frame{"reports": []frame{offReport("ie-box", "source", "stopped", true)}}),
+ offView("offline-open", frame{"next": "finish"}),
+ },
+ }
+ code, out := runOfflineAgainst(t, c, "finish")
+ if code != 0 || !strings.HasSuffix(out, "It was offline for 12 seconds.\n") || !strings.HasPrefix(out, "Waiting for ie-box to clean up acme.go.tito.io.\n") {
+ t.Fatalf("exit %d:\n%s", code, out)
+ }
+ reqs := c.requests()
+ post := -1
+ for i, r := range reqs {
+ if strings.HasPrefix(r, "POST") {
+ post = i
+ }
+ }
+ if post < 2 {
+ t.Fatalf("the door was posted before the status showed finish:\n%v", reqs)
+ }
+}
+
+func TestMoveOfflineFinishAfterAbort(t *testing.T) {
+ c := &fakeOfflineConsole{
+ posts: map[string][]offPost{"/finish": {{202, offDone("aborted", 0)}}},
+ gets: []frame{offView("offline-reverting", frame{"next": "finish"})},
+ }
+ code, out := runOfflineAgainst(t, c, "finish")
+ if want := "acme.go.tito.io is back in ie and serving. Nothing was lost.\n"; code != 3 || out != want {
+ t.Fatalf("exit %d, output:\n%s\nwant 3:\n%s", code, out, want)
+ }
+ if c.posted("/finish") != 1 {
+ t.Error("finish after an abort posted more than once")
+ }
+}
+
+func TestMoveOfflineFinishRefusedAndOutcomeNotRecorded(t *testing.T) {
+ // Nothing in flight: the door's own sentence, exit 5.
+ c := &fakeOfflineConsole{
+ posts: map[string][]offPost{"/finish": {{409, frame{"error": "acme.go.tito.io has no offline move in flight"}}}},
+ gets: []frame{{"host": tHost, "state": "none"}},
+ }
+ if code, out := runOfflineAgainst(t, c, "finish"); code != 5 || out != "acme.go.tito.io has no offline move in flight\n" {
+ t.Fatalf("exit %d:\n%s", code, out)
+ }
+ // An outcome that is another move's is not this move's.
+ c = &fakeOfflineConsole{
+ posts: map[string][]offPost{"/finish": {{202, frame{"host": tHost, "state": "none", "last": frame{"id": "mv_other", "outcome": "done"}}}}},
+ gets: []frame{offView("offline-reverting", frame{"next": "finish"})},
+ }
+ if code, out := runOfflineAgainst(t, c, "finish"); code != 4 || out != "This move's outcome is not recorded.\n" {
+ t.Fatalf("exit %d:\n%s", code, out)
+ }
+}
+
+func TestMoveOfflineAbort(t *testing.T) {
+ c := &fakeOfflineConsole{
+ posts: map[string][]offPost{"/abort": {{202, offView("offline-reverting", nil)}}},
+ gets: []frame{
+ offView("offline-reverting", frame{"reports": []frame{offReport("ie-box", "source", "stopped", true)}}),
+ offView("offline-reverting", frame{"next": "finish", "reports": []frame{offReport("ie-box", "source", "reverted", true)}}),
+ },
+ }
+ code, out := runOfflineAgainst(t, c, "abort")
+ want := oBack + "\nWaiting for ie-box to clean up acme.go.tito.io.\n" + oPutBack + "\n"
+ if code != 3 || out != want {
+ t.Fatalf("exit %d, output:\n%s\nwant 3:\n%s", code, out, want)
+ }
+ if c.posted("/finish") != 0 {
+ t.Error("abort ran finish by itself")
+ }
+ // Once open, the door refuses and the CLI says so as sent.
+ c = &fakeOfflineConsole{posts: map[string][]offPost{"/abort": {{409, frame{"error": "acme.go.tito.io is already served from uk. move it back with a new move"}}}}}
+ if code, out := runOfflineAgainst(t, c, "abort"); code != 5 || out != "acme.go.tito.io is already served from uk. move it back with a new move\n" {
+ t.Fatalf("exit %d:\n%s", code, out)
+ }
+}
+
+// A verb whose step is overtaken (another terminal aborted) says where the
+// move stands and leaves the decision to the operator.
+func TestMoveOfflineOvertaken(t *testing.T) {
+ c := &fakeOfflineConsole{
+ posts: map[string][]offPost{"/seal": {{202, offView("offline-sealing", nil)}}},
+ gets: []frame{offView("offline-reverting", nil)},
+ }
+ code, out := runOfflineAgainst(t, c, "seal")
+ if want := oCopying + "\n" + oStarting + "\n" + oBack + "\nWaiting for ie-box to clean up acme.go.tito.io.\n"; code != 4 || out != want {
+ t.Fatalf("exit %d, output:\n%s\nwant 4:\n%s", code, out, want)
+ }
+}
+
+func TestMoveOfflineStatus(t *testing.T) {
+ c := &fakeOfflineConsole{gets: []frame{offView("offline-sealed", frame{
+ "next": "flip",
+ "items": []frame{
+ {"id": "db", "owner": "landing", "status": "waiting"},
+ {"id": "files", "owner": "seal", "status": "ok", "detail": "12 files"},
+ },
+ "reports": []frame{offReport("ie-box", "source", "stopped", true), offReport("ie-box", "sealer", "sealed", false)},
+ })}}
+ code, out := runOfflineAgainst(t, c, "status")
+ want := oStarting + "\n" + oCopied + "\n" +
+ "Item db is waiting.\nItem files is ok: 12 files.\n" +
+ "ie-box is stopped as the source.\nie-box is sealed as the sealer. Its report is not current.\n"
+ if code != 0 || out != want {
+ t.Fatalf("exit %d, output:\n%s\nwant:\n%s", code, out, want)
+ }
+ if reqs := c.requests(); len(reqs) != 1 || !strings.HasPrefix(reqs[0], "GET /offline") {
+ t.Fatalf("status sent %v", reqs)
+ }
+
+ // A failed item is the operator's: exit 4.
+ c = &fakeOfflineConsole{gets: []frame{offView("offline-landing", frame{"items": []frame{{"id": "db", "owner": "landing", "status": "failed", "detail": "digest differs"}}})}}
+ code, out = runOfflineAgainst(t, c, "status")
+ wantF := oStarting + "\nuk could not take acme.go.tito.io: digest differs. Run carlos move offline abort --host acme.go.tito.io to move it back.\nItem db is failed: digest differs.\n"
+ if code != 4 || out != wantF {
+ t.Fatalf("exit %d, output:\n%s\nwant 4:\n%s", code, out, wantF)
+ }
+
+ // Waiting states say the console's sentence.
+ c = &fakeOfflineConsole{gets: []frame{offView("offline-stopping", frame{"waiting": oWaitBox})}}
+ if code, out := runOfflineAgainst(t, c, "status"); code != 0 || out != oStarting+"\n"+oWaitBox+"\n" {
+ t.Fatalf("exit %d:\n%s", code, out)
+ }
+
+ // Nothing in flight.
+ c = &fakeOfflineConsole{gets: []frame{{"host": tHost, "state": "none"}}}
+ if code, out := runOfflineAgainst(t, c, "status"); code != 0 || out != "No move is in flight for acme.go.tito.io.\n" {
+ t.Fatalf("exit %d:\n%s", code, out)
+ }
+ c = &fakeOfflineConsole{gets: []frame{offDone("done", 5)}}
+ if code, out := runOfflineAgainst(t, c, "status"); code != 0 || out != "Move "+tMoveID+" of acme.go.tito.io is done.\n" {
+ t.Fatalf("exit %d:\n%s", code, out)
+ }
+}
+
+func TestMoveOfflineWaitingAfterOpenAndAbort(t *testing.T) {
+ // Status of an open move names the boxes still cleaning up.
+ c := &fakeOfflineConsole{gets: []frame{offView("offline-finishing", frame{
+ "reports": []frame{offReport("ie-box", "source", "gone", true), offReport("ie-box", "sealer", "sealed", true)}})}}
+ code, out := runOfflineAgainst(t, c, "status")
+ want := oStarting + "\n" + oServed + "\nWaiting for ie-box to clean up acme.go.tito.io.\n" +
+ "ie-box is gone as the source.\nie-box is sealed as the sealer.\n"
+ if code != 0 || out != want {
+ t.Fatalf("exit %d, output:\n%s\nwant:\n%s", code, out, want)
+ }
+}
+
+func TestMoveOfflineNeedsNoPhase1AndRefusesRoot(t *testing.T) {
+ // The Phase 1 door is never read: the fake 404s everything outside
+ // /move/offline, and the verb still works (the other tests). Root is
+ // refused as for every carlos move.
+ c := &fakeOfflineConsole{gets: []frame{{"host": tHost, "state": "none"}}}
+ ts := httptest.NewServer(c)
+ defer ts.Close()
+ var out bytes.Buffer
+ deps := moveDeps{session: func(string) (*apiSession, error) { t.Error("signed in as root"); return nil, nil }, euid: func() int { return 0 }, poll: time.Millisecond}
+ if code := moveMain(context.Background(), []string{"offline", "status", "--host", tHost, "--app", "titogo"}, &out, deps); code != 5 {
+ t.Fatalf("exit %d:\n%s", code, out.String())
+ }
+ wantLines(t, out.String(), "Run carlos move as yourself, not as root. It uses your own credentials.")
+ if len(c.requests()) != 0 {
+ t.Error("a root run reached the console")
+ }
+}
+
+func TestMoveOfflineRidesOutARateLimit(t *testing.T) {
+ c := &fakeOfflineConsole{
+ posts: map[string][]offPost{"/seal": {{429, frame{"error": "too many instance requests. wait a minute and try again"}}, {202, offView("offline-sealing", nil)}}},
+ gets: []frame{offView("offline-sealed", frame{"next": "flip"})},
+ }
+ code, out := runOfflineAgainst(t, c, "seal")
+ if want := "The console is limiting requests. Checking every 10 seconds.\n" + oCopying + "\n" + oCopied + "\n"; code != 0 || out != want {
+ t.Fatalf("exit %d, output:\n%s\nwant:\n%s", code, out, want)
+ }
+ if c.posted("/seal") != 2 {
+ t.Errorf("seal posted %d times, want 2", c.posted("/seal"))
+ }
+}
+
+// The page's paragraph is the copy record's rows MV-O32 and MV-O33, word for
+// word, and its examples parse as the seven offline verbs.
+func TestMoveDocsOfflineParagraph(t *testing.T) {
+ raw, err := os.ReadFile("docs/site/cli.md")
+ if err != nil {
+ t.Fatal(err)
+ }
+ page := string(raw)
+ rec, err := os.ReadFile("docs/superpowers/plans/2026-10-06-moves-phase1-copy.md")
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, id := range []string{"MV-O32", "MV-O33"} {
+ text := ""
+ for _, line := range strings.Split(string(rec), "\n") {
+ if cells := strings.Split(line, "|"); len(cells) >= 5 && strings.TrimSpace(cells[1]) == id {
+ text = strings.TrimSpace(cells[3])
+ }
+ }
+ if text == "" {
+ t.Fatalf("the copy record has no row %s", id)
+ }
+ if !strings.Contains(page, "\n"+text+"\n") {
+ t.Errorf("the page does not carry %s word for word: %q", id, text)
+ }
+ }
+ seen := map[string]bool{}
+ for _, line := range strings.Split(page, "\n") {
+ fields := strings.Fields(line)
+ if len(fields) < 4 || fields[0] != "carlos" || fields[1] != "move" || fields[2] != "offline" {
+ continue
+ }
+ args := fields[2:]
+ for i, a := range args {
+ if strings.HasPrefix(a, "<") {
+ args[i] = "x"
+ }
+ }
+ got, err := parseMoveArgs(args, io.Discard)
+ if err != nil {
+ t.Errorf("the page's example %q does not parse: %v", line, err)
+ continue
+ }
+ seen[got.offline] = true
+ }
+ for _, v := range offlineVerbs {
+ if !seen[v] {
+ t.Errorf("the page has no example of carlos move offline %s", v)
+ }
+ }
+}