From 9f49bc92ad0077422f5e169f6a1a3a149970364d Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 3 Nov 2026 16:39:23 +0100 Subject: [PATCH] =?UTF-8?q?The=20seat=20mesh-catalog's=20four=20verbs=20an?= =?UTF-8?q?swered=20from=20the=20indexer's=20modules,=20manifests=20and=20?= =?UTF-8?q?graph,=20with=20the=20old=20catalog's=20field=20names=20(captur?= =?UTF-8?q?ed=20answers=20as=20fixtures);=20served=20once=20the=20indexer?= =?UTF-8?q?=20claims=20the=20seat,=20in=20the=20release=20after=20L3's=20(?= =?UTF-8?q?ADR=200604=20=C2=A71,=20=C2=A72)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mesh-delivery-indexer-tools/catalog.go | 101 +++++++++++ .../internal/index/catalog.go | 171 ++++++++++++++++++ .../internal/index/catalog_test.go | 125 +++++++++++++ .../internal/index/manifests_test.go | 1 + .../testdata/catalog/catalog_dependents.json | 4 + .../testdata/catalog/catalog_module.json | 18 ++ .../testdata/catalog/catalog_modules.json | 22 +++ .../testdata/catalog/catalog_provides.json | 15 ++ 8 files changed, 457 insertions(+) create mode 100644 modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/catalog.go create mode 100644 modules/mesh-delivery-indexer/internal/index/catalog.go create mode 100644 modules/mesh-delivery-indexer/internal/index/catalog_test.go create mode 100644 modules/mesh-delivery-indexer/internal/index/testdata/catalog/catalog_dependents.json create mode 100644 modules/mesh-delivery-indexer/internal/index/testdata/catalog/catalog_module.json create mode 100644 modules/mesh-delivery-indexer/internal/index/testdata/catalog/catalog_modules.json create mode 100644 modules/mesh-delivery-indexer/internal/index/testdata/catalog/catalog_provides.json diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/catalog.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/catalog.go new file mode 100644 index 00000000..24411bac --- /dev/null +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/catalog.go @@ -0,0 +1,101 @@ +package main + +import ( + "fmt" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" + + "mesh-delivery-indexer/internal/index" +) + +// CatalogTools are the seat mesh-catalog's four verbs as tools named ., with the descriptions and inputs the +// seat's contract gives them (novox/hq ADR 0604 §1, §2), answered from the indexer's own states. They are served once the +// indexer's manifest claims the seat, in the release after L3's (the lead's decision): until then main serves them not. +func CatalogTools(c index.CatalogRead) []stdio.Tool { + seat, ok := catalogue.SeatNamed(catalogue.CatalogSeat) + if !ok { + return nil + } + str := func(args map[string]any, k string) string { s, _ := args[k].(string); return s } + runs := map[string]func(map[string]any) (any, error){ + "modules": func(map[string]any) (any, error) { return c.CatalogModulesAnswer() }, + "module": func(a map[string]any) (any, error) { return c.CatalogModuleAnswer(str(a, "module")) }, + "provides": func(a map[string]any) (any, error) { return c.CatalogProvidesAnswer(str(a, "provision")) }, + "dependents": func(a map[string]any) (any, error) { return c.CatalogDependentsAnswer(str(a, "module")) }, + } + var out []stdio.Tool + for _, v := range seat.Serves { + if run, ok := runs[v.Name]; ok { + out = append(out, stdio.Tool{Name: catalogue.CatalogSeat + "." + v.Name, Description: v.Description, Input: v.Input, Run: run}) + } + } + return out +} + +// catalogRead reads the indexer's modules, manifests and graph states for the seat's verbs. +func catalogRead(modules, manifests, graph stdio.KeptState) index.CatalogRead { + return index.CatalogRead{ + Modules: func() (map[string]delivery.Module, error) { + keys, err := modules.Keys() + if err != nil { + return nil, err + } + out := map[string]delivery.Module{} + for _, k := range keys { + e, err := modules.Get(k) + if err != nil { + return nil, fmt.Errorf("modules.%s: %w", k, err) + } + if e == nil { + continue + } + if m, err := delivery.DecodeModule(e.Value); err == nil { + out[k] = m + } + } + return out, nil + }, + Manifest: func(digest string) (string, error) { + key, err := delivery.ManifestsKey(digest) + if err != nil { + return "", err + } + e, err := manifests.Get(key) + if err != nil { + return "", err + } + if e == nil { + return "", fmt.Errorf("the manifests state holds no %s", digest) + } + kept, err := delivery.DecodeManifest(e.Value) + return kept.JSON, err + }, + Graph: func() (delivery.Graph, error) { + keys, err := graph.Keys() + if err != nil { + return nil, err + } + g := delivery.Graph{} + for _, k := range keys { + e, err := graph.Get(k) + if err != nil { + return nil, fmt.Errorf("graph.%s: %w", k, err) + } + if e == nil { + continue + } + if n, err := delivery.DecodeGraphNode(e.Value); err == nil { + g[k] = n + } + } + return g, nil + }, + } +} + +// catalogTools are the seat's verbs over the indexer's own states, as main serves them once the indexer claims the seat. +func catalogTools() []stdio.Tool { + return CatalogTools(catalogRead(stdio.State("modules"), stdio.State("manifests"), stdio.State(delivery.GraphState))) +} diff --git a/modules/mesh-delivery-indexer/internal/index/catalog.go b/modules/mesh-delivery-indexer/internal/index/catalog.go new file mode 100644 index 00000000..0d5c30ce --- /dev/null +++ b/modules/mesh-delivery-indexer/internal/index/catalog.go @@ -0,0 +1,171 @@ +package index + +import ( + "encoding/json" + "fmt" + "maps" + "slices" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// The seat mesh-catalog's verbs, answered from the indexer's own states (novox/hq ADR 0604 §1, §2): modules, module, +// provides and dependents read the modules state, the manifests state and the module graph, and write nothing. The +// indexer serves no request subject (ADR 0437 §4): its tools bundle serves them as .. Each answer keeps the +// field names of the old catalog module's tool it replaces (catalog_modules, catalog_module, catalog_provides, +// catalog_dependents), so a caller that moves to the seat reads the same fields. + +// CatalogRead reads the states the seat's verbs answer from. +type CatalogRead struct { + // Modules is every value of the modules state, by module. + Modules func() (map[string]delivery.Module, error) + // Manifest is the manifest kept under a digest in the manifests state, as its JSON. + Manifest func(digest string) (string, error) + // Graph is the module graph, by module. + Graph func() (delivery.Graph, error) +} + +// CatalogEntry is one module of the catalog: its repository, its directory there (delivery.RootPath for the root), the +// merge commit it was last changed at, and its fingerprint or why it has none. +type CatalogEntry struct { + Module string `json:"module"` + Repository string `json:"repository"` + Path string `json:"path"` + Commit string `json:"commit"` + FP string `json:"fp,omitempty"` + None string `json:"none,omitempty"` +} + +// CatalogModule is one module whole: its entry and its manifest as the trunk holds it. +type CatalogModule struct { + CatalogEntry + Manifest json.RawMessage `json:"manifest"` +} + +// CatalogEnd is a module that provides, requires or depends on something, with its commit; Scope is a provider's scope. +type CatalogEnd struct { + Module string `json:"module"` + Commit string `json:"commit"` + Scope string `json:"scope,omitempty"` +} + +func entryOf(name string, m delivery.Module) CatalogEntry { + return CatalogEntry{Module: name, Repository: m.Repository, Path: m.Path, Commit: m.Commit, FP: m.FP2, None: m.None2} +} + +// CatalogModulesAnswer is the verb modules: every module the modules state holds, by name. +func (c CatalogRead) CatalogModulesAnswer() (map[string]any, error) { + mods, err := c.Modules() + if err != nil { + return nil, fmt.Errorf("the indexer's modules cannot be read: %w", err) + } + out := []CatalogEntry{} + for _, name := range slices.Sorted(maps.Keys(mods)) { + out = append(out, entryOf(name, mods[name])) + } + return map[string]any{"modules": out}, nil +} + +// CatalogModuleAnswer is the verb module: one module whole, its manifest included. +func (c CatalogRead) CatalogModuleAnswer(name string) (map[string]any, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, fmt.Errorf("name the module") + } + mods, err := c.Modules() + if err != nil { + return nil, fmt.Errorf("the indexer's modules cannot be read: %w", err) + } + m, ok := mods[name] + if !ok { + return nil, fmt.Errorf("the trunk holds no module %s", name) + } + one := CatalogModule{CatalogEntry: entryOf(name, m)} + if m.Manifest == "" { + return nil, fmt.Errorf("the indexer keeps no manifest of %s", name) + } + raw, err := c.Manifest(m.Manifest) + if err != nil { + return nil, fmt.Errorf("the manifest %s of %s cannot be read: %w", m.Manifest, name, err) + } + one.Manifest = json.RawMessage(raw) + return map[string]any{"module": one}, nil +} + +// CatalogProvidesAnswer is the verb provides: the modules that provide a provision, each with its scope (a module provides +// its own name, at node scope), and the modules that require it, from the module graph's provision edges. +func (c CatalogRead) CatalogProvidesAnswer(provision string) (map[string]any, error) { + provision = strings.TrimSpace(provision) + if provision == "" { + return nil, fmt.Errorf("name the provision") + } + g, mods, err := c.graphAndModules() + if err != nil { + return nil, err + } + provides, requires := []CatalogEnd{}, []CatalogEnd{} + for _, name := range g.Providers(provision) { + end := CatalogEnd{Module: name, Commit: mods[name].Commit, Scope: catalogue.ScopeNode} + if name != provision { + end.Scope = c.scopeOf(mods[name], provision) + } + provides = append(provides, end) + } + for _, name := range g.Requirers(provision) { + requires = append(requires, CatalogEnd{Module: name, Commit: mods[name].Commit}) + } + return map[string]any{"provision": provision, "provides": provides, "requires": requires}, nil +} + +// scopeOf is the scope the module's manifest offers provision at, "" when its manifest cannot be read. +func (c CatalogRead) scopeOf(m delivery.Module, provision string) string { + if m.Manifest == "" { + return "" + } + raw, err := c.Manifest(m.Manifest) + if err != nil { + return "" + } + man, err := catalogue.Decode([]byte(raw)) + if err != nil { + return "" + } + for _, o := range man.Provides { + if o.Name == provision { + return o.At() + } + } + return "" +} + +// CatalogDependentsAnswer is the verb dependents: the modules whose build edges reach the module, directly or through +// others, from the module graph. +func (c CatalogRead) CatalogDependentsAnswer(module string) (map[string]any, error) { + module = strings.TrimSpace(module) + if module == "" { + return nil, fmt.Errorf("name the module") + } + g, mods, err := c.graphAndModules() + if err != nil { + return nil, err + } + out := []CatalogEnd{} + for _, name := range g.Dependents(module) { + out = append(out, CatalogEnd{Module: name, Commit: mods[name].Commit}) + } + return map[string]any{"module": module, "dependents": out}, nil +} + +func (c CatalogRead) graphAndModules() (delivery.Graph, map[string]delivery.Module, error) { + g, err := c.Graph() + if err != nil { + return nil, nil, fmt.Errorf("the module graph cannot be read: %w", err) + } + mods, err := c.Modules() + if err != nil { + return nil, nil, fmt.Errorf("the indexer's modules cannot be read: %w", err) + } + return g, mods, nil +} diff --git a/modules/mesh-delivery-indexer/internal/index/catalog_test.go b/modules/mesh-delivery-indexer/internal/index/catalog_test.go new file mode 100644 index 00000000..6cec8c2a --- /dev/null +++ b/modules/mesh-delivery-indexer/internal/index/catalog_test.go @@ -0,0 +1,125 @@ +package index + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// capturedKeys are the field names of the first object at path (a list's first element when the value is a list) in the +// old catalog module's answer captured with mesh_call on 2026-11-03 (testdata/catalog; trimmed to a few modules, and the +// manifest of catalog_module to three of its keys). +func capturedKeys(t *testing.T, file string, path ...string) []string { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", "catalog", file)) + if err != nil { + t.Fatal(err) + } + return keysAt(t, raw, path...) +} + +func keysAt(t *testing.T, raw []byte, path ...string) []string { + t.Helper() + var v any + if err := json.Unmarshal(raw, &v); err != nil { + t.Fatal(err) + } + for _, p := range path { + v = v.(map[string]any)[p] + if l, ok := v.([]any); ok { + v = l[0] + } + } + var out []string + for k := range v.(map[string]any) { + out = append(out, k) + } + slices.Sort(out) + return out +} + +// catalogVerbsAnswerFromTheStates: the seat mesh-catalog's four verbs answer from the indexer's modules, manifests and +// graph, with the field names of the old catalog module's answers (ADR 0604 §1, §2). +func catalogVerbsAnswerFromTheStates(t *testing.T) { + withAcme := strings.Replace(renamed(t, "audit-logger", "acme"), `"module": "acme",`, + `"module": "acme", "provides": [{"name": "internal-acme-ca", "scope": "mesh", "answered-by": "helper"}],`, 1) + ix, mods := newFormIndexer(t, mod{"mesh-tools", renamed(t, "avahi", "mesh-tools")}, + mod{"avahi", standingOn(t, catalogueManifest(t, "avahi"), "mesh-tools")}, + mod{"ca-trust", catalogueManifest(t, "ca-trust")}, mod{"acme", withAcme}) + reportToolchain(t, ix, "mesh-build-go", "novox", "go", "go1.27.1") + c := CatalogRead{ + Modules: func() (map[string]delivery.Module, error) { + out := map[string]delivery.Module{} + for k := range mods.vals { + m, _ := moduleOf(t, mods, k) + out[k] = m + } + return out, nil + }, + Manifest: func(digest string) (string, error) { + raw, _, err := ix.manifestRaw(context.Background(), digest) + return raw, err + }, + Graph: func() (delivery.Graph, error) { + g := delivery.Graph{} + for k := range ix.Graph.(*memStore).vals { + g[k] = graphOf(t, ix, k) + } + return g, nil + }, + } + answer := func(v map[string]any, err error) []byte { + t.Helper() + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(v) + return raw + } + contains := func(got, want []string) bool { + for _, w := range want { + if !slices.Contains(got, w) { + return false + } + } + return true + } + raw := answer(c.CatalogModulesAnswer()) + if got, want := keysAt(t, raw, "modules"), capturedKeys(t, "catalog_modules.json", "modules"); !contains(got, want) { + t.Errorf("modules' fields %v, want the old catalog's %v", got, want) + } + if !strings.Contains(string(raw), `"module":"avahi"`) || !strings.Contains(string(raw), `"fp":"src2:`) { + t.Errorf("modules = %s", raw) + } + raw = answer(c.CatalogModuleAnswer("ca-trust")) + want := slices.DeleteFunc(capturedKeys(t, "catalog_module.json", "module"), func(k string) bool { return k == "ref" }) // the trunk's, no ref + if got := keysAt(t, raw, "module"); !contains(got, want) || !strings.Contains(string(raw), `"internal-acme-ca"`) { + t.Errorf("module ca-trust = %s, want the fields %v and its manifest", raw, want) + } + if _, err := c.CatalogModuleAnswer("gone"); err == nil || !strings.Contains(err.Error(), "no module gone") { + t.Errorf("a module the trunk does not hold: %v", err) + } + raw = answer(c.CatalogProvidesAnswer("internal-acme-ca")) + if got, want := keysAt(t, raw, "provides"), capturedKeys(t, "catalog_provides.json", "provides"); !reflect.DeepEqual(got, want) || + !reflect.DeepEqual(keysAt(t, raw, "requires"), capturedKeys(t, "catalog_provides.json", "requires")) || + !strings.Contains(string(raw), `"provides":[{"module":"acme","commit":"`+merge+`","scope":"mesh"}]`) || + !strings.Contains(string(raw), `"requires":[{"module":"ca-trust"`) { + t.Errorf("provides internal-acme-ca = %s", raw) + } + raw = answer(c.CatalogDependentsAnswer("mesh-tools")) + if got, want := keysAt(t, raw), capturedKeys(t, "catalog_dependents.json"); !reflect.DeepEqual(got, want) || + !strings.Contains(string(raw), `"dependents":[{"module":"avahi"`) { + t.Errorf("dependents of mesh-tools = %s", raw) + } + raw = answer(c.CatalogDependentsAnswer("mesh-build-go")) // the build module reaches every Go module it builds + if !strings.Contains(string(raw), `"avahi"`) || !strings.Contains(string(raw), `"mesh-tools"`) { + t.Errorf("dependents of mesh-build-go = %s", raw) + } +} diff --git a/modules/mesh-delivery-indexer/internal/index/manifests_test.go b/modules/mesh-delivery-indexer/internal/index/manifests_test.go index 835092ca..b9817ec9 100644 --- a/modules/mesh-delivery-indexer/internal/index/manifests_test.go +++ b/modules/mesh-delivery-indexer/internal/index/manifests_test.go @@ -45,6 +45,7 @@ func withManifests(f delivery.Forge, manifests map[string]string, locks map[stri func TestAMergedManifestIsKeptOnceByItsDigestAndNamedByTheModule(t *testing.T) { t.Run("kept by its digest and named by the module", mergedManifestIsKeptByItsDigestAndNamedByTheModule) t.Run("the same manifest applied again is written once", sameManifestAppliedAgainIsWrittenOnce) + t.Run("the seat mesh-catalog's verbs answer from the states", catalogVerbsAnswerFromTheStates) } func mergedManifestIsKeptByItsDigestAndNamedByTheModule(t *testing.T) { // 1449/AC1 diff --git a/modules/mesh-delivery-indexer/internal/index/testdata/catalog/catalog_dependents.json b/modules/mesh-delivery-indexer/internal/index/testdata/catalog/catalog_dependents.json new file mode 100644 index 00000000..8f6b152b --- /dev/null +++ b/modules/mesh-delivery-indexer/internal/index/testdata/catalog/catalog_dependents.json @@ -0,0 +1,4 @@ +{ + "dependents": [], + "module": "mesh-build-go" +} diff --git a/modules/mesh-delivery-indexer/internal/index/testdata/catalog/catalog_module.json b/modules/mesh-delivery-indexer/internal/index/testdata/catalog/catalog_module.json new file mode 100644 index 00000000..eecc2522 --- /dev/null +++ b/modules/mesh-delivery-indexer/internal/index/testdata/catalog/catalog_module.json @@ -0,0 +1,18 @@ +{ + "module": { + "commit": "b5475cfa5c3daffd52b5b25eb7063e745b976026", + "manifest": { + "module": "gitea", + "requires": [ + "postgres-database", + "route", + "secret" + ], + "version": "1" + }, + "module": "gitea", + "path": "modules/gitea", + "ref": "refs/pull/571/head", + "repository": "novox/mesh-catalog" + } +} diff --git a/modules/mesh-delivery-indexer/internal/index/testdata/catalog/catalog_modules.json b/modules/mesh-delivery-indexer/internal/index/testdata/catalog/catalog_modules.json new file mode 100644 index 00000000..e5d979fa --- /dev/null +++ b/modules/mesh-delivery-indexer/internal/index/testdata/catalog/catalog_modules.json @@ -0,0 +1,22 @@ +{ + "modules": [ + { + "commit": "b5475cfa5c3daffd52b5b25eb7063e745b976026", + "module": "gitea", + "path": "modules/gitea", + "repository": "novox/mesh-catalog" + }, + { + "commit": "dc90b3f5bd1386747ac70f2792169babf8eeed73", + "module": "mesh-controller", + "path": ".", + "repository": "novox/mesh-controller" + }, + { + "commit": "432326264f6c34ceec1fb67f5ca9d450b4caaa1d", + "module": "postgres", + "path": "modules/postgres", + "repository": "novox/mesh-catalog" + } + ] +} diff --git a/modules/mesh-delivery-indexer/internal/index/testdata/catalog/catalog_provides.json b/modules/mesh-delivery-indexer/internal/index/testdata/catalog/catalog_provides.json new file mode 100644 index 00000000..b4b1606f --- /dev/null +++ b/modules/mesh-delivery-indexer/internal/index/testdata/catalog/catalog_provides.json @@ -0,0 +1,15 @@ +{ + "provides": [ + { + "commit": "432326264f6c34ceec1fb67f5ca9d450b4caaa1d", + "module": "postgres", + "scope": "mesh" + } + ], + "requires": [ + { + "commit": "b5475cfa5c3daffd52b5b25eb7063e745b976026", + "module": "gitea" + } + ] +}