Merge branch 'moves-offline' into moves-offline-t5
22 files changed,
+5105
−86
approved_moves_copy_scan_test.go+25 −1approved_moves_copy_test.go+58 −1docs/site/cli.md+14 −0docs/superpowers/plans/2026-10-06-moves-phase1-copy.md+67 −0internal/console/apiinstancemove_offline.go+41 −11internal/console/apiinstancemove_offline_test.go+42 −0internal/host/movesoffline.go+7 −13internal/host/movesoffline_seal.go+1159 −14internal/host/movesoffline_seal_test.go+1405 −0internal/layout/plan.go+77 −0internal/layout/plan_test.go+63 −0internal/lease/leasetest/s3server.go+126 −1internal/lease/leasetest/s3server_test.go+132 −0internal/lease/tombstone.go+15 −8internal/lease/tombstone_test.go+29 −22internal/moves/archive/archive.go+31 −5internal/moves/archive/archive_test.go+131 −0internal/moves/archive/archive_unix_test.go+21 −0internal/moves/archive/limits.go+6 −1move.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..ab3f90371 100644 |
| --- a/approved_moves_copy_test.go |
| +++ b/approved_moves_copy_test.go |
| @@ -96,7 +96,10 @@ 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", |
| + "MV-O66", "MV-O67", "MV-O68", "MV-O69", "MV-O70", "MV-O71", "MV-O72", "MV-O73", "MV-O74", "MV-O75", "MV-O76", "MV-O77", "MV-O78", "MV-O79", "MV-O80", "MV-O81", |
| + "MV-O82", "MV-O83", "MV-O84", "MV-O85", "MV-O86", "MV-O87", "MV-O88", "MV-O89", "MV-O90", "MV-O91", "MV-O92", "MV-O93", "MV-O94", "MV-O95", "MV-O96", "MV-O97", |
| + "MV-O98", |
| } |
| // copyRow is one row of the record. The File cell is the file the sentence is |
| @@ -1261,6 +1264,8 @@ var movesScanFiles = []string{ |
| "internal/host/movesparks.go", |
| "internal/host/movespaging.go", |
| "internal/host/movespass.go", |
| + "internal/host/movesoffline.go", |
| + "internal/host/movesoffline_seal.go", |
| "internal/host/movesphase1.go", |
| "internal/host/movespolicy.go", |
| "internal/host/movesprobe.go", |
| @@ -1446,6 +1451,9 @@ var movesReturnedSentences = map[string]map[string]bool{ |
| // The box's move pages (Plan 6 Task 5, MV-N385 to MV-N390) and M-C20, |
| // the operators' sentence for a stray process (the stray page's body). |
| "internal/host/movespaging.go": {"rolledBackPage": true, "strayPage": true, "strayLine": true, "startRefusedPage": true}, |
| + // The offline sealer's lost-stop page, its two causes and the lease |
| + // states the first names (offline move Task 4, MV-O66 to MV-O75). |
| + "internal/host/movesoffline_seal.go": {"offlineLostStopPage": true, "offlineLeaseCause": true, "offlinePastSealCause": true, "offlineLeaseText": true}, |
| // The console's move pass pages (Plan 5 Task 7, MV-N325 to MV-N334), and |
| // the dirty fenced target's page (moves after claim Plan 12, Task 5, |
| // MV-N450 and MV-N451). |
| @@ -1524,6 +1532,39 @@ 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, |
| + }, |
| + }, |
| + "internal/host/movesoffline_seal.go": { |
| + "offlineTakeSeal|sealRefusal.Reason": { |
| + Why: "the env-pin refusal for a CARLOS_SECRETS_KEY that does not decode is moves.ReportKeyFromSecrets' own error text (err.Error()), a box diagnostic that `carlos move offline status` shows as MV-O55's <detail>; it is not copy", |
| + Count: 1, |
| + }, |
| + "offlineArchive|sealRefusal.Reason": { |
| + Why: "the files refusal for an archive Build refuses is archive.Build's own error text (err.Error()), whose sentinels are already rows MV-N77 to MV-N81 and MV-N86; a box diagnostic that `carlos move offline status` shows as MV-O55's <detail>", |
| + Count: 1, |
| + }, |
| + }, |
| +} |
| + |
| // 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 +2512,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..f6db016aa 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,70 @@ 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 | |
| + |
| +The sealer's lost-stop page (`internal/host/movesoffline_seal.go`, Task 4 fix round 1, review I2 and M2). After the seal the stop is kept, never taken again; when it no longer holds, the sealer pages an operator once per move. The body splits by state: before `open`, `abort` puts the instance back with nothing lost; from `open` on, `abort` is refused (MV-O40) and the move is fixed forward by hand. `<state>` is the record's offline state (`offline-sealed`, `offline-open` and so on). `<cause>` is one of the two causes below, and the failed item's reason (MV-O71) carries the same cause. `<lease>` is one of the four lease states MV-O72 to MV-O75; `<owner>` is a lease owner (`moved:<id>` or a box's lease id) and `<time>` an RFC 3339 time. |
| + |
| +| id | file | text | status | |
| +|---|---|---|---| |
| +| MV-O66 | internal/host/movesoffline_seal.go | offline move of <host> lost its stop | pending | |
| +| MV-O67 | internal/host/movesoffline_seal.go | The offline move <id> of <host> is <state>, and its stop no longer holds: <cause>. The move cannot go on. Read carlos move offline status --host <host> and abort the move. | pending | |
| +| MV-O68 | internal/host/movesoffline_seal.go | The offline move <id> of <host> is <state>, and its stop no longer holds: <cause>. It cannot be aborted once it is open. Read carlos move offline status --host <host> and follow the offline move runbook. | pending | |
| +| MV-O69 | internal/host/movesoffline_seal.go | the source's lease is <lease>, not this move's tombstone | pending | |
| +| MV-O70 | internal/host/movesoffline_seal.go | <box> reports TXID <n>, past the sealed position <final> | pending | |
| +| MV-O71 | internal/host/movesoffline_seal.go | lost stop: <cause> | pending | |
| +| MV-O72 | internal/host/movesoffline_seal.go | absent | pending | |
| +| MV-O73 | internal/host/movesoffline_seal.go | lifted (<owner>) | pending | |
| +| MV-O74 | internal/host/movesoffline_seal.go | expired (<owner> at <time>) | pending | |
| +| MV-O75 | internal/host/movesoffline_seal.go | held by <owner> until <time> | pending | |
| + |
| +The sealer's item reasons, which `carlos move offline status` shows as MV-O55's `<detail>`. The copy gate reads every Check reason, so each has a row. MV-O76 is the `files` refusal for an entry the app keeps beside its files directory (review I3): the move cannot carry it, and moving it into the files directory or removing it would lose the app's data, so the row offers neither and points to the runbook; `<paths>` are the entries' paths on the box. MV-O77 to MV-O84 are the other refusals. Two refusals carry an error's own text and are box diagnostics, not rows (`movesDiagnosticWrites`): the archive's own refusal (its sentinels are MV-N77 to MV-N81 and MV-N86) and a secrets key that does not decode. MV-O85 to MV-O98 are the reasons of the items that passed; `<rung>` is the replication rung, quoted. |
| + |
| +| id | file | text | status | |
| +|---|---|---|---| |
| +| MV-O76 | internal/host/movesoffline_seal.go | <host> keeps <paths> beside its files directory, and this move can carry only the files directory. see the offline move runbook | pending | |
| +| MV-O77 | internal/host/movesoffline_seal.go | <box> keeps a sidecar file for <host> | pending | |
| +| MV-O78 | internal/host/movesoffline_seal.go | this box's local position <n> is past the replica's <replica> and there is no database at <path> to checkpoint | pending | |
| +| MV-O79 | internal/host/movesoffline_seal.go | the replica at <bucket>/<prefix> holds no database | pending | |
| +| MV-O80 | internal/host/movesoffline_seal.go | <box>'s local position <n> is past the replica's final position <final> | pending | |
| +| MV-O81 | internal/host/movesoffline_seal.go | this box has no exec backend to compose the launch environment | pending | |
| +| MV-O82 | internal/host/movesoffline_seal.go | this box holds no CARLOS_SECRETS_KEY to key the env report | pending | |
| +| MV-O83 | internal/host/movesoffline_seal.go | no build is live for <host> on this box | pending | |
| +| MV-O84 | internal/host/movesoffline_seal.go | this box holds no CARLOS_SECRETS_KEY to seal the archive under | pending | |
| +| MV-O85 | internal/host/movesoffline_seal.go | no source box keeps a sidecar file | pending | |
| +| MV-O86 | internal/host/movesoffline_seal.go | every source box is at or below TXID <final> | pending | |
| +| MV-O87 | internal/host/movesoffline_seal.go | <owner> holds <location>/lock.json | pending | |
| +| MV-O88 | internal/host/movesoffline_seal.go | the request record is in the build state the replica prefix carries | pending | |
| +| MV-O89 | internal/host/movesoffline_seal.go | this box keeps no request records; the build state travels as the replica prefix holds it | pending | |
| +| MV-O90 | internal/host/movesoffline_seal.go | <n> objects staged at <bucket>/<prefix>, newest TXID <final> | pending | |
| +| MV-O91 | internal/host/movesoffline_seal.go | <n> files, <bytes> bytes, sealed at <prefix><object> | pending | |
| +| MV-O92 | internal/host/movesoffline_seal.go | <n> active timers at <prefix><object> | pending | |
| +| MV-O93 | internal/host/movesoffline_seal.go | absent on the source, so none is carried | pending | |
| +| MV-O94 | internal/host/movesoffline_seal.go | carried in the archive | pending | |
| +| MV-O95 | internal/host/movesoffline_seal.go | launch environment digest <digest> | pending | |
| +| MV-O96 | internal/host/movesoffline_seal.go | live build <version> | pending | |
| +| MV-O97 | internal/host/movesoffline_seal.go | replication "<rung>" | pending | |
| +| MV-O98 | internal/host/movesoffline_seal.go | next due <unix time> | 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/internal/host/movesoffline.go b/internal/host/movesoffline.go| index 9b1832ef6..cb1019574 100644 |
| --- a/internal/host/movesoffline.go |
| +++ b/internal/host/movesoffline.go |
| @@ -499,17 +499,12 @@ func isDir(p string) bool { |
| return err == nil && st.IsDir() |
| } |
| -// offlinePlatformNames are a nested instance directory's platform entries: |
| -// everything else in it is the app's files. |
| -func offlinePlatformNames(host string) map[string]bool { |
| - db := host + ".db" |
| - return map[string]bool{ |
| - db: true, db + "-wal": true, db + "-shm": true, db + "-litestream": true, |
| - ".activator-" + db + "-litestream": true, db + ".corrupt": true, db + ".gen": true, |
| - host + ".env": true, host + ".config.env": true, host + "-sidecar.env": true, |
| - host + ".instance-key-adoption.json": true, ".files-gen": true, |
| - } |
| -} |
| +// offlinePlatformName reports a nested instance directory's platform entry |
| +// (layout.IsNestedPlatformName: the database and its files, the env files, |
| +// the generation, seed and files markers, the adoption record, and the |
| +// launch descriptors, launch.gen.<n>.json included): everything else in it |
| +// is the app's files. |
| +func offlinePlatformName(host, name string) bool { return layout.IsNestedPlatformName(host, name) } |
| // localState reads what this box keeps for host: the DB file or either |
| // litestream meta dir (S8: a meta dir alone is local state, since |
| @@ -543,9 +538,8 @@ func (w *movesWorker) localState(host string, rt *registry.Route) (offlineLocal, |
| if err != nil && !errors.Is(err, os.ErrNotExist) { |
| return out, err |
| } |
| - platform := offlinePlatformNames(host) |
| for _, e := range ents { |
| - if !platform[e.Name()] { |
| + if !offlinePlatformName(host, e.Name()) { |
| out.Files = true |
| break |
| } |
diff --git a/internal/host/movesoffline_seal.go b/internal/host/movesoffline_seal.go| index e13489c57..cbfb60cde 100644 |
| --- a/internal/host/movesoffline_seal.go |
| +++ b/internal/host/movesoffline_seal.go |
| @@ -2,27 +2,1172 @@ package host |
| import ( |
| "context" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "io" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "sort" |
| + "strings" |
| + "sync" |
| + "time" |
| + "github.com/superfly/ltx" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/alerting" |
| + "github.com/carlosframework/platform/internal/appconfig" |
| + "github.com/carlosframework/platform/internal/instancekey" |
| + "github.com/carlosframework/platform/internal/layout" |
| + "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/moves/archive" |
| + "github.com/carlosframework/platform/internal/park" |
| "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| -// offlineSealerStep is the sealer's part of an offline move |
| -// (docs/superpowers/plans/2026-10-20-offline-operator-move.md, Task 4, which |
| -// owns this file). offlineRound calls it on the box whose moveCfg.Box equals |
| -// rec.Move.Sealer, in every offline state, after this box's source step has |
| -// written its report for the pass. rec carries an offline move |
| -// (rec.Move.Offline()) and the box is capable (moveCfg.OfflineCapable). |
| -// Every step must be an ensure; an error is logged by the pass, throttled |
| -// per host, and the next pass runs the step again. |
| +// The sealer of an offline operator move (docs/superpowers/plans/2026-10-20- |
| +// offline-operator-move.md, Task 4): the one source box that turns "every |
| +// source box holds, fences and reports" into a stop at the data layer, and |
| +// records what the target must land. |
| +// |
| +// The tombstone is the linearization point of the whole move. Every wake on |
| +// every box of the source region takes the host's single-writer lease |
| +// (instances/<host>/lock.json in the source's own bucket) before it can |
| +// write anything, and from the tombstone on that lease is held by |
| +// moved:<id>. The order is the plan's (Review Focus 2): |
| // |
| -// The immutable seal record (moves.OfflineSealKey(host, id), added on |
| -// Task 6's branch) is written once through putOfflineCreateOnly, never |
| -// rewritten: on a re-run it returns the stored body with created false, and |
| -// the step compares instead of overwriting. The per-pass sealer report goes |
| -// through writeOfflineReport, which replaces. |
| +// 1. this box's own report for the pass says held, fenced, quiesced, no |
| +// process and no layout journal, and no source box keeps a sidecar; |
| +// 2. the local database is checkpointed unless it is behind the replica |
| +// (cfg.Checkpoint takes the lease, syncs and releases it); |
| +// 3. lease.Tombstone, which never replaces a live holder; |
| +// 4. only then the final position is read from the replica, and every |
| +// source box's local position must be at or below it; |
| +// 5. CarryRequest, a final MirrorPrefix round with nothing deferred, and |
| +// the staged listing; |
| +// 6. to 9. the files archive, the timers, the env and its pin, the release, |
| +// replication and next due; |
| +// 10. seal.json, written once and never rewritten (putOfflineCreateOnly), |
| +// then the record's CAS to offline-sealed. |
| // |
| -// Task 3 leaves it a no-op. |
| +// Every step is an ensure. Until seal.json exists the whole seal is taken |
| +// again on each pass (a tombstone that is already this move's is a no-op, |
| +// the mirror resumes from its receipts, the archive is rebuilt and its |
| +// objects overwritten), so a crash anywhere before it converges. Once |
| +// seal.json exists the sealed position is fixed, and from then on the |
| +// tombstone is never taken again: each pass reads lease.LockState first, |
| +// and a tombstone that is absent, expired or anyone else's is a lost stop, |
| +// as is a source report written since the seal at a position past the |
| +// sealed one. The pass reports failed and pages: before open the operator |
| +// aborts, from open on the runbook fixes it forward. Only a tombstone held |
| +// by this move is re-asserted (lease.Reassert, If-Match on the ETag it |
| +// reads, and lease.ErrTombstoneLost, never a new tombstone, when it went |
| +// in between). After it is lifted (finish, abort) it is never re-asserted. |
| + |
| +// The objects the sealer writes beside the sealed archive, under |
| +// moves.OfflineArchivePrefix(host, id) in the target's data bucket. The |
| +// landing (Task 5) reads them there. |
| +const ( |
| + // offlineArchiveSealed is the sealed files archive (archive.Seal of |
| + // archive.Build's output). Its sha256 is the seal's FilesSealed. |
| + offlineArchiveSealed = "files.sealed" |
| + // offlineArchiveManifest is its archive.Manifest (JSON), with the |
| + // sealed data key and every entry. |
| + offlineArchiveManifest = "files.json" |
| + // offlineTimersObject is the moves.TimersSnapshot of the row's active |
| + // timers. moves.TimersDigest of its rows is the seal's TimersDigest. |
| + offlineTimersObject = "timers.json" |
| + // offlineEnvReportObject is the source's moves.EnvReport (names and |
| + // HMACs, never a value), so the landing can name the variables that |
| + // differ (moves.CompareEnv) when the env pin does not match. Its |
| + // Digest is the seal's EnvDigest. |
| + offlineEnvReportObject = "env-report.json" |
| +) |
| + |
| +// offlineSealFresh is how old this box's own source report may be when the |
| +// sealer reads it: the source step writes it earlier in the same pass, so |
| +// anything older means that step failed this pass. |
| +const offlineSealFresh = 2 * time.Minute |
| + |
| +// offlinePageLostStop is the page rule of a lost stop: one page per move. |
| +const offlinePageLostStop = "offline-lost-stop" |
| + |
| +// offlineSealFaults is the sealer's test seam: a worker's entry is called |
| +// after each step with the step's name, and an error returned is a crash |
| +// there. Keyed by the worker, so tests that run side by side never share |
| +// one. Empty in production. |
| +var offlineSealFaults sync.Map // *movesWorker → func(point string) error |
| + |
| +func (w *movesWorker) sealFault(point string) error { |
| + if f, ok := offlineSealFaults.Load(w); ok { |
| + return f.(func(string) error)(point) |
| + } |
| + return nil |
| +} |
| + |
| +// offlineSealerStep is the sealer's part of an offline move. offlineRound |
| +// calls it on the box whose label is rec.Move.Sealer, after this box's own |
| +// source step has written its report for the pass. |
| func (w *movesWorker) offlineSealerStep(ctx context.Context, s release.Store, rec provision.Record) error { |
| + m := rec.Move |
| + if !m.Offline() || w.cfg.Box == "" || m.Sealer != w.cfg.Box { |
| + return nil |
| + } |
| + switch m.State { |
| + case provision.OfflineSealing: |
| + return w.offlineSeal(ctx, s, rec) |
| + case provision.OfflineSealed, provision.OfflineLanding, provision.OfflineLanded, provision.OfflineOpen: |
| + raw, err := w.offlineSealRecord(ctx, s, rec) |
| + if err != nil { |
| + return err |
| + } |
| + return w.offlineKeepStop(ctx, s, rec, raw) |
| + case provision.OfflineFinishing: |
| + return w.offlineFinishLift(ctx, s, rec) |
| + case provision.OfflineReverting: |
| + return w.offlineSealRevert(ctx, s, rec) |
| + } |
| + return nil |
| +} |
| + |
| +// tombstoneAt is where the host's single-writer lease lives in this box's |
| +// own bucket: the activator's lease location, as ExecBackend.Acquire takes |
| +// it for every wake. |
| +func (c moveCfg) tombstoneAt(host string) (bucket, location, region string, err error) { |
| + if c.OwnBucket == "" { |
| + return "", "", "", errors.New("offline move: this box has no hibernate bucket") |
| + } |
| + return c.OwnBucket, activator.HibernatePrefix(c.OwnPrefix, host), c.OwnBucketRegion, nil |
| +} |
| + |
| +// tombState is the lease as lease.LockState read it: an absent lock has no |
| +// owner and a zero expiry. |
| +type tombState struct { |
| + owner string |
| + expires time.Time |
| +} |
| + |
| +// mine reports a tombstone this move holds now. "Expired" is the lease's |
| +// own reading (Acquire takes a lease the moment its expiry has passed), so |
| +// it is judged against the wall clock the lease is written by. |
| +func (t tombState) mine(id string) bool { |
| + return t.owner == moves.TombstoneOwner(id) && time.Now().Before(t.expires) |
| +} |
| + |
| +// free reports a lease nothing holds: absent, or past its expiry, whoever |
| +// wrote it (a lifted tombstone included: lease.IsLiftedTombstone). |
| +func (t tombState) free() bool { |
| + return t.owner == "" || !time.Now().Before(t.expires) |
| +} |
| + |
| +func (t tombState) String() string { return offlineLeaseText(t) } |
| + |
| +// offlineLeaseText is the lease as the lost stop's page and the sealer's |
| +// report name it (copy rows MV-O72 to MV-O75). |
| +func offlineLeaseText(t tombState) string { |
| + switch { |
| + case t.owner == "": |
| + return "absent" |
| + case lease.IsLiftedTombstone(t.owner, t.expires) && t.expires.Unix() == 0: |
| + // LiftTombstone's expiry: the epoch. |
| + return fmt.Sprintf("lifted (%s)", t.owner) |
| + case t.free(): |
| + return fmt.Sprintf("expired (%s at %s)", t.owner, t.expires.UTC().Format(time.RFC3339)) |
| + } |
| + return fmt.Sprintf("held by %s until %s", t.owner, t.expires.UTC().Format(time.RFC3339)) |
| +} |
| + |
| +// offlineLock reads the host's lease. |
| +func (w *movesWorker) offlineLock(ctx context.Context, host string) (tombState, error) { |
| + bucket, loc, region, err := w.cfg.tombstoneAt(host) |
| + if err != nil { |
| + return tombState{}, err |
| + } |
| + _, owner, expires, err := lease.LockState(ctx, bucket, loc, region) |
| + if err != nil { |
| + return tombState{}, fmt.Errorf("read the lease of %s: %w", host, err) |
| + } |
| + return tombState{owner: owner, expires: expires}, nil |
| +} |
| + |
| +// offlineTarget is the target's coordinates as its edges file publishes |
| +// them: the box at move.to (the lexically first, as every move reads the |
| +// fleet), matched exactly and then by placement under the home binding. |
| +func (w *movesWorker) offlineTarget(ctx context.Context, s release.Store, m *provision.MoveState) (edgesFile, error) { |
| + fleet, err := loadMoveFleet(ctx, s) |
| + if err != nil { |
| + return edgesFile{}, fmt.Errorf("read the fleet's edges files: %w", err) |
| + } |
| + if t, ok := fleet.targets[m.To]; ok { |
| + return t, nil |
| + } |
| + codes := make([]string, 0, len(fleet.targets)) |
| + for code := range fleet.targets { |
| + codes = append(codes, code) |
| + } |
| + sort.Strings(codes) |
| + for _, code := range codes { |
| + if park.PlacementMatches(w.cfg.ParkHomeRegion, m.To, park.PlacementRegion(code)) { |
| + return fleet.targets[code], nil |
| + } |
| + } |
| + return edgesFile{}, fmt.Errorf("the target region %q publishes no data bucket yet", m.To) |
| +} |
| + |
| +// offlineMirror is the mirror pair between this box's bucket and region. |
| +func (w *movesWorker) offlineMirror(ctx context.Context, region string) (lease.MirrorStore, error) { |
| + if w.cfg.Mirror == nil { |
| + return nil, errors.New("offline move: no S3 mirror on this box") |
| + } |
| + return w.mirror(ctx, region) |
| +} |
| + |
| +// replicaPosition is the newest TXID of the replica listed in objs under |
| +// prefix (newestLTX), 0 when it holds no LTX file. |
| +func replicaPosition(objs map[string]lease.MirrorObject, prefix string) uint64 { |
| + newest := newestLTX(objs, prefix, nil) |
| + if newest == "" { |
| + return 0 |
| + } |
| + _, maxTX, err := ltx.ParseFilename(filepath.Base(newest)) |
| + if err != nil { |
| + return 0 |
| + } |
| + return uint64(maxTX) |
| +} |
| + |
| +// sealRefusal is a seal item that failed for a reason a later pass will |
| +// meet again: the pass reports it (phase failed) and the operator aborts. |
| +// Reason is the item's reason as `carlos move offline status` shows it. |
| +// Each refusal is written as a literal where it is decided, so the copy |
| +// gate reads every sentence a Reason can carry (copy rows MV-O76 to |
| +// MV-O84); the two that carry an error's own text (the archive's refusal |
| +// and the secrets key's) are box diagnostics (movesDiagnosticWrites). |
| +type sealRefusal struct { |
| + item, Reason string |
| +} |
| + |
| +func (r *sealRefusal) Error() string { return r.item + ": " + r.Reason } |
| + |
| +// offlineSeal is the sealer's step in offline-sealing. |
| +func (w *movesWorker) offlineSeal(ctx context.Context, s release.Store, rec provision.Record) error { |
| + m, host := rec.Move, rec.Host |
| + sealKey := moves.OfflineSealKey(host, m.ID) |
| + if sealKey == "" { |
| + return fmt.Errorf("offline move %s: %s has no seal key", m.ID, host) |
| + } |
| + raw, _, err := s.Get(ctx, sealKey) |
| + switch { |
| + case err == nil: |
| + // Sealed on an earlier pass, which crashed before the CAS: the |
| + // position is fixed, so the stop is only kept, never taken again. |
| + return w.offlineKeepStop(ctx, s, rec, raw) |
| + case !errors.Is(err, os.ErrNotExist): |
| + return fmt.Errorf("read %s: %w", sealKey, err) |
| + } |
| + items := map[string]moves.Check{} |
| + seal, err := w.offlineTakeSeal(ctx, s, rec, items) |
| + var ref *sealRefusal |
| + if errors.As(err, &ref) { |
| + items[ref.item] = moves.Check{OK: false, Reason: ref.Reason} |
| + r := moves.OfflineReport{Op: m.ID, Host: host, Box: w.cfg.Box, Role: moves.OfflineRoleSealer, Phase: moves.OfflinePhaseFailed, |
| + At: w.cfg.now().UTC(), Items: items} |
| + return errors.Join(fmt.Errorf("offline move %s: %s cannot be sealed: %w", m.ID, host, err), writeOfflineReport(ctx, s, r)) |
| + } |
| + if err != nil { |
| + return fmt.Errorf("offline move %s: sealing %s: %w", m.ID, host, err) |
| + } |
| + body, err := json.Marshal(seal) |
| + if err != nil { |
| + return err |
| + } |
| + stored, created, err := putOfflineCreateOnly(ctx, s, sealKey, body) |
| + if err != nil { |
| + return err |
| + } |
| + if created { |
| + log.Printf("moves: %s: offline move %s: sealed at TXID %d (%d staged objects, files %s)", host, m.ID, seal.FinalTXID, len(seal.Staged), seal.FilesPlain) |
| + } |
| + if err := w.sealFault("seal"); err != nil { |
| + return err |
| + } |
| + return w.offlineKeepStop(ctx, s, rec, stored) |
| +} |
| + |
| +// offlineTakeSeal runs steps 1 to 9 and returns the seal to write. items |
| +// collects each item's check as it is decided; a *sealRefusal is the item |
| +// that failed. |
| +func (w *movesWorker) offlineTakeSeal(ctx context.Context, s release.Store, rec provision.Record, items map[string]moves.Check) (moves.OfflineReport, error) { |
| + cfg := w.cfg |
| + m, host := rec.Move, rec.Host |
| + var seal moves.OfflineReport |
| + |
| + // 1. The stop, as the source reports say it now: this box held, fenced, |
| + // quiesced, with no process and no layout journal; every box the seal |
| + // named held; no sidecar anywhere. |
| + if len(m.Boxes) == 0 { |
| + return seal, errors.New("the record names no source boxes") |
| + } |
| + reports, err := readOfflineReports(ctx, s, host, m.ID, moves.OfflineRoleSource) |
| + if err != nil { |
| + return seal, fmt.Errorf("read the source reports: %w", err) |
| + } |
| + now := cfg.now().UTC() |
| + own, ok := reports[cfg.Box] |
| + if !ok || own.Phase != moves.OfflinePhaseStopped || !own.Held || !own.Fenced || !own.Quiesced || own.Procs != 0 || own.LayoutJournal || now.Sub(own.At) > offlineSealFresh { |
| + return seal, fmt.Errorf("this box has not stopped %s this pass: report found %v, held %v, fenced %v, quiesced %v, processes %d, layout journal %v, at %s", |
| + host, ok, own.Held, own.Fenced, own.Quiesced, own.Procs, own.LayoutJournal, own.At.Format(time.RFC3339)) |
| + } |
| + boxes := append([]string(nil), m.Boxes...) |
| + sort.Strings(boxes) |
| + for _, b := range boxes { |
| + r, ok := reports[b] |
| + if !ok || !r.Held { |
| + return seal, fmt.Errorf("waiting for %s to report %s held", b, host) |
| + } |
| + } |
| + for _, b := range boxes { |
| + if reports[b].Sidecar { |
| + return seal, &sealRefusal{item: "sidecar", Reason: fmt.Sprintf("%s keeps a sidecar file for %s", b, host)} |
| + } |
| + } |
| + items["sidecar"] = moves.Check{OK: true, Reason: "no source box keeps a sidecar file"} |
| + |
| + rt, err := w.offlineRow(host) |
| + if err != nil { |
| + return seal, err |
| + } |
| + if rt == nil { |
| + return seal, fmt.Errorf("this box has no row for %s", host) |
| + } |
| + tgt, err := w.offlineTarget(ctx, s, m) |
| + if err != nil { |
| + return seal, err |
| + } |
| + mirror, err := w.offlineMirror(ctx, tgt.DataBucketRegion) |
| + if err != nil { |
| + return seal, err |
| + } |
| + bucket, loc, region, err := cfg.tombstoneAt(host) |
| + if err != nil { |
| + return seal, err |
| + } |
| + srcPrefix := loc + "/" |
| + dstPrefix := activator.MovesPrefix(host) |
| + dbPath := rt.DBPath |
| + if dbPath == "" { |
| + dbPath = cfg.flatDBPath(host, rt) |
| + } |
| + |
| + // 2. The checkpoint, while no tombstone is down: the local database is |
| + // synced under the lease, which the checkpoint releases again. That |
| + // carries a tail the stop failed to checkpoint, whether litestream had |
| + // already turned it into a local LTX file (the local position is past |
| + // the replica's) or it is still in the WAL (the positions are equal), |
| + // as the legacy cut re-checkpoints unconditionally. A local copy behind |
| + // the replica is a stale warm cache: the replica is the instance, and |
| + // syncing the stale copy into it is the one thing never done. A |
| + // tombstone already this move's means an earlier pass checkpointed and |
| + // took it, and nothing has woken since; the checkpoint could not take |
| + // the lease anyway. |
| + local, err := w.localState(host, rt) |
| + if err != nil { |
| + return seal, err |
| + } |
| + objs, err := mirror.List(ctx, bucket, srcPrefix) |
| + if err != nil { |
| + return seal, fmt.Errorf("list the replica: %w", err) |
| + } |
| + before, err := w.offlineLock(ctx, host) |
| + if err != nil { |
| + return seal, err |
| + } |
| + replica := replicaPosition(objs, srcPrefix) |
| + switch { |
| + case before.mine(m.ID): |
| + case local.LocalTXID < replica: |
| + log.Printf("moves: %s: offline move %s: the local copy (position %d) is behind the replica (%d); the replica is sealed as it is", host, m.ID, local.LocalTXID, replica) |
| + case lexists(dbPath): |
| + if cfg.Checkpoint == nil { |
| + return seal, errors.New("no checkpoint on this box") |
| + } |
| + if err := cfg.Checkpoint(ctx, dbPath); err != nil { |
| + return seal, fmt.Errorf("final checkpoint of %s: %w", dbPath, err) |
| + } |
| + log.Printf("moves: %s: offline move %s: checkpointed the local database (position %d, replica %d)", host, m.ID, local.LocalTXID, replica) |
| + case local.LocalTXID > replica: |
| + return seal, &sealRefusal{item: "db-tail", Reason: fmt.Sprintf("this box's local position %d is past the replica's %d and there is no database at %s to checkpoint", local.LocalTXID, replica, dbPath)} |
| + } |
| + if err := w.sealFault("checkpoint"); err != nil { |
| + return seal, err |
| + } |
| + |
| + // 3. The tombstone: from here no wake on any box can take the lease. |
| + owner := moves.TombstoneOwner(m.ID) |
| + if err := lease.Tombstone(ctx, bucket, loc, region, owner, moves.TombstoneTTL); err != nil { |
| + return seal, fmt.Errorf("tombstone %s/%s/lock.json: %w", bucket, loc, err) |
| + } |
| + if err := w.sealFault("tombstone"); err != nil { |
| + return seal, err |
| + } |
| + |
| + // 4. The final position, read only now, and no source box past it. |
| + objs, err = mirror.List(ctx, bucket, srcPrefix) |
| + if err != nil { |
| + return seal, fmt.Errorf("list the replica: %w", err) |
| + } |
| + final := replicaPosition(objs, srcPrefix) |
| + if final == 0 { |
| + return seal, &sealRefusal{item: "db", Reason: fmt.Sprintf("the replica at %s/%s holds no database", bucket, srcPrefix)} |
| + } |
| + mine, err := w.localState(host, rt) // after the checkpoint, not as reported before it |
| + if err != nil { |
| + return seal, err |
| + } |
| + for _, b := range boxes { |
| + pos := reports[b].LocalTXID |
| + if b == cfg.Box { |
| + pos = mine.LocalTXID |
| + } |
| + if pos > final { |
| + return seal, &sealRefusal{item: "db-tail", Reason: fmt.Sprintf("%s's local position %d is past the replica's final position %d", b, pos, final)} |
| + } |
| + } |
| + items["db-tail"] = moves.Check{OK: true, Reason: fmt.Sprintf("every source box is at or below TXID %d", final)} |
| + items["lease"] = moves.Check{OK: true, Reason: fmt.Sprintf("%s holds %s/lock.json", owner, loc)} |
| + if err := w.sealFault("final"); err != nil { |
| + return seal, err |
| + } |
| + |
| + // 5. The request record into the build state, then the byte-final |
| + // round into the target's staging lane and its listing. |
| + if cfg.CarryRequest != nil { |
| + if err := cfg.CarryRequest(ctx, host, bucket, loc); err != nil { |
| + return seal, fmt.Errorf("carry the request record into the build state: %w", err) |
| + } |
| + items["build-state"] = moves.Check{OK: true, Reason: "the request record is in the build state the replica prefix carries"} |
| + } else { |
| + items["build-state"] = moves.Check{OK: true, Reason: "this box keeps no request records; the build state travels as the replica prefix holds it"} |
| + } |
| + receipts := lease.Receipts{Bucket: bucket, Key: activator.MoveReceiptsKey(host)} |
| + round, err := lease.MirrorPrefix(ctx, mirror, bucket, srcPrefix, tgt.DataBucket, dstPrefix, receipts) |
| + if err != nil { |
| + return seal, err |
| + } |
| + if round.Deferred > 0 { |
| + return seal, fmt.Errorf("final mirror round: %d object(s) changed at the source during the copy", round.Deferred) |
| + } |
| + source, err := mirror.List(ctx, bucket, srcPrefix) |
| + if err != nil { |
| + return seal, fmt.Errorf("list the replica: %w", err) |
| + } |
| + listed, err := mirror.List(ctx, tgt.DataBucket, dstPrefix) |
| + if err != nil { |
| + return seal, fmt.Errorf("list the staged copy: %w", err) |
| + } |
| + staged := map[string]moves.StagedObject{} |
| + for k, o := range listed { |
| + staged[strings.TrimPrefix(k, dstPrefix)] = moves.StagedObject{Size: o.Size, ETag: o.ETag} |
| + } |
| + if err := sameListing(source, srcPrefix, staged); err != nil { |
| + return seal, fmt.Errorf("the staged copy is not the replica: %w", err) |
| + } |
| + if replicaPosition(listed, dstPrefix) != final { |
| + return seal, fmt.Errorf("the staged copy's newest TXID is %d, the replica's %d", replicaPosition(listed, dstPrefix), final) |
| + } |
| + items["db"] = moves.Check{OK: true, Reason: fmt.Sprintf("%d objects staged at %s/%s, newest TXID %d", len(staged), tgt.DataBucket, dstPrefix, final)} |
| + if err := w.sealFault("mirror"); err != nil { |
| + return seal, err |
| + } |
| + |
| + // 6. The files and <host>.env, sealed, beside the staging lane. |
| + prefix := moves.OfflineArchivePrefix(host, m.ID) |
| + files, envPath, configPath, stray, err := w.offlineFilesOf(host, rt) |
| + if err != nil { |
| + return seal, fmt.Errorf("read what %s keeps: %w", host, err) |
| + } |
| + if len(stray) > 0 { |
| + sort.Strings(stray) |
| + // What the app keeps there is the app's data, in a place it reads |
| + // from: moving it into the files directory changes that place, and |
| + // removing it loses it. So the refusal offers neither (MV-O76). |
| + return seal, &sealRefusal{item: "files", Reason: fmt.Sprintf("%s keeps %s beside its files directory, and this move can carry only the files directory. see the offline move runbook", host, strings.Join(stray, ", "))} |
| + } |
| + a, err := w.offlineArchive(ctx, rec, mirror, tgt.DataBucket, prefix, files, envPath) |
| + if err != nil { |
| + return seal, err |
| + } |
| + items["files"] = moves.Check{OK: true, Reason: fmt.Sprintf("%d files, %d bytes, sealed at %s%s", a.entries, a.bytes, prefix, offlineArchiveSealed)} |
| + if err := w.sealFault("archive"); err != nil { |
| + return seal, err |
| + } |
| + |
| + // 7. The timers. |
| + if cfg.Registry == nil { |
| + return seal, errors.New("no registry on this box") |
| + } |
| + rows, err := cfg.Registry.TimersFor(host) |
| + if err != nil { |
| + return seal, fmt.Errorf("read the timers: %w", err) |
| + } |
| + active := moves.ActiveTimers(rows) |
| + timersBody, err := moves.TimersSnapshot{V: 1, Host: host, Rows: active, TakenAt: now}.Encode() |
| + if err != nil { |
| + return seal, err |
| + } |
| + if err := mirror.Put(ctx, tgt.DataBucket, prefix+offlineTimersObject, timersBody); err != nil { |
| + return seal, fmt.Errorf("put the timers: %w", err) |
| + } |
| + timersDigest := moves.TimersDigest(rows) |
| + items["timers"] = moves.Check{OK: true, Reason: fmt.Sprintf("%d active timers at %s%s", len(active), prefix, offlineTimersObject)} |
| + if err := w.sealFault("timers"); err != nil { |
| + return seal, err |
| + } |
| + |
| + // 8. The env file, its effective instance key, and the env pin. |
| + envSHA, keySHA, err := offlineEnvDigests(envPath, configPath, a.envSHA) |
| + if err != nil { |
| + return seal, err |
| + } |
| + // The digest itself is the seal's EnvSHA256, not part of the reason. |
| + items["env"] = moves.Check{OK: true, Reason: "absent on the source, so none is carried"} |
| + if envSHA != "" { |
| + items["env"] = moves.Check{OK: true, Reason: "carried in the archive"} |
| + } |
| + if cfg.LaunchLayers == nil { |
| + return seal, &sealRefusal{item: "env-pin", Reason: "this box has no exec backend to compose the launch environment"} |
| + } |
| + if cfg.SecretsKey == "" { |
| + return seal, &sealRefusal{item: "env-pin", Reason: "this box holds no CARLOS_SECRETS_KEY to key the env report"} |
| + } |
| + env, instance, _, err := cfg.LaunchLayers(*rt) |
| + if err != nil { |
| + return seal, fmt.Errorf("compose the launch environment: %w", err) |
| + } |
| + mac, err := moves.ReportKeyFromSecrets(cfg.SecretsKey) |
| + if err != nil { |
| + return seal, &sealRefusal{item: "env-pin", Reason: err.Error()} |
| + } |
| + envReport := moves.ReportLaunch(mac, env, instance) |
| + envReportBody, err := json.Marshal(envReport) |
| + if err != nil { |
| + return seal, err |
| + } |
| + if err := mirror.Put(ctx, tgt.DataBucket, prefix+offlineEnvReportObject, envReportBody); err != nil { |
| + return seal, fmt.Errorf("put the env report: %w", err) |
| + } |
| + items["env-pin"] = moves.Check{OK: true, Reason: "launch environment digest " + envReport.Digest} |
| + if err := w.sealFault("env"); err != nil { |
| + return seal, err |
| + } |
| + |
| + // 9. The build, the replication rung and the next due time. |
| + if cfg.LiveVersion == nil { |
| + return seal, errors.New("no version store on this box") |
| + } |
| + version, err := cfg.LiveVersion(host) |
| + if err != nil { |
| + return seal, fmt.Errorf("read the live build: %w", err) |
| + } |
| + if version == "" { |
| + return seal, &sealRefusal{item: "release", Reason: fmt.Sprintf("no build is live for %s on this box", host)} |
| + } |
| + items["release"] = moves.Check{OK: true, Reason: "live build " + version} |
| + items["replication"] = moves.Check{OK: true, Reason: fmt.Sprintf("replication %q", rt.Replication)} |
| + items["next-due"] = moves.Check{OK: true, Reason: fmt.Sprintf("next due %d", rt.NextDue)} |
| + |
| + // The tombstone must still be this move's when the seal is written: |
| + // the position it records was read under it. One lost before the seal |
| + // is taken again on the next pass, which seals afresh. |
| + st, err := w.offlineLock(ctx, host) |
| + if err != nil { |
| + return seal, err |
| + } |
| + if !st.mine(m.ID) { |
| + return seal, fmt.Errorf("the lease is %s, not this move's tombstone; the next pass takes it again", st) |
| + } |
| + seal = moves.OfflineReport{ |
| + V: moves.OfflineReportVersion, Op: m.ID, Host: host, Box: cfg.Box, Role: moves.OfflineRoleSealer, Phase: moves.OfflinePhaseSealed, |
| + At: now, Items: items, TombstoneOwner: owner, |
| + FinalTXID: final, Staged: staged, |
| + FilesPlain: a.plain, FilesSealed: a.sealed, FilesBytes: a.bytes, FilesEntries: a.entries, |
| + EnvSHA256: envSHA, KeySHA256: keySHA, EnvDigest: envReport.Digest, |
| + Release: version, TimersDigest: timersDigest, Replication: rt.Replication, NextDue: rt.NextDue, |
| + } |
| + if err := moves.ValidateOfflineSeal(seal, host, m.ID); err != nil { |
| + return seal, err |
| + } |
| + return seal, nil |
| +} |
| + |
| +// sameListing reports whether staged (keyed relative to the staging |
| +// prefix) holds exactly the source's objects under srcPrefix, lock.json |
| +// aside (the mirror never copies it), size for size. ETags may differ: a |
| +// multipart source's copy gets a new one (lease.Receipts). |
| +func sameListing(source map[string]lease.MirrorObject, srcPrefix string, staged map[string]moves.StagedObject) error { |
| + want := map[string]int64{} |
| + for k, o := range source { |
| + rel := strings.TrimPrefix(k, srcPrefix) |
| + if rel == "lock.json" { |
| + continue |
| + } |
| + want[rel] = o.Size |
| + } |
| + if len(want) != len(staged) { |
| + return fmt.Errorf("%d objects at the source, %d staged", len(want), len(staged)) |
| + } |
| + for rel, size := range want { |
| + got, ok := staged[rel] |
| + if !ok || got.Size != size { |
| + return fmt.Errorf("%s: staged %v, source size %d", rel, got, size) |
| + } |
| + } |
| return nil |
| } |
| + |
| +// offlineFilesOf is what the archive carries for the row's layout, and |
| +// what it cannot. |
| +// |
| +// The target lands the instance flat (the row is minted at flat paths), so |
| +// the archive's files root is the app's files directory as the flat layout |
| +// names it: <DataDir>/<host>, with <host>.env beside it. The layout pass |
| +// maps the flat data directory onto the nested instance directory name for |
| +// name (layout/extras.go: an app finds its files beside its database, and |
| +// nested the database is <instance dir>/<host>.db), so a nested row's |
| +// files directory is <instance dir>/<host>, read through the raw |
| +// directory as every agent read of it is, and its env file is |
| +// <instance dir>/<host>.env. Archiving the whole instance directory would |
| +// land the app's files one level down on the target (<DataDir>/<host>/ |
| +// <host>/...), where the app would not find them. |
| +// |
| +// stray is every entry the app keeps beside its files directory, which |
| +// the flat files directory cannot carry: on a nested row every entry of |
| +// the instance directory that is neither the platform's |
| +// (layout.IsNestedPlatformName: the seed marker and the launch descriptors |
| +// included) nor <host>; on a flat row every entry of |
| +// the data directory the layout's naming rules give this host |
| +// (layout.FlatLeftovers; <host>-blobs is the live example) that is |
| +// neither. The seal refuses on any, rather than leave it behind. |
| +// |
| +// A host that never kept a files directory (an app that writes nothing of |
| +// its own) has none at the files path: the archive then carries the env |
| +// file alone, FilesPlain is the digest of that, and the landing extracts |
| +// an empty files directory. |
| +// |
| +// configPath is the rendered configuration beside the env file, which the |
| +// effective instance key may come from. |
| +func (w *movesWorker) offlineFilesOf(host string, rt *registry.Route) (files, envPath, configPath string, stray []string, err error) { |
| + dd := w.cfg.DataDir |
| + platform := func(name string) bool { return layout.IsNestedPlatformName(host, name) } |
| + if rt != nil && provision.NestedPaths(dd, *rt) { |
| + raw := layout.RawInstanceDir(dd, host) |
| + ents, err := os.ReadDir(raw) |
| + if err != nil && !errors.Is(err, os.ErrNotExist) { |
| + return "", "", "", nil, err |
| + } |
| + for _, e := range ents { |
| + if !platform(e.Name()) && e.Name() != host { |
| + stray = append(stray, filepath.Join(raw, e.Name())) |
| + } |
| + } |
| + return filepath.Join(raw, host), filepath.Join(raw, host+".env"), filepath.Join(raw, host+".config.env"), stray, nil |
| + } |
| + var hosts []string |
| + if w.cfg.Registry != nil { |
| + rows, err := w.cfg.Registry.List() |
| + if err != nil { |
| + return "", "", "", nil, err |
| + } |
| + for _, r := range rows { |
| + hosts = append(hosts, r.Host) |
| + } |
| + } |
| + for _, p := range layout.FlatLeftovers(dd, host, hosts) { |
| + if name := filepath.Base(p); !platform(name) && name != host { |
| + stray = append(stray, p) |
| + } |
| + } |
| + return filepath.Join(dd, host), filepath.Join(dd, host+".env"), filepath.Join(dd, host+".config.env"), stray, nil |
| +} |
| + |
| +// sealedArchive is what the seal records of the archive. |
| +type sealedArchive struct { |
| + plain, sealed string |
| + bytes int64 |
| + entries int |
| + // envSHA is the env entry's digest as the archive recorded it |
| + // ("sha256:<hex>"), "" when the archive carries no env. |
| + envSHA string |
| +} |
| + |
| +// offlineArchive builds the archive of files and envPath into a scratch |
| +// directory on the data disk, seals it under the deployment's config key |
| +// bound to the record's account and app, and puts it and its manifest |
| +// under prefix in the target bucket. A file the archive refuses is a |
| +// refusal of the files item, never a partial archive; a file that changed |
| +// while it was read is retried. The files directory holds nothing of the |
| +// platform's but the files marker, which every build leaves out, so no |
| +// filter is needed (archive.Build is BuildFiltered with none). |
| +func (w *movesWorker) offlineArchive(ctx context.Context, rec provision.Record, mirror lease.MirrorStore, bucket, prefix, files, envPath string) (sealedArchive, error) { |
| + cfg := w.cfg |
| + var out sealedArchive |
| + if cfg.ConfigPub == nil { |
| + return out, &sealRefusal{item: "files", Reason: "this box holds no CARLOS_SECRETS_KEY to seal the archive under"} |
| + } |
| + dir := moves.OfflineParkDir(cfg.DataDir, rec.Host, rec.Move.ID, moves.OfflineRoleSealer) |
| + if dir == "" { |
| + return out, fmt.Errorf("no scratch directory for %s under %q", rec.Host, cfg.DataDir) |
| + } |
| + if err := os.MkdirAll(dir, 0o700); err != nil { |
| + return out, err |
| + } |
| + tmp, err := os.MkdirTemp(dir, "archive-") |
| + if err != nil { |
| + return out, err |
| + } |
| + defer os.RemoveAll(tmp) |
| + built, err := os.Create(filepath.Join(tmp, "archive.tar.zst")) |
| + if err != nil { |
| + return out, err |
| + } |
| + defer built.Close() |
| + plain, entries, size, err := archive.Build(files, envPath, built) |
| + switch { |
| + case errors.Is(err, archive.ErrChanged): |
| + return out, fmt.Errorf("archive the files: %w; built again next pass", err) |
| + case err != nil: |
| + return out, &sealRefusal{item: "files", Reason: err.Error()} |
| + } |
| + if _, err := built.Seek(0, io.SeekStart); err != nil { |
| + return out, err |
| + } |
| + sealedFile, err := os.Create(filepath.Join(tmp, "archive.sealed")) |
| + if err != nil { |
| + return out, err |
| + } |
| + defer sealedFile.Close() |
| + dataKey, sealedSHA, err := archive.Seal(built, cfg.ConfigPub, appconfig.Binding{Account: rec.Account, App: rec.App}, sealedFile) |
| + if err != nil { |
| + return out, fmt.Errorf("seal the archive: %w", err) |
| + } |
| + manifest, err := json.Marshal(archive.Manifest{V: archive.ManifestVersion, Op: rec.Move.ID, Plain: plain, Sealed: sealedSHA, Bytes: size, |
| + Entries: entries, SealedDataKey: dataKey, Chunk: archive.ChunkSize}) |
| + if err != nil { |
| + return out, err |
| + } |
| + body, err := os.ReadFile(sealedFile.Name()) |
| + if err != nil { |
| + return out, err |
| + } |
| + if err := mirror.Put(ctx, bucket, prefix+offlineArchiveSealed, body); err != nil { |
| + return out, fmt.Errorf("put the sealed archive: %w", err) |
| + } |
| + if err := mirror.Put(ctx, bucket, prefix+offlineArchiveManifest, manifest); err != nil { |
| + return out, fmt.Errorf("put the archive manifest: %w", err) |
| + } |
| + out = sealedArchive{plain: plain, sealed: sealedSHA, bytes: size, entries: len(entries)} |
| + for _, e := range entries { |
| + if e.Path == archive.EnvEntry { |
| + out.envSHA = e.SHA256 |
| + } |
| + } |
| + return out, nil |
| +} |
| + |
| +// readOwnFile reads a regular file without following a link, as the agent |
| +// reads every file a nested tenant can write: exists false when there is |
| +// none. |
| +func readOwnFile(path string) (body []byte, exists bool, err error) { |
| + info, err := os.Lstat(path) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, false, nil |
| + } |
| + if err != nil { |
| + return nil, false, err |
| + } |
| + if !info.Mode().IsRegular() { |
| + return nil, true, fmt.Errorf("%s is not a regular file", path) |
| + } |
| + f, err := os.Open(path) |
| + if err != nil { |
| + return nil, true, err |
| + } |
| + defer f.Close() |
| + got, err := f.Stat() |
| + if err != nil { |
| + return nil, true, err |
| + } |
| + if !os.SameFile(info, got) { |
| + return nil, true, fmt.Errorf("%s changed while it was read", path) |
| + } |
| + body, err = io.ReadAll(io.LimitReader(f, instancekey.EnvReadLimit+1)) |
| + if err != nil { |
| + return nil, true, err |
| + } |
| + if len(body) > instancekey.EnvReadLimit { |
| + return nil, true, fmt.Errorf("%s is larger than an env file", path) |
| + } |
| + return body, true, nil |
| +} |
| + |
| +// offlineEnvDigests is the env item's two values: the lowercase hex sha256 |
| +// of <host>.env's bytes ("" when the source has none), and the lowercase |
| +// hex sha256 of the instance key a launch hands the app |
| +// (instancekey.Effective over the rendered configuration and the own file, |
| +// instancekey.Digest; "" when neither supplies one). archived is the env |
| +// entry's digest as the archive recorded it ("sha256:<hex>", or "" for no |
| +// env entry); the file read here must be the one archived. |
| +func offlineEnvDigests(envPath, configPath, archived string) (envSHA, keySHA string, err error) { |
| + own, exists, err := readOwnFile(envPath) |
| + if err != nil { |
| + return "", "", fmt.Errorf("read the env file: %w", err) |
| + } |
| + if exists { |
| + sum := sha256.Sum256(own) |
| + envSHA = hex.EncodeToString(sum[:]) |
| + } |
| + if want := strings.TrimPrefix(archived, "sha256:"); want != envSHA { |
| + return "", "", fmt.Errorf("the env file changed while it was archived (archived %q, now %q); sealed again next pass", want, envSHA) |
| + } |
| + config, _, err := readOwnFile(configPath) |
| + if err != nil { |
| + return "", "", fmt.Errorf("read the rendered configuration: %w", err) |
| + } |
| + if key, _ := instancekey.Effective(instancekey.ParseEnv(config), instancekey.ParseEnv(own)); key != "" { |
| + keySHA = instancekey.Digest(key) |
| + } |
| + return envSHA, keySHA, nil |
| +} |
| + |
| +// offlineSealRecord reads seal.json, nil when it is not there. |
| +func (w *movesWorker) offlineSealRecord(ctx context.Context, s release.Store, rec provision.Record) ([]byte, error) { |
| + key := moves.OfflineSealKey(rec.Host, rec.Move.ID) |
| + raw, _, err := s.Get(ctx, key) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil, nil |
| + } |
| + if err != nil { |
| + return nil, fmt.Errorf("read %s: %w", key, err) |
| + } |
| + return raw, nil |
| +} |
| + |
| +// decodeSeal parses and checks the seal record; nil raw is no seal. |
| +func decodeSeal(raw []byte, host, id string) (*moves.OfflineReport, error) { |
| + if raw == nil { |
| + return nil, nil |
| + } |
| + var seal moves.OfflineReport |
| + if err := json.Unmarshal(raw, &seal); err != nil { |
| + return nil, fmt.Errorf("the seal record does not parse: %w", err) |
| + } |
| + if err := moves.ValidateOfflineSeal(seal, host, id); err != nil { |
| + return nil, err |
| + } |
| + return &seal, nil |
| +} |
| + |
| +// offlineKeepStop is every pass after the seal, until the tombstone is |
| +// lifted: read the lease first; a tombstone that is not this move's any |
| +// more is a lost stop, never taken again. Then the source reports: one |
| +// written since the seal at a position past the sealed one is a lost stop |
| +// too (a write the stop did not hold off, which the staged copy lacks). |
| +// Only then is this move's tombstone re-asserted. In |
| +// offline-sealing (a seal written by a pass that crashed before the CAS) |
| +// it also makes the CAS. Then it rewrites the living sealer report: the |
| +// seal's values and items, the tombstone's owner and expiry, and At. |
| +func (w *movesWorker) offlineKeepStop(ctx context.Context, s release.Store, rec provision.Record, raw []byte) error { |
| + cfg := w.cfg |
| + m, host := rec.Move, rec.Host |
| + seal, err := decodeSeal(raw, host, m.ID) |
| + if err != nil { |
| + return fmt.Errorf("offline move %s: %s: %w; it is never rewritten, so abort the move", m.ID, host, err) |
| + } |
| + if seal == nil && m.State == provision.OfflineSealing { |
| + return fmt.Errorf("offline move %s: %s has no seal record", m.ID, host) |
| + } |
| + st, err := w.offlineLock(ctx, host) |
| + if err != nil { |
| + return err |
| + } |
| + if !st.mine(m.ID) { |
| + return w.offlineLeaseLost(ctx, s, rec, seal, st) |
| + } |
| + if seal != nil { |
| + if err := w.offlineCheckPositions(ctx, s, rec, seal); err != nil { |
| + return err |
| + } |
| + } |
| + bucket, loc, region, err := cfg.tombstoneAt(host) |
| + if err != nil { |
| + return err |
| + } |
| + if err := w.sealFault("reassert"); err != nil { |
| + return err |
| + } |
| + if err := lease.Reassert(ctx, bucket, loc, region, moves.TombstoneOwner(m.ID), moves.TombstoneTTL); err != nil { |
| + var held *lease.HeldError |
| + if errors.As(err, &held) { |
| + return w.offlineLeaseLost(ctx, s, rec, seal, tombState{owner: held.Owner, expires: held.ExpiresAt}) |
| + } |
| + if errors.Is(err, lease.ErrTombstoneLost) { |
| + // Gone between the read above and Reassert's own: the same |
| + // lost stop, and Reassert wrote nothing. Read it again for |
| + // what it is now (absent, when even that read fails). |
| + now, rerr := w.offlineLock(ctx, host) |
| + if rerr != nil { |
| + now = tombState{} |
| + } |
| + return w.offlineLeaseLost(ctx, s, rec, seal, now) |
| + } |
| + return fmt.Errorf("offline move %s: re-assert the tombstone of %s: %w", m.ID, host, err) |
| + } |
| + if st, err = w.offlineLock(ctx, host); err != nil { |
| + return err |
| + } |
| + if !st.mine(m.ID) { |
| + return w.offlineLeaseLost(ctx, s, rec, seal, st) |
| + } |
| + if m.State == provision.OfflineSealing { |
| + src := provision.MoveSource{ |
| + FinalTXID: seal.FinalTXID, Replication: seal.Replication, NextDue: seal.NextDue, |
| + Files: &provision.MoveFilesDigest{Plain: seal.FilesPlain, Sealed: seal.FilesSealed, Bytes: seal.FilesBytes, Entries: seal.FilesEntries}, |
| + TimersSHA256: seal.TimersDigest, Release: seal.Release, EnvDigest: seal.EnvDigest, |
| + Evidence: moves.OfflineSealEvidence(host, m.ID, raw), |
| + } |
| + _, err := provision.SetRecordMove(ctx, s, rec.Account, rec.App, host, provision.OfflineSealedBy(m.ID, cfg.Box, src)) |
| + switch { |
| + case errors.Is(err, provision.ErrMoveConflict): |
| + log.Printf("moves: %s: offline move %s: the record moved on before the seal's CAS: %v", host, m.ID, err) |
| + case err != nil: |
| + return fmt.Errorf("offline move %s: record the seal of %s: %w", m.ID, host, err) |
| + default: |
| + nudgeSerial(ctx, s) |
| + log.Printf("moves: %s: offline move %s: sealed", host, m.ID) |
| + } |
| + if err := w.sealFault("cas"); err != nil { |
| + return err |
| + } |
| + } |
| + living := moves.OfflineReport{Op: m.ID, Host: host, Box: cfg.Box, Role: moves.OfflineRoleSealer} |
| + if seal != nil { |
| + living = *seal |
| + } |
| + living.Phase = moves.OfflinePhaseSealed |
| + living.At = cfg.now().UTC() |
| + living.TombstoneOwner = st.owner |
| + living.TombstoneUntil = st.expires.UTC() |
| + return writeOfflineReport(ctx, s, living) |
| +} |
| + |
| +// offlineCheckPositions is the backstop behind the holds and the fence: |
| +// every source report written since the seal (at or after its At) must be |
| +// at or below the sealed final position. One past it is a box that wrote |
| +// what the staged copy does not hold, so the sealed position is no longer |
| +// the instance's: a lost stop, in the db-tail item. |
| +func (w *movesWorker) offlineCheckPositions(ctx context.Context, s release.Store, rec provision.Record, seal *moves.OfflineReport) error { |
| + reports, err := readOfflineReports(ctx, s, rec.Host, rec.Move.ID, moves.OfflineRoleSource) |
| + if err != nil { |
| + return fmt.Errorf("read the source reports: %w", err) |
| + } |
| + boxes := make([]string, 0, len(reports)) |
| + for b := range reports { |
| + boxes = append(boxes, b) |
| + } |
| + sort.Strings(boxes) |
| + for _, b := range boxes { |
| + r := reports[b] |
| + if r.At.Before(seal.At) || r.LocalTXID <= seal.FinalTXID { |
| + continue |
| + } |
| + return w.offlineLostStop(ctx, s, rec, seal, "db-tail", offlinePastSealCause(b, r.LocalTXID, seal.FinalTXID)) |
| + } |
| + return nil |
| +} |
| + |
| +// offlineLeaseLost is the lost stop of a tombstone that is absent, |
| +// expired, lifted or someone else's, in the lease item. |
| +func (w *movesWorker) offlineLeaseLost(ctx context.Context, s release.Store, rec provision.Record, seal *moves.OfflineReport, st tombState) error { |
| + return w.offlineLostStop(ctx, s, rec, seal, "lease", offlineLeaseCause(st)) |
| +} |
| + |
| +// offlineLeaseCause and offlinePastSealCause are the two causes of a lost |
| +// stop as its page and the failed item say them (MV-O69, MV-O70). |
| +func offlineLeaseCause(st tombState) string { |
| + return fmt.Sprintf("the source's lease is %s, not this move's tombstone", offlineLeaseText(st)) |
| +} |
| + |
| +func offlinePastSealCause(box string, pos, final uint64) string { |
| + return fmt.Sprintf("%s reports TXID %d, past the sealed position %d", box, pos, final) |
| +} |
| + |
| +// offlineLostStop reports a stop that no longer holds after the seal: |
| +// cause says why, and item is the seal item it fails. The tombstone is |
| +// never taken again (a wake may have written the replica in between, so |
| +// the sealed position is no longer the source's), the living report says |
| +// failed with that item not OK and no tombstone owner (so open refuses), |
| +// and an operator is paged once per move. |
| +func (w *movesWorker) offlineLostStop(ctx context.Context, s release.Store, rec provision.Record, seal *moves.OfflineReport, item, cause string) error { |
| + cfg := w.cfg |
| + m, host := rec.Move, rec.Host |
| + r := moves.OfflineReport{Op: m.ID, Host: host, Box: cfg.Box, Role: moves.OfflineRoleSealer} |
| + if seal != nil { |
| + r = *seal |
| + } |
| + r.Phase = moves.OfflinePhaseFailed |
| + r.At = cfg.now().UTC() |
| + r.TombstoneOwner, r.TombstoneUntil = "", time.Time{} |
| + items := map[string]moves.Check{} |
| + for k, v := range r.Items { |
| + items[k] = v |
| + } |
| + items[item] = moves.Check{OK: false, Reason: "lost stop: " + cause} |
| + r.Items = items |
| + werr := writeOfflineReport(ctx, s, r) |
| + subject, body := offlineLostStopPage(host, m.ID, m.State, cause) |
| + w.firePage(ctx, host, m.ID, offlinePageLostStop, alerting.LevelIncident, subject, body) |
| + log.Printf("moves: %s: offline move %s: LOST STOP: %s", host, m.ID, cause) |
| + return errors.Join(fmt.Errorf("offline move %s: %s: lost stop: %s", m.ID, host, cause), werr) |
| +} |
| + |
| +// offlineLostStopPage is the lost stop's page (copy rows MV-O66 to |
| +// MV-O68; the item's reason is MV-O71): its subject, and a body for the move's state. Before open, |
| +// abort puts the instance back with nothing lost, so the body says to |
| +// abort. From open on, abort is refused (MV-O40) and the move is fixed |
| +// forward by hand, so the body points to the status and the runbook. |
| +func offlineLostStopPage(host, id, state, cause string) (subject, body string) { |
| + subject = fmt.Sprintf("offline move of %s lost its stop", host) |
| + if state == provision.OfflineOpen || state == provision.OfflineFinishing { |
| + body = fmt.Sprintf("The offline move %s of %s is %s, and its stop no longer holds: %s. It cannot be aborted once it is open. Read carlos move offline status --host %s and follow the offline move runbook.", id, host, state, cause, host) |
| + return subject, body |
| + } |
| + body = fmt.Sprintf("The offline move %s of %s is %s, and its stop no longer holds: %s. The move cannot go on. Read carlos move offline status --host %s and abort the move.", id, host, state, cause, host) |
| + return subject, body |
| +} |
| + |
| +// offlineFinishLift is the sealer's step in offline-finishing. Until every |
| +// box the seal named reports gone (it parked at open and reconcile has |
| +// removed its row since), the stop is kept as before. Then the tombstone |
| +// is lifted (only this move's, with the ETag read), the copy receipts are |
| +// cleared and the report says lifted. A lease that is already free (lifted |
| +// by an earlier pass) is left as it is; one someone else holds live is a |
| +// lost stop. |
| +func (w *movesWorker) offlineFinishLift(ctx context.Context, s release.Store, rec provision.Record) error { |
| + cfg := w.cfg |
| + m, host := rec.Move, rec.Host |
| + reports, err := readOfflineReports(ctx, s, host, m.ID, moves.OfflineRoleSource) |
| + if err != nil { |
| + return fmt.Errorf("read the source reports: %w", err) |
| + } |
| + gone := len(m.Boxes) > 0 |
| + for _, b := range m.Boxes { |
| + if r, ok := reports[b]; !ok || r.Phase != moves.OfflinePhaseGone { |
| + gone = false |
| + } |
| + } |
| + raw, err := w.offlineSealRecord(ctx, s, rec) |
| + if err != nil { |
| + return err |
| + } |
| + if !gone { |
| + return w.offlineKeepStop(ctx, s, rec, raw) |
| + } |
| + st, err := w.offlineLock(ctx, host) |
| + if err != nil { |
| + return err |
| + } |
| + switch { |
| + case st.mine(m.ID): |
| + bucket, loc, region, err := cfg.tombstoneAt(host) |
| + if err != nil { |
| + return err |
| + } |
| + if _, err := lease.LiftTombstone(ctx, bucket, loc, region, func(owner string) bool { return owner == moves.TombstoneOwner(m.ID) }); err != nil { |
| + return fmt.Errorf("offline move %s: lift the tombstone of %s: %w", m.ID, host, err) |
| + } |
| + log.Printf("moves: %s: offline move %s: tombstone lifted", host, m.ID) |
| + case st.free(): |
| + default: |
| + seal, _ := decodeSeal(raw, host, m.ID) |
| + return w.offlineLeaseLost(ctx, s, rec, seal, st) |
| + } |
| + if err := w.sealFault("lift"); err != nil { |
| + return err |
| + } |
| + own, err := w.offlineMirror(ctx, cfg.OwnBucketRegion) |
| + if err != nil { |
| + return err |
| + } |
| + clearOfflineReceipts(ctx, own, cfg.OwnBucket, host) |
| + r := moves.OfflineReport{Op: m.ID, Host: host, Box: cfg.Box, Role: moves.OfflineRoleSealer, Phase: moves.OfflinePhaseLifted, At: cfg.now().UTC()} |
| + return writeOfflineReport(ctx, s, r) |
| +} |
| + |
| +// clearOfflineReceipts is clearMoveReceipts as an ensure: receipts that are |
| +// already gone are not deleted again, so a pass that repeats finish or |
| +// revert writes nothing (on a versioned bucket every DELETE of an absent |
| +// key leaves one more delete marker). |
| +func clearOfflineReceipts(ctx context.Context, m lease.MirrorStore, bucket, host string) { |
| + rc := lease.Receipts{Bucket: bucket, Key: activator.MoveReceiptsKey(host)} |
| + if _, err := m.Get(ctx, rc.Bucket, rc.Key); errors.Is(err, lease.ErrMirrorNotFound) { |
| + return |
| + } |
| + clearMoveReceipts(ctx, m, rc, host) |
| +} |
| + |
| +// offlineSealRevert is the sealer's step in offline-reverting: lift the |
| +// tombstone if it is this move's (a lease anyone else holds, or none, is |
| +// left alone), delete the two prefixes this move wrote in the target's |
| +// bucket (the staged replica and the archive), clear the copy receipts, |
| +// lift this move's fence (the source step lifts it too) and report |
| +// reverted. Every part is an ensure. |
| +func (w *movesWorker) offlineSealRevert(ctx context.Context, s release.Store, rec provision.Record) error { |
| + cfg := w.cfg |
| + m, host := rec.Move, rec.Host |
| + bucket, loc, region, err := cfg.tombstoneAt(host) |
| + if err != nil { |
| + return err |
| + } |
| + lifted, err := lease.LiftTombstone(ctx, bucket, loc, region, func(owner string) bool { return owner == moves.TombstoneOwner(m.ID) }) |
| + var held *lease.HeldError |
| + switch { |
| + case err == nil: |
| + if lifted { |
| + log.Printf("moves: %s: offline move %s: tombstone lifted on abort", host, m.ID) |
| + } |
| + case errors.As(err, &held): |
| + w.notice(host+"#offline-revert-lease", "moves: %s: offline move %s: the lease is %s's, not this move's; left as it is", host, m.ID, held.Owner) |
| + default: |
| + return fmt.Errorf("offline move %s: lift the tombstone of %s: %w", m.ID, host, err) |
| + } |
| + if err := w.sealFault("revert-lift"); err != nil { |
| + return err |
| + } |
| + tgt, err := w.offlineTarget(ctx, s, m) |
| + if err != nil { |
| + return err |
| + } |
| + mirror, err := w.offlineMirror(ctx, tgt.DataBucketRegion) |
| + if err != nil { |
| + return err |
| + } |
| + for _, prefix := range []string{activator.MovesPrefix(host), moves.OfflineArchivePrefix(host, m.ID)} { |
| + if prefix == "" || prefix == "moves/" { |
| + return fmt.Errorf("offline move %s: no staging prefix for %s", m.ID, host) |
| + } |
| + if err := lease.DeletePrefix(ctx, mirror, tgt.DataBucket, prefix); err != nil { |
| + return fmt.Errorf("offline move %s: delete %s/%s: %w", m.ID, tgt.DataBucket, prefix, err) |
| + } |
| + } |
| + clearOfflineReceipts(ctx, mirror, bucket, host) |
| + w.offlineLiftFence(host, m) |
| + r := moves.OfflineReport{Op: m.ID, Host: host, Box: cfg.Box, Role: moves.OfflineRoleSealer, Phase: moves.OfflinePhaseReverted, At: cfg.now().UTC()} |
| + return writeOfflineReport(ctx, s, r) |
| +} |
diff --git a/internal/host/movesoffline_seal_test.go b/internal/host/movesoffline_seal_test.go| new file mode 100644 |
| index 000000000..02158838d |
| --- /dev/null |
| +++ b/internal/host/movesoffline_seal_test.go |
| @@ -0,0 +1,1405 @@ |
| +//go:build linux |
| + |
| +package host |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "crypto/ecdh" |
| + "crypto/sha256" |
| + "database/sql" |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "syscall" |
| + "testing" |
| + "time" |
| + |
| + "github.com/benbjohnson/litestream" |
| + "github.com/superfly/ltx" |
| + |
| + "github.com/carlosframework/platform/internal/activator" |
| + "github.com/carlosframework/platform/internal/appconfig" |
| + "github.com/carlosframework/platform/internal/instancekey" |
| + "github.com/carlosframework/platform/internal/layout" |
| + "github.com/carlosframework/platform/internal/lease" |
| + "github.com/carlosframework/platform/internal/lease/leasetest" |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/moves/archive" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The sealer (Task 4 of docs/superpowers/plans/2026-10-20-offline-operator- |
| +// move.md), tested through offlineRound, the moves pass's dispatch, over |
| +// the S3 wire protocol (leasetest.NewS3Server, with buckets data-ie and |
| +// data-uk behind one endpoint), real litestream replicas written by |
| +// lease.ReplicateWithLease, the real lease package, a real registry and |
| +// activator per box, and the production edges-file writer for the target's |
| +// coordinates. The deployment store is T3's DirStore world. |
| + |
| +const ( |
| + sealIE = "data-ie" |
| + sealUK = "data-uk" |
| + sealIERegion = "eu-west-1" |
| + sealUKRegion = "eu-west-2" |
| + sealPrefix = "instances" |
| + sealVersion = "v42" |
| +) |
| + |
| +type sealWorld struct { |
| + *offWorld |
| + fake *leasetest.VersionedBucket |
| + keyB64 string |
| + priv *ecdh.PrivateKey |
| + ie, uk *offBox |
| + pages []string |
| + bodies []string // each page's body, beside pages |
| +} |
| + |
| +func newSealWorld(t *testing.T) *sealWorld { |
| + t.Helper() |
| + fake := leasetest.NewVersionedBucket() |
| + fake.AddBucket(sealIE) |
| + fake.AddBucket(sealUK) |
| + h, _ := leasetest.NewS3Server(fake) |
| + srv := httptest.NewServer(h) // 127.0.0.1 only |
| + t.Cleanup(srv.Close) |
| + t.Setenv("AWS_ACCESS_KEY_ID", "test-key") |
| + t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret") |
| + t.Setenv("AWS_SESSION_TOKEN", "") |
| + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") |
| + t.Setenv("AWS_CONFIG_FILE", filepath.Join(t.TempDir(), "absent")) |
| + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", filepath.Join(t.TempDir(), "absent")) |
| + t.Setenv("CARLOS_S3_ENDPOINT", srv.URL) |
| + keyB64, _, err := appconfig.GenerateKey() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + priv, err := appconfig.ParsePrivateKey(keyB64, "test key") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sw := &sealWorld{offWorld: newOffWorld(t), fake: fake, keyB64: keyB64, priv: priv} |
| + sw.ie = sw.sourceBox("ie-a") |
| + sw.uk = sw.box("uk-box", "uk", map[string]string{"CARLOS_HIBERNATE_BUCKET": sealUK, "CARLOS_SECRETS_KEY": keyB64}) |
| + var last []byte |
| + if err := edgesFileWrite(context.Background(), sw.s, sw.uk.reg, edgesFile{Box: "uk-box", Placement: "uk", DataBucket: sealUK, DataBucketRegion: sealUKRegion}, "", &last, false); err != nil { |
| + t.Fatal(err) |
| + } |
| + return sw |
| +} |
| + |
| +// sourceBox is a home box armed as armMovesPass arms one: armOffline, then |
| +// the hibernate coordinates, the S3 mirror, the production checkpoint and |
| +// the box's exec backend for the launch environment. |
| +func (sw *sealWorld) sourceBox(name string) *offBox { |
| + b := sw.box(name, "", map[string]string{"CARLOS_HIBERNATE_BUCKET": sealIE, "CARLOS_SECRETS_KEY": sw.keyB64}) |
| + sw.arm(b) |
| + return b |
| +} |
| + |
| +func (sw *sealWorld) arm(b *offBox) { |
| + c := &b.w.cfg |
| + c.OwnBucket, c.OwnBucketRegion, c.OwnPrefix = sealIE, sealIERegion, sealPrefix |
| + c.Mirror = func(ctx context.Context, dst string) (lease.MirrorStore, error) { |
| + return lease.NewS3Mirror(ctx, sealIERegion, dst) |
| + } |
| + // armMovesPass's Checkpoint, as it is wired there. |
| + c.Checkpoint = func(ctx context.Context, dbPath string) error { |
| + host := strings.TrimSuffix(filepath.Base(dbPath), ".db") |
| + rep, err := lease.ReplicateWithLease(ctx, dbPath, sealIE, activator.HibernatePrefix(sealPrefix, host), sealIERegion) |
| + if err != nil { |
| + return err |
| + } |
| + defer rep.Close(ctx) |
| + return rep.SyncAndWait(ctx) |
| + } |
| + c.CarryRequest = func(ctx context.Context, host, bucket, prefix string) error { |
| + sw.fake.Put(bucket, prefix+"/build-state.json", []byte(`{"request":"carried"}`)) |
| + return nil |
| + } |
| + c.LaunchLayers = (&activator.ExecBackend{DataDir: b.dd}).LaunchLayers |
| + c.Page = func(_ context.Context, level, subject, body string) error { |
| + sw.pages = append(sw.pages, level+": "+subject) |
| + sw.bodies = append(sw.bodies, body) |
| + return nil |
| + } |
| +} |
| + |
| +// pass is a tick in the agent's order (reconcile, the edges file, the moves |
| +// pass) whose moves pass is movesPass's offline part: the keep view's |
| +// refresh from the record list, then offlineRound, returning the round's |
| +// error rather than logging it. |
| +func (sw *sealWorld) pass(b *offBox) error { |
| + sw.t.Helper() |
| + b.reconcile() |
| + b.edges() |
| + ctx := context.Background() |
| + refreshOfflineView(ctx, sw.s, b.w.cfg.Offline) |
| + return b.w.offlineRound(ctx, sw.s, sw.record()) |
| +} |
| + |
| +func (sw *sealWorld) mustPass(b *offBox) { |
| + sw.t.Helper() |
| + if err := sw.pass(b); err != nil { |
| + sw.t.Fatal(err) |
| + } |
| +} |
| + |
| +func (sw *sealWorld) dbPath(b *offBox) string { |
| + sw.t.Helper() |
| + rt := b.row(offHost) |
| + if rt == nil { |
| + sw.t.Fatal("no row") |
| + } |
| + return rt.DBPath |
| +} |
| + |
| +// instance gives b a live instance of offHost, as a box that has served it |
| +// has: the row (minted ready by reconcile), its replication rung, next due |
| +// and timers, a database written through lease.ReplicateWithLease into |
| +// data-ie, the files directory, <host>.env with an instance key, the |
| +// rendered configuration and a live build. |
| +func (sw *sealWorld) instance(b *offBox) { |
| + t := sw.t |
| + t.Helper() |
| + b.tick() |
| + rt := b.row(offHost) |
| + if rt == nil || rt.HoldOp != "" { |
| + t.Fatalf("fixture: row %+v", rt) |
| + } |
| + if err := b.reg.SetReplication(offHost, registry.ReplicationBatched); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := b.reg.SetNextDue(offHost, 1_800_000_000); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, tm := range []registry.Timer{ |
| + {Host: offHost, Name: "nightly", Kind: "cron", Spec: "0 3 * * *", Path: "/jobs/nightly", NextAt: 1_800_000_100}, |
| + {Host: offHost, Name: "reminder", Kind: "once", Path: "/jobs/remind", NextAt: 1_800_000_200}, |
| + {Host: offHost, Name: "fired", Kind: "once", Path: "/jobs/fired", LastAt: 1_700_000_000, LastStatus: "200"}, |
| + } { |
| + if err := b.reg.UpsertTimer(tm); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + writeFile(t, filepath.Join(b.dd, offHost, "instance.key"), "app key file") |
| + writeFile(t, filepath.Join(b.dd, offHost, "uploads", "a.png"), "png bytes") |
| + writeFile(t, filepath.Join(b.dd, offHost+".env"), "CARLOS_INSTANCE_KEY=the-instance-key\nCARLOS_ADMIN_TOKEN=token\n") |
| + writeFile(t, filepath.Join(b.dd, offHost+".config.env"), "APP_COLOUR=green\n") |
| + sw.writeRows(b, rt.DBPath, 0, 20, true) |
| + versions := filepath.Join(b.opt, "versions", sealVersion, sw.acct, "app") |
| + if err := os.MkdirAll(versions, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.MkdirAll(filepath.Dir(release.LiveLink(b.opt, offHost)), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(versions, release.LiveLink(b.opt, offHost)); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// writeRows inserts rows [from, to) into the database at dbPath, then, with |
| +// replicate, syncs it to data-ie under the lease as a wake's stop does. |
| +func (sw *sealWorld) writeRows(b *offBox, dbPath string, from, to int, replicate bool) { |
| + t := sw.t |
| + t.Helper() |
| + db, err := sql.Open("sqlite", dbPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, q := range []string{`PRAGMA journal_mode=WAL`, `CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, body TEXT)`} { |
| + if _, err := db.Exec(q); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + for i := from; i < to; i++ { |
| + if _, err := db.Exec(`INSERT INTO t (id, body) VALUES (?, ?)`, i, fmt.Sprintf("row-%d", i)); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := db.Close(); err != nil { |
| + t.Fatal(err) |
| + } |
| + if !replicate { |
| + return |
| + } |
| + ctx := context.Background() |
| + rep, err := lease.ReplicateWithLease(ctx, dbPath, sealIE, activator.HibernatePrefix(sealPrefix, offHost), sealIERegion) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := rep.SyncAndWait(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := rep.Close(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func countRows(t *testing.T, dbPath string) int { |
| + t.Helper() |
| + db, err := sql.Open("sqlite", dbPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer db.Close() |
| + var n int |
| + if err := db.QueryRow(`SELECT count(*) FROM t`).Scan(&n); err != nil { |
| + t.Fatal(err) |
| + } |
| + return n |
| +} |
| + |
| +// start moves offHost to the record's offline-stopping, ticks every source |
| +// box until it holds and reports, and names the sealer and the boxes as the |
| +// seal door does. |
| +func (sw *sealWorld) startAndSeal(sealer string, boxes ...*offBox) { |
| + sw.t.Helper() |
| + sw.start() |
| + names := []string{} |
| + for _, b := range boxes { |
| + b.tick() |
| + names = append(names, b.name) |
| + } |
| + sw.move(provision.OfflineSeal(offID, sealer, names)) |
| +} |
| + |
| +func (sw *sealWorld) mirror() lease.MirrorStore { |
| + sw.t.Helper() |
| + m, err := lease.NewS3Mirror(context.Background(), sealIERegion, sealUKRegion) |
| + if err != nil { |
| + sw.t.Fatal(err) |
| + } |
| + return m |
| +} |
| + |
| +func (sw *sealWorld) list(bucket, prefix string) map[string]lease.MirrorObject { |
| + sw.t.Helper() |
| + objs, err := sw.mirror().List(context.Background(), bucket, prefix) |
| + if err != nil { |
| + sw.t.Fatal(err) |
| + } |
| + return objs |
| +} |
| + |
| +func (sw *sealWorld) lock() (owner string, expires time.Time, generation int64) { |
| + sw.t.Helper() |
| + body, ok := sw.fake.Current(sealIE, sealPrefix+"/"+offHost+"/lock.json") |
| + if !ok { |
| + return "", time.Time{}, 0 |
| + } |
| + var l litestream.Lease |
| + if err := json.Unmarshal(body, &l); err != nil { |
| + sw.t.Fatal(err) |
| + } |
| + return l.Owner, l.ExpiresAt, l.Generation |
| +} |
| + |
| +func (sw *sealWorld) sealRecord() (moves.OfflineReport, []byte, bool) { |
| + sw.t.Helper() |
| + raw, _, err := sw.s.Get(context.Background(), moves.OfflineSealKey(offHost, offID)) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return moves.OfflineReport{}, nil, false |
| + } |
| + if err != nil { |
| + sw.t.Fatal(err) |
| + } |
| + var r moves.OfflineReport |
| + if err := json.Unmarshal(raw, &r); err != nil { |
| + sw.t.Fatal(err) |
| + } |
| + return r, raw, true |
| +} |
| + |
| +// acquireAsAWake is what every wake does first (ExecBackend.Acquire). |
| +func acquireAsAWake(ctx context.Context) (*lease.Held, error) { |
| + return lease.Acquire(ctx, sealIE, activator.HibernatePrefix(sealPrefix, offHost), sealIERegion) |
| +} |
| + |
| +func wantHeldByMove(t *testing.T, err error, what string) { |
| + t.Helper() |
| + var he *lease.HeldError |
| + if !errors.As(err, &he) || he.Owner != moves.TombstoneOwner(offID) { |
| + t.Fatalf("%s: %v, want a HeldError naming %s", what, err, moves.TombstoneOwner(offID)) |
| + } |
| +} |
| + |
| +// openArchive fetches the sealed archive and its manifest from data-uk, |
| +// verifies it and extracts it into a fresh flat files directory and env |
| +// path, returning them. |
| +func (sw *sealWorld) openArchive(seal moves.OfflineReport) (files, env string) { |
| + t := sw.t |
| + t.Helper() |
| + prefix := moves.OfflineArchivePrefix(offHost, offID) |
| + sealed, ok := sw.fake.Current(sealUK, prefix+offlineArchiveSealed) |
| + if !ok { |
| + t.Fatal("no sealed archive") |
| + } |
| + rawManifest, ok := sw.fake.Current(sealUK, prefix+offlineArchiveManifest) |
| + if !ok { |
| + t.Fatal("no archive manifest") |
| + } |
| + var m archive.Manifest |
| + if err := json.Unmarshal(rawManifest, &m); err != nil { |
| + t.Fatal(err) |
| + } |
| + bind := appconfig.Binding{Account: sw.acct, App: "app"} |
| + if err := archive.Verify(bytes.NewReader(sealed), m, sw.priv, bind); err != nil { |
| + t.Fatalf("Verify: %v", err) |
| + } |
| + sum := sha256.Sum256(sealed) |
| + if m.Plain != seal.FilesPlain || m.Sealed != seal.FilesSealed || "sha256:"+hex.EncodeToString(sum[:]) != seal.FilesSealed || m.Bytes != seal.FilesBytes || len(m.Entries) != seal.FilesEntries { |
| + t.Fatalf("manifest %+v against the seal %+v", m, seal) |
| + } |
| + var plain bytes.Buffer |
| + if err := archive.Open(bytes.NewReader(sealed), m.SealedDataKey, sw.priv, bind, &plain); err != nil { |
| + t.Fatalf("Open: %v", err) |
| + } |
| + parent := t.TempDir() |
| + files, env = filepath.Join(parent, offHost), filepath.Join(parent, offHost+".env") |
| + // A *bytes.Reader, not the buffer: zstd decodes a *bytes.Buffer under |
| + // 128 KiB from its Bytes() without draining it, and Extract's |
| + // end-of-stream check then finds the whole archive still unread. |
| + if err := archive.Extract(bytes.NewReader(plain.Bytes()), files, env); err != nil { |
| + t.Fatalf("Extract: %v", err) |
| + } |
| + if got, _, _, err := archive.PlainDigest(files, env); err != nil || got != seal.FilesPlain { |
| + t.Fatalf("the extraction digests to %s (%v), the seal says %s", got, err, seal.FilesPlain) |
| + } |
| + return files, env |
| +} |
| + |
| +func readString(t *testing.T, p string) string { |
| + t.Helper() |
| + b, err := os.ReadFile(p) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return string(b) |
| +} |
| + |
| +// (a) The happy seal, end to end, and the passes after it. |
| +func TestOfflineSealHappyPath(t *testing.T) { |
| + sw := newSealWorld(t) |
| + ie := sw.ie |
| + sw.instance(ie) |
| + // A tail the stop never checkpointed: rows in the local database only. |
| + sw.writeRows(ie, sw.dbPath(ie), 20, 25, false) |
| + sw.startAndSeal("ie-a", ie) |
| + ctx := context.Background() |
| + |
| + sw.mustPass(ie) |
| + rec := sw.record() |
| + if rec.Move.State != provision.OfflineSealed { |
| + t.Fatalf("state %s, want sealed", rec.Move.State) |
| + } |
| + seal, raw, ok := sw.sealRecord() |
| + if !ok { |
| + t.Fatal("no seal record") |
| + } |
| + if err := moves.ValidateOfflineSeal(seal, offHost, offID); err != nil { |
| + t.Fatal(err) |
| + } |
| + src := rec.Move.Source |
| + if src == nil || src.Evidence != moves.OfflineSealEvidence(offHost, offID, raw) || src.FinalTXID != seal.FinalTXID || |
| + src.Files == nil || src.Files.Plain != seal.FilesPlain || src.Files.Sealed != seal.FilesSealed || src.TimersSHA256 != seal.TimersDigest || |
| + src.Release != sealVersion || src.EnvDigest != seal.EnvDigest || src.Replication != registry.ReplicationBatched || src.NextDue != 1_800_000_000 { |
| + t.Fatalf("record source %+v, seal %+v", src, seal) |
| + } |
| + |
| + // The tombstone, and a wake meets it. |
| + owner, expires, _ := sw.lock() |
| + if owner != moves.TombstoneOwner(offID) || time.Until(expires) < moves.TombstoneTTL-time.Minute { |
| + t.Fatalf("lock %s until %s", owner, expires) |
| + } |
| + if h, err := acquireAsAWake(ctx); err == nil { |
| + h.Release(ctx) |
| + t.Fatal("a wake took the lease after the seal") |
| + } else { |
| + wantHeldByMove(t, err, "a wake after the seal") |
| + } |
| + |
| + // The staged copy is the replica, object for object, and its listing is |
| + // the seal's. |
| + srcObjs := sw.list(sealIE, sealPrefix+"/"+offHost+"/") |
| + staged := sw.list(sealUK, activator.MovesPrefix(offHost)) |
| + if len(staged) != len(seal.Staged) || len(staged) != len(srcObjs)-1 { |
| + t.Fatalf("staged %d objects, seal %d, source %d (lock.json included)", len(staged), len(seal.Staged), len(srcObjs)) |
| + } |
| + for k, o := range staged { |
| + rel := strings.TrimPrefix(k, activator.MovesPrefix(offHost)) |
| + if s := seal.Staged[rel]; s.Size != o.Size || s.ETag != o.ETag { |
| + t.Fatalf("%s staged %+v, sealed %+v", rel, o, s) |
| + } |
| + if so := srcObjs[sealPrefix+"/"+offHost+"/"+rel]; so.Size != o.Size { |
| + t.Fatalf("%s staged %+v, source %+v", rel, o, so) |
| + } |
| + } |
| + if _, ok := seal.Staged["build-state.json"]; !ok { |
| + t.Fatal("the carried build state is not staged") |
| + } |
| + if replicaPosition(staged, activator.MovesPrefix(offHost)) != seal.FinalTXID { |
| + t.Fatal("the staged newest TXID is not the seal's") |
| + } |
| + // The staged replica restores to every row, the tail included: the |
| + // checkpoint ran before the tombstone. |
| + restored := filepath.Join(t.TempDir(), "restored.db") |
| + if ok, err := lease.Restore(ctx, restored, sealUK, strings.TrimSuffix(activator.MovesPrefix(offHost), "/"), sealUKRegion); err != nil || !ok { |
| + t.Fatalf("restore the staged copy: %v %v", ok, err) |
| + } |
| + if n := countRows(t, restored); n != 25 { |
| + t.Fatalf("the staged copy holds %d rows, want 25", n) |
| + } |
| + |
| + // The archive opens, verifies and extracts to the same plaintext, with |
| + // the files and the env byte for byte. |
| + files, env := sw.openArchive(seal) |
| + for rel, want := range map[string]string{"instance.key": "app key file", "uploads/a.png": "png bytes"} { |
| + if got := readString(t, filepath.Join(files, rel)); got != want { |
| + t.Fatalf("%s = %q", rel, got) |
| + } |
| + } |
| + envBody := readString(t, filepath.Join(ie.dd, offHost+".env")) |
| + if readString(t, env) != envBody { |
| + t.Fatal("the env file did not travel byte for byte") |
| + } |
| + sum := sha256.Sum256([]byte(envBody)) |
| + if seal.EnvSHA256 != hex.EncodeToString(sum[:]) || seal.KeySHA256 != instancekey.Digest("the-instance-key") { |
| + t.Fatalf("env %s key %s", seal.EnvSHA256, seal.KeySHA256) |
| + } |
| + |
| + // The timers snapshot (active rows only) and the env report. |
| + prefix := moves.OfflineArchivePrefix(offHost, offID) |
| + timersBody, _ := sw.fake.Current(sealUK, prefix+offlineTimersObject) |
| + snap, err := moves.DecodeTimers(timersBody) |
| + if err != nil || len(snap.Rows) != 2 || moves.TimersDigest(snap.Rows) != seal.TimersDigest { |
| + t.Fatalf("timers snapshot %+v %v", snap, err) |
| + } |
| + reportBody, _ := sw.fake.Current(sealUK, prefix+offlineEnvReportObject) |
| + var envReport moves.EnvReport |
| + if err := json.Unmarshal(reportBody, &envReport); err != nil || envReport.Digest != seal.EnvDigest || envReport.Vars["APP_COLOUR"] == "" || strings.Contains(string(reportBody), "green") { |
| + t.Fatalf("env report %s %v", reportBody, err) |
| + } |
| + if seal.Release != sealVersion || seal.Replication != registry.ReplicationBatched || seal.NextDue != 1_800_000_000 { |
| + t.Fatalf("seal %+v", seal) |
| + } |
| + |
| + // The living report: the tombstone and the pass's time. |
| + living := sw.report(moves.OfflineRoleSealer, "ie-a") |
| + if living.Phase != moves.OfflinePhaseSealed || living.TombstoneOwner != moves.TombstoneOwner(offID) || !living.TombstoneUntil.Equal(expires) || !living.At.Equal(sw.clock()) { |
| + t.Fatalf("living report %+v", living) |
| + } |
| + if ok, bad := living.SourceItemsOK(); !ok { |
| + t.Fatalf("living report items %v", bad) |
| + } |
| + |
| + // Later passes re-assert the expiry, keep the tombstone's generation, |
| + // never touch the seal record, and rewrite the living report. |
| + _, _, gen := sw.lock() |
| + for _, state := range []func(){func() {}, sw.flip, sw.landed} { |
| + state() |
| + time.Sleep(5 * time.Millisecond) |
| + sw.advance(time.Second) |
| + if err := ie.w.offlineSealerStep(ctx, sw.s, sw.record()); err != nil { |
| + t.Fatal(err) |
| + } |
| + owner2, expires2, gen2 := sw.lock() |
| + if owner2 != owner || !expires2.After(expires) || gen2 != gen { |
| + t.Fatalf("after a later pass: %s until %s (generation %d), before %s until %s (generation %d)", owner2, expires2, gen2, owner, expires, gen) |
| + } |
| + expires = expires2 |
| + living = sw.report(moves.OfflineRoleSealer, "ie-a") |
| + if !living.TombstoneUntil.Equal(expires2) || !living.At.Equal(sw.clock()) || living.Phase != moves.OfflinePhaseSealed { |
| + t.Fatalf("living report after a later pass: %+v", living) |
| + } |
| + if _, again, _ := sw.sealRecord(); !bytes.Equal(again, raw) { |
| + t.Fatal("the seal record was rewritten") |
| + } |
| + } |
| + if len(sw.pages) != 0 { |
| + t.Fatalf("paged: %v", sw.pages) |
| + } |
| +} |
| + |
| +// (b) A live writer's lease: with a local database the checkpoint cannot |
| +// take it, without one the tombstone cannot; either way the state stays |
| +// sealing and nothing is staged or sealed. A wake that races the tombstone |
| +// meets it. |
| +func TestOfflineSealWaitsForALiveWriter(t *testing.T) { |
| + ctx := context.Background() |
| + for _, local := range []bool{true, false} { |
| + t.Run(fmt.Sprintf("local database %v", local), func(t *testing.T) { |
| + sw := newSealWorld(t) |
| + ie := sw.ie |
| + sw.instance(ie) |
| + if !local { |
| + db := sw.dbPath(ie) |
| + for _, p := range []string{db, db + "-wal", db + "-shm", activatorMetaDir(db)} { |
| + if err := os.RemoveAll(p); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + } |
| + sw.startAndSeal("ie-a", ie) |
| + wake, err := acquireAsAWake(ctx) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + err = sw.pass(ie) |
| + var he *lease.HeldError |
| + if !errors.As(err, &he) || he.Owner == moves.TombstoneOwner(offID) { |
| + t.Fatalf("a live writer: %v", err) |
| + } |
| + if !local && !strings.Contains(err.Error(), "tombstone") { |
| + t.Fatalf("not refused at the tombstone: %v", err) |
| + } |
| + if sw.record().Move.State != provision.OfflineSealing { |
| + t.Fatal("the state moved") |
| + } |
| + if _, _, ok := sw.sealRecord(); ok { |
| + t.Fatal("sealed under a live writer") |
| + } |
| + if n := len(sw.list(sealUK, "moves/")); n != 0 { |
| + t.Fatalf("%d objects staged under a live writer", n) |
| + } |
| + if err := wake.Err(); err != nil { |
| + t.Fatalf("the writer lost its lease: %v", err) |
| + } |
| + if err := wake.Release(ctx); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // The race: a wake that comes for the lease the moment after |
| + // the tombstone is written is refused by it. |
| + raced := false |
| + offlineSealFaults.Store(ie.w, func(point string) error { |
| + if point == "tombstone" { |
| + raced = true |
| + h, err := acquireAsAWake(ctx) |
| + if err == nil { |
| + h.Release(ctx) |
| + return errors.New("a wake took the lease after the tombstone") |
| + } |
| + wantHeldByMove(t, err, "a wake racing the tombstone") |
| + } |
| + return nil |
| + }) |
| + t.Cleanup(func() { offlineSealFaults.Delete(ie.w) }) |
| + sw.mustPass(ie) |
| + if !raced || sw.record().Move.State != provision.OfflineSealed { |
| + t.Fatalf("raced %v, state %s", raced, sw.record().Move.State) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// (c) A source box whose local position is past the replica's final |
| +// position refuses the seal: the db-tail item fails, the report says |
| +// failed, nothing is sealed and the state stays sealing. |
| +func TestOfflineSealRefusesASourceAheadOfTheFinalPosition(t *testing.T) { |
| + sw := newSealWorld(t) |
| + ie := sw.ie |
| + sw.instance(ie) |
| + ieB := sw.sourceBox("ie-b") |
| + ieB.tick() |
| + final := replicaPosition(sw.list(sealIE, sealPrefix+"/"+offHost+"/"), sealPrefix+"/"+offHost+"/") |
| + if final == 0 { |
| + t.Fatal("fixture: no replica") |
| + } |
| + // ie-b woke the host once and kept a position past the replica's. |
| + meta := filepath.Join(activatorMetaDir(provision.DefaultDBPath(ieB.dd, offHost)), "ltx", "0") |
| + writeFile(t, filepath.Join(meta, ltxName(final+1, final+5)), "ltx") |
| + sw.startAndSeal("ie-a", ie, ieB) |
| + if r := sw.report(moves.OfflineRoleSource, "ie-b"); r.LocalTXID != final+5 { |
| + t.Fatalf("fixture: ie-b reports %d", r.LocalTXID) |
| + } |
| + err := sw.pass(ie) |
| + var ref *sealRefusal |
| + if !errors.As(err, &ref) || ref.item != "db-tail" || !strings.Contains(ref.Reason, "ie-b") { |
| + t.Fatalf("pass: %v", err) |
| + } |
| + r := sw.report(moves.OfflineRoleSealer, "ie-a") |
| + if r.Phase != moves.OfflinePhaseFailed || r.Items["db-tail"].OK || !r.Items["sidecar"].OK { |
| + t.Fatalf("report %+v", r) |
| + } |
| + if _, _, ok := sw.sealRecord(); ok || sw.record().Move.State != provision.OfflineSealing { |
| + t.Fatal("sealed past a source ahead of the replica") |
| + } |
| +} |
| + |
| +func ltxName(min, max uint64) string { |
| + return ltx.FormatFilename(ltx.TXID(min), ltx.TXID(max)) |
| +} |
| + |
| +// (d) After the seal the stop is kept, never taken again: a tombstone |
| +// stolen by another move, expired, lifted or deleted is a lost stop. The |
| +// pass reports failed with the lease item down, pages once for the move, |
| +// and leaves the lease exactly as it found it. |
| +func TestOfflineSealLostStopIsNeverRetaken(t *testing.T) { |
| + sw := newSealWorld(t) |
| + ie := sw.ie |
| + sw.instance(ie) |
| + sw.startAndSeal("ie-a", ie) |
| + sw.mustPass(ie) |
| + ctx := context.Background() |
| + key := sealPrefix + "/" + offHost + "/lock.json" |
| + ours, _ := sw.fake.Current(sealIE, key) |
| + lockBody := func(owner string, expires time.Time) []byte { |
| + b, err := json.Marshal(litestream.Lease{Generation: 1, Owner: owner, ExpiresAt: expires}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return b |
| + } |
| + cases := []struct { |
| + name string |
| + damage func() |
| + }{ |
| + {"stolen by another move", func() { |
| + sw.fake.Put(sealIE, key, lockBody(moves.TombstoneOwner(offID2), time.Now().Add(time.Hour))) |
| + }}, |
| + {"stolen by a wake", func() { |
| + sw.fake.Put(sealIE, key, lockBody("box-x", time.Now().Add(time.Hour))) |
| + }}, |
| + {"expired", func() { |
| + sw.fake.Put(sealIE, key, lockBody(moves.TombstoneOwner(offID), time.Now().Add(-time.Second))) |
| + }}, |
| + {"lifted", func() { |
| + if _, err := lease.LiftTombstone(ctx, sealIE, sealPrefix+"/"+offHost, sealIERegion, func(o string) bool { return o == moves.TombstoneOwner(offID) }); err != nil { |
| + t.Fatal(err) |
| + } |
| + }}, |
| + {"deleted", func() { sw.fake.BareDelete(sealIE, key) }}, |
| + } |
| + for _, c := range cases { |
| + // The stop holds: a pass is clean. |
| + sw.fake.Put(sealIE, key, ours) |
| + if err := ie.w.offlineSealerStep(ctx, sw.s, sw.record()); err != nil { |
| + t.Fatalf("%s: before: %v", c.name, err) |
| + } |
| + c.damage() |
| + before := sw.fake.Snapshot() |
| + for i := range 2 { |
| + err := ie.w.offlineSealerStep(ctx, sw.s, sw.record()) |
| + if err == nil || !strings.Contains(err.Error(), "lost stop") { |
| + t.Fatalf("%s, pass %d: %v", c.name, i, err) |
| + } |
| + if sw.fake.Snapshot() != before { |
| + t.Fatalf("%s, pass %d: the lease was written after the stop was lost", c.name, i) |
| + } |
| + r := sw.report(moves.OfflineRoleSealer, "ie-a") |
| + if r.Phase != moves.OfflinePhaseFailed || r.Items["lease"].OK || !strings.Contains(r.Items["lease"].Reason, "lost stop") || r.TombstoneOwner != "" || !r.Items["files"].OK { |
| + t.Fatalf("%s: report %+v", c.name, r) |
| + } |
| + } |
| + } |
| + if len(sw.pages) != 1 || !strings.HasPrefix(sw.pages[0], "incident: ") { |
| + t.Fatalf("pages %v, want one incident", sw.pages) |
| + } |
| + if sw.record().Move.State != provision.OfflineSealed { |
| + t.Fatal("the record moved") |
| + } |
| +} |
| + |
| +// (e) A crash after each step, then the next pass: one tombstone (one |
| +// generation, created once), staged objects copied once and identical, the |
| +// archive the seal names, and a sealed record. |
| +func TestOfflineSealReplaysAfterACrashAtEachStep(t *testing.T) { |
| + sw := newSealWorld(t) |
| + ie := sw.ie |
| + sw.instance(ie) |
| + sw.writeRows(ie, sw.dbPath(ie), 20, 23, false) |
| + sw.startAndSeal("ie-a", ie) |
| + ctx := context.Background() |
| + points := []string{"checkpoint", "tombstone", "final", "mirror", "archive", "timers", "env", "seal", "cas"} |
| + t.Cleanup(func() { offlineSealFaults.Delete(ie.w) }) |
| + var gen int64 |
| + var stagedAfter map[string]lease.MirrorObject |
| + for i, crash := range points { |
| + hit := false |
| + offlineSealFaults.Store(ie.w, func(point string) error { |
| + if point == crash { |
| + hit = true |
| + return errors.New("crash at " + point) |
| + } |
| + return nil |
| + }) |
| + err := sw.pass(ie) |
| + if !hit || err == nil || !strings.Contains(err.Error(), "crash at "+crash) { |
| + t.Fatalf("crash at %s: hit %v, %v", crash, hit, err) |
| + } |
| + if i >= 1 { // the tombstone is down from the crash after it on |
| + owner, _, g := sw.lock() |
| + if owner != moves.TombstoneOwner(offID) || (gen != 0 && g != gen) { |
| + t.Fatalf("after the crash at %s: lock %s generation %d (was %d)", crash, owner, g, gen) |
| + } |
| + gen = g |
| + } |
| + if crash == "mirror" { |
| + stagedAfter = sw.list(sealUK, activator.MovesPrefix(offHost)) |
| + } |
| + wantState := provision.OfflineSealing |
| + if crash == "cas" { |
| + wantState = provision.OfflineSealed |
| + } |
| + if s := sw.record().Move.State; s != wantState { |
| + t.Fatalf("after the crash at %s: state %s", crash, s) |
| + } |
| + } |
| + offlineSealFaults.Delete(ie.w) |
| + sw.mustPass(ie) |
| + if sw.record().Move.State != provision.OfflineSealed { |
| + t.Fatal("not sealed after the replays") |
| + } |
| + if r := sw.report(moves.OfflineRoleSealer, "ie-a"); r.Phase != moves.OfflinePhaseSealed || r.TombstoneOwner != moves.TombstoneOwner(offID) { |
| + t.Fatalf("living report %+v", r) |
| + } |
| + // One tombstone: created once (one version with the move's owner and |
| + // no expiry change before the seal), one generation throughout. |
| + if _, _, g := sw.lock(); g != gen { |
| + t.Fatalf("generation %d, was %d", g, gen) |
| + } |
| + versions, err := sw.fake.ListVersions(ctx, sealIE, sealPrefix+"/"+offHost+"/lock.json") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + created := 0 |
| + for _, v := range versions { |
| + if v.DeleteMarker { |
| + continue |
| + } |
| + body, err := sw.fake.GetVersion(ctx, sealIE, v.Key, v.VersionID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var l litestream.Lease |
| + if err := json.Unmarshal(body, &l); err == nil && l.Owner == moves.TombstoneOwner(offID) && l.Generation != gen { |
| + t.Fatalf("a tombstone of another generation %d", l.Generation) |
| + } |
| + if l.Owner == moves.TombstoneOwner(offID) { |
| + created++ |
| + } |
| + } |
| + // The tombstone is written once before the seal (the replays after it |
| + // find it this move's and write nothing); after the seal the re-asserts |
| + // of the last two passes rewrite it, same generation. |
| + if created != 3 { |
| + t.Fatalf("%d versions of the tombstone", created) |
| + } |
| + // The staged objects: identical to those after the first mirror round, |
| + // and each copied once. |
| + final := sw.list(sealUK, activator.MovesPrefix(offHost)) |
| + if len(final) != len(stagedAfter) { |
| + t.Fatalf("staged %d objects, %d after the first round", len(final), len(stagedAfter)) |
| + } |
| + for k, o := range final { |
| + if stagedAfter[k] != o { |
| + t.Fatalf("%s changed: %+v, was %+v", k, o, stagedAfter[k]) |
| + } |
| + vs, err := sw.fake.ListVersions(ctx, sealUK, k) |
| + if err != nil || len(vs) != 1 { |
| + t.Fatalf("%s has %d versions (%v)", k, len(vs), err) |
| + } |
| + } |
| + seal, _, ok := sw.sealRecord() |
| + if !ok { |
| + t.Fatal("no seal record") |
| + } |
| + sw.openArchive(seal) |
| + if n := len(seal.Staged); n != len(final) { |
| + t.Fatalf("seal names %d staged objects, %d staged", n, len(final)) |
| + } |
| +} |
| + |
| +// (f) The revert: the sealer lifts this move's tombstone, deletes exactly |
| +// the staging lane and the archive prefix in the target's bucket, clears |
| +// the receipts and lifts the fence; a wake can take the lease again. A |
| +// lease that is not this move's is left alone. Every part is an ensure. |
| +func TestOfflineSealRevertLiftsAndUnfences(t *testing.T) { |
| + sw := newSealWorld(t) |
| + ie := sw.ie |
| + sw.instance(ie) |
| + sw.startAndSeal("ie-a", ie) |
| + sw.mustPass(ie) |
| + ctx := context.Background() |
| + // Neighbours the revert must not touch. |
| + sw.fake.Put(sealUK, "moves/other.example.com/0000/x.ltx", []byte("x")) |
| + sw.fake.Put(sealUK, "moves/@offline/other.example.com/"+offID2+"/files.sealed", []byte("x")) |
| + sw.fake.Put(sealUK, sealPrefix+"/"+offHost+"/0000/y.ltx", []byte("y")) |
| + // Copy receipts, as a round over a multipart source leaves them. |
| + sw.fake.Put(sealIE, activator.MoveReceiptsKey(offHost), []byte(`{"v":1}`)) |
| + sw.flip() |
| + sw.abort() |
| + if sw.record().Move.State != provision.OfflineReverting { |
| + t.Fatal("fixture: not reverting") |
| + } |
| + if !ie.svc.Fenced(offHost) { |
| + t.Fatal("fixture: not fenced") |
| + } |
| + // The sealer's own step lifts the fence too (the source step does it |
| + // as well, earlier in the same round). |
| + if err := ie.w.offlineSealerStep(ctx, sw.s, sw.record()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if ie.svc.Fenced(offHost) { |
| + t.Fatal("still fenced after the revert") |
| + } |
| + owner, expires, _ := sw.lock() |
| + if !lease.IsLiftedTombstone(owner, expires) || owner != moves.TombstoneOwner(offID) { |
| + t.Fatalf("lock %s %s, want this move's lifted tombstone", owner, expires) |
| + } |
| + for _, prefix := range []string{activator.MovesPrefix(offHost), moves.OfflineArchivePrefix(offHost, offID)} { |
| + if n := len(sw.list(sealUK, prefix)); n != 0 { |
| + t.Fatalf("%s holds %d objects", prefix, n) |
| + } |
| + } |
| + for _, k := range []string{"moves/other.example.com/0000/x.ltx", "moves/@offline/other.example.com/" + offID2 + "/files.sealed", sealPrefix + "/" + offHost + "/0000/y.ltx"} { |
| + if _, ok := sw.fake.Current(sealUK, k); !ok { |
| + t.Fatalf("the revert deleted %s", k) |
| + } |
| + } |
| + if _, ok := sw.fake.Current(sealIE, activator.MoveReceiptsKey(offHost)); ok { |
| + t.Fatal("the receipts are still there") |
| + } |
| + if r := sw.report(moves.OfflineRoleSealer, "ie-a"); r.Phase != moves.OfflinePhaseReverted { |
| + t.Fatalf("report %+v", r) |
| + } |
| + // Again: nothing changes, and the lifted tombstone is not taken again. |
| + before := sw.fake.Snapshot() |
| + sw.mustPass(ie) |
| + if sw.fake.Snapshot() != before { |
| + t.Fatal("a second revert pass wrote to the buckets") |
| + } |
| + wake, err := acquireAsAWake(ctx) |
| + if err != nil { |
| + t.Fatalf("a wake after the revert: %v", err) |
| + } |
| + // A lease that is not this move's (a live wake now) is left alone. |
| + if err := ie.w.offlineSealerStep(ctx, sw.s, sw.record()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := wake.Err(); err != nil { |
| + t.Fatalf("the revert disturbed a wake's lease: %v", err) |
| + } |
| + wake.Release(ctx) |
| + // Another move's tombstone is never lifted by this one. |
| + other, err := json.Marshal(litestream.Lease{Generation: 9, Owner: moves.TombstoneOwner(offID2), ExpiresAt: time.Now().Add(time.Hour)}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sw.fake.Put(sealIE, sealPrefix+"/"+offHost+"/lock.json", other) |
| + if err := ie.w.offlineSealerStep(ctx, sw.s, sw.record()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if owner, expires, _ := sw.lock(); owner != moves.TombstoneOwner(offID2) || !time.Now().Before(expires) { |
| + t.Fatalf("another move's tombstone was lifted: %s %s", owner, expires) |
| + } |
| +} |
| + |
| +// The revert before anything was sealed (abort while sealing failed on a |
| +// refused item) lifts the tombstone the failed seal took, and a revert |
| +// with no tombstone at all is a clean pass. |
| +func TestOfflineSealRevertBeforeTheSeal(t *testing.T) { |
| + sw := newSealWorld(t) |
| + ie := sw.ie |
| + sw.instance(ie) |
| + writeFile(t, filepath.Join(ie.dd, offHost, "big"), "") |
| + if err := os.Truncate(filepath.Join(ie.dd, offHost, "big"), archive.Cap+1); err != nil { |
| + t.Fatal(err) |
| + } |
| + sw.startAndSeal("ie-a", ie) |
| + if err := sw.pass(ie); err == nil { |
| + t.Fatal("sealed over the cap") |
| + } |
| + if owner, _, _ := sw.lock(); owner != moves.TombstoneOwner(offID) { |
| + t.Fatal("fixture: no tombstone") |
| + } |
| + sw.abort() |
| + sw.mustPass(ie) |
| + if owner, expires, _ := sw.lock(); !lease.IsLiftedTombstone(owner, expires) { |
| + t.Fatalf("lock %s %s after the revert", owner, expires) |
| + } |
| + if n := len(sw.list(sealUK, "moves/")); n != 0 { |
| + t.Fatalf("%d objects left in the staging prefixes", n) |
| + } |
| + sw.mustPass(ie) |
| +} |
| + |
| +// The files item: a file over the cap, a FIFO, and an entry of the host's |
| +// beside its files directory are failed items (the report says which), |
| +// never a partial archive and never a seal; once the file is gone the next |
| +// pass seals. |
| +func TestOfflineSealFailsAFileOverTheCapAndAFIFO(t *testing.T) { |
| + sw := newSealWorld(t) |
| + ie := sw.ie |
| + sw.instance(ie) |
| + sw.startAndSeal("ie-a", ie) |
| + files := filepath.Join(ie.dd, offHost) |
| + big := filepath.Join(files, "big.bin") |
| + writeFile(t, big, "") |
| + if err := os.Truncate(big, archive.Cap+1); err != nil { |
| + t.Fatal(err) |
| + } |
| + fifo := filepath.Join(files, "pipe") |
| + blobs := filepath.Join(ie.dd, offHost+"-blobs") |
| + cases := []struct { |
| + name string |
| + setup func() |
| + want string |
| + }{ |
| + {"over the cap", func() {}, strings.TrimPrefix(archive.ErrOverCap.Error(), "archive: ")}, |
| + {"a FIFO", func() { |
| + if err := os.Remove(big); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := syscall.Mkfifo(fifo, 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + }, strings.TrimPrefix(archive.ErrSpecialFile.Error(), "archive: ") + ": pipe"}, |
| + // The flat layout's rules give <host>-blobs to the host, and the |
| + // archive carries the files directory alone. |
| + {"an entry beside the files directory", func() { |
| + if err := os.Remove(fifo); err != nil { |
| + t.Fatal(err) |
| + } |
| + writeFile(t, filepath.Join(blobs, "b"), "blob") |
| + }, blobs}, |
| + } |
| + for _, c := range cases { |
| + c.setup() |
| + err := sw.pass(ie) |
| + var ref *sealRefusal |
| + if !errors.As(err, &ref) || ref.item != "files" || !strings.Contains(ref.Reason, c.want) { |
| + t.Fatalf("%s: %v", c.name, err) |
| + } |
| + r := sw.report(moves.OfflineRoleSealer, "ie-a") |
| + if r.Phase != moves.OfflinePhaseFailed || r.Items["files"].OK || !r.Items["db"].OK { |
| + t.Fatalf("%s: report %+v", c.name, r) |
| + } |
| + if _, _, ok := sw.sealRecord(); ok || sw.record().Move.State != provision.OfflineSealing { |
| + t.Fatalf("%s: sealed", c.name) |
| + } |
| + if n := len(sw.list(sealUK, moves.OfflineArchivePrefix(offHost, offID))); n != 0 { |
| + t.Fatalf("%s: %d archive objects written", c.name, n) |
| + } |
| + } |
| + if err := os.RemoveAll(blobs); err != nil { |
| + t.Fatal(err) |
| + } |
| + sw.mustPass(ie) |
| + if sw.record().Move.State != provision.OfflineSealed { |
| + t.Fatal("not sealed once the files were fixed") |
| + } |
| + if r := sw.report(moves.OfflineRoleSealer, "ie-a"); r.Phase != moves.OfflinePhaseSealed || !r.Items["files"].OK { |
| + t.Fatalf("report %+v", r) |
| + } |
| +} |
| + |
| +// A nested instance, made nested by the real layout pass, which takes the |
| +// flat files directory <data>/<host> into the instance directory as |
| +// <instance dir>/<host>, where the app finds it beside its database. The |
| +// archive is read through the raw instance directory and carries that |
| +// files directory and the env file, never the database, its litestream |
| +// state, the rendered configuration or any other platform entry; it |
| +// extracts flat with the app's files at the flat files directory's root, |
| +// where the flat app looks. An entry beside the files directory that the |
| +// flat layout cannot carry refuses the files item until it is gone. |
| +func TestOfflineSealArchivesANestedInstanceFlat(t *testing.T) { |
| + sw := newSealWorld(t) |
| + ie := sw.ie |
| + sw.instance(ie) |
| + raw := sw.nest(ie) |
| + // More platform entries, which stay behind: the generation and files |
| + // markers, the adoption record, the seed marker of a seeded database, |
| + // and the launch descriptors (fixed and per generation) as a real |
| + // nested host keeps them. |
| + for _, name := range []string{offHost + ".db.gen", ".files-gen", offHost + instancekey.AdoptionSuffix, offHost + ".db" + activator.SeedMarkerSuffix, |
| + "launch.run.json", "launch.seed.json", "launch.gen.7.json"} { |
| + writeFile(t, filepath.Join(raw, name), "platform") |
| + } |
| + // An entry of the app's beside its files directory, which no flat |
| + // files directory can hold. |
| + stray := filepath.Join(raw, offHost+"-blobs", "b") |
| + writeFile(t, stray, "blob") |
| + sw.startAndSeal("ie-a", ie) |
| + err := sw.pass(ie) |
| + var ref *sealRefusal |
| + if !errors.As(err, &ref) || ref.item != "files" || !strings.Contains(ref.Reason, offHost+"-blobs") { |
| + t.Fatalf("a stray entry: %v", err) |
| + } |
| + // The refusal says the move cannot carry it, and offers no fix that |
| + // moves or removes the app's data (review I3). |
| + if !strings.Contains(ref.Reason, "this move can carry only the files directory. see the offline move runbook") || strings.Contains(ref.Reason, "move it") || strings.Contains(ref.Reason, "remove") { |
| + t.Fatalf("the refusal: %q", ref.Reason) |
| + } |
| + for _, name := range []string{"launch.run.json", "launch.gen.7.json", ".db.seed"} { |
| + if strings.Contains(ref.Reason, name) { |
| + t.Fatalf("the refusal names the platform's %s: %q", name, ref.Reason) |
| + } |
| + } |
| + if r := sw.report(moves.OfflineRoleSealer, "ie-a"); r.Phase != moves.OfflinePhaseFailed || r.Items["files"].OK { |
| + t.Fatalf("report %+v", r) |
| + } |
| + if err := os.RemoveAll(filepath.Dir(stray)); err != nil { |
| + t.Fatal(err) |
| + } |
| + sw.mustPass(ie) |
| + if sw.record().Move.State != provision.OfflineSealed { |
| + t.Fatal("not sealed") |
| + } |
| + seal, _, _ := sw.sealRecord() |
| + files, env := sw.openArchive(seal) |
| + got := map[string]bool{} |
| + if err := filepath.Walk(files, func(p string, info os.FileInfo, err error) error { |
| + if err != nil { |
| + return err |
| + } |
| + if !info.IsDir() { |
| + rel, _ := filepath.Rel(files, p) |
| + got[rel] = true |
| + } |
| + return nil |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(got) != 2 || !got["instance.key"] || !got[filepath.Join("uploads", "a.png")] { |
| + t.Fatalf("the archive carried %v, want the app's two files", got) |
| + } |
| + if readString(t, env) != readString(t, filepath.Join(raw, offHost+".env")) { |
| + t.Fatal("the env file did not travel byte for byte") |
| + } |
| + if seal.KeySHA256 != instancekey.Digest("the-instance-key") { |
| + t.Fatal("the key digest") |
| + } |
| +} |
| + |
| +// nest makes b's instance of offHost nested with the real layout pass, |
| +// which takes the flat files directory <data>/<host> into the instance |
| +// directory as <instance dir>/<host>, and returns the raw instance |
| +// directory. |
| +func (sw *sealWorld) nest(ie *offBox) string { |
| + t := sw.t |
| + t.Helper() |
| + ctx := context.Background() |
| + // The bind mount at <dd>/instances, as a link in the fixture. |
| + if err := os.MkdirAll(filepath.Join(ie.dd, ".instances"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Symlink(".instances", filepath.Join(ie.dd, "instances")); err != nil { |
| + t.Fatal(err) |
| + } |
| + // The layout pass works on the row's own paths; the sockets live in a |
| + // short directory of the test's, as the pass's tests keep them. |
| + sd, err := os.MkdirTemp("", "ly") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { os.RemoveAll(sd) }) |
| + rt := ie.row(offHost) |
| + if err := ie.reg.SetInstancePaths(offHost, rt.DBPath, rt.Socket, rt.DBPath, filepath.Join(sd, offHost+".sock")); err != nil { |
| + t.Fatal(err) |
| + } |
| + pass := &layout.Pass{Registry: ie.reg, DataDir: ie.dd, SocketDir: sd, SocketsGID: -1, Now: time.Now, SweptAtStart: true, Logf: t.Logf} |
| + res := pass.Converge(ctx, layout.ModeNested) |
| + migrated := false |
| + for _, r := range res { |
| + if r.Host == offHost && r.Outcome == "migrated" { |
| + migrated = true |
| + } |
| + } |
| + if !migrated { |
| + t.Fatalf("the layout pass: %+v", res) |
| + } |
| + ie.nested = true |
| + raw := layout.RawInstanceDir(ie.dd, offHost) |
| + if rt := ie.row(offHost); !provision.NestedPaths(ie.dd, *rt) { |
| + t.Fatalf("fixture: row %+v is not nested", rt) |
| + } |
| + if lexists(filepath.Join(ie.dd, offHost)) { |
| + t.Fatal("fixture: the layout pass left the flat files directory") |
| + } |
| + for _, name := range []string{offHost + ".db", offHost + ".env", offHost + ".config.env", ".activator-" + offHost + ".db-litestream", filepath.Join(offHost, "instance.key")} { |
| + if !lexists(filepath.Join(raw, name)) { |
| + t.Fatalf("fixture: the layout pass did not move %s into the instance directory", name) |
| + } |
| + } |
| + return raw |
| +} |
| + |
| +// Finishing lifts the tombstone only once every box the seal named reports |
| +// gone: until then each pass keeps the stop. After the lift a wake takes |
| +// the lease, the report says lifted, and a later pass changes nothing. |
| +func TestOfflineSealFinishingLiftsOnlyAfterEverySourceIsGone(t *testing.T) { |
| + sw := newSealWorld(t) |
| + ie := sw.ie |
| + sw.instance(ie) |
| + ieB := sw.sourceBox("ie-b") |
| + sw.startAndSeal("ie-a", ie, ieB) |
| + sw.mustPass(ie) |
| + ieB.tick() |
| + if sw.record().Move.State != provision.OfflineSealed { |
| + t.Fatal("not sealed") |
| + } |
| + sw.flip() |
| + sw.landed() |
| + sw.open() |
| + sw.mustPass(ie) |
| + sw.mustPass(ieB) |
| + for _, b := range []string{"ie-a", "ie-b"} { |
| + if r := sw.report(moves.OfflineRoleSource, b); r.Phase != moves.OfflinePhaseParked { |
| + t.Fatalf("%s: %+v", b, r) |
| + } |
| + } |
| + sw.finishing() |
| + ctx := context.Background() |
| + // ie-a goes; ie-b has not run a pass since. |
| + for range 3 { |
| + sw.mustPass(ie) |
| + } |
| + if r := sw.report(moves.OfflineRoleSource, "ie-a"); r.Phase != moves.OfflinePhaseGone { |
| + t.Fatalf("ie-a: %+v", r) |
| + } |
| + owner, _, _ := sw.lock() |
| + if owner != moves.TombstoneOwner(offID) { |
| + t.Fatal("fixture: no tombstone") |
| + } |
| + if _, err := acquireAsAWake(ctx); err == nil { |
| + t.Fatal("the tombstone was lifted before ie-b was gone") |
| + } |
| + if r := sw.report(moves.OfflineRoleSealer, "ie-a"); r.Phase != moves.OfflinePhaseSealed || r.TombstoneOwner != moves.TombstoneOwner(offID) { |
| + t.Fatalf("sealer report while waiting: %+v", r) |
| + } |
| + for range 3 { |
| + sw.mustPass(ieB) |
| + } |
| + if r := sw.report(moves.OfflineRoleSource, "ie-b"); r.Phase != moves.OfflinePhaseGone { |
| + t.Fatalf("ie-b: %+v", r) |
| + } |
| + sw.mustPass(ie) |
| + if r := sw.report(moves.OfflineRoleSealer, "ie-a"); r.Phase != moves.OfflinePhaseLifted { |
| + t.Fatalf("sealer report: %+v", r) |
| + } |
| + owner, expires, _ := sw.lock() |
| + if !lease.IsLiftedTombstone(owner, expires) || owner != moves.TombstoneOwner(offID) { |
| + t.Fatalf("lock %s %s", owner, expires) |
| + } |
| + before := sw.fake.Snapshot() |
| + sw.mustPass(ie) |
| + if sw.fake.Snapshot() != before { |
| + t.Fatal("a pass after the lift wrote to the buckets") |
| + } |
| + wake, err := acquireAsAWake(ctx) |
| + if err != nil { |
| + t.Fatalf("a wake after the lift: %v", err) |
| + } |
| + wake.Release(ctx) |
| + if len(sw.pages) != 0 { |
| + t.Fatalf("paged: %v", sw.pages) |
| + } |
| +} |
| + |
| +// A host that never kept a files directory (seen on real nested hosts: the |
| +// instance directory holds the database, the env files and the launch |
| +// descriptors, and no <host>) seals with an empty files item: the archive |
| +// carries the env file alone, FilesPlain is archive.PlainDigest of no files |
| +// directory and that env file (FilesEntries 1), and it extracts to an |
| +// empty files directory beside the env file. Nested and flat alike. |
| +func TestOfflineSealAHostWithNoFilesDirectory(t *testing.T) { |
| + for _, nested := range []bool{true, false} { |
| + t.Run(map[bool]string{true: "nested", false: "flat"}[nested], func(t *testing.T) { |
| + sw := newSealWorld(t) |
| + ie := sw.ie |
| + sw.instance(ie) |
| + files := filepath.Join(ie.dd, offHost) |
| + envPath := filepath.Join(ie.dd, offHost+".env") |
| + if nested { |
| + raw := sw.nest(ie) |
| + files, envPath = filepath.Join(raw, offHost), filepath.Join(raw, offHost+".env") |
| + writeFile(t, filepath.Join(raw, "launch.run.json"), "{}") |
| + } |
| + if err := os.RemoveAll(files); err != nil { |
| + t.Fatal(err) |
| + } |
| + sw.startAndSeal("ie-a", ie) |
| + sw.mustPass(ie) |
| + if sw.record().Move.State != provision.OfflineSealed { |
| + t.Fatal("not sealed") |
| + } |
| + seal, _, _ := sw.sealRecord() |
| + want, wantBytes, wantEntries, err := archive.PlainDigest(files, envPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if seal.FilesPlain != want || seal.FilesBytes != wantBytes || seal.FilesEntries != wantEntries || wantEntries != 1 { |
| + t.Fatalf("seal files %s %d bytes %d entries, want %s %d bytes 1 entry (env only)", seal.FilesPlain, seal.FilesBytes, seal.FilesEntries, want, wantBytes) |
| + } |
| + if r := sw.report(moves.OfflineRoleSealer, "ie-a"); r.Phase != moves.OfflinePhaseSealed || !r.Items["files"].OK { |
| + t.Fatalf("report %+v", r) |
| + } |
| + got, env := sw.openArchive(seal) |
| + ents, err := os.ReadDir(got) |
| + if err != nil || len(ents) != 0 { |
| + t.Fatalf("the landing's files directory: %v entries, %v", len(ents), err) |
| + } |
| + if readString(t, env) != readString(t, envPath) { |
| + t.Fatal("the env file did not travel byte for byte") |
| + } |
| + if lexists(files) { |
| + t.Fatal("the seal made a files directory on the source") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// The tombstone goes between the sealer's LockState and Reassert's own |
| +// read: Reassert reports it lost (lease.ErrTombstoneLost) and writes |
| +// nothing, so the sealer pages a lost stop once and never takes it again. |
| +func TestOfflineSealLostBetweenTheReadAndTheReassert(t *testing.T) { |
| + sw := newSealWorld(t) |
| + ie := sw.ie |
| + sw.instance(ie) |
| + sw.startAndSeal("ie-a", ie) |
| + sw.mustPass(ie) |
| + ctx := context.Background() |
| + key := sealPrefix + "/" + offHost + "/lock.json" |
| + var after string |
| + t.Cleanup(func() { offlineSealFaults.Delete(ie.w) }) |
| + offlineSealFaults.Store(ie.w, func(point string) error { |
| + if point == "reassert" { |
| + sw.fake.BareDelete(sealIE, key) |
| + after = sw.fake.Snapshot() |
| + } |
| + return nil |
| + }) |
| + err := ie.w.offlineSealerStep(ctx, sw.s, sw.record()) |
| + if err == nil || !strings.Contains(err.Error(), "lost stop") { |
| + t.Fatalf("pass: %v", err) |
| + } |
| + if after == "" || sw.fake.Snapshot() != after { |
| + t.Fatal("the bucket was written after the tombstone went") |
| + } |
| + if _, ok := sw.fake.Current(sealIE, key); ok { |
| + t.Fatal("the tombstone was taken again") |
| + } |
| + r := sw.report(moves.OfflineRoleSealer, "ie-a") |
| + if r.Phase != moves.OfflinePhaseFailed || r.Items["lease"].OK || r.Items["lease"].Reason != "lost stop: "+offlineLeaseCause(tombState{}) { |
| + t.Fatalf("report %+v", r) |
| + } |
| + // The next pass finds it gone at LockState: still nothing written, |
| + // still one page. |
| + offlineSealFaults.Delete(ie.w) |
| + if err := ie.w.offlineSealerStep(ctx, sw.s, sw.record()); err == nil || !strings.Contains(err.Error(), "lost stop") { |
| + t.Fatalf("second pass: %v", err) |
| + } |
| + if sw.fake.Snapshot() != after { |
| + t.Fatal("the second pass wrote to the bucket") |
| + } |
| + if len(sw.pages) != 1 { |
| + t.Fatalf("pages %v, want one", sw.pages) |
| + } |
| +} |
| + |
| +// The lost stop's page says abort only while abort can still put the |
| +// instance back. From open on, abort is refused, so it points to the |
| +// status and the runbook instead. Each state in its own world: the page |
| +// goes out once per move. |
| +func TestOfflineSealLostStopPageByState(t *testing.T) { |
| + for _, state := range []string{provision.OfflineSealed, provision.OfflineOpen, provision.OfflineFinishing} { |
| + t.Run(state, func(t *testing.T) { |
| + sw := newSealWorld(t) |
| + ie := sw.ie |
| + sw.instance(ie) |
| + sw.startAndSeal("ie-a", ie) |
| + sw.mustPass(ie) |
| + ctx := context.Background() |
| + if state != provision.OfflineSealed { |
| + sw.flip() |
| + sw.landed() |
| + sw.open() |
| + } |
| + if state == provision.OfflineFinishing { |
| + sw.finishing() |
| + } |
| + if err := ie.w.offlineSealerStep(ctx, sw.s, sw.record()); err != nil { |
| + t.Fatalf("the stop held: %v", err) |
| + } |
| + if got := sw.record().Move.State; got != state { |
| + t.Fatalf("fixture: state %s", got) |
| + } |
| + sw.fake.BareDelete(sealIE, sealPrefix+"/"+offHost+"/lock.json") |
| + if err := ie.w.offlineSealerStep(ctx, sw.s, sw.record()); err == nil || !strings.Contains(err.Error(), "lost stop") { |
| + t.Fatalf("lost: %v", err) |
| + } |
| + if len(sw.pages) != 1 || sw.pages[0] != "incident: offline move of "+offHost+" lost its stop" { |
| + t.Fatalf("pages %v", sw.pages) |
| + } |
| + body := sw.bodies[0] |
| + if !strings.HasPrefix(body, "The offline move "+offID+" of "+offHost+" is "+state+", and its stop no longer holds: the source's lease is absent, not this move's tombstone. ") { |
| + t.Fatalf("body: %s", body) |
| + } |
| + abort := strings.HasSuffix(body, "The move cannot go on. Read carlos move offline status --host "+offHost+" and abort the move.") |
| + runbook := strings.HasSuffix(body, "It cannot be aborted once it is open. Read carlos move offline status --host "+offHost+" and follow the offline move runbook.") |
| + if wantAbort := state == provision.OfflineSealed; abort != wantAbort || runbook == wantAbort { |
| + t.Fatalf("body in %s: %s", state, body) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// The backstop behind the holds and the fence (review M2): a source report |
| +// written since the seal at a position past the sealed one is a lost stop |
| +// in the db-tail item, even with the tombstone held. Nothing is written to |
| +// the lease, and the page names the box. |
| +func TestOfflineSealLostStopOnASourcePastTheSealedPosition(t *testing.T) { |
| + sw := newSealWorld(t) |
| + ie := sw.ie |
| + sw.instance(ie) |
| + ieB := sw.sourceBox("ie-b") |
| + sw.startAndSeal("ie-a", ie, ieB) |
| + sw.mustPass(ie) |
| + if sw.record().Move.State != provision.OfflineSealed { |
| + t.Fatal("not sealed") |
| + } |
| + seal, _, _ := sw.sealRecord() |
| + ctx := context.Background() |
| + // A pass with every report at or below the seal keeps the stop. |
| + ieB.tick() |
| + if err := ie.w.offlineSealerStep(ctx, sw.s, sw.record()); err != nil { |
| + t.Fatalf("at the seal: %v", err) |
| + } |
| + // ie-b writes past the sealed position, and reports it. |
| + meta := filepath.Join(activatorMetaDir(provision.DefaultDBPath(ieB.dd, offHost)), "ltx", "0") |
| + writeFile(t, filepath.Join(meta, ltxName(seal.FinalTXID+1, seal.FinalTXID+3)), "ltx") |
| + ieB.tick() |
| + if r := sw.report(moves.OfflineRoleSource, "ie-b"); r.LocalTXID != seal.FinalTXID+3 || r.At.Before(seal.At) { |
| + t.Fatalf("fixture: ie-b reports %d at %s", r.LocalTXID, r.At) |
| + } |
| + before := sw.fake.Snapshot() |
| + err := ie.w.offlineSealerStep(ctx, sw.s, sw.record()) |
| + if err == nil || !strings.Contains(err.Error(), "lost stop") { |
| + t.Fatalf("past the seal: %v", err) |
| + } |
| + if sw.fake.Snapshot() != before { |
| + t.Fatal("the lease was written after the stop was lost") |
| + } |
| + cause := offlinePastSealCause("ie-b", seal.FinalTXID+3, seal.FinalTXID) |
| + r := sw.report(moves.OfflineRoleSealer, "ie-a") |
| + if r.Phase != moves.OfflinePhaseFailed || r.Items["db-tail"].OK || r.Items["db-tail"].Reason != "lost stop: "+cause || r.TombstoneOwner != "" || !r.Items["lease"].OK { |
| + t.Fatalf("report %+v", r) |
| + } |
| + if len(sw.bodies) != 1 || !strings.Contains(sw.bodies[0], "its stop no longer holds: "+cause+".") { |
| + t.Fatalf("pages %v %v", sw.pages, sw.bodies) |
| + } |
| + if owner, _, _ := sw.lock(); owner != moves.TombstoneOwner(offID) { |
| + t.Fatalf("the tombstone is %s", owner) |
| + } |
| +} |
diff --git a/internal/layout/plan.go b/internal/layout/plan.go| index ed1b17194..2fc6a9120 100644 |
| --- a/internal/layout/plan.go |
| +++ b/internal/layout/plan.go |
| @@ -10,8 +10,13 @@ package layout |
| import ( |
| "fmt" |
| "path/filepath" |
| + "strconv" |
| + "strings" |
| + "github.com/carlosframework/platform/internal/activator" |
| "github.com/carlosframework/platform/internal/controlname" |
| + "github.com/carlosframework/platform/internal/instancectl" |
| + "github.com/carlosframework/platform/internal/instancekey" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -154,6 +159,78 @@ func nestedFiles(dataDir, socketDir, host string) []string { |
| } |
| } |
| +// filesMarkerName is the files directory's generation marker |
| +// (archive.MarkerName, hostfiles' marker), named here rather than imported: |
| +// the archive package depends on hostfiles and appconfig, which this |
| +// package has no other reason to. |
| +const filesMarkerName = ".files-gen" |
| + |
| +// NestedPlatformNames is every name in a nested host's instance directory |
| +// that is the platform's, not the app's: the names nestedFiles puts in the |
| +// raw instance directory (the database, its WAL, SHM and quarantine, the |
| +// own and sidecar env files, the rendered configuration, the activator's |
| +// litestream shadow; the socket is in the run directory and is not here), |
| +// the database's generation marker (<db>.gen), litestream's stock meta |
| +// directory (<db>-litestream), the files generation marker (.files-gen) and |
| +// the instance-key adoption record, the seed marker a seeded database |
| +// keeps beside it for its life (<db>.seed, activator.SeedMarkerSuffix, |
| +// written by ExecBackend.seededRestore in activator/seedplace.go), and the |
| +// unit launch descriptors written into the instance directory: |
| +// launch.<role>.json for every instancectl role |
| +// (instancectl.LaunchFileName; written by ExecBackend.writeLaunchFor in |
| +// activator/prepare.go, and launch.run.json also by root's role launch in |
| +// activator/rolelaunch_root_linux.go). A |
| +// generation's descriptor, launch.gen.<n>.json (instancectl. |
| +// GenLaunchFileName, written by writeLaunchFor), has no fixed name: |
| +// IsNestedPlatformName answers for it. Everything else in the directory is |
| +// the app's: its files directory <host> and anything it keeps beside it. |
| +// |
| +// The offline move's sealer uses these to find what the app keeps beside |
| +// its files directory, which the move cannot carry. It archives the files |
| +// directory alone (archive.Build over <instance dir>/<host>), so none of |
| +// these is ever carried; the source's retire parks every entry of the |
| +// instance directory, these included. |
| +func NestedPlatformNames(host string) map[string]bool { |
| + const dataDir, socketDir = "/data", "/run/carlos" |
| + raw := rawInstanceDir(dataDir, host) |
| + out := map[string]bool{} |
| + for _, p := range nestedFiles(dataDir, socketDir, host) { |
| + if filepath.Dir(p) == raw { |
| + out[filepath.Base(p)] = true |
| + } |
| + } |
| + db := host + ".db" |
| + out[db+".gen"] = true |
| + out[db+"-litestream"] = true |
| + out[db+activator.SeedMarkerSuffix] = true |
| + out[filesMarkerName] = true |
| + out[host+instancekey.AdoptionSuffix] = true |
| + for _, role := range []instancectl.Role{instancectl.RoleRun, instancectl.RoleSeed, instancectl.RoleSidecar, instancectl.RoleCmd} { |
| + out[instancectl.LaunchFileName(role)] = true |
| + } |
| + return out |
| +} |
| + |
| +// IsNestedPlatformName reports whether name, an entry of host's instance |
| +// directory, is the platform's: one of NestedPlatformNames, or a |
| +// generation's launch descriptor, exactly launch.gen.<n>.json with n a |
| +// decimal number as instancectl.GenLaunchFileName writes it. |
| +func IsNestedPlatformName(host, name string) bool { |
| + if NestedPlatformNames(host)[name] { |
| + return true |
| + } |
| + n, ok := strings.CutPrefix(name, "launch.gen.") |
| + if !ok { |
| + return false |
| + } |
| + n, ok = strings.CutSuffix(n, ".json") |
| + if !ok || n == "" { |
| + return false |
| + } |
| + gen, err := strconv.ParseUint(n, 10, 64) |
| + return err == nil && instancectl.GenLaunchFileName(gen) == name |
| +} |
| + |
| // PlanFor derives the plan for host in the given direction from the host |
| // name alone (design §8.2 step 3). The endpoints are the row values the |
| // pass will compare against and swap; the artifacts are the files (and one |
diff --git a/internal/layout/plan_test.go b/internal/layout/plan_test.go| index 27381c460..eaebf3593 100644 |
| --- a/internal/layout/plan_test.go |
| +++ b/internal/layout/plan_test.go |
| @@ -150,6 +150,69 @@ func TestRawRunDirIsTheDottedRawSocketTree(t *testing.T) { |
| } |
| } |
| +// NestedPlatformNames is exactly the plan's raw-directory names (every |
| +// nested artifact the pass moves into the instance directory, the socket |
| +// aside) plus the generation markers, litestream's stock meta directory and |
| +// the adoption record; and it covers every name a nested plan's artifacts |
| +// land at in the raw directory, so no platform file of the layout pass can |
| +// reach an app archive. |
| +func TestNestedPlatformNamesAreThePlansRawNames(t *testing.T) { |
| + const h = "h.example.com" |
| + got := NestedPlatformNames(h) |
| + want := map[string]bool{ |
| + h + ".db": true, h + ".db-wal": true, h + ".db-shm": true, h + ".db.corrupt": true, |
| + h + ".env": true, h + "-sidecar.env": true, h + ".config.env": true, |
| + ".activator-" + h + ".db-litestream": true, ".files-gen": true, |
| + h + ".db.gen": true, h + ".db-litestream": true, h + ".instance-key-adoption.json": true, |
| + h + ".db.seed": true, |
| + "launch.run.json": true, "launch.seed.json": true, "launch.sidecar.json": true, "launch.cmd.json": true, |
| + } |
| + if len(got) != len(want) { |
| + t.Fatalf("NestedPlatformNames = %v, want %v", got, want) |
| + } |
| + for n := range want { |
| + if !got[n] { |
| + t.Errorf("%s missing", n) |
| + } |
| + } |
| + p, err := PlanFor("/data", "/run/carlos", h, ModeNested) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + raw := rawInstanceDir("/data", h) |
| + for _, a := range p.Artifacts { |
| + if filepath.Dir(a.Dst) == raw && !got[filepath.Base(a.Dst)] { |
| + t.Errorf("artifact %s lands at %s, which NestedPlatformNames does not name", a.Name, a.Dst) |
| + } |
| + if a.Name == "socket" && got[filepath.Base(a.Dst)] { |
| + t.Errorf("the socket is in the run directory, not the instance directory") |
| + } |
| + } |
| +} |
| + |
| +// IsNestedPlatformName is NestedPlatformNames plus a generation's launch |
| +// descriptor exactly as instancectl.GenLaunchFileName writes it; anything |
| +// shaped almost like one is the app's. |
| +func TestIsNestedPlatformNameMatchesGenerationLaunchFilesExactly(t *testing.T) { |
| + const h = "h.example.com" |
| + for name := range NestedPlatformNames(h) { |
| + if !IsNestedPlatformName(h, name) { |
| + t.Errorf("%s: not a platform name", name) |
| + } |
| + } |
| + for _, name := range []string{"launch.gen.0.json", "launch.gen.7.json", "launch.gen.18446744073709551615.json"} { |
| + if !IsNestedPlatformName(h, name) { |
| + t.Errorf("%s: not a platform name", name) |
| + } |
| + } |
| + for _, name := range []string{h, "launch.gen.json", "launch.gen..json", "launch.gen.07.json", "launch.gen.-1.json", "launch.gen.7.json.bak", |
| + "launch.gen.7", "xlaunch.gen.7.json", "launch.gen.x.json", "launch.gen.18446744073709551616.json", "launch.other.json", h + "-blobs", "uploads"} { |
| + if IsNestedPlatformName(h, name) { |
| + t.Errorf("%s: taken for a platform name", name) |
| + } |
| + } |
| +} |
| + |
| func TestPlanForRefusesBadInput(t *testing.T) { |
| if _, err := PlanFor("/data", "/run/carlos", "h.example.com", Mode("sideways")); err == nil { |
| t.Error("unknown mode accepted") |
diff --git a/internal/lease/leasetest/s3server.go b/internal/lease/leasetest/s3server.go| index 051b9f391..ee7819d3b 100644 |
| --- a/internal/lease/leasetest/s3server.go |
| +++ b/internal/lease/leasetest/s3server.go |
| @@ -2,6 +2,7 @@ package leasetest |
| import ( |
| "crypto/sha256" |
| + "encoding/base64" |
| "encoding/hex" |
| "encoding/xml" |
| "errors" |
| @@ -9,6 +10,7 @@ import ( |
| "io" |
| "net/http" |
| "net/url" |
| + "sort" |
| "strconv" |
| "strings" |
| "sync/atomic" |
| @@ -105,7 +107,7 @@ func writeErr(w http.ResponseWriter, err error) { |
| } |
| // NewS3Server speaks the subset of the S3 wire protocol VersionedStore uses, |
| -// path-style, over f, holding f.mu for each request. The second result |
| +// and ListObjectsV2 (serveListV2), path-style, over f, holding f.mu for each request. The second result |
| // counts requests served. With serves non-empty, the endpoint answers only |
| // for those request buckets (a regional endpoint); any other bucket gets |
| // 301 PermanentRedirect, as S3 answers a wrong-region request. A copy's |
| @@ -145,6 +147,8 @@ func NewS3Server(f *VersionedBucket, serves ...string) (http.Handler, func() int |
| switch { |
| case r.Method == http.MethodGet && key == "" && q.Has("versions"): |
| f.serveList(w, bucket, q) |
| + case r.Method == http.MethodGet && key == "" && q.Get("list-type") == "2": |
| + f.serveListV2(w, bucket, q) |
| case r.Method == http.MethodGet && key != "": |
| versionID := q.Get("versionId") |
| if versionID == "" { // no version named: the current one |
| @@ -289,6 +293,127 @@ func (f *VersionedBucket) serveList(w http.ResponseWriter, bucket string, q url. |
| xml.NewEncoder(w).Encode(res) |
| } |
| +type xmlObject struct { |
| + Key string |
| + LastModified string |
| + ETag string |
| + Size int64 |
| + StorageClass string |
| +} |
| + |
| +type xmlCommonPrefix struct { |
| + Prefix string |
| +} |
| + |
| +type listBucketResult struct { |
| + XMLName xml.Name `xml:"ListBucketResult"` |
| + Name string |
| + Prefix string |
| + Delimiter string `xml:",omitempty"` |
| + MaxKeys int |
| + KeyCount int |
| + IsTruncated bool |
| + ContinuationToken string `xml:",omitempty"` |
| + NextContinuationToken string `xml:",omitempty"` |
| + StartAfter string `xml:",omitempty"` |
| + Contents []xmlObject |
| + CommonPrefixes []xmlCommonPrefix |
| +} |
| + |
| +// serveListV2 is ListObjectsV2, the subset litestream's replica client |
| +// (its restore lists with a delimiter) and lease.NewS3Mirror page through: |
| +// the current objects under prefix in key order (an object whose newest |
| +// version is a delete marker is not listed), a delimiter rolling every key |
| +// that holds it after the prefix into one common prefix, max-keys (at most |
| +// 1000, and at most PageSize when that is set lower; keys and common |
| +// prefixes both count) and continuation-token pagination. The token is |
| +// opaque to the client; here it is the last key or common prefix of the |
| +// previous page, and a page resumes after it (after every key under it, |
| +// for a common prefix). start-after is honoured on the first page. |
| +func (f *VersionedBucket) serveListV2(w http.ResponseWriter, bucket string, q url.Values) { |
| + prefix, delim := q.Get("prefix"), q.Get("delimiter") |
| + if err := f.fail("list", prefix); err != nil { |
| + writeErr(w, err) |
| + return |
| + } |
| + size := 1000 |
| + if f.PageSize > 0 && f.PageSize < size { |
| + size = f.PageSize |
| + } |
| + if raw := q.Get("max-keys"); raw != "" { |
| + n, err := strconv.Atoi(raw) |
| + if err != nil || n < 0 { |
| + s3err(w, http.StatusBadRequest, "InvalidArgument") |
| + return |
| + } |
| + size = min(size, n) |
| + } |
| + after := q.Get("start-after") |
| + token := q.Get("continuation-token") |
| + if token != "" { |
| + last, err := base64.StdEncoding.DecodeString(token) |
| + if err != nil || len(last) == 0 { |
| + s3errMsg(w, http.StatusBadRequest, "InvalidArgument", "The continuation token provided is incorrect") |
| + return |
| + } |
| + after = string(last) |
| + } |
| + // entries are the page candidates in key order: a key, or a common |
| + // prefix (isPrefix) standing for every key under it. |
| + type entry struct { |
| + name string |
| + isPrefix bool |
| + } |
| + var keys []string |
| + for k, vs := range f.buckets[bucket] { |
| + if strings.HasPrefix(k, prefix) && len(vs) > 0 && !vs[0].deleteMarker { |
| + keys = append(keys, k) |
| + } |
| + } |
| + sort.Strings(keys) |
| + var entries []entry |
| + for _, k := range keys { |
| + e := entry{name: k} |
| + if delim != "" { |
| + if i := strings.Index(k[len(prefix):], delim); i >= 0 { |
| + e = entry{name: k[:len(prefix)+i+len(delim)], isPrefix: true} |
| + } |
| + } |
| + if n := len(entries); n > 0 && entries[n-1] == e { |
| + continue |
| + } |
| + // A common prefix that ended the last page is that page's token |
| + // itself, and so is every key rolled into it. |
| + if e.name <= after { |
| + continue |
| + } |
| + entries = append(entries, e) |
| + } |
| + res := listBucketResult{Name: bucket, Prefix: prefix, Delimiter: delim, MaxKeys: size, ContinuationToken: token} |
| + if token == "" { |
| + res.StartAfter = q.Get("start-after") |
| + } |
| + page := entries |
| + if len(page) > size { |
| + page = page[:size] |
| + res.IsTruncated = size > 0 |
| + } |
| + for _, e := range page { |
| + if e.isPrefix { |
| + res.CommonPrefixes = append(res.CommonPrefixes, xmlCommonPrefix{Prefix: e.name}) |
| + continue |
| + } |
| + v := f.buckets[bucket][e.name][0] |
| + res.Contents = append(res.Contents, xmlObject{Key: e.name, LastModified: v.modified.UTC().Format(time.RFC3339), ETag: `"` + v.etag + `"`, Size: int64(len(v.body)), StorageClass: "STANDARD"}) |
| + } |
| + res.KeyCount = len(page) |
| + if res.IsTruncated && len(page) > 0 { |
| + res.NextContinuationToken = base64.StdEncoding.EncodeToString([]byte(page[len(page)-1].name)) |
| + } |
| + w.Header().Set("Content-Type", "application/xml") |
| + xml.NewEncoder(w).Encode(res) |
| +} |
| + |
| func (f *VersionedBucket) serveCopy(w http.ResponseWriter, r *http.Request, bucket, key string) { |
| src := strings.TrimPrefix(r.Header.Get("x-amz-copy-source"), "/") |
| srcPath, rawQ, _ := strings.Cut(src, "?") |
diff --git a/internal/lease/leasetest/s3server_test.go b/internal/lease/leasetest/s3server_test.go| index 3b3581c2b..93e5efee8 100644 |
| --- a/internal/lease/leasetest/s3server_test.go |
| +++ b/internal/lease/leasetest/s3server_test.go |
| @@ -4,12 +4,17 @@ import ( |
| "context" |
| "crypto/sha256" |
| "encoding/hex" |
| + "encoding/xml" |
| "errors" |
| + "fmt" |
| "io" |
| "net/http" |
| "net/http/httptest" |
| + "net/url" |
| "strings" |
| "testing" |
| + |
| + "github.com/carlosframework/platform/internal/lease" |
| ) |
| // failingReader yields some bytes and then an error, like a dropped upload. |
| @@ -287,3 +292,130 @@ func TestS3ServerConditionalDeleteUsesReadETag(t *testing.T) { |
| t.Fatal("plain delete changed", got.Code) |
| } |
| } |
| + |
| +// listV2 decodes one ListObjectsV2 page from the raw wire. |
| +func listV2(t *testing.T, h http.Handler, query string) (listBucketResult, int) { |
| + t.Helper() |
| + rec := do(t, h, http.MethodGet, "/data?list-type=2"+query, "", nil) |
| + var res listBucketResult |
| + if rec.Code == http.StatusOK { |
| + if err := xml.Unmarshal(rec.Body.Bytes(), &res); err != nil { |
| + t.Fatalf("decode %q: %v", rec.Body.String(), err) |
| + } |
| + } |
| + return res, rec.Code |
| +} |
| + |
| +// ListObjectsV2 on the wire: the current objects under a prefix in key |
| +// order, each with its size and quoted ETag; a key whose newest version is |
| +// a delete marker, an older version and a sibling prefix are not listed; |
| +// max-keys pages, and the continuation token resumes after the page; |
| +// start-after skips; a bad token and a negative max-keys are 400s; a |
| +// delimiter rolls keys into common prefixes, which page like keys. |
| +func TestS3ServerListObjectsV2(t *testing.T) { |
| + m := NewVersionedBucket() |
| + m.AddBucket("data") |
| + h, _ := NewS3Server(m) |
| + for _, k := range []string{"p/c", "p/a", "p/b/x", "p/d", "q/a", "p2/a"} { |
| + m.Put("data", k, []byte("body of "+k)) |
| + } |
| + m.Put("data", "p/a", []byte("newer")) |
| + m.BareDelete("data", "p/d") |
| + res, code := listV2(t, h, "&prefix=p/") |
| + if code != http.StatusOK { |
| + t.Fatal(code) |
| + } |
| + var keys []string |
| + for _, o := range res.Contents { |
| + keys = append(keys, o.Key) |
| + } |
| + if strings.Join(keys, ",") != "p/a,p/b/x,p/c" || res.IsTruncated || res.KeyCount != 3 || res.NextContinuationToken != "" { |
| + t.Fatalf("listing %+v", res) |
| + } |
| + if o := res.Contents[0]; o.Size != int64(len("newer")) || o.ETag != `"`+md5hex([]byte("newer"))+`"` { |
| + t.Fatalf("p/a listed as %+v", o) |
| + } |
| + |
| + first, _ := listV2(t, h, "&prefix=p/&max-keys=2") |
| + if len(first.Contents) != 2 || !first.IsTruncated || first.NextContinuationToken == "" || first.Contents[1].Key != "p/b/x" { |
| + t.Fatalf("first page %+v", first) |
| + } |
| + second, _ := listV2(t, h, "&prefix=p/&max-keys=2&continuation-token="+url.QueryEscape(first.NextContinuationToken)) |
| + if len(second.Contents) != 1 || second.Contents[0].Key != "p/c" || second.IsTruncated || second.ContinuationToken != first.NextContinuationToken { |
| + t.Fatalf("second page %+v", second) |
| + } |
| + after, _ := listV2(t, h, "&prefix=p/&start-after=p/a") |
| + if len(after.Contents) != 2 || after.Contents[0].Key != "p/b/x" { |
| + t.Fatalf("start-after %+v", after) |
| + } |
| + if _, code := listV2(t, h, "&continuation-token=%21%21"); code != http.StatusBadRequest { |
| + t.Fatal("bad token", code) |
| + } |
| + if _, code := listV2(t, h, "&max-keys=-1"); code != http.StatusBadRequest { |
| + t.Fatal("negative max-keys", code) |
| + } |
| + // A delimiter rolls p/b/x into the common prefix p/b/, which counts |
| + // against max-keys and can end a page. |
| + m.Put("data", "p/b/y", []byte("y")) |
| + d1, _ := listV2(t, h, "&prefix=p/&delimiter=/&max-keys=2") |
| + if len(d1.Contents) != 1 || d1.Contents[0].Key != "p/a" || len(d1.CommonPrefixes) != 1 || d1.CommonPrefixes[0].Prefix != "p/b/" || |
| + d1.KeyCount != 2 || !d1.IsTruncated || d1.Delimiter != "/" { |
| + t.Fatalf("delimited first page %+v", d1) |
| + } |
| + d2, _ := listV2(t, h, "&prefix=p/&delimiter=/&max-keys=2&continuation-token="+url.QueryEscape(d1.NextContinuationToken)) |
| + if len(d2.Contents) != 1 || d2.Contents[0].Key != "p/c" || len(d2.CommonPrefixes) != 0 || d2.IsTruncated { |
| + t.Fatalf("delimited second page %+v", d2) |
| + } |
| + top, _ := listV2(t, h, "&delimiter=/") |
| + var tops []string |
| + for _, p := range top.CommonPrefixes { |
| + tops = append(tops, p.Prefix) |
| + } |
| + if len(top.Contents) != 0 || strings.Join(tops, ",") != "p/,p2/,q/" { |
| + t.Fatalf("top level %+v", top) |
| + } |
| +} |
| + |
| +// The SDK's own paginator, through lease.NewS3Mirror, reads every page: |
| +// 2,500 keys at the default 1,000 a page, and again at a PageSize of 7. |
| +func TestS3ServerListObjectsV2PaginatesTheSDK(t *testing.T) { |
| + m := NewVersionedBucket() |
| + m.AddBucket("data") |
| + h, _ := NewS3Server(m) |
| + srv := httptest.NewServer(h) |
| + defer srv.Close() |
| + t.Setenv("AWS_ACCESS_KEY_ID", "test-key") |
| + t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret") |
| + t.Setenv("AWS_SESSION_TOKEN", "") |
| + t.Setenv("AWS_EC2_METADATA_DISABLED", "true") |
| + t.Setenv("AWS_CONFIG_FILE", t.TempDir()+"/absent") |
| + t.Setenv("AWS_SHARED_CREDENTIALS_FILE", t.TempDir()+"/absent") |
| + t.Setenv("CARLOS_S3_ENDPOINT", srv.URL) |
| + want := map[string]bool{} |
| + for i := range 2500 { |
| + k := fmt.Sprintf("big/%05d", i) |
| + m.Put("data", k, []byte(k)) |
| + want[k] = true |
| + } |
| + m.Put("data", "bigger/x", []byte("x")) // a sibling prefix, never listed |
| + ctx := context.Background() |
| + mirror, err := lease.NewS3Mirror(ctx, "eu-west-1", "eu-west-1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, page := range []int{1000, 7} { |
| + m.PageSize = page |
| + got, err := mirror.List(ctx, "data", "big/") |
| + if err != nil { |
| + t.Fatalf("page size %d: %v", page, err) |
| + } |
| + if len(got) != len(want) { |
| + t.Fatalf("page size %d: listed %d keys, want %d", page, len(got), len(want)) |
| + } |
| + for k, o := range got { |
| + if !want[k] || o.Size != int64(len(k)) || o.ETag != md5hex([]byte(k)) { |
| + t.Fatalf("page size %d: %s listed as %+v", page, k, o) |
| + } |
| + } |
| + } |
| +} |
diff --git a/internal/lease/tombstone.go b/internal/lease/tombstone.go| index c0054208f..7f9969a21 100644 |
| --- a/internal/lease/tombstone.go |
| +++ b/internal/lease/tombstone.go |
| @@ -60,6 +60,9 @@ var ( |
| // between the read and the conditional write. Nothing was overwritten; |
| // the caller's next pass reads it afresh. |
| ErrTombstoneChanged = errors.New("lease: the lock changed under the tombstone write") |
| + // ErrTombstoneLost reports that the tombstone Reassert was asked to |
| + // keep is absent or expired: the stop was lost. Nothing was written. |
| + ErrTombstoneLost = errors.New("lease: the tombstone is absent or expired; the stop was lost") |
| ) |
| // leaseFree is the predicate litestream's AcquireLease applies to the lease |
| @@ -209,14 +212,15 @@ func Tombstone(ctx context.Context, bucket, location, region, owner string, ttl |
| // |
| // - live and held by owner: rewritten with IfMatch on the ETag just read. |
| // If the lock changed since, ErrTombstoneChanged and nothing is written; |
| -// - absent, or expired (including lifted): Tombstone, which writes a new |
| -// one; |
| +// - absent, or expired (including lifted), whoever wrote it: |
| +// ErrTombstoneLost, and nothing is written. The stop was lost: in |
| +// between a wake may have acquired, written and released the lease, so |
| +// a tombstone is never taken again here; |
| // - live and held by anyone else: a *HeldError, nothing written. |
| // |
| -// Callers must read LockState first and treat absent or expired as a lost |
| -// stop; never Reassert after Lift. Reassert cannot tell a tombstone that was |
| -// kept from one that lapsed and was re-created, and in between a wake may |
| -// have acquired, written and released the lease. |
| +// Callers read LockState first and treat absent or expired as a lost stop; |
| +// ErrTombstoneLost is the same verdict for a lock that went between that |
| +// read and this one. Never Reassert after Lift. |
| func Reassert(ctx context.Context, bucket, location, region, owner string, ttl time.Duration) error { |
| if !tombstoneOwnerOK(owner) { |
| return ErrTombstoneOwner |
| @@ -232,8 +236,11 @@ func Reassert(ctx context.Context, bucket, location, region, owner string, ttl t |
| if err != nil { |
| return err |
| } |
| - if absent || leaseFree(cur.ExpiresAt, time.Now()) { |
| - return Tombstone(ctx, bucket, location, region, owner, ttl) |
| + if absent { |
| + return fmt.Errorf("reassert tombstone: %w", ErrTombstoneLost) |
| + } |
| + if leaseFree(cur.ExpiresAt, time.Now()) { |
| + return fmt.Errorf("reassert tombstone: %w (%s, expired %s)", ErrTombstoneLost, cur.Owner, cur.ExpiresAt.UTC().Format(time.RFC3339)) |
| } |
| if cur.Owner != owner { |
| return heldErr(cur) |
diff --git a/internal/lease/tombstone_test.go b/internal/lease/tombstone_test.go| index 9dc522871..abdc5e70b 100644 |
| --- a/internal/lease/tombstone_test.go |
| +++ b/internal/lease/tombstone_test.go |
| @@ -1,6 +1,7 @@ |
| package lease_test |
| import ( |
| + "bytes" |
| "context" |
| "encoding/json" |
| "errors" |
| @@ -387,32 +388,38 @@ func TestReassertMovesTheExpiry(t *testing.T) { |
| wantHeld(t, err, tombMove) |
| } |
| -func TestReassertWithNothingToReassertTombstones(t *testing.T) { |
| +// With nothing live to keep, Reassert reports the stop lost and writes |
| +// nothing: absent, its own tombstone expired or lifted, and someone else's |
| +// expired lease. It never takes a tombstone again (a wake may have used the |
| +// lease in between). |
| +func TestReassertWithNothingToReassertReportsTheStopLost(t *testing.T) { |
| s := tombFixture(t) |
| ctx := context.Background() |
| - if err := lease.Reassert(ctx, "data", tombLoc, tombRegion, tombMove, time.Hour); err != nil { |
| - t.Fatalf("absent: %v", err) |
| - } |
| - if l, _, ok := s.lock(t, tombLoc); !ok || l.Owner != tombMove { |
| - t.Fatalf("absent: lock = %+v present %v", l, ok) |
| - } |
| - // An expired tombstone of its own is replaced and live again. |
| - s.putLock(t, tombLoc, tombMove, 5, time.Now().Add(-time.Hour)) |
| - if err := lease.Reassert(ctx, "data", tombLoc, tombRegion, tombMove, time.Hour); err != nil { |
| - t.Fatalf("expired: %v", err) |
| - } |
| - l, _, _ := s.lock(t, tombLoc) |
| - if l.Owner != tombMove || l.Generation != 6 { |
| - t.Fatalf("expired: lock = %+v, want owner %s at generation 6", l, tombMove) |
| + if err := lease.Reassert(ctx, "data", tombLoc, tombRegion, tombMove, time.Hour); !errors.Is(err, lease.ErrTombstoneLost) { |
| + t.Fatalf("absent: %v, want ErrTombstoneLost", err) |
| } |
| - near(t, l.ExpiresAt, time.Hour) |
| - // An expired lease of someone else is replaced the same way. |
| - s.putLock(t, tombLoc, tombOther, 2, time.Now().Add(-time.Hour)) |
| - if err := lease.Reassert(ctx, "data", tombLoc, tombRegion, tombMove, time.Hour); err != nil { |
| - t.Fatalf("expired other: %v", err) |
| + if _, _, ok := s.lock(t, tombLoc); ok || s.writeCount() != 0 { |
| + t.Fatalf("absent: a lock was written (%d writes)", s.writeCount()) |
| } |
| - if l, _, _ := s.lock(t, tombLoc); l.Owner != tombMove { |
| - t.Fatalf("expired other: lock = %+v", l) |
| + for _, c := range []struct { |
| + name string |
| + owner string |
| + expires time.Time |
| + }{ |
| + {"its own, expired", tombMove, time.Now().Add(-time.Hour)}, |
| + {"its own, lifted", tombMove, time.Unix(0, 0)}, |
| + {"someone else's, expired", tombOther, time.Now().Add(-time.Hour)}, |
| + {"an ordinary lease, expired", "box-a:1", time.Now().Add(-time.Second)}, |
| + } { |
| + s.putLock(t, tombLoc, c.owner, 5, c.expires) |
| + _, before, _ := s.lock(t, tombLoc) |
| + writes := s.writeCount() |
| + if err := lease.Reassert(ctx, "data", tombLoc, tombRegion, tombMove, time.Hour); !errors.Is(err, lease.ErrTombstoneLost) { |
| + t.Fatalf("%s: %v, want ErrTombstoneLost", c.name, err) |
| + } |
| + if _, after, _ := s.lock(t, tombLoc); !bytes.Equal(before, after) || s.writeCount() != writes { |
| + t.Fatalf("%s: the lock was rewritten", c.name) |
| + } |
| } |
| } |
diff --git a/internal/moves/archive/archive.go b/internal/moves/archive/archive.go| index 1c3287872..a017e20ad 100644 |
| --- a/internal/moves/archive/archive.go |
| +++ b/internal/moves/archive/archive.go |
| @@ -143,7 +143,14 @@ type item struct { |
| // marker. The files directory is read through an os.Root, so nothing a |
| // tenant does inside it (a directory swapped for a symlink mid-walk) can |
| // make the walk or a later open leave it. |
| -func walk(root *os.Root, envPath string) ([]item, error) { |
| +// |
| +// skip, when non-nil, is asked each path relative to the files directory |
| +// (slash-separated, as the archive names it) before anything else is read |
| +// about it: a path it accepts is left out as the marker is, a directory |
| +// with everything under it, and is neither typed nor counted, so a |
| +// platform artifact of any kind (a socket, a FIFO) cannot fail the walk. |
| +// envPath is never asked. |
| +func walk(root *os.Root, envPath string, skip func(rel string) bool) ([]item, error) { |
| var items []item |
| add := func(it item) error { |
| if len(items) >= entryLimit { |
| @@ -160,9 +167,10 @@ func walk(root *os.Root, envPath string) ([]item, error) { |
| if p == "." { |
| return nil |
| } |
| - if p == MarkerName { |
| + if p == MarkerName || (skip != nil && skip(p)) { |
| // The marker is never archived, nor anything under it if |
| - // it is (wrongly) a directory. |
| + // it is (wrongly) a directory; nor is a path the caller |
| + // skips. |
| if d.IsDir() { |
| return fs.SkipDir |
| } |
| @@ -323,6 +331,11 @@ func openFilesRoot(filesDir string) (*os.Root, error) { |
| // build writes the tar of filesDir and envPath to out and returns what |
| // Build and PlainDigest report. |
| func build(filesDir, envPath string, out io.Writer) (built, error) { |
| + return buildFiltered(filesDir, envPath, nil, out) |
| +} |
| + |
| +// buildFiltered is build over the paths skip leaves in (walk). |
| +func buildFiltered(filesDir, envPath string, skip func(rel string) bool, out io.Writer) (built, error) { |
| root, err := openFilesRoot(filesDir) |
| if err != nil { |
| return built{}, err |
| @@ -330,7 +343,7 @@ func build(filesDir, envPath string, out io.Writer) (built, error) { |
| if root != nil { |
| defer root.Close() |
| } |
| - items, err := walk(root, envPath) |
| + items, err := walk(root, envPath, skip) |
| if err != nil { |
| return built{}, err |
| } |
| @@ -410,11 +423,24 @@ func copyItem(tw io.Writer, it item, size int64) (Entry, error) { |
| // can never collide with the overlay. A missing envPath (or "") means the |
| // archive has no env entry; a missing filesDir is an empty one. |
| func Build(filesDir, envPath string, w io.Writer) (plain string, entries []Entry, bytes int64, err error) { |
| + return BuildFiltered(filesDir, envPath, nil, w) |
| +} |
| + |
| +// BuildFiltered is Build over the files directory without the paths skip |
| +// accepts. skip is asked each path relative to filesDir, slash-separated |
| +// (a top-level entry is its bare name); a skipped directory is skipped |
| +// whole, and a skipped entry of any type is never read, typed or counted. |
| +// The env entry is never asked. A nil skip is Build. Nothing calls it with |
| +// a filter today: the offline move's sealer archives a nested instance's |
| +// files directory (<instance dir>/<host>, which holds nothing of the |
| +// platform's but the marker) with Build, and refuses the seal over any |
| +// entry of the app's beside it. |
| +func BuildFiltered(filesDir, envPath string, skip func(rel string) bool, w io.Writer) (plain string, entries []Entry, bytes int64, err error) { |
| zw, err := zstd.NewWriter(w, zstd.WithEncoderConcurrency(1), zstd.WithWindowSize(zstdWindow)) |
| if err != nil { |
| return "", nil, 0, err |
| } |
| - b, err := build(filesDir, envPath, zw) |
| + b, err := buildFiltered(filesDir, envPath, skip, zw) |
| if err != nil { |
| zw.Close() |
| return "", nil, 0, err |
diff --git a/internal/moves/archive/archive_test.go b/internal/moves/archive/archive_test.go| index 9cf54e7d7..833507a49 100644 |
| --- a/internal/moves/archive/archive_test.go |
| +++ b/internal/moves/archive/archive_test.go |
| @@ -104,6 +104,98 @@ func TestBuildIsDeterministicAndExcludesTheMarker(t *testing.T) { |
| } |
| } |
| +// BuildFiltered over a nested-shaped directory leaves out exactly what |
| +// skip accepts (a top-level file, a top-level directory with everything |
| +// under it, and a symlink, which unskipped would refuse the walk), never |
| +// asks about env, and writes byte for byte the archive Build writes over a |
| +// flat directory holding only the app's files; that archive extracts flat |
| +// and its extraction digests to the same plaintext. Build is BuildFiltered |
| +// with a nil skip. |
| +func TestBuildFilteredLeavesOutWhatSkipAccepts(t *testing.T) { |
| + flat, env := filesFixture(t) |
| + nested := filepath.Join(t.TempDir(), "nested") |
| + if err := os.CopyFS(nested, os.DirFS(flat)); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, rel := range []string{"a.txt", "sub", "sub/b.bin", "env", "sub/env", "sub/env/x", ".files-gen"} { |
| + info, err := os.Lstat(filepath.Join(flat, rel)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.Chmod(filepath.Join(nested, rel), info.Mode().Perm()); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + mustWrite(t, filepath.Join(nested, "h.db"), []byte("database"), 0o600) |
| + if err := os.MkdirAll(filepath.Join(nested, ".activator-h.db-litestream", "ltx", "0"), 0o700); err != nil { |
| + t.Fatal(err) |
| + } |
| + mustWrite(t, filepath.Join(nested, ".activator-h.db-litestream", "ltx", "0", "f.ltx"), []byte("ltx"), 0o600) |
| + if err := os.Symlink("/etc/passwd", filepath.Join(nested, "h.config.env")); err != nil { |
| + t.Fatal(err) |
| + } |
| + // A nested file that shares a skipped name below the top level is kept. |
| + mustWrite(t, filepath.Join(nested, "sub", "h.db"), []byte("an app file"), 0o644) |
| + mustWrite(t, filepath.Join(flat, "sub", "h.db"), []byte("an app file"), 0o644) |
| + platform := map[string]bool{"h.db": true, ".activator-h.db-litestream": true, "h.config.env": true} |
| + var asked []string |
| + skip := func(rel string) bool { |
| + asked = append(asked, rel) |
| + return !strings.Contains(rel, "/") && platform[rel] |
| + } |
| + if _, _, _, err := Build(nested, env, io.Discard); !errors.Is(err, ErrSpecialFile) { |
| + t.Fatalf("unfiltered, the symlink should refuse the walk: %v", err) |
| + } |
| + var got, want bytes.Buffer |
| + plain, entries, n, err := BuildFiltered(nested, env, skip, &got) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + wplain, wentries, wn, err := Build(flat, env, &want) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if plain != wplain || n != wn || len(entries) != len(wentries) || !bytes.Equal(got.Bytes(), want.Bytes()) { |
| + t.Fatalf("filtered %s %d %v, flat %s %d %v", plain, n, entries, wplain, wn, wentries) |
| + } |
| + for _, rel := range asked { |
| + if strings.HasPrefix(rel, ".activator-h.db-litestream/") { |
| + t.Fatalf("asked about %q inside a skipped directory", rel) |
| + } |
| + } |
| + if !slicesContains(asked, "sub/h.db") || !slicesContains(asked, "h.db") { |
| + t.Fatalf("asked %v", asked) |
| + } |
| + // Extract flat, then digest the extraction as Build would. |
| + parent := t.TempDir() |
| + files, outEnv := filepath.Join(parent, "h"), filepath.Join(parent, "h.env") |
| + if err := Extract(bytes.NewReader(got.Bytes()), files, outEnv); err != nil { |
| + t.Fatal(err) |
| + } |
| + if p, _, _, err := PlainDigest(files, outEnv); err != nil || p != plain { |
| + t.Fatalf("extraction digests to %s (%v), want %s", p, err, plain) |
| + } |
| + for _, name := range []string{"h.db", ".activator-h.db-litestream", "h.config.env"} { |
| + if _, err := os.Lstat(filepath.Join(files, name)); !errors.Is(err, fs.ErrNotExist) { |
| + t.Fatalf("%s extracted: %v", name, err) |
| + } |
| + } |
| + // A nil skip is Build. |
| + var nilSkip bytes.Buffer |
| + if p, _, _, err := BuildFiltered(flat, env, nil, &nilSkip); err != nil || p != wplain || !bytes.Equal(nilSkip.Bytes(), want.Bytes()) { |
| + t.Fatalf("nil skip: %s %v", p, err) |
| + } |
| +} |
| + |
| +func slicesContains(s []string, v string) bool { |
| + for _, x := range s { |
| + if x == v { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| func mustDecode(t *testing.T, b []byte) io.Reader { |
| t.Helper() |
| zr, err := zstd.NewReader(bytes.NewReader(b)) |
| @@ -831,6 +923,45 @@ func TestExtractWithoutEnvLeavesNoEnv(t *testing.T) { |
| } |
| } |
| +// A small archive handed over in a *bytes.Buffer (under the decoder's |
| +// 128 KiB shortcut for a reader with Bytes() and Len()) is read as a |
| +// stream like any other: Extract, the decode Verify runs, and Verify itself |
| +// accept it, rather than finding the whole archive still unread after it. |
| +func TestASmallArchiveInABytesBufferExtractsAndVerifies(t *testing.T) { |
| + dir, env := filesFixture(t) |
| + var tarz bytes.Buffer |
| + plain, entries, n, err := Build(dir, env, &tarz) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if tarz.Len() >= 128<<10 { |
| + t.Fatalf("fixture: %d bytes is not under 128 KiB", tarz.Len()) |
| + } |
| + raw := append([]byte(nil), tarz.Bytes()...) |
| + parent := t.TempDir() |
| + files, envOut := filepath.Join(parent, "h"), filepath.Join(parent, "h.env") |
| + if err := Extract(bytes.NewBuffer(append([]byte(nil), raw...)), files, envOut); err != nil { |
| + t.Fatalf("Extract from a *bytes.Buffer: %v", err) |
| + } |
| + if got, _, _, err := PlainDigest(files, envOut); err != nil || got != plain { |
| + t.Fatalf("the extraction digests to %s (%v), want %s", got, err, plain) |
| + } |
| + m := Manifest{V: ManifestVersion, Op: "mv_x", Plain: plain, Bytes: n, Entries: entries, Chunk: ChunkSize} |
| + if got, err := verifyStream(bytes.NewBuffer(append([]byte(nil), raw...)), m); err != nil || got != plain { |
| + t.Fatalf("verifyStream from a *bytes.Buffer: %s, %v", got, err) |
| + } |
| + pub, priv, b := keys(t) |
| + var sealed bytes.Buffer |
| + key, sealedSHA, err := Seal(bytes.NewReader(raw), pub, b, &sealed) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + m.Sealed, m.SealedDataKey = sealedSHA, key |
| + if err := Verify(&sealed, m, priv, b); err != nil { |
| + t.Fatalf("Verify from a *bytes.Buffer: %v", err) |
| + } |
| +} |
| + |
| // sealedArchive is a good archive with its manifest, over a tree large |
| // enough to seal into three chunks. |
| type sealedArchive struct { |
diff --git a/internal/moves/archive/archive_unix_test.go b/internal/moves/archive/archive_unix_test.go| index caab62dde..ef987b669 100644 |
| --- a/internal/moves/archive/archive_unix_test.go |
| +++ b/internal/moves/archive/archive_unix_test.go |
| @@ -135,3 +135,24 @@ func TestBuildRefusalsCarryTheirPath(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +// A FIFO (a platform socket or pipe in a nested instance directory) that |
| +// skip accepts is never opened or typed: BuildFiltered archives around it. |
| +// The same FIFO unskipped is refused, naming it. |
| +func TestBuildFilteredNeverTypesASkippedFIFO(t *testing.T) { |
| + files := t.TempDir() |
| + if err := os.WriteFile(filepath.Join(files, "a"), []byte("a"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := syscall.Mkfifo(filepath.Join(files, "app.fifo"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + var pe *PathError |
| + if _, _, _, err := BuildFiltered(files, "", nil, io.Discard); !errors.Is(err, ErrSpecialFile) || !errors.As(err, &pe) || pe.Path != "app.fifo" { |
| + t.Fatalf("unskipped FIFO: %v", err) |
| + } |
| + _, entries, _, err := BuildFiltered(files, "", func(rel string) bool { return rel == "app.fifo" }, io.Discard) |
| + if err != nil || len(entries) != 1 || entries[0].Path != "files/a" { |
| + t.Fatalf("skipped FIFO: %v %v", entries, err) |
| + } |
| +} |
diff --git a/internal/moves/archive/limits.go b/internal/moves/archive/limits.go| index 44f6bf90c..0a4fcd66c 100644 |
| --- a/internal/moves/archive/limits.go |
| +++ b/internal/moves/archive/limits.go |
| @@ -209,8 +209,13 @@ func (b *boundedReader) Read(p []byte) (int, error) { |
| return n, err |
| } |
| +// newDecoder always streams from r. WithDecodeBuffersBelow(0) turns off |
| +// the decoder's shortcut for a reader with Bytes() and Len() (a |
| +// *bytes.Buffer) under 128 KiB, which decodes the whole buffer at once and |
| +// never drains it: requireEOF would then read the archive again and refuse |
| +// it as data after the archive. |
| func newDecoder(r io.Reader) (*zstd.Decoder, error) { |
| - return zstd.NewReader(r, zstd.WithDecoderConcurrency(1), zstd.WithDecoderMaxWindow(zstdWindow)) |
| + return zstd.NewReader(r, zstd.WithDecoderConcurrency(1), zstd.WithDecoderMaxWindow(zstdWindow), zstd.WithDecodeBuffersBelow(0)) |
| } |
| // requireEOF reads the raw stream once more after the decoder finished. A |
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.") |
| +} |