diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main.go index 2fef5a6..6030353 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main.go @@ -59,11 +59,52 @@ func Tools(r check.Reader) []stdio.Tool { kept, err := r.Kept(level, digest) return map[string]any{"kept": kept}, err }}, + {Name: check.PreviewsReadTool, + Description: "The merge previews the checker holds, each as stored under previews...: the merge commit, per module directory " + + "its tree, manifest digest and lock hashes, or the files in conflict. With a repository, that repository's; with a head (7 or more characters) " + + "only that head's. Keys that are no preview key are listed as unreadable. Read-only.", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "repository": map[string]any{"type": "string", "description": "/"}, + "head": map[string]any{"type": "string", "description": "a pull request head commit, or a prefix of at least 7 characters"}}}, + Run: func(args map[string]any) (any, error) { + repository, _ := args["repository"].(string) + head, _ := args["head"].(string) + if head != "" && len(head) < 7 { + return nil, fmt.Errorf("the head %q is too short: give at least 7 characters", head) + } + ps, bad, err := r.Previews(repository, head) + return map[string]any{"previews": ps, "unreadable": bad}, err + }}, + {Name: check.StatusReadTool, + Description: "What holds the checker back and what runs: the capacity per node (the setting, the default one step per node), whether the controller's " + + "delivery plans in their build phase hold a key (no new check starts while one does), the steps in flight on the build modules by node, and the " + + "candidates without a verdict for their heads. The steps the checker holds waiting for a place are in its process, not in a bucket, and the " + + "answer says so. Read-only.", + Input: map[string]any{"type": "object", "properties": map[string]any{}}, + Run: func(map[string]any) (any, error) { return r.Status() }}, } } +func capacity() (string, error) { + path := os.Getenv("MESH_CHECKER_SETTINGS") + if path == "" { + return os.Getenv("MESH_CHECKER_CAPACITY"), nil + } + raw, err := os.ReadFile(path) + if err != nil { + return "", err + } + var s struct { + Capacity string `json:"capacity"` + } + return s.Capacity, json.Unmarshal(raw, &s) +} + func main() { - r := check.Reader{VerdictState: read(stdio.State("verdicts")), KeptState: read(stdio.State("kept")), CandidateState: read(stdio.State("mesh-delivery-indexer.candidates"))} + r := check.Reader{VerdictState: read(stdio.State("verdicts")), KeptState: read(stdio.State("kept")), CandidateState: read(stdio.State("mesh-delivery-indexer.candidates")), + PreviewState: read(stdio.State("previews")), PlansState: read(stdio.State("mesh-controller.plans-building")), + StepStates: map[string]check.StateRead{"mesh-build-go": read(stdio.State("mesh-build-go.steps")), "mesh-build-node": read(stdio.State("mesh-build-node.steps"))}, + Capacity: capacity} if err := stdio.Serve("", Tools(r)); err != nil { fmt.Fprintf(os.Stderr, "[mesh-delivery-checker-tools] %v\n", err) os.Exit(1) diff --git a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main_test.go b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main_test.go index 5fec12b..96d29e7 100644 --- a/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main_test.go +++ b/modules/mesh-delivery-checker/cmd/mesh-delivery-checker-tools/main_test.go @@ -1,12 +1,13 @@ package main import ( + "slices" "testing" "mesh-delivery-checker/internal/check" ) -func TestTheToolsServedAreTheThreeReadTools(t *testing.T) { +func TestTheToolsServedAreTheFiveReadTools(t *testing.T) { var names []string for _, tl := range Tools(check.Reader{}) { names = append(names, tl.Name) @@ -14,12 +15,15 @@ func TestTheToolsServedAreTheThreeReadTools(t *testing.T) { t.Errorf("%s has no description or no run", tl.Name) } } - want := []string{check.VerdictsReadTool, check.PullReadTool, check.KeptReadTool} - if len(names) != 3 || names[0] != want[0] || names[1] != want[1] || names[2] != want[2] { + want := []string{check.VerdictsReadTool, check.PullReadTool, check.KeptReadTool, check.PreviewsReadTool, check.StatusReadTool} + if !slices.Equal(names, want) { t.Errorf("tools = %v, want %v", names, want) } // With nothing to read from a tool says so and changes nothing. for _, tl := range Tools(check.Reader{}) { + if tl.Name == check.StatusReadTool { + continue // it answers from what it can read, and says what it could not + } if _, err := tl.Run(map[string]any{"repository": "novox/x", "number": float64(1)}); err == nil { t.Errorf("%s answered with no state to read", tl.Name) } diff --git a/modules/mesh-delivery-checker/internal/check/read.go b/modules/mesh-delivery-checker/internal/check/read.go index 874e99b..d02b04c 100644 --- a/modules/mesh-delivery-checker/internal/check/read.go +++ b/modules/mesh-delivery-checker/internal/check/read.go @@ -7,6 +7,7 @@ import ( "strings" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/delivery/buildrun" ) // The read tools (novox/hq ADR 0484 clauses 8 and 13, issue 1686): what the checker stored, as stored, with the line of the @@ -17,6 +18,9 @@ const ( VerdictsReadTool = "checker_verdicts" PullReadTool = "checker_pull" KeptReadTool = "checker_kept" + // PreviewsReadTool reads the previews state (issue 1680); StatusReadTool reads what the checker is held by and what it runs (issue 1685). + PreviewsReadTool = "checker_previews" + StatusReadTool = "checker_status" ) // StateRead reads one state: its keys and a key's stored value (nil for a key with none). @@ -26,7 +30,14 @@ type StateRead struct { } // Reader answers the read tools from the checker's verdicts and kept states and the indexer's candidates. -type Reader struct{ VerdictState, KeptState, CandidateState StateRead } +type Reader struct { + VerdictState, KeptState, CandidateState StateRead + + PreviewState StateRead // the checker's merge previews + PlansState StateRead // the controller's delivery plans in their build phase; zero where the bucket is absent + StepStates map[string]StateRead // the build modules' steps states, by module + Capacity func() (string, error) // the capacity setting as written, `node=steps,...` +} // VerdictRead is a verdict as stored with the full report of the status of each pull request it judged (Report), when the // candidate still holds those heads; without it the report is that of the verdict alone. @@ -191,3 +202,153 @@ func (r Reader) Kept(level, digest string) (map[string]json.RawMessage, error) { } return out, nil } + +// PreviewRead is one preview as stored, with the parts of its key. +type PreviewRead struct { + Key string `json:"key"` + Repository string `json:"repository"` + Head string `json:"head"` + Trunk string `json:"trunk"` + Preview json.RawMessage `json:"preview"` +} + +// Previews are the previews the checker holds, each as stored. With a repository only that repository's; with a head (a +// prefix of at least 7 characters is enough) only previews of that head. A key that is no preview key is listed as unreadable. +func (r Reader) Previews(repository, head string) ([]PreviewRead, []string, error) { + if r.PreviewState.Keys == nil || r.PreviewState.Get == nil { + return nil, nil, fmt.Errorf("the previews state cannot be read here") + } + keys, err := r.PreviewState.Keys() + if err != nil { + return nil, nil, err + } + sort.Strings(keys) + out := []PreviewRead{} + var bad []string + for _, k := range keys { + repo, h, trunk, err := delivery.ParsePreviewKey(k) + if err != nil { + bad = append(bad, k) + continue + } + if (repository != "" && repo != repository) || (head != "" && !strings.HasPrefix(h, head)) { + continue + } + raw, err := r.PreviewState.Get(k) + if err != nil { + return nil, nil, err + } + if len(raw) > 0 { + out = append(out, PreviewRead{Key: k, Repository: repo, Head: h, Trunk: trunk, Preview: raw}) + } + } + return out, bad, nil +} + +// RunningStep is a step in flight on a build module. +type RunningStep struct { + Module string `json:"module"` + Node string `json:"node"` + ID string `json:"id"` + Tool string `json:"tool"` + Since string `json:"since,omitempty"` +} + +// Status is what holds the checker back and what runs now, read from the buckets: the checker's held and running steps live in +// its process, so they are shown as what the buckets say (the steps in flight on the build modules, the candidates without a +// verdict for their heads, and the delivery plans in their build phase), not as the process's own counters. +type Status struct { + Capacity map[string]int `json:"capacity"` + CapacityNote string `json:"capacity_note,omitempty"` + DefaultPerNode int `json:"default_per_node"` + PlansBuilding struct { + Readable bool `json:"readable"` + Keys []string `json:"keys"` + Holds bool `json:"holds"` + } `json:"plans_building"` + Running []RunningStep `json:"running_steps"` + RunningByNode map[string]int `json:"running_by_node"` + Unjudged []string `json:"unjudged_candidates"` + Notes []string `json:"notes,omitempty"` +} + +// Status reads the capacity, the plan bucket, the build modules' steps and the candidates against their verdicts. +func (r Reader) Status() (Status, error) { + st := Status{DefaultPerNode: buildrun.DefaultCapacity, Capacity: map[string]int{}, RunningByNode: map[string]int{}} + if r.Capacity == nil { + st.CapacityNote = "the capacity setting cannot be read from here" + } else if setting, err := r.Capacity(); err != nil { + st.CapacityNote = "the capacity setting cannot be read: " + err.Error() + } else if st.Capacity, err = buildrun.ParseCapacity(setting); err != nil { + st.Capacity, st.CapacityNote = map[string]int{}, "the capacity setting is not valid: "+err.Error() + } + if r.PlansState.Keys == nil { + st.Notes = append(st.Notes, PlansBuildingBucket+" is not readable here (absent), so it is read as empty: no delivery holds the checker") + } else if keys, err := r.PlansState.Keys(); err != nil { + st.Notes = append(st.Notes, PlansBuildingBucket+" cannot be listed, so it is read as empty: "+err.Error()) + } else { + sort.Strings(keys) + st.PlansBuilding.Readable, st.PlansBuilding.Keys, st.PlansBuilding.Holds = true, keys, len(keys) > 0 + } + mods := make([]string, 0, len(r.StepStates)) + for m := range r.StepStates { + mods = append(mods, m) + } + sort.Strings(mods) + st.Running = []RunningStep{} + for _, m := range mods { + sr := r.StepStates[m] + if sr.Keys == nil || sr.Get == nil { + continue + } + keys, err := sr.Keys() + if err != nil { + st.Notes = append(st.Notes, "the steps of "+m+" cannot be listed: "+err.Error()) + continue + } + sort.Strings(keys) + for _, k := range keys { + raw, err := sr.Get(k) + if err != nil || len(raw) == 0 { + continue + } + step, err := delivery.DecodeStep(raw) + if err != nil || step.State != delivery.StepRunning { + continue + } + rs := RunningStep{Module: m, Node: step.Node, ID: step.ID, Tool: step.Tool} + if !step.Started.IsZero() { + rs.Since = step.Started.UTC().Format("2006-01-02T15:04:05Z") + } + st.Running = append(st.Running, rs) + st.RunningByNode[step.Node]++ + } + } + st.Unjudged = []string{} + if r.CandidateState.Keys != nil && r.CandidateState.Get != nil && r.VerdictState.Get != nil { + keys, err := r.CandidateState.Keys() + if err != nil { + return st, err + } + sort.Strings(keys) + for _, k := range keys { + raw, err := r.CandidateState.Get(k) + if err != nil || len(raw) == 0 { + continue + } + c, err := delivery.DecodeCandidate(raw) + if err != nil { + continue + } + vkey, err := delivery.CheckerVerdictKey(c.Group, c.HeadsHash) + if err != nil { + continue + } + if v, err := r.VerdictState.Get(vkey); err == nil && len(v) == 0 { + st.Unjudged = append(st.Unjudged, c.Group) + } + } + } + st.Notes = append(st.Notes, "the steps the checker holds waiting for a place are in its process and in no bucket: a candidate without a verdict while a plan key exists, or while its node runs as many steps as its capacity, is held") + return st, nil +} diff --git a/modules/mesh-delivery-checker/internal/check/read_test.go b/modules/mesh-delivery-checker/internal/check/read_test.go index 1d1c8fb..de12542 100644 --- a/modules/mesh-delivery-checker/internal/check/read_test.go +++ b/modules/mesh-delivery-checker/internal/check/read_test.go @@ -3,8 +3,10 @@ package check import ( "context" "encoding/json" + "os" "strings" "testing" + "time" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -102,3 +104,64 @@ func TestTheKeptToolAnswersTheKeysAsStoredByLevelAndByDigest(t *testing.T) { // t.Error("a digest nothing kept was answered") } } + +// 1680 live acceptance: checker_previews shows the preview key of an open pull request, as stored, narrowed by repository and head. +func TestThePreviewsToolShowsTheStoredPreviewOfAPullRequest(t *testing.T) { + pv := delivery.Preview{Repository: repoA, Head: head1, Trunk: head2, Merge: "m1", Modules: map[string]delivery.PreviewModule{}} + data, err := delivery.EncodePreview(pv) + if err != nil { + t.Fatal(err) + } + key, _ := delivery.PreviewKey(repoA, head1, head2) + rd := Reader{PreviewState: fakeState{key: data, "junk": []byte("{}")}.read()} + got, bad, err := rd.Previews(repoA, head1[:8]) + if err != nil || len(got) != 1 || got[0].Key != key || got[0].Head != head1 || got[0].Trunk != head2 || string(got[0].Preview) != string(data) { + t.Fatalf("previews = %+v, %v", got, err) + } + if len(bad) != 1 || bad[0] != "junk" { + t.Errorf("unreadable = %v", bad) + } + if got, _, _ := rd.Previews("novox/other", ""); len(got) != 0 { + t.Errorf("another repository's = %+v", got) + } + if _, _, err := (Reader{}).Previews("", ""); err == nil { + t.Error("answered with no state to read") + } +} + +// 1685 live acceptance: checker_status reads the capacity, whether a plan key holds the checker, the steps in flight by node and +// the candidates without a verdict. +func TestTheStatusToolReadsCapacityHoldsAndRunningSteps(t *testing.T) { + running, _ := delivery.EncodeStep(delivery.Step{ID: "abc1", Tool: "build_go_test", Node: "shanks", State: delivery.StepRunning, Started: time.Date(2026, 10, 22, 15, 0, 0, 0, time.UTC)}) + passed, _ := delivery.EncodeStep(delivery.Step{ID: "abc2", Tool: "build_go_test", Node: "ace", State: delivery.StepPassed}) + cand := candidateOf(t, "feat/x", pullOf(repoA, 7, head1)) + ckey, _ := delivery.CandidatesKey("feat/x") + rd := Reader{ + Capacity: func() (string, error) { return "shanks=2,ace=0", nil }, + PlansState: fakeState{"p1": []byte("{}")}.read(), + StepStates: map[string]StateRead{"mesh-build-go": fakeState{"shanks.abc1": running, "ace.abc2": passed}.read()}, + CandidateState: fakeState{ckey: cand}.read(), + VerdictState: fakeState{}.read(), + } + st, err := rd.Status() + if err != nil { + t.Fatal(err) + } + if st.Capacity["shanks"] != 2 || st.Capacity["ace"] != 0 || st.DefaultPerNode != 1 { + t.Errorf("capacity = %v default %d", st.Capacity, st.DefaultPerNode) + } + if !st.PlansBuilding.Holds || len(st.PlansBuilding.Keys) != 1 { + t.Errorf("plans = %+v", st.PlansBuilding) + } + if len(st.Running) != 1 || st.Running[0].Node != "shanks" || st.RunningByNode["shanks"] != 1 || st.RunningByNode["ace"] != 0 { + t.Errorf("running = %+v %v", st.Running, st.RunningByNode) + } + if len(st.Unjudged) != 1 || st.Unjudged[0] != "feat/x" { + t.Errorf("unjudged = %v", st.Unjudged) + } + // An absent plan bucket is read as empty and said; an unreadable capacity is said, not guessed. + st, err = Reader{Capacity: func() (string, error) { return "", os.ErrNotExist }}.Status() + if err != nil || st.PlansBuilding.Holds || st.CapacityNote == "" || len(st.Notes) == 0 { + t.Errorf("empty status = %+v, %v", st, err) + } +} diff --git a/modules/mesh-delivery-checker/manifest_test.go b/modules/mesh-delivery-checker/manifest_test.go index c3d4592..bf22da6 100644 --- a/modules/mesh-delivery-checker/manifest_test.go +++ b/modules/mesh-delivery-checker/manifest_test.go @@ -118,7 +118,7 @@ func TestManifestDeclarations(t *testing.T) { // 1678/AC1 t.Errorf("streams = %+v, want %+v", m.Streams, want) } // The read tools (issue 1686) are served by the tools bundle, not by the checker's process. - if want := []string{"checker_kept", "checker_pull", "checker_verdicts"}; !reflect.DeepEqual(sorted(m.Tools), want) { + if want := []string{"checker_kept", "checker_previews", "checker_pull", "checker_status", "checker_verdicts"}; !reflect.DeepEqual(sorted(m.Tools), want) { t.Errorf("tools = %v, want %v", m.Tools, want) } for _, k := range []string{"logs", "emits", "claims", "provides", "requires", "uses", "defines-seats", "seat", "seats", "bus"} { diff --git a/modules/mesh-delivery-checker/module.json b/modules/mesh-delivery-checker/module.json index 87c1005..8168ace 100644 --- a/modules/mesh-delivery-checker/module.json +++ b/modules/mesh-delivery-checker/module.json @@ -31,7 +31,9 @@ ], "tools": [ "checker_kept", + "checker_previews", "checker_pull", + "checker_status", "checker_verdicts" ], "invokes": [