| new file mode 100644 |
| index 000000000..43fe0e56d |
| --- /dev/null |
| +++ b/internal/console/selfserve_checks_test.go |
| @@ -0,0 +1,112 @@ |
| +package console |
| + |
| +import ( |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/moves" |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// TestSelfServeDecisionsSpeakEachFact: the job's decisions over a target's |
| +// ready report and a source's precheck, fact by fact: each failing fact |
| +// its own sentence, a missing or unknown one "could not determine <fact> |
| +// on <label>", free disk under twice the replica, and an empty stray |
| +// directory passing (the start removes it). |
| +func TestSelfServeDecisionsSpeakEachFact(t *testing.T) { |
| + const host, label = "shop.example.com", "uk-box" |
| + ok := moves.Fact{Known: true, OK: true} |
| + ready := func() *moves.TargetReadyReport { |
| + r := &moves.TargetReadyReport{RouteAbsent: ok, TimersAbsent: ok, LiveLink: ok, ProgramsFile: ok, ParkDir: ok, DiskFree: ok, |
| + DiskFreeBytes: 10 << 30, MovesPrefix: moves.PrefixFacts{Known: true}, InstancesPrefix: moves.PrefixFacts{Known: true}, Lock: ok, |
| + DataPaths: map[string]moves.Fact{}} |
| + for _, p := range targetDataPaths(host, nil) { |
| + r.DataPaths[p] = ok |
| + } |
| + return r |
| + } |
| + if got := targetReadyDecision(host, label, ready(), 1<<20, true); got != "" { |
| + t.Fatalf("a clean target: %q", got) |
| + } |
| + for _, c := range []struct { |
| + name, want string |
| + edit func(r *moves.TargetReadyReport) |
| + replica int64 |
| + }{ |
| + {"route unknown", "could not determine the route on uk-box", func(r *moves.TargetReadyReport) { r.RouteAbsent = moves.Fact{} }, 0}, |
| + {"route held", "the target box already has a route for shop.example.com", func(r *moves.TargetReadyReport) { r.RouteAbsent.OK = false }, 0}, |
| + {"timers", "the target box has timers for shop.example.com", func(r *moves.TargetReadyReport) { r.TimersAbsent.OK = false }, 0}, |
| + {"stale live link", "the target box has a stale live link for shop.example.com naming 2026-10-01.1", func(r *moves.TargetReadyReport) { |
| + r.LiveLink = moves.Fact{Known: true, Detail: "/opt/carlos/versions/2026-10-01.1/bab/shop"} |
| + }, 0}, |
| + {"programs file", "the target box's programs file names ./cmd/shop", func(r *moves.TargetReadyReport) { |
| + r.ProgramsFile = moves.Fact{Known: true, Detail: "./cmd/shop"} |
| + }, 0}, |
| + {"a data path held", "the target box already holds data for shop.example.com: /data/shop.example.com.db", func(r *moves.TargetReadyReport) { |
| + r.DataPaths["/data/shop.example.com.db"] = moves.Fact{Known: true} |
| + }, 0}, |
| + {"a data path left out", "could not determine /data/shop.example.com.env on uk-box", func(r *moves.TargetReadyReport) { |
| + delete(r.DataPaths, "/data/shop.example.com.env") |
| + }, 0}, |
| + {"park directory", "the target box's park directory is not clean", func(r *moves.TargetReadyReport) { r.ParkDir.OK = false }, 0}, |
| + {"disk under twice the replica", "the target box has too little free disk for shop.example.com", func(r *moves.TargetReadyReport) { |
| + r.DiskFreeBytes = 3 << 20 |
| + }, 2 << 20}, |
| + {"staging prefix unknown", "could not determine the staging prefix on uk-box", func(r *moves.TargetReadyReport) { r.MovesPrefix.Known = false }, 0}, |
| + {"instances prefix held", "the target bucket already holds objects for shop.example.com", func(r *moves.TargetReadyReport) { |
| + r.InstancesPrefix.Objects = map[string]moves.PrefixObject{"db.ltx": {}} |
| + }, 0}, |
| + {"a lock", "the target bucket holds a lock for shop.example.com (moves/shop.example.com/lock.json)", func(r *moves.TargetReadyReport) { |
| + r.Lock = moves.Fact{Known: true, Detail: "moves/shop.example.com/lock.json"} |
| + }, 0}, |
| + } { |
| + r := ready() |
| + c.edit(r) |
| + if got := targetReadyDecision(host, label, r, c.replica, true); got != c.want { |
| + t.Errorf("%s: %q, want %q", c.name, got, c.want) |
| + } |
| + } |
| + // After staging the staging prefix holds the staged copy: the book |
| + // decides it, not the ready decision. |
| + r := ready() |
| + r.MovesPrefix.Objects = map[string]moves.PrefixObject{"db.ltx": {}} |
| + if got := targetReadyDecision(host, label, r, 0, false); got != "" { |
| + t.Errorf("after staging: %q", got) |
| + } |
| + |
| + pre := func() *moves.SourcePrecheck { |
| + return &moves.SourcePrecheck{ArchiveEligible: ok, Strays: moves.StraysFact{Known: true}} |
| + } |
| + for _, c := range []struct { |
| + name, want string |
| + edit func(p *moves.SourcePrecheck) |
| + }{ |
| + {"clean", "", func(*moves.SourcePrecheck) {}}, |
| + {"archive unknown", "could not determine the archive on ie-box", func(p *moves.SourcePrecheck) { p.ArchiveEligible = moves.Fact{} }}, |
| + {"archive refused", "shop.example.com's files cannot be archived: an unsupported file type", func(p *moves.SourcePrecheck) { |
| + p.ArchiveEligible = moves.Fact{Known: true, Detail: "an unsupported file type"} |
| + }}, |
| + {"strays unknown", "could not determine the stray entries on ie-box", func(p *moves.SourcePrecheck) { p.Strays.Known = false }}, |
| + {"an empty directory", "", func(p *moves.SourcePrecheck) { |
| + p.Strays.Entries = []moves.StrayEntry{{Path: "/data/x", Kind: moves.StrayDir, Known: true}} |
| + }}, |
| + {"a stray file", "shop.example.com has a stray entry beside its files directory: /data/x.txt", func(p *moves.SourcePrecheck) { |
| + p.Strays.Entries = []moves.StrayEntry{{Path: "/data/x.txt", Kind: moves.StrayFile, Known: true}} |
| + }}, |
| + {"an unread directory", "could not determine /data/y on ie-box", func(p *moves.SourcePrecheck) { |
| + p.Strays.Entries = []moves.StrayEntry{{Path: "/data/y", Kind: moves.StrayDir}} |
| + }}, |
| + } { |
| + p := pre() |
| + c.edit(p) |
| + if got := sourcePrecheckDecision(host, "ie-box", p); got != c.want { |
| + t.Errorf("%s: %q, want %q", c.name, got, c.want) |
| + } |
| + } |
| + if got := targetDataPaths(host, map[string]moves.Fact{"/srv/d/" + host + ".db": ok})[0]; got != provision.InstanceDir("/srv/d", host) { |
| + t.Errorf("the data directory is not read from the report: %q", got) |
| + } |
| + if stagingReady(moves.OfflineReport{Rounds: 1}) || !stagingReady(moves.OfflineReport{Rounds: 2, RoundCopiedBytes: 1 << 20}) || |
| + stagingReady(moves.OfflineReport{Rounds: 3, RoundCopiedBytes: 8 << 20, RoundDeferredBytes: 8 << 20}) { |
| + t.Error("the ready rule") |
| + } |
| +} |