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 <file|dir>... runs what registration runs with no store, for a manifest in any repository (hq issue 148).
This commit is contained in:
@@ -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 <manifest.json>... — 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 <dir>`.
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -161,6 +161,7 @@ func usage() {
|
|||||||
overlay place <node> [flags] say where a node is and how it is reached
|
overlay place <node> [flags] say where a node is and how it is reached
|
||||||
overlay show the private network, as the mesh computes it
|
overlay show the private network, as the mesh computes it
|
||||||
module add <file> register a module from its manifest
|
module add <file> register a module from its manifest
|
||||||
|
module check <file|dir>... judge manifests where they are written, with no mesh (exit 1 on any problem)
|
||||||
module list what modules this mesh knows about
|
module list what modules this mesh knows about
|
||||||
module moved <name> <commit> the source has a newer commit than the mesh built
|
module moved <name> <commit> the source has a newer commit than the mesh built
|
||||||
module forget <name> remove one, unless a node runs it or the mesh holds things for it
|
module forget <name> remove one, unless a node runs it or the mesh holds things for it
|
||||||
|
|||||||
@@ -54,7 +54,24 @@ var provided = providedModules()
|
|||||||
|
|
||||||
func moduleCommand(ctx context.Context, args []string) error {
|
func moduleCommand(ctx context.Context, args []string) error {
|
||||||
if len(args) == 0 {
|
if len(args) == 0 {
|
||||||
return errors.New("module add <file>, module list, or module forget <name>")
|
return errors.New("module add <file>, module check <file>..., module list, or module forget <name>")
|
||||||
|
}
|
||||||
|
// `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)
|
open, err := openStores(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -275,7 +292,7 @@ func moduleCommand(ctx context.Context, args []string) error {
|
|||||||
return issueOnTheNewBus(ctx, inv, m, *forNode, busAddress)
|
return issueOnTheNewBus(ctx, inv, m, *forNode, busAddress)
|
||||||
|
|
||||||
default:
|
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])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.>")
|
||||||
|
}
|
||||||
+40
-14
@@ -70,12 +70,14 @@ type Principal struct {
|
|||||||
// a namespace no such module owns. Every service started and the graph stayed empty.
|
// a namespace no such module owns. Every service started and the graph stayed empty.
|
||||||
Watches []Seat
|
Watches []Seat
|
||||||
|
|
||||||
// Invokes are the tools a person may call, as `<module>.<tool>`; a single `*` is every tool,
|
// Invokes are the tools this principal may call, as `<module>.<tool>`; a single `*` is every
|
||||||
// for an administrator. Only meaningful for KindPerson.
|
// 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
|
// **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.
|
// 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
|
Invokes []string
|
||||||
|
|
||||||
// PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal
|
// 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:
|
case KindPerson:
|
||||||
// Tools, and nothing else. Every subject a person may publish is a tool call; a person
|
// 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.
|
// who could publish an event would be able to claim a module said something.
|
||||||
for _, t := range p.Invokes {
|
invoked, err := invokedSubjects(p.Invokes)
|
||||||
if t == "*" {
|
if err != nil {
|
||||||
pub = append(pub, "mesh.mod.*.tool.>")
|
return Permissions{}, err
|
||||||
continue
|
|
||||||
}
|
|
||||||
module, tool, ok := strings.Cut(t, ".")
|
|
||||||
if !ok {
|
|
||||||
return Permissions{}, fmt.Errorf(
|
|
||||||
"%q does not name a tool: a person invokes <module>.<tool>, or * for every one", t)
|
|
||||||
}
|
|
||||||
pub = append(pub, "mesh.mod."+module+".tool."+tool)
|
|
||||||
}
|
}
|
||||||
|
pub = append(pub, invoked...)
|
||||||
|
|
||||||
case KindEnrolment:
|
case KindEnrolment:
|
||||||
// A leaked token is useless for anything but enrolling: it cannot read a declaration, hear
|
// 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.
|
// still granted per tool, by name, on the publish side.
|
||||||
sub = append(sub, own+".tool.>")
|
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
|
// 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).
|
// emitter, because the emitter's identity is the meaning (ADR 0118).
|
||||||
for _, c := range p.Consumes {
|
for _, c := range p.Consumes {
|
||||||
@@ -601,3 +606,24 @@ func quoted(values []string) string {
|
|||||||
}
|
}
|
||||||
return strings.Join(out, ", ")
|
return strings.Join(out, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// invokedSubjects is the publish side of a grant to call tools: one subject per `<module>.<tool>`,
|
||||||
|
// 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 <module>.<tool>, or * for every one", t)
|
||||||
|
}
|
||||||
|
out = append(out, "mesh.mod."+module+".tool."+tool)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ type Declared struct {
|
|||||||
Uses []Seat
|
Uses []Seat
|
||||||
// Watches are the seats whose events it consumes.
|
// Watches are the seats whose events it consumes.
|
||||||
Watches []Seat
|
Watches []Seat
|
||||||
|
// Invokes are the tools it calls, `<module>.<tool>` or `*` (novox/hq ADR 0152).
|
||||||
|
Invokes []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Records is what composing a user list needs to know about the mesh, and nothing more.
|
// 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{
|
out = append(out, Principal{
|
||||||
Kind: KindModule, Node: node, Module: d.Module,
|
Kind: KindModule, Node: node, Module: d.Module,
|
||||||
Emits: d.Emits, Consumes: d.Consumes, Serves: d.Serves,
|
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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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-]*)*$`)
|
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 `<module>.<tool>`, 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.
|
// Claim is a singular resource a module takes over.
|
||||||
type Claim struct {
|
type Claim struct {
|
||||||
Name string `json:"name"`
|
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).
|
// module claiming a seat answers what that seat's protocol promises (novox/hq ADR 0118).
|
||||||
Tools []string `json:"tools,omitempty"`
|
Tools []string `json:"tools,omitempty"`
|
||||||
|
|
||||||
|
// Invokes are the tools this module calls, each `<module>.<tool>`, 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
|
// 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
|
// differs: a missing module can be assigned, and a missing capability means the wrong
|
||||||
// machine.
|
// 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))
|
"%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, endpointNameProblems(m)...)
|
||||||
problems = append(problems, RouteProblems(m)...)
|
problems = append(problems, RouteProblems(m)...)
|
||||||
for _, port := range m.Guards {
|
for _, port := range m.Guards {
|
||||||
@@ -1766,3 +1782,25 @@ func EndpointPort(m Manifest, name string) (int, bool) {
|
|||||||
}
|
}
|
||||||
return 0, false
|
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 <module>.<tool>, or "+
|
||||||
|
"* for every tool on the mesh (novox/hq ADR 0152)", m.Module, t))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return problems
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// facts a consumer needs to reach a provision, a different meaning under a similar word.
|
||||||
Serves: m.Tools,
|
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 {
|
for _, c := range m.Claims {
|
||||||
// Every seat with a protocol, the mesh's own included. One that says only who does a job is
|
// Every seat with a protocol, the mesh's own included. One that says only who does a job is
|
||||||
|
|||||||
Reference in New Issue
Block a user