Compare commits
79
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35b800aaef | ||
|
|
9c9ed7a1d8 | ||
|
|
f4ab7a2bc8 | ||
|
|
9290cdda4d | ||
|
|
b1d0c6881a | ||
|
|
06cb0cdd92 | ||
|
|
fc921c13d0 | ||
|
|
4dfb6af6ef | ||
|
|
625007cbc1 | ||
|
|
63bbabacbf | ||
|
|
043e3fb979 | ||
|
|
35da943fb5 | ||
|
|
1b9a2bc302 | ||
|
|
e96f8fd1b6 | ||
|
|
cd201a79f1 | ||
|
|
61aa338887 | ||
|
|
03bb56481a | ||
|
|
2d201f781c | ||
|
|
d58b717243 | ||
|
|
29c525f48d | ||
|
|
66cf82e7b6 | ||
|
|
205a3b3750 | ||
|
|
b1094a9265 | ||
|
|
4907470e90 | ||
|
|
55fb32ba25 | ||
|
|
cd01f1c196 | ||
|
|
261f8d54f7 | ||
|
|
643ec7cd91 | ||
|
|
c0e5bc2834 | ||
|
|
6f048a239c | ||
|
|
fab533dfd0 | ||
|
|
39285c95bd | ||
|
|
1b262125ee | ||
|
|
bfe4caf196 | ||
|
|
3742a7c54c | ||
|
|
f1e6520037 | ||
|
|
4ddf78395d | ||
|
|
8c67adaf91 | ||
|
|
e3735da928 | ||
|
|
78d57e1cc6 | ||
|
|
9f9e743b2f | ||
|
|
abe9ea3796 | ||
|
|
f9578cb7b4 | ||
|
|
69a2790099 | ||
|
|
88a47be251 | ||
|
|
bd5eb33e9e | ||
|
|
c2b8ac3370 | ||
|
|
92c38981ea | ||
|
|
45386212da | ||
|
|
b97e277d2d | ||
|
|
8823cc29d6 | ||
|
|
55936c1cd7 | ||
|
|
31ff708f88 | ||
|
|
2fcd61baa6 | ||
|
|
655791b8a3 | ||
|
|
3c832d1a2d | ||
|
|
0605962960 | ||
|
|
343321f11e | ||
|
|
4b7a31b2c7 | ||
|
|
4596fbe1b7 | ||
|
|
c78941e80b | ||
|
|
7b7096f37e | ||
|
|
4e4601a1eb | ||
|
|
a395b49be2 | ||
|
|
1040c8ec46 | ||
|
|
92dc5f03d0 | ||
|
|
ef13e63268 | ||
|
|
9c6e70e555 | ||
|
|
ad9a353484 | ||
|
|
a762701e3d | ||
|
|
d6b6f24e98 | ||
|
|
ed4f07b0d8 | ||
|
|
7609f2d9a2 | ||
|
|
d11a5af113 | ||
|
|
531d05ab4b | ||
|
|
071a28f218 | ||
|
|
72633722c5 | ||
|
|
0579e4e74d | ||
|
|
2cb3198049 |
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,15 +103,25 @@ func TryTools(tr Trying) []stdio.Tool {
|
||||
str := func(args map[string]any, k string) string { s, _ := args[k].(string); return s }
|
||||
return []stdio.Tool{
|
||||
{Name: check.TryTool,
|
||||
Description: "Try a pushed head before its pull request is opened: the checker judges it as it judges a pull request's head, merged onto its trunk's head, " +
|
||||
"its unit and integration levels through the build modules, and owes the forge nothing. Answers the try's id at once; " + check.TryReadTool + " reads " +
|
||||
"where it stands. A try of the same head that waits or runs is the answer to a second ask; a new try is refused while 4 tries run on a build node or 8 wait for one, mesh-wide; tries let in while waiting may later all run at once, and each node's build capacity bounds what runs on it. A repository " +
|
||||
"the checker checks no level of (one whose trunk the gate does not name) is refused. A try whose ask no build node took within the step " +
|
||||
"bound ends as error; its ask stays in the build seat's stream and may still run later, judged by nobody (issue 734).",
|
||||
Input: map[string]any{"type": "object", "required": []string{"repository", "head"}, "properties": map[string]any{
|
||||
"repository": map[string]any{"type": "string", "description": "<owner>/<name>"},
|
||||
"head": map[string]any{"type": "string", "description": "the full commit to try, 40 or 64 hex digits"},
|
||||
"trunk": map[string]any{"type": "string", "description": "the trunk commit to merge it onto, or the trunk's branch for its head now; the trunk's head when absent"},
|
||||
Description: "Try a delivery group, or one pushed head, before its pull requests are opened (try a group, group try, try several repositories): " +
|
||||
"name the members, each a repository and the exact commit asked (never the branch's head now, so a push after the ask changes nothing of what is tried), " +
|
||||
"or one repository and its head for a try of one member (the seat verb try of mesh-delivery-checker takes members from the registry milestone on; until it is delivered, ask a group through this tool). The checker judges it by the code a group's pull-request check runs, so the two cannot drift: " +
|
||||
"each member merged onto its trunk's head, the unit and the integration level of every member through the build modules, the manifest validator over " +
|
||||
"the members' manifests, the module graph, and the composition on every placed node. Every failure is named (each member, level, test, manifest and node), not " +
|
||||
"the first. A try owes the forge nothing (no status, no Approve asked) and writes no verdict of a pull request; what its passing levels prove is kept, so a pull request's " +
|
||||
"check of the same heads on the same trunk runs only what the try did not pass. A try narrowed to a level or to some tests judges only those, no manifest and no composition, and keeps nothing of tests. " +
|
||||
"Answers the try's id at once; " + check.TryReadTool + " reads where it stands, " + check.TryEndTool + " ends it by hand. A try of the same commits that waits or runs is the answer to a second ask " +
|
||||
"(whatever the order of the members); a new try is refused while 4 tries run on a build node or 8 wait for one, mesh-wide. A repository the checker checks no level of (one whose trunk the gate does not name) " +
|
||||
"is refused. A try whose ask no build node took within the step bound ends as error; its ask stays in the build seat's stream and may still run later, judged by nobody (issue 734).",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"members": map[string]any{"type": "array", "description": "the group: one repository at most once, each at the exact commit asked and the trunk commit it is merged onto",
|
||||
"items": map[string]any{"type": "object", "required": []string{"repository", "head"}, "properties": map[string]any{
|
||||
"repository": map[string]any{"type": "string", "description": "<owner>/<name>"},
|
||||
"head": map[string]any{"type": "string", "description": "the full commit to try, 40 or 64 hex digits"},
|
||||
"trunk": map[string]any{"type": "string", "description": "the trunk commit to merge it onto, or the trunk's branch for its head now; the trunk's head when absent"}}}},
|
||||
"repository": map[string]any{"type": "string", "description": "<owner>/<name>, for a try of one member (instead of members)"},
|
||||
"head": map[string]any{"type": "string", "description": "the full commit to try, 40 or 64 hex digits (with repository)"},
|
||||
"trunk": map[string]any{"type": "string", "description": "the trunk commit to merge it onto, or the trunk's branch for its head now; the trunk's head when absent (with repository)"},
|
||||
"tests": map[string]any{"type": "string", "description": "a go test -run pattern: only those tests run, and nothing is kept"},
|
||||
"level": map[string]any{"type": "string", "enum": []string{"unit", "integration"}, "description": "only this level; both when absent"}}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
@@ -126,16 +136,32 @@ func TryTools(tr Trying) []stdio.Tool {
|
||||
if tr.Now != nil {
|
||||
now = tr.Now
|
||||
}
|
||||
var members []check.TryAskMember
|
||||
if raw, ok := args["members"]; ok {
|
||||
list, isList := raw.([]any)
|
||||
if !isList {
|
||||
return nil, fmt.Errorf("members is a list of {repository, head, trunk}")
|
||||
}
|
||||
for _, item := range list {
|
||||
m, isMap := item.(map[string]any)
|
||||
if !isMap {
|
||||
return nil, fmt.Errorf("a member is {repository, head, trunk}")
|
||||
}
|
||||
members = append(members, check.TryAskMember{Repository: str(m, "repository"), Head: str(m, "head"), Trunk: str(m, "trunk")})
|
||||
}
|
||||
}
|
||||
id, err := check.AskTry(tr.Tries, trunks, check.TryAsk{Repository: str(args, "repository"), Head: str(args, "head"),
|
||||
Trunk: str(args, "trunk"), Tests: str(args, "tests"), Level: str(args, "level")}, now())
|
||||
Trunk: str(args, "trunk"), Tests: str(args, "tests"), Level: str(args, "level"), Members: members}, now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"id": id}, nil
|
||||
}},
|
||||
{Name: check.TryReadTool,
|
||||
Description: "Where a try stands, as stored: queued, running, or its end (pass, fail, or error when it could not run), with what was said of it, " +
|
||||
"the head and the trunk commit it was merged onto, the time it took and what failed first; while it runs, each ask it put as soon as it is put, " +
|
||||
Description: "Where a try (of a group or of one head) stands, read whole, as stored: queued, running, or its end (pass, fail, or error when it could not run, or " +
|
||||
"when its manifests and composition could not be judged), with what was said of it, every member's commit and the trunk commit it was merged onto, the time it took, " +
|
||||
"every failure one line each (members, levels, tests, manifests, the composition per node), the levels' tests run and kept, the composition on every placed node, " +
|
||||
"the notes of the check (manifests judged, module graph, kept verdicts); while it runs, each ask it put as soon as it is put, " +
|
||||
"with the node that took it, its state and the phase its step says (fetch and merge, a level's digest, lint, tests) and since when. Read-only.",
|
||||
Input: map[string]any{"type": "object", "required": []string{"id"}, "properties": map[string]any{
|
||||
"id": map[string]any{"type": "string", "description": "the try's id, try-<digits>"}}},
|
||||
@@ -146,7 +172,7 @@ func TryTools(tr Trying) []stdio.Tool {
|
||||
return check.ReadTry(tr.Tries, str(args, "id"))
|
||||
}},
|
||||
{Name: check.TryEndTool,
|
||||
Description: "End a try that waits or runs, as error, by hand: say why (kept in what it says). Its place among the 4 is free at once, " +
|
||||
Description: "End a try of a group (or of one head) that waits or runs, as error, by hand: say why (kept in what it says). Its place among the 4 is free at once, " +
|
||||
"the checker stops waiting for its asks, and the next " + check.TryTool + " of its head asks anew with a new id. For a try whose ask " +
|
||||
"was lost (no step runs for it in checker_status); the checker ends one by itself when the build seat gives its ask up, or no node " +
|
||||
"takes it within the step bound. A try that ended already is refused.",
|
||||
@@ -170,6 +196,24 @@ func TryTools(tr Trying) []stdio.Tool {
|
||||
}
|
||||
}
|
||||
|
||||
// OpenTriesTools is the tool that lists the tries that wait or run, each with its members at their commits, and whether it is
|
||||
// a whole try (the indexer writes the candidate of a whole try's group): the read a part uses at its start to find the tries
|
||||
// whose start event it missed, instead of reading the checker's state.
|
||||
func OpenTriesTools(tr Trying) []stdio.Tool {
|
||||
return []stdio.Tool{{Name: check.TriesTool,
|
||||
Description: "The tries that wait or run now (a try of a group or of one head; try.started and try.ended on the checker's stream say the same), each with its id, state, its members at their exact commits and " +
|
||||
"trunk commits, and whether it is a whole try (not narrowed to a level or to tests, so it is composed and its manifests judged). For a part that " +
|
||||
"missed the checker's try.started or try.ended event (the indexer at its start); checker_try_read reads one try whole. Read-only.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{}},
|
||||
Run: func(map[string]any) (any, error) {
|
||||
if tr.Tries == nil {
|
||||
return nil, fmt.Errorf("the tries state cannot be read here")
|
||||
}
|
||||
open, err := check.OpenTries(tr.Tries)
|
||||
return map[string]any{"tries": open}, err
|
||||
}}}
|
||||
}
|
||||
|
||||
// Seat is the seat the checker holds (novox/hq ADR 0547 §10, issue 774); its verbs are served as tools named <seat>.<verb>.
|
||||
const Seat = "mesh-delivery-checker"
|
||||
|
||||
@@ -185,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"}}},
|
||||
@@ -199,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"}}},
|
||||
@@ -220,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) " +
|
||||
@@ -281,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
|
||||
@@ -325,7 +394,8 @@ func main() {
|
||||
_, err := verdicts.Update(key, json.RawMessage(value), revision)
|
||||
return err
|
||||
}}
|
||||
tools := append(append(append(Tools(r), TryTools(trying)...), SeatTryTools(trying)...), RecoveryTools(r, written)...)
|
||||
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)
|
||||
|
||||
@@ -8,6 +8,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/tryevent"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"mesh-delivery-checker/internal/check"
|
||||
)
|
||||
|
||||
@@ -19,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)
|
||||
}
|
||||
@@ -106,6 +109,54 @@ 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) },
|
||||
Trunks: func() (map[string]string, error) {
|
||||
return map[string]string{"novox/mesh-delivery": strings.Repeat("a", 40)}, nil
|
||||
}})
|
||||
g, err := later[0].Run(map[string]any{"members": []any{map[string]any{"repository": "novox/mesh-delivery", "head": strings.Repeat("2", 40)}}})
|
||||
if err != nil || g.(map[string]any)["id"] == id {
|
||||
t.Errorf("a group try = %v, %v, want a try of its own", g, err)
|
||||
}
|
||||
if _, err := tools[0].Run(map[string]any{"members": []any{map[string]any{"repository": "novox/mesh-delivery", "head": strings.Repeat("2", 40)}},
|
||||
"repository": "novox/mesh-delivery", "head": strings.Repeat("1", 40)}); err == nil {
|
||||
t.Error("members and a repository were both taken")
|
||||
}
|
||||
if _, err := tools[0].Run(map[string]any{"members": "novox/mesh-delivery"}); err == nil {
|
||||
t.Error("members that are no list were taken")
|
||||
}
|
||||
for _, tl := range tools {
|
||||
for _, words := range []string{"group"} {
|
||||
if !strings.Contains(strings.ToLower(tl.Description), words) {
|
||||
t.Errorf("%s says nothing of a %s: %q", tl.Name, words, tl.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
if d := strings.ToLower(tools[0].Description); !strings.Contains(d, "try a group") || !strings.Contains(d, "exact commit") || !strings.Contains(d, "every failure") ||
|
||||
!strings.Contains(d, "composition on every placed node") || !strings.Contains(d, "owes the forge nothing") || !strings.Contains(d, "kept") {
|
||||
t.Errorf("the try's description does not say what a group try does: %q", d)
|
||||
}
|
||||
if open := OpenTriesTools(Trying{Tries: store}); len(open) != 1 || open[0].Name != check.TriesTool || !strings.Contains(open[0].Description, "try.started") {
|
||||
t.Errorf("open tries tool = %+v", open)
|
||||
} else if out, err := open[0].Run(nil); err != nil || len(out.(map[string]any)["tries"].([]tryevent.Open)) != 2 {
|
||||
t.Errorf("open tries = %v, %v, want the two tries asked", out, err)
|
||||
}
|
||||
// Issue 734: a try whose ask was lost is ended by hand with why, and its head is asked anew.
|
||||
ended, err := tools[2].Run(map[string]any{"id": id, "why": "its ask was lost"})
|
||||
if err != nil || ended.(map[string]any)["state"] != "error" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -862,9 +1018,11 @@ func (s triesStore) Create(key string, value []byte) error {
|
||||
|
||||
// TestATryIsJudgedAndItsEndWrittenWithNoStatusOwed: a try written as checker_try writes it is taken by the running checker,
|
||||
// judged through the build modules as a pull request's head merged onto its trunk, and ended pass in its key, with no
|
||||
// verdict and no status owed (ADR 0501 §5).
|
||||
// verdict and no status owed (ADR 0501 §5). A whole try waits for the indexer's candidate of its group, which it reads on the
|
||||
// key's watch, and writes the merge previews the indexer makes it from (issue 1921).
|
||||
func TestATryIsJudgedAndItsEndWrittenWithNoStatusOwed(t *testing.T) {
|
||||
b := startBed(t)
|
||||
asked := b.engine(t, "n1") // a whole try is composed on every placed node, as a group's check is
|
||||
b.start(t, nil)
|
||||
store := triesStore{b.buckets[check.TriesBucket], b.ctx}
|
||||
id, err := check.AskTry(store, map[string]string{repo: trunk}, check.TryAsk{Repository: repo, Head: head1}, time.Now())
|
||||
@@ -872,19 +1030,36 @@ func TestATryIsJudgedAndItsEndWrittenWithNoStatusOwed(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var last check.Try
|
||||
// The indexer's candidate of the try's group (here written by the test), at the heads of its members.
|
||||
raw0, _ := store.Get(id)
|
||||
first, _ := check.DecodeTry(raw0)
|
||||
cand := delivery.Candidate{V: delivery.Version, Group: id, HeadsHash: check.TryCase(id, first, nil).HeadsHash,
|
||||
Pulls: []delivery.CandidatePull{{Head: delivery.Head{Repository: repo, Head: head1}, Modules: []string{}}}}
|
||||
cand.FPSet = delivery.FingerprintSet(cand.Pulls)
|
||||
data, err := delivery.EncodeCandidate(cand)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ckey, _ := delivery.CandidatesKey(id)
|
||||
if _, err := b.buckets[delivery.CandidatesBucket].Create(b.ctx, ckey, data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
|
||||
raw, _ := store.Get(id)
|
||||
if last, err = check.DecodeTry(raw); err == nil && last.Over() {
|
||||
break
|
||||
}
|
||||
}
|
||||
if last.State != check.TryPass || last.Trunk != trunk || last.Took == "" {
|
||||
t.Fatalf("try = %+v, want passed on the trunk", last)
|
||||
if last.State != check.TryPass || last.Trunk != trunk || last.Took == "" || last.Composition == nil {
|
||||
t.Fatalf("try = %+v, want passed on the trunk with its composition made", last)
|
||||
}
|
||||
if got := asked(); len(got) != 1 || !strings.HasPrefix(got[0].Candidate, id+".") {
|
||||
t.Errorf("the placed node's engine was asked %+v, want the try's candidate %s.<heads>", got, id)
|
||||
}
|
||||
if b.fake.count("mesh-build-go.build_go_fetch") == 0 {
|
||||
t.Errorf("the try fetched nothing")
|
||||
}
|
||||
for _, name := range []string{delivery.CheckerOwedBucket, delivery.CheckerVerdictsBucket, delivery.PreviewsBucket} {
|
||||
for _, name := range []string{delivery.CheckerOwedBucket, delivery.CheckerVerdictsBucket} {
|
||||
if keys, _ := b.buckets[name].Keys(b.ctx); len(keys) != 0 {
|
||||
t.Errorf("a try wrote %s: %v", name, keys)
|
||||
}
|
||||
@@ -959,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,12 +24,19 @@ 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
|
||||
repos *repoModules
|
||||
// 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
|
||||
repos *repoModules
|
||||
// published is the indexer's packages state as it is followed (packages.go).
|
||||
published *publishedPackages
|
||||
forge check.Forge
|
||||
@@ -157,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, 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)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/tryevent"
|
||||
"git.novox.be/novox/mesh-sdk/go/announce"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
@@ -71,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},
|
||||
@@ -124,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
|
||||
@@ -152,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]},
|
||||
@@ -189,7 +200,13 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
Verdicts: verdicts.all,
|
||||
}
|
||||
// The tries (ADR 0501 §5) are judged by the same judge, held by the same deliveries.
|
||||
tryer := &check.Tryer{Tries: kvStore{buckets[check.TriesBucket]}, Bound: checkBound, NotTaken: stepBound, Logf: logger.Printf}
|
||||
tryer := &check.Tryer{Tries: kvStore{buckets[check.TriesBucket]}, Bound: checkBound, NotTaken: stepBound, Logf: logger.Printf,
|
||||
// A whole try's start and end are said on the stream checks, for the indexer to write and delete the candidate of its group.
|
||||
Say: func(ctx context.Context, event string, body tryevent.Event) error {
|
||||
data, _ := json.Marshal(body)
|
||||
_, err := js.Publish(ctx, eventSubject(event), data, jetstream.WithRetryAttempts(10), jetstream.WithRetryWait(time.Second))
|
||||
return err
|
||||
}}
|
||||
announce := func(ctx context.Context, event, key string) {
|
||||
body, _ := json.Marshal(map[string]string{"key": key})
|
||||
if _, err := js.Publish(ctx, eventSubject(event), body); err != nil {
|
||||
@@ -202,6 +219,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
// The indexer's packages state (ADR 0593 §2): followed once the bus has it, so a Go check builds every mesh package at
|
||||
// its published version; until then a check builds each at the version its go.mod names.
|
||||
published := &publishedPackages{}
|
||||
compositions.Published = published
|
||||
process.AwaitBucket(ctx, js, conn, delivery.PackagesBucket, "a Go check builds each mesh package at the version its go.mod names",
|
||||
logger.Printf).Ready(func(kv jetstream.KeyValue) {
|
||||
w, err := settle(ctx, kv, func(e jetstream.KeyValueEntry) { published.Put(e.Key(), e.Value(), !opOf(e)) })
|
||||
@@ -216,9 +234,9 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
ch.Runs = repos // the node-engine a check reads beside at its running commit (ADR 0548 §6)
|
||||
ch.Judge = newJudge(judgeDeps{
|
||||
asks: buildrun.Asker{Publisher: asks, Waiter: waiter, Bound: stepBound}, tools: tools, trunks: trunks,
|
||||
kept: kvStore{buckets[delivery.CheckerKeptBucket]}, previews: kvStore{buckets[delivery.PreviewsBucket]},
|
||||
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)},
|
||||
@@ -333,6 +351,9 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
})
|
||||
}()
|
||||
each := func(e jetstream.KeyValueEntry) {
|
||||
if check.IsTryGroup(e.Key()) {
|
||||
return // a group try's candidate: its try judges it, and the forge is owed nothing (issue 1921)
|
||||
}
|
||||
if opOf(e) {
|
||||
ch.Candidate(ctx, e.Key(), e.Value())
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// candidateWaitsOnPreview is the None2 of a module the indexer wrote from a pull message alone, before it had the merge
|
||||
// preview of the head (the indexer's NoneNoPreview): its fingerprint comes with the preview, so a candidate that still says
|
||||
// it is not yet the candidate of the heads previewed. It moves to mesh-sdk's delivery package with the next change there.
|
||||
const candidateWaitsOnPreview = "no merge preview is computed"
|
||||
|
||||
// tryCandidates answers the indexer's candidate of a try's group (novox/hq ADR 0593 §7, issue 1921): the candidate the
|
||||
// engines compose and the manifest validator judges, which the indexer makes of the try's previews. It waits for it on the
|
||||
// key's own watch, with no timer between reads, and gives up at the bound.
|
||||
type tryCandidates struct{ kv jetstream.KeyValue }
|
||||
|
||||
func (t tryCandidates) TryCandidate(ctx context.Context, group, heads string, bound time.Duration) (delivery.Candidate, error) {
|
||||
key, err := delivery.CandidatesKey(group)
|
||||
if err != nil {
|
||||
return delivery.Candidate{}, err
|
||||
}
|
||||
if bound <= 0 {
|
||||
bound = time.Minute
|
||||
}
|
||||
waiting, cancel := context.WithTimeout(ctx, bound)
|
||||
defer cancel()
|
||||
w, err := t.kv.Watch(waiting, key) // the value now, then each change
|
||||
if err != nil {
|
||||
return delivery.Candidate{}, fmt.Errorf("the indexer's candidate %s cannot be watched: %w", key, err)
|
||||
}
|
||||
defer func() { _ = w.Stop() }()
|
||||
last := "the indexer holds none"
|
||||
for {
|
||||
select {
|
||||
case <-waiting.Done():
|
||||
return delivery.Candidate{}, fmt.Errorf("the indexer wrote no candidate of %s at heads %s within %s (%s)", group, heads, bound, last)
|
||||
case e := <-w.Updates():
|
||||
if e == nil || e.Operation() != jetstream.KeyValuePut {
|
||||
continue
|
||||
}
|
||||
c, err := delivery.DecodeCandidate(e.Value())
|
||||
switch {
|
||||
case err != nil:
|
||||
last = "what it holds is not a candidate: " + err.Error()
|
||||
case c.HeadsHash != heads:
|
||||
last = "it holds heads " + c.HeadsHash
|
||||
case waitsOnPreview(c):
|
||||
last = "it has not yet the merge previews' fingerprints"
|
||||
default:
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// waitsOnPreview says a module of the candidate has no fingerprint yet because its preview was not read.
|
||||
func waitsOnPreview(c delivery.Candidate) bool {
|
||||
for _, p := range c.Pulls {
|
||||
for _, m := range p.Fps {
|
||||
if m.None2 == candidateWaitsOnPreview {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -3,7 +3,7 @@ module mesh-delivery-checker
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104002338-5a38a1221659
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a h1:oxgksCiq272/afXbUION/dxtAS5MPGnmyq6+AZQxmXg=
|
||||
git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep v0.0.0-20261102193506-d221c633799a/go.mod h1:kpsX4Pgss5EeuBvel1zWa2ogj7MkR3jZso6oa/J0YUQ=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
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=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -2,7 +2,13 @@ package check
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// BesideRef is a sibling repository a repository's tests read, placed beside the fetched tree as a plain directory (ADR
|
||||
@@ -29,24 +35,32 @@ const (
|
||||
nodeEngineModule = "mesh-host"
|
||||
// networkingRepository holds route-proxy, whose manifest the node-engine's proofs of a composition read.
|
||||
networkingRepository = "novox/mesh-networking"
|
||||
// contractsRepository and deliveryRepository hold the seat definitions mesh-sdk's bootstrapped seats are compared with: its
|
||||
// tests read both beside it (beside.Dir "mesh-contracts" and "mesh-delivery"), at the group's head for a member, else at the
|
||||
// trunk (a group try lays them out the same way: besideFor serves the check and the try).
|
||||
contractsRepository = "novox/mesh-contracts"
|
||||
deliveryRepository = DeliveryRepository
|
||||
)
|
||||
|
||||
// besideRules are the repositories each core repository's tests read, as the tests state it (mesh-sdk go/internal/beside
|
||||
// beside.go): the catalogue, the node-engine, the SDK, and the delivery's pinned forge dependencies (mesh-catalog
|
||||
// checks/deps). Which commit of each is besideCommit's rule. No repository's tests read the controller, which ended by its
|
||||
// unassignment (novox/hq ADR 0590, issue 1126), and its repository is emptied and archived (ADR 0593 §11), so it has no
|
||||
// siblings placed either. The catalogue's tests read the node-engine since its composer command moved there (ADR 0593
|
||||
// §7): mesh-build-go builds it for a compose ask. The node-engine's read mesh-networking since route-proxy's proofs of a
|
||||
// composition moved into them (ADR 0593).
|
||||
// siblings placed either. mesh-sdk reads mesh-contracts and mesh-delivery (its bootstrapped seats against their contracts files). The catalogue's tests read the node-engine since its composer command moved there (ADR 0593
|
||||
// §7): mesh-build-go builds it for a compose ask, and with it every repository its composer requires at the zero version
|
||||
// (issue 1921: with mesh-sdk alone beside, that build looked them up on the network and failed): those are not named
|
||||
// here but derived from each sibling's go.mod (zeroVersionSiblings), so no rule can lack one. The node-engine's read
|
||||
// mesh-networking since route-proxy's proofs of a composition moved into them (ADR 0593).
|
||||
var besideRules = map[string][]string{
|
||||
nodeEngineRepo: {catalogueRepository, SDKRepository, networkingRepository},
|
||||
SDKRepository: {catalogueRepository, nodeEngineRepo},
|
||||
SDKRepository: {catalogueRepository, nodeEngineRepo, contractsRepository, deliveryRepository},
|
||||
catalogueRepository: {SDKRepository, nodeEngineRepo},
|
||||
"novox/mesh-delivery": {SDKRepository, catalogueRepository},
|
||||
}
|
||||
|
||||
// besideFor are the siblings a pull request's tests read (ADR 0484 clause 4): the node-engine at the commit the mesh runs
|
||||
// (the commit of the indexer's module mesh-host, its trunk head when none is known); any other sibling, the catalogue
|
||||
// besideFor are the siblings a pull request's tests read (ADR 0484 clause 4): the node-engine at the head of the group's pull
|
||||
// request for it when the group holds one, else at the commit the mesh runs (the commit of the indexer's module mesh-host,
|
||||
// its trunk head when none is known); any other sibling, the catalogue
|
||||
// included, at the head of the group's pull request for it when the group holds one, else at its trunk, since a group's
|
||||
// check judges each member against the group's heads (ADR 0482 §2, ADR 0573 §4; issue 1126: a judge a group changed was
|
||||
// judged against the catalogue's trunk). And first, the change's own repository at the trunk it is merged onto, so a test
|
||||
@@ -61,8 +75,14 @@ func (j *Examiner) besideFor(ctx context.Context, c *Case, p Pull) ([]BesideRef,
|
||||
if p.Trunk != "" {
|
||||
out = append(out, BesideRef{Repository: p.Repository, Commit: p.Trunk})
|
||||
}
|
||||
for _, repo := range besideRules[p.Repository] {
|
||||
place := func(repo string) {
|
||||
switch {
|
||||
case repo == nodeEngineRepo && c.member(repo) != nil:
|
||||
// A group's check judges each member against the group's heads (ADR 0573 §4, ADR 0482 §2), and the group's
|
||||
// node-engine is what runs after its merge: it is placed at the member's head, as any member is. Neither atTrunk
|
||||
// nor runs, so no move of the trunk or of the running commit puts the verdict out of date (besideAtTrunk).
|
||||
member := c.member(repo)
|
||||
out = append(out, BesideRef{Repository: repo, Commit: member.Head, Ref: refOf(member.Number)})
|
||||
case repo == nodeEngineRepo:
|
||||
commit := ""
|
||||
if j.Runs != nil {
|
||||
@@ -85,9 +105,89 @@ func (j *Examiner) besideFor(ctx context.Context, c *Case, p Pull) ([]BesideRef,
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, repo := range besideRules[p.Repository] {
|
||||
place(repo)
|
||||
}
|
||||
// A sibling a test builds needs every repository its go.mod requires at the zero version beside it too: the build
|
||||
// resolves them from what lies beside, with nothing fetched (issue 1921). Its go.mod names them all, indirect ones
|
||||
// included, so one level is enough.
|
||||
derived, err := j.zeroVersionSiblings(ctx, p.Repository, out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, repo := range derived {
|
||||
place(repo)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// zeroVersionSiblings are the repositories the root go.mod of each sibling in refs requires at the zero version that are
|
||||
// not beside already, sorted; none where the checker reads no forge. A sibling with no root go.mod requires none; one the
|
||||
// forge does not answer for is an error, never a sibling left out in silence.
|
||||
func (j *Examiner) zeroVersionSiblings(ctx context.Context, own string, refs []BesideRef) ([]string, error) {
|
||||
if j.Forge == nil {
|
||||
return nil, nil
|
||||
}
|
||||
have := map[string]bool{own: true}
|
||||
for _, r := range refs {
|
||||
have[r.Repository] = true
|
||||
}
|
||||
var out []string
|
||||
for _, r := range refs {
|
||||
if r.Repository == own {
|
||||
continue
|
||||
}
|
||||
text, err := j.Forge.File(ctx, r.Repository, "go.mod", r.Commit)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the go.mod of %s at %.12s, beside the change, cannot be read%s: %w", r.Repository, r.Commit, unheardWords(err), err)
|
||||
}
|
||||
for _, path := range zeroVersionRequires(text) {
|
||||
repo, _, ok := delivery.PackageRepository(path)
|
||||
if !ok || have[repo] {
|
||||
continue
|
||||
}
|
||||
have[repo] = true
|
||||
out = append(out, repo)
|
||||
}
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// zeroVersionRequires are the module paths a go.mod's require lines (a block or single ones) name at the zero version.
|
||||
func zeroVersionRequires(gomod string) []string {
|
||||
var out []string
|
||||
block := false
|
||||
for _, line := range strings.Split(gomod, "\n") {
|
||||
line, _, _ = strings.Cut(line, "//")
|
||||
f := strings.Fields(line)
|
||||
switch {
|
||||
case len(f) == 0:
|
||||
continue
|
||||
case f[0] == "require" && len(f) > 1 && f[1] == "(":
|
||||
block = true
|
||||
continue
|
||||
case block && f[0] == ")":
|
||||
block = false
|
||||
continue
|
||||
case f[0] == "require":
|
||||
f = f[1:]
|
||||
case !block:
|
||||
continue
|
||||
}
|
||||
if len(f) >= 2 && f[1] == zeroVersion {
|
||||
out = append(out, f[0])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// zeroVersion is the version a go.mod requires a mesh package at (ADR 0573 §4): the builder writes the version it builds.
|
||||
const zeroVersion = "v0.0.0-00010101000000-000000000000"
|
||||
|
||||
func (c *Case) member(repository string) *Pull {
|
||||
for i := range c.Pulls {
|
||||
if c.Pulls[i].Repository == repository {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -55,6 +59,8 @@ type Case struct {
|
||||
Watch func(askID string, step *delivery.Step)
|
||||
// Progress says what the check is doing, so a check that ran past its bound names the step it was stuck at.
|
||||
Progress *Progress
|
||||
// Wait says what a whole try waits for now ("" when nothing); the try keeps it for try-read. Nil for a pull request's check.
|
||||
Wait func(why string)
|
||||
// Kind is what asked the check (KindNew, KindNodes, KindTrunk): a carry after a trunk or sibling move is asked on the
|
||||
// build seats' recheck stream, every other check on check (ADR 0536 §4, ADR 0548 §5).
|
||||
Kind string
|
||||
|
||||
@@ -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"
|
||||
@@ -52,7 +53,12 @@ type EngineCompositions struct {
|
||||
// Manifests are the indexer's trunk manifest digests by module, and Verdicts its refusals of them: the catalogue the
|
||||
// sandbox composes with, before the case's own manifests are laid over.
|
||||
Manifests TrunkManifests
|
||||
Verdicts func() map[string]delivery.ManifestVerdict
|
||||
// Published names the latest published version of every mesh package (Examiner.Published): the composer is built
|
||||
// against them and the group's heads, as a check is (issue 1921); nil names the group's heads alone.
|
||||
Published interface {
|
||||
LatestPackages() delivery.PackageVersions
|
||||
}
|
||||
Verdicts func() map[string]delivery.ManifestVerdict
|
||||
}
|
||||
|
||||
// composerHead is the node-engine member's head of a case, or empty.
|
||||
@@ -63,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
|
||||
@@ -124,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")
|
||||
}
|
||||
@@ -132,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.
|
||||
@@ -215,12 +171,12 @@ 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
|
||||
}
|
||||
job := delivery.ComposeJob{Fetch: map[string]any{"repository": nodeEngineRepo, "commit": head}, Dir: delivery.RootPath,
|
||||
job := delivery.ComposeJob{Fetch: withPackages(map[string]any{"repository": nodeEngineRepo, "commit": head}, packagesFor(e.Published, c, nodeEngineRepo)), Dir: delivery.RootPath,
|
||||
Package: composer.Package, Request: raw, Reader: "the node-engine head of " + c.Group}
|
||||
verdict, err := e.Sandbox.Compose(ctx, job)
|
||||
if err != nil {
|
||||
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"mesh-delivery-checker/rules"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/composer"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
@@ -79,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) {
|
||||
@@ -105,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)
|
||||
}
|
||||
@@ -130,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))
|
||||
}
|
||||
@@ -142,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}}
|
||||
@@ -150,8 +155,9 @@ func TestTheCompositionIsMadeFromTheEnginesAnswers(t *testing.T) {
|
||||
Machines: map[string]composer.Machine{node: {Composes: false, Reason: "the new composer refuses it"}}}}}
|
||||
}}
|
||||
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)}}
|
||||
v, err := ec.Compose(context.Background(), host)
|
||||
Sandbox: sb, Manifests: digests{"web": "sha256:" + strings.Repeat("c", 64)}, Published: published{}}
|
||||
raw, err := ec.Compose(context.Background(), host)
|
||||
v := rules.JudgeEngines(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -162,9 +168,14 @@ 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.
|
||||
if pkgs, _ := buildstep.PackagesArg(job.Fetch["packages"]); len(pkgs) != 1 || pkgs[delivery.ForgeModulePrefix+"mesh-delivery"] != head1 {
|
||||
t.Errorf("the composer is fetched with the packages %v, want the group's mesh-delivery member at its head %s alone", pkgs, head1)
|
||||
}
|
||||
if v.State != delivery.CompositionFail || v.SDK != head2 || !strings.Contains(v.Words, "the new composer refuses it") {
|
||||
t.Errorf("verdict = %+v", v)
|
||||
}
|
||||
@@ -181,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)
|
||||
@@ -190,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,11 +18,13 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
gobuild "git.novox.be/novox/mesh-catalog/modules/mesh-build-go/buildstep"
|
||||
"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/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/composer"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
@@ -124,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.
|
||||
@@ -182,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")
|
||||
@@ -206,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
|
||||
}
|
||||
|
||||
@@ -298,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.
|
||||
@@ -444,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}}
|
||||
@@ -479,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...)
|
||||
}
|
||||
@@ -549,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
|
||||
@@ -919,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)
|
||||
@@ -955,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) {
|
||||
@@ -1035,8 +1181,29 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) {
|
||||
// mail's tool while mail awaits web's database: both left out, so neither is refused (ADR 0614 §3, §6).
|
||||
cycle string
|
||||
trunkCycle bool
|
||||
// indexer makes web the holder of the seat mesh-catalog, and the trunk's manifest validator answers for it: "held" with no verdict,
|
||||
// "aside" with a field it does not know; ownJudge has the group change the judge, so the manifest is also judged by the
|
||||
// trunk's, in a second ask (ADR 0633 clause 6, 1585/T22, T23)
|
||||
indexer string
|
||||
ownJudge bool
|
||||
// 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"},
|
||||
{name: "the indexer accepted by the trunk's manifest validator", indexer: "ok"},
|
||||
{name: "the indexer held by the trunk's manifest validator", indexer: "held", fails: "the indexer web, the holder of the seat mesh-catalog, would never run: the trunk's manifest validator at mesh-delivery's trunk " + strings.Repeat("d", 40) + " does not accept its manifest: the manifest validator holds it, with no verdict yet"},
|
||||
{name: "the indexer with a field the trunk's manifest validator does not know", indexer: "aside", fails: "the manifest validator does not know its fields new_field, so it waits"},
|
||||
{name: "the indexer refused by the trunk's manifest validator", indexer: "refused", refused: "web has no id", fails: "does not accept its manifest: web has no id"},
|
||||
{name: "the indexer under a changed judge is judged by the trunk's as well", indexer: "aside", ownJudge: true, fails: "the manifest validator does not know its fields new_field, so it waits"},
|
||||
{name: "a touched manifest the judge holds", indexer: "heldplain", fails: "holds the manifest of web: it gave no verdict"},
|
||||
{name: "an untouched manifest's problem the trunk had", untouched: "mail says no credential form", trunkMail: "mail says no credential form"},
|
||||
{name: "a problem the change causes in an untouched manifest", untouched: "mail reads a state web no longer keeps",
|
||||
fails: "refuses the manifest of mail: mail reads a state web no longer keeps"},
|
||||
@@ -1048,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"}}`)
|
||||
@@ -1077,6 +1247,10 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) {
|
||||
}
|
||||
b.ex.Read = fixedManifests{strings.Repeat("a", 64): mail, strings.Repeat("b", 64): trunkWeb, web: changed}
|
||||
}
|
||||
if row.indexer != "" && row.indexer != "heldplain" {
|
||||
b.ex.Read = fixedManifests{strings.Repeat("a", 64): {Module: "mail"}, strings.Repeat("b", 64): {Module: "web"},
|
||||
web: {Module: "web", Claims: []catalogue.Claim{{Name: CatalogSeat}}}}
|
||||
}
|
||||
var judged []delivery.ComposeJob
|
||||
answer := b.asks.Answer
|
||||
b.asks.Answer = func(a delivery.StepAsk) delivery.Step {
|
||||
@@ -1085,6 +1259,12 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) {
|
||||
}
|
||||
judged = append(judged, *a.Compose)
|
||||
read := composer.ManifestRead{Accepted: row.refused == "", Refused: row.refused}
|
||||
switch row.indexer {
|
||||
case "held", "heldplain":
|
||||
read = composer.ManifestRead{}
|
||||
case "aside":
|
||||
read.SetAside = "new_field"
|
||||
}
|
||||
verdict, _ := json.Marshal(composer.Verdict{V: composer.Version, SDK: "exe:1", Cases: []composer.CaseVerdict{{Name: JudgeCase,
|
||||
Manifests: map[string]composer.ManifestRead{"web": read, "mail": {Accepted: row.untouched == "", Refused: row.untouched}}},
|
||||
{Name: JudgeTrunk, Manifests: map[string]composer.ManifestRead{"web": {Accepted: true}, "mail": {Accepted: row.trunkMail == "", Refused: row.trunkMail}}}}})
|
||||
@@ -1092,13 +1272,64 @@ func TestTheIndexersJudgeJudgesTheChangesManifests(t *testing.T) {
|
||||
return delivery.Step{ID: a.ID, State: delivery.StepPassed, Result: result}
|
||||
}
|
||||
p := pullOf(repoA, 7, head1)
|
||||
if row.ownJudge {
|
||||
p = pullOf(DeliveryRepository, 7, head1)
|
||||
b.fetch.Changed = []string{JudgeDir + "/judge/judge.go"}
|
||||
}
|
||||
p.Modules = []string{"web"}
|
||||
p.Fps = map[string]delivery.CandidateModule{"web": {FP2: "src2:" + strings.Repeat("f", 64), Manifest: web}}
|
||||
v := b.judge(t, caseOf(t, p)).Verdict
|
||||
// Issue 1921: the judge is built at the group's heads too, also a member that publishes no package yet.
|
||||
cs := caseOf(t, p, pullOf("novox/mesh-networking", 9, head2))
|
||||
if row.try {
|
||||
cs = TryCase("try-1", Try{Members: []TryMember{{Repository: repoA, Head: head1, Trunk: trunkA}}}, nil)
|
||||
b.ex.Candidates = &fakeTryCandidates{cand: delivery.Candidate{FPSet: "fps", Pulls: []delivery.CandidatePull{p}}}
|
||||
}
|
||||
v := b.judge(t, cs).Verdict
|
||||
if row.indexer == "ok" && !slices.ContainsFunc(cs.notes, func(n string) bool {
|
||||
return strings.Contains(n, "the new indexer's manifest of web is accepted by the trunk's manifest validator")
|
||||
}) {
|
||||
t.Errorf("%s: the case says %v, want the new indexer's manifest said accepted by the trunk's manifest validator", row.name, cs.notes)
|
||||
}
|
||||
// Issue 1921: the judge of the change is built at the group's heads (also a member that publishes no package yet), but
|
||||
// the trunk's manifest validator that rules on a new indexer is built at the latest published versions alone, never
|
||||
// at a group's head (ADR 0633 clause 6): a group with a member of its own then judges the indexer a second time.
|
||||
trunkRuns := 0
|
||||
if row.indexer != "" && row.indexer != "heldplain" && (row.ownJudge || !row.try) {
|
||||
trunkRuns = 1
|
||||
}
|
||||
if want := 1 + trunkRuns; len(judged) != want {
|
||||
t.Fatalf("%s: %d judge asks %+v, want %d", row.name, len(judged), judged, want)
|
||||
}
|
||||
for i, j := range judged {
|
||||
pkgs, _ := buildstep.PackagesArg(j.Fetch["packages"])
|
||||
atGroup := pkgs[delivery.ForgeModulePrefix+"mesh-networking"] == head2
|
||||
switch trunkRun := trunkRuns == 1 && i == len(judged)-1; {
|
||||
case row.try:
|
||||
case trunkRun && atGroup:
|
||||
t.Errorf("%s: the trunk's manifest validator is built with the group's head: %v", row.name, pkgs)
|
||||
case !trunkRun && !atGroup:
|
||||
t.Errorf("%s: the judge at %v is fetched with the packages %v, want the group's mesh-networking member at its head %s", row.name, j.Fetch["commit"], pkgs, head2)
|
||||
}
|
||||
}
|
||||
if row.ownJudge && (len(judged) != 2 || judged[0].Fetch["commit"] != head1 || judged[1].Fetch["commit"] != trunk) {
|
||||
t.Fatalf("%s: judged %+v, want the head's judge and then the trunk's", row.name, judged)
|
||||
}
|
||||
if row.ownJudge {
|
||||
judged = judged[1:]
|
||||
} else if trunkRuns == 1 {
|
||||
judged = judged[1:] // the group's run; the trunk's is the one read below
|
||||
}
|
||||
if len(judged) != 1 || judged[0].Fetch["repository"] != DeliveryRepository || judged[0].Fetch["commit"] != trunk ||
|
||||
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.c.Try != nil {
|
||||
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
|
||||
@@ -139,13 +134,28 @@ type Examiner struct {
|
||||
Nodes interface {
|
||||
Names(ctx context.Context) ([]string, error)
|
||||
}
|
||||
Now func() time.Time
|
||||
Logf func(format string, args ...any)
|
||||
// Candidates answers the indexer's candidate of a try's group once it wrote it from the try's previews, the manifests
|
||||
// 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)
|
||||
}
|
||||
|
||||
// CompositionReader makes a check's composition verdict; an error is only a case that cannot be composed at all.
|
||||
// TryCandidates answers the candidate the indexer holds for a try's group at its heads hash, waiting for it within the
|
||||
// bound; an error says it did not come (and why).
|
||||
type TryCandidates interface {
|
||||
TryCandidate(ctx context.Context, group, heads string, bound time.Duration) (delivery.Candidate, error)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -218,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
|
||||
@@ -246,102 +250,130 @@ type state struct {
|
||||
// unpreviewed are the members (pullKey) with no preview of a merge: a head that does not merge, or a fetch that did not
|
||||
// answer. The indexer holds no manifest of them, so the assigner's composition would only wait: it is not awaited.
|
||||
unpreviewed []string
|
||||
// noCandidate says why a whole try has no candidate of its group from the indexer, so its manifests were not judged and
|
||||
// it was not composed; empty when it has one, and for a pull request's check.
|
||||
noCandidate string
|
||||
}
|
||||
|
||||
// narrowTry says the case is a try narrowed to a level or to some tests: it judges no manifest, graph or composition.
|
||||
func (st *state) narrowTry() bool { return st.c.Try != nil && !st.c.Try.Whole() }
|
||||
|
||||
// takeTryCandidate gives a whole try's pulls the fingerprints and manifests the indexer's candidate of its group holds
|
||||
// (the group's pull-request check has them from its candidate, CaseOf), once the indexer wrote it from the try's previews.
|
||||
// A try with no such candidate is said so, in a note and in its composition, and is no pass (Tryer.run).
|
||||
func (st *state) takeTryCandidate(ctx context.Context) {
|
||||
if st.c.Try == nil || !st.c.Try.Whole() {
|
||||
return
|
||||
}
|
||||
no := func(why string) {
|
||||
st.noCandidate = "the group's manifests and composition were not judged: " + why
|
||||
st.c.note(st.noCandidate)
|
||||
}
|
||||
if st.j.Candidates == nil {
|
||||
no("the checker reads no candidate of the indexer's")
|
||||
return
|
||||
}
|
||||
if len(st.unpreviewed) > 0 {
|
||||
no("no preview of a merge of " + strings.Join(st.unpreviewed, ", ") + " (its head does not merge or its fetch did not answer)")
|
||||
return
|
||||
}
|
||||
if st.c.Wait != nil {
|
||||
st.c.Wait("the indexer's candidate of the group at its heads (up to " + st.j.buildWait().String() + ")")
|
||||
defer st.c.Wait("")
|
||||
}
|
||||
cand, err := st.j.Candidates.TryCandidate(ctx, st.c.Group, st.c.HeadsHash, st.j.buildWait())
|
||||
if err != nil {
|
||||
no(err.Error())
|
||||
return
|
||||
}
|
||||
for i, p := range st.c.Pulls {
|
||||
for _, cp := range cand.Pulls {
|
||||
if cp.Repository == p.Repository && cp.Head.Head == p.Head {
|
||||
st.c.Pulls[i].Modules, st.c.Pulls[i].Fps = cp.Modules, cp.Fps
|
||||
}
|
||||
}
|
||||
}
|
||||
st.c.FPSet = cand.FPSet
|
||||
}
|
||||
|
||||
// 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)
|
||||
if v := settled(joinFailures(tests, st.judgeManifests(ctx), st.refuseCycle(ctx))); v != nil {
|
||||
return st.finish(ctx, *v), nil
|
||||
// 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)
|
||||
// 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
|
||||
}
|
||||
if st.j.Compositions == nil || st.c.Try != nil {
|
||||
if st.narrowTry() {
|
||||
return
|
||||
}
|
||||
if st.noCandidate != "" {
|
||||
st.composition = &rules.Composition{NotRun: st.noCandidate}
|
||||
return
|
||||
}
|
||||
if st.j.Compositions == nil {
|
||||
return
|
||||
}
|
||||
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
|
||||
@@ -357,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.
|
||||
@@ -375,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)
|
||||
@@ -389,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
|
||||
}
|
||||
@@ -412,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 {
|
||||
@@ -426,14 +466,12 @@ 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).
|
||||
switch {
|
||||
case st.c.Try != nil: // a try owes the indexer no preview: no pull request holds its head
|
||||
case st.narrowTry(): // a try of a level or some tests owes the indexer no preview: it judges no manifest
|
||||
case len(merged) == len(modules):
|
||||
st.j.writePreview(ctx, p, merged)
|
||||
default:
|
||||
@@ -444,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
|
||||
@@ -455,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}
|
||||
@@ -472,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
|
||||
}
|
||||
@@ -501,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 {
|
||||
@@ -513,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 {
|
||||
@@ -536,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.c.Try == nil {
|
||||
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) {
|
||||
@@ -573,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 {
|
||||
@@ -611,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.
|
||||
@@ -640,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
|
||||
@@ -718,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 {
|
||||
if st.j.Manifests == nil || st.c.Try != nil {
|
||||
return nil
|
||||
// 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
|
||||
}
|
||||
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,97 @@ 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)
|
||||
}
|
||||
// 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
|
||||
}
|
||||
m := rules.Manifests{Touched: touched, At: at, Change: cv, Trunk: trunkCase}
|
||||
if !st.indexerOf(ctx, req, touched, cv, at, &m) {
|
||||
return
|
||||
}
|
||||
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; 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 fail(goModule, err.Error())
|
||||
st.fail(goModule, err.Error())
|
||||
return change, trunk, false
|
||||
}
|
||||
job := delivery.ComposeJob{Fetch: map[string]any{"repository": DeliveryRepository, "commit": commit}, Dir: JudgeDir,
|
||||
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}
|
||||
a := delivery.StepAsk{ID: buildstep.NewID(), Kind: delivery.AskCheck, Language: delivery.AskGo, Asker: delivery.CheckerModule,
|
||||
At: st.j.now().UTC(), Compose: &job}
|
||||
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 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 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
|
||||
}
|
||||
cv, trunkCase, why := judgeAnswer(step)
|
||||
change, trunk, why := judgeAnswer(step)
|
||||
if why != "" {
|
||||
return 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
|
||||
}
|
||||
// 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 || r.Refused == "" {
|
||||
continue
|
||||
return change, trunk, true
|
||||
}
|
||||
|
||||
// CatalogSeat is the seat the indexer holds (rules.CatalogSeat).
|
||||
const CatalogSeat = rules.CatalogSeat
|
||||
|
||||
// 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 true
|
||||
}
|
||||
var indexer string
|
||||
for _, module := range touched {
|
||||
man, err := st.j.Read.Manifest(ctx, req.Cases[0].Manifests[module].Digest)
|
||||
if err != nil {
|
||||
st.fail(goModule, fmt.Sprintf("the manifest of %s cannot be read, so the indexer cannot be ruled out: %v", module, err))
|
||||
return false
|
||||
}
|
||||
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)
|
||||
for _, c := range man.Claims {
|
||||
if c.Name == CatalogSeat {
|
||||
indexer = module
|
||||
}
|
||||
}
|
||||
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 indexer == "" {
|
||||
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
|
||||
// indexer. The head's run is reused when it was that validator already.
|
||||
if trunkVersions := st.trunkJudgeVersions(); st.judgeHead != "" || !maps.Equal(st.packagesOf(DeliveryRepository), trunkVersions) {
|
||||
trunk := ""
|
||||
if st.j.Trunks != nil {
|
||||
trunk = st.j.Trunks.Heads()[DeliveryRepository]
|
||||
}
|
||||
if len(old) > 0 {
|
||||
warned = append(warned, module+": "+strings.Join(old, "; "))
|
||||
if trunk == "" {
|
||||
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
|
||||
}
|
||||
judgedAt = "mesh-delivery's trunk " + trunk
|
||||
var ok bool
|
||||
if cv, _, ok = st.runJudge(ctx, req, touched, trunk, judgedAt, trunkVersions); !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
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
|
||||
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.
|
||||
@@ -257,3 +290,9 @@ func (st *state) judgedByMember(p Pull, merged []Fetched) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// trunkJudgeVersions are the versions the trunk's manifest validator is built at: the latest published version of each
|
||||
// package, with no group member's head (ADR 0633 clause 6).
|
||||
func (st *state) trunkJudgeVersions() delivery.PackageVersions {
|
||||
return packagesFor(st.j.Published, &Case{}, DeliveryRepository)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ package check
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"maps"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/grouppackages"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -31,37 +31,28 @@ type Forge interface {
|
||||
File(ctx context.Context, repository, path, ref string) (string, error)
|
||||
}
|
||||
|
||||
// packagesOf is the version of each mesh package a Go check of repository builds against (ADR 0573 §2, §4): the latest
|
||||
// published version of each package the mesh's modules use (Published), and for a package one of the case's pull
|
||||
// requests publishes, that pull request's head (mesh-sdk's Go module whenever mesh-sdk is a member). A package of
|
||||
// repository itself comes with the tree the check fetched, and is left out. Nil for none.
|
||||
// packagesOf is the version of each mesh package a Go build of repository (a check ask, the judge, the composer) builds
|
||||
// against (ADR 0573 §2, §4): grouppackages.Heads over the latest published version of each package the mesh's modules use
|
||||
// (Published) and the heads of the case's pull requests, so a member that publishes no package yet is named too (issue
|
||||
// 1921). Nil for none.
|
||||
func (st *state) packagesOf(repository string) delivery.PackageVersions {
|
||||
versions := delivery.PackageVersions{}
|
||||
if st.j.Published != nil {
|
||||
maps.Copy(versions, st.j.Published.LatestPackages())
|
||||
return packagesFor(st.j.Published, st.c, repository)
|
||||
}
|
||||
|
||||
// packagesFor is packagesOf for a case and the published versions: every producer of a build at a group's heads takes its
|
||||
// versions from here, so none leaves a member out.
|
||||
func packagesFor(published interface {
|
||||
LatestPackages() delivery.PackageVersions
|
||||
}, c *Case, repository string) delivery.PackageVersions {
|
||||
var latest delivery.PackageVersions
|
||||
if published != nil {
|
||||
latest = published.LatestPackages()
|
||||
}
|
||||
if st.c.member(SDKRepository) != nil {
|
||||
if _, named := versions[SDKModulePath]; !named {
|
||||
versions[SDKModulePath] = ""
|
||||
}
|
||||
heads := map[string]string{}
|
||||
for _, m := range c.Pulls {
|
||||
heads[m.Repository] = m.Head
|
||||
}
|
||||
for pkg := range versions {
|
||||
repo, _, ok := delivery.PackageRepository(pkg)
|
||||
if !ok || delivery.SameRepository(repo, repository) {
|
||||
delete(versions, pkg)
|
||||
continue
|
||||
}
|
||||
if m := st.c.member(repo); m != nil && len(m.Head) == 40 {
|
||||
versions[pkg] = m.Head
|
||||
}
|
||||
if versions[pkg] == "" {
|
||||
delete(versions, pkg)
|
||||
}
|
||||
}
|
||||
if len(versions) == 0 {
|
||||
return nil
|
||||
}
|
||||
return versions
|
||||
return grouppackages.Heads(latest, heads, repository)
|
||||
}
|
||||
|
||||
// unheardWords ends the words of a failed read of the forge that reached nobody: the forge was not heard (UnheardWords).
|
||||
@@ -80,3 +71,17 @@ func forgeMachine(err error, node string) string {
|
||||
}
|
||||
return node
|
||||
}
|
||||
|
||||
// withPackages puts the versions into a fetch's arguments (the fetch tool's packages argument, which a compose ask has no
|
||||
// field for: a compose ask names none, StepAsk.Packages), so the composer or the judge is built at the group's heads.
|
||||
func withPackages(fetch map[string]any, versions delivery.PackageVersions) map[string]any {
|
||||
if len(versions) == 0 {
|
||||
return fetch
|
||||
}
|
||||
packages := make(map[string]any, len(versions))
|
||||
for pkg, version := range versions {
|
||||
packages[pkg] = version
|
||||
}
|
||||
fetch["packages"] = packages
|
||||
return fetch
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package check
|
||||
|
||||
import (
|
||||
"context"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/grouppackages"
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
@@ -60,6 +62,48 @@ func TestAGroupMemberIsBuiltAgainstTheSDKMembersHeadWhateverItsGoModNames(t *tes
|
||||
if got := packagesOfFetch(t, b, "novox/mesh-catalog"); len(got) != 2 || got[SDKModulePath] != trunkS || got[contract] != trunkA {
|
||||
t.Errorf("a pull request alone is built against %v, want every package at its latest published version", got)
|
||||
}
|
||||
// A member that publishes no package yet is still named, by its root module path, at its head (issue 1921).
|
||||
b = newBench(t)
|
||||
b.ex.Published = published{}
|
||||
if v := b.judge(t, caseOf(t, pullOf(repoA, 7, head1), pullOf("novox/mesh-host", 8, head2))).Verdict; v.State != delivery.VerdictPass {
|
||||
t.Fatalf("an unpublished member: verdict = %+v", v)
|
||||
}
|
||||
if got := packagesOfFetch(t, b, repoA); len(got) != 1 || got["git.novox.be/novox/mesh-host"] != head2 {
|
||||
t.Errorf("the member's packages = %v, want the unpublished member's root at its head %s alone", got, head2)
|
||||
}
|
||||
// The function every producer takes the versions from (issue 1921): rows for what a build at a group's heads is asked.
|
||||
h := func(c string) string { return strings.Repeat(c, 40) }
|
||||
const (
|
||||
contracts = "git.novox.be/novox/mesh-contracts/go/clients/assignerclient"
|
||||
tools = "git.novox.be/novox/mesh-tools/go/x"
|
||||
)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
latest delivery.PackageVersions
|
||||
heads map[string]string
|
||||
own string
|
||||
want delivery.PackageVersions
|
||||
}{
|
||||
{"none", nil, nil, "novox/mesh-host", nil},
|
||||
{"trunk latest alone", delivery.PackageVersions{tools: h("a")}, nil, "novox/mesh-host", delivery.PackageVersions{tools: h("a")}},
|
||||
{"a member at its head replaces the latest", delivery.PackageVersions{SDKModulePath: h("a")}, map[string]string{SDKRepository: h("b")}, "novox/mesh-host",
|
||||
delivery.PackageVersions{SDKModulePath: h("b")}},
|
||||
{"a member that publishes no package yet is named by its root module", delivery.PackageVersions{SDKModulePath: h("a")},
|
||||
map[string]string{"novox/mesh-contracts": h("c")}, "novox/mesh-host",
|
||||
delivery.PackageVersions{SDKModulePath: h("a"), "git.novox.be/novox/mesh-contracts": h("c")}},
|
||||
{"mesh-sdk is named by its go module before any package of it is known", nil, map[string]string{SDKRepository: h("b")}, "novox/mesh-host",
|
||||
delivery.PackageVersions{SDKModulePath: h("b")}},
|
||||
{"a member whose packages are known gets no root", delivery.PackageVersions{contracts: h("a")},
|
||||
map[string]string{"novox/mesh-contracts": h("c")}, "novox/mesh-host", delivery.PackageVersions{contracts: h("c")}},
|
||||
{"the build's own repository is left out", delivery.PackageVersions{contracts: h("a")},
|
||||
map[string]string{"novox/mesh-contracts": h("c")}, "novox/mesh-contracts", nil},
|
||||
{"a head that is no full commit names nothing", nil, map[string]string{"novox/mesh-contracts": "abc"}, "novox/mesh-host", nil},
|
||||
} {
|
||||
got := grouppackages.Heads(tc.latest, tc.heads, tc.own)
|
||||
if !maps.Equal(got, tc.want) {
|
||||
t.Errorf("%s: got %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type runsAt map[string]string
|
||||
@@ -111,10 +155,16 @@ func TestTheSiblingsAreWhereTheTestsStateThem(t *testing.T) { // review D2, ADR
|
||||
}
|
||||
// Alone, mesh-sdk's tests read the catalogue's trunk, and the verdict names it.
|
||||
b = newBench(t)
|
||||
b.ex.Trunks = trunksOf(t, map[string]string{SDKRepository: trunkS, catalogue: trunkA, host: trunkA2})
|
||||
const contracts = "novox/mesh-contracts"
|
||||
b.ex.Trunks = trunksOf(t, map[string]string{SDKRepository: trunkS, catalogue: trunkA, host: trunkA2, contracts: trunkA, DeliveryRepository: trunkA2})
|
||||
if out := b.judge(t, caseOf(t, pullOf(SDKRepository, 7, head1))); out.Beside[catalogue] != trunkA {
|
||||
t.Errorf("the siblings named for the verdict alone = %v, want the catalogue at its trunk %s", out.Beside, trunkA)
|
||||
}
|
||||
// mesh-sdk's tests compare its bootstrapped seats with their contracts files, read beside it: mesh-contracts and
|
||||
// mesh-delivery, at their trunks alone (a group try is placed by the same besideFor, so it lays them out the same way).
|
||||
if got := besideOf(t, b, SDKRepository); got[contracts].Commit != trunkA || got[DeliveryRepository].Commit != trunkA2 {
|
||||
t.Errorf("beside mesh-sdk alone = %+v, want mesh-contracts at %s and mesh-delivery at %s", got, trunkA, trunkA2)
|
||||
}
|
||||
if c := besideOf(t, b, SDKRepository)[catalogue]; c.Commit != trunkA || c.Ref != "" {
|
||||
t.Errorf("the catalogue beside mesh-sdk alone = %+v, want its trunk %s", c, trunkA)
|
||||
}
|
||||
@@ -150,8 +200,16 @@ func TestTheChangesOwnRepositoryIsBesideItAtItsTrunk(t *testing.T) {
|
||||
func TestACatalogueChangeHasTheCatalogueBesideItAtItsTrunk(t *testing.T) {
|
||||
hostRuns := strings.Repeat("7", 40)
|
||||
b := newBench(t)
|
||||
b.ex.Trunks = trunksOf(t, map[string]string{catalogueRepository: trunkA, SDKRepository: trunkS, "novox/mesh-controller": trunkA2, nodeEngineRepo: trunkA2})
|
||||
b.ex.Trunks = trunksOf(t, map[string]string{catalogueRepository: trunkA, SDKRepository: trunkS, "novox/mesh-controller": trunkA2, nodeEngineRepo: trunkA2,
|
||||
contractsRepository: trunkA2, deliveryRepository: trunkA2, networkingRepository: trunkA2, "novox/mesh-tools": trunkA2})
|
||||
b.ex.Runs = runsAt{nodeEngineModule: hostRuns}
|
||||
// The node-engine's go.mod at the commit the mesh runs: what it requires at the zero version is placed beside too,
|
||||
// derived, not named in a rule (issue 1921); a version a build wrote, or a package outside the forge, is not.
|
||||
b.ex.Forge = headFiles{nodeEngineRepo + " go.mod " + hostRuns: "module github.com/novox/mesh-host\n\ngo 1.26\n\nrequire (\n" +
|
||||
"\tgit.novox.be/novox/mesh-contracts/go/clients/meshmoduleassignerclient v0.0.0-00010101000000-000000000000\n" +
|
||||
"\tgit.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000 // indirect\n" +
|
||||
"\tgit.novox.be/novox/mesh-tools/go v0.1.2\n\tgithub.com/nats-io/nats.go v1.40.0\n)\n\n" +
|
||||
"require git.novox.be/novox/mesh-networking/modules/mesh-wireguard/go v0.0.0-00010101000000-000000000000\n"}
|
||||
out := b.judge(t, caseOf(t, pullOf(catalogueRepository, 12, head1)))
|
||||
// ADR 0548 §6: the outcome names the siblings read at their trunk's head or the commit the mesh runs, never the
|
||||
// repository it merges.
|
||||
@@ -162,12 +220,31 @@ func TestACatalogueChangeHasTheCatalogueBesideItAtItsTrunk(t *testing.T) {
|
||||
if c := got[catalogueRepository]; c.Commit != trunkA || c.Ref != "" {
|
||||
t.Fatalf("the catalogue beside its own change = %+v, want its trunk %s", c, trunkA)
|
||||
}
|
||||
if _, ok := got[SDKRepository]; !ok {
|
||||
t.Errorf("mesh-sdk is no longer beside a catalogue change: %v", got)
|
||||
// Issue 1921: and every repository the node-engine's composer requires at the zero version, which mesh-build-go's
|
||||
// composer test builds from what lies beside, with nothing fetched.
|
||||
for _, repo := range []string{SDKRepository, contractsRepository, deliveryRepository, networkingRepository} {
|
||||
if _, ok := got[repo]; !ok {
|
||||
t.Errorf("%s is not beside a catalogue change: %v", repo, got)
|
||||
}
|
||||
}
|
||||
if _, ok := got["novox/mesh-tools"]; ok {
|
||||
t.Errorf("mesh-tools, required at a version a build wrote, is beside a catalogue change: %v", got)
|
||||
}
|
||||
if h := got[nodeEngineRepo]; h.Commit != hostRuns {
|
||||
t.Errorf("the node-engine beside a catalogue change = %+v, want the commit the mesh runs %s", h, hostRuns)
|
||||
}
|
||||
// A group that holds the node-engine is judged against its head, which is what runs after its merge, never the commit
|
||||
// the mesh runs now; the verdict names it in no carry (it is a member, not read at a trunk or a running commit).
|
||||
b = newBench(t)
|
||||
b.ex.Trunks = trunksOf(t, map[string]string{catalogueRepository: trunkA, SDKRepository: trunkS, nodeEngineRepo: trunkA2})
|
||||
b.ex.Runs = runsAt{nodeEngineModule: hostRuns}
|
||||
out = b.judge(t, caseOf(t, pullOf(catalogueRepository, 12, head1), pullOf(nodeEngineRepo, 8, head2)))
|
||||
if h := besideOf(t, b, catalogueRepository)[nodeEngineRepo]; h.Commit != head2 || h.Ref != "refs/pull/8/head" {
|
||||
t.Errorf("the node-engine beside a catalogue change in a group holding it = %+v, want the member's head %s", h, head2)
|
||||
}
|
||||
if out.Beside[nodeEngineRepo] != "" {
|
||||
t.Errorf("the verdict names the member node-engine in its carry: %v", out.Beside)
|
||||
}
|
||||
}
|
||||
|
||||
// 1887/A12: a mesh-lab head gets a check: with its trunk known it is judged like any repository, its Go fetch finding
|
||||
@@ -191,3 +268,13 @@ func TestAMeshLabHeadGetsACheck(t *testing.T) {
|
||||
t.Fatalf("mesh-lab was not fetched: %+v", fetched)
|
||||
}
|
||||
}
|
||||
|
||||
// headFiles are the forge's files at a ref, by "<repository> <path> <ref>".
|
||||
type headFiles map[string]string
|
||||
|
||||
func (h headFiles) File(_ context.Context, repository, path, ref string) (string, error) {
|
||||
if text, ok := h[repository+" "+path+" "+ref]; ok {
|
||||
return text, nil
|
||||
}
|
||||
return "", ErrNotFound
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -164,6 +172,7 @@ func (r Reader) Pull(repository string, number int) (VerdictRead, error) {
|
||||
if err != nil {
|
||||
return VerdictRead{}, err
|
||||
}
|
||||
keys = slices.DeleteFunc(keys, IsTryGroup)
|
||||
sort.Strings(keys)
|
||||
keys, cut := capKeys(keys, "")
|
||||
// The candidates are read in batches, each in parallel and taken in key order, so the read stops at the batch that holds it.
|
||||
@@ -256,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"`
|
||||
@@ -444,6 +512,7 @@ func (r Reader) Status() (Status, error) {
|
||||
if err != nil {
|
||||
return st, err
|
||||
}
|
||||
keys = slices.DeleteFunc(keys, IsTryGroup) // a try's candidate is judged by its try, never as a check
|
||||
sort.Strings(keys)
|
||||
total := len(keys)
|
||||
keys, cut := capKeys(keys, "")
|
||||
@@ -712,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.
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/tryevent"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -27,6 +28,8 @@ const (
|
||||
TryTool = "checker_try"
|
||||
TryReadTool = "checker_try_read"
|
||||
TryEndTool = "checker_try_end"
|
||||
// TriesTool lists the tries that wait or run (OpenTries).
|
||||
TriesTool = "checker_tries"
|
||||
// TriesState is the checker's state of tries: the bucket mesh-delivery-checker_tries, keys try-<unix nanoseconds>.
|
||||
TriesState = "tries"
|
||||
TriesBucket = delivery.CheckerModule + "_" + TriesState
|
||||
@@ -62,18 +65,28 @@ var (
|
||||
|
||||
// Try is the value of a tries key.
|
||||
type Try struct {
|
||||
V int `json:"v"`
|
||||
ID string `json:"id"`
|
||||
V int `json:"v"`
|
||||
ID string `json:"id"`
|
||||
// Repository, Head, Trunk and Branch are the first member's (the try's headline, and all a try of one repository
|
||||
// names); Members are every member of the try, by repository: each an exact commit merged onto a trunk commit (a
|
||||
// group try, issue 1921). A try written before members read as one member.
|
||||
Repository string `json:"repository"`
|
||||
// Head is the commit tried; Trunk the trunk commit it is merged onto, and Branch the trunk's branch when it was asked by name.
|
||||
Head string `json:"head"`
|
||||
Trunk string `json:"trunk"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
Head string `json:"head"`
|
||||
Trunk string `json:"trunk"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
Members []TryMember `json:"members,omitempty"`
|
||||
// Tests is a go test -run pattern: only those tests run, and nothing is kept. Level is unit or integration: only that
|
||||
// level runs. Empty is every test of both levels.
|
||||
Tests string `json:"tests,omitempty"`
|
||||
Level string `json:"level,omitempty"`
|
||||
State string `json:"state"`
|
||||
// Told is true from the moment a whole try's start is said on the checker's stream until its end is said there: kept in the
|
||||
// try's own state, so an end that is written and not said (the checker stopped between) is said when the checker starts.
|
||||
Told bool `json:"told,omitempty"`
|
||||
// Waiting is what the running try waits for now (its group's candidate from the indexer, ADR 0639 §5), empty when it waits for nothing;
|
||||
// try-read gives it, so a try that sits still says why.
|
||||
Waiting string `json:"waiting,omitempty"`
|
||||
// Said is the try's report in words: what failed first, the tests run, kept and skipped per level, the time of each part.
|
||||
Said string `json:"said,omitempty"`
|
||||
// Took is the time from its start to its end, as Go writes a duration.
|
||||
@@ -83,6 +96,11 @@ type Try struct {
|
||||
Failed []string `json:"failed,omitempty"`
|
||||
Levels map[string]delivery.LevelResult `json:"levels,omitempty"`
|
||||
Machines []string `json:"machines,omitempty"`
|
||||
// Composition is what the composition on every placed node came to (a whole try, not one narrowed to a level or to
|
||||
// some tests), and Notes the words the check noted on its way (the manifests judged, the module graph, the kept
|
||||
// verdicts taken): the same as a group's pull-request check reports.
|
||||
Composition *delivery.Composition `json:"composition,omitempty"`
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
// Asks are the ids of the asks the try put on the build seats' streams, each ending on the step key <node>.<id> of its
|
||||
// build module: the delivery view counts those steps as a try's, not a candidate's check (build_usage).
|
||||
Asks []string `json:"asks,omitempty"`
|
||||
@@ -94,6 +112,23 @@ type Try struct {
|
||||
Ended time.Time `json:"ended,omitzero"`
|
||||
}
|
||||
|
||||
// TryMember is one repository of a try: the exact commit asked, never the branch's head now, and the trunk commit it is
|
||||
// merged onto. Branch is the trunk's branch when the trunk was asked by name.
|
||||
type TryMember struct {
|
||||
Repository string `json:"repository"`
|
||||
Head string `json:"head"`
|
||||
Trunk string `json:"trunk"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
}
|
||||
|
||||
// MembersOf are the try's members: its Members, or the one its headline names.
|
||||
func (t Try) MembersOf() []TryMember {
|
||||
if len(t.Members) > 0 {
|
||||
return t.Members
|
||||
}
|
||||
return []TryMember{{Repository: t.Repository, Head: t.Head, Trunk: t.Trunk, Branch: t.Branch}}
|
||||
}
|
||||
|
||||
// TryAsked is a TryStep's state before a build node took its ask: it waits in the seat's stream.
|
||||
const TryAsked = "asked"
|
||||
|
||||
@@ -148,6 +183,17 @@ func (w *tryWriter) heard(askID string, step *delivery.Step) {
|
||||
}
|
||||
}
|
||||
|
||||
// waiting writes what the try waits for now ("" when it waits for nothing).
|
||||
func (w *tryWriter) waiting(why string) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.stopped || w.t.Waiting == why {
|
||||
return
|
||||
}
|
||||
w.t.Waiting = why
|
||||
w.write()
|
||||
}
|
||||
|
||||
// write writes the record at the revision written last; a refusal stops the writes.
|
||||
func (w *tryWriter) write() {
|
||||
data, err := encodeTry(w.t)
|
||||
@@ -195,6 +241,7 @@ func DecodeTry(raw []byte) (Try, error) {
|
||||
if !tryKeyOK.MatchString(t.ID) || t.Repository == "" || t.Head == "" || t.Trunk == "" {
|
||||
return Try{}, errors.New("not a try: it names no id, repository, head or trunk")
|
||||
}
|
||||
t.Members = t.MembersOf()
|
||||
return t, nil
|
||||
}
|
||||
|
||||
@@ -206,6 +253,14 @@ func encodeTry(t Try) ([]byte, error) {
|
||||
// TryAsk is what checker_try is given.
|
||||
type TryAsk struct {
|
||||
Repository, Head, Trunk, Tests, Level string
|
||||
// Members are the repositories of a group try, each an exact commit and, optionally, its trunk (a commit, or the
|
||||
// trunk's branch for its head now); the single Repository, Head and Trunk above are one member. Both at once are refused.
|
||||
Members []TryAskMember
|
||||
}
|
||||
|
||||
// TryAskMember is one repository of a group try as it is asked.
|
||||
type TryAskMember struct {
|
||||
Repository, Head, Trunk string
|
||||
}
|
||||
|
||||
// TryStore is the tries state as the tools see it.
|
||||
@@ -221,21 +276,16 @@ func NotChecked(repository string) error {
|
||||
return fmt.Errorf("no level of %s is checked by the checker", repository)
|
||||
}
|
||||
|
||||
// AskTry writes a new try, queued, and answers its id: a try of the same head that waits or runs is the answer to a second
|
||||
// ask, and a new try is refused while MaxTries tries run on a build node or MaxTriesWaiting wait for one. trunks are the
|
||||
// gate's trunk heads of every repository (source.main): a repository with none is no repository the checker checks. A trunk asked by name is its trunk head now.
|
||||
func AskTry(store TryStore, trunks map[string]string, a TryAsk, now time.Time) (string, error) {
|
||||
// tryMember checks one member as it is asked and resolves its trunk: the gate's trunk head of its repository, a commit
|
||||
// asked, or the trunk's branch (its head now). A repository with no trunk head is no repository the checker checks.
|
||||
func tryMember(trunks map[string]string, a TryAskMember) (TryMember, error) {
|
||||
repo := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(a.Repository), ".git"))
|
||||
head := strings.ToLower(strings.TrimSpace(a.Head))
|
||||
switch {
|
||||
case !tryRepoOK.MatchString(repo) || strings.Contains(repo, ".."):
|
||||
return "", fmt.Errorf("%q is not a repository, <owner>/<name>", a.Repository)
|
||||
return TryMember{}, fmt.Errorf("%q is not a repository, <owner>/<name>", a.Repository)
|
||||
case !tryHeadOK.MatchString(head):
|
||||
return "", fmt.Errorf("the head %q is not a full commit, 40 or 64 hex digits", a.Head)
|
||||
case a.Level != "" && a.Level != delivery.LevelUnit && a.Level != delivery.LevelIntegration:
|
||||
return "", fmt.Errorf("the level %q is neither unit nor integration", a.Level)
|
||||
case a.Tests != "" && !tryTestsOK.MatchString(a.Tests):
|
||||
return "", fmt.Errorf("the tests %q are not a go test -run pattern", a.Tests)
|
||||
return TryMember{}, fmt.Errorf("the head %q of %s is not a full commit, 40 or 64 hex digits", a.Head, repo)
|
||||
}
|
||||
trunkHead := ""
|
||||
for r, h := range trunks {
|
||||
@@ -244,20 +294,67 @@ func AskTry(store TryStore, trunks map[string]string, a TryAsk, now time.Time) (
|
||||
}
|
||||
}
|
||||
if trunkHead == "" {
|
||||
return "", NotChecked(repo)
|
||||
return TryMember{}, NotChecked(repo)
|
||||
}
|
||||
t := Try{Repository: repo, Head: head, Tests: a.Tests, Level: a.Level, State: TryQueued, Asked: now.UTC()}
|
||||
m := TryMember{Repository: repo, Head: head}
|
||||
switch trunk := strings.TrimSpace(a.Trunk); {
|
||||
case trunk == "":
|
||||
t.Trunk = trunkHead
|
||||
m.Trunk = trunkHead
|
||||
case tryHeadOK.MatchString(strings.ToLower(trunk)):
|
||||
t.Trunk = strings.ToLower(trunk)
|
||||
m.Trunk = strings.ToLower(trunk)
|
||||
case tryTrunkOK.MatchString(trunk) && !strings.Contains(trunk, ".."):
|
||||
// The checker judges pull requests into the trunk alone (ADR 0484 clause 11): a branch named is the trunk's head now.
|
||||
t.Trunk, t.Branch = trunkHead, trunk
|
||||
m.Trunk, m.Branch = trunkHead, trunk
|
||||
default:
|
||||
return "", fmt.Errorf("the trunk %q is neither a full commit nor a branch", a.Trunk)
|
||||
return TryMember{}, fmt.Errorf("the trunk %q of %s is neither a full commit nor a branch", a.Trunk, repo)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// sameMembers says whether two tries ask the same commits onto the same trunk commits.
|
||||
func sameMembers(a, b []TryMember) bool {
|
||||
return slices.EqualFunc(a, b, func(x, y TryMember) bool {
|
||||
return x.Repository == y.Repository && x.Head == y.Head && x.Trunk == y.Trunk
|
||||
})
|
||||
}
|
||||
|
||||
// AskTry writes a new try, queued, and answers its id: a try of the same commits that waits or runs is the answer to a second
|
||||
// ask, and a new try is refused while MaxTries tries run on a build node or MaxTriesWaiting wait for one. A try is a group of
|
||||
// one or more members (issue 1921), each an exact commit, never the branch's head now: a push after the ask changes nothing
|
||||
// of what is tried. trunks are the gate's trunk heads of every repository (source.main): a repository with none is no
|
||||
// repository the checker checks. A trunk asked by name is its trunk head now.
|
||||
func AskTry(store TryStore, trunks map[string]string, a TryAsk, now time.Time) (string, error) {
|
||||
asked := a.Members
|
||||
if a.Repository != "" || a.Head != "" || a.Trunk != "" {
|
||||
if len(asked) > 0 {
|
||||
return "", errors.New("name the members, or one repository with its head, not both")
|
||||
}
|
||||
asked = []TryAskMember{{Repository: a.Repository, Head: a.Head, Trunk: a.Trunk}}
|
||||
}
|
||||
if len(asked) == 0 {
|
||||
return "", errors.New("name what to try: members (repository and head each), or a repository and its head")
|
||||
}
|
||||
switch {
|
||||
case a.Level != "" && a.Level != delivery.LevelUnit && a.Level != delivery.LevelIntegration:
|
||||
return "", fmt.Errorf("the level %q is neither unit nor integration", a.Level)
|
||||
case a.Tests != "" && !tryTestsOK.MatchString(a.Tests):
|
||||
return "", fmt.Errorf("the tests %q are not a go test -run pattern", a.Tests)
|
||||
}
|
||||
var members []TryMember
|
||||
for _, m := range asked {
|
||||
tm, err := tryMember(trunks, m)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if slices.ContainsFunc(members, func(o TryMember) bool { return o.Repository == tm.Repository }) {
|
||||
return "", fmt.Errorf("%s is named twice: a try holds one commit of a repository", tm.Repository)
|
||||
}
|
||||
members = append(members, tm)
|
||||
}
|
||||
slices.SortFunc(members, func(x, y TryMember) int { return strings.Compare(x.Repository, y.Repository) })
|
||||
first := members[0]
|
||||
t := Try{Repository: first.Repository, Head: first.Head, Trunk: first.Trunk, Branch: first.Branch, Members: members,
|
||||
Tests: a.Tests, Level: a.Level, State: TryQueued, Asked: now.UTC()}
|
||||
keys, err := store.Keys()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("the tries cannot be listed: %w", err)
|
||||
@@ -291,10 +388,10 @@ func AskTry(store TryStore, trunks map[string]string, a TryAsk, now time.Time) (
|
||||
if !live[i] {
|
||||
continue
|
||||
}
|
||||
if cur.Repository == t.Repository && cur.Head == t.Head && cur.Trunk == t.Trunk && cur.Tests == t.Tests && cur.Level == t.Level {
|
||||
if sameMembers(cur.MembersOf(), t.Members) && cur.Tests == t.Tests && cur.Level == t.Level {
|
||||
return cur.ID, nil
|
||||
}
|
||||
said := fmt.Sprintf("%s (%s@%.12s, %s)", cur.ID, cur.Repository, cur.Head, cur.State)
|
||||
said := fmt.Sprintf("%s (%s, %s)", cur.ID, sayMembers(cur.MembersOf()), cur.State)
|
||||
if cur.HoldsANode() {
|
||||
holding = append(holding, said)
|
||||
} else {
|
||||
@@ -379,12 +476,50 @@ func EndTry(store TryEnder, id, why string, now time.Time) (Try, error) {
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// IsTryGroup says a candidates key is the group of a try (the indexer makes the candidate of a group try's previews, for the
|
||||
// manifest validator and the composition): the try judges it, so the checker's check of candidates leaves it alone, writes
|
||||
// no verdict and owes the forge no status for it.
|
||||
func IsTryGroup(key string) bool { return tryKeyOK.MatchString(key) }
|
||||
|
||||
// TryOptions narrow what a try runs; a pull request's check has none.
|
||||
type TryOptions struct {
|
||||
Level string
|
||||
Run string
|
||||
}
|
||||
|
||||
// Whole says the try runs everything a group's pull-request check runs (both levels of every member, the manifests judged by
|
||||
// the manifest validator, the module graph, the composition on every placed node): it is narrowed to neither a level nor
|
||||
// some tests. Such a try judges by the same code as the check, so the two cannot drift (issue 1921).
|
||||
func (o *TryOptions) Whole() bool { return o != nil && o.Level == "" && o.Run == "" }
|
||||
|
||||
// TryCase is the case a try is judged as: one pull of the group per member at its exact commit, with no pull request number
|
||||
// (no ref: the commit is fetched as it is), the group named by the try's key, and the heads hash its candidate is keyed by.
|
||||
func TryCase(key string, t Try, watch func(string, *delivery.Step)) *Case {
|
||||
cs := &Case{Key: key, Group: key, Watch: watch, Trunk: map[string]string{}, Queued: t.Asked, Progress: &Progress{},
|
||||
Try: &TryOptions{Level: t.Level, Run: t.Tests}}
|
||||
var heads []delivery.Head
|
||||
for _, m := range t.MembersOf() {
|
||||
cs.Pulls = append(cs.Pulls, Pull{Repository: m.Repository, Head: m.Head, Trunk: m.Trunk})
|
||||
cs.Trunk[m.Repository] = m.Trunk
|
||||
heads = append(heads, delivery.Head{Repository: m.Repository, Head: m.Head})
|
||||
}
|
||||
cs.HeadsHash, _ = delivery.HeadsHash(heads)
|
||||
cs.Heads = cs.HeadsHash
|
||||
return cs
|
||||
}
|
||||
|
||||
// compositionMade says whether a composition was made; notMadeWords what it says when it was not.
|
||||
func compositionMade(c *delivery.Composition) bool {
|
||||
return c != nil && c.State != delivery.CompositionNotRun
|
||||
}
|
||||
|
||||
func notMadeWords(c *delivery.Composition) string {
|
||||
if c == nil || c.Words == "" {
|
||||
return "no reason was given"
|
||||
}
|
||||
return c.Words
|
||||
}
|
||||
|
||||
// Tryer runs the tries: it takes a queued try, judges it through the same Judge as a pull request's check (a check ask, ADR
|
||||
// 0525 §1), and writes its end. It holds no timer: a try starts when its key is written.
|
||||
type Tryer struct {
|
||||
@@ -396,7 +531,13 @@ type Tryer struct {
|
||||
Now func() time.Time
|
||||
Logf func(format string, args ...any)
|
||||
|
||||
// Say publishes an event of the checker's stream (tryevent.Started, tryevent.Ended) with its body; nil says nothing. The
|
||||
// indexer, which hears them, writes and deletes the candidate of a whole try's group.
|
||||
// It answers the publish's error, nil only when the stream acknowledged it: an end not acknowledged is said again.
|
||||
Say func(ctx context.Context, event string, body tryevent.Event) error
|
||||
|
||||
mu sync.Mutex
|
||||
ending map[string]bool // tries whose end is being said now
|
||||
wg sync.WaitGroup
|
||||
inflight map[string]context.CancelFunc
|
||||
seen map[string]bool // looked at since this process started: a try left running is taken over the first time
|
||||
@@ -436,6 +577,7 @@ func (tr *Tryer) Put(ctx context.Context, key string, value []byte) {
|
||||
stop()
|
||||
}
|
||||
tr.mu.Unlock()
|
||||
tr.sendEnd(ctx, key) // ended by hand while it ran, or its end was written and not said: the indexer drops its candidate
|
||||
return
|
||||
}
|
||||
if tr.inflight[key] != nil || (t.State == TryRunning && tr.seen[key]) {
|
||||
@@ -491,6 +633,7 @@ func (tr *Tryer) run(ctx context.Context, key string) {
|
||||
return
|
||||
}
|
||||
t.State, t.Started = TryRunning, tr.now().UTC()
|
||||
t.Told = (&TryOptions{Level: t.Level, Run: t.Tests}).Whole() // its start is said below; its end is owed from now on
|
||||
data, err := encodeTry(t)
|
||||
if err != nil {
|
||||
return
|
||||
@@ -502,12 +645,15 @@ func (tr *Tryer) run(ctx context.Context, key string) {
|
||||
return
|
||||
}
|
||||
progress := &tryWriter{tr: tr, ctx: ctx, key: key, t: t, rev: rev}
|
||||
cs := &Case{Key: key, Group: key, Watch: progress.heard,
|
||||
Pulls: []Pull{{Repository: t.Repository, Head: t.Head, Trunk: t.Trunk}},
|
||||
Trunk: map[string]string{t.Repository: t.Trunk},
|
||||
Queued: t.Asked,
|
||||
Progress: &Progress{},
|
||||
Try: &TryOptions{Level: t.Level, Run: t.Tests}}
|
||||
if t.Told {
|
||||
started := tryevent.Event{ID: key}
|
||||
for _, m := range t.MembersOf() {
|
||||
started.Members = append(started.Members, tryevent.Member{Repository: m.Repository, Head: m.Head, Trunk: m.Trunk})
|
||||
}
|
||||
tr.say(ctx, tryevent.Started, started)
|
||||
}
|
||||
cs := TryCase(key, t, progress.heard)
|
||||
cs.Wait = progress.waiting
|
||||
judging := ctx
|
||||
if tr.NotTaken > 0 {
|
||||
judging = buildrun.NotTakenBound(ctx, tr.NotTaken)
|
||||
@@ -517,12 +663,18 @@ func (tr *Tryer) run(ctx context.Context, key string) {
|
||||
return // left running: the checker takes it over when it starts; or ended by hand, which wrote its end
|
||||
}
|
||||
t, rev = progress.end()
|
||||
t.Waiting = ""
|
||||
v := out.Verdict
|
||||
t.Ended = tr.now().UTC()
|
||||
t.Took = t.Ended.Sub(t.Started).Round(time.Second).String()
|
||||
t.Levels, t.Machines, t.Asks = v.Levels, v.Machines, cs.Asks()
|
||||
t.Composition = v.Composition
|
||||
t.Said = Report(v, cs, cs.Pulls[0], out.Builds, out.Slowest, out.Judged)
|
||||
for _, n := range cs.Notes() {
|
||||
if len(cs.Pulls) > 1 {
|
||||
t.Said = sayMembers(t.MembersOf()) + ": " + t.Said
|
||||
}
|
||||
t.Notes = cs.Notes()
|
||||
for _, n := range t.Notes {
|
||||
t.Said += " | " + n
|
||||
}
|
||||
switch {
|
||||
@@ -532,6 +684,16 @@ func (tr *Tryer) run(ctx context.Context, key string) {
|
||||
if IsLostAsk(v) || IsNotTaken(v) {
|
||||
t.Failed = []string{v.Failed.Words}
|
||||
}
|
||||
case v.State == delivery.VerdictPass && cs.Try.Whole() && compositionMade(v.Composition) && strings.Contains(v.Composition.Words, delivery.ComposeNoAnswer):
|
||||
// A node that gave no answer passes a group's check with a warning; a try that is to say the group composes says it
|
||||
// did not, naming the node (ADR 0637 §3).
|
||||
t.State = TryError
|
||||
t.Failed = []string{"the composition was not made on every placed node: " + v.Composition.Words}
|
||||
case v.State == delivery.VerdictPass && cs.Try.Whole() && !compositionMade(v.Composition):
|
||||
// A whole try that judged the tests and not the manifests and the composition has not tried what its group's check
|
||||
// judges: it is no pass, and it says why (failure must be loud). It is an error, not the change's failure.
|
||||
t.State = TryError
|
||||
t.Failed = []string{"the composition was not made: " + notMadeWords(v.Composition)}
|
||||
case v.State == delivery.VerdictPass:
|
||||
t.State = TryPass
|
||||
default:
|
||||
@@ -549,6 +711,100 @@ func (tr *Tryer) run(ctx context.Context, key string) {
|
||||
return
|
||||
}
|
||||
tr.logf("%s: %s", key, t.State)
|
||||
// The end just written comes back on the key's watch, and Put then stops this run's context (it stops judging a try
|
||||
// ended by hand) while this run says the end, which Put's own say leaves to it: the end is said on a context that
|
||||
// stop does not cancel, or it is never said and the indexer keeps the candidate of the try's group.
|
||||
tr.sendEnd(context.WithoutCancel(ctx), key)
|
||||
}
|
||||
|
||||
// sendEnd says the end of an ended try whose start was said (Told) with the state it ended in, then clears Told in the try's
|
||||
// own state; a checker that stopped between the two says it again at its start. Every end is said so: pass, fail, error and
|
||||
// by hand.
|
||||
func (tr *Tryer) sendEnd(ctx context.Context, key string) {
|
||||
tr.mu.Lock()
|
||||
if tr.ending[key] {
|
||||
tr.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if tr.ending == nil {
|
||||
tr.ending = map[string]bool{}
|
||||
}
|
||||
tr.ending[key] = true
|
||||
tr.mu.Unlock()
|
||||
defer func() { tr.mu.Lock(); delete(tr.ending, key); tr.mu.Unlock() }()
|
||||
raw, rev, found, err := tr.Tries.Get(ctx, key)
|
||||
if err != nil || !found {
|
||||
return
|
||||
}
|
||||
t, err := DecodeTry(raw)
|
||||
if err != nil || !t.Over() || !t.Told {
|
||||
return
|
||||
}
|
||||
if tr.say(ctx, tryevent.Ended, tryevent.Event{ID: key, State: t.State}) != nil {
|
||||
return // not acknowledged: Told stays, and the next Put or Reconsider says the end again
|
||||
}
|
||||
t.Told = false
|
||||
data, err := encodeTry(t)
|
||||
if err == nil {
|
||||
_, err = tr.Tries.Update(ctx, key, data, rev)
|
||||
}
|
||||
if err != nil && !errors.Is(err, ErrConflict) {
|
||||
tr.logf("%s: its end was said and Told could not be cleared: %v", key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// say publishes a try event; a failure is said in the log (the indexer finds the open tries again at its start).
|
||||
func (tr *Tryer) say(ctx context.Context, event string, body tryevent.Event) error {
|
||||
if tr.Say == nil {
|
||||
return nil
|
||||
}
|
||||
err := tr.Say(ctx, event, body)
|
||||
if err != nil {
|
||||
tr.logf("%s of %s could not be said: %v", event, body.ID, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// OpenTries are every try that waits or runs now: what checker_tries answers, so a part that missed an event (the indexer at
|
||||
// its start) finds them without reading the checker's state. All the keys are read (an old open try is as open as a new one), in
|
||||
// parallel.
|
||||
func OpenTries(store TryStore) ([]tryevent.Open, error) {
|
||||
keys, err := store.Keys()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the tries cannot be listed: %w", err)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if len(keys[i]) != len(keys[j]) {
|
||||
return len(keys[i]) > len(keys[j])
|
||||
}
|
||||
return keys[i] > keys[j]
|
||||
})
|
||||
found := make([]*tryevent.Open, len(keys))
|
||||
if err := parallel(len(keys), func(i int) error {
|
||||
raw, err := store.Get(keys[i])
|
||||
if err != nil || len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
t, err := DecodeTry(raw)
|
||||
if err != nil || t.Over() {
|
||||
return nil
|
||||
}
|
||||
o := tryevent.Open{ID: t.ID, State: t.State, Whole: (&TryOptions{Level: t.Level, Run: t.Tests}).Whole()}
|
||||
for _, m := range t.MembersOf() {
|
||||
o.Members = append(o.Members, tryevent.Member{Repository: m.Repository, Head: m.Head, Trunk: m.Trunk})
|
||||
}
|
||||
found[i] = &o
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []tryevent.Open{} // none open is an empty list, never null: a reader tells it from no answer (issue 1921)
|
||||
for _, o := range found {
|
||||
if o != nil {
|
||||
out = append(out, *o)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// judgeWithin runs a case within the bound (none when 0: the asks' steps are bounded from their start, ADR 0525 §6). A
|
||||
@@ -579,6 +835,15 @@ func judgeWithin(ctx context.Context, j Judge, cs *Case, bound time.Duration) (O
|
||||
return out, true
|
||||
}
|
||||
|
||||
// sayMembers names a try's members, each repository at its short commit.
|
||||
func sayMembers(ms []TryMember) string {
|
||||
var out []string
|
||||
for _, m := range ms {
|
||||
out = append(out, fmt.Sprintf("%s@%.12s", m.Repository, m.Head))
|
||||
}
|
||||
return strings.Join(out, " + ")
|
||||
}
|
||||
|
||||
// orNone joins the tries said, or says none.
|
||||
func orNone(said []string) string {
|
||||
if len(said) == 0 {
|
||||
|
||||
@@ -3,11 +3,15 @@ package check
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"mesh-delivery-checker/rules"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/tryevent"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -70,6 +74,25 @@ func TestATryIsAskedQueuedOncePerHeadAndAtMostFourAtOnce(t *testing.T) { // ADR
|
||||
}
|
||||
// Issue 1890: tries on a build node (a step of theirs running, or none named yet) and tries waiting for one (queued, or
|
||||
// their asks not taken) are bounded apart, MaxTries and MaxTriesWaiting; the refusal names both counts.
|
||||
// A group try is the same try whatever the order of its members; the commits asked are the commits tried.
|
||||
group := TryAsk{Members: []TryAskMember{{Repository: repoSDK, Head: head2}, {Repository: repoA, Head: head1}}}
|
||||
gid, err := AskTry(store, tryTrunks, group, at.Add(time.Second))
|
||||
if err != nil || gid == id {
|
||||
t.Fatalf("the group try = %q, %v, want a try of its own", gid, err)
|
||||
}
|
||||
group.Members[0], group.Members[1] = group.Members[1], group.Members[0]
|
||||
if again, err := AskTry(store, tryTrunks, group, at.Add(time.Minute)); err != nil || again != gid {
|
||||
t.Errorf("the group asked in another order answered %q, %v, want %q", again, err, gid)
|
||||
}
|
||||
if g := readTry(t, kv, gid); len(g.Members) != 2 || g.Members[0].Repository != repoA || g.Members[0].Head != head1 || g.Members[1].Trunk != trunkS {
|
||||
t.Errorf("group try = %+v, want its members by repository at the commits asked", g.Members)
|
||||
}
|
||||
if open, err := OpenTries(store); err != nil || len(open) != 2 || !open[0].Whole || len(open[1].Members) != 2 && len(open[0].Members) != 2 {
|
||||
t.Errorf("open tries = %+v, %v, want the two queued", open, err)
|
||||
}
|
||||
if !IsTryGroup(gid) || IsTryGroup("feat/x") || IsTryGroup("fix/1921-try-1") {
|
||||
t.Errorf("which candidates keys are a try's group: %s is, a branch is not", gid)
|
||||
}
|
||||
onNode := func(i int) Try {
|
||||
return Try{ID: fmt.Sprintf("try-%d", i), Repository: repoA, Head: fmt.Sprintf("%040x", i), Trunk: trunkA, State: TryRunning,
|
||||
Steps: []TryStep{{Ask: "a", State: delivery.StepRunning}}}
|
||||
@@ -112,59 +135,167 @@ func TestATryOfARepositoryWithNoCheckedLevelIsRefused(t *testing.T) { // the agr
|
||||
}
|
||||
}
|
||||
for _, bad := range []TryAsk{{Repository: repoA, Head: "abc"}, {Repository: repoA, Head: head1, Level: "e2e"},
|
||||
{Repository: repoA, Head: head1, Tests: "a b"}, {Repository: "x", Head: head1}, {Repository: repoA, Head: head1, Trunk: "-x"}} {
|
||||
{Repository: repoA, Head: head1, Tests: "a b"}, {Repository: "x", Head: head1}, {Repository: repoA, Head: head1, Trunk: "-x"},
|
||||
// A group try (issue 1921): members or one repository, never both or none; one commit per repository; every member checked.
|
||||
{},
|
||||
{Repository: repoA, Head: head1, Members: []TryAskMember{{Repository: repoSDK, Head: head2}}},
|
||||
{Members: []TryAskMember{{Repository: repoA, Head: head1}, {Repository: repoA, Head: head2}}},
|
||||
{Members: []TryAskMember{{Repository: repoA, Head: head1}, {Repository: repoSDK, Head: "main"}}},
|
||||
{Members: []TryAskMember{{Repository: repoA, Head: head1}, {Repository: "novox/no-trunk", Head: head2}}}} {
|
||||
if _, err := AskTry(store, tryTrunks, bad, time.Now()); err == nil {
|
||||
t.Errorf("%+v was taken", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestATryIsJudgedAsAPullRequestsHeadAndOwesTheForgeNothing(t *testing.T) { // ADR 0501 §5
|
||||
// fakeTryCandidates answers the indexer's candidate of a try's group from memory, recording the keys asked.
|
||||
type fakeTryCandidates struct {
|
||||
cand delivery.Candidate
|
||||
err error
|
||||
asked []string
|
||||
// during is called while the try waits for the candidate.
|
||||
during func()
|
||||
}
|
||||
|
||||
func (f *fakeTryCandidates) TryCandidate(_ context.Context, group, heads string, _ time.Duration) (delivery.Candidate, error) {
|
||||
if f.during != nil {
|
||||
f.during()
|
||||
}
|
||||
f.asked = append(f.asked, group+"."+heads)
|
||||
return f.cand, f.err
|
||||
}
|
||||
|
||||
// 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: composedOn("v0.1.14-x", 40, "g14", "shanks")}
|
||||
cands := &fakeTryCandidates{cand: cand}
|
||||
b.ex.Compositions, b.ex.Candidates = comps, cands
|
||||
return comps, cands
|
||||
}
|
||||
|
||||
// A group try (issue 1921): several repositories each at the exact commit asked, judged by the code of a group's pull-request
|
||||
// check; the two cannot drift, and a later check of the same heads keeps what the try passed.
|
||||
func TestATryIsJudgedAsAGroupsCheckAndOwesTheForgeNothing(t *testing.T) { // ADR 0501 §5, issue 1921
|
||||
ctx := context.Background()
|
||||
kv := newMemKV()
|
||||
id, err := AskTry(memTries{kv}, tryTrunks, TryAsk{Repository: repoA, Head: head1, Trunk: trunkA}, time.Now())
|
||||
id, err := AskTry(memTries{kv}, tryTrunks, TryAsk{Members: []TryAskMember{{Repository: repoSDK, Head: head2, Trunk: trunkS}, {Repository: repoA, Head: head1, Trunk: trunkA}}}, time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := newBench(t)
|
||||
comps, cands := wholeBench(b, delivery.Candidate{FPSet: "fps-of-the-try"})
|
||||
written := &writtenTries{memKV: kv}
|
||||
tr := &Tryer{Tries: written, Judge: b.ex}
|
||||
var waiting string
|
||||
cands.during = func() { waiting = readTry(t, kv, id).Waiting } // ADR 0639 §5: the stored try says what it waits for
|
||||
var said []string
|
||||
var tr *Tryer
|
||||
echoed := false
|
||||
tr = &Tryer{Tries: written, Judge: b.ex, Say: func(ctx context.Context, event string, e tryevent.Event) error {
|
||||
if event == tryevent.Ended && !echoed {
|
||||
// The end the run wrote comes back on the key's watch while the run says it: Put stops the run's context and
|
||||
// leaves the saying to the run, whose say must not be stopped with it (issue 1921).
|
||||
echoed = true
|
||||
raw, _, _, _ := kv.Get(context.Background(), id)
|
||||
tr.Put(context.Background(), id, raw)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err // a publish on a stopped context is never acknowledged
|
||||
}
|
||||
said = append(said, fmt.Sprintf("%s %s %d %s", event, e.ID, len(e.Members), e.State))
|
||||
return nil
|
||||
}}
|
||||
raw, _, _, _ := kv.Get(ctx, id)
|
||||
tr.Put(ctx, id, raw)
|
||||
tr.Wait()
|
||||
// The indexer hears the start with the members at their commits, and the end (it writes and deletes the group's candidate).
|
||||
if len(said) != 2 || said[0] != "try.started "+id+" 2 " || said[1] != "try.ended "+id+" 0 "+TryPass {
|
||||
t.Errorf("events = %v, want the start with 2 members and the end", said)
|
||||
}
|
||||
// What the indexer reads: tryevent.Decode accepts the events as said and refuses a body that is no try's.
|
||||
for _, row := range []struct {
|
||||
name, event, body string
|
||||
ok bool
|
||||
}{
|
||||
{"a start with its members", tryevent.Started, `{"id":"` + id + `","members":[{"repository":"` + repoA + `","head":"` + head1 + `","trunk":"` + trunkA + `"}]}`, true},
|
||||
{"an end with its state", tryevent.Ended, `{"id":"` + id + `","state":"pass"}`, true},
|
||||
{"an id that is no try's", tryevent.Ended, `{"id":"pull-7","state":"pass"}`, false},
|
||||
{"a start with no member", tryevent.Started, `{"id":"` + id + `"}`, false},
|
||||
{"a body that is no JSON", tryevent.Ended, `try-7`, false},
|
||||
} {
|
||||
if e, err := tryevent.Decode(row.event, []byte(row.body)); (err == nil) != row.ok || (row.ok && e.ID != id) {
|
||||
t.Errorf("Decode of %s = %+v, %v, want ok=%v", row.name, e, err, row.ok)
|
||||
}
|
||||
}
|
||||
got := readTry(t, kv, id)
|
||||
if got.State != TryPass || got.Took == "" || got.Head != head1 || got.Trunk != trunkA || got.Said == "" || got.Levels[delivery.LevelUnit].Run != 2 {
|
||||
if !strings.Contains(waiting, "candidate") || got.Waiting != "" {
|
||||
t.Errorf("waiting while the candidate was awaited = %q, after the end %q: want the reason, then none", waiting, got.Waiting)
|
||||
}
|
||||
if got.Told { // the end was said: nothing is owed
|
||||
t.Errorf("the ended try still owes its end: %+v", got)
|
||||
}
|
||||
if open, err := OpenTries(memTries{kv}); err != nil || len(open) != 0 {
|
||||
t.Errorf("open tries after the end = %v, %v", open, err)
|
||||
} else if raw, _ := json.Marshal(open); string(raw) != "[]" { // issue 1921: none open is [] on the wire, never null
|
||||
t.Errorf("none open is answered %s, want []", raw)
|
||||
}
|
||||
if got.State != TryPass || got.Took == "" || len(got.Members) != 2 || got.Members[0].Repository != repoA || got.Members[1].Head != head2 ||
|
||||
got.Said == "" || got.Levels[delivery.LevelUnit].Run+got.Levels[delivery.LevelUnit].Kept != 4 { // the bench gives both members the same digests
|
||||
t.Fatalf("try = %+v", got)
|
||||
}
|
||||
// Issue 1890: while it runs, the try names its ask as soon as it is put, and then where its step stands; the end
|
||||
// keeps each ask's node and state.
|
||||
if len(written.running) < 3 || len(written.running[1].Asks) != 1 || written.running[1].Steps[0].State != TryAsked ||
|
||||
written.running[2].Steps[0].Node != "shanks" {
|
||||
t.Errorf("the running try's writes = %+v, want its ask named while it ran, then its node", written.running)
|
||||
if got.Composition == nil || got.Composition.State != delivery.CompositionPass || len(got.Machines) == 0 {
|
||||
t.Errorf("the try's composition = %+v, want the composition on every placed node, as a check's", got.Composition)
|
||||
}
|
||||
if len(got.Steps) != 1 || got.Steps[0].Node != "shanks" || got.Steps[0].State != delivery.StepPassed {
|
||||
t.Errorf("the ended try's steps = %+v", got.Steps)
|
||||
// The same key the indexer's candidate of the group has: the try's id and the heads of its members.
|
||||
if len(comps.asked) != 1 || comps.asked[0] != id || len(cands.asked) != 1 || !strings.HasPrefix(cands.asked[0], id+".") {
|
||||
t.Errorf("composed %v, candidate asked %v, want the try's key %s", comps.asked, cands.asked, id)
|
||||
}
|
||||
// The try names the asks it put, so the delivery view counts their steps as a try's (build_usage).
|
||||
if len(got.Asks) != 1 {
|
||||
t.Errorf("asks = %v, want the one check ask the try put", got.Asks)
|
||||
} else if _, err := delivery.AskStepKey("shanks", got.Asks[0]); err != nil {
|
||||
t.Errorf("the try names %q, which is no ask id: %v", got.Asks[0], err)
|
||||
// Issue 1890: while it runs, the try names its asks as soon as they are put, and then where each step stands.
|
||||
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)
|
||||
}
|
||||
// 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)
|
||||
if _, err := delivery.AskStepKey("shanks", a); err != nil {
|
||||
t.Errorf("the try names %q, which is no ask id: %v", a, err)
|
||||
}
|
||||
}
|
||||
// Each member is fetched at the exact commit asked, merged onto its own trunk, with no pull request's ref.
|
||||
fetch := b.steps.tool("fetch")
|
||||
if len(fetch) != 1 || fetch[0].args["commit"] != head1 || fetch[0].args["base"] != trunkA || fetch[0].args["ref"] != "" {
|
||||
t.Errorf("fetch = %+v, want the head merged onto the trunk, with no pull request's ref", fetch)
|
||||
if len(fetch) != 2 {
|
||||
t.Fatalf("fetch = %+v, want one per member", fetch)
|
||||
}
|
||||
if keys, _ := b.prev.Keys(ctx); len(keys) != 0 {
|
||||
t.Errorf("a try wrote the previews %v", keys)
|
||||
for _, f := range fetch {
|
||||
want := map[string][2]string{repoA: {head1, trunkA}, repoSDK: {head2, trunkS}}[f.args["repository"].(string)]
|
||||
if f.args["commit"] != want[0] || f.args["base"] != want[1] || f.args["ref"] != "" {
|
||||
t.Errorf("fetch = %+v, want the exact commit %s merged onto %s", f.args, want[0], want[1])
|
||||
}
|
||||
}
|
||||
// A whole try writes the merge previews the indexer makes its candidate from; it writes no status and asks no Approve.
|
||||
if keys, _ := b.prev.Keys(ctx); len(keys) != 2 {
|
||||
t.Errorf("a whole try wrote the previews %v, want one per member", keys)
|
||||
}
|
||||
// An ended try is not run again.
|
||||
raw, _, _, _ = kv.Get(ctx, id)
|
||||
tr.Put(ctx, id, raw)
|
||||
tr.Wait()
|
||||
if n := len(b.steps.tool("fetch")); n != 1 {
|
||||
if n := len(b.steps.tool("fetch")); n != 2 {
|
||||
t.Errorf("an ended try was fetched again: %d fetches", n)
|
||||
}
|
||||
// The kept verdicts: what the try passed is kept under the digests of the merged trees, so the pull-request check of
|
||||
// the same head on the same trunk runs none of it again (and a push of other code runs only what changed).
|
||||
before := len(b.steps.tool("test"))
|
||||
out := b.judge(t, caseOf(t, pullOf(repoA, 7, head1)))
|
||||
if out.Verdict.State != delivery.VerdictPass || out.Verdict.Levels[delivery.LevelUnit].Kept == 0 || out.Verdict.Levels[delivery.LevelUnit].Run != 0 ||
|
||||
out.Verdict.Levels[delivery.LevelIntegration].Run != 0 {
|
||||
t.Errorf("the check of the tried head = %+v, want everything the try passed kept and nothing run", out.Verdict.Levels)
|
||||
}
|
||||
if after := len(b.steps.tool("test")); after != before {
|
||||
t.Errorf("the check of the tried head ran %d test steps, want none: the try's passes are kept", after-before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATryOfOneLevelAndSomeTestsRunsThemAloneAndKeepsNothing(t *testing.T) {
|
||||
@@ -175,10 +306,16 @@ func TestATryOfOneLevelAndSomeTestsRunsThemAloneAndKeepsNothing(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := newBench(t)
|
||||
tr := &Tryer{Tries: kv, Judge: b.ex}
|
||||
tr := &Tryer{Tries: kv, Judge: b.ex, Say: func(context.Context, string, tryevent.Event) error {
|
||||
t.Error("a try of one level and some tests said an event: it has no candidate")
|
||||
return nil
|
||||
}}
|
||||
raw, _, _, _ := kv.Get(ctx, id)
|
||||
tr.Put(ctx, id, raw)
|
||||
tr.Wait()
|
||||
if open, _ := OpenTries(memTries{kv}); len(open) != 0 {
|
||||
t.Errorf("open = %v", open)
|
||||
}
|
||||
tests := b.steps.tool("test")
|
||||
if len(tests) != 1 || tests[0].args["level"] != delivery.LevelIntegration || tests[0].args["run"] != "TestBus|TestStore" {
|
||||
t.Fatalf("tests = %+v, want the integration level alone with the run pattern", tests)
|
||||
@@ -192,6 +329,24 @@ func TestATryOfOneLevelAndSomeTestsRunsThemAloneAndKeepsNothing(t *testing.T) {
|
||||
if got := readTry(t, kv, id); got.State != TryPass || !strings.Contains(got.Said, "only the tests TestBus|TestStore") {
|
||||
t.Errorf("try = %+v", got)
|
||||
}
|
||||
t.Run("a try of one level keeps only that level", func(t *testing.T) {
|
||||
kv := newMemKV()
|
||||
id, _ := AskTry(memTries{kv}, tryTrunks, TryAsk{Repository: repoA, Head: head1, Level: delivery.LevelUnit}, time.Now())
|
||||
b := newBench(t)
|
||||
tr := &Tryer{Tries: kv, Judge: b.ex}
|
||||
raw, _, _, _ := kv.Get(ctx, id)
|
||||
tr.Put(ctx, id, raw)
|
||||
tr.Wait()
|
||||
keys, _ := b.kept.Keys(ctx)
|
||||
if got := readTry(t, kv, id); got.State != TryPass || len(keys) == 0 {
|
||||
t.Fatalf("try = %+v, kept %v", got, keys)
|
||||
}
|
||||
for _, k := range keys {
|
||||
if !strings.HasPrefix(k, delivery.LevelUnit+".") {
|
||||
t.Errorf("a try of the unit level kept %s", k)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAFailingTryNamesWhatFailedAndAStepThatCannotRunIsAnError(t *testing.T) {
|
||||
@@ -208,26 +363,135 @@ func TestAFailingTryNamesWhatFailedAndAStepThatCannotRunIsAnError(t *testing.T)
|
||||
!strings.Contains(got.Failed[1], "integration: fixture/failing/bad.TestThreeIsFour") { // both levels ran (the operator, 2026-11-02)
|
||||
t.Errorf("try = %+v", got)
|
||||
}
|
||||
// A whole try that could not judge the manifests and the composition is no pass; a failed composition is named beside
|
||||
// the failed tests (the group's check does as much), and a candidate of other heads is not the try's.
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
cand *fakeTryCandidates
|
||||
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: &rules.Composed{Composer: "x", Nodes: []string{"g14"}},
|
||||
state: TryError, said: "g14: " + delivery.ComposeNoAnswer},
|
||||
{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 = 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
|
||||
tr := &Tryer{Tries: kv, Judge: b.ex, Say: func(_ context.Context, event string, e tryevent.Event) error {
|
||||
if event == tryevent.Ended {
|
||||
ends = append(ends, e.State)
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
raw, _, _, _ := kv.Get(ctx, id)
|
||||
tr.Put(ctx, id, raw)
|
||||
tr.Wait()
|
||||
if len(ends) != 1 || ends[0] != row.state { // every end says its state: error as well as fail
|
||||
t.Errorf("%s: ends said = %v, want once %s", row.name, ends, row.state)
|
||||
}
|
||||
if got := readTry(t, kv, id); got.State != row.state || !strings.Contains(strings.Join(got.Failed, " | ")+got.Said, row.said) {
|
||||
t.Errorf("%s: try = %s, failed %v, said %q, want %s naming %q", row.name, got.State, got.Failed, got.Said, row.state, row.said)
|
||||
}
|
||||
}
|
||||
// No build node is no error since R4g: the try's ask waits in the check stream until a node pulls it (ADR 0525 §4).
|
||||
}
|
||||
|
||||
func TestATryLeftRunningIsTakenOver(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
kv := newMemKV()
|
||||
id, _ := AskTry(memTries{kv}, tryTrunks, TryAsk{Repository: repoA, Head: head1}, time.Now())
|
||||
// Left running by a checker that stopped.
|
||||
raw, rev, _, _ := kv.Get(ctx, id)
|
||||
cur, _ := DecodeTry(raw)
|
||||
cur.State = TryRunning
|
||||
data, _ := encodeTry(cur)
|
||||
if _, err := kv.Update(ctx, id, data, rev); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b := newBench(t)
|
||||
tr := &Tryer{Tries: kv, Judge: b.ex}
|
||||
tr.Reconsider(ctx)
|
||||
tr.Wait()
|
||||
if got := readTry(t, kv, id); got.State != TryPass {
|
||||
t.Errorf("the try left running = %+v, want taken over and passed", got)
|
||||
// Whether the start was said is in the try's own state (Told), so a checker that starts later says the end of a try it did
|
||||
// not start, whatever way the try ended.
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
told bool
|
||||
failFirst bool // the stream does not acknowledge the first end said
|
||||
after func(t *testing.T, kv *memKV, id string)
|
||||
ends []string
|
||||
}{
|
||||
{name: "the first publish of the end is not acknowledged", told: true, failFirst: true, ends: []string{TryPass}},
|
||||
{name: "left running after its start was said", told: true, ends: []string{TryPass}},
|
||||
{name: "left running with no start said", told: false, ends: []string{TryPass}},
|
||||
{name: "ended by hand after the start was said", told: true, ends: []string{TryError}, after: func(t *testing.T, kv *memKV, id string) {
|
||||
if _, err := EndTry(endable{kv}, id, "stuck", time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
} {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
kv := newMemKV()
|
||||
id, _ := AskTry(memTries{kv}, tryTrunks, TryAsk{Repository: repoA, Head: head1}, time.Now())
|
||||
// Left running by a checker that stopped.
|
||||
raw, rev, _, _ := kv.Get(ctx, id)
|
||||
cur, _ := DecodeTry(raw)
|
||||
cur.State, cur.Told = TryRunning, row.told
|
||||
data, _ := encodeTry(cur)
|
||||
if _, err := kv.Update(ctx, id, data, rev); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.after != nil {
|
||||
row.after(t, kv, id)
|
||||
}
|
||||
b := newBench(t)
|
||||
wholeBench(b, delivery.Candidate{})
|
||||
var ends []string
|
||||
failed := false
|
||||
tr := &Tryer{Tries: kv, Judge: b.ex, Say: func(_ context.Context, event string, e tryevent.Event) error {
|
||||
if event == tryevent.Ended {
|
||||
if row.failFirst && !failed {
|
||||
failed = true
|
||||
return errors.New("the stream did not acknowledge")
|
||||
}
|
||||
ends = append(ends, e.State)
|
||||
}
|
||||
return nil
|
||||
}}
|
||||
tr.Reconsider(ctx)
|
||||
tr.Wait()
|
||||
if row.failFirst && !readTry(t, kv, id).Told {
|
||||
t.Errorf("an end the stream did not acknowledge cleared Told: it is never said again")
|
||||
}
|
||||
tr.Reconsider(ctx) // said once: the second look finds nothing owed
|
||||
tr.Wait()
|
||||
if row.after == nil {
|
||||
if got := readTry(t, kv, id); got.State != TryPass {
|
||||
t.Errorf("the try left running = %+v, want taken over and passed", got)
|
||||
}
|
||||
}
|
||||
if !slices.Equal(ends, row.ends) || readTry(t, kv, id).Told {
|
||||
t.Errorf("ends said = %v, want %v, and nothing owed after", ends, row.ends)
|
||||
}
|
||||
})
|
||||
}
|
||||
// An ended try is kept TryKeep and removed by the next Reconsider after.
|
||||
t.Run("an ended try is kept seven days", func(t *testing.T) {
|
||||
kv := newMemKV()
|
||||
id, _ := AskTry(memTries{kv}, tryTrunks, TryAsk{Repository: repoA, Head: head1}, time.Now())
|
||||
raw, rev, _, _ := kv.Get(ctx, id)
|
||||
cur, _ := DecodeTry(raw)
|
||||
ended := time.Now()
|
||||
cur.State, cur.Ended = TryPass, ended
|
||||
data, _ := encodeTry(cur)
|
||||
if _, err := kv.Update(ctx, id, data, rev); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := ended.Add(TryKeep - time.Hour)
|
||||
tr := &Tryer{Tries: kv, Now: func() time.Time { return now }}
|
||||
tr.Reconsider(ctx)
|
||||
if _, _, found, _ := kv.Get(ctx, id); !found {
|
||||
t.Fatal("the try was removed before TryKeep")
|
||||
}
|
||||
now = ended.Add(TryKeep + time.Hour)
|
||||
tr.Reconsider(ctx)
|
||||
if _, _, found, _ := kv.Get(ctx, id); found {
|
||||
t.Error("the try was kept past TryKeep")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
@@ -130,20 +131,47 @@ func TestManifestDeclarations(t *testing.T) { // 1678/AC1
|
||||
if m.SecretsOwner != "mesh-delivery-checker" {
|
||||
t.Errorf("secrets-owner = %q", m.SecretsOwner)
|
||||
}
|
||||
// The checker says each time it wrote a preview or a status, so the indexer's and the forge bridge's watches find the buckets (ADR 0484 clauses 2 and 3).
|
||||
if want := []stream{{Name: "checks", Events: []string{"preview.**", "owed.**"}}}; !reflect.DeepEqual(m.Streams, want) {
|
||||
// The checker says each time it wrote a preview or a status, so the indexer's and the forge bridge's watches find the buckets (ADR 0484 clauses 2 and 3). A whole try says try.started with its members and try.ended, so the indexer writes and deletes the candidate of its group (issue 1921).
|
||||
if want := []stream{{Name: "checks", Events: []string{"preview.**", "owed.**", "try.**"}}}; !reflect.DeepEqual(m.Streams, want) {
|
||||
t.Errorf("streams = %+v, want %+v", m.Streams, want)
|
||||
}
|
||||
// Only the checker's account publishes try.* on its stream checks: the stream is declared by its owner alone, and no other
|
||||
// module of the repository declares a stream carrying a try event (the indexer and the others only consume them).
|
||||
siblings, err := filepath.Glob("../*/module.json")
|
||||
if err != nil || len(siblings) < 2 {
|
||||
t.Fatalf("the sibling manifests = %v, %v", siblings, err)
|
||||
}
|
||||
for _, f := range siblings {
|
||||
raw, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var other struct {
|
||||
Module string `json:"module"`
|
||||
Streams []stream `json:"streams"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &other); err != nil {
|
||||
t.Fatalf("%s: %v", f, err)
|
||||
}
|
||||
for _, st := range other.Streams {
|
||||
for _, e := range st.Events {
|
||||
if strings.HasPrefix(e, "try.") && other.Module != m.Module {
|
||||
t.Errorf("%s declares the stream %s with %s: only the checker's account publishes try events", other.Module, st.Name, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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_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"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -18,11 +19,12 @@
|
||||
"mesh-build-node.toolchain.reported"
|
||||
],
|
||||
"streams": [
|
||||
{ "name": "checks", "events": ["preview.**", "owed.**"] }
|
||||
{ "name": "checks", "events": ["preview.**", "owed.**", "try.**"] }
|
||||
],
|
||||
"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,10 +54,12 @@
|
||||
],
|
||||
"tools": [
|
||||
"checker_ask_again",
|
||||
"checker_inputs",
|
||||
"checker_kept",
|
||||
"checker_previews",
|
||||
"checker_pull",
|
||||
"checker_status",
|
||||
"checker_tries",
|
||||
"checker_try",
|
||||
"checker_try_end",
|
||||
"checker_try_read",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
// Command mesh-delivery-forge-bridge-tools serves the forge bridge's tools (novox/hq ADR 0633 §4, issue 1585): the runtime
|
||||
// launches it for a call. bridge_groups reads the delivery groups the gate holds and what the bridge kept of their ends;
|
||||
// bridge_group_end_again writes an ask into the bridge's state end-asks and waits for the bridge's answer there, since the
|
||||
// bridge is the other program and serves no request subject (ADR 0437 §4).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
|
||||
"mesh-delivery-forge-bridge/internal/bridge"
|
||||
"mesh-delivery-forge-bridge/internal/groups"
|
||||
)
|
||||
|
||||
// Tool names.
|
||||
const (
|
||||
GroupsTool = "bridge_groups"
|
||||
EndAgainTool = "bridge_group_end_again"
|
||||
// MergedTool answers the merged message of a merge on request (issue 1585).
|
||||
MergedTool = "bridge_merged_message"
|
||||
)
|
||||
|
||||
// asker writes an end-asks value and waits for the bridge's answer to it.
|
||||
type asker func(key string, value []byte) (groups.EndAsk, error)
|
||||
|
||||
// Tools are the bridge's tools: groupsRead reads the groups the gate holds with what the bridge kept of them.
|
||||
func Tools(ask asker, now func() time.Time, groupsRead func() ([]groups.Row, []string, error), merged func(repository, merge string) (json.RawMessage, error)) []stdio.Tool {
|
||||
str := func(args map[string]any, k string) string { s, _ := args[k].(string); return strings.TrimSpace(s) }
|
||||
return []stdio.Tool{
|
||||
{Name: MergedTool,
|
||||
Description: "The merged message of one merge, asked again (issue 1585): the message the bridge wrote on its stream FORGE when the " +
|
||||
"pull request was merged into the trunk, built the same way from the forge (the merge commit's trees and the manifests " +
|
||||
"and lock files of its module directories) and from the gate's merges state (the pull request's number, head and " +
|
||||
"group). For the indexer, which asks it when it takes in a held merge it does not hold in memory (a restart, a new " +
|
||||
"build), and the ask-again a lost message lacked. About 0.5 MB for a repository of many modules. Answers {message} " +
|
||||
"with the forge message whole, or why it cannot: the gate holds no merge with that commit, or the forge did not answer.",
|
||||
Input: map[string]any{"type": "object", "required": []string{"repository", "merge"}, "properties": map[string]any{
|
||||
"repository": map[string]any{"type": "string", "description": "the repository, owner/name, as the forge names it"},
|
||||
"merge": map[string]any{"type": "string", "description": "the merge commit, 40 hex digits"}}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
raw, err := merged(str(args, "repository"), str(args, "merge"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"message": raw}, nil
|
||||
}},
|
||||
{Name: GroupsTool,
|
||||
Description: "The delivery groups the gate holds (more than one member), each with its members' merge state and whether each " +
|
||||
"merged message is written on the forge stream, and whether the group's end (forge.group.merged) is written: ended, " +
|
||||
"or open with why (gathered, or merged and its end not written yet). An open group with every member merged is " +
|
||||
"what bridge_group_end_again says the end of. Each group names its last end ask (who, why, when) and the bridge's " +
|
||||
"answer to it, from the state end-asks. A group's name narrows it to that group; open lists only open ones.",
|
||||
Input: map[string]any{"type": "object", "properties": map[string]any{
|
||||
"group": map[string]any{"type": "string", "description": "only this group, by the name the gate keys it"},
|
||||
"open": map[string]any{"type": "boolean", "description": "only the groups whose end is not written"}}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
rows, unread, err := groupsRead()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
only, _ := args["open"].(bool)
|
||||
group := str(args, "group")
|
||||
kept := rows[:0]
|
||||
for _, r := range rows {
|
||||
if (group != "" && r.Group != group) || (only && !r.Open) {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, r)
|
||||
}
|
||||
return map[string]any{"groups": kept, "unread": unread}, nil
|
||||
}},
|
||||
{Name: EndAgainTool,
|
||||
Description: "Say a delivery group's end (forge.group.merged) again, by hand, for a group whose end the indexer did not take " +
|
||||
"in (a group bridge_groups names as merged with its end not written, or one the indexer waits for). The bridge " +
|
||||
"writes it again from the gate's merges, with the time appended to the message id so the stream does not drop it as " +
|
||||
"a duplicate; the indexer takes an end for a group it already took in without changing anything. Refused while " +
|
||||
"a member is not merged or its merged message is not written. Answers whether it was written.",
|
||||
Input: map[string]any{"type": "object", "required": []string{"group"}, "properties": map[string]any{
|
||||
"group": map[string]any{"type": "string", "description": "the group, by the name the gate keys it"},
|
||||
"why": map[string]any{"type": "string", "description": "why its end is said again, in plain words"}}},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
key, value, err := groups.NewEndAsk(str(args, "group"), str(args, "why"), stdio.CallerOf(args), now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a, err := ask(key, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"group": a.Group, "written": a.Written, "answer": a.Answer, "by": a.By, "why": a.Why}, nil
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// MergedOf is the merged message of the merge of repository at commit merge, as the bridge writes it: the pull request's
|
||||
// number, head and group from the gate's merges (raw, by key), the rest from the forge through trees and files
|
||||
// (bridge.MergedMessage), and, for a member of a delivery group, the group and its members as the process sets them.
|
||||
func MergedOf(raws map[string][]byte, repository, merge string, trees bridge.Trees, files bridge.Files) (json.RawMessage, error) {
|
||||
owner, repo, ok := strings.Cut(repository, "/")
|
||||
if !ok || owner == "" || repo == "" {
|
||||
return nil, fmt.Errorf("%q is not owner/name", repository)
|
||||
}
|
||||
for _, key := range slices.Sorted(maps.Keys(raws)) {
|
||||
m, err := delivery.DecodeMerge(raws[key])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
at := slices.IndexFunc(m.Pulls, func(p delivery.MergePull) bool {
|
||||
return delivery.SameRepository(p.Repository, repository) && p.Commit == merge
|
||||
})
|
||||
if at < 0 {
|
||||
continue
|
||||
}
|
||||
p := m.Pulls[at]
|
||||
msg, err := bridge.MergedMessage(owner, repo, p.Number, m.Group, p.Head.Head, merge, trees, files)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the merged message of %s at %s cannot be built: %w", repository, merge, err)
|
||||
}
|
||||
f, err := forgemsg.DecodeForge(msg.Data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, approved := m.Approved[delivery.ApprovedKey(f.Repository, f.Number)]; approved && len(m.Approved) >= 2 {
|
||||
f.Group, f.Members = m.Group, groups.MembersOf(m)
|
||||
}
|
||||
out, err := forgemsg.EncodeForge(f)
|
||||
return out, err
|
||||
}
|
||||
return nil, fmt.Errorf("the gate's merges hold no merge of %s with the commit %s, so its pull request is not known", repository, merge)
|
||||
}
|
||||
|
||||
// read is the groups the gate holds with what the bridge kept of them: their ends in performed, and the last ask of
|
||||
// each in end-asks with the bridge's answer.
|
||||
func read(merges, performed, asks stdio.KeptState) ([]groups.Row, []string, error) {
|
||||
keys, err := merges.Keys()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("the gate's merges cannot be listed: %w", err)
|
||||
}
|
||||
raws := map[string][]byte{}
|
||||
for _, k := range keys {
|
||||
e, err := merges.Get(k)
|
||||
if err != nil || e == nil {
|
||||
continue
|
||||
}
|
||||
raws[k] = e.Value
|
||||
}
|
||||
pkeys, err := performed.Keys()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("the bridge's performed state cannot be listed: %w", err)
|
||||
}
|
||||
published, ended := map[string]bool{}, map[string]uint64{}
|
||||
for _, k := range pkeys {
|
||||
switch {
|
||||
case strings.HasPrefix(k, "published."):
|
||||
published[k] = true
|
||||
case strings.HasPrefix(k, "groups."):
|
||||
if e, err := performed.Get(k); err == nil && e != nil {
|
||||
rev, _ := strconv.ParseUint(strings.TrimSpace(string(e.Value)), 10, 64)
|
||||
ended[strings.TrimPrefix(k, "groups.")] = rev
|
||||
}
|
||||
}
|
||||
}
|
||||
akeys, err := asks.Keys()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("the bridge's state %s cannot be listed: %w", groups.EndAsksState, err)
|
||||
}
|
||||
askRaws := map[string][]byte{}
|
||||
for _, k := range akeys {
|
||||
if e, err := asks.Get(k); err == nil && e != nil {
|
||||
askRaws[k] = e.Value
|
||||
}
|
||||
}
|
||||
rows, unread := groups.Rows(raws, published, ended, askRaws)
|
||||
return rows, unread, nil
|
||||
}
|
||||
|
||||
// askEnd writes the end-asks value at key and waits, on a watch of the key, at most bound for the bridge's answer.
|
||||
func askEnd(s stdio.KeptState, bound time.Duration) asker {
|
||||
return func(key string, value []byte) (groups.EndAsk, error) {
|
||||
answered := make(chan groups.EndAsk, 1)
|
||||
var rev uint64
|
||||
var mu sync.Mutex
|
||||
take := func(revision uint64, v []byte) {
|
||||
mu.Lock()
|
||||
mine := rev
|
||||
mu.Unlock()
|
||||
if mine == 0 || revision <= mine {
|
||||
return
|
||||
}
|
||||
if a, err := groups.DecodeEndAsk(v); err == nil && a.Done() {
|
||||
select {
|
||||
case answered <- a:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := s.Watch(key, func(c stdio.StateChange) error {
|
||||
if c.Op == "put" {
|
||||
take(c.Revision, c.Value)
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return groups.EndAsk{}, fmt.Errorf("watching %s.%s: %w", groups.EndAsksState, key, err)
|
||||
}
|
||||
r, err := s.Put(key, json.RawMessage(value))
|
||||
if err != nil {
|
||||
return groups.EndAsk{}, fmt.Errorf("the ask could not be written to %s: %w", groups.EndAsksBucket, err)
|
||||
}
|
||||
mu.Lock()
|
||||
rev = r
|
||||
mu.Unlock()
|
||||
if e, err := s.Get(key); err == nil && e != nil {
|
||||
take(e.Revision, e.Value)
|
||||
}
|
||||
wait, cancel := context.WithTimeout(context.Background(), bound) // a context's bound, no timer (ADR 0437 §8)
|
||||
defer cancel()
|
||||
select {
|
||||
case a := <-answered:
|
||||
return a, nil
|
||||
case <-wait.Done():
|
||||
return groups.EndAsk{}, fmt.Errorf("the bridge did not answer within %s: its answer will be in %s.%s", bound, groups.EndAsksBucket, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// askCaller calls a tool as this bundle's module, through the runtime (stdio.Ask), the module's own invokes granting it.
|
||||
func askCaller(_ context.Context, key string, body any) (json.RawMessage, error) {
|
||||
return stdio.Ask(key, body)
|
||||
}
|
||||
|
||||
func main() {
|
||||
merges, performed, asks := stdio.State(delivery.GateModule+".merges"), stdio.State(groups.PerformedState), stdio.State(groups.EndAsksState)
|
||||
tools := Tools(askEnd(asks, 25*time.Second), time.Now,
|
||||
func() ([]groups.Row, []string, error) { return read(merges, performed, asks) },
|
||||
func(repository, merge string) (json.RawMessage, error) {
|
||||
keys, err := merges.Keys()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the gate's merges cannot be listed: %w", err)
|
||||
}
|
||||
raws := map[string][]byte{}
|
||||
for _, k := range keys {
|
||||
if e, err := merges.Get(k); err == nil && e != nil {
|
||||
raws[k] = e.Value
|
||||
}
|
||||
}
|
||||
ctx := context.Background()
|
||||
return MergedOf(raws, repository, merge, bridge.TreesVia(ctx, askCaller), bridge.FilesVia(ctx, askCaller))
|
||||
})
|
||||
if err := stdio.Serve("", tools); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[mesh-delivery-forge-bridge-tools] %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"mesh-delivery-forge-bridge/internal/bridge"
|
||||
|
||||
"mesh-delivery-forge-bridge/internal/groups"
|
||||
)
|
||||
|
||||
// 1585/T18, T19 (ADR 0633 §4): bridge_groups names each group the gate holds with its members' state and whether its end is
|
||||
// written (open or ended, with why), narrowed by group and by open; bridge_group_end_again writes an ask naming the group,
|
||||
// who asked and why, and answers what the bridge answered.
|
||||
func TestTheBridgeToolsReadTheGroupsAndAskTheEndAgain(t *testing.T) {
|
||||
h1, h2 := strings.Repeat("1", 40), strings.Repeat("2", 40)
|
||||
c1, c2 := strings.Repeat("c", 40), strings.Repeat("d", 40)
|
||||
enc := func(group string, state2 delivery.MergeState, c string) []byte {
|
||||
v, err := delivery.EncodeMerge(delivery.Merge{Group: group, Ask: "ask", Approved: map[string]string{"novox/a@1": h1, "novox/b@2": h2},
|
||||
Pulls: []delivery.MergePull{{Head: delivery.Head{Repository: "novox/a", Number: 1, Head: h1}, State: delivery.MergeMerged, Commit: c1},
|
||||
{Head: delivery.Head{Repository: "novox/b", Number: 2, Head: h2}, State: state2, Commit: c}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
lone, _ := delivery.EncodeMerge(delivery.Merge{Group: "solo", Ask: "ask", Approved: map[string]string{"novox/a@5": h1},
|
||||
Pulls: []delivery.MergePull{{Head: delivery.Head{Repository: "novox/a", Number: 5, Head: h1}, State: delivery.MergeMerged, Commit: c1}}})
|
||||
merges := map[string][]byte{"ended": enc("ended", delivery.MergeMerged, c2), "ready": enc("ready", delivery.MergeMerged, c2),
|
||||
"gathered": enc("gathered", delivery.MergeMerging, ""), "solo": lone, "bad": []byte("{")}
|
||||
performed := map[string]bool{}
|
||||
for _, c := range []string{c1, c2} {
|
||||
performed[groups.PublishedKey("novox/a", 1, c)], performed[groups.PublishedKey("novox/b", 2, c)] = true, true
|
||||
}
|
||||
// The state end-asks keeps the last ask of the group ready, answered, and one that does not read.
|
||||
answeredAsk, _ := json.Marshal(groups.EndAsk{V: 1, Group: "ready", Why: "lost", By: "lead@shanks", At: time.Date(2026, 11, 4, 22, 0, 0, 0, time.UTC),
|
||||
Written: true, Answer: "the end of the group is written again", Answered: time.Date(2026, 11, 4, 22, 0, 1, 0, time.UTC)})
|
||||
asks := map[string][]byte{"ready": answeredAsk, "gathered": []byte("{")}
|
||||
var asked groups.EndAsk
|
||||
var askedKey string
|
||||
ask := func(key string, value []byte) (groups.EndAsk, error) {
|
||||
askedKey = key
|
||||
a, err := groups.DecodeEndAsk(value)
|
||||
a.Written, a.Answer, a.Answered = true, "the end of the group is written again", time.Now()
|
||||
asked = a
|
||||
return a, err
|
||||
}
|
||||
tools := Tools(ask, func() time.Time { return time.Date(2026, 11, 4, 23, 0, 0, 0, time.UTC) },
|
||||
func() ([]groups.Row, []string, error) {
|
||||
rows, unread := groups.Rows(merges, performed, map[string]uint64{"ended": 7}, asks)
|
||||
return rows, unread, nil
|
||||
}, nil)
|
||||
call := func(name string, args map[string]any) any {
|
||||
for _, tl := range tools {
|
||||
if tl.Name == name {
|
||||
out, err := tl.Run(args)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
t.Fatalf("no tool %s", name)
|
||||
return nil
|
||||
}
|
||||
rowsOf := func(args map[string]any) map[string]groups.Row {
|
||||
raw, _ := json.Marshal(call(GroupsTool, args))
|
||||
var out struct {
|
||||
Groups []groups.Row `json:"groups"`
|
||||
Unread []string `json:"unread"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := map[string]groups.Row{}
|
||||
for _, r := range out.Groups {
|
||||
m[r.Group] = r
|
||||
}
|
||||
if len(args) == 0 && strings.Join(out.Unread, " ") != "bad end-asks.gathered" {
|
||||
t.Errorf("unread = %v, want bad and end-asks.gathered", out.Unread)
|
||||
}
|
||||
return m
|
||||
}
|
||||
all := rowsOf(map[string]any{})
|
||||
if len(all) != 3 || !all["ended"].Ended || all["ended"].EndedAt != 7 {
|
||||
t.Fatalf("groups = %+v", all)
|
||||
}
|
||||
if r := all["ready"]; !r.Open || !strings.Contains(r.Words, "bridge_group_end_again") || !r.Members[1].Published {
|
||||
t.Errorf("ready = %+v, want open, merged and published, its end the thing to say", r)
|
||||
}
|
||||
if a := all["ready"].LastEndAsk; a == nil || a.By != "lead@shanks" || a.Why != "lost" || !a.Written || a.Answer == "" || all["ended"].LastEndAsk != nil {
|
||||
t.Errorf("ready's last end ask = %+v, ended's %+v: want ready's ask and its answer, none for ended", a, all["ended"].LastEndAsk)
|
||||
}
|
||||
if r := all["gathered"]; !r.Open || !strings.Contains(r.Words, "gathered") || r.Members[1].State != string(delivery.MergeMerging) {
|
||||
t.Errorf("gathered = %+v", r)
|
||||
}
|
||||
if open := rowsOf(map[string]any{"open": true}); len(open) != 2 || open["ended"].Group != "" {
|
||||
t.Errorf("open = %+v, want ready and gathered", open)
|
||||
}
|
||||
if one := rowsOf(map[string]any{"group": "ready"}); len(one) != 1 {
|
||||
t.Errorf("group ready = %+v", one)
|
||||
}
|
||||
out := call(EndAgainTool, map[string]any{"group": "ready", "why": "the indexer never took it in"}).(map[string]any)
|
||||
if askedKey != "ready" || asked.Group != "ready" || asked.Why != "the indexer never took it in" || out["written"] != true {
|
||||
t.Errorf("the ask %q %+v, answered %v", askedKey, asked, out)
|
||||
}
|
||||
for _, tl := range tools {
|
||||
if tl.Name == EndAgainTool {
|
||||
if _, err := tl.Run(map[string]any{}); err == nil {
|
||||
t.Error("an ask naming no group was taken")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1585: bridge_merged_message answers the merged message as the bridge publishes it: for a lone pull request the same bytes
|
||||
// the event gives through Convert, for a member of a delivery group those with its group and members set as the process
|
||||
// sets them; a merge the gate does not hold, or a forge that does not answer, is said.
|
||||
t.Run("bridge_merged_message answers the merged message as published", func(t *testing.T) {
|
||||
h1, h2 := strings.Repeat("1", 40), strings.Repeat("2", 40)
|
||||
c1, c2 := strings.Repeat("c", 40), strings.Repeat("d", 40)
|
||||
trees := func(owner, repo, sha string) (string, map[string]string, error) {
|
||||
return "root-" + sha[:4], map[string]string{"": "r", "modules/a": "ta-" + sha[:4]}, nil
|
||||
}
|
||||
files := func(owner, repo, path, ref string) ([]byte, error) {
|
||||
if strings.HasSuffix(path, "module.json") {
|
||||
return []byte(`{"module":"a"}`), nil
|
||||
}
|
||||
return nil, bridge.ErrNotFound
|
||||
}
|
||||
published := func(repo string, number int, head, branch, merge string) forgemsg.Forge {
|
||||
body, _ := json.Marshal(map[string]any{"owner": "novox", "repo": repo, "number": number, "head": branch, "head_sha": head, "base": "main", "merge_commit_sha": merge})
|
||||
msg, err := bridge.Convert(bridge.MergedSubject, body, trees, files)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := forgemsg.DecodeForge(msg.Data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return f
|
||||
}
|
||||
lone, _ := delivery.EncodeMerge(delivery.Merge{Group: "solo", Ask: "a", Approved: map[string]string{"novox/a@5": h1},
|
||||
Pulls: []delivery.MergePull{{Head: delivery.Head{Repository: "novox/a", Number: 5, Head: h1}, State: delivery.MergeMerged, Commit: c1}}})
|
||||
group, _ := delivery.EncodeMerge(delivery.Merge{Group: "feat/g", Ask: "a", Approved: map[string]string{"novox/a@6": h1, "novox/b@7": h2},
|
||||
Pulls: []delivery.MergePull{{Head: delivery.Head{Repository: "novox/a", Number: 6, Head: h1}, State: delivery.MergeMerged, Commit: c1},
|
||||
{Head: delivery.Head{Repository: "novox/b", Number: 7, Head: h2}, State: delivery.MergeMerging}}})
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
raws map[string][]byte
|
||||
repository string
|
||||
merge string
|
||||
want forgemsg.Forge
|
||||
fail string
|
||||
}{
|
||||
{name: "a lone pull request", raws: map[string][]byte{"solo": lone}, repository: "novox/a", merge: c1, want: published("a", 5, h1, "solo", c1)},
|
||||
{name: "a member of a group", raws: map[string][]byte{"feat~g": group}, repository: "novox/a", merge: c1, want: func() forgemsg.Forge {
|
||||
f := published("a", 6, h1, "feat/g", c1)
|
||||
f.Group, f.Members = "feat/g", []delivery.Head{{Repository: "novox/a", Number: 6, Head: h1}, {Repository: "novox/b", Number: 7, Head: h2}}
|
||||
return f
|
||||
}()},
|
||||
{name: "a merge the gate does not hold", raws: map[string][]byte{"solo": lone, "bad": []byte("{")}, repository: "novox/a", merge: c2, fail: "hold no merge"},
|
||||
} {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
raw, err := MergedOf(c.raws, c.repository, c.merge, trees, files)
|
||||
if c.fail != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), c.fail) {
|
||||
t.Fatalf("err %v, want %q", err, c.fail)
|
||||
}
|
||||
return
|
||||
}
|
||||
want, _ := forgemsg.EncodeForge(c.want)
|
||||
if err != nil || !bytes.Equal(raw, want) {
|
||||
t.Fatalf("err %v, answer\n%s\nwant the published message\n%s", err, raw, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
if _, err := MergedOf(map[string][]byte{"solo": lone}, "novox/a", c1, func(string, string, string) (string, map[string]string, error) {
|
||||
return "", nil, errors.New("the forge does not answer")
|
||||
}, files); err == nil || !strings.Contains(err.Error(), "does not answer") {
|
||||
t.Errorf("a forge that does not answer: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -66,58 +65,12 @@ func callerOn(nc *nats.Conn, user string) caller {
|
||||
}
|
||||
}
|
||||
|
||||
// commitTreesTool is the forge's tool for a commit's trees (mesh-catalog modules/gitea, issue 1234): arguments owner,
|
||||
// repo and sha, answer {root, trees}, the root directory spelled "" (1234/AC4).
|
||||
const commitTreesTool = "gitea.gitea_commit_trees"
|
||||
|
||||
// treesVia asks the forge for a commit's trees through call.
|
||||
func treesVia(ctx context.Context, call caller) bridge.Trees {
|
||||
return func(owner, repo, sha string) (string, map[string]string, error) {
|
||||
raw, err := call(ctx, commitTreesTool, map[string]string{"owner": owner, "repo": repo, "sha": sha})
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
var a struct {
|
||||
Root string `json:"root"`
|
||||
Trees map[string]string `json:"trees"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return "", nil, fmt.Errorf("the answer of %s is not {root, trees}: %w", commitTreesTool, err)
|
||||
}
|
||||
if a.Root == "" {
|
||||
return "", nil, fmt.Errorf("the answer of %s names no root tree", commitTreesTool)
|
||||
}
|
||||
return a.Root, a.Trees, nil
|
||||
}
|
||||
return bridge.TreesVia(ctx, bridge.Caller(call))
|
||||
}
|
||||
|
||||
// getFileTool is the forge's tool for one file at a ref (mesh-catalog modules/gitea tools/index.ts gitea_get_file):
|
||||
// arguments owner, repo, path and ref, answer {file: {path, ref, sha, size, content}}, the content decoded to text
|
||||
// (client.ts getFile). A path the forge does not have fails with `Gitea API <path>: 404 <body>` (client.ts request).
|
||||
const getFileTool = "gitea.gitea_get_file"
|
||||
|
||||
// notFound is how the forge's error says 404 (client.ts request: `Gitea API ${path}: ${status} ${text}`; client.ts:670
|
||||
// recognises it as /: 404 /).
|
||||
var notFound = regexp.MustCompile(`: 404 `)
|
||||
|
||||
// filesVia asks the forge for a file through call. A file the forge says is not there is bridge.ErrNotFound.
|
||||
func filesVia(ctx context.Context, call caller) bridge.Files {
|
||||
return func(owner, repo, path, ref string) ([]byte, error) {
|
||||
raw, err := call(ctx, getFileTool, map[string]string{"owner": owner, "repo": repo, "path": path, "ref": ref})
|
||||
if err != nil {
|
||||
if notFound.MatchString(err.Error()) {
|
||||
return nil, fmt.Errorf("%w: %v", bridge.ErrNotFound, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var a struct {
|
||||
File *struct {
|
||||
Content *string `json:"content"`
|
||||
} `json:"file"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil || a.File == nil || a.File.Content == nil {
|
||||
return nil, fmt.Errorf("the answer of %s is not {file: {content}}: %v", getFileTool, err)
|
||||
}
|
||||
return []byte(*a.File.Content), nil
|
||||
}
|
||||
return bridge.FilesVia(ctx, bridge.Caller(call))
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -120,8 +121,8 @@ func TestARefusedActIsWrittenOnForgeOnceAndNotRetried(t *testing.T) { // 1341/AC
|
||||
if len(pub.subjects) != 1 || pub.subjects[0] != "mesh.stream.mesh-delivery-forge-bridge.event.forge.novox.mesh-delivery.refused" {
|
||||
t.Fatalf("published on %v", pub.subjects)
|
||||
}
|
||||
m, err := delivery.DecodeForge(pub.data[0])
|
||||
if err != nil || m.Kind != delivery.ForgeRefused || m.Act != "comment.1" || m.Number != 12 || m.Head != headA || m.Words != "the pull request is locked" {
|
||||
m, err := forgemsg.DecodeForge(pub.data[0])
|
||||
if err != nil || m.Kind != forgemsg.ForgeRefused || m.Act != "comment.1" || m.Number != 12 || m.Head != headA || m.Words != "the pull request is locked" {
|
||||
t.Errorf("message %+v err %v", m, err)
|
||||
}
|
||||
f.refuse = nil
|
||||
@@ -188,8 +189,8 @@ func TestARefusedMergeIsWrittenOnForgeOnceAndNotRetried(t *testing.T) { // 1342/
|
||||
if len(f.calls) != 1 || len(pub.data) != 1 {
|
||||
t.Fatalf("calls %d messages %d, want one each", len(f.calls), len(pub.data))
|
||||
}
|
||||
m, err := delivery.DecodeForge(pub.data[0])
|
||||
if err != nil || m.Kind != delivery.ForgeRefused || m.Repository != "novox/mesh-delivery" || m.Number != 12 || m.Head != headA ||
|
||||
m, err := forgemsg.DecodeForge(pub.data[0])
|
||||
if err != nil || m.Kind != forgemsg.ForgeRefused || m.Repository != "novox/mesh-delivery" || m.Number != 12 || m.Head != headA ||
|
||||
m.Act != "merge" || m.Words != "novox/mesh-delivery#12 is not merged: its head moved" {
|
||||
t.Errorf("message %+v err %v", m, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"mesh-delivery-forge-bridge/internal/groups"
|
||||
)
|
||||
|
||||
// endAsked answers one value of the state end-asks (the tool bridge_group_end_again, ADR 0633 §4): a value not yet answered
|
||||
// says the group's end again from the gate's merges, with the ask's time appended to the message id so the stream's duplicate
|
||||
// window does not drop it, and is written back by compare-and-set at its revision with whether it was written. A value
|
||||
// answered already (this bridge's own write) is left.
|
||||
func (g *grouper) endAsked(ctx context.Context, kv jetstream.KeyValue, key string, rev uint64, value []byte) {
|
||||
a, err := groups.DecodeEndAsk(value)
|
||||
if err != nil {
|
||||
g.logger.Printf("%s.%s holds what is not an end ask, left: %v", groups.EndAsksState, key, err)
|
||||
return
|
||||
}
|
||||
if a.Done() {
|
||||
return
|
||||
}
|
||||
a = g.answerEnd(ctx, key, a, time.Now())
|
||||
g.logger.Printf("the end of the group %q said again by %s (%s): %s", a.Group, a.By, a.Why, a.Answer)
|
||||
data, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if _, err := kv.Update(ctx, key, data, rev); err != nil && !errors.Is(err, jetstream.ErrKeyExists) && !errors.Is(err, jetstream.ErrKeyRevisionMismatch) {
|
||||
g.logger.Printf("%s.%s: the answer could not be written: %v", groups.EndAsksState, key, err)
|
||||
}
|
||||
}
|
||||
|
||||
// readMerges reads one key of the gate's merges state.
|
||||
func (g *grouper) readMerges(ctx context.Context, key string) ([]byte, uint64, bool, error) {
|
||||
if g.merges == nil {
|
||||
return nil, 0, false, errors.New("the bridge does not read the gate's merges (its bucket was not there at the start)")
|
||||
}
|
||||
value, rev, found, err := g.merges(ctx, key)
|
||||
return value, rev, found, err
|
||||
}
|
||||
|
||||
// answerEnd says the group's end again for an ask and returns the ask with its answer.
|
||||
func (g *grouper) answerEnd(ctx context.Context, key string, a groups.EndAsk, at time.Time) groups.EndAsk {
|
||||
a.Answered = at.UTC()
|
||||
switch merges, mrev, found, err := g.readMerges(ctx, key); {
|
||||
case err != nil:
|
||||
a.Answer = "the gate's merges " + key + " cannot be read: " + err.Error()
|
||||
case !found:
|
||||
a.Answer = "the gate holds no merge of the group " + a.Group
|
||||
case g.end(ctx, key, mrev, merges, groups.AgainStamp(a.At)):
|
||||
a.Written, a.Answer = true, "the end of the group is written again"
|
||||
default:
|
||||
a.Answer = "the end is not written: a member is not merged or its merged message is not written, or FORGE refused it (the bridge's journal says which)"
|
||||
}
|
||||
return a
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"slices"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
groupkeys "mesh-delivery-forge-bridge/internal/groups"
|
||||
)
|
||||
|
||||
// A delivery group's merges reach the indexer together (novox/hq ADR 0633 §4): the bridge sets the group on each
|
||||
// member's merged message, from the gate's merges state it reads (ADR 0437 §8), and once every approved member is
|
||||
// merged and its merged message written on FORGE it says the group's end, forge.group.merged, once.
|
||||
//
|
||||
// What it keeps of it is in its state performed, beside the gate's keys it acted on: `published.<hash>` for each
|
||||
// member's merged message written (the hash is the first 16 hex digits of the sha256 of `<repository>#<number>@<merge>`),
|
||||
// and `groups.<merges key>` once the group's end is written, its value the revision of the gate's merges key it was
|
||||
// worked out from. A group whose end is not written yet is open (bridge_groups reads them).
|
||||
|
||||
// merges reads one key of the gate's merges state.
|
||||
type mergesRead func(ctx context.Context, key string) (value []byte, rev uint64, found bool, err error)
|
||||
|
||||
// grouper sets a member's group and says a group's end.
|
||||
type grouper struct {
|
||||
merges mergesRead
|
||||
send func(ctx context.Context, subject string, data []byte, id string) error // writes on FORGE with the message id
|
||||
done revisions
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
// sendVia writes on FORGE through pub, with the message id.
|
||||
func sendVia(pub publisher) func(context.Context, string, []byte, string) error {
|
||||
return func(ctx context.Context, subject string, data []byte, id string) error {
|
||||
_, err := pub.Publish(ctx, subject, data, jetstream.WithMsgID(id))
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func kvMerges(kv jetstream.KeyValue) mergesRead {
|
||||
return func(ctx context.Context, key string) ([]byte, uint64, bool, error) {
|
||||
e, err := kv.Get(ctx, key)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return nil, 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, 0, false, err
|
||||
}
|
||||
return e.Value(), e.Revision(), true, nil
|
||||
}
|
||||
}
|
||||
|
||||
// groupOf is the gate's merge of the group a merged message's branch names, when the message's pull request is one of
|
||||
// its approved members and the group has more than one: a lone pull request is no group.
|
||||
func (g *grouper) groupOf(ctx context.Context, f forgemsg.Forge) (delivery.Merge, uint64, bool) {
|
||||
if g == nil || g.merges == nil || f.Branch == "" {
|
||||
return delivery.Merge{}, 0, false
|
||||
}
|
||||
key, err := delivery.MergesKey(f.Branch)
|
||||
if err != nil {
|
||||
return delivery.Merge{}, 0, false
|
||||
}
|
||||
value, rev, found, err := g.merges(ctx, key)
|
||||
if err != nil {
|
||||
g.logger.Printf("%s#%d: the gate's merges.%s cannot be read, so its merged message names no group: %v", f.Repository, f.Number, key, err)
|
||||
return delivery.Merge{}, 0, false
|
||||
}
|
||||
if !found {
|
||||
return delivery.Merge{}, 0, false
|
||||
}
|
||||
m, err := delivery.DecodeMerge(value)
|
||||
if err != nil {
|
||||
g.logger.Printf("%s#%d: the gate's merges.%s is not a merge, so its merged message names no group: %v", f.Repository, f.Number, key, err)
|
||||
return delivery.Merge{}, 0, false
|
||||
}
|
||||
if _, ok := m.Approved[delivery.ApprovedKey(f.Repository, f.Number)]; !ok || len(m.Approved) < 2 {
|
||||
return delivery.Merge{}, 0, false
|
||||
}
|
||||
return m, rev, true
|
||||
}
|
||||
|
||||
// member sets the group and its approved members on a merged message's body (ADR 0633 §4); any other body, and a
|
||||
// merge of no group, is left as it is.
|
||||
func (g *grouper) member(ctx context.Context, data []byte) ([]byte, forgemsg.Forge, bool) {
|
||||
f, err := forgemsg.DecodeForge(data)
|
||||
if err != nil || f.Kind != forgemsg.ForgeMerged {
|
||||
return data, f, false
|
||||
}
|
||||
m, _, ok := g.groupOf(ctx, f)
|
||||
if !ok {
|
||||
return data, f, false
|
||||
}
|
||||
f.Group, f.Members = m.Group, groupkeys.MembersOf(m)
|
||||
out, err := forgemsg.EncodeForge(f)
|
||||
if err != nil {
|
||||
g.logger.Printf("%s#%d: its merged message cannot name the group %q, written without: %v", f.Repository, f.Number, m.Group, err)
|
||||
return data, f, false
|
||||
}
|
||||
return out, f, true
|
||||
}
|
||||
|
||||
// published keeps that a member's merged message is written, and says the group's end when it was the last.
|
||||
func (g *grouper) published(ctx context.Context, f forgemsg.Forge) {
|
||||
if err := g.done.Put(ctx, groupkeys.PublishedKey(f.Repository, f.Number, f.Merge), 1); err != nil {
|
||||
g.logger.Printf("%s#%d: that its merged message is written is not kept, so the group's end waits for the next act: %v", f.Repository, f.Number, err)
|
||||
return
|
||||
}
|
||||
key, err := delivery.MergesKey(f.Branch)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
value, rev, found, err := g.merges(ctx, key)
|
||||
if err != nil || !found {
|
||||
return
|
||||
}
|
||||
g.ended(ctx, key, rev, value)
|
||||
}
|
||||
|
||||
// ended says the group's end (forge.group.merged) once every approved member of the gate's merge is merged and its
|
||||
// merged message written, after the last of them; never on a refusal (ADR 0441 §3: a held group merges on from it).
|
||||
// It is written once per group: `groups.<key>` in performed says it was. again says it is written again by hand
|
||||
// (bridge_group_end_again), with the time appended to the message id, so the duplicate window does not drop it.
|
||||
func (g *grouper) ended(ctx context.Context, key string, rev uint64, value []byte) {
|
||||
g.end(ctx, key, rev, value, "")
|
||||
}
|
||||
|
||||
func (g *grouper) end(ctx context.Context, key string, rev uint64, value []byte, again string) bool {
|
||||
m, err := delivery.DecodeMerge(value)
|
||||
if err != nil || len(m.Approved) < 2 {
|
||||
return false
|
||||
}
|
||||
if again == "" {
|
||||
if _, seen, err := g.done.Get(ctx, groupkeys.Key(key)); err == nil && seen {
|
||||
return false
|
||||
}
|
||||
}
|
||||
end := forgemsg.ForgeGroupEnd{Group: m.Group}
|
||||
for _, member := range groupkeys.MembersOf(m) {
|
||||
at := slices.IndexFunc(m.Pulls, func(p delivery.MergePull) bool {
|
||||
return delivery.SameRepository(p.Repository, member.Repository) && p.Number == member.Number
|
||||
})
|
||||
if at < 0 || m.Pulls[at].Commit == "" {
|
||||
return false // not every member is merged (a merge commit is written only for a merged pull): the group stays gathered
|
||||
}
|
||||
if _, ok, err := g.done.Get(ctx, groupkeys.PublishedKey(member.Repository, member.Number, m.Pulls[at].Commit)); err != nil || !ok {
|
||||
return false // its merged message is not written yet: said when it is (published)
|
||||
}
|
||||
end.Members = append(end.Members, forgemsg.GroupMerge{Repository: member.Repository, Number: member.Number, Merge: m.Pulls[at].Commit})
|
||||
}
|
||||
// The members in the order the gate merged them, so the last commit names the end.
|
||||
slices.SortStableFunc(end.Members, func(a, b forgemsg.GroupMerge) int {
|
||||
return slices.IndexFunc(m.Pulls, func(p delivery.MergePull) bool { return p.Commit == a.Merge }) -
|
||||
slices.IndexFunc(m.Pulls, func(p delivery.MergePull) bool { return p.Commit == b.Merge })
|
||||
})
|
||||
data, err := forgemsg.EncodeForgeGroupEnd(end)
|
||||
if err != nil {
|
||||
g.logger.Printf("the end of the group %q cannot be written: %v", m.Group, err)
|
||||
return false
|
||||
}
|
||||
id := groupkeys.EndID(m.Group, end.Members[len(end.Members)-1].Merge, "")
|
||||
if again != "" {
|
||||
id += "." + again
|
||||
}
|
||||
if err := g.send(ctx, forgeSubject(forgemsg.ForgeGroupEndEvent), data, id); err != nil {
|
||||
g.logger.Printf("the end of the group %q is not written on FORGE, written at the gate's next write or by bridge_group_end_again: %v", m.Group, err)
|
||||
return false
|
||||
}
|
||||
if err := g.done.Put(ctx, groupkeys.Key(key), rev); err != nil {
|
||||
g.logger.Printf("the end of the group %q is written and not kept: the stream's duplicate window drops a second one: %v", m.Group, err)
|
||||
}
|
||||
g.logger.Printf("the end of the group %q is written: %d members merged, the last %s", m.Group, len(end.Members), end.Members[len(end.Members)-1].Merge)
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
groupkeys "mesh-delivery-forge-bridge/internal/groups"
|
||||
)
|
||||
|
||||
// idPub records the subjects, bodies and message ids it is handed.
|
||||
type idPub struct {
|
||||
subjects, ids []string
|
||||
data [][]byte
|
||||
}
|
||||
|
||||
func (p *idPub) send(_ context.Context, subject string, data []byte, id string) error {
|
||||
p.subjects, p.data, p.ids = append(p.subjects, subject), append(p.data, data), append(p.ids, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAGroupsMembersNameItAndItsEndIsSaidOnce(t *testing.T) { // 1585/T16, T17, T19 (ADR 0633 §4)
|
||||
ctx := context.Background()
|
||||
h1, h2 := strings.Repeat("1", 40), strings.Repeat("2", 40)
|
||||
c1, c2 := strings.Repeat("c", 40), strings.Repeat("d", 40)
|
||||
merge := func(states ...delivery.MergePull) []byte {
|
||||
v, err := delivery.EncodeMerge(delivery.Merge{Group: "feat/take-in", Ask: "ask", Pulls: states,
|
||||
Approved: map[string]string{"novox/mesh-sdk@37": h1, "novox/mesh-delivery@9": h2}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
sdk := func(state delivery.MergeState, commit, words string) delivery.MergePull {
|
||||
return delivery.MergePull{Head: delivery.Head{Repository: "novox/mesh-sdk", Number: 37, Head: h1}, State: state, Commit: commit, Words: words}
|
||||
}
|
||||
dlv := func(state delivery.MergeState, commit string) delivery.MergePull {
|
||||
return delivery.MergePull{Head: delivery.Head{Repository: "novox/mesh-delivery", Number: 9, Head: h2}, State: state, Commit: commit}
|
||||
}
|
||||
gate := map[string][]byte{}
|
||||
var logs bytes.Buffer
|
||||
pub, done := &idPub{}, memRevisions{}
|
||||
g := &grouper{send: pub.send, done: done, logger: log.New(&logs, "", 0),
|
||||
merges: func(_ context.Context, key string) ([]byte, uint64, bool, error) {
|
||||
v, ok := gate[key]
|
||||
return v, 7, ok, nil
|
||||
}}
|
||||
mergedOf := func(repo string, number int, head, commit string) []byte {
|
||||
data, err := forgemsg.EncodeForge(forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: repo, Branch: "feat/take-in", Number: number, Head: head, Merge: commit, Root: "r"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
// A lone pull request's merged message carries no group.
|
||||
if _, f, ok := g.member(ctx, mergedOf("novox/hq", 4, h1, c1)); ok || f.Group != "" {
|
||||
t.Errorf("a lone pull request names the group %q", f.Group)
|
||||
}
|
||||
// The first member: its message names the group and both members; the group is not ended.
|
||||
gate["feat/take-in"] = merge(sdk(delivery.MergeMerged, c1, ""), dlv(delivery.MergeMerging, ""))
|
||||
data, f, ok := g.member(ctx, mergedOf("novox/mesh-sdk", 37, h1, c1))
|
||||
if back, err := forgemsg.DecodeForge(data); !ok || err != nil || back.Group != "feat/take-in" || len(back.Members) != 2 {
|
||||
t.Fatalf("a member's message: %+v %v", back, err)
|
||||
}
|
||||
g.published(ctx, f)
|
||||
// A refusal of the other member is no end (ADR 0441 §3: the group merges on from it later).
|
||||
gate["feat/take-in"] = merge(sdk(delivery.MergeMerged, c1, ""), delivery.MergePull{Head: delivery.Head{Repository: "novox/mesh-delivery", Number: 9, Head: h2}, State: delivery.MergeRefused, Words: "405"})
|
||||
g.ended(ctx, "feat/take-in", 8, gate["feat/take-in"])
|
||||
// Merged on at the gate before its merged message is written: still no end; then its message ends the group.
|
||||
gate["feat/take-in"] = merge(sdk(delivery.MergeMerged, c1, ""), dlv(delivery.MergeMerged, c2))
|
||||
g.ended(ctx, "feat/take-in", 9, gate["feat/take-in"])
|
||||
if len(pub.subjects) != 0 {
|
||||
t.Fatalf("an end before every member's merged message is written: %v", pub.subjects)
|
||||
}
|
||||
_, f, _ = g.member(ctx, mergedOf("novox/mesh-delivery", 9, h2, c2))
|
||||
g.published(ctx, f)
|
||||
g.ended(ctx, "feat/take-in", 10, gate["feat/take-in"]) // a later write of the gate says no second end
|
||||
if len(pub.subjects) != 1 || pub.subjects[0] != "mesh.stream.mesh-delivery-forge-bridge.event.forge.group.merged" || pub.ids[0] != "group-end.feat/take-in."+c2 {
|
||||
t.Fatalf("ends %v ids %v, want one forge.group.merged with id group-end.feat/take-in.%s", pub.subjects, pub.ids, c2)
|
||||
}
|
||||
end, err := forgemsg.DecodeForgeGroupEnd(pub.data[0])
|
||||
if err != nil || len(end.Members) != 2 || end.Members[0].Merge != c1 || end.Members[1].Merge != c2 {
|
||||
t.Errorf("the end = %+v %v, want both members in their merge order", end, err)
|
||||
}
|
||||
if rev, ok := done[groupkeys.Key("feat/take-in")]; !ok || rev != 7 {
|
||||
t.Errorf("performed %v: want groups.feat/take-in kept", done)
|
||||
}
|
||||
// Again by hand: written once more, its id not the first's.
|
||||
if !g.end(ctx, "feat/take-in", 10, gate["feat/take-in"], "20261104T2300") || pub.ids[1] == pub.ids[0] {
|
||||
t.Errorf("the end again by hand: ids %v", pub.ids)
|
||||
}
|
||||
// 1585/T19: an ask to say a group's end again is answered by the bridge from the gate's merges, with the ask's time on
|
||||
// the message id; not written while a member is not merged, with the reason; not for a group the gate does not hold.
|
||||
pull := func(repo string, n int, head string, state delivery.MergeState, commit string) delivery.MergePull {
|
||||
return delivery.MergePull{Head: delivery.Head{Repository: repo, Number: n, Head: head}, State: state, Commit: commit}
|
||||
}
|
||||
askMerge := func(second delivery.MergeState, commit2 string) []byte {
|
||||
v, err := delivery.EncodeMerge(delivery.Merge{Group: "feat/take-in", Ask: "ask", Approved: map[string]string{"novox/mesh-sdk@37": h1, "novox/mesh-delivery@9": h2},
|
||||
Pulls: []delivery.MergePull{pull("novox/mesh-sdk", 37, h1, delivery.MergeMerged, c1), pull("novox/mesh-delivery", 9, h2, second, commit2)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
ask := groupkeys.EndAsk{V: 1, Group: "feat/take-in", By: "operator@shanks", Why: "the indexer never took it in", At: time.Date(2026, 11, 4, 23, 0, 0, 123, time.UTC)}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
gate map[string][]byte
|
||||
published bool
|
||||
written bool
|
||||
answer string
|
||||
}{
|
||||
{"merged and published: written again", map[string][]byte{"feat/take-in": askMerge(delivery.MergeMerged, c2)}, true, true, "written again"},
|
||||
{"a member's message not written", map[string][]byte{"feat/take-in": askMerge(delivery.MergeMerged, c2)}, false, false, "not written"},
|
||||
{"a member not merged", map[string][]byte{"feat/take-in": askMerge(delivery.MergeMerging, "")}, true, false, "not written"},
|
||||
{"no merge at the gate", map[string][]byte{}, true, false, "holds no merge"},
|
||||
} {
|
||||
pub, done := &idPub{}, memRevisions{}
|
||||
if tc.published {
|
||||
done[groupkeys.PublishedKey("novox/mesh-sdk", 37, c1)], done[groupkeys.PublishedKey("novox/mesh-delivery", 9, c2)] = 1, 1
|
||||
}
|
||||
g := &grouper{send: pub.send, done: done, logger: log.New(&bytes.Buffer{}, "", 0),
|
||||
merges: func(_ context.Context, key string) ([]byte, uint64, bool, error) {
|
||||
v, ok := tc.gate[key]
|
||||
return v, 4, ok, nil
|
||||
}}
|
||||
got := g.answerEnd(ctx, "feat/take-in", ask, ask.At.Add(time.Second))
|
||||
if got.Written != tc.written || !strings.Contains(got.Answer, tc.answer) || !got.Done() || len(pub.ids) != map[bool]int{true: 1}[tc.written] {
|
||||
t.Errorf("%s: %+v, ids %v", tc.name, got, pub.ids)
|
||||
}
|
||||
if tc.written && pub.ids[0] != groupkeys.EndID("feat/take-in", c2, groupkeys.AgainStamp(ask.At)) {
|
||||
t.Errorf("%s: the id %s does not carry the ask's time", tc.name, pub.ids[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-13
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
)
|
||||
@@ -36,7 +37,7 @@ import (
|
||||
//
|
||||
// The lists are what mesh-controller's broker.PermissionsFor answers (internal/broker/nats.go:577) for a module principal
|
||||
// with the manifest of module.json: consumes the three gitea.pull events, invokes gitea.gitea_open_pulls,
|
||||
// gitea.gitea_commit_trees and gitea.gitea_get_file, state heads, and the stream forge; `**` is written `>` on the wire (modstreams.go
|
||||
// gitea.gitea_commit_trees and gitea.gitea_get_file, state heads, performed, inputs and end-asks (ADR 0633 §5's hand end), and the stream forge; `**` is written `>` on the wire (modstreams.go
|
||||
// WirePattern). The consumer's durable is <node>_<module> on EVENTS (nats.go consumerDurable). The controller's own
|
||||
// user keeps all rights, as it has.
|
||||
const busConf = `
|
||||
@@ -83,6 +84,12 @@ accounts {
|
||||
"$JS.FC.KV_mesh-delivery-forge-bridge_inputs.>"
|
||||
"$KV.mesh-delivery-forge-bridge_performed.>"
|
||||
"$KV.mesh-delivery-forge-bridge_inputs.>"
|
||||
"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-forge-bridge_end-asks.>"
|
||||
"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-forge-bridge_end-asks.>"
|
||||
"$JS.API.DIRECT.GET.KV_mesh-delivery-forge-bridge_end-asks.>"
|
||||
"$JS.API.STREAM.INFO.KV_mesh-delivery-forge-bridge_end-asks"
|
||||
"$JS.FC.KV_mesh-delivery-forge-bridge_end-asks.>"
|
||||
"$KV.mesh-delivery-forge-bridge_end-asks.>"
|
||||
"$JS.API.CONSUMER.CREATE.KV_mesh-delivery-gate_merges.>"
|
||||
"$JS.API.CONSUMER.DELETE.KV_mesh-delivery-gate_merges.>"
|
||||
"$JS.API.DIRECT.GET.KV_mesh-delivery-gate_merges.>"
|
||||
@@ -321,13 +328,13 @@ func TestBridgeWritesPullMessages(t *testing.T) { // 1235/AC2
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := delivery.DecodeForge(raw.Data)
|
||||
f, err := forgemsg.DecodeForge(raw.Data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := delivery.Forge{V: delivery.Version, Kind: "pull", Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: head,
|
||||
want := forgemsg.Forge{V: delivery.Version, Kind: "pull", Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: head,
|
||||
Action: "updated", Dirs: []string{"modules/a"}, DirsKnown: true, Manifests: map[string]string{"modules/a": "{}"}}
|
||||
if !delivery.SameForge(f, want) {
|
||||
if !forgemsg.SameForge(f, want) {
|
||||
t.Errorf("message = %+v, want %+v", f, want)
|
||||
}
|
||||
|
||||
@@ -416,16 +423,16 @@ func TestBridgeWritesMergedMessage(t *testing.T) { // 1236/AC4, 1448/AC5
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := delivery.DecodeForge(raw.Data)
|
||||
f, err := forgemsg.DecodeForge(raw.Data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := delivery.Forge{V: delivery.Version, Kind: "merged", Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: head,
|
||||
want := forgemsg.Forge{V: delivery.Version, Kind: "merged", Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: head,
|
||||
Merge: merge, Root: "r0", Trees: map[string]string{"": "t0", "modules/a": "t1"},
|
||||
Manifests: map[string]string{"": string(manifest), "modules/a": string(manifest)},
|
||||
// No mesh package: only the indexer computes what a fingerprint takes (ADR 0593 §2).
|
||||
Locks: map[string]map[string]string{"modules/a": {"go.sum": hex.EncodeToString(sha256Sum(sum))}}}
|
||||
if !delivery.SameForge(f, want) {
|
||||
if !forgemsg.SameForge(f, want) {
|
||||
t.Errorf("message = %+v, want %+v", f, want)
|
||||
}
|
||||
select {
|
||||
@@ -478,7 +485,7 @@ func TestBridgeSaysTheOpenPullsAtStart(t *testing.T) { // 1237/AC1
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f, err := delivery.DecodeForge(raw.Data); err != nil || f.Action != "updated" || f.DirsKnown || f.Branch != "feat/x" {
|
||||
if f, err := forgemsg.DecodeForge(raw.Data); err != nil || f.Action != "updated" || f.DirsKnown || f.Branch != "feat/x" {
|
||||
t.Errorf("%s: %+v err %v", repo, f, err)
|
||||
}
|
||||
}
|
||||
@@ -548,20 +555,20 @@ func TestBridgeReadsTheForgeAtStartAndAfterAReconnectAndHearsNoOlderEvent(t *tes
|
||||
t.Fatalf("FORGE holds %d messages (%v), want %d", info.State.Msgs, err, want)
|
||||
}
|
||||
}
|
||||
named := func(seq uint64) []delivery.OpenPull {
|
||||
named := func(seq uint64) []forgemsg.OpenPull {
|
||||
t.Helper()
|
||||
raw, err := forge.GetMsg(b.ctx, seq)
|
||||
if err != nil || raw.Subject != openSubject {
|
||||
t.Fatalf("message %d: %v %v, want the open message", seq, raw, err)
|
||||
}
|
||||
f, err := delivery.DecodeForge(raw.Data)
|
||||
f, err := forgemsg.DecodeForge(raw.Data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return f.Open
|
||||
}
|
||||
bothOpen := []delivery.OpenPull{{Repository: "novox/mesh-catalog", Number: 300}, {Repository: "novox/mesh-delivery", Number: 12}}
|
||||
same := func(got []delivery.OpenPull) bool {
|
||||
bothOpen := []forgemsg.OpenPull{{Repository: "novox/mesh-catalog", Number: 300}, {Repository: "novox/mesh-delivery", Number: 12}}
|
||||
same := func(got []forgemsg.OpenPull) bool {
|
||||
return len(got) == 2 && got[0] == bothOpen[0] && got[1] == bothOpen[1]
|
||||
}
|
||||
|
||||
@@ -713,7 +720,7 @@ func forgeMerged(t *testing.T, b *bed) []string {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if f, err := delivery.DecodeForge(raw.Data); err == nil && f.Kind == delivery.ForgeMerged {
|
||||
if f, err := forgemsg.DecodeForge(raw.Data); err == nil && f.Kind == forgemsg.ForgeMerged {
|
||||
out = append(out, f.Repository)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,9 +21,11 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"mesh-delivery-forge-bridge/internal/bridge"
|
||||
groupkeys "mesh-delivery-forge-bridge/internal/groups"
|
||||
)
|
||||
|
||||
// eventsStream is the shared stream every module's events are on; the module's consumer on it is the durable
|
||||
@@ -68,6 +70,12 @@ type publisher interface {
|
||||
// redelivered, and the log names its subject and why. A message it could not write stays unacknowledged: the server
|
||||
// delivers it again (the server's ack wait is the only clock).
|
||||
func handle(ctx context.Context, m delivered, pub publisher, trees bridge.Trees, files bridge.Files, kept bridge.Kept, logger *log.Logger) {
|
||||
handleGrouped(ctx, m, pub, trees, files, kept, nil, logger)
|
||||
}
|
||||
|
||||
// handleGrouped is handle; a merged message of a delivery group's member names its group (g, ADR 0633 §4; nil names
|
||||
// none), and the member's merged message written may end the group.
|
||||
func handleGrouped(ctx context.Context, m delivered, pub publisher, trees bridge.Trees, files bridge.Files, kept bridge.Kept, g *grouper, logger *log.Logger) {
|
||||
msg, err := bridge.ConvertKept(m.Subject(), m.Data(), trees, files, kept)
|
||||
switch {
|
||||
case errors.Is(err, bridge.ErrRetry):
|
||||
@@ -88,10 +96,18 @@ func handle(ctx context.Context, m delivered, pub publisher, trees bridge.Trees,
|
||||
if id := m.Headers().Get("x-event-id"); id != "" {
|
||||
opts = append(opts, jetstream.WithMsgID(id))
|
||||
}
|
||||
var member forgemsg.Forge
|
||||
grouped := false
|
||||
if g != nil {
|
||||
msg.Data, member, grouped = g.member(ctx, msg.Data)
|
||||
}
|
||||
if _, err := pub.Publish(ctx, forgeSubject(msg.Event), msg.Data, opts...); err != nil {
|
||||
logger.Printf("%s: %s could not be written on FORGE, left for redelivery: %v", m.Subject(), msg.Event, err)
|
||||
return
|
||||
}
|
||||
if grouped {
|
||||
g.published(ctx, member)
|
||||
}
|
||||
if msg.Said != "" {
|
||||
logger.Printf("%s: %s", m.Subject(), msg.Said)
|
||||
}
|
||||
@@ -136,17 +152,25 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
return err
|
||||
}
|
||||
perf := &performer{call: call, pub: rec, done: kvRevisions{performedKV}, logger: logger}
|
||||
// A delivery group's member names its group, and its end is said once (ADR 0633 §4), from the gate's merges.
|
||||
groups := &grouper{send: sendVia(js), done: kvRevisions{performedKV}, logger: logger}
|
||||
watching := make(chan error, 3)
|
||||
for _, w := range []struct {
|
||||
bucket string
|
||||
react func(ctx context.Context, key string, rev uint64, value []byte)
|
||||
}{{delivery.OwedBucket, perf.owed}, {delivery.MergesBucket, perf.merges}} {
|
||||
}{{delivery.OwedBucket, perf.owed}, {delivery.MergesBucket, func(ctx context.Context, key string, rev uint64, value []byte) {
|
||||
perf.merges(ctx, key, rev, value)
|
||||
groups.ended(ctx, key, rev, value)
|
||||
}}} {
|
||||
// The gate's buckets come with the gate and its grant; the bridge does not wait for them (no timer), and is started
|
||||
// again by its delivery when the gate arrives. A bucket refused or not answered is the same (issue 1945).
|
||||
kv := process.OptionalBucket(ctx, js, w.bucket, "nothing of it is performed until the bridge starts again", logger.Printf)
|
||||
if kv == nil {
|
||||
continue
|
||||
}
|
||||
if w.bucket == delivery.MergesBucket {
|
||||
groups.merges = kvMerges(kv)
|
||||
}
|
||||
go func() {
|
||||
watching <- watch(ctx, kv, func(key string, rev uint64, value []byte) {
|
||||
hctx, cancel := context.WithTimeout(ctx, syncDeadline)
|
||||
@@ -155,6 +179,16 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
})
|
||||
}()
|
||||
}
|
||||
// A person says a group's end again with bridge_group_end_again, which writes the state end-asks (ADR 0633 §4): watched, and
|
||||
// answered there; a state not there at this start is answered from the next start.
|
||||
if kv := process.OptionalBucket(ctx, js, groupkeys.EndAsksBucket, "bridge_group_end_again is not answered until the bridge starts again", logger.Printf); kv != nil {
|
||||
go func() {
|
||||
err := watch(ctx, kv, func(key string, rev uint64, value []byte) { groups.endAsked(ctx, kv, key, rev, value) })
|
||||
if err != nil && ctx.Err() == nil {
|
||||
logger.Printf("the end asks are no longer watched, so bridge_group_end_again is not answered until the bridge starts again: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
if err := said(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -171,7 +205,7 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
// Bounded: the turn is held, and no sync or other event goes on without it.
|
||||
hctx, cancel := context.WithTimeout(ctx, syncDeadline)
|
||||
defer cancel()
|
||||
handle(hctx, m, rec, treesVia(hctx, call), filesVia(hctx, call), inputs, logger)
|
||||
handleGrouped(hctx, m, rec, treesVia(hctx, call), filesVia(hctx, call), inputs, groups, logger)
|
||||
})
|
||||
}, logger.Printf)
|
||||
if err != nil {
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
|
||||
"mesh-delivery-forge-bridge/internal/bridge"
|
||||
)
|
||||
@@ -85,7 +85,7 @@ func TestReadableEventIsWrittenOnForgeAndAcked(t *testing.T) { // 1235/AC2 (unit
|
||||
if len(pub.subjects) != 1 || pub.subjects[0] != "mesh.stream.mesh-delivery-forge-bridge.event.forge.novox.mesh-delivery.pull" {
|
||||
t.Fatalf("published on %v", pub.subjects)
|
||||
}
|
||||
f, err := delivery.DecodeForge(pub.data[0])
|
||||
f, err := forgemsg.DecodeForge(pub.data[0])
|
||||
if err != nil || f.Number != 12 || f.Branch != "feat/x" || f.Head != head || f.Action != "updated" || !f.DirsKnown {
|
||||
t.Errorf("message %+v err %v", f, err)
|
||||
}
|
||||
@@ -107,11 +107,10 @@ func TestUnreadableEventIsTerminatedAndLogged(t *testing.T) { // 1235/AC5
|
||||
if !strings.Contains(logs.String(), "mesh.mod.gitea.event.pull.updated") || !strings.Contains(logs.String(), "head") {
|
||||
t.Errorf("log %q names neither the subject nor why", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnwrittenMessageIsNotAcked(t *testing.T) { // 1235 (a failed write is redelivered by the server, not retried here)
|
||||
var logs bytes.Buffer
|
||||
m, pub := &fakeMsg{subject: "mesh.mod.gitea.event.pull.updated", data: body(nil)}, &fakePub{err: errors.New("no stream")}
|
||||
// 1235: an unwritten message is not acked (a failed write is redelivered by the server, not retried here); merged here
|
||||
// from a test of its own, for the test budget.
|
||||
logs.Reset()
|
||||
m, pub = &fakeMsg{subject: "mesh.mod.gitea.event.pull.updated", data: body(nil)}, &fakePub{err: errors.New("no stream")}
|
||||
handle(context.Background(), m, pub, nil, someFiles, nil, log.New(&logs, "", 0))
|
||||
if m.acked != 0 || m.termd != 0 {
|
||||
t.Errorf("acked %d terminated %d, want neither", m.acked, m.termd)
|
||||
@@ -173,7 +172,7 @@ func TestMergeCallsTheToolOnceAndWritesTheMergedMessage(t *testing.T) { // 1236/
|
||||
if len(pub.subjects) != 1 || pub.subjects[0] != "mesh.stream.mesh-delivery-forge-bridge.event.forge.novox.mesh-delivery.merged" {
|
||||
t.Fatalf("published on %v", pub.subjects)
|
||||
}
|
||||
f, err := delivery.DecodeForge(pub.data[0])
|
||||
f, err := forgemsg.DecodeForge(pub.data[0])
|
||||
if err != nil || f.Root != "r0" || f.Trees[""] != "t0" || f.Trees["modules/a"] != "t1" || f.Merge == "" {
|
||||
t.Errorf("message %+v err %v", f, err)
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
|
||||
"mesh-delivery-forge-bridge/internal/bridge"
|
||||
)
|
||||
@@ -41,7 +41,7 @@ func (r *recorder) Publish(ctx context.Context, subject string, payload []byte,
|
||||
if err != nil {
|
||||
return ack, err
|
||||
}
|
||||
f, derr := delivery.DecodeForge(payload)
|
||||
f, derr := forgemsg.DecodeForge(payload)
|
||||
if derr != nil {
|
||||
return ack, nil
|
||||
}
|
||||
@@ -101,7 +101,7 @@ func syncOpen(ctx context.Context, call caller, pub publisher, heads headStore,
|
||||
}
|
||||
var differing []bridge.Message
|
||||
for _, m := range msgs {
|
||||
f, err := delivery.DecodeForge(m.Data)
|
||||
f, err := forgemsg.DecodeForge(m.Data)
|
||||
if err != nil {
|
||||
differing = append(differing, m)
|
||||
continue
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
)
|
||||
|
||||
// openAnswer is gitea_open_pulls's answer as mesh-catalog modules/gitea/client.ts:369-381 builds its entries inside {pulls}.
|
||||
@@ -43,7 +43,7 @@ func TestSyncOpenWritesOneUpdatedMessagePerOpenPull(t *testing.T) { // 1237/AC1,
|
||||
}
|
||||
want := []string{"mesh.stream.mesh-delivery-forge-bridge.event.forge.novox.mesh-delivery.pull", "mesh.stream.mesh-delivery-forge-bridge.event.forge.novox.mesh-catalog.pull"}
|
||||
for i, w := range want {
|
||||
f, err := delivery.DecodeForge(pub.data[i])
|
||||
f, err := forgemsg.DecodeForge(pub.data[i])
|
||||
if pub.subjects[i] != w || err != nil || f.Action != "updated" || f.DirsKnown || f.Branch != "feat/x" {
|
||||
t.Errorf("message %d on %s = %+v err %v", i, pub.subjects[i], f, err)
|
||||
}
|
||||
@@ -112,8 +112,8 @@ func TestSyncOpenWritesPullsForNewHeadsThenOneOpenMessageNamingAll(t *testing.T)
|
||||
if pub.subjects[1] != "mesh.stream.mesh-delivery-forge-bridge.event.forge.open" {
|
||||
t.Fatalf("last message on %s, want the open message after the pull messages", pub.subjects[1])
|
||||
}
|
||||
f, err := delivery.DecodeForge(pub.data[1])
|
||||
want := []delivery.OpenPull{{Repository: "novox/mesh-catalog", Number: 300}, {Repository: "novox/mesh-delivery", Number: 12}}
|
||||
f, err := forgemsg.DecodeForge(pub.data[1])
|
||||
want := []forgemsg.OpenPull{{Repository: "novox/mesh-catalog", Number: 300}, {Repository: "novox/mesh-delivery", Number: 12}}
|
||||
if err != nil || len(f.Open) != 2 || f.Open[0] != want[0] || f.Open[1] != want[1] {
|
||||
t.Errorf("open message = %+v err %v, want it to name %v, the pull request whose head did not differ among them", f, err, want)
|
||||
}
|
||||
@@ -140,10 +140,10 @@ func TestWhatIsWrittenOnFORGEIsKeptInTheBucket(t *testing.T) { // 1279/AC6
|
||||
pub := &fakePub{}
|
||||
heads := newHeads()
|
||||
r := &recorder{pub: pub, heads: heads, logger: log.New(&bytes.Buffer{}, "", 0)}
|
||||
write := func(f delivery.Forge) {
|
||||
write := func(f forgemsg.Forge) {
|
||||
t.Helper()
|
||||
ev, _ := delivery.ForgeEvent(f.Repository, f.Kind)
|
||||
raw, err := delivery.EncodeForge(f)
|
||||
ev, _ := forgemsg.ForgeEvent(f.Repository, f.Kind)
|
||||
raw, err := forgemsg.EncodeForge(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -151,19 +151,19 @@ func TestWhatIsWrittenOnFORGEIsKeptInTheBucket(t *testing.T) { // 1279/AC6
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
p := delivery.Forge{Kind: delivery.ForgePull, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: head, Action: delivery.PullUpdated}
|
||||
p := forgemsg.Forge{Kind: forgemsg.ForgePull, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: head, Action: forgemsg.PullUpdated}
|
||||
write(p)
|
||||
if heads.get("novox.mesh-delivery.12") != head {
|
||||
t.Fatalf("bucket %v", heads.vals)
|
||||
}
|
||||
p.Action = delivery.PullClosed
|
||||
p.Action = forgemsg.PullClosed
|
||||
write(p)
|
||||
if _, kept := heads.vals["novox.mesh-delivery.12"]; kept {
|
||||
t.Errorf("a closed pull request is still kept: %v", heads.vals)
|
||||
}
|
||||
p.Action = delivery.PullUpdated
|
||||
p.Action = forgemsg.PullUpdated
|
||||
write(p)
|
||||
write(delivery.Forge{Kind: delivery.ForgeMerged, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: head, Merge: "m", Root: "r", Trees: map[string]string{"": "t"}})
|
||||
write(forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: head, Merge: "m", Root: "r", Trees: map[string]string{"": "t"}})
|
||||
if _, kept := heads.vals["novox.mesh-delivery.12"]; kept {
|
||||
t.Errorf("a merged pull request is still kept: %v", heads.vals)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
// Package forgemsg is the forge bridge's contract on its own stream `forge` (novox/hq ADR 0633 §4): its messages (Forge,
|
||||
// one per pull request, merge, refusal or list of open pull requests) and a delivery group's end (ForgeGroupEnd). The
|
||||
// forge bridge writes them; the indexer and the gate read them. It is the forge bridge's own package: one module owns
|
||||
// these types, so they live with it and not in mesh-sdk (the operator, 2026-11-04).
|
||||
package forgemsg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Version is the version of every value this package writes and the only one it reads: mesh-sdk's delivery values'.
|
||||
const Version = delivery.Version
|
||||
|
||||
// ForgeStream is the forge bridge's stream as its manifest declares it; the broker derives the real names.
|
||||
const ForgeStream = "forge"
|
||||
|
||||
// The kinds of forge message, the last part of a forge event's name.
|
||||
const (
|
||||
ForgePull = "pull"
|
||||
ForgeMerged = "merged"
|
||||
ForgeRefused = "refused"
|
||||
ForgeOpen = "open"
|
||||
)
|
||||
|
||||
// OpenPull names an open pull request into the trunk: the repository as `<owner>/<name>` and the number.
|
||||
type OpenPull struct {
|
||||
Repository string `json:"repository"`
|
||||
Number int `json:"number"`
|
||||
}
|
||||
|
||||
// OpenEvent is the name of an open message: `forge.open`. It names no repository: it says every open pull request of
|
||||
// every repository the forge bridge reads (novox/hq ADR 0443 §5).
|
||||
func OpenEvent() string { return "forge." + ForgeOpen }
|
||||
|
||||
// The actions of a pull message.
|
||||
const (
|
||||
PullOpened = "opened"
|
||||
PullUpdated = "updated"
|
||||
PullClosed = "closed"
|
||||
)
|
||||
|
||||
// checkRepository refuses a repository that is not `<owner>/<name>`: exactly one slash, each part a valid name.
|
||||
func checkRepository(repository string) error {
|
||||
owner, name, ok := strings.Cut(repository, "/")
|
||||
if !ok || strings.Contains(name, "/") {
|
||||
return fmt.Errorf("delivery: the repository %q is not <owner>/<name>, with exactly one slash", repository)
|
||||
}
|
||||
if err := checkName("repository owner", owner); err != nil {
|
||||
return fmt.Errorf("delivery: the repository %q: %w", repository, err)
|
||||
}
|
||||
if err := checkName("repository name", name); err != nil {
|
||||
return fmt.Errorf("delivery: the repository %q: %w", repository, err)
|
||||
}
|
||||
if strings.Contains(owner, "/") {
|
||||
return fmt.Errorf("delivery: the repository %q is not <owner>/<name>, with exactly one slash", repository)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkForgeKind(kind string) error {
|
||||
switch kind {
|
||||
case ForgePull, ForgeMerged, ForgeRefused, ForgeOpen:
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("delivery: the forge kind %q is not pull, merged, refused or open", kind)
|
||||
}
|
||||
|
||||
// ForgeEvent is the name of a forge message of the given kind: `forge.<owner>.<name>.<kind>`.
|
||||
func ForgeEvent(repository, kind string) (string, error) {
|
||||
if err := checkForgeKind(kind); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if kind == ForgeOpen {
|
||||
return "", fmt.Errorf("delivery: an open message names no repository, its event is %s", OpenEvent())
|
||||
}
|
||||
if err := checkRepository(repository); err != nil {
|
||||
return "", err
|
||||
}
|
||||
owner, name, _ := strings.Cut(repository, "/")
|
||||
return "forge." + owner + "." + name + "." + kind, nil
|
||||
}
|
||||
|
||||
// ParseForgeEvent is the inverse of ForgeEvent.
|
||||
func ParseForgeEvent(event string) (repository, kind string, err error) {
|
||||
if event == OpenEvent() {
|
||||
return "", ForgeOpen, nil
|
||||
}
|
||||
parts := strings.Split(event, ".")
|
||||
if len(parts) != 4 || parts[0] != "forge" {
|
||||
return "", "", fmt.Errorf("delivery: %q is not a forge event, forge.<owner>.<name>.<kind>", event)
|
||||
}
|
||||
repository = parts[1] + "/" + parts[2]
|
||||
if _, err := ForgeEvent(repository, parts[3]); err != nil {
|
||||
return "", "", fmt.Errorf("delivery: %q is not a forge event: %w", event, err)
|
||||
}
|
||||
return repository, parts[3], nil
|
||||
}
|
||||
|
||||
// Forge is a message of the forge bridge's stream, trimmed to repository, branch, pull request and head. A pull
|
||||
// message says Action; a merged message carries the merge commit, the tree of every directory holding a
|
||||
// module.json and the repository's root tree; a refused message names the owed act it answers and the forge's own
|
||||
// words. A pull message may also carry the module directories its head changes (Dirs, in the order the forge said) and
|
||||
// DirsKnown, true when the forge could say them: known and empty is DirsKnown with no Dirs, not known is neither
|
||||
// (the forge's module_dirs and module_dirs_said, mesh-catalog modules/gitea/trunk.ts). Other messages carry neither.
|
||||
type Forge struct {
|
||||
V int `json:"v"`
|
||||
Kind string `json:"kind"`
|
||||
Repository string `json:"repository"`
|
||||
Branch string `json:"branch,omitempty"`
|
||||
Number int `json:"number,omitempty"`
|
||||
Head string `json:"head,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Dirs []string `json:"dirs,omitempty"`
|
||||
DirsKnown bool `json:"dirs-known,omitempty"`
|
||||
Merge string `json:"merge,omitempty"`
|
||||
Trees map[string]string `json:"trees,omitempty"`
|
||||
Root string `json:"root,omitempty"`
|
||||
// Manifests are the module.json bytes, exact, of each module directory: of the merge's trees on a merged message,
|
||||
// of Dirs at the head on a pull message. Locks are, of each such directory, its lock files' hex sha256 by path
|
||||
// relative to it (novox/hq ADR 0465 clause 1). Older writers send neither.
|
||||
Manifests map[string]string `json:"manifests,omitempty"`
|
||||
Locks map[string]map[string]string `json:"locks,omitempty"`
|
||||
// Paths are, of each module directory whose manifest names paths on every artifact, each named path's git object
|
||||
// id at the same commit as its manifest, by path relative to the directory (novox/hq ADR 0499 clause 3). The
|
||||
// indexer names the paths; a message without them gives such a module none. Older writers send none.
|
||||
Paths map[string]map[string]string `json:"paths,omitempty"`
|
||||
// Packages are, of each module directory, the mesh packages its lock file names (MeshPackagesOf; novox/hq ADR 0573
|
||||
// §2): the indexer keeps them in the module's inputs and takes each one's latest published version into its
|
||||
// fingerprint. Absent for a directory that uses none, and from an older writer.
|
||||
Packages map[string][]string `json:"packages,omitempty"`
|
||||
Act string `json:"act,omitempty"`
|
||||
Words string `json:"words,omitempty"`
|
||||
Open []OpenPull `json:"open,omitempty"`
|
||||
// RetiredPlacements is the placement files a bridge before ADR 0567 carried (ADR 0549 §2, ADR 0559 §2): placement is
|
||||
// the seat mesh-placement's state now and no repository holds it (ADR 0567 §11), so a message carrying them is
|
||||
// refused, loudly, rather than read with them dropped.
|
||||
RetiredPlacements json.RawMessage `json:"placements,omitempty"`
|
||||
RetiredRead bool `json:"placements-read,omitempty"`
|
||||
// Predefined is the catalogue's predefined.json, exact, at the merge commit of a merged message (novox/hq ADR 0565
|
||||
// §2): what the indexer keeps as its state `predefined`. PredefinedRead says the bridge read the merge and it holds
|
||||
// no predefined.json. Carried on a merged message only; an older bridge sends neither, and the indexer keeps what it
|
||||
// held.
|
||||
Predefined json.RawMessage `json:"predefined,omitempty"`
|
||||
PredefinedRead bool `json:"predefined-read,omitempty"`
|
||||
// Group is the delivery group a merged message's pull request was merged in, and Members every approved member of
|
||||
// that group with its approved head, this one included (novox/hq ADR 0633 §4): the forge bridge sets both from the
|
||||
// gate's merges state, and the indexer gathers the group's merges until the group's end (ForgeGroupEnd). A merge
|
||||
// with no group carries neither, and an older bridge sends neither; an older reader ignores both.
|
||||
Group string `json:"group,omitempty"`
|
||||
Members []delivery.Head `json:"members,omitempty"`
|
||||
}
|
||||
|
||||
// checkGroup refuses a group on anything but a merged message, a group without its members or members without a
|
||||
// group, a member that is not a repository's pull request at a head, a member named twice, and members that leave out
|
||||
// the message's own pull request.
|
||||
func (f Forge) checkGroup() error {
|
||||
if f.Group == "" && len(f.Members) == 0 {
|
||||
return nil
|
||||
}
|
||||
if f.Kind != ForgeMerged {
|
||||
return fmt.Errorf("delivery: only a merged message names its delivery group, not a %s message", f.Kind)
|
||||
}
|
||||
if f.Group == "" || len(f.Members) == 0 {
|
||||
return fmt.Errorf("delivery: a merged message names its group and the group's members together, or neither")
|
||||
}
|
||||
self := false
|
||||
seen := map[string]bool{}
|
||||
for _, m := range f.Members {
|
||||
if err := checkRepository(m.Repository); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Number <= 0 || m.Head == "" {
|
||||
return fmt.Errorf("delivery: the member %s of the group %q needs its pull request number and head", m.Repository, f.Group)
|
||||
}
|
||||
k := fmt.Sprintf("%s#%d", m.Repository, m.Number)
|
||||
if seen[k] {
|
||||
return fmt.Errorf("delivery: the group %q names its member %s twice", f.Group, k)
|
||||
}
|
||||
seen[k] = true
|
||||
self = self || (delivery.SameRepository(m.Repository, f.Repository) && m.Number == f.Number)
|
||||
}
|
||||
if !self {
|
||||
return fmt.Errorf("delivery: the members of the group %q leave out the merged pull request %s#%d", f.Group, f.Repository, f.Number)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkPredefined refuses the predefined list on anything but a merged message, a list that is not one JSON object, and
|
||||
// a list said both present and absent.
|
||||
func (f Forge) checkPredefined() error {
|
||||
if len(f.Predefined) == 0 && !f.PredefinedRead {
|
||||
return nil
|
||||
}
|
||||
if f.Kind != ForgeMerged {
|
||||
return fmt.Errorf("delivery: only a merged message carries the predefined list, not a %s message", f.Kind)
|
||||
}
|
||||
if len(f.Predefined) > 0 && f.PredefinedRead {
|
||||
return fmt.Errorf("delivery: a merged message carries the predefined list and says the merge holds none (predefined-read)")
|
||||
}
|
||||
var list map[string]json.RawMessage
|
||||
if len(f.Predefined) > 0 && json.Unmarshal(f.Predefined, &list) != nil {
|
||||
return fmt.Errorf("delivery: the predefined list a merged message carries is not one JSON object")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f Forge) check() error {
|
||||
if err := checkForgeKind(f.Kind); err != nil {
|
||||
return err
|
||||
}
|
||||
if f.Kind == ForgeOpen {
|
||||
return f.checkOpen()
|
||||
}
|
||||
if len(f.Open) > 0 {
|
||||
return fmt.Errorf("delivery: only an open message names open pull requests")
|
||||
}
|
||||
if len(f.RetiredPlacements) > 0 || f.RetiredRead {
|
||||
return fmt.Errorf("delivery: a forge message carries placement files, which no repository holds since ADR 0567: " +
|
||||
"placement is the seat mesh-placement's state")
|
||||
}
|
||||
if err := f.checkPredefined(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := f.checkGroup(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkRepository(f.Repository); err != nil {
|
||||
return err
|
||||
}
|
||||
switch f.Kind {
|
||||
case ForgePull:
|
||||
if f.Number <= 0 {
|
||||
return fmt.Errorf("delivery: a pull message needs its pull request number")
|
||||
}
|
||||
if f.Head == "" {
|
||||
return fmt.Errorf("delivery: a pull message needs its head")
|
||||
}
|
||||
switch f.Action {
|
||||
case PullOpened, PullUpdated, PullClosed:
|
||||
default:
|
||||
return fmt.Errorf("delivery: the pull action %q is not opened, updated or closed", f.Action)
|
||||
}
|
||||
if len(f.Dirs) > 0 && !f.DirsKnown {
|
||||
return fmt.Errorf("delivery: a pull message with module directories must say it knows them (dirs-known)")
|
||||
}
|
||||
dirs := map[string]bool{}
|
||||
for _, d := range f.Dirs {
|
||||
dirs[d] = true
|
||||
}
|
||||
if err := f.checkInputs(func(d string) bool { return dirs[d] }, "its module directories"); err != nil {
|
||||
return err
|
||||
}
|
||||
case ForgeMerged:
|
||||
if len(f.Dirs) > 0 || f.DirsKnown {
|
||||
return fmt.Errorf("delivery: a merged message carries no module directories")
|
||||
}
|
||||
if f.Number <= 0 {
|
||||
return fmt.Errorf("delivery: a merged message needs its pull request number")
|
||||
}
|
||||
if f.Head == "" {
|
||||
return fmt.Errorf("delivery: a merged message needs its head")
|
||||
}
|
||||
if f.Merge == "" {
|
||||
return fmt.Errorf("delivery: a merged message needs its merge commit")
|
||||
}
|
||||
if f.Root == "" {
|
||||
return fmt.Errorf("delivery: a merged message needs the repository's root tree")
|
||||
}
|
||||
// No trees is a merge that leaves the repository with no module.json anywhere (a documents repository, or one
|
||||
// whose last module was removed): it is written all the same, so the indexer takes the merge in and drops the
|
||||
// repository's modules (novox/hq issue 1126). An older writer always sent at least one tree, so none is unambiguous;
|
||||
// such a message carries no manifests, locks or paths (checkInputs below: each names a directory of its trees).
|
||||
for dir, tree := range f.Trees {
|
||||
if tree == "" {
|
||||
return fmt.Errorf("delivery: a merged message's tree of the directory %q is empty", dir)
|
||||
}
|
||||
}
|
||||
if err := f.checkInputs(func(d string) bool { _, ok := f.Trees[d]; return ok }, "its trees"); err != nil {
|
||||
return err
|
||||
}
|
||||
case ForgeRefused:
|
||||
if len(f.Dirs) > 0 || f.DirsKnown {
|
||||
return fmt.Errorf("delivery: a refused message carries no module directories")
|
||||
}
|
||||
if len(f.Manifests) > 0 || len(f.Locks) > 0 || len(f.Paths) > 0 {
|
||||
return fmt.Errorf("delivery: a refused message carries no manifests, lock hashes or named paths")
|
||||
}
|
||||
if f.Act == "" {
|
||||
return fmt.Errorf("delivery: a refused message needs the act it answers")
|
||||
}
|
||||
if f.Words == "" {
|
||||
return fmt.Errorf("delivery: a refused message needs the forge's words")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkInputs refuses a manifest or a lock hash of a directory that known does not know (of is what it knows them
|
||||
// by), an empty manifest and an empty lock path or hash, naming the directory.
|
||||
func (f Forge) checkInputs(known func(dir string) bool, of string) error {
|
||||
for dir, m := range f.Manifests {
|
||||
if !known(dir) {
|
||||
return fmt.Errorf("delivery: the manifest of the directory %q is not of %s", dir, of)
|
||||
}
|
||||
if m == "" {
|
||||
return fmt.Errorf("delivery: the manifest of the directory %q is empty", dir)
|
||||
}
|
||||
}
|
||||
for dir, locks := range f.Locks {
|
||||
if !known(dir) {
|
||||
return fmt.Errorf("delivery: the lock hashes of the directory %q are not of %s", dir, of)
|
||||
}
|
||||
for path, hash := range locks {
|
||||
if path == "" || hash == "" {
|
||||
return fmt.Errorf("delivery: the lock %q of the directory %q has an empty path or hash", path, dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
for dir, paths := range f.Paths {
|
||||
if !known(dir) {
|
||||
return fmt.Errorf("delivery: the named paths of the directory %q are not of %s", dir, of)
|
||||
}
|
||||
for path, object := range paths {
|
||||
if path == "" || object == "" {
|
||||
return fmt.Errorf("delivery: the named path %q of the directory %q has an empty path or object", path, dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkOpen refuses an open message that names a repository, a branch, a head or anything of the other kinds, or
|
||||
// an open pull request that is not `<owner>/<name>` and a number.
|
||||
func (f Forge) checkOpen() error {
|
||||
if f.Repository != "" || f.Branch != "" || f.Number != 0 || f.Head != "" || f.Action != "" || len(f.Dirs) > 0 || f.DirsKnown ||
|
||||
f.Merge != "" || len(f.Trees) > 0 || len(f.Manifests) > 0 || len(f.Locks) > 0 || len(f.Paths) > 0 || f.Root != "" || f.Act != "" || f.Words != "" || f.Group != "" || len(f.Members) > 0 {
|
||||
return fmt.Errorf("delivery: an open message carries only the open pull requests it names")
|
||||
}
|
||||
for _, p := range f.Open {
|
||||
if err := checkRepository(p.Repository); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Number <= 0 {
|
||||
return fmt.Errorf("delivery: the open pull request of %s has no number", p.Repository)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeForge is f as JSON at Version; it refuses a message missing what its kind needs.
|
||||
func EncodeForge(f Forge) ([]byte, error) {
|
||||
if err := f.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.V = Version
|
||||
return json.Marshal(f)
|
||||
}
|
||||
|
||||
// DecodeForge reads a forge message; it refuses an unknown version (ErrUnknownVersion) and what EncodeForge refuses.
|
||||
func DecodeForge(data []byte) (Forge, error) {
|
||||
var f Forge
|
||||
if err := decode(data, &f); err != nil {
|
||||
return Forge{}, err
|
||||
}
|
||||
if err := f.check(); err != nil {
|
||||
return Forge{}, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// SameForge says whether two forge messages are the same value.
|
||||
func SameForge(a, b Forge) bool { return reflect.DeepEqual(a, b) }
|
||||
|
||||
var nameOK = regexp.MustCompile(`^[-/_=a-zA-Z0-9]+$`)
|
||||
|
||||
// checkName refuses a name that is empty or holds a dot, a wildcard, whitespace or a character a bus key refuses
|
||||
// (mesh-sdk delivery's rule for a name).
|
||||
func checkName(kind, name string) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("delivery: the %s name is empty", kind)
|
||||
}
|
||||
if !nameOK.MatchString(name) {
|
||||
return fmt.Errorf("delivery: the %s name %q holds a dot, a wildcard, whitespace or a character a key refuses", kind, name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decode reads a value of Version, refusing another or none with delivery.ErrUnknownVersion, as mesh-sdk delivery's
|
||||
// values are read.
|
||||
func decode(data []byte, into any) error {
|
||||
var probe struct {
|
||||
V *int `json:"v"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &probe); err != nil {
|
||||
return fmt.Errorf("delivery: value is not JSON: %w", err)
|
||||
}
|
||||
if probe.V == nil {
|
||||
return fmt.Errorf("%w: no version in the value, this reader reads %d", delivery.ErrUnknownVersion, Version)
|
||||
}
|
||||
if *probe.V != Version {
|
||||
return fmt.Errorf("%w: the value is version %d, this reader reads %d", delivery.ErrUnknownVersion, *probe.V, Version)
|
||||
}
|
||||
if err := json.Unmarshal(data, into); err != nil {
|
||||
return fmt.Errorf("delivery: value does not fit its shape: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package forgemsg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
const oddManifest = "{\n\t\"module\": \"a\",\n \"z\": 1, \"a\": [ \"x\",\"y\" ],\n \"why\": \"é — <b> & \\n\"\n}\n\n"
|
||||
|
||||
func sampleForges() []Forge {
|
||||
return []Forge{
|
||||
{Kind: ForgePull, Repository: "novox/mesh-sdk", Branch: "feat/x", Number: 36, Head: "aaaa", Action: "opened"},
|
||||
{Kind: ForgeMerged, Repository: "novox/mesh-sdk", Number: 36, Head: "aaaa", Merge: "cccc",
|
||||
Trees: map[string]string{"modules/a": "t1"}, Root: "t0"},
|
||||
{Kind: ForgeRefused, Repository: "novox/mesh-sdk", Act: "novox/mesh-sdk.36.aaaa.view", Words: "403 forbidden"},
|
||||
{Kind: ForgeOpen, Open: []OpenPull{{Repository: "novox/mesh-sdk", Number: 36}, {Repository: "novox/mesh-control", Number: 7}}},
|
||||
// 1585/T1: a group member's merged message names its group and every approved member (ADR 0633 §4).
|
||||
{Kind: ForgeMerged, Repository: "novox/mesh-sdk", Number: 37, Head: "dddd", Merge: "eeee", Root: "t0",
|
||||
Group: "feat/take-in", Members: []delivery.Head{{Repository: "novox/mesh-sdk", Number: 37, Head: "dddd"}, {Repository: "novox/mesh-delivery", Number: 9, Head: "ffff"}}},
|
||||
}
|
||||
}
|
||||
|
||||
func mergedWithInputs() Forge {
|
||||
return Forge{Kind: ForgeMerged, Repository: "novox/mesh-sdk", Number: 36, Head: "aaaa", Merge: "cccc", Root: "t0",
|
||||
Trees: map[string]string{"a": "t1", "b": "t2"},
|
||||
Manifests: map[string]string{"a": oddManifest},
|
||||
Locks: map[string]map[string]string{"a": {"go.sum": strings.Repeat("ab", 32)}}}
|
||||
}
|
||||
|
||||
// TestForge holds the forge bridge's contract, moved whole from mesh-sdk go/delivery with its tests (1200/AC1, AC3,
|
||||
// AC4; 1279/AC5; 1442/AC1-AC4; 1585/T1).
|
||||
func TestForge(t *testing.T) {
|
||||
t.Run("events", func(t *testing.T) {
|
||||
ev, err := ForgeEvent("novox/mesh-sdk", ForgeMerged)
|
||||
if err != nil || ev != "forge.novox.mesh-sdk.merged" {
|
||||
t.Fatalf("forge event = %q, %v", ev, err)
|
||||
}
|
||||
if repo, kind, err := ParseForgeEvent(ev); err != nil || repo != "novox/mesh-sdk" || kind != ForgeMerged {
|
||||
t.Fatalf("parse forge event = %q %q %v", repo, kind, err)
|
||||
}
|
||||
for _, bad := range []struct{ repo, kind string }{
|
||||
{"", "pull"}, {"novox", "pull"}, {"a/b/c", "pull"}, {"/b", "pull"}, {"a/", "pull"},
|
||||
{"a.b/c", "pull"}, {"a/b c", "pull"}, {"a/b", "closed"}, {"a/b", ""}, {"a/b", ForgeOpen},
|
||||
} {
|
||||
if _, err := ForgeEvent(bad.repo, bad.kind); err == nil {
|
||||
t.Errorf("forge event %q %q accepted", bad.repo, bad.kind)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "forge.a.b", "forge.a.b.closed", "x.a.b.pull", "forge.a.b.pull.x", "forge..b.pull", ForgeGroupEndEvent} {
|
||||
if _, _, err := ParseForgeEvent(bad); err == nil {
|
||||
t.Errorf("parse forge event %q accepted", bad)
|
||||
}
|
||||
}
|
||||
if repo, kind, err := ParseForgeEvent(OpenEvent()); OpenEvent() != "forge.open" || err != nil || repo != "" || kind != ForgeOpen {
|
||||
t.Errorf("open event %q = %q %q %v", OpenEvent(), repo, kind, err)
|
||||
}
|
||||
})
|
||||
t.Run("round trip", func(t *testing.T) {
|
||||
fs := append(sampleForges(), mergedWithInputs())
|
||||
for _, f := range fs {
|
||||
data, err := EncodeForge(f)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", f.Kind, err)
|
||||
}
|
||||
var probe map[string]any
|
||||
if err := json.Unmarshal(data, &probe); err != nil || probe["v"] != float64(1) {
|
||||
t.Fatalf("%s: json %s does not carry v:1", f.Kind, data)
|
||||
}
|
||||
f.V = Version
|
||||
if got, err := DecodeForge(data); err != nil || !SameForge(got, f) {
|
||||
t.Errorf("forge %s round trip: %+v %v", f.Kind, got, err)
|
||||
}
|
||||
}
|
||||
// An empty list is an open message too: nothing is open.
|
||||
if data, err := EncodeForge(Forge{Kind: ForgeOpen, Open: []OpenPull{}}); err != nil {
|
||||
t.Errorf("empty open: %v", err)
|
||||
} else if f, err := DecodeForge(data); err != nil || f.Kind != ForgeOpen || len(f.Open) != 0 {
|
||||
t.Errorf("empty open round trip: %+v %v", f, err)
|
||||
}
|
||||
ge := ForgeGroupEnd{Group: "feat/take-in", Members: []GroupMerge{{Repository: "novox/mesh-sdk", Number: 37, Merge: "eeee"}}}
|
||||
data, err := EncodeForgeGroupEnd(ge)
|
||||
ge.V = Version
|
||||
if got, err2 := DecodeForgeGroupEnd(data); err != nil || err2 != nil || !SameForgeGroupEnd(got, ge) {
|
||||
t.Errorf("group end round trip: %+v %v %v", got, err, err2)
|
||||
}
|
||||
// An older bridge's message reads, and is written back byte for byte.
|
||||
old := `{"v":1,"kind":"merged","repository":"novox/mesh-sdk","number":36,"head":"aaaa","merge":"cccc","trees":{"a":"t1"},"root":"t0"}`
|
||||
if f, err := DecodeForge([]byte(old)); err != nil || f.Manifests != nil {
|
||||
t.Fatalf("%+v, %v", f, err)
|
||||
} else if data, err := EncodeForge(f); err != nil || string(data) != old {
|
||||
t.Errorf("%s, %v, want exactly %s", data, err, old)
|
||||
}
|
||||
// A merge leaving the repository with no module.json is a message too (issue 1126).
|
||||
f := sampleForges()[1]
|
||||
f.Trees = nil
|
||||
if data, err := EncodeForge(f); err != nil {
|
||||
t.Errorf("a merged message with no trees is refused: %v", err)
|
||||
} else if got, err := DecodeForge(data); err != nil || len(got.Trees) != 0 {
|
||||
t.Errorf("a merged message with no trees comes back as %+v, %v", got, err)
|
||||
}
|
||||
for name, dec := range map[string]func([]byte) error{
|
||||
"forge": func(b []byte) error { _, err := DecodeForge(b); return err },
|
||||
"group end": func(b []byte) error { _, err := DecodeForgeGroupEnd(b); return err },
|
||||
} {
|
||||
for _, in := range []string{`{"group":"g"}`, `{"v":2,"group":"g"}`} {
|
||||
if err := dec([]byte(in)); !errors.Is(err, delivery.ErrUnknownVersion) {
|
||||
t.Errorf("%s decode of %s = %v, want delivery.ErrUnknownVersion", name, in, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
t.Run("refused", func(t *testing.T) {
|
||||
at := func(i int, mut func(*Forge)) Forge { f := sampleForges()[i]; mut(&f); return f }
|
||||
in := func(mut func(*Forge)) Forge { f := mergedWithInputs(); mut(&f); return f }
|
||||
pullIn := func(mut func(*Forge)) Forge {
|
||||
f := Forge{Kind: ForgePull, Repository: "novox/mesh-sdk", Number: 1, Head: "h", Action: PullOpened,
|
||||
Dirs: []string{"a"}, DirsKnown: true, Manifests: map[string]string{"a": oddManifest},
|
||||
Locks: map[string]map[string]string{"a": {"go.sum": strings.Repeat("0", 64)}}}
|
||||
mut(&f)
|
||||
return f
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name, want string
|
||||
f Forge
|
||||
}{
|
||||
{"merged without merge commit", "merge", at(1, func(f *Forge) { f.Merge = "" })},
|
||||
{"merged without root", "root", at(1, func(f *Forge) { f.Root = "" })},
|
||||
{"merged with a manifest and no trees", "modules/a", at(1, func(f *Forge) { f.Trees = nil; f.Manifests = map[string]string{"modules/a": "{}"} })},
|
||||
{"merged with an empty tree", "modules/a", at(1, func(f *Forge) { f.Trees = map[string]string{"modules/a": ""} })},
|
||||
{"pull with an unknown action", "reopened", at(0, func(f *Forge) { f.Action = "reopened" })},
|
||||
{"pull without head", "head", at(0, func(f *Forge) { f.Head = "" })},
|
||||
{"refused without words", "words", at(2, func(f *Forge) { f.Words = "" })},
|
||||
{"refused without act", "act", at(2, func(f *Forge) { f.Act = "" })},
|
||||
{"unknown kind", "closed", Forge{Kind: "closed", Repository: "a/b"}},
|
||||
{"bad repository", "nope", Forge{Kind: ForgePull, Repository: "nope", Number: 1, Head: "h", Action: "opened"}},
|
||||
{"open with a repository", "only the open", Forge{Kind: ForgeOpen, Repository: "a/b", Open: []OpenPull{{Repository: "a/b", Number: 1}}}},
|
||||
{"open with a bad entry", "", Forge{Kind: ForgeOpen, Open: []OpenPull{{Repository: "a", Number: 1}}}},
|
||||
{"open with a zero number", "no number", Forge{Kind: ForgeOpen, Open: []OpenPull{{Repository: "a/b"}}}},
|
||||
{"pull naming open pull requests", "only an open message", Forge{Kind: ForgePull, Repository: "a/b", Number: 1, Head: "h", Action: "updated", Open: []OpenPull{{Repository: "a/b", Number: 1}}}},
|
||||
{"manifest outside trees", `"zz"`, in(func(f *Forge) { f.Manifests["zz"] = "{}" })},
|
||||
{"locks outside trees", `"yy"`, in(func(f *Forge) { f.Locks["yy"] = map[string]string{"go.sum": "h"} })},
|
||||
{"empty manifest", `"b"`, in(func(f *Forge) { f.Manifests["b"] = "" })},
|
||||
{"empty lock hash", `"a"`, in(func(f *Forge) { f.Locks["a"]["go.sum"] = "" })},
|
||||
{"named paths outside trees", `"other"`, in(func(f *Forge) { f.Paths = map[string]map[string]string{"other": {"src": "o"}} })},
|
||||
{"a named path with no object", `"src"`, in(func(f *Forge) { f.Paths = map[string]map[string]string{"a": {"src": ""}} })},
|
||||
{"placement files", "mesh-placement", in(func(f *Forge) { f.RetiredPlacements = []byte(`{"anchor":"{}"}`) })},
|
||||
{"placements read", "mesh-placement", in(func(f *Forge) { f.RetiredRead = true })},
|
||||
{"predefined not an object", "one JSON object", in(func(f *Forge) { f.Predefined = []byte(`["domain"]`) })},
|
||||
{"predefined both", "says the merge holds none", in(func(f *Forge) { f.Predefined = []byte(`{}`); f.PredefinedRead = true })},
|
||||
{"pull with the predefined list", "only a merged message", Forge{Kind: ForgePull, Repository: "novox/mesh-sdk", Number: 1, Head: "h", Action: PullOpened, PredefinedRead: true}},
|
||||
{"pull manifest outside dirs", `"b"`, pullIn(func(f *Forge) { f.Manifests["b"] = "{}" })},
|
||||
{"pull locks outside dirs", `"c"`, pullIn(func(f *Forge) { f.Locks["c"] = map[string]string{"go.sum": "h"} })},
|
||||
{"refused with manifests", "no manifests", Forge{Kind: ForgeRefused, Repository: "a/b", Act: "x", Words: "no", Manifests: map[string]string{"a": "{}"}}},
|
||||
{"open with locks", "only the open", Forge{Kind: ForgeOpen, Locks: map[string]map[string]string{"a": {"go.sum": "h"}}}},
|
||||
// 1585/T1: a group only on a merged message, with its members, the message's own pull request among them.
|
||||
{"pull with a group", "only a merged message names its delivery group", at(0, func(f *Forge) {
|
||||
f.Group, f.Members = "g", []delivery.Head{{Repository: "novox/mesh-sdk", Number: 36, Head: "aaaa"}}
|
||||
})},
|
||||
{"merged with a group and no members", "together", at(4, func(f *Forge) { f.Members = nil })},
|
||||
{"merged with members and no group", "together", at(4, func(f *Forge) { f.Group = "" })},
|
||||
{"merged with members leaving itself out", "leave out", at(4, func(f *Forge) { f.Members = f.Members[1:] })},
|
||||
{"merged with a member twice", "twice", at(4, func(f *Forge) { f.Members = append(f.Members, f.Members[0]) })},
|
||||
{"open with a group", "only the open pull requests", Forge{Kind: ForgeOpen, Group: "g"}},
|
||||
} {
|
||||
if _, err := EncodeForge(c.f); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: %v, want a refusal naming %q", c.name, err, c.want)
|
||||
}
|
||||
}
|
||||
// A member's repository in another case still names the merged pull request (10c91fba).
|
||||
f := sampleForges()[4]
|
||||
f.Members[0].Repository = "Novox/Mesh-SDK"
|
||||
if _, err := EncodeForge(f); err != nil {
|
||||
t.Errorf("a member in another case: %v", err)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name, want string
|
||||
e ForgeGroupEnd
|
||||
}{
|
||||
{"group end without members", "no member", ForgeGroupEnd{Group: "g"}},
|
||||
{"group end without a group", "names its group", ForgeGroupEnd{Members: []GroupMerge{{Repository: "a/b", Number: 1, Merge: "m"}}}},
|
||||
{"group end with a member without its merge", "merge commit", ForgeGroupEnd{Group: "g", Members: []GroupMerge{{Repository: "a/b", Number: 1}}}},
|
||||
{"group end with a member twice", "twice", ForgeGroupEnd{Group: "g", Members: []GroupMerge{{Repository: "a/b", Number: 1, Merge: "m"}, {Repository: "a/b", Number: 1, Merge: "m"}}}},
|
||||
} {
|
||||
if _, err := EncodeForgeGroupEnd(c.e); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: %v, want a refusal naming %q", c.name, err, c.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104002338-5a38a1221659
|
||||
@@ -0,0 +1,14 @@
|
||||
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=
|
||||
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
|
||||
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
|
||||
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
|
||||
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
|
||||
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
@@ -0,0 +1,78 @@
|
||||
package forgemsg
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
// ForgeGroupEndEvent is the name of a delivery group's end on the forge bridge's stream (novox/hq ADR 0633 §4): the
|
||||
// bridge publishes it once every approved member of the group is merged, after the last member's merged message. It
|
||||
// has three parts, so no repository's forge event (`forge.<owner>.<name>.<kind>`) is ever this name.
|
||||
const ForgeGroupEndEvent = "forge.group.merged"
|
||||
|
||||
// GroupMerge is one merged member of a delivery group: the repository as `<owner>/<name>`, its pull request and the
|
||||
// merge commit the forge made of it.
|
||||
type GroupMerge struct {
|
||||
Repository string `json:"repository"`
|
||||
Number int `json:"number"`
|
||||
Merge string `json:"merge"`
|
||||
}
|
||||
|
||||
// ForgeGroupEnd is the body of ForgeGroupEndEvent: the group and every member merged in it, with its merge commit.
|
||||
// The indexer takes the members it holds in as one source revision once it hears it (ADR 0633 §4).
|
||||
type ForgeGroupEnd struct {
|
||||
V int `json:"v"`
|
||||
Group string `json:"group"`
|
||||
Members []GroupMerge `json:"members"`
|
||||
}
|
||||
|
||||
func (e ForgeGroupEnd) check() error {
|
||||
if e.Group == "" {
|
||||
return fmt.Errorf("delivery: a group's end names its group")
|
||||
}
|
||||
if len(e.Members) == 0 {
|
||||
return fmt.Errorf("delivery: the end of the group %q names no member merged in it", e.Group)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, m := range e.Members {
|
||||
if err := checkRepository(m.Repository); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.Number <= 0 || m.Merge == "" {
|
||||
return fmt.Errorf("delivery: the member %s of the group %q's end needs its pull request number and merge commit", m.Repository, e.Group)
|
||||
}
|
||||
k := fmt.Sprintf("%s#%d", m.Repository, m.Number)
|
||||
if seen[k] {
|
||||
return fmt.Errorf("delivery: the end of the group %q names its member %s twice", e.Group, k)
|
||||
}
|
||||
seen[k] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EncodeForgeGroupEnd is e as JSON at Version; it refuses an end with no group or no member, and a member without its
|
||||
// pull request or merge commit.
|
||||
func EncodeForgeGroupEnd(e ForgeGroupEnd) ([]byte, error) {
|
||||
if err := e.check(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.V = Version
|
||||
return json.Marshal(e)
|
||||
}
|
||||
|
||||
// DecodeForgeGroupEnd reads a group's end; it refuses an unknown version (ErrUnknownVersion) and what
|
||||
// EncodeForgeGroupEnd refuses.
|
||||
func DecodeForgeGroupEnd(data []byte) (ForgeGroupEnd, error) {
|
||||
var e ForgeGroupEnd
|
||||
if err := decode(data, &e); err != nil {
|
||||
return ForgeGroupEnd{}, err
|
||||
}
|
||||
if err := e.check(); err != nil {
|
||||
return ForgeGroupEnd{}, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// SameForgeGroupEnd says whether two group ends are the same value.
|
||||
func SameForgeGroupEnd(a, b ForgeGroupEnd) bool { return reflect.DeepEqual(a, b) }
|
||||
@@ -3,12 +3,13 @@ module mesh-delivery-forge-bridge
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104002338-5a38a1221659
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg v0.0.0-00010101000000-000000000000
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/klauspost/compress v1.20.0 // indirect
|
||||
@@ -20,3 +21,5 @@ require (
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
)
|
||||
|
||||
replace git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg => ./forgemsg
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
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=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -145,11 +146,11 @@ func MergeActs(value []byte) ([]Act, error) {
|
||||
// RefusedMessage is the refused message the bridge writes on FORGE when the forge refuses an act: the act, the pull
|
||||
// request and head it was on, and the forge's own words.
|
||||
func RefusedMessage(a Act, words string) (Message, error) {
|
||||
event, err := delivery.ForgeEvent(a.Repository, delivery.ForgeRefused)
|
||||
event, err := forgemsg.ForgeEvent(a.Repository, forgemsg.ForgeRefused)
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
body, err := delivery.EncodeForge(delivery.Forge{Kind: delivery.ForgeRefused, Repository: a.Repository, Number: a.Number,
|
||||
body, err := forgemsg.EncodeForge(forgemsg.Forge{Kind: forgemsg.ForgeRefused, Repository: a.Repository, Number: a.Number,
|
||||
Head: a.Head, Act: a.Name, Words: words})
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -27,7 +28,7 @@ func owedValue(t *testing.T, repository string, number int, act string, o delive
|
||||
return key, b
|
||||
}
|
||||
|
||||
func TestOwedStatusIsACommitStatusCall(t *testing.T) { // 1341/AC1
|
||||
func TestOwedStatusIsACommitStatusCall(t *testing.T) { // 1341/AC1, K1
|
||||
key, v := owedValue(t, "novox/mesh-delivery", 12, "status=mesh/merge-gate",
|
||||
delivery.Owed{Act: delivery.OwedStatus, Context: "mesh/merge-gate", State: "pending", Text: "waiting", Target: "https://forge.example/novox/mesh-delivery/pulls/12"})
|
||||
a, err := OwedAct(key, v)
|
||||
@@ -42,6 +43,19 @@ func TestOwedStatusIsACommitStatusCall(t *testing.T) { // 1341/AC1
|
||||
if a.Repository != "novox/mesh-delivery" || a.Number != 12 || a.Head != headA || a.Name != "status=mesh/merge-gate" {
|
||||
t.Errorf("act = %+v", a)
|
||||
}
|
||||
// R4e findings K1 and R2-1 (issue 1301 S4; ADR 0503 §3, ADR 0521): no tool writes mesh/check, since a tool is told no
|
||||
// caller and node-tools invokes every tool; the forge module writes it from the checker's owed state, and the bridge
|
||||
// performs no mesh/check act.
|
||||
ck, cv := checkerKV(t, delivery.Owed{Act: delivery.OwedStatus, Context: delivery.StatusContextCheck, State: "success", Text: "pass", Target: "https://view.example/checks/g.1"})
|
||||
if a, err := OwedAct(ck, cv); err == nil || !strings.Contains(err.Error(), "mesh-delivery-checker.owed") {
|
||||
t.Fatalf("OwedAct(mesh/check) = %+v, %v; want refused, naming where the forge module reads it", a, err)
|
||||
}
|
||||
// Nor through the gate's own owed bucket: the context is refused whichever key carries it.
|
||||
gk, gv := owedValue(t, "novox/mesh-delivery", 12, "status=mesh/check",
|
||||
delivery.Owed{Act: delivery.OwedStatus, Context: delivery.StatusContextCheck, State: "success", Text: "pass", Target: "https://x.example"})
|
||||
if a, err := OwedAct(gk, gv); err == nil || !strings.Contains(err.Error(), "mesh-delivery-checker.owed") {
|
||||
t.Fatalf("OwedAct(gate's mesh/check) = %+v, %v; want refused", a, err)
|
||||
}
|
||||
}
|
||||
|
||||
// checkerKV is the status the checker owes, as it writes it (mesh-sdk go/delivery CheckerOwedKey, EncodeCheckerOwed).
|
||||
@@ -58,23 +72,6 @@ func checkerKV(t *testing.T, o delivery.Owed) (string, []byte) {
|
||||
return key, v
|
||||
}
|
||||
|
||||
// R4e findings K1 and R2-1 (issue 1301 S4; ADR 0503 §3, ADR 0521): no tool writes mesh/check, since a tool is told no
|
||||
// caller and node-tools invokes every tool; the forge module writes it from the checker's owed state, and the bridge
|
||||
// performs no mesh/check act.
|
||||
func TestOwedMeshCheckIsNoToolCall(t *testing.T) { // K1
|
||||
key, v := checkerKV(t, delivery.Owed{Act: delivery.OwedStatus, Context: delivery.StatusContextCheck, State: "success", Text: "pass", Target: "https://view.example/checks/g.1"})
|
||||
a, err := OwedAct(key, v)
|
||||
if err == nil || !strings.Contains(err.Error(), "mesh-delivery-checker.owed") {
|
||||
t.Fatalf("OwedAct(mesh/check) = %+v, %v; want refused, naming where the forge module reads it", a, err)
|
||||
}
|
||||
// Nor through the gate's own owed bucket: the context is refused whichever key carries it.
|
||||
gk, gv := owedValue(t, "novox/mesh-delivery", 12, "status=mesh/check",
|
||||
delivery.Owed{Act: delivery.OwedStatus, Context: delivery.StatusContextCheck, State: "success", Text: "pass", Target: "https://x.example"})
|
||||
if a, err := OwedAct(gk, gv); err == nil || !strings.Contains(err.Error(), "mesh-delivery-checker.owed") {
|
||||
t.Fatalf("OwedAct(gate's mesh/check) = %+v, %v; want refused", a, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwedViewNoteAndCommentCalls(t *testing.T) { // 1341/AC2
|
||||
cases := []struct {
|
||||
act string
|
||||
@@ -111,18 +108,12 @@ func TestOwedActWithoutAPullRequestNeedsOneOnlyWhereTheToolDoes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwedActOfADottedRepositoryIsUnescaped(t *testing.T) {
|
||||
key, v := owedValue(t, "novox/my.repo", 3, "comment.1", delivery.Owed{Act: delivery.OwedComment, Text: "x"})
|
||||
a, err := OwedAct(key, v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.Call.Args["repo"] != "my.repo" || a.Repository != "novox/my.repo" {
|
||||
t.Errorf("act = %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwedActThatDisagreesWithItsKeyIsRefused(t *testing.T) {
|
||||
// A dotted repository's key is unescaped, and its act names the repository whole.
|
||||
dk, dv := owedValue(t, "novox/my.repo", 3, "comment.1", delivery.Owed{Act: delivery.OwedComment, Text: "x"})
|
||||
if a, err := OwedAct(dk, dv); err != nil || a.Call.Args["repo"] != "my.repo" || a.Repository != "novox/my.repo" {
|
||||
t.Errorf("a dotted repository's act = %+v, %v", a, err)
|
||||
}
|
||||
key, _ := owedValue(t, "novox/mesh-delivery", 12, "view", delivery.Owed{Act: delivery.OwedView, Text: "v"})
|
||||
v, _ := delivery.EncodeOwed(delivery.Owed{Act: delivery.OwedComment, Text: "v"})
|
||||
if _, err := OwedAct(key, v); err == nil {
|
||||
@@ -166,13 +157,11 @@ func TestMergeActsHandOnlyTheMemberHandedWithEveryApprovedHead(t *testing.T) { /
|
||||
if acts[0].Name != "merge" || acts[0].Number != 12 || acts[0].Head != headA {
|
||||
t.Errorf("act = %+v", acts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeActsOfAMergeWithoutApprovedHeadsAreRefused(t *testing.T) {
|
||||
v := mergeValue(t, delivery.Merge{Group: "g", Ask: "a", Pulls: []delivery.MergePull{
|
||||
// A merge with no approved heads is refused: the forge's tool would refuse it with nothing to say which.
|
||||
v = mergeValue(t, delivery.Merge{Group: "g", Ask: "a", Pulls: []delivery.MergePull{
|
||||
{Head: delivery.Head{Repository: "novox/mesh-delivery", Number: 12, Head: headA}, State: delivery.MergeMerging}}})
|
||||
if _, err := MergeActs(v); err == nil {
|
||||
t.Error("a merge with no approved heads was taken: the forge's tool would refuse it with nothing to say which")
|
||||
t.Error("a merge with no approved heads was taken")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,13 +174,13 @@ func TestRefusedMessageNamesTheActAndTheForgesWords(t *testing.T) { // 1341/AC3,
|
||||
if m.Event != "forge.novox.mesh-delivery.refused" {
|
||||
t.Errorf("event = %q", m.Event)
|
||||
}
|
||||
f, err := delivery.DecodeForge(m.Data)
|
||||
f, err := forgemsg.DecodeForge(m.Data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := delivery.Forge{V: delivery.Version, Kind: delivery.ForgeRefused, Repository: "novox/mesh-delivery", Number: 12, Head: headA,
|
||||
want := forgemsg.Forge{V: delivery.Version, Kind: forgemsg.ForgeRefused, Repository: "novox/mesh-delivery", Number: 12, Head: headA,
|
||||
Act: "merge", Words: "novox/mesh-delivery#12 is not merged: its head moved"}
|
||||
if !delivery.SameForge(f, want) {
|
||||
if !forgemsg.SameForge(f, want) {
|
||||
t.Errorf("message = %+v", f)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
)
|
||||
|
||||
// ErrUnreadable is wrapped by every error that says an event cannot be turned into a message; it is never
|
||||
@@ -110,11 +110,11 @@ func ConvertKept(subject string, data []byte, trees Trees, files Files, kept Kep
|
||||
var action string
|
||||
switch subject {
|
||||
case UpdatedSubject:
|
||||
action = delivery.PullUpdated
|
||||
action = forgemsg.PullUpdated
|
||||
case ClosedSubject:
|
||||
action = delivery.PullClosed
|
||||
action = forgemsg.PullClosed
|
||||
case MergedSubject:
|
||||
action = delivery.PullClosed // unless it merged into the trunk, below
|
||||
action = forgemsg.PullClosed // unless it merged into the trunk, below
|
||||
default:
|
||||
return Message{}, fmt.Errorf("%w: %s", ErrNotHandled, subject)
|
||||
}
|
||||
@@ -136,29 +136,29 @@ func ConvertKept(subject string, data []byte, trees Trees, files Files, kept Kep
|
||||
return merged(subject, p, trees, files, kept)
|
||||
}
|
||||
said := ""
|
||||
if action == delivery.PullUpdated && p.Base != "" && p.Base != Trunk {
|
||||
if action == forgemsg.PullUpdated && p.Base != "" && p.Base != Trunk {
|
||||
// The checker judges pull requests into main only, and source.main names main's head only (novox/hq ADR 0484 §11): a
|
||||
// pull request into another base is no candidate, as it is none at the start (OpenPulls) and at its merge. It is
|
||||
// written closed, so a candidate an older head made is taken out, and said (issue 1422).
|
||||
action = delivery.PullClosed
|
||||
action = forgemsg.PullClosed
|
||||
said = fmt.Sprintf("%s/%s#%d at %s is into %s, not %s: written closed, so it is no candidate; the checker judges pull requests into %s only (novox/hq ADR 0484 §11)",
|
||||
p.Owner, p.Repo, p.Number, p.HeadSHA[:12], p.Base, Trunk, Trunk)
|
||||
}
|
||||
f := delivery.Forge{Kind: delivery.ForgePull, Repository: p.Owner + "/" + p.Repo, Branch: p.Head, Number: p.Number,
|
||||
f := forgemsg.Forge{Kind: forgemsg.ForgePull, Repository: p.Owner + "/" + p.Repo, Branch: p.Head, Number: p.Number,
|
||||
Head: p.HeadSHA, Action: action}
|
||||
// The directories are the forge's word only when it said it could read them (module_dirs_said).
|
||||
if action == delivery.PullUpdated && p.ModuleDirsSaid {
|
||||
if action == forgemsg.PullUpdated && p.ModuleDirsSaid {
|
||||
f.Dirs, f.DirsKnown = p.ModuleDirs, true
|
||||
var err error
|
||||
if f.Manifests, f.Locks, _, err = inputsRead(p.Owner, p.Repo, p.HeadSHA, p.ModuleDirs, files); err != nil {
|
||||
return Message{}, fmt.Errorf("%w: %s: the manifests of %s/%s at the head %s: %v", ErrRetry, subject, p.Owner, p.Repo, p.HeadSHA, err)
|
||||
}
|
||||
}
|
||||
event, err := delivery.ForgeEvent(f.Repository, delivery.ForgePull)
|
||||
event, err := forgemsg.ForgeEvent(f.Repository, forgemsg.ForgePull)
|
||||
if err != nil {
|
||||
return Message{}, unreadable(subject, "%v", err)
|
||||
}
|
||||
body, err := delivery.EncodeForge(f)
|
||||
body, err := forgemsg.EncodeForge(f)
|
||||
if err != nil {
|
||||
return Message{}, unreadable(subject, "%v", err)
|
||||
}
|
||||
@@ -313,11 +313,11 @@ func merged(subject string, p pull, trees Trees, files Files, kept Kept) (Messag
|
||||
if len(locks) == 0 {
|
||||
locks = nil
|
||||
}
|
||||
event, err := delivery.ForgeEvent(repository, delivery.ForgeMerged)
|
||||
event, err := forgemsg.ForgeEvent(repository, forgemsg.ForgeMerged)
|
||||
if err != nil {
|
||||
return Message{}, unreadable(subject, "%v", err)
|
||||
}
|
||||
f := delivery.Forge{Kind: delivery.ForgeMerged, Repository: repository, Branch: p.Head, Number: p.Number,
|
||||
f := forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: repository, Branch: p.Head, Number: p.Number,
|
||||
Head: p.HeadSHA, Merge: p.MergeSHA, Root: root, Trees: dirs, Manifests: manifests, Locks: locks}
|
||||
if repository == CatalogRepository {
|
||||
// The catalogue's predefined list at the merge (novox/hq ADR 0565 §2): the indexer keeps it for mesh-config.
|
||||
@@ -331,7 +331,7 @@ func merged(subject string, p pull, trees Trees, files Files, kept Kept) (Messag
|
||||
f.Predefined = json.RawMessage(raw)
|
||||
}
|
||||
}
|
||||
body, err := delivery.EncodeForge(f)
|
||||
body, err := forgemsg.EncodeForge(f)
|
||||
if err != nil {
|
||||
return Message{}, unreadable(subject, "%v", err)
|
||||
}
|
||||
@@ -373,12 +373,12 @@ func OpenPulls(answer []byte) (msgs []Message, skipped []string, err error) {
|
||||
skipped = append(skipped, name+": it names no owner, repo, number, branch or 40-hex head")
|
||||
continue
|
||||
}
|
||||
f := delivery.Forge{Kind: delivery.ForgePull, Repository: p.Owner + "/" + p.Repo, Branch: p.Head, Number: p.Number,
|
||||
Head: p.HeadSHA, Action: delivery.PullUpdated}
|
||||
event, err := delivery.ForgeEvent(f.Repository, delivery.ForgePull)
|
||||
f := forgemsg.Forge{Kind: forgemsg.ForgePull, Repository: p.Owner + "/" + p.Repo, Branch: p.Head, Number: p.Number,
|
||||
Head: p.HeadSHA, Action: forgemsg.PullUpdated}
|
||||
event, err := forgemsg.ForgeEvent(f.Repository, forgemsg.ForgePull)
|
||||
var body []byte
|
||||
if err == nil {
|
||||
body, err = delivery.EncodeForge(f)
|
||||
body, err = forgemsg.EncodeForge(f)
|
||||
}
|
||||
if err != nil {
|
||||
skipped = append(skipped, name+": "+err.Error())
|
||||
@@ -392,12 +392,12 @@ func OpenPulls(answer []byte) (msgs []Message, skipped []string, err error) {
|
||||
// Track says what a message the bridge wrote changes in the head it keeps for each pull request (the bucket heads,
|
||||
// novox/hq ADR 0443 §5): the key `<owner>.<name>.<number>`, and the head to keep under it, or "" to take the key away
|
||||
// (a closed pull request, a merged one). ok is false for a message that keeps no head.
|
||||
func Track(f delivery.Forge) (key, head string, ok bool) {
|
||||
func Track(f forgemsg.Forge) (key, head string, ok bool) {
|
||||
key = strings.ReplaceAll(f.Repository, "/", ".") + "." + strconv.Itoa(f.Number)
|
||||
switch {
|
||||
case f.Kind == delivery.ForgePull && f.Action != delivery.PullClosed:
|
||||
case f.Kind == forgemsg.ForgePull && f.Action != forgemsg.PullClosed:
|
||||
return key, f.Head, true
|
||||
case f.Kind == delivery.ForgePull || f.Kind == delivery.ForgeMerged:
|
||||
case f.Kind == forgemsg.ForgePull || f.Kind == forgemsg.ForgeMerged:
|
||||
return key, "", true
|
||||
}
|
||||
return "", "", false
|
||||
@@ -413,22 +413,22 @@ func OpenMessage(answer []byte) (Message, error) {
|
||||
if err := json.Unmarshal(answer, &a); err != nil || a.Pulls == nil {
|
||||
return Message{}, fmt.Errorf("the answer of gitea_open_pulls is not {pulls: [...]}: %v", err)
|
||||
}
|
||||
open := []delivery.OpenPull{}
|
||||
open := []forgemsg.OpenPull{}
|
||||
for _, p := range *a.Pulls {
|
||||
if p.Base != Trunk || p.Owner == "" || p.Repo == "" || p.Number <= 0 {
|
||||
continue
|
||||
}
|
||||
open = append(open, delivery.OpenPull{Repository: p.Owner + "/" + p.Repo, Number: p.Number})
|
||||
open = append(open, forgemsg.OpenPull{Repository: p.Owner + "/" + p.Repo, Number: p.Number})
|
||||
}
|
||||
slices.SortFunc(open, func(a, b delivery.OpenPull) int {
|
||||
slices.SortFunc(open, func(a, b forgemsg.OpenPull) int {
|
||||
if c := strings.Compare(a.Repository, b.Repository); c != 0 {
|
||||
return c
|
||||
}
|
||||
return a.Number - b.Number
|
||||
})
|
||||
body, err := delivery.EncodeForge(delivery.Forge{Kind: delivery.ForgeOpen, Open: open})
|
||||
body, err := forgemsg.EncodeForge(forgemsg.Forge{Kind: forgemsg.ForgeOpen, Open: open})
|
||||
if err != nil {
|
||||
return Message{}, fmt.Errorf("the open pull requests cannot be said: %w", err)
|
||||
}
|
||||
return Message{Event: delivery.OpenEvent(), Data: body}, nil
|
||||
return Message{Event: forgemsg.OpenEvent(), Data: body}, nil
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -54,13 +55,13 @@ func TestUpdatedBecomesAPullMessage(t *testing.T) { // 1235/AC2 (the conversion;
|
||||
if msg.Event != "forge.novox.mesh-delivery.pull" {
|
||||
t.Errorf("event = %q", msg.Event)
|
||||
}
|
||||
f, err := delivery.DecodeForge(msg.Data)
|
||||
f, err := forgemsg.DecodeForge(msg.Data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := delivery.Forge{V: delivery.Version, Kind: delivery.ForgePull, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12,
|
||||
Head: head, Action: delivery.PullUpdated, Dirs: []string{"modules/a"}, DirsKnown: true, Manifests: map[string]string{"modules/a": "{}"}}
|
||||
if !delivery.SameForge(f, want) {
|
||||
want := forgemsg.Forge{V: delivery.Version, Kind: forgemsg.ForgePull, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12,
|
||||
Head: head, Action: forgemsg.PullUpdated, Dirs: []string{"modules/a"}, DirsKnown: true, Manifests: map[string]string{"modules/a": "{}"}}
|
||||
if !forgemsg.SameForge(f, want) {
|
||||
t.Errorf("message = %+v, want %+v", f, want)
|
||||
}
|
||||
}
|
||||
@@ -70,7 +71,7 @@ func TestDirsNotSaidAreNotKnown(t *testing.T) { // 1235/AC3
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, _ := delivery.DecodeForge(msg.Data)
|
||||
f, _ := forgemsg.DecodeForge(msg.Data)
|
||||
if f.DirsKnown || len(f.Dirs) != 0 {
|
||||
t.Errorf("dirs = %v known = %v, want not known", f.Dirs, f.DirsKnown)
|
||||
}
|
||||
@@ -79,7 +80,7 @@ func TestDirsNotSaidAreNotKnown(t *testing.T) { // 1235/AC3
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, _ = delivery.DecodeForge(msg.Data)
|
||||
f, _ = forgemsg.DecodeForge(msg.Data)
|
||||
if !f.DirsKnown || len(f.Dirs) != 0 {
|
||||
t.Errorf("dirs = %v known = %v, want known and empty", f.Dirs, f.DirsKnown)
|
||||
}
|
||||
@@ -88,7 +89,7 @@ func TestDirsNotSaidAreNotKnown(t *testing.T) { // 1235/AC3
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f, _ = delivery.DecodeForge(msg.Data); f.DirsKnown || len(f.Dirs) != 0 {
|
||||
if f, _ = forgemsg.DecodeForge(msg.Data); f.DirsKnown || len(f.Dirs) != 0 {
|
||||
t.Errorf("dirs %v known %v without module_dirs_said", f.Dirs, f.DirsKnown)
|
||||
}
|
||||
}
|
||||
@@ -101,13 +102,13 @@ func TestClosedBecomesAClosedPullMessage(t *testing.T) { // 1235/AC4
|
||||
if msg.Event != "forge.novox.mesh-delivery.pull" {
|
||||
t.Errorf("event = %q", msg.Event)
|
||||
}
|
||||
f, err := delivery.DecodeForge(msg.Data)
|
||||
f, err := forgemsg.DecodeForge(msg.Data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := delivery.Forge{V: delivery.Version, Kind: delivery.ForgePull, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12,
|
||||
Head: head, Action: delivery.PullClosed}
|
||||
if !delivery.SameForge(f, want) {
|
||||
want := forgemsg.Forge{V: delivery.Version, Kind: forgemsg.ForgePull, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12,
|
||||
Head: head, Action: forgemsg.PullClosed}
|
||||
if !forgemsg.SameForge(f, want) {
|
||||
t.Errorf("message = %+v, want %+v", f, want)
|
||||
}
|
||||
// Issue 1422, ADR 0484 §11: a head pushed to a pull request into another base is written closed, read nothing for, and said;
|
||||
@@ -116,12 +117,12 @@ func TestClosedBecomesAClosedPullMessage(t *testing.T) { // 1235/AC4
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f, err = delivery.DecodeForge(msg.Data); err != nil || !delivery.SameForge(f, want) || !strings.Contains(msg.Said, "is into nox-mesh, not main") {
|
||||
if f, err = forgemsg.DecodeForge(msg.Data); err != nil || !forgemsg.SameForge(f, want) || !strings.Contains(msg.Said, "is into nox-mesh, not main") {
|
||||
t.Errorf("an update into nox-mesh is %+v (%v), said %q; want it closed and said", f, err, msg.Said)
|
||||
}
|
||||
for _, base := range []any{"main", nil} {
|
||||
msg, err = Convert(updatedSubject, updated(map[string]any{"base": base}), nil, anyFiles)
|
||||
if f, _ = delivery.DecodeForge(msg.Data); err != nil || f.Action != delivery.PullUpdated || msg.Said != "" {
|
||||
if f, _ = forgemsg.DecodeForge(msg.Data); err != nil || f.Action != forgemsg.PullUpdated || msg.Said != "" {
|
||||
t.Errorf("an update with base %v is %+v (%v), said %q; want it updated", base, f, err, msg.Said)
|
||||
}
|
||||
}
|
||||
@@ -170,12 +171,18 @@ func mustDrop(body []byte, key string) []byte {
|
||||
return b
|
||||
}
|
||||
|
||||
func TestOtherSubjectsAreNotHandled(t *testing.T) { // 1235
|
||||
func TestOtherSubjectsAreNotHandled(t *testing.T) { // 1235, 1236
|
||||
for _, s := range []string{"mesh.mod.gitea.event.something.else", "mesh.mod.other.event.pull.updated"} {
|
||||
if _, err := Convert(s, updated(nil), nil, anyFiles); !errors.Is(err, ErrNotHandled) {
|
||||
t.Errorf("%s: err = %v, want ErrNotHandled", s, err)
|
||||
}
|
||||
}
|
||||
// 1236: a merged event without a full merge commit is unreadable, and the forge is not asked.
|
||||
var calls []call
|
||||
_, err := Convert(mergedSubject, mergedBody(map[string]any{"merge_commit_sha": "abc"}), treesOf(&calls, "r0", map[string]string{"a": "t"}, nil), anyFiles)
|
||||
if !errors.Is(err, ErrUnreadable) || len(calls) != 0 {
|
||||
t.Errorf("merged without a merge commit: err = %v calls = %v", err, calls)
|
||||
}
|
||||
}
|
||||
|
||||
const merge = "89abcdef0123456789abcdef0123456789abcdef"
|
||||
@@ -222,13 +229,13 @@ func TestMergeIntoMainBecomesAMergedMessage(t *testing.T) { // 1236/AC1
|
||||
if msg.Event != "forge.novox.mesh-delivery.merged" {
|
||||
t.Errorf("event = %q", msg.Event)
|
||||
}
|
||||
f, err := delivery.DecodeForge(msg.Data)
|
||||
f, err := forgemsg.DecodeForge(msg.Data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := delivery.Forge{V: delivery.Version, Kind: delivery.ForgeMerged, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12,
|
||||
want := forgemsg.Forge{V: delivery.Version, Kind: forgemsg.ForgeMerged, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12,
|
||||
Head: head, Merge: merge, Root: "r0", Trees: answer, Manifests: map[string]string{"modules/a": "{}", "modules/b": "{}"}}
|
||||
if !delivery.SameForge(f, want) {
|
||||
if !forgemsg.SameForge(f, want) {
|
||||
t.Errorf("message = %+v, want %+v", f, want)
|
||||
}
|
||||
if len(calls) != 1 || calls[0] != (call{"novox", "mesh-delivery", merge}) {
|
||||
@@ -242,13 +249,13 @@ func TestMergeIntoAnotherBranchIsAClosedPull(t *testing.T) { // 1236/AC2
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := delivery.DecodeForge(msg.Data)
|
||||
f, err := forgemsg.DecodeForge(msg.Data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := delivery.Forge{V: delivery.Version, Kind: delivery.ForgePull, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12,
|
||||
Head: head, Action: delivery.PullClosed}
|
||||
if msg.Event != "forge.novox.mesh-delivery.pull" || !delivery.SameForge(f, want) {
|
||||
want := forgemsg.Forge{V: delivery.Version, Kind: forgemsg.ForgePull, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12,
|
||||
Head: head, Action: forgemsg.PullClosed}
|
||||
if msg.Event != "forge.novox.mesh-delivery.pull" || !forgemsg.SameForge(f, want) {
|
||||
t.Errorf("event %q message %+v, want a closed pull message", msg.Event, f)
|
||||
}
|
||||
if len(calls) != 0 {
|
||||
@@ -277,22 +284,14 @@ func TestNoModuleJSONAnywhereWritesAMergedMessageWithNoTrees(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := delivery.DecodeForge(msg.Data)
|
||||
want := delivery.Forge{V: delivery.Version, Kind: delivery.ForgeMerged, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12,
|
||||
f, err := forgemsg.DecodeForge(msg.Data)
|
||||
want := forgemsg.Forge{V: delivery.Version, Kind: forgemsg.ForgeMerged, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12,
|
||||
Head: head, Merge: merge, Root: "r0"}
|
||||
if err != nil || msg.Event != "forge.novox.mesh-delivery.merged" || !delivery.SameForge(f, want) || !strings.Contains(msg.Said, "no module.json") {
|
||||
if err != nil || msg.Event != "forge.novox.mesh-delivery.merged" || !forgemsg.SameForge(f, want) || !strings.Contains(msg.Said, "no module.json") {
|
||||
t.Errorf("event %q message %+v said %q, %v; want a merged message with no trees", msg.Event, f, msg.Said, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergedWithoutAMergeCommitIsUnreadable(t *testing.T) { // 1236
|
||||
var calls []call
|
||||
_, err := Convert(mergedSubject, mergedBody(map[string]any{"merge_commit_sha": "abc"}), treesOf(&calls, "r0", map[string]string{"a": "t"}, nil), anyFiles)
|
||||
if !errors.Is(err, ErrUnreadable) || len(calls) != 0 {
|
||||
t.Errorf("err = %v calls = %v", err, calls)
|
||||
}
|
||||
}
|
||||
|
||||
// openAnswer is gitea_open_pulls's answer as mesh-catalog modules/gitea/client.ts:369-381 builds each entry
|
||||
// (owner, repo, number, base, head, head_sha, html_url, required, statuses, checks, labels) inside {pulls}.
|
||||
func openAnswer(pulls ...map[string]any) []byte {
|
||||
@@ -311,12 +310,12 @@ func TestOpenPullsAreUpdatedPullMessagesWithUnknownDirs(t *testing.T) { // 1237/
|
||||
if err != nil || len(skipped) != 0 || len(msgs) != 2 {
|
||||
t.Fatalf("msgs %d skipped %v err %v", len(msgs), skipped, err)
|
||||
}
|
||||
for i, want := range []delivery.Forge{
|
||||
{V: delivery.Version, Kind: delivery.ForgePull, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: head, Action: delivery.PullUpdated},
|
||||
{V: delivery.Version, Kind: delivery.ForgePull, Repository: "novox/mesh-catalog", Branch: "feat/x", Number: 300, Head: h2, Action: delivery.PullUpdated},
|
||||
for i, want := range []forgemsg.Forge{
|
||||
{V: delivery.Version, Kind: forgemsg.ForgePull, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: head, Action: forgemsg.PullUpdated},
|
||||
{V: delivery.Version, Kind: forgemsg.ForgePull, Repository: "novox/mesh-catalog", Branch: "feat/x", Number: 300, Head: h2, Action: forgemsg.PullUpdated},
|
||||
} {
|
||||
f, err := delivery.DecodeForge(msgs[i].Data)
|
||||
if err != nil || !delivery.SameForge(f, want) {
|
||||
f, err := forgemsg.DecodeForge(msgs[i].Data)
|
||||
if err != nil || !forgemsg.SameForge(f, want) {
|
||||
t.Errorf("message %d = %+v err %v, want %+v", i, f, err, want)
|
||||
}
|
||||
if f.DirsKnown {
|
||||
@@ -328,14 +327,11 @@ func TestOpenPullsAreUpdatedPullMessagesWithUnknownDirs(t *testing.T) { // 1237/
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenPullIntoAnotherBranchGetsNoMessage(t *testing.T) { // 1237/AC4
|
||||
msgs, skipped, err := OpenPulls(openAnswer(openEntry("mesh-delivery", 12, "feat/y", "feat/x", head)))
|
||||
if err != nil || len(msgs) != 0 || len(skipped) != 0 {
|
||||
t.Errorf("msgs %d skipped %v err %v, want none", len(msgs), skipped, err)
|
||||
func TestOpenPullsUnreadableEntryIsNamedAndTheRestGo(t *testing.T) { // 1237, 1237/AC4
|
||||
// 1237/AC4: an open pull request into another branch gets no message.
|
||||
if msgs, skipped, err := OpenPulls(openAnswer(openEntry("mesh-delivery", 12, "feat/y", "feat/x", head))); err != nil || len(msgs) != 0 || len(skipped) != 0 {
|
||||
t.Errorf("into another branch: msgs %d skipped %v err %v, want none", len(msgs), skipped, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenPullsUnreadableEntryIsNamedAndTheRestGo(t *testing.T) { // 1237
|
||||
msgs, skipped, err := OpenPulls(openAnswer(openEntry("a", 1, "main", "b", "short"), openEntry("mesh-delivery", 12, "main", "feat/x", head)))
|
||||
if err != nil || len(msgs) != 1 || len(skipped) != 1 || !strings.Contains(skipped[0], "novox/a#1") {
|
||||
t.Errorf("msgs %d skipped %v err %v", len(msgs), skipped, err)
|
||||
@@ -355,15 +351,15 @@ func TestOpenMessageNamesEveryOpenPullIntoTheTrunk(t *testing.T) { // 1279/AC6
|
||||
if err != nil || m.Event != "forge.open" {
|
||||
t.Fatalf("message %+v err %v", m, err)
|
||||
}
|
||||
f, err := delivery.DecodeForge(m.Data)
|
||||
f, err := forgemsg.DecodeForge(m.Data)
|
||||
// Every one into the trunk, whether its head reads or not (it is open); none into another branch; in a fixed order.
|
||||
want := []delivery.OpenPull{{Repository: "novox/a", Number: 1}, {Repository: "novox/mesh-catalog", Number: 300}, {Repository: "novox/mesh-delivery", Number: 12}}
|
||||
if err != nil || f.Kind != delivery.ForgeOpen || len(f.Open) != 3 || f.Open[0] != want[0] || f.Open[1] != want[1] || f.Open[2] != want[2] {
|
||||
want := []forgemsg.OpenPull{{Repository: "novox/a", Number: 1}, {Repository: "novox/mesh-catalog", Number: 300}, {Repository: "novox/mesh-delivery", Number: 12}}
|
||||
if err != nil || f.Kind != forgemsg.ForgeOpen || len(f.Open) != 3 || f.Open[0] != want[0] || f.Open[1] != want[1] || f.Open[2] != want[2] {
|
||||
t.Errorf("open message = %+v err %v, want %v", f, err, want)
|
||||
}
|
||||
// Nothing open is a message too: the indexer then drops every candidate.
|
||||
m, err = OpenMessage([]byte(`{"pulls":[]}`))
|
||||
if f, derr := delivery.DecodeForge(m.Data); err != nil || derr != nil || f.Kind != delivery.ForgeOpen || len(f.Open) != 0 {
|
||||
if f, derr := forgemsg.DecodeForge(m.Data); err != nil || derr != nil || f.Kind != forgemsg.ForgeOpen || len(f.Open) != 0 {
|
||||
t.Errorf("empty answer: %+v %v %v", f, err, derr)
|
||||
}
|
||||
for _, bad := range []string{`{"pulls":null}`, `{}`, `x`} {
|
||||
@@ -374,24 +370,24 @@ func TestOpenMessageNamesEveryOpenPullIntoTheTrunk(t *testing.T) { // 1279/AC6
|
||||
}
|
||||
|
||||
func TestTrackSaysWhichHeadOfWhichPullRequestWasWritten(t *testing.T) { // 1279/AC6
|
||||
pullMsg := func(action string) delivery.Forge {
|
||||
return delivery.Forge{Kind: delivery.ForgePull, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: head, Action: action}
|
||||
pullMsg := func(action string) forgemsg.Forge {
|
||||
return forgemsg.Forge{Kind: forgemsg.ForgePull, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: head, Action: action}
|
||||
}
|
||||
for _, a := range []string{delivery.PullOpened, delivery.PullUpdated} {
|
||||
for _, a := range []string{forgemsg.PullOpened, forgemsg.PullUpdated} {
|
||||
if key, h, ok := Track(pullMsg(a)); key != "novox.mesh-delivery.12" || h != head || !ok {
|
||||
t.Errorf("%s: Track = %q %q %v, want the head kept under a bucket key", a, key, h, ok)
|
||||
}
|
||||
}
|
||||
// A closed pull request and a merged one are no longer kept: their key goes.
|
||||
if key, h, ok := Track(pullMsg(delivery.PullClosed)); key != "novox.mesh-delivery.12" || h != "" || !ok {
|
||||
if key, h, ok := Track(pullMsg(forgemsg.PullClosed)); key != "novox.mesh-delivery.12" || h != "" || !ok {
|
||||
t.Errorf("closed: Track = %q %q %v", key, h, ok)
|
||||
}
|
||||
merged := delivery.Forge{Kind: delivery.ForgeMerged, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: head, Merge: "m", Root: "r", Trees: map[string]string{"": "t"}}
|
||||
merged := forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: head, Merge: "m", Root: "r", Trees: map[string]string{"": "t"}}
|
||||
if key, h, ok := Track(merged); key != "novox.mesh-delivery.12" || h != "" || !ok {
|
||||
t.Errorf("merged: Track = %q %q %v", key, h, ok)
|
||||
}
|
||||
// The open message and the rest hold no head.
|
||||
if _, _, ok := Track(delivery.Forge{Kind: delivery.ForgeOpen}); ok {
|
||||
if _, _, ok := Track(forgemsg.Forge{Kind: forgemsg.ForgeOpen}); ok {
|
||||
t.Error("an open message was tracked")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// Caller calls a module's tool by its manifest key (`<module>.<tool>`) with a JSON body and returns the tool's result:
|
||||
// the bridge's process calls through the bus as its own principal, its tools bundle through the runtime (stdio.Ask).
|
||||
type Caller func(ctx context.Context, key string, body any) (json.RawMessage, error)
|
||||
|
||||
// CommitTreesTool is the forge's tool for a commit's trees (mesh-catalog modules/gitea, issue 1234): arguments owner,
|
||||
// repo and sha, answer {root, trees}, the root directory spelled "" (1234/AC4).
|
||||
const CommitTreesTool = "gitea.gitea_commit_trees"
|
||||
|
||||
// TreesVia asks the forge for a commit's trees through call.
|
||||
func TreesVia(ctx context.Context, call Caller) Trees {
|
||||
return func(owner, repo, sha string) (string, map[string]string, error) {
|
||||
raw, err := call(ctx, CommitTreesTool, map[string]string{"owner": owner, "repo": repo, "sha": sha})
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
var a struct {
|
||||
Root string `json:"root"`
|
||||
Trees map[string]string `json:"trees"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return "", nil, fmt.Errorf("the answer of %s is not {root, trees}: %w", CommitTreesTool, err)
|
||||
}
|
||||
if a.Root == "" {
|
||||
return "", nil, fmt.Errorf("the answer of %s names no root tree", CommitTreesTool)
|
||||
}
|
||||
return a.Root, a.Trees, nil
|
||||
}
|
||||
}
|
||||
|
||||
// GetFileTool is the forge's tool for one file at a ref (mesh-catalog modules/gitea tools/index.ts gitea_get_file):
|
||||
// arguments owner, repo, path and ref, answer {file: {path, ref, sha, size, content}}, the content decoded to text
|
||||
// (client.ts getFile). A path the forge does not have fails with `Gitea API <path>: 404 <body>` (client.ts request).
|
||||
const GetFileTool = "gitea.gitea_get_file"
|
||||
|
||||
// notFound is how the forge's error says 404 (client.ts request: `Gitea API ${path}: ${status} ${text}`; client.ts:670
|
||||
// recognises it as /: 404 /).
|
||||
var notFound = regexp.MustCompile(`: 404 `)
|
||||
|
||||
// FilesVia asks the forge for a file through call. A file the forge says is not there is ErrNotFound.
|
||||
func FilesVia(ctx context.Context, call Caller) Files {
|
||||
return func(owner, repo, path, ref string) ([]byte, error) {
|
||||
raw, err := call(ctx, GetFileTool, map[string]string{"owner": owner, "repo": repo, "path": path, "ref": ref})
|
||||
if err != nil {
|
||||
if notFound.MatchString(err.Error()) {
|
||||
return nil, fmt.Errorf("%w: %v", ErrNotFound, err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var a struct {
|
||||
File *struct {
|
||||
Content *string `json:"content"`
|
||||
} `json:"file"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil || a.File == nil || a.File.Content == nil {
|
||||
return nil, fmt.Errorf("the answer of %s is not {file: {content}}: %v", GetFileTool, err)
|
||||
}
|
||||
return []byte(*a.File.Content), nil
|
||||
}
|
||||
}
|
||||
|
||||
// MergedMessage is the merged message of a merge into the trunk, asked again: built as the bridge builds the one it writes
|
||||
// on FORGE (it is that event's body through ConvertKept), from the pull request's owner, repository, number, branch, head
|
||||
// and merge commit, which the gate's merges state holds. It reads the forge for every module directory (no kept inputs).
|
||||
func MergedMessage(owner, repo string, number int, branch, head, merge string, trees Trees, files Files) (Message, error) {
|
||||
body, err := json.Marshal(pull{Owner: owner, Repo: repo, Number: number, Head: branch, HeadSHA: head, Base: Trunk, MergeSHA: merge})
|
||||
if err != nil {
|
||||
return Message{}, err
|
||||
}
|
||||
return ConvertKept(MergedSubject, body, trees, files, nil)
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
)
|
||||
|
||||
// The bytes the forge answers in these tests are two real files of this repository, read from the working tree: the
|
||||
@@ -69,7 +69,7 @@ func TestMergedCarriesManifestsAndLocks(t *testing.T) { // 1448/AC1
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := delivery.DecodeForge(msg.Data)
|
||||
f, err := forgemsg.DecodeForge(msg.Data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -90,7 +90,7 @@ func TestMergedCarriesManifestsAndLocks(t *testing.T) { // 1448/AC1
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f, err = delivery.DecodeForge(msg.Data); err != nil {
|
||||
if f, err = forgemsg.DecodeForge(msg.Data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(f.RetiredPlacements) > 0 || f.RetiredRead || strings.Contains(string(msg.Data), `"placements`) {
|
||||
@@ -108,7 +108,7 @@ func TestMergedCarriesManifestsAndLocks(t *testing.T) { // 1448/AC1
|
||||
}
|
||||
forge.have[PredefinedFile] = []byte(`{"font-size":{"type":"integer","value":13}}`)
|
||||
msg, _ = Convert(mergedSubject, mergedBody(map[string]any{"repo": "mesh-catalog"}), treesOf(&calls, "r0", map[string]string{"modules/a": "t1"}, nil), forge.get)
|
||||
if f, err = delivery.DecodeForge(msg.Data); err != nil || string(f.Predefined) != string(forge.have[PredefinedFile]) || f.PredefinedRead {
|
||||
if f, err = forgemsg.DecodeForge(msg.Data); err != nil || string(f.Predefined) != string(forge.have[PredefinedFile]) || f.PredefinedRead {
|
||||
t.Errorf("predefined = %s (%v)", f.Predefined, err)
|
||||
}
|
||||
forge.calls = nil
|
||||
@@ -133,7 +133,7 @@ func TestPullWithKnownDirsCarriesTheSameAtTheHead(t *testing.T) { // 1448/AC2
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, _ := delivery.DecodeForge(msg.Data)
|
||||
f, _ := forgemsg.DecodeForge(msg.Data)
|
||||
if !reflect.DeepEqual(f.Manifests, map[string]string{"modules/a": string(manifest)}) || len(f.Locks) != 1 || len(f.Packages) != 0 {
|
||||
t.Errorf("manifests %v locks %v packages %v", f.Manifests, f.Locks, f.Packages)
|
||||
}
|
||||
@@ -146,7 +146,7 @@ func TestPullWithKnownDirsCarriesTheSameAtTheHead(t *testing.T) { // 1448/AC2
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, _ = delivery.DecodeForge(msg.Data)
|
||||
f, _ = forgemsg.DecodeForge(msg.Data)
|
||||
if f.Manifests != nil || f.Locks != nil || len(forge.calls) != 0 {
|
||||
t.Errorf("manifests %v locks %v calls %v", f.Manifests, f.Locks, forge.calls)
|
||||
}
|
||||
@@ -175,7 +175,7 @@ func TestAForgeErrorOrAMissingManifestIsRetryAndALockNotFoundIsLeftOut(t *testin
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f, _ := delivery.DecodeForge(msg.Data); f.Locks != nil {
|
||||
if f, _ := forgemsg.DecodeForge(msg.Data); f.Locks != nil {
|
||||
t.Errorf("locks = %v, want none for lock files the forge does not have", f.Locks)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
)
|
||||
|
||||
// ADR 0495 clause 1 with the lead's decision L8 of R4e: the forge bridge is no reader of a manifest's `paths`. A merge of a
|
||||
@@ -26,7 +26,7 @@ func TestAMergedMessageCarriesNoNamedPathsTheBridgeDerived(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := delivery.DecodeForge(msg.Data)
|
||||
f, err := forgemsg.DecodeForge(msg.Data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
// Package groups is what the forge bridge's process and its tools bundle share of a delivery group's end (novox/hq ADR
|
||||
// 0633 §4, issue 1585): the keys of the bridge's state performed, and the asks of the state end-asks, through which the
|
||||
// tool bridge_group_end_again asks the process to say a group's end again (the bridge serves no request subject, ADR 0437
|
||||
// §4, so a tool asks in a state the process watches, as indexer_take_in asks the indexer in takeins).
|
||||
package groups
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// The bridge's states the tools read and write.
|
||||
const (
|
||||
// PerformedState is the bridge's state of what it performed and published.
|
||||
PerformedState = "performed"
|
||||
// EndAsksState is the state of asks to say a group's end again, keyed by the gate's merges key of the group.
|
||||
EndAsksState = "end-asks"
|
||||
// EndAsksBucket is its bucket on the bus.
|
||||
EndAsksBucket = delivery.ForgeBridgeModule + "_" + EndAsksState
|
||||
)
|
||||
|
||||
// Key is the key of performed that says a group's end is written: `groups.<merges key>`, its value the revision of the
|
||||
// gate's merges key it was worked out from.
|
||||
func Key(mergesKey string) string { return "groups." + mergesKey }
|
||||
|
||||
// PublishedKey is the key of performed that says a member's merged message is written: `published.<16 hex digits of the
|
||||
// sha256 of <repository>#<number>@<merge>>`.
|
||||
func PublishedKey(repository string, number int, merge string) string {
|
||||
sum := sha256.Sum256([]byte(fmt.Sprintf("%s#%d@%s", strings.ToLower(repository), number, merge)))
|
||||
return "published." + hex.EncodeToString(sum[:8])
|
||||
}
|
||||
|
||||
// EndID is the message id of a group's end, and of one said again by hand at a time: the stream's duplicate window drops a
|
||||
// second publish of the same id.
|
||||
func EndID(group, lastMerge, again string) string {
|
||||
id := "group-end." + group + "." + lastMerge
|
||||
if again != "" {
|
||||
id += "." + again
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// AgainStamp is the time a hand ask appends to a group end's message id.
|
||||
func AgainStamp(at time.Time) string { return at.UTC().Format("20060102T150405.000000000Z") }
|
||||
|
||||
// EndAsk is a value of the end-asks state: which group's end a person asked to be said again, why, who and when, and, once
|
||||
// the bridge read it, its answer.
|
||||
type EndAsk struct {
|
||||
V int `json:"v"`
|
||||
Group string `json:"group"`
|
||||
Why string `json:"why,omitempty"`
|
||||
By string `json:"by,omitempty"`
|
||||
At time.Time `json:"at"`
|
||||
Written bool `json:"written,omitempty"`
|
||||
Answer string `json:"answer,omitempty"`
|
||||
Answered time.Time `json:"answered,omitzero"`
|
||||
}
|
||||
|
||||
// Done says the ask is answered.
|
||||
func (a EndAsk) Done() bool { return !a.Answered.IsZero() }
|
||||
|
||||
// NewEndAsk is the value bridge_group_end_again writes for a group: its key (the gate's merges key) and the value.
|
||||
func NewEndAsk(group, why, by string, at time.Time) (string, []byte, error) {
|
||||
group = strings.TrimSpace(group)
|
||||
if group == "" {
|
||||
return "", nil, errors.New("name the group whose end is said again (group)")
|
||||
}
|
||||
key, err := delivery.MergesKey(group)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
data, err := json.Marshal(EndAsk{V: 1, Group: group, Why: strings.TrimSpace(why), By: by, At: at.UTC()})
|
||||
return key, data, err
|
||||
}
|
||||
|
||||
// DecodeEndAsk reads an end-asks value.
|
||||
func DecodeEndAsk(data []byte) (EndAsk, error) {
|
||||
var a EndAsk
|
||||
if err := json.Unmarshal(data, &a); err != nil {
|
||||
return a, err
|
||||
}
|
||||
if a.V != 1 || a.Group == "" {
|
||||
return a, errors.New("an end-asks value of no version 1 naming no group")
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Row is one group as bridge_groups answers it: the gate's merge of it and what the bridge kept of its end.
|
||||
type Row struct {
|
||||
Key string `json:"key"`
|
||||
Group string `json:"group"`
|
||||
Members []RowMember `json:"members"`
|
||||
Ended bool `json:"ended"`
|
||||
EndedAt uint64 `json:"ended-at-revision,omitempty"`
|
||||
Open bool `json:"open"`
|
||||
Words string `json:"words"`
|
||||
// LastEndAsk is the last ask to say the group's end again (bridge_group_end_again), its who, why and when, and the
|
||||
// bridge's answer once it read it; absent when none was asked. The state end-asks keeps the last per group.
|
||||
LastEndAsk *EndAsk `json:"last-end-ask,omitempty"`
|
||||
}
|
||||
|
||||
// RowMember is one approved member of a group: its merge's state at the gate and whether its merged message is written.
|
||||
type RowMember struct {
|
||||
Repository string `json:"repository"`
|
||||
Number int `json:"number"`
|
||||
State string `json:"state"`
|
||||
Commit string `json:"commit,omitempty"`
|
||||
Published bool `json:"published"`
|
||||
}
|
||||
|
||||
// Rows are the groups the gate's merges hold with more than one member, each with what the bridge kept: ended (its end
|
||||
// written, the revision it was worked out from) or open (some member is not merged or its merged message not written, or its
|
||||
// end not written). merges is the gate's merges by key, performed the bridge's performed keys by key; unread are the gate's
|
||||
// keys that do not read. asks are the end-asks values by the same key; one that does not read is named unread as
|
||||
// `end-asks.<key>`.
|
||||
func Rows(merges map[string][]byte, performed map[string]bool, endedAt map[string]uint64, asks map[string][]byte) (rows []Row, unread []string) {
|
||||
lastAsk := map[string]*EndAsk{}
|
||||
for key, raw := range asks {
|
||||
a, err := DecodeEndAsk(raw)
|
||||
if err != nil {
|
||||
unread = append(unread, EndAsksState+"."+key)
|
||||
continue
|
||||
}
|
||||
lastAsk[key] = &a
|
||||
}
|
||||
for key, raw := range merges {
|
||||
m, err := delivery.DecodeMerge(raw)
|
||||
if err != nil {
|
||||
unread = append(unread, key)
|
||||
continue
|
||||
}
|
||||
if len(m.Approved) < 2 {
|
||||
continue
|
||||
}
|
||||
r := Row{Key: key, Group: m.Group}
|
||||
allMerged := true
|
||||
for _, p := range m.Pulls {
|
||||
pub := p.Commit != "" && performed[PublishedKey(p.Repository, p.Number, p.Commit)]
|
||||
r.Members = append(r.Members, RowMember{Repository: p.Repository, Number: p.Number, State: string(p.State), Commit: p.Commit, Published: pub})
|
||||
if p.State != delivery.MergeMerged || !pub {
|
||||
allMerged = false
|
||||
}
|
||||
}
|
||||
if len(m.Pulls) < len(m.Approved) {
|
||||
allMerged = false
|
||||
}
|
||||
r.EndedAt, r.Ended = endedAt[key]
|
||||
r.LastEndAsk = lastAsk[key]
|
||||
r.Open = !r.Ended
|
||||
switch {
|
||||
case r.Ended:
|
||||
r.Words = "its end is written"
|
||||
case allMerged:
|
||||
r.Words = "every member is merged and its message written, and its end is not written: bridge_group_end_again says it"
|
||||
default:
|
||||
r.Words = "gathered: not every member is merged and its merged message written"
|
||||
}
|
||||
rows = append(rows, r)
|
||||
}
|
||||
sortRows(rows)
|
||||
sort.Strings(unread)
|
||||
return rows, unread
|
||||
}
|
||||
|
||||
func sortRows(rows []Row) { sort.Slice(rows, func(i, j int) bool { return rows[i].Key < rows[j].Key }) }
|
||||
|
||||
// MembersOf are a merge's approved members with their heads, in the order of their keys.
|
||||
func MembersOf(m delivery.Merge) []delivery.Head {
|
||||
var out []delivery.Head
|
||||
for _, k := range slices.Sorted(func(yield func(string) bool) {
|
||||
for k := range m.Approved {
|
||||
if !yield(k) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}) {
|
||||
at := strings.LastIndex(k, "@")
|
||||
if at < 0 {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(k[at+1:])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, delivery.Head{Repository: k[:at], Number: n, Head: m.Approved[k]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -28,12 +28,13 @@ type state struct {
|
||||
}
|
||||
|
||||
type artifact struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Language string `json:"language"`
|
||||
System string `json:"system"`
|
||||
From string `json:"from"`
|
||||
Binary string `json:"binary"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Language string `json:"language"`
|
||||
System string `json:"system"`
|
||||
From string `json:"from"`
|
||||
Binary string `json:"binary"`
|
||||
Loads []string `json:"loads"`
|
||||
}
|
||||
|
||||
type manifest struct {
|
||||
@@ -44,6 +45,7 @@ type manifest struct {
|
||||
Streams []stream `json:"streams"`
|
||||
State []state `json:"state"`
|
||||
Reads []string `json:"reads"`
|
||||
Tools []string `json:"tools"`
|
||||
Invokes []string `json:"invokes"`
|
||||
OwnSecrets map[string]string `json:"own-secrets"`
|
||||
SecretsOwner string `json:"secrets-owner"`
|
||||
@@ -107,11 +109,15 @@ func TestManifestDeclarations(t *testing.T) { // 1235/AC1
|
||||
// §5), the revision of each key of the gate's buckets the bridge performed, so a key is performed once per
|
||||
// revision (ADR 0437 §8; issue 1341), and what a merge read of each module directory by its tree, so a merge reads
|
||||
// the forge only for the directories it changed (issue 1126).
|
||||
if !reflect.DeepEqual(m.State, []state{{Name: "heads", History: 1}, {Name: "performed", History: 1}, {Name: "inputs", History: 1}}) { // 1279/AC6, 1341, 1126
|
||||
t.Errorf("state = %+v, want the buckets heads, performed and inputs", m.State)
|
||||
if !reflect.DeepEqual(m.State, []state{{Name: "heads", History: 1}, {Name: "performed", History: 1}, {Name: "inputs", History: 1}, {Name: "end-asks", History: 1}}) { // 1279/AC6, 1341, 1126, 1585
|
||||
t.Errorf("state = %+v, want the buckets heads, performed, inputs and end-asks", m.State)
|
||||
}
|
||||
// No other state, no bus, no seat, no tool, no event (ADR 0437 §4; ADR 0521: no check.status).
|
||||
for _, k := range []string{"logs", "emits", "claims", "provides", "requires", "uses", "defines-seats", "tools", "seat", "seats"} {
|
||||
// 1585/T18: the tools read the groups and ask the bridge to say a group's end again, through its state end-asks. The tool bridge_merged_message answers a merge's merged message again (issue 1585).
|
||||
if want := []string{"bridge_group_end_again", "bridge_groups", "bridge_merged_message"}; !reflect.DeepEqual(sorted(m.Tools), want) {
|
||||
t.Errorf("tools = %v, want %v", m.Tools, want)
|
||||
}
|
||||
// No other state, no bus, no seat, no event (ADR 0437 §4; ADR 0521: no check.status).
|
||||
for _, k := range []string{"logs", "emits", "claims", "provides", "requires", "uses", "defines-seats", "seat", "seats"} {
|
||||
if _, ok := top[k]; ok {
|
||||
t.Errorf("the manifest declares %q: the bridge has none", k)
|
||||
}
|
||||
@@ -143,8 +149,8 @@ func TestManifestProcess(t *testing.T) { // 1235/AC1
|
||||
if _, bus := p["bus"]; bus {
|
||||
t.Errorf("the process declares a bus: it runs on its own broker credential")
|
||||
}
|
||||
if len(m.Build.Artifacts) != 1 {
|
||||
t.Fatalf("artifacts = %+v", m.Build.Artifacts)
|
||||
if len(m.Build.Artifacts) != 2 || m.Build.Artifacts[1].Name != "tools" || m.Build.Artifacts[1].From != "cmd/mesh-delivery-forge-bridge-tools" {
|
||||
t.Fatalf("artifacts = %+v, want the program and the tools bundle", m.Build.Artifacts)
|
||||
}
|
||||
a := m.Build.Artifacts[0]
|
||||
if st, err := os.Stat(a.From); err != nil || !st.IsDir() || a.Binary != "mesh-delivery-forge-bridge" || !strings.HasSuffix(a.From, a.Binary) {
|
||||
|
||||
@@ -13,7 +13,13 @@
|
||||
"state": [
|
||||
{ "name": "heads", "history": 1 },
|
||||
{ "name": "performed", "history": 1 },
|
||||
{ "name": "inputs", "history": 1 }
|
||||
{ "name": "inputs", "history": 1 },
|
||||
{ "name": "end-asks", "history": 1 }
|
||||
],
|
||||
"tools": [
|
||||
"bridge_groups",
|
||||
"bridge_group_end_again",
|
||||
"bridge_merged_message"
|
||||
],
|
||||
"reads": [
|
||||
"mesh-delivery-gate.merges",
|
||||
@@ -75,6 +81,17 @@
|
||||
"system": "arch",
|
||||
"from": "cmd/mesh-delivery-forge-bridge",
|
||||
"binary": "mesh-delivery-forge-bridge"
|
||||
},
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/mesh-delivery-forge-bridge-tools",
|
||||
"binary": "mesh-delivery-forge-bridge-tools",
|
||||
"loads": [
|
||||
"mesh-delivery-forge-bridge-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -191,8 +192,33 @@ func heldCounts(s stdio.KeptState) func() (index.OldSectionsCount, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// keptRead is a state the tools bundle reads, as the index package reads a store: Get and Keys; it writes nothing.
|
||||
type keptRead struct{ s stdio.KeptState }
|
||||
|
||||
func (k keptRead) Get(_ context.Context, key string) ([]byte, uint64, bool, error) {
|
||||
e, err := k.s.Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, 0, false, err
|
||||
}
|
||||
return e.Value, e.Revision, true, nil
|
||||
}
|
||||
|
||||
func (k keptRead) Keys(context.Context) ([]string, error) { return k.s.Keys() }
|
||||
|
||||
func (keptRead) Create(context.Context, string, []byte) error { return errReadOnly }
|
||||
|
||||
func (keptRead) Update(context.Context, string, []byte, uint64) error { return errReadOnly }
|
||||
|
||||
func (keptRead) Delete(context.Context, string, uint64) error { return errReadOnly }
|
||||
|
||||
var errReadOnly = errors.New("the tools bundle only reads this state")
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", append(Tools(askTakeIn(stdio.State(index.TakeInsState), 25*time.Second), time.Now, stdio.State(index.VerdictsState), heldCounts(stdio.State(index.OldSectionsState))), catalogTools()...)); err != nil {
|
||||
ask := askTakeIn(stdio.State(index.TakeInsState), 25*time.Second)
|
||||
tools := append(Tools(ask, time.Now, stdio.State(index.VerdictsState), heldCounts(stdio.State(index.OldSectionsState))), catalogTools()...)
|
||||
tools = append(tools, index.PendingTools(keptRead{stdio.State(index.PendingState)}, keptRead{stdio.State(index.OwnBuildStateName)}, ask, time.Now)...)
|
||||
tools = append(tools, index.TryCandidateTools(keptRead{stdio.State("candidates")}, keptRead{stdio.State(index.TryRecoveryState)}, ask, time.Now)...)
|
||||
if err := stdio.Serve("", tools); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[mesh-delivery-indexer-tools] %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// mergedTool is the forge bridge's tool that answers a merge's merged message again (issue 1585): arguments repository
|
||||
// and merge, answer {message}, the message whole as the bridge writes it on FORGE. The indexer's manifest lists it in
|
||||
// invokes, which is the grant to call it.
|
||||
const mergedTool = delivery.ForgeBridgeModule + ".bridge_merged_message"
|
||||
|
||||
// messageVia asks the forge bridge for a merge's merged message, for a held merge the indexer does not hold in memory (a
|
||||
// restart, a new build). An error is the bridge's own words.
|
||||
func messageVia(call toolCaller) func(ctx context.Context, repository, merge string) ([]byte, error) {
|
||||
return func(_ context.Context, repository, merge string) ([]byte, error) {
|
||||
raw, err := call(mergedTool, map[string]string{"repository": repository, "merge": merge})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var a struct {
|
||||
Message json.RawMessage `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil || len(a.Message) == 0 {
|
||||
return nil, fmt.Errorf("the answer of %s is not {message}: %v", mergedTool, err)
|
||||
}
|
||||
return a.Message, nil
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,10 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -17,9 +20,15 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/takein"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/graph"
|
||||
|
||||
"mesh-delivery-indexer/internal/index"
|
||||
)
|
||||
|
||||
// busConf is the shape of the composed accounts file (mesh-controller internal/broker): one account with JetStream, the
|
||||
@@ -42,22 +51,89 @@ accounts {
|
||||
// bus with the stream FORGE and the indexer's durable <node>_<module> on it, the buckets the controller makes from the
|
||||
// manifest's state (mesh-delivery-indexer_candidates, history 10) and mesh-controller_builds holding build records, and
|
||||
// no mesh-delivery-gate_source bucket.
|
||||
func TestIndexerWhole(t *testing.T) { // 1238/AC2, AC10
|
||||
js, ctx, candidates, _ := startIndexer(t, func(ctx context.Context, builds buildLog) {
|
||||
//
|
||||
// And the take-in step (ADR 0633, issue 1585 I8a, I8b): the indexer's states pending, verdicts, graph, taken and packages
|
||||
// and its stream take-in are there, mesh-build-go reported its toolchain, and the node-engine's composition answers the
|
||||
// build of the indexer that runs. A merge changing the indexer's own tree writes only the indexer's entry, its manifest,
|
||||
// its verdict and its key of the graph (and the packages it publishes); the rest is pending, said take-in.pending and
|
||||
// named by indexer_pending with the build it waits for. A group's first member is gathered meanwhile. Started again as
|
||||
// that build, the indexer takes the revision in and leaves the group gathered; the group's second member and its end
|
||||
// then make one take-in, said take-in.taken under the group's key.
|
||||
func TestIndexerWhole(t *testing.T) { // 1238/AC2, AC10; 1585/T14, T15
|
||||
var running atomic.Pointer[string]
|
||||
first := "fp-the-indexer-that-runs"
|
||||
running.Store(&first)
|
||||
// The forge bridge's tool bridge_merged_message (issue 1585): answers the merged message published for a merge, by
|
||||
// repository@merge, as the bridge does; a restarted indexer holds none in memory and asks it.
|
||||
var bridgeMessages sync.Map
|
||||
js, ctx, candidates, _, restart := startIndexerRun(t, func(ctx context.Context, builds buildLog) {
|
||||
// The builder's log entries of two builds' ends (ADR 0571 clause 1), as the builder appends them.
|
||||
builds.end(t, "novox/mesh-delivery", "a", "modules/a")
|
||||
builds.end(t, "novox/mesh-catalog", "c", "modules/c")
|
||||
})
|
||||
h1, h2 := "0123456789abcdef0123456789abcdef01234567", "89abcdef0123456789abcdef0123456789abcdef"
|
||||
for _, f := range []delivery.Forge{
|
||||
{Kind: delivery.ForgePull, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: h1, Action: delivery.PullUpdated, Dirs: []string{"modules/a"}, DirsKnown: true},
|
||||
{Kind: delivery.ForgePull, Repository: "novox/mesh-catalog", Branch: "feat/x", Number: 300, Head: h2, Action: delivery.PullUpdated, Dirs: []string{"modules/c"}, DirsKnown: true},
|
||||
} {
|
||||
data, err := delivery.EncodeForge(f)
|
||||
}, func(ctx context.Context, js jetstream.JetStream) {
|
||||
// The states and the stream the controller makes from the indexer's manifest (old sections, ADR 0595 §11).
|
||||
for bucket, history := range map[string]uint8{index.PendingBucket: 1, delivery.VerdictsBucket: delivery.VerdictsHistory,
|
||||
graph.Bucket: 1, delivery.TakenBucket: 1, delivery.PackagesBucket: 1} {
|
||||
if _, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: bucket, History: history}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// The take-in events are the indexer's ordinary events (ADR 0633 §5), on the path manifest-accepted takes.
|
||||
for name, subject := range map[string]string{"INDEXER_EVENTS": "mesh.mod.mesh-delivery-indexer.event.>"} {
|
||||
if _, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: name, Subjects: []string{subject}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
tc, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: delivery.ToolchainsBucket("mesh-build-go"), History: 1})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
event, _ := delivery.ForgeEvent(f.Repository, delivery.ForgePull)
|
||||
key, _ := delivery.ToolchainKey("n1", "go")
|
||||
value, err := delivery.EncodeToolchain(delivery.Toolchain{Language: "go", Version: "go1.27.1", Recipe: "1", At: time.Now()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tc.Put(ctx, key, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The node-engine's composition on n1 (process.Conn.Call's subjects): the build of the indexer that runs there.
|
||||
answer := func(m *nats.Msg) {
|
||||
raw, _ := json.Marshal(map[string]any{"result": map[string]any{"runs": map[string]string{"fp": *running.Load()}}, "node": "n1"})
|
||||
_ = m.Respond(raw)
|
||||
}
|
||||
bridge := func(m *nats.Msg) {
|
||||
var ask struct{ Repository, Merge string }
|
||||
_ = json.Unmarshal(m.Data, &ask)
|
||||
data, ok := bridgeMessages.Load(ask.Repository + "@" + ask.Merge)
|
||||
if !ok {
|
||||
raw, _ := json.Marshal(map[string]any{"error": "the gate holds no merge of " + ask.Repository + " with that commit"})
|
||||
_ = m.Respond(raw)
|
||||
return
|
||||
}
|
||||
raw, _ := json.Marshal(map[string]any{"result": map[string]any{"message": json.RawMessage(data.([]byte))}, "node": "n1"})
|
||||
_ = m.Respond(raw)
|
||||
}
|
||||
for _, subject := range []string{"mesh.mod.mesh-delivery-forge-bridge.call.bridge_merged_message.>", "mesh.mod.mesh-delivery-forge-bridge.tool.bridge_merged_message"} {
|
||||
if _, err := js.Conn().Subscribe(subject, bridge); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, subject := range []string{"mesh.seat.node-engine.call.composition.n1.>", "mesh.seat.node-engine.tool.composition.n1"} {
|
||||
if _, err := js.Conn().Subscribe(subject, answer); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
h1, h2 := "0123456789abcdef0123456789abcdef01234567", "89abcdef0123456789abcdef0123456789abcdef"
|
||||
for _, f := range []forgemsg.Forge{
|
||||
{Kind: forgemsg.ForgePull, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: h1, Action: forgemsg.PullUpdated, Dirs: []string{"modules/a"}, DirsKnown: true},
|
||||
{Kind: forgemsg.ForgePull, Repository: "novox/mesh-catalog", Branch: "feat/x", Number: 300, Head: h2, Action: forgemsg.PullUpdated, Dirs: []string{"modules/c"}, DirsKnown: true},
|
||||
} {
|
||||
data, err := forgemsg.EncodeForge(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
event, _ := forgemsg.ForgeEvent(f.Repository, forgemsg.ForgePull)
|
||||
if _, err := js.Publish(ctx, "mesh.stream.mesh-delivery-forge-bridge.event."+event, data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -83,11 +159,202 @@ func TestIndexerWhole(t *testing.T) { // 1238/AC2, AC10
|
||||
t.Errorf("%s#%d modules = %v, want [%s]", p.Repository, p.Number, p.Modules, want)
|
||||
}
|
||||
}
|
||||
|
||||
// I8a: pull request 12 merged, changing the indexer's own tree and a's.
|
||||
manifest, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const ix = delivery.IndexerModule
|
||||
m1 := forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: h1,
|
||||
Merge: "1111111111111111111111111111111111111111", Root: "r0",
|
||||
Trees: map[string]string{"modules/" + ix: "tree-indexer-2", "modules/a": "tree-a-2"},
|
||||
Manifests: map[string]string{"modules/" + ix: string(manifest), "modules/a": `{"module":"a","version":"1"}`},
|
||||
Locks: map[string]map[string]string{"modules/" + ix: {"go.sum": "3b5028f0c1e9bde04dd766abd63eb5931bc516bae695cf46f00abcd8510b0381"}}}
|
||||
merged := func(f forgemsg.Forge) uint64 {
|
||||
t.Helper()
|
||||
data, err := forgemsg.EncodeForge(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
event, _ := forgemsg.ForgeEvent(f.Repository, forgemsg.ForgeMerged)
|
||||
bridgeMessages.Store(f.Repository+"@"+f.Merge, data)
|
||||
ack, err := js.Publish(ctx, "mesh.stream.mesh-delivery-forge-bridge.event."+event, data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ack.Sequence
|
||||
}
|
||||
m1Key := takein.PendingKey(merged(m1))
|
||||
events, err := js.Stream(ctx, "INDEXER_EVENTS")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// saidAll is every take-in event of kind said under key so far, oldest first.
|
||||
saidAll := func(kind, key string) []takein.TakeIn {
|
||||
event, _ := takein.TakeInEvent(kind)
|
||||
info, err := events.Info(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out []takein.TakeIn
|
||||
for seq := info.State.FirstSeq; seq <= info.State.LastSeq && seq > 0; seq++ {
|
||||
msg, err := events.GetMsg(ctx, seq)
|
||||
if err != nil || msg.Subject != "mesh.mod."+ix+".event."+event {
|
||||
continue
|
||||
}
|
||||
if ti, err := takein.DecodeTakeIn(msg.Data); err == nil && ti.Key == key {
|
||||
out = append(out, ti)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
// said is the last take-in event under kind and key, within 20 s.
|
||||
said := func(kind, key string) (takein.TakeIn, bool) {
|
||||
for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
|
||||
if all := saidAll(kind, key); len(all) > 0 {
|
||||
return all[len(all)-1], true
|
||||
}
|
||||
}
|
||||
return takein.TakeIn{}, false
|
||||
}
|
||||
held, ok := said(takein.TakeInPending, m1Key)
|
||||
if !ok || held.What != takein.TakeInRevision || held.WaitsFor == "" || held.WaitsFor == first || len(held.Merges) != 1 {
|
||||
t.Fatalf("take-in.pending.%s = %+v (said %v) within 20 s, want the revision waiting for another build than %s", m1Key, held, ok, first)
|
||||
}
|
||||
named := held.WaitsFor
|
||||
get := func(bucket, key string) ([]byte, bool) {
|
||||
t.Helper()
|
||||
kv, err := js.KeyValue(ctx, bucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e, err := kv.Get(ctx, key)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return e.Value(), true
|
||||
}
|
||||
// Only what building and moving the named indexer needs is written.
|
||||
raw, ok := get(delivery.ModulesBucket, ix)
|
||||
own, err := delivery.DecodeModule(raw)
|
||||
if !ok || err != nil || own.FP2 != named || own.Commit != m1.Merge {
|
||||
t.Fatalf("modules.%s = %+v (%v), want the named build %s at the merge", ix, own, err, named)
|
||||
}
|
||||
if _, ok := get(delivery.ManifestsBucket, own.Manifest); !ok {
|
||||
t.Errorf("manifests.%s is not written", own.Manifest)
|
||||
}
|
||||
if _, ok := get(delivery.VerdictsBucket, own.Manifest); !ok {
|
||||
t.Errorf("the verdict on the indexer's manifest %s is not written", own.Manifest)
|
||||
}
|
||||
if gk, _ := graph.Key(ix); !func() bool { _, ok := get(graph.Bucket, gk); return ok }() {
|
||||
t.Errorf("the indexer's key %s of the module graph is not written", gk)
|
||||
}
|
||||
repoKey, _ := delivery.RepositoryKey(m1.Repository)
|
||||
if _, ok := get(delivery.ModulesBucket, "a"); ok {
|
||||
t.Errorf("modules.a is written while the revision is pending")
|
||||
}
|
||||
if _, ok := get(delivery.TakenBucket, repoKey); ok {
|
||||
t.Errorf("taken.%s is written while the revision is pending", repoKey)
|
||||
}
|
||||
if e, err := candidates.Get(ctx, "feat/x"); err != nil {
|
||||
t.Errorf("the candidate feat/x is gone while the merge of #12 is pending: %v", err)
|
||||
} else if c, err := delivery.DecodeCandidate(e.Value()); err != nil || len(c.Pulls) != 2 {
|
||||
t.Errorf("the candidate feat/x = %+v while the merge of #12 is pending, want its two pull requests", c)
|
||||
}
|
||||
|
||||
// A group's first member arrives meanwhile; its end is still to come (the knowledge base's remark: it stays across the restart).
|
||||
members := []delivery.Head{{Repository: "novox/mesh-catalog", Number: 40, Head: "4040404040404040404040404040404040404040"},
|
||||
{Repository: "novox/mesh-controller", Number: 41, Head: "4141414141414141414141414141414141414141"}}
|
||||
g1 := forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: "novox/mesh-catalog", Branch: "feat/g", Number: 40, Head: members[0].Head,
|
||||
Merge: "4000000000000000000000000000000000000000", Root: "r0", Trees: map[string]string{"modules/c": "tree-c-2"},
|
||||
Manifests: map[string]string{"modules/c": `{"module":"c","version":"1"}`}, Group: "feat/g", Members: members}
|
||||
g2 := forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: "novox/mesh-controller", Branch: "feat/g", Number: 41, Head: members[1].Head,
|
||||
Merge: "4100000000000000000000000000000000000000", Root: "r0", Trees: map[string]string{"modules/k": "tree-k-1"},
|
||||
Manifests: map[string]string{"modules/k": `{"module":"k","version":"1"}`}, Group: "feat/g", Members: members}
|
||||
g1Key, groupKey := takein.PendingKey(merged(g1)), takein.GroupTakeInKey("feat/g")
|
||||
if gathered, ok := said(takein.TakeInPending, groupKey); !ok || gathered.What != takein.TakeInGroup || len(gathered.Merges) != 1 {
|
||||
t.Fatalf("take-in.pending.%s = %+v (said %v) within 20 s, want the group with its first member", groupKey, gathered, ok)
|
||||
}
|
||||
// indexer_pending, the tool over the same state, names both waits.
|
||||
pendingKV, err := js.KeyValue(ctx, index.PendingBucket)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waits := func() []takein.PendingWait {
|
||||
t.Helper()
|
||||
answer, err := index.PendingTools(kvStore{pendingKV}, nil, nil, time.Now)[0].Run(map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return answer.(takein.PendingAnswer).Waits
|
||||
}
|
||||
var w []takein.PendingWait
|
||||
for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
|
||||
if w = waits(); len(w) == 2 && w[0].WaitsFor == named && w[1].Said != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(w) != 2 || w[0].Key != m1Key || w[0].WaitsFor != named || !slices.Contains(w[0].Holds, "a") || !slices.Contains(w[0].Holds, ix) ||
|
||||
w[1].Key != groupKey || w[1].What != takein.TakeInGroup || len(w[1].Merges) != 1 || w[1].Said != takein.TakeInPendingEvent {
|
||||
t.Fatalf("indexer_pending = %+v, want the revision %s waiting for %s holding a and the indexer, then the group gathered", w, m1Key, named)
|
||||
}
|
||||
|
||||
// The named indexer starts (the node-engine now runs that build): it takes the revision in, and leaves the group.
|
||||
running.Store(&named)
|
||||
restart()
|
||||
if taken, ok := said(takein.TakeInTaken, m1Key); !ok || len(taken.Merges) != 1 || taken.Merges[0].Merge != m1.Merge {
|
||||
t.Fatalf("take-in.taken.%s = %+v (said %v) within 20 s of the start of %s", m1Key, taken, ok, named)
|
||||
}
|
||||
for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
|
||||
if w = waits(); len(w) == 1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if raw, ok := get(delivery.ModulesBucket, "a"); !ok || !strings.Contains(string(raw), m1.Merge) {
|
||||
t.Errorf("modules.a = %s after the take-in at the start, want it at the merge %s", raw, m1.Merge)
|
||||
}
|
||||
if _, ok := get(delivery.TakenBucket, repoKey); !ok {
|
||||
t.Errorf("taken.%s is not written by the take-in at the start", repoKey)
|
||||
}
|
||||
if len(w) != 1 || w[0].Key != groupKey || len(w[0].Keys) != 1 || w[0].Keys[0] != g1Key {
|
||||
t.Fatalf("indexer_pending after the start = %+v, want only the group gathered, its first member %s", w, g1Key)
|
||||
}
|
||||
if _, ok := get(delivery.ModulesBucket, "c"); ok {
|
||||
t.Errorf("modules.c is written while its group is gathered")
|
||||
}
|
||||
|
||||
// I8b: the group's second member and its end: one take-in of both, said under the group's key.
|
||||
merged(g2)
|
||||
end, err := forgemsg.EncodeForgeGroupEnd(forgemsg.ForgeGroupEnd{Group: "feat/g", Members: []forgemsg.GroupMerge{
|
||||
{Repository: g1.Repository, Number: g1.Number, Merge: g1.Merge}, {Repository: g2.Repository, Number: g2.Number, Merge: g2.Merge}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := js.Publish(ctx, "mesh.stream.mesh-delivery-forge-bridge.event."+forgemsg.ForgeGroupEndEvent, end); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if taken, ok := said(takein.TakeInTaken, groupKey); !ok || taken.What != takein.TakeInGroup || len(taken.Merges) != 2 {
|
||||
t.Fatalf("take-in.taken.%s = %+v (said %v) within 20 s of the group's end, want both members", groupKey, taken, ok)
|
||||
}
|
||||
var hasC, hasK bool
|
||||
for deadline := time.Now().Add(20 * time.Second); time.Now().Before(deadline) && !(hasC && hasK && len(w) == 0); time.Sleep(50 * time.Millisecond) {
|
||||
_, hasC = get(delivery.ModulesBucket, "c")
|
||||
_, hasK = get(delivery.ModulesBucket, "k")
|
||||
w = waits()
|
||||
}
|
||||
if !hasC || !hasK || len(w) != 0 {
|
||||
t.Errorf("after the group's end: modules.c %v, modules.k %v, indexer_pending %+v: want both taken in and nothing waiting", hasC, hasK, w)
|
||||
}
|
||||
// One take-in: neither member was said as a revision of its own.
|
||||
if len(saidAll(takein.TakeInPending, g1Key)) != 0 {
|
||||
t.Errorf("the group's first member %s was said pending as a revision of its own", g1Key)
|
||||
}
|
||||
}
|
||||
|
||||
// startIndexer starts the bus, the stream FORGE with the indexer's durable consumer, the buckets, lets put fill
|
||||
// mesh-controller_builds, and runs the program. mesh-delivery-gate_source is never created.
|
||||
func startIndexer(t *testing.T, put func(context.Context, buildLog), before ...func(context.Context, jetstream.JetStream)) (jetstream.JetStream, context.Context, jetstream.KeyValue, jetstream.KeyValue) {
|
||||
// startIndexerRun starts the bus, the stream FORGE with the indexer's durable consumer, the buckets, lets put fill
|
||||
// mesh-controller_builds, and runs the program; restart starts it again. mesh-delivery-gate_source is never created.
|
||||
func startIndexerRun(t *testing.T, put func(context.Context, buildLog), before ...func(context.Context, jetstream.JetStream)) (jetstream.JetStream, context.Context, jetstream.KeyValue, jetstream.KeyValue, func()) {
|
||||
dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "i")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -166,11 +433,24 @@ func startIndexer(t *testing.T, put func(context.Context, buildLog), before ...f
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := map[string]string{"MESH_BROKER_FILE": broker}
|
||||
runCtx, stop := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }()
|
||||
t.Cleanup(func() { stop(); <-done })
|
||||
var stop func()
|
||||
start := func() {
|
||||
runCtx, cancel := context.WithCancel(ctx)
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- run(runCtx, func(k string) string { return env[k] }, io.Discard) }()
|
||||
stop = func() { cancel(); <-done }
|
||||
}
|
||||
start()
|
||||
t.Cleanup(func() { stop() })
|
||||
// restart stops the program and starts it again on the same bus, as the node-engine does with a new build.
|
||||
restart := func() { stop(); start() }
|
||||
|
||||
return js, ctx, candidates, modules, restart
|
||||
}
|
||||
|
||||
// startIndexer is startIndexerRun for a test that never starts the program again.
|
||||
func startIndexer(t *testing.T, put func(context.Context, buildLog), before ...func(context.Context, jetstream.JetStream)) (jetstream.JetStream, context.Context, jetstream.KeyValue, jetstream.KeyValue) {
|
||||
js, ctx, candidates, modules, _ := startIndexerRun(t, put, before...)
|
||||
return js, ctx, candidates, modules
|
||||
}
|
||||
|
||||
@@ -202,7 +482,7 @@ func TestIndexerWritesModulesAtAMerge(t *testing.T) { // 1239/AC9; I1, I3: the m
|
||||
builds.end(t, "novox/mesh-controller", "build-agent", "modules/build-agent")
|
||||
})
|
||||
const merge = "89abcdef0123456789abcdef0123456789abcdef"
|
||||
data, err := delivery.EncodeForge(delivery.Forge{Kind: delivery.ForgeMerged, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12,
|
||||
data, err := forgemsg.EncodeForge(forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12,
|
||||
Head: "0123456789abcdef0123456789abcdef01234567", Merge: merge, Root: "r0", Trees: map[string]string{"modules/a": "newtree"},
|
||||
Manifests: map[string]string{"modules/a": `{"module":"a","version":"1"}`}})
|
||||
if err != nil {
|
||||
@@ -256,7 +536,7 @@ func TestIndexerKeepsManifestsAtAMerge(t *testing.T) { // 1444/AC4, 1449/AC4, 15
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
forge := delivery.Forge{Kind: delivery.ForgeMerged, Repository: "novox/mesh-catalog", Branch: "feat/x", Number: 301,
|
||||
forge := forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: "novox/mesh-catalog", Branch: "feat/x", Number: 301,
|
||||
Head: "0123456789abcdef0123456789abcdef01234567", Merge: "89abcdef0123456789abcdef0123456789abcdef", Root: "r0",
|
||||
Trees: map[string]string{"modules/avahi": "newtree"},
|
||||
Manifests: map[string]string{"modules/avahi": string(manifest)},
|
||||
@@ -264,7 +544,7 @@ func TestIndexerKeepsManifestsAtAMerge(t *testing.T) { // 1444/AC4, 1449/AC4, 15
|
||||
for name, raw := range judged {
|
||||
forge.Manifests["modules/"+name], forge.Trees["modules/"+name] = raw, "tree-"+name
|
||||
}
|
||||
data, err := delivery.EncodeForge(forge)
|
||||
data, err := forgemsg.EncodeForge(forge)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -358,7 +638,7 @@ func TestIndexerWritesAGoModuleAgainWhenTheBuildModuleReportsItsToolchain(t *tes
|
||||
js, ctx, _, modules := startIndexer(t, func(ctx context.Context, builds buildLog) {
|
||||
builds.end(t, "novox/mesh-catalog", "avahi", "modules/avahi")
|
||||
})
|
||||
data, err := delivery.EncodeForge(delivery.Forge{Kind: delivery.ForgeMerged, Repository: "novox/mesh-catalog", Branch: "feat/x", Number: 301,
|
||||
data, err := forgemsg.EncodeForge(forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: "novox/mesh-catalog", Branch: "feat/x", Number: 301,
|
||||
Head: "0123456789abcdef0123456789abcdef01234567", Merge: "89abcdef0123456789abcdef0123456789abcdef", Root: "r0",
|
||||
Trees: map[string]string{"modules/avahi": "newtree"},
|
||||
Manifests: map[string]string{"modules/avahi": string(manifest)},
|
||||
@@ -445,7 +725,7 @@ func TestIndexerGivesACandidateItsFingerprintWhenTheCheckerCreatesThePreviewsBuc
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
pull, err := delivery.EncodeForge(delivery.Forge{Kind: delivery.ForgePull, Repository: "novox/mesh-catalog", Branch: "feat/x", Number: 301, Head: head, Action: delivery.PullUpdated,
|
||||
pull, err := forgemsg.EncodeForge(forgemsg.Forge{Kind: forgemsg.ForgePull, Repository: "novox/mesh-catalog", Branch: "feat/x", Number: 301, Head: head, Action: forgemsg.PullUpdated,
|
||||
Dirs: []string{"modules/avahi"}, DirsKnown: true, Manifests: map[string]string{"modules/avahi": string(manifest)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -615,7 +895,7 @@ func TestIndexerCountsOnlyThePlacedBuildNodesToolchains(t *testing.T) {
|
||||
place(ctx, t, "n1", "mesh-build-go")
|
||||
place(ctx, t, "n2")
|
||||
})
|
||||
data, err := delivery.EncodeForge(delivery.Forge{Kind: delivery.ForgeMerged, Repository: "novox/mesh-catalog", Branch: "feat/x", Number: 302,
|
||||
data, err := forgemsg.EncodeForge(forgemsg.Forge{Kind: forgemsg.ForgeMerged, Repository: "novox/mesh-catalog", Branch: "feat/x", Number: 302,
|
||||
Head: "0123456789abcdef0123456789abcdef01234567", Merge: "89abcdef0123456789abcdef0123456789abcdef", Root: "r0",
|
||||
Trees: map[string]string{"modules/avahi": "newtree"},
|
||||
Manifests: map[string]string{"modules/avahi": string(manifest)},
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
@@ -153,6 +154,34 @@ type delivered interface {
|
||||
Metadata() (*jetstream.MsgMetadata, error)
|
||||
}
|
||||
|
||||
// groupEndSubject is the whole subject of a delivery group's end on the forge bridge's stream: compared exactly, so a
|
||||
// repository named forge/group, whose merged message ends the same way, is never taken for one.
|
||||
var groupEndSubject = process.ModuleStreamSubject(delivery.ForgeBridgeModule, forgemsg.ForgeGroupEndEvent)
|
||||
|
||||
// handleGroupEnd takes a group's end in (index.EndGroup): a body that does not read is terminated and said; an end
|
||||
// whose members are not all held yet, or while the build this program runs is not known, is left for redelivery.
|
||||
func handleGroupEnd(ctx context.Context, m delivered, ix *index.Indexer, h *index.Heard, logger *log.Logger) {
|
||||
end, err := forgemsg.DecodeForgeGroupEnd(m.Data())
|
||||
if err != nil {
|
||||
logger.Printf("%s: not a group's end, dropped: %v", m.Subject(), err)
|
||||
h.Outcome, h.Words = index.HeardDropped, err.Error()
|
||||
_ = m.Term()
|
||||
return
|
||||
}
|
||||
h.Kind, h.Branch = forgemsg.ForgeGroupEndEvent, end.Group
|
||||
applied, words, err := ix.EndGroup(ctx, end)
|
||||
h.Written, h.Late = applied.Written, applied.Late
|
||||
if err != nil {
|
||||
logger.Printf("%s: the end of the group %q left for redelivery: %v", m.Subject(), end.Group, err)
|
||||
h.Outcome, h.Words = index.HeardLeft, err.Error()
|
||||
return
|
||||
}
|
||||
h.Outcome, h.Words = index.HeardApplied, words
|
||||
if err := m.DoubleAck(ctx); err != nil {
|
||||
logger.Printf("%s: the end of the group %q applied, its acknowledgement was not confirmed: %v", m.Subject(), end.Group, err)
|
||||
}
|
||||
}
|
||||
|
||||
// handle takes one message of FORGE into the candidates and acknowledges it. A message it can never read is terminated,
|
||||
// never redelivered, and the log names its subject and why. A write that failed stays unacknowledged: the server
|
||||
// delivers it again (the server's ack wait is the only clock). Each message handled is written to the heard state
|
||||
@@ -171,7 +200,12 @@ func handle(ctx context.Context, m delivered, ix *index.Indexer, heard index.Sto
|
||||
logger.Printf("%s: sequence %d is not written to the heard state: %v", h.Subject, h.Sequence, err)
|
||||
}
|
||||
}()
|
||||
f, err := delivery.DecodeForge(m.Data())
|
||||
// A delivery group's end (novox/hq ADR 0633 §4) has a body of its own: told apart by its name, before the decode.
|
||||
if m.Subject() == groupEndSubject {
|
||||
handleGroupEnd(ctx, m, ix, &h, logger)
|
||||
return
|
||||
}
|
||||
f, err := forgemsg.DecodeForge(m.Data())
|
||||
if err != nil {
|
||||
logger.Printf("%s: not a forge message, dropped: %v", m.Subject(), err)
|
||||
h.Outcome, h.Words = index.HeardDropped, err.Error()
|
||||
@@ -182,7 +216,13 @@ func handle(ctx context.Context, m delivered, ix *index.Indexer, heard index.Sto
|
||||
if f.Merge != "" {
|
||||
h.Commit = f.Merge
|
||||
}
|
||||
applied, err := ix.ApplyMessage(ctx, f)
|
||||
var applied index.Applied
|
||||
if f.Kind == forgemsg.ForgeMerged && h.Sequence > 0 {
|
||||
// A merge is taken in only by the indexer its revision names (ADR 0633 §1): kept in pending until then.
|
||||
applied, h.Words, err = ix.Arrive(ctx, h.Sequence, f)
|
||||
} else {
|
||||
applied, err = ix.ApplyMessage(ctx, f)
|
||||
}
|
||||
h.Written, h.Late = applied.Written, applied.Late
|
||||
switch {
|
||||
case errors.Is(err, index.ErrUnreadable):
|
||||
@@ -303,12 +343,22 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
oldSections := process.AwaitBucket(ctx, js, conn, index.OldSectionsBucket, "the count of the old sections is not kept, so the judge refuses no use of one", logger.Printf)
|
||||
// The module graph (ADR 0603 §2): a state this delivery brings, bound when it appears; until then no graph is written.
|
||||
graph := process.AwaitBucket(ctx, js, conn, graph.Bucket, "no module graph is written", logger.Printf)
|
||||
ix := &index.Indexer{OldSections: awaitedStore{oldSections}, Store: kvStore{candidates}, Modules: kvStore{modules}, Manifests: kvStore{manifests}, Taken: awaitedStore{taken}, Builds: known,
|
||||
ownBuildState := process.AwaitBucket(ctx, js, conn, index.OwnBuildBucket, "which build this indexer runs is not kept", logger.Printf)
|
||||
tryRecovery := process.AwaitBucket(ctx, js, conn, index.TryRecoveryBucket, "how the recovery of the open tries went is not kept", logger.Printf)
|
||||
ix := &index.Indexer{OwnBuildState: awaitedStore{ownBuildState}, TryRecovery: awaitedStore{tryRecovery}, OldSections: awaitedStore{oldSections}, Store: kvStore{candidates}, Modules: kvStore{modules}, Manifests: kvStore{manifests}, Taken: awaitedStore{taken}, Builds: known,
|
||||
Verdicts: awaitedStore{verdicts}, SayRefused: sayRefused(js, cred, logger.Printf), SayAccepted: sayAccepted(js, cred, logger.Printf),
|
||||
Predefined: awaitedStore{predefined}, ConfigCandidates: awaitedStore{configCandidates}, Logf: logger.Printf,
|
||||
Named: namedVia(calls.Call), Trunk: trunkVia(calls.Call), Contains: containsVia(calls.Call), Read: readVia(calls.Call),
|
||||
ReadOpen: openVia(calls.Call), Packages: awaitedStore{packages}, Files: filesVia(calls.Call), GoSum: goSumVia(calls.Call), GoMod: goModVia(calls.Call),
|
||||
OwnBuild: ownBuildVia(calls.Call, cred.Node), Graph: awaitedStore{graph}}
|
||||
ReadOpen: openVia(calls.Call), Packages: awaitedStore{packages}, Files: filesVia(calls.Call), GoSum: goSumVia(calls.Call), GoMod: goModVia(calls.Call), ReadFile: readFileVia(calls.Call),
|
||||
OwnBuild: ownBuildVia(calls.Call, cred.Node), OpenTries: openTriesVia(calls.Call), Graph: awaitedStore{graph}, Message: messageVia(calls.Call),
|
||||
// The take-in step (ADR 0633): its state pending and its stream take-in, both this delivery's, bound when they appear;
|
||||
// until then every merge is taken in at once, as before.
|
||||
Pending: awaitedStore{process.AwaitBucket(ctx, js, conn, index.PendingBucket, "no merge is held for the indexer its revision names: each is taken in at once", logger.Printf)},
|
||||
SayTakeIn: sayTakeIn(js, cred)}
|
||||
// Which build this program is, fixed here once, before it hears any merge and never asked again (index.FixOwnBuild): the
|
||||
// node-engine names the successor while it replaces this process, and an old process that learnt it later would take
|
||||
// revisions in with its old judge.
|
||||
ix.FixOwnBuild(ctx)
|
||||
// The log's entries at the start are read before any fingerprint is computed or registered module indexed (below); each
|
||||
// new one indexes the module it names when that module is registered and the bucket holds none (registered.go).
|
||||
var started atomic.Bool
|
||||
@@ -368,7 +418,9 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
if !gate.Toolchain(module, key, value, deleted) {
|
||||
return nil
|
||||
}
|
||||
return ix.Recompute(ctx)
|
||||
// The modules written again, and pending evaluated again (ADR 0633 §1): the build a revision names moves with
|
||||
// the toolchain.
|
||||
return ix.ToolchainReported(ctx)
|
||||
},
|
||||
recompute: ix.Recompute}, logger)
|
||||
if err := tools.start(); err != nil {
|
||||
@@ -448,11 +500,27 @@ func run(ctx context.Context, getenv func(string) string, logOut io.Writer) erro
|
||||
}
|
||||
defer tc.Stop()
|
||||
}
|
||||
switch pcons, err := cred.EventConsumer(ctx, js, PreviewsStream); {
|
||||
case err != nil:
|
||||
logger.Printf("the consumer %s_%s on %s does not exist yet, so the checker's previews are not heard until the indexer starts again: %v", cred.Node, cred.Module, PreviewsStream, err)
|
||||
default:
|
||||
pc, err := process.HearEvents(pcons, func(m jetstream.Msg) { previews.Written(m) }, logger.Printf)
|
||||
// The consumer on the checker's stream exists before the open tries are listed and is heard after (ADR 0638 §3): an event
|
||||
// said between the call and the hearing waits in it, and a try that started or ended meanwhile is found either way.
|
||||
pcons, perr := cred.EventConsumer(ctx, js, PreviewsStream)
|
||||
if perr != nil {
|
||||
logger.Printf("the consumer %s_%s on %s does not exist yet, so the checker's previews and tries are not heard until the indexer starts again: %v", cred.Node, cred.Module, PreviewsStream, perr)
|
||||
}
|
||||
// The pending revisions that name this build are taken in now, oldest first, before the consumer on the forge bridge's
|
||||
// stream starts (ADR 0633 §2). When the build this program runs is not known yet, nothing waits for it: the consumer
|
||||
// starts, and a merged message left unacknowledged asks the build again at its redelivery (clause 1). A hand take-in
|
||||
// (indexer_take_in_pending) is answered at any time: the takeins watch runs already.
|
||||
if _, words, err := ix.TakeInAtStart(ctx); err != nil {
|
||||
return fmt.Errorf("the take-in at start: %w", err)
|
||||
} else if words != "" {
|
||||
logger.Printf("take-in at start: %s", words)
|
||||
}
|
||||
// The open tries are found again through the checker's tool: a try event missed while the indexer was down would leave a
|
||||
// try with no candidate, or a candidate with no try. Not fatal: the events come, and a preview written writes the candidates.
|
||||
// A recovery that fails is said (journal, and indexer_try_candidates) and the events are heard all the same.
|
||||
_ = ix.RecoverTries(ctx)
|
||||
if perr == nil {
|
||||
pc, err := process.HearEvents(pcons, func(m jetstream.Msg) { previews.heardChecks(m) }, logger.Printf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"mesh-delivery-indexer/internal/index"
|
||||
@@ -73,8 +74,8 @@ const head = "0123456789abcdef0123456789abcdef01234567"
|
||||
func TestAMessageIsIndexedAndAcked(t *testing.T) { // 1238/AC2 (unit part)
|
||||
st := &memStore{vals: map[string][]byte{}}
|
||||
ix := &index.Indexer{Store: st, Builds: index.NewBuilds()}
|
||||
data, _ := delivery.EncodeForge(delivery.Forge{Kind: delivery.ForgePull, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: head,
|
||||
Action: delivery.PullUpdated, Dirs: []string{"modules/a"}, DirsKnown: true})
|
||||
data, _ := forgemsg.EncodeForge(forgemsg.Forge{Kind: forgemsg.ForgePull, Repository: "novox/mesh-delivery", Branch: "feat/x", Number: 12, Head: head,
|
||||
Action: forgemsg.PullUpdated, Dirs: []string{"modules/a"}, DirsKnown: true})
|
||||
m := &fakeMsg{subject: "mesh.stream.mesh-delivery-forge-bridge.event.forge.novox.mesh-delivery.pull", data: data}
|
||||
heard := &memStore{vals: map[string][]byte{}}
|
||||
handle(context.Background(), m, ix, heard, log.New(&bytes.Buffer{}, "", 0))
|
||||
@@ -84,7 +85,7 @@ func TestAMessageIsIndexedAndAcked(t *testing.T) { // 1238/AC2 (unit part)
|
||||
}
|
||||
// Issue 1126 note 76: what the indexer heard of FORGE is readable (indexer_heard): subject, sequence, deliveries, time,
|
||||
// repository, commit and what it did.
|
||||
if h := heardOf(t, heard); h.Subject != m.subject || h.Sequence != 41 || h.Delivered != 2 || h.Published.Hour() != 13 || h.Kind != delivery.ForgePull ||
|
||||
if h := heardOf(t, heard); h.Subject != m.subject || h.Sequence != 41 || h.Delivered != 2 || h.Published.Hour() != 13 || h.Kind != forgemsg.ForgePull ||
|
||||
h.Repository != "novox/mesh-delivery" || h.Number != 12 || h.Commit != head || h.Outcome != index.HeardApplied || h.Handled.IsZero() {
|
||||
t.Errorf("heard = %+v", h)
|
||||
}
|
||||
@@ -102,6 +103,31 @@ func TestAMessageThatIsNoForgeMessageIsTerminatedAndLogged(t *testing.T) { // 12
|
||||
if h := heardOf(t, heard); h.Outcome != index.HeardDropped || h.Sequence != 41 || h.Words == "" {
|
||||
t.Errorf("heard = %+v", h)
|
||||
}
|
||||
// 1585/T9 (ADR 0633 §4): a group's end is told apart by its name before the forge decode: one that reads is acked
|
||||
// (an indexer with no pending state takes the members in one by one), one that does not is dropped as a group's end.
|
||||
for _, c := range []struct {
|
||||
data string
|
||||
termd int
|
||||
logged string
|
||||
}{{`{"v":1,"group":"feat/x","members":[{"repository":"novox/mesh-sdk","number":3,"merge":"` + head + `"}]}`, 0, ""},
|
||||
{`{"v":1,"group":"feat/x"}`, 1, "not a group's end"}} {
|
||||
logs.Reset()
|
||||
m := &fakeMsg{subject: "mesh.stream.mesh-delivery-forge-bridge.event." + forgemsg.ForgeGroupEndEvent, data: []byte(c.data)}
|
||||
handle(context.Background(), m, &index.Indexer{Store: st, Builds: index.NewBuilds()}, heard, log.New(&logs, "", 0))
|
||||
if m.termd != c.termd || m.acked+m.termd != 1 || !strings.Contains(logs.String(), c.logged) {
|
||||
t.Errorf("group end %s: terminated %d acked %d log %q", c.data, m.termd, m.acked, logs.String())
|
||||
}
|
||||
if h := heardOf(t, heard); h.Kind != forgemsg.ForgeGroupEndEvent && c.termd == 0 {
|
||||
t.Errorf("heard of a group's end = %+v", h)
|
||||
}
|
||||
}
|
||||
// The repository forge/group's merged message ends as a group's end does: it is a forge message all the same.
|
||||
logs.Reset()
|
||||
m = &fakeMsg{subject: "mesh.stream.mesh-delivery-forge-bridge.event.forge.forge.group.merged", data: []byte(`{"v":1,"group":"feat/x","members":[{"repository":"novox/mesh-sdk","number":3,"merge":"` + head + `"}]}`)}
|
||||
handle(context.Background(), m, &index.Indexer{Store: st, Builds: index.NewBuilds()}, heard, log.New(&logs, "", 0))
|
||||
if !strings.Contains(logs.String(), "not a forge message") {
|
||||
t.Errorf("forge/group's merged message read as a group's end: %q", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSourceMainKeyIsTakenAtItsRevision(t *testing.T) { // 1344/AC1 (the watch's side: key, revision, value)
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/tryevent"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"mesh-delivery-indexer/internal/fingerprint"
|
||||
@@ -18,7 +20,7 @@ import (
|
||||
// commitTreesTool is the forge's tool for a commit's trees (mesh-catalog modules/gitea): with `paths`, by module directory
|
||||
// (the root spelled ""), the paths relative to it, it answers {root, trees, paths} with paths by directory then path, each
|
||||
// path's git object id; a path not at the commit fails the call naming it (the interface agreed in R4e; mesh-sdk
|
||||
// delivery.Forge.Paths; novox/hq ADR 0499 clause 3).
|
||||
// forgemsg.Forge.Paths; novox/hq ADR 0499 clause 3).
|
||||
const commitTreesTool = "gitea.gitea_commit_trees"
|
||||
|
||||
// toolCaller calls a module's tool, `<module>.<tool>`, as the indexer's own account (mesh-sdk process.Conn.Call).
|
||||
@@ -100,7 +102,7 @@ func filesVia(call toolCaller) func(ctx context.Context, repository, sha string,
|
||||
}
|
||||
for _, d := range dirs {
|
||||
if _, ok := a.Files[d]; !ok {
|
||||
return nil, fmt.Errorf("the answer of %s lists no files of %q", commitTreesTool, d)
|
||||
return nil, fmt.Errorf("the answer of %s lists no files of %q: %w", commitTreesTool, d, index.ErrNotInTree)
|
||||
}
|
||||
}
|
||||
return a.Files, nil
|
||||
@@ -119,6 +121,12 @@ func goModVia(call toolCaller) func(ctx context.Context, repository, sha, dir st
|
||||
return goFileVia(call, "go.mod")
|
||||
}
|
||||
|
||||
// readFileVia reads one file of a repository at a commit by its path (gitea_get_file); a file the commit does not hold is
|
||||
// none. The seat definitions of a revision are read with it (seatsof.go).
|
||||
func readFileVia(call toolCaller) func(ctx context.Context, repository, sha, path string) ([]byte, error) {
|
||||
return goFileVia(call, "")
|
||||
}
|
||||
|
||||
// goFileVia reads one Go file of a module directory at a commit through the forge's get-file tool; a file the commit does
|
||||
// not hold is none.
|
||||
func goFileVia(call toolCaller, name string) func(ctx context.Context, repository, sha, dir string) ([]byte, error) {
|
||||
@@ -128,7 +136,10 @@ func goFileVia(call toolCaller, name string) func(ctx context.Context, repositor
|
||||
return nil, fmt.Errorf("%q is not <owner>/<name>", repository)
|
||||
}
|
||||
p := name
|
||||
if dir != "" && dir != delivery.RootPath {
|
||||
switch {
|
||||
case name == "": // readFileVia: dir is the file's whole path
|
||||
p = dir
|
||||
case dir != "" && dir != delivery.RootPath:
|
||||
p = dir + "/" + name
|
||||
}
|
||||
raw, err := call(getFileTool, map[string]string{"owner": owner, "repo": repo, "path": p, "ref": sha})
|
||||
@@ -258,8 +269,8 @@ const (
|
||||
)
|
||||
|
||||
// openVia asks the forge which pull requests into the trunk are open now (gitea_open_pulls), every repository's.
|
||||
func openVia(call toolCaller) func(ctx context.Context) ([]delivery.OpenPull, error) {
|
||||
return func(context.Context) ([]delivery.OpenPull, error) {
|
||||
func openVia(call toolCaller) func(ctx context.Context) ([]forgemsg.OpenPull, error) {
|
||||
return func(context.Context) ([]forgemsg.OpenPull, error) {
|
||||
raw, err := call(openPullsTool, map[string]any{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -275,12 +286,40 @@ func openVia(call toolCaller) func(ctx context.Context) ([]delivery.OpenPull, er
|
||||
if err := json.Unmarshal(raw, &o); err != nil || o.Pulls == nil {
|
||||
return nil, fmt.Errorf("the answer of %s is not {pulls}: %v", openPullsTool, err)
|
||||
}
|
||||
out := []delivery.OpenPull{}
|
||||
out := []forgemsg.OpenPull{}
|
||||
for _, p := range *o.Pulls {
|
||||
if p.Base == trunkBranch && p.Number > 0 {
|
||||
out = append(out, delivery.OpenPull{Repository: p.Owner + "/" + p.Repo, Number: p.Number})
|
||||
out = append(out, forgemsg.OpenPull{Repository: p.Owner + "/" + p.Repo, Number: p.Number})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
|
||||
// openTriesTool is the checker's tool that lists the tries that wait or run (mesh-delivery-checker checker_tries).
|
||||
const openTriesTool = "mesh-delivery-checker.checker_tries"
|
||||
|
||||
// openTriesVia lists the open tries through the checker's tool, never its state (ADR 0596).
|
||||
func openTriesVia(call toolCaller) func(ctx context.Context) ([]tryevent.Open, error) {
|
||||
return func(context.Context) ([]tryevent.Open, error) {
|
||||
raw, err := call(openTriesTool, map[string]any{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// No try open is an answer: the checker's list is empty, or null in an older checker (issue 1921). An answer without the
|
||||
// key is none.
|
||||
var o map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &o); err != nil {
|
||||
return nil, fmt.Errorf("the answer of %s is not {tries}: %v", openTriesTool, err)
|
||||
}
|
||||
list, ok := o["tries"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("the answer of %s is not {tries}: no tries key", openTriesTool)
|
||||
}
|
||||
var tries []tryevent.Open
|
||||
if err := json.Unmarshal(list, &tries); err != nil {
|
||||
return nil, fmt.Errorf("the answer of %s is not {tries}: %v", openTriesTool, err)
|
||||
}
|
||||
return tries, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"mesh-delivery-indexer/internal/index"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -50,6 +51,15 @@ func TestTheTrunkIsTheDirectoriesTheForgeListsTrees(t *testing.T) { // 1900/AC3
|
||||
if _, has := gotArgs.(map[string]any)["paths"]; has {
|
||||
t.Error("the trunk asked for paths")
|
||||
}
|
||||
// A directory the answer lists no files of is a failure of the tree (index.ErrNotInTree); a call that fails is not.
|
||||
noDir := func(string, any) (json.RawMessage, error) { return json.RawMessage(`{"files":{}}`), nil }
|
||||
if _, err := filesVia(noDir)(context.Background(), "novox/mesh-sdk", strings.Repeat("a", 40), []string{"go"}); !errors.Is(err, index.ErrNotInTree) {
|
||||
t.Errorf("a missing directory: %v, want ErrNotInTree", err)
|
||||
}
|
||||
down := func(string, any) (json.RawMessage, error) { return nil, errors.New("down") }
|
||||
if _, err := filesVia(down)(context.Background(), "novox/mesh-sdk", strings.Repeat("a", 40), []string{"go"}); err == nil || errors.Is(err, index.ErrNotInTree) {
|
||||
t.Errorf("a failed call: %v, want an error that is not ErrNotInTree", err)
|
||||
}
|
||||
// The build the indexer runs is the engine's word (Runs.FP of its composition verb), asked of its own machine.
|
||||
var asked string
|
||||
own := func(key string, body any) (json.RawMessage, error) {
|
||||
@@ -59,10 +69,45 @@ func TestTheTrunkIsTheDirectoriesTheForgeListsTrees(t *testing.T) { // 1900/AC3
|
||||
if fp, err := ownBuildVia(own, "n1")(context.Background()); err != nil || fp != "src2:abc" || asked != "seat:node-engine.composition@n1" {
|
||||
t.Errorf("own build = %q %v asked %q", fp, err, asked)
|
||||
}
|
||||
// Issue 1921: the start during a replacement. What runs still names the old build, the want (composed by the node itself)
|
||||
// the successor, which this process is: it fixes the successor. A want that is not self, or is removed, leaves what runs.
|
||||
for _, row := range []struct{ name, answer, want string }{
|
||||
{"replacing: runs old, want new and self", `{"runs":{"fp":"src2:old"},"want":{"fp":"src2:new","self":true}}`, "src2:new"},
|
||||
{"a want the node does not compose", `{"runs":{"fp":"src2:old"},"want":{"fp":"src2:new"}}`, "src2:old"},
|
||||
{"replacing: the apply of the want failed or is held", `{"runs":{"fp":"src2:old"},"want":{"fp":"src2:new","self":true},"attempt":{"fp":"src2:new","held":true}}`, "src2:old"},
|
||||
{"replacing: an attempt of another build", `{"runs":{"fp":"src2:old"},"want":{"fp":"src2:new","self":true},"attempt":{"fp":"src2:older"}}`, "src2:new"},
|
||||
{"the want already runs", `{"runs":{"fp":"src2:new"},"want":{"fp":"src2:new","self":true}}`, "src2:new"},
|
||||
{"a removed want", `{"runs":{"fp":"src2:old"},"want":{"fp":"src2:new","self":true,"removed":true}}`, "src2:old"},
|
||||
{"no build runs yet, want self", `{"want":{"fp":"src2:new","self":true}}`, "src2:new"},
|
||||
} {
|
||||
ask := func(string, any) (json.RawMessage, error) { return json.RawMessage(row.answer), nil }
|
||||
if fp, err := ownBuildVia(ask, "n1")(context.Background()); err != nil || fp != row.want {
|
||||
t.Errorf("%s: own build = %q %v, want %q", row.name, fp, err, row.want)
|
||||
}
|
||||
}
|
||||
none := func(string, any) (json.RawMessage, error) { return json.RawMessage(`{}`), nil }
|
||||
if _, err := ownBuildVia(none, "n1")(context.Background()); err == nil {
|
||||
t.Error("an engine that says nothing runs gave a build")
|
||||
}
|
||||
// Issue 1921: the checker's list of open tries; none open is an answer, whether [] or null.
|
||||
for _, row := range []struct {
|
||||
name, answer string
|
||||
want int
|
||||
refused bool
|
||||
}{
|
||||
{"one open", `{"tries":[{"id":"try-1","state":"running","whole":true,"members":[{"repository":"novox/a","head":"h","trunk":"t"}]}]}`, 1, false},
|
||||
{"an empty list", `{"tries":[]}`, 0, false},
|
||||
{"null, as an older checker answers none", `{"tries":null}`, 0, false},
|
||||
{"no tries key", `{}`, 0, true},
|
||||
{"not json", `nope`, 0, true},
|
||||
{"tries of another shape", `{"tries":{"a":1}}`, 0, true},
|
||||
} {
|
||||
call := func(string, any) (json.RawMessage, error) { return json.RawMessage(row.answer), nil }
|
||||
got, err := openTriesVia(call)(context.Background())
|
||||
if (err != nil) != row.refused || len(got) != row.want {
|
||||
t.Errorf("%s: %d tries, error %v; want %d, refused %v", row.name, len(got), err, row.want, row.refused)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadViaReadsManifestsAndLocks(t *testing.T) {
|
||||
|
||||
@@ -25,10 +25,32 @@ func ownBuildVia(call toolCaller, node string) func(ctx context.Context) (string
|
||||
Runs *struct {
|
||||
FP string `json:"fp"`
|
||||
} `json:"runs"`
|
||||
Want *struct {
|
||||
FP string `json:"fp"`
|
||||
Self bool `json:"self"`
|
||||
Removed bool `json:"removed"`
|
||||
} `json:"want"`
|
||||
// Attempt is the engine's last apply that did not end running (failed, or held), with the build it was of.
|
||||
Attempt *struct {
|
||||
FP string `json:"fp"`
|
||||
} `json:"attempt"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &a); err != nil {
|
||||
return "", fmt.Errorf("the answer of %s is not a composition: %w", compositionVerb, err)
|
||||
}
|
||||
// While the engine's apply of the want is in progress (what runs still names the old build, the want names another, and no
|
||||
// attempt names that want as failed or held), this process is the successor and fixes the want's build. self is the node's
|
||||
// standing way of composing the module, not a mark of this process, so it is not enough alone: an old process started again
|
||||
// while the want names a successor that does not run (its apply failed or is held) takes what runs. The start fixes the
|
||||
// build once (quick fix, ADR 0639 §7: nothing is learnt later, nothing waits on a timer); the thorough form is the
|
||||
// engine passing each process the fingerprint it starts it with.
|
||||
running := ""
|
||||
if a.Runs != nil {
|
||||
running = a.Runs.FP
|
||||
}
|
||||
if w := a.Want; w != nil && w.Self && !w.Removed && w.FP != "" && w.FP != running && (a.Attempt == nil || a.Attempt.FP != w.FP) {
|
||||
return w.FP, nil
|
||||
}
|
||||
if a.Runs == nil || a.Runs.FP == "" {
|
||||
return "", errors.New("the engine says no fingerprint of the indexer runs")
|
||||
}
|
||||
|
||||
+8
-7
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
"git.novox.be/novox/mesh-sdk/go/buscompose"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
@@ -71,10 +72,10 @@ func TestAMeshSDKMergeMovesOnlyTheModulesImportingAChangedPackage(t *testing.T)
|
||||
}
|
||||
serveForge(t, js.Conn(), listing, goSumContent)
|
||||
})
|
||||
merged := func(repo string, number int, merge string, f delivery.Forge) {
|
||||
merged := func(repo string, number int, merge string, f forgemsg.Forge) {
|
||||
t.Helper()
|
||||
f.Kind, f.Repository, f.Branch, f.Number, f.Head, f.Merge = delivery.ForgeMerged, repo, "feat/x", number, "0123456789abcdef0123456789abcdef01234567", merge
|
||||
data, err := delivery.EncodeForge(f)
|
||||
f.Kind, f.Repository, f.Branch, f.Number, f.Head, f.Merge = forgemsg.ForgeMerged, repo, "feat/x", number, "0123456789abcdef0123456789abcdef01234567", merge
|
||||
data, err := forgemsg.EncodeForge(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -101,10 +102,10 @@ func TestAMeshSDKMergeMovesOnlyTheModulesImportingAChangedPackage(t *testing.T)
|
||||
"tsconfig.json": "5555555555555555555555555555555555555555"}}
|
||||
}
|
||||
// mesh-sdk at m1, then the catalogue's two modules: their go.sum names mesh-sdk, read by the indexer itself.
|
||||
merged("novox/mesh-sdk", 70, m1, delivery.Forge{Root: sdkRoot1, Trees: map[string]string{"": sdkRoot1},
|
||||
merged("novox/mesh-sdk", 70, m1, forgemsg.Forge{Root: sdkRoot1, Trees: map[string]string{"": sdkRoot1},
|
||||
Manifests: map[string]string{"": string(sdkManifest)}, Paths: sdkObjects("6666666666666666666666666666666666666666")})
|
||||
module("mesh-sdk", func(m delivery.Module) bool { return m.Commit == m1 })
|
||||
merged("novox/mesh-catalog", 301, catalogMerge, delivery.Forge{Root: "r0",
|
||||
merged("novox/mesh-catalog", 301, catalogMerge, forgemsg.Forge{Root: "r0",
|
||||
Trees: map[string]string{"modules/importer": "t1", "modules/plain": "t2"},
|
||||
Manifests: map[string]string{"modules/importer": named("importer"), "modules/plain": named("plain")},
|
||||
Locks: map[string]map[string]string{"modules/importer": {"go.sum": goSumHash}, "modules/plain": {"go.sum": goSumHash}}})
|
||||
@@ -140,11 +141,11 @@ func TestAMeshSDKMergeMovesOnlyTheModulesImportingAChangedPackage(t *testing.T)
|
||||
// over its named tree, is not written (issue 1758, ADR 0499 clause 6; the operator, 2026-10-25: a module's value moves
|
||||
// only with the module).
|
||||
const goOnly = "4444444444444444444444444444444444444444"
|
||||
merged("novox/mesh-sdk", 72, goOnly, delivery.Forge{Root: "cccccccccccccccccccccccccccccccccccccccc",
|
||||
merged("novox/mesh-sdk", 72, goOnly, forgemsg.Forge{Root: "cccccccccccccccccccccccccccccccccccccccc",
|
||||
Trees: map[string]string{"": "cccccccccccccccccccccccccccccccccccccccc"}, Manifests: map[string]string{"": string(sdkManifest)},
|
||||
Paths: sdkObjects("6666666666666666666666666666666666666666")})
|
||||
// mesh-sdk merges a change of buildstep alone (its version moves: src changed too).
|
||||
merged("novox/mesh-sdk", 71, m2, delivery.Forge{Root: sdk2, Trees: map[string]string{"": sdk2},
|
||||
merged("novox/mesh-sdk", 71, m2, forgemsg.Forge{Root: sdk2, Trees: map[string]string{"": sdk2},
|
||||
Manifests: map[string]string{"": string(sdkManifest)}, Paths: sdkObjects("7777777777777777777777777777777777777777")})
|
||||
sdkAt2 := module("mesh-sdk", func(m delivery.Module) bool { return m.Commit == m2 })
|
||||
if sdkAt2.Inputs2 == nil || !strings.HasPrefix(sdkAt2.Inputs2.Tree, delivery.NamedTreePrefix) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/sdk/tryevent"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
|
||||
"mesh-delivery-indexer/internal/index"
|
||||
@@ -73,7 +74,10 @@ func (w *previewWatch) ensure() error {
|
||||
}
|
||||
})
|
||||
}()
|
||||
return w.ix.RecomputeCandidates(w.ctx)
|
||||
if err := w.ix.RecomputeCandidates(w.ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return w.ix.WriteTryCandidates(w.ctx)
|
||||
}
|
||||
|
||||
// Written takes one preview.written event of the checker in: its bucket is watched if it is not. A subject that is not the
|
||||
@@ -90,3 +94,44 @@ func (w *previewWatch) Written(m delivered) {
|
||||
}
|
||||
_ = m.Ack()
|
||||
}
|
||||
|
||||
// tryEventSubject is the subject of an event of the checker's stream checks about a try.
|
||||
func tryEventSubject(event string) string {
|
||||
return "mesh.stream." + delivery.CheckerModule + ".event." + event
|
||||
}
|
||||
|
||||
// heardChecks takes one event of the checker's stream in: a preview written (Written), or a whole try's start or end, from
|
||||
// which the candidate of its group is written or deleted (index.Indexer.StartTry, EndTry). A failure stays unacknowledged, and
|
||||
// the server delivers it again; a body that is no try event is terminated.
|
||||
func (w *previewWatch) heardChecks(m delivered) {
|
||||
var event string
|
||||
switch m.Subject() {
|
||||
case tryEventSubject(tryevent.Started):
|
||||
event = tryevent.Started
|
||||
case tryEventSubject(tryevent.Ended):
|
||||
event = tryevent.Ended
|
||||
default:
|
||||
w.Written(m)
|
||||
return
|
||||
}
|
||||
e, err := tryevent.Decode(event, m.Data())
|
||||
if err != nil {
|
||||
w.logger.Printf("%s: not a try event, dropped: %v", m.Subject(), err)
|
||||
_ = m.Term()
|
||||
return
|
||||
}
|
||||
if event == tryevent.Started {
|
||||
var ms []index.TryMember
|
||||
for _, x := range e.Members {
|
||||
ms = append(ms, index.TryMember{Repository: x.Repository, Head: x.Head, Trunk: x.Trunk})
|
||||
}
|
||||
err = w.ix.StartTry(w.ctx, e.ID, ms)
|
||||
} else {
|
||||
err = w.ix.EndTry(w.ctx, e.ID)
|
||||
}
|
||||
if err != nil {
|
||||
w.logger.Printf("%s of %s: left for redelivery: %v", event, e.ID, err)
|
||||
return
|
||||
}
|
||||
_ = m.Ack()
|
||||
}
|
||||
|
||||
@@ -16,6 +16,11 @@ type takerIn interface {
|
||||
TakeIn(ctx context.Context, module string) index.TakenIn
|
||||
TakeInRepository(ctx context.Context, repository string) index.TakenIn
|
||||
JudgeAgain(ctx context.Context, module, digest string) index.TakenIn
|
||||
TakeInPendingByHand(ctx context.Context, key, by, why string) index.TakenIn
|
||||
DropPending(ctx context.Context, key, by, why string) index.TakenIn
|
||||
TakeInMerge(ctx context.Context, repository, merge string, seq uint64, by, why string) index.TakenIn
|
||||
DropTryCandidate(ctx context.Context, id, by, why string) index.TakenIn
|
||||
RecoverTriesByHand(ctx context.Context, by, why string) index.TakenIn
|
||||
}
|
||||
|
||||
// takenIn answers one value of the takeins state (the tool indexer_take_in, issue 1945): a value not yet answered takes the
|
||||
@@ -34,7 +39,22 @@ func takenIn(ctx context.Context, ix takerIn, kv jetstream.KeyValue, e jetstream
|
||||
return
|
||||
}
|
||||
asked, r := "take-in", index.TakenIn{}
|
||||
if a.Repository != "" {
|
||||
if a.RecoverTries {
|
||||
asked, r = "recovery of the tries", ix.RecoverTriesByHand(ctx, a.By, a.Why)
|
||||
a.Module = "tries"
|
||||
} else if a.TryCandidate != "" && a.Drop {
|
||||
asked, r = "drop of a try's candidate", ix.DropTryCandidate(ctx, a.TryCandidate, a.By, a.Why)
|
||||
a.Module = a.TryCandidate
|
||||
} else if a.Merge != "" {
|
||||
asked, r = "hand take-in of a merge", ix.TakeInMerge(ctx, a.MergeRepository, a.Merge, a.Sequence, a.By, a.Why)
|
||||
a.Module = r.Module
|
||||
} else if a.Pending != "" && a.Drop {
|
||||
asked, r = "drop of a pending merge", ix.DropPending(ctx, a.Pending, a.By, a.Why)
|
||||
a.Module = a.Pending
|
||||
} else if a.Pending != "" {
|
||||
asked, r = "hand take-in of what waits", ix.TakeInPendingByHand(ctx, a.Pending, a.By, a.Why)
|
||||
a.Module = a.Pending
|
||||
} else if a.Repository != "" {
|
||||
asked, r = "take-in of a repository", ix.TakeInRepository(ctx, a.Repository)
|
||||
a.Module = a.Repository
|
||||
} else if a.Judge {
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/takein"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
"github.com/nats-io/nats.go"
|
||||
@@ -62,3 +63,28 @@ func sayAccepted(js jetstream.JetStream, cred process.Account, logf func(string,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sayTakeIn says a take-in event (novox/hq ADR 0633 §5) as the indexer's ordinary event, on the path its other events
|
||||
// take: mesh.mod.<module>.event.take-in.<kind>, the wait's key in the body. A publish that fails is the caller's to say.
|
||||
func sayTakeIn(js jetstream.JetStream, cred process.Account) func(context.Context, takein.TakeIn) error {
|
||||
return func(ctx context.Context, t takein.TakeIn) error {
|
||||
event, err := takein.TakeInEvent(t.Kind)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := takein.EncodeTakeIn(t)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg := nats.NewMsg(cred.EventSubject(event))
|
||||
msg.Header.Set("x-source", cred.Module)
|
||||
msg.Header.Set("x-node", cred.Node)
|
||||
msg.Header.Set("x-time", time.Now().UTC().Format(time.RFC3339Nano))
|
||||
msg.Header.Set("content-type", "application/json")
|
||||
msg.Data = data
|
||||
pub, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
|
||||
defer cancel()
|
||||
_, err = js.PublishMsg(pub, msg)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,19 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
fmt.Fprintln(stderr, "mesh-judge:", err)
|
||||
return 1
|
||||
}
|
||||
v, err := judge.Request(r)
|
||||
// The request's seat definitions (ADR 0636 §4): the composer's request type of this build does not decode them.
|
||||
var seats struct {
|
||||
Seats []json.RawMessage `json:"seats"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &seats); err != nil {
|
||||
fmt.Fprintln(stderr, "mesh-judge:", err)
|
||||
return 1
|
||||
}
|
||||
files := make([][]byte, len(seats.Seats))
|
||||
for i, s := range seats.Seats {
|
||||
files[i] = s
|
||||
}
|
||||
v, err := judge.RequestWith(r, files)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, "mesh-judge:", err)
|
||||
return 1
|
||||
|
||||
@@ -3,7 +3,7 @@ module mesh-delivery-indexer
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261104002338-5a38a1221659
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
@@ -24,6 +24,17 @@ require (
|
||||
|
||||
replace git.novox.be/novox/mesh-delivery/modules/mesh-delivery-builder/buildrun => ../mesh-delivery-builder/buildrun
|
||||
|
||||
require git.novox.be/novox/mesh-delivery/sdk v0.0.0-00010101000000-000000000000
|
||||
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
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8 h1:XEbIVImp6yGjA91rp0iZBoDdL5KfamjOuDLwWvqh0hc=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261103221822-85ba0a9a70d8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
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=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -7,12 +7,13 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
func manifestPull(t *testing.T, number int, head, manifest string) delivery.Forge {
|
||||
f := delivery.Forge{V: delivery.Version, Kind: delivery.ForgePull, Repository: catalogueRepo, Branch: "feat/x", Number: number, Head: head,
|
||||
Action: delivery.PullUpdated, Dirs: []string{"modules/avahi"}, DirsKnown: true}
|
||||
func manifestPull(t *testing.T, number int, head, manifest string) forgemsg.Forge {
|
||||
f := forgemsg.Forge{V: delivery.Version, Kind: forgemsg.ForgePull, Repository: catalogueRepo, Branch: "feat/x", Number: number, Head: head,
|
||||
Action: forgemsg.PullUpdated, Dirs: []string{"modules/avahi"}, DirsKnown: true}
|
||||
if manifest != "" {
|
||||
f.Manifests = map[string]string{"modules/avahi": manifest}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-indexer/takein"
|
||||
"git.novox.be/novox/mesh-delivery/sdk/tryevent"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -80,6 +83,9 @@ type Indexer struct {
|
||||
// repository it replaces by a directory (fingerprint.ReplacedSiblings), which the version line takes by their
|
||||
// content; nil reads none.
|
||||
GoMod func(ctx context.Context, repository, sha, dir string) ([]byte, error)
|
||||
// ReadFile reads one file of a repository at a commit (gitea.gitea_get_file); a file the commit does not hold is nil.
|
||||
// With Files it reads the seat definitions a revision carries (seatsof.go); nil reads none.
|
||||
ReadFile func(ctx context.Context, repository, sha, path string) ([]byte, error)
|
||||
// Now is the clock of a taken value; nil is time.Now.
|
||||
Now func() time.Time
|
||||
Builds *Builds
|
||||
@@ -98,7 +104,7 @@ type Indexer struct {
|
||||
// ReadOpen asks the forge which pull requests into the trunk are open now (gitea_open_pulls). A catalogue pull message
|
||||
// that finds the candidates of another pull request asks it and deletes those of a pull request no longer open, so a
|
||||
// closed message the indexer missed heals at the next one (issue 1126); nil asks none.
|
||||
ReadOpen func(ctx context.Context) ([]delivery.OpenPull, error)
|
||||
ReadOpen func(ctx context.Context) ([]forgemsg.OpenPull, error)
|
||||
|
||||
mu sync.Mutex // guards source, toolchains and previews, which the watches and the forge messages all reach
|
||||
source sourceNow
|
||||
@@ -106,7 +112,7 @@ type Indexer struct {
|
||||
toolchains map[string]map[string]delivery.Toolchain // language, then node (toolchains.go)
|
||||
tried map[string]bool // repository, commit and directory a registered module's manifest was tried at (registered.go)
|
||||
unread map[string]bool // registered modules whose last read the forge could not answer, read again by RetryUnread
|
||||
moving map[string]delivery.Forge // by modules key: a merge naming a module another repository holds, waiting for that trunk to drop it (moves.go)
|
||||
moving map[string]forgemsg.Forge // by modules key: a merge naming a module another repository holds, waiting for that trunk to drop it (moves.go)
|
||||
retrying atomic.Bool // a RetryUnread runs in the background (kickRetry)
|
||||
retryAgain atomic.Bool // an event asked for one more RetryUnread after the running one
|
||||
retryWG sync.WaitGroup // the background RetryUnread, for tests to wait on
|
||||
@@ -115,12 +121,38 @@ type Indexer struct {
|
||||
filling atomic.Bool // a FillTaken runs in the background (KickFill)
|
||||
fillAgain atomic.Bool // a kick arrived while it ran
|
||||
atHeads map[string]map[string]delivery.PublishedPackages // a member head's published Go modules, by repository@head (candidatePublished)
|
||||
saidAt map[string]string // the head of a repository whose failed read was said, by repository: said once per head
|
||||
failedAt map[string]string // the head of a repository whose tree lacks a directory asked, by repository: not asked again until the head changes
|
||||
// OwnBuild answers the fingerprint of the build this program runs, from the node-engine's seat; nil knows none.
|
||||
OwnBuild func(ctx context.Context) (string, error)
|
||||
judgeMu sync.Mutex // one judge pass at a time (verdicts.go)
|
||||
ownMu sync.Mutex // guards ownFP
|
||||
ownFP string // the fingerprint of the build this program runs, asked at the start pass (learnOwnBuild)
|
||||
judgePending atomic.Bool // the start's judge pass failed: judged again at the next message (verdicts.go)
|
||||
OwnBuild func(ctx context.Context) (string, error)
|
||||
underSeats bool // a dry run inside judge.WithSeats: its judge passes do not hold the seat list (verdicts.go)
|
||||
judgeMu sync.Mutex // one judge pass at a time (verdicts.go)
|
||||
ownMu sync.Mutex // guards ownFP
|
||||
ownFP string // the fingerprint of the build this program runs, fixed once at the start (FixOwnBuild)
|
||||
ownFixed bool // FixOwnBuild asked already: it asks once
|
||||
ownSince time.Time // when the build was asked and not known, with ownWhy
|
||||
ownWhy string
|
||||
// OwnBuildState keeps which build this process knows it runs, or since when and why not (FixOwnBuild); nil keeps none.
|
||||
OwnBuildState Store
|
||||
judgePending atomic.Bool // the start's judge pass failed: judged again at the next message (verdicts.go)
|
||||
// Message asks the forge bridge for the merged message of a merge, repository and merge commit in, the message whole out (a
|
||||
// pending value keeps a reference, not a copy: issue 1585); an error is the bridge's words. Nil asks none.
|
||||
Message func(ctx context.Context, repository, merge string) ([]byte, error)
|
||||
heldMu sync.Mutex
|
||||
held map[uint64]forgemsg.Forge // the merged messages Arrive was handed, by stream sequence, until their merge is taken in or dropped
|
||||
// Pending is the state of merges held for the indexer their revision names (pending.go, novox/hq ADR 0633 §1); nil
|
||||
// takes every merge in at once, as before the take-in step.
|
||||
Pending Store
|
||||
// SayTakeIn publishes a take-in event (take-in.pending, take-in.taken; ADR 0633 §5); nil says none.
|
||||
SayTakeIn func(ctx context.Context, t takein.TakeIn) error
|
||||
// OpenTries lists the tries that wait or run, from the checker's tool (checker_tries); nil recovers none (trycandidates.go).
|
||||
OpenTries func(ctx context.Context) ([]tryevent.Open, error)
|
||||
// TryRecovery keeps how the last recovery of the open tries went (trycandidates.go); nil keeps none.
|
||||
TryRecovery Store
|
||||
tryMu sync.Mutex // one try event or recovery at a time (trycandidates.go)
|
||||
recoveryFailed atomic.Bool // the last recovery of the open tries failed (trycandidates.go)
|
||||
tries map[string][]TryMember // the whole tries that wait or run, by try id (trycandidates.go); guarded by mu
|
||||
pendingMu sync.Mutex // one take-in step at a time: a merge, a group's end, the start and a hand act (pending.go)
|
||||
}
|
||||
|
||||
func (ix *Indexer) logf(format string, args ...any) {
|
||||
@@ -133,23 +165,23 @@ func (ix *Indexer) logf(format string, args ...any) {
|
||||
// pull request, a closed one or a merged message takes it out; a group left with none is deleted. A change is written
|
||||
// by compare-and-set on the revision read, and read again on a conflict. A lone pull request is a group of one. A
|
||||
// merged message also writes the fingerprint of each module of its repository (modules.go).
|
||||
func (ix *Indexer) Apply(ctx context.Context, f delivery.Forge) error {
|
||||
func (ix *Indexer) Apply(ctx context.Context, f forgemsg.Forge) error {
|
||||
_, err := ix.ApplyMessage(ctx, f)
|
||||
return err
|
||||
}
|
||||
|
||||
// ApplyMessage is Apply, and answers which modules a merged message wrote and kept (for the indexer's heard state). Each
|
||||
// applied merged message is said in one line: its repository, commit and pull request, and the modules written.
|
||||
func (ix *Indexer) ApplyMessage(ctx context.Context, f delivery.Forge) (Applied, error) {
|
||||
func (ix *Indexer) ApplyMessage(ctx context.Context, f forgemsg.Forge) (Applied, error) {
|
||||
ix.kickJudge(ctx)
|
||||
var applied Applied
|
||||
if f.Kind == delivery.ForgeOpen {
|
||||
if f.Kind == forgemsg.ForgeOpen {
|
||||
return applied, ix.applyOpen(ctx, f)
|
||||
}
|
||||
if f.Kind != delivery.ForgePull && f.Kind != delivery.ForgeMerged {
|
||||
if f.Kind != forgemsg.ForgePull && f.Kind != forgemsg.ForgeMerged {
|
||||
return applied, nil
|
||||
}
|
||||
if f.Kind == delivery.ForgeMerged && ix.Modules != nil {
|
||||
if f.Kind == forgemsg.ForgeMerged && ix.Modules != nil {
|
||||
w, err := ix.applyMerged(ctx, f)
|
||||
applied = Applied{Written: w.written, Late: w.late}
|
||||
if err != nil {
|
||||
@@ -178,7 +210,7 @@ func (ix *Indexer) ApplyMessage(ctx context.Context, f delivery.Forge) (Applied,
|
||||
}
|
||||
var digests map[string]string
|
||||
var held map[string]delivery.Module
|
||||
if f.Kind == delivery.ForgePull {
|
||||
if f.Kind == forgemsg.ForgePull {
|
||||
var err error
|
||||
if digests, err = ix.keepManifests(ctx, f); err != nil {
|
||||
return applied, err
|
||||
@@ -207,15 +239,26 @@ func (ix *Indexer) ApplyMessage(ctx context.Context, f delivery.Forge) (Applied,
|
||||
ix.logf("%s#%d: its config declarations are not written: %v", f.Repository, f.Number, err)
|
||||
}
|
||||
}
|
||||
return applied, ix.writeCandidate(ctx, f, digests, held)
|
||||
}
|
||||
|
||||
// removeCandidate takes a merged pull request out of its group's candidate and writes nothing else of its message: for a
|
||||
// merge a newer merge of the same repository covers (it needs only the repository, number and branch).
|
||||
func (ix *Indexer) removeCandidate(ctx context.Context, f forgemsg.Forge) error {
|
||||
return ix.writeCandidate(ctx, f, nil, nil)
|
||||
}
|
||||
|
||||
// writeCandidate writes the group's candidate with the pull request f names (taken out for a merged or closed one).
|
||||
func (ix *Indexer) writeCandidate(ctx context.Context, f forgemsg.Forge, digests map[string]string, held map[string]delivery.Module) error {
|
||||
if trunkLike[f.Branch] {
|
||||
ix.logf("%s#%d: its branch %q is no delivery group, so no candidate", f.Repository, f.Number, f.Branch)
|
||||
return applied, nil
|
||||
return nil
|
||||
}
|
||||
key, err := delivery.CandidatesKey(f.Branch)
|
||||
if err != nil {
|
||||
return applied, fmt.Errorf("%w: %s#%d: %v", ErrUnreadable, f.Repository, f.Number, err)
|
||||
return fmt.Errorf("%w: %s#%d: %v", ErrUnreadable, f.Repository, f.Number, err)
|
||||
}
|
||||
return applied, write(ctx, ix.Store, key, func(value []byte, found bool) ([]byte, bool, error) {
|
||||
return write(ctx, ix.Store, key, func(value []byte, found bool) ([]byte, bool, error) {
|
||||
var cur *delivery.Candidate
|
||||
if found {
|
||||
c, err := delivery.DecodeCandidate(value)
|
||||
@@ -245,8 +288,8 @@ func (ix *Indexer) ApplyMessage(ctx context.Context, f delivery.Forge) (Applied,
|
||||
// applyOpen takes an open message into the candidates (novox/hq ADR 0443 §5): every pull request it does not name is
|
||||
// taken out of its group, and a group left with none is deleted. What the message names is left as it is. A pull request
|
||||
// opened after the bridge read the forge is heard after this message and added again by its own.
|
||||
func (ix *Indexer) applyOpen(ctx context.Context, f delivery.Forge) error {
|
||||
named := make(map[delivery.OpenPull]bool, len(f.Open))
|
||||
func (ix *Indexer) applyOpen(ctx context.Context, f forgemsg.Forge) error {
|
||||
named := make(map[forgemsg.OpenPull]bool, len(f.Open))
|
||||
for _, p := range f.Open {
|
||||
named[p] = true
|
||||
}
|
||||
@@ -255,6 +298,9 @@ func (ix *Indexer) applyOpen(ctx context.Context, f delivery.Forge) error {
|
||||
return fmt.Errorf("listing the candidates: %w", err)
|
||||
}
|
||||
for _, key := range keys {
|
||||
if IsTryGroup(key) {
|
||||
continue // a try's candidate is no pull request's: the checker's try owns it (trycandidates.go)
|
||||
}
|
||||
err := write(ctx, ix.Store, key, func(value []byte, found bool) ([]byte, bool, error) {
|
||||
if !found {
|
||||
return nil, false, nil
|
||||
@@ -264,7 +310,7 @@ func (ix *Indexer) applyOpen(ctx context.Context, f delivery.Forge) error {
|
||||
return nil, false, fmt.Errorf("candidates.%s holds what is not a candidate: %w", key, err)
|
||||
}
|
||||
kept := slices.DeleteFunc(slices.Clone(c.Pulls), func(p delivery.CandidatePull) bool {
|
||||
return !named[delivery.OpenPull{Repository: p.Repository, Number: p.Number}]
|
||||
return !named[forgemsg.OpenPull{Repository: p.Repository, Number: p.Number}]
|
||||
})
|
||||
switch {
|
||||
case len(kept) == len(c.Pulls):
|
||||
@@ -331,14 +377,14 @@ func write(ctx context.Context, st Store, key string, compute func(value []byte,
|
||||
// its manifest and why it has no fingerprint of the new form (NoneNoPreview), and the candidate its FPSet (issue 1451); a
|
||||
// pull request whose head is held again without its directories keeps the Fps it had. digests are the manifests' digests by
|
||||
// directory; held are the modules the modules bucket holds, which name the module of a changed directory (moduleOfDir).
|
||||
func (ix *Indexer) next(cur *delivery.Candidate, f delivery.Forge, digests map[string]string, held map[string]delivery.Module) *delivery.Candidate {
|
||||
func (ix *Indexer) next(cur *delivery.Candidate, f forgemsg.Forge, digests map[string]string, held map[string]delivery.Module) *delivery.Candidate {
|
||||
var pulls []delivery.CandidatePull
|
||||
var base uint64
|
||||
if cur != nil {
|
||||
pulls, base = slices.Clone(cur.Pulls), cur.Base
|
||||
}
|
||||
at := slices.IndexFunc(pulls, func(p delivery.CandidatePull) bool { return p.Repository == f.Repository && p.Number == f.Number })
|
||||
if f.Kind == delivery.ForgeMerged || f.Action == delivery.PullClosed {
|
||||
if f.Kind == forgemsg.ForgeMerged || f.Action == forgemsg.PullClosed {
|
||||
if at < 0 {
|
||||
return cur
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
)
|
||||
@@ -27,7 +28,7 @@ var (
|
||||
|
||||
// writePredefined keeps the predefined list a merged message carries, unless the key names a later merge: a merge older
|
||||
// than the one it names never replaces it (ADR 0348 §7). The same list at the same merge writes nothing (ADR 0289 §1).
|
||||
func (ix *Indexer) writePredefined(ctx context.Context, f delivery.Forge) error {
|
||||
func (ix *Indexer) writePredefined(ctx context.Context, f forgemsg.Forge) error {
|
||||
if ix.Predefined == nil || len(f.Predefined) == 0 {
|
||||
return nil
|
||||
}
|
||||
@@ -203,8 +204,8 @@ func (ix *Indexer) dropConfigCandidate(ctx context.Context, repository string, n
|
||||
|
||||
// configCandidateOfPull is a pull message's part in config-candidates: a head whose manifests the message carries is written,
|
||||
// a closed one deleted. A head held again without its directories leaves the key as it is.
|
||||
func (ix *Indexer) configCandidateOfPull(ctx context.Context, f delivery.Forge, held map[string]delivery.Module) error {
|
||||
if f.Action == delivery.PullClosed {
|
||||
func (ix *Indexer) configCandidateOfPull(ctx context.Context, f forgemsg.Forge, held map[string]delivery.Module) error {
|
||||
if f.Action == forgemsg.PullClosed {
|
||||
return ix.dropConfigCandidate(ctx, f.Repository, f.Number)
|
||||
}
|
||||
if !f.DirsKnown {
|
||||
@@ -220,7 +221,7 @@ func (ix *Indexer) configCandidateOfPull(ctx context.Context, f delivery.Forge,
|
||||
// configCandidatesAfterMerge writes again every open pull request's key that names a module the merge changed, since what
|
||||
// it adds is judged against the trunk (ADR 0565 §2), from the head's manifests its candidate keeps; and deletes the merged
|
||||
// pull request's.
|
||||
func (ix *Indexer) configCandidatesAfterMerge(ctx context.Context, f delivery.Forge, changed []string) error {
|
||||
func (ix *Indexer) configCandidatesAfterMerge(ctx context.Context, f forgemsg.Forge, changed []string) error {
|
||||
if ix.ConfigCandidates == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -243,6 +244,9 @@ func (ix *Indexer) configCandidatesAfterMerge(ctx context.Context, f delivery.Fo
|
||||
}
|
||||
var errs []error
|
||||
for _, g := range groups {
|
||||
if IsTryGroup(g) {
|
||||
continue // a try's candidate holds no pull request's config declarations (trycandidates.go)
|
||||
}
|
||||
raw, _, found, err := ix.Store.Get(ctx, g)
|
||||
if err != nil || !found {
|
||||
continue
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-delivery/modules/mesh-delivery-forge-bridge/forgemsg"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/process"
|
||||
)
|
||||
@@ -77,12 +78,29 @@ func TestTheControlRecords(t *testing.T) {
|
||||
t.Errorf("a head declaring nothing new = %+v, want an empty set", c)
|
||||
}
|
||||
closed := pull(catalogueRepo, 7, "feat/y", strings.Repeat("d", 40))
|
||||
closed.Action = delivery.PullClosed
|
||||
closed.Action = forgemsg.PullClosed
|
||||
if err := ix.Apply(ctx, closed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := cands.vals[key]; ok {
|
||||
t.Error("a closed pull request's declarations stayed")
|
||||
}
|
||||
// A group try's candidate (number 0) holds no pull request's declarations: a merge that touches its module writes none
|
||||
// for it (fails when configCandidatesAfterMerge no longer skips a try's group).
|
||||
tryPull := delivery.CandidatePull{Head: delivery.Head{Repository: catalogueRepo, Head: h1}, Modules: []string{"avahi"},
|
||||
Fps: map[string]delivery.CandidateModule{"avahi": {Manifest: delivery.ManifestDigest([]byte(trunk)), None2: NoneNoPreview}}}
|
||||
tryCand := delivery.Candidate{V: delivery.Version, Group: "try-5", Pulls: []delivery.CandidatePull{tryPull}}
|
||||
tryCand.HeadsHash, _ = delivery.HeadsHash([]delivery.Head{tryPull.Head})
|
||||
tryCand.FPSet = delivery.FingerprintSet(tryCand.Pulls)
|
||||
data, err := delivery.EncodeCandidate(tryCand)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ix.Store.Create(ctx, "try-5", data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ix.configCandidatesAfterMerge(ctx, mergedMsg(catalogueRepo, map[string]string{"modules/avahi": "t2"}), []string{"avahi"}); err != nil || len(cands.vals) != 0 {
|
||||
t.Errorf("a merge touching a try's module wrote %v for it (%v), want none", cands.vals, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user