From c76d8ef381da08f8e860b1310fcd9419b52459e5 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 12 Nov 2026 06:24:13 +0100 Subject: [PATCH] IX5: the validation's request, run and verdict over the change and trunk cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The running indexer's validation before take-in asks its own manifest validator (cmd/mesh-judge under the indexer entry's Go module, at the revision's head) over two cases and refuses only what the revision causes; mesh-judge reads the file MESH_RUN_INPUT names, where a run writes its input. Builds: design 47 G §712, A-213, A-232 Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h --- .../cmd/mesh-judge/main.go | 10 +- .../cmd/mesh-judge/main_test.go | 37 +++ .../internal/index/seatsof.go | 11 +- .../internal/index/validate.go | 234 ++++++++++++++++++ .../internal/index/validate_test.go | 74 ++++++ 5 files changed, 363 insertions(+), 3 deletions(-) create mode 100644 modules/mesh-delivery-indexer/cmd/mesh-judge/main_test.go create mode 100644 modules/mesh-delivery-indexer/internal/index/validate.go create mode 100644 modules/mesh-delivery-indexer/internal/index/validate_test.go diff --git a/modules/mesh-delivery-indexer/cmd/mesh-judge/main.go b/modules/mesh-delivery-indexer/cmd/mesh-judge/main.go index 0fb0713a..e0fab53e 100644 --- a/modules/mesh-delivery-indexer/cmd/mesh-judge/main.go +++ b/modules/mesh-delivery-indexer/cmd/mesh-judge/main.go @@ -1,7 +1,9 @@ // Command mesh-judge is the indexer's judge as a program the checker runs (novox/hq ADR 0578 §2): built at the trunk, or // at a group's mesh-delivery head when that head changes the judge, and run by a Go build module's compose step in its -// sandbox exactly as the composer is (ADR 0482 §4; delivery.ComposeJob with this package). It reads a composer request -// (mesh-sdk composer.Request; `-request `, or its standard input) and writes one line, a composer verdict, whose +// sandbox exactly as the composer is (ADR 0482 §4; delivery.ComposeJob with this package), and by the running indexer's +// validation before take-in as a build seat's run (design 47 G §712). It reads a composer request (mesh-sdk +// composer.Request; `-request `, else the file MESH_RUN_INPUT names, where a run writes its input, else its standard +// input) and writes one line, a composer verdict, whose // cases say of each manifest whether the judge accepts it and, when not, every problem. It reads nothing else: no bus, // store, network or environment. package main @@ -13,6 +15,7 @@ import ( "io" "os" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/buildstep" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/composer" "mesh-delivery-indexer/judge" @@ -32,6 +35,9 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { } var raw []byte var err error + if *file == "" { + *file = os.Getenv(buildstep.RunInputEnv) + } if *file != "" { raw, err = os.ReadFile(*file) } else { diff --git a/modules/mesh-delivery-indexer/cmd/mesh-judge/main_test.go b/modules/mesh-delivery-indexer/cmd/mesh-judge/main_test.go new file mode 100644 index 00000000..2fe499f5 --- /dev/null +++ b/modules/mesh-delivery-indexer/cmd/mesh-judge/main_test.go @@ -0,0 +1,37 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/buildstep" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/composer" +) + +// A run ask writes the request to the file MESH_RUN_INPUT names and passes no argument (design 47 G §712): the validator +// reads it there and answers both cases. +func TestTheRunsInputFileIsTheRequest(t *testing.T) { + req := composer.Request{V: composer.Version, Input: json.RawMessage("{}"), Cases: []composer.Case{ + {Name: "change", Manifests: map[string]composer.Manifest{}}, {Name: "trunk", Manifests: map[string]composer.Manifest{}}}} + raw, err := json.Marshal(req) + if err != nil { + t.Fatal(err) + } + file := filepath.Join(t.TempDir(), "input") + if err := os.WriteFile(file, raw, 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(buildstep.RunInputEnv, file) + var out, errs bytes.Buffer + if code := run(nil, strings.NewReader(""), &out, &errs); code != 0 { + t.Fatalf("exit %d: %s", code, errs.String()) + } + var v composer.Verdict + if err := json.Unmarshal(out.Bytes(), &v); err != nil || len(v.Cases) != 2 { + t.Fatalf("the verdict %s: %v", out.String(), err) + } +} diff --git a/modules/mesh-delivery-indexer/internal/index/seatsof.go b/modules/mesh-delivery-indexer/internal/index/seatsof.go index 56c64c40..e32fcd5a 100644 --- a/modules/mesh-delivery-indexer/internal/index/seatsof.go +++ b/modules/mesh-delivery-indexer/internal/index/seatsof.go @@ -23,6 +23,15 @@ import ( // (the take-in is tried again); a definition that does not parse is said and left out, as the indexer that keeps it // would say it unparsed. func (ix *Indexer) revisionSeats(ctx context.Context, ready []PendingEntry) ([]catalogue.Seat, error) { + defs, err := ix.revisionSeatDefinitions(ctx, ready) + if err != nil || defs == nil { + return nil, err + } + return catalogue.SeatsFromDefinitions(slices.Collect(maps.Values(defs))), nil +} + +// revisionSeatDefinitions are the definitions revisionSeats reads, by seat; nil where the indexer has no forge reader. +func (ix *Indexer) revisionSeatDefinitions(ctx context.Context, ready []PendingEntry) (map[string]seatdef.Definition, error) { if ix.ReadFile == nil || ix.Files == nil { return nil, nil } @@ -48,5 +57,5 @@ func (ix *Indexer) revisionSeats(ctx context.Context, ready []PendingEntry) ([]c } } } - return catalogue.SeatsFromDefinitions(slices.Collect(maps.Values(defs))), nil + return defs, nil } diff --git a/modules/mesh-delivery-indexer/internal/index/validate.go b/modules/mesh-delivery-indexer/internal/index/validate.go new file mode 100644 index 00000000..a394aa1e --- /dev/null +++ b/modules/mesh-delivery-indexer/internal/index/validate.go @@ -0,0 +1,234 @@ +package index + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "slices" + "strings" + + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/buildstep" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/composer" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/trunk" + "git.novox.be/novox/mesh-sdk/go/catalogue" + + "mesh-delivery-indexer/judge" +) + +// The validation before take-in (design 47 G §712, A-213, A-232): for a pending revision of a watched branch the running +// indexer asks the seat node-build-go a `run` of its own manifest validator over two cases, change and trunk, and refuses only +// what the revision causes (ADR 0579 §2). The ask rides the check queue under the indexer's name, its kind `run` in the body. +const ( + // validationProgram is the manifest validator's program, by its directory under the indexer entry's Go module: the indexer's + // own code, so no repository or module name is fixed here (ADR 0666 §5). + validationProgram = "cmd/mesh-judge" + // validationAsker is the asker the ask is put under. A-232 names the indexer's seat name mesh-catalog; the ask code still + // names askers by their nicknames until the asker rename after the join (A-163), so this is `indexer` until then. + validationAsker = "indexer" + // validationCatalogSeat is the seat the indexer entry claims. + validationCatalogSeat = "mesh-catalog" + // The cases of the request, as the manifest validator names them. + validationChange = "change" + validationTrunk = judge.TrunkCase +) + +// validationInputVerb is where the build seat's holder asks the input: the seat mesh-catalog's verb input (§354). +var validationInputVerb = "seat:" + meshcatalog.Seat + "." + meshcatalog.VerbInput + +// validationRequest is the manifest validator's request for the ready merges of branch over the branch's current revision +// (§712): case change, every module of the revision by its entry's manifest, each one the merges carry replaced by the merged +// message's bytes (the newest merge governs) and one added for a new directory; case trunk, every module as it stands; the +// seat definitions the merges' trees carry; the packages and the predefined list as the take-in's own validator has them; +// no machines. It answers the indexer entry of the change case too, the one that claims the seat mesh-catalog. +func (ix *Indexer) validationRequest(ctx context.Context, branch string, ready []PendingEntry) (composer.Request, meshcatalog.CatalogEntry, error) { + var indexer meshcatalog.CatalogEntry + if ix.Revisions == nil { + return composer.Request{}, indexer, errors.New("the catalog graph is not kept here: no revision to validate over") + } + rev, found, err := newestOf(ctx, ix.Revisions, branch) + if err != nil { + return composer.Request{}, indexer, err + } + var entries []meshcatalog.CatalogEntry + if found { + if _, entries, err = ReadRevision(ctx, ix.Revisions, RevisionObject(rev)); err != nil { + return composer.Request{}, indexer, err + } + } + trunkCase := composer.Case{Name: validationTrunk, Manifests: map[string]composer.Manifest{}} + byPlace := map[string]string{} // -> module + of := map[string]meshcatalog.CatalogEntry{} + for _, e := range entries { + raw := ix.manifestBytes(ctx, e.ManifestDigest) + if raw == "" { + b, err := json.Marshal(e.Manifest) + if err != nil { + return composer.Request{}, indexer, fmt.Errorf("the manifest of %s: %w", e.Module, err) + } + raw = string(b) + } + trunkCase.Manifests[e.Module] = composer.Manifest{Raw: json.RawMessage(raw)} + byPlace[placeKey(e.Repository, e.Path)] = e.Module + of[e.Module] = e + } + change := composer.Case{Name: validationChange, Manifests: maps.Clone(trunkCase.Manifests)} + for _, e := range ready { + if e.Covered { + continue + } + for _, dir := range slices.Sorted(maps.Keys(e.Forge.Manifests)) { + raw := e.Forge.Manifests[dir] + module, known := byPlace[placeKey(e.Forge.Repository, dir)] + if !known { + m, err := judge.ForInputs([]byte(raw)) + if err != nil || m.Module == "" { + module = e.Forge.Repository + "/" + dir // a new directory whose manifest names no module: judged under its place + } else { + module = m.Module + } + byPlace[placeKey(e.Forge.Repository, dir)] = module + of[module] = meshcatalog.CatalogEntry{Module: module, Repository: e.Forge.Repository, Path: dir} + } + change.Manifests[module] = composer.Manifest{Raw: json.RawMessage(raw)} + } + } + for _, module := range slices.Sorted(maps.Keys(change.Manifests)) { + m, err := judge.ForInputs(change.Manifests[module].Raw) + if err != nil || !slices.ContainsFunc(m.Claims, func(c catalogue.Claim) bool { return c.Name == validationCatalogSeat }) { + continue + } + indexer = of[module] + if indexer.Module == "" { + indexer.Module = module + } + break + } + seats, err := ix.revisionDefinitions(ctx, ready) + if err != nil { + return composer.Request{}, indexer, err + } + req := composer.Request{V: composer.Version, Input: json.RawMessage("{}"), Cases: []composer.Case{change, trunkCase}, Seats: seats, + Predefined: ix.predefinedList(ctx)} + if known := ix.knownPackages(ctx); known != nil { + req.Packages = slices.Sorted(maps.Keys(known)) + } + return req, indexer, nil +} + +func placeKey(repository, path string) string { + return strings.ToLower(trunk.RepositoryName(repository)) + " " + path +} + +// revisionDefinitions are the seat definitions the merges' trees carry, raw (§712 `seats`). +func (ix *Indexer) revisionDefinitions(ctx context.Context, ready []PendingEntry) ([]json.RawMessage, error) { + defs, err := ix.revisionSeatDefinitions(ctx, ready) + if err != nil { + return nil, err + } + var out []json.RawMessage + for _, seat := range slices.Sorted(maps.Keys(defs)) { + raw, err := json.Marshal(defs[seat]) + if err != nil { + return nil, fmt.Errorf("the definition of %s: %w", seat, err) + } + out = append(out, raw) + } + return out, nil +} + +// validationJob is the run of the manifest validator for the request (§712): the program cmd/mesh-judge under the Go module +// the indexer entry's Go artifact names, at the entry's path, built at head, the revision's head of the entry's repository; no +// heads and no arguments; the input the request's bytes, served by the seat mesh-catalog's verb input. +func validationJob(indexer meshcatalog.CatalogEntry, head, branch string, seq uint64, input []byte) (buildstep.RunJob, error) { + var pkg string + for _, a := range indexer.Artifacts { + if a.Language == buildstep.AskGo && a.Package != nil && a.Package.Name != "" { + pkg = a.Package.Name + break + } + } + switch { + case indexer.Repository == "": + return buildstep.RunJob{}, fmt.Errorf("no entry of the revision claims the seat %s", validationCatalogSeat) + case pkg == "": + return buildstep.RunJob{}, fmt.Errorf("the indexer entry %s names no Go artifact's package", indexer.Module) + case head == "": + return buildstep.RunJob{}, fmt.Errorf("the revision names no head of %s, the repository of the indexer entry %s", indexer.Repository, indexer.Module) + } + in, err := buildstep.RunInputOf(validationInputVerb, input) + if err != nil { + return buildstep.RunJob{}, err + } + dir := indexer.Path + if dir == "" { + dir = "." + } + return buildstep.RunJob{ + Program: buildstep.RunProgram{Repository: indexer.Repository, Commit: head, Dir: dir, Package: pkg + "/" + validationProgram}, + Input: in, + Reader: fmt.Sprintf("manifest validator of %s at %d", branchName(branch), seq), + }, nil +} + +func branchName(branch string) string { + if isTrunkBranch(branch) { + return TrunkBranch + } + return branch +} + +// ValidationRefusal is what a validation's verdict refuses (ADR 0579 §2, §712): of each manifest the change case refuses that +// the revision adds or changes (its bytes differ from the trunk case's), each problem the trunk case lacks; and each problem +// between manifests the trunk case lacks. A problem only in unchanged manifests refuses nothing. It is empty when the revision +// is accepted. An answer that holds not both cases is no verdict: it answers an error, so the run could not validate. +func ValidationRefusal(req composer.Request, v composer.Verdict) (map[string][]string, error) { + cases := map[string]composer.CaseVerdict{} + for _, c := range v.Cases { + cases[c.Name] = c + } + change, okChange := cases[validationChange] + trunkV, okTrunk := cases[validationTrunk] + if !okChange || !okTrunk { + return nil, fmt.Errorf("the manifest validator's answer holds not both cases %s and %s", validationChange, validationTrunk) + } + raws := map[string]map[string]string{} + for _, c := range req.Cases { + raws[c.Name] = map[string]string{} + for name, m := range c.Manifests { + raws[c.Name][name] = InputDigest(m.Raw) + } + } + refused := map[string][]string{} + for _, module := range slices.Sorted(maps.Keys(change.Manifests)) { + r := change.Manifests[module] + if r.Accepted || r.Refused == "" { + continue + } + before, had := raws[validationTrunk][module] + if module != judge.Unnamed && had && before == raws[validationChange][module] { + continue // unchanged: its problems refuse nothing, and its entry keeps its verdict (ADR 0579 §1) + } + old := map[string]bool{} + for _, p := range strings.Split(trunkV.Manifests[module].Refused, "\n") { + old[p] = true + } + for _, p := range strings.Split(r.Refused, "\n") { + if p != "" && !old[p] { + refused[module] = append(refused[module], p) + } + } + } + return refused, nil +} + +// RefusedWords is a refusal as the pending value's `refused` says it: each manifest and its problems, one manifest a line. +func RefusedWords(refused map[string][]string) string { + var lines []string + for _, module := range slices.Sorted(maps.Keys(refused)) { + lines = append(lines, module+": "+strings.Join(refused[module], "; ")) + } + return strings.Join(lines, "\n") +} diff --git a/modules/mesh-delivery-indexer/internal/index/validate_test.go b/modules/mesh-delivery-indexer/internal/index/validate_test.go new file mode 100644 index 00000000..d99a3358 --- /dev/null +++ b/modules/mesh-delivery-indexer/internal/index/validate_test.go @@ -0,0 +1,74 @@ +package index + +import ( + "encoding/json" + "strings" + "testing" + + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshcatalog" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/buildstep" + "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/composer" + + "mesh-delivery-indexer/judge" +) + +// The validation refuses only what the revision causes (design 47 G §712, ADR 0579 §2), and its run names the indexer entry's +// repository, path and the revision's head, and no heads. +func TestValidationRefusesWhatTheRevisionCauses(t *testing.T) { + manifests := func(m map[string]string) map[string]composer.Manifest { + out := map[string]composer.Manifest{} + for k, v := range m { + out[k] = composer.Manifest{Raw: json.RawMessage(v)} + } + return out + } + req := composer.Request{Cases: []composer.Case{ + {Name: "change", Manifests: manifests(map[string]string{"a": `{"module":"a","v":2}`, "b": `{"module":"b"}`})}, + {Name: "trunk", Manifests: manifests(map[string]string{"a": `{"module":"a"}`, "b": `{"module":"b"}`})}, + }} + verdict := func(change, trunk map[string]string) composer.Verdict { + read := func(m map[string]string) map[string]composer.ManifestRead { + out := map[string]composer.ManifestRead{"a": {Accepted: true}, "b": {Accepted: true}} + for k, v := range m { + out[k] = composer.ManifestRead{Refused: v} + } + return out + } + return composer.Verdict{Cases: []composer.CaseVerdict{{Name: "change", Manifests: read(change)}, {Name: "trunk", Manifests: read(trunk)}}} + } + for _, c := range []struct { + name string + v composer.Verdict + want, wantErr string + }{ + {"a changed manifest gains a problem", verdict(map[string]string{"a": "new\nold"}, map[string]string{"a": "old"}), "a: new", ""}, + {"an unchanged manifest's problem refuses nothing", verdict(map[string]string{"b": "bad"}, nil), "", ""}, + {"a problem between manifests the trunk lacks", verdict(map[string]string{judge.Unnamed: "x\ny"}, map[string]string{judge.Unnamed: "y"}), judge.Unnamed + ": x", ""}, + {"an answer with one case is no verdict", composer.Verdict{Cases: verdict(nil, nil).Cases[:1]}, "", "not both cases"}, + } { + got, err := ValidationRefusal(req, c.v) + if c.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), c.wantErr) { + t.Errorf("%s: %v, want %q", c.name, err, c.wantErr) + } + continue + } + if err != nil || RefusedWords(got) != c.want { + t.Errorf("%s: %q, %v, want %q", c.name, RefusedWords(got), err, c.want) + } + } + head := strings.Repeat("a", 40) + entry := meshcatalog.CatalogEntry{Module: "mesh-delivery-indexer", Repository: "novox/mesh-delivery", Path: "modules/mesh-delivery-indexer", + Artifacts: []meshcatalog.Artifact{{Language: "go", Package: &meshcatalog.ArtifactPackage{Ecosystem: "go", Name: "mesh-delivery-indexer"}}}} + job, err := validationJob(entry, head, "main", 7, []byte(`{"v":1}`)) + if err != nil { + t.Fatal(err) + } + want := buildstep.RunProgram{Repository: "novox/mesh-delivery", Commit: head, Dir: "modules/mesh-delivery-indexer", Package: "mesh-delivery-indexer/cmd/mesh-judge"} + if job.Program != want || len(job.Heads) != 0 || len(job.Args) != 0 || job.Input.Verb != "seat:mesh-catalog.input" || job.Reader != "manifest validator of main at 7" { + t.Errorf("the job %+v", job) + } + if _, err := validationJob(meshcatalog.CatalogEntry{}, head, "main", 7, nil); err == nil || !strings.Contains(err.Error(), "claims the seat mesh-catalog") { + t.Errorf("no indexer entry: %v", err) + } +}