carlos / platform Public

offline-move: carlos move offline fix round 1

Real-console test of start and status (ties the CLI's view structs to the
console's JSON). MV-O7 waits for the target's opened report or finish
being next. A failed view says MV-O6 for the target and MV-O9 for the
sealer, by the role the view marks; status exits 4 on the flag and does
not headline a failed seal as progress. status lists paths a source box
left in place (new pending row MV-O58).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 6a9ddc9db5cfd6bcd8591677838791d9ce1c61da parent b8ec65b
5 files changed, +402 −43
  • approved_moves_copy_test.go +1 −1
  • docs/superpowers/plans/2026-10-06-moves-phase1-copy.md +2 −1
  • move.go +118 −37
  • move_test.go +63 −4
  • offline_move_e2e_test.go +218 −0
diff --git a/approved_moves_copy_test.go b/approved_moves_copy_test.go
index ab2f3e7ef..e9ac74371 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-O52", "MV-O53", "MV-O54", "MV-O55", "MV-O56", "MV-O57",
+ "MV-O50", "MV-O51", "MV-O52", "MV-O53", "MV-O54", "MV-O55", "MV-O56", "MV-O57", "MV-O58",
}
// copyRow is one row of the record. The File cell is the file the sentence is
diff --git a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
index 401ef893d..e84f331bf 100644
--- a/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
+++ b/docs/superpowers/plans/2026-10-06-moves-phase1-copy.md
@@ -543,7 +543,7 @@ 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.
+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 |
|---|---|---|---|
@@ -553,3 +553,4 @@ Six more lines of `carlos move offline` (`move.go`, Task 7), for places MV-O1 to
| 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 |
diff --git a/move.go b/move.go
index c0080f313..161000c9e 100644
--- a/move.go
+++ b/move.go
@@ -1007,6 +1007,7 @@ type offlineView struct {
// 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"`
@@ -1020,6 +1021,14 @@ type offlineReportView struct {
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 {
@@ -1072,37 +1081,75 @@ func (v offlineView) failed(owner string) string {
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
+// 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]
+ }
}
- for _, r := range v.Reports {
- if r.Role != role || r.Phase != moves.OfflinePhaseFailed {
- continue
+ 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)
}
- 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
+}
+
+// failure says whether a box has failed its step, which role it was (the
+// target landing, else the sealer sealing) and why: the items the role's
+// report failed, else what its failed report says, else the word failed.
+// Both roles at once go by the state the move is in.
+func (v offlineView) failure() (landing bool, reason string, ok bool) {
+ 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()
}
- if len(parts) > 0 {
- return strings.Join(parts, "; ")
+ return false, sealReason, true
+ case landFailed:
+ if landReason == "" && landRep != nil {
+ landReason = landRep.reason()
}
- return r.Phase
+ return true, landReason, true
+ case v.Failed:
+ return !sealing, moves.OfflinePhaseFailed, true
}
- if v.Failed {
- return moves.OfflinePhaseFailed
+ return false, "", false
+}
+
+// sayFailure says a failed step: MV-O6 for the target, MV-O9 for the sealer.
+func (w *offlineRun) sayFailure(v offlineView) bool {
+ landing, reason, ok := v.failure()
+ if !ok {
+ return false
+ }
+ host := w.m.host
+ if landing {
+ 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 true
}
- return ""
+ 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
@@ -1250,11 +1297,39 @@ 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))
switch v.State {
+ case provision.OfflineStopping, provision.OfflineSealing, provision.OfflineSealed, provision.OfflineLanding, provision.OfflineLanded:
+ 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))
@@ -1270,10 +1345,6 @@ func (w *offlineRun) headline(r offlineReply) {
}
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" {
@@ -1282,7 +1353,11 @@ func (w *offlineRun) headline(r offlineReply) {
}
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))
+ 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))
@@ -1356,7 +1431,10 @@ func (w *offlineRun) runStatus(ctx context.Context) int {
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 {
+ for _, l := range v.Left {
+ moveSay(w.m.out, fmt.Sprintf("%s left %s in place: %s.", l.Holder, l.Where, l.Because))
+ }
+ if failed || v.Failed {
return moveExitWaiting
}
return moveExitDone
@@ -1394,8 +1472,7 @@ func (w *offlineRun) runSeal(ctx context.Context) int {
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))
+ if w.sayFailure(r.v) {
return moveExitWaiting, true
}
if r.v.State == provision.OfflineSealed {
@@ -1421,8 +1498,7 @@ func (w *offlineRun) runFlip(ctx context.Context) int {
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))
+ if w.sayFailure(r.v) {
return moveExitWaiting, true
}
if r.v.State == provision.OfflineLanded {
@@ -1443,10 +1519,15 @@ func (w *offlineRun) runOpen(ctx context.Context) int {
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))
+ // 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.served(r.v) {
+ w.sayServed(r.v)
+ } else {
+ w.sayWaiting(r)
+ }
if r.v.Next == "finish" {
return moveExitDone, true
}
diff --git a/move_test.go b/move_test.go
index caeb52fa0..e96b4c8e1 100644
--- a/move_test.go
+++ b/move_test.go
@@ -1421,19 +1421,31 @@ func TestMoveOfflineFlipFails(t *testing.T) {
}
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{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)}}),
+ 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")
- want := oServed + "\nWaiting for ie-box to clean up acme.go.tito.io.\n"
+ // 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
@@ -1696,3 +1708,50 @@ func TestMoveDocsOfflineParagraph(t *testing.T) {
}
}
}
+
+// 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.")
+}
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.")
+}