carlos / platform Public

Merge branch 'moves-offline' into moves-offline-t4

claude agent, operated by paul@keymail.dev d3017130beba5ac5b1a0a68b39ac9f66282db948 parent f7af024parent d96b326
9 files changed, +1843 −22
  • approved_moves_copy_scan_test.go +25 −1
  • approved_moves_copy_test.go +41 −1
  • docs/site/cli.md +14 −0
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +24 −0
  • internal/console/apiinstancemove_offline.go +41 −11
  • internal/console/apiinstancemove_offline_test.go +42 −0
  • move.go +726 −9
  • move_test.go +712 −0
  • offline_move_e2e_test.go +218 −0
diff --git a/approved_moves_copy_scan_test.go b/approved_moves_copy_scan_test.go
index 1de9a25b0..2de558d4d 100644
--- a/approved_moves_copy_scan_test.go
+++ b/approved_moves_copy_scan_test.go
@@ -373,6 +373,24 @@ type movesUniverse struct {
paramSinks map[string]bool // functions whose parameter reaches a sink
files []string
steps int
+ // diagSkipped counts, per movesDiagnosticWrites entry, the writes the
+ // universe left out of the field's writes.
+ diagSkipped map[string]int
+}
+
+// callsError reports whether e calls an Error method: the write carries an
+// error's text. Only such writes can be listed in movesDiagnosticWrites.
+func callsError(e ast.Expr) bool {
+ found := false
+ ast.Inspect(e, func(n ast.Node) bool {
+ if c, ok := n.(*ast.CallExpr); ok {
+ if sel, ok := c.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "Error" {
+ found = true
+ }
+ }
+ return !found
+ })
+ return found
}
func (u *movesUniverse) at(p string) string {
@@ -386,7 +404,7 @@ func newUniverse(t *testing.T, root string, files []string) *movesUniverse {
t.Helper()
u := &movesUniverse{t: t, root: root, fieldWrites: map[string][]*fieldWrite{}, fieldTypes: map[string]map[string]bool{},
structFields: map[string][]string{}, fieldTypeOf: map[string]map[string]ast.Expr{}, results: map[string]*fieldResult{}, fieldTexts: map[string]string{}, used: map[string]bool{},
- paramSinks: map[string]bool{}, files: files}
+ paramSinks: map[string]bool{}, files: files, diagSkipped: map[string]int{}}
parsed := map[string]*ast.File{}
for _, f := range files {
af, err := parser.ParseFile(movesFset, u.at(f), nil, 0)
@@ -468,6 +486,12 @@ func newUniverse(t *testing.T, root string, files []string) *movesUniverse {
for i, el := range n.Elts {
if kv, ok := el.(*ast.KeyValueExpr); ok {
if k, ok := kv.Key.(*ast.Ident); ok {
+ if fd != nil {
+ if key := fd.Name.Name + "|" + ctype + "." + k.Name; movesDiagnosticWrites[f][key].Why != "" && callsError(kv.Value) {
+ u.diagSkipped[f+"|"+key]++
+ continue
+ }
+ }
u.fieldWrites[k.Name] = append(u.fieldWrites[k.Name], &fieldWrite{file: f, fd: fd, expr: kv.Value, ctype: ctype})
}
} else if i < len(order) {
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index 4716b4dfe..490e1bb38 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", "MV-O58", "MV-O59", "MV-O60", "MV-O61", "MV-O62", "MV-O63", "MV-O64", "MV-O65",
}
// copyRow is one row of the record. The File cell is the file the sentence is
@@ -1261,6 +1261,7 @@ var movesScanFiles = []string{
"internal/host/movesparks.go",
"internal/host/movespaging.go",
"internal/host/movespass.go",
+ "internal/host/movesoffline.go",
"internal/host/movesphase1.go",
"internal/host/movespolicy.go",
"internal/host/movesprobe.go",
@@ -1524,6 +1525,29 @@ var movesSinkAllow = map[string]map[string]string{
},
}
+// movesDiagnosticWrites are field writes the scan leaves out of a field's
+// writes because what they write is an OS diagnostic for the operator's
+// eye, not copy. Paul's rule (2026-10-16): internal error fragments and
+// diagnostics stay out of copy review. The key is "<function>|<struct>.<field>".
+// An entry leaves out only the composite-literal writes of that field in that
+// function of that file whose value calls Error(), and says how many there are
+// (Count), so the field's other writes in the same function stay scanned, and
+// so do its other writers (the Phase 1 Reason reads in move.go among them).
+// TestMovesDiagnosticWritesAreReal holds each entry to exactly that many.
+type movesDiagnosticWrite struct {
+ Why string
+ Count int
+}
+
+var movesDiagnosticWrites = map[string]map[string]movesDiagnosticWrite{
+ "internal/host/movesoffline.go": {
+ "retire|RetiredPath.Reason": {
+ Why: "the parked report's reason for a path left in place ends in the OS error of the rename (leftCannot or leftNotYet plus err.Error()), a box diagnostic that `carlos move offline status` shows as MV-O58's <reason>; it is not copy",
+ Count: 2,
+ },
+ },
+}
+
// movesServerText names, per file, the type that carries text the console
// wrote (fix round 2 of Plan 6 Task 1a, ruling 1). It is made in one function
// that reads an HTTP response body, and said through one sink; the gate
@@ -2471,3 +2495,19 @@ func TestMovesServerTextRefusesForgedValues(t *testing.T) {
}
}
}
+
+// Every movesDiagnosticWrites entry leaves out exactly the writes it counts,
+// and the reason it gives is a sentence.
+func TestMovesDiagnosticWritesAreReal(t *testing.T) {
+ u := newUniverse(t, ".", movesScanFiles)
+ for f, keys := range movesDiagnosticWrites {
+ for key, w := range keys {
+ if len(w.Why) < 40 {
+ t.Errorf("%s %s: the reason is too short to be one", f, key)
+ }
+ if got := u.diagSkipped[f+"|"+key]; got != w.Count {
+ t.Errorf("%s %s leaves out %d writes, want exactly %d", f, key, got, w.Count)
+ }
+ }
+ }
+}
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..2cdb820ff 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,27 @@ 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 |
+
+Seven 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), the usage line, and the line `status` prints for a path a source box could not park and left where it was (MV-O58; `<reason>` is the box's own reason, not copy). 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 |
+| MV-O58 | move.go | <box> left <path> in place: <reason>. | pending |
+| MV-O65 | move.go | <box> could not clean up <host>: <reason>. Run carlos move offline status --host <host> for each item. | pending |
+
+The source box's reasons for a path it left in place (`internal/host/movesoffline.go`, `retire`), which `carlos move offline status` shows as MV-O58's `<reason>`. They are human text the box writes into its parked report. The two that end in the box's own OS error (`<error>`, a diagnostic, not copy) are recorded for their fixed beginning.
+
+| id | file | text | status |
+|---|---|---|---|
+| MV-O59 | internal/host/movesoffline.go | root-owned: listed for the census, not renamed | pending |
+| MV-O60 | internal/host/movesoffline.go | the mount view of the instance directory: it stays, empty | pending |
+| MV-O61 | internal/host/movesoffline.go | the instance directory stays, empty | pending |
+| MV-O62 | internal/host/movesoffline.go | not renamed yet: the stop could not be confirmed this pass | pending |
+| MV-O63 | internal/host/movesoffline.go | cannot be renamed: <error> | pending |
+| MV-O64 | internal/host/movesoffline.go | not renamed yet: <error> | pending |
diff --git a/internal/console/apiinstancemove_offline.go b/internal/console/apiinstancemove_offline.go
index 033ceb635..4d0813f9a 100644
--- a/internal/console/apiinstancemove_offline.go
+++ b/internal/console/apiinstancemove_offline.go
@@ -546,27 +546,57 @@ func (s *Server) handleAPIInstanceOfflineStart(w http.ResponseWriter, r *http.Re
writeRefusal(w, ref)
return
}
- got, ok := s.offlineWrite(w, r, d, start)
- if !ok {
- s.offlineReleaseLock(ctx, host, id)
+ // The write is bounded well under offlineLockGrace, so the lock cannot be
+ // judged stale while it is still in flight.
+ wctx, cancel := context.WithTimeout(ctx, offlineStartWrite)
+ defer cancel()
+ got, err := s.offlineApply(wctx, d, start)
+ if err != nil {
+ // A failed write does not prove nothing was written (a commit whose
+ // reply was lost). The lock is never released here: if the record
+ // carries this move the start succeeded, and if it does not the
+ // lock goes stale and frees itself.
+ rctx, rcancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
+ defer rcancel()
+ if cur, rerr := provision.GetRecord(rctx, s.cfg.Releases, d.acct.Sqid, d.app, host); rerr == nil && cur.Move.Offline() && cur.Move.ID == id {
+ got, err = cur, nil
+ }
+ }
+ if err != nil {
+ s.offlineWriteFailed(w, d, err)
return
}
+ s.bumpSerial(ctx)
writeJSON(w, http.StatusAccepted, s.offlineViewOf(ctx, got))
}
+// offlineStartWrite bounds the start door's record write.
+const offlineStartWrite = 30 * time.Second
+
+// offlineApply is one transition through the record writer.
+func (s *Server) offlineApply(ctx context.Context, d offlineDoor, mut func(*provision.Record) error) (*provision.Record, error) {
+ return provision.SetRecordMove(ctx, s.cfg.Releases, d.acct.Sqid, d.app, d.host, mut)
+}
+
+// offlineWriteFailed answers a transition's failure: a refusal with its
+// recorded sentence, anything else as unwritten.
+func (s *Server) offlineWriteFailed(w http.ResponseWriter, d offlineDoor, err error) {
+ if errors.Is(err, provision.ErrMoveConflict) {
+ jsonError(w, http.StatusConflict, offlineConflict(err))
+ return
+ }
+ log.Printf("console: offline move %s/%s/%s: %v", d.acct.Sqid, d.app, d.host, err)
+ jsonError(w, http.StatusBadGateway, "could not write the move. try again")
+}
+
// offlineWrite applies one transition and bumps the serial, or answers the
// refusal itself. When the move it leaves is cleared (finish, or the finish
// that ends an abort) it releases the lock.
func (s *Server) offlineWrite(w http.ResponseWriter, r *http.Request, d offlineDoor, mut func(*provision.Record) error) (*provision.Record, bool) {
ctx := r.Context()
- rec, err := provision.SetRecordMove(ctx, s.cfg.Releases, d.acct.Sqid, d.app, d.host, mut)
- if errors.Is(err, provision.ErrMoveConflict) {
- jsonError(w, http.StatusConflict, offlineConflict(err))
- return nil, false
- }
+ rec, err := s.offlineApply(ctx, d, mut)
if err != nil {
- log.Printf("console: offline move %s/%s/%s: %v", d.acct.Sqid, d.app, d.host, err)
- jsonError(w, http.StatusBadGateway, "could not write the move. try again")
+ s.offlineWriteFailed(w, d, err)
return nil, false
}
s.bumpSerial(ctx)
@@ -795,7 +825,7 @@ func (s *Server) offlineSealCheck(f offlineFleet, ev offlineEvidence, mv *provis
// byte for byte. The seal is written once, so the hash holds for the whole
// move whatever the sealer's living report does each pass.
func offlineSealHolds(ev offlineEvidence, mv *provision.MoveState, host string) bool {
- return ev.seal != nil && mv.Source != nil &&
+ return ev.seal != nil && ev.seal.Box == mv.Sealer && mv.Source != nil &&
offlineEvidenceMatches(mv.Source.Evidence, moves.OfflineSealKey(host, mv.ID), ev.seal.raw)
}
diff --git a/internal/console/apiinstancemove_offline_test.go b/internal/console/apiinstancemove_offline_test.go
index 7e453142f..6a991ff82 100644
--- a/internal/console/apiinstancemove_offline_test.go
+++ b/internal/console/apiinstancemove_offline_test.go
@@ -784,6 +784,10 @@ func TestOfflineFlipDoor(t *testing.T) {
bad = w.putSeal("ie-box", func(r *moves.OfflineReport) { r.TombstoneOwner = moves.TombstoneOwner("mv_other") })
w.rehash(bad)
w.refused(http.MethodPost, "/flip", "", http.StatusConflict, msgO25(provision.OfflineSealed))
+ // A seal written by a box other than the recorded sealer.
+ bad = w.putSeal("ie-x", nil)
+ w.rehash(bad)
+ w.refused(http.MethodPost, "/flip", "", http.StatusConflict, msgO25(provision.OfflineSealed))
offlinePut(t, w.fx.rel, sealKey, seal)
w.rehash(seal)
// A process left, a layout journal, a stale report.
@@ -1383,3 +1387,41 @@ func TestOfflineStartIgnoresOtherRecords(t *testing.T) {
offlinePut(t, w.fx.rel, provision.RecordKey("bab", "titogo", "junk.go.tito.io"), []byte("{not a record"))
w.start()
}
+
+// A failed record write does not prove nothing was written, so start never
+// releases its lock on one. A write that lands and then reports an error is a
+// start that succeeded; a write that does not land leaves the lock, which
+// goes stale by itself. Either way no other host starts meanwhile.
+func TestOfflineStartKeepsTheLockWhenTheRecordWriteFails(t *testing.T) {
+ for _, landed := range []bool{true, false} {
+ t.Run(fmt.Sprintf("landed=%v", landed), func(t *testing.T) {
+ w := newOfflineWorld(t, oneEach...)
+ if c, _, out := rawReq(t, http.MethodPost, instancesURL(w.fx, "bab", "titogo"), w.token, `{"host":"beta.go.tito.io","region":"ie"}`); c != http.StatusCreated {
+ t.Fatalf("create = %d %s", c, out)
+ }
+ w.fx.srv.cfg.Releases = &recordWriteFault{Store: w.fx.rel, key: provision.RecordKey("bab", "titogo", offlineHost), land: landed}
+ code, out := w.req(http.MethodPost, "", `{"region":"uk"}`)
+ if landed {
+ // The record carries the move: the start succeeded.
+ if code != http.StatusAccepted {
+ t.Fatalf("start = %d %s", code, out)
+ }
+ if v := w.view(code, out); v.State != provision.OfflineStopping || v.ID == "" {
+ t.Fatalf("view = %+v", v)
+ }
+ } else if code != http.StatusBadGateway || errorText(out) != "could not write the move. try again" {
+ t.Fatalf("start = %d %s", code, out)
+ }
+ a, ok := w.lock()
+ if !ok || a.Host != offlineHost {
+ t.Fatalf("lock = %+v %v, want it kept", a, ok)
+ }
+ // A second host's start is refused whichever way the write went.
+ bu := instancesURL(w.fx, "bab", "titogo") + "/beta.go.tito.io/move/offline"
+ c, _, o := rawReq(t, http.MethodPost, bu, w.token, `{"region":"uk"}`)
+ if c != http.StatusConflict || errorText(o) != offlineHost+" is moving offline. move one instance at a time" {
+ t.Fatalf("beta start = %d %s", c, o)
+ }
+ })
+ }
+}
diff --git a/move.go b/move.go
index 6f4c67a7d..3ebb08307 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,680 @@ 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"`
+ Left []offlineLeftView `json:"left"`
+ 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"`
+}
+
+// offlineLeftView is a path a source box could not park and left in place,
+// with the reason it gave (a box diagnostic, not copy).
+type offlineLeftView struct {
+ Holder string `json:"box"`
+ Where string `json:"path"`
+ Because string `json:"reason"`
+}
+
+// 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, "; ")
+}
+
+// failedReport is the role's report when it says it failed.
+func (v offlineView) failedReport(role string) *offlineReportView {
+ for i := range v.Reports {
+ if v.Reports[i].Role == role && v.Reports[i].Phase == moves.OfflinePhaseFailed {
+ return &v.Reports[i]
+ }
+ }
+ return nil
+}
+
+// reason is what a failed report says: its failing checks, else its phase.
+func (r offlineReportView) reason() string {
+ 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
+}
+
+// The roles a failed step can be: the sealer sealing, the target landing,
+// or a source box cleaning up after the open.
+const (
+ failSealer = "sealer"
+ failTarget = "target"
+ failSource = "source"
+)
+
+// failure says whether a box has failed its step, which role it was and why:
+// the items the role's report failed, else what its failed report says, else
+// the word failed. Before the open it is the target landing, else the sealer
+// sealing (both at once go by the state the move is in). Once the move is
+// open or finishing a failed report of any role counts, source boxes included;
+// while it reverts only a failed source box does, since a target or sealer
+// that failed earlier is being put back.
+func (v offlineView) failure() (kind, reason string, ok bool) {
+ switch v.State {
+ case provision.OfflineOpen, provision.OfflineFinishing, provision.OfflineReverting:
+ roles := []string{moves.OfflineRoleSealer, moves.OfflineRoleTarget, moves.OfflineRoleSource}
+ if v.State == provision.OfflineReverting {
+ roles = roles[2:]
+ }
+ for _, role := range roles {
+ if r := v.failedReport(role); r != nil {
+ return role, r.reason(), true
+ }
+ }
+ return "", "", false
+ }
+ sealing := v.State == provision.OfflineStopping || v.State == provision.OfflineSealing || v.State == provision.OfflineSealed
+ sealReason, landReason := v.failed(moves.OfflineOwnerSeal), v.failed(moves.OfflineOwnerLanding)
+ sealRep, landRep := v.failedReport(moves.OfflineRoleSealer), v.failedReport(moves.OfflineRoleTarget)
+ sealFailed := sealRep != nil || (sealing && sealReason != "")
+ landFailed := landRep != nil || (!sealing && landReason != "")
+ switch {
+ case sealFailed && (sealing || !landFailed):
+ if sealReason == "" && sealRep != nil {
+ sealReason = sealRep.reason()
+ }
+ return failSealer, sealReason, true
+ case landFailed:
+ if landReason == "" && landRep != nil {
+ landReason = landRep.reason()
+ }
+ return failTarget, landReason, true
+ case v.Failed:
+ if sealing {
+ return failSealer, moves.OfflinePhaseFailed, true
+ }
+ return failTarget, moves.OfflinePhaseFailed, true
+ }
+ return "", "", false
+}
+
+// sayFailure says a failed step: MV-O6 for the target before the open,
+// MV-O9 for the sealer or a target once open (abort refuses there), MV-O65
+// for a source box.
+func (w *offlineRun) sayFailure(v offlineView) bool {
+ kind, reason, ok := v.failure()
+ if !ok {
+ return false
+ }
+ host := w.m.host
+ if kind == failTarget && (v.State == provision.OfflineOpen || v.State == provision.OfflineFinishing) {
+ // Once the move is open, abort refuses (MV-O27), so MV-O6's advice
+ // would send the operator to a verb that says no; MV-O9 says it.
+ kind = failSealer
+ }
+ switch kind {
+ case failTarget:
+ 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))
+ case failSource:
+ for _, r := range v.Reports {
+ if r.Role == moves.OfflineRoleSource && r.Phase == moves.OfflinePhaseFailed {
+ moveSay(w.m.out, fmt.Sprintf("%s could not clean up %s: %s. Run carlos move offline status --host %s for each item.", r.Box, host, reason, host))
+ return true
+ }
+ }
+ default:
+ 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 true
+}
+
+// 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))
+}
+
+// served reports that the target serves the instance: its report says
+// opened, the move has gone on to finishing, or the next step is finish.
+func (w *offlineRun) served(v offlineView) bool {
+ if v.State == provision.OfflineFinishing || v.Next == "finish" {
+ return true
+ }
+ for _, r := range v.Reports {
+ if r.Role == moves.OfflineRoleTarget && r.Phase == moves.OfflinePhaseOpened {
+ return true
+ }
+ }
+ return false
+}
+
+// sayServed is MV-O7, said once.
+func (w *offlineRun) sayServed(v offlineView) {
+ if w.once("served") {
+ 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, v.To, host, v.From))
+ }
+}
+
+// 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))
+ if w.sayFailure(v) {
+ return
+ }
+ 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:
+ 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:
+ if w.served(v) {
+ w.sayServed(v)
+ } else {
+ w.sayWaiting(r)
+ }
+ 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))
+ }
+ }
+ for _, l := range v.Left {
+ moveSay(w.m.out, fmt.Sprintf("%s left %s in place: %s.", l.Holder, l.Where, l.Because))
+ }
+ if _, _, stepFailed := v.failure(); failed || v.Failed || stepFailed {
+ 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 w.sayFailure(r.v) {
+ 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 w.sayFailure(r.v) {
+ 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
+ }
+ // The line is said once the target serves (its report says opened, or the
+ // next step is finish), not when the door only accepted the step.
+ return w.follow(ctx, r, func(r offlineReply) (int, bool) {
+ if r.v.State == provision.OfflineOpen {
+ if w.sayFailure(r.v) {
+ return moveExitWaiting, true
+ }
+ if w.served(r.v) {
+ w.sayServed(r.v)
+ } else {
+ w.sayWaiting(r)
+ }
+ if r.v.Next == "finish" {
+ return moveExitDone, true
+ }
+ if w.sayFailure(r.v) {
+ return moveExitWaiting, 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
+ }
+ if w.sayFailure(r.v) {
+ return moveExitWaiting, 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..3ae90a02d 100644
--- a/move_test.go
+++ b/move_test.go
@@ -1094,3 +1094,715 @@ 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) {
+ src := func(phase string) frame { return offReport("ie-box", "source", phase, true) }
+ tgt := func(phase string) frame { return offReport("uk-box", "target", phase, true) }
+ c := &fakeOfflineConsole{
+ posts: map[string][]offPost{"/open": {{202, offView("offline-open", nil)}}},
+ gets: []frame{
+ offView("offline-open", frame{"reports": []frame{src("stopped"), tgt("landed")}}),
+ offView("offline-open", frame{"reports": []frame{src("stopped"), tgt("landed")}}),
+ offView("offline-open", frame{"reports": []frame{src("stopped"), tgt("opened")}}),
+ offView("offline-open", frame{"next": "finish", "reports": []frame{src("parked"), tgt("opened")}}),
+ },
+ }
+ code, out := runOfflineAgainst(t, c, "open")
+ // The served line waits for the target's opened report.
+ want := "Waiting for ie-box to clean up acme.go.tito.io.\n" + oServed + "\n"
+ if code != 0 || out != want {
+ t.Fatalf("exit %d, output:\n%s\nwant:\n%s", code, out, want)
+ }
+ // Or for finish to be next, when the target's report is not in the view.
+ c = &fakeOfflineConsole{
+ posts: map[string][]offPost{"/open": {{202, offView("offline-open", nil)}}},
+ gets: []frame{offView("offline-open", frame{"next": "finish"})},
+ }
+ if code, out := runOfflineAgainst(t, c, "open"); code != 0 || out != "Waiting for ie-box to clean up acme.go.tito.io.\n"+oServed+"\n" {
+ t.Fatalf("exit %d, output:\n%s", code, out)
+ }
+}
+
+// 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)
+ }
+ }
+}
+
+// status of a failed move: exit 4 on the flag alone, a failed seal is not
+// headlined as progress, and the role the view marks decides MV-O6 or MV-O9.
+func TestMoveOfflineStatusFailed(t *testing.T) {
+ sealerFailed := frame{"box": "ie-box", "role": "sealer", "phase": "failed", "fresh": true,
+ "items": frame{"files": frame{"ok": false, "reason": "special file in the archive"}}}
+ targetFailed := frame{"box": "uk-box", "role": "target", "phase": "failed", "fresh": true}
+ for _, c := range []struct {
+ name string
+ state string
+ extra frame
+ say string
+ }{
+ {"failed seal, flag only", "offline-sealing", frame{"failed": true},
+ "The move could not finish a step: failed. Run carlos move offline status --host acme.go.tito.io for each item."},
+ {"failed seal, sealer report", "offline-sealing", frame{"failed": true, "reports": []frame{sealerFailed}},
+ "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."},
+ {"failed landing, target report", "offline-landed", frame{"failed": true, "reports": []frame{targetFailed}},
+ "uk could not take acme.go.tito.io: failed. Run carlos move offline abort --host acme.go.tito.io to move it back."},
+ {"failed target before the flip", "offline-stopping", frame{"failed": true, "reports": []frame{targetFailed}},
+ "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{gets: []frame{offView(c.state, c.extra)}}
+ code, out := runOfflineAgainst(t, f, "status")
+ if code != 4 {
+ t.Errorf("exit %d, want 4:\n%s", code, out)
+ }
+ if !strings.HasPrefix(out, oStarting+"\n"+c.say+"\n") {
+ t.Errorf("output:\n%s\nwant the failure line after the headline: %s", out, c.say)
+ }
+ wantNoLine(t, out, "Copying")
+ })
+ }
+}
+
+func TestMoveOfflineStatusShowsPathsLeft(t *testing.T) {
+ c := &fakeOfflineConsole{gets: []frame{offView("offline-finishing", frame{
+ "next": "finish",
+ "left": []frame{{"box": "ie-box", "path": "/opt/carlos/live/acme.go.tito.io", "reason": "a root-owned directory"}},
+ })}}
+ code, out := runOfflineAgainst(t, c, "status")
+ if code != 0 {
+ t.Fatalf("exit %d:\n%s", code, out)
+ }
+ wantLines(t, out, "ie-box left /opt/carlos/live/acme.go.tito.io in place: a root-owned directory.")
+}
+
+// A failed report seen after the open is not silent: status says it and exits
+// 4, and open stops polling with the same line.
+func TestMoveOfflineFailedReportAfterTheOpen(t *testing.T) {
+ failedRep := func(box, role, check string) frame {
+ f := offReport(box, role, "failed", true)
+ f["items"] = frame{check: frame{"ok": false, "reason": "the " + check + " check failed"}}
+ return f
+ }
+ for _, c := range []struct {
+ name string
+ state string
+ rep frame
+ say string
+ }{
+ // Abort refuses once open, so a target failure there says MV-O9.
+ {"target, open", "offline-open", failedRep("uk-box", "target", "config"),
+ "The move could not finish a step: the config check failed. Run carlos move offline status --host acme.go.tito.io for each item."},
+ {"sealer, finishing", "offline-finishing", failedRep("ie-box", "sealer", "lease"),
+ "The move could not finish a step: the lease check failed. Run carlos move offline status --host acme.go.tito.io for each item."},
+ {"source, open", "offline-open", failedRep("ie-box", "source", "warm-cache"),
+ "ie-box could not clean up acme.go.tito.io: the warm-cache check failed. Run carlos move offline status --host acme.go.tito.io for each item."},
+ {"source, reverting", "offline-reverting", failedRep("ie-box", "source", "warm-cache"),
+ "ie-box could not clean up acme.go.tito.io: the warm-cache check failed. Run carlos move offline status --host acme.go.tito.io for each item."},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ f := &fakeOfflineConsole{gets: []frame{offView(c.state, frame{"reports": []frame{c.rep}})}}
+ code, out := runOfflineAgainst(t, f, "status")
+ if code != 4 || !strings.HasPrefix(out, oStarting+"\n"+c.say+"\n") {
+ t.Errorf("status exit %d, output:\n%s\nwant 4 and %q after the headline", code, out, c.say)
+ }
+ if c.state == "offline-open" {
+ f := &fakeOfflineConsole{
+ posts: map[string][]offPost{"/open": {{202, offView("offline-open", nil)}}},
+ gets: []frame{offView(c.state, frame{"reports": []frame{c.rep}})},
+ }
+ code, out := runOfflineAgainst(t, f, "open")
+ if code != 4 || !strings.HasSuffix(out, c.say+"\n") {
+ t.Errorf("open exit %d, output:\n%s\nwant 4 ending in %q", code, out, c.say)
+ }
+ }
+ })
+ }
+ // A target or sealer that failed before an abort is being put back: a
+ // reverting move shows only a failed source box.
+ stale := offReport("uk-box", "target", "failed", true)
+ f := &fakeOfflineConsole{gets: []frame{offView("offline-reverting", frame{"next": "finish", "reports": []frame{stale}})}}
+ if code, out := runOfflineAgainst(t, f, "status"); code != 0 || strings.Contains(out, "could not") {
+ t.Errorf("a reverting move with an old failed target: exit %d:\n%s", code, out)
+ }
+}
diff --git a/offline_move_e2e_test.go b/offline_move_e2e_test.go
new file mode 100644
index 000000000..a924edb49
--- /dev/null
+++ b/offline_move_e2e_test.go
@@ -0,0 +1,218 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "crypto/ecdh"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/console"
+ "github.com/carlosframework/platform/internal/moves"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+// TestMoveOfflineAgainstTheRealDoors drives `carlos move offline start` and
+// `status` through moveMain against a real console.Server: the stub tests
+// type the status view by hand, and this one ties the CLI's view structs to
+// the JSON the console writes (next, waiting, failed, items, reports, left).
+// The boxes are played by reports written where the real boxes write them.
+func TestMoveOfflineAgainstTheRealDoors(t *testing.T) {
+ const (
+ host = "acme.go.tito.io"
+ addr = "paul@keymail.dev"
+ )
+ tmp := t.TempDir()
+ rel := release.NewDirStore(filepath.Join(tmp, "store"))
+ seed, pubB64, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ priv, err := release.ParseSigningKey(seed, "e2e")
+ if err != nil {
+ t.Fatal(err)
+ }
+ pub, err := release.ParsePublicKey(pubB64, "e2e")
+ if err != nil {
+ t.Fatal(err)
+ }
+ cst, err := console.Open(filepath.Join(tmp, "console.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { cst.Close() })
+ if err := cst.SetOperator(addr, true); err != nil {
+ t.Fatal(err)
+ }
+ secrets, err := ecdh.P256().NewPrivateKey(bytes.Repeat([]byte{1}, 32))
+ if err != nil {
+ t.Fatal(err)
+ }
+ fp := appconfig.KeyFingerprint(secrets.PublicKey())
+
+ var srv *console.Server
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv.ServeHTTP(w, r) }))
+ t.Cleanup(ts.Close)
+ srv, err = console.NewServer(console.Config{
+ Origin: ts.URL,
+ Keymail: "http://keymail.invalid",
+ Registry: filepath.Join(tmp, "registry.db"),
+ Releases: rel,
+ ReleasePub: pub,
+ Key: priv,
+ AppScheme: "http",
+ SecretsPub: base64.StdEncoding.EncodeToString(secrets.PublicKey().Bytes()),
+ }, cst)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := cst.AddMember(console.OpsAccountID, addr, console.RoleOwner, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := cst.CreateAppIn(console.OpsAccountID, "titogo", addr, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ token, _ := e2eDeviceLogin(t, ts.URL, cst, addr, "offline@laptop")
+ ctx := context.Background()
+
+ // A hibernating instance in ie, and the two regions' boxes published.
+ if err := provision.PutManifest(ctx, rel, release.OpsAccount, "titogo",
+ provision.Manifest{Schema: 1, App: "titogo", Domain: "go.tito.io", Channel: "stable"}); err != nil {
+ t.Fatal(err)
+ }
+ req, _ := http.NewRequest(http.MethodPost, ts.URL+"/api/cli/apps/"+release.OpsAccount+"/titogo/instances",
+ strings.NewReader(`{"host":"`+host+`","region":"ie"}`))
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Content-Type", "application/json")
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ res.Body.Close()
+ if res.StatusCode != http.StatusCreated {
+ t.Fatalf("create the instance = %d", res.StatusCode)
+ }
+ for _, b := range []struct{ box, placement, aws, bucket string }{
+ {"ie-box", "ie", "eu-west-1", "data-ie"},
+ {"uk-box", "uk", "eu-west-2", "data-uk"},
+ } {
+ body, _ := json.Marshal(map[string]any{
+ "v": 1, "box": b.box, "box_id": b.box, "eip": "1.2.3.4", "region": b.aws, "placement": b.placement,
+ "data_bucket": b.bucket, "hibernation_configured": true, "serves_shared_pool": true,
+ "accepts_inbound": true, "secrets_key_fp": fp,
+ "capabilities": []string{"region-ha-1", "offline-move-1"}, "hosts": map[string]any{},
+ })
+ if _, err := rel.Put(ctx, "control/edges/"+b.box+".json", body, ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := status.WriteBox(ctx, rel, status.Box{Label: b.box, GeneratedAt: time.Now(), Metrics: &status.BoxMetrics{}}); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ run := func(c context.Context, args ...string) (int, string) {
+ var out bytes.Buffer
+ deps := moveDeps{
+ session: func(string) (*apiSession, error) {
+ return &apiSession{c: newConsoleClient(ts.URL, token), console: ts.URL, address: addr}, nil
+ },
+ euid: func() int { return 1000 },
+ poll: 20 * time.Millisecond,
+ }
+ args = append(append([]string{"offline"}, args...), "--host", host, "--account", release.OpsAccount, "--app", "titogo")
+ code := moveMain(c, args, &out, deps)
+ return code, out.String()
+ }
+ put := func(id, box, role, phase string, edit func(*moves.OfflineReport)) {
+ t.Helper()
+ r := moves.OfflineReport{V: moves.OfflineReportVersion, Op: id, Host: host, Box: box, Role: role, Phase: phase,
+ At: time.Now().UTC(), Held: true, Fenced: true, Quiesced: true}
+ if edit != nil {
+ edit(&r)
+ }
+ body, err := json.Marshal(r)
+ if err != nil {
+ t.Fatal(err)
+ }
+ key := moves.OfflineReportKey(host, id, role, box)
+ _, etag, _ := rel.Get(ctx, key)
+ if _, err := rel.Put(ctx, key, body, etag); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ // start waits for the source box; it runs on while the test plays it.
+ ctxStart, cancel := context.WithTimeout(ctx, 30*time.Second)
+ defer cancel()
+ type result struct {
+ code int
+ out string
+ }
+ started := make(chan result, 1)
+ go func() {
+ code, out := run(ctxStart, "start", "--to", "uk")
+ started <- result{code, out}
+ }()
+ var id string
+ for deadline := time.Now().Add(20 * time.Second); id == "" && time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) {
+ if rec, err := provision.GetRecord(ctx, rel, release.OpsAccount, "titogo", host); err == nil && rec.Move != nil {
+ id = rec.Move.ID
+ }
+ }
+ if id == "" {
+ t.Fatal("start never moved the record")
+ }
+
+ // status, before the box has reported: the console's own waiting sentence.
+ code, out := run(ctx, "status")
+ wantWait := "Waiting for ie-box in ie to confirm it has stopped " + host + ". " + host + " is offline."
+ wantLines(t, out, "Moving "+host+" from ie to uk.", wantWait, "Item class is ok.", "Item db is waiting.")
+ if code != 0 {
+ t.Fatalf("status exit %d:\n%s", code, out)
+ }
+
+ // The box reports; start's next poll sees next == seal.
+ put(id, "ie-box", moves.OfflineRoleSource, moves.OfflinePhaseStopped, nil)
+ select {
+ case r := <-started:
+ if r.code != 0 {
+ t.Fatalf("start exit %d:\n%s", r.code, r.out)
+ }
+ wantLines(t, r.out, "Moving "+host+" from ie to uk.", host+" is offline from now until the move opens or is aborted.",
+ "Every box in ie has stopped "+host+". Run carlos move offline seal --host "+host+" to copy it.")
+ case <-time.After(20 * time.Second):
+ t.Fatal("start never finished")
+ }
+ code, out = run(ctx, "status")
+ wantLines(t, out, "Every box in ie has stopped "+host+". Run carlos move offline seal --host "+host+" to copy it.",
+ "ie-box is stopped as the source.")
+ if code != 0 {
+ t.Fatalf("status exit %d:\n%s", code, out)
+ }
+
+ // A target that says it failed sets the view's failed flag and its check.
+ put(id, "uk-box", moves.OfflineRoleTarget, moves.OfflinePhaseFailed, func(r *moves.OfflineReport) {
+ r.Items = map[string]moves.Check{"config": {OK: false, Reason: "the app settings differ"}}
+ })
+ code, out = run(ctx, "status")
+ if code != 4 {
+ t.Fatalf("status of a failed move exit %d, want 4:\n%s", code, out)
+ }
+ wantLines(t, out, "uk could not take "+host+": the app settings differ. Run carlos move offline abort --host "+host+" to move it back.")
+
+ // A source box that left a path in place says so, with its reason.
+ put(id, "ie-box", moves.OfflineRoleSource, moves.OfflinePhaseParked, func(r *moves.OfflineReport) {
+ r.Retired = []moves.RetiredPath{{Path: "/opt/carlos/live/" + host, Action: moves.RetiredLeft, Reason: "a root-owned directory"}}
+ })
+ _, out = run(ctx, "status")
+ wantLines(t, out, "ie-box left /opt/carlos/live/"+host+" in place: a root-owned directory.")
+}