package host import ( "context" "os" "path/filepath" "strings" "testing" "github.com/carlosframework/platform/internal/provision" ) // pagesSeen records the pages a box arm sends through moveCfg.Page. type pagesSeen []struct{ level, subject, body string } func (p *pagesSeen) hook() func(context.Context, string, string, string) error { return func(_ context.Context, level, subject, body string) error { *p = append(*p, struct{ level, subject, body string }{level, subject, body}) return nil } } // stuckPageBody is the page the record's stuck mark sends (MV-N314), spelled // out here rather than built by the code under test. func stuckPageBody(box, role, reason string) string { return "The move " + p1ID + " of " + p1Host + " stopped for an operator on " + box + ", its " + role + ": " + reason + ". Run carlos move resolve --host " + p1Host + " --retry once the cause is fixed." } // pagedOnce asserts exactly one incident page for the stuck mark, carrying // no path and no secret. func pagedOnce(t *testing.T, pages pagesSeen, box, role, reason string) { t.Helper() if len(pages) != 1 { t.Fatalf("pages %+v, want one", pages) } p := pages[0] if p.level != "incident" || p.subject != "carlos move stuck: "+p1Host || p.body != stuckPageBody(box, role, reason) { t.Fatalf("page %+v\nwant body %q", p, stuckPageBody(box, role, reason)) } if strings.Contains(p.subject+p.body, "/") { t.Fatalf("a page carries a path: %+v", p) } } // TestStuckMarksPageAnOperator (moves spec ยง13, K11): wherever a box arm's // MoveMarkStuck lands, the operator is paged once, as an incident, with the // recorded reason; a pass over the stuck record pages nothing more. The // hook is nil in the other tests and in production until armMovesPass sets // it to PageOperator. func TestStuckMarksPageAnOperator(t *testing.T) { t.Parallel() t.Run("the target's return", func(t *testing.T) { h := newRollbackHarness(t) db := provision.DefaultDBPath(h.tdir, p1Host) if err := os.Rename(db, db+".aside"); err != nil { t.Fatal(err) } h.rollBack(t) h.rollbackPass(t) h.stuckAtReturning(t, provision.StuckReturnTargetDatabase) h.rollbackPass(t) pagedOnce(t, h.pages, "uk-box", "target", provision.StuckReturnTargetDatabase) }) t.Run("the source's recovery", func(t *testing.T) { h := newRecoverHarness(t, false) h.released(t) if err := os.WriteFile(filepath.Join(h.sdir, p1Host, "upload.txt"), []byte("changed on the source"), 0o644); err != nil { t.Fatal(err) } h.pages = nil h.srcPass(t) if m := h.read(t, p1Host).Move; m.State != provision.MoveStuck || m.StuckReason != provision.StuckBaselineMismatch { t.Fatalf("not stuck: %+v", m) } h.srcPass(t) pagedOnce(t, h.pages, "ie-box", "source", provision.StuckBaselineMismatch) }) t.Run("the source's dirty return install", func(t *testing.T) { h := newRecoverHarness(t, true) h.released(t) sentinel := filepath.Join(h.sdir, p1Host, "sentinel.txt") h.landFault = func(p string) error { if p == "after-"+landStepRetired { if err := os.MkdirAll(filepath.Dir(sentinel), 0o755); err != nil { t.Fatal(err) } return os.WriteFile(sentinel, []byte("not the return's"), 0o644) } return nil } h.pages = nil h.srcPass(t) if m := h.read(t, p1Host).Move; m.State != provision.MoveStuck || m.StuckReason != provision.StuckReturnFilesOccupied { t.Fatalf("not stuck: %+v", m) } h.srcPass(t) pagedOnce(t, h.pages, "ie-box", "source", provision.StuckReturnFilesOccupied) }) t.Run("the source's revert", func(t *testing.T) { h := newRevertHarness(t) h.fromStopped(t) h.arm(t) var pages pagesSeen h.cfg.Page = pages.hook() if err := os.WriteFile(filepath.Join(h.cfg.DataDir, p1Host, "upload.txt"), []byte("changed while stopped"), 0o644); err != nil { t.Fatal(err) } h.pass(t) if m := h.move(t); m.State != provision.MoveStuck || m.StuckReason != provision.StuckBaselineMismatch { t.Fatalf("not stuck: %+v", m) } h.pass(t) pagedOnce(t, pages, "ie-box", "source", provision.StuckBaselineMismatch) }) }