diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go new file mode 100644 index 0000000..ecef8be --- /dev/null +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main.go @@ -0,0 +1,54 @@ +// Command mesh-delivery-view-tools serves the read tools of the indexer's modules (novox/hq issue 1562): the runtime launches it for a +// call, it reads the indexer's modules bucket through the view's reads (mesh-delivery-indexer.modules) and writes nothing. +// The view is the other program: the indexer serves no tool (ADR 0437 §4). +package main + +import ( + "encoding/json" + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" + "mesh-delivery-view/internal/view" +) + +// Tools are the read tools over a modules state. +func Tools(r view.Reader) []stdio.Tool { + return []stdio.Tool{ + {Name: view.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: view.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("mesh-delivery-indexer.modules") + r := view.Reader{State: view.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-view-tools] %v\n", err) + os.Exit(1) + } +} diff --git a/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go new file mode 100644 index 0000000..bfa3124 --- /dev/null +++ b/modules/mesh-delivery-view/cmd/mesh-delivery-view-tools/main_test.go @@ -0,0 +1,96 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + + "mesh-delivery-view/internal/view" +) + +type fake map[string]string + +func (f fake) reader() view.Reader { + return view.Reader{State: view.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 view.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, view.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, view.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, view.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, view.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, view.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, view.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-view/internal/view/read.go b/modules/mesh-delivery-view/internal/view/read.go new file mode 100644 index 0000000..05e547e --- /dev/null +++ b/modules/mesh-delivery-view/internal/view/read.go @@ -0,0 +1,82 @@ +package view + +// The read tools of the indexer's modules (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. The view reads the bucket through its reads; 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 indexer's modules state, as the view's reads grant it: 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-view/manifest_test.go b/modules/mesh-delivery-view/manifest_test.go index f3bc40e..aa54e36 100644 --- a/modules/mesh-delivery-view/manifest_test.go +++ b/modules/mesh-delivery-view/manifest_test.go @@ -19,12 +19,13 @@ import ( // takes this manifest (1134/AC7); these tests hold the rules so a unit run catches a refusal early.** 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"` Context *struct { Repository string `json:"repository"` } `json:"context"` @@ -41,6 +42,7 @@ type manifest struct { Version string `json:"version"` Slug string `json:"slug"` Reads []string `json:"reads"` + Tools []string `json:"tools"` OwnSecrets map[string]string `json:"own-secrets"` SecretsOwner string `json:"secrets-owner"` Resources []map[string]any `json:"resources"` @@ -91,11 +93,23 @@ func TestManifestIsTakenAndReadsOnly(t *testing.T) { // 1134/AC7 if m.SecretsOwner != "mesh-delivery-view" { t.Errorf("secrets-owner = %q", m.SecretsOwner) } + // The two read tools of the indexer's modules (issue 1562), carried by a bundle the runtime launches; the indexer + // itself serves no tool (ADR 0437 §4). Carrying them gives the tool runner read access to every bucket the view reads. + if want := []string{"indexer_module", "indexer_modules"}; !reflect.DeepEqual(m.Tools, want) { + t.Errorf("tools = %v, want %v", m.Tools, want) + } + for _, a := range m.Build.Artifacts { + // A bundle compiled to a binary is one executable: what the runtime loads is that binary's name, or the controller's + // module check refuses the manifest (mesh-sdk catalogue build.go, ADR 0193). + if a.Name == "tools" && !reflect.DeepEqual(a.Loads, []string{a.Binary}) { + t.Errorf("the tools artifact loads %v, want its binary %q", a.Loads, a.Binary) + } + } } func TestManifestIsWholeInItsRepository(t *testing.T) { // 1134/AC7 m, _ := load(t) - if len(m.Build.Artifacts) != 3 { + if len(m.Build.Artifacts) != 4 { t.Fatalf("artifacts = %+v", m.Build.Artifacts) } for _, a := range m.Build.Artifacts { @@ -119,7 +133,7 @@ func TestManifestIsWholeInItsRepository(t *testing.T) { // 1134/AC7 for _, a := range m.Build.Artifacts { names[a.Name] = a.Binary } - if names["program"] != "mesh-delivery-view" || names["cli"] != "mesh-delivery-cli" { + if names["program"] != "mesh-delivery-view" || names["cli"] != "mesh-delivery-cli" || names["tools"] != "mesh-delivery-view-tools" { t.Errorf("artifacts = %v", names) } } diff --git a/modules/mesh-delivery-view/module.json b/modules/mesh-delivery-view/module.json index eb33058..dc14e37 100644 --- a/modules/mesh-delivery-view/module.json +++ b/modules/mesh-delivery-view/module.json @@ -3,6 +3,10 @@ "version": "1", "moved": true, "slug": "view", + "tools": [ + "indexer_module", + "indexer_modules" + ], "reads": [ "mesh-controller.assignments", "mesh-controller.builds", @@ -97,6 +101,17 @@ "from": "cmd/mesh-delivery-cli", "binary": "mesh-delivery-cli" }, + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-delivery-view-tools", + "binary": "mesh-delivery-view-tools", + "loads": [ + "mesh-delivery-view-tools" + ] + }, { "name": "files", "kind": "archive",