Merge branch 'moves-offline' into moves-offline-t4
9 files changed,
+1843
−22
approved_moves_copy_scan_test.go+25 −1approved_moves_copy_test.go+41 −1docs/site/cli.md+14 −0docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+24 −0internal/console/apiinstancemove_offline.go+41 −11internal/console/apiinstancemove_offline_test.go+42 −0move.go+726 −9move_test.go+712 −0offline_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.") |
| +} |