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>
5 files changed,
+402
−43
approved_moves_copy_test.go+1 −1docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+2 −1move.go+118 −37move_test.go+63 −4offline_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.") |
| +} |