diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/main.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/main.go new file mode 100644 index 0000000..2282273 --- /dev/null +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/main.go @@ -0,0 +1,53 @@ +// Command mesh-delivery-indexer-tools serves the indexer's read tools (novox/hq issue 1562): the runtime launches it for a +// call, it reads the module's own modules state and writes nothing. The indexer itself is the other program. +package main + +import ( + "encoding/json" + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" + "mesh-delivery-indexer/internal/index" +) + +// Tools are the read tools over a modules state. +func Tools(r index.Reader) []stdio.Tool { + return []stdio.Tool{ + {Name: index.ModuleReadTool, + Description: "What the indexer stored for one module under modules., as stored: its fingerprint (fp, none), the " + + "new-form fingerprint (fp2) or why it has none (none2), the inputs it was computed from (inputs2), the module's " + + "directory (path) and the toolchains it was computed with. Read-only.", + Input: map[string]any{"type": "object", "required": []string{"module"}, "properties": map[string]any{ + "module": map[string]any{"type": "string", "description": "the module's name"}}}, + Run: func(args map[string]any) (any, error) { + name, _ := args["module"].(string) + return r.Module(name) + }}, + {Name: index.ModulesReadTool, + Description: "Every module the indexer holds, each as stored under modules.. With none2 true, only the modules " + + "that have no new-form fingerprint, each with the reason (none2). Read-only.", + Input: map[string]any{"type": "object", "properties": map[string]any{ + "none2": map[string]any{"type": "boolean", "description": "only the modules without a new-form fingerprint"}}}, + Run: func(args map[string]any) (any, error) { + only, _ := args["none2"].(bool) + mods, err := r.Modules(only) + return map[string]any{"modules": mods}, err + }}, + } +} + +func main() { + state := stdio.State("modules") + r := index.Reader{State: index.StateRead{Keys: state.Keys, Get: func(key string) (json.RawMessage, error) { + e, err := state.Get(key) + if err != nil || e == nil { + return nil, err + } + return e.Value, nil + }}} + if err := stdio.Serve("", Tools(r)); err != nil { + fmt.Fprintf(os.Stderr, "[mesh-delivery-indexer-tools] %v\n", err) + os.Exit(1) + } +} diff --git a/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/main_test.go b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/main_test.go new file mode 100644 index 0000000..4e4b2c5 --- /dev/null +++ b/modules/mesh-delivery-indexer/cmd/mesh-delivery-indexer-tools/main_test.go @@ -0,0 +1,96 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + + "mesh-delivery-indexer/internal/index" +) + +type fake map[string]string + +func (f fake) reader() index.Reader { + return index.Reader{State: index.StateRead{ + Keys: func() ([]string, error) { + var k []string + for key := range f { + k = append(k, key) + } + return k, nil + }, + Get: func(key string) (json.RawMessage, error) { + if v, ok := f[key]; ok { + return json.RawMessage(v), nil + } + return nil, nil + }, + }} +} + +func run(t *testing.T, r index.Reader, name string, args map[string]any) (any, error) { + t.Helper() + for _, tl := range Tools(r) { + if tl.Name == name { + return tl.Run(args) + } + } + t.Fatalf("no tool %s", name) + return nil, nil +} + +const ( + withFP2 = `{"v":3,"fp":"a1","fp2":"b2","manifest":"m1","inputs2":{"tree":"t1","bases":["x"]},"repository":"novox/r","commit":"c1","path":".","toolchains":{"go":{"version":"go1.27.1","recipe":"2"}}}` + withNone = `{"v":3,"none":"no successful trunk build recorded its inputs","none2":"builder's fingerprint not yet known","repository":"novox/r","commit":"c2","path":"modules/b"}` +) + +// 1562/AC1: indexer_module answers the key's value as stored: FP, FP2, None2, Inputs2, Path and Toolchains are all there. +func TestModuleToolAnswersTheStoredValue(t *testing.T) { + r := fake{"a": withFP2, "b": withNone}.reader() + for key, want := range map[string]string{"a": withFP2, "b": withNone} { + out, err := run(t, r, index.ModuleReadTool, map[string]any{"module": key}) + if err != nil { + t.Fatal(err) + } + if got := string(out.(json.RawMessage)); got != want { + t.Errorf("%s = %s, want %s", key, got, want) + } + } + out, _ := run(t, r, index.ModuleReadTool, map[string]any{"module": "a"}) + var m map[string]any + if err := json.Unmarshal(out.(json.RawMessage), &m); err != nil || m["fp"] != "a1" || m["fp2"] != "b2" || m["path"] != "." || m["inputs2"] == nil || m["toolchains"] == nil { + t.Errorf("stored fields are not all there: %v %v", m, err) + } +} + +// A module with no key, and a name that is no key, are said so. +func TestModuleToolRefusesWhatIsNotThere(t *testing.T) { + r := fake{"a": withFP2}.reader() + if _, err := run(t, r, index.ModuleReadTool, map[string]any{"module": "gone"}); err == nil || !strings.Contains(err.Error(), "no module gone") { + t.Errorf("a missing module: %v", err) + } + if _, err := run(t, r, index.ModuleReadTool, map[string]any{"module": ""}); err == nil { + t.Errorf("an empty name was answered") + } +} + +// 1562/AC1: indexer_modules answers every key as stored, and with none2 only those that say why they have no FP2. +func TestModulesToolAnswersEveryKeyAsStored(t *testing.T) { + r := fake{"a": withFP2, "b": withNone}.reader() + out, err := run(t, r, index.ModulesReadTool, map[string]any{}) + if err != nil { + t.Fatal(err) + } + all := out.(map[string]any)["modules"].(map[string]json.RawMessage) + if len(all) != 2 || string(all["a"]) != withFP2 || string(all["b"]) != withNone { + t.Errorf("modules = %v", all) + } + out, err = run(t, r, index.ModulesReadTool, map[string]any{"none2": true}) + if err != nil { + t.Fatal(err) + } + only := out.(map[string]any)["modules"].(map[string]json.RawMessage) + if len(only) != 1 || string(only["b"]) != withNone { + t.Errorf("none2 modules = %v", only) + } +} diff --git a/modules/mesh-delivery-indexer/internal/index/read.go b/modules/mesh-delivery-indexer/internal/index/read.go new file mode 100644 index 0000000..61558ab --- /dev/null +++ b/modules/mesh-delivery-indexer/internal/index/read.go @@ -0,0 +1,82 @@ +package index + +// The indexer's read tools (novox/hq issue 1562): what the indexer stored under modules., answered as stored. The new-form +// fingerprint (FP2, None2, Inputs2), the reason it has none, and the path and toolchains it was computed with are only +// there; a module reading its own state needs no grant. The reads write nothing. + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// The read tools, named as the module is: indexer_ and the verb. +const ( + ModuleReadTool = "indexer_module" + ModulesReadTool = "indexer_modules" +) + +// StateRead reads the module's own modules state: its keys and a key's stored value (nil for a key with none). +type StateRead struct { + Keys func() ([]string, error) + Get func(key string) (json.RawMessage, error) +} + +// Reader answers the read tools from a modules state. +type Reader struct{ State StateRead } + +// Module is the stored value of modules., exactly as the indexer wrote it. +func (r Reader) Module(module string) (json.RawMessage, error) { + module = strings.TrimSpace(module) + key, err := delivery.ModulesKey(module) + if err != nil { + return nil, err + } + if r.State.Get == nil { + return nil, fmt.Errorf("the modules state cannot be read here") + } + raw, err := r.State.Get(key) + if err != nil { + return nil, err + } + if len(raw) == 0 { + return nil, fmt.Errorf("no module %s in the modules state", module) + } + return raw, nil +} + +// Modules is every key of the modules state with its stored value; with none2 only the modules that have no new-form +// fingerprint and say why (a None2 reason), which is what the fingerprint's check reads live. +func (r Reader) Modules(none2 bool) (map[string]json.RawMessage, error) { + if r.State.Keys == nil || r.State.Get == nil { + return nil, fmt.Errorf("the modules state cannot be read here") + } + keys, err := r.State.Keys() + if err != nil { + return nil, err + } + sort.Strings(keys) + out := map[string]json.RawMessage{} + for _, k := range keys { + raw, err := r.State.Get(k) + if err != nil { + return nil, err + } + if len(raw) == 0 { + continue + } + if none2 { + var m struct { + None2 string `json:"none2"` + } + if json.Unmarshal(raw, &m) != nil || m.None2 == "" { + continue + } + } + out[k] = raw + } + return out, nil +} diff --git a/modules/mesh-delivery-indexer/manifest_test.go b/modules/mesh-delivery-indexer/manifest_test.go index 90d96c3..fa4c335 100644 --- a/modules/mesh-delivery-indexer/manifest_test.go +++ b/modules/mesh-delivery-indexer/manifest_test.go @@ -23,12 +23,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 { @@ -41,6 +42,7 @@ type manifest struct { OwnSecrets map[string]string `json:"own-secrets"` SecretsOwner string `json:"secrets-owner"` Resources []map[string]any `json:"resources"` + Tools []string `json:"tools"` Build struct { Artifacts []artifact `json:"artifacts"` } `json:"build"` @@ -93,8 +95,11 @@ func TestManifestDeclarations(t *testing.T) { // 1238/AC1, 1444 (reads the build if m.SecretsOwner != "mesh-delivery-indexer" { t.Errorf("secrets-owner = %q", m.SecretsOwner) } - // No invokes, no emits, no streams, no bus, no seat, no tool (ADR 0437 §4). - for _, k := range []string{"invokes", "streams", "logs", "emits", "claims", "provides", "requires", "uses", "defines-seats", "tools", "seat", "seats", "bus"} { + // No invokes, no emits, no streams, no bus, no seat (ADR 0437 §4); the only tools are the two read-only ones of issue 1562. + if want := []string{"indexer_module", "indexer_modules"}; !reflect.DeepEqual(sorted(m.Tools), want) { + t.Errorf("tools = %v, want %v", m.Tools, want) + } + for _, k := range []string{"invokes", "streams", "logs", "emits", "claims", "provides", "requires", "uses", "defines-seats", "seat", "seats", "bus"} { if _, ok := top[k]; ok { t.Errorf("the manifest declares %q: the indexer has none", k) } @@ -109,7 +114,7 @@ func TestManifestProcess(t *testing.T) { // 1238/AC1 procs = append(procs, r) } if r["type"] == "tool" || r["type"] == "seat" { - t.Errorf("resource %v: the indexer serves no tool and holds no seat", r) + t.Errorf("resource %v: the indexer holds no seat and its tools are a bundle, not a tool resource", r) } } if len(procs) != 1 { @@ -126,12 +131,18 @@ func TestManifestProcess(t *testing.T) { // 1238/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 { + if len(m.Build.Artifacts) != 2 { t.Fatalf("artifacts = %+v", m.Build.Artifacts) } - a := m.Build.Artifacts[0] - if st, err := os.Stat(a.From); err != nil || !st.IsDir() || a.Binary != "mesh-delivery-indexer" || !strings.HasSuffix(a.From, a.Binary) { - t.Errorf("artifact = %+v", a) + // The program, and the tools bundle the runtime launches for a read call (issue 1562); no process runs the tools. + for i, binary := range []string{"mesh-delivery-indexer", "mesh-delivery-indexer-tools"} { + a := m.Build.Artifacts[i] + if st, err := os.Stat(a.From); err != nil || !st.IsDir() || a.Binary != binary || !strings.HasSuffix(a.From, a.Binary) { + t.Errorf("artifact = %+v", a) + } + } + if a := m.Build.Artifacts[1]; a.Name != "tools" || !reflect.DeepEqual(a.Loads, []string{"mesh-delivery-indexer"}) { + t.Errorf("tools artifact = %+v", a) } } diff --git a/modules/mesh-delivery-indexer/module.json b/modules/mesh-delivery-indexer/module.json index fcfe983..11de832 100644 --- a/modules/mesh-delivery-indexer/module.json +++ b/modules/mesh-delivery-indexer/module.json @@ -12,6 +12,10 @@ { "name": "manifests", "history": 1 }, { "name": "modules", "history": 20 } ], + "tools": [ + "indexer_module", + "indexer_modules" + ], "reads": [ "mesh-build-go.toolchains", "mesh-build-node.toolchains", @@ -64,6 +68,17 @@ "system": "arch", "from": "cmd/mesh-delivery-indexer", "binary": "mesh-delivery-indexer" + }, + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-delivery-indexer-tools", + "binary": "mesh-delivery-indexer-tools", + "loads": [ + "mesh-delivery-indexer" + ] } ] }