From 2882b5fcb1c83b86dc933e212247008bcb949205 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 30 Sep 2026 16:12:43 +0200 Subject: [PATCH] A module may invoke tools, and a manifest is checked where it is written invokes: a manifest word that becomes exactly the publish grant a person's account gets (ADR 0152), derived by the same composition; refused at parse when it names no tool. module check ... runs what registration runs with no store, for a manifest in any repository (hq issue 148). --- cmd/mesh-controller/check.go | 122 +++++++++++++++++++++++++++++ cmd/mesh-controller/check_test.go | 69 ++++++++++++++++ cmd/mesh-controller/main.go | 1 + cmd/mesh-controller/modules.go | 21 ++++- internal/broker/invokes_test.go | 92 ++++++++++++++++++++++ internal/broker/nats.go | 54 +++++++++---- internal/broker/users.go | 4 +- internal/catalogue/invokes_test.go | 31 ++++++++ internal/catalogue/manifest.go | 38 +++++++++ internal/inventory/busrecords.go | 2 + 10 files changed, 417 insertions(+), 17 deletions(-) create mode 100644 cmd/mesh-controller/check.go create mode 100644 cmd/mesh-controller/check_test.go create mode 100644 internal/broker/invokes_test.go create mode 100644 internal/catalogue/invokes_test.go diff --git a/cmd/mesh-controller/check.go b/cmd/mesh-controller/check.go new file mode 100644 index 0000000..ab75fab --- /dev/null +++ b/cmd/mesh-controller/check.go @@ -0,0 +1,122 @@ +package main + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" + + "github.com/novox/mesh-controller/internal/catalogue" +) + +// moduleCheck judges manifests where they are written, with no mesh (novox/hq ADR 0037, issue 148). +// +// **The same functions registration runs, and nothing the command line adds** (ADR 0035): the strict +// parse with every per-manifest problem, then the rules no single manifest can be judged against, +// over exactly the manifests given. Somebody describing their own application in their own +// repository runs this before pushing and finds out there, rather than when a running mesh refuses +// the registration or, later, when a machine applies something that resolved and should not have. +// +// **What it cannot know without a store, it says.** The mesh's own seat set is the store's (ADR +// 0122); this binary carries a compiled copy that the store overrides when loaded, so a claim on a +// mesh seat is judged fully only at registration. A seat another module declares is unknown unless +// that module's manifest is passed too. Both are printed as a note, not as a problem — a check that +// refused what it could not see would teach people to ignore it. +func moduleCheck(paths []string, out io.Writer) error { + if len(paths) == 0 { + return errors.New("module check ... — one file per module; pass every " + + "manifest of a repository together so the rules between them are checked too") + } + shelf := catalogue.Shelf{} + failed := 0 + for _, path := range paths { + raw, err := os.ReadFile(path) + if err != nil { + fmt.Fprintf(out, "%s: %v\n", path, err) + failed++ + continue + } + m, err := catalogue.ParseManifest(raw) + if err != nil { + fmt.Fprintf(out, "%s: %v\n", path, err) + failed++ + continue + } + if first, twice := shelf[m.Module]; twice { + _ = first + fmt.Fprintf(out, "%s: %s was already given; two manifests name one module\n", path, m.Module) + failed++ + continue + } + shelf[m.Module] = m + } + + // Between the manifests: a seat declared twice, a use of a seat nothing declares, a claim on + // a seat that does not exist. Run only over what parsed, because a problem inside one manifest + // has already been said and would be said again here in a worse form. + problems := catalogue.CatalogueProblems(shelf) + sort.Strings(problems) + for _, p := range problems { + fmt.Fprintln(out, p) + } + failed += len(problems) + + var names []string + for name := range shelf { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + m := shelf[name] + fmt.Fprintf(out, "%s: ok", name) + if n := len(m.Tools); n > 0 { + fmt.Fprintf(out, ", %d tool(s)", n) + } + if len(m.Invokes) > 0 { + fmt.Fprintf(out, ", invokes %s", joinInvokes(m.Invokes)) + } + fmt.Fprintln(out) + } + if failed > 0 { + return fmt.Errorf("%d problem(s) in %d manifest(s)", failed, len(paths)) + } + fmt.Fprintf(out, "%d manifest(s) checked. Judged against the seats this binary carries; a claim on "+ + "one of the mesh's own seats is judged fully at registration, and a seat declared by a "+ + "module not given here reads as unknown\n", len(paths)) + return nil +} + +func joinInvokes(invokes []string) string { + if len(invokes) == 1 && invokes[0] == "*" { + return "every tool" + } + s := "" + for i, t := range invokes { + if i > 0 { + s += ", " + } + s += t + } + return s +} + +// manifestsUnder lists every module.json below a directory, for `module check `. +func manifestsUnder(dir string) ([]string, error) { + var found []string + err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() && (d.Name() == "node_modules" || d.Name() == ".git" || d.Name() == "dist") { + return filepath.SkipDir + } + if !d.IsDir() && d.Name() == "module.json" { + found = append(found, path) + } + return nil + }) + sort.Strings(found) + return found, err +} diff --git a/cmd/mesh-controller/check_test.go b/cmd/mesh-controller/check_test.go new file mode 100644 index 0000000..5f98a8a --- /dev/null +++ b/cmd/mesh-controller/check_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +// The check anybody can run is the check registration runs (novox/hq issue 148, ADR 0037): a manifest +// with a known fault is named, and one without passes, with no store opened. +func TestModuleCheckNamesAFaultAndNeedsNoMesh(t *testing.T) { + dir := t.TempDir() + good := filepath.Join(dir, "good.json") + bad := filepath.Join(dir, "bad.json") + os.WriteFile(good, []byte(`{"module":"shop","version":"1","tools":["price"],"invokes":["mesh-catalog.catalog_modules"]}`), 0o600) + os.WriteFile(bad, []byte(`{"module":"till","version":"1","invokes":["shop"]}`), 0o600) + + var out bytes.Buffer + if err := moduleCheck([]string{good}, &out); err != nil { + t.Fatalf("a sound manifest was refused: %v\n%s", err, out.String()) + } + if !strings.Contains(out.String(), "shop: ok, 1 tool(s), invokes mesh-catalog.catalog_modules") { + t.Fatalf("the report does not say what it checked:\n%s", out.String()) + } + + out.Reset() + err := moduleCheck([]string{good, bad}, &out) + if err == nil { + t.Fatal("a manifest invoking a module and no tool passed") + } + if !strings.Contains(out.String(), `till invokes "shop", which does not name a tool`) { + t.Fatalf("the fault is not named in the manifest's words:\n%s", out.String()) + } +} + +// The rules between manifests run over what was given together: a seat two modules declare is +// refused, which no single-manifest check can see. +func TestModuleCheckJudgesBetweenTheManifestsGiven(t *testing.T) { + dir := t.TempDir() + a := filepath.Join(dir, "a.json") + b := filepath.Join(dir, "b.json") + os.WriteFile(a, []byte(`{"module":"a","version":"1","seats":[{"name":"printer","scope":"mesh"}]}`), 0o600) + os.WriteFile(b, []byte(`{"module":"b","version":"1","seats":[{"name":"printer","scope":"mesh"}]}`), 0o600) + var out bytes.Buffer + if err := moduleCheck([]string{a, b}, &out); err == nil { + t.Fatalf("two declarations of one seat passed:\n%s", out.String()) + } + if !strings.Contains(out.String(), "a seat name means one protocol") { + t.Fatalf("the cross-manifest rule was not the one named:\n%s", out.String()) + } +} + +// The real catalogue passes the command, the way it passes the test that used to be the only check. +func TestModuleCheckPassesTheCatalogue(t *testing.T) { + root := filepath.Join("..", "..", "..", "mesh-catalog", "modules") + if _, err := os.Stat(root); err != nil { + t.Skipf("catalogue sibling not present: %v", err) + } + paths, err := manifestsUnder(root) + if err != nil || len(paths) == 0 { + t.Fatalf("no manifests under %s: %v", root, err) + } + var out bytes.Buffer + if err := moduleCheck(paths, &out); err != nil { + t.Fatalf("the catalogue does not pass its own check: %v\n%s", err, out.String()) + } +} diff --git a/cmd/mesh-controller/main.go b/cmd/mesh-controller/main.go index 2e32e88..da8a23b 100644 --- a/cmd/mesh-controller/main.go +++ b/cmd/mesh-controller/main.go @@ -161,6 +161,7 @@ func usage() { overlay place [flags] say where a node is and how it is reached overlay show the private network, as the mesh computes it module add register a module from its manifest + module check ... judge manifests where they are written, with no mesh (exit 1 on any problem) module list what modules this mesh knows about module moved the source has a newer commit than the mesh built module forget remove one, unless a node runs it or the mesh holds things for it diff --git a/cmd/mesh-controller/modules.go b/cmd/mesh-controller/modules.go index dbd31f9..91ef197 100644 --- a/cmd/mesh-controller/modules.go +++ b/cmd/mesh-controller/modules.go @@ -54,7 +54,24 @@ var provided = providedModules() func moduleCommand(ctx context.Context, args []string) error { if len(args) == 0 { - return errors.New("module add , module list, or module forget ") + return errors.New("module add , module check ..., module list, or module forget ") + } + // `check` needs no mesh, and must not: it is what somebody runs in their own repository before + // there is a mesh in reach (novox/hq issue 148). A directory expands to every manifest under it. + if args[0] == "check" { + var paths []string + for _, a := range args[1:] { + if info, err := os.Stat(a); err == nil && info.IsDir() { + under, err := manifestsUnder(a) + if err != nil { + return err + } + paths = append(paths, under...) + continue + } + paths = append(paths, a) + } + return moduleCheck(paths, os.Stdout) } open, err := openStores(ctx) if err != nil { @@ -275,7 +292,7 @@ func moduleCommand(ctx context.Context, args []string) error { return issueOnTheNewBus(ctx, inv, m, *forNode, busAddress) default: - return fmt.Errorf("module has no %q; it has add, list, moved, forget and issue", args[0]) + return fmt.Errorf("module has no %q; it has add, check, list, moved, forget and issue", args[0]) } } diff --git a/internal/broker/invokes_test.go b/internal/broker/invokes_test.go new file mode 100644 index 0000000..58e227b --- /dev/null +++ b/internal/broker/invokes_test.go @@ -0,0 +1,92 @@ +package broker + +import ( + "strings" + "testing" +) + +// A module that says it calls a tool may publish exactly that subject (novox/hq ADR 0152): the same +// grant a person gets, derived the same way, so one list answers "what may this ask" for everybody. +func TestAModuleMayAskOnlyTheToolsItInvokes(t *testing.T) { + perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "desk", Module: "mesh-console", + Invokes: []string{"shop.price"}, PasswordHash: "x"}) + if err != nil { + t.Fatal(err) + } + has(t, perms.Publish, "mesh.mod.shop.tool.price") + hasNot(t, perms.Publish, "mesh.mod.shop.tool.refund") + hasNot(t, perms.Publish, "mesh.mod.*.tool.>") +} + +// The console's grant: every tool, as one subject, and it reads as one. +func TestAModuleInvokingEverythingMayAskAnyTool(t *testing.T) { + perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "desk", Module: "mesh-console", + Invokes: []string{"*"}, PasswordHash: "x"}) + if err != nil { + t.Fatal(err) + } + has(t, perms.Publish, "mesh.mod.*.tool.>") +} + +// **A grant to call widens nothing else.** A module that invokes may not publish an event it did not +// declare, may not answer as another module, and subscribes nothing it did not consume — the +// difference between the console and a person is that the console is on a machine, not that it may +// do more. +func TestInvokingGrantsNothingButTheCall(t *testing.T) { + perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "desk", Module: "mesh-console", + Invokes: []string{"*"}, PasswordHash: "x"}) + if err != nil { + t.Fatal(err) + } + for _, p := range perms.Publish { + if strings.Contains(p, ".event.") { + t.Errorf("a module that only invokes may publish %q, an event it never declared", p) + } + if strings.HasPrefix(p, "mesh.seat.") { + t.Errorf("a module that only invokes may publish %q, a seat it neither holds nor uses", p) + } + } + for _, s := range perms.Subscribe { + if strings.Contains(s, ".tool.") && !strings.HasPrefix(s, "mesh.mod.mesh-console.") { + t.Errorf("a module that invokes may subscribe %q, another module's tools", s) + } + } +} + +// A module that declares no invokes calls nothing, which is every module but the console. +func TestAModuleThatInvokesNothingCallsNothing(t *testing.T) { + perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "shop", + Emits: []string{"order.placed"}, PasswordHash: "x"}) + if err != nil { + t.Fatal(err) + } + for _, p := range perms.Publish { + if strings.Contains(p, ".tool.") { + t.Errorf("a module with no invokes may publish %q", p) + } + } +} + +// The malformed entry is refused for a module as it is for a person, and in the same words. +func TestAModulesToolGrantThatNamesNoToolIsRefused(t *testing.T) { + if _, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "shop", + Invokes: []string{"telegram"}, PasswordHash: "x"}); err == nil { + t.Fatal("a grant naming a module but no tool was accepted") + } +} + +// What a declaration says reaches the composed user, so a manifest's `invokes` is the grant. +func TestADeclaredInvokeReachesTheComposedUser(t *testing.T) { + users, err := Users(Records{ + Nodes: []string{"desk"}, + Assigned: map[string][]Declared{"desk": {{Module: "mesh-console", Invokes: []string{"*"}}}}, + }) + if err != nil { + t.Fatal(err) + } + perms, err := PermissionsFor(users[len(users)-1]) + if err != nil { + t.Fatal(err) + } + has(t, perms.Publish, "mesh.mod.*.tool.>") +} diff --git a/internal/broker/nats.go b/internal/broker/nats.go index 8e2c8dc..58a5e0b 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -70,12 +70,14 @@ type Principal struct { // a namespace no such module owns. Every service started and the graph stayed empty. Watches []Seat - // Invokes are the tools a person may call, as `.`; a single `*` is every tool, - // for an administrator. Only meaningful for KindPerson. + // Invokes are the tools this principal may call, as `.`; a single `*` is every + // tool. A person's whole authority (design 25 §7), and a module's only if its manifest says so + // (novox/hq ADR 0152) — the console's does, and nothing else's. // // **A list, not a role.** A person is not a module and holds no seat: nothing is addressed // to them, nothing is delivered to them, and they have no durable consumer to acknowledge. - // What they have is permission to ask. + // What they have is permission to ask. A module that invokes gains exactly the same + // permission and nothing beside it. Invokes []string // PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal @@ -224,18 +226,11 @@ func PermissionsFor(p Principal) (Permissions, error) { case KindPerson: // Tools, and nothing else. Every subject a person may publish is a tool call; a person // who could publish an event would be able to claim a module said something. - for _, t := range p.Invokes { - if t == "*" { - pub = append(pub, "mesh.mod.*.tool.>") - continue - } - module, tool, ok := strings.Cut(t, ".") - if !ok { - return Permissions{}, fmt.Errorf( - "%q does not name a tool: a person invokes ., or * for every one", t) - } - pub = append(pub, "mesh.mod."+module+".tool."+tool) + invoked, err := invokedSubjects(p.Invokes) + if err != nil { + return Permissions{}, err } + pub = append(pub, invoked...) case KindEnrolment: // A leaked token is useless for anything but enrolling: it cannot read a declaration, hear @@ -293,6 +288,16 @@ func PermissionsFor(p Principal) (Permissions, error) { // still granted per tool, by name, on the publish side. sub = append(sub, own+".tool.>") + // 1b. The tools it calls, if its manifest says it calls any (novox/hq ADR 0152). The same + // grant a person gets and derived the same way, so "what may this module ask" is + // answered by the one list that answers it for everybody. Publish only: an answer + // arrives on its own inbox, which every principal has below. + invoked, err := invokedSubjects(p.Invokes) + if err != nil { + return Permissions{}, err + } + pub = append(pub, invoked...) + // 2. What it consumes, by the emitter's own subject — an event is addressed to its // emitter, because the emitter's identity is the meaning (ADR 0118). for _, c := range p.Consumes { @@ -601,3 +606,24 @@ func quoted(values []string) string { } return strings.Join(out, ", ") } + +// invokedSubjects is the publish side of a grant to call tools: one subject per `.`, +// or the whole tool namespace for `*`. A person's authority and a module's `invokes` are both this +// (novox/hq ADR 0152), so a malformed entry is refused in one place, before it could be widened into +// something that happens to parse. +func invokedSubjects(invokes []string) ([]string, error) { + var out []string + for _, t := range invokes { + if t == "*" { + out = append(out, "mesh.mod.*.tool.>") + continue + } + module, tool, ok := strings.Cut(t, ".") + if !ok || module == "" || tool == "" { + return nil, fmt.Errorf( + "%q does not name a tool: one invokes ., or * for every one", t) + } + out = append(out, "mesh.mod."+module+".tool."+tool) + } + return out, nil +} diff --git a/internal/broker/users.go b/internal/broker/users.go index 17d0ce4..de181e4 100644 --- a/internal/broker/users.go +++ b/internal/broker/users.go @@ -31,6 +31,8 @@ type Declared struct { Uses []Seat // Watches are the seats whose events it consumes. Watches []Seat + // Invokes are the tools it calls, `.` or `*` (novox/hq ADR 0152). + Invokes []string } // Records is what composing a user list needs to know about the mesh, and nothing more. @@ -61,7 +63,7 @@ func Users(r Records) ([]Principal, error) { out = append(out, Principal{ Kind: KindModule, Node: node, Module: d.Module, Emits: d.Emits, Consumes: d.Consumes, Serves: d.Serves, - Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, + Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, Invokes: d.Invokes, }) } } diff --git a/internal/catalogue/invokes_test.go b/internal/catalogue/invokes_test.go new file mode 100644 index 0000000..7612ad2 --- /dev/null +++ b/internal/catalogue/invokes_test.go @@ -0,0 +1,31 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A manifest may say which tools its module calls (novox/hq ADR 0152), and the parser accepts the +// two shapes the grant has: a named tool, and every tool. +func TestAManifestMaySayWhatItInvokes(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"mesh-console","version":"1",` + + `"invokes":["mesh-catalog.catalog_modules","*"]}`)) + if err != nil { + t.Fatal(err) + } + if len(m.Invokes) != 2 || m.Invokes[1] != "*" { + t.Fatalf("invokes not read: %v", m.Invokes) + } +} + +// An entry that names a module and no tool is refused at parse, in the manifest's words, rather than +// at the composition of the bus's user list where it would stop the file for everybody. +func TestAnInvokeThatNamesNoToolIsRefusedAtParse(t *testing.T) { + _, err := ParseManifest([]byte(`{"module":"mesh-console","version":"1","invokes":["shop"]}`)) + if err == nil { + t.Fatal("an invoke naming no tool was accepted") + } + if !strings.Contains(err.Error(), `invokes "shop", which does not name a tool`) { + t.Fatalf("refused for the wrong reason: %v", err) + } +} diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index eb2f554..1d07488 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -42,6 +42,11 @@ var renamed = map[string]string{ var name = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*$`) +// toolName is what a module calls one of its tools: the sdk's tools are `catalog_modules` and +// `gitea_list_repos`, so an underscore is ordinary here and a dot is not — the dot is what separates +// the module from the tool in `.`, and a tool name carrying one would be two grants. +var toolName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) + // Claim is a singular resource a module takes over. type Claim struct { Name string `json:"name"` @@ -247,6 +252,16 @@ type Manifest struct { // module claiming a seat answers what that seat's protocol promises (novox/hq ADR 0118). Tools []string `json:"tools,omitempty"` + // Invokes are the tools this module calls, each `.`, or the single entry `*` for + // every tool on the mesh (novox/hq ADR 0152). + // + // **A grant, and only a grant.** The bus lets this module publish exactly those tool subjects + // and nothing beside them — no event, no subscription, no seat. A module that declares none + // calls nothing, which is every module but the console today. ADR 0095 made the control plane + // the one caller and deferred this until a consumer asked; the console is that consumer, and a + // person's account (design 25 §7) already had the same shape. + Invokes []string `json:"invokes,omitempty"` + // Capabilities the machine must have. A different field from Requires because the remedy // differs: a missing module can be assigned, and a missing capability means the wrong // machine. @@ -1290,6 +1305,7 @@ func ParseManifest(raw []byte) (Manifest, error) { "%s listens on %d over %q, which is tcp or udp", m.Module, l.Port, p)) } } + problems = append(problems, invokeProblems(m)...) problems = append(problems, endpointNameProblems(m)...) problems = append(problems, RouteProblems(m)...) for _, port := range m.Guards { @@ -1766,3 +1782,25 @@ func EndpointPort(m Manifest, name string) (int, bool) { } return 0, false } + +// invokeProblems judges what a module says it calls (novox/hq ADR 0152). +// +// Refused here, in the manifest's words, rather than at the next composition of the bus's user +// list — where a bad entry would stop the whole file being written for everybody, as a person's +// malformed grant would have (operator.go). An entry that names a module and no tool is the one +// mistake worth naming: `shop` reads like a grant to a module's tools and would be a grant to nothing. +func invokeProblems(m Manifest) []string { + var problems []string + for _, t := range m.Invokes { + if t == "*" { + continue + } + module, tool, named := strings.Cut(t, ".") + if !named || !name.MatchString(module) || !toolName.MatchString(tool) { + problems = append(problems, fmt.Sprintf( + "%s invokes %q, which does not name a tool: a module invokes ., or "+ + "* for every tool on the mesh (novox/hq ADR 0152)", m.Module, t)) + } + } + return problems +} diff --git a/internal/inventory/busrecords.go b/internal/inventory/busrecords.go index 4472269..1de57e9 100644 --- a/internal/inventory/busrecords.go +++ b/internal/inventory/busrecords.go @@ -118,6 +118,8 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio // The tools it answers, which is `tools` and not `serves`: the manifest's `serves` is the // facts a consumer needs to reach a provision, a different meaning under a similar word. Serves: m.Tools, + // And what it calls (novox/hq ADR 0152) — the console's `*`, nothing else's. + Invokes: m.Invokes, } for _, c := range m.Claims { // Every seat with a protocol, the mesh's own included. One that says only who does a job is -- 2.54.0