| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "github.com/carlosframework/platform/internal/provision" |
| 11 | ) |
| 12 | |
| 13 | // pagesSeen records the pages a box arm sends through moveCfg.Page. |
| 14 | type pagesSeen []struct{ level, subject, body string } |
| 15 | |
| 16 | func (p *pagesSeen) hook() func(context.Context, string, string, string) error { |
| 17 | return func(_ context.Context, level, subject, body string) error { |
| 18 | *p = append(*p, struct{ level, subject, body string }{level, subject, body}) |
| 19 | return nil |
| 20 | } |
| 21 | } |
| 22 | |
| 23 | // stuckPageBody is the page the record's stuck mark sends (MV-N314), spelled |
| 24 | // out here rather than built by the code under test. |
| 25 | func stuckPageBody(box, role, reason string) string { |
| 26 | return "The move " + p1ID + " of " + p1Host + " stopped for an operator on " + box + ", its " + role + ": " + reason + |
| 27 | ". Run carlos move resolve --host " + p1Host + " --retry once the cause is fixed." |
| 28 | } |
| 29 | |
| 30 | // pagedOnce asserts exactly one incident page for the stuck mark, carrying |
| 31 | // no path and no secret. |
| 32 | func pagedOnce(t *testing.T, pages pagesSeen, box, role, reason string) { |
| 33 | t.Helper() |
| 34 | if len(pages) != 1 { |
| 35 | t.Fatalf("pages %+v, want one", pages) |
| 36 | } |
| 37 | p := pages[0] |
| 38 | if p.level != "incident" || p.subject != "carlos move stuck: "+p1Host || p.body != stuckPageBody(box, role, reason) { |
| 39 | t.Fatalf("page %+v\nwant body %q", p, stuckPageBody(box, role, reason)) |
| 40 | } |
| 41 | if strings.Contains(p.subject+p.body, "/") { |
| 42 | t.Fatalf("a page carries a path: %+v", p) |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | // TestStuckMarksPageAnOperator (moves spec §13, K11): wherever a box arm's |
| 47 | // MoveMarkStuck lands, the operator is paged once, as an incident, with the |
| 48 | // recorded reason; a pass over the stuck record pages nothing more. The |
| 49 | // hook is nil in the other tests and in production until armMovesPass sets |
| 50 | // it to PageOperator. |
| 51 | func TestStuckMarksPageAnOperator(t *testing.T) { |
| 52 | t.Parallel() |
| 53 | t.Run("the target's return", func(t *testing.T) { |
| 54 | h := newRollbackHarness(t) |
| 55 | db := provision.DefaultDBPath(h.tdir, p1Host) |
| 56 | if err := os.Rename(db, db+".aside"); err != nil { |
| 57 | t.Fatal(err) |
| 58 | } |
| 59 | h.rollBack(t) |
| 60 | h.rollbackPass(t) |
| 61 | h.stuckAtReturning(t, provision.StuckReturnTargetDatabase) |
| 62 | h.rollbackPass(t) |
| 63 | pagedOnce(t, h.pages, "uk-box", "target", provision.StuckReturnTargetDatabase) |
| 64 | }) |
| 65 | t.Run("the source's recovery", func(t *testing.T) { |
| 66 | h := newRecoverHarness(t, false) |
| 67 | h.released(t) |
| 68 | if err := os.WriteFile(filepath.Join(h.sdir, p1Host, "upload.txt"), []byte("changed on the source"), 0o644); err != nil { |
| 69 | t.Fatal(err) |
| 70 | } |
| 71 | h.pages = nil |
| 72 | h.srcPass(t) |
| 73 | if m := h.read(t, p1Host).Move; m.State != provision.MoveStuck || m.StuckReason != provision.StuckBaselineMismatch { |
| 74 | t.Fatalf("not stuck: %+v", m) |
| 75 | } |
| 76 | h.srcPass(t) |
| 77 | pagedOnce(t, h.pages, "ie-box", "source", provision.StuckBaselineMismatch) |
| 78 | }) |
| 79 | t.Run("the source's dirty return install", func(t *testing.T) { |
| 80 | h := newRecoverHarness(t, true) |
| 81 | h.released(t) |
| 82 | sentinel := filepath.Join(h.sdir, p1Host, "sentinel.txt") |
| 83 | h.landFault = func(p string) error { |
| 84 | if p == "after-"+landStepRetired { |
| 85 | if err := os.MkdirAll(filepath.Dir(sentinel), 0o755); err != nil { |
| 86 | t.Fatal(err) |
| 87 | } |
| 88 | return os.WriteFile(sentinel, []byte("not the return's"), 0o644) |
| 89 | } |
| 90 | return nil |
| 91 | } |
| 92 | h.pages = nil |
| 93 | h.srcPass(t) |
| 94 | if m := h.read(t, p1Host).Move; m.State != provision.MoveStuck || m.StuckReason != provision.StuckReturnFilesOccupied { |
| 95 | t.Fatalf("not stuck: %+v", m) |
| 96 | } |
| 97 | h.srcPass(t) |
| 98 | pagedOnce(t, h.pages, "ie-box", "source", provision.StuckReturnFilesOccupied) |
| 99 | }) |
| 100 | t.Run("the source's revert", func(t *testing.T) { |
| 101 | h := newRevertHarness(t) |
| 102 | h.fromStopped(t) |
| 103 | h.arm(t) |
| 104 | var pages pagesSeen |
| 105 | h.cfg.Page = pages.hook() |
| 106 | if err := os.WriteFile(filepath.Join(h.cfg.DataDir, p1Host, "upload.txt"), []byte("changed while stopped"), 0o644); err != nil { |
| 107 | t.Fatal(err) |
| 108 | } |
| 109 | h.pass(t) |
| 110 | if m := h.move(t); m.State != provision.MoveStuck || m.StuckReason != provision.StuckBaselineMismatch { |
| 111 | t.Fatalf("not stuck: %+v", m) |
| 112 | } |
| 113 | h.pass(t) |
| 114 | pagedOnce(t, pages, "ie-box", "source", provision.StuckBaselineMismatch) |
| 115 | }) |
| 116 | } |
| 117 | |