Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a17a9a2eb | ||
|
|
23907984a3 | ||
|
|
f0634e11f4 | ||
|
|
542ce76c0a | ||
|
|
2882b5fcb1 | ||
|
|
481b1a7b05 |
@@ -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 show the private network, as the mesh computes it
|
||||
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 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
|
||||
|
||||
@@ -54,7 +54,24 @@ var provided = providedModules()
|
||||
|
||||
func moduleCommand(ctx context.Context, args []string) error {
|
||||
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)
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
Watches []Seat
|
||||
|
||||
// Invokes are the tools a person may call, as `<module>.<tool>`; a single `*` is every tool,
|
||||
// for an administrator. Only meaningful for KindPerson.
|
||||
// Invokes are the tools this principal may call, as `<module>.<tool>`; 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 <module>.<tool>, 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 `<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
|
||||
// Watches are the seats whose events it consumes.
|
||||
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.
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,27 @@ func knownFor(m Manifest, needs []Needed, node string) map[string]map[string]str
|
||||
return out
|
||||
}
|
||||
|
||||
// withOwnNames adds a module's own composed names to what it may name from one binding:
|
||||
// `${bound:<provision>:name}` and `:internal-name`, and for several contributions to one requirement
|
||||
// `:name-<local>` / `:internal-name-<local>`. Set over anything the provider serves under those keys:
|
||||
// what the module is called is the mesh's statement, not the provider's.
|
||||
func withOwnNames(values map[string]string, own map[string]any) {
|
||||
for _, key := range []string{"name", "internal-name"} {
|
||||
if v, ok := own[key].(string); ok {
|
||||
values[key] = v
|
||||
}
|
||||
}
|
||||
many, _ := own["names"].(map[string]any)
|
||||
for local, raw := range many {
|
||||
names, _ := raw.(map[string]any)
|
||||
for _, key := range []string{"name", "internal-name"} {
|
||||
if v, ok := names[key].(string); ok {
|
||||
values[key+"-"+local] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// plainly renders a served value as a program would expect to read it.
|
||||
func plainly(value any) string {
|
||||
switch v := value.(type) {
|
||||
|
||||
@@ -574,7 +574,11 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri
|
||||
}
|
||||
found = here
|
||||
}
|
||||
file, err := boundFile(*found, m.Binds[to], ConsumerIdentity(r.Node, IdentitySource(m.Slug, m.Module)))
|
||||
own, err := r.ownNames(m, to, with.Settings[m.Module])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file, err := boundFile(*found, m.Binds[to], ConsumerIdentity(r.Node, IdentitySource(m.Slug, m.Module)), own)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -635,6 +639,35 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri
|
||||
}
|
||||
// And what its bindings say, for the half of a connection that is not secret.
|
||||
known := knownFor(m, r.Needs, r.Node)
|
||||
// A requirement answered on this same machine is not in r.Needs — its binding file is
|
||||
// written from `here` (above) — and so `${bound:…}` could not name it, though the file
|
||||
// beside it said the same facts. Filled from the same answer, so the two cannot disagree.
|
||||
for _, want := range m.Wants() {
|
||||
if _, has := known[want]; has {
|
||||
continue
|
||||
}
|
||||
answered, err := here(r, want, with)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if answered == nil {
|
||||
continue
|
||||
}
|
||||
local := *answered
|
||||
local.For = m.Module
|
||||
for provision, values := range knownFor(m, []Needed{local}, r.Node) {
|
||||
known[provision] = values
|
||||
}
|
||||
}
|
||||
// And what the module is called through each requirement it contributes to (novox/hq
|
||||
// 04-ISSUES/122) — the same composition its binding file carries.
|
||||
for provision, values := range known {
|
||||
own, err := r.ownNames(m, provision, with.Settings[m.Module])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
withOwnNames(values, own)
|
||||
}
|
||||
// And where this node places the directories the module declared without a path
|
||||
// (novox/hq ADR 0112) — resolved once per module, named by ${dir:…} from any resource.
|
||||
dirs := dirsFor(m, with)
|
||||
@@ -1087,21 +1120,11 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
|
||||
// Settings reach a contribution the same way they reach a file. A route's hostname is
|
||||
// exactly the kind of thing that differs between one mesh and the next, and a module
|
||||
// that could not have it set would have to be edited to be reused.
|
||||
values, err := settle(m.Contributes[to], settings[m.Module], nil,
|
||||
values, err := r.composed(m, to, m.Contributes[to], settings[m.Module],
|
||||
m.Module+" contributing to "+to)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s contributing to %s: %w", m.Module, to, err)
|
||||
return nil, err
|
||||
}
|
||||
reaches, err := Reaches(m, settings[m.Module])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s contributing to %s: %w", m.Module, to, err)
|
||||
}
|
||||
blocks, err := Endpoints(m, settings[m.Module])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s contributing to %s: %w", m.Module, to, err)
|
||||
}
|
||||
portOfEndpoint(values, endpointPorts(m))
|
||||
composeName(values, r.PublicDomain, servingAt(r, to), reaches, endpointPorts(m), blocks)
|
||||
out[to] = append(out[to], Contribution{From: m.Module, Values: values})
|
||||
}
|
||||
// Several contributions to one requirement (ADR 0094's sibling for `contributes`): an
|
||||
@@ -1110,21 +1133,11 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
|
||||
// name always reaches the provider from here.
|
||||
for _, to := range sortedKeys(m.ContributesMany) {
|
||||
for _, local := range sortedKeys(m.ContributesMany[to]) {
|
||||
values, err := settle(m.ContributesMany[to][local], settings[m.Module], nil,
|
||||
values, err := r.composed(m, to, m.ContributesMany[to][local], settings[m.Module],
|
||||
m.Module+" contributing "+local+" to "+to)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s contributing %s to %s: %w", m.Module, local, to, err)
|
||||
return nil, err
|
||||
}
|
||||
reaches, err := Reaches(m, settings[m.Module])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s contributing %s to %s: %w", m.Module, local, to, err)
|
||||
}
|
||||
blocks, err := Endpoints(m, settings[m.Module])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s contributing %s to %s: %w", m.Module, local, to, err)
|
||||
}
|
||||
portOfEndpoint(values, endpointPorts(m))
|
||||
composeName(values, r.PublicDomain, servingAt(r, to), reaches, endpointPorts(m), blocks)
|
||||
out[to] = append(out[to], Contribution{From: m.Module, Values: values})
|
||||
}
|
||||
}
|
||||
@@ -1132,6 +1145,82 @@ func (r Resolution) contributions(settings SettingsBy, grants []Grant,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// composed is one contribution as its provider receives it: settled with this node's settings, its
|
||||
// endpoint's port filled in, and its names composed from the label.
|
||||
//
|
||||
// **One function, because two readers must agree.** The provider is told the names in its received
|
||||
// file; the contributing module is told the same names in its own binding (novox/hq 04-ISSUES/122).
|
||||
// Composing them twice, in two places, is how the proxy would come to serve one name while the
|
||||
// module wrote another into its configuration.
|
||||
func (r Resolution) composed(m Manifest, to string, raw map[string]any, layers []Layer, what string) (
|
||||
map[string]any, error) {
|
||||
values, err := settle(raw, layers, nil, what)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", what, err)
|
||||
}
|
||||
reaches, err := Reaches(m, layers)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", what, err)
|
||||
}
|
||||
blocks, err := Endpoints(m, layers)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", what, err)
|
||||
}
|
||||
portOfEndpoint(values, endpointPorts(m))
|
||||
composeName(values, r.PublicDomain, servingAt(r, to), reaches, endpointPorts(m), blocks)
|
||||
return values, nil
|
||||
}
|
||||
|
||||
// ownNames is what a module is known by through what it contributes to one requirement — the names
|
||||
// the mesh composed for it, and nothing else of the contribution.
|
||||
//
|
||||
// **The half a module could not learn** (novox/hq 04-ISSUES/122). A module contributes a label, the
|
||||
// mesh joins it with this node's domains, and the provider serves the result — and the module itself
|
||||
// was never told. Software that must know its own address (a login redirect, a canonical URL, an
|
||||
// issuer) had it written into the manifest as a literal, which is a domain in a definition and wrong
|
||||
// on every other machine. `${bound:<requirement>:name}` is the answer, from the same composition the
|
||||
// provider receives.
|
||||
//
|
||||
// Several contributions to one requirement are keyed by their local name under `names`.
|
||||
func (r Resolution) ownNames(m Manifest, to string, layers []Layer) (map[string]any, error) {
|
||||
pick := func(values map[string]any) map[string]any {
|
||||
names := map[string]any{}
|
||||
for _, key := range []string{"name", "internal-name"} {
|
||||
if v, ok := values[key].(string); ok && v != "" {
|
||||
names[key] = v
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
out := map[string]any{}
|
||||
if raw, ok := m.Contributes[to]; ok {
|
||||
values, err := r.composed(m, to, raw, layers, m.Module+" contributing to "+to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, v := range pick(values) {
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
if locals := m.ContributesMany[to]; len(locals) > 0 {
|
||||
many := map[string]any{}
|
||||
for _, local := range sortedKeys(locals) {
|
||||
values, err := r.composed(m, to, locals[local], layers,
|
||||
m.Module+" contributing "+local+" to "+to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if names := pick(values); len(names) > 0 {
|
||||
many[local] = names
|
||||
}
|
||||
}
|
||||
if len(many) > 0 {
|
||||
out["names"] = many
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// composeName joins a contribution's label with a node's public domain, and separately with its
|
||||
// private one, in place (novox/hq ADR 0056).
|
||||
//
|
||||
@@ -1346,14 +1435,14 @@ func sortedKeys[V any](m map[string]V) []string {
|
||||
// Where it is and what the providing module said about using it. **No credential**, and the file
|
||||
// says so rather than leaving a reader to wonder whether one was meant to be there — a missing
|
||||
// field looks like a bug, and a stated absence looks like a boundary.
|
||||
func boundFile(n Needed, path, as string) (map[string]any, error) {
|
||||
func boundFile(n Needed, path, as string, own map[string]any) (map[string]any, error) {
|
||||
// A record has no machine and no address. Saying so is the difference between a reader
|
||||
// concluding "somewhere with no address" and concluding the mesh failed to fill something in.
|
||||
where := any(n.At)
|
||||
if n.ByRecord {
|
||||
where = "a record in this mesh, not a machine"
|
||||
}
|
||||
body, err := json.MarshalIndent(map[string]any{
|
||||
doc := map[string]any{
|
||||
"binding": 1,
|
||||
"provision": n.Name,
|
||||
"from": n.From,
|
||||
@@ -1369,7 +1458,14 @@ func boundFile(n Needed, path, as string) (map[string]any, error) {
|
||||
"generated": "by the mesh — do not edit; replaced whenever this changes. " +
|
||||
"The credential is not here: it is sealed, in the file this module's manifest " +
|
||||
"names under `secrets`",
|
||||
}, "", " ")
|
||||
}
|
||||
// **What this module is called through what it contributes here** (novox/hq 04-ISSUES/122):
|
||||
// `name`, `internal-name`, or `names` by local name — composed exactly as the provider receives
|
||||
// them. Absent when the module contributes nothing named, rather than written empty.
|
||||
for key, value := range own {
|
||||
doc[key] = value
|
||||
}
|
||||
body, err := json.MarshalIndent(doc, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1576,14 +1672,23 @@ func publishedOn(resource map[string]any, module string, with Rendering) {
|
||||
out = append(out, givenOuter(written, with.Given[module]))
|
||||
continue
|
||||
}
|
||||
wanted, err := strconv.Atoi(strings.TrimSpace(written))
|
||||
// A short form may carry the protocol — `"3478/udp"` — and the number is what the mesh
|
||||
// assigns for; the protocol rides along. Read as one token, the `/udp` made the whole
|
||||
// entry "not a port", and passing it through let the runtime publish it wherever it
|
||||
// liked: unifi's STUN and discovery landed on random machine ports while every TCP pin
|
||||
// beside them held.
|
||||
mapping, protocol := written, ""
|
||||
if cut := strings.LastIndex(written, "/"); cut >= 0 {
|
||||
mapping, protocol = written[:cut], written[cut:]
|
||||
}
|
||||
wanted, err := strconv.Atoi(strings.TrimSpace(mapping))
|
||||
if err != nil {
|
||||
// Not a port at all. Passed through, so the host refuses it with its own words rather
|
||||
// than this quietly dropping something somebody meant.
|
||||
out = append(out, written)
|
||||
continue
|
||||
}
|
||||
out = append(out, fmt.Sprintf("%d:%d", with.machinePort(module, wanted), wanted))
|
||||
out = append(out, fmt.Sprintf("%d:%d%s", with.machinePort(module, wanted), wanted, protocol))
|
||||
}
|
||||
resource["ports"] = out
|
||||
}
|
||||
|
||||
@@ -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-]*)*$`)
|
||||
|
||||
// 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.
|
||||
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 `<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
|
||||
// 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 <module>.<tool>, or "+
|
||||
"* for every tool on the mesh (novox/hq ADR 0152)", m.Module, t))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A module that must know its own address — a login redirect, a canonical URL, an issuer — had it
|
||||
// written into its manifest as a literal (novox/hq 04-ISSUES/122): a domain in a definition, wrong on
|
||||
// every other machine. It is told instead, from the same composition the provider receives.
|
||||
|
||||
// selfAware contributes a labelled route, binds the requirement, and writes its own name into a file.
|
||||
func selfAware(label string) Manifest {
|
||||
m := labelled("board", label, 8080)
|
||||
m.Requires = []string{"reverse-proxy"}
|
||||
m.Binds = map[string]string{"reverse-proxy": "/var/lib/board/route.json"}
|
||||
m.Resources = []map[string]any{
|
||||
{"id": "conf", "type": "file", "path": "/var/lib/board/app.conf",
|
||||
"content": "root = https://${bound:reverse-proxy:name}/\ninternal = ${bound:reverse-proxy:internal-name}\n"},
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// servingProxy is proxy() as the catalogue's route providers are declared: the provision scoped to
|
||||
// the mesh, serving nothing a consumer must know (route-adapter, route-proxy: `"serves": {"route": {}}`).
|
||||
func servingProxy() Manifest {
|
||||
p := proxy()
|
||||
p.Provides = []Offer{{Name: "reverse-proxy", Scope: ScopeMesh}}
|
||||
p.Serves = map[string]map[string]any{"reverse-proxy": {}}
|
||||
return p
|
||||
}
|
||||
|
||||
// nodeProxy is the same provider scoped to its node, whose answer on the same machine comes from
|
||||
// `here` rather than from the mesh's needs — the other path a binding is written by.
|
||||
func nodeProxy() Manifest {
|
||||
p := proxy()
|
||||
p.Serves = map[string]map[string]any{"reverse-proxy": {"scheme": "http"}}
|
||||
return p
|
||||
}
|
||||
|
||||
// onBoth is a node with a public domain and a private-network address, so both names compose.
|
||||
func onBoth(domain string) Node {
|
||||
n := withDomain(domain)
|
||||
n.At = "anchor.internal"
|
||||
return n
|
||||
}
|
||||
|
||||
func fileAt(t *testing.T, out []map[string]any, path string) string {
|
||||
t.Helper()
|
||||
for _, r := range out {
|
||||
if r["path"] == path {
|
||||
return r["content"].(string)
|
||||
}
|
||||
}
|
||||
t.Fatalf("nothing was declared at %s", path)
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestAModuleIsToldTheNameItsProviderServes(t *testing.T) {
|
||||
got, err := Resolve(shelf(servingProxy(), selfAware("git")), []string{"traefik", "board"},
|
||||
onBoth("example.tld"), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := mustDeclare(t, got)
|
||||
served := received(t, out)[0].Values
|
||||
|
||||
var binding map[string]any
|
||||
if err := json.Unmarshal([]byte(fileAt(t, out, "/var/lib/board/route.json")), &binding); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if binding["name"] != served["name"] || binding["name"] != "git.example.tld" {
|
||||
t.Fatalf("the module was told %v, the provider serves %v", binding["name"], served["name"])
|
||||
}
|
||||
if binding["internal-name"] != served["internal-name"] || binding["internal-name"] == nil {
|
||||
t.Fatalf("internal name: module told %v, provider serves %v",
|
||||
binding["internal-name"], served["internal-name"])
|
||||
}
|
||||
|
||||
conf := fileAt(t, out, "/var/lib/board/app.conf")
|
||||
want := "root = https://git.example.tld/\ninternal = " + served["internal-name"].(string) + "\n"
|
||||
if conf != want {
|
||||
t.Fatalf("the file was rendered as\n%s\nwant\n%s", conf, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheNameAModuleIsToldFollowsTheNodesDomain(t *testing.T) {
|
||||
// The whole point: the same definition, two machines, two names — nothing edited.
|
||||
for _, domain := range []string{"example.tld", "other.example"} {
|
||||
got, err := Resolve(shelf(servingProxy(), selfAware("git")), []string{"traefik", "board"},
|
||||
onBoth(domain), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf := fileAt(t, mustDeclare(t, got), "/var/lib/board/app.conf")
|
||||
if !strings.HasPrefix(conf, "root = https://git."+domain+"/") {
|
||||
t.Fatalf("on %s the module wrote %q", domain, conf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleWithNoPublicNameIsNotToldOne(t *testing.T) {
|
||||
// No public domain on the node: nothing composed, so no `name` — and a file asking for one is
|
||||
// refused rather than rendered with a placeholder or an empty host.
|
||||
m := selfAware("git")
|
||||
m.Resources[0]["content"] = "root = https://${bound:reverse-proxy:name}/\n"
|
||||
got, err := Resolve(shelf(servingProxy(), m), []string{"traefik", "board"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := got.Declaration(Rendering{}); err == nil ||
|
||||
!strings.Contains(err.Error(), `"name"`) {
|
||||
t.Fatalf("a file asking for a name that was never composed was not refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleIsToldItsNameByANodeScopedProviderToo(t *testing.T) {
|
||||
m := selfAware("git")
|
||||
m.Resources[0]["content"] = "root = https://${bound:reverse-proxy:name}/\n"
|
||||
got, err := Resolve(shelf(nodeProxy(), m), []string{"board"},
|
||||
withDomain("example.tld"), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf := fileAt(t, mustDeclare(t, got), "/var/lib/board/app.conf")
|
||||
if !strings.HasPrefix(conf, "root = https://git.example.tld/") {
|
||||
t.Fatalf("a same-machine, node-scoped answer did not tell the module its name: %q", conf)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A short-form port may name its protocol — "3478/udp" — and the mesh assigns the number exactly
|
||||
// as it does for "3478": the protocol rides along on the outside. Read as one token, the suffix made
|
||||
// the entry "not a port" and the runtime published it on a random machine port (found on ace: unifi's
|
||||
// STUN and discovery, while every TCP pin beside them held).
|
||||
func TestAShortFormPortKeepsItsProtocolAndGetsItsMachinePort(t *testing.T) {
|
||||
container := map[string]any{"type": "container", "ports": []any{"3478/udp", "8443", "10001/udp"}}
|
||||
with := Rendering{
|
||||
Given: map[string]map[int]int{"unifi": {3478: 3478}},
|
||||
Ports: map[string]map[int]int{"unifi": {8443: 20010, 10001: 20011}},
|
||||
}
|
||||
publishedOn(container, "unifi", with)
|
||||
got := fmt.Sprint(container["ports"])
|
||||
want := "[3478:3478/udp 20010:8443 20011:10001/udp]"
|
||||
if got != want {
|
||||
t.Fatalf("published %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user