diff --git a/modules/mesh-delivery-indexer/cmd/mesh-judge/main.go b/modules/mesh-delivery-indexer/cmd/mesh-judge/main.go new file mode 100644 index 0000000..3d42826 --- /dev/null +++ b/modules/mesh-delivery-indexer/cmd/mesh-judge/main.go @@ -0,0 +1,102 @@ +// 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 +// 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 + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "os" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/composer" + + "mesh-delivery-indexer/judge" +) + +// Package is this command's import path in the indexer's Go module (the directory delivery.ComposeJob's Dir names). +const Package = "mesh-delivery-indexer/cmd/mesh-judge" + +// Between names, in a case's verdict, the problems between manifests that name no module of the case. +const Between = "(between manifests)" + +func main() { os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) } + +func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + flags := flag.NewFlagSet("mesh-judge", flag.ContinueOnError) + flags.SetOutput(stderr) + file := flags.String("request", "", "the request's file; without it, the standard input") + if err := flags.Parse(args); err != nil { + return 2 + } + var raw []byte + var err error + if *file != "" { + raw, err = os.ReadFile(*file) + } else { + raw, err = io.ReadAll(stdin) + } + if err != nil { + fmt.Fprintln(stderr, "mesh-judge:", err) + return 1 + } + r, err := composer.Decode(raw) + if err != nil { + fmt.Fprintln(stderr, "mesh-judge:", err) + return 1 + } + v, err := Judge(r) + if err != nil { + fmt.Fprintln(stderr, "mesh-judge:", err) + return 1 + } + out, err := json.Marshal(v) + if err != nil { + fmt.Fprintln(stderr, "mesh-judge:", err) + return 1 + } + fmt.Fprintln(stdout, string(out)) + return 0 +} + +// Judge judges every case of r: each case's manifests as one catalogue (judge.Shelf), every manifest accepted or +// refused with its problems; problems between manifests that name none of them under Between. The verdict's SDK +// names this judge (judge.Identity). A manifest left as a digest is refused as a request it cannot run. +func Judge(r composer.Request) (composer.Verdict, error) { + v := composer.Verdict{V: composer.Version, SDK: judge.Identity()} + seen := map[string]bool{} + for _, c := range r.Cases { + if seen[c.Name] { + return composer.Verdict{}, fmt.Errorf("the case %q is named twice", c.Name) + } + seen[c.Name] = true + raws := map[string][]byte{} + for name, m := range c.Manifests { + if len(m.Raw) == 0 { + return composer.Verdict{}, fmt.Errorf("%w: %s (digest %s)", composer.ErrUnfilled, name, m.Digest) + } + raws[name] = m.Raw + } + refused, unnamed := judge.Shelf(raws, r.Predefined, 0) + cv := composer.CaseVerdict{Name: c.Name, Manifests: map[string]composer.ManifestRead{}} + for name := range raws { + if problems := refused[name]; len(problems) > 0 { + cv.Manifests[name] = composer.ManifestRead{Refused: strings.Join(problems, "\n")} + continue + } + cv.Manifests[name] = composer.ManifestRead{Accepted: true} + } + if len(unnamed) > 0 { + sort.Strings(unnamed) + cv.Manifests[Between] = composer.ManifestRead{Refused: strings.Join(unnamed, "\n")} + } + v.Cases = append(v.Cases, cv) + } + return v, nil +} 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 0000000..0edf390 --- /dev/null +++ b/modules/mesh-delivery-indexer/cmd/mesh-judge/main_test.go @@ -0,0 +1,40 @@ +package main + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/composer" +) + +// The command the checker runs (novox/hq ADR 0578 §2): a composer request in, one line of a composer verdict out, each +// manifest accepted or refused with its problems; a request it cannot run exits non-zero. +func TestTheCommandJudgesARequest(t *testing.T) { + r := composer.Request{V: composer.Version, Cases: []composer.Case{{Name: "change", Manifests: map[string]composer.Manifest{ + "a": {Raw: json.RawMessage(`{"module":"a","version":"1"}`)}, + "b": {Raw: json.RawMessage(`{"module":"b","version":"1","requires":["amqp"]}`)}, + }}}} + raw, _ := json.Marshal(r) + var out, errs bytes.Buffer + if code := run(nil, bytes.NewReader(raw), &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 || v.V != composer.Version || len(v.Cases) != 1 || v.SDK == "" { + t.Fatalf("verdict %s: %v", out.String(), err) + } + c := v.Cases[0] + if !c.Manifests["a"].Accepted || c.Manifests["b"].Accepted || !strings.Contains(c.Manifests["b"].Refused, `requires "amqp"`) { + t.Fatalf("case: %+v", c) + } + r.Cases[0].Manifests["c"] = composer.Manifest{Digest: strings.Repeat("a", 64)} + raw, _ = json.Marshal(r) + if code := run(nil, bytes.NewReader(raw), &out, &errs); code == 0 { + t.Fatal("a manifest left as a digest was judged") + } + if code := run(nil, strings.NewReader("{"), &out, &errs); code == 0 { + t.Fatal("a request that is no JSON was judged") + } +} diff --git a/modules/mesh-delivery-indexer/go.mod b/modules/mesh-delivery-indexer/go.mod index 499e961..d26ebf6 100644 --- a/modules/mesh-delivery-indexer/go.mod +++ b/modules/mesh-delivery-indexer/go.mod @@ -3,7 +3,7 @@ module mesh-delivery-indexer go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101005736-7b09de1e5502 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 ) diff --git a/modules/mesh-delivery-indexer/go.sum b/modules/mesh-delivery-indexer/go.sum index 8e0c33a..e0ce2ef 100644 --- a/modules/mesh-delivery-indexer/go.sum +++ b/modules/mesh-delivery-indexer/go.sum @@ -1,5 +1,7 @@ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a h1:DcSvfEmdPBKqCg5OmXRNb269EkkPe7BsEp+RykEhkgE= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261031084708-acbf80ad910a/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101005736-7b09de1e5502 h1:XndvkzyAFNde/821lEfYObqMTURzmve/w9Ks+QrBBJ8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261101005736-7b09de1e5502/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= diff --git a/modules/mesh-delivery-indexer/judge/judge.go b/modules/mesh-delivery-indexer/judge/judge.go new file mode 100644 index 0000000..bab6eab --- /dev/null +++ b/modules/mesh-delivery-indexer/judge/judge.go @@ -0,0 +1,190 @@ +// Package judge is the mesh's one judge of module manifests (novox/hq ADR 0578 §1, §2): strict decoding, the seat and +// namespace rules, the bus and stream limits, and the rules between the manifests of one catalogue. Only the indexer, +// which judges every manifest it takes in and writes the verdicts (delivery.VerdictsBucket), and the checker, which +// judges every manifest a change touches by running this judge built at the trunk or a group's head (cmd/mesh-judge, +// as the composer is run), use it. Every other part decodes a manifest leniently (catalogue.Decode) and takes the +// verdict; it never judges (ADR 0578 §3). +// +// Its rules are still mesh-sdk's catalogue functions it calls; they leave mesh-sdk for this package in the group's last +// mesh-sdk commit, once no reader imports them (ADR 0578 §7, issue 1585 S19). Callers depend only on what this package +// answers, so that move changes nothing for them. +package judge + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "maps" + "os" + "regexp" + "slices" + "sort" + "strings" + "sync" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// Manifest judges one manifest alone: decoded strictly, and every rule a manifest is held to by itself. The list of +// predefined keys is the one a config value falling back to a key is judged against (ADR 0348 §7); nil judges each +// fallback as its value declares it, where no list is at hand. Problems is empty when the manifest is accepted, and +// then m is the manifest as the judge read it. +func Manifest(raw []byte, predefined catalogue.PredefinedList) (m catalogue.Manifest, problems []string) { + var err error + if predefined != nil { + m, err = catalogue.ParseManifestWith(raw, predefined) + } else { + m, err = catalogue.ParseManifestForInputs(raw) + } + if err != nil { + return catalogue.Manifest{}, problemsOf(err) + } + return m, nil +} + +// listed is how the strict parser words a refusal of several problems, one per line after its header. +const listed = "this manifest cannot be used:\n - " + +// problemsOf splits the strict parser's refusal into its problems, one each. +func problemsOf(err error) []string { + text := err.Error() + if rest, ok := strings.CutPrefix(text, listed); ok { + return strings.Split(rest, "\n - ") + } + return []string{text} +} + +// Shelf judges every manifest of one catalogue, by the name it is given under (a module's name): each alone (Manifest), +// then the rules between the manifests accepted alone (Between). A problem between manifests is a problem of each module +// whose removal from the catalogue ends it (a rule two modules break together refuses both); one no removal ends is in +// unnamed, for the caller to say. A module not in the answer is accepted. +func Shelf(raws map[string][]byte, predefined catalogue.PredefinedList, longestMachine int) (refused map[string][]string, unnamed []string) { + refused = map[string][]string{} + shelf := catalogue.Shelf{} + for name, raw := range raws { + m, problems := Manifest(raw, predefined) + if len(problems) > 0 { + refused[name] = problems + continue + } + if m.Module != name { + refused[name] = []string{fmt.Sprintf("the manifest is of module %q, not %q", m.Module, name)} + continue + } + shelf[name] = m + } + between := Between(shelf, longestMachine) + without := map[string]map[string]bool{} // by module: the problems between the rest, once asked + ends := func(module, problem string) bool { + if without[module] == nil { + rest := maps.Clone(shelf) + delete(rest, module) + without[module] = map[string]bool{} + for _, p := range Between(rest, longestMachine) { + without[module][p] = true + } + } + return !without[module][problem] + } + for _, p := range between { + // The modules it names are asked first; only when none of them ends it is every module asked. + var of []string + for _, name := range Named(p, shelf) { + if ends(name, p) { + of = append(of, name) + } + } + if len(of) == 0 { + for _, name := range slices.Sorted(maps.Keys(shelf)) { + if ends(name, p) { + of = append(of, name) + } + } + } + if len(of) == 0 { + unnamed = append(unnamed, p) + continue + } + for _, name := range of { + refused[name] = append(refused[name], p) + } + } + for name := range refused { + sort.Strings(refused[name]) + } + return refused, unnamed +} + +// Between is every problem between the manifests of shelf, sorted: catalogue.CatalogueProblems, IdentityProblems over +// the longest machine name (catalogue.DefaultLongestMachine when not above 0) and DataProblems. +func Between(shelf catalogue.Shelf, longestMachine int) []string { + if longestMachine <= 0 { + longestMachine = catalogue.DefaultLongestMachine + } + between := catalogue.CatalogueProblems(shelf) + between = append(between, catalogue.IdentityProblems(shelf, longestMachine)...) + between = append(between, catalogue.DataProblems(shelf)...) + sort.Strings(between) + return slices.Compact(between) +} + +// nameChar is a character a module's name may hold; a name is named in a text only where none borders it. +var nameChar = regexp.MustCompile(`[a-z0-9.-]`) + +// Named is every module of shelf a problem names, as a word of its own (not part of a longer name), sorted: the modules +// Shelf asks first whether their removal ends the problem. A module named only as a word ("mounts" in "f1-circuits +// mounts its directory") is not blamed for it, since its removal ends nothing. +func Named(problem string, shelf catalogue.Shelf) []string { + var out []string + for name := range shelf { + for at := 0; ; { + i := strings.Index(problem[at:], name) + if i < 0 { + break + } + i += at + end := i + len(name) + before := i == 0 || !nameChar.MatchString(problem[i-1:i]) + after := end == len(problem) || !nameChar.MatchString(problem[end:end+1]) || + // A sentence's full stop after a name ends it. + (problem[end] == '.' && (end+1 == len(problem) || problem[end+1] == ' ')) + if before && after { + out = append(out, name) + break + } + at = i + 1 + } + } + sort.Strings(out) + return out +} + +var ( + identityOnce sync.Once + identity string +) + +// Identity names this judge: the sha256 of the running program, "exe:" and 16 hex digits, so a verdict says which judge +// made it and the indexer knows its judge changed when the program it runs does. "unknown" when the program cannot be +// read. +func Identity() string { + identityOnce.Do(func() { + identity = "unknown" + path, err := os.Executable() + if err != nil { + return + } + f, err := os.Open(path) + if err != nil { + return + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return + } + identity = "exe:" + hex.EncodeToString(h.Sum(nil)[:8]) + }) + return identity +} diff --git a/modules/mesh-delivery-indexer/judge/judge_test.go b/modules/mesh-delivery-indexer/judge/judge_test.go new file mode 100644 index 0000000..7f0ac40 --- /dev/null +++ b/modules/mesh-delivery-indexer/judge/judge_test.go @@ -0,0 +1,67 @@ +package judge + +import ( + "reflect" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// The judge (novox/hq ADR 0578 §1): a manifest alone is accepted, or refused with each problem; an unknown field is +// refused (strict decoding); a rule between manifests refuses every module it names, and a word inside a longer name +// is no naming. +func TestTheJudge(t *testing.T) { + for _, row := range []struct { + name string + raws map[string]string + want map[string]string // module: a word of its first problem; absent: accepted + }{ + {name: "a valid manifest", raws: map[string]string{"a": `{"module":"a","version":"1"}`}}, + {name: "a refused manifest", raws: map[string]string{"a": `{"module":"a","version":"1","provides":[{"name":"amqp"}]}`}, + want: map[string]string{"a": `provides "amqp", which is not a provision`}}, + {name: "an unknown field", raws: map[string]string{"a": `{"module":"a","version":"1","a-field-from-later":1}`}, + want: map[string]string{"a": `"a-field-from-later"`}}, + {name: "a manifest under another name", raws: map[string]string{"b": `{"module":"a","version":"1"}`}, + want: map[string]string{"b": `of module "a", not "b"`}}, + {name: "a seat declared twice", + raws: map[string]string{ + "one": `{"module":"one","version":"1","seats":[{"name":"thing","scope":"mesh"}]}`, + "two": `{"module":"two","version":"1","seats":[{"name":"thing","scope":"mesh"}]}`, + "ok": `{"module":"ok","version":"1"}`}, + want: map[string]string{"one": "already declares", "two": "already declares"}}, + } { + raws := map[string][]byte{} + for k, v := range row.raws { + raws[k] = []byte(v) + } + refused, unnamed := Shelf(raws, nil, 0) + if len(unnamed) > 0 { + t.Errorf("%s: unnamed %v", row.name, unnamed) + } + if len(refused) != len(row.want) { + t.Errorf("%s: refused %v, want %v", row.name, refused, row.want) + continue + } + for m, word := range row.want { + if len(refused[m]) == 0 || !strings.Contains(strings.Join(refused[m], "\n"), word) { + t.Errorf("%s: %s refused with %v, want %q", row.name, m, refused[m], word) + } + } + } + shelf := catalogue.Shelf{"nats": {}, "mesh-nats": {}, "a.b": {}} + for problem, want := range map[string][]string{ + "nats removes a stream": {"nats"}, + "mesh-nats reads nats.": {"mesh-nats", "nats"}, + "the state nats.modules and mesh-natsy": nil, + "a.b and a.bc": {"a.b"}, + `"nats" is quoted, (mesh-nats) bracketed`: {"mesh-nats", "nats"}, + } { + if got := Named(problem, shelf); !reflect.DeepEqual(got, want) { + t.Errorf("Named(%q) = %v, want %v", problem, got, want) + } + } + if id := Identity(); !strings.HasPrefix(id, "exe:") || id != Identity() { + t.Errorf("identity %q", id) + } +}