Merge pull request 'The checking program judges every check: the rules leave the running checker (issue 1921, B0)' (#162) from feat/b0-checking-program into main
This commit was merged in pull request #162.
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 (delivery.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(delivery.Step)) context.Context {
|
||||
if before := stepSeenOf(ctx); before != nil {
|
||||
later := seen
|
||||
seen = func(s delivery.Step) { before(s); later(s) }
|
||||
}
|
||||
return context.WithValue(ctx, stepSeenKey{}, seen)
|
||||
}
|
||||
|
||||
|
||||
@@ -26,10 +26,13 @@ func TestAnAskerPutsOnceAndReadsTheStep(t *testing.T) {
|
||||
return delivery.Step{ID: a.ID, Node: "ace", State: delivery.StepPassed, Result: []byte(`{"artifacts":[]}`)}
|
||||
}}
|
||||
k := Asker{Publisher: f, Waiter: f}
|
||||
var seen []delivery.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 delivery.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 []delivery.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 delivery.Step) { seen = append(seen, s) }),
|
||||
func(s delivery.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 delivery.StepAsk) delivery.Step {
|
||||
return delivery.Step{ID: a.ID, Node: "shanks", State: delivery.StepRefused, Reason: "go 1.27 installed"}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// 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"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"mesh-delivery-checker/rules"
|
||||
)
|
||||
|
||||
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(delivery.RunHeadsEnv); dir != "" {
|
||||
heads = os.DirFS(dir)
|
||||
}
|
||||
input := func() ([]byte, error) {
|
||||
file := getenv(delivery.RunInputEnv)
|
||||
if file == "" {
|
||||
return nil, fmt.Errorf("%s names no file", delivery.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", delivery.RunHeadsEnv)
|
||||
}
|
||||
fmt.Fprintf(stderr, "mesh-checking-program: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
fmt.Fprintf(stdout, "%s\n", line)
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"mesh-delivery-checker/rules"
|
||||
)
|
||||
|
||||
// 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: delivery.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: &delivery.LevelAnswer{Level: delivery.LevelUnit, Tests: []json.RawMessage{failing}}},
|
||||
{Repository: "novox/x", Number: 3, Failed: &delivery.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)
|
||||
heads := filepath.Join(dir, "heads")
|
||||
for p, body := range map[string]string{
|
||||
"novox/mesh-contracts/contracts/seats/node-build-x/seat.json": `{"format":1,"seat":"node-build-x","scope":"node","description":"kept","includes":["build"],"verbs":[{"name":"own"}]}`,
|
||||
"novox/mesh-contracts/contracts/parts/build.json": `{"part":"build","verbs":[{"name":"current"}],"events":[{"name":"ended"}]}`,
|
||||
"novox/mesh-contracts/contracts/seats/broken/seat.json": `{"format":1,"seat":"broken","scope":"nowhere"}`,
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Dir(filepath.Join(heads, p)), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(heads, p), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
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"}, heads: heads},
|
||||
"no seats": {args: []string{"-seats"}, heads: none},
|
||||
"no heads laid": {args: []string{"-seats"}, exit: 2, said: delivery.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: delivery.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 delivery.RunInputEnv:
|
||||
return c.input
|
||||
case delivery.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
|
||||
}
|
||||
want := `{"description":"kept","events":[{"name":"ended"}],"format":1,"scope":"node","seat":"node-build-x","verbs":[{"name":"current"},{"name":"own"}]}`
|
||||
if len(sa.Seats) != 1 || string(sa.Seats[0]) != want || len(sa.Left) != 1 || !strings.Contains(sa.Left[0].Why, `not "broken" and "nowhere"`) {
|
||||
t.Errorf("%s: seats %s, left %+v; want %s and broken left out", name, sa.Seats, sa.Left, want)
|
||||
}
|
||||
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 != delivery.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[delivery.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 != delivery.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 != delivery.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,11 +229,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 (delivery.RunInput.Read), never read from the checker's state by it.
|
||||
func SeatInputTools(r check.Reader) []stdio.Tool {
|
||||
return []stdio.Tool{{Name: Seat + "." + delivery.CheckerInputVerb,
|
||||
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"}}},
|
||||
@@ -243,7 +257,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"}}},
|
||||
@@ -264,6 +278,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) " +
|
||||
@@ -325,9 +350,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
|
||||
@@ -370,6 +395,7 @@ func main() {
|
||||
return err
|
||||
}}
|
||||
tools := append(append(append(append(Tools(r), TryTools(trying)...), OpenTriesTools(trying)...), SeatTryTools(trying)...), RecoveryTools(r, written)...)
|
||||
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)
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/tryevent"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"mesh-delivery-checker/internal/check"
|
||||
)
|
||||
@@ -21,7 +22,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)
|
||||
}
|
||||
@@ -108,6 +109,21 @@ func TestTheTryToolsAskAndReadATry(t *testing.T) { // ADR 0501 §5, the agreed i
|
||||
if _, err := verbs[0].Run(map[string]any{"repository": "novox/hq", "head": strings.Repeat("1", 40)}); err == nil {
|
||||
t.Error("the verb tried a repository nothing checks")
|
||||
}
|
||||
// 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, _ := delivery.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 || string(a.(delivery.RunInputAnswer).Input) != string(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
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats-server/v2/server"
|
||||
@@ -28,6 +29,7 @@ import (
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
|
||||
"mesh-delivery-checker/internal/check"
|
||||
"mesh-delivery-checker/rules"
|
||||
)
|
||||
|
||||
// busConf is the shape of the composed accounts file (mesh-controller internal/broker): one account with JetStream, the
|
||||
@@ -91,6 +93,115 @@ type fakeBuildModules struct {
|
||||
beside map[string][]string
|
||||
// names are the nodes' names the last check ask carried, for the names check (ADR 0567 §12).
|
||||
names []string
|
||||
// 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 delivery.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 (delivery.RunInput.Read, as mesh-build-go
|
||||
// reads it), with no heads of its own laid.
|
||||
func (f *fakeBuildModules) run(ctx context.Context, a delivery.StepAsk) (any, error) {
|
||||
f.runs.Add(1)
|
||||
if why, _ := f.broken.Load().(string); why != "" {
|
||||
return nil, errors.New(why)
|
||||
}
|
||||
answer := delivery.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 := a.Run.Input.Read(func(key string, body any) (json.RawMessage, error) { return askVerb(f.js.Conn(), key, 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."+delivery.CheckerInputVerb, func(m *nats.Msg) {
|
||||
var ask delivery.RunInputAsk
|
||||
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 {
|
||||
@@ -191,7 +302,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 delivery.StepAsk) (any, error) {
|
||||
if a.Check != nil {
|
||||
@@ -199,6 +310,9 @@ func (f *fakeBuildModules) serve(t *testing.T, module string, kept jetstream.Key
|
||||
f.names = a.Check.NodeNames
|
||||
f.mu.Unlock()
|
||||
}
|
||||
if a.Run != nil {
|
||||
return f.run(ctx, a)
|
||||
}
|
||||
return gobuild.RunAsk(ctx, steps, a)
|
||||
}}
|
||||
go func() { _ = p.Pull(f.ctx) }()
|
||||
@@ -256,11 +370,13 @@ type bed struct {
|
||||
js jetstream.JetStream
|
||||
buckets map[string]jetstream.KeyValue
|
||||
tools map[string]jetstream.KeyValue
|
||||
fake *fakeBuildModules
|
||||
srvURL string
|
||||
announce []string
|
||||
mu sync.Mutex
|
||||
logs saidLog
|
||||
// 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.
|
||||
@@ -398,7 +514,7 @@ func startBed(t *testing.T) *bed {
|
||||
delivery.CheckerOwedBucket: delivery.CheckerOwedHistory, delivery.PreviewsBucket: delivery.PreviewsHistory, delivery.CandidatesBucket: delivery.CandidatesHistory,
|
||||
delivery.ModulesBucket: delivery.ModulesHistory, delivery.ManifestsBucket: delivery.ManifestsHistory, delivery.SourceBucket: delivery.SourceHistory,
|
||||
delivery.PresenceBucket: delivery.PresenceHistory, delivery.HaveBucket: delivery.HaveHistory, check.TriesBucket: 1,
|
||||
delivery.TurnsBucket: delivery.TurnsHistory, delivery.BuilderBuildsBucket: 1} {
|
||||
delivery.TurnsBucket: delivery.TurnsHistory, delivery.BuilderBuildsBucket: 1, check.InputsBucket: 1} {
|
||||
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: name, History: uint8(history)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -420,6 +536,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[delivery.CheckerKeptBucket])
|
||||
b.fake.serve(t, "mesh-build-node", b.buckets[delivery.CheckerKeptBucket])
|
||||
@@ -647,6 +764,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()
|
||||
@@ -751,28 +881,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 != delivery.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, _ := delivery.PreviewKey(repo, head1, trunk)
|
||||
if e, err := b.buckets[delivery.PreviewsBucket].Get(b.ctx, pk); err != nil {
|
||||
t.Errorf("previews.%s: %v", pk, err)
|
||||
} else if pv, err := delivery.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 != delivery.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, _ := delivery.PreviewKey(repo, head1, trunk)
|
||||
if e, err := b.buckets[delivery.PreviewsBucket].Get(b.ctx, pk); err != nil {
|
||||
t.Errorf("previews.%s: %v", pk, err)
|
||||
} else if pv, err := delivery.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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -978,7 +1134,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") || len(said.States) != 7 {
|
||||
t.Fatalf("the checker said %+v, want its seven states, asks and hearing its own, 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("inputs") || said.Readable("inputs") || len(said.States) != 8 {
|
||||
t.Fatalf("the checker said %+v, want its eight states, asks, hearing and inputs its own, with kept, owed, previews, tries and verdicts readable, tries by the delivery view, inputs asked through the seat's verb input (1929/AC4; 1887 A5b; build_usage; 1921)", said)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,11 +24,15 @@ import (
|
||||
|
||||
// judgeDeps are the parts the judge of a check is made of.
|
||||
type judgeDeps struct {
|
||||
asks check.Asks
|
||||
tools *buildrun.ToolchainSet
|
||||
trunks *check.Trunks
|
||||
kept check.KV
|
||||
previews check.KV
|
||||
// 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
|
||||
tools *buildrun.ToolchainSet
|
||||
trunks *check.Trunks
|
||||
kept check.KV
|
||||
previews check.KV
|
||||
// candidates answers the indexer's candidate of a try's group (try_candidates.go); nil leaves a whole try with no
|
||||
// manifest or composition judged, said.
|
||||
candidates check.TryCandidates
|
||||
@@ -160,7 +164,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, 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, Predefined: d.predefined, Logf: d.logf}
|
||||
Kept: check.Kept{Store: d.kept}, Previews: d.previews, Candidates: d.candidates, Modules: d.repos, Packages: d.repos, Published: 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, Predefined: d.predefined, 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)
|
||||
}
|
||||
|
||||
@@ -72,6 +72,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},
|
||||
@@ -125,6 +129,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
|
||||
@@ -153,6 +162,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[delivery.CheckerVerdictsBucket]},
|
||||
@@ -226,7 +236,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
asks: buildrun.Asker{Publisher: asks, Waiter: waiter, Bound: stepBound}, tools: tools, trunks: trunks,
|
||||
kept: kvStore{buckets[delivery.CheckerKeptBucket]}, previews: kvStore{buckets[delivery.PreviewsBucket]}, candidates: tryCandidates{buckets[delivery.CandidatesBucket]},
|
||||
repos: repos, published: published, builds: builds, ends: ends, buildWait: set.buildWait, forge: forgeReads{call: caller.Call}, logf: logger.Printf,
|
||||
announce: announce, compositions: compositions,
|
||||
announce: announce, compositions: compositions, inputs: runInputs{awaitedKV{inputsKV}}, beginBound: beginBound,
|
||||
nodes: openNodeNames(ctx, js, logger.Printf),
|
||||
predefined: keptPredefined{process.AwaitBucket(ctx, js, conn, delivery.IndexerModule+"_"+delivery.IndexerPredefinedState,
|
||||
"a change's manifests are judged with no predefined keys, each fallback as its value declares it", logger.Printf)},
|
||||
|
||||
@@ -29,3 +29,7 @@ replace git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun
|
||||
require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000
|
||||
|
||||
replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk
|
||||
|
||||
require git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/seatfiles v0.0.0-00010101000000-000000000000
|
||||
|
||||
replace git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/seatfiles => ../mesh-delivery-indexer/seatfiles
|
||||
|
||||
@@ -2,7 +2,8 @@ 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
|
||||
@@ -13,30 +14,20 @@ import (
|
||||
// tell, so the check does not refuse a program it did not look for.
|
||||
const NoProgramsReported = "the build module did not report programs: a compiled program in the change is not refused"
|
||||
|
||||
// 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 build module that reports no programs leaves a
|
||||
// warning on the check and passes it.
|
||||
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 build module that reports no programs leaves a warning on
|
||||
// the check and passes it.
|
||||
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
|
||||
}
|
||||
if t.fetched.Programs == nil {
|
||||
st.c.note(fmt.Sprintf("%s: %s (%s)", key, NoProgramsReported, t.module))
|
||||
st.c.warn(key, fmt.Sprintf("a compiled program is not refused: %s reported no programs", t.module))
|
||||
st.j.logf("%s: %s (%s)", key, NoProgramsReported, t.module)
|
||||
return nil
|
||||
return
|
||||
}
|
||||
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)")
|
||||
}
|
||||
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: t.fetched.Programs}})
|
||||
}
|
||||
|
||||
@@ -123,19 +123,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 *delivery.Verdict) []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, []delivery.FailedFirst) {
|
||||
wait, words := st.buildsOfTheCheck(ctx)
|
||||
if len(wait) == 0 {
|
||||
return words
|
||||
return words, nil
|
||||
}
|
||||
modules := slices.Sorted(maps.Keys(wait))
|
||||
if v.State == delivery.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)
|
||||
@@ -146,25 +147,21 @@ func (st *state) awaitBuilds(ctx context.Context, v *delivery.Verdict) []string
|
||||
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 builds []delivery.FailedFirst
|
||||
for _, module := range modules {
|
||||
b, done := ended[module]
|
||||
switch {
|
||||
case !done:
|
||||
late = append(late, module)
|
||||
case b.State == delivery.BuildFailed:
|
||||
f := delivery.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 != delivery.VerdictFail {
|
||||
v.State, v.Failed = delivery.VerdictFail, f
|
||||
continue
|
||||
}
|
||||
addFailure(v, f)
|
||||
builds = append(builds, delivery.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, builds
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
@@ -39,6 +40,9 @@ 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 heads' seat list the checking program made, which the manifest validator's and the node-engine
|
||||
// composer's requests carry (ADR 0636 §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
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"mesh-delivery-checker/rules"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -449,7 +450,7 @@ func (ch *Checker) untrunked(ctx context.Context, c delivery.Candidate, left []P
|
||||
continue
|
||||
}
|
||||
o := delivery.Owed{Act: delivery.OwedStatus, Context: delivery.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 := delivery.EncodeCheckerOwed(o)
|
||||
if err != nil {
|
||||
ch.logf("%s: the status of %s cannot be written: %v", key, okey, err)
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// withComposition is v with the check's composition taken in: a failed one fails the check, naming its machine.
|
||||
func withComposition(v *delivery.Verdict, comp delivery.CompositionVerdict) {
|
||||
v.Composition = comp.Composition()
|
||||
v.Took.Composition = comp.Took
|
||||
if comp.State == delivery.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 == delivery.CompositionFail {
|
||||
machine := comp.Machine
|
||||
if machine == "" {
|
||||
machine = "the composition"
|
||||
}
|
||||
f := delivery.FailedFirst{Machine: machine, Words: comp.Words}
|
||||
if v.State == delivery.VerdictFail {
|
||||
addFailure(v, f) // the tests failed too: the composition is named after them (the operator, 2026-11-02)
|
||||
return
|
||||
}
|
||||
v.State, v.Failed = delivery.VerdictFail, f
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"maps"
|
||||
"mesh-delivery-checker/rules"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -14,28 +15,43 @@ import (
|
||||
|
||||
// fakeCompositions answers a check's composition from memory, recording the keys asked.
|
||||
type fakeCompositions struct {
|
||||
v delivery.CompositionVerdict
|
||||
v rules.Composed
|
||||
err error
|
||||
asked []string
|
||||
// waits answers only when its context ends.
|
||||
waits bool
|
||||
}
|
||||
|
||||
func (f *fakeCompositions) Compose(ctx context.Context, c *Case) (delivery.CompositionVerdict, error) {
|
||||
func (f *fakeCompositions) Compose(ctx context.Context, c *Case) (rules.Composed, error) {
|
||||
f.asked = append(f.asked, c.Key)
|
||||
if f.waits {
|
||||
<-ctx.Done()
|
||||
return delivery.CompositionVerdict{}, 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]delivery.ComposeCandidateAnswer{}}
|
||||
for _, n := range nodes {
|
||||
c.Answers[n] = delivery.ComposeCandidateAnswer{Node: n, Verdict: delivery.ComposeCandidateComposed}
|
||||
}
|
||||
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]delivery.ComposeCandidateAnswer{node: {Node: node, Verdict: delivery.ComposeCandidateRefused, 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: delivery.CompositionVerdict{Group: "feat/x", Heads: "0123456789ab", State: delivery.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"
|
||||
@@ -67,8 +83,7 @@ func compositionMentions(v delivery.Verdict, word string) bool {
|
||||
// 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: delivery.CompositionVerdict{Group: "feat/x", Heads: "0123456789ab", State: delivery.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 != delivery.VerdictFail || v.Failed.Machine != "g14" || v.Composition == nil || v.Composition.State != delivery.CompositionFail {
|
||||
t.Fatalf("verdict = %+v", v)
|
||||
@@ -89,7 +104,7 @@ func TestACompositionNotHadIsNotRun(t *testing.T) {
|
||||
t.Fatalf("verdict = %+v", v)
|
||||
}
|
||||
b2 := newBench(t)
|
||||
f2 := &fakeCompositions{v: delivery.CompositionVerdict{State: delivery.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{}
|
||||
@@ -212,7 +227,7 @@ func aChangeToABuildModuleOrBaseNamesTheModulesBuiltOnIt(t *testing.T) {
|
||||
return r, nil
|
||||
}
|
||||
}
|
||||
comps := &fakeCompositions{v: delivery.CompositionVerdict{Group: "feat/x", Heads: "0123456789ab", State: delivery.CompositionPass, SDK: "x", Machines: []string{"g14"}}}
|
||||
comps := &fakeCompositions{v: composedOn("x", 0, "g14")}
|
||||
b.ex.Compositions = comps
|
||||
var ends *fakeEnds
|
||||
if row.builds != nil {
|
||||
@@ -265,3 +280,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"
|
||||
@@ -68,17 +69,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) (delivery.CompositionVerdict, 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 := delivery.CompositionKey(c.Group, c.HeadsHash)
|
||||
if err != nil {
|
||||
return delivery.CompositionVerdict{}, err
|
||||
return rules.Composed{}, err
|
||||
}
|
||||
var nodes []string
|
||||
if e.Placed != nil {
|
||||
if nodes, err = e.Placed(); err != nil {
|
||||
return delivery.CompositionVerdict{}, fmt.Errorf("the placement cannot be read, so no node can be asked: %w", err)
|
||||
return rules.Composed{}, fmt.Errorf("the placement cannot be read, so no node can be asked: %w", err)
|
||||
}
|
||||
}
|
||||
bound := e.Bound
|
||||
@@ -129,7 +131,7 @@ func (e EngineCompositions) Compose(ctx context.Context, c *Case) (delivery.Comp
|
||||
} else if e.Composer != nil && e.Composer() != "" {
|
||||
composerName = e.Composer()
|
||||
}
|
||||
v := JudgeEngines(c.Group, c.HeadsHash, 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")
|
||||
}
|
||||
@@ -137,57 +139,6 @@ func (e EngineCompositions) Compose(ctx context.Context, c *Case) (delivery.Comp
|
||||
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 delivery.ComposeNoAnswer as a warning. composer names what composed, for the line. Pure.
|
||||
func JudgeEngines(group, heads, composerName string, nodes []string, answers map[string]delivery.ComposeCandidateAnswer, silent map[string]string) delivery.CompositionVerdict {
|
||||
v := delivery.CompositionVerdict{Group: group, Heads: heads, State: delivery.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+": "+delivery.ComposeNoAnswer)
|
||||
continue
|
||||
}
|
||||
if a.Verdict == delivery.ComposeCandidateNotJudged {
|
||||
// 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+": "+delivery.ComposeNoAnswer+" ("+a.Refused+")")
|
||||
continue
|
||||
}
|
||||
v.Machines = append(v.Machines, node)
|
||||
if a.Verdict != delivery.ComposeCandidateRefused {
|
||||
continue
|
||||
}
|
||||
if v.Machine == "" {
|
||||
v.Machine = node
|
||||
}
|
||||
var words []string
|
||||
if a.Refused != "" {
|
||||
words = append(words, a.Refused)
|
||||
}
|
||||
for _, m := range append(append([]delivery.ModuleComposed(nil), a.Modules...), a.Own) {
|
||||
if m.State == delivery.ModuleRefusedState {
|
||||
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 = delivery.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.
|
||||
@@ -220,7 +171,7 @@ func (e EngineCompositions) inSandbox(ctx context.Context, c *Case, head, node s
|
||||
}
|
||||
}
|
||||
req := composer.Request{V: composer.Version, Input: a.Input, Depth: composer.DepthCompose, Machines: []string{node},
|
||||
Cases: []composer.Case{{Name: "change", Manifests: manifests, Verdicts: verdicts}}}
|
||||
Cases: []composer.Case{{Name: "change", Manifests: manifests, Verdicts: verdicts}}, Seats: c.Seats}
|
||||
raw, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return a, err
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"mesh-delivery-checker/rules"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -80,15 +81,17 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
|
||||
}
|
||||
cs := caseOf(t, pullOf(repoA, 7, head1))
|
||||
key, _ := delivery.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) delivery.CompositionVerdict {
|
||||
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)
|
||||
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) {
|
||||
@@ -106,7 +109,7 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
|
||||
t.Errorf("asked %+v, want the check's candidate %s and no input", e.asked, key)
|
||||
}
|
||||
var ver delivery.Verdict
|
||||
withComposition(&ver, v)
|
||||
rules.JudgeComposition(&ver, rules.Composition{Composed: &last})
|
||||
if ver.State != delivery.VerdictFail || ver.Failed.Machine != "laptop" {
|
||||
t.Errorf("the check's verdict = %+v", ver)
|
||||
}
|
||||
@@ -131,7 +134,7 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
|
||||
}
|
||||
var ver delivery.Verdict
|
||||
ver.State = delivery.VerdictPass
|
||||
withComposition(&ver, v)
|
||||
rules.JudgeComposition(&ver, rules.Composition{Composed: &last})
|
||||
if ver.State != delivery.VerdictPass || !strings.Contains(composition(ver.Composition, 0, false), "laptop: "+delivery.ComposeNoAnswer) {
|
||||
t.Errorf("the line = %q", composition(ver.Composition, 0, false))
|
||||
}
|
||||
@@ -143,6 +146,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 = json.RawMessage(`{"nodes":[{"name":"anchor"}]}`)
|
||||
e := &fakeEngines{answers: map[string]delivery.ComposeCandidateAnswer{"anchor": withInput}}
|
||||
@@ -152,7 +156,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)
|
||||
raw, err := ec.Compose(context.Background(), host)
|
||||
v := rules.JudgeEngines(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -163,7 +168,8 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
|
||||
var req composer.Request
|
||||
_ = json.Unmarshal(job.Request, &req)
|
||||
if job.Fetch["repository"] != nodeEngineRepo || job.Fetch["commit"] != head2 || job.Package != composer.Package ||
|
||||
string(req.Input) != string(withInput.Input) || req.Cases[0].Manifests["web"].Digest == "" {
|
||||
string(req.Input) != string(withInput.Input) || req.Cases[0].Manifests["web"].Digest == "" ||
|
||||
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.
|
||||
@@ -186,7 +192,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)
|
||||
raw, err = ec.Compose(context.Background(), host)
|
||||
v = rules.JudgeEngines(raw)
|
||||
named := len(v.Warnings) == 1 && strings.Contains(v.Warnings[0], "anchor: "+delivery.ComposeNoAnswer+" (its node-engine sends no running state for web)")
|
||||
if err != nil || (v.State == delivery.CompositionPass) != row.pass || named != row.pass {
|
||||
t.Errorf("%+v: verdict %+v, %v", row.m, v, err)
|
||||
@@ -195,7 +202,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)
|
||||
raw, err = ec.Compose(context.Background(), host)
|
||||
v = rules.JudgeEngines(raw)
|
||||
if err != nil || v.State != delivery.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)
|
||||
|
||||
@@ -3,6 +3,7 @@ package check
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mesh-delivery-checker/rules"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -21,9 +22,17 @@ func failingResult(t *testing.T, tests int, failed ...Failure) json.RawMessage {
|
||||
|
||||
func judgeLevel(t *testing.T, tests ...json.RawMessage) (*delivery.Verdict, *state) {
|
||||
t.Helper()
|
||||
st := &state{j: &Examiner{}, c: &Case{}, levels: map[string]*acc{delivery.LevelUnit: {}, delivery.LevelIntegration: {}}, nodes: map[string]bool{}}
|
||||
v, _ := st.level(t.Context(), &target{pull: Pull{Repository: "novox/x"}}, delivery.LevelAnswer{Level: delivery.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"}}, delivery.LevelAnswer{Level: delivery.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[delivery.LevelIntegration] != (delivery.LevelResult{Run: st.levels[delivery.LevelIntegration].Run}) {
|
||||
t.Errorf("the rules count %+v, the running checker %+v", judged.Levels, *st.levels[delivery.LevelIntegration])
|
||||
}
|
||||
if judged.State == delivery.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
|
||||
@@ -48,7 +57,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[delivery.LevelIntegration].run; run != 10 {
|
||||
if run := st.levels[delivery.LevelIntegration].Run; run != 10 {
|
||||
t.Errorf("%s: %d tests counted, want all 10", name, run)
|
||||
}
|
||||
if _, err := delivery.EncodeVerdict(delivery.Verdict{Group: "g", Heads: "0123456789ab", State: v.State, Failed: v.Failed, Failures: v.Failures}); err != nil {
|
||||
@@ -58,7 +67,7 @@ func TestTwoFailingTestsInTwoPackagesAreTwoEntries(t *testing.T) {
|
||||
}
|
||||
|
||||
// Issue 1921: past delivery.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 {
|
||||
@@ -68,10 +77,10 @@ func TestFailuresAreCappedWithHowManyMore(t *testing.T) {
|
||||
if len(v.Failures) != delivery.MaxFailures+1 || v.Failures[delivery.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"}}}
|
||||
@@ -80,8 +89,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, delivery.CompositionVerdict{State: delivery.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"
|
||||
|
||||
@@ -125,6 +129,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 delivery.StepAsk) (delivery.Step, bool)
|
||||
mu sync.Mutex
|
||||
runs []delivery.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(delivery.Step)
|
||||
}
|
||||
|
||||
func (b *bench) asker(inner buildrun.AskPublisher, write func(delivery.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 := delivery.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 delivery.StepAsk) delivery.Step {
|
||||
began := time.Date(2026, 10, 21, 19, 0, 0, 0, time.UTC)
|
||||
step := delivery.Step{ID: a.ID, Tool: a.Kind + " ask", Node: "shanks", State: delivery.StepPassed, Started: began, Ended: began.Add(time.Second)}
|
||||
if b.run != nil {
|
||||
if s, ok := b.run(a); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
answer := delivery.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.
|
||||
@@ -183,7 +250,7 @@ func (b *bench) answer(a delivery.StepAsk) delivery.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")
|
||||
@@ -207,8 +274,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
|
||||
}
|
||||
|
||||
@@ -299,6 +366,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: delivery.Verdict{State: state, Failed: delivery.FailedFirst{Words: words}}})
|
||||
return out
|
||||
}
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
repo string // the member's repository: mesh-delivery unless named
|
||||
run func(a delivery.StepAsk, s delivery.Step) (delivery.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(delivery.StepAsk, delivery.Step) (delivery.Step, bool) { return delivery.Step{}, true },
|
||||
fails: "no build node began the job run of the asks", runs: 2},
|
||||
{name: "does not build", run: func(_ delivery.StepAsk, s delivery.Step) (delivery.Step, bool) {
|
||||
s.State, s.Reason = delivery.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 delivery.StepAsk, s delivery.Step) (delivery.Step, bool) {
|
||||
if a.Run.Args[0] != "-verdict" {
|
||||
return s, false
|
||||
}
|
||||
s.Result, _ = json.Marshal(delivery.RunAnswer{Key: "program-0123456789abcdef", Output: verdictOf(delivery.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 delivery.StepAsk) (delivery.Step, bool) {
|
||||
return row.run(a, delivery.Step{ID: a.ID, Tool: "check ask", Node: "shanks", State: delivery.StepPassed})
|
||||
}
|
||||
}
|
||||
repo := cmp.Or(row.repo, repoA)
|
||||
v := b.judge(t, caseOf(t, pullOf(repo, 7, head1))).Verdict
|
||||
if row.fails != "" {
|
||||
if v.State != delivery.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 delivery.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 != delivery.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.
|
||||
@@ -445,8 +587,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: delivery.CompositionVerdict{Group: "feat/x", Heads: "0123456789ab", State: delivery.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[delivery.LevelUnit] = Digested{Level: "unit", Packages: map[string]string{"m/internal/a": digA, "m/internal/b": digB}}
|
||||
@@ -480,7 +621,7 @@ func TestEveryFailureOfEveryMemberLevelAndTheCompositionIsNamed(t *testing.T) {
|
||||
got = append(got, firstFailed(delivery.Verdict{Failed: f}))
|
||||
}
|
||||
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...)
|
||||
}
|
||||
@@ -550,6 +691,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
|
||||
@@ -920,7 +1065,7 @@ func TestAnAskWhoseStepNeverEndsEndsTheCheckLoudly(t *testing.T) {
|
||||
b.asks.Answer = func(a delivery.StepAsk) delivery.Step {
|
||||
return delivery.Step{ID: a.ID, Tool: "check ask", Node: "shanks", State: delivery.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 != delivery.VerdictFail || !strings.Contains(f.Words, "on shanks did not end within") {
|
||||
t.Fatalf("verdict = %+v", out.Verdict)
|
||||
@@ -956,7 +1101,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) {
|
||||
@@ -1044,8 +1189,12 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) {
|
||||
// try judges the same change as a whole group try, whose manifests come from the indexer's candidate of the try's
|
||||
// group (issue 1921): the same judge, the same verdicts.
|
||||
try bool
|
||||
// seats 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).
|
||||
seats bool
|
||||
}{
|
||||
{name: "accepted"},
|
||||
{name: "the heads' seat list is carried by the head's judge and the trunk's", seats: 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"},
|
||||
@@ -1066,6 +1215,9 @@ 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.seats {
|
||||
b.heads = fstest.MapFS{"novox/mesh-contracts/contracts/seats/node-x/seat.json": {Data: []byte(`{"format":1,"seat":"node-x","scope":"node"}`)}}
|
||||
}
|
||||
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"}}`)
|
||||
@@ -1171,6 +1323,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.seats ||
|
||||
(row.seats && !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) ||
|
||||
|
||||
@@ -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:])
|
||||
}
|
||||
_ = delivery.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:])
|
||||
}
|
||||
_ = delivery.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 != delivery.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 != delivery.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-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/composer"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"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) *delivery.Verdict {
|
||||
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,115 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/composer"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
||||
"mesh-delivery-checker/rules"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"mesh-delivery-checker/rules"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
@@ -47,11 +47,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 = *delivery.Verdict
|
||||
|
||||
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 {
|
||||
@@ -119,7 +114,7 @@ type Examiner struct {
|
||||
// Manifests are the indexer's manifest digests by module: the catalogue a change's manifests are judged in by the
|
||||
// indexer's judge (manifests_judge.go, novox/hq ADR 0578 §2); nil judges no manifest.
|
||||
Manifests TrunkManifests
|
||||
// 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
|
||||
@@ -143,6 +138,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)
|
||||
}
|
||||
@@ -153,9 +152,10 @@ type TryCandidates interface {
|
||||
TryCandidate(ctx context.Context, group, heads string, bound time.Duration) (delivery.Candidate, error)
|
||||
}
|
||||
|
||||
// CompositionReader makes a check's composition verdict; an error is only a case that cannot be composed at all.
|
||||
// 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) (delivery.CompositionVerdict, error)
|
||||
Compose(ctx context.Context, c *Case) (rules.Composed, error)
|
||||
}
|
||||
|
||||
func (j *Examiner) now() time.Time {
|
||||
@@ -228,24 +228,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
|
||||
@@ -305,35 +299,43 @@ 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{delivery.LevelUnit: {}, delivery.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)
|
||||
if v := settled(joinFailures(tests, st.judgeManifests(ctx), st.refuseCycle(ctx))); v != nil {
|
||||
return st.finish(ctx, *v), nil
|
||||
// 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 *delivery.JudgedBy
|
||||
if st.seatList(ctx) {
|
||||
st.judgeManifests(ctx)
|
||||
st.gatherGraph(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, delivery.Verdict{State: delivery.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 delivery.Verdict) 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 *delivery.Verdict) {
|
||||
// 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
|
||||
}
|
||||
@@ -341,7 +343,7 @@ func (st *state) composed(ctx context.Context, v *delivery.Verdict) {
|
||||
return
|
||||
}
|
||||
if st.noCandidate != "" {
|
||||
v.Composition = &delivery.Composition{State: delivery.CompositionNotRun, Words: st.noCandidate}
|
||||
st.composition = &rules.Composition{NotRun: st.noCandidate}
|
||||
return
|
||||
}
|
||||
if st.j.Compositions == nil {
|
||||
@@ -349,61 +351,29 @@ func (st *state) composed(ctx context.Context, v *delivery.Verdict) {
|
||||
}
|
||||
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 = &delivery.Composition{State: delivery.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
|
||||
// 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 == delivery.VerdictFail
|
||||
words, builds := st.awaitBuilds(ctx, failed)
|
||||
c := &rules.Composition{Waits: words, BuildsFailed: builds, FPSet: st.c.FPSet}
|
||||
st.composition = c
|
||||
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 = &delivery.Composition{State: delivery.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 != delivery.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 delivery.Verdict) Outcome {
|
||||
if v.State == delivery.VerdictPass || len(v.Levels) == 0 {
|
||||
v.Levels = map[string]delivery.LevelResult{}
|
||||
for level, a := range st.levels {
|
||||
v.Levels[level] = delivery.LevelResult{Run: a.run, Kept: a.kept, Skipped: a.skipped}
|
||||
}
|
||||
}
|
||||
ms := func(s float64) int64 { return int64(s * 1000) }
|
||||
u, i := st.levels[delivery.LevelUnit], st.levels[delivery.LevelIntegration]
|
||||
v.Took = delivery.Took{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{delivery.LevelUnit, delivery.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 = &delivery.Composition{State: delivery.CompositionNotRun}
|
||||
var slow []string
|
||||
for _, level := range []string{delivery.LevelUnit, delivery.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}
|
||||
if len(st.beside) > 0 {
|
||||
out.Beside = st.beside
|
||||
@@ -419,10 +389,18 @@ func (st *state) outcome(v delivery.Verdict) Outcome {
|
||||
return out
|
||||
}
|
||||
|
||||
// fail is a verdict of failure at a machine.
|
||||
func fail(machine, words string) *delivery.Verdict {
|
||||
v := failure(machine, words)
|
||||
return &v
|
||||
// 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: &delivery.FailedFirst{Machine: machine, Words: words}})
|
||||
}
|
||||
|
||||
// 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()
|
||||
return f
|
||||
}
|
||||
|
||||
// levels are the levels a case's asks run: both, or the one a try names.
|
||||
@@ -437,7 +415,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) *delivery.Verdict {
|
||||
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)
|
||||
@@ -451,11 +429,11 @@ func (st *state) judgeAll(ctx context.Context) *delivery.Verdict {
|
||||
return 0
|
||||
})
|
||||
var groupPackage map[string]any
|
||||
var failures []*delivery.Verdict
|
||||
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(delivery.CheckerModule, ctx.Err().Error()))
|
||||
if !st.failedSince(met) { // an ask that ended with the context said it already
|
||||
st.fail(delivery.CheckerModule, ctx.Err().Error())
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -474,7 +452,7 @@ func (st *state) judgeAll(ctx context.Context) *delivery.Verdict {
|
||||
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 {
|
||||
@@ -488,9 +466,7 @@ func (st *state) judgeAll(ctx context.Context) *delivery.Verdict {
|
||||
}
|
||||
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).
|
||||
@@ -506,7 +482,16 @@ func (st *state) judgeAll(ctx context.Context) *delivery.Verdict {
|
||||
groupPackage = map[string]any{"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
|
||||
@@ -517,14 +502,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) *delivery.Verdict {
|
||||
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) *delivery.Verdict {
|
||||
func (st *state) askNeeding(ctx context.Context, t *target, merged *[]Fetched, levels, needs, packages []string) {
|
||||
p := t.pull
|
||||
placed := len(needs) > 0
|
||||
job := &delivery.CheckJob{Fetch: t.fetchArgs, Levels: levels, Kept: true, Packages: packages, Toolchain: t.agreed}
|
||||
@@ -534,7 +519,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
|
||||
}
|
||||
@@ -563,7 +549,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 {
|
||||
@@ -575,9 +562,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 := delivery.DeclinedOf(step)
|
||||
if !declined {
|
||||
@@ -598,36 +587,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 []*delivery.Verdict
|
||||
// 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 != delivery.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 delivery.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 err := json.Unmarshal(answer.Fetch, &t.fetched); err != nil || t.fetched.Workspace == "" {
|
||||
return fail(step.Node, fmt.Sprintf("the fetch of %s#%d answered no workspace: %s", p.Repository, p.Number, answer.Fetch))
|
||||
st.fail(step.Node, fmt.Sprintf("the fetch of %s#%d answered no workspace: %s", p.Repository, p.Number, answer.Fetch))
|
||||
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) {
|
||||
@@ -635,25 +628,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 := delivery.UnmetNeeds(answer.Levels); len(unmet) > 0 && !placed {
|
||||
@@ -673,9 +661,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.
|
||||
@@ -702,63 +689,31 @@ func unmetOf(levels []delivery.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 delivery.LevelAnswer) (*delivery.Verdict, 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 delivery.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
|
||||
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)
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -780,258 +735,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 delivery.MaxFailures and an entry
|
||||
// saying how many more (issue 1921).
|
||||
func levelFailure(t *target, level string, results ...Tested) *delivery.Verdict {
|
||||
v := delivery.Verdict{State: delivery.VerdictFail}
|
||||
var all []delivery.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, delivery.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, delivery.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, delivery.FailedFirst{Test: level + ": " + t.pull.Repository, Words: "the " + level + " level did not pass"})
|
||||
}
|
||||
v.Failed = all[0]
|
||||
v.Failures = 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 ...*delivery.Verdict) *delivery.Verdict {
|
||||
var out *delivery.Verdict
|
||||
var all []delivery.FailedFirst
|
||||
listed := false
|
||||
for _, v := range vs {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
if out == nil {
|
||||
out = &delivery.Verdict{State: delivery.VerdictFail, Failed: v.Failed}
|
||||
}
|
||||
if len(v.Failures) > 0 {
|
||||
all, listed = append(all, v.Failures...), true
|
||||
continue
|
||||
}
|
||||
all = append(all, v.Failed)
|
||||
}
|
||||
if out != nil && (listed || len(all) > 1) {
|
||||
out.Failures = all
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// settled is a joined verdict as it is written: each failure of its list cut to its first line, at most
|
||||
// delivery.MaxFailures and then how many more (failureLines).
|
||||
func settled(v *delivery.Verdict) *delivery.Verdict {
|
||||
if v != nil && len(v.Failures) > 0 {
|
||||
v.Failures = failureLines(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 delivery.MaxFailures the last entry counts one more.
|
||||
func addFailure(v *delivery.Verdict, f delivery.FailedFirst) {
|
||||
if len(v.Failures) == 0 {
|
||||
v.Failures = failureLines([]delivery.FailedFirst{v.Failed})
|
||||
}
|
||||
if n := len(v.Failures); n > delivery.MaxFailures {
|
||||
v.Failures[n-1].Test = fmt.Sprintf("and %d more", failedCount(*v)-delivery.MaxFailures+1)
|
||||
return
|
||||
}
|
||||
v.Failures = append(v.Failures, failureLines([]delivery.FailedFirst{f})...)
|
||||
}
|
||||
|
||||
// failureLines is every failure with the first line of its words, cut to MaxFailureLine, at most delivery.MaxFailures
|
||||
// and then one entry saying how many more failed.
|
||||
func failureLines(all []delivery.FailedFirst) []delivery.FailedFirst {
|
||||
n := min(len(all), delivery.MaxFailures)
|
||||
out := make([]delivery.FailedFirst, 0, n+1)
|
||||
for _, f := range all[:n] {
|
||||
out = append(out, delivery.FailedFirst{Test: f.Test, Machine: f.Machine, Words: clipLine(firstLine(f.Words), MaxFailureLine)})
|
||||
}
|
||||
if more := len(all) - n; more > 0 {
|
||||
out = append(out, delivery.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"
|
||||
@@ -70,7 +71,7 @@ func failedLines(v delivery.Verdict) []string {
|
||||
// are cut to fit room characters when room is not negative; the count and "and N more" are never cut.
|
||||
func failedHead(v delivery.Verdict, room int) string {
|
||||
first := firstFailed(v)
|
||||
n := failedCount(v)
|
||||
n := rules.FailedCount(v)
|
||||
if n <= 1 {
|
||||
return clipRunes("failed: "+first, room)
|
||||
}
|
||||
@@ -93,20 +94,8 @@ func clipRunes(s string, n int) string {
|
||||
return string(r[:n-1]) + "…"
|
||||
}
|
||||
|
||||
// failedCount is how many failures a verdict names, the ones after delivery.MaxFailures counted too.
|
||||
func failedCount(v delivery.Verdict) int {
|
||||
n := len(v.Failures)
|
||||
if n > delivery.MaxFailures {
|
||||
var more int
|
||||
if _, err := fmt.Sscanf(v.Failures[n-1].Test, "and %d more", &more); err == nil {
|
||||
return delivery.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 {
|
||||
|
||||
@@ -66,100 +66,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[delivery.LevelUnit] = delivery.LevelResult{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 = delivery.Took{}
|
||||
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 := delivery.Verdict{State: delivery.VerdictFail, Failed: delivery.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(delivery.Verdict{State: delivery.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 = &delivery.Composition{State: delivery.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 = &delivery.Composition{State: delivery.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 = delivery.VerdictFail, delivery.FailedFirst{Machine: "novox", Words: "composition refused"}
|
||||
v.Composition = &delivery.Composition{State: delivery.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[delivery.LevelUnit] = delivery.LevelResult{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 = delivery.Took{}
|
||||
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 := delivery.Verdict{State: delivery.VerdictFail, Failed: delivery.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(delivery.Verdict{State: delivery.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 = &delivery.Composition{State: delivery.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 = &delivery.Composition{State: delivery.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 = delivery.VerdictFail, delivery.FailedFirst{Machine: "novox", Words: "composition refused"}
|
||||
v.Composition = &delivery.Composition{State: delivery.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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,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
|
||||
}
|
||||
|
||||
@@ -282,7 +282,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]delivery.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)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"mesh-delivery-checker/rules"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
@@ -31,7 +32,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
|
||||
@@ -93,16 +94,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) *delivery.Verdict {
|
||||
// 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 == "" {
|
||||
@@ -112,7 +114,8 @@ func (st *state) judgeManifests(ctx context.Context) *delivery.Verdict {
|
||||
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.
|
||||
@@ -120,67 +123,26 @@ func (st *state) judgeManifests(ctx context.Context) *delivery.Verdict {
|
||||
req.Predefined = list
|
||||
st.c.note("the manifests are judged against the predefined keys of " + from)
|
||||
}
|
||||
cv, trunkCase, v := st.runJudge(ctx, req, touched, commit, at, st.packagesOf(DeliveryRepository))
|
||||
if v != nil {
|
||||
return v
|
||||
// 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
|
||||
}
|
||||
if v := st.refuseUnmovableIndexer(ctx, req, touched, cv, at); 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
|
||||
}
|
||||
// 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 delivery.PackageVersions) (change, trunk composer.CaseVerdict, v *delivery.Verdict) {
|
||||
// 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 delivery.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 := delivery.ComposeJob{Fetch: withPackages(map[string]any{"repository": DeliveryRepository, "commit": commit}, versions), Dir: JudgeDir,
|
||||
Package: JudgePackage, Request: raw, Reader: "the indexer's judge at " + at}
|
||||
@@ -189,48 +151,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) *delivery.Verdict {
|
||||
// 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
|
||||
@@ -241,32 +203,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 *delivery.Verdict
|
||||
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.
|
||||
|
||||
@@ -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,229 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
"mesh-delivery-checker/rules"
|
||||
)
|
||||
|
||||
// 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
|
||||
// (delivery.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 (delivery.RunInput.Read), never reading the state (ADR 0596
|
||||
// §2; the operator's answer on milestone 47/B0).
|
||||
InputsStateName = "inputs"
|
||||
InputsBucket = delivery.CheckerModule + "_" + InputsStateName
|
||||
InputVerb = "seat:" + catalogue.CheckerSeat + "." + delivery.CheckerInputVerb
|
||||
// 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() (delivery.RunProgram, string, bool) {
|
||||
p := delivery.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() []delivery.RunHead {
|
||||
out := make([]delivery.RunHead, 0, len(st.c.Pulls))
|
||||
for _, p := range st.c.Pulls {
|
||||
out = append(out, delivery.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, *delivery.JudgedBy, 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 := delivery.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 := delivery.RunJob{Program: prog, Heads: st.runHeads(), Input: in, Args: args, Reader: "the checking program at " + at}
|
||||
var ids []string
|
||||
for range 2 {
|
||||
a := delivery.StepAsk{ID: buildstep.NewID(), Kind: delivery.AskCheck, Language: delivery.AskGo, Asker: delivery.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 delivery.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 != delivery.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 delivery.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, &delivery.JudgedBy{Program: prog, Key: answer.Key, Built: answer.Built, Heads: 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 delivery.StepAsk) (delivery.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 delivery.Step
|
||||
err error
|
||||
}
|
||||
done := make(chan ended, 1)
|
||||
go func() {
|
||||
step, err := st.j.Asks.Wait(buildrun.WithStepSeen(wait, func(delivery.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 delivery.Step{}, true, ctx.Err()
|
||||
}
|
||||
return delivery.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
|
||||
}
|
||||
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 = a.Seats
|
||||
st.c.note(fmt.Sprintf("the heads' seat list holds %d seat definitions", len(a.Seats)))
|
||||
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
|
||||
}
|
||||
|
||||
// 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, *delivery.JudgedBy) {
|
||||
body, err := json.Marshal(st.factsOf())
|
||||
var by *delivery.JudgedBy
|
||||
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
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
@@ -20,6 +22,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"
|
||||
@@ -36,6 +39,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
|
||||
@@ -63,7 +67,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"`
|
||||
}
|
||||
@@ -88,6 +95,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.HeadsHash != v.Heads && GateHeads(c) != v.Heads) {
|
||||
out.Note = "the candidate no longer holds these heads"
|
||||
@@ -257,6 +265,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 (delivery.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) (delivery.RunInputAnswer, error) {
|
||||
if r.InputState.Get == nil {
|
||||
return delivery.RunInputAnswer{}, fmt.Errorf("the state %s cannot be read here", InputsStateName)
|
||||
}
|
||||
if len(digest) != 64 || strings.Trim(digest, "0123456789abcdef") != "" {
|
||||
return delivery.RunInputAnswer{}, fmt.Errorf("%q is no sha256 digest of 64 lowercase hex digits", digest)
|
||||
}
|
||||
raw, err := r.InputState.Get(digest)
|
||||
if err != nil {
|
||||
return delivery.RunInputAnswer{}, fmt.Errorf("the input %s cannot be read: %w", digest, err)
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return delivery.RunInputAnswer{}, 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 delivery.RunInputAnswer{}, fmt.Errorf("the input kept under %s reads back as %d bytes of another digest", digest, len(raw))
|
||||
}
|
||||
return delivery.RunInputAnswer{Digest: digest, Input: 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"`
|
||||
@@ -714,3 +781,20 @@ func (r Reader) buildsNotEnded(c delivery.Candidate) 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 *delivery.JudgedBy) 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, ", "))
|
||||
}
|
||||
|
||||
@@ -64,15 +64,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, _ := delivery.EncodeVerdict(delivery.Verdict{Group: "feat/x", Heads: "0123456789ab", State: delivery.VerdictFail,
|
||||
Failed: delivery.FailedFirst{Test: "unit: m/a.TestA", Words: "a_test.go:3: boom\nmore"},
|
||||
Failures: []delivery.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: []delivery.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: &delivery.JudgedBy{Program: delivery.RunProgram{Repository: repoA, Commit: head1, Dir: CheckingProgramDir, Package: CheckingProgramPackage},
|
||||
Key: "program-0123456789abcdef", Built: true, Heads: []delivery.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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +122,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, _ := delivery.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 := in.Read(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.(delivery.RunInputAsk).Digest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}); 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.
|
||||
|
||||
@@ -4,8 +4,8 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"mesh-delivery-checker/rules"
|
||||
)
|
||||
|
||||
// The shapes of what the build modules' steps answer, as their code writes them (mesh-catalog modules/mesh-build-go
|
||||
@@ -52,65 +52,16 @@ type Fetched struct {
|
||||
Programs *[]ProgramFile `json:"programs,omitempty"`
|
||||
}
|
||||
|
||||
// ProgramFile is a changed file that is a compiled program, and its format (ELF, Mach-O or PE).
|
||||
type ProgramFile struct {
|
||||
Path string `json:"path"`
|
||||
Kind string `json:"kind"`
|
||||
// Commit is set for a program a commit of the branch added and a later one removed: it stays in history once merged.
|
||||
Commit string `json:"commit,omitempty"`
|
||||
}
|
||||
// ProgramFile is a changed file that is a compiled program (rules.ProgramFile).
|
||||
type ProgramFile = rules.ProgramFile
|
||||
|
||||
// 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"`
|
||||
}
|
||||
// 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.
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mesh-delivery-checker/rules"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -167,7 +168,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 delivery.Candidate) (*fakeCompositions, *fakeTryCandidates) {
|
||||
comps := &fakeCompositions{v: delivery.CompositionVerdict{State: delivery.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
|
||||
@@ -253,7 +254,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 != delivery.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 != delivery.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)
|
||||
@@ -366,21 +368,21 @@ func TestAFailingTryNamesWhatFailedAndAStepThatCannotRunIsAnError(t *testing.T)
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
cand *fakeTryCandidates
|
||||
comp delivery.CompositionVerdict
|
||||
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: delivery.CompositionVerdict{State: delivery.CompositionPass, SDK: "x", Warnings: []string{"g14: " + delivery.ComposeNoAnswer}},
|
||||
{name: "a node that gave no composition answer", cand: &fakeTryCandidates{}, comp: &rules.Composed{Composer: "x", Nodes: []string{"g14"}},
|
||||
state: TryError, said: "g14: " + delivery.ComposeNoAnswer},
|
||||
{name: "a composition refused on a node", cand: &fakeTryCandidates{}, comp: delivery.CompositionVerdict{State: delivery.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, delivery.Candidate{})
|
||||
b.ex.Candidates, comps.v = row.cand, row.comp
|
||||
if row.comp.State == "" {
|
||||
comps.v.State = delivery.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
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
type state struct {
|
||||
Name string `json:"name"`
|
||||
History int `json:"history,omitempty"`
|
||||
TTL int `json:"ttl-seconds,omitempty"`
|
||||
}
|
||||
|
||||
type artifact struct {
|
||||
@@ -101,7 +102,7 @@ func TestManifestDeclarations(t *testing.T) { // 1678/AC1
|
||||
t.Errorf("module = %q", m.Module)
|
||||
}
|
||||
// delivery.CheckerKeptHistory, CheckerOwedHistory, PreviewsHistory, CheckerVerdictsHistory; the names follow the SDK's buckets.
|
||||
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}}; !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}}; !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",
|
||||
@@ -163,13 +164,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_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_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"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -23,6 +24,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 },
|
||||
@@ -52,6 +54,7 @@
|
||||
],
|
||||
"tools": [
|
||||
"checker_ask_again",
|
||||
"checker_inputs",
|
||||
"checker_kept",
|
||||
"checker_previews",
|
||||
"checker_pull",
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
// 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/modules/mesh-delivery-builder/buildrun"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// 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 (delivery.Verdict'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"`
|
||||
// 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 (delivery.Took.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 *delivery.LevelAnswer `json:"level,omitempty"`
|
||||
Failed *delivery.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"`
|
||||
}
|
||||
|
||||
// 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 (delivery.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 delivery.LevelAnswer) *delivery.LevelAnswer {
|
||||
out := &delivery.LevelAnswer{Level: la.Level}
|
||||
kept := 0
|
||||
for _, k := range la.Kept {
|
||||
kept += k.Tests
|
||||
}
|
||||
if len(la.Kept) > 0 {
|
||||
out.Kept = []delivery.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{delivery.LevelUnit: {}, delivery.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 delivery.LevelAnswer) (*delivery.Verdict, 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) delivery.Verdict { 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 []*delivery.Verdict
|
||||
nodes := map[string]bool{}
|
||||
for _, s := range f.Steps {
|
||||
if s.Node != "" {
|
||||
nodes[s.Node] = true
|
||||
}
|
||||
switch {
|
||||
case s.Failed != nil:
|
||||
failures = append(failures, &delivery.Verdict{State: delivery.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, &delivery.Verdict{State: delivery.VerdictFail, Failed: *ff})
|
||||
}
|
||||
case s.Graph != nil:
|
||||
ff, said := JudgeGraph(*s.Graph)
|
||||
notes = append(notes, said...)
|
||||
if ff != nil {
|
||||
failures = append(failures, &delivery.Verdict{State: delivery.VerdictFail, Failed: *ff})
|
||||
}
|
||||
case s.Manifests != nil:
|
||||
ff, said := JudgeManifests(*s.Manifests)
|
||||
notes = append(notes, said...)
|
||||
if ff != nil {
|
||||
failures = append(failures, &delivery.Verdict{State: delivery.VerdictFail, Failed: *ff})
|
||||
}
|
||||
}
|
||||
}
|
||||
v := delivery.Verdict{State: delivery.VerdictPass}
|
||||
if joined := Settled(JoinFailures(failures...)); joined != nil {
|
||||
v = *joined
|
||||
}
|
||||
v.Group, v.Heads, v.Trunk = f.Group, f.Heads, f.Trunk
|
||||
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 *delivery.Verdict, tally Tally, cloneMS int64, nodes map[string]bool) {
|
||||
if v.State == delivery.VerdictPass || len(v.Levels) == 0 {
|
||||
v.Levels = map[string]delivery.LevelResult{}
|
||||
for level, a := range tally {
|
||||
v.Levels[level] = delivery.LevelResult{Run: a.Run, Kept: a.Kept, Skipped: a.Skipped}
|
||||
}
|
||||
}
|
||||
ms := func(s float64) int64 { return int64(s * 1000) }
|
||||
u, i := tally[delivery.LevelUnit], tally[delivery.LevelIntegration]
|
||||
v.Took = delivery.Took{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{delivery.LevelUnit, delivery.LevelIntegration} {
|
||||
if tally[level].Over {
|
||||
v.OverBound = append(v.OverBound, level)
|
||||
}
|
||||
}
|
||||
v.Composition = &delivery.Composition{State: delivery.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{delivery.LevelUnit, delivery.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) *delivery.Verdict {
|
||||
return &delivery.Verdict{State: delivery.VerdictFail, Failed: delivery.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 delivery.Verdict `json:"verdict"`
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// 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) (*delivery.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 &delivery.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,277 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
"git.novox.be/novox/mesh-sdk/go/composer"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// 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.
|
||||
type ProgramFile struct {
|
||||
Path string `json:"path"`
|
||||
Kind string `json:"kind"`
|
||||
Commit string `json:"commit,omitempty"`
|
||||
}
|
||||
|
||||
// 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 *delivery.Composition `json:"kept,omitempty"`
|
||||
NotRun string `json:"not-run,omitempty"`
|
||||
BuildsFailed []delivery.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]delivery.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) (*delivery.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 &delivery.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 &delivery.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) *delivery.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 &delivery.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 delivery.ComposeNoAnswer as a warning.
|
||||
func JudgeEngines(c Composed) delivery.CompositionVerdict {
|
||||
v := delivery.CompositionVerdict{Group: c.Group, Heads: c.Heads, State: delivery.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+": "+delivery.ComposeNoAnswer)
|
||||
continue
|
||||
}
|
||||
if a.Verdict == delivery.ComposeCandidateNotJudged {
|
||||
// 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+": "+delivery.ComposeNoAnswer+" ("+a.Refused+")")
|
||||
continue
|
||||
}
|
||||
v.Machines = append(v.Machines, node)
|
||||
if a.Verdict != delivery.ComposeCandidateRefused {
|
||||
continue
|
||||
}
|
||||
if v.Machine == "" {
|
||||
v.Machine = node
|
||||
}
|
||||
var words []string
|
||||
if a.Refused != "" {
|
||||
words = append(words, a.Refused)
|
||||
}
|
||||
for _, m := range append(append([]delivery.ModuleComposed(nil), a.Modules...), a.Own) {
|
||||
if m.State == delivery.ModuleRefusedState {
|
||||
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 = delivery.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 *delivery.Verdict, c Composition) {
|
||||
if c.Kept != nil {
|
||||
kept := *c.Kept
|
||||
v.Composition = &kept
|
||||
return
|
||||
}
|
||||
for _, f := range c.BuildsFailed {
|
||||
if v.State != delivery.VerdictFail {
|
||||
v.State, v.Failed = delivery.VerdictFail, f
|
||||
continue
|
||||
}
|
||||
AddFailure(v, f)
|
||||
}
|
||||
if c.NotRun != "" {
|
||||
v.Composition = &delivery.Composition{State: delivery.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 = comp.Composition()
|
||||
v.Took.Composition = comp.Took
|
||||
if comp.State == delivery.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 == delivery.CompositionFail {
|
||||
machine := comp.Machine
|
||||
if machine == "" {
|
||||
machine = "the composition"
|
||||
}
|
||||
f := delivery.FailedFirst{Machine: machine, Words: comp.Words}
|
||||
if v.State == delivery.VerdictFail {
|
||||
AddFailure(v, f) // the tests failed too: the composition is named after them (the operator, 2026-11-02)
|
||||
} else {
|
||||
v.State, v.Failed = delivery.VerdictFail, f
|
||||
}
|
||||
}
|
||||
if v.Composition != nil && v.Composition.State != delivery.CompositionNotRun {
|
||||
v.Composition.FPSet = c.FPSet // a carry keeps it while the fingerprints stay the same (ADR 0548 §5)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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")
|
||||
}
|
||||
answer, err := SeatsOf(heads)
|
||||
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,59 @@
|
||||
package rules
|
||||
|
||||
import "git.novox.be/novox/mesh-sdk/go/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"`
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"maps"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/seatfiles"
|
||||
)
|
||||
|
||||
// The seat list a check makes from its heads (novox/hq ADR 0636 §4, ADR 0644 §1; the lead's ruling Q2 on milestone
|
||||
// 47/B0): the checking program built from the heads reads the seat definitions they carry and answers them resolved, so
|
||||
// the running checker hands the manifest validator and the composer bytes it does not read itself. A definition is read
|
||||
// by the indexer's one reading of the format (package seatfiles); this file finds the definitions and says why one is
|
||||
// left out.
|
||||
|
||||
// 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 (the verbs and
|
||||
// events of the parts it includes put first, as the indexer keeps it), as the requests' field `seats` carries them; 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 hold: its file and why.
|
||||
type LeftOut struct {
|
||||
Path string `json:"path"`
|
||||
Why string `json:"why"`
|
||||
}
|
||||
|
||||
// SeatsOf reads the seat definitions in heads, a directory holding each head at its repository's path: a definition is a
|
||||
// contracts module's `seats/<seat>/seat.json`, with the parts it includes from the `parts/<part>.json` beside that
|
||||
// `seats` directory (design 26, *A seat's definition*). A file that is not a definition, a part it names that is not
|
||||
// there, and a seat defined in more than one place are left out, said; heads with no definition give an empty list.
|
||||
func SeatsOf(heads fs.FS) (SeatsAnswer, error) {
|
||||
out := SeatsAnswer{V: SeatsVersion, Seats: []json.RawMessage{}}
|
||||
var files []string
|
||||
err := fs.WalkDir(heads, ".", func(p string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() && (d.Name() == ".git" || d.Name() == "testdata") {
|
||||
return fs.SkipDir
|
||||
}
|
||||
if !d.IsDir() && d.Name() == "seat.json" && path.Base(path.Dir(path.Dir(p))) == "seats" {
|
||||
files = append(files, p)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return SeatsAnswer{}, fmt.Errorf("rules: the heads cannot be read: %w", err)
|
||||
}
|
||||
type found struct {
|
||||
path string
|
||||
raw json.RawMessage
|
||||
}
|
||||
bySeat := map[string][]found{}
|
||||
for _, p := range files {
|
||||
seat, raw, err := resolvedDefinition(heads, p)
|
||||
if err != nil {
|
||||
out.Left = append(out.Left, LeftOut{Path: p, Why: err.Error()})
|
||||
continue
|
||||
}
|
||||
bySeat[seat] = append(bySeat[seat], found{p, raw})
|
||||
}
|
||||
for _, seat := range slices.Sorted(maps.Keys(bySeat)) {
|
||||
fs := bySeat[seat]
|
||||
if len(fs) > 1 {
|
||||
var paths []string
|
||||
for _, f := range fs {
|
||||
paths = append(paths, f.path)
|
||||
}
|
||||
for _, f := range fs {
|
||||
out.Left = append(out.Left, LeftOut{Path: f.path, Why: "the seat " + seat + " is defined in more than one place: " + strings.Join(paths, ", ")})
|
||||
}
|
||||
continue
|
||||
}
|
||||
out.Seats = append(out.Seats, fs[0].raw)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// resolvedDefinition is the definition at p resolved with the parts it includes from the `parts` directory beside its
|
||||
// `seats` directory, as the indexer's one reading of the format resolves it (seatfiles.ResolveRaw), every field kept as
|
||||
// written.
|
||||
func resolvedDefinition(heads fs.FS, p string) (string, json.RawMessage, error) {
|
||||
raw, err := fs.ReadFile(heads, p)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
partsDir := path.Join(path.Dir(path.Dir(path.Dir(p))), "parts")
|
||||
d, out, err := seatfiles.ResolveRaw(raw, func(name string) ([]byte, error) {
|
||||
praw, err := fs.ReadFile(heads, path.Join(partsDir, name+".json"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("it is not at %s", partsDir)
|
||||
}
|
||||
return praw, nil
|
||||
})
|
||||
return d.Seat, out, err
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// 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 delivery.MaxFailures and an entry
|
||||
// saying how many more (issue 1921).
|
||||
func LevelFailure(repository, module, level string, results ...Tested) *delivery.Verdict {
|
||||
v := delivery.Verdict{State: delivery.VerdictFail}
|
||||
var all []delivery.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, delivery.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, delivery.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, delivery.FailedFirst{Test: level + ": " + repository, Words: "the " + level + " level did not pass"})
|
||||
}
|
||||
v.Failed = all[0]
|
||||
v.Failures = 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 ...*delivery.Verdict) *delivery.Verdict {
|
||||
var out *delivery.Verdict
|
||||
var all []delivery.FailedFirst
|
||||
listed := false
|
||||
for _, v := range vs {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
if out == nil {
|
||||
out = &delivery.Verdict{State: delivery.VerdictFail, Failed: v.Failed}
|
||||
}
|
||||
if len(v.Failures) > 0 {
|
||||
all, listed = append(all, v.Failures...), true
|
||||
continue
|
||||
}
|
||||
all = append(all, v.Failed)
|
||||
}
|
||||
if out != nil && (listed || len(all) > 1) {
|
||||
out.Failures = all
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Settled is a joined verdict as it is written: each failure of its list cut to its first line, at most
|
||||
// delivery.MaxFailures and then how many more (FailureLines).
|
||||
func Settled(v *delivery.Verdict) *delivery.Verdict {
|
||||
if v != nil && len(v.Failures) > 0 {
|
||||
v.Failures = FailureLines(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 delivery.MaxFailures the last entry counts one more.
|
||||
func AddFailure(v *delivery.Verdict, f delivery.FailedFirst) {
|
||||
if len(v.Failures) == 0 {
|
||||
v.Failures = FailureLines([]delivery.FailedFirst{v.Failed})
|
||||
}
|
||||
if n := len(v.Failures); n > delivery.MaxFailures {
|
||||
v.Failures[n-1].Test = fmt.Sprintf("and %d more", FailedCount(*v)-delivery.MaxFailures+1)
|
||||
return
|
||||
}
|
||||
v.Failures = append(v.Failures, FailureLines([]delivery.FailedFirst{f})...)
|
||||
}
|
||||
|
||||
// FailureLines is every failure with the first line of its words, cut to MaxFailureLine, at most delivery.MaxFailures
|
||||
// and then one entry saying how many more failed.
|
||||
func FailureLines(all []delivery.FailedFirst) []delivery.FailedFirst {
|
||||
n := min(len(all), delivery.MaxFailures)
|
||||
out := make([]delivery.FailedFirst, 0, n+1)
|
||||
for _, f := range all[:n] {
|
||||
out = append(out, delivery.FailedFirst{Test: f.Test, Machine: f.Machine, Words: ClipLine(FirstLine(f.Words), MaxFailureLine)})
|
||||
}
|
||||
if more := len(all) - n; more > 0 {
|
||||
out = append(out, delivery.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 delivery.MaxFailures counted too.
|
||||
func FailedCount(v delivery.Verdict) int {
|
||||
n := len(v.Failures)
|
||||
if n > delivery.MaxFailures {
|
||||
var more int
|
||||
if _, err := fmt.Sscanf(v.Failures[n-1].Test, "and %d more", &more); err == nil {
|
||||
return delivery.MaxFailures + more
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -26,6 +26,7 @@ replace git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/seatfiles v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/takein v0.0.0-00010101000000-000000000000
|
||||
git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000
|
||||
)
|
||||
@@ -34,4 +35,6 @@ replace git.novox.be/novox/mesh-delivery/sdk => ../../sdk
|
||||
|
||||
replace git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/takein => ./takein
|
||||
|
||||
replace git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/seatfiles => ./seatfiles
|
||||
|
||||
replace git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg => ../mesh-delivery-forge-bridge/forgemsg
|
||||
|
||||
@@ -18,9 +18,8 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/seatfiles"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
|
||||
"mesh-delivery-indexer/judge"
|
||||
)
|
||||
|
||||
// definitionsDir is the directory of a contracts module's definitions that the module.json at dir names, from the first
|
||||
@@ -58,7 +57,7 @@ func (ix *Indexer) revisionSeats(ctx context.Context, ready []PendingEntry) ([]c
|
||||
if ix.ReadFile == nil || ix.Files == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var defs []judge.Definition
|
||||
var defs []seatfiles.Definition
|
||||
for _, e := range ready {
|
||||
if e.Covered || e.Forge.Merge == "" {
|
||||
continue
|
||||
@@ -75,7 +74,7 @@ func (ix *Indexer) revisionSeats(ctx context.Context, ready []PendingEntry) ([]c
|
||||
defs = append(defs, got...)
|
||||
}
|
||||
}
|
||||
seats, err := judge.Seats(defs)
|
||||
seats, err := seatfiles.Seats(defs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -84,7 +83,7 @@ func (ix *Indexer) revisionSeats(ctx context.Context, ready []PendingEntry) ([]c
|
||||
|
||||
// readDefinitions reads the definitions under a contracts module's definitions directory at a commit, resolved with their
|
||||
// parts.
|
||||
func (ix *Indexer) readDefinitions(ctx context.Context, repository, commit, defsDir string) ([]judge.Definition, error) {
|
||||
func (ix *Indexer) readDefinitions(ctx context.Context, repository, commit, defsDir string) ([]seatfiles.Definition, error) {
|
||||
file := func(p string) ([]byte, error) {
|
||||
if defsDir != "." && defsDir != "" {
|
||||
p = defsDir + "/" + p
|
||||
@@ -103,8 +102,8 @@ func (ix *Indexer) readDefinitions(ctx context.Context, repository, commit, defs
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing %s of %s at %.12s: %w", dir, repository, commit, err)
|
||||
}
|
||||
parts := map[string]judge.Part{}
|
||||
var out []judge.Definition
|
||||
parts := map[string]seatfiles.Part{}
|
||||
var out []seatfiles.Definition
|
||||
for _, p := range slices.Sorted(maps.Keys(listed[dir])) {
|
||||
seat, ok := strings.CutSuffix(p, "/seat.json")
|
||||
if !ok || seat == "" || strings.Contains(seat, "/") {
|
||||
@@ -114,7 +113,7 @@ func (ix *Indexer) readDefinitions(ctx context.Context, repository, commit, defs
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d, err := judge.ParseDefinition(raw)
|
||||
d, err := seatfiles.ParseDefinition(raw)
|
||||
if err == nil && d.Seat != seat {
|
||||
err = fmt.Errorf("it names the seat %q, not %q", d.Seat, seat)
|
||||
}
|
||||
@@ -129,11 +128,11 @@ func (ix *Indexer) readDefinitions(ctx context.Context, repository, commit, defs
|
||||
if perr != nil {
|
||||
return nil, perr
|
||||
}
|
||||
var part judge.Part
|
||||
var part seatfiles.Part
|
||||
if praw == nil {
|
||||
perr = errors.New("the commit holds no such part")
|
||||
} else {
|
||||
part, perr = judge.ParsePart(praw)
|
||||
part, perr = seatfiles.ParsePart(praw)
|
||||
}
|
||||
if perr != nil {
|
||||
err = fmt.Errorf("its part %s: %w", name, perr)
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/seatfiles"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/composer"
|
||||
)
|
||||
@@ -212,7 +213,7 @@ func Request(r composer.Request) (composer.Verdict, error) { return RequestWith(
|
||||
// the members' heads' definitions, so a manifest that serves or invokes what the group's definitions add is judged by
|
||||
// them. None is Request.
|
||||
func RequestWith(r composer.Request, seats [][]byte) (composer.Verdict, error) {
|
||||
over, err := SeatsOfFiles(seats)
|
||||
over, err := seatfiles.SeatsOfFiles(seats)
|
||||
if err != nil {
|
||||
return composer.Verdict{}, fmt.Errorf("a seat definition of the request: %w", err)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/seatfiles"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/composer"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
@@ -155,8 +156,8 @@ func TestTheJudge(t *testing.T) {
|
||||
t.Fatalf("%s: the seats are not put back", row.name)
|
||||
}
|
||||
}
|
||||
parts := map[string]Part{"reads": {Verbs: []DefVerb{{Name: "manifest-reads"}}}}
|
||||
d, _ := ParseDefinition(defs[0])
|
||||
parts := map[string]seatfiles.Part{"reads": {Verbs: []seatfiles.DefVerb{{Name: "manifest-reads"}}}}
|
||||
d, _ := seatfiles.ParseDefinition(defs[0])
|
||||
if d, err := d.Resolve(parts); err != nil || len(d.Verbs) != 2 || d.Verbs[0].Name != "manifest-reads" {
|
||||
t.Errorf("a resolved definition: %+v %v", d, err)
|
||||
}
|
||||
|
||||
@@ -4848,7 +4848,7 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Join(checkerVerbs, ",") != "try,try-read,try-end" {
|
||||
if strings.Join(checkerVerbs, ",") != "try,try-read,try-end,input" { // input: a checking program's input (issue 1921)
|
||||
t.Errorf("the checker seat serves %v", checkerVerbs)
|
||||
}
|
||||
// The four health seats (novox/hq ADR 0564 clause 1): at mesh scope, each held once, each naming ADR 0564, with the
|
||||
|
||||
@@ -1,137 +1,15 @@
|
||||
package judge
|
||||
|
||||
// The seat definitions a revision carries (novox/hq ADR 0633 §1, §6; ADR 0635, ADR 0636 §4): a manifest is judged
|
||||
// against the seats of the revision it belongs to, never against the list this program was built with. A definition is
|
||||
// the contracts module's `seats/<seat>/seat.json` (design 26 *A seat's definition*), with the parts it includes from
|
||||
// `parts/<part>.json`. This reader takes of it only what a manifest is judged by (the seat's scope, what it delivers,
|
||||
// its verbs and events) and reads the rest leniently: the format's one strict reader (mesh-sdk seatdef) is not in the
|
||||
// mesh-sdk this judge is built with, and the indexer of the revision keeps the definitions whole.
|
||||
// The seat list a judge pass judges with: the seats a request or a revision carries (read by the indexer's one reading,
|
||||
// package seatfiles) put over the catalogue's list for that pass.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// Definition is the part of one seat's definition a manifest is judged by.
|
||||
type Definition struct {
|
||||
Format int `json:"format"`
|
||||
Seat string `json:"seat"`
|
||||
Scope string `json:"scope"`
|
||||
Decision string `json:"decision"`
|
||||
HolderDeclares bool `json:"holder-declares"`
|
||||
Delivers string `json:"delivers"`
|
||||
Replicated bool `json:"replicated"`
|
||||
PulledByKind bool `json:"pulled-by-kind"`
|
||||
Yields bool `json:"yields"`
|
||||
HeldByTheEngine bool `json:"held-by-the-engine"`
|
||||
Includes []string `json:"includes"`
|
||||
Verbs []DefVerb `json:"verbs"`
|
||||
Accepts []DefName `json:"accepts"`
|
||||
Events []DefName `json:"events"`
|
||||
}
|
||||
|
||||
// DefVerb is a verb of a definition.
|
||||
type DefVerb struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Optional bool `json:"optional"`
|
||||
Replaces []string `json:"replaces"`
|
||||
}
|
||||
|
||||
// DefName is an accepted message or an event of a definition.
|
||||
type DefName struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// Part is a part a definition includes: verbs and events written once for the seats that share them.
|
||||
type Part struct {
|
||||
Part string `json:"part"`
|
||||
Verbs []DefVerb `json:"verbs"`
|
||||
Events []DefName `json:"events"`
|
||||
}
|
||||
|
||||
// ParseDefinition reads one definition; a file that is not one names a seat and a scope.
|
||||
func ParseDefinition(raw []byte) (Definition, error) {
|
||||
var d Definition
|
||||
if err := json.Unmarshal(raw, &d); err != nil {
|
||||
return Definition{}, fmt.Errorf("a seat definition is not JSON: %w", err)
|
||||
}
|
||||
if d.Seat == "" || (d.Scope != catalogue.ScopeMesh && d.Scope != catalogue.ScopeNode) {
|
||||
return Definition{}, fmt.Errorf("a seat definition names its seat and its scope (mesh or node), not %q and %q", d.Seat, d.Scope)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// ParsePart reads one part.
|
||||
func ParsePart(raw []byte) (Part, error) {
|
||||
var p Part
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
return Part{}, fmt.Errorf("a part of a seat definition is not JSON: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Resolve is the definition with the verbs and events of the parts it includes put first, as the indexer keeps it.
|
||||
func (d Definition) Resolve(parts map[string]Part) (Definition, error) {
|
||||
out := d
|
||||
out.Includes, out.Verbs, out.Events = nil, nil, nil
|
||||
for _, name := range d.Includes {
|
||||
p, ok := parts[name]
|
||||
if !ok {
|
||||
return Definition{}, fmt.Errorf("the seat %s includes the part %q, which is not given", d.Seat, name)
|
||||
}
|
||||
out.Verbs = append(out.Verbs, p.Verbs...)
|
||||
out.Events = append(out.Events, p.Events...)
|
||||
}
|
||||
out.Verbs = append(out.Verbs, d.Verbs...)
|
||||
out.Events = append(out.Events, d.Events...)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Seats are the seats of resolved definitions, in the catalogue's types; a seat its holder declares itself is left out.
|
||||
// A definition still naming parts it includes is refused: resolve it first.
|
||||
func Seats(defs []Definition) ([]catalogue.Seat, error) {
|
||||
var out []catalogue.Seat
|
||||
for _, d := range defs {
|
||||
if d.HolderDeclares {
|
||||
continue
|
||||
}
|
||||
if len(d.Includes) > 0 {
|
||||
return nil, fmt.Errorf("the seat %s names parts it includes: resolve it first", d.Seat)
|
||||
}
|
||||
s := catalogue.Seat{Name: d.Seat, Scope: d.Scope, Delivers: d.Delivers, Replicated: d.Replicated,
|
||||
PulledByKind: d.PulledByKind, Yields: d.Yields, HeldByTheEngine: d.HeldByTheEngine, Decision: d.Decision}
|
||||
for _, a := range d.Accepts {
|
||||
s.Accepts = append(s.Accepts, a.Name)
|
||||
}
|
||||
for _, e := range d.Events {
|
||||
s.Emits = append(s.Emits, e.Name)
|
||||
}
|
||||
for _, v := range d.Verbs {
|
||||
s.Serves = append(s.Serves, catalogue.Verb{Name: v.Name, Description: v.Description, Optional: v.Optional, Replaces: v.Replaces})
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SeatsOfFiles are the seats of resolved definitions given as files, as a request carries them.
|
||||
func SeatsOfFiles(raws [][]byte) ([]catalogue.Seat, error) {
|
||||
defs := make([]Definition, 0, len(raws))
|
||||
for _, raw := range raws {
|
||||
d, err := ParseDefinition(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defs = append(defs, d)
|
||||
}
|
||||
return Seats(defs)
|
||||
}
|
||||
|
||||
// merged is base with each seat of over put in place of the seat of its name, or added: a definition is a seat's whole
|
||||
// contract. A verb the base seat holds keeps its input and output (a definition read here carries none).
|
||||
func merged(base, over []catalogue.Seat) []catalogue.Seat {
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
module git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/seatfiles
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104002338-5a38a1221659
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104002338-5a38a1221659 h1:tK7MzxVm/ECNg1Yblhh1ynVEthbZCg2mSPW0wQZC0b0=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104002338-5a38a1221659/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
@@ -0,0 +1,181 @@
|
||||
// Package seatfiles is the indexer's one reading of the seat definitions a revision carries (novox/hq ADR 0633 §1, §6;
|
||||
// ADR 0635, ADR 0636 §4): a manifest is judged against the seats of the revision it belongs to, never against the list
|
||||
// the judge was built with. A definition is the contracts module's `seats/<seat>/seat.json` (design 26 *A seat's
|
||||
// definition*), with the parts it includes from `parts/<part>.json`. This reader takes of it only what a manifest is
|
||||
// judged by (the seat's scope, what it delivers, its verbs and events) and reads the rest leniently: the format's one
|
||||
// strict reader (mesh-sdk seatdef) is not in the mesh-sdk the judge is built with, and the indexer of the revision keeps
|
||||
// the definitions whole.
|
||||
//
|
||||
// It is the indexer's own package, a module of its own so that the checker's rules can require it (the lead's ruling on
|
||||
// milestone 47/B0, ADR 0634 §1: shared code lives with its owner): the indexer's judge and the checker's seat list read
|
||||
// definitions here and nowhere else. Milestone G's domain SDK reading replaces it.
|
||||
package seatfiles
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// Definition is the part of one seat's definition a manifest is judged by.
|
||||
type Definition struct {
|
||||
Format int `json:"format"`
|
||||
Seat string `json:"seat"`
|
||||
Scope string `json:"scope"`
|
||||
Decision string `json:"decision"`
|
||||
HolderDeclares bool `json:"holder-declares"`
|
||||
Delivers string `json:"delivers"`
|
||||
Replicated bool `json:"replicated"`
|
||||
PulledByKind bool `json:"pulled-by-kind"`
|
||||
Yields bool `json:"yields"`
|
||||
HeldByTheEngine bool `json:"held-by-the-engine"`
|
||||
Includes []string `json:"includes"`
|
||||
Verbs []DefVerb `json:"verbs"`
|
||||
Accepts []DefName `json:"accepts"`
|
||||
Events []DefName `json:"events"`
|
||||
}
|
||||
|
||||
// DefVerb is a verb of a definition.
|
||||
type DefVerb struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Optional bool `json:"optional"`
|
||||
Replaces []string `json:"replaces"`
|
||||
}
|
||||
|
||||
// DefName is an accepted message or an event of a definition.
|
||||
type DefName struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// Part is a part a definition includes: verbs and events written once for the seats that share them.
|
||||
type Part struct {
|
||||
Part string `json:"part"`
|
||||
Verbs []DefVerb `json:"verbs"`
|
||||
Events []DefName `json:"events"`
|
||||
}
|
||||
|
||||
// ParseDefinition reads one definition; a file that is not one names a seat and a scope.
|
||||
func ParseDefinition(raw []byte) (Definition, error) {
|
||||
var d Definition
|
||||
if err := json.Unmarshal(raw, &d); err != nil {
|
||||
return Definition{}, fmt.Errorf("a seat definition is not JSON: %w", err)
|
||||
}
|
||||
if d.Seat == "" || (d.Scope != catalogue.ScopeMesh && d.Scope != catalogue.ScopeNode) {
|
||||
return Definition{}, fmt.Errorf("a seat definition names its seat and its scope (mesh or node), not %q and %q", d.Seat, d.Scope)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// ParsePart reads one part.
|
||||
func ParsePart(raw []byte) (Part, error) {
|
||||
var p Part
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
return Part{}, fmt.Errorf("a part of a seat definition is not JSON: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// Resolve is the definition with the verbs and events of the parts it includes put first, as the indexer keeps it.
|
||||
func (d Definition) Resolve(parts map[string]Part) (Definition, error) {
|
||||
out := d
|
||||
out.Includes, out.Verbs, out.Events = nil, nil, nil
|
||||
for _, name := range d.Includes {
|
||||
p, ok := parts[name]
|
||||
if !ok {
|
||||
return Definition{}, fmt.Errorf("the seat %s includes the part %q, which is not given", d.Seat, name)
|
||||
}
|
||||
out.Verbs = append(out.Verbs, p.Verbs...)
|
||||
out.Events = append(out.Events, p.Events...)
|
||||
}
|
||||
out.Verbs = append(out.Verbs, d.Verbs...)
|
||||
out.Events = append(out.Events, d.Events...)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Seats are the seats of resolved definitions, in the catalogue's types; a seat its holder declares itself is left out.
|
||||
// A definition still naming parts it includes is refused: resolve it first.
|
||||
func Seats(defs []Definition) ([]catalogue.Seat, error) {
|
||||
var out []catalogue.Seat
|
||||
for _, d := range defs {
|
||||
if d.HolderDeclares {
|
||||
continue
|
||||
}
|
||||
if len(d.Includes) > 0 {
|
||||
return nil, fmt.Errorf("the seat %s names parts it includes: resolve it first", d.Seat)
|
||||
}
|
||||
s := catalogue.Seat{Name: d.Seat, Scope: d.Scope, Delivers: d.Delivers, Replicated: d.Replicated,
|
||||
PulledByKind: d.PulledByKind, Yields: d.Yields, HeldByTheEngine: d.HeldByTheEngine, Decision: d.Decision}
|
||||
for _, a := range d.Accepts {
|
||||
s.Accepts = append(s.Accepts, a.Name)
|
||||
}
|
||||
for _, e := range d.Events {
|
||||
s.Emits = append(s.Emits, e.Name)
|
||||
}
|
||||
for _, v := range d.Verbs {
|
||||
s.Serves = append(s.Serves, catalogue.Verb{Name: v.Name, Description: v.Description, Optional: v.Optional, Replaces: v.Replaces})
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// SeatsOfFiles are the seats of resolved definitions given as files, as a request carries them.
|
||||
func SeatsOfFiles(raws [][]byte) ([]catalogue.Seat, error) {
|
||||
defs := make([]Definition, 0, len(raws))
|
||||
for _, raw := range raws {
|
||||
d, err := ParseDefinition(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defs = append(defs, d)
|
||||
}
|
||||
return Seats(defs)
|
||||
}
|
||||
|
||||
// ResolveRaw is the definition raw resolved with the parts it includes, each read by part, every field it carries kept as
|
||||
// written: the verbs and events of its parts put first, then its own, and no includes. It reads raw and every part as
|
||||
// ParseDefinition and ParsePart do, and answers the definition read with it. It is how a request's `seats` carry a
|
||||
// definition whole (ADR 0636 §4).
|
||||
func ResolveRaw(raw []byte, part func(name string) ([]byte, error)) (Definition, json.RawMessage, error) {
|
||||
d, err := ParseDefinition(raw)
|
||||
if err != nil {
|
||||
return Definition{}, nil, err
|
||||
}
|
||||
var whole map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &whole); err != nil {
|
||||
return Definition{}, nil, err
|
||||
}
|
||||
type listed struct{ Verbs, Events []json.RawMessage }
|
||||
var verbs, events []json.RawMessage
|
||||
for _, name := range d.Includes {
|
||||
praw, err := part(name)
|
||||
if err != nil {
|
||||
return Definition{}, nil, fmt.Errorf("the seat %s includes the part %q: %w", d.Seat, name, err)
|
||||
}
|
||||
var l listed
|
||||
if _, err := ParsePart(praw); err != nil {
|
||||
return Definition{}, nil, fmt.Errorf("the part %q of the seat %s: %w", name, d.Seat, err)
|
||||
}
|
||||
_ = json.Unmarshal(praw, &l)
|
||||
verbs, events = append(verbs, l.Verbs...), append(events, l.Events...)
|
||||
}
|
||||
var own listed
|
||||
_ = json.Unmarshal(raw, &own)
|
||||
verbs, events = append(verbs, own.Verbs...), append(events, own.Events...)
|
||||
delete(whole, "includes")
|
||||
for k, list := range map[string][]json.RawMessage{"verbs": verbs, "events": events} {
|
||||
if len(list) == 0 {
|
||||
delete(whole, k)
|
||||
continue
|
||||
}
|
||||
b, err := json.Marshal(list)
|
||||
if err != nil {
|
||||
return Definition{}, nil, err
|
||||
}
|
||||
whole[k] = b
|
||||
}
|
||||
out, err := json.Marshal(whole)
|
||||
return d, out, err
|
||||
}
|
||||
Reference in New Issue
Block a user