Merge the trunk: B0's facts and checking program carry the registry's steps (issue 1921, milestone 47/G D3-D11)
B0's shape wins: the running checker asks and records facts, the checking program decides. The registry's types win: the rules and the program read the generated checker contract and the domain SDK, and no mesh-sdk delivery import is left. The registry's series, renames and unparsed seat definitions become facts with their refusals as rules; the tagged versions ride on the case. The program reads the heads' seat definitions through sdk/seatdefs and the running checker lays them over its reader's list by seat, reading no file of the heads: the reader's list is larger than a run input's bound. Claude-Session: https://claude.ai/code/session_01NcbgfFSnn24M3GcqttoU7i
This commit is contained in:
@@ -149,8 +149,13 @@ type stepSeenKey struct{}
|
||||
|
||||
// WithStepSeen is ctx carrying seen, which Asker.Wait hands every value the ask's step key takes while it waits: the node
|
||||
// that took it, each phase it says while running (buildstep.Step.Phase), and its end. A try names its asks' progress so
|
||||
// (issue 1890). seen runs in the waiting goroutine and must not block.
|
||||
// (issue 1890). seen runs in the waiting goroutine and must not block. A seen ctx already carries is kept: both are handed
|
||||
// every value, the earlier first (a check's begin bound watches the steps of a try that names them).
|
||||
func WithStepSeen(ctx context.Context, seen func(buildstep.Step)) context.Context {
|
||||
if before := stepSeenOf(ctx); before != nil {
|
||||
later := seen
|
||||
seen = func(s buildstep.Step) { before(s); later(s) }
|
||||
}
|
||||
return context.WithValue(ctx, stepSeenKey{}, seen)
|
||||
}
|
||||
|
||||
|
||||
@@ -26,10 +26,13 @@ func TestAnAskerPutsOnceAndReadsTheStep(t *testing.T) {
|
||||
return buildstep.Step{ID: a.ID, Node: "ace", State: buildstep.StepPassed, Result: []byte(`{"artifacts":[]}`)}
|
||||
}}
|
||||
k := Asker{Publisher: f, Waiter: f}
|
||||
var seen []buildstep.Step // issue 1890: a waiter that asks to see each value of the step sees it
|
||||
step, err := k.Ask(WithStepSeen(context.Background(), func(s buildstep.Step) { seen = append(seen, s) }), goAsk("mvl0frv18d1957"))
|
||||
if err != nil || step.Node != "ace" || len(f.Asks()) != 1 || len(seen) != 1 || seen[0].Node != "ace" {
|
||||
t.Fatalf("step %+v, %v, asks %d, seen %+v", step, err, len(f.Asks()), seen)
|
||||
var seen, also []buildstep.Step // issue 1890: a waiter that asks to see each value of the step sees it
|
||||
// Issue 1921: a second seen on the same context (a check's begin bound inside a try) is added, never in place of the first.
|
||||
both := WithStepSeen(WithStepSeen(context.Background(), func(s buildstep.Step) { seen = append(seen, s) }),
|
||||
func(s buildstep.Step) { also = append(also, s) })
|
||||
step, err := k.Ask(both, goAsk("mvl0frv18d1957"))
|
||||
if err != nil || step.Node != "ace" || len(f.Asks()) != 1 || len(seen) != 1 || seen[0].Node != "ace" || len(also) != 1 {
|
||||
t.Fatalf("step %+v, %v, asks %d, seen %+v and %+v", step, err, len(f.Asks()), seen, also)
|
||||
}
|
||||
f.Answer = func(a buildstep.StepAsk) buildstep.Step {
|
||||
return buildstep.Step{ID: a.ID, Node: "shanks", State: buildstep.StepRefused, Reason: "go 1.27 installed"}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// Command mesh-checking-program is the checker's checking program (novox/hq ADR 0644, ADR 0656; design 47's bootstrap
|
||||
// delivery): built from a check's heads and run in the build seat's sandbox through the seat node-build-go's ask run,
|
||||
// with no network and no bus account, it reads the check's facts from the file MESH_RUN_INPUT names and answers the
|
||||
// verdict the judging rules give them, as one JSON line on its standard output (rules.Answer).
|
||||
//
|
||||
// mesh-checking-program -verdict
|
||||
// mesh-checking-program -seats
|
||||
//
|
||||
// Asked -seats, it answers the seat list of the heads laid in the directory MESH_RUN_HEADS names (rules.SeatsAnswer).
|
||||
// It refuses facts of a version it does not read, naming it, with exit 2.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"slices"
|
||||
|
||||
"mesh-delivery-checker/rules"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:], os.Getenv, os.Stdout, os.Stderr))
|
||||
}
|
||||
|
||||
// run is the command over its arguments, environment and outputs, answering its exit.
|
||||
func run(args []string, getenv func(string) string, stdout, stderr io.Writer) int {
|
||||
var heads fs.FS
|
||||
if dir := getenv(buildstep.RunHeadsEnv); dir != "" {
|
||||
heads = os.DirFS(dir)
|
||||
}
|
||||
input := func() ([]byte, error) {
|
||||
file := getenv(buildstep.RunInputEnv)
|
||||
if file == "" {
|
||||
return nil, fmt.Errorf("%s names no file", buildstep.RunInputEnv)
|
||||
}
|
||||
return os.ReadFile(file)
|
||||
}
|
||||
line, err := rules.Program(args, input, heads)
|
||||
if err != nil {
|
||||
if heads == nil && slices.Contains(args, "-seats") {
|
||||
err = fmt.Errorf("no heads: %s names no directory", buildstep.RunHeadsEnv)
|
||||
}
|
||||
fmt.Fprintf(stderr, "mesh-checking-program: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
fmt.Fprintf(stdout, "%s\n", line)
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"mesh-delivery-checker/rules"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
|
||||
)
|
||||
|
||||
// 1921 (B0, D2): the checking program answers, on its last line, the verdict the rules give the facts in its input, and
|
||||
// refuses facts of a version it does not read, an input it is not given, and a question it is not asked, naming each.
|
||||
// 1921 (B0, D3): asked -seats, it answers the seat list of the heads laid for it: a definition a head adds, resolved with
|
||||
// the parts it includes; what it left out, and why; and an empty list for heads with no definition.
|
||||
func TestTheCheckingProgramAnswersTheVerdictOfItsFacts(t *testing.T) {
|
||||
failing, _ := json.Marshal(rules.Tested{Level: buildstep.LevelUnit, Tests: 3, Failed: []rules.Failure{{Package: "x/a", Test: "TestA", Cause: "a_test.go:9: boom"}}})
|
||||
facts := rules.Facts{V: rules.FactsVersion, Group: "feat/x", Heads: "0123456789ab", Members: []rules.Member{{Repository: "novox/x", Number: 3, Head: "h"}},
|
||||
Steps: []rules.Step{
|
||||
{Repository: "novox/x", Number: 3, Module: "mesh-build-go", Node: "laptop", Level: &buildstep.LevelAnswer{Level: buildstep.LevelUnit, Tests: []json.RawMessage{failing}}},
|
||||
{Repository: "novox/x", Number: 3, Failed: &rules.FailedFirst{Machine: "laptop", Words: "novox/x#3 has files in conflict: go.mod"}},
|
||||
}}
|
||||
dir := t.TempDir()
|
||||
write := func(name string, v any) string {
|
||||
data, _ := json.Marshal(v)
|
||||
p := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(p, data, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
v1 := write("v1.json", facts)
|
||||
later := facts
|
||||
later.V = rules.FactsVersion + 1
|
||||
v2 := write("v2.json", later)
|
||||
// The heads as the build node lays them, each at its repository's path: a contracts module of its own defining a seat
|
||||
// with a part, and the mesh's own contracts module defining a seat of a domain that is not its (ADR 0636 §5).
|
||||
lay := func(name string, files map[string]string) string {
|
||||
root := filepath.Join(dir, name)
|
||||
for p, body := range files {
|
||||
if err := os.MkdirAll(filepath.Dir(filepath.Join(root, p)), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, p), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return root
|
||||
}
|
||||
contracts := func(module string) string {
|
||||
return `{"module": "` + module + `", "build": {"artifacts": [{"name": "go", "kind": "package", "language": "go", "generated-from": "."}]}}`
|
||||
}
|
||||
heads := lay("heads", map[string]string{
|
||||
"novox/x/x-contracts/module.json": contracts("x-contracts"),
|
||||
"novox/x/x-contracts/seats/node-build-x/seat.json": `{"format":1,"seat":"node-build-x","scope":"node","grain":"whole","domain":"Delivery","decision":"ADR 1","description":"kept","includes":["build"],"verbs":[{"name":"own","input":{"type":"object"},"answer":{"type":"object"}}]}`,
|
||||
"novox/x/x-contracts/parts/build.json": `{"format":1,"part":"build","verbs":[{"name":"current","input":{"type":"object"},"answer":{"type":"object"}}],"events":[{"name":"ended","body":{"type":"object"}}]}`,
|
||||
"novox/x/contracts/module.json": contracts("mesh-contracts"),
|
||||
"novox/x/contracts/seats/mesh-catalog/seat.json": `{"format":1,"seat":"mesh-catalog","scope":"mesh","grain":"whole","domain":"Delivery","decision":"ADR 1"}`,
|
||||
})
|
||||
broken := lay("broken", map[string]string{
|
||||
"novox/x/x-contracts/module.json": contracts("x-contracts"),
|
||||
"novox/x/x-contracts/seats/broken/seat.json": `{"format":1,"seat":"broken","scope":"nowhere"}`,
|
||||
})
|
||||
none := filepath.Join(dir, "no-heads")
|
||||
if err := os.MkdirAll(filepath.Join(none, "novox/x"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, c := range map[string]struct {
|
||||
args []string
|
||||
input, heads string
|
||||
exit int
|
||||
said string
|
||||
}{
|
||||
"the seats": {args: []string{"-seats"}, input: v1, heads: heads},
|
||||
"no seats": {args: []string{"-seats"}, input: v1, heads: none},
|
||||
"a seat unparsed": {args: []string{"-seats"}, input: v1, heads: broken, exit: 2, said: "the seat definition of broken (module x-contracts) at the head of novox/x is not taken"},
|
||||
"no heads laid": {args: []string{"-seats"}, exit: 2, said: buildstep.RunHeadsEnv + " names no directory"},
|
||||
"the facts": {args: []string{"-verdict"}, input: v1},
|
||||
"a later version": {args: []string{"-verdict"}, input: v2, exit: 2, said: "facts of version 2, this checking program reads version 1"},
|
||||
"no input": {args: []string{"-verdict"}, exit: 2, said: buildstep.RunInputEnv + " names no file"},
|
||||
"nothing asked": {input: v1, exit: 2, said: "nothing asked"},
|
||||
"an unknown asking": {args: []string{"-judge"}, input: v1, exit: 2, said: "-judge"},
|
||||
} {
|
||||
var out, errs bytes.Buffer
|
||||
exit := run(c.args, func(k string) string {
|
||||
switch k {
|
||||
case buildstep.RunInputEnv:
|
||||
return c.input
|
||||
case buildstep.RunHeadsEnv:
|
||||
return c.heads
|
||||
}
|
||||
return ""
|
||||
}, &out, &errs)
|
||||
if exit != c.exit || !strings.Contains(errs.String(), c.said) {
|
||||
t.Errorf("%s: exit %d, said %q; want %d and %q", name, exit, errs.String(), c.exit, c.said)
|
||||
continue
|
||||
}
|
||||
if c.exit != 0 {
|
||||
continue
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
|
||||
if c.heads != "" {
|
||||
var sa rules.SeatsAnswer
|
||||
if err := json.Unmarshal([]byte(lines[len(lines)-1]), &sa); err != nil || sa.V != rules.SeatsVersion || sa.Seats == nil {
|
||||
t.Fatalf("%s: the last line %q: %v", name, lines[len(lines)-1], err)
|
||||
}
|
||||
if c.heads == none {
|
||||
if len(sa.Seats) != 0 || len(sa.Left) != 0 {
|
||||
t.Errorf("%s: %+v, want an empty list", name, sa)
|
||||
}
|
||||
continue
|
||||
}
|
||||
var d struct {
|
||||
Seat string `json:"seat"`
|
||||
Verbs []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"verbs"`
|
||||
}
|
||||
if len(sa.Seats) == 1 {
|
||||
_ = json.Unmarshal(sa.Seats[0], &d)
|
||||
}
|
||||
if d.Seat != "node-build-x" || len(d.Verbs) != 2 || len(sa.Left) != 1 || !strings.Contains(sa.Left[0].Why, "whose seats mesh-delivery-contracts defines") {
|
||||
t.Errorf("%s: seats %s, left %+v; want node-build-x with its part's verb, and mesh-catalog left out", name, sa.Seats, sa.Left)
|
||||
}
|
||||
continue
|
||||
}
|
||||
var a rules.Answer
|
||||
if err := json.Unmarshal([]byte(lines[len(lines)-1]), &a); err != nil || a.V != rules.AnswerVersion {
|
||||
t.Fatalf("%s: the last line %q: %v", name, lines[len(lines)-1], err)
|
||||
}
|
||||
if want := rules.Judge(facts); !reflect.DeepEqual(a.Verdict, want) || a.Verdict.State != meshdeliverycheckerclient.VerdictFail || len(a.Verdict.Failures) != 2 ||
|
||||
a.Verdict.Failures[1].Words != "novox/x#3 has files in conflict: go.mod" || a.Verdict.Machines[0] != "laptop" || a.Verdict.Levels[buildstep.LevelUnit].Run != 3 {
|
||||
t.Errorf("%s: the verdict %+v, want %+v", name, a.Verdict, want)
|
||||
}
|
||||
} // Review round 1 of milestone 47/B0: the module graph's cycle rule is the program's (ADR 0603 §2, ADR 0656 §5), over the
|
||||
// edges the running checker gathers. A cycle the change makes fails; one the trunk has among the same modules is noted.
|
||||
web, mail := rules.Edge{From: "web", To: "mail", Kind: rules.EdgeAwaits}, rules.Edge{From: "mail", To: "web", Kind: rules.EdgeAwaits}
|
||||
only := func(e rules.Edge, where string) rules.Edge { e.Only = where; return e }
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
graph rules.Graph
|
||||
fails string
|
||||
note string
|
||||
}{
|
||||
{name: "no cycle", graph: rules.Graph{Trunk: true, Edges: []rules.Edge{web}}, note: "has no cycle"},
|
||||
{name: "a cycle the change makes", graph: rules.Graph{Trunk: true, Edges: []rules.Edge{mail, only(web, rules.EdgeChange)}}, fails: "has the cycle mail -> web -> mail"},
|
||||
{name: "a cycle the trunk has", graph: rules.Graph{Trunk: true, Edges: []rules.Edge{mail, web}}, note: "which the trunk already has"},
|
||||
{name: "the trunk unread", graph: rules.Graph{Edges: []rules.Edge{mail, web}}, fails: "has the cycle mail -> web -> mail"},
|
||||
{name: "a base on itself", graph: rules.Graph{Trunk: true, Edges: []rules.Edge{only(rules.Edge{From: "web", To: "web", Kind: rules.EdgeBase}, rules.EdgeChange)}}, fails: "has the cycle web -> web"},
|
||||
{name: "an edge only the trunk has", graph: rules.Graph{Trunk: true, Edges: []rules.Edge{mail, only(web, rules.EdgeTrunk)}}, note: "has no cycle"},
|
||||
} {
|
||||
g := row.graph
|
||||
a := rules.Judged(rules.Facts{V: rules.FactsVersion, Steps: []rules.Step{{Graph: &g}}})
|
||||
if row.fails != "" && (a.Verdict.State != meshdeliverycheckerclient.VerdictFail || !strings.Contains(a.Verdict.Failed.Words, row.fails)) {
|
||||
t.Errorf("%s: verdict %+v, want a fail saying %q", row.name, a.Verdict, row.fails)
|
||||
}
|
||||
if row.note != "" && (a.Verdict.State != meshdeliverycheckerclient.VerdictPass || !strings.Contains(strings.Join(a.Notes, "\n"), row.note)) {
|
||||
t.Errorf("%s: verdict %s, notes %q, want a pass noting %q", row.name, a.Verdict.State, a.Notes, row.note)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"git.novox.be/novox/mesh-contracts/health-and-repair/go/clients/meshhandactsclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliveryclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/views"
|
||||
@@ -232,11 +233,25 @@ func SeatTryTools(tr Trying) []stdio.Tool {
|
||||
return out
|
||||
}
|
||||
|
||||
// SeatInputTools are the seat's verb input (check.InputVerb): a checking program's input as the checker keeps it, asked by
|
||||
// the Go build module that runs the program (buildstep.RunInput.Read), never read from the checker's state by it.
|
||||
func SeatInputTools(r check.Reader) []stdio.Tool {
|
||||
return []stdio.Tool{{Name: Seat + "." + meshdeliverychecker.VerbInput,
|
||||
Description: "A checking program's input the checker keeps, by the sha256 of its exact bytes, as a run ask names it: the digest " +
|
||||
"and the bytes, base64. A digest it keeps no input of, or bytes that read back as another digest, is an error. Read-only.",
|
||||
Input: map[string]any{"type": "object", "required": []string{"digest"}, "properties": map[string]any{
|
||||
"digest": map[string]any{"type": "string", "description": "the input's sha256, 64 lowercase hex digits"}}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
digest, _ := args["digest"].(string)
|
||||
return r.Input(digest)
|
||||
}}}
|
||||
}
|
||||
|
||||
// Tools are the read tools over the checker's states.
|
||||
func Tools(r check.Reader) []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{Name: check.VerdictsReadTool,
|
||||
Description: "The verdicts the checker holds, each as stored under verdicts.<group>.<heads>: its state, every failure it names one line each (every member's failed tests, refused steps and conflicts, the manifests' refusals and the composition), the tests run, kept and skipped per level, the time of each part, the trunk heads it judged against and the composition, with the full " +
|
||||
Description: "The verdicts the checker holds, each as stored under verdicts.<group>.<heads>: its state, every failure it names one line each (every member's failed tests, refused steps and conflicts, the manifests' refusals and the composition), the tests run, kept and skipped per level, the time of each part, the trunk heads it judged against, the composition and which checking program judged it (judged: its build and the heads it read), with the full " +
|
||||
"report of the status each pull request carries (the line the forge shows is cut at 140 characters, this is not). With a group, that group's only. Read-only.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"group": map[string]any{"type": "string", "description": "the delivery group's branch name"}}},
|
||||
@@ -246,7 +261,7 @@ func Tools(r check.Reader) []stdio.Tool {
|
||||
return cutAnswer(map[string]any{"verdicts": vs}, cut), err
|
||||
}},
|
||||
{Name: check.PullReadTool,
|
||||
Description: "The verdict that judges one pull request, every failure it names one line each (all of them, not only the first the status line shows), and the full report of its status mesh/check. Read-only.",
|
||||
Description: "The verdict that judges one pull request, every failure it names one line each (all of them, not only the first the status line shows), which checking program judged it (judged: its build, its key and the heads it read), and the full report of its status mesh/check. Read-only.",
|
||||
Input: map[string]any{"type": "object", "required": []string{"repository", "number"}, "properties": map[string]any{
|
||||
"repository": map[string]any{"type": "string", "description": "<owner>/<name>"},
|
||||
"number": map[string]any{"type": "integer", "description": "the pull request's number"}}},
|
||||
@@ -267,6 +282,17 @@ func Tools(r check.Reader) []stdio.Tool {
|
||||
kept, cut, err := r.Kept(level, digest)
|
||||
return cutAnswer(map[string]any{"kept": kept}, cut), err
|
||||
}},
|
||||
{Name: check.InputsReadTool,
|
||||
Description: "The checking program's inputs the checker keeps, each under the sha256 of its exact bytes (inputs.<digest>, kept a day): " +
|
||||
"the facts of a check its verdict is asked of, or the heads its seat list is asked of. With a digest, that input as kept; " +
|
||||
"without, every digest and its size. Read-only.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"digest": map[string]any{"type": "string", "description": "the input's sha256, as a run ask names it"}}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
digest, _ := args["digest"].(string)
|
||||
inputs, cut, err := r.Inputs(digest)
|
||||
return cutAnswer(map[string]any{"inputs": inputs}, cut), err
|
||||
}},
|
||||
{Name: check.PreviewsReadTool,
|
||||
Description: "The merge previews the checker holds, each as stored under previews.<repository>.<head>.<trunk>: 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) " +
|
||||
@@ -328,9 +354,9 @@ func stepBound() time.Duration {
|
||||
|
||||
func main() {
|
||||
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")),
|
||||
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"))},
|
||||
HaveState: read(stdio.State("node-engine.have")), StepBound: stepBound(),
|
||||
PreviewState: read(stdio.State("previews")), InputState: read(stdio.State(check.InputsStateName)),
|
||||
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"))},
|
||||
HaveState: read(stdio.State("node-engine.have")), StepBound: stepBound(),
|
||||
TurnState: read(stdio.State("mesh-delivery-gate.turns")), HearingState: read(stdio.State(buildrun.HearingState)),
|
||||
AskState: read(stdio.State(check.AsksState)), BuildState: read(stdio.State("mesh-delivery-builder.builds"))}
|
||||
r.Runs = runsOf{read(stdio.State("mesh-delivery-indexer.modules"))} // the node-engine at the commit the mesh runs, as the checker reads it
|
||||
@@ -376,6 +402,7 @@ func main() {
|
||||
// checker_first_views and checker_first_view_again: where the checker's first views of the indexer's and the assigner's
|
||||
// states stand, and asking one again by hand (ADR 0615, DLV-M2).
|
||||
tools = append(tools, views.StatusTools(check.CheckerModule, "checker_first_views", "checker_first_view_again", stdio.State(views.StatusState), meshhandactsclient.Recorder{Ask: stdio.Ask}, time.Now)...)
|
||||
tools = append(tools, SeatInputTools(r)...)
|
||||
if err := stdio.Serve("", logged(tools)); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[mesh-delivery-checker-tools] %v\n", err)
|
||||
os.Exit(1)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
@@ -9,7 +10,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-checker/checkermsg"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
|
||||
|
||||
"mesh-delivery-checker/internal/check"
|
||||
)
|
||||
@@ -22,7 +25,7 @@ func TestTheToolsServedAreTheFiveReadTools(t *testing.T) {
|
||||
t.Errorf("%s has no description or no run", tl.Name)
|
||||
}
|
||||
}
|
||||
want := []string{check.VerdictsReadTool, check.PullReadTool, check.KeptReadTool, check.PreviewsReadTool, check.StatusReadTool}
|
||||
want := []string{check.VerdictsReadTool, check.PullReadTool, check.KeptReadTool, check.InputsReadTool, check.PreviewsReadTool, check.StatusReadTool}
|
||||
if !slices.Equal(names, want) {
|
||||
t.Errorf("tools = %v, want %v", names, want)
|
||||
}
|
||||
@@ -145,6 +148,21 @@ func TestTheTryToolsAskAndReadATry(t *testing.T) { // ADR 0501 §5, the agreed i
|
||||
t.Errorf("the verb try requires %v and says %q: want members as an alternative to one repository and head", v.Input.Required, v.Description)
|
||||
}
|
||||
}
|
||||
// The seat's verb input (issue 1921, ADR 0596 §2): a checking program's input as kept, which the Go build module asks.
|
||||
body := []byte(`{"v":1}`)
|
||||
in, _ := buildstep.RunInputOf(check.InputVerb, body)
|
||||
inputs := SeatInputTools(check.Reader{InputState: check.StateRead{Get: func(key string) (json.RawMessage, error) {
|
||||
if key == in.Digest {
|
||||
return body, nil
|
||||
}
|
||||
return nil, nil
|
||||
}}})
|
||||
if len(inputs) != 1 || inputs[0].Name != Seat+".input" {
|
||||
t.Fatalf("input verbs = %+v", inputs)
|
||||
}
|
||||
if a, err := inputs[0].Run(map[string]any{"digest": in.Digest}); err != nil || a.(meshdeliverychecker.InputAnswer).Input != base64.StdEncoding.EncodeToString(body) {
|
||||
t.Errorf("verb input = %+v, %v", a, err)
|
||||
}
|
||||
// A group try (issue 1921): the members, each an exact commit; members and one repository together are refused; the
|
||||
// description says what it does in the words a search for "try a group" finds.
|
||||
later := TryTools(Trying{Tries: store, Ender: store, Now: func() time.Time { return time.Unix(2, 5) },
|
||||
|
||||
@@ -183,3 +183,15 @@ func (a awaitedKV) Delete(ctx context.Context, key string) error {
|
||||
}
|
||||
return s.Delete(ctx, key)
|
||||
}
|
||||
|
||||
// runInputs keeps the checking program's inputs in the checker's state inputs (check.InputsBucket), each under the
|
||||
// sha256 of its exact bytes: an input already kept under its digest is the same bytes, so a second keep is no error.
|
||||
type runInputs struct{ a awaitedKV }
|
||||
|
||||
func (r runInputs) Keep(ctx context.Context, digest string, body []byte) error {
|
||||
_, err := r.a.Create(ctx, digest, body)
|
||||
if errors.Is(err, check.ErrConflict) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
@@ -48,6 +49,8 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/keys"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
"git.novox.be/novox/mesh-sdk/go/views/viewstest"
|
||||
|
||||
"mesh-delivery-checker/rules"
|
||||
)
|
||||
|
||||
// busConf is the shape of the composed accounts file (mesh-controller internal/broker): one account with JetStream, the
|
||||
@@ -113,6 +116,118 @@ type fakeBuildModules struct {
|
||||
names []string
|
||||
// packages are, by repository, the package versions the last Go check ask of it carried (StepAsk.Packages).
|
||||
packages map[string]buildstep.PackageVersions
|
||||
// runs counts the run asks answered, and broken, when set, is why the checking program does not build (issue 1921).
|
||||
runs atomic.Int32
|
||||
broken atomic.Value
|
||||
// drop, when set, drops every run ask unanswered and counts it in dropped, as a build module that does not know the job
|
||||
// run (the lead's ruling on milestone 47/B0).
|
||||
drop atomic.Bool
|
||||
dropped atomic.Int32
|
||||
}
|
||||
|
||||
// droppingAsks is the seat's asks as a build module that does not know the job run takes them: a run ask is acknowledged and
|
||||
// never begun while the fake drops them.
|
||||
type droppingAsks struct {
|
||||
gobuild.AskSource
|
||||
f *fakeBuildModules
|
||||
}
|
||||
|
||||
func (d droppingAsks) Next(ctx context.Context, kind string, wait time.Duration) (*gobuild.Pulled, error) {
|
||||
msg, err := d.AskSource.Next(ctx, kind, wait)
|
||||
if err != nil || msg == nil || !d.f.drop.Load() {
|
||||
return msg, err
|
||||
}
|
||||
var a buildstep.StepAsk
|
||||
if json.Unmarshal(msg.Data, &a) == nil && a.Run != nil {
|
||||
d.f.dropped.Add(1)
|
||||
return nil, msg.Ack()
|
||||
}
|
||||
return msg, nil
|
||||
}
|
||||
|
||||
// run answers a run ask as mesh-build-go does: the checking program's work (rules.Program) over the input the ask names,
|
||||
// asked of the seat verb it names on the bus and checked against its digest (buildstep.RunInput.Read, as mesh-build-go
|
||||
// reads it), with no heads of its own laid.
|
||||
func (f *fakeBuildModules) run(ctx context.Context, a buildstep.StepAsk) (any, error) {
|
||||
f.runs.Add(1)
|
||||
if why, _ := f.broken.Load().(string); why != "" {
|
||||
return nil, errors.New(why)
|
||||
}
|
||||
answer := buildstep.RunAnswer{Built: true, Key: "program-0123456789abcdef"}
|
||||
// As mesh-build-go does, the input is asked before the program runs, and a step whose input cannot be had fails.
|
||||
body, err := meshdeliverycheckerclient.ReadInput(func(key string, body any) (json.RawMessage, error) { return askVerb(f.js.Conn(), key, body) }, a.Run.Input.Digest)
|
||||
if err == nil {
|
||||
err = a.Run.Input.Verify(body)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out, err := rules.Program(a.Run.Args, func() ([]byte, error) { return body, nil }, fstest.MapFS{})
|
||||
if err != nil {
|
||||
answer.Exit, answer.Said = 2, err.Error()
|
||||
}
|
||||
answer.Output = out
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// askVerb asks a seat verb on the subject that names no caller and reads the runtime's reply, {result, error}.
|
||||
func askVerb(nc *nats.Conn, key string, body any) (json.RawMessage, error) {
|
||||
seat, verb, ok := strings.Cut(strings.TrimPrefix(key, "seat:"), ".")
|
||||
if !ok || !strings.HasPrefix(key, "seat:") {
|
||||
return nil, fmt.Errorf("%q names no seat verb", key)
|
||||
}
|
||||
data, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msg, err := nc.Request("mesh.seat."+seat+".tool."+verb, data, 5*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var r struct {
|
||||
Result json.RawMessage `json:"result"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(msg.Data, &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.Error != "" {
|
||||
return nil, errors.New(r.Error)
|
||||
}
|
||||
return r.Result, nil
|
||||
}
|
||||
|
||||
// serveInputVerb answers the checker seat's verb input on the bus as the checker's tools do (check.Reader.Input over the
|
||||
// checker's state inputs), and counts what it answered (issue 1921, ADR 0596 §2).
|
||||
func (b *bed) serveInputVerb(t *testing.T, nc *nats.Conn) {
|
||||
t.Helper()
|
||||
kv := b.buckets[check.InputsBucket]
|
||||
r := check.Reader{InputState: check.StateRead{Get: func(key string) (json.RawMessage, error) {
|
||||
e, err := kv.Get(b.ctx, key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.Value(), nil
|
||||
}}}
|
||||
if _, err := nc.Subscribe("mesh.seat.mesh-delivery-checker.tool."+meshdeliverychecker.VerbInput, func(m *nats.Msg) {
|
||||
var ask meshdeliverychecker.InputInput
|
||||
reply := map[string]any{}
|
||||
if err := json.Unmarshal(m.Data, &ask); err != nil {
|
||||
reply["error"] = err.Error()
|
||||
} else if a, err := r.Input(ask.Digest); err != nil {
|
||||
reply["error"] = err.Error()
|
||||
} else {
|
||||
reply["result"] = a
|
||||
b.inputsAnswered.Add(1)
|
||||
}
|
||||
data, _ := json.Marshal(reply)
|
||||
_ = m.Respond(data)
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeBuildModules) count(tool string) int {
|
||||
@@ -213,7 +328,7 @@ func (f *fakeBuildModules) serve(t *testing.T, module string, kept jetstream.Key
|
||||
raw, _ := value.(json.RawMessage)
|
||||
return kv.Put(f.ctx, key, raw)
|
||||
}
|
||||
p := &gobuild.Puller{Source: &gobuild.JetStreamAsks{JS: f.js, Language: language}, Language: language, Node: "n1", Put: put,
|
||||
p := &gobuild.Puller{Source: droppingAsks{&gobuild.JetStreamAsks{JS: f.js, Language: language}, f}, Language: language, Node: "n1", Put: put,
|
||||
Capacity: func() int { return 2 }, PullWait: 200 * time.Millisecond,
|
||||
Run: func(ctx context.Context, a buildstep.StepAsk) (any, error) {
|
||||
if a.Check != nil {
|
||||
@@ -227,6 +342,9 @@ func (f *fakeBuildModules) serve(t *testing.T, module string, kept jetstream.Key
|
||||
}
|
||||
f.mu.Unlock()
|
||||
}
|
||||
if a.Run != nil {
|
||||
return f.run(ctx, a)
|
||||
}
|
||||
return gobuild.RunAsk(ctx, steps, a)
|
||||
}}
|
||||
go func() { _ = p.Pull(f.ctx) }()
|
||||
@@ -290,16 +408,18 @@ func mustRead(t *testing.T, name string) []byte {
|
||||
type bed struct {
|
||||
stalePin atomic.Bool
|
||||
// heads are the manifests the forge holds at a member's head, by "<repository> <path> <ref>".
|
||||
heads sync.Map
|
||||
ctx context.Context
|
||||
js jetstream.JetStream
|
||||
buckets map[string]jetstream.KeyValue
|
||||
tools map[string]jetstream.KeyValue
|
||||
fake *fakeBuildModules
|
||||
srvURL string
|
||||
announce []string
|
||||
mu sync.Mutex
|
||||
logs saidLog
|
||||
heads sync.Map
|
||||
ctx context.Context
|
||||
js jetstream.JetStream
|
||||
buckets map[string]jetstream.KeyValue
|
||||
tools map[string]jetstream.KeyValue
|
||||
// inputsAnswered counts the inputs the seat's verb input answered (issue 1921).
|
||||
inputsAnswered atomic.Int32
|
||||
fake *fakeBuildModules
|
||||
srvURL string
|
||||
announce []string
|
||||
mu sync.Mutex
|
||||
logs saidLog
|
||||
}
|
||||
|
||||
// saidLog is what the program said, safe for its goroutines and the test's.
|
||||
@@ -471,7 +591,7 @@ func startBed(t *testing.T) *bed {
|
||||
check.CheckerOwedBucket: 5, check.PreviewsBucket: 5, check.CandidatesBucket: 10,
|
||||
check.ModulesBucket: 20, check.ManifestsBucket: 1, check.SourceBucket: 64,
|
||||
check.PresenceBucket: 20, check.HaveBucket: 20, check.TriesBucket: 1,
|
||||
check.TurnsBucket: 5, builds.BuilderBuildsBucket: 1} {
|
||||
check.TurnsBucket: 5, builds.BuilderBuildsBucket: 1, check.InputsBucket: 1} {
|
||||
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: name, History: uint8(history)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -499,6 +619,7 @@ func startBed(t *testing.T) *bed {
|
||||
b.setHealth(t, "n1", module)
|
||||
}
|
||||
b.serveForge(t, ctl)
|
||||
b.serveInputVerb(t, ctl)
|
||||
b.fake = &fakeBuildModules{js: js, ctx: ctx, calls: map[string]int{}}
|
||||
b.fake.serve(t, "mesh-build-go", b.buckets[check.CheckerKeptBucket])
|
||||
b.fake.serve(t, "mesh-build-node", b.buckets[check.CheckerKeptBucket])
|
||||
@@ -730,6 +851,19 @@ func TestCheckerJudgesACandidateAndOwesItsStatus(t *testing.T) { // N6/AC5, 1300
|
||||
if got := asked(); len(got) == 0 || got[0].Candidate != key {
|
||||
t.Errorf("n1's engine was asked %+v, want the check's candidate %s", got, key)
|
||||
}
|
||||
// Issue 1921 (B0): the verdict is the checking program's, built at the group's mesh-delivery head over its input kept in
|
||||
// the state inputs and asked of the seat's verb input, two run asks a check (the seat list, the verdict); the verdict names the program's build.
|
||||
if by := v.JudgedBy; by == nil || by.Program.Commit != head1 || by.Program.Package != check.CheckingProgramPackage ||
|
||||
by.Key != "program-0123456789abcdef" || len(by.Heads) != 1 || by.Heads[0].Commit != head1 {
|
||||
t.Errorf("judged by %+v, want the checking program at the head %s", v.JudgedBy, head1)
|
||||
}
|
||||
if n := b.fake.runs.Load(); n != 4 {
|
||||
t.Errorf("%d run asks, want two for each of the two checks", n)
|
||||
}
|
||||
// The operator's answer on B0: each run's input reached the build module through the seat's verb input on the bus.
|
||||
if n := b.inputsAnswered.Load(); n != 4 {
|
||||
t.Errorf("the verb input answered %d inputs, want one for each run ask", n)
|
||||
}
|
||||
b.fake.mu.Lock()
|
||||
names := b.fake.names
|
||||
b.fake.mu.Unlock()
|
||||
@@ -834,28 +968,54 @@ func keptCount(t *testing.T, b *bed) int {
|
||||
return len(keys)
|
||||
}
|
||||
|
||||
// TestAHeadThatDoesNotMergeFailsNamingTheFilesAndRunsNoTest: the fetch answers the files in conflict; the verdict fails naming
|
||||
// them, no test or digest step is run, a preview of the conflicts alone is written and the status is owed as failure.
|
||||
func TestAHeadThatDoesNotMergeFailsNamingTheFilesAndRunsNoTest(t *testing.T) { // 1289/AC4
|
||||
b := startBed(t)
|
||||
b.fake.conflict.Store(true)
|
||||
key := b.candidate(t, "feat/x", pull(repo, 7, head1))
|
||||
b.start(t, nil)
|
||||
v := b.verdict(t, key, ended)
|
||||
if v.State != meshdeliverycheckerclient.VerdictFail || !strings.Contains(v.Failed.Words, "files in conflict: a.go, sub/b.go") {
|
||||
t.Fatalf("verdict = %+v", v)
|
||||
}
|
||||
if o := b.owed(t, 7, head1, "failure"); !strings.HasPrefix(o.Text, "failed: n1: ") || !strings.Contains(o.Text, "files in conflict: a.go, sub/b.go") {
|
||||
t.Errorf("owed = %+v", o)
|
||||
}
|
||||
if n := b.fake.count("mesh-build-go.build_go_digest") + b.fake.count("mesh-build-go.build_go_test"); n != 0 {
|
||||
t.Errorf("%d digest and test steps ran after a conflict", n)
|
||||
}
|
||||
pk, _ := meshdeliverycheckerclient.PreviewKey(repo, head1, trunk)
|
||||
if e, err := b.buckets[check.PreviewsBucket].Get(b.ctx, pk); err != nil {
|
||||
t.Errorf("previews.%s: %v", pk, err)
|
||||
} else if pv, err := meshdeliverycheckerclient.DecodePreview(e.Value()); err != nil || strings.Join(pv.Conflicts, ",") != "a.go,sub/b.go" || pv.Merge != "" {
|
||||
t.Errorf("preview = %+v, %v, want the files in conflict alone", pv, err)
|
||||
// TestACheckThatCannotBeJudgedFailsNamingWhy: a head that does not merge fails naming the files in conflict and runs no
|
||||
// test (1289/AC4); a checking program whose run asks no build node begins, and one that does not build, fail the check
|
||||
// naming the checking program, never judged by the running rules (issue 1921, the lead's ruling on milestone 47/B0: the
|
||||
// begin bound is the checker's setting, shortened here).
|
||||
func TestACheckThatCannotBeJudgedFailsNamingWhy(t *testing.T) { // 1289/AC4, 1921
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
set func(f *fakeBuildModules)
|
||||
env map[string]string
|
||||
words string
|
||||
tests bool // the digest and test steps ran
|
||||
dropped int32 // the run asks dropped: the seat list's, put twice, and no verdict asked after it
|
||||
program bool // the checking program judged it
|
||||
}{
|
||||
{name: "a head that does not merge", set: func(f *fakeBuildModules) { f.conflict.Store(true) }, words: "files in conflict: a.go, sub/b.go", program: true},
|
||||
{name: "a run ask no build node begins", set: func(f *fakeBuildModules) { f.drop.Store(true) }, env: map[string]string{"MESH_CHECKER_BEGIN_BOUND": "500ms"},
|
||||
words: "no build node began the job run of the asks", tests: true, dropped: 2},
|
||||
{name: "a program that does not build", set: func(f *fakeBuildModules) { f.broken.Store("go build: undefined: rules.Nothing") },
|
||||
words: "by the checking program: its run ask", tests: true},
|
||||
} {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
b := startBed(t)
|
||||
row.set(b.fake)
|
||||
key := b.candidate(t, "feat/x", pull(repo, 7, head1))
|
||||
b.start(t, row.env)
|
||||
v := b.verdict(t, key, ended)
|
||||
if v.State != meshdeliverycheckerclient.VerdictFail || !strings.Contains(v.Failed.Words, row.words) || (v.JudgedBy != nil) != row.program {
|
||||
t.Fatalf("verdict = %+v, want a fail saying %q, judged by the program %v", v, row.words, row.program)
|
||||
}
|
||||
if o := b.owed(t, 7, head1, "failure"); !strings.HasPrefix(o.Text, "failed") ||
|
||||
!strings.Contains(o.Text, map[bool]string{true: row.words, false: "the checking program"}[row.program]) {
|
||||
t.Errorf("owed = %+v", o)
|
||||
}
|
||||
if n := b.fake.count("mesh-build-go.build_go_digest") + b.fake.count("mesh-build-go.build_go_test"); (n != 0) != row.tests {
|
||||
t.Errorf("%d digest and test steps ran, want them run %v", n, row.tests)
|
||||
}
|
||||
if n := b.fake.dropped.Load(); n != row.dropped {
|
||||
t.Errorf("%d run asks dropped, want %d", n, row.dropped)
|
||||
}
|
||||
if !row.tests {
|
||||
pk, _ := meshdeliverycheckerclient.PreviewKey(repo, head1, trunk)
|
||||
if e, err := b.buckets[check.PreviewsBucket].Get(b.ctx, pk); err != nil {
|
||||
t.Errorf("previews.%s: %v", pk, err)
|
||||
} else if pv, err := meshdeliverycheckerclient.DecodePreview(e.Value()); err != nil || strings.Join(pv.Conflicts, ",") != "a.go,sub/b.go" || pv.Merge != "" {
|
||||
t.Errorf("preview = %+v, %v, want the files in conflict alone", pv, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1137,7 +1297,7 @@ func TestTheCheckerSaysOwedReadableOnTheBus(t *testing.T) { // R2-1
|
||||
}
|
||||
if said.Module != "mesh-delivery-checker" || said.Node != "n1" || !said.Keeps("owed") || !said.Readable("owed") ||
|
||||
!said.Readable("previews") || !said.Readable("verdicts") || !said.Readable("kept") || !said.Readable("tries") || said.Readable("hearing") ||
|
||||
!said.Keeps("asks") || said.Readable("asks") || !said.Keeps("first-views") || said.Readable("first-views") || !said.Keeps("findings") || len(said.States) != 9 {
|
||||
t.Fatalf("the checker said %+v, want its nine states, asks, hearing and first-views its own (DLV-M2), with kept, owed, previews, tries and verdicts readable, tries by the delivery view (1929/AC4; 1887 A5b; build_usage)", said)
|
||||
!said.Keeps("asks") || said.Readable("asks") || !said.Keeps("first-views") || said.Readable("first-views") || !said.Keeps("findings") || !said.Keeps("inputs") || said.Readable("inputs") || len(said.States) != 10 {
|
||||
t.Fatalf("the checker said %+v, want its ten states, asks, hearing, first-views and inputs its own (DLV-M2), with kept, owed, previews, tries and verdicts readable, tries by the delivery view (1929/AC4; 1887 A5b; build_usage)", said)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,10 +30,14 @@ import (
|
||||
|
||||
// judgeDeps are the parts the judge of a check is made of.
|
||||
type judgeDeps struct {
|
||||
asks check.Asks
|
||||
renames check.Renames
|
||||
seatDefs check.SeatDefinitions
|
||||
// seats are the seat definitions the manifests are judged against (seatlist.go).
|
||||
// inputs keeps the checking program's inputs (check.InputsStateName, served on check.InputVerb).
|
||||
inputs check.Inputs
|
||||
// beginBound is how long a run ask may wait for a build node to begin it; check.DefaultBeginBound when 0.
|
||||
beginBound time.Duration
|
||||
asks check.Asks
|
||||
renames check.Renames
|
||||
seatDefs check.SeatDefinitions
|
||||
// seats are the seat list the checker's reader holds, the base the checking program lays the heads over (seatlist.go).
|
||||
seats check.SeatList
|
||||
tools *buildrun.ToolchainSet
|
||||
trunks *check.Trunks
|
||||
@@ -46,7 +50,6 @@ type judgeDeps struct {
|
||||
// published is the indexer's packages state as it is followed (packages.go).
|
||||
published *publishedPackages
|
||||
forge check.Forge
|
||||
trees check.Trees
|
||||
announce check.Announcer
|
||||
builds check.BuildStates
|
||||
// ends waits on the builder's builds of a candidate before the check composes (ADR 0603 §10); nil composes without waiting.
|
||||
@@ -231,7 +234,7 @@ func newJudge(d judgeDeps) check.Judge {
|
||||
nodes = d.nodes
|
||||
}
|
||||
ex := &check.Examiner{Nodes: nodes, Compositions: d.compositions, Asks: d.asks, Toolchains: d.tools,
|
||||
Kept: check.Kept{Store: d.kept}, Previews: d.previews, Candidates: d.candidates, Modules: d.repos, Packages: d.repos, Published: d.published, KnownPackages: d.published, Forge: d.forge, Runs: d.repos, Builds: d.builds, BuildEnds: d.ends, BuildWait: d.buildWait, Announce: d.announce, Trunks: d.trunks, Manifests: d.repos, Read: d.repos, Dirs: d.repos, Predefined: d.predefined, Tags: d.tags, Renames: d.renames, SeatDefinitions: d.seatDefs, SeatList: d.seats, Trees: d.trees, Logf: d.logf}
|
||||
Kept: check.Kept{Store: d.kept}, Previews: d.previews, Candidates: d.candidates, Modules: d.repos, Packages: d.repos, Published: d.published, KnownPackages: d.published, Forge: d.forge, Runs: d.repos, Builds: d.builds, BuildEnds: d.ends, BuildWait: d.buildWait, Announce: d.announce, Trunks: d.trunks, Manifests: d.repos, Read: d.repos, Dirs: d.repos, Predefined: d.predefined, Tags: d.tags, Renames: d.renames, SeatDefinitions: d.seatDefs, SeatList: d.seats, Inputs: d.inputs, BeginBound: d.beginBound, Logf: d.logf}
|
||||
if d.nodes != nil && d.builds != nil && d.repos != nil {
|
||||
ex.Serving = runningServes{have: d.nodes, builds: d.builds, read: d.repos, logf: d.logf}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ func TestTheCheckerAnnouncesEveryStateItKeepsAndOwedReadable(t *testing.T) { //
|
||||
t.Errorf("announced %v, the manifest keeps %v", announced, manifest)
|
||||
}
|
||||
// kept is read by the Go build module for a check ask (issue 1887 design limit 1); tries by the delivery view (build_usage).
|
||||
// inputs are the checker's own: the Go build module asks the seat's verb input for one (ADR 0596 §2, issue 1921).
|
||||
if want := []string{"kept", "owed", "previews", "tries", "verdicts"}; !equal(readable, want) {
|
||||
t.Errorf("readable %v, want %v", readable, want)
|
||||
}
|
||||
|
||||
@@ -101,6 +101,10 @@ var keptStates = []announce.State{
|
||||
{Name: "hearing"},
|
||||
// The asks each running check put (check.AsksState): read by the checker's own checker_ask_again alone.
|
||||
{Name: "asks"},
|
||||
// The checking program's inputs by their digest (check.InputsStateName): the checker's own, read by its tools alone,
|
||||
// which serve one on the seat's verb input to the Go build module that runs the program (check.InputVerb; ADR 0596 §2,
|
||||
// issue 1921).
|
||||
{Name: check.InputsStateName},
|
||||
{Name: "kept", Readable: true},
|
||||
{Name: "owed", Readable: true},
|
||||
{Name: "previews", Readable: true},
|
||||
@@ -187,6 +191,11 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The begin bound of a run ask of the checking program (check.DefaultBeginBound, issue 1921).
|
||||
beginBound, err := durationEnv(getenv, "MESH_CHECKER_BEGIN_BOUND", check.DefaultBeginBound)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
link := strings.TrimSpace(getenv("MESH_CHECKER_LINK"))
|
||||
if link == "" {
|
||||
link = defaultLink
|
||||
@@ -215,6 +224,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
conn.SetReconnectHandler(func(*nats.Conn) { hearing.Again(); heard.Nudge("") })
|
||||
waiter := buildrun.LostAskWaiter{Inner: heard, Dead: dead, Hearing: hearing}
|
||||
asksKV := process.AwaitBucket(ctx, js, conn, check.AsksBucket, "checker_ask_again refuses every check, as it cannot tell whether its ask waits in a stream", logger.Printf)
|
||||
inputsKV := process.AwaitBucket(ctx, js, conn, check.InputsBucket, "no checking program can be asked, so every check fails naming it", logger.Printf)
|
||||
ch := &check.Checker{
|
||||
Asks: awaitedKV{asksKV},
|
||||
Verdicts: kvStore{buckets[check.CheckerVerdictsBucket]},
|
||||
@@ -290,8 +300,8 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
ch.Judge = newJudge(judgeDeps{
|
||||
asks: buildrun.Asker{Publisher: asks, Waiter: waiter, Bound: stepBound}, tools: tools, trunks: trunks,
|
||||
kept: kvStore{buckets[check.CheckerKeptBucket]}, previews: kvStore{buckets[check.PreviewsBucket]}, candidates: tryCandidates{candidates},
|
||||
repos: repos, published: published, builds: buildStates, ends: ends, buildWait: set.buildWait, forge: forgeReads{call: caller.Call}, trees: forgeReads{call: caller.Call}, logf: logger.Printf,
|
||||
announce: announce, compositions: compositions, tags: tags,
|
||||
repos: repos, published: published, builds: buildStates, ends: ends, buildWait: set.buildWait, forge: forgeReads{call: caller.Call}, logf: logger.Printf,
|
||||
announce: announce, compositions: compositions, tags: tags, inputs: runInputs{awaitedKV{inputsKV}}, beginBound: beginBound,
|
||||
nodes: openNodeNames(ctx, js, logger.Printf),
|
||||
predefined: keptPredefined{predefined},
|
||||
renames: seatRenames{call: caller.Call},
|
||||
|
||||
@@ -2,31 +2,26 @@ package check
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"mesh-delivery-checker/rules"
|
||||
)
|
||||
|
||||
// A change that adds or modifies a compiled program fails (issue 1577): it is build output, `go build` in a module's folder
|
||||
// writes one beside go.mod, and `git add -A` takes it. The build module's fetch step, which holds the merged clone, reads the
|
||||
// first bytes of each changed file and reports the programs (the build modules' fetch tools); the checker judges the answer.
|
||||
|
||||
// checkBinariesOf fails a pull request whose change adds or modifies a compiled program, naming each file. It judges the
|
||||
// first fetch answer of the pull request, whichever build module gave it. A fetch that made the merge and names no program has
|
||||
// none: the answer's `programs` is an omitted empty list (B2g), and `merge` tells a preview was made.
|
||||
func (st *state) checkBinariesOf(t *target) verdictPtr {
|
||||
// checkBinariesOf records what a pull request's first fetch answer read of compiled programs, whichever build module
|
||||
// gave it, as a fact the rules judge (rules.JudgePrograms). A fetch that made the merge and names no program has none: the
|
||||
// answer's `programs` is an omitted empty list (B2g), and `merge` tells a preview was made.
|
||||
func (st *state) checkBinariesOf(t *target) {
|
||||
key := fmt.Sprintf("%s#%d", t.pull.Repository, t.pull.Number)
|
||||
if t.fetched.Merge == "" {
|
||||
return nil // no merge was made: nothing was changed to judge
|
||||
return // no merge was made: nothing was changed to judge
|
||||
}
|
||||
var found []string
|
||||
for _, p := range t.fetched.Programs {
|
||||
if p.Commit != "" {
|
||||
found = append(found, fmt.Sprintf("%s: a compiled program added in %s and removed later: it stays in history; squash the branch (issue 1577)", p.Path, short12(p.Commit)))
|
||||
continue
|
||||
}
|
||||
found = append(found, p.Path+": a compiled program is build output: remove it and add it to .gitignore (issue 1577)")
|
||||
files := t.fetched.Programs
|
||||
if files == nil {
|
||||
files = []ProgramFile{} // reported, and none: the rules refuse nothing (a nil list is a module that reports none)
|
||||
}
|
||||
if len(found) > 0 {
|
||||
return failed(t.node, fmt.Sprintf("%s: %s", key, strings.Join(found, "; ")))
|
||||
}
|
||||
return nil
|
||||
st.facts = append(st.facts, rules.Step{Repository: t.pull.Repository, Number: t.pull.Number, Module: t.module, Node: t.node,
|
||||
Programs: &rules.Programs{Key: key, Files: &files}})
|
||||
}
|
||||
|
||||
@@ -125,19 +125,20 @@ func (j *Examiner) buildWait() time.Duration {
|
||||
// awaitBuilds waits for the candidate's builds before the check composes (ADR 0603 §10): the check builds what the builder
|
||||
// builds of the candidate and composes with it. A failed build fails the check, naming the module and the builder's words;
|
||||
// a build not ended within the bound is named, and the check composes without it. It answers the words of the composition's
|
||||
// warnings: what the check composes without waiting on its build, and why.
|
||||
func (st *state) awaitBuilds(ctx context.Context, v *meshdeliverychecker.VerdictsByGroupHeadsValue) []string {
|
||||
// warnings (what the check composes without waiting on its build, and why) and the failures of the builds that failed, for
|
||||
// the rules to take in (rules.JudgeComposition); failed says the check has failed already.
|
||||
func (st *state) awaitBuilds(ctx context.Context, failed bool) ([]string, []FailedFirst) {
|
||||
wait, words := st.buildsOfTheCheck(ctx)
|
||||
if len(wait) == 0 {
|
||||
return words
|
||||
return words, nil
|
||||
}
|
||||
modules := slices.Sorted(maps.Keys(wait))
|
||||
if v.State == meshdeliverycheckerclient.VerdictFail {
|
||||
if failed {
|
||||
// The check has failed already: its red status is never held back by a build (the lead, 2026-11-03).
|
||||
return append(words, fmt.Sprintf("the check failed before its builds were waited on, so it composes at once, without waiting on the builds of %s (ADR 0603 §10)", strings.Join(modules, ", ")))
|
||||
return append(words, fmt.Sprintf("the check failed before its builds were waited on, so it composes at once, without waiting on the builds of %s (ADR 0603 §10)", strings.Join(modules, ", "))), nil
|
||||
}
|
||||
if st.j.BuildEnds == nil {
|
||||
return append(words, fmt.Sprintf("the builds of %s cannot be heard, so the check composes without waiting on them (ADR 0603 §10)", strings.Join(modules, ", ")))
|
||||
return append(words, fmt.Sprintf("the builds of %s cannot be heard, so the check composes without waiting on them (ADR 0603 §10)", strings.Join(modules, ", "))), nil
|
||||
}
|
||||
bound := st.j.buildWait()
|
||||
waits := fmt.Sprintf("waits on the builder's builds of %s before it composes, at most %s (ADR 0603 §10)", strings.Join(modules, ", "), bound)
|
||||
@@ -148,25 +149,21 @@ func (st *state) awaitBuilds(ctx context.Context, v *meshdeliverychecker.Verdict
|
||||
cancel()
|
||||
if err != nil {
|
||||
st.j.logf("%s: the builds of %s cannot be heard: %v", st.c.Key, strings.Join(modules, ", "), err)
|
||||
return append(words, fmt.Sprintf("the builds of %s cannot be heard (%v), so the check composes without waiting on them (ADR 0603 §10)", strings.Join(modules, ", "), err))
|
||||
return append(words, fmt.Sprintf("the builds of %s cannot be heard (%v), so the check composes without waiting on them (ADR 0603 §10)", strings.Join(modules, ", "), err)), nil
|
||||
}
|
||||
var late []string
|
||||
var failedBuilds []FailedFirst
|
||||
for _, module := range modules {
|
||||
b, done := ended[module]
|
||||
switch {
|
||||
case !done:
|
||||
late = append(late, module)
|
||||
case b.State == builds.BuildFailed:
|
||||
f := FailedFirst{Test: BuildFailedTest + module, Words: fmt.Sprintf("the candidate's build at %s failed (rebuild with builder_build_again; the check asks itself again when the build is done): %s", wait[module], b.Error)}
|
||||
if v.State != meshdeliverycheckerclient.VerdictFail {
|
||||
v.State, v.Failed = meshdeliverycheckerclient.VerdictFail, &f
|
||||
continue
|
||||
}
|
||||
addFailure(v, f)
|
||||
failedBuilds = append(failedBuilds, FailedFirst{Test: BuildFailedTest + module, Words: fmt.Sprintf("the candidate's build at %s failed (rebuild with builder_build_again; the check asks itself again when the build is done): %s", wait[module], b.Error)})
|
||||
}
|
||||
}
|
||||
if len(late) > 0 && ctx.Err() == nil {
|
||||
words = append(words, fmt.Sprintf("the builds of %s did not end within the build-wait-bound of %s, so the check composes without them (ADR 0603 §10)", strings.Join(late, ", "), bound))
|
||||
}
|
||||
return words
|
||||
return words, failedBuilds
|
||||
}
|
||||
|
||||
@@ -44,11 +44,15 @@ type Case struct {
|
||||
Left []string
|
||||
// Queued is when the checker took the case up, for the time of its queue.
|
||||
Queued time.Time
|
||||
// Seats is the seat list the checking program made, the checker's reader's list with the heads' definitions laid over
|
||||
// it, which the manifest validator's and the node-engine composer's requests carry (ADR 0636 §1, §4); none until it is
|
||||
// made.
|
||||
Seats []json.RawMessage
|
||||
// Try narrows a try's check (its level, its tests); nil for a pull request's.
|
||||
Try *TryOptions
|
||||
// Seats is the seat list the check composes against (state.checkSeats), raw, handed to the composer of a node-engine
|
||||
// member's head; nil when the checker's reader has not read its list.
|
||||
Seats []json.RawMessage
|
||||
// Tagged are the group's tagged versions of its Go packages the check heard (design 51 *Tagged versions*), nil for none:
|
||||
// a composer built at the node-engine member's head is built against them, as the check's other Go builds are.
|
||||
Tagged buildstep.PackageVersions
|
||||
|
||||
notesMu sync.Mutex
|
||||
// warnings are, by pull request, what part of its check did not run (Case.warn).
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"mesh-delivery-checker/rules"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -464,7 +465,7 @@ func (ch *Checker) untrunked(ctx context.Context, c meshcatalog.CandidatesByGrou
|
||||
continue
|
||||
}
|
||||
o := meshdeliverychecker.OwedByRepositoryNumberHeadActValue{Act: meshdeliveryclient.OwedStatus, Context: meshdeliveryclient.StatusContextCheck, State: "error",
|
||||
Target: strings.TrimSuffix(ch.Link, "/") + "/" + key, Text: clipLine(words, MaxLine)}
|
||||
Target: strings.TrimSuffix(ch.Link, "/") + "/" + key, Text: rules.ClipLine(words, MaxLine)}
|
||||
data, err := EncodeCheckerOwed(o)
|
||||
if err != nil {
|
||||
ch.logf("%s: the status of %s cannot be written: %v", key, okey, err)
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
|
||||
)
|
||||
|
||||
// withComposition is v with the check's composition taken in: a failed one fails the check, naming its machine.
|
||||
func withComposition(v *meshdeliverychecker.VerdictsByGroupHeadsValue, comp meshwants.CompositionsByGroupHeadsValue) {
|
||||
v.Composition = compositionOf(comp)
|
||||
if v.Took == nil {
|
||||
v.Took = &meshdeliverychecker.VerdictsByGroupHeadsValueTook{}
|
||||
}
|
||||
v.Took.Composition = comp.Took
|
||||
if comp.State == meshdeliverycheckerclient.CompositionPass && len(comp.Warnings) > 0 && v.Composition != nil {
|
||||
v.Composition.Words = strings.Join(comp.Warnings, "; ") // passed with a warning (ADR 0593 §9)
|
||||
}
|
||||
if comp.State == meshdeliverycheckerclient.CompositionFail {
|
||||
machine := comp.Machine
|
||||
if machine == "" {
|
||||
machine = "the composition"
|
||||
}
|
||||
f := FailedFirst{Machine: machine, Words: comp.Words}
|
||||
if v.State == meshdeliverycheckerclient.VerdictFail {
|
||||
addFailure(v, f) // the tests failed too: the composition is named after them (the operator, 2026-11-02)
|
||||
return
|
||||
}
|
||||
v.State, v.Failed = meshdeliverycheckerclient.VerdictFail, &f
|
||||
}
|
||||
}
|
||||
|
||||
// compositionOf is what the checker writes into its verdict from the assigner's composition answer c: nil while c waits (the
|
||||
// check is still running).
|
||||
func compositionOf(c meshwants.CompositionsByGroupHeadsValue) *meshdeliverychecker.VerdictsByGroupHeadsValueComposition {
|
||||
if c.State == compositionWaits {
|
||||
return nil
|
||||
}
|
||||
return &meshdeliverychecker.VerdictsByGroupHeadsValueComposition{State: c.State, Words: c.Words, Sdk: c.Sdk, Machines: c.Machines}
|
||||
}
|
||||
@@ -5,22 +5,24 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"maps"
|
||||
"mesh-delivery-checker/rules"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
ne "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
|
||||
buildsrec "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// fakeCompositions answers a check's composition from memory, recording the keys asked.
|
||||
type fakeCompositions struct {
|
||||
v meshwants.CompositionsByGroupHeadsValue
|
||||
v rules.Composed
|
||||
err error
|
||||
asked []string
|
||||
// seats are the seat lists the cases asked carried (Case.Seats), one per ask.
|
||||
@@ -29,22 +31,37 @@ type fakeCompositions struct {
|
||||
waits bool
|
||||
}
|
||||
|
||||
func (f *fakeCompositions) Compose(ctx context.Context, c *Case, _ buildstep.PackageVersions) (meshwants.CompositionsByGroupHeadsValue, error) {
|
||||
func (f *fakeCompositions) Compose(ctx context.Context, c *Case) (rules.Composed, error) {
|
||||
f.asked = append(f.asked, c.Key)
|
||||
f.seats = append(f.seats, c.Seats)
|
||||
if f.waits {
|
||||
<-ctx.Done()
|
||||
return meshwants.CompositionsByGroupHeadsValue{}, ctx.Err()
|
||||
return rules.Composed{}, ctx.Err()
|
||||
}
|
||||
return f.v, f.err
|
||||
}
|
||||
|
||||
// composedOn is the engines' answers of a composition by composer on the nodes, each composing it, taking took ms.
|
||||
func composedOn(composer string, took int64, nodes ...string) rules.Composed {
|
||||
c := rules.Composed{Group: "feat/x", Heads: "0123456789ab", Composer: composer, Nodes: nodes, Took: took, Answers: map[string]ne.ComposeCandidateAnswer{}}
|
||||
for _, n := range nodes {
|
||||
c.Answers[n] = ne.ComposeCandidateAnswer{Node: n, Verdict: nec.Composed}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// refusedOn is the engines' answers of a composition the node refuses with the words.
|
||||
func refusedOn(composer, node, words string) rules.Composed {
|
||||
return rules.Composed{Group: "feat/x", Heads: "0123456789ab", Composer: composer, Nodes: []string{node},
|
||||
Answers: map[string]ne.ComposeCandidateAnswer{node: {Node: node, Verdict: nec.Refused, Refused: words}}}
|
||||
}
|
||||
|
||||
// 1297/AC3 (its unit half): a check whose tests pass takes the assigner's composition of it, by the check's own key,
|
||||
// into its verdict: a pass says it passed with the mesh-sdk and machines it composed, and is no longer "not run".
|
||||
func TestAPassingCheckTakesTheAssignersComposition(t *testing.T) {
|
||||
t.Run("a change to a build module or base names the modules built on it", aChangeToABuildModuleOrBaseNamesTheModulesBuiltOnIt)
|
||||
b := newBench(t)
|
||||
f := &fakeCompositions{v: meshwants.CompositionsByGroupHeadsValue{Group: "feat/x", Heads: "0123456789ab", State: meshdeliverycheckerclient.CompositionPass, Sdk: "v0.1.14-x", Machines: []string{"g14"}, Took: 40}}
|
||||
f := &fakeCompositions{v: composedOn("v0.1.14-x", 40, "g14")}
|
||||
b.ex.Compositions = f
|
||||
cs := caseOf(t, pullOf(repoA, 7, head1))
|
||||
cs.FPSet = "fps-of-the-candidate"
|
||||
@@ -68,26 +85,25 @@ func TestAPassingCheckTakesTheAssignersComposition(t *testing.T) {
|
||||
}
|
||||
for _, read := range []bool{true, false} {
|
||||
b.ex.SeatList = fakeSeatList{read: read}
|
||||
b.judge(t, caseOf(t, pullOf(repoA, 7, head1)))
|
||||
got := f.seats[len(f.seats)-1]
|
||||
if want := 0; read {
|
||||
want = 2
|
||||
if len(got) != want || string(got[0]) != `{"seat":"a"}` {
|
||||
asked := len(f.seats)
|
||||
rv := b.judge(t, caseOf(t, pullOf(repoA, 7, head1))).Verdict
|
||||
if read {
|
||||
if got := f.seats[len(f.seats)-1]; len(got) != 2 || string(got[0]) != `{"seat":"a"}` {
|
||||
t.Errorf("with the list read the composition was handed %s, want the reader's two definitions", got)
|
||||
}
|
||||
} else if len(got) != want {
|
||||
t.Errorf("with the list not read the composition was handed %s, want none", got)
|
||||
} else if len(f.seats) != asked || rv.Failed == nil || !strings.Contains(rv.Failed.Words, "the seat list is not read") {
|
||||
// A reader that has not read the list acts on nothing new (ADR 0636 §1): the check ends before any judge.
|
||||
t.Errorf("with the list not read the composition was asked %d more, verdict %+v; want none and a fail naming the list", len(f.seats)-asked, rv.Failed)
|
||||
}
|
||||
}
|
||||
// ADR 0635 §3: at a node-engine member's head a list not read fails the composition, naming why, and asks no composer
|
||||
// ADR 0635 §3: at a node-engine member's head a list not read fails the check, naming why, and asks no composer
|
||||
// (a composer handed none would judge no machine and the composition would pass); read, it composes.
|
||||
for _, read := range []bool{false, true} {
|
||||
b.ex.SeatList = fakeSeatList{read: read}
|
||||
asked := len(f.asked)
|
||||
hv := b.judge(t, caseOf(t, pullOf(repoA, 7, head1), pullOf(nodeEngineRepo, 3, head2))).Verdict
|
||||
switch {
|
||||
case !read && (hv.State != meshdeliverycheckerclient.VerdictFail || hv.Composition == nil || hv.Composition.State != meshdeliverycheckerclient.CompositionFail ||
|
||||
!strings.Contains(hv.Composition.Words, "seat list") || len(f.asked) != asked):
|
||||
case !read && (hv.State != meshdeliverycheckerclient.VerdictFail || hv.Failed == nil || !strings.Contains(hv.Failed.Words, "seat list") || len(f.asked) != asked):
|
||||
t.Errorf("at a node-engine head with the list not read: verdict %+v, composition %+v, asked %d more; want a failed composition naming the seat list, no ask", hv, hv.Composition, len(f.asked)-asked)
|
||||
case read && (hv.Composition == nil || hv.Composition.State != meshdeliverycheckerclient.CompositionPass || len(f.asked) != asked+1):
|
||||
t.Errorf("at a node-engine head with the list read: composition %+v, want the composer's pass", hv.Composition)
|
||||
@@ -110,8 +126,7 @@ func compositionMentions(v meshdeliverychecker.VerdictsByGroupHeadsValue, word s
|
||||
// 1297/AC2 at the checker: a failed composition fails the check, naming the machine it failed at and its words.
|
||||
func TestAFailedCompositionFailsTheCheck(t *testing.T) {
|
||||
b := newBench(t)
|
||||
b.ex.Compositions = &fakeCompositions{v: meshwants.CompositionsByGroupHeadsValue{Group: "feat/x", Heads: "0123456789ab", State: meshdeliverycheckerclient.CompositionFail, Sdk: "x",
|
||||
Machine: "g14", Words: "g14 runs web, which the change removes from its source"}}
|
||||
b.ex.Compositions = &fakeCompositions{v: refusedOn("x", "g14", "g14 runs web, which the change removes from its source")}
|
||||
v := b.judge(t, caseOf(t, pullOf(repoA, 7, head1))).Verdict
|
||||
if v.State != meshdeliverycheckerclient.VerdictFail || v.Failed.Machine != "g14" || v.Composition == nil || v.Composition.State != meshdeliverycheckerclient.CompositionFail {
|
||||
t.Fatalf("verdict = %+v", v)
|
||||
@@ -132,7 +147,7 @@ func TestACompositionNotHadIsNotRun(t *testing.T) {
|
||||
t.Fatalf("verdict = %+v", v)
|
||||
}
|
||||
b2 := newBench(t)
|
||||
f2 := &fakeCompositions{v: meshwants.CompositionsByGroupHeadsValue{State: meshdeliverycheckerclient.CompositionFail, Words: "w", Sdk: "x"}}
|
||||
f2 := &fakeCompositions{v: refusedOn("x", "g14", "w")}
|
||||
b2.ex.Compositions = f2
|
||||
cs := caseOf(t, pullOf(repoA, 7, head1))
|
||||
cs.Try = &TryOptions{}
|
||||
@@ -255,7 +270,7 @@ func aChangeToABuildModuleOrBaseNamesTheModulesBuiltOnIt(t *testing.T) {
|
||||
return r, nil
|
||||
}
|
||||
}
|
||||
comps := &fakeCompositions{v: meshwants.CompositionsByGroupHeadsValue{Group: "feat/x", Heads: "0123456789ab", State: meshdeliverycheckerclient.CompositionPass, Sdk: "x", Machines: []string{"g14"}}}
|
||||
comps := &fakeCompositions{v: composedOn("x", 0, "g14")}
|
||||
b.ex.Compositions = comps
|
||||
var ends *fakeEnds
|
||||
if row.builds != nil {
|
||||
@@ -308,3 +323,5 @@ func aChangeToABuildModuleOrBaseNamesTheModulesBuiltOnIt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mesh-delivery-checker/rules"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -13,9 +14,7 @@ import (
|
||||
nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
ne "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/composer"
|
||||
)
|
||||
@@ -75,17 +74,18 @@ func composerHead(c *Case) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Compose asks every placed node and judges their answers (JudgeEngines). An error is only a case it cannot ask at all.
|
||||
func (e EngineCompositions) Compose(ctx context.Context, c *Case, tagged buildstep.PackageVersions) (meshwants.CompositionsByGroupHeadsValue, error) {
|
||||
// Compose asks every placed node and answers their answers, for the rules to judge (rules.JudgeEngines). An error is only a
|
||||
// case it cannot ask at all.
|
||||
func (e EngineCompositions) Compose(ctx context.Context, c *Case) (rules.Composed, error) {
|
||||
began := time.Now()
|
||||
key, err := nodeengineclient.CompositionKey(c.Group, c.Heads)
|
||||
if err != nil {
|
||||
return meshwants.CompositionsByGroupHeadsValue{}, err
|
||||
return rules.Composed{}, err
|
||||
}
|
||||
var nodes []string
|
||||
if e.Placed != nil {
|
||||
if nodes, err = e.Placed(); err != nil {
|
||||
return meshwants.CompositionsByGroupHeadsValue{}, fmt.Errorf("the assignments cannot be read, so no node can be asked: %w", err)
|
||||
return rules.Composed{}, fmt.Errorf("the assignments cannot be read, so no node can be asked: %w", err)
|
||||
}
|
||||
}
|
||||
bound := e.Bound
|
||||
@@ -109,7 +109,7 @@ func (e EngineCompositions) Compose(ctx context.Context, c *Case, tagged buildst
|
||||
// The composer's build and run are the check's to bound, not the engine's answer bound. A composer that
|
||||
// cannot run is no silent node (ADR 0593 §9 lets only that pass): the node's composition fails, naming
|
||||
// the head.
|
||||
if composed, serr := e.inSandbox(ctx, c, tagged, head, node, a); serr != nil {
|
||||
if composed, serr := e.inSandbox(ctx, c, head, node, a); serr != nil {
|
||||
a = ne.ComposeCandidateAnswer{V: a.V, Node: node, Candidate: a.Candidate, Verdict: nec.Refused,
|
||||
Refused: "the composer at the node-engine's head " + head + " could not compose it: " + serr.Error()}
|
||||
} else {
|
||||
@@ -136,7 +136,7 @@ func (e EngineCompositions) Compose(ctx context.Context, c *Case, tagged buildst
|
||||
} else if e.Composer != nil && e.Composer() != "" {
|
||||
composerName = e.Composer()
|
||||
}
|
||||
v := JudgeEngines(c.Group, c.Heads, composerName, nodes, answers, silent)
|
||||
v := rules.Composed{Group: c.Group, Heads: c.HeadsHash, Composer: composerName, Nodes: nodes, Answers: answers, Silent: silent}
|
||||
if head != "" && e.Sandbox == nil {
|
||||
v.Warnings = append(v.Warnings, "the group changes the node-engine's composition code and was composed by the running engines")
|
||||
}
|
||||
@@ -144,61 +144,10 @@ func (e EngineCompositions) Compose(ctx context.Context, c *Case, tagged buildst
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// JudgeEngines is the check's composition verdict from the engines' answers (ADR 0593 §7, §9): failed when any node's
|
||||
// answer is refused, naming each node and each module refused with its words (the first refusing node is the machine);
|
||||
// passed otherwise. Every node in nodes without an answer (silent, by node with why, for the journal only) is named
|
||||
// with nec.NoAnswer as a warning. composer names what composed, for the line. Pure.
|
||||
func JudgeEngines(group, heads, composerName string, nodes []string, answers map[string]ne.ComposeCandidateAnswer, silent map[string]string) meshwants.CompositionsByGroupHeadsValue {
|
||||
v := meshwants.CompositionsByGroupHeadsValue{Group: group, Heads: heads, State: meshdeliverycheckerclient.CompositionPass, Sdk: composerName}
|
||||
sorted := append([]string(nil), nodes...)
|
||||
sort.Strings(sorted)
|
||||
var fails []string
|
||||
for _, node := range sorted {
|
||||
a, answered := answers[node]
|
||||
if !answered {
|
||||
v.Warnings = append(v.Warnings, node+": "+nec.NoAnswer)
|
||||
continue
|
||||
}
|
||||
if a.Verdict == nec.NotJudged {
|
||||
// Composed in the sandbox and not judged (ADR 0593 §9): named as no answer, with the composer's words; decided by the
|
||||
// verdict, never by the words (ADR 0601 §4's kind).
|
||||
v.Warnings = append(v.Warnings, node+": "+nec.NoAnswer+" ("+a.Refused+")")
|
||||
continue
|
||||
}
|
||||
v.Machines = append(v.Machines, node)
|
||||
if a.Verdict != nec.Refused {
|
||||
continue
|
||||
}
|
||||
if v.Machine == "" {
|
||||
v.Machine = node
|
||||
}
|
||||
var words []string
|
||||
if a.Refused != "" {
|
||||
words = append(words, a.Refused)
|
||||
}
|
||||
for _, m := range append(append([]ne.Module(nil), a.Modules...), a.Own) {
|
||||
if m.State == nec.ModuleRefused {
|
||||
words = append(words, m.Module+": "+m.Words)
|
||||
}
|
||||
}
|
||||
if len(words) == 0 {
|
||||
words = append(words, "refused, naming no module")
|
||||
}
|
||||
fails = append(fails, "not composed on "+node+": "+strings.Join(words, "; "))
|
||||
}
|
||||
if len(fails) > 0 {
|
||||
v.State, v.Words = meshdeliverycheckerclient.CompositionFail, strings.Join(fails, "; ")
|
||||
}
|
||||
if len(sorted) == 0 {
|
||||
v.Warnings = append(v.Warnings, "no node is placed, so the change was composed on none")
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// inSandbox composes a node's answer again with the composer built from the node-engine member's head, over the input the
|
||||
// node's engine composed from: the node's answer becomes the sandbox's, refused with the composer's words where the
|
||||
// composer refuses the node.
|
||||
func (e EngineCompositions) inSandbox(ctx context.Context, c *Case, tagged buildstep.PackageVersions, head, node string, a ne.ComposeCandidateAnswer) (ne.ComposeCandidateAnswer, error) {
|
||||
func (e EngineCompositions) inSandbox(ctx context.Context, c *Case, head, node string, a ne.ComposeCandidateAnswer) (ne.ComposeCandidateAnswer, error) {
|
||||
if len(a.Input) == 0 {
|
||||
return a, fmt.Errorf("the engine of %s answered no composition input, so the node-engine's head cannot compose it", node)
|
||||
}
|
||||
@@ -242,7 +191,7 @@ func (e EngineCompositions) inSandbox(ctx context.Context, c *Case, tagged build
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
job := buildstep.ComposeJob{Fetch: buildstep.Fetch{Repository: nodeEngineRepo, Commit: head, Packages: packagesFor(e.Published, c, tagged, nodeEngineRepo)}, Dir: meshcatalogclient.RootPath,
|
||||
job := buildstep.ComposeJob{Fetch: buildstep.Fetch{Repository: nodeEngineRepo, Commit: head, Packages: packagesFor(e.Published, c, c.Tagged, nodeEngineRepo)}, Dir: meshcatalogclient.RootPath,
|
||||
Package: composer.Package, Request: raw, Reader: "the node-engine head of " + c.Group}
|
||||
verdict, err := e.Sandbox.Compose(ctx, job)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"mesh-delivery-checker/rules"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -87,15 +88,17 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
|
||||
}
|
||||
cs := caseOf(t, pullOf(repoA, 7, head1))
|
||||
key, _ := nodeengineclient.CompositionKey(cs.Group, cs.HeadsHash)
|
||||
var last rules.Composed // the engines' answers of the last compose, for the check's verdict to take in
|
||||
compose := func(e *fakeEngines, nodes ...string) meshwants.CompositionsByGroupHeadsValue {
|
||||
t.Helper()
|
||||
ec := EngineCompositions{Asker: e, Placed: func() ([]string, error) { return nodes, nil }, Bound: 50 * time.Millisecond,
|
||||
Composer: func() string { return strings.Repeat("7", 40) }}
|
||||
v, err := ec.Compose(context.Background(), cs, nil)
|
||||
raw, err := ec.Compose(context.Background(), cs)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v
|
||||
last = raw
|
||||
return rules.JudgeEngines(raw)
|
||||
}
|
||||
|
||||
t.Run("one node refusing fails the composition, naming the node and the module", func(t *testing.T) {
|
||||
@@ -113,7 +116,7 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
|
||||
t.Errorf("asked %+v, want the check's candidate %s and no input", e.asked, key)
|
||||
}
|
||||
var ver meshdeliverychecker.VerdictsByGroupHeadsValue
|
||||
withComposition(&ver, v)
|
||||
rules.JudgeComposition(&ver, rules.Composition{Composed: &last})
|
||||
if ver.State != meshdeliverycheckerclient.VerdictFail || ver.Failed.Machine != "laptop" {
|
||||
t.Errorf("the check's verdict = %+v", ver)
|
||||
}
|
||||
@@ -138,7 +141,7 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
|
||||
}
|
||||
var ver meshdeliverychecker.VerdictsByGroupHeadsValue
|
||||
ver.State = meshdeliverycheckerclient.VerdictPass
|
||||
withComposition(&ver, v)
|
||||
rules.JudgeComposition(&ver, rules.Composition{Composed: &last})
|
||||
if ver.State != meshdeliverycheckerclient.VerdictPass || !strings.Contains(composition(ver.Composition, 0, false), "laptop: "+nec.NoAnswer) {
|
||||
t.Errorf("the line = %q", composition(ver.Composition, 0, false))
|
||||
}
|
||||
@@ -150,6 +153,7 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
host := CaseOf(c, map[string]string{repoA: trunkA, nodeEngineRepo: trunkA})
|
||||
host.Seats = []json.RawMessage{json.RawMessage(`{"seat":"node-x","scope":"node"}`)} // the heads' list (ADR 0636 §4)
|
||||
withInput := composed("anchor")
|
||||
withInput.Input = map[string]any{"nodes": []any{map[string]any{"name": "anchor"}}}
|
||||
// Issue 1966 (the group try of 2026-11-06 10:07): the composer at the head composes against the check's seat list.
|
||||
@@ -161,7 +165,8 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
|
||||
}}
|
||||
ec := EngineCompositions{Asker: e, Placed: func() ([]string, error) { return []string{"anchor"}, nil }, Bound: time.Second,
|
||||
Sandbox: sb, Manifests: digests{"web": "sha256:" + strings.Repeat("c", 64)}, Published: published{}}
|
||||
v, err := ec.Compose(context.Background(), host, nil)
|
||||
raw, err := ec.Compose(context.Background(), host)
|
||||
v := rules.JudgeEngines(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -173,7 +178,7 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
|
||||
_ = json.Unmarshal(job.Request, &req)
|
||||
if job.Fetch.Repository != nodeEngineRepo || job.Fetch.Commit != head2 || job.Package != composer.Package ||
|
||||
string(req.Input) != `{"nodes":[{"name":"anchor"}]}` || req.Cases[0].Manifests["web"].Digest == "" ||
|
||||
len(req.Seats) != 1 || string(req.Seats[0]) != `{"format":1,"seat":"node-uplink"}` {
|
||||
len(req.Seats) != 1 || string(req.Seats[0]) != string(host.Seats[0]) {
|
||||
t.Errorf("job = %+v, request %+v", job, req)
|
||||
}
|
||||
// Issue 1921: the composer is built at the group's heads, also a member that publishes no package yet.
|
||||
@@ -183,7 +188,8 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
|
||||
// ADR 0607 §3, §5, design 51 *Tagged versions*: once the check heard the group's tagged version of a member's Go
|
||||
// package, the composer is built against that, not the member's head, as the check's other Go builds are.
|
||||
const tagged = "v0.1.15-feat-x.1"
|
||||
if _, err := ec.Compose(context.Background(), host, buildstep.PackageVersions{trunk.ForgeModulePrefix + "mesh-delivery": tagged}); err != nil {
|
||||
host.Tagged = buildstep.PackageVersions{trunk.ForgeModulePrefix + "mesh-delivery": tagged}
|
||||
if _, err := ec.Compose(context.Background(), host); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pkgs := sb.jobs[len(sb.jobs)-1].Fetch.Packages; len(pkgs) != 1 || pkgs[trunk.ForgeModulePrefix+"mesh-delivery"] != tagged {
|
||||
@@ -205,7 +211,8 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
|
||||
sb.v = func(node string) composer.Verdict {
|
||||
return composer.Verdict{V: composer.Version, Cases: []composer.CaseVerdict{{Name: "change", Machines: map[string]composer.Machine{node: row.m}}}}
|
||||
}
|
||||
v, err = ec.Compose(context.Background(), host, nil)
|
||||
raw, err = ec.Compose(context.Background(), host)
|
||||
v = rules.JudgeEngines(raw)
|
||||
named := len(v.Warnings) == 1 && strings.Contains(v.Warnings[0], "anchor: "+nec.NoAnswer+" (its node-engine sends no running state for web)")
|
||||
if err != nil || (v.State == meshdeliverycheckerclient.CompositionPass) != row.pass || named != row.pass {
|
||||
t.Errorf("%+v: verdict %+v, %v", row.m, v, err)
|
||||
@@ -214,7 +221,8 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
|
||||
// Review finding 3 (ADR 0593 §9 lets only a node that does not answer pass): a composer at the head that cannot
|
||||
// run fails the composition, naming the head, never passing as a silent node.
|
||||
sb.err = errors.New("the build of mesh-compose failed")
|
||||
v, err = ec.Compose(context.Background(), host, nil)
|
||||
raw, err = ec.Compose(context.Background(), host)
|
||||
v = rules.JudgeEngines(raw)
|
||||
if err != nil || v.State != meshdeliverycheckerclient.CompositionFail || !strings.Contains(v.Words, head2) ||
|
||||
!strings.Contains(v.Words, "the build of mesh-compose failed") || len(v.Warnings) != 0 {
|
||||
t.Errorf("a composer that cannot run: %+v, %v", v, err)
|
||||
@@ -224,7 +232,7 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
|
||||
// Review finding 4: assignments that cannot be read is no mesh with no node: the composition is not made, said.
|
||||
t.Run("an unreadable assignments makes no composition", func(t *testing.T) {
|
||||
ec := EngineCompositions{Asker: &fakeEngines{}, Placed: func() ([]string, error) { return nil, errors.New("no such bucket") }}
|
||||
if _, err := ec.Compose(context.Background(), cs, nil); err == nil || !strings.Contains(err.Error(), "no such bucket") {
|
||||
if _, err := ec.Compose(context.Background(), cs); err == nil || !strings.Contains(err.Error(), "no such bucket") {
|
||||
t.Errorf("an unreadable assignments: %v", err)
|
||||
}
|
||||
b := newBench(t)
|
||||
|
||||
@@ -3,12 +3,12 @@ package check
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mesh-delivery-checker/rules"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
|
||||
)
|
||||
|
||||
@@ -24,9 +24,17 @@ func failingResult(t *testing.T, tests int, failed ...Failure) json.RawMessage {
|
||||
|
||||
func judgeLevel(t *testing.T, tests ...json.RawMessage) (*meshdeliverychecker.VerdictsByGroupHeadsValue, *state) {
|
||||
t.Helper()
|
||||
st := &state{j: &Examiner{}, c: &Case{}, levels: map[string]*acc{buildstep.LevelUnit: {}, buildstep.LevelIntegration: {}}, nodes: map[string]bool{}}
|
||||
v, _ := st.level(t.Context(), &target{pull: Pull{Repository: "novox/x"}}, buildstep.LevelAnswer{Level: buildstep.LevelIntegration, Tests: tests})
|
||||
return settled(joinFailures(v)), st
|
||||
st := &state{j: &Examiner{}, c: &Case{}, levels: rules.NewTally()}
|
||||
st.level(t.Context(), &target{pull: Pull{Repository: "novox/x"}}, buildstep.LevelAnswer{Level: buildstep.LevelIntegration, Tests: tests})
|
||||
// 1921 (B0, D1a): the running checker only gathers the level as a fact; the rules judge it, and count what it kept.
|
||||
judged := rules.Judge(rules.Facts{V: rules.FactsVersion, Steps: st.facts})
|
||||
if judged.Levels[buildstep.LevelIntegration] != (meshdeliverychecker.VerdictsByGroupHeadsValueLevelsValue{Run: int64(st.levels[buildstep.LevelIntegration].Run)}) {
|
||||
t.Errorf("the rules count %+v, the running checker %+v", judged.Levels, *st.levels[buildstep.LevelIntegration])
|
||||
}
|
||||
if judged.State == meshdeliverycheckerclient.VerdictPass {
|
||||
return nil, st
|
||||
}
|
||||
return &judged, st
|
||||
}
|
||||
|
||||
// Issue 1921: two failing tests in two packages are two entries of the verdict's Failures and two lines of a try's
|
||||
@@ -51,7 +59,7 @@ func TestTwoFailingTestsInTwoPackagesAreTwoEntries(t *testing.T) {
|
||||
if len(v.Failures) != 2 || v.Failed.Test != "integration: x/a.TestA" || !strings.Contains(v.Failed.Words, "a_test.go:3: logged") {
|
||||
t.Errorf("%s: verdict %+v", name, v)
|
||||
}
|
||||
if run := st.levels[buildstep.LevelIntegration].run; run != 10 {
|
||||
if run := st.levels[buildstep.LevelIntegration].Run; run != 10 {
|
||||
t.Errorf("%s: %d tests counted, want all 10", name, run)
|
||||
}
|
||||
if _, err := meshdeliverycheckerclient.EncodeVerdict(meshdeliverychecker.VerdictsByGroupHeadsValue{Group: "g", Heads: "0123456789ab", State: v.State, Failed: v.Failed, Failures: v.Failures}); err != nil {
|
||||
@@ -74,7 +82,7 @@ func TestTwoFailingTestsInTwoPackagesAreTwoEntries(t *testing.T) {
|
||||
}
|
||||
|
||||
// Issue 1921: past meshdeliverycheckerclient.MaxFailures the list names the first fifty and how many more, the status line says how many
|
||||
// failed and names the first, cut so "and N more" stays whole, and each entry's line is cut to MaxFailureLine.
|
||||
// failed and names the first, cut so "and N more" stays whole, and each entry's line is cut to rules.MaxFailureLine.
|
||||
func TestFailuresAreCappedWithHowManyMore(t *testing.T) {
|
||||
var fs []Failure
|
||||
for i := range 60 {
|
||||
@@ -84,10 +92,10 @@ func TestFailuresAreCappedWithHowManyMore(t *testing.T) {
|
||||
if len(v.Failures) != meshdeliverycheckerclient.MaxFailures+1 || v.Failures[meshdeliverycheckerclient.MaxFailures].Test != "and 10 more" {
|
||||
t.Fatalf("%d entries, last %+v", len(v.Failures), v.Failures[len(v.Failures)-1])
|
||||
}
|
||||
if len(v.Failures[0].Words) > MaxFailureLine {
|
||||
if len(v.Failures[0].Words) > rules.MaxFailureLine {
|
||||
t.Errorf("an entry's line is %d bytes", len(v.Failures[0].Words))
|
||||
}
|
||||
if n := failedCount(*v); n != 60 {
|
||||
if n := rules.FailedCount(*v); n != 60 {
|
||||
t.Errorf("failedCount = %d, want 60", n)
|
||||
}
|
||||
cs := &Case{Pulls: []Pull{{Repository: "novox/x", Head: "0123456789abcdef", Trunk: "fedcba9876543210"}}}
|
||||
@@ -96,8 +104,9 @@ func TestFailuresAreCappedWithHowManyMore(t *testing.T) {
|
||||
t.Errorf("line %q", line)
|
||||
}
|
||||
// A composition failing after them is counted in the last entry (the operator, 2026-11-02).
|
||||
withComposition(v, meshwants.CompositionsByGroupHeadsValue{State: meshdeliverycheckerclient.CompositionFail, Machine: "g14", Words: "route-proxy is defined twice"})
|
||||
if n := failedCount(*v); n != 61 || v.Failed.Test != "integration: x/p0.TestT" {
|
||||
refused := refusedOn("x", "g14", "route-proxy is defined twice")
|
||||
rules.JudgeComposition(v, rules.Composition{Composed: &refused})
|
||||
if n := rules.FailedCount(*v); n != 61 || v.Failed.Test != "integration: x/p0.TestT" {
|
||||
t.Errorf("after the composition failedCount = %d, failed %+v", n, v.Failed)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"mesh-delivery-checker/rules"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -15,6 +18,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -25,7 +29,6 @@ import (
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdelivery"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
|
||||
@@ -132,6 +135,69 @@ type bench struct {
|
||||
// declines is how many asks naming needs a node lacking them declines before shanks, which has every need, takes
|
||||
// one (issue 890); an ask naming none runs on shanks, which then has none.
|
||||
declines int
|
||||
// inputs are the checking program's kept inputs; heads the heads laid for it (none when nil); run, when set, answers
|
||||
// a run ask in its place (a program that does not build or answer).
|
||||
inputs *memKV
|
||||
heads fs.FS
|
||||
run func(a buildstep.StepAsk) (buildstep.Step, bool)
|
||||
mu sync.Mutex
|
||||
runs []buildstep.StepAsk // the run asks put
|
||||
}
|
||||
|
||||
// benchAsks puts the bench's asks: a run ask is answered at once by the checking program's work (answerRun), written by
|
||||
// write and kept apart (bench.runs); every other ask goes to inner (the fake bus by default), answered as it answers.
|
||||
type benchAsks struct {
|
||||
b *bench
|
||||
inner buildrun.AskPublisher
|
||||
write func(buildstep.Step)
|
||||
}
|
||||
|
||||
func (b *bench) asker(inner buildrun.AskPublisher, write func(buildstep.Step)) benchAsks {
|
||||
return benchAsks{b: b, inner: inner, write: write}
|
||||
}
|
||||
|
||||
func (p benchAsks) PublishAsk(ctx context.Context, subject, id string, data []byte) error {
|
||||
inner, write := p.inner, p.write
|
||||
if inner == nil {
|
||||
inner, write = p.b.asks, p.b.asks.Write
|
||||
}
|
||||
a, err := buildstep.DecodeStepAsk(data)
|
||||
if err != nil || a.Run == nil {
|
||||
return inner.PublishAsk(ctx, subject, id, data)
|
||||
}
|
||||
p.b.mu.Lock()
|
||||
p.b.runs = append(p.b.runs, a)
|
||||
p.b.mu.Unlock()
|
||||
if step := p.b.answerRun(a); step.ID != "" {
|
||||
write(step)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// trunkD is mesh-delivery's trunk the bench's checking program is built at.
|
||||
var trunkD = strings.Repeat("d", 40)
|
||||
|
||||
// answerRun answers a run ask as the build module would: the checking program's work (rules.Program) over the input the
|
||||
// ask names, read from the kept inputs and checked against its digest.
|
||||
func (b *bench) answerRun(a buildstep.StepAsk) buildstep.Step {
|
||||
began := time.Date(2026, 10, 21, 19, 0, 0, 0, time.UTC)
|
||||
step := buildstep.Step{ID: a.ID, Tool: a.Kind + " ask", Node: "shanks", State: buildstep.StepPassed, Started: began, Ended: began.Add(time.Second)}
|
||||
if b.run != nil {
|
||||
if s, ok := b.run(a); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
answer := buildstep.RunAnswer{Built: true, Key: "program-0123456789abcdef"}
|
||||
out, err := rules.Program(a.Run.Args, func() ([]byte, error) {
|
||||
body, _, _, _ := b.inputs.Get(context.Background(), a.Run.Input.Digest)
|
||||
return body, a.Run.Input.Verify(body)
|
||||
}, b.heads)
|
||||
if err != nil {
|
||||
answer.Exit, answer.Said = 2, err.Error()
|
||||
}
|
||||
answer.Output = out
|
||||
step.Result, _ = json.Marshal(answer)
|
||||
return step
|
||||
}
|
||||
|
||||
// refsOf reads the siblings of a fetch's arguments, as they arrive in an ask (JSON) or as the checker gave them.
|
||||
@@ -190,7 +256,7 @@ func (b *bench) answer(a buildstep.StepAsk) buildstep.Step {
|
||||
// newBench is an examiner over fake build modules that merge cleanly, digest the captured fixture's packages and pass
|
||||
// the captured slow fixture's result at both levels, on shanks.
|
||||
func newBench(t testing.TB) *bench {
|
||||
b := &bench{steps: &fakeSteps{}, kept: newMemKV(), prev: newMemKV()}
|
||||
b := &bench{steps: &fakeSteps{}, kept: newMemKV(), prev: newMemKV(), inputs: newMemKV(), heads: fstest.MapFS{}}
|
||||
b.fetch = captured[Fetched](t, "go-fetch-clean")
|
||||
b.fetch.Modules, b.fetch.Changed, b.fetch.Trees = []string{"."}, []string{"README.md"}, nil
|
||||
one := captured[Digested](t, "go-digest")
|
||||
@@ -214,8 +280,8 @@ func newBench(t testing.TB) *bench {
|
||||
}
|
||||
now := time.Date(2026, 10, 21, 19, 0, 0, 0, time.UTC)
|
||||
b.asks = &buildrun.FakeAsks{Answer: b.answer}
|
||||
b.ex = &Examiner{Asks: buildrun.Asker{Publisher: b.asks, Waiter: b.asks, Bound: 5 * time.Second}, Toolchains: goEntries("shanks"), Kept: Kept{Store: b.kept}, Previews: b.prev,
|
||||
Modules: langs{}, Trunks: &Trunks{}, Now: func() time.Time { now = now.Add(50 * time.Millisecond); return now }}
|
||||
b.ex = &Examiner{Asks: buildrun.Asker{Publisher: b.asker(nil, nil), Waiter: b.asks, Bound: 5 * time.Second}, Toolchains: goEntries("shanks"), Kept: Kept{Store: b.kept}, Previews: b.prev,
|
||||
Modules: langs{}, Trunks: &Trunks{heads: map[string]string{DeliveryRepository: trunkD}}, Inputs: memInputs{b.inputs}, Now: func() time.Time { now = now.Add(50 * time.Millisecond); return now }}
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -306,6 +372,81 @@ func TestACleanMergeIsFetchedAtItsTrunkAndBothLevelsPass(t *testing.T) { // 1679
|
||||
t.Errorf("the check ask carries the names %v", asks)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1921 (B0, D4 to D6): the check's verdict is the checking program's, built at the group's mesh-delivery head,
|
||||
// else at mesh-delivery's trunk, over the facts kept as its input; a program that is never begun, does not build or
|
||||
// does not answer fails the check naming it, and a rule the change's program alters judges the check.
|
||||
verdictOf := func(state, words string) json.RawMessage {
|
||||
out, _ := json.Marshal(rules.Answer{V: rules.AnswerVersion, Verdict: meshdeliverychecker.VerdictsByGroupHeadsValue{State: state, Failed: &FailedFirst{Words: words}}})
|
||||
return out
|
||||
}
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
repo string // the member's repository: mesh-delivery unless named
|
||||
run func(a buildstep.StepAsk, s buildstep.Step) (buildstep.Step, bool)
|
||||
at string // the commit the program is built at; empty when it fails
|
||||
fails string
|
||||
runs int // the run asks put when it fails: each put twice when never begun, and none after the seat list's
|
||||
}{
|
||||
{name: "at the group's mesh-delivery head", at: head1},
|
||||
{name: "at mesh-delivery's trunk", repo: "novox/mesh-catalog", at: trunkD},
|
||||
{name: "never begun", run: func(buildstep.StepAsk, buildstep.Step) (buildstep.Step, bool) { return buildstep.Step{}, true },
|
||||
fails: "no build node began the job run of the asks", runs: 2},
|
||||
{name: "does not build", run: func(_ buildstep.StepAsk, s buildstep.Step) (buildstep.Step, bool) {
|
||||
s.State, s.Reason = buildstep.StepFailed, "the program does not build: undefined: rules.Nothing"
|
||||
return s, true
|
||||
}, fails: "by the checking program: its run ask", runs: 1},
|
||||
{name: "a rule the change alters", at: head1, run: func(a buildstep.StepAsk, s buildstep.Step) (buildstep.Step, bool) {
|
||||
if a.Run.Args[0] != "-verdict" {
|
||||
return s, false
|
||||
}
|
||||
s.Result, _ = json.Marshal(buildstep.RunAnswer{Key: "program-0123456789abcdef", Output: verdictOf(meshdeliverycheckerclient.VerdictFail, "the change's own rule refuses it")})
|
||||
return s, true
|
||||
}, fails: "the change's own rule refuses it"},
|
||||
} {
|
||||
b := newBench(t)
|
||||
b.ex.BeginBound = 20 * time.Millisecond
|
||||
if row.run != nil {
|
||||
b.run = func(a buildstep.StepAsk) (buildstep.Step, bool) {
|
||||
return row.run(a, buildstep.Step{ID: a.ID, Tool: "check ask", Node: "shanks", State: buildstep.StepPassed})
|
||||
}
|
||||
}
|
||||
repo := cmp.Or(row.repo, repoA)
|
||||
v := b.judge(t, caseOf(t, pullOf(repo, 7, head1))).Verdict
|
||||
if row.fails != "" {
|
||||
if v.State != meshdeliverycheckerclient.VerdictFail || !strings.Contains(v.Failed.Words, row.fails) {
|
||||
t.Errorf("%s: verdict %+v, want a fail saying %q", row.name, v, row.fails)
|
||||
}
|
||||
if row.at == "" && (len(b.runs) != row.runs || v.JudgedBy != nil || slices.ContainsFunc(b.runs, func(a buildstep.StepAsk) bool { return a.Run.Args[0] == "-verdict" })) {
|
||||
t.Errorf("%s: %d run asks, judged by %+v: want %d and none judging", row.name, len(b.runs), v.JudgedBy, row.runs)
|
||||
}
|
||||
} else if v.State != meshdeliverycheckerclient.VerdictPass {
|
||||
t.Errorf("%s: verdict %+v", row.name, v)
|
||||
}
|
||||
if row.at == "" {
|
||||
continue
|
||||
}
|
||||
if by := v.JudgedBy; by == nil || by.Program.Commit != row.at || by.Program.Package != CheckingProgramPackage || by.Key == "" ||
|
||||
len(by.Heads) != 1 || by.Heads[0].Repository != repo || by.Heads[0].Commit != head1 {
|
||||
t.Errorf("%s: judged by %+v, want the program at %s over the member's head", row.name, v.JudgedBy, row.at)
|
||||
}
|
||||
if len(b.runs) != 2 || b.runs[0].Run.Args[0] != "-seats" || b.runs[1].Run.Args[0] != "-verdict" {
|
||||
t.Fatalf("%s: run asks %+v, want the seat list and then the verdict", row.name, b.runs)
|
||||
}
|
||||
in := b.runs[1].Run.Input // the facts kept as their exact bytes under their digest
|
||||
body, _, found, _ := b.inputs.Get(context.Background(), in.Digest)
|
||||
if !found || in.Verify(body) != nil || in.Verb != InputVerb ||
|
||||
!strings.Contains(string(body), `"group":"feat/x"`) {
|
||||
t.Errorf("%s: the verdict's input %+v is not kept whole: %s", row.name, in, body)
|
||||
}
|
||||
// Review round 1 of milestone 47/B0: a level is carried as the rules read it, without its digest, its packages or
|
||||
// its kept packages' names, so a group's facts stay within the input's bound.
|
||||
for _, unread := range []string{`"digest":{`, `"packages":[`, `"unmet"`, `"package":"m/`} {
|
||||
if strings.Contains(string(body), unread) {
|
||||
t.Errorf("%s: the facts carry %s, which no rule reads: %s", row.name, unread, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// namesOf is the nodes' names as a test gives them.
|
||||
@@ -452,8 +593,7 @@ func TestANodePackageIsNeverKeptAndRunsEveryCheck(t *testing.T) { // 1288/AC4, 1
|
||||
func TestEveryFailureOfEveryMemberLevelAndTheCompositionIsNamed(t *testing.T) {
|
||||
for _, conflicts := range []bool{false, true} {
|
||||
b := newBench(t)
|
||||
comps := &fakeCompositions{v: meshwants.CompositionsByGroupHeadsValue{Group: "feat/x", Heads: "0123456789ab", State: meshdeliverycheckerclient.CompositionFail, Sdk: "x",
|
||||
Machine: "g14", Words: "route-proxy is defined twice"}, waits: conflicts}
|
||||
comps := &fakeCompositions{v: refusedOn("x", "g14", "route-proxy is defined twice"), waits: conflicts}
|
||||
b.ex.Compositions = comps
|
||||
b.fetch.Changed = []string{"modules/m/internal/b/b_test.go"}
|
||||
b.digest[buildstep.LevelUnit] = Digested{Level: "unit", Packages: map[string]string{"m/internal/a": digA, "m/internal/b": digB}}
|
||||
@@ -487,7 +627,7 @@ func TestEveryFailureOfEveryMemberLevelAndTheCompositionIsNamed(t *testing.T) {
|
||||
got = append(got, firstFailed(meshdeliverychecker.VerdictsByGroupHeadsValue{Failed: &FailedFirst{Test: f.Test, Machine: f.Machine, Words: f.Words}}))
|
||||
}
|
||||
tests := []string{"unit: fixture/failing/bad.TestThreeIsFour", "integration: fixture/failing/bad.TestThreeIsFour"}
|
||||
want := append(append(slices.Clone(tests), tests...), "g14: route-proxy is defined twice") // both members fail their tests
|
||||
want := append(append(slices.Clone(tests), tests...), "g14: not composed on g14: route-proxy is defined twice") // both members fail their tests
|
||||
if conflicts {
|
||||
want = append([]string{"shanks: novox/mesh-sdk#12 does not merge into the trunk: files in conflict: a.go"}, tests...)
|
||||
}
|
||||
@@ -557,6 +697,10 @@ func TestAHeadThatDoesNotMergeEndsFailedNamingTheFilesAndRunsNoTest(t *testing.T
|
||||
if n := len(b.steps.tool("digest")) + len(b.steps.tool("test")); n != 0 {
|
||||
t.Errorf("%d digest and test steps ran after a conflict", n)
|
||||
}
|
||||
// 1921/D1c: the node that ran the step is a fact, so the verdict judged from the facts names its machine.
|
||||
if m := out.Verdict.Machines; len(m) != 1 || m[0] != "shanks" {
|
||||
t.Errorf("machines = %v, want the node that ran the step", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALevelWhoseDigestIsNotNamedRunsWholeAndKeepsNothing(t *testing.T) { // 1287/AC4
|
||||
@@ -927,7 +1071,7 @@ func TestAnAskWhoseStepNeverEndsEndsTheCheckLoudly(t *testing.T) {
|
||||
b.asks.Answer = func(a buildstep.StepAsk) buildstep.Step {
|
||||
return buildstep.Step{ID: a.ID, Tool: "check ask", Node: "shanks", State: buildstep.StepRunning, Started: time.Now()}
|
||||
}
|
||||
b.ex.Asks = buildrun.Asker{Publisher: b.asks, Waiter: b.asks, Bound: 20 * time.Millisecond}
|
||||
b.ex.Asks = buildrun.Asker{Publisher: b.asker(nil, nil), Waiter: b.asks, Bound: 20 * time.Millisecond}
|
||||
out := b.judge(t, caseOf(t, pullOf(repoA, 7, head1)))
|
||||
if f := out.Verdict.Failed; out.Verdict.State != meshdeliverycheckerclient.VerdictFail || !strings.Contains(f.Words, "on shanks did not end within") {
|
||||
t.Fatalf("verdict = %+v", out.Verdict)
|
||||
@@ -967,7 +1111,7 @@ func TestACheckAskWaitingInItsStreamIsNotBounded(t *testing.T) {
|
||||
b := newBench(t)
|
||||
answer := b.asks.Answer
|
||||
b.asks.Answer = nil
|
||||
b.ex.Asks = buildrun.Asker{Publisher: b.asks, Waiter: b.asks, Bound: 20 * time.Millisecond}
|
||||
b.ex.Asks = buildrun.Asker{Publisher: b.asker(nil, nil), Waiter: b.asks, Bound: 20 * time.Millisecond}
|
||||
done := make(chan Outcome, 1)
|
||||
go func() { done <- b.judge(t, caseOf(t, pullOf(repoA, 7, head1))) }()
|
||||
for deadline := time.Now().Add(10 * time.Second); len(b.asks.Asks()) == 0; time.Sleep(time.Millisecond) {
|
||||
@@ -1076,6 +1220,9 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) {
|
||||
// (ADR 0636 §4, ADR 0633 §6); "outside" is mesh-contracts, whose definition of another domain's seat is not taken
|
||||
// (ADR 0636 §5); "misnamed" is the domain's module whose seats/mesh-catalog/seat.json names another seat.
|
||||
heads string
|
||||
// headSeats lays heads that add a seat: the checking program's seat list is carried by every judge ask's request (ADR
|
||||
// 0636 §4, issue 1921 D4).
|
||||
headSeats bool
|
||||
}{
|
||||
{name: "the seat list read is the request's Seats", seats: "read"},
|
||||
{name: "the members' heads' definitions over the list's, for the trunk's manifest validator too", seats: "read", indexer: "ok", heads: "domain"},
|
||||
@@ -1099,6 +1246,7 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) {
|
||||
{name: "a package series kept", series: "0.1"},
|
||||
{name: "a package new in the group", series: "0.1", known: []string{"git.novox.be/novox/mesh-sdk/go", "git.novox.be/novox/web/go"}},
|
||||
{name: "an answered packages state holding no name", emptyState: true, known: []string{}},
|
||||
{name: "the heads' seat list is carried by the head's judge and the trunk's", headSeats: true, indexer: "ok", ownJudge: true},
|
||||
{name: "accepted in a group try", try: true},
|
||||
{name: "refused in a group try", try: true, refused: "web has no id", fails: "the indexer's judge at mesh-delivery's trunk " + strings.Repeat("d", 40) + ` refuses the manifest of web: web has no id`},
|
||||
{name: "a cycle the group try makes", try: true, cycle: "provisions", fails: "the change's module graph has the cycle mail -> web -> mail"},
|
||||
@@ -1119,6 +1267,12 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) {
|
||||
{name: "refused", refused: "web requires \"amqp\"\nweb has no id", fails: "the indexer's judge at mesh-delivery's trunk " + strings.Repeat("d", 40) + ` refuses the manifest of web: web requires "amqp"; web has no id`},
|
||||
} {
|
||||
b := newBench(t)
|
||||
if row.headSeats {
|
||||
b.heads = fstest.MapFS{
|
||||
repoA + "/modules/x-contracts/module.json": {Data: []byte(`{"module": "x-contracts", "build": {"artifacts": [{"name": "go", "kind": "package", "language": "go", "generated-from": "."}]}}`)},
|
||||
repoA + "/modules/x-contracts/seats/node-x/seat.json": {Data: []byte(`{"format":1,"seat":"node-x","scope":"node","grain":"whole","domain":"Delivery","decision":"ADR 1"}`)},
|
||||
}
|
||||
}
|
||||
b.ex.Manifests = fixedDigests{"mail": strings.Repeat("a", 64), "web": strings.Repeat("b", 64)}
|
||||
b.ex.Trunks = fixedTrunks{heads: map[string]string{DeliveryRepository: trunk}}
|
||||
b.ex.Predefined = keptList(`{"font-size": {"type": "number", "description": "the size"}}`)
|
||||
@@ -1233,13 +1387,14 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) {
|
||||
}
|
||||
dir := "modules/" + contracts
|
||||
p.Modules = append(p.Modules, contracts)
|
||||
b.ex.Forge = headFiles{
|
||||
repoA + " " + dir + "/module.json " + head1: `{"module": "` + contracts + `", "build": {"artifacts": [{"name": "go", "kind": "package", "language": "go", "generated-from": "."}]}}`,
|
||||
repoA + " " + dir + "/seats/mesh-catalog/seat.json " + head1: `{"format":1,"seat":"` + seat + `","scope":"mesh","grain":"whole","domain":"Delivery","decision":"ADR 1","includes":["lists"],` +
|
||||
`"verbs":[{"name":"modules","input":{"type":"object"},"answer":{"type":"object"}}]}`,
|
||||
repoA + " " + dir + "/parts/lists.json " + head1: `{"format":1,"part":"lists","verbs":[{"name":"seat-definitions","input":{"type":"object"},"answer":{"type":"object"}}]}`,
|
||||
// The checking program reads the heads the build node lays for its run (D6): the running checker reads none.
|
||||
b.heads = fstest.MapFS{
|
||||
repoA + "/" + dir + "/module.json": {Data: []byte(`{"module": "` + contracts + `", "build": {"artifacts": [{"name": "go", "kind": "package", "language": "go", "generated-from": "."}]}}`)},
|
||||
repoA + "/" + dir + "/seats/mesh-catalog/seat.json": {Data: []byte(`{"format":1,"seat":"` + seat + `","scope":"mesh","grain":"whole","domain":"Delivery","decision":"ADR 1","includes":["lists"],` +
|
||||
`"verbs":[{"name":"modules","input":{"type":"object"},"answer":{"type":"object"}}]}`)},
|
||||
repoA + "/" + dir + "/seats/mesh-catalog/fixtures/a.json": {Data: []byte(`{}`)},
|
||||
repoA + "/" + dir + "/parts/lists.json": {Data: []byte(`{"format":1,"part":"lists","verbs":[{"name":"seat-definitions","input":{"type":"object"},"answer":{"type":"object"}}]}`)},
|
||||
}
|
||||
b.ex.Trees = headTrees{repoA + " " + dir + "/seats " + head1: {"mesh-catalog/seat.json": "1", "mesh-catalog/fixtures/a.json": "2"}}
|
||||
}
|
||||
p.Fps = map[string]meshcatalog.CandidatesByGroupValuePullsItemFpsValue{"web": {Fp2: "src2:" + strings.Repeat("f", 64), Manifest: web}}
|
||||
// Issue 1921: the judge is built at the group's heads too, also a member that publishes no package yet.
|
||||
@@ -1304,6 +1459,13 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) {
|
||||
judged[0].Package != JudgePackage || judged[0].Dir != JudgeDir {
|
||||
t.Fatalf("%s: judged %+v (verdict %+v)", row.name, judged, v)
|
||||
}
|
||||
for _, j := range judged {
|
||||
var seated composer.Request
|
||||
if err := json.Unmarshal(j.Request, &seated); err != nil || (len(seated.Seats) == 1) != row.headSeats ||
|
||||
(row.headSeats && !strings.Contains(string(seated.Seats[0]), `"node-x"`)) {
|
||||
t.Errorf("%s: the judge at %v is handed the seats %s, want the heads' list: %v", row.name, j.Fetch.Commit, seated.Seats, err)
|
||||
}
|
||||
}
|
||||
var req composer.Request
|
||||
if err := json.Unmarshal(judged[0].Request, &req); err != nil || req.Cases[0].Manifests["web"].Digest != web ||
|
||||
req.Cases[1].Name != JudgeTrunk || req.Cases[1].Manifests["web"].Digest != strings.Repeat("b", 64) ||
|
||||
@@ -1336,7 +1498,7 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) {
|
||||
} else if row.seats == "read" && (len(req.Seats) != 2 || string(req.Seats[0]) != `{"seat":"a"}` || string(req.Seats[1]) != `{"seat":"b"}`) {
|
||||
t.Errorf("%s: the request's seats %s, want the two definitions the reader holds", row.name, req.Seats)
|
||||
}
|
||||
if row.seats == "" && len(req.Seats) != 0 {
|
||||
if row.seats == "" && !row.headSeats && len(req.Seats) != 0 {
|
||||
t.Errorf("%s: the request's seats %s, want none from a checker that follows no list", row.name, req.Seats)
|
||||
}
|
||||
if !reflect.DeepEqual(req.Packages, row.known) || len(req.Input) != 0 && string(req.Input) != "null" {
|
||||
|
||||
@@ -2,6 +2,7 @@ package check
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"mesh-delivery-checker/rules"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -20,81 +21,88 @@ func fatalAfterLog() Failure {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFailedTestsVerdictKeepsItsWholeFailureMessage(t *testing.T) { // 1901/AC1
|
||||
v := levelFailure(&target{}, "unit", Tested{Failed: []Failure{fatalAfterLog()}})
|
||||
for _, want := range []string{"3 subscription(s) derive to a subject no emitter owns:", "a.b.c", "d.e.f", "g.h.i"} {
|
||||
if !strings.Contains(v.Failed.Words, want) {
|
||||
t.Errorf("words lack %q: %q", want, v.Failed.Words)
|
||||
}
|
||||
}
|
||||
if len(v.Failed.Words) > MaxWords+200 {
|
||||
t.Errorf("words are %d bytes, bound %d", len(v.Failed.Words), MaxWords)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheStatusLineNamesTheFailureNotTheFirstLoggedLine(t *testing.T) { // 1901/AC2
|
||||
v := levelFailure(&target{}, "unit", Tested{Failed: []Failure{fatalAfterLog()}})
|
||||
got := firstFailed(*v)
|
||||
if !strings.Contains(got, "3 subscription(s) derive to a subject no emitter owns:") || strings.Contains(got, "judged against") || strings.Contains(got, "\n") {
|
||||
t.Errorf("firstFailed = %q", got)
|
||||
}
|
||||
// With no cause told apart (an older build module), the words still start at the first line, as before.
|
||||
f := fatalAfterLog()
|
||||
f.Cause = ""
|
||||
v = levelFailure(&target{}, "unit", Tested{Failed: []Failure{f}})
|
||||
if !strings.HasPrefix(afterHid(v.Failed.Words), "agreement_catalogue_test.go:130") {
|
||||
t.Errorf("words = %q", v.Failed.Words)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWordsLongerThanTheBoundKeepTheirLastLines(t *testing.T) { // 1901/AC1
|
||||
f := Failure{Output: strings.Repeat("log line of some length\n", 500) + "the cause is here"}
|
||||
v := levelFailure(&target{}, "unit", Tested{Failed: []Failure{f}})
|
||||
if !strings.HasSuffix(v.Failed.Words, "the cause is here") || len(v.Failed.Words) > MaxWords+200 {
|
||||
t.Errorf("%d bytes, ends %q", len(v.Failed.Words), v.Failed.Words[len(v.Failed.Words)-30:])
|
||||
}
|
||||
_ = meshdeliverycheckerclient.VerdictFail
|
||||
}
|
||||
|
||||
// An older build module sends no "cause": its answer is still accepted and the verdict's words are its output, as before
|
||||
// (1901/AC3). A failure that hides its cause (none, or a passing line) is said loudly first in the words and in the status
|
||||
// line, naming the build module and the version it says it is, and the level still fails; a failure that keeps the
|
||||
// contract is worded as before (1901/AC8).
|
||||
func TestAnOlderBuildModulesFailureIsAcceptedWithItsWholeOutput(t *testing.T) { // 1901/AC3, AC8
|
||||
old := `{"package":"p","test":"TestX","output":"x_test.go:1: logged\nx_test.go:2: the old kept lines"}`
|
||||
var f Failure
|
||||
if err := json.Unmarshal([]byte(old), &f); err != nil || f.Cause != "" {
|
||||
t.Fatalf("1901/AC3: err = %v, cause = %q", err, f.Cause)
|
||||
}
|
||||
v := levelFailure(&target{}, "unit", Tested{Failed: []Failure{f}})
|
||||
if afterHid(v.Failed.Words) != "x_test.go:1: logged\nx_test.go:2: the old kept lines" {
|
||||
t.Errorf("1901/AC3: words = %q", v.Failed.Words)
|
||||
}
|
||||
if got := firstFailed(*v); !strings.Contains(got, "the build module hid the failure") || strings.Contains(got, "\n") {
|
||||
t.Errorf("1901/AC3, AC8: status = %q", got)
|
||||
}
|
||||
|
||||
by := "mesh-build-node with mesh-sdk v0.1.14-0.20261026184535-e40dd0f56a78"
|
||||
for _, c := range []struct {
|
||||
name, module, by, cause, output, loud string
|
||||
// A failed test's words are its whole failure message, kept within their bound by their last lines (issue 1901).
|
||||
func TestAFailuresWordsAreItsWholeMessageWithinTheirBound(t *testing.T) {
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
test func(t *testing.T)
|
||||
}{
|
||||
{"no cause", "mesh-build-node", by, "", "test/b.test.ts: something", "the build module hid the failure (mesh-build-node at mesh-sdk v0.1.14-0.20261026184535-e40dd0f56a78): it sent no cause"},
|
||||
{"a passing line as its cause", "mesh-build-node", by, "\u2714 the same merge has the same id (8.006913ms)", "", "the build module hid the failure (mesh-build-node at mesh-sdk v0.1.14-0.20261026184535-e40dd0f56a78): its cause is a passing line"},
|
||||
{"from before the contract", "mesh-build-node", "", "", "\u2714 the same merge has the same id (8.006913ms)", "the build module hid the failure (mesh-build-node at a version before the failure contract): it sent no cause"},
|
||||
{"a real cause", "mesh-build-node", by, "one plus one is not three", "not ok 3 - fails", ""},
|
||||
{"a failed tests verdict keeps its whole failure message", func(t *testing.T) { // 1901/AC1
|
||||
v := rules.LevelFailure("", "", "unit", Tested{Failed: []Failure{fatalAfterLog()}})
|
||||
for _, want := range []string{"3 subscription(s) derive to a subject no emitter owns:", "a.b.c", "d.e.f", "g.h.i"} {
|
||||
if !strings.Contains(v.Failed.Words, want) {
|
||||
t.Errorf("words lack %q: %q", want, v.Failed.Words)
|
||||
}
|
||||
}
|
||||
if len(v.Failed.Words) > rules.MaxWords+200 {
|
||||
t.Errorf("words are %d bytes, bound %d", len(v.Failed.Words), rules.MaxWords)
|
||||
}
|
||||
}},
|
||||
{"the status line names the failure not the first logged line", func(t *testing.T) { // 1901/AC2
|
||||
v := rules.LevelFailure("", "", "unit", Tested{Failed: []Failure{fatalAfterLog()}})
|
||||
got := firstFailed(*v)
|
||||
if !strings.Contains(got, "3 subscription(s) derive to a subject no emitter owns:") || strings.Contains(got, "judged against") || strings.Contains(got, "\n") {
|
||||
t.Errorf("firstFailed = %q", got)
|
||||
}
|
||||
// With no cause told apart (an older build module), the words still start at the first line, as before.
|
||||
f := fatalAfterLog()
|
||||
f.Cause = ""
|
||||
v = rules.LevelFailure("", "", "unit", Tested{Failed: []Failure{f}})
|
||||
if !strings.HasPrefix(afterHid(v.Failed.Words), "agreement_catalogue_test.go:130") {
|
||||
t.Errorf("words = %q", v.Failed.Words)
|
||||
}
|
||||
}},
|
||||
{"words longer than the bound keep their last lines", func(t *testing.T) { // 1901/AC1
|
||||
f := Failure{Output: strings.Repeat("log line of some length\n", 500) + "the cause is here"}
|
||||
v := rules.LevelFailure("", "", "unit", Tested{Failed: []Failure{f}})
|
||||
if !strings.HasSuffix(v.Failed.Words, "the cause is here") || len(v.Failed.Words) > rules.MaxWords+200 {
|
||||
t.Errorf("%d bytes, ends %q", len(v.Failed.Words), v.Failed.Words[len(v.Failed.Words)-30:])
|
||||
}
|
||||
_ = meshdeliverycheckerclient.VerdictFail
|
||||
}},
|
||||
// An older build module sends no "cause": its answer is still accepted and the verdict's words are its output, as before
|
||||
// (1901/AC3). A failure that hides its cause (none, or a passing line) is said loudly first in the words and in the status
|
||||
// line, naming the build module and the version it says it is, and the level still fails; a failure that keeps the
|
||||
// contract is worded as before (1901/AC8).
|
||||
{"an older build modules failure is accepted with its whole output", func(t *testing.T) { // 1901/AC3, AC8
|
||||
old := `{"package":"p","test":"TestX","output":"x_test.go:1: logged\nx_test.go:2: the old kept lines"}`
|
||||
var f Failure
|
||||
if err := json.Unmarshal([]byte(old), &f); err != nil || f.Cause != "" {
|
||||
t.Fatalf("1901/AC3: err = %v, cause = %q", err, f.Cause)
|
||||
}
|
||||
v := rules.LevelFailure("", "", "unit", Tested{Failed: []Failure{f}})
|
||||
if afterHid(v.Failed.Words) != "x_test.go:1: logged\nx_test.go:2: the old kept lines" {
|
||||
t.Errorf("1901/AC3: words = %q", v.Failed.Words)
|
||||
}
|
||||
if got := firstFailed(*v); !strings.Contains(got, "the build module hid the failure") || strings.Contains(got, "\n") {
|
||||
t.Errorf("1901/AC3, AC8: status = %q", got)
|
||||
}
|
||||
|
||||
by := "mesh-build-node with mesh-sdk v0.1.14-0.20261026184535-e40dd0f56a78"
|
||||
for _, c := range []struct {
|
||||
name, module, by, cause, output, loud string
|
||||
}{
|
||||
{"no cause", "mesh-build-node", by, "", "test/b.test.ts: something", "the build module hid the failure (mesh-build-node at mesh-sdk v0.1.14-0.20261026184535-e40dd0f56a78): it sent no cause"},
|
||||
{"a passing line as its cause", "mesh-build-node", by, "\u2714 the same merge has the same id (8.006913ms)", "", "the build module hid the failure (mesh-build-node at mesh-sdk v0.1.14-0.20261026184535-e40dd0f56a78): its cause is a passing line"},
|
||||
{"from before the contract", "mesh-build-node", "", "", "\u2714 the same merge has the same id (8.006913ms)", "the build module hid the failure (mesh-build-node at a version before the failure contract): it sent no cause"},
|
||||
{"a real cause", "mesh-build-node", by, "one plus one is not three", "not ok 3 - fails", ""},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
v := rules.LevelFailure("", c.module, "unit", Tested{By: c.by, Failed: []Failure{{Package: "modules/gitea", Test: "fails", Cause: c.cause, Output: c.output}}})
|
||||
if v.State != meshdeliverycheckerclient.VerdictFail {
|
||||
t.Errorf("1901/AC8: the verdict is %q, want it failed", v.State)
|
||||
}
|
||||
line := firstFailed(*v)
|
||||
switch {
|
||||
case c.loud == "" && strings.Contains(v.Failed.Words, "hid the failure"):
|
||||
t.Errorf("1901/AC8: a failure that keeps the contract is said hidden: %q", v.Failed.Words)
|
||||
case c.loud != "" && (!strings.HasPrefix(v.Failed.Words, c.loud) || !strings.Contains(line, c.loud) || !strings.Contains(v.Failures[0].Words, "hid the failure")):
|
||||
t.Errorf("1901/AC8: words %q, status %q, want both to start with %q", v.Failed.Words, line, c.loud)
|
||||
}
|
||||
})
|
||||
}
|
||||
}},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
v := levelFailure(&target{module: c.module}, "unit", Tested{By: c.by, Failed: []Failure{{Package: "modules/gitea", Test: "fails", Cause: c.cause, Output: c.output}}})
|
||||
if v.State != meshdeliverycheckerclient.VerdictFail {
|
||||
t.Errorf("1901/AC8: the verdict is %q, want it failed", v.State)
|
||||
}
|
||||
line := firstFailed(*v)
|
||||
switch {
|
||||
case c.loud == "" && strings.Contains(v.Failed.Words, "hid the failure"):
|
||||
t.Errorf("1901/AC8: a failure that keeps the contract is said hidden: %q", v.Failed.Words)
|
||||
case c.loud != "" && (!strings.HasPrefix(v.Failed.Words, c.loud) || !strings.Contains(line, c.loud) || !strings.Contains(v.Failures[0].Words, "hid the failure")):
|
||||
t.Errorf("1901/AC8: words %q, status %q, want both to start with %q", v.Failed.Words, line, c.loud)
|
||||
}
|
||||
})
|
||||
t.Run(row.name, row.test)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,3 +68,14 @@ func (m *memKV) Delete(_ context.Context, key string) error {
|
||||
delete(m.revs, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
// memInputs keeps run inputs in a memKV, under their digest.
|
||||
type memInputs struct{ kv *memKV }
|
||||
|
||||
func (m memInputs) Keep(ctx context.Context, digest string, body []byte) error {
|
||||
if _, _, found, _ := m.kv.Get(ctx, digest); found {
|
||||
return nil
|
||||
}
|
||||
_, err := m.kv.Create(ctx, digest, body)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/composer"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
||||
)
|
||||
|
||||
// The check refuses a change whose module graph has a cycle and names it (novox/hq ADR 0603 §2): modules that would each
|
||||
// wait for the other for good, through their bases, the provisions and bus state they await, or the verb edges the graph
|
||||
// keeps (ADR 0614 §6). A verb edge on a cycle is left out of the graph, so it is never refused (§3), and a package or a
|
||||
// build module's edge is a trigger, never a wait, so a cycle through one is not refused either (§6). The graph is worked out from the
|
||||
// catalogue as the change leaves it (every trunk manifest, each one a pull request touches replaced by its candidate's),
|
||||
// with the contract's ManifestGraph, the way the indexer works out its graph's edges. A cycle the trunk already has among
|
||||
// the same modules is the trunk's: said in the verdict's notes, not the change's failure.
|
||||
|
||||
// ManifestReader reads a kept manifest by its digest (the indexer's manifests state).
|
||||
type ManifestReader interface {
|
||||
Manifest(ctx context.Context, digest string) (catalogue.Manifest, error)
|
||||
}
|
||||
|
||||
// refuseCycle fails the check for a cycle of the change's module graph, naming it; nil when the case touches no manifest,
|
||||
// when nothing reads manifests, or when there is none. A manifest that cannot be read fails the check, said, never passes it.
|
||||
func (st *state) refuseCycle(ctx context.Context) *meshdeliverychecker.VerdictsByGroupHeadsValue {
|
||||
if st.j.Manifests == nil || st.j.Read == nil || st.narrowTry() {
|
||||
return nil
|
||||
}
|
||||
trunk := st.j.Manifests.ManifestDigests()
|
||||
req, touched := judgeRequest(trunk, st.c.Pulls)
|
||||
if len(touched) == 0 {
|
||||
return nil
|
||||
}
|
||||
read := func(digests map[string]composer.Manifest) (map[string]catalogue.Manifest, error) {
|
||||
out := map[string]catalogue.Manifest{}
|
||||
for _, module := range slices.Sorted(maps.Keys(digests)) {
|
||||
m, err := st.j.Read.Manifest(ctx, digests[module].Digest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the manifest %s of %s: %w", digests[module].Digest, module, err)
|
||||
}
|
||||
out[module] = m
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
change, err := read(req.Cases[0].Manifests)
|
||||
if err != nil {
|
||||
return fail(goModule, "the change's module graph cannot be worked out, so no cycle can be ruled out: "+err.Error())
|
||||
}
|
||||
cycle := graph.OfManifests(change).Cycle()
|
||||
if cycle == nil {
|
||||
st.c.note("the change's module graph has no cycle")
|
||||
return nil
|
||||
}
|
||||
words := strings.Join(cycle, " -> ")
|
||||
if before, err := read(req.Cases[1].Manifests); err == nil {
|
||||
among := graph.Graph{}
|
||||
for name, n := range graph.OfManifests(before) {
|
||||
if slices.Contains(cycle, name) {
|
||||
among[name] = n
|
||||
}
|
||||
}
|
||||
if among.Cycle() != nil {
|
||||
st.c.note("the module graph has the cycle " + words + ", which the trunk already has among the same modules (ADR 0603 §2)")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fail(goModule, fmt.Sprintf("the change's module graph has the cycle %s: these modules would each wait for the other for "+
|
||||
"good, through their bases, the provisions and bus state they await, or the verbs they invoke (ADR 0603 §2, ADR 0614 §6)", words))
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/composer"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
|
||||
"mesh-delivery-checker/rules"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
||||
)
|
||||
|
||||
// The module graph of a change (novox/hq ADR 0603 §2, ADR 0614 §6): the running checker gathers its ordering edges, the
|
||||
// checking program judges whether it has a cycle (rules.JudgeGraph, ADR 0656 §5). The graph is worked out from the
|
||||
// catalogue as the change leaves it (every trunk manifest, each one a pull request touches replaced by its candidate's)
|
||||
// and as the trunk has it, with the contract's ManifestGraph, the way the indexer works out its graph's edges; the facts
|
||||
// carry only each edge's two modules and its kind.
|
||||
|
||||
// ManifestReader reads a kept manifest by its digest (the indexer's manifests state).
|
||||
type ManifestReader interface {
|
||||
Manifest(ctx context.Context, digest string) (catalogue.Manifest, error)
|
||||
}
|
||||
|
||||
// gatherGraph records the change's and the trunk's module graph edges as a fact; none when the case touches no manifest
|
||||
// or nothing reads manifests. A change's manifest that cannot be read fails the check, said, never passes it; a trunk's
|
||||
// that cannot be read leaves the trunk's graph unread, so no cycle is the trunk's.
|
||||
func (st *state) gatherGraph(ctx context.Context) {
|
||||
if st.j.Manifests == nil || st.j.Read == nil || st.narrowTry() {
|
||||
return
|
||||
}
|
||||
req, touched := judgeRequest(st.j.Manifests.ManifestDigests(), st.c.Pulls)
|
||||
if len(touched) == 0 {
|
||||
return
|
||||
}
|
||||
read := func(digests map[string]composer.Manifest) (map[string]catalogue.Manifest, error) {
|
||||
out := map[string]catalogue.Manifest{}
|
||||
for _, module := range slices.Sorted(maps.Keys(digests)) {
|
||||
m, err := st.j.Read.Manifest(ctx, digests[module].Digest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the manifest %s of %s: %w", digests[module].Digest, module, err)
|
||||
}
|
||||
out[module] = m
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
change, err := read(req.Cases[0].Manifests)
|
||||
if err != nil {
|
||||
st.fail(goModule, "the change's module graph cannot be worked out, so no cycle can be ruled out: "+err.Error())
|
||||
return
|
||||
}
|
||||
g := rules.Graph{}
|
||||
changed := orderingEdges(graph.OfManifests(change))
|
||||
trunk := map[[2]string]string{}
|
||||
if before, err := read(req.Cases[1].Manifests); err == nil {
|
||||
g.Trunk = true
|
||||
trunk = orderingEdges(graph.OfManifests(before))
|
||||
}
|
||||
for _, pair := range slices.SortedFunc(maps.Keys(changed), cmpPair) {
|
||||
e := rules.Edge{From: pair[0], To: pair[1], Kind: changed[pair]}
|
||||
if _, both := trunk[pair]; !both && g.Trunk {
|
||||
e.Only = rules.EdgeChange
|
||||
}
|
||||
g.Edges = append(g.Edges, e)
|
||||
}
|
||||
for _, pair := range slices.SortedFunc(maps.Keys(trunk), cmpPair) {
|
||||
if _, both := changed[pair]; !both {
|
||||
g.Edges = append(g.Edges, rules.Edge{From: pair[0], To: pair[1], Kind: trunk[pair], Only: rules.EdgeTrunk})
|
||||
}
|
||||
}
|
||||
st.facts = append(st.facts, rules.Step{Graph: &g})
|
||||
}
|
||||
|
||||
func cmpPair(a, b [2]string) int {
|
||||
return cmp.Or(strings.Compare(a[0], b[0]), strings.Compare(a[1], b[1]))
|
||||
}
|
||||
|
||||
// orderingEdges are the graph's ordering edges (graph.Graph.Before) between its modules, each pair once with the kind it
|
||||
// is first met by: a base, a provision or bus state awaited of another provider, or a verb the graph keeps served by
|
||||
// another module (a base on itself is a cycle). A verb edge the graph leaves out (ADR 0614 §3), a toolchain and a package edge are none (§6).
|
||||
func orderingEdges(g graph.Graph) map[[2]string]string {
|
||||
out := map[[2]string]string{}
|
||||
add := func(from, to, kind string) {
|
||||
if _, ours := g[to]; !ours || (from == to && kind != rules.EdgeBase) {
|
||||
return
|
||||
}
|
||||
if _, met := out[[2]string{from, to}]; !met {
|
||||
out[[2]string{from, to}] = kind
|
||||
}
|
||||
}
|
||||
for _, name := range slices.Sorted(maps.Keys(g)) {
|
||||
n := g[name]
|
||||
for _, b := range n.Bases {
|
||||
add(name, b, rules.EdgeBase)
|
||||
}
|
||||
for _, a := range n.Awaits {
|
||||
for _, p := range g.Providers(a) {
|
||||
add(name, p, rules.EdgeAwaits)
|
||||
}
|
||||
}
|
||||
for _, v := range n.Invokes {
|
||||
if slices.Contains(n.LeftOut, v) {
|
||||
continue
|
||||
}
|
||||
for _, s := range g.Servers(v) {
|
||||
add(name, s, rules.EdgeInvokes)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
package check
|
||||
|
||||
// The seat definitions of the members' heads (novox/hq ADR 0636 §4, ADR 0633 §6 as its insight of 2026-11-05 (2) says): the
|
||||
// manifest validator inside a check is the one reader that reads them. A manifest of a group may serve or invoke what the
|
||||
// group's contracts modules add to a seat (a verb, an event), which the seat list the checker follows (the trunks') does not
|
||||
// hold yet; judged without them, by the trunk's manifest validator above all, the group's new indexer would be refused for
|
||||
// serving its own seat's new verb, and so would every module that calls it. The definitions are read from each member's head
|
||||
// with the indexer's own reading (the domain SDK's seatdefs: the contracts module's `seats/<seat>/seat.json` with the parts it
|
||||
// includes, and the rule which module may define a seat), through the forge, and put over the list's in the request's Seats.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/seatdefs"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// Trees lists the files of a repository under directories at a commit (the forge's commit trees): {directory: {path
|
||||
// relative to it: blob id}}. A directory the commit lacks is an error wrapping ErrNotFound.
|
||||
type Trees interface {
|
||||
Files(ctx context.Context, repository, commit string, dirs []string) (map[string]map[string]string, error)
|
||||
}
|
||||
|
||||
// headSeats are the definitions, resolved, that the contracts modules the case's members touch hold at their heads, by
|
||||
// seat; none where the checker lists no trees. They are read and ruled on as the indexer reads and rules on a revision's,
|
||||
// with the domain SDK's one reading (mesh-delivery sdk/seatdefs): a seat of the mesh's own contracts is taken only from
|
||||
// its domain's contracts module (ADR 0636 §5), so an outside definer's is left out and said, and a seat two touched modules
|
||||
// define is refused unless one hands it over to its domain's module. A head's definition that cannot be read, does not
|
||||
// parse or names another seat than its directory's is an error: the manifests cannot be judged by the group's seats then.
|
||||
func (st *state) headSeats(ctx context.Context) (map[string]json.RawMessage, error) {
|
||||
if st.j.Trees == nil || st.j.Forge == nil {
|
||||
return nil, nil
|
||||
}
|
||||
out := map[string]json.RawMessage{}
|
||||
by := map[string]string{} // seat -> the module whose head's definition is taken
|
||||
for _, p := range st.c.Pulls {
|
||||
for _, module := range p.Modules {
|
||||
c, ok, err := st.contractsModule(ctx, p, module)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
defs, unparsed, err := st.headForge().Read(ctx, p.Repository, p.Head, c)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the seat definitions of %s at the head of %s cannot be read%s: %w", module, pullKey(p), unheardWords(err), err)
|
||||
}
|
||||
if len(unparsed) > 0 {
|
||||
seat := slices.Sorted(maps.Keys(unparsed))[0]
|
||||
return nil, fmt.Errorf("the seat definition of %s (module %s) at the head of %s is not taken: %s", seat, module, pullKey(p), unparsed[seat])
|
||||
}
|
||||
for _, seat := range slices.Sorted(maps.Keys(defs)) {
|
||||
d := defs[seat]
|
||||
if why := seatdefs.Refusal(c.Module, d); why != "" {
|
||||
st.c.note(fmt.Sprintf("the head of %s's definition of %s is not taken: %s", pullKey(p), seat, why))
|
||||
continue
|
||||
}
|
||||
if prev, twice := by[seat]; twice && prev != c.Module {
|
||||
switch {
|
||||
case seatdefs.HandsOver(prev, c.Module, d):
|
||||
case seatdefs.HandsOver(c.Module, prev, d):
|
||||
continue
|
||||
default:
|
||||
return nil, fmt.Errorf("the seat %s is defined at the heads by both %s and %s: a seat has one definition", seat, prev, c.Module)
|
||||
}
|
||||
}
|
||||
raw, err := json.Marshal(d)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the definition of %s cannot be encoded: %w", seat, err)
|
||||
}
|
||||
out[seat], by[seat] = raw, c.Module
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// headForge is the domain SDK's reading bound to the checker's forge listing and get-file: a directory or a file the head
|
||||
// does not hold is none, every other failure the forge's.
|
||||
func (st *state) headForge() seatdefs.Forge {
|
||||
return seatdefs.Forge{
|
||||
Files: func(ctx context.Context, repository, commit string, dirs []string) (map[string]map[string]string, error) {
|
||||
got, err := st.j.Trees.Files(ctx, repository, commit, dirs)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return got, err
|
||||
},
|
||||
File: func(ctx context.Context, repository, commit, file string) ([]byte, error) {
|
||||
text, err := st.j.Forge.File(ctx, repository, file, commit)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []byte(text), nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// contractsModule is a touched module as a contracts module at its member's head (its package artifact's generated-from,
|
||||
// under the module's directory), and whether the module is a contracts module at all.
|
||||
func (st *state) contractsModule(ctx context.Context, p Pull, module string) (seatdefs.Module, bool, error) {
|
||||
var m catalogue.Manifest
|
||||
dir, known := "", false
|
||||
if st.j.Dirs != nil {
|
||||
dir, known = st.j.Dirs.ModuleDir(module)
|
||||
}
|
||||
if digest := p.Fps[module].Manifest; known && digest != "" && st.j.Read != nil {
|
||||
var err error
|
||||
if m, err = st.j.Read.Manifest(ctx, digest); err != nil {
|
||||
return seatdefs.Module{}, false, fmt.Errorf("the manifest of %s at the head of %s cannot be read: %w", module, pullKey(p), err)
|
||||
}
|
||||
} else {
|
||||
var found bool
|
||||
var err error
|
||||
if m, dir, found, err = st.manifestAtHead(ctx, p, module); err != nil || !found {
|
||||
return seatdefs.Module{}, false, err
|
||||
}
|
||||
}
|
||||
c, ok := seatdefs.Of(dir, m)
|
||||
return c, ok, nil
|
||||
}
|
||||
|
||||
// overSeats is the list's definitions with each of the heads' put in place of the one of its seat, or added, sorted by seat.
|
||||
func overSeats(list []json.RawMessage, heads map[string]json.RawMessage) []json.RawMessage {
|
||||
if len(heads) == 0 {
|
||||
return list
|
||||
}
|
||||
by := map[string]json.RawMessage{}
|
||||
for _, raw := range list {
|
||||
var d struct {
|
||||
Seat string `json:"seat"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &d)
|
||||
by[d.Seat] = raw
|
||||
}
|
||||
maps.Copy(by, heads)
|
||||
out := make([]json.RawMessage, 0, len(by))
|
||||
for _, seat := range slices.Sorted(maps.Keys(by)) {
|
||||
out = append(out, by[seat])
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"mesh-delivery-checker/rules"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
|
||||
)
|
||||
@@ -50,11 +49,6 @@ const (
|
||||
judgedByCandidate = "judged by the candidate's mesh-build-go at "
|
||||
)
|
||||
|
||||
// verdictPtr is a verdict of failure a step of the check ends with.
|
||||
type verdictPtr = *meshdeliverychecker.VerdictsByGroupHeadsValue
|
||||
|
||||
func failed(machine, words string) verdictPtr { return fail(machine, words) }
|
||||
|
||||
// Asks puts a check's ask on its build seat's stream and waits for its step (buildrun.Asker; ADR 0525 §1, §6): the
|
||||
// checker chooses no node, starts no step and holds none; whichever build node pulls the ask runs the check.
|
||||
type Asks interface {
|
||||
@@ -140,16 +134,13 @@ type Examiner struct {
|
||||
Manifests TrunkManifests
|
||||
// SeatDefinitions reads the indexer's seat definitions of a pull request (seatdefs.go, design 26); nil reads none.
|
||||
SeatDefinitions SeatDefinitions
|
||||
// SeatList gives the seat definitions the change's manifests are judged against, as the seat list reader holds them
|
||||
// (manifests_judge.go, novox/hq ADR 0636 §1); nil gives the judge none.
|
||||
// SeatList gives the seat list the checker's reader holds from the seat mesh-catalog (verb seat-definitions): the base the
|
||||
// checking program lays the heads' definitions over (programs.go, novox/hq ADR 0636 §1, §4); nil gives it none.
|
||||
SeatList SeatList
|
||||
// Trees lists a member's files at its head, so the seat definitions of the contracts modules a case touches are read
|
||||
// at their heads and put over SeatList's (headseats.go, novox/hq ADR 0636 §4); nil reads none.
|
||||
Trees Trees
|
||||
// Renames asks the seat mesh-config's verb renames for a change that brings or drops a `renamed-from` (renames.go,
|
||||
// design 32); nil fails such a change, saying it cannot ask.
|
||||
Renames Renames
|
||||
// Read reads a kept manifest by its digest: the change's module graph is worked out from them (graph_cycle.go, novox/hq
|
||||
// Read reads a kept manifest by its digest: the change's module graph is worked out from them (graph_edges.go, novox/hq
|
||||
// ADR 0603 §2); nil works out no graph.
|
||||
Read ManifestReader
|
||||
// Serving says, from what runs, whether a running healthy build serves a seat verb or module tool (novox/hq ADR 0614
|
||||
@@ -173,6 +164,10 @@ type Examiner struct {
|
||||
// and fingerprints a group try's manifest validator and composition judge (issue 1921); nil leaves a whole try with no
|
||||
// manifest or composition judged, said.
|
||||
Candidates TryCandidates
|
||||
// Inputs keeps the checking program's inputs (the state InputsStateName, served on InputVerb); BeginBound is how long a run ask may wait for a
|
||||
// build node to begin it (DefaultBeginBound when zero).
|
||||
Inputs Inputs
|
||||
BeginBound time.Duration
|
||||
Now func() time.Time
|
||||
Logf func(format string, args ...any)
|
||||
}
|
||||
@@ -183,11 +178,10 @@ type TryCandidates interface {
|
||||
TryCandidate(ctx context.Context, group, heads string, bound time.Duration) (meshcatalog.CandidatesByGroupValue, error)
|
||||
}
|
||||
|
||||
// CompositionReader makes a check's composition verdict; an error is only a case that cannot be composed at all. tagged
|
||||
// are the group's tagged versions of its Go packages the check heard (design 51 *Tagged versions*), nil for none: a
|
||||
// composer built at the node-engine member's head is built against them, as the check's other Go builds are.
|
||||
// CompositionReader composes a check: the engines' answers, which the rules judge (rules.JudgeEngines); an error is only
|
||||
// a case that cannot be composed at all.
|
||||
type CompositionReader interface {
|
||||
Compose(ctx context.Context, c *Case, tagged buildstep.PackageVersions) (meshwants.CompositionsByGroupHeadsValue, error)
|
||||
Compose(ctx context.Context, c *Case) (rules.Composed, error)
|
||||
}
|
||||
|
||||
func (j *Examiner) now() time.Time {
|
||||
@@ -269,24 +263,18 @@ func (t *target) stepArgs(args map[string]any) map[string]any {
|
||||
return args
|
||||
}
|
||||
|
||||
// acc collects a level's counts across targets.
|
||||
type acc struct {
|
||||
run, kept, skipped int
|
||||
elapsed, setup float64
|
||||
lint float64
|
||||
over bool
|
||||
slowest []TestTime
|
||||
}
|
||||
|
||||
// state is one judging.
|
||||
type state struct {
|
||||
j *Examiner
|
||||
c *Case
|
||||
targets []*target
|
||||
levels map[string]*acc
|
||||
nodes map[string]bool
|
||||
clone time.Duration
|
||||
slow []string
|
||||
levels rules.Tally
|
||||
// facts are the facts of the check met so far, in order: what the checking program judges (milestone 47/B0).
|
||||
facts []rules.Step
|
||||
// composition is how the check composed, recorded after every step (compose).
|
||||
composition *rules.Composition
|
||||
clone time.Duration
|
||||
slow []string
|
||||
// beside are the siblings read at their trunk's head, by repository (ADR 0548 §6).
|
||||
beside map[string]string
|
||||
// judgeHead is the group's mesh-delivery member head when its merge changes the judge (ADR 0578 §2); empty judges
|
||||
@@ -304,9 +292,6 @@ type state struct {
|
||||
// noCandidate says why a whole try has no candidate of its group from the indexer, so its manifests were not judged and
|
||||
// it was not composed; empty when it has one, and for a pull request's check.
|
||||
noCandidate string
|
||||
// seats is the check's seat list once read (checkSeats), and seatsErr why it is not.
|
||||
seats *checkSeats
|
||||
seatsErr error
|
||||
}
|
||||
|
||||
// narrowTry says the case is a try narrowed to a level or to some tests: it judges no manifest, graph or composition.
|
||||
@@ -353,36 +338,46 @@ func (st *state) takeTryCandidate(ctx context.Context) {
|
||||
// Judge runs the case (it is the Judge of a Checker). A failure of the change, of a merge or of a step is a verdict; an error is only a case that cannot
|
||||
// be judged at all.
|
||||
func (j *Examiner) Judge(ctx context.Context, c *Case) (Outcome, error) {
|
||||
st := &state{j: j, c: c, levels: map[string]*acc{buildstep.LevelUnit: {}, buildstep.LevelIntegration: {}}, nodes: map[string]bool{},
|
||||
st := &state{j: j, c: c, levels: rules.NewTally(),
|
||||
beside: map[string]string{}}
|
||||
// Every member and level runs to its end, and every manifest the change touches is judged by the indexer's judge (ADR
|
||||
// 0578 §2), also after a failure: the verdict names every failure, not only the first (the operator, 2026-11-02).
|
||||
tests := st.judgeAll(ctx)
|
||||
// Each failure is a fact recorded where it is met; the verdict is the judging rules' of the facts.
|
||||
st.judgeAll(ctx)
|
||||
st.takeTryCandidate(ctx)
|
||||
st.seriesChanges(ctx) // DLV-C4: named in the verdict's line, whatever the verdict
|
||||
if v := settled(joinFailures(tests, st.judgeManifests(ctx), st.refuseCycle(ctx), st.judgeRenames(ctx), st.refuseUnparsed(ctx))); v != nil {
|
||||
return st.finish(ctx, *v), nil
|
||||
st.seriesChanges(ctx) // DLV-C4: a fact of the check, named in the verdict whatever it is
|
||||
// The heads' seat list, from the checking program, before the validator and the composer are asked (ADR 0636 §4).
|
||||
// A list it cannot make ends the check there, failed naming the checking program, worded from the facts so far.
|
||||
var answer rules.Answer
|
||||
var by *meshdeliverychecker.VerdictsByGroupHeadsValueJudgedBy
|
||||
if st.seatList(ctx) {
|
||||
st.judgeManifests(ctx)
|
||||
st.gatherGraph(ctx)
|
||||
st.judgeRenames(ctx)
|
||||
st.refuseUnparsed(ctx)
|
||||
st.compose(ctx)
|
||||
// The verdict is the checking program's of the facts (ADR 0644 §1, ADR 0656 §5).
|
||||
answer, by = st.judged(ctx)
|
||||
} else {
|
||||
answer = rules.Answer{V: rules.AnswerVersion, Verdict: rules.Judge(st.factsOf())}
|
||||
}
|
||||
return st.finish(ctx, meshdeliverychecker.VerdictsByGroupHeadsValue{State: meshdeliverycheckerclient.VerdictPass}), nil
|
||||
}
|
||||
|
||||
// finish is the outcome of the verdict with the state of the builds the candidate's modules carry and the assigner's
|
||||
// composition of it, read also when the tests failed, so a failed composition is named beside them.
|
||||
func (st *state) finish(ctx context.Context, v meshdeliverychecker.VerdictsByGroupHeadsValue) Outcome {
|
||||
out := st.outcome(v)
|
||||
st.composed(ctx, &out.Verdict)
|
||||
for _, n := range answer.Notes {
|
||||
st.c.note(n)
|
||||
}
|
||||
answer.Verdict.JudgedBy = by
|
||||
out := st.outcome(answer.Verdict)
|
||||
out.Builds = st.j.buildNotes(ctx, st.c)
|
||||
return out
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// composed makes the check's composition from the engines' answers into its verdict (ADR 0593 §7, §9): a failed
|
||||
// composition fails the check, naming the machine it failed at; a node that did not answer is a warning. A try is not
|
||||
// composed (it has no candidate); a composition that cannot be made leaves it not run, said.
|
||||
func (st *state) composed(ctx context.Context, v *meshdeliverychecker.VerdictsByGroupHeadsValue) {
|
||||
// compose records how the check composed as the facts' composition, after every step (ADR 0593 §7, §9): kept by a carry,
|
||||
// not run (said why), or the engines' answers with the candidate's builds awaited first (ADR 0603 §10). A narrowed try
|
||||
// and an examiner that composes nothing record none.
|
||||
func (st *state) compose(ctx context.Context) {
|
||||
if st.c.Composition != nil && st.c.Try == nil {
|
||||
// A carry whose candidate's fingerprints did not change keeps the composition it passed (ADR 0548 §5).
|
||||
kept := *st.c.Composition
|
||||
v.Composition = &kept
|
||||
st.composition = &rules.Composition{Kept: &kept}
|
||||
st.c.note("the composition is kept: no fingerprint of a module it composed changed")
|
||||
return
|
||||
}
|
||||
@@ -390,7 +385,7 @@ func (st *state) composed(ctx context.Context, v *meshdeliverychecker.VerdictsBy
|
||||
return
|
||||
}
|
||||
if st.noCandidate != "" {
|
||||
v.Composition = &meshdeliverychecker.VerdictsByGroupHeadsValueComposition{State: meshdeliverycheckerclient.CompositionNotRun, Words: st.noCandidate}
|
||||
st.composition = &rules.Composition{NotRun: st.noCandidate}
|
||||
return
|
||||
}
|
||||
if st.j.Compositions == nil {
|
||||
@@ -398,78 +393,32 @@ func (st *state) composed(ctx context.Context, v *meshdeliverychecker.VerdictsBy
|
||||
}
|
||||
if len(st.unpreviewed) > 0 {
|
||||
// An engine would only say it cannot read a manifest the indexer will not have: the check does not ask it.
|
||||
v.Composition = &meshdeliverychecker.VerdictsByGroupHeadsValueComposition{State: meshdeliverycheckerclient.CompositionNotRun, Words: "no preview of a merge of " +
|
||||
strings.Join(st.unpreviewed, ", ") + " (its head does not merge or its fetch did not answer), so the change cannot be composed"}
|
||||
st.composition = &rules.Composition{NotRun: "no preview of a merge of " + strings.Join(st.unpreviewed, ", ") +
|
||||
" (its head does not merge or its fetch did not answer), so the change cannot be composed"}
|
||||
return
|
||||
}
|
||||
words := st.awaitBuilds(ctx, v) // ADR 0603 §10: the check composes with the candidate's builds
|
||||
// The composer of a node-engine member's head composes against the check's seat list, the judge's (ADR 0635 §1, ADR 0636
|
||||
// §4); only a list the reader read is handed: the heads' alone are no list, and the composer then judges no machine.
|
||||
st.c.Seats = nil
|
||||
seats, serr := st.checkSeats(ctx)
|
||||
switch head := composerHead(st.c); {
|
||||
case serr != nil && head != "":
|
||||
// A composition at a node-engine member's head that cannot read the seat list it needs fails, naming why, rather than
|
||||
// passing with no machine judged (ADR 0635 §3, ADR 0636 §1).
|
||||
withComposition(v, meshwants.CompositionsByGroupHeadsValue{Group: st.c.Group, Heads: st.c.Heads, State: meshdeliverycheckerclient.CompositionFail,
|
||||
Sdk: head, Words: "the composer at the node-engine's head " + head + " cannot be given the seat list it composes against: " + serr.Error()})
|
||||
return
|
||||
case serr != nil:
|
||||
st.c.note("the composition is given no seat list: " + serr.Error())
|
||||
case seats.listed:
|
||||
st.c.Seats = seats.defs
|
||||
}
|
||||
comp, err := st.j.Compositions.Compose(ctx, st.c, st.tags.goPackages)
|
||||
// ADR 0603 §10: the check composes with the candidate's builds; whether it failed already is the running rules' word
|
||||
// on the facts so far, an asking choice only (the verdict is the rules' of every fact).
|
||||
failed := rules.Judge(st.factsOf()).State == meshdeliverycheckerclient.VerdictFail
|
||||
words, builds := st.awaitBuilds(ctx, failed)
|
||||
c := &rules.Composition{Waits: words, BuildsFailed: builds, FPSet: st.c.FPSet}
|
||||
st.composition = c
|
||||
// A composer built at the node-engine member's head is built against the group's tagged versions the check heard, as
|
||||
// its other Go builds are (design 51 *Tagged versions*); it composes against the heads' seat list (seatList).
|
||||
st.c.Tagged = st.tags.goPackages
|
||||
comp, err := st.j.Compositions.Compose(ctx, st.c)
|
||||
if err != nil {
|
||||
// Said in the verdict, never left as a composition not yet run (review finding 4).
|
||||
st.j.logf("%s: the composition could not be made, so it is said as not run: %v", st.c.Key, err)
|
||||
v.Composition = &meshdeliverychecker.VerdictsByGroupHeadsValueComposition{State: meshdeliverycheckerclient.CompositionNotRun, Words: "not made: " + err.Error()}
|
||||
c.NotRun = "not made: " + err.Error()
|
||||
return
|
||||
}
|
||||
comp.Warnings = append(comp.Warnings, words...) // ADR 0603 §10
|
||||
withComposition(v, comp)
|
||||
if v.Composition != nil && v.Composition.State != meshdeliverycheckerclient.CompositionNotRun {
|
||||
v.Composition.Fpset = st.c.FPSet // a carry keeps it while the fingerprints stay the same (ADR 0548 §5)
|
||||
}
|
||||
c.Composed = &comp
|
||||
}
|
||||
|
||||
// outcome fills the verdict's levels, times, machines and composition from what ran.
|
||||
// outcome is the verdict judged from the facts with the slowest tests, the siblings read and what judged a build module.
|
||||
func (st *state) outcome(v meshdeliverychecker.VerdictsByGroupHeadsValue) Outcome {
|
||||
v.Series = st.c.allSeries() // the series changes, as structures (A2j finding 2)
|
||||
if v.State == meshdeliverycheckerclient.VerdictPass || len(v.Levels) == 0 {
|
||||
v.Levels = map[string]meshdeliverychecker.VerdictsByGroupHeadsValueLevelsValue{}
|
||||
for level, a := range st.levels {
|
||||
v.Levels[level] = meshdeliverychecker.VerdictsByGroupHeadsValueLevelsValue{Run: int64(a.run), Kept: int64(a.kept), Skipped: int64(a.skipped)}
|
||||
}
|
||||
}
|
||||
ms := func(s float64) int64 { return int64(s * 1000) }
|
||||
u, i := st.levels[buildstep.LevelUnit], st.levels[buildstep.LevelIntegration]
|
||||
v.Took = &meshdeliverychecker.VerdictsByGroupHeadsValueTook{Clone: st.clone.Milliseconds(), Unit: ms(u.elapsed), Lint: ms(u.lint), IntegrationSetup: ms(i.setup), IntegrationRun: ms(i.elapsed)}
|
||||
for node := range st.nodes {
|
||||
v.Machines = append(v.Machines, node)
|
||||
}
|
||||
sort.Strings(v.Machines)
|
||||
for _, level := range []string{buildstep.LevelUnit, buildstep.LevelIntegration} {
|
||||
if st.levels[level].over {
|
||||
v.OverBound = append(v.OverBound, level)
|
||||
}
|
||||
}
|
||||
// The composition is made from the engines' answers (ADR 0593 §7): until it is, a verdict says it was not run (ADR 0484 clause 6).
|
||||
v.Composition = &meshdeliverychecker.VerdictsByGroupHeadsValueComposition{State: meshdeliverycheckerclient.CompositionNotRun}
|
||||
var slow []string
|
||||
for _, level := range []string{buildstep.LevelUnit, buildstep.LevelIntegration} {
|
||||
a := st.levels[level]
|
||||
if !a.over {
|
||||
continue
|
||||
}
|
||||
sort.SliceStable(a.slowest, func(x, y int) bool { return a.slowest[x].Elapsed > a.slowest[y].Elapsed })
|
||||
for n, s := range a.slowest {
|
||||
if n == 3 {
|
||||
break
|
||||
}
|
||||
slow = append(slow, fmt.Sprintf("%s %s %.1fs", level, path.Base(s.Package)+"."+s.Test, s.Elapsed))
|
||||
}
|
||||
}
|
||||
slow := rules.Slowest(st.levels)
|
||||
out := Outcome{Verdict: v, Slowest: slow, OutOfStep: st.outOfStep}
|
||||
if len(st.beside) > 0 {
|
||||
out.Beside = st.beside
|
||||
@@ -499,12 +448,39 @@ func (st *state) stepOutOfStep(p Pull, files []string) {
|
||||
st.outOfStep[k] = slices.Compact(all)
|
||||
}
|
||||
|
||||
// fail is a verdict of failure at a machine.
|
||||
// fail records a failure the asking met as a fact of the check, where it is met, and answers it as a verdict of failure
|
||||
// at a machine: the verdict is the judging rules' of the facts (rules.Judge), so a failure only returned is no failure
|
||||
// (milestone 47/B0).
|
||||
func (st *state) fail(machine, words string) {
|
||||
st.facts = append(st.facts, rules.Step{Failed: &FailedFirst{Machine: machine, Words: words}})
|
||||
}
|
||||
|
||||
// fail is a verdict of failure at a machine, for a step that answers one before any member is asked (tagged); it is
|
||||
// recorded as facts (failedAll), never returned as the check's verdict.
|
||||
func fail(machine, words string) *meshdeliverychecker.VerdictsByGroupHeadsValue {
|
||||
v := failure(machine, words)
|
||||
return &v
|
||||
}
|
||||
|
||||
// failedAll records every failure a verdict of the asking names (its list, else its first) as facts of the check.
|
||||
func (st *state) failedAll(v meshdeliverychecker.VerdictsByGroupHeadsValue) {
|
||||
all := failedList(v.Failures)
|
||||
if len(all) == 0 {
|
||||
all = []FailedFirst{failedOf(v)}
|
||||
}
|
||||
for _, f := range all {
|
||||
st.facts = append(st.facts, rules.Step{Failed: &f})
|
||||
}
|
||||
}
|
||||
|
||||
// factsOf are the check's facts as met: what the checking program judges.
|
||||
func (st *state) factsOf() rules.Facts {
|
||||
f := st.factsHead()
|
||||
f.Steps, f.CloneMS, f.Composition, f.Notes = st.facts, st.clone.Milliseconds(), st.composition, st.c.Notes()
|
||||
f.Series = st.c.allSeries()
|
||||
return f
|
||||
}
|
||||
|
||||
// levels are the levels a case's asks run: both, or the one a try names.
|
||||
func (st *state) levelsOf() []string {
|
||||
if st.c.Try != nil && st.c.Try.Level != "" {
|
||||
@@ -517,7 +493,7 @@ func (st *state) levelsOf() []string {
|
||||
// judges each answer. A head that does not merge, a pin that is not allowed or a level that fails is a failure of that
|
||||
// member; every other member is still judged, and the verdict names every failure (the operator, 2026-11-02), unjoined
|
||||
// to its first lines (settled does that). Only a check whose context ended stops.
|
||||
func (st *state) judgeAll(ctx context.Context) *meshdeliverychecker.VerdictsByGroupHeadsValue {
|
||||
func (st *state) judgeAll(ctx context.Context) {
|
||||
// The mesh-sdk member first: whether its merge changes the TypeScript package's paths decides whether the other members'
|
||||
// Node fetches take the group's package (ADR 0500 §5).
|
||||
pulls := slices.Clone(st.c.Pulls)
|
||||
@@ -531,16 +507,18 @@ func (st *state) judgeAll(ctx context.Context) *meshdeliverychecker.VerdictsByGr
|
||||
return 0
|
||||
})
|
||||
// A group's members are built only after each package they declare is published as the group's tagged version
|
||||
// (design 51 *Tagged versions*): a package not published within the bound fails the check before any member is built.
|
||||
// (design 51 *Tagged versions*): a package not published within the bound fails the check before any member is built,
|
||||
// each failure a fact of the check.
|
||||
if v := st.tagged(ctx); v != nil {
|
||||
return v
|
||||
st.failedAll(*v)
|
||||
return
|
||||
}
|
||||
var groupPackage *buildstep.FetchPackage
|
||||
var failures []*meshdeliverychecker.VerdictsByGroupHeadsValue
|
||||
met := len(st.facts)
|
||||
for _, p := range pulls {
|
||||
if ctx.Err() != nil {
|
||||
if len(failures) == 0 { // an ask that ended with the context said it already
|
||||
failures = append(failures, fail(CheckerModule, ctx.Err().Error()))
|
||||
if !st.failedSince(met) { // an ask that ended with the context said it already
|
||||
st.fail(CheckerModule, ctx.Err().Error())
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -563,7 +541,7 @@ func (st *state) judgeAll(ctx context.Context) *meshdeliverychecker.VerdictsByGr
|
||||
if bm == buildrun.GoBuildModule {
|
||||
beside, err := st.j.besideFor(ctx, st.c, p)
|
||||
if err != nil {
|
||||
failures = append(failures, fail(forgeMachine(err, bm), err.Error()+unheardWords(err)))
|
||||
st.fail(forgeMachine(err, bm), err.Error()+unheardWords(err))
|
||||
continue
|
||||
}
|
||||
if len(beside) > 0 {
|
||||
@@ -577,9 +555,7 @@ func (st *state) judgeAll(ctx context.Context) *meshdeliverychecker.VerdictsByGr
|
||||
}
|
||||
t.fetchArgs = args
|
||||
st.targets = append(st.targets, t)
|
||||
if v := st.ask(ctx, t, &merged); v != nil {
|
||||
failures = append(failures, v)
|
||||
}
|
||||
st.ask(ctx, t, &merged)
|
||||
}
|
||||
// Every build module's fetch merged (each is in merged once): the preview is written, also when the member's tests
|
||||
// failed, so the indexer holds its manifests and the assigner can compose the change (review of 650a1d0).
|
||||
@@ -598,7 +574,16 @@ func (st *state) judgeAll(ctx context.Context) *meshdeliverychecker.VerdictsByGr
|
||||
groupPackage = &buildstep.FetchPackage{Repository: p.Repository, Commit: p.Head, Ref: refOf(p.Number)}
|
||||
}
|
||||
}
|
||||
return joinFailures(failures...)
|
||||
}
|
||||
|
||||
// failedSince says a failure was recorded among the facts from the nth on.
|
||||
func (st *state) failedSince(n int) bool {
|
||||
for _, f := range st.facts[n:] {
|
||||
if f.Failed != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DefaultDeclineBound is an Examiner's DeclineBound when it names none: longer than a check runs, so a node with the
|
||||
@@ -609,14 +594,14 @@ const DefaultDeclineBound = 30 * time.Minute
|
||||
// each level's digest, kept verdicts and tests. When the answer leaves packages unmet,
|
||||
// their tests needing what that node lacks (issue 890), the passes are kept and the levels from the first such one are
|
||||
// asked again of a node that has it (placed).
|
||||
func (st *state) ask(ctx context.Context, t *target, merged *[]Fetched) *meshdeliverychecker.VerdictsByGroupHeadsValue {
|
||||
return st.askNeeding(ctx, t, merged, st.levelsOf(), nil, nil)
|
||||
func (st *state) ask(ctx context.Context, t *target, merged *[]Fetched) {
|
||||
st.askNeeding(ctx, t, merged, st.levelsOf(), nil, nil)
|
||||
}
|
||||
|
||||
// askNeeding is ask with the levels to run, what the node must have and the packages it tests: none for the first ask
|
||||
// of a target; for the placed one the unmet packages' needs and those packages alone, so no package the first ask judged
|
||||
// is run or counted again (ADR 0552 §4).
|
||||
func (st *state) askNeeding(ctx context.Context, t *target, merged *[]Fetched, levels, needs, packages []string) *meshdeliverychecker.VerdictsByGroupHeadsValue {
|
||||
func (st *state) askNeeding(ctx context.Context, t *target, merged *[]Fetched, levels, needs, packages []string) {
|
||||
p := t.pull
|
||||
placed := len(needs) > 0
|
||||
job := &buildstep.CheckJob{Fetch: t.fetchArgs, Levels: levels, Kept: true, Packages: packages, Toolchain: t.agreed}
|
||||
@@ -626,7 +611,8 @@ func (st *state) askNeeding(ctx context.Context, t *target, merged *[]Fetched, l
|
||||
err = fmt.Errorf("no node record is on the bus")
|
||||
}
|
||||
if err != nil {
|
||||
return fail(t.module, fmt.Sprintf("the check of %s#%d is not asked: the nodes' names the names check needs cannot be read: %v", p.Repository, p.Number, err))
|
||||
st.fail(t.module, fmt.Sprintf("the check of %s#%d is not asked: the nodes' names the names check needs cannot be read: %v", p.Repository, p.Number, err))
|
||||
return
|
||||
}
|
||||
job.NodeNames = names
|
||||
}
|
||||
@@ -655,7 +641,8 @@ func (st *state) askNeeding(ctx context.Context, t *target, merged *[]Fetched, l
|
||||
st.c.Progress.Set(what, "")
|
||||
st.c.Asked(a.ID)
|
||||
if err := st.j.Asks.Put(ctx, a); err != nil {
|
||||
return fail(t.module, fmt.Sprintf("the check of %s#%d could not be asked: %v", p.Repository, p.Number, err))
|
||||
st.fail(t.module, fmt.Sprintf("the check of %s#%d could not be asked: %v", p.Repository, p.Number, err))
|
||||
return
|
||||
}
|
||||
waiting := ctx
|
||||
if watch := st.c.Watch; watch != nil {
|
||||
@@ -667,9 +654,11 @@ func (st *state) askNeeding(ctx context.Context, t *target, merged *[]Fetched, l
|
||||
step, err = st.j.Asks.Wait(waiting, a)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return fail(t.module, ctx.Err().Error())
|
||||
st.fail(t.module, ctx.Err().Error())
|
||||
return
|
||||
}
|
||||
return fail(t.module, fmt.Sprintf("the check of %s#%d: %v", p.Repository, p.Number, err))
|
||||
st.fail(t.module, fmt.Sprintf("the check of %s#%d: %v", p.Repository, p.Number, err))
|
||||
return
|
||||
}
|
||||
d, declined := buildstep.DeclinedOf(step)
|
||||
if !declined {
|
||||
@@ -690,36 +679,40 @@ func (st *state) askNeeding(ctx context.Context, t *target, merged *[]Fetched, l
|
||||
if lacks == "" {
|
||||
lacks = strings.Join(d.Lacks, ", ") // a node off the agreed toolchain declined it (ADR 0584)
|
||||
}
|
||||
return fail(step.Node, fmt.Sprintf("the check of %s#%d needs a node with %s, and none took it in %s: every node that pulled it lacks it (last %s)",
|
||||
st.fail(step.Node, fmt.Sprintf("the check of %s#%d needs a node with %s, and none took it in %s: every node that pulled it lacks it (last %s)",
|
||||
p.Repository, p.Number, lacks, bound, step.Node))
|
||||
return
|
||||
}
|
||||
st.j.logf("%s#%d: the ask %s was declined on %s, which lacks %s; asked again", p.Repository, p.Number, a.ID, step.Node, strings.Join(d.Lacks, ", "))
|
||||
a.ID, a.At = buildstep.NewID(), now.UTC()
|
||||
}
|
||||
var failures []*meshdeliverychecker.VerdictsByGroupHeadsValue
|
||||
// The fetch and the digests are what of the ask's time its levels' running did not take (Took.Clone).
|
||||
took := time.Duration(0)
|
||||
if !step.Started.IsZero() && step.Ended.After(step.Started) {
|
||||
took = step.Ended.Sub(step.Started)
|
||||
}
|
||||
t.node = step.Node
|
||||
st.nodes[step.Node] = true
|
||||
st.facts = append(st.facts, rules.Step{Repository: p.Repository, Number: p.Number, Module: t.module, Node: step.Node})
|
||||
st.c.Progress.Set(fmt.Sprintf("judge the check of %s#%d", p.Repository, p.Number), step.Node)
|
||||
if step.State != buildstep.StepPassed {
|
||||
return fail(step.Node, fmt.Sprintf("the check of %s#%d %s: %s", p.Repository, p.Number, step.State, step.Reason))
|
||||
st.fail(step.Node, fmt.Sprintf("the check of %s#%d %s: %s", p.Repository, p.Number, step.State, step.Reason))
|
||||
return
|
||||
}
|
||||
var answer buildstep.CheckAnswer
|
||||
if err := json.Unmarshal(step.Result, &answer); err != nil {
|
||||
return fail(step.Node, fmt.Sprintf("the check of %s#%d answered what is not a check: %s", p.Repository, p.Number, step.Result))
|
||||
st.fail(step.Node, fmt.Sprintf("the check of %s#%d answered what is not a check: %s", p.Repository, p.Number, step.Result))
|
||||
return
|
||||
}
|
||||
if t.fetched = answer.Fetch; t.fetched.Workspace == "" {
|
||||
return fail(step.Node, fmt.Sprintf("the fetch of %s#%d answered no workspace: %s", p.Repository, p.Number, step.Result))
|
||||
st.fail(step.Node, fmt.Sprintf("the fetch of %s#%d answered no workspace: %s", p.Repository, p.Number, step.Result))
|
||||
return
|
||||
}
|
||||
if len(t.fetched.Conflicts) > 0 {
|
||||
if !st.narrowTry() {
|
||||
st.j.writePreview(ctx, p, append(*merged, t.fetched))
|
||||
}
|
||||
return fail(step.Node, fmt.Sprintf("%s#%d %s%s", p.Repository, p.Number, ConflictWords, strings.Join(t.fetched.Conflicts, ", ")))
|
||||
st.fail(step.Node, fmt.Sprintf("%s#%d %s%s", p.Repository, p.Number, ConflictWords, strings.Join(t.fetched.Conflicts, ", ")))
|
||||
return
|
||||
}
|
||||
t.changed = t.fetched.Changed
|
||||
if !changesBuildGo(p.Repository, t.changed) {
|
||||
@@ -727,25 +720,20 @@ func (st *state) askNeeding(ctx context.Context, t *target, merged *[]Fetched, l
|
||||
}
|
||||
if !placed { // the placed ask fetched the same merge: its preview was read from the first
|
||||
*merged = append(*merged, t.fetched)
|
||||
if v := st.checkBinariesOf(t); v != nil {
|
||||
failures = append(failures, v) // the levels still run: the verdict names every failure
|
||||
}
|
||||
st.checkBinariesOf(t) // the levels still run: the verdict names every failure
|
||||
}
|
||||
ran := 0.0
|
||||
for _, la := range answer.Levels {
|
||||
if placed {
|
||||
if len(la.Unmet) > 0 {
|
||||
st.clone += max(0, took-time.Duration(ran*float64(time.Second)))
|
||||
return fail(step.Node, fmt.Sprintf("the %s level of %s#%d left %s untested on %s, which was asked for a node with %s",
|
||||
st.fail(step.Node, fmt.Sprintf("the %s level of %s#%d left %s untested on %s, which was asked for a node with %s",
|
||||
la.Level, p.Repository, p.Number, unmetNames(la.Unmet), step.Node, strings.Join(needs, ", ")))
|
||||
return
|
||||
}
|
||||
la.Kept = nil // it names its packages and takes no other's kept verdict; this is only a guard against an older module
|
||||
}
|
||||
v, elapsed := st.level(ctx, t, la)
|
||||
ran += elapsed
|
||||
if v != nil {
|
||||
failures = append(failures, v) // the next level was run too (buildstep): its failures are named as well
|
||||
}
|
||||
ran += st.level(ctx, t, la) // the next level was run too (buildstep): its failures are named as well
|
||||
}
|
||||
st.clone += max(0, took-time.Duration(ran*float64(time.Second)))
|
||||
if unmet := buildstep.UnmetNeeds(answer.Levels); len(unmet) > 0 && !placed {
|
||||
@@ -765,9 +753,8 @@ func (st *state) askNeeding(ctx context.Context, t *target, merged *[]Fetched, l
|
||||
}
|
||||
}
|
||||
st.j.logf("%s#%d: %s on %s left untested; asked of a node with %s", p.Repository, p.Number, unmetOf(answer.Levels), step.Node, strings.Join(unmet, ", "))
|
||||
failures = append(failures, st.askNeeding(ctx, t, merged, again, unmet, packages))
|
||||
st.askNeeding(ctx, t, merged, again, unmet, packages)
|
||||
}
|
||||
return joinFailures(failures...)
|
||||
}
|
||||
|
||||
// unmetNames names the unmet packages and their needs.
|
||||
@@ -794,64 +781,32 @@ func unmetOf(levels []buildstep.LevelAnswer) string {
|
||||
// of the packages that passed, the kept ones taken are counted, and every test result is read and counted, also after
|
||||
// one that did not pass, so the verdict names every failed test of the level (issue 1921). It answers the level's
|
||||
// running time (its tests' and setup's).
|
||||
func (st *state) level(ctx context.Context, t *target, la buildstep.LevelAnswer) (*meshdeliverychecker.VerdictsByGroupHeadsValue, float64) {
|
||||
level := la.Level
|
||||
a := st.levels[level]
|
||||
if a == nil {
|
||||
return fail(t.node, fmt.Sprintf("the check of %s#%d answered the level %q, which is no level", t.pull.Repository, t.pull.Number, level)), 0
|
||||
}
|
||||
var digests map[string]string
|
||||
if t.module == buildrun.GoBuildModule && la.Digest != nil {
|
||||
// The packages come as a list, never keyed by a package path (issue 1930); an answer stored before reads too.
|
||||
digests = la.Digest.Map()
|
||||
if t.runner != "" {
|
||||
t.judgedBy(la.Digest.JudgedBy) // the installed module judged it: the runner is not used again
|
||||
}
|
||||
func (st *state) level(ctx context.Context, t *target, la buildstep.LevelAnswer) float64 {
|
||||
if t.runner != "" && t.module == buildrun.GoBuildModule && la.Digest != nil {
|
||||
t.judgedBy(la.Digest.JudgedBy) // the installed module judged it: the runner is not used again
|
||||
}
|
||||
if t.runner != "" && la.Judged != "" {
|
||||
t.judgedBy(la.Judged) // the candidate judged the level: nothing is kept (ADR 0535 §4)
|
||||
}
|
||||
for _, k := range la.Kept {
|
||||
a.kept += k.Tests
|
||||
_, elapsed, results := st.levels.Level(t.pull.Repository, t.pull.Number, t.module, t.node, la)
|
||||
st.facts = append(st.facts, rules.Step{Repository: t.pull.Repository, Number: t.pull.Number, Module: t.module, Node: t.node, Level: rules.LevelFact(la)})
|
||||
var digests map[string]string
|
||||
if t.module == buildrun.GoBuildModule && la.Digest != nil {
|
||||
// The packages come as a list, never keyed by a package path (issue 1930); an answer stored before reads too.
|
||||
digests = la.Digest.Map()
|
||||
}
|
||||
elapsed := 0.0
|
||||
var failedResults []Tested
|
||||
for _, raw := range la.Tests {
|
||||
var res Tested
|
||||
if err := json.Unmarshal(raw, &res); err != nil {
|
||||
return fail(t.node, fmt.Sprintf("the %s level of %s#%d answered what is not a result: %v", level, t.pull.Repository, t.pull.Number, err)), elapsed
|
||||
}
|
||||
for _, res := range results {
|
||||
if t.runner != "" {
|
||||
t.judgedBy(res.JudgedBy)
|
||||
}
|
||||
elapsed += res.Elapsed + res.Setup + res.Lint
|
||||
if t.module == buildrun.NodeBuildModule {
|
||||
if !res.NoTests {
|
||||
a.run++ // a Node package is counted whole: its build module counts no tests (ADR 0484 clause 10)
|
||||
}
|
||||
} else {
|
||||
a.run += res.Tests
|
||||
a.skipped += res.Skipped
|
||||
a.slowest = append(a.slowest, res.Slowest...)
|
||||
}
|
||||
a.elapsed += res.Elapsed
|
||||
a.setup += res.Setup
|
||||
a.lint += res.Lint
|
||||
a.over = a.over || res.OverBound
|
||||
st.stepOutOfStep(t.pull, res.OutOfStep)
|
||||
if len(digests) > 0 {
|
||||
if _, err := st.j.Kept.KeepPasses(ctx, t.pull.Repository, level, t.toolchain, res, digests, st.j.now()); err != nil {
|
||||
if _, err := st.j.Kept.KeepPasses(ctx, t.pull.Repository, la.Level, t.toolchain, res, digests, st.j.now()); err != nil {
|
||||
st.j.logf("%s#%d: a passing package could not be kept: %v", t.pull.Repository, t.pull.Number, err)
|
||||
}
|
||||
}
|
||||
if !res.Passed {
|
||||
failedResults = append(failedResults, res)
|
||||
}
|
||||
st.stepOutOfStep(t.pull, res.OutOfStep)
|
||||
}
|
||||
if len(failedResults) > 0 {
|
||||
return levelFailure(t, level, failedResults...), elapsed
|
||||
}
|
||||
return nil, elapsed
|
||||
return elapsed
|
||||
}
|
||||
|
||||
// changesPackage says whether the merge of a pull request changes a file inside the named paths of one of its repository's
|
||||
@@ -873,259 +828,3 @@ func (j *Examiner) changesPackage(p Pull, merged []Fetched) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// failedName names a failure: its package and test.
|
||||
func failedName(f Failure) string {
|
||||
if f.Test == "" {
|
||||
return f.Package
|
||||
}
|
||||
return f.Package + "." + f.Test
|
||||
}
|
||||
|
||||
// MaxWords is the most a failed test's words in a verdict hold (issue 1901). **The words keep the failure's last lines**,
|
||||
// where a test says why it failed: the first line is often something it logged before (the group r4g verdict kept a
|
||||
// "judged against" line and lost the t.Fatalf after it), and the 120 characters kept before cut a docker command's echo
|
||||
// before docker's own error. Four kilobytes holds a long message and is small beside a verdict's other parts.
|
||||
const MaxWords = 4096
|
||||
|
||||
// failureWords is a failure's words: its output's last lines up to MaxWords, with the line the build module told apart as
|
||||
// the test's error first when the output does not start with it, so the first line of the words is the cause and the
|
||||
// status line, which is cut short, names it. **A failure's words are never "PASS" or "ok"** (issue 1901, its third
|
||||
// meeting: a package that failed after its tests passed was worded "PASS"): go test's summary lines are dropped, and with
|
||||
// no cause told apart (an older build module) the cause is the output's first "panic:", "fatal error:" or "signal:"
|
||||
// line, read before the output is cut, or the words say the tests passed and the program failed after them.
|
||||
func failureWords(f Failure) string {
|
||||
return failureWordsAfter(f, "")
|
||||
}
|
||||
|
||||
// failureWordsAfter is failureWords with the line loud put first, within the same MaxWords: what the checker itself says of
|
||||
// the failure (that the build module hid it), which the status line then names.
|
||||
func failureWordsAfter(f Failure, loud string) string {
|
||||
out, passed := dropSummary(strings.TrimSpace(f.Output))
|
||||
cause := strings.TrimSpace(f.Cause)
|
||||
if cause == "" {
|
||||
cause = causeIn(out)
|
||||
}
|
||||
switch {
|
||||
case cause == "" && out == "" && passed:
|
||||
cause = "the tests passed, then the test program exited with a failure: an os.Exit or a panic in TestMain or after the tests"
|
||||
case cause == "" && out == "":
|
||||
// The build module on main sends a package that stopped (a timeout, an exit, a kill) as its "FAIL" line alone.
|
||||
cause = "the package failed with no test to name and no panic, signal or build error in its output (an older build module sent no cause)"
|
||||
}
|
||||
if len(cause) > MaxCause {
|
||||
cause = strings.ToValidUTF8(cause[:MaxCause], "") + "..."
|
||||
}
|
||||
// The words keep MaxWords exactly: the loud line, the cause and their newlines, the marker and the kept lines.
|
||||
budget := MaxWords
|
||||
if cause != "" {
|
||||
budget -= len(cause) + 1
|
||||
}
|
||||
if loud != "" {
|
||||
budget -= len(loud) + 1
|
||||
}
|
||||
if len(out) > budget {
|
||||
const marker = "... "
|
||||
tail := out[len(out)-(budget-len(marker)):]
|
||||
// Start on a whole line, and a whole character.
|
||||
if i := strings.IndexByte(tail, '\n'); i >= 0 {
|
||||
tail = strings.TrimLeft(tail[i:], "\n")
|
||||
}
|
||||
out = marker + strings.ToValidUTF8(tail, "")
|
||||
}
|
||||
if cause != "" && !strings.HasPrefix(firstLine(out), cause) {
|
||||
out = strings.TrimRight(cause+"\n"+out, "\n")
|
||||
}
|
||||
if loud != "" {
|
||||
out = strings.TrimRight(loud+"\n"+out, "\n")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hidWords is the checker's loud line for a failure the build module sent hiding its cause (issue 1901, its thorough
|
||||
// fix): with no cause, or a passing line as its cause, under the failure contract (buildstep.Hidden). It names the build
|
||||
// module and the version it says it is (Tested.By, "<module> with mesh-sdk <version>"); one from before the contract
|
||||
// says nothing of itself. "" when the failure keeps the contract. The check fails either way: only its words change.
|
||||
func hidWords(module, by string, f Failure) string {
|
||||
why := buildstep.Hidden(f)
|
||||
if why == "" {
|
||||
return ""
|
||||
}
|
||||
version := "a version before the failure contract"
|
||||
if module == "" {
|
||||
module = "an unnamed build module"
|
||||
}
|
||||
if m, v, ok := strings.Cut(by, " with "); ok && m != "" {
|
||||
module, version = m, v
|
||||
}
|
||||
return fmt.Sprintf("the build module hid the failure (%s at %s): %s", module, version, why)
|
||||
}
|
||||
|
||||
// MaxCause is the most a cause in a failure's words holds: one long line (the 1901 review: a cause of a kilobyte could
|
||||
// push the words past MaxWords).
|
||||
const MaxCause = 1024
|
||||
|
||||
// dropSummary drops go test's summary lines from a failure's output ("PASS", "FAIL", "ok <pkg>", "FAIL <pkg>"), and says
|
||||
// whether a "PASS" was among them: the package's tests passed before it failed.
|
||||
func dropSummary(out string) (string, bool) {
|
||||
passed := false
|
||||
lines := strings.Split(out, "\n")
|
||||
kept := lines[:0]
|
||||
for _, l := range lines {
|
||||
// go's exact forms only (review round 1): "PASS" or "FAIL" alone, "ok \t<pkg>\t<time>", "FAIL\t<pkg>\t<time>".
|
||||
switch t := strings.TrimRight(l, "\r"); {
|
||||
case t == "PASS":
|
||||
passed = true
|
||||
case t == "FAIL", strings.HasPrefix(t, "ok \t"), strings.HasPrefix(t, "FAIL\t"):
|
||||
default:
|
||||
kept = append(kept, l)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(kept, "\n")), passed
|
||||
}
|
||||
|
||||
// causeIn is a race the race detector reported, as its heading, its access and the frame that made it ("WARNING: DATA
|
||||
// RACE: Write at 0x... by goroutine 9 in pkg.F.func1()"); else the first "panic:", "fatal error:" or "signal:" line of a
|
||||
// failure's output, in that order; or "". **Only lines at column 0**, where go prints the runtime's: a test's indented log
|
||||
// line holding the same words is not the cause (review round 1).
|
||||
func causeIn(out string) string {
|
||||
lines := strings.Split(out, "\n")
|
||||
for i, l := range lines {
|
||||
if strings.TrimRight(l, "\r") != "WARNING: DATA RACE" {
|
||||
continue
|
||||
}
|
||||
words := "WARNING: DATA RACE"
|
||||
if i+1 < len(lines) && strings.TrimSpace(lines[i+1]) != "" {
|
||||
words += ": " + strings.TrimSuffix(strings.TrimSpace(lines[i+1]), ":")
|
||||
if i+2 < len(lines) && strings.HasPrefix(lines[i+2], " ") {
|
||||
words += " in " + strings.TrimSpace(lines[i+2])
|
||||
}
|
||||
}
|
||||
return words
|
||||
}
|
||||
for _, prefix := range []string{"panic: ", "fatal error: ", "signal: "} {
|
||||
for _, l := range lines {
|
||||
if l = strings.TrimRight(l, "\r"); strings.HasPrefix(l, prefix) {
|
||||
return l
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// firstLine is the first non-empty line of a failure's words.
|
||||
func firstLine(s string) string {
|
||||
for _, l := range strings.Split(s, "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// MaxFailureLine is the most bytes of a failure's line in a verdict's Failures: the first line of its words, which is
|
||||
// its cause where the build module told one apart (issue 1901). Failed keeps the first failure's words whole.
|
||||
const MaxFailureLine = 300
|
||||
|
||||
// levelFailure turns a level that did not pass into a verdict: Failed names what failed first with its words whole,
|
||||
// and Failures every failed test of the level's results with its first line, at most meshdeliverycheckerclient.MaxFailures and an entry
|
||||
// saying how many more (issue 1921).
|
||||
func levelFailure(t *target, level string, results ...Tested) *meshdeliverychecker.VerdictsByGroupHeadsValue {
|
||||
v := meshdeliverychecker.VerdictsByGroupHeadsValue{State: meshdeliverycheckerclient.VerdictFail}
|
||||
var all []FailedFirst
|
||||
for _, res := range results {
|
||||
if len(res.Failed) == 0 {
|
||||
why := res.Why
|
||||
if why == "" {
|
||||
why = "the " + level + " level did not pass"
|
||||
}
|
||||
all = append(all, FailedFirst{Test: level + ": " + t.pull.Repository, Words: why})
|
||||
continue
|
||||
}
|
||||
for _, f := range res.Failed {
|
||||
name := failedName(f)
|
||||
if t.module == buildrun.NodeBuildModule && f.Package == "" {
|
||||
name = res.Module
|
||||
}
|
||||
all = append(all, FailedFirst{Test: level + ": " + name, Words: failureWordsAfter(f, hidWords(t.module, res.By, f))})
|
||||
}
|
||||
}
|
||||
if len(all) == 0 { // no result: a level said failed with nothing to name
|
||||
all = append(all, FailedFirst{Test: level + ": " + t.pull.Repository, Words: "the " + level + " level did not pass"})
|
||||
}
|
||||
first := all[0]
|
||||
v.Failed = &first
|
||||
v.Failures = failureItems(all) // whole: settled cuts them to their first lines once every failure of the check is joined
|
||||
return &v
|
||||
}
|
||||
|
||||
// joinFailures is one failed verdict of every failure of vs, in the order given (a nil one passed), unsettled: Failed is
|
||||
// the first whole, and Failures every failure whole, or none when the one failure is no level's (a conflict, a refused
|
||||
// step: one line, as before issue 1921). Nil when none failed.
|
||||
func joinFailures(vs ...*meshdeliverychecker.VerdictsByGroupHeadsValue) *meshdeliverychecker.VerdictsByGroupHeadsValue {
|
||||
var out *meshdeliverychecker.VerdictsByGroupHeadsValue
|
||||
var all []FailedFirst
|
||||
listed := false
|
||||
for _, v := range vs {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
if out == nil {
|
||||
out = &meshdeliverychecker.VerdictsByGroupHeadsValue{State: meshdeliverycheckerclient.VerdictFail, Failed: v.Failed}
|
||||
}
|
||||
if len(v.Failures) > 0 {
|
||||
all, listed = append(all, failedList(v.Failures)...), true
|
||||
continue
|
||||
}
|
||||
all = append(all, failedOf(*v))
|
||||
}
|
||||
if out != nil && (listed || len(all) > 1) {
|
||||
out.Failures = failureItems(all)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// settled is a joined verdict as it is written: each failure of its list cut to its first line, at most
|
||||
// meshdeliverycheckerclient.MaxFailures and then how many more (failureLines).
|
||||
func settled(v *meshdeliverychecker.VerdictsByGroupHeadsValue) *meshdeliverychecker.VerdictsByGroupHeadsValue {
|
||||
if v != nil && len(v.Failures) > 0 {
|
||||
v.Failures = failureItems(failureLines(failedList(v.Failures)))
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// addFailure is a settled list of failures with one more at its end: the first failure is put first when the list is
|
||||
// empty (a verdict of one failure keeps none), and past meshdeliverycheckerclient.MaxFailures the last entry counts one more.
|
||||
func addFailure(v *meshdeliverychecker.VerdictsByGroupHeadsValue, f FailedFirst) {
|
||||
if len(v.Failures) == 0 {
|
||||
v.Failures = failureItems(failureLines([]FailedFirst{failedOf(*v)}))
|
||||
}
|
||||
if n := len(v.Failures); n > meshdeliverycheckerclient.MaxFailures {
|
||||
v.Failures[n-1].Test = fmt.Sprintf("and %d more", failedCount(*v)-meshdeliverycheckerclient.MaxFailures+1)
|
||||
return
|
||||
}
|
||||
v.Failures = append(v.Failures, failureItems(failureLines([]FailedFirst{f}))...)
|
||||
}
|
||||
|
||||
// failureLines is every failure with the first line of its words, cut to MaxFailureLine, at most meshdeliverycheckerclient.MaxFailures
|
||||
// and then one entry saying how many more failed.
|
||||
func failureLines(all []FailedFirst) []FailedFirst {
|
||||
n := min(len(all), meshdeliverycheckerclient.MaxFailures)
|
||||
out := make([]FailedFirst, 0, n+1)
|
||||
for _, f := range all[:n] {
|
||||
out = append(out, FailedFirst{Test: f.Test, Machine: f.Machine, Words: clipLine(firstLine(f.Words), MaxFailureLine)})
|
||||
}
|
||||
if more := len(all) - n; more > 0 {
|
||||
out = append(out, FailedFirst{Test: fmt.Sprintf("and %d more", more)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// clipLine cuts a line to at most n bytes on a whole character, marking the cut.
|
||||
func clipLine(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
s = strings.ToValidUTF8(s[:n-3], "")
|
||||
return s + "..."
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package check
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"mesh-delivery-checker/rules"
|
||||
"slices"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
@@ -72,7 +73,7 @@ func failedLines(v meshdeliverychecker.VerdictsByGroupHeadsValue) []string {
|
||||
// are cut to fit room characters when room is not negative; the count and "and N more" are never cut.
|
||||
func failedHead(v meshdeliverychecker.VerdictsByGroupHeadsValue, room int) string {
|
||||
first := firstFailed(v)
|
||||
n := failedCount(v)
|
||||
n := rules.FailedCount(v)
|
||||
if n <= 1 {
|
||||
return clipRunes("failed: "+first, room)
|
||||
}
|
||||
@@ -95,20 +96,8 @@ func clipRunes(s string, n int) string {
|
||||
return string(r[:n-1]) + "…"
|
||||
}
|
||||
|
||||
// failedCount is how many failures a verdict names, the ones after meshdeliverycheckerclient.MaxFailures counted too.
|
||||
func failedCount(v meshdeliverychecker.VerdictsByGroupHeadsValue) int {
|
||||
n := len(v.Failures)
|
||||
if n > meshdeliverycheckerclient.MaxFailures {
|
||||
var more int
|
||||
if _, err := fmt.Sscanf(v.Failures[n-1].Test, "and %d more", &more); err == nil {
|
||||
return meshdeliverycheckerclient.MaxFailures + more
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// firstWordsLine is the first line of a failure's words: the words are several lines (issue 1901) and the status line one.
|
||||
func firstWordsLine(w string) string { return firstLine(w) }
|
||||
func firstWordsLine(w string) string { return rules.FirstLine(w) }
|
||||
|
||||
// pieces are the parts of a line, from the most to the least needed.
|
||||
type pieces struct {
|
||||
|
||||
@@ -69,100 +69,103 @@ func TestAFailureNamesTheTestTheConflictOrTheRefusedStepFirst(t *testing.T) { //
|
||||
}
|
||||
}
|
||||
|
||||
func TestALevelOverItsBoundIsNamedWithItsSlowestTestsAndTheVerdictIsNotChanged(t *testing.T) { // 1300/AC3
|
||||
cs, p := lineCase(t, pullOf(repoA, 7, head1))
|
||||
v := passVerdict()
|
||||
v.OverBound = []string{"unit"}
|
||||
line := Line(v, cs, p, nil, []string{"unit pkg.TestSlow 7.0s"}, nil)
|
||||
if !strings.HasPrefix(line, "pass ") || !strings.Contains(Report(v, cs, p, nil, []string{"unit pkg.TestSlow 7.0s"}, nil), "over bound: unit (slowest unit pkg.TestSlow 7.0s)") {
|
||||
t.Errorf("line = %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkippedTestsAreSaidAndNeverAFlatPass(t *testing.T) { // 1300/AC6
|
||||
cs, p := lineCase(t, pullOf(repoA, 7, head1))
|
||||
v := passVerdict()
|
||||
v.Levels[buildstep.LevelUnit] = meshdeliverychecker.VerdictsByGroupHeadsValueLevelsValue{Run: 12, Kept: 301, Skipped: 2}
|
||||
if line := Line(v, cs, p, nil, nil, nil); !strings.Contains(line, "unit 12 run, 301 kept, 2 skipped") {
|
||||
t.Errorf("line = %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAGroupsMembersEachCarryTheSameVerdict(t *testing.T) { // 1300/AC4
|
||||
cs := caseOf(t, pullOf(repoA, 7, head1), pullOf(repoSDK, 12, head2))
|
||||
a := Line(passVerdict(), cs, cs.Pulls[0], nil, nil, nil)
|
||||
b := Line(passVerdict(), cs, cs.Pulls[1], nil, nil, nil)
|
||||
if !strings.HasPrefix(a, "pass ") || !strings.HasPrefix(b, "pass ") || !strings.Contains(Report(passVerdict(), cs, cs.Pulls[0], nil, nil, nil), "group feat/x of 2 pull requests") {
|
||||
t.Errorf("lines %q and %q", a, b)
|
||||
}
|
||||
if !strings.Contains(a, "111111111111") || !strings.Contains(b, "222222222222") {
|
||||
t.Errorf("each member's line names its own head: %q and %q", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheBuildsOfTheTouchedModulesAreSaidAndDroppedFirstWhenTheLineIsTooLong(t *testing.T) { // ADR 0484 clause 7
|
||||
cs, p := lineCase(t, pullOf(repoA, 7, head1))
|
||||
builds := []BuildNote{{"avahi", "built"}, {"mesh-tools", "waiting"}}
|
||||
if r := Report(passVerdict(), cs, p, builds, nil, nil); !strings.Contains(r, "builds: avahi built, mesh-tools waiting") {
|
||||
t.Errorf("report = %q", r)
|
||||
}
|
||||
line := Line(passVerdict(), cs, p, builds, nil, nil)
|
||||
if utf8.RuneCountInString(line) > MaxLine {
|
||||
t.Errorf("line %q is too long", line)
|
||||
}
|
||||
short := passVerdict()
|
||||
short.Took = &meshdeliverychecker.VerdictsByGroupHeadsValueTook{}
|
||||
if l := Line(short, cs, p, builds[:1], nil, nil); !strings.Contains(l, "builds: avahi built") {
|
||||
t.Errorf("a short line keeps its builds: %q", l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALineIsNeverLongerThanTheLimit(t *testing.T) { // 1300/AC1
|
||||
cs, p := lineCase(t, pullOf(repoA, 7, head1))
|
||||
v := meshdeliverychecker.VerdictsByGroupHeadsValue{State: meshdeliverycheckerclient.VerdictFail, Failed: &FailedFirst{Test: "integration: " + strings.Repeat("pkg/", 50) + ".TestSomething", Words: strings.Repeat("because ", 40)}}
|
||||
if line := Line(v, cs, p, nil, nil, nil); utf8.RuneCountInString(line) > MaxLine || !strings.HasPrefix(line, "failed: integration: ") {
|
||||
t.Errorf("line = %q (%d)", line, utf8.RuneCountInString(line))
|
||||
}
|
||||
}
|
||||
|
||||
func TestARunningCheckIsPending(t *testing.T) {
|
||||
cs, p := lineCase(t, pullOf(repoA, 7, head1))
|
||||
if line := Line(meshdeliverychecker.VerdictsByGroupHeadsValue{State: meshdeliverycheckerclient.VerdictRunning}, cs, p, nil, nil, nil); line != "checking 111111111111 on aaaaaaaaaaaa" {
|
||||
t.Errorf("line = %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThePassedCompositionIsSaidOnlyWhenItWasRun(t *testing.T) { // ADR 0484 clause 6
|
||||
cs, p := lineCase(t, pullOf(repoA, 7, head1))
|
||||
v := passVerdict()
|
||||
v.Composition = &meshdeliverychecker.VerdictsByGroupHeadsValueComposition{State: meshdeliverycheckerclient.CompositionPass, Sdk: "afbe371003a570f088be4a394d83aa7a9458f7ab"}
|
||||
if r := Report(v, cs, p, nil, nil, nil); !strings.Contains(r, "composition passed with afbe371003a5") {
|
||||
t.Errorf("report = %q", r)
|
||||
}
|
||||
v.Composition = nil
|
||||
if r := Report(v, cs, p, nil, nil, nil); !strings.Contains(r, "composition not yet run") || strings.Contains(r, "composition passed") {
|
||||
t.Errorf("a verdict with no composition: %q", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheCompositionsTimeAndMachinesAreSaidOnceTheAssignerReportsThem(t *testing.T) { // 1300 (C10), the assigner's verdict (issue 1297)
|
||||
cs, p := lineCase(t, pullOf(repoA, 7, head1))
|
||||
v := passVerdict()
|
||||
v.Took.Composition = 2400
|
||||
v.Composition = &meshdeliverychecker.VerdictsByGroupHeadsValueComposition{State: meshdeliverycheckerclient.CompositionPass, Sdk: "afbe371003a570f088be4a394d83aa7a9458f7ab", Machines: []string{"g14", "ace", "novox", "shanks"}}
|
||||
r := Report(v, cs, p, nil, nil, nil)
|
||||
for _, want := range []string{"composition passed in 2.4s with afbe371003a5 on 4 machines: ace, g14, novox, shanks"} {
|
||||
if !strings.Contains(r, want) {
|
||||
t.Errorf("report %q lacks %q", r, want)
|
||||
}
|
||||
}
|
||||
if l := Line(v, cs, p, nil, nil, nil); !strings.Contains(l, "composition passed in 2.4s") || !strings.Contains(l, "on 4 machines") || utf8.RuneCountInString(l) > MaxLine {
|
||||
t.Errorf("line = %q", l)
|
||||
}
|
||||
v.State, v.Failed = meshdeliverycheckerclient.VerdictFail, &FailedFirst{Machine: "novox", Words: "composition refused"}
|
||||
v.Composition = &meshdeliverychecker.VerdictsByGroupHeadsValueComposition{State: meshdeliverycheckerclient.CompositionFail, Words: "novox: two seats claim dns", Machines: []string{"novox"}}
|
||||
if r := Report(v, cs, p, nil, nil, nil); !strings.Contains(r, "composition failed in 2.4s on novox: novox: two seats claim dns") {
|
||||
t.Errorf("failed composition report = %q", r)
|
||||
// The status line and its full report say each part of a verdict, within the forge's limit (issue 1300, ADR 0484).
|
||||
func TestTheLineAndTheReportSayEachPartOfTheVerdict(t *testing.T) {
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
test func(t *testing.T)
|
||||
}{
|
||||
{"a level over its bound is named with its slowest tests and the verdict is not changed", func(t *testing.T) { // 1300/AC3
|
||||
cs, p := lineCase(t, pullOf(repoA, 7, head1))
|
||||
v := passVerdict()
|
||||
v.OverBound = []string{"unit"}
|
||||
line := Line(v, cs, p, nil, []string{"unit pkg.TestSlow 7.0s"}, nil)
|
||||
if !strings.HasPrefix(line, "pass ") || !strings.Contains(Report(v, cs, p, nil, []string{"unit pkg.TestSlow 7.0s"}, nil), "over bound: unit (slowest unit pkg.TestSlow 7.0s)") {
|
||||
t.Errorf("line = %q", line)
|
||||
}
|
||||
}},
|
||||
{"skipped tests are said and never a flat pass", func(t *testing.T) { // 1300/AC6
|
||||
cs, p := lineCase(t, pullOf(repoA, 7, head1))
|
||||
v := passVerdict()
|
||||
v.Levels[buildstep.LevelUnit] = meshdeliverychecker.VerdictsByGroupHeadsValueLevelsValue{Run: 12, Kept: 301, Skipped: 2}
|
||||
if line := Line(v, cs, p, nil, nil, nil); !strings.Contains(line, "unit 12 run, 301 kept, 2 skipped") {
|
||||
t.Errorf("line = %q", line)
|
||||
}
|
||||
}},
|
||||
{"a groups members each carry the same verdict", func(t *testing.T) { // 1300/AC4
|
||||
cs := caseOf(t, pullOf(repoA, 7, head1), pullOf(repoSDK, 12, head2))
|
||||
a := Line(passVerdict(), cs, cs.Pulls[0], nil, nil, nil)
|
||||
b := Line(passVerdict(), cs, cs.Pulls[1], nil, nil, nil)
|
||||
if !strings.HasPrefix(a, "pass ") || !strings.HasPrefix(b, "pass ") || !strings.Contains(Report(passVerdict(), cs, cs.Pulls[0], nil, nil, nil), "group feat/x of 2 pull requests") {
|
||||
t.Errorf("lines %q and %q", a, b)
|
||||
}
|
||||
if !strings.Contains(a, "111111111111") || !strings.Contains(b, "222222222222") {
|
||||
t.Errorf("each member's line names its own head: %q and %q", a, b)
|
||||
}
|
||||
}},
|
||||
{"the builds of the touched modules are said and dropped first when the line is too long", func(t *testing.T) { // ADR 0484 clause 7
|
||||
cs, p := lineCase(t, pullOf(repoA, 7, head1))
|
||||
builds := []BuildNote{{"avahi", "built"}, {"mesh-tools", "waiting"}}
|
||||
if r := Report(passVerdict(), cs, p, builds, nil, nil); !strings.Contains(r, "builds: avahi built, mesh-tools waiting") {
|
||||
t.Errorf("report = %q", r)
|
||||
}
|
||||
line := Line(passVerdict(), cs, p, builds, nil, nil)
|
||||
if utf8.RuneCountInString(line) > MaxLine {
|
||||
t.Errorf("line %q is too long", line)
|
||||
}
|
||||
short := passVerdict()
|
||||
short.Took = &meshdeliverychecker.VerdictsByGroupHeadsValueTook{}
|
||||
if l := Line(short, cs, p, builds[:1], nil, nil); !strings.Contains(l, "builds: avahi built") {
|
||||
t.Errorf("a short line keeps its builds: %q", l)
|
||||
}
|
||||
}},
|
||||
{"a line is never longer than the limit", func(t *testing.T) { // 1300/AC1
|
||||
cs, p := lineCase(t, pullOf(repoA, 7, head1))
|
||||
v := meshdeliverychecker.VerdictsByGroupHeadsValue{State: meshdeliverycheckerclient.VerdictFail, Failed: &FailedFirst{Test: "integration: " + strings.Repeat("pkg/", 50) + ".TestSomething", Words: strings.Repeat("because ", 40)}}
|
||||
if line := Line(v, cs, p, nil, nil, nil); utf8.RuneCountInString(line) > MaxLine || !strings.HasPrefix(line, "failed: integration: ") {
|
||||
t.Errorf("line = %q (%d)", line, utf8.RuneCountInString(line))
|
||||
}
|
||||
}},
|
||||
{"a running check is pending", func(t *testing.T) {
|
||||
cs, p := lineCase(t, pullOf(repoA, 7, head1))
|
||||
if line := Line(meshdeliverychecker.VerdictsByGroupHeadsValue{State: meshdeliverycheckerclient.VerdictRunning}, cs, p, nil, nil, nil); line != "checking 111111111111 on aaaaaaaaaaaa" {
|
||||
t.Errorf("line = %q", line)
|
||||
}
|
||||
}},
|
||||
{"the passed composition is said only when it was run", func(t *testing.T) { // ADR 0484 clause 6
|
||||
cs, p := lineCase(t, pullOf(repoA, 7, head1))
|
||||
v := passVerdict()
|
||||
v.Composition = &meshdeliverychecker.VerdictsByGroupHeadsValueComposition{State: meshdeliverycheckerclient.CompositionPass, Sdk: "afbe371003a570f088be4a394d83aa7a9458f7ab"}
|
||||
if r := Report(v, cs, p, nil, nil, nil); !strings.Contains(r, "composition passed with afbe371003a5") {
|
||||
t.Errorf("report = %q", r)
|
||||
}
|
||||
v.Composition = nil
|
||||
if r := Report(v, cs, p, nil, nil, nil); !strings.Contains(r, "composition not yet run") || strings.Contains(r, "composition passed") {
|
||||
t.Errorf("a verdict with no composition: %q", r)
|
||||
}
|
||||
}},
|
||||
{"the compositions time and machines are said once the assigner reports them", func(t *testing.T) { // 1300 (C10), the assigner's verdict (issue 1297)
|
||||
cs, p := lineCase(t, pullOf(repoA, 7, head1))
|
||||
v := passVerdict()
|
||||
v.Took.Composition = 2400
|
||||
v.Composition = &meshdeliverychecker.VerdictsByGroupHeadsValueComposition{State: meshdeliverycheckerclient.CompositionPass, Sdk: "afbe371003a570f088be4a394d83aa7a9458f7ab", Machines: []string{"g14", "ace", "novox", "shanks"}}
|
||||
r := Report(v, cs, p, nil, nil, nil)
|
||||
for _, want := range []string{"composition passed in 2.4s with afbe371003a5 on 4 machines: ace, g14, novox, shanks"} {
|
||||
if !strings.Contains(r, want) {
|
||||
t.Errorf("report %q lacks %q", r, want)
|
||||
}
|
||||
}
|
||||
if l := Line(v, cs, p, nil, nil, nil); !strings.Contains(l, "composition passed in 2.4s") || !strings.Contains(l, "on 4 machines") || utf8.RuneCountInString(l) > MaxLine {
|
||||
t.Errorf("line = %q", l)
|
||||
}
|
||||
v.State, v.Failed = meshdeliverycheckerclient.VerdictFail, &FailedFirst{Machine: "novox", Words: "composition refused"}
|
||||
v.Composition = &meshdeliverychecker.VerdictsByGroupHeadsValueComposition{State: meshdeliverycheckerclient.CompositionFail, Words: "novox: two seats claim dns", Machines: []string{"novox"}}
|
||||
if r := Report(v, cs, p, nil, nil, nil); !strings.Contains(r, "composition failed in 2.4s on novox: novox: two seats claim dns") {
|
||||
t.Errorf("failed composition report = %q", r)
|
||||
}
|
||||
}},
|
||||
} {
|
||||
t.Run(row.name, row.test)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ func (l *losingAsks) PublishAsk(ctx context.Context, subject, id string, data []
|
||||
|
||||
func losing(b *bench, lose int) *losingAsks {
|
||||
l := &losingAsks{FakeAsks: &buildrun.FakeAsks{}, answer: b.answer, dead: &buildrun.DeadLetters{}, lose: lose}
|
||||
b.ex.Asks = buildrun.Asker{Publisher: l, Waiter: buildrun.LostAskWaiter{Inner: l.FakeAsks, Dead: l.dead}, Bound: 5 * time.Second}
|
||||
b.ex.Asks = buildrun.Asker{Publisher: b.asker(l, l.FakeAsks.Write), Waiter: buildrun.LostAskWaiter{Inner: l.FakeAsks, Dead: l.dead}, Bound: 5 * time.Second}
|
||||
return l
|
||||
}
|
||||
|
||||
@@ -285,7 +285,7 @@ func TestACheckWhoseAskJudgedNothingIsAskedAgainOrRead(t *testing.T) {
|
||||
tr = &troubled{FakeAsks: &buildrun.FakeAsks{}, answer: b.answer, restarts: row.restarts, deaf: row.deaf,
|
||||
deafIDs: map[string]bool{}, steps: map[string]buildstep.Step{}}
|
||||
heard = &buildrun.HeardEnds{Inner: tr, Read: tr}
|
||||
b.ex.Asks = buildrun.Asker{Publisher: tr, Waiter: heard, Bound: 5 * time.Second}
|
||||
b.ex.Asks = buildrun.Asker{Publisher: b.asker(tr, tr.Write), Waiter: heard, Bound: 5 * time.Second}
|
||||
asked = func() int { return len(tr.Asks()) }
|
||||
}
|
||||
r := newRig(t, b.ex)
|
||||
|
||||
@@ -6,11 +6,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"mesh-delivery-checker/rules"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/composer"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
@@ -35,7 +35,7 @@ const (
|
||||
JudgeCase = "change"
|
||||
JudgeTrunk = "trunk"
|
||||
// judgeBetween is how the judge names the problems between manifests that no one module's removal ends.
|
||||
judgeBetween = "(between manifests)"
|
||||
judgeBetween = rules.BetweenManifests
|
||||
)
|
||||
|
||||
// judgePaths are the paths of mesh-delivery whose change changes the judge: a group whose mesh-delivery member changes
|
||||
@@ -104,16 +104,17 @@ func judgeRequest(trunk map[string]string, pulls []Pull) (req composer.Request,
|
||||
{Name: JudgeTrunk, Manifests: before}}}, slices.Sorted(maps.Keys(seen))
|
||||
}
|
||||
|
||||
// judgeManifests runs the indexer's judge over the case's manifests and fails the check for each touched manifest it
|
||||
// refuses. A case that touches no manifest, or an examiner that reads no trunk manifests, judges nothing; a judge that
|
||||
// cannot be asked or does not answer fails the check, said, never passes it.
|
||||
func (st *state) judgeManifests(ctx context.Context) *meshdeliverychecker.VerdictsByGroupHeadsValue {
|
||||
// judgeManifests runs the indexer's judge over the case's manifests and records its answers as a fact the rules judge
|
||||
// (rules.JudgeManifests), with the trunk validator's answer on a new indexer (ADR 0633 clause 6). A case that touches no
|
||||
// manifest, or an examiner that reads no trunk manifests, judges nothing; a judge that cannot be asked or does not answer
|
||||
// is a failure, said, never a pass.
|
||||
func (st *state) judgeManifests(ctx context.Context) {
|
||||
if st.j.Manifests == nil || st.narrowTry() {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
req, touched := judgeRequest(st.j.Manifests.ManifestDigests(), st.c.Pulls)
|
||||
if len(touched) == 0 {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
commit, at := st.judgeHead, "the group's mesh-delivery head "+st.judgeHead
|
||||
if commit == "" {
|
||||
@@ -123,7 +124,8 @@ func (st *state) judgeManifests(ctx context.Context) *meshdeliverychecker.Verdic
|
||||
at = "mesh-delivery's trunk " + commit
|
||||
}
|
||||
if commit == "" {
|
||||
return fail(goModule, "the change's manifests cannot be judged: the trunk head of "+DeliveryRepository+" is not known")
|
||||
st.fail(goModule, "the change's manifests cannot be judged: the trunk head of "+DeliveryRepository+" is not known")
|
||||
return
|
||||
}
|
||||
// The list of predefined keys the manifests are judged against (ADR 0578 §2): the catalogue member's predefined.json
|
||||
// when the change alters it, else the one the indexer keeps.
|
||||
@@ -132,72 +134,26 @@ func (st *state) judgeManifests(ctx context.Context) *meshdeliverychecker.Verdic
|
||||
st.c.note("the manifests are judged against the predefined keys of " + from)
|
||||
}
|
||||
req.Packages = st.knownPackages(ctx)
|
||||
seats, err := st.checkSeats(ctx)
|
||||
if err != nil {
|
||||
return fail(goModule, "the change's manifests cannot be judged: "+err.Error())
|
||||
// The heads' seat list, for the change case and the trunk case both (ADR 0636 §4).
|
||||
req.Seats = st.c.Seats
|
||||
cv, trunkCase, ok := st.runJudge(ctx, req, touched, commit, at, st.packagesOf(DeliveryRepository))
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
req.Seats = seats.defs
|
||||
cv, trunkCase, v := st.runJudge(ctx, req, touched, commit, at, st.packagesOf(DeliveryRepository))
|
||||
if v != nil {
|
||||
return v
|
||||
m := rules.Manifests{Touched: touched, At: at, Change: cv, Trunk: trunkCase}
|
||||
if !st.indexerOf(ctx, req, touched, cv, at, &m) {
|
||||
return
|
||||
}
|
||||
if v := st.refuseUnmovableIndexer(ctx, req, touched, cv, at); v != nil {
|
||||
return v
|
||||
}
|
||||
// A new or changed manifest is judged in full; of one the change leaves as it is, a problem the trunk already had is a
|
||||
// warning (it is not refused for a rule it already broke), and a problem the change causes in it, which the trunk case
|
||||
// lacks, fails the check as the change's (ADR 0579 §2).
|
||||
var refused, warned []string
|
||||
for _, module := range slices.Sorted(maps.Keys(cv.Manifests)) {
|
||||
r := cv.Manifests[module]
|
||||
if r.Accepted {
|
||||
continue
|
||||
}
|
||||
if r.Refused == "" { // held: a verdict not given is not one given (ADR 0633 clause 6)
|
||||
if slices.Contains(touched, module) {
|
||||
refused = append(refused, fmt.Sprintf("the indexer's judge at %s holds the manifest of %s: it gave no verdict", at, module))
|
||||
}
|
||||
continue
|
||||
}
|
||||
had := map[string]bool{}
|
||||
for _, p := range strings.Split(trunkCase.Manifests[module].Refused, "\n") {
|
||||
had[p] = true
|
||||
}
|
||||
var caused, old []string
|
||||
for _, p := range strings.Split(r.Refused, "\n") {
|
||||
if slices.Contains(touched, module) || !had[p] {
|
||||
caused = append(caused, p)
|
||||
} else {
|
||||
old = append(old, p)
|
||||
}
|
||||
}
|
||||
if len(caused) > 0 {
|
||||
what := "the manifest of " + module
|
||||
if module == judgeBetween {
|
||||
what = "the catalogue"
|
||||
}
|
||||
refused = append(refused, fmt.Sprintf("the indexer's judge at %s refuses %s: %s", at, what, strings.Join(caused, "; ")))
|
||||
}
|
||||
if len(old) > 0 {
|
||||
warned = append(warned, module+": "+strings.Join(old, "; "))
|
||||
}
|
||||
}
|
||||
if len(warned) > 0 {
|
||||
st.c.note("warnings of the indexer's judge on manifests the change leaves as they are (ADR 0579 §2): " + strings.Join(warned, " | "))
|
||||
}
|
||||
if len(refused) > 0 {
|
||||
return fail(goModule, strings.Join(refused, "\n"))
|
||||
}
|
||||
st.c.note(fmt.Sprintf("the manifests of %s are accepted by the indexer's judge at %s", strings.Join(touched, ", "), at))
|
||||
return nil
|
||||
st.facts = append(st.facts, rules.Step{Module: goModule, Manifests: &m})
|
||||
}
|
||||
|
||||
// runJudge asks the Go build seat to build the judge at the commit and run it over the request, and reads its change and
|
||||
// trunk cases; a failed verdict when it cannot be asked or does not answer.
|
||||
func (st *state) runJudge(ctx context.Context, req composer.Request, touched []string, commit, at string, versions buildstep.PackageVersions) (change, trunk composer.CaseVerdict, v *meshdeliverychecker.VerdictsByGroupHeadsValue) {
|
||||
// trunk cases; false, the failure recorded, when it cannot be asked or does not answer.
|
||||
func (st *state) runJudge(ctx context.Context, req composer.Request, touched []string, commit, at string, versions buildstep.PackageVersions) (change, trunk composer.CaseVerdict, ok bool) {
|
||||
raw, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return change, trunk, fail(goModule, err.Error())
|
||||
st.fail(goModule, err.Error())
|
||||
return change, trunk, false
|
||||
}
|
||||
job := buildstep.ComposeJob{Fetch: buildstep.Fetch{Repository: DeliveryRepository, Commit: commit, Packages: versions}, Dir: JudgeDir,
|
||||
Package: JudgePackage, Request: raw, Reader: "the indexer's judge at " + at}
|
||||
@@ -206,48 +162,48 @@ func (st *state) runJudge(ctx context.Context, req composer.Request, touched []s
|
||||
st.c.Progress.Set(fmt.Sprintf("the judge ask %s of the manifests of %s", a.ID, strings.Join(touched, ", ")), "")
|
||||
st.c.Asked(a.ID)
|
||||
if err := st.j.Asks.Put(ctx, a); err != nil {
|
||||
return change, trunk, fail(goModule, "the judge of the change's manifests could not be asked: "+err.Error())
|
||||
st.fail(goModule, "the judge of the change's manifests could not be asked: "+err.Error())
|
||||
return change, trunk, false
|
||||
}
|
||||
step, err := st.j.Asks.Wait(ctx, a)
|
||||
if err != nil {
|
||||
return change, trunk, fail(goModule, "the judge of the change's manifests: "+err.Error())
|
||||
st.fail(goModule, "the judge of the change's manifests: "+err.Error())
|
||||
return change, trunk, false
|
||||
}
|
||||
change, trunk, why := judgeAnswer(step)
|
||||
if why != "" {
|
||||
return change, trunk, fail(goModule, fmt.Sprintf("the indexer's judge at %s did not judge the change's manifests: %s", at, why))
|
||||
st.fail(goModule, fmt.Sprintf("the indexer's judge at %s did not judge the change's manifests: %s", at, why))
|
||||
return change, trunk, false
|
||||
}
|
||||
return change, trunk, nil
|
||||
return change, trunk, true
|
||||
}
|
||||
|
||||
// CatalogSeat is the seat the indexer holds: the module whose manifest claims it is the indexer, found by the claim and
|
||||
// never by its name (novox/hq ADR 0633 clause 6, ADR 0601 §4).
|
||||
const CatalogSeat = "mesh-catalog"
|
||||
// CatalogSeat is the seat the indexer holds (rules.CatalogSeat).
|
||||
const CatalogSeat = rules.CatalogSeat
|
||||
|
||||
// refuseUnmovableIndexer fails the check when the trunk's manifest validator, the one the running parts hold, would
|
||||
// refuse or hold the change's new indexer manifest: such an indexer would never run, and every revision behind it would
|
||||
// wait for ever (ADR 0633 clause 6). The indexer is the touched module whose manifest claims the seat mesh-catalog.
|
||||
// When the change judges with its own head's manifest validator (it changes it), the manifest is judged by the trunk's
|
||||
// as well. The verdicts read are the trunk's manifest validator's: its change case, where refused, held (not accepted
|
||||
// with no reason) or with a field set aside (a field the running manifest validator does not know waits, ADR 0601 §3)
|
||||
// each fail it, naming the module and the problems.
|
||||
func (st *state) refuseUnmovableIndexer(ctx context.Context, req composer.Request, touched []string, cv composer.CaseVerdict, judgedAt string) *meshdeliverychecker.VerdictsByGroupHeadsValue {
|
||||
// indexerOf finds the touched module whose manifest claims the seat mesh-catalog, the indexer, and puts in m the trunk's
|
||||
// manifest validator's change case on it, the one the running parts hold (ADR 0633 clause 6): when the change judges
|
||||
// with its own head's validator, or at other package versions than the latest published, the trunk's is asked as well.
|
||||
// False, the failure recorded, when a manifest cannot be read or the trunk's validator cannot be asked.
|
||||
func (st *state) indexerOf(ctx context.Context, req composer.Request, touched []string, cv composer.CaseVerdict, judgedAt string, m *rules.Manifests) bool {
|
||||
if st.j.Read == nil {
|
||||
return nil
|
||||
return true
|
||||
}
|
||||
var indexer string
|
||||
for _, module := range touched {
|
||||
m, err := st.j.Read.Manifest(ctx, req.Cases[0].Manifests[module].Digest)
|
||||
man, err := st.j.Read.Manifest(ctx, req.Cases[0].Manifests[module].Digest)
|
||||
if err != nil {
|
||||
return fail(goModule, fmt.Sprintf("the manifest of %s cannot be read, so the indexer cannot be ruled out: %v", module, err))
|
||||
st.fail(goModule, fmt.Sprintf("the manifest of %s cannot be read, so the indexer cannot be ruled out: %v", module, err))
|
||||
return false
|
||||
}
|
||||
for _, c := range m.Claims {
|
||||
for _, c := range man.Claims {
|
||||
if c.Name == CatalogSeat {
|
||||
indexer = module
|
||||
}
|
||||
}
|
||||
}
|
||||
if indexer == "" {
|
||||
return nil
|
||||
return true
|
||||
}
|
||||
// The validator the running parts hold is the trunk's, built at the latest published versions alone, never at the group's
|
||||
// heads (ADR 0633 clause 6): a group's mesh-sdk or mesh-contracts head must not change the validator that judges the new
|
||||
@@ -258,32 +214,17 @@ func (st *state) refuseUnmovableIndexer(ctx context.Context, req composer.Reques
|
||||
trunk = st.j.Trunks.Heads()[DeliveryRepository]
|
||||
}
|
||||
if trunk == "" {
|
||||
return fail(goModule, "the new indexer cannot be judged by the trunk's manifest validator: the trunk head of "+DeliveryRepository+" is not known")
|
||||
st.fail(goModule, "the new indexer cannot be judged by the trunk's manifest validator: the trunk head of "+DeliveryRepository+" is not known")
|
||||
return false
|
||||
}
|
||||
var v *meshdeliverychecker.VerdictsByGroupHeadsValue
|
||||
judgedAt = "mesh-delivery's trunk " + trunk
|
||||
if cv, _, v = st.runJudge(ctx, req, touched, trunk, judgedAt, trunkVersions); v != nil {
|
||||
return v
|
||||
var ok bool
|
||||
if cv, _, ok = st.runJudge(ctx, req, touched, trunk, judgedAt, trunkVersions); !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
r, ok := cv.Manifests[indexer]
|
||||
var problems []string
|
||||
switch {
|
||||
case !ok:
|
||||
problems = append(problems, "the manifest validator gave no verdict")
|
||||
case r.Refused != "":
|
||||
problems = append(problems, strings.Split(r.Refused, "\n")...)
|
||||
case !r.Accepted:
|
||||
problems = append(problems, "the manifest validator holds it, with no verdict yet")
|
||||
case r.SetAside != "":
|
||||
problems = append(problems, "the manifest validator does not know its fields "+r.SetAside+", so it waits")
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return fail(goModule, fmt.Sprintf("the indexer %s, the holder of the seat %s, would never run: the trunk's manifest validator at %s does not accept its manifest: %s",
|
||||
indexer, CatalogSeat, judgedAt, strings.Join(problems, "; ")))
|
||||
}
|
||||
st.c.note(fmt.Sprintf("the new indexer's manifest of %s is accepted by the trunk's manifest validator at %s", indexer, judgedAt))
|
||||
return nil
|
||||
m.Indexer, m.IndexerAt, m.OfIndexer = indexer, judgedAt, &cv
|
||||
return true
|
||||
}
|
||||
|
||||
// judgeAnswer reads the judge's change and trunk cases from its step, or why it cannot.
|
||||
@@ -427,37 +368,3 @@ func (st *state) knownPackages(ctx context.Context) []string {
|
||||
func (st *state) trunkJudgeVersions() buildstep.PackageVersions {
|
||||
return packagesFor(st.j.Published, &Case{}, nil, DeliveryRepository)
|
||||
}
|
||||
|
||||
// checkSeats is the seat list a check judges and composes against, read once per check: the list the checker's reader holds
|
||||
// with the members' heads' definitions laid over it (ADR 0636 §4), the trunk's manifest validator of a new indexer as well.
|
||||
// listed says the reader's list was read; without a reader it is the heads' alone. An error is a list that is not read or a
|
||||
// head whose definitions are not taken.
|
||||
func (st *state) checkSeats(ctx context.Context) (checkSeats, error) {
|
||||
if st.seats != nil {
|
||||
return *st.seats, st.seatsErr
|
||||
}
|
||||
var out checkSeats
|
||||
err := func() error {
|
||||
if st.j.SeatList != nil {
|
||||
defs, err := st.j.SeatList.Definitions()
|
||||
if err != nil {
|
||||
return fmt.Errorf("the seat list is not read (%s)", err.Error())
|
||||
}
|
||||
out.defs, out.listed = defs, true
|
||||
}
|
||||
heads, err := st.headSeats(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.defs = overSeats(out.defs, heads)
|
||||
return nil
|
||||
}()
|
||||
st.seats, st.seatsErr = &out, err
|
||||
return out, err
|
||||
}
|
||||
|
||||
// checkSeats is a check's seat list (state.checkSeats): the raw definitions, and whether the reader's list is among them.
|
||||
type checkSeats struct {
|
||||
defs []json.RawMessage
|
||||
listed bool
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"mesh-delivery-checker/rules"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -19,75 +20,81 @@ const (
|
||||
oldPassThenRace = "PASS\n==================\nWARNING: DATA RACE\nWrite at 0x000000836428 by goroutine 9:\n example.com/racemain.TestMain.func1()\n /tmp/fix-1901b/racemain/p_test.go:15 +0x24\nPrevious write at 0x000000836428 by main goroutine:\n example.com/racemain.TestMain()\n /tmp/fix-1901b/racemain/p_test.go:16 +0x44\n main.main()\n _testmain.go:48 +0x171\nGoroutine 9 (running) created at:\n example.com/racemain.TestMain()\n /tmp/fix-1901b/racemain/p_test.go:15 +0x37\n main.main()\n _testmain.go:48 +0x171\n==================\nFound 1 data race(s)\nFAIL\texample.com/racemain\t1.031s"
|
||||
)
|
||||
|
||||
// A failure with no cause told apart never has "PASS" or "ok" as its words: the checker takes the panic line from the
|
||||
// output, or says that the tests passed and the program failed after them (1901/AC5).
|
||||
func TestAPackageFailureIsNeverWordedPass(t *testing.T) { // 1901/AC5
|
||||
cases := []struct {
|
||||
name, output, want string
|
||||
// A package's failure is worded by its cause, cut to its bound, and never as pass (issue 1901, AC5).
|
||||
func TestAPackageFailuresCauseIsWordedNeverPass(t *testing.T) {
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
test func(t *testing.T)
|
||||
}{
|
||||
{"panic after the tests", oldPassThenPanic, "panic: the bus closed after the tests: nats: connection closed"},
|
||||
{"exit after the tests", oldPassThenExit, "the tests passed, then the test program exited with a failure"},
|
||||
{"race after the tests", oldPassThenRace, "WARNING: DATA RACE: Write at 0x000000836428 by goroutine 9 in example.com/racemain.TestMain.func1()"},
|
||||
{"a panic deep in a long output", "PASS\npanic: the seat row vanished\n" + strings.Repeat("goroutine 7 [chan receive]:\n\tsome/frame.go:1 +0x1\n", 300) + "FAIL\tp\t2.0s", "panic: the seat row vanished"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
v := levelFailure(&target{}, "integration", Tested{Failed: []Failure{{Package: "github.com/novox/mesh-controller/cmd/mesh-controller", Output: c.output}}})
|
||||
for _, l := range strings.Split(v.Failed.Words, "\n") {
|
||||
if l = strings.TrimSpace(l); l == "PASS" || strings.HasPrefix(l, "ok ") || strings.HasPrefix(l, "ok\t") {
|
||||
t.Errorf("1901/AC5: the words keep %q: %q", l, v.Failed.Words)
|
||||
// A failure with no cause told apart never has "PASS" or "ok" as its words: the checker takes the panic line from the
|
||||
// output, or says that the tests passed and the program failed after them (1901/AC5).
|
||||
{"a package failure is never worded pass", func(t *testing.T) { // 1901/AC5
|
||||
cases := []struct {
|
||||
name, output, want string
|
||||
}{
|
||||
{"panic after the tests", oldPassThenPanic, "panic: the bus closed after the tests: nats: connection closed"},
|
||||
{"exit after the tests", oldPassThenExit, "the tests passed, then the test program exited with a failure"},
|
||||
{"race after the tests", oldPassThenRace, "WARNING: DATA RACE: Write at 0x000000836428 by goroutine 9 in example.com/racemain.TestMain.func1()"},
|
||||
{"a panic deep in a long output", "PASS\npanic: the seat row vanished\n" + strings.Repeat("goroutine 7 [chan receive]:\n\tsome/frame.go:1 +0x1\n", 300) + "FAIL\tp\t2.0s", "panic: the seat row vanished"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
v := rules.LevelFailure("", "", "integration", Tested{Failed: []Failure{{Package: "github.com/novox/mesh-controller/cmd/mesh-controller", Output: c.output}}})
|
||||
for _, l := range strings.Split(v.Failed.Words, "\n") {
|
||||
if l = strings.TrimSpace(l); l == "PASS" || strings.HasPrefix(l, "ok ") || strings.HasPrefix(l, "ok\t") {
|
||||
t.Errorf("1901/AC5: the words keep %q: %q", l, v.Failed.Words)
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(afterHid(v.Failed.Words), c.want) {
|
||||
t.Errorf("1901/AC5: words after the loud line start %q, want %q", rules.FirstLine(afterHid(v.Failed.Words)), c.want)
|
||||
}
|
||||
if got := firstFailed(*v); !strings.Contains(got, "the build module hid the failure") || strings.HasSuffix(got, ": PASS") {
|
||||
t.Errorf("1901/AC5, AC8: status = %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}},
|
||||
// A cause the build module told apart is cut to a line's bound, so the words keep their bound (the 1901 review's note).
|
||||
{"a long cause is cut to a lines bound", func(t *testing.T) { // 1901/AC5
|
||||
f := Failure{Package: "p", Test: "TestX", Cause: strings.Repeat("c", 5000), Output: "x_test.go:1: something"}
|
||||
v := rules.LevelFailure("", "", "unit", Tested{Failed: []Failure{f}})
|
||||
if len(v.Failed.Words) > rules.MaxWords+200 {
|
||||
t.Errorf("1901/AC5: words are %d bytes, bound %d", len(v.Failed.Words), rules.MaxWords)
|
||||
}
|
||||
}},
|
||||
// The build module on main sends a package that stopped (a timeout, an exit, a kill) as only go test's last line, with no
|
||||
// cause: the words then say so, never nothing (review round 1). Each output is what that build module sends for the
|
||||
// capture of the same name (go-test-json-pkg-timeout, -exiting, -killed): the package's own output, its "FAIL" line.
|
||||
{"a package stopped with only its fail line is still worded", func(t *testing.T) { // 1901/AC5
|
||||
for _, out := range []string{"FAIL\texample.com/timeout\t2.017s", "FAIL\texample.com/exiting\t0.006s", "FAIL\texample.com/killed\t0.006s"} {
|
||||
v := rules.LevelFailure("", "", "integration", Tested{Failed: []Failure{{Package: "example.com/x", Output: out}}})
|
||||
if !strings.HasPrefix(afterHid(v.Failed.Words), "the package failed with no test to name and no panic, signal or build error in its output") {
|
||||
t.Errorf("1901/AC5: %q is worded %q", out, v.Failed.Words)
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(afterHid(v.Failed.Words), c.want) {
|
||||
t.Errorf("1901/AC5: words after the loud line start %q, want %q", firstLine(afterHid(v.Failed.Words)), c.want)
|
||||
}},
|
||||
// A line of the test's own that starts as go's summary does, or that holds a runtime's words indented, is not taken for
|
||||
// either (review round 1): go prints its summary in exact forms and the runtime's lines at column 0.
|
||||
{"only gos exact forms are summary or cause", func(t *testing.T) { // 1901/AC5
|
||||
out := " x_test.go:3: ok so far\nok then: the seat is free\n panic: child retried and recovered\npanic: the real one\nFAIL\tp\t1.0s"
|
||||
v := rules.LevelFailure("", "", "unit", Tested{Failed: []Failure{{Package: "p", Output: out}}})
|
||||
if !strings.HasPrefix(afterHid(v.Failed.Words), "panic: the real one") || !strings.Contains(v.Failed.Words, "ok then: the seat is free") {
|
||||
t.Errorf("1901/AC5: words = %q", v.Failed.Words)
|
||||
}
|
||||
if got := firstFailed(*v); !strings.Contains(got, "the build module hid the failure") || strings.HasSuffix(got, ": PASS") {
|
||||
t.Errorf("1901/AC5, AC8: status = %q", got)
|
||||
}},
|
||||
// The words keep rules.MaxWords exactly, counting the cause, the marker and the newlines (review round 1).
|
||||
{"the words keep their bound exactly", func(t *testing.T) { // 1901/AC5
|
||||
for _, cause := range []string{"", "a cause", strings.Repeat("c", 3000)} {
|
||||
for _, n := range []int{100, 170, 171, 172, 500} {
|
||||
f := Failure{Package: "p", Test: "TestX", Cause: cause, Output: strings.Repeat("a log line of twenty\n", n) + "the end"}
|
||||
if w := rules.FailureWords(f); len(w) > rules.MaxWords {
|
||||
t.Errorf("1901/AC5: cause %d bytes, %d lines: words are %d bytes, bound %d", len(cause), n, len(w), rules.MaxWords)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A cause the build module told apart is cut to a line's bound, so the words keep their bound (the 1901 review's note).
|
||||
func TestALongCauseIsCutToALinesBound(t *testing.T) { // 1901/AC5
|
||||
f := Failure{Package: "p", Test: "TestX", Cause: strings.Repeat("c", 5000), Output: "x_test.go:1: something"}
|
||||
v := levelFailure(&target{}, "unit", Tested{Failed: []Failure{f}})
|
||||
if len(v.Failed.Words) > MaxWords+200 {
|
||||
t.Errorf("1901/AC5: words are %d bytes, bound %d", len(v.Failed.Words), MaxWords)
|
||||
}
|
||||
}
|
||||
|
||||
// The build module on main sends a package that stopped (a timeout, an exit, a kill) as only go test's last line, with no
|
||||
// cause: the words then say so, never nothing (review round 1). Each output is what that build module sends for the
|
||||
// capture of the same name (go-test-json-pkg-timeout, -exiting, -killed): the package's own output, its "FAIL" line.
|
||||
func TestAPackageStoppedWithOnlyItsFailLineIsStillWorded(t *testing.T) { // 1901/AC5
|
||||
for _, out := range []string{"FAIL\texample.com/timeout\t2.017s", "FAIL\texample.com/exiting\t0.006s", "FAIL\texample.com/killed\t0.006s"} {
|
||||
v := levelFailure(&target{}, "integration", Tested{Failed: []Failure{{Package: "example.com/x", Output: out}}})
|
||||
if !strings.HasPrefix(afterHid(v.Failed.Words), "the package failed with no test to name and no panic, signal or build error in its output") {
|
||||
t.Errorf("1901/AC5: %q is worded %q", out, v.Failed.Words)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A line of the test's own that starts as go's summary does, or that holds a runtime's words indented, is not taken for
|
||||
// either (review round 1): go prints its summary in exact forms and the runtime's lines at column 0.
|
||||
func TestOnlyGosExactFormsAreSummaryOrCause(t *testing.T) { // 1901/AC5
|
||||
out := " x_test.go:3: ok so far\nok then: the seat is free\n panic: child retried and recovered\npanic: the real one\nFAIL\tp\t1.0s"
|
||||
v := levelFailure(&target{}, "unit", Tested{Failed: []Failure{{Package: "p", Output: out}}})
|
||||
if !strings.HasPrefix(afterHid(v.Failed.Words), "panic: the real one") || !strings.Contains(v.Failed.Words, "ok then: the seat is free") {
|
||||
t.Errorf("1901/AC5: words = %q", v.Failed.Words)
|
||||
}
|
||||
}
|
||||
|
||||
// The words keep MaxWords exactly, counting the cause, the marker and the newlines (review round 1).
|
||||
func TestTheWordsKeepTheirBoundExactly(t *testing.T) { // 1901/AC5
|
||||
for _, cause := range []string{"", "a cause", strings.Repeat("c", 3000)} {
|
||||
for _, n := range []int{100, 170, 171, 172, 500} {
|
||||
f := Failure{Package: "p", Test: "TestX", Cause: cause, Output: strings.Repeat("a log line of twenty\n", n) + "the end"}
|
||||
if w := failureWords(f); len(w) > MaxWords {
|
||||
t.Errorf("1901/AC5: cause %d bytes, %d lines: words are %d bytes, bound %d", len(cause), n, len(w), MaxWords)
|
||||
}
|
||||
}
|
||||
}},
|
||||
} {
|
||||
t.Run(row.name, row.test)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
|
||||
|
||||
"mesh-delivery-checker/rules"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
)
|
||||
|
||||
// The checking program (novox/hq ADR 0644 §1, ADR 0656 §5; design 47's bootstrap delivery; issue 1921): every check has
|
||||
// the program built from the group's mesh-delivery member head, else from mesh-delivery's trunk, and run in the build
|
||||
// seat's sandbox through the seat node-build-go's ask `run`, with every member's head laid for it. It answers the heads'
|
||||
// seat list first (`-seats`), which the manifest validator's and the composer's requests carry, and then the check's
|
||||
// verdict of the facts the asking gathered (`-verdict`). The running checker asks, keeps and records; it judges nothing.
|
||||
|
||||
const (
|
||||
// InputsStateName is the checker's own state of run inputs and InputsBucket its bucket: every input by the sha256 of
|
||||
// its exact bytes, read by the checker alone. InputVerb is the seat verb a run ask's input names
|
||||
// (buildstep.RunInput.Verb), which the checker's tools serve from that state (Reader.Input): the Go build module asks it
|
||||
// as its manifest's invokes grants it and checks the digest (buildstep.RunInput.Read), never reading the state (ADR 0596
|
||||
// §2; the operator's answer on milestone 47/B0).
|
||||
InputsStateName = "inputs"
|
||||
InputsBucket = CheckerModule + "_" + InputsStateName
|
||||
InputVerb = meshdeliverycheckerclient.InputVerb
|
||||
// CheckingProgramDir and CheckingProgramPackage are where in mesh-delivery the checking program is built.
|
||||
CheckingProgramDir = "modules/mesh-delivery-checker"
|
||||
CheckingProgramPackage = "mesh-delivery-checker/cmd/mesh-checking-program"
|
||||
// DefaultBeginBound is how long a run ask may wait for a build node to begin its step before it is put once more, and
|
||||
// after that once more before the check fails naming the job: a build module that does not know the job `run` drops
|
||||
// it unanswered (the lead's ruling on milestone 47/B0, 2026-11-06).
|
||||
DefaultBeginBound = 5 * time.Minute
|
||||
)
|
||||
|
||||
// Inputs keeps a run's input under its digest, exactly its bytes.
|
||||
type Inputs interface {
|
||||
Keep(ctx context.Context, digest string, body []byte) error
|
||||
}
|
||||
|
||||
func (j *Examiner) beginBound() time.Duration {
|
||||
if j.BeginBound > 0 {
|
||||
return j.BeginBound
|
||||
}
|
||||
return DefaultBeginBound
|
||||
}
|
||||
|
||||
// checkingProgram is where this check's checking program is built: the group's mesh-delivery member head, else
|
||||
// mesh-delivery's trunk; false when neither is known.
|
||||
func (st *state) checkingProgram() (buildstep.RunProgram, string, bool) {
|
||||
p := buildstep.RunProgram{Repository: DeliveryRepository, Dir: CheckingProgramDir, Package: CheckingProgramPackage}
|
||||
for _, pull := range st.c.Pulls {
|
||||
if pull.Repository == DeliveryRepository {
|
||||
p.Commit = pull.Head
|
||||
return p, "the group's mesh-delivery head " + pull.Head, true
|
||||
}
|
||||
}
|
||||
if st.j.Trunks != nil {
|
||||
p.Commit = st.j.Trunks.Heads()[DeliveryRepository]
|
||||
}
|
||||
return p, "mesh-delivery's trunk " + p.Commit, p.Commit != ""
|
||||
}
|
||||
|
||||
// runHeads are the members' heads, as the program reads them.
|
||||
func (st *state) runHeads() []buildstep.RunHead {
|
||||
out := make([]buildstep.RunHead, 0, len(st.c.Pulls))
|
||||
for _, p := range st.c.Pulls {
|
||||
out = append(out, buildstep.RunHead{Repository: p.Repository, Commit: p.Head})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// runProgram has the checking program built and run with args over body, and answers its output and what judged; an
|
||||
// error says why it did not answer: its input over its bound, no build node beginning the job, a step that did not
|
||||
// pass, or a program that exited otherwise than 0.
|
||||
func (st *state) runProgram(ctx context.Context, args []string, body []byte) (json.RawMessage, *meshdeliverychecker.VerdictsByGroupHeadsValueJudgedBy, error) {
|
||||
prog, at, ok := st.checkingProgram()
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("it cannot be built: the trunk head of %s is not known", DeliveryRepository)
|
||||
}
|
||||
if st.j.Inputs == nil {
|
||||
return nil, nil, fmt.Errorf("its input cannot be kept: the checker keeps no state %s", InputsStateName)
|
||||
}
|
||||
in, err := buildstep.RunInputOf(InputVerb, body)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := st.j.Inputs.Keep(ctx, in.Digest, body); err != nil {
|
||||
return nil, nil, fmt.Errorf("its input could not be kept in %s: %w", InputsStateName, err)
|
||||
}
|
||||
job := buildstep.RunJob{Program: prog, Heads: st.runHeads(), Input: in, Args: args, Reader: "the checking program at " + at}
|
||||
var ids []string
|
||||
for range 2 {
|
||||
a := buildstep.StepAsk{ID: buildstep.NewID(), Kind: buildstep.AskCheck, Language: buildstep.AskGo, Asker: CheckerModule,
|
||||
At: st.j.now().UTC(), Packages: st.packagesOf(DeliveryRepository), Run: &job}
|
||||
ids = append(ids, a.ID)
|
||||
st.c.Progress.Set(fmt.Sprintf("the run ask %s of the checking program %s", a.ID, strings.Join(args, " ")), "")
|
||||
st.c.Asked(a.ID)
|
||||
if err := st.j.Asks.Put(ctx, a); err != nil {
|
||||
return nil, nil, fmt.Errorf("its run ask could not be put: %w", err)
|
||||
}
|
||||
waiting := ctx
|
||||
if watch := st.c.Watch; watch != nil {
|
||||
id := a.ID
|
||||
watch(id, nil) // a try names it at once (issue 1890)
|
||||
waiting = buildrun.WithStepSeen(ctx, func(s buildstep.Step) { watch(id, &s) })
|
||||
}
|
||||
step, begun, err := st.waitBegun(waiting, a)
|
||||
if !begun {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("its run ask %s: %w", a.ID, err)
|
||||
}
|
||||
if step.State != buildstep.StepPassed {
|
||||
return nil, nil, fmt.Errorf("its run ask %s ended %s on %s: %s", a.ID, step.State, step.Node, step.Reason)
|
||||
}
|
||||
var answer buildstep.RunAnswer
|
||||
if err := json.Unmarshal(step.Result, &answer); err != nil {
|
||||
return nil, nil, fmt.Errorf("its run ask %s answered no run answer: %w", a.ID, err)
|
||||
}
|
||||
if answer.Exit != 0 {
|
||||
return nil, nil, fmt.Errorf("it exited %d on %s: %s", answer.Exit, step.Node, answer.Said)
|
||||
}
|
||||
return answer.Output, judgedByOf(prog, answer, job.Heads), nil
|
||||
}
|
||||
return nil, nil, fmt.Errorf("no build node began the job run of the asks %s within %s each: a build module that does not know the job run drops it",
|
||||
strings.Join(ids, " and "), st.j.beginBound())
|
||||
}
|
||||
|
||||
// waitBegun waits for a's step, and stops waiting, begun false, when no build node began it within the begin bound: a
|
||||
// bound on the wait's context, no timer of its own.
|
||||
func (st *state) waitBegun(ctx context.Context, a buildstep.StepAsk) (buildstep.Step, bool, error) {
|
||||
wait, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
begin, stop := context.WithTimeout(ctx, st.j.beginBound())
|
||||
defer stop()
|
||||
seen := make(chan struct{})
|
||||
var once sync.Once
|
||||
type ended struct {
|
||||
step buildstep.Step
|
||||
err error
|
||||
}
|
||||
done := make(chan ended, 1)
|
||||
go func() {
|
||||
step, err := st.j.Asks.Wait(buildrun.WithStepSeen(wait, func(buildstep.Step) { once.Do(func() { close(seen) }) }), a)
|
||||
done <- ended{step, err}
|
||||
}()
|
||||
select {
|
||||
case e := <-done:
|
||||
return e.step, true, e.err
|
||||
case <-seen:
|
||||
case <-begin.Done():
|
||||
if ctx.Err() != nil {
|
||||
return buildstep.Step{}, true, ctx.Err()
|
||||
}
|
||||
return buildstep.Step{}, false, nil
|
||||
}
|
||||
e := <-done
|
||||
return e.step, true, e.err
|
||||
}
|
||||
|
||||
// seatList asks the checking program for the heads' seat list, which the validator's and the composer's requests carry
|
||||
// (ADR 0636 §4); the list and what it left out are the check's notes. A list it cannot make fails the check, naming why,
|
||||
// and answers false: the check ends there, with no verdict asked of a program that could not answer its list (review
|
||||
// round 1 of milestone 47/B0: a build node with no job run would hold the check two begin bounds more).
|
||||
func (st *state) seatList(ctx context.Context) bool {
|
||||
if st.narrowTry() {
|
||||
return true
|
||||
}
|
||||
// The list the checker's reader holds from the seat mesh-catalog: a reader that has not read it acts on nothing new
|
||||
// (ADR 0636 §1), so the check ends here, said.
|
||||
var base []json.RawMessage
|
||||
if st.j.SeatList != nil {
|
||||
defs, err := st.j.SeatList.Definitions()
|
||||
if err != nil {
|
||||
st.fail(goModule, "the change's manifests cannot be judged: the seat list is not read ("+err.Error()+")")
|
||||
return false
|
||||
}
|
||||
base = defs
|
||||
}
|
||||
body, err := json.Marshal(st.factsHead())
|
||||
if err == nil {
|
||||
var out json.RawMessage
|
||||
if out, _, err = st.runProgram(ctx, []string{"-seats"}, body); err == nil {
|
||||
var a rules.SeatsAnswer
|
||||
if err = json.Unmarshal(out, &a); err == nil && a.V != rules.SeatsVersion {
|
||||
err = fmt.Errorf("it answered a seat list of version %d, not %d", a.V, rules.SeatsVersion)
|
||||
}
|
||||
if err == nil {
|
||||
st.c.Seats = overSeats(base, a.Seats)
|
||||
if len(a.Seats) > 0 {
|
||||
st.c.note(fmt.Sprintf("the heads' seat list holds %d seat definitions, laid over the %d the seat list holds", len(a.Seats), len(base)))
|
||||
}
|
||||
for _, l := range a.Left {
|
||||
st.c.note("the heads' seat list leaves out " + l.Path + ": " + l.Why)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
st.fail(goModule, "the heads' seat list could not be made by the checking program: "+err.Error())
|
||||
return false
|
||||
}
|
||||
|
||||
// overSeats is the list's definitions with each of the heads' put in place of the one of its seat, or added, sorted by
|
||||
// seat: the checker reads of a definition only its seat's name.
|
||||
func overSeats(list, heads []json.RawMessage) []json.RawMessage {
|
||||
by := map[string]json.RawMessage{}
|
||||
for _, l := range [][]json.RawMessage{list, heads} {
|
||||
for _, raw := range l {
|
||||
var d struct {
|
||||
Seat string `json:"seat"`
|
||||
}
|
||||
if json.Unmarshal(raw, &d) == nil && d.Seat != "" {
|
||||
by[d.Seat] = raw
|
||||
}
|
||||
}
|
||||
}
|
||||
var out []json.RawMessage // none when neither holds one: the requests then carry no list
|
||||
for _, seat := range slices.Sorted(maps.Keys(by)) {
|
||||
out = append(out, by[seat])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// factsHead are the facts that name the check, without what the asking gathered.
|
||||
func (st *state) factsHead() rules.Facts {
|
||||
f := rules.Facts{V: rules.FactsVersion, Group: st.c.Group, Heads: st.c.Heads, Trunk: st.c.Trunk}
|
||||
for _, p := range st.c.Pulls {
|
||||
f.Members = append(f.Members, rules.Member{Repository: p.Repository, Number: p.Number, Head: p.Head, Trunk: p.Trunk})
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// judged is the checking program's answer to the check's facts (`-verdict`), with what judged; a program that cannot be
|
||||
// asked, built or run, or does not answer a verdict, fails the check naming the checking program, never the running
|
||||
// checker's rules in its place: the facts gain the failure and the verdict is worded from them as a failure.
|
||||
func (st *state) judged(ctx context.Context) (rules.Answer, *meshdeliverychecker.VerdictsByGroupHeadsValueJudgedBy) {
|
||||
body, err := json.Marshal(st.factsOf())
|
||||
var by *meshdeliverychecker.VerdictsByGroupHeadsValueJudgedBy
|
||||
if err == nil {
|
||||
var out json.RawMessage
|
||||
if out, by, err = st.runProgram(ctx, []string{"-verdict"}, body); err == nil {
|
||||
var a rules.Answer
|
||||
if err = json.Unmarshal(out, &a); err == nil && a.V != rules.AnswerVersion {
|
||||
err = fmt.Errorf("it answered a verdict of version %d, not %d", a.V, rules.AnswerVersion)
|
||||
}
|
||||
if err == nil {
|
||||
return a, by
|
||||
}
|
||||
}
|
||||
}
|
||||
st.fail(goModule, "the check could not be judged by the checking program: "+err.Error())
|
||||
return rules.Answer{V: rules.AnswerVersion, Verdict: rules.Judge(st.factsOf())}, by
|
||||
}
|
||||
|
||||
// judgedByOf is the verdict's judged-by of a run of the checking program: where it was built, the key its build is kept
|
||||
// under, whether this run built it, and the heads it read.
|
||||
func judgedByOf(prog buildstep.RunProgram, answer buildstep.RunAnswer, heads []buildstep.RunHead) *meshdeliverychecker.VerdictsByGroupHeadsValueJudgedBy {
|
||||
by := &meshdeliverychecker.VerdictsByGroupHeadsValueJudgedBy{Program: meshdeliverychecker.VerdictsByGroupHeadsValueJudgedByProgram{
|
||||
Repository: prog.Repository, Commit: prog.Commit, Dir: prog.Dir, Package: prog.Package}, Key: answer.Key, Built: answer.Built}
|
||||
for _, h := range heads {
|
||||
by.Heads = append(by.Heads, meshdeliverychecker.VerdictsByGroupHeadsValueJudgedByHeadsItem{Repository: h.Repository, Commit: h.Commit})
|
||||
}
|
||||
return by
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
@@ -12,6 +14,7 @@ import (
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/builds"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
|
||||
@@ -25,6 +28,7 @@ const (
|
||||
VerdictsReadTool = "checker_verdicts"
|
||||
PullReadTool = "checker_pull"
|
||||
KeptReadTool = "checker_kept"
|
||||
InputsReadTool = "checker_inputs"
|
||||
// PreviewsReadTool reads the previews state (issue 1680); StatusReadTool reads what the checker runs (issue 1685: nothing holds it since R4g).
|
||||
PreviewsReadTool = "checker_previews"
|
||||
StatusReadTool = "checker_status"
|
||||
@@ -41,6 +45,7 @@ type Reader struct {
|
||||
VerdictState, KeptState, CandidateState StateRead
|
||||
|
||||
PreviewState StateRead // the checker's merge previews
|
||||
InputState StateRead // the checking program's inputs (InputsStateName)
|
||||
StepStates map[string]StateRead // the build modules' steps states, by module
|
||||
HaveState StateRead // the node-engine's have: whether a build module is on a node and running
|
||||
StepBound time.Duration // how long the checker waits for one step; DefaultStepBound when 0
|
||||
@@ -68,7 +73,10 @@ type VerdictRead struct {
|
||||
Verdict json.RawMessage `json:"verdict"`
|
||||
// Failed is everything the verdict names as failed, one line each, in the order judged (failedLines): every member's
|
||||
// failed tests, refused steps and conflicts, the manifests' refusals and the composition (the operator, 2026-11-02).
|
||||
Failed []string `json:"failed,omitempty"`
|
||||
Failed []string `json:"failed,omitempty"`
|
||||
// Judged says which checking program judged the verdict, in one line (judgedLine): where it was built, the key its build
|
||||
// is kept under, whether this check built it, and the heads it read (issue 1921); empty for a verdict written before.
|
||||
Judged string `json:"judged,omitempty"`
|
||||
Reports map[string]string `json:"reports,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
@@ -93,6 +101,7 @@ func (r Reader) read(key string, raw json.RawMessage) (VerdictRead, error) {
|
||||
return out, fmt.Errorf("%s holds what is not a verdict: %w", key, err)
|
||||
}
|
||||
out.Failed = failedLines(v)
|
||||
out.Judged = judgedLine(v.JudgedBy)
|
||||
c, ok := r.candidate(v.Group)
|
||||
if !ok || (c.Heads != v.Heads && GateHeads(c) != v.Heads) {
|
||||
out.Note = "the candidate no longer holds these heads"
|
||||
@@ -262,6 +271,65 @@ func (r Reader) Kept(level, digest string) (map[string]json.RawMessage, string,
|
||||
return out, cut, nil
|
||||
}
|
||||
|
||||
// Input is the answer of the seat's verb input (InputVerb): the input kept under digest, its exact bytes, which the Go build
|
||||
// module hands the checking program a run ask names (buildstep.RunInput.Read). A digest that is no sha256, one the checker
|
||||
// keeps nothing under, and bytes that read back as another digest are errors, never an answer.
|
||||
func (r Reader) Input(digest string) (meshdeliverychecker.InputAnswer, error) {
|
||||
if r.InputState.Get == nil {
|
||||
return meshdeliverychecker.InputAnswer{}, fmt.Errorf("the state %s cannot be read here", InputsStateName)
|
||||
}
|
||||
if len(digest) != 64 || strings.Trim(digest, "0123456789abcdef") != "" {
|
||||
return meshdeliverychecker.InputAnswer{}, fmt.Errorf("%q is no sha256 digest of 64 lowercase hex digits", digest)
|
||||
}
|
||||
raw, err := r.InputState.Get(digest)
|
||||
if err != nil {
|
||||
return meshdeliverychecker.InputAnswer{}, fmt.Errorf("the input %s cannot be read: %w", digest, err)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return meshdeliverychecker.InputAnswer{}, fmt.Errorf("the checker keeps no input %s (an input is kept a day)", digest)
|
||||
}
|
||||
if sum := sha256.Sum256(raw); hex.EncodeToString(sum[:]) != digest {
|
||||
return meshdeliverychecker.InputAnswer{}, fmt.Errorf("the input kept under %s reads back as %d bytes of another digest", digest, len(raw))
|
||||
}
|
||||
return meshdeliverycheckerclient.InputAnswerOf(raw), nil
|
||||
}
|
||||
|
||||
// Inputs are the checking program's kept inputs: with a digest that input as kept, else every digest with its size.
|
||||
func (r Reader) Inputs(digest string) (map[string]json.RawMessage, string, error) {
|
||||
if r.InputState.Keys == nil || r.InputState.Get == nil {
|
||||
return nil, "", fmt.Errorf("the state %s cannot be read here", InputsStateName)
|
||||
}
|
||||
if digest != "" {
|
||||
raw, err := r.InputState.Get(digest)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, "", fmt.Errorf("no input %s is kept", digest)
|
||||
}
|
||||
return map[string]json.RawMessage{digest: raw}, "", nil
|
||||
}
|
||||
keys, err := r.InputState.Keys()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
sort.Strings(keys)
|
||||
keys, cut := capKeys(keys, "give a digest to read one")
|
||||
sizes := make([]int, len(keys))
|
||||
if err := parallel(len(keys), func(i int) error {
|
||||
raw, err := r.InputState.Get(keys[i])
|
||||
sizes[i] = len(raw)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
out := map[string]json.RawMessage{}
|
||||
for i, k := range keys {
|
||||
out[k] = json.RawMessage(fmt.Sprintf(`{"size":%d}`, sizes[i]))
|
||||
}
|
||||
return out, cut, nil
|
||||
}
|
||||
|
||||
// PreviewRead is one preview as stored, with the parts of its key.
|
||||
type PreviewRead struct {
|
||||
Key string `json:"key"`
|
||||
@@ -719,3 +787,20 @@ func (r Reader) buildsNotEnded(c meshcatalog.CandidatesByGroupValue) string {
|
||||
sort.Strings(out)
|
||||
return strings.Join(slices.Compact(out), ", ")
|
||||
}
|
||||
|
||||
// judgedLine is a verdict's JudgedBy in one line, as the verdict tools answer it.
|
||||
func judgedLine(by *meshdeliverychecker.VerdictsByGroupHeadsValueJudgedBy) string {
|
||||
if by == nil {
|
||||
return ""
|
||||
}
|
||||
built := "kept"
|
||||
if by.Built {
|
||||
built = "built for this check"
|
||||
}
|
||||
heads := make([]string, len(by.Heads))
|
||||
for i, h := range by.Heads {
|
||||
heads[i] = h.Repository + " " + h.Commit
|
||||
}
|
||||
return fmt.Sprintf("by the checking program %s of %s at %s (%s), %s as %s, over %s", by.Program.Package, by.Program.Repository,
|
||||
by.Program.Commit, by.Program.Dir, built, by.Key, strings.Join(heads, ", "))
|
||||
}
|
||||
|
||||
@@ -71,15 +71,23 @@ func TestTheVerdictToolsAnswerTheStoredVerdictWithTheFullReportOfEachPullRequest
|
||||
if got := vs[0].Reports["novox/mesh-delivery#7"]; !strings.HasPrefix(got, "pass 111111111111 on aaaaaaaaaaaa") || !strings.Contains(got, "composition not yet run") {
|
||||
t.Errorf("reports = %v", vs[0].Reports)
|
||||
}
|
||||
if vs[0].Judged != "" {
|
||||
t.Errorf("judged = %q of a verdict with no JudgedBy", vs[0].Judged)
|
||||
}
|
||||
if only, _, _ := rd.Verdicts("feat/other"); len(only) != 0 {
|
||||
t.Errorf("another group's verdicts = %+v", only)
|
||||
}
|
||||
// A failed verdict is answered with every failure it names, one line each (the operator, 2026-11-02).
|
||||
raw, _ := meshdeliverycheckerclient.EncodeVerdict(meshdeliverychecker.VerdictsByGroupHeadsValue{Group: "feat/x", Heads: "0123456789ab", State: meshdeliverycheckerclient.VerdictFail,
|
||||
Failed: &FailedFirst{Test: "unit: m/a.TestA", Words: "a_test.go:3: boom\nmore"},
|
||||
Failures: failureItems([]FailedFirst{{Test: "unit: m/a.TestA", Words: "a_test.go:3: boom"}, {Machine: "g14", Words: "route-proxy is defined twice"}})})
|
||||
if got, _ := rd.read(key, raw); strings.Join(got.Failed, " | ") != "unit: m/a.TestA: a_test.go:3: boom | g14: route-proxy is defined twice" {
|
||||
t.Errorf("failed = %q", got.Failed)
|
||||
Failures: failureItems([]FailedFirst{{Test: "unit: m/a.TestA", Words: "a_test.go:3: boom"}, {Machine: "g14", Words: "route-proxy is defined twice"}}),
|
||||
// Issue 1921: it names the checking program that judged it, its build and the heads it read.
|
||||
JudgedBy: judgedByOf(buildstep.RunProgram{Repository: repoA, Commit: head1, Dir: CheckingProgramDir, Package: CheckingProgramPackage},
|
||||
buildstep.RunAnswer{Key: "program-0123456789abcdef", Built: true}, []buildstep.RunHead{{Repository: repoA, Commit: head1}})})
|
||||
want := "by the checking program " + CheckingProgramPackage + " of " + repoA + " at " + head1 + " (" + CheckingProgramDir +
|
||||
"), built for this check as program-0123456789abcdef, over " + repoA + " " + head1
|
||||
if got, _ := rd.read(key, raw); strings.Join(got.Failed, " | ") != "unit: m/a.TestA: a_test.go:3: boom | g14: route-proxy is defined twice" || got.Judged != want {
|
||||
t.Errorf("failed = %q, judged %q, want %q", got.Failed, got.Judged, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +129,41 @@ func TestTheKeptToolAnswersTheKeysAsStoredByLevelAndByDigest(t *testing.T) { //
|
||||
if _, _, err := rd.Kept("unit", digB); err == nil {
|
||||
t.Error("a digest nothing kept was answered")
|
||||
}
|
||||
// Issue 1921: checker_inputs answers the checking program's inputs, every digest with its size, or one as kept.
|
||||
inputs := newMemKV()
|
||||
body := []byte(`{"v":1,"group":"feat/x"}`)
|
||||
in, _ := buildstep.RunInputOf(InputVerb, body)
|
||||
_ = memInputs{inputs}.Keep(context.Background(), in.Digest, body)
|
||||
rd.InputState = kvRead(inputs)
|
||||
if all, _, err := rd.Inputs(""); err != nil || string(all[in.Digest]) != fmt.Sprintf(`{"size":%d}`, len(body)) {
|
||||
t.Errorf("inputs = %s, %v", all, err)
|
||||
}
|
||||
if one, _, err := rd.Inputs(in.Digest); err != nil || string(one[in.Digest]) != string(body) {
|
||||
t.Errorf("one input = %s, %v", one, err)
|
||||
}
|
||||
if _, _, err := rd.Inputs(strings.Repeat("0", 64)); err == nil {
|
||||
t.Error("an input nothing kept was answered")
|
||||
}
|
||||
// The operator's answer on B0: the seat's verb input answers the exact bytes, which the build module's read takes;
|
||||
// a digest that is none, one nothing is kept under, and bytes kept under another digest are errors.
|
||||
if got, err := meshdeliverycheckerclient.ReadInput(func(key string, b any) (json.RawMessage, error) {
|
||||
if key != "seat:mesh-delivery-checker.input" {
|
||||
return nil, fmt.Errorf("asked %s", key)
|
||||
}
|
||||
a, err := rd.Input(b.(meshdeliverychecker.InputInput).Digest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}, in.Digest); err != nil || string(got) != string(body) {
|
||||
t.Errorf("the input through the verb = %s, %v", got, err)
|
||||
}
|
||||
_, _ = inputs.Create(context.Background(), strings.Repeat("1", 64), []byte(`{"v":2}`))
|
||||
for _, digest := range []string{"d1", strings.Repeat("0", 64), strings.Repeat("1", 64)} {
|
||||
if a, err := rd.Input(digest); err == nil {
|
||||
t.Errorf("the verb answered %s: %+v", digest, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1680 live acceptance: checker_previews shows the preview key of an open pull request, as stored, narrowed by repository and head.
|
||||
|
||||
@@ -3,14 +3,9 @@ package check
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"mesh-delivery-checker/rules"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/meshconfig"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
@@ -20,7 +15,7 @@ import (
|
||||
// naming the nodes holding it open; a check that cannot ask fails, saying so.
|
||||
|
||||
// RenamesSeat is the verb the checker asks, as its manifest's `invokes` names it.
|
||||
var RenamesSeat = "seat:" + meshconfig.Seat + "." + meshconfig.VerbRenames
|
||||
var RenamesSeat = rules.RenamesSeat
|
||||
|
||||
// ErrRenamesNotServed is the answer of Renames when the seat's holder serves no verb renames.
|
||||
var ErrRenamesNotServed = errors.New("the holder of the seat " + meshconfig.Seat + " does not serve its verb " + meshconfig.VerbRenames)
|
||||
@@ -52,25 +47,20 @@ func renamesOf(was, now catalogue.Manifest) renameChange {
|
||||
return rc
|
||||
}
|
||||
|
||||
// judgeRenames fails the check for a rename the seat refuses, a window a change would drop, or an ask that cannot be made.
|
||||
func (st *state) judgeRenames(ctx context.Context) *meshdeliverychecker.VerdictsByGroupHeadsValue {
|
||||
// judgeRenames records, of each module a member changes that brings or drops a `renamed-from`, what it brings and drops
|
||||
// and how the seat mesh-config's verb renames answered for it, as a fact the rules judge (rules.JudgeRenames): a rename
|
||||
// whose value cannot be moved, and the drop of one whose window is open, fail the check (design 32).
|
||||
func (st *state) judgeRenames(ctx context.Context) {
|
||||
if st.c.Try != nil {
|
||||
return nil
|
||||
}
|
||||
var out *meshdeliverychecker.VerdictsByGroupHeadsValue
|
||||
failed := func(words string) {
|
||||
f := FailedFirst{Machine: meshconfig.Seat, Words: words}
|
||||
if out == nil {
|
||||
out = &meshdeliverychecker.VerdictsByGroupHeadsValue{State: meshdeliverycheckerclient.VerdictFail, Failed: &f}
|
||||
return
|
||||
}
|
||||
addFailure(out, f)
|
||||
return
|
||||
}
|
||||
for _, p := range st.c.Pulls {
|
||||
for _, module := range p.Modules {
|
||||
r := rules.Renames{Module: module, Key: pullKey(p)}
|
||||
was, now, ok, err := st.manifestPair(ctx, p, module)
|
||||
if err != nil {
|
||||
failed(fmt.Sprintf("%s (%s): its config renames cannot be judged: %v", module, pullKey(p), err))
|
||||
r.Unread = err.Error()
|
||||
st.facts = append(st.facts, rules.Step{Repository: p.Repository, Number: p.Number, Renames: &r})
|
||||
continue
|
||||
}
|
||||
if !ok {
|
||||
@@ -83,39 +73,21 @@ func (st *state) judgeRenames(ctx context.Context) *meshdeliverychecker.Verdicts
|
||||
if len(rc.brought) == 0 && len(rc.dropped) == 0 {
|
||||
continue
|
||||
}
|
||||
if st.j.Renames == nil {
|
||||
failed(fmt.Sprintf("%s (%s) changes a renamed-from, and this checker cannot ask %s", module, pullKey(p), RenamesSeat))
|
||||
continue
|
||||
}
|
||||
answer, err := st.j.Renames.Renames(ctx, module)
|
||||
switch {
|
||||
case errors.Is(err, ErrRenamesNotServed):
|
||||
for _, key := range slices.Sorted(maps.Keys(rc.brought)) {
|
||||
failed(fmt.Sprintf("%s (%s) renames %s from %s: refused, %v, so the value cannot be moved", module, pullKey(p), key, rc.brought[key], err))
|
||||
}
|
||||
for _, key := range slices.Sorted(maps.Keys(rc.dropped)) {
|
||||
failed(fmt.Sprintf("%s (%s) drops renamed-from %s of %s: refused, %v, so whether its window is open is not known", module, pullKey(p), rc.dropped[key], key, err))
|
||||
}
|
||||
continue
|
||||
case err != nil:
|
||||
failed(fmt.Sprintf("%s (%s) changes a renamed-from, and the check cannot ask %s: %v", module, pullKey(p), RenamesSeat, err))
|
||||
continue
|
||||
}
|
||||
for _, key := range slices.Sorted(maps.Keys(rc.dropped)) {
|
||||
for _, r := range answer.Renames {
|
||||
if r.Module == module && r.From == rc.dropped[key] && r.To == key && len(r.Open) > 0 {
|
||||
open := slices.Sorted(slices.Values(r.Open))
|
||||
failed(fmt.Sprintf("%s (%s) drops renamed-from %s of %s while its rename window is open on %s", module, pullKey(p), r.From, key, strings.Join(open, ", ")))
|
||||
}
|
||||
r.Brought, r.Dropped = rc.brought, rc.dropped
|
||||
if st.j.Renames != nil {
|
||||
answer, err := st.j.Renames.Renames(ctx, module)
|
||||
switch {
|
||||
case err != nil:
|
||||
r.Unasked, r.NotServed = err.Error(), errors.Is(err, ErrRenamesNotServed)
|
||||
default:
|
||||
r.Answer = &answer
|
||||
}
|
||||
}
|
||||
st.c.note(fmt.Sprintf("%s.%s answered for %s (%s)", meshconfig.Seat, meshconfig.VerbRenames, module, pullKey(p)))
|
||||
st.facts = append(st.facts, rules.Step{Repository: p.Repository, Number: p.Number, Renames: &r})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// headManifest is a module's manifest at the pull request's head, new on it: by its candidate's digest, else the forge's.
|
||||
func (st *state) headManifest(ctx context.Context, p Pull, module string) (catalogue.Manifest, bool) {
|
||||
if digest := p.Fps[module].Manifest; digest != "" && st.j.Read != nil {
|
||||
if st.j.Manifests != nil && st.j.Manifests.ManifestDigests()[module] == digest {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"mesh-delivery-checker/rules"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
|
||||
)
|
||||
|
||||
@@ -14,61 +16,16 @@ import (
|
||||
// trees. A conflict is an answer.
|
||||
type Fetched = buildstep.FetchAnswer
|
||||
|
||||
// GoPackage is one package of a Go test result.
|
||||
type GoPackage struct {
|
||||
Dir string `json:"dir"`
|
||||
Package string `json:"package"`
|
||||
Passed bool `json:"passed"`
|
||||
Elapsed float64 `json:"elapsed"`
|
||||
Tests int `json:"tests"`
|
||||
NoTests bool `json:"no_tests,omitempty"`
|
||||
// Skipped is how many of the package's tests skipped; an older build module names none.
|
||||
Skipped int `json:"skipped,omitempty"`
|
||||
}
|
||||
// ProgramFile is a changed file that is a compiled program (rules.ProgramFile).
|
||||
type ProgramFile = rules.ProgramFile
|
||||
|
||||
// Failure is a test that failed, or a package that failed with no test to name: the build modules' failure contract
|
||||
// (buildstep.Failure, issue 1901), its cause never empty and never a passing line from a build module that keeps it.
|
||||
type Failure = buildstep.Failure
|
||||
|
||||
// TestTime is one test and the seconds it took.
|
||||
type TestTime struct {
|
||||
Package string `json:"package"`
|
||||
Test string `json:"test"`
|
||||
Elapsed float64 `json:"elapsed"`
|
||||
}
|
||||
|
||||
// Tested is the answer of a test step of either build module (the Go one names packages and counts tests; the Node one
|
||||
// names the package directory as Module and counts none).
|
||||
type Tested struct {
|
||||
Level string `json:"level"`
|
||||
Module string `json:"module,omitempty"`
|
||||
Passed bool `json:"passed"`
|
||||
Why string `json:"why,omitempty"`
|
||||
NoTests bool `json:"no_tests,omitempty"`
|
||||
Tests int `json:"tests"`
|
||||
Skipped int `json:"skipped"`
|
||||
Packages []GoPackage `json:"packages,omitempty"`
|
||||
Elapsed float64 `json:"elapsed"`
|
||||
Setup float64 `json:"setup,omitempty"`
|
||||
// Lint is the unit level's gofmt and go vet time, before its tests (mesh-build-go, ADR 0529 §1).
|
||||
Lint float64 `json:"lint,omitempty"`
|
||||
Bound float64 `json:"bound"`
|
||||
SetupBound float64 `json:"setup_bound,omitempty"`
|
||||
Slowest []TestTime `json:"slowest,omitempty"`
|
||||
Failed []Failure `json:"failed,omitempty"`
|
||||
OverBound bool `json:"over_bound"`
|
||||
OverWhich string `json:"over_which,omitempty"`
|
||||
// JudgedBy is the program that ran the step (ADR 0535 §5): "installed", or "candidate <merge commit>" under runner
|
||||
// workspace; absent from a build module that does not take the runner.
|
||||
JudgedBy string `json:"judged_by,omitempty"`
|
||||
// By is the build module that answered and the SDK it was built with (buildstep.By); absent from a build module from
|
||||
// before the failure contract.
|
||||
By string `json:"by,omitempty"`
|
||||
// OutOfStep are the generated files the step's regenerate-and-compare found out of step with their seat definitions,
|
||||
// each a repository-relative path (design 26; the build modules' compare step, mesh-catalog CAT-BG5 and CAT-BN2):
|
||||
// the checker names them in its verdict event. Absent from a build module that does not compare.
|
||||
OutOfStep []string `json:"out-of-step,omitempty"`
|
||||
}
|
||||
// The test step's answer is read by the judging rules (milestone 47/B0); these name their shapes here.
|
||||
type (
|
||||
Failure = rules.Failure
|
||||
TestTime = rules.TestTime
|
||||
Tested = rules.Tested
|
||||
GoPackage = rules.GoPackage
|
||||
)
|
||||
|
||||
// Digested is the answer of the Go build module's digest step: one digest per package that has tests at the level, by import
|
||||
// path.
|
||||
|
||||
@@ -2,21 +2,15 @@ package check
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
|
||||
"mesh-delivery-checker/rules"
|
||||
)
|
||||
|
||||
// A pull request whose head's seat definition does not parse fails its check, naming the seat and where it lives (design
|
||||
// 26; the lead's ruling on A2i's finding 1): the indexer keeps such a definition as the pull request's entry of its state
|
||||
// seat-definitions with `unparsed` set, and cannot refuse the manifest itself, whose digest the trunk may share.
|
||||
|
||||
// UnparsedSeat is one seat definition of a pull request's head that does not parse: the seat (the contracts module's name
|
||||
// when its index did not parse), its module's directory and the indexer's words of why.
|
||||
type UnparsedSeat struct {
|
||||
Seat, Module, Directory, Why string
|
||||
}
|
||||
// UnparsedSeat is one seat definition of a pull request's head that does not parse (rules.UnparsedSeat).
|
||||
type UnparsedSeat = rules.UnparsedSeat
|
||||
|
||||
// SeatDefinitions reads the indexer's seat definitions of one pull request (its state seat-definitions, through its
|
||||
// first view, DLV-M2).
|
||||
@@ -24,33 +18,24 @@ type SeatDefinitions interface {
|
||||
Unparsed(ctx context.Context, repository string, pull int) ([]UnparsedSeat, error)
|
||||
}
|
||||
|
||||
// refuseUnparsed fails the check of each pull request with a seat definition that does not parse; a state that cannot be
|
||||
// read fails it too, saying so.
|
||||
func (st *state) refuseUnparsed(ctx context.Context) *meshdeliverychecker.VerdictsByGroupHeadsValue {
|
||||
// refuseUnparsed records, of each pull request, the indexer's seat definitions of its head that do not parse, or why
|
||||
// they cannot be read, as a fact the rules judge (rules.JudgeUnparsed).
|
||||
func (st *state) refuseUnparsed(ctx context.Context) {
|
||||
if st.j.SeatDefinitions == nil || st.c.Try != nil {
|
||||
return nil
|
||||
}
|
||||
var out *meshdeliverychecker.VerdictsByGroupHeadsValue
|
||||
failed := func(words string) {
|
||||
f := FailedFirst{Machine: SeatDefinitionsMachine, Words: words}
|
||||
if out == nil {
|
||||
out = &meshdeliverychecker.VerdictsByGroupHeadsValue{State: meshdeliverycheckerclient.VerdictFail, Failed: &f}
|
||||
return
|
||||
}
|
||||
addFailure(out, f)
|
||||
return
|
||||
}
|
||||
for _, p := range st.c.Pulls {
|
||||
u := rules.Unparsed{Key: pullKey(p)}
|
||||
bad, err := st.j.SeatDefinitions.Unparsed(ctx, p.Repository, p.Number)
|
||||
if err != nil {
|
||||
failed(fmt.Sprintf("%s: the indexer's seat definitions of it cannot be read, so none is known to parse: %v", pullKey(p), err))
|
||||
continue
|
||||
u.Unread = err.Error()
|
||||
}
|
||||
for _, u := range bad {
|
||||
failed(fmt.Sprintf("%s: the seat definition of %s (module %s, %s) does not parse: %s", pullKey(p), u.Seat, u.Module, u.Directory, u.Why))
|
||||
u.Seats = bad
|
||||
if u.Unread != "" || len(u.Seats) > 0 {
|
||||
st.facts = append(st.facts, rules.Step{Repository: p.Repository, Number: p.Number, Unparsed: &u})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SeatDefinitionsMachine names what refused a seat definition in a verdict's failure.
|
||||
const SeatDefinitionsMachine = "seat-definitions"
|
||||
const SeatDefinitionsMachine = rules.SeatDefinitionsMachine
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mesh-delivery-checker/rules"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -14,7 +15,6 @@ import (
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshcatalogclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshcatalog"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-checker/checkermsg"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
|
||||
)
|
||||
@@ -172,7 +172,7 @@ func (f *fakeTryCandidates) TryCandidate(_ context.Context, group, heads string,
|
||||
// wholeBench makes the bench judge a whole try as the checker does: the composition on every placed node and the
|
||||
// indexer's candidate of the try's group.
|
||||
func wholeBench(b *bench, cand meshcatalog.CandidatesByGroupValue) (*fakeCompositions, *fakeTryCandidates) {
|
||||
comps := &fakeCompositions{v: meshwants.CompositionsByGroupHeadsValue{State: meshdeliverycheckerclient.CompositionPass, Sdk: "v0.1.14-x", Machines: []string{"g14", "shanks"}, Took: 40}}
|
||||
comps := &fakeCompositions{v: composedOn("v0.1.14-x", 40, "g14", "shanks")}
|
||||
cands := &fakeTryCandidates{cand: cand}
|
||||
b.ex.Compositions, b.ex.Candidates = comps, cands
|
||||
return comps, cands
|
||||
@@ -258,7 +258,8 @@ func TestATryIsJudgedAsAGroupsCheckAndOwesTheForgeNothing(t *testing.T) { // ADR
|
||||
if len(written.running) < 3 || len(written.running[1].Asks) != 1 || written.running[1].Steps[0].State != TryAsked {
|
||||
t.Errorf("the running try's writes = %+v, want its ask named while it ran", written.running)
|
||||
}
|
||||
if len(got.Steps) != 2 || got.Steps[0].Node != "shanks" || got.Steps[0].State != buildstep.StepPassed || len(got.Asks) != 2 {
|
||||
// Two check asks and the checking program's two run asks (the seat list and the verdict).
|
||||
if len(got.Steps) != 4 || got.Steps[0].Node != "shanks" || got.Steps[0].State != buildstep.StepPassed || len(got.Asks) != 4 {
|
||||
t.Errorf("the ended try's steps = %+v, asks %v", got.Steps, got.Asks)
|
||||
}
|
||||
for _, a := range got.Asks { // the delivery view counts their steps as a try's (build_usage)
|
||||
@@ -371,21 +372,21 @@ func TestAFailingTryNamesWhatFailedAndAStepThatCannotRunIsAnError(t *testing.T)
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
cand *fakeTryCandidates
|
||||
comp meshwants.CompositionsByGroupHeadsValue
|
||||
comp *rules.Composed
|
||||
state, said string
|
||||
}{
|
||||
{name: "the indexer wrote no candidate", cand: &fakeTryCandidates{err: errors.New("the indexer wrote no candidate within 5m0s")}, state: TryError, said: "the indexer wrote no candidate within 5m0s"},
|
||||
{name: "a node that gave no composition answer", cand: &fakeTryCandidates{}, comp: meshwants.CompositionsByGroupHeadsValue{State: meshdeliverycheckerclient.CompositionPass, Sdk: "x", Warnings: []string{"g14: " + nodeengineclient.NoAnswer}},
|
||||
{name: "a node that gave no composition answer", cand: &fakeTryCandidates{}, comp: &rules.Composed{Composer: "x", Nodes: []string{"g14"}},
|
||||
state: TryError, said: "g14: " + nodeengineclient.NoAnswer},
|
||||
{name: "a composition refused on a node", cand: &fakeTryCandidates{}, comp: meshwants.CompositionsByGroupHeadsValue{State: meshdeliverycheckerclient.CompositionFail, Machine: "g14", Words: "not composed on g14: web: needs a state mail does not keep"},
|
||||
{name: "a composition refused on a node", cand: &fakeTryCandidates{}, comp: ptr(refusedOn("x", "g14", "web: needs a state mail does not keep")),
|
||||
state: TryFail, said: "not composed on g14: web: needs a state mail does not keep"},
|
||||
} {
|
||||
kv := newMemKV()
|
||||
b := newBench(t)
|
||||
comps, _ := wholeBench(b, meshcatalog.CandidatesByGroupValue{})
|
||||
b.ex.Candidates, comps.v = row.cand, row.comp
|
||||
if row.comp.State == "" {
|
||||
comps.v.State = meshdeliverycheckerclient.CompositionPass
|
||||
b.ex.Candidates = row.cand
|
||||
if row.comp != nil {
|
||||
comps.v = *row.comp
|
||||
}
|
||||
id, _ := AskTry(memTries{kv}, tryTrunks, TryAsk{Repository: repoA, Head: head1}, time.Now())
|
||||
var ends []string
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
type state struct {
|
||||
Name string `json:"name"`
|
||||
History int `json:"history,omitempty"`
|
||||
TTL int `json:"ttl-seconds,omitempty"`
|
||||
}
|
||||
|
||||
type artifact struct {
|
||||
@@ -107,7 +108,7 @@ func TestManifestDeclarations(t *testing.T) { // 1678/AC1
|
||||
t.Errorf("module = %q", m.Module)
|
||||
}
|
||||
// The histories of the checker's four seat states (checked against internal/check names below); the names follow the seat mesh-delivery-checker.
|
||||
if want := []state{{Name: "asks", History: 1}, {Name: "hearing", History: 1}, {Name: "kept", History: 1}, {Name: "owed", History: 5}, {Name: "previews", History: 5}, {Name: "tries", History: 1}, {Name: "verdicts", History: 5}, {Name: "first-views", History: 1}, {Name: "findings", History: 1}}; !reflect.DeepEqual(m.State, want) {
|
||||
if want := []state{{Name: "asks", History: 1}, {Name: "hearing", History: 1}, {Name: "inputs", TTL: 86400}, {Name: "kept", History: 1}, {Name: "owed", History: 5}, {Name: "previews", History: 5}, {Name: "tries", History: 1}, {Name: "verdicts", History: 5}, {Name: "first-views", History: 1}, {Name: "findings", History: 1}}; !reflect.DeepEqual(m.State, want) {
|
||||
t.Errorf("state = %+v, want %+v", m.State, want)
|
||||
}
|
||||
if want := []string{"mesh-build-go.steps", "mesh-build-go.toolchains", "mesh-build-node.steps", "mesh-build-node.toolchains",
|
||||
@@ -185,13 +186,14 @@ func TestManifestDeclarations(t *testing.T) { // 1678/AC1
|
||||
// The read tools (issue 1686) are served by the tools bundle, not by the checker's process.
|
||||
// The try's tools (ADR 0501 §5; the end by hand, issue 734) beside them: mesh-controller's try calls them. A lost check
|
||||
// is asked again by hand (checker_ask_again).
|
||||
if want := []string{"checker_ask_again", "checker_first_view_again", "checker_first_views", "checker_kept", "checker_previews", "checker_pull", "checker_status", "checker_tries", "checker_try", "checker_try_end", "checker_try_read", "checker_verdicts"}; !reflect.DeepEqual(sorted(m.Tools), want) {
|
||||
if want := []string{"checker_ask_again", "checker_first_view_again", "checker_first_views", "checker_inputs", "checker_kept", "checker_previews", "checker_pull", "checker_status", "checker_tries", "checker_try", "checker_try_end", "checker_try_read", "checker_verdicts"}; !reflect.DeepEqual(sorted(m.Tools), want) {
|
||||
t.Errorf("tools = %v, want %v", m.Tools, want)
|
||||
}
|
||||
// The try is the checker's own seat's verbs (ADR 0547 §10, issue 774), served beside the tools by the same code.
|
||||
// The try is the checker's own seat's verbs (ADR 0547 §10, issue 774), served beside the tools by the same code; so is
|
||||
// input, a checking program's input the Go build module asks (issue 1921, ADR 0596 §2).
|
||||
if len(m.Claims) != 1 || m.Claims[0].Name != "mesh-delivery-checker" || m.Claims[0].Scope != "mesh" ||
|
||||
!reflect.DeepEqual(m.Claims[0].Serves, []string{"try", "try-read", "try-end"}) {
|
||||
t.Errorf("claims = %+v, want the mesh seat mesh-delivery-checker serving try, try-read and try-end", m.Claims)
|
||||
!reflect.DeepEqual(m.Claims[0].Serves, []string{"try", "try-read", "try-end", "input"}) {
|
||||
t.Errorf("claims = %+v, want the mesh seat mesh-delivery-checker serving try, try-read, try-end and input", m.Claims)
|
||||
}
|
||||
for _, k := range []string{"logs", "emits", "provides", "requires", "defines-seats", "seat", "seats", "bus"} { // config: compose-answer-bound (ADR 0593 §7)
|
||||
if _, ok := top[k]; ok {
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"serves": [
|
||||
"try",
|
||||
"try-read",
|
||||
"try-end"
|
||||
"try-end",
|
||||
"input"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -35,6 +36,7 @@
|
||||
"state": [
|
||||
{ "name": "asks", "history": 1 },
|
||||
{ "name": "hearing", "history": 1 },
|
||||
{ "name": "inputs", "ttl-seconds": 86400 },
|
||||
{ "name": "kept", "history": 1 },
|
||||
{ "name": "owed", "history": 5 },
|
||||
{ "name": "previews", "history": 5 },
|
||||
@@ -60,6 +62,7 @@
|
||||
],
|
||||
"tools": [
|
||||
"checker_ask_again",
|
||||
"checker_inputs",
|
||||
"checker_kept",
|
||||
"checker_previews",
|
||||
"checker_pull",
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
// Package rules is the checker's judging rules, the checking program's: given the facts of one check (what the build
|
||||
// modules, the manifest validator and the composer answered, and what failed in the asking), the verdict (novox/hq ADR
|
||||
// 0644, ADR 0656; design 47's bootstrap delivery). The running checker asks, waits, keeps and records; it gathers the
|
||||
// facts and has them judged by the checking program built from the change's heads (cmd/mesh-checking-program), which
|
||||
// imports this package, so a change to a rule is judged by its own rule.
|
||||
//
|
||||
// It is the checker module's own package (novox/hq ADR 0634 §1): no seat carries it and no other module imports it.
|
||||
package rules
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
|
||||
)
|
||||
|
||||
// FactsVersion is the version of Facts this program reads. A checking program built from a later change reads the facts
|
||||
// a running checker of this version writes, so a version is added to, never changed; a program refuses one it does not
|
||||
// read, naming it (DecodeFacts).
|
||||
const FactsVersion = 1
|
||||
|
||||
// Facts are everything one check judges, in the order the running checker met it.
|
||||
type Facts struct {
|
||||
V int `json:"v"`
|
||||
// Group and Heads name the check (meshdeliverychecker.VerdictsByGroupHeadsValue's Group and Heads); Trunk is each repository's trunk head merged.
|
||||
Group string `json:"group"`
|
||||
Heads string `json:"heads"`
|
||||
Trunk map[string]string `json:"trunk,omitempty"`
|
||||
Members []Member `json:"members"`
|
||||
// Series are the changes of the members' modules' series, as structures, put in the verdict whatever it is (DLV-C4).
|
||||
Series []meshdeliverychecker.VerdictsByGroupHeadsValueSeriesItem `json:"series,omitempty"`
|
||||
// Steps are, in the order met, each level a build module answered and each failure the asking met.
|
||||
Steps []Step `json:"steps,omitempty"`
|
||||
// CloneMS is how much of the steps' time their levels' running did not take (meshdeliverychecker.VerdictsByGroupHeadsValueTook.Clone).
|
||||
CloneMS int64 `json:"clone-ms,omitempty"`
|
||||
// Composition is how the check composed, after every step; nil when it was not composed (a narrowed try, a checker
|
||||
// that composes nothing): its composition is then not run, unsaid.
|
||||
Composition *Composition `json:"composition,omitempty"`
|
||||
// Notes are the running checker's notes of the check, for a reader.
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// Member is one pull request of the check.
|
||||
type Member struct {
|
||||
Repository string `json:"repository"`
|
||||
Number int `json:"number"`
|
||||
Head string `json:"head"`
|
||||
Trunk string `json:"trunk,omitempty"`
|
||||
}
|
||||
|
||||
// Step is one fact of the check, in the order met: the node that ran it, and one of a level its build module answered
|
||||
// (Level, as the build module gave it), what its fetch read of compiled programs (Programs), the manifest validator's
|
||||
// answers (Manifests), the module graph's ordering edges (Graph) or a failure the asking met (Failed: a step not asked, refused or not answered, a conflict, worded
|
||||
// by the running checker). A step with none only says which node ran it.
|
||||
type Step struct {
|
||||
Repository string `json:"repository"`
|
||||
Number int `json:"number"`
|
||||
Module string `json:"module,omitempty"`
|
||||
Node string `json:"node,omitempty"`
|
||||
Level *buildstep.LevelAnswer `json:"level,omitempty"`
|
||||
Failed *FailedFirst `json:"failed,omitempty"`
|
||||
Programs *Programs `json:"programs,omitempty"`
|
||||
Manifests *Manifests `json:"manifests,omitempty"`
|
||||
// Graph is the module graph's ordering edges, as the change leaves them and as the trunk has them (JudgeGraph).
|
||||
Graph *Graph `json:"graph,omitempty"`
|
||||
// Renames is what a module brings and drops of `renamed-from` and how the seat mesh-config answered (JudgeRenames).
|
||||
Renames *Renames `json:"renames,omitempty"`
|
||||
// Unparsed is what the indexer keeps of a pull request's seat definitions that do not parse (JudgeUnparsed).
|
||||
Unparsed *Unparsed `json:"unparsed,omitempty"`
|
||||
}
|
||||
|
||||
// LevelFact is a level answer as the facts carry it: only what the rules read (Tally.Level), so a group's facts stay
|
||||
// within the run input's bound (buildstep.RunInputOf). The digest, the unmet packages and the level's judged program
|
||||
// are left out, the kept packages are one count, and each test result keeps only the fields Tested names, without its
|
||||
// packages, bounds and judged program (review round 1 of milestone 47/B0). An answer this cannot read is carried as given,
|
||||
// for the rules to refuse it.
|
||||
func LevelFact(la buildstep.LevelAnswer) *buildstep.LevelAnswer {
|
||||
out := &buildstep.LevelAnswer{Level: la.Level}
|
||||
kept := 0
|
||||
for _, k := range la.Kept {
|
||||
kept += k.Tests
|
||||
}
|
||||
if len(la.Kept) > 0 {
|
||||
out.Kept = []buildstep.KeptTaken{{Tests: kept}}
|
||||
}
|
||||
for _, raw := range la.Tests {
|
||||
var res Tested
|
||||
if err := json.Unmarshal(raw, &res); err != nil {
|
||||
out.Tests = append(out.Tests, raw)
|
||||
continue
|
||||
}
|
||||
res.Packages, res.JudgedBy, res.Bound, res.SetupBound, res.OverWhich = nil, "", 0, 0, ""
|
||||
small, err := json.Marshal(res)
|
||||
if err != nil {
|
||||
small = raw
|
||||
}
|
||||
out.Tests = append(out.Tests, small)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DecodeFacts reads facts, refusing a version this program does not read, naming it.
|
||||
func DecodeFacts(data []byte) (Facts, error) {
|
||||
var f Facts
|
||||
if err := json.Unmarshal(data, &f); err != nil {
|
||||
return Facts{}, fmt.Errorf("rules: the facts are not JSON of their shape: %w", err)
|
||||
}
|
||||
if f.V != FactsVersion {
|
||||
return Facts{}, fmt.Errorf("rules: facts of version %d, this checking program reads version %d", f.V, FactsVersion)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// Counts are one level's counts across a check's members.
|
||||
type Counts struct {
|
||||
Run, Kept, Skipped int
|
||||
Elapsed, Setup float64
|
||||
Lint float64
|
||||
Over bool
|
||||
Slowest []TestTime
|
||||
}
|
||||
|
||||
// Tally is a check's counts by level.
|
||||
type Tally map[string]*Counts
|
||||
|
||||
// NewTally is a tally of both levels, at nothing.
|
||||
func NewTally() Tally {
|
||||
return Tally{buildstep.LevelUnit: {}, buildstep.LevelIntegration: {}}
|
||||
}
|
||||
|
||||
// Level judges one level of a member's check step as it was answered: the kept ones taken are counted, and every test
|
||||
// result is read and counted, also after one that did not pass, so the verdict names every failed test of the level
|
||||
// (issue 1921). It answers the level's failure (nil when it passed), its running time (its tests' and setup's) and the
|
||||
// results read, for the running checker to keep the passes from.
|
||||
func (t Tally) Level(repository string, number int, module, node string, la buildstep.LevelAnswer) (*meshdeliverychecker.VerdictsByGroupHeadsValue, float64, []Tested) {
|
||||
a := t[la.Level]
|
||||
if a == nil {
|
||||
return failAt(node, fmt.Sprintf("the check of %s#%d answered the level %q, which is no level", repository, number, la.Level)), 0, nil
|
||||
}
|
||||
for _, k := range la.Kept {
|
||||
a.Kept += k.Tests
|
||||
}
|
||||
elapsed := 0.0
|
||||
var results, failed []Tested
|
||||
for _, raw := range la.Tests {
|
||||
var res Tested
|
||||
if err := json.Unmarshal(raw, &res); err != nil {
|
||||
return failAt(node, fmt.Sprintf("the %s level of %s#%d answered what is not a result: %v", la.Level, repository, number, err)), elapsed, results
|
||||
}
|
||||
results = append(results, res)
|
||||
elapsed += res.Elapsed + res.Setup + res.Lint
|
||||
if module == buildrun.NodeBuildModule {
|
||||
if !res.NoTests {
|
||||
a.Run++ // a Node package is counted whole: its build module counts no tests (ADR 0484 clause 10)
|
||||
}
|
||||
} else {
|
||||
a.Run += res.Tests
|
||||
a.Skipped += res.Skipped
|
||||
a.Slowest = append(a.Slowest, res.Slowest...)
|
||||
}
|
||||
a.Elapsed += res.Elapsed
|
||||
a.Setup += res.Setup
|
||||
a.Lint += res.Lint
|
||||
a.Over = a.Over || res.OverBound
|
||||
if !res.Passed {
|
||||
failed = append(failed, res)
|
||||
}
|
||||
}
|
||||
if len(failed) > 0 {
|
||||
return LevelFailure(repository, module, la.Level, failed...), elapsed, results
|
||||
}
|
||||
return nil, elapsed, results
|
||||
}
|
||||
|
||||
// Judge is the verdict of the facts (Judged without its notes).
|
||||
func Judge(f Facts) meshdeliverychecker.VerdictsByGroupHeadsValue { return Judged(f).Verdict }
|
||||
|
||||
// Judged is the answer of the facts: every failure in the order met, joined and settled, or a pass; the levels'
|
||||
// counts, times, machines and the levels over their bound; then the composition taken in; and the notes the rules give
|
||||
// a reader (the manifest validator's warnings and acceptances).
|
||||
func Judged(f Facts) Answer {
|
||||
var notes []string
|
||||
tally := NewTally()
|
||||
var failures []*meshdeliverychecker.VerdictsByGroupHeadsValue
|
||||
nodes := map[string]bool{}
|
||||
for _, s := range f.Steps {
|
||||
if s.Node != "" {
|
||||
nodes[s.Node] = true
|
||||
}
|
||||
switch {
|
||||
case s.Failed != nil:
|
||||
failures = append(failures, &meshdeliverychecker.VerdictsByGroupHeadsValue{State: meshdeliverycheckerclient.VerdictFail, Failed: s.Failed})
|
||||
case s.Level != nil:
|
||||
if v, _, _ := tally.Level(s.Repository, s.Number, s.Module, s.Node, *s.Level); v != nil {
|
||||
failures = append(failures, v)
|
||||
}
|
||||
case s.Programs != nil:
|
||||
if ff := JudgePrograms(s.Node, *s.Programs); ff != nil {
|
||||
failures = append(failures, &meshdeliverychecker.VerdictsByGroupHeadsValue{State: meshdeliverycheckerclient.VerdictFail, Failed: ff})
|
||||
}
|
||||
case s.Graph != nil:
|
||||
ff, said := JudgeGraph(*s.Graph)
|
||||
notes = append(notes, said...)
|
||||
if ff != nil {
|
||||
failures = append(failures, &meshdeliverychecker.VerdictsByGroupHeadsValue{State: meshdeliverycheckerclient.VerdictFail, Failed: ff})
|
||||
}
|
||||
case s.Renames != nil:
|
||||
ffs, said := JudgeRenames(*s.Renames)
|
||||
notes = append(notes, said...)
|
||||
for _, ff := range ffs {
|
||||
failures = append(failures, &meshdeliverychecker.VerdictsByGroupHeadsValue{State: meshdeliverycheckerclient.VerdictFail, Failed: &ff})
|
||||
}
|
||||
case s.Unparsed != nil:
|
||||
for _, ff := range JudgeUnparsed(*s.Unparsed) {
|
||||
failures = append(failures, &meshdeliverychecker.VerdictsByGroupHeadsValue{State: meshdeliverycheckerclient.VerdictFail, Failed: &ff})
|
||||
}
|
||||
case s.Manifests != nil:
|
||||
ff, said := JudgeManifests(*s.Manifests)
|
||||
notes = append(notes, said...)
|
||||
if ff != nil {
|
||||
failures = append(failures, &meshdeliverychecker.VerdictsByGroupHeadsValue{State: meshdeliverycheckerclient.VerdictFail, Failed: ff})
|
||||
}
|
||||
}
|
||||
}
|
||||
v := meshdeliverychecker.VerdictsByGroupHeadsValue{State: meshdeliverycheckerclient.VerdictPass}
|
||||
if joined := Settled(JoinFailures(failures...)); joined != nil {
|
||||
v = *joined
|
||||
}
|
||||
v.Group, v.Heads, v.Trunk, v.Series = f.Group, f.Heads, f.Trunk, f.Series
|
||||
Outcome(&v, tally, f.CloneMS, nodes)
|
||||
if f.Composition != nil {
|
||||
JudgeComposition(&v, *f.Composition)
|
||||
}
|
||||
return Answer{V: AnswerVersion, Verdict: v, Notes: notes}
|
||||
}
|
||||
|
||||
// Outcome fills a verdict's levels (on a pass, or when it names none), times, machines and levels over their bound from
|
||||
// the tally, and says its composition is not yet run (ADR 0484 clause 6).
|
||||
func Outcome(v *meshdeliverychecker.VerdictsByGroupHeadsValue, tally Tally, cloneMS int64, nodes map[string]bool) {
|
||||
if v.State == meshdeliverycheckerclient.VerdictPass || len(v.Levels) == 0 {
|
||||
v.Levels = map[string]meshdeliverychecker.VerdictsByGroupHeadsValueLevelsValue{}
|
||||
for level, a := range tally {
|
||||
v.Levels[level] = meshdeliverychecker.VerdictsByGroupHeadsValueLevelsValue{Run: int64(a.Run), Kept: int64(a.Kept), Skipped: int64(a.Skipped)}
|
||||
}
|
||||
}
|
||||
ms := func(s float64) int64 { return int64(s * 1000) }
|
||||
u, i := tally[buildstep.LevelUnit], tally[buildstep.LevelIntegration]
|
||||
v.Took = &meshdeliverychecker.VerdictsByGroupHeadsValueTook{Clone: cloneMS, Unit: ms(u.Elapsed), Lint: ms(u.Lint), IntegrationSetup: ms(i.Setup), IntegrationRun: ms(i.Elapsed)}
|
||||
v.Machines = nil
|
||||
for node := range nodes {
|
||||
v.Machines = append(v.Machines, node)
|
||||
}
|
||||
sort.Strings(v.Machines)
|
||||
v.OverBound = nil
|
||||
for _, level := range []string{buildstep.LevelUnit, buildstep.LevelIntegration} {
|
||||
if tally[level].Over {
|
||||
v.OverBound = append(v.OverBound, level)
|
||||
}
|
||||
}
|
||||
v.Composition = &meshdeliverychecker.VerdictsByGroupHeadsValueComposition{State: meshdeliverycheckerclient.CompositionNotRun}
|
||||
}
|
||||
|
||||
// Slowest are, of each level over its bound, its three slowest tests, worded for the status line.
|
||||
func Slowest(tally Tally) []string {
|
||||
var slow []string
|
||||
for _, level := range []string{buildstep.LevelUnit, buildstep.LevelIntegration} {
|
||||
a := tally[level]
|
||||
if !a.Over {
|
||||
continue
|
||||
}
|
||||
sort.SliceStable(a.Slowest, func(x, y int) bool { return a.Slowest[x].Elapsed > a.Slowest[y].Elapsed })
|
||||
for n, s := range a.Slowest {
|
||||
if n == 3 {
|
||||
break
|
||||
}
|
||||
slow = append(slow, fmt.Sprintf("%s %s %.1fs", level, path.Base(s.Package)+"."+s.Test, s.Elapsed))
|
||||
}
|
||||
}
|
||||
return slow
|
||||
}
|
||||
|
||||
func failAt(machine, words string) *meshdeliverychecker.VerdictsByGroupHeadsValue {
|
||||
return &meshdeliverychecker.VerdictsByGroupHeadsValue{State: meshdeliverycheckerclient.VerdictFail, Failed: &FailedFirst{Machine: machine, Words: words}}
|
||||
}
|
||||
|
||||
// AnswerVersion is the version of Answer this program writes.
|
||||
const AnswerVersion = 1
|
||||
|
||||
// Answer is what the checking program answers on the last line of its standard output: its version, the verdict and the
|
||||
// notes its rules give a reader of the check.
|
||||
type Answer struct {
|
||||
V int `json:"v"`
|
||||
Verdict meshdeliverychecker.VerdictsByGroupHeadsValue `json:"verdict"`
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
)
|
||||
|
||||
// The module graph's cycle rule (novox/hq ADR 0603 §2, ADR 0614 §3, §6), judged by the checking program (ADR 0656 §5):
|
||||
// the check refuses a change whose module graph has a cycle and names it; a cycle the trunk already has among the same
|
||||
// modules is the trunk's, said in the notes. The running checker gathers the graph's ordering edges of the catalogue as
|
||||
// the change leaves it and as the trunk has it, worked out with the contract's graph (sdk/graph: bases, the provisions
|
||||
// and bus state a module awaits, the verb edges the graph keeps), as module names and the edge's kind, never the
|
||||
// manifests themselves, so the facts stay within the run input's bound.
|
||||
|
||||
// The kinds of an ordering edge.
|
||||
const (
|
||||
EdgeBase = "base"
|
||||
EdgeAwaits = "awaits"
|
||||
EdgeInvokes = "invokes"
|
||||
)
|
||||
|
||||
// Edge is one ordering edge: From waits for To. Only is EdgeChange when only the change's graph has it, EdgeTrunk when
|
||||
// only the trunk's does, empty when both have it.
|
||||
type Edge struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Kind string `json:"kind"`
|
||||
Only string `json:"only,omitempty"`
|
||||
}
|
||||
|
||||
// Where only one graph has an edge.
|
||||
const (
|
||||
EdgeChange = "change"
|
||||
EdgeTrunk = "trunk"
|
||||
)
|
||||
|
||||
// Graph is the module graph's facts: every ordering edge of either graph, between modules of the catalogue. Trunk says
|
||||
// whether the trunk's graph was read; when it was not, every edge is the change's and no cycle is the trunk's.
|
||||
type Graph struct {
|
||||
Edges []Edge `json:"edges"`
|
||||
Trunk bool `json:"trunk"`
|
||||
}
|
||||
|
||||
// JudgeGraph is the cycle rule over the graph's facts: the failure of a cycle the change makes, or the note of no cycle
|
||||
// or of a cycle the trunk already has.
|
||||
func JudgeGraph(g Graph) (*FailedFirst, []string) {
|
||||
cycle := firstCycle(g.Edges, func(e Edge) bool { return e.Only != EdgeTrunk || !g.Trunk })
|
||||
if cycle == nil {
|
||||
return nil, []string{"the change's module graph has no cycle"}
|
||||
}
|
||||
words := strings.Join(cycle, " -> ")
|
||||
if g.Trunk {
|
||||
among := firstCycle(g.Edges, func(e Edge) bool {
|
||||
return e.Only != EdgeChange && slices.Contains(cycle, e.From) && slices.Contains(cycle, e.To)
|
||||
})
|
||||
if among != nil {
|
||||
return nil, []string{"the module graph has the cycle " + words + ", which the trunk already has among the same modules (ADR 0603 §2)"}
|
||||
}
|
||||
}
|
||||
return &FailedFirst{Machine: buildrun.GoBuildModule, Words: fmt.Sprintf("the change's module graph has the cycle %s: these modules would "+
|
||||
"each wait for the other for good, through their bases, the provisions and bus state they await, or the verbs they invoke (ADR 0603 §2, ADR 0614 §6)", words)}, nil
|
||||
}
|
||||
|
||||
// firstCycle is the first cycle of the edges kept, as the modules it passes through with the first named again at its
|
||||
// end, visiting the modules and each one's edges in sorted order (as sdk/graph's Cycle does), or nil for none.
|
||||
func firstCycle(edges []Edge, keep func(Edge) bool) []string {
|
||||
next := map[string][]string{}
|
||||
for _, e := range edges {
|
||||
if keep(e) && !slices.Contains(next[e.From], e.To) {
|
||||
next[e.From] = append(next[e.From], e.To)
|
||||
}
|
||||
}
|
||||
var names []string
|
||||
for name, to := range next {
|
||||
slices.Sort(to)
|
||||
names = append(names, name)
|
||||
}
|
||||
slices.Sort(names)
|
||||
const (
|
||||
unseen = iota
|
||||
onPath
|
||||
done
|
||||
)
|
||||
state := map[string]int{}
|
||||
var path []string
|
||||
var visit func(string) []string
|
||||
visit = func(name string) []string {
|
||||
state[name] = onPath
|
||||
path = append(path, name)
|
||||
for _, to := range next[name] {
|
||||
switch state[to] {
|
||||
case onPath:
|
||||
i := slices.Index(path, to)
|
||||
return append(slices.Clone(path[i:]), to)
|
||||
case unseen:
|
||||
if c := visit(to); c != nil {
|
||||
return c
|
||||
}
|
||||
}
|
||||
}
|
||||
path = path[:len(path)-1]
|
||||
state[name] = done
|
||||
return nil
|
||||
}
|
||||
for _, name := range names {
|
||||
if state[name] == unseen {
|
||||
if c := visit(name); c != nil {
|
||||
return c
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
nec "git.novox.be/novox/mesh-contracts/go/clients/nodeengineclient"
|
||||
ne "git.novox.be/novox/mesh-contracts/go/seats/nodeengine"
|
||||
"maps"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshwants"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/composer"
|
||||
)
|
||||
|
||||
// The facts the manifest validator, the fetch's programs and the composer answered, and the rules that judge them
|
||||
// (milestone 47/B0, D1b): the running checker asks and records; these rules give the verdict.
|
||||
|
||||
// manifestsMachine is the machine a manifest's failure names: the build seat that ran the manifest validator.
|
||||
const manifestsMachine = buildrun.GoBuildModule
|
||||
|
||||
// CatalogSeat is the seat the indexer holds: the module whose manifest claims it is the indexer, found by the claim and
|
||||
// never by its name (novox/hq ADR 0633 clause 6, ADR 0601 §4).
|
||||
const CatalogSeat = "mesh-catalog"
|
||||
|
||||
// BetweenManifests is the manifest validator's name for what it judges between manifests (the catalogue as a whole).
|
||||
const BetweenManifests = "(between manifests)"
|
||||
|
||||
// Manifests are the manifest validator's answers on the manifests a check touches (ADR 0578 §2, ADR 0579 §2, ADR 0633
|
||||
// clause 6): its change and trunk cases where it judged (At), and, when a touched module claims the seat mesh-catalog,
|
||||
// that indexer and the trunk validator's change case on it (IndexerAt names where it judged).
|
||||
type Manifests struct {
|
||||
Touched []string `json:"touched"`
|
||||
At string `json:"at"`
|
||||
Change composer.CaseVerdict `json:"change"`
|
||||
Trunk composer.CaseVerdict `json:"trunk"`
|
||||
Indexer string `json:"indexer,omitempty"`
|
||||
IndexerAt string `json:"indexer-at,omitempty"`
|
||||
OfIndexer *composer.CaseVerdict `json:"of-indexer,omitempty"`
|
||||
}
|
||||
|
||||
// Programs are what one member's fetch read of the change's compiled programs (issue 1577): Files nil when the build
|
||||
// module reported none (an older module: not refused, the running checker warns), else each changed file that is one.
|
||||
type Programs struct {
|
||||
Key string `json:"key"`
|
||||
Files *[]ProgramFile `json:"files"`
|
||||
}
|
||||
|
||||
// ProgramFile is a changed file that is a compiled program, its format (ELF, Mach-O or PE) and, when it was added in an
|
||||
// earlier commit of the branch and removed later, that commit: the build step's own (buildstep.Program).
|
||||
type ProgramFile = buildstep.Program
|
||||
|
||||
// Composition is how the check's composition went (ADR 0593 §7, §9; ADR 0603 §10; ADR 0548 §5): kept from the check it
|
||||
// carries, not run (NotRun says why), or composed by the engines; BuildsFailed are the candidate's builds that failed
|
||||
// before it composed, worded by the running checker; Waits are what it composed without waiting on, as warnings.
|
||||
type Composition struct {
|
||||
Kept *meshdeliverychecker.VerdictsByGroupHeadsValueComposition `json:"kept,omitempty"`
|
||||
NotRun string `json:"not-run,omitempty"`
|
||||
BuildsFailed []FailedFirst `json:"builds-failed,omitempty"`
|
||||
Waits []string `json:"waits,omitempty"`
|
||||
Composed *Composed `json:"composed,omitempty"`
|
||||
FPSet string `json:"fp-set,omitempty"`
|
||||
}
|
||||
|
||||
// Composed are the engines' answers to one composition: the nodes placed, each answering node's answer, each silent
|
||||
// node with why (for the journal only), what composed (the node-engine's commit), the composer's own warnings and how
|
||||
// long it took in milliseconds.
|
||||
type Composed struct {
|
||||
Group string `json:"group"`
|
||||
Heads string `json:"heads"`
|
||||
Composer string `json:"composer"`
|
||||
Nodes []string `json:"nodes"`
|
||||
Answers map[string]ne.ComposeCandidateAnswer `json:"answers,omitempty"`
|
||||
Silent map[string]string `json:"silent,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Took int64 `json:"took,omitempty"`
|
||||
}
|
||||
|
||||
// JudgeManifests is the failure of the manifests' facts (nil when they pass) and the notes a reader gets: the trunk
|
||||
// validator refusing, holding or setting aside the new indexer's manifest fails first (ADR 0633 clause 6); else a new or
|
||||
// changed manifest is judged in full, a held one is refused, and of one the change leaves as it is a problem the trunk
|
||||
// already had is a warning while one the change causes fails the check (ADR 0579 §2).
|
||||
func JudgeManifests(m Manifests) (*FailedFirst, []string) {
|
||||
var notes []string
|
||||
if m.Indexer != "" {
|
||||
var problems []string
|
||||
ok := false
|
||||
var iv composer.ManifestRead
|
||||
if m.OfIndexer != nil {
|
||||
iv, ok = m.OfIndexer.Manifests[m.Indexer]
|
||||
}
|
||||
switch {
|
||||
case !ok:
|
||||
problems = append(problems, "the manifest validator gave no verdict")
|
||||
case iv.Refused != "":
|
||||
problems = append(problems, strings.Split(iv.Refused, "\n")...)
|
||||
case !iv.Accepted:
|
||||
problems = append(problems, "the manifest validator holds it, with no verdict yet")
|
||||
case iv.SetAside != "":
|
||||
problems = append(problems, "the manifest validator does not know its fields "+iv.SetAside+", so it waits")
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return &FailedFirst{Machine: manifestsMachine, Words: fmt.Sprintf("the indexer %s, the holder of the seat %s, would never run: the trunk's manifest validator at %s does not accept its manifest: %s",
|
||||
m.Indexer, CatalogSeat, m.IndexerAt, strings.Join(problems, "; "))}, nil
|
||||
}
|
||||
notes = append(notes, fmt.Sprintf("the new indexer's manifest of %s is accepted by the trunk's manifest validator at %s", m.Indexer, m.IndexerAt))
|
||||
}
|
||||
var refused, warned []string
|
||||
for _, module := range slices.Sorted(maps.Keys(m.Change.Manifests)) {
|
||||
r := m.Change.Manifests[module]
|
||||
if r.Accepted {
|
||||
continue
|
||||
}
|
||||
if r.Refused == "" { // held: a verdict not given is not one given (ADR 0633 clause 6)
|
||||
if slices.Contains(m.Touched, module) {
|
||||
refused = append(refused, fmt.Sprintf("the indexer's judge at %s holds the manifest of %s: it gave no verdict", m.At, module))
|
||||
}
|
||||
continue
|
||||
}
|
||||
had := map[string]bool{}
|
||||
for _, p := range strings.Split(m.Trunk.Manifests[module].Refused, "\n") {
|
||||
had[p] = true
|
||||
}
|
||||
var caused, old []string
|
||||
for _, p := range strings.Split(r.Refused, "\n") {
|
||||
if slices.Contains(m.Touched, module) || !had[p] {
|
||||
caused = append(caused, p)
|
||||
} else {
|
||||
old = append(old, p)
|
||||
}
|
||||
}
|
||||
if len(caused) > 0 {
|
||||
what := "the manifest of " + module
|
||||
if module == BetweenManifests {
|
||||
what = "the catalogue"
|
||||
}
|
||||
refused = append(refused, fmt.Sprintf("the indexer's judge at %s refuses %s: %s", m.At, what, strings.Join(caused, "; ")))
|
||||
}
|
||||
if len(old) > 0 {
|
||||
warned = append(warned, module+": "+strings.Join(old, "; "))
|
||||
}
|
||||
}
|
||||
if len(warned) > 0 {
|
||||
notes = append(notes, "warnings of the indexer's judge on manifests the change leaves as they are (ADR 0579 §2): "+strings.Join(warned, " | "))
|
||||
}
|
||||
if len(refused) > 0 {
|
||||
return &FailedFirst{Machine: manifestsMachine, Words: strings.Join(refused, "\n")}, notes
|
||||
}
|
||||
return nil, append(notes, fmt.Sprintf("the manifests of %s are accepted by the indexer's judge at %s", strings.Join(m.Touched, ", "), m.At))
|
||||
}
|
||||
|
||||
// JudgePrograms fails a member whose change adds or modifies a compiled program, naming each file (issue 1577); a fetch
|
||||
// that reported none, or found none, passes.
|
||||
func JudgePrograms(machine string, p Programs) *FailedFirst {
|
||||
if p.Files == nil {
|
||||
return nil
|
||||
}
|
||||
var found []string
|
||||
for _, f := range *p.Files {
|
||||
if f.Commit != "" {
|
||||
found = append(found, fmt.Sprintf("%s: a compiled program added in %s and removed later: it stays in history; squash the branch (issue 1577)", f.Path, short12(f.Commit)))
|
||||
continue
|
||||
}
|
||||
found = append(found, f.Path+": a compiled program is build output: remove it and add it to .gitignore (issue 1577)")
|
||||
}
|
||||
if len(found) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &FailedFirst{Machine: machine, Words: fmt.Sprintf("%s: %s", p.Key, strings.Join(found, "; "))}
|
||||
}
|
||||
|
||||
func short12(s string) string {
|
||||
if len(s) > 12 {
|
||||
return s[:12]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// JudgeEngines is the check's composition verdict from the engines' answers (ADR 0593 §7, §9): failed when any node's
|
||||
// answer is refused, naming each node and each module refused with its words (the first refusing node is the machine);
|
||||
// passed otherwise. Every placed node without an answer is named with nec.NoAnswer as a warning.
|
||||
func JudgeEngines(c Composed) meshwants.CompositionsByGroupHeadsValue {
|
||||
v := meshwants.CompositionsByGroupHeadsValue{Group: c.Group, Heads: c.Heads, State: meshdeliverycheckerclient.CompositionPass, Sdk: c.Composer, Took: c.Took}
|
||||
sorted := append([]string(nil), c.Nodes...)
|
||||
sort.Strings(sorted)
|
||||
var fails []string
|
||||
for _, node := range sorted {
|
||||
a, answered := c.Answers[node]
|
||||
if !answered {
|
||||
v.Warnings = append(v.Warnings, node+": "+nec.NoAnswer)
|
||||
continue
|
||||
}
|
||||
if a.Verdict == nec.NotJudged {
|
||||
// Composed in the sandbox and not judged (ADR 0593 §9): named as no answer, with the composer's words; decided
|
||||
// by the verdict, never by the words (ADR 0601 §4's kind).
|
||||
v.Warnings = append(v.Warnings, node+": "+nec.NoAnswer+" ("+a.Refused+")")
|
||||
continue
|
||||
}
|
||||
v.Machines = append(v.Machines, node)
|
||||
if a.Verdict != nec.Refused {
|
||||
continue
|
||||
}
|
||||
if v.Machine == "" {
|
||||
v.Machine = node
|
||||
}
|
||||
var words []string
|
||||
if a.Refused != "" {
|
||||
words = append(words, a.Refused)
|
||||
}
|
||||
for _, m := range append(append([]ne.Module(nil), a.Modules...), a.Own) {
|
||||
if m.State == nec.ModuleRefused {
|
||||
words = append(words, m.Module+": "+m.Words)
|
||||
}
|
||||
}
|
||||
if len(words) == 0 {
|
||||
words = append(words, "refused, naming no module")
|
||||
}
|
||||
fails = append(fails, "not composed on "+node+": "+strings.Join(words, "; "))
|
||||
}
|
||||
if len(fails) > 0 {
|
||||
v.State, v.Words = meshdeliverycheckerclient.CompositionFail, strings.Join(fails, "; ")
|
||||
}
|
||||
if len(sorted) == 0 {
|
||||
v.Warnings = append(v.Warnings, "no node is placed, so the change was composed on none")
|
||||
}
|
||||
v.Warnings = append(v.Warnings, c.Warnings...)
|
||||
return v
|
||||
}
|
||||
|
||||
// JudgeComposition takes the composition's facts into the verdict: a failed build of the candidate fails it, named after
|
||||
// the tests' failures; a failed composition fails it, naming its machine; a passed one with warnings says them.
|
||||
func JudgeComposition(v *meshdeliverychecker.VerdictsByGroupHeadsValue, c Composition) {
|
||||
if c.Kept != nil {
|
||||
kept := *c.Kept
|
||||
v.Composition = &kept
|
||||
return
|
||||
}
|
||||
for _, f := range c.BuildsFailed {
|
||||
if v.State != meshdeliverycheckerclient.VerdictFail {
|
||||
v.State, v.Failed = meshdeliverycheckerclient.VerdictFail, &f
|
||||
continue
|
||||
}
|
||||
AddFailure(v, f)
|
||||
}
|
||||
if c.NotRun != "" {
|
||||
v.Composition = &meshdeliverychecker.VerdictsByGroupHeadsValueComposition{State: meshdeliverycheckerclient.CompositionNotRun, Words: c.NotRun}
|
||||
return
|
||||
}
|
||||
if c.Composed == nil {
|
||||
return
|
||||
}
|
||||
comp := JudgeEngines(*c.Composed)
|
||||
comp.Warnings = append(comp.Warnings, c.Waits...) // ADR 0603 §10
|
||||
v.Composition = compositionOf(comp)
|
||||
if v.Took == nil {
|
||||
v.Took = &meshdeliverychecker.VerdictsByGroupHeadsValueTook{}
|
||||
}
|
||||
v.Took.Composition = comp.Took
|
||||
if comp.State == meshdeliverycheckerclient.CompositionPass && len(comp.Warnings) > 0 && v.Composition != nil {
|
||||
v.Composition.Words = strings.Join(comp.Warnings, "; ") // passed with a warning (ADR 0593 §9)
|
||||
}
|
||||
if comp.State == meshdeliverycheckerclient.CompositionFail {
|
||||
machine := comp.Machine
|
||||
if machine == "" {
|
||||
machine = "the composition"
|
||||
}
|
||||
f := FailedFirst{Machine: machine, Words: comp.Words}
|
||||
if v.State == meshdeliverycheckerclient.VerdictFail {
|
||||
AddFailure(v, f) // the tests failed too: the composition is named after them (the operator, 2026-11-02)
|
||||
} else {
|
||||
v.State, v.Failed = meshdeliverycheckerclient.VerdictFail, &f
|
||||
}
|
||||
}
|
||||
if v.Composition != nil && v.Composition.State != meshdeliverycheckerclient.CompositionNotRun {
|
||||
v.Composition.Fpset = c.FPSet // a carry keeps it while the fingerprints stay the same (ADR 0548 §5)
|
||||
}
|
||||
}
|
||||
|
||||
// compositionWaits is the state of the assigner's composition answer that waits for what it composes from.
|
||||
const compositionWaits = "waits"
|
||||
|
||||
// compositionOf is what the verdict carries of a composition answer c: nil while c waits (the check is still running).
|
||||
func compositionOf(c meshwants.CompositionsByGroupHeadsValue) *meshdeliverychecker.VerdictsByGroupHeadsValueComposition {
|
||||
if c.State == compositionWaits {
|
||||
return nil
|
||||
}
|
||||
return &meshdeliverychecker.VerdictsByGroupHeadsValueComposition{State: c.State, Words: c.Words, Sdk: c.Sdk, Machines: c.Machines}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package rules
|
||||
|
||||
import checker "git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
|
||||
|
||||
// FailedFirst names what failed: a test (its package and name) or a machine, with the words of the failure. It is the shape
|
||||
// of the verdict's `failed` and of each entry of its `failures` (the seat's two types of it have the same fields), so the
|
||||
// rules judge with one and convert at the verdict's edge (failureItems, failedList).
|
||||
type FailedFirst = checker.VerdictsByGroupHeadsValueFailed
|
||||
|
||||
func failureItems(list []FailedFirst) []checker.VerdictsByGroupHeadsValueFailuresItem {
|
||||
if list == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]checker.VerdictsByGroupHeadsValueFailuresItem, len(list))
|
||||
for i, f := range list {
|
||||
out[i] = checker.VerdictsByGroupHeadsValueFailuresItem(f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func failedList(items []checker.VerdictsByGroupHeadsValueFailuresItem) []FailedFirst {
|
||||
if items == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]FailedFirst, len(items))
|
||||
for i, f := range items {
|
||||
out[i] = FailedFirst(f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// failedOf is the first failure a verdict names: the zero value of a verdict that names none.
|
||||
func failedOf(v checker.VerdictsByGroupHeadsValue) FailedFirst {
|
||||
if v.Failed == nil {
|
||||
return FailedFirst{}
|
||||
}
|
||||
return *v.Failed
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
// Program is the checking program's work (cmd/mesh-checking-program; novox/hq ADR 0644, ADR 0656): over its arguments,
|
||||
// its input (read only when asked for the verdict) and the heads laid for it (nil when none are), the one JSON line it
|
||||
// answers, or why it refuses. `-seats` answers the seat list of the heads (SeatsOf), `-verdict` the verdict of the
|
||||
// facts in the input (Judged), refusing facts of a version it does not read.
|
||||
func Program(args []string, input func() ([]byte, error), heads fs.FS) ([]byte, error) {
|
||||
set := flag.NewFlagSet("mesh-checking-program", flag.ContinueOnError)
|
||||
set.SetOutput(io.Discard)
|
||||
verdict := set.Bool("verdict", false, "answer the verdict of the facts in the input")
|
||||
seats := set.Bool("seats", false, "answer the seat list of the heads laid for it")
|
||||
if err := set.Parse(args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
case *seats:
|
||||
if heads == nil {
|
||||
return nil, errors.New("no heads are laid for it")
|
||||
}
|
||||
data, err := input()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the input cannot be read: %w", err)
|
||||
}
|
||||
facts, err := DecodeFacts(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
answer, err := SeatsOf(heads, facts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(answer)
|
||||
case *verdict:
|
||||
data, err := input()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the input cannot be read: %w", err)
|
||||
}
|
||||
facts, err := DecodeFacts(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(Judged(facts))
|
||||
}
|
||||
return nil, errors.New("nothing asked: give -verdict or -seats")
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-contracts/go/seats/meshconfig"
|
||||
)
|
||||
|
||||
// The refusals of what the running checker asked of other seats for a check (milestone 47/G): it asks and records the
|
||||
// answers as facts, and these rules decide what fails.
|
||||
|
||||
// RenamesSeat is the verb a change that brings or drops a `renamed-from` is asked of (design 32).
|
||||
var RenamesSeat = "seat:" + meshconfig.Seat + "." + meshconfig.VerbRenames
|
||||
|
||||
// Renames is what one module of a member brings and drops of `renamed-from` (key to the old key), and how the seat
|
||||
// mesh-config's verb renames answered for it: Answer when it answered; else Unasked says why it was not asked or did not
|
||||
// answer, NotServed that its holder does not serve the verb. Unread says the module's manifests could not be read.
|
||||
type Renames struct {
|
||||
Module string `json:"module"`
|
||||
Key string `json:"key"`
|
||||
Brought map[string]string `json:"brought,omitempty"`
|
||||
Dropped map[string]string `json:"dropped,omitempty"`
|
||||
Answer *meshconfig.RenamesAnswer `json:"answer,omitempty"`
|
||||
Unasked string `json:"unasked,omitempty"`
|
||||
NotServed bool `json:"not-served,omitempty"`
|
||||
Unread string `json:"unread,omitempty"`
|
||||
}
|
||||
|
||||
// JudgeRenames fails a rename the seat cannot move the value of, and the drop of a `renamed-from` while its window is
|
||||
// open on a node; it says which module the seat answered for.
|
||||
func JudgeRenames(r Renames) (failed []FailedFirst, notes []string) {
|
||||
at := func(words string) { failed = append(failed, FailedFirst{Machine: meshconfig.Seat, Words: words}) }
|
||||
switch {
|
||||
case r.Unread != "":
|
||||
at(fmt.Sprintf("%s (%s): its config renames cannot be judged: %s", r.Module, r.Key, r.Unread))
|
||||
return failed, nil
|
||||
case r.NotServed:
|
||||
for _, key := range slices.Sorted(maps.Keys(r.Brought)) {
|
||||
at(fmt.Sprintf("%s (%s) renames %s from %s: refused, %s, so the value cannot be moved", r.Module, r.Key, key, r.Brought[key], r.Unasked))
|
||||
}
|
||||
for _, key := range slices.Sorted(maps.Keys(r.Dropped)) {
|
||||
at(fmt.Sprintf("%s (%s) drops renamed-from %s of %s: refused, %s, so whether its window is open is not known", r.Module, r.Key, r.Dropped[key], key, r.Unasked))
|
||||
}
|
||||
return failed, nil
|
||||
case r.Answer == nil && r.Unasked == "":
|
||||
at(fmt.Sprintf("%s (%s) changes a renamed-from, and this checker cannot ask %s", r.Module, r.Key, RenamesSeat))
|
||||
return failed, nil
|
||||
case r.Answer == nil:
|
||||
at(fmt.Sprintf("%s (%s) changes a renamed-from, and the check cannot ask %s: %s", r.Module, r.Key, RenamesSeat, r.Unasked))
|
||||
return failed, nil
|
||||
}
|
||||
for _, key := range slices.Sorted(maps.Keys(r.Dropped)) {
|
||||
for _, n := range r.Answer.Renames {
|
||||
if n.Module == r.Module && n.From == r.Dropped[key] && n.To == key && len(n.Open) > 0 {
|
||||
open := slices.Sorted(slices.Values(n.Open))
|
||||
at(fmt.Sprintf("%s (%s) drops renamed-from %s of %s while its rename window is open on %s", r.Module, r.Key, n.From, key, strings.Join(open, ", ")))
|
||||
}
|
||||
}
|
||||
}
|
||||
return failed, []string{fmt.Sprintf("%s.%s answered for %s (%s)", meshconfig.Seat, meshconfig.VerbRenames, r.Module, r.Key)}
|
||||
}
|
||||
|
||||
// SeatDefinitionsMachine names what refused a seat definition in a verdict's failure.
|
||||
const SeatDefinitionsMachine = "seat-definitions"
|
||||
|
||||
// UnparsedSeat is one seat definition of a pull request's head that does not parse, as the indexer keeps it in its state
|
||||
// seat-definitions: the seat (the contracts module's name when its index did not parse), its module's directory and the
|
||||
// indexer's words of why.
|
||||
type UnparsedSeat struct {
|
||||
Seat string `json:"seat"`
|
||||
Module string `json:"module"`
|
||||
Directory string `json:"directory"`
|
||||
Why string `json:"why"`
|
||||
}
|
||||
|
||||
// Unparsed is what the indexer keeps of one pull request's seat definitions that do not parse; Unread says why its state
|
||||
// could not be read.
|
||||
type Unparsed struct {
|
||||
Key string `json:"key"`
|
||||
Seats []UnparsedSeat `json:"seats,omitempty"`
|
||||
Unread string `json:"unread,omitempty"`
|
||||
}
|
||||
|
||||
// JudgeUnparsed fails a pull request whose head's seat definition does not parse, naming the seat and where it lives
|
||||
// (design 26; the lead's ruling on A2i's finding 1), and one whose seat definitions cannot be read, since none is then
|
||||
// known to parse.
|
||||
func JudgeUnparsed(u Unparsed) []FailedFirst {
|
||||
if u.Unread != "" {
|
||||
return []FailedFirst{{Machine: SeatDefinitionsMachine, Words: fmt.Sprintf("%s: the indexer's seat definitions of it cannot be read, so none is known to parse: %s", u.Key, u.Unread)}}
|
||||
}
|
||||
var out []FailedFirst
|
||||
for _, s := range u.Seats {
|
||||
out = append(out, FailedFirst{Machine: SeatDefinitionsMachine, Words: fmt.Sprintf("%s: the seat definition of %s (module %s, %s) does not parse: %s", u.Key, s.Seat, s.Module, s.Directory, s.Why)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package rules
|
||||
|
||||
import "git.novox.be/novox/mesh-delivery/sdk/buildstep"
|
||||
|
||||
// The shapes of a test step's answer, as the build modules' code writes them (mesh-catalog modules/mesh-build-go
|
||||
// cmd/mesh-build-go levels.go TestResult; modules/mesh-build-node cmd/mesh-build-node check.go TestResult). Only the
|
||||
// fields the rules read are named; the rest is ignored.
|
||||
|
||||
// GoPackage is one package of a Go test result.
|
||||
type GoPackage struct {
|
||||
Dir string `json:"dir"`
|
||||
Package string `json:"package"`
|
||||
Passed bool `json:"passed"`
|
||||
Elapsed float64 `json:"elapsed"`
|
||||
Tests int `json:"tests"`
|
||||
NoTests bool `json:"no_tests,omitempty"`
|
||||
// Skipped is how many of the package's tests skipped; an older build module names none.
|
||||
Skipped int `json:"skipped,omitempty"`
|
||||
}
|
||||
|
||||
// Failure is a test that failed, or a package that failed with no test to name: the build modules' failure contract
|
||||
// (buildstep.Failure, issue 1901), its cause never empty and never a passing line from a build module that keeps it.
|
||||
type Failure = buildstep.Failure
|
||||
|
||||
// TestTime is one test and the seconds it took.
|
||||
type TestTime struct {
|
||||
Package string `json:"package"`
|
||||
Test string `json:"test"`
|
||||
Elapsed float64 `json:"elapsed"`
|
||||
}
|
||||
|
||||
// Tested is the answer of a test step of either build module (the Go one names packages and counts tests; the Node one
|
||||
// names the package directory as Module and counts none).
|
||||
type Tested struct {
|
||||
Level string `json:"level"`
|
||||
Module string `json:"module,omitempty"`
|
||||
Passed bool `json:"passed"`
|
||||
Why string `json:"why,omitempty"`
|
||||
NoTests bool `json:"no_tests,omitempty"`
|
||||
Tests int `json:"tests"`
|
||||
Skipped int `json:"skipped"`
|
||||
Packages []GoPackage `json:"packages,omitempty"`
|
||||
Elapsed float64 `json:"elapsed"`
|
||||
Setup float64 `json:"setup,omitempty"`
|
||||
// Lint is the unit level's gofmt and go vet time, before its tests (mesh-build-go, ADR 0529 §1).
|
||||
Lint float64 `json:"lint,omitempty"`
|
||||
Bound float64 `json:"bound"`
|
||||
SetupBound float64 `json:"setup_bound,omitempty"`
|
||||
Slowest []TestTime `json:"slowest,omitempty"`
|
||||
Failed []Failure `json:"failed,omitempty"`
|
||||
OverBound bool `json:"over_bound"`
|
||||
OverWhich string `json:"over_which,omitempty"`
|
||||
// JudgedBy is the program that ran the step (ADR 0535 §5): "installed", or "candidate <merge commit>" under runner
|
||||
// workspace; absent from a build module that does not take the runner.
|
||||
JudgedBy string `json:"judged_by,omitempty"`
|
||||
// By is the build module that answered and the SDK it was built with (buildstep.By); absent from a build module from
|
||||
// before the failure contract.
|
||||
By string `json:"by,omitempty"`
|
||||
// OutOfStep are the generated files the step's regenerate-and-compare found out of step with their seat definitions,
|
||||
// each a repository-relative path (design 26): the running checker names them in its verdict event. Absent from a
|
||||
// build module that does not compare.
|
||||
OutOfStep []string `json:"out-of-step,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"maps"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/seatdefs"
|
||||
)
|
||||
|
||||
// The seat list a check judges and composes against (novox/hq ADR 0636 §1, §4, ADR 0644 §1; the lead's rulings Q2 on
|
||||
// milestones 47/B0 and 47/G): the checking program built from the heads reads the definitions the heads' contracts
|
||||
// modules carry, by the domain SDK's one reading (sdk/seatdefs), the indexer's too, and answers them; the running checker
|
||||
// lays them by seat over the list its reader holds from the seat mesh-catalog (verb seat-definitions) and reads no file
|
||||
// of the heads. The reader's list is not in the program's input: the mesh's definitions are larger than a run input's
|
||||
// bound (buildstep.RunInputBound).
|
||||
|
||||
// SeatsVersion is the version of SeatsAnswer this program writes.
|
||||
const SeatsVersion = 1
|
||||
|
||||
// SeatsAnswer is the checking program's `-seats` answer: every seat definition the heads carry, resolved, as the requests'
|
||||
// field `seats` carries them, sorted by seat; and each definition left out, with why.
|
||||
type SeatsAnswer struct {
|
||||
V int `json:"v"`
|
||||
Seats []json.RawMessage `json:"seats"`
|
||||
Left []LeftOut `json:"left,omitempty"`
|
||||
}
|
||||
|
||||
// LeftOut is a seat definition the list does not take from the heads: its file and why.
|
||||
type LeftOut struct {
|
||||
Path string `json:"path"`
|
||||
Why string `json:"why"`
|
||||
}
|
||||
|
||||
// SeatsOf is the seat definitions of the facts' members' heads. heads holds each member's head at its repository's path; a contracts module is a manifest there whose package artifact names
|
||||
// `generated-from` (seatdefs.Of). A seat of the mesh's own a module that is not its domain's defines (seatdefs.Refusal),
|
||||
// and a seat two modules define where neither hands it over, are left out, said; a definition that does not parse is an
|
||||
// error.
|
||||
// Heads with no contracts module give an empty list.
|
||||
func SeatsOf(heads fs.FS, f Facts) (SeatsAnswer, error) {
|
||||
out := SeatsAnswer{V: SeatsVersion}
|
||||
type taken struct {
|
||||
raw json.RawMessage
|
||||
module, file string
|
||||
}
|
||||
by := map[string]taken{}
|
||||
refused := map[string]bool{}
|
||||
repositories := map[string]string{}
|
||||
for _, m := range f.Members {
|
||||
repositories[m.Repository] = m.Head
|
||||
}
|
||||
for _, repository := range slices.Sorted(maps.Keys(repositories)) {
|
||||
if _, err := fs.Stat(heads, repository); errors.Is(err, fs.ErrNotExist) {
|
||||
continue // no head of it is laid: it carries no definition here
|
||||
}
|
||||
sub, err := fs.Sub(heads, repository)
|
||||
if err != nil {
|
||||
return SeatsAnswer{}, err
|
||||
}
|
||||
manifests, err := manifestsIn(sub)
|
||||
if err != nil {
|
||||
return SeatsAnswer{}, err
|
||||
}
|
||||
forge := localForge(sub)
|
||||
for _, c := range seatdefs.ContractsModules(manifests) {
|
||||
defs, unparsed, err := forge.Read(context.Background(), repository, repositories[repository], c)
|
||||
if err != nil {
|
||||
return SeatsAnswer{}, fmt.Errorf("the seat definitions of %s at the head of %s cannot be read: %w", c.Module, repository, err)
|
||||
}
|
||||
// A head's definition that does not parse, or names another seat than its directory's, leaves the group's seats
|
||||
// unknown: no list is made, so no manifest is judged by a list without it (design 26).
|
||||
for _, seat := range slices.Sorted(maps.Keys(unparsed)) {
|
||||
return SeatsAnswer{}, fmt.Errorf("the seat definition of %s (module %s) at the head of %s is not taken: %s", seat, c.Module, repository, unparsed[seat])
|
||||
}
|
||||
for _, seat := range slices.Sorted(maps.Keys(defs)) {
|
||||
d, file := defs[seat], defFile(repository, c, seat)
|
||||
if why := seatdefs.Refusal(c.Module, d); why != "" {
|
||||
out.Left = append(out.Left, LeftOut{Path: file, Why: why})
|
||||
continue
|
||||
}
|
||||
if prev, twice := by[seat]; twice || refused[seat] {
|
||||
switch {
|
||||
case refused[seat]:
|
||||
out.Left = append(out.Left, LeftOut{Path: file, Why: "the seat " + seat + " is defined in more than one place"})
|
||||
continue
|
||||
case seatdefs.HandsOver(prev.module, c.Module, d):
|
||||
case seatdefs.HandsOver(c.Module, prev.module, d):
|
||||
continue
|
||||
default:
|
||||
why := "the seat " + seat + " is defined in more than one place: " + prev.file + ", " + file
|
||||
out.Left = append(out.Left, LeftOut{Path: prev.file, Why: why}, LeftOut{Path: file, Why: why})
|
||||
delete(by, seat)
|
||||
refused[seat] = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
raw, err := json.Marshal(d)
|
||||
if err != nil {
|
||||
out.Left = append(out.Left, LeftOut{Path: file, Why: err.Error()})
|
||||
continue
|
||||
}
|
||||
by[seat] = taken{raw: raw, module: c.Module, file: file}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.Seats = make([]json.RawMessage, 0, len(by))
|
||||
for _, seat := range slices.Sorted(maps.Keys(by)) {
|
||||
out.Seats = append(out.Seats, by[seat].raw)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// defFile is a seat's definition file in a head, by its repository's path.
|
||||
func defFile(repository string, c seatdefs.Module, seat string) string {
|
||||
return path.Join(repository, c.Defs, "seats", seat, "seat.json")
|
||||
}
|
||||
|
||||
// manifestsIn are the raw manifests of a head by their directory (the root's is "."), the generated contracts, the tests'
|
||||
// data and the packages' dependencies left out.
|
||||
func manifestsIn(head fs.FS) (map[string]string, error) {
|
||||
out := map[string]string{}
|
||||
err := fs.WalkDir(head, ".", func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() && (d.Name() == ".git" || d.Name() == "testdata" || d.Name() == "node_modules") {
|
||||
return fs.SkipDir
|
||||
}
|
||||
if !d.IsDir() && d.Name() == "module.json" {
|
||||
raw, err := fs.ReadFile(head, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out[path.Dir(p)] = string(raw)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// localForge is the domain SDK's reading bound to a head laid on disk: a directory or a file the head does not hold is
|
||||
// none.
|
||||
func localForge(head fs.FS) seatdefs.Forge {
|
||||
return seatdefs.Forge{
|
||||
Files: func(_ context.Context, _, _ string, dirs []string) (map[string]map[string]string, error) {
|
||||
out := map[string]map[string]string{}
|
||||
for _, dir := range dirs {
|
||||
files := map[string]string{}
|
||||
err := fs.WalkDir(head, dir, func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() {
|
||||
files[strings.TrimPrefix(p, dir+"/")] = ""
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[dir] = files
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
File: func(_ context.Context, _, _, file string) ([]byte, error) {
|
||||
raw, err := fs.ReadFile(head, file)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return raw, err
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/clients/meshdeliverycheckerclient"
|
||||
"git.novox.be/novox/mesh-delivery/contracts/go/seats/meshdeliverychecker"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/buildstep"
|
||||
)
|
||||
|
||||
// The words rules: how a failed test, a failed package and a list of failures are worded in a verdict (issue 1901, issue
|
||||
// 1921). Moved whole from the running checker's package (milestone 47/B0): they are the checking program's.
|
||||
|
||||
// FailedName names a failure: its package and test.
|
||||
func FailedName(f Failure) string {
|
||||
if f.Test == "" {
|
||||
return f.Package
|
||||
}
|
||||
return f.Package + "." + f.Test
|
||||
}
|
||||
|
||||
// MaxWords is the most a failed test's words in a verdict hold (issue 1901). **The words keep the failure's last lines**,
|
||||
// where a test says why it failed: the first line is often something it logged before (the group r4g verdict kept a
|
||||
// "judged against" line and lost the t.Fatalf after it), and the 120 characters kept before cut a docker command's echo
|
||||
// before docker's own error. Four kilobytes holds a long message and is small beside a verdict's other parts.
|
||||
const MaxWords = 4096
|
||||
|
||||
// FailureWords is a failure's words: its output's last lines up to MaxWords, with the line the build module told apart as
|
||||
// the test's error first when the output does not start with it, so the first line of the words is the cause and the
|
||||
// status line, which is cut short, names it. **A failure's words are never "PASS" or "ok"** (issue 1901, its third
|
||||
// meeting: a package that failed after its tests passed was worded "PASS"): go test's summary lines are dropped, and with
|
||||
// no cause told apart (an older build module) the cause is the output's first "panic:", "fatal error:" or "signal:"
|
||||
// line, read before the output is cut, or the words say the tests passed and the program failed after them.
|
||||
func FailureWords(f Failure) string {
|
||||
return FailureWordsAfter(f, "")
|
||||
}
|
||||
|
||||
// FailureWordsAfter is FailureWords with the line loud put first, within the same MaxWords: what the checker itself says of
|
||||
// the failure (that the build module hid it), which the status line then names.
|
||||
func FailureWordsAfter(f Failure, loud string) string {
|
||||
out, passed := DropSummary(strings.TrimSpace(f.Output))
|
||||
cause := strings.TrimSpace(f.Cause)
|
||||
if cause == "" {
|
||||
cause = CauseIn(out)
|
||||
}
|
||||
switch {
|
||||
case cause == "" && out == "" && passed:
|
||||
cause = "the tests passed, then the test program exited with a failure: an os.Exit or a panic in TestMain or after the tests"
|
||||
case cause == "" && out == "":
|
||||
// The build module on main sends a package that stopped (a timeout, an exit, a kill) as its "FAIL" line alone.
|
||||
cause = "the package failed with no test to name and no panic, signal or build error in its output (an older build module sent no cause)"
|
||||
}
|
||||
if len(cause) > MaxCause {
|
||||
cause = strings.ToValidUTF8(cause[:MaxCause], "") + "..."
|
||||
}
|
||||
// The words keep MaxWords exactly: the loud line, the cause and their newlines, the marker and the kept lines.
|
||||
budget := MaxWords
|
||||
if cause != "" {
|
||||
budget -= len(cause) + 1
|
||||
}
|
||||
if loud != "" {
|
||||
budget -= len(loud) + 1
|
||||
}
|
||||
if len(out) > budget {
|
||||
const marker = "... "
|
||||
tail := out[len(out)-(budget-len(marker)):]
|
||||
// Start on a whole line, and a whole character.
|
||||
if i := strings.IndexByte(tail, '\n'); i >= 0 {
|
||||
tail = strings.TrimLeft(tail[i:], "\n")
|
||||
}
|
||||
out = marker + strings.ToValidUTF8(tail, "")
|
||||
}
|
||||
if cause != "" && !strings.HasPrefix(FirstLine(out), cause) {
|
||||
out = strings.TrimRight(cause+"\n"+out, "\n")
|
||||
}
|
||||
if loud != "" {
|
||||
out = strings.TrimRight(loud+"\n"+out, "\n")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// HidWords is the checker's loud line for a failure the build module sent hiding its cause (issue 1901, its thorough
|
||||
// fix): with no cause, or a passing line as its cause, under the failure contract (buildstep.Hidden). It names the build
|
||||
// module and the version it says it is (Tested.By, "<module> with mesh-sdk <version>"); one from before the contract
|
||||
// says nothing of itself. "" when the failure keeps the contract. The check fails either way: only its words change.
|
||||
func HidWords(module, by string, f Failure) string {
|
||||
why := buildstep.Hidden(f)
|
||||
if why == "" {
|
||||
return ""
|
||||
}
|
||||
version := "a version before the failure contract"
|
||||
if module == "" {
|
||||
module = "an unnamed build module"
|
||||
}
|
||||
if m, v, ok := strings.Cut(by, " with "); ok && m != "" {
|
||||
module, version = m, v
|
||||
}
|
||||
return fmt.Sprintf("the build module hid the failure (%s at %s): %s", module, version, why)
|
||||
}
|
||||
|
||||
// MaxCause is the most a cause in a failure's words holds: one long line (the 1901 review: a cause of a kilobyte could
|
||||
// push the words past MaxWords).
|
||||
const MaxCause = 1024
|
||||
|
||||
// DropSummary drops go test's summary lines from a failure's output ("PASS", "FAIL", "ok <pkg>", "FAIL <pkg>"), and says
|
||||
// whether a "PASS" was among them: the package's tests passed before it failed.
|
||||
func DropSummary(out string) (string, bool) {
|
||||
passed := false
|
||||
lines := strings.Split(out, "\n")
|
||||
kept := lines[:0]
|
||||
for _, l := range lines {
|
||||
// go's exact forms only (review round 1): "PASS" or "FAIL" alone, "ok \t<pkg>\t<time>", "FAIL\t<pkg>\t<time>".
|
||||
switch t := strings.TrimRight(l, "\r"); {
|
||||
case t == "PASS":
|
||||
passed = true
|
||||
case t == "FAIL", strings.HasPrefix(t, "ok \t"), strings.HasPrefix(t, "FAIL\t"):
|
||||
default:
|
||||
kept = append(kept, l)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(kept, "\n")), passed
|
||||
}
|
||||
|
||||
// CauseIn is a race the race detector reported, as its heading, its access and the frame that made it ("WARNING: DATA
|
||||
// RACE: Write at 0x... by goroutine 9 in pkg.F.func1()"); else the first "panic:", "fatal error:" or "signal:" line of a
|
||||
// failure's output, in that order; or "". **Only lines at column 0**, where go prints the runtime's: a test's indented log
|
||||
// line holding the same words is not the cause (review round 1).
|
||||
func CauseIn(out string) string {
|
||||
lines := strings.Split(out, "\n")
|
||||
for i, l := range lines {
|
||||
if strings.TrimRight(l, "\r") != "WARNING: DATA RACE" {
|
||||
continue
|
||||
}
|
||||
words := "WARNING: DATA RACE"
|
||||
if i+1 < len(lines) && strings.TrimSpace(lines[i+1]) != "" {
|
||||
words += ": " + strings.TrimSuffix(strings.TrimSpace(lines[i+1]), ":")
|
||||
if i+2 < len(lines) && strings.HasPrefix(lines[i+2], " ") {
|
||||
words += " in " + strings.TrimSpace(lines[i+2])
|
||||
}
|
||||
}
|
||||
return words
|
||||
}
|
||||
for _, prefix := range []string{"panic: ", "fatal error: ", "signal: "} {
|
||||
for _, l := range lines {
|
||||
if l = strings.TrimRight(l, "\r"); strings.HasPrefix(l, prefix) {
|
||||
return l
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// FirstLine is the first non-empty line of a failure's words.
|
||||
func FirstLine(s string) string {
|
||||
for _, l := range strings.Split(s, "\n") {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
return l
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// MaxFailureLine is the most bytes of a failure's line in a verdict's Failures: the first line of its words, which is
|
||||
// its cause where the build module told one apart (issue 1901). Failed keeps the first failure's words whole.
|
||||
const MaxFailureLine = 300
|
||||
|
||||
// LevelFailure turns a level that did not pass into a verdict: Failed names what failed first with its words whole,
|
||||
// and Failures every failed test of the level's results with its first line, at most meshdeliverycheckerclient.MaxFailures and an entry
|
||||
// saying how many more (issue 1921).
|
||||
func LevelFailure(repository, module, level string, results ...Tested) *meshdeliverychecker.VerdictsByGroupHeadsValue {
|
||||
v := meshdeliverychecker.VerdictsByGroupHeadsValue{State: meshdeliverycheckerclient.VerdictFail}
|
||||
var all []FailedFirst
|
||||
for _, res := range results {
|
||||
if len(res.Failed) == 0 {
|
||||
why := res.Why
|
||||
if why == "" {
|
||||
why = "the " + level + " level did not pass"
|
||||
}
|
||||
all = append(all, FailedFirst{Test: level + ": " + repository, Words: why})
|
||||
continue
|
||||
}
|
||||
for _, f := range res.Failed {
|
||||
name := FailedName(f)
|
||||
if module == buildrun.NodeBuildModule && f.Package == "" {
|
||||
name = res.Module
|
||||
}
|
||||
all = append(all, FailedFirst{Test: level + ": " + name, Words: FailureWordsAfter(f, HidWords(module, res.By, f))})
|
||||
}
|
||||
}
|
||||
if len(all) == 0 { // no result: a level said failed with nothing to name
|
||||
all = append(all, FailedFirst{Test: level + ": " + repository, Words: "the " + level + " level did not pass"})
|
||||
}
|
||||
first := all[0]
|
||||
v.Failed = &first
|
||||
v.Failures = failureItems(all) // whole: Settled cuts them to their first lines once every failure of the check is joined
|
||||
return &v
|
||||
}
|
||||
|
||||
// JoinFailures is one failed verdict of every failure of vs, in the order given (a nil one passed), unsettled: Failed is
|
||||
// the first whole, and Failures every failure whole, or none when the one failure is no level's (a conflict, a refused
|
||||
// step: one line, as before issue 1921). Nil when none failed.
|
||||
func JoinFailures(vs ...*meshdeliverychecker.VerdictsByGroupHeadsValue) *meshdeliverychecker.VerdictsByGroupHeadsValue {
|
||||
var out *meshdeliverychecker.VerdictsByGroupHeadsValue
|
||||
var all []FailedFirst
|
||||
listed := false
|
||||
for _, v := range vs {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
if out == nil {
|
||||
out = &meshdeliverychecker.VerdictsByGroupHeadsValue{State: meshdeliverycheckerclient.VerdictFail, Failed: v.Failed}
|
||||
}
|
||||
if len(v.Failures) > 0 {
|
||||
all, listed = append(all, failedList(v.Failures)...), true
|
||||
continue
|
||||
}
|
||||
all = append(all, failedOf(*v))
|
||||
}
|
||||
if out != nil && (listed || len(all) > 1) {
|
||||
out.Failures = failureItems(all)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Settled is a joined verdict as it is written: each failure of its list cut to its first line, at most
|
||||
// meshdeliverycheckerclient.MaxFailures and then how many more (FailureLines).
|
||||
func Settled(v *meshdeliverychecker.VerdictsByGroupHeadsValue) *meshdeliverychecker.VerdictsByGroupHeadsValue {
|
||||
if v != nil && len(v.Failures) > 0 {
|
||||
v.Failures = failureItems(FailureLines(failedList(v.Failures)))
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// AddFailure is a Settled list of failures with one more at its end: the first failure is put first when the list is
|
||||
// empty (a verdict of one failure keeps none), and past meshdeliverycheckerclient.MaxFailures the last entry counts one more.
|
||||
func AddFailure(v *meshdeliverychecker.VerdictsByGroupHeadsValue, f FailedFirst) {
|
||||
if len(v.Failures) == 0 {
|
||||
v.Failures = failureItems(FailureLines([]FailedFirst{failedOf(*v)}))
|
||||
}
|
||||
if n := len(v.Failures); n > meshdeliverycheckerclient.MaxFailures {
|
||||
v.Failures[n-1].Test = fmt.Sprintf("and %d more", FailedCount(*v)-meshdeliverycheckerclient.MaxFailures+1)
|
||||
return
|
||||
}
|
||||
v.Failures = append(v.Failures, failureItems(FailureLines([]FailedFirst{f}))...)
|
||||
}
|
||||
|
||||
// FailureLines is every failure with the first line of its words, cut to MaxFailureLine, at most meshdeliverycheckerclient.MaxFailures
|
||||
// and then one entry saying how many more failed.
|
||||
func FailureLines(all []FailedFirst) []FailedFirst {
|
||||
n := min(len(all), meshdeliverycheckerclient.MaxFailures)
|
||||
out := make([]FailedFirst, 0, n+1)
|
||||
for _, f := range all[:n] {
|
||||
out = append(out, FailedFirst{Test: f.Test, Machine: f.Machine, Words: ClipLine(FirstLine(f.Words), MaxFailureLine)})
|
||||
}
|
||||
if more := len(all) - n; more > 0 {
|
||||
out = append(out, FailedFirst{Test: fmt.Sprintf("and %d more", more)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ClipLine cuts a line to at most n bytes on a whole character, marking the cut.
|
||||
func ClipLine(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
s = strings.ToValidUTF8(s[:n-3], "")
|
||||
return s + "..."
|
||||
}
|
||||
|
||||
// FailedCount is how many failures a verdict names, the ones after meshdeliverycheckerclient.MaxFailures counted too.
|
||||
func FailedCount(v meshdeliverychecker.VerdictsByGroupHeadsValue) int {
|
||||
n := len(v.Failures)
|
||||
if n > meshdeliverycheckerclient.MaxFailures {
|
||||
var more int
|
||||
if _, err := fmt.Sscanf(v.Failures[n-1].Test, "and %d more", &more); err == nil {
|
||||
return meshdeliverycheckerclient.MaxFailures + more
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
Reference in New Issue
Block a user