IX5: the validation's request, run and verdict over the change and trunk cases
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
This commit is contained in:
@@ -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 <file>`, 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 <file>`, 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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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{} // <repository> <path> -> 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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user