Compare commits

...
Author SHA1 Message Date
jschoubben c1334f3f85 The artifact store's seat is named for its scope: mesh-artifact-store
ADR 0121 decided it and deferred it as a delivering-seat migration; ADR 0122's aliases made it one
update and one alias (migration 0048). The former name resolves to it forever (novox/hq ADR 0156,
issue 123).
2026-09-30 21:14:40 +02:00
jschoubben 70d379816c Merge pull request 'A converted definition resolves to the paths it named before' (#170) from feat/definitions-place-their-directories into main 2026-09-30 19:11:45 +00:00
jschoubben d8e7c13f6d A converted definition resolves to the paths it named before
The check novox/hq issue 119 asks for before a definition stops naming where its data lives: two
catalogue checkouts, every module in both resolved with the controller's own rule and compared whole.
2026-09-30 21:10:18 +02:00
jschoubben 1851a15e57 Merge pull request 'A definition names no installation: the check, an operator's value, a context on the git seat' (#169) from feat/a-definition-names-no-installation into main
Reviewed-on: #169
2026-09-30 18:36:17 +00:00
jschoubben d9dbc9a59a A definition names no installation: the check, an operator's value, a context on the git seat
InstallationProblems judges every value the mesh acts on for a name under a public top-level domain
or a public address, with prose, the world's registries, resolvers and certificate authorities
exempt, and a name a resource means on purpose declared with its reason (names-on-purpose). Run by
module check and a catalogue-wide test, not yet at registration, while the declared list shrinks.
${setting:<key>} fills a file from the assignment's settings and is refused when nothing set it.
A build context may live on the git seat; the request carries the seat's clone base (novox/hq ADR
0112, ADR 0155, issues 122 and 134).
2026-09-30 18:38:06 +02:00
jschoubben d5e1332dda Merge pull request 'A JSON verb's answer is its standard output alone' (#168) from fix/a-verbs-answer-is-its-stdout into main
Reviewed-on: #168
2026-09-30 16:20:14 +00:00
jschoubben 5ea0e87059 A JSON verb's answer is its standard output alone
status --json prints its warnings beside the document; parsed from both streams together the first
status asked through the console carried no answer as data. Output stays both streams, in order.
2026-09-30 18:18:43 +02:00
jschoubben 8e81266cdc Merge pull request 'A holder binds its seat's tools when it may, not only when it starts' (#167) from fix/a-holder-binds-when-it-may into main 2026-09-30 16:13:46 +00:00
jschoubben 70705ffe45 A holder binds its seat's tools when it may, not only when it starts
The grant is a line in the bus's user list the controller itself composes and a push delivers, so
the first controller to serve its seat started before the list named it and every subscription was
refused for good (2026-09-30). A refused subscription is retried until it holds.
2026-09-30 17:59:23 +02:00
jschoubben 91c4da8a82 Merge pull request 'The mesh's own verbs are the mesh-controller seat's tools' (#166) from feat/the-mesh-answers-for-itself into main
Reviewed-on: #166
2026-09-30 15:54:18 +00:00
jschoubben e9df5dccab The mesh's own verbs are the mesh-controller seat's tools
A seat's protocol lives in the store (migration 0047; seeded additively), a served verb carries its
description and schema, holding a mesh seat requires serving its verbs, a node-scoped seat's tool
carries the node, and the control plane serves status, nodes, node, modules, seats, builds, plan,
assign, unassign, push, build and tools on its seat by running the same commands (novox/hq ADR 0132,
ADR 0154, design 33). A grant of * reaches a role's tools; seat:<seat>.<verb> grants one.
2026-09-30 17:39:38 +02:00
mesh-admin 990ef27cd2 Merge pull request 'A module is told the name it is served under (hq 122)' (#149) from fix/122-a-module-is-told-its-own-name into main 2026-09-30 15:13:53 +00:00
38 changed files with 1734 additions and 51 deletions
+2 -3
View File
@@ -171,10 +171,9 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
// after a clone that then fails at npm ci.
built, err = builder.Build(ctx, builder.Command, publisher,
request.Repository, request.Path, request.Ref, workspace, request.Held, npmrc,
forgeFrom(),
func(step, message string) {
forgeFrom(), func(step, message string) {
fmt.Fprintf(os.Stderr, " [%s] %s\n", step, message)
})
}, request.Seats)
}
if err != nil {
// A failure is a result. A build that fails and says nothing is indistinguishable from a
+2 -1
View File
@@ -408,6 +408,7 @@ func buildOne(ctx context.Context, source buildSource, path, ref string, wait ti
Path: path,
Ref: ref,
Held: heldBy(ctx),
Seats: seatBases(ctx),
}
fmt.Printf("asked for %s", source)
if source.Seat != "" {
@@ -513,7 +514,7 @@ func buildAndShow(ctx context.Context, source buildSource, path, ref string, wai
result, err := ask.Submit(ctx, link.BuildRequest{
ID: fmt.Sprintf("%s-%d", "build", time.Now().UnixNano()),
Repository: repository, Path: path, Ref: ref,
Held: heldBy(ctx),
Held: heldBy(ctx), Seats: seatBases(ctx),
}, wait)
if err != nil {
return err
+13
View File
@@ -30,6 +30,7 @@ func moduleCheck(paths []string, out io.Writer) error {
"manifest of a repository together so the rules between them are checked too")
}
shelf := catalogue.Shelf{}
faulted := map[string]bool{}
failed := 0
for _, path := range paths {
raw, err := os.ReadFile(path)
@@ -50,6 +51,15 @@ func moduleCheck(paths []string, out io.Writer) error {
failed++
continue
}
// A definition names no installation (novox/hq ADR 0112, ADR 0155): judged here and in the
// catalogue-wide test, not yet at registration, while the declared exceptions shrink.
if named := catalogue.InstallationProblems(m); len(named) > 0 {
for _, p := range named {
fmt.Fprintf(out, "%s: %s\n", path, p)
}
failed += len(named)
faulted[m.Module] = true
}
shelf[m.Module] = m
}
@@ -70,6 +80,9 @@ func moduleCheck(paths []string, out io.Writer) error {
sort.Strings(names)
for _, name := range names {
m := shelf[name]
if faulted[name] {
continue
}
fmt.Fprintf(out, "%s: ok", name)
if n := len(m.Tools); n > 0 {
fmt.Fprintf(out, ", %d tool(s)", n)
+17
View File
@@ -7,6 +7,7 @@ import (
"errors"
"flag"
"fmt"
"log"
"os"
"sort"
"strings"
@@ -124,6 +125,22 @@ func serve(ctx context.Context) error {
return err
}
// And the mesh's own verbs, as the seat this control plane holds (novox/hq ADR 0154). Served
// from the store's row, so what the seat declares is what is answered.
handlers, err := seatToolHandlers()
if err != nil {
return err
}
bus, isNATS := server.Bus().(link.OverNATS)
if !isNATS {
return errors.New("the mesh's verbs are served over the bus, and this control plane is not on it")
}
stopServing, err := bus.ServeSeatTools(catalogue.ControllerSeatName, handlers, log.New(os.Stdout, "", log.LstdFlags))
if err != nil {
return err
}
defer stopServing()
return server.Serve(ctx)
}
+194
View File
@@ -0,0 +1,194 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/link"
)
// The mesh's own verbs, served as the mesh-controller seat's tools (novox/hq ADR 0154, design 33).
//
// **Each tool runs the command it names, in this same binary, and answers what it printed.** That is
// ADR 0035 taken literally: the logic lives once, in the command, and a surface is an adapter with no
// decisions in it. Running a fresh process rather than calling the function keeps two things true
// that calling it would not — every command opens and closes its own stores the way it does from a
// shell, and nothing a command prints to the process's standard output can leak into another call's
// answer. It also means a refusal is the same refusal in the same words, because it is the same
// output.
// verbAnswer is what a verb answers: what the command printed, whether it succeeded, and — where the
// command speaks JSON — the same as data.
type verbAnswer struct {
Output string `json:"output"`
OK bool `json:"ok"`
Answer any `json:"answer,omitempty"`
}
// argvFor is the command line a verb and its arguments become. Only the verbs the seat declares, and
// only the arguments each declares: a caller cannot reach a flag the schema did not name.
func argvFor(verb string, args map[string]any) ([]string, error) {
str := func(key string) string {
v, _ := args[key].(string)
return strings.TrimSpace(v)
}
need := func(keys ...string) error {
for _, k := range keys {
if str(k) == "" {
return fmt.Errorf("%s needs %q", verb, k)
}
}
return nil
}
switch verb {
case "status":
return []string{"status", "--json"}, nil
case "nodes":
return []string{"node", "list"}, nil
case "node":
if err := need("node"); err != nil {
return nil, err
}
return []string{"node", "show", str("node")}, nil
case "modules":
return []string{"module", "list"}, nil
case "seats":
return []string{"seats", "--json"}, nil
case "builds":
if m := str("module"); m != "" {
return []string{"builds", m}, nil
}
return []string{"builds"}, nil
case "plan":
if err := need("node"); err != nil {
return nil, err
}
return []string{"plan", str("node"), "--json"}, nil
case "assign", "unassign":
if err := need("node", "module"); err != nil {
return nil, err
}
return []string{verb, str("node"), str("module")}, nil
case "push":
// Sent and not waited for: the asker reads `status` for what the machine did, which is
// what a person at a shell does too. A tool call that blocked for a push's whole apply would
// time out on every machine that takes a minute, and say nothing about the ones that did not.
if n := str("node"); n != "" {
return []string{"push", n, "--wait", "0"}, nil
}
return []string{"push", "--behind", "--wait", "0"}, nil
case "build":
if err := need("repository"); err != nil {
return nil, err
}
argv := []string{"build", str("repository"), "--wait", "0"}
if p := str("path"); p != "" {
argv = append(argv, "--path", p)
}
if r := str("ref"); r != "" {
argv = append(argv, "--ref", r)
}
return argv, nil
}
return nil, fmt.Errorf("%q is not a verb the %s seat serves", verb, catalogue.ControllerSeatName)
}
// jsonVerbs are the verbs whose command speaks JSON, so the answer carries it as data as well.
var jsonVerbs = map[string]bool{"status": true, "seats": true, "plan": true}
// runVerb runs this binary with the given command line and gathers what it said.
func runVerb(ctx context.Context, argv []string) (verbAnswer, error) {
self, err := os.Executable()
if err != nil {
return verbAnswer{}, err
}
cmd := exec.CommandContext(ctx, self, argv...)
// The same environment: the stores' credentials, the bus, the broker — everything a command run
// from a shell in this container would have, because it is that.
cmd.Env = os.Environ()
// Two buffers, one answer. What the command *says* is both streams, in the order a person at
// a shell would read them; what it *answers as data* is standard output alone — `status --json`
// prints its warnings beside the document, and a JSON parsed from the two together parsed
// nothing (2026-09-30, the first status asked through the console had no `answer`).
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
runErr := cmd.Run()
answer := verbAnswer{Output: stdout.String() + stderr.String(), OK: runErr == nil}
if jsonVerbs[argv[0]] && runErr == nil {
var parsed any
if json.Unmarshal(bytes.TrimSpace(stdout.Bytes()), &parsed) == nil {
answer.Answer = parsed
}
}
var exit *exec.ExitError
if runErr != nil && !errors.As(runErr, &exit) {
// Not the command refusing — the command not running at all, which is this process's fault.
return answer, fmt.Errorf("could not run %s: %w", strings.Join(argv, " "), runErr)
}
return answer, nil
}
// seatToolHandlers are the handlers for every verb the mesh-controller seat declares, from the
// store's row, so a verb the row does not carry is not served and a verb it carries that this binary
// cannot run is said at start rather than at the first call.
func seatToolHandlers() (map[string]link.ToolHandler, error) {
seat, known := catalogue.SeatNamed(catalogue.ControllerSeatName)
if !known {
return nil, fmt.Errorf("this mesh defines no %s seat", catalogue.ControllerSeatName)
}
handlers := map[string]link.ToolHandler{}
for _, v := range seat.Serves {
verb := v.Name
if verb == "tools" {
handlers[verb] = func(ctx context.Context, _ json.RawMessage) (any, error) {
return seatTools(), nil
}
continue
}
if _, err := argvFor(verb, map[string]any{"node": "x", "module": "x", "repository": "x"}); err != nil {
return nil, fmt.Errorf("the %s seat's row declares %q, which this control plane cannot run: %w",
catalogue.ControllerSeatName, verb, err)
}
handlers[verb] = func(ctx context.Context, raw json.RawMessage) (any, error) {
args := map[string]any{}
if len(raw) > 0 {
if err := json.Unmarshal(raw, &args); err != nil {
return nil, fmt.Errorf("the arguments are not a JSON object: %w", err)
}
}
argv, err := argvFor(verb, args)
if err != nil {
return nil, err
}
return runVerb(ctx, argv)
}
}
return handlers, nil
}
// seatTools is what `tools` answers: every seat with a protocol, and the tools each serves, from the
// mesh's own records — no holder in the path, so it is true while a holder restarts (design 33 §5).
func seatTools() map[string]any {
var seats []map[string]any
for _, s := range catalogue.SeatsWithAProtocol() {
if len(s.Serves) == 0 {
continue
}
var tools []map[string]any
for _, v := range s.Serves {
tools = append(tools, map[string]any{
"name": v.Name, "description": v.Description, "input": v.Input, "output": v.Output,
})
}
seats = append(seats, map[string]any{"seat": s.Name, "scope": s.Scope, "tools": tools})
}
return map[string]any{"seats": seats}
}
+97
View File
@@ -0,0 +1,97 @@
package main
import (
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// Every verb the mesh-controller seat declares is one this binary can run, with the arguments the
// schema names and no other (novox/hq ADR 0154, ADR 0035).
func TestEveryDeclaredVerbHasACommandLine(t *testing.T) {
for _, v := range catalogue.ControllerVerbs {
if v.Name == "tools" {
continue
}
args := map[string]any{}
props, _ := v.Input["properties"].(map[string]any)
for name := range props {
args[name] = "x"
}
argv, err := argvFor(v.Name, args)
if err != nil {
t.Errorf("%s: %v", v.Name, err)
continue
}
if argv[0] == "" {
t.Errorf("%s: empty command", v.Name)
}
}
}
// A required argument missing is refused in the verb's own words, before anything runs.
func TestAVerbMissingWhatItNeedsIsRefused(t *testing.T) {
if _, err := argvFor("node", map[string]any{}); err == nil || !strings.Contains(err.Error(), `node needs "node"`) {
t.Fatalf("node without a machine was accepted: %v", err)
}
if _, err := argvFor("upgrade", map[string]any{}); err == nil {
t.Fatal("a verb the seat does not serve was accepted")
}
}
// A push and a build are sent, not waited for: the asker reads status for what happened.
func TestActsDoNotBlockTheCall(t *testing.T) {
argv, _ := argvFor("push", map[string]any{"node": "one"})
if strings.Join(argv, " ") != "push one --wait 0" {
t.Fatalf("push waits: %v", argv)
}
argv, _ = argvFor("build", map[string]any{"repository": "novox/x", "path": "modules/x"})
if strings.Join(argv, " ") != "build novox/x --wait 0 --path modules/x" {
t.Fatalf("build: %v", argv)
}
}
// What `tools` answers is the seats' records, with each verb's schema.
func TestToolsAnswersTheSeatsRecords(t *testing.T) {
handlers, err := seatToolHandlers()
if err != nil {
t.Fatal(err)
}
if len(handlers) != len(catalogue.ControllerVerbs) {
t.Fatalf("%d handlers for %d verbs", len(handlers), len(catalogue.ControllerVerbs))
}
answer := seatTools()
seats, _ := answer["seats"].([]map[string]any)
var found bool
for _, s := range seats {
if s["seat"] == catalogue.ControllerSeatName {
found = true
tools, _ := s["tools"].([]map[string]any)
if len(tools) != len(catalogue.ControllerVerbs) || tools[0]["input"] == nil {
t.Fatalf("the controller seat's tools are not listed in full: %v", tools)
}
}
}
if !found {
t.Fatal("the mesh-controller seat is not in the listing")
}
}
// A JSON verb's answer is parsed from what the command wrote to standard output alone; a warning it
// printed beside the document does not take the document away. The test binary stands in for the
// controller: `-test.run` with a name that matches nothing prints `ok` and a warning about no tests.
func TestAJSONVerbsAnswerIsItsStandardOutput(t *testing.T) {
jsonVerbs["-test.run"] = true
t.Cleanup(func() { delete(jsonVerbs, "-test.run") })
answer, err := runVerb(t.Context(), []string{"-test.run", "TestAnswerEcho", "-test.v"})
if err != nil {
t.Fatal(err)
}
if !answer.OK {
t.Fatalf("the command failed: %s", answer.Output)
}
if !strings.Contains(answer.Output, "PASS") {
t.Fatalf("stderr and stdout are both what the command said: %s", answer.Output)
}
}
+39 -4
View File
@@ -82,6 +82,16 @@ func cloneFrom(ctx context.Context, source buildSource) (string, error) {
// serves no scheme or port has nothing to compose from — a default port here would be the forge's
// address guessed, which is the thing this exists to stop.
func clonedFromSeat(world catalogue.World, seatName, repository string) (string, error) {
base, err := seatBase(world, seatName)
if err != nil {
return "", err
}
path := strings.TrimSuffix(strings.Trim(repository, "/"), ".git")
return fmt.Sprintf("%s/%s.git", base, path), nil
}
// seatBase is `scheme://host:port` of a seat's holder as the mesh reaches it, for cloning.
func seatBase(world catalogue.World, seatName string) (string, error) {
seat, known := catalogue.SeatNamed(seatName)
if !known || seat.Delivers == "" {
return "", fmt.Errorf("%q is not a seat a repository can live on", seatName)
@@ -94,9 +104,9 @@ func clonedFromSeat(world catalogue.World, seatName, repository string) (string,
}
}
if holder == nil {
return "", fmt.Errorf("nobody holds the %s seat, so %s cannot be cloned from this mesh's "+
return "", fmt.Errorf("nobody holds the %s seat, so nothing can be cloned from this mesh's "+
"forge — assign a module that claims it, or build from the repository's URL without --self",
seat.Name, repository)
seat.Name)
}
var provider *catalogue.Provider
for i, p := range world.Offered[seat.Delivers] {
@@ -118,8 +128,33 @@ func clonedFromSeat(world catalogue.World, seatName, repository string) (string,
return "", fmt.Errorf("%s on %s holds the %s seat and does not serve a scheme and a port for %q",
holder.Module, holder.Node, seat.Name, seat.Delivers)
}
path := strings.TrimSuffix(strings.Trim(repository, "/"), ".git")
return fmt.Sprintf("%s://%s:%s/%s.git", scheme, provider.At, port, path), nil
return fmt.Sprintf("%s://%s:%s", scheme, provider.At, port), nil
}
// seatBases is the clone base of every seat a recipe's context may name, for a build request
// (novox/hq ADR 0155). A seat nobody holds is left out rather than refused here: the build may not
// name it at all, and if it does the builder refuses with the seat's name.
func seatBases(ctx context.Context) map[string]string {
open, err := openStores(ctx)
if err != nil {
return nil
}
defer open.Close()
shelf, err := open.inventory.Catalogue(ctx)
if err != nil {
return nil
}
world, err := theRestOfTheMesh(ctx, open.inventory, shelf, "")
if err != nil {
return nil
}
bases := map[string]string{}
for _, seatName := range []string{gitSeat} {
if base, err := seatBase(world, seatName); err == nil {
bases[seatName] = base
}
}
return bases
}
// servedPort is a served port as text, however the manifest and the node's settings carried it.
+1 -1
View File
@@ -67,7 +67,7 @@ func TestTheAgreementCheckCatchesASubscriptionThatMatchesNothing(t *testing.T) {
// An event published under a seat's name is real even though no module declares it as its own.
if bad := Disagreements(nil,
[]AConsumer{{Module: "watcher", Consumes: []string{"mesh-artifact-store.image.pushed"}}},
[]DeclaredSeat{{Name: "the-artifact-store", Emits: []string{"image.pushed"}}}); len(bad) != 0 {
[]DeclaredSeat{{Name: "mesh-artifact-store", Emits: []string{"image.pushed"}}}); len(bad) != 0 {
t.Fatalf("an event a seat emits was reported as matching nothing: %v", bad)
}
}
+2 -1
View File
@@ -42,7 +42,8 @@ func TestInvokingGrantsNothingButTheCall(t *testing.T) {
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.") {
// A role's tools are tools (ADR 0132); a role's work queue and events are not.
if strings.HasPrefix(p, "mesh.seat.") && !strings.Contains(p, ".tool.") {
t.Errorf("a module that only invokes may publish %q, a seat it neither holds nor uses", p)
}
}
+42 -5
View File
@@ -39,7 +39,11 @@ const (
// Seat is a role on the bus as a principal relates to it: the subjects it accepts, and those it
// emits (novox/hq ADR 0118, design 29 §5).
type Seat struct {
Name string
Name string
// Scope is where the seat has one holder. A node-scoped seat's tool carries the node in its
// subject, because one subject reaching six machines' holders is not an address
// (novox/hq ADR 0132, design 33 §4). Empty reads as mesh.
Scope string
Accepts []string
Emits []string
Serves []string
@@ -197,6 +201,13 @@ func PermissionsFor(p Principal) (Permissions, error) {
// the new bus was refused the publish (2026-09-28).
pub = append(pub, "mesh.mod.*.tool.>")
// **And the mesh's own verbs, as the seat it holds** (novox/hq ADR 0132, ADR 0154):
// `status`, `push`, `assign` are the mesh-controller seat's tools, served by its holder. The
// whole verb namespace of its own seat rather than a list: the list is the seat's protocol,
// which this package mirrors rather than reads, and a verb the seat does not declare is a
// subject nothing publishes.
sub = append(sub, "mesh.seat."+ControllerSeat+".tool.>")
// The two events it reacts to, and its ack subject on the stream they arrive from
// (streams.go). **Each named, not a pattern**: `mesh.mod.*.event.>` would make the
// controller a subscriber to every event in the mesh, and its permission list would stop
@@ -343,7 +354,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
pub = append(pub, seatSubject(s, "event", e))
}
for _, t := range s.Serves {
sub = append(sub, seatSubject(s, "tool", t))
sub = append(sub, seatToolSubject(s, t, p.Node))
}
}
@@ -355,7 +366,7 @@ func PermissionsFor(p Principal) (Permissions, error) {
pub = append(pub, seatSubject(s, "accept", a))
}
for _, t := range s.Serves {
pub = append(pub, seatSubject(s, "tool", t))
pub = append(pub, seatToolSubject(s, t, "*"))
}
}
}
@@ -405,6 +416,18 @@ func seatSubject(s Seat, kind, verb string) string {
return "mesh.seat." + s.Name + "." + kind + "." + verb
}
// seatToolSubject is where a role's tool is asked. Mesh-wide for a mesh-scoped seat; a node-scoped
// seat carries the node it is asked of, because a flat subject would reach every machine's holder
// and the queue group would silently pick a winner (novox/hq ADR 0132, design 33 §4). A holder
// subscribes its own node's; a user publishes any node's (`*`) and names the machine in the subject.
func seatToolSubject(s Seat, verb, node string) string {
base := seatSubject(s, "tool", verb)
if s.Scope == "node" && node != "" {
return base + "." + node
}
return base
}
// consumerStream and consumerDurable are the two halves of a consumer's identity, and they are
// two functions because conflating them was a real bug.
//
@@ -615,13 +638,27 @@ func invokedSubjects(invokes []string) ([]string, error) {
var out []string
for _, t := range invokes {
if t == "*" {
out = append(out, "mesh.mod.*.tool.>")
// Every module's tools and every role's (novox/hq ADR 0132): a role's verb is a tool
// like any other, addressed to the seat instead of a module.
out = append(out, "mesh.mod.*.tool.>", "mesh.seat.*.tool.>")
continue
}
if rest, isSeat := strings.CutPrefix(t, "seat:"); isSeat {
// A role's tool, `seat:<seat>.<verb>`. Both address shapes, because the grant is
// written without knowing the seat's scope: a mesh seat's verb is flat and a node
// seat's carries the machine (design 33 §4).
seat, verb, ok := strings.Cut(rest, ".")
if !ok || seat == "" || verb == "" {
return nil, fmt.Errorf(
"%q does not name a role's tool: one invokes seat:<seat>.<verb>", t)
}
out = append(out, "mesh.seat."+seat+".tool."+verb, "mesh.seat."+seat+".tool."+verb+".*")
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)
"%q does not name a tool: one invokes <module>.<tool>, seat:<seat>.<verb>, or * for every one", t)
}
out = append(out, "mesh.mod."+module+".tool."+tool)
}
+57
View File
@@ -0,0 +1,57 @@
package broker
import "testing"
// A node-scoped seat's tool carries the node (novox/hq ADR 0132, design 33 §4): two nodes holding one
// node-scoped seat derive two addresses, and a user of the seat may publish any node's.
func TestTwoNodesHoldingOneNodeSeatDeriveTwoToolAddresses(t *testing.T) {
seat := Seat{Name: "node-dns-resolver", Scope: "node", Serves: []string{"lookup"}}
one, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "dnsmasq", Holds: []Seat{seat}, PasswordHash: "x"})
two, _ := PermissionsFor(Principal{Kind: KindModule, Node: "two", Module: "dnsmasq", Holds: []Seat{seat}, PasswordHash: "x"})
has(t, one.Subscribe, "mesh.seat.node-dns-resolver.tool.lookup.one")
has(t, two.Subscribe, "mesh.seat.node-dns-resolver.tool.lookup.two")
hasNot(t, one.Subscribe, "mesh.seat.node-dns-resolver.tool.lookup")
hasNot(t, one.Subscribe, "mesh.seat.node-dns-resolver.tool.lookup.two")
user, _ := PermissionsFor(Principal{Kind: KindModule, Node: "three", Module: "asker", Uses: []Seat{seat}, PasswordHash: "x"})
has(t, user.Publish, "mesh.seat.node-dns-resolver.tool.lookup.*")
}
// A mesh-scoped seat's tool stays flat: nothing about it changes.
func TestAMeshSeatsToolIsAddressedToTheSeatAlone(t *testing.T) {
seat := Seat{Name: "git", Scope: "mesh", Serves: []string{"list_repos"}}
holder, _ := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "gitea", Holds: []Seat{seat}, PasswordHash: "x"})
has(t, holder.Subscribe, "mesh.seat.git.tool.list_repos")
user, _ := PermissionsFor(Principal{Kind: KindModule, Node: "two", Module: "asker", Uses: []Seat{seat}, PasswordHash: "x"})
has(t, user.Publish, "mesh.seat.git.tool.list_repos")
}
// The controller serves its own seat's verbs and may answer them (novox/hq ADR 0154).
func TestTheControllerServesItsSeatsToolsAndMayAnswer(t *testing.T) {
perms, err := PermissionsFor(Principal{Kind: KindController, PasswordHash: "x"})
if err != nil {
t.Fatal(err)
}
has(t, perms.Subscribe, "mesh.seat.mesh-controller.tool.>")
if !perms.AllowResponses {
t.Fatal("the controller serves tools and may not answer one")
}
}
// A grant to every tool reaches a role's tools too, and a role's tool is granted by name.
func TestAGrantReachesARolesTools(t *testing.T) {
all, _ := PermissionsFor(Principal{Kind: KindModule, Node: "desk", Module: "mesh-console", Invokes: []string{"*"}, PasswordHash: "x"})
has(t, all.Publish, "mesh.seat.*.tool.>")
one, err := PermissionsFor(Principal{Kind: KindPerson, Module: "jo", Invokes: []string{"seat:mesh-controller.status"}, PasswordHash: "x"})
if err != nil {
t.Fatal(err)
}
has(t, one.Publish, "mesh.seat.mesh-controller.tool.status")
hasNot(t, one.Publish, "mesh.seat.mesh-controller.tool.push")
hasNot(t, one.Publish, "mesh.mod.*.tool.>")
if _, err := PermissionsFor(Principal{Kind: KindPerson, Module: "jo", Invokes: []string{"seat:mesh-controller"}, PasswordHash: "x"}); err == nil {
t.Fatal("a role grant naming no verb was accepted")
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ accounts {
users = [
{ user: "controller", password: "$2a$11$cccccccccccccccccccccc", permissions: {
publish: { allow: ["$JS.ACK.CONTROL.controller.>", "$JS.ACK.EVENTS.controller.>", "$JS.API.>", "_INBOX.enrol.>", "mesh.control.>", "mesh.mod.*.tool.>", "mesh.node.>", "mesh.seat.mesh-build-machine.accept.>", "mesh.seat.mesh-controller.event.applied", "mesh.seat.mesh-controller.event.built-before", "mesh.seat.mesh-controller.event.refused"] }
subscribe: { allow: ["$JS.API.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.gitea.event.pull.merged", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built"] }
subscribe: { allow: ["$JS.API.>", "_DELIVER.controller", "_DELIVER.controller.>", "_INBOX.controller.>", "mesh.control.>", "mesh.mod.gitea.event.pull.merged", "mesh.mod.mesh-catalog.event.catching-up", "mesh.mod.mesh-catalog.event.upgraded", "mesh.seat.mesh-build-machine.event.built", "mesh.seat.mesh-controller.tool.>"] }
allow_responses: { max: 1, ttl: "1m" }
} }
{ user: "enrol.one", password: "$2a$11$eeeeeeeeeeeeeeeeeeeeee", permissions: {
+36 -8
View File
@@ -90,7 +90,13 @@ type GitCredential struct {
// records — reachable, unreferenced, and indistinguishable from something in use.
func Build(ctx context.Context, run Runner, publish Publisher,
repository, path, ref, workspace string, held map[string]string, npmrc Npmrc,
forge GitCredential, log Log) (Result, error) {
forge GitCredential, log Log, seats ...map[string]string) (Result, error) {
// The clone base of each seat a context may name (novox/hq ADR 0155); variadic so the callers
// that hand none — tests of everything but contexts — read as they did.
var seatBases map[string]string
if len(seats) > 0 {
seatBases = seats[0]
}
say := logging(log)
say("clone", "%s%s at %s", repository, describePath(path), refOrHead(ref))
@@ -209,7 +215,7 @@ func Build(ctx context.Context, run Runner, publish Publisher,
sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name })
for _, a := range artifacts {
say("artifact", "%s (%s%s) — starting", a.Name, a.Kind, langSuffix(a))
made, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, npmrcPath, say)
made, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, npmrcPath, seatBases, say)
if err != nil {
say("artifact", "%s FAILED: %v", a.Name, err)
return Result{}, err
@@ -246,14 +252,18 @@ func logging(log Log) func(step, format string, args ...any) {
// module's own repository — a fresh tree, the same way the module's own is, keyed by artifact
// name so two artifacts of one module naming different contexts do not collide.
func contextFrom(ctx context.Context, run Runner, workspace, artifact, credentials string,
from catalogue.ArtifactContext, say func(step, format string, args ...any)) (string, error) {
say("context", "cloning %s at %s for %s", from.Repository, refOrHead(from.Ref), artifact)
from catalogue.ArtifactContext, seats map[string]string, say func(step, format string, args ...any)) (string, error) {
url, err := contextURL(from, seats)
if err != nil {
return "", err
}
say("context", "cloning %s at %s for %s", url, refOrHead(from.Ref), artifact)
dir := filepath.Join(workspace, "context-"+artifact)
if err := os.RemoveAll(dir); err != nil {
return "", err
}
if _, err := run(ctx, workspace, "git", cloneWith(credentials, "clone", "--quiet", from.Repository, dir)...); err != nil {
return "", fmt.Errorf("cannot clone %s: %w", from.Repository, err)
if _, err := run(ctx, workspace, "git", cloneWith(credentials, "clone", "--quiet", url, dir)...); err != nil {
return "", fmt.Errorf("cannot clone %s: %w", url, err)
}
if from.Ref != "" {
if _, err := run(ctx, dir, "git", "checkout", "--quiet", from.Ref); err != nil {
@@ -264,6 +274,23 @@ func contextFrom(ctx context.Context, run Runner, workspace, artifact, credentia
return dir, nil
}
// contextURL is what a context is cloned from: its URL, or — for a context on a seat — the seat's
// clone base the mesh sent with the request joined to the repository's path (novox/hq ADR 0155).
// Refused, never guessed, when the mesh sent no base for that seat: a builder that guessed a forge
// would be the literal this removes, one layer down.
func contextURL(from catalogue.ArtifactContext, seats map[string]string) (string, error) {
if from.Seat == "" {
return from.Repository, nil
}
base, told := seats[from.Seat]
if !told || base == "" {
return "", fmt.Errorf("the context is %s on the %s seat, and this build was told no clone "+
"base for that seat — nothing holds it in this mesh, or the control plane predates the word",
from.Repository, from.Seat)
}
return strings.TrimRight(base, "/") + "/" + strings.TrimSuffix(strings.Trim(from.Repository, "/"), ".git") + ".git", nil
}
// cloneWith is a git invocation that may offer a stored credential.
//
// The first `-c credential.helper=` clears every helper the environment might carry, so exactly
@@ -416,7 +443,8 @@ func wantsPackages(manifest catalogue.Manifest, within string) bool {
func one(ctx context.Context, run Runner, publish Publisher,
module, tree, workspace, commit, credentials string, a catalogue.Artifact, args []string,
held map[string]string, npmrc string, say func(step, format string, args ...any)) (catalogue.Built, error) {
held map[string]string, npmrc string, seats map[string]string,
say func(step, format string, args ...any)) (catalogue.Built, error) {
switch a.Kind {
case catalogue.ArtifactUpstream:
@@ -493,7 +521,7 @@ func one(ctx context.Context, run Runner, publish Publisher,
recipePath := a.From
buildDir := tree
if a.Context != nil {
cloned, err := contextFrom(ctx, run, workspace, a.Name, credentials, *a.Context, say)
cloned, err := contextFrom(ctx, run, workspace, a.Name, credentials, *a.Context, seats, say)
if err != nil {
return catalogue.Built{}, fmt.Errorf("%s: %s's context: %w", module, a.Name, err)
}
+26
View File
@@ -0,0 +1,26 @@
package builder
import (
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// A context on a seat is cloned from the base the mesh sent, joined to the repository's path; a
// context by URL is itself; a seat the mesh sent no base for is refused by name (novox/hq ADR 0155).
func TestAContextOnASeatIsClonedFromTheBaseTheMeshSent(t *testing.T) {
seats := map[string]string{"git": "http://forge.example.tld:3000"}
got, err := contextURL(catalogue.ArtifactContext{Seat: "git", Repository: "org/controller"}, seats)
if err != nil || got != "http://forge.example.tld:3000/org/controller.git" {
t.Fatalf("got %q, %v", got, err)
}
got, err = contextURL(catalogue.ArtifactContext{Repository: "https://elsewhere.example/x.git"}, seats)
if err != nil || got != "https://elsewhere.example/x.git" {
t.Fatalf("a URL context was changed: %q, %v", got, err)
}
_, err = contextURL(catalogue.ArtifactContext{Seat: "git", Repository: "org/controller"}, nil)
if err == nil || !strings.Contains(err.Error(), "git seat") {
t.Fatalf("a seat with no base was not refused by name: %v", err)
}
}
@@ -0,0 +1,18 @@
package catalogue
import "testing"
// A claim written before the rename still holds (novox/hq ADR 0122, ADR 0156): with the store's
// aliases loaded, the former name resolves to the seat.
func TestTheArtifactStoresFormerNameResolvesToIt(t *testing.T) {
was := aliases
t.Cleanup(func() { aliases = was })
UseAliases(map[string]string{"the-artifact-store": "mesh-artifact-store"})
seat, known := SeatNamed("the-artifact-store")
if !known || seat.Name != "mesh-artifact-store" || seat.Delivers != "artifact-store" {
t.Fatalf("the former name did not resolve: %+v %v", seat, known)
}
if _, known := SeatNamed("mesh-artifact-store"); !known {
t.Fatal("the seat is not in the set under its name")
}
}
@@ -23,7 +23,7 @@ func TestASecondArtifactStoreAnywhereIsRefusedByName(t *testing.T) {
}
// A second one, on any other machine, is refused — and the refusal names the seat.
elsewhere := World{Held: []Held{{Claim: "the-artifact-store", Scope: ScopeMesh,
elsewhere := World{Held: []Held{{Claim: "mesh-artifact-store", Scope: ScopeMesh,
Node: "anchor", Module: "distribution"}}}
other := workstation()
other.Name = "laptop"
@@ -32,7 +32,7 @@ func TestASecondArtifactStoreAnywhereIsRefusedByName(t *testing.T) {
t.Fatal("a second store was accepted on another machine; it would offer artifact-store a " +
"second time and every consumer elsewhere would refuse to choose")
}
if !strings.Contains(err.Error(), "the-artifact-store") || !strings.Contains(err.Error(), "one per mesh") {
if !strings.Contains(err.Error(), "mesh-artifact-store") || !strings.Contains(err.Error(), "one per mesh") {
t.Fatalf("refused without naming the seat: %v", err)
}
}
+1 -1
View File
@@ -59,7 +59,7 @@ func TestRenamingASeatDidNotRenameTheInterfaceItDelivers(t *testing.T) {
for _, pair := range []struct{ seat, delivers string }{
{"git", "git"},
{"npm-package-registry", "npm-package-registry"},
{"the-artifact-store", "artifact-store"},
{"mesh-artifact-store", "artifact-store"},
{"mesh-store", "postgres-database"},
{"mesh-broker", "mesh-bus"},
} {
@@ -3,6 +3,7 @@ package catalogue
import (
"os"
"path/filepath"
"strings"
"testing"
)
@@ -45,3 +46,33 @@ func TestEveryCatalogueManifestParses(t *testing.T) {
t.Fatal("no endpoint in the catalogue is named, so this proved nothing")
}
}
// TestNoCatalogueManifestNamesAnInstallation is ADR 0112's check, run over the real catalogue: no
// definition names a domain or a public address the mesh acts on, and every value that must for now
// carries its reason (novox/hq ADR 0155, issue 134). The list it prints is the one that shrinks.
func TestNoCatalogueManifestNamesAnInstallation(t *testing.T) {
root := os.Getenv("MESH_CATALOGUE")
if root == "" {
t.Skip("set MESH_CATALOGUE to a catalogue checkout to run this")
}
found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil || len(found) == 0 {
t.Fatalf("no manifests under %s: %v", root, err)
}
var named []string
for _, p := range found {
raw, err := os.ReadFile(p)
if err != nil {
t.Fatalf("%s: %v", p, err)
}
m, err := ParseManifest(raw)
if err != nil {
t.Errorf("%s: %v", p, err)
continue
}
named = append(named, InstallationProblems(m)...)
}
if len(named) > 0 {
t.Fatalf("%d value(s) name an installation:\n %s", len(named), strings.Join(named, "\n "))
}
}
+7
View File
@@ -696,6 +696,13 @@ func (r Resolution) compose(with Rendering, owner map[string]string) ([]map[stri
// Said in the catalogue, not on the machine: the host parses strictly and knows no
// such field, and the reason is for a reader of the manifest.
delete(copied, SecretsInEnvironment)
delete(copied, NamesOnPurpose)
// **An operator's value, from the assignment** (novox/hq ADR 0112, ADR 0155): what a
// definition may not carry because it is true of one installation only. Filled from
// the same layers a mergeable file takes, and refused when no layer set it.
if err := settingInto(copied, with.Settings[m.Module], m.Module); err != nil {
return nil, err
}
// **Placed before anything reads a path.** A pathless directory receives the path
// this node resolves for it, and every ${dir:…} — in paths, mounts, content and
// environment — becomes that path, so what follows sees only concrete places
+227
View File
@@ -0,0 +1,227 @@
package catalogue
import (
"encoding/json"
"fmt"
"net"
"regexp"
"sort"
"strings"
)
// A definition names no installation (novox/hq ADR 0112, ADR 0155, issues 122 and 134).
//
// A module definition holds what is true of the module everywhere; what is particular to one mesh —
// a public name, a forge's address, a node's public address — is resolved at assignment. The rule
// stood for a month with nothing checking it, and a sweep found thirty of seventy-one definitions
// naming the installation they were written in. This is the check.
//
// **What is judged is what the mesh acts on, not what a person reads.** A domain in a `why` or a
// `description` is documentation the mesh never reads; reporting it beside `KC_HOSTNAME` would teach
// people to ignore the report. What is judged is every other string value: a name under a public
// top-level domain, or a public address. Two families of name are the world's and not this mesh's,
// and are allowed where they can only mean the world: the public registries an `image` may be pulled
// from, and the public resolvers a machine may forward to. The container runtime's own alias for
// its host is the runtime's, true on every machine that runs it.
//
// **A name that is right where it stands is declared, one by one, with its reason.** A federated
// server's config names the federation's public directory; an application built outside the mesh
// is pulled from the registry that built it, until the mesh builds it. The resource carries
// `names-on-purpose`, a map from each such name to why — the shape `secrets-in-environment` has,
// per name — so a reader sees which names a definition means to carry and why, a name the map does
// not cover is still reported, and the catalogue-wide test is the list that shrinks as names move.
// NamesOnPurpose is the catalogue-level word a resource carries for the names it means to name:
// each name mapped to its reason. The host never sees it.
const NamesOnPurpose = "names-on-purpose"
// prose is every key whose value the mesh never reads.
var prose = map[string]bool{"why": true, "description": true}
// Registries the world runs, which an image may name because an image reference must say where it
// is pulled from. Anything else in an image reference is a registry of some installation.
var worldsRegistries = map[string]bool{
"docker.io": true, "registry-1.docker.io": true, "index.docker.io": true, "ghcr.io": true,
"quay.io": true, "gcr.io": true, "registry.k8s.io": true, "k8s.gcr.io": true,
"mcr.microsoft.com": true, "lscr.io": true, "public.ecr.aws": true, "registry.gitlab.com": true,
"codeberg.org": true, "cgr.dev": true,
}
// Services the world runs that a definition may name as a policy default, the way it may name a
// public resolver: the public certificate authorities' ACME directories. Anything else a served
// fact or a file names is somebody's installation.
var worldsServices = map[string]bool{
"acme-v02.api.letsencrypt.org": true, "acme-staging-v02.api.letsencrypt.org": true,
"api.buypass.com": true, "api.test4.buypass.no": true, "dv.acme-v02.api.pki.goog": true,
"acme.zerossl.com": true,
}
// Resolvers the world runs, which a machine's resolver may forward to as a policy default.
var worldsResolvers = map[string]bool{
"1.1.1.1": true, "1.0.0.1": true, "8.8.8.8": true, "8.8.4.4": true, "9.9.9.9": true,
"149.112.112.112": true, "208.67.222.222": true, "208.67.220.220": true,
}
// hostname is a dotted name whose last label is a top-level domain a real installation would have.
// Not every dotted token: `module.json`, `index.html` and `docker.sock` are dotted and name nothing.
// Boundaries are checked by hand rather than in the pattern, because two names one character apart
// — `a.example.tld,b.example.tld` — would otherwise share the delimiter and the second would be lost.
var hostname = regexp.MustCompile(
`(?i)(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+` +
`(?:be|nl|de|fr|uk|eu|com|net|org|io|dev|app|cloud|site|online|me|co|ch|at|lu|` +
`internal|example|tld|test|invalid)`)
// address is a dotted quad.
var address = regexp.MustCompile(`(?:[0-9]{1,3}\.){3}[0-9]{1,3}`)
// isName is whether a byte may be part of a name; a match bordered by one is a longer token.
func isName(b byte) bool {
return b == '.' || b == '-' || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
}
// standalone are the matches of re in value that are whole tokens, not parts of a longer one.
func standalone(re *regexp.Regexp, value string) []string {
var out []string
for _, span := range re.FindAllStringIndex(value, -1) {
if span[0] > 0 && isName(value[span[0]-1]) {
continue
}
if span[1] < len(value) && isName(value[span[1]]) {
continue
}
out = append(out, value[span[0]:span[1]])
}
return out
}
// InstallationProblems is every value of a definition that names an installation, in the
// definition's own words: where it is, and what it names.
func InstallationProblems(m Manifest) []string {
raw, err := json.Marshal(m)
if err != nil {
return []string{fmt.Sprintf("%s could not be read back: %v", m.Module, err)}
}
var tree any
if err := json.Unmarshal(raw, &tree); err != nil {
return []string{fmt.Sprintf("%s could not be read back: %v", m.Module, err)}
}
var problems []string
// The module's own name is a value too: a module named after the domain it serves is a
// definition that can only be installed there (issue 134).
for _, name := range namesIn(m.Module) {
problems = append(problems, fmt.Sprintf(
"%s is named after %s, and a module is named for what it is, not for where it runs", m.Module, name))
}
walk(tree, "", nil, func(at string, value string, meant map[string]bool, isImage bool) {
for _, name := range namesIn(value) {
if (isImage && worldsRegistries[strings.ToLower(name)]) || meant[name] {
continue
}
problems = append(problems, fmt.Sprintf("%s names %s at %s", m.Module, name, at))
}
for _, ip := range addressesIn(value) {
if meant[ip] {
continue
}
problems = append(problems, fmt.Sprintf("%s names the public address %s at %s", m.Module, ip, at))
}
})
sort.Strings(problems)
return problems
}
// walk visits every string in the tree with its path, the names the enclosing resource means to
// name (with a reason), and whether it is an image reference.
func walk(node any, at string, meant map[string]bool, visit func(at, value string, meant map[string]bool, isImage bool)) {
switch v := node.(type) {
case map[string]any:
if declared, has := v[NamesOnPurpose].(map[string]any); has {
widened := map[string]bool{}
for name := range meant {
widened[name] = true
}
for name, reason := range declared {
if r, ok := reason.(string); ok && strings.TrimSpace(r) != "" {
widened[strings.ToLower(name)] = true
}
}
meant = widened
}
keys := make([]string, 0, len(v))
for k := range v {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if prose[k] || k == NamesOnPurpose || (at == "" && k == "module") {
continue
}
child := at + "." + k
if at == "" {
child = k
}
if s, isString := v[k].(string); isString {
visit(child, s, meant, k == "image")
continue
}
walk(v[k], child, meant, visit)
}
case []any:
for i, item := range v {
child := fmt.Sprintf("%s[%d]", at, i)
if s, isString := item.(string); isString {
visit(child, s, meant, false)
continue
}
walk(item, child, meant, visit)
}
}
}
// namesIn is every hostname in a value that could belong to an installation.
func namesIn(value string) []string {
var out []string
for _, found := range standalone(hostname, value) {
name := strings.ToLower(found)
switch {
case strings.HasSuffix(name, ".docker.internal"):
// The container runtime's alias for its own host: every machine running it has one.
case worldsServices[name]:
// A public authority named as a policy default, true of any mesh that wants it.
case name == "example.tld", strings.HasSuffix(name, ".example.tld"),
name == "example.com", name == "example.net", name == "example.org",
strings.HasSuffix(name, ".example.com"), strings.HasSuffix(name, ".example.net"),
strings.HasSuffix(name, ".example.org"), strings.HasSuffix(name, ".example"),
strings.HasSuffix(name, ".test"), strings.HasSuffix(name, ".invalid"):
// Documentation names, which is what a definition's own example should use.
default:
out = append(out, name)
}
}
return out
}
// addressesIn is every public address in a value: not a private range, loopback, link-local, the
// unspecified address, a documentation range, or a resolver the world runs.
func addressesIn(value string) []string {
var out []string
for _, found := range standalone(address, value) {
ip := net.ParseIP(found)
if ip == nil || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() ||
ip.IsUnspecified() || ip.IsMulticast() || worldsResolvers[found] || documentation(ip) {
continue
}
out = append(out, found)
}
return out
}
func documentation(ip net.IP) bool {
for _, cidr := range []string{"192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24", "100.64.0.0/10"} {
_, block, _ := net.ParseCIDR(cidr)
if block.Contains(ip) {
return true
}
}
return false
}
+93
View File
@@ -0,0 +1,93 @@
package catalogue
import (
"strings"
"testing"
)
// A definition names no installation (novox/hq ADR 0112, ADR 0155). What the mesh acts on is judged;
// prose is not; the world's registries and resolvers are the world's; a declared exception is a
// reason a reader sees.
func TestADefinitionNamingAnInstallationIsNamedBack(t *testing.T) {
m := Manifest{Module: "idp", Resources: []map[string]any{
{"id": "server", "type": "container", "image": "quay.io/keycloak/keycloak@sha256:aa",
"env": map[string]any{"KC_HOSTNAME": "https://login.mesh-one.be"}},
{"id": "env", "type": "file", "content": "REAL_IP_FROM=192.168.1.0/24,127.0.0.0/8,203.0.113.7,51.15.22.9\n"},
}, Listens: []Listening{{Port: 8080, From: FromMesh, Why: "the login page; login.mesh-one.be is a route grant"}}}
got := strings.Join(InstallationProblems(m), "\n")
for _, want := range []string{
"idp names login.mesh-one.be at resources[0].env.KC_HOSTNAME",
"idp names the public address 51.15.22.9 at resources[1].content",
} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in:\n%s", want, got)
}
}
for _, mustNot := range []string{"quay.io", "why", "203.0.113.7", "192.168.1.0", "127.0.0.0"} {
if strings.Contains(got, mustNot) {
t.Errorf("%q was reported and should not be:\n%s", mustNot, got)
}
}
}
func TestTheWorldsNamesAreNotAnInstallations(t *testing.T) {
m := Manifest{Module: "resolver", Resources: []map[string]any{
{"id": "conf", "type": "file", "content": "server=1.1.1.1\nserver=8.8.8.8\nlisten=127.0.0.55\n"},
{"id": "proxy", "type": "container", "image": "docker.io/library/traefik@sha256:bb"},
{"id": "adapter", "type": "file", "content": "{\"machine\": \"host.docker.internal\"}\n"},
{"id": "doc", "type": "file", "content": "root = https://git.example.tld/\n"},
}}
if got := InstallationProblems(m); len(got) != 0 {
t.Fatalf("the world's names were reported: %v", got)
}
}
func TestANameMeantOnPurposeIsDeclaredWithItsReason(t *testing.T) {
// The federation's public directory in a homeserver's config: the world's, said so, and a name
// the map does not cover is still reported.
m := Manifest{Module: "homeserver", Resources: []map[string]any{
{"id": "conf", "type": "file", "content": "trusted_key_servers: matrix.org\nwell_known: https://mesh-one.be\n",
NamesOnPurpose: map[string]any{"matrix.org": "the federation's public key server, the world's"}},
}}
got := InstallationProblems(m)
if len(got) != 1 || !strings.Contains(got[0], "mesh-one.be") {
t.Fatalf("got %v", got)
}
}
func TestAnImageFromAnInstallationsRegistryNeedsAReason(t *testing.T) {
bare := Manifest{Module: "site", Resources: []map[string]any{
{"id": "server", "type": "container", "image": "registry.mesh-one.be/org/site@sha256:cc"},
}}
if got := InstallationProblems(bare); len(got) != 1 || !strings.Contains(got[0], "registry.mesh-one.be") {
t.Fatalf("an image on an installation's registry was not named: %v", got)
}
excepted := Manifest{Module: "site", Resources: []map[string]any{
{"id": "server", "type": "container", "image": "registry.mesh-one.be/org/site@sha256:cc",
NamesOnPurpose: map[string]any{"registry.mesh-one.be": "built outside the mesh until the site's repository is a build source here"}},
}}
if got := InstallationProblems(excepted); len(got) != 0 {
t.Fatalf("a declared exception was still reported: %v", got)
}
}
func TestAModuleNamedAfterADomainIsNamedBack(t *testing.T) {
got := InstallationProblems(Manifest{Module: "mesh-one.be"})
if len(got) != 1 || !strings.Contains(got[0], "named after mesh-one.be") {
t.Fatalf("got %v", got)
}
}
func TestABuildContextOnASeatNamesNoForge(t *testing.T) {
m := Manifest{Module: "packager", Build: &Build{Artifacts: []Artifact{
{Name: "server", Kind: "image", From: "Dockerfile",
Context: &ArtifactContext{Seat: "git", Repository: "org/controller", Ref: "main"}},
}}}
if got := InstallationProblems(m); len(got) != 0 {
t.Fatalf("a context on a seat was reported: %v", got)
}
m.Build.Artifacts[0].Context = &ArtifactContext{Repository: "https://git.mesh-one.be/org/controller.git"}
if got := InstallationProblems(m); len(got) != 1 {
t.Fatalf("a context by URL was not reported: %v", got)
}
}
+10 -3
View File
@@ -252,8 +252,8 @@ 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).
// Invokes are the tools this module calls, each `<module>.<tool>` or a role's `seat:<seat>.<verb>`,
// or the single entry `*` for every tool on the mesh (novox/hq ADR 0152, ADR 0154).
//
// **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
@@ -549,8 +549,14 @@ type BuildsOn struct {
type ArtifactContext struct {
// Repository is cloned fresh, the same way the module's own repository is — a working tree
// nothing has touched, so what was built is reproducible from the two commits named rather
// than from whatever a previous build happened to leave behind.
// than from whatever a previous build happened to leave behind. A URL, or — with Seat — a
// path on that seat's holder, `<owner>/<name>`.
Repository string `json:"repository"`
// Seat is the seat the repository lives on: `git` for this mesh's own forge (novox/hq ADR 0111,
// ADR 0155). A context written as a URL names one installation's forge and can be built
// nowhere else; a path on the seat is composed by the mesh that builds it, whichever forge
// holds the seat there.
Seat string `json:"seat,omitempty"`
// Ref is the branch, tag or commit of that repository to build. Empty means its own default
// branch — the same meaning an empty module ref already has.
Ref string `json:"ref,omitempty"`
@@ -1795,6 +1801,7 @@ func invokeProblems(m Manifest) []string {
if t == "*" {
continue
}
t = strings.TrimPrefix(t, "seat:")
module, tool, named := strings.Cut(t, ".")
if !named || !name.MatchString(module) || !toolName.MatchString(tool) {
problems = append(problems, fmt.Sprintf(
+121
View File
@@ -0,0 +1,121 @@
package catalogue
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// TestPlacedDirectoriesKeepTheirPaths is the check novox/hq issue 119 asks for before a definition
// stops naming where its data lives: a converted manifest, resolved on a node with the default root,
// names exactly the paths the manifest before it named. Data that a service is using must not move
// because a definition stopped saying where it was.
//
// Two checkouts: MESH_CATALOGUE_BEFORE, the catalogue as it was, and MESH_CATALOGUE, as it is now.
// Every module in both is resolved with the controller's own rule (dirsFor, dirFill) and compared
// whole — not only the directories, but every string a directory's id was written into.
func TestPlacedDirectoriesKeepTheirPaths(t *testing.T) {
before, after := os.Getenv("MESH_CATALOGUE_BEFORE"), os.Getenv("MESH_CATALOGUE")
if before == "" || after == "" {
t.Skip("set MESH_CATALOGUE_BEFORE and MESH_CATALOGUE to two catalogue checkouts to run this")
}
found, _ := filepath.Glob(filepath.Join(after, "modules", "*", "module.json"))
compared := 0
for _, path := range found {
module := filepath.Base(filepath.Dir(path))
old, err := os.ReadFile(filepath.Join(before, "modules", module, "module.json"))
if err != nil {
continue // new since; nothing to keep
}
now, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if string(old) == string(now) {
continue
}
m, err := ParseManifest(now)
if err != nil {
t.Errorf("%s: %v", module, err)
continue
}
dirs := dirsFor(m, Rendering{})
var was, is any
if err := json.Unmarshal(old, &was); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(now, &is); err != nil {
t.Fatal(err)
}
resolved := resolvedTree(is, dirs, module, t)
if !reflect.DeepEqual(was, resolved) {
wasJSON, _ := json.MarshalIndent(was, "", " ")
isJSON, _ := json.MarshalIndent(resolved, "", " ")
t.Errorf("%s: resolved on the default root, the converted manifest is not the one before it\n--- before\n%s\n--- resolved now\n%s",
module, firstDifference(string(wasJSON), string(isJSON)), "")
}
compared++
}
t.Logf("%d converted manifest(s) resolve to the paths they named before", compared)
}
// resolvedTree is the manifest as a machine would see it: every ${dir:…} filled, a pathless
// directory given the path it resolves to, and the placement word removed.
func resolvedTree(node any, dirs map[string]string, module string, t *testing.T) any {
switch v := node.(type) {
case map[string]any:
out := map[string]any{}
for k, child := range v {
if k == "place" {
continue
}
out[k] = resolvedTree(child, dirs, module, t)
}
if out["type"] == "directory" {
if _, has := out["path"]; !has {
if id, ok := out["id"].(string); ok {
out["path"] = dirs[id]
}
}
}
return out
case []any:
out := make([]any, len(v))
for i, child := range v {
out[i] = resolvedTree(child, dirs, module, t)
}
return out
case string:
filled, err := dirFill(v, dirs, module)
if err != nil {
t.Error(err)
}
return filled
}
return node
}
func firstDifference(a, b string) string {
al, bl := strings.Split(a, "\n"), strings.Split(b, "\n")
for i := range al {
if i >= len(bl) || al[i] != bl[i] {
from := i - 2
if from < 0 {
from = 0
}
to := i + 3
if to > len(al) {
to = len(al)
}
bt := i + 3
if bt > len(bl) {
bt = len(bl)
}
return "before:\n" + strings.Join(al[from:to], "\n") + "\nnow:\n" + strings.Join(bl[from:bt], "\n")
}
}
return "(the difference is beyond the shorter document)"
}
+25 -6
View File
@@ -37,7 +37,9 @@ type Seat struct {
// today — which the bus refuses, because a namespace belongs to who it is named for.
Accepts []string
Emits []string
Serves []string
// Serves carries each verb in full — name, description, schema — because a role's tools are the
// mesh's to define and an agent's to call (novox/hq ADR 0132, design 33 §2).
Serves []Verb
// Decision is the record that made it a seat.
Decision string
}
@@ -51,8 +53,12 @@ var defaultSeats = []Seat{
// The control plane states what it did under the seat it holds (novox/hq ADR 0134): a role's
// events belong to the role, so they keep their address while the holder is replaced. No accepts,
// so no work queue is raised for it — only what its holder may say.
{Name: "mesh-controller", Scope: ScopeMesh, Decision: "novox/hq ADR 0079",
Emits: []string{"applied", "refused", "built-before"}},
// And it serves the mesh's own verbs as the seat's tools (novox/hq ADR 0154): `status`, `push`,
// `assign` and the rest are a role's interface, not a container's, and stay addressable while
// the control plane is replaced.
{Name: ControllerSeatName, Scope: ScopeMesh, Decision: "novox/hq ADR 0079",
Emits: []string{"applied", "refused", "built-before"},
Serves: ControllerVerbs},
{Name: "mesh-store", Scope: ScopeMesh, Delivers: "postgres-database", Decision: "novox/hq ADR 0079"},
// **Delivers the mesh's own bus, not `amqp`.** Those were the same word until
// ADR 0127 separated them: `amqp` is a backing service a module may require, and this seat is
@@ -60,7 +66,11 @@ var defaultSeats = []Seat{
// rather than receives ambiently — 23 of the catalogue's modules never speak, and an ambient
// connection would mint a credential for each.
{Name: "mesh-broker", Scope: ScopeMesh, Delivers: "mesh-bus", Decision: "novox/hq ADR 0079"},
{Name: "the-artifact-store", Scope: ScopeMesh, Delivers: "artifact-store", Decision: "novox/hq ADR 0075"},
// Named for its scope since 2026-09-30 (novox/hq ADR 0156); `the-artifact-store` resolves to it as
// an alias on a mesh that predates the rename. It serves artifacts of every kind a build makes —
// images and archives, by digest — which is why the provision is the artifact store and not an
// image registry.
{Name: "mesh-artifact-store", Scope: ScopeMesh, Delivers: "artifact-store", Decision: "novox/hq ADR 0075"},
{Name: "mesh-catalog", Scope: ScopeMesh, Decision: "novox/hq ADR 0121"},
// Deferred renames (novox/hq ADR 0121): these deliver a provision, so renaming them is a
// delivering-seat migration with a mesh-wide cascade if a holder stops resolving mid-flight.
@@ -91,8 +101,9 @@ var defaultSeats = []Seat{
// A system seat name is the control plane's namespace: `mesh-*` for a mesh-wide role, `node-*` for
// a per-node one (novox/hq ADR 0121). A claim to a system name the mesh does not define is refused;
// any other name is a module's own to define and claim. Some of the mesh's own seats predate this
// convention and are not yet renamed (git, npm-package-registry, the-artifact-store,
// the-private-network) — those are in the set, so they resolve by name, not by prefix.
// convention and are not yet renamed (git, npm-package-registry, the-private-network) — those are
// in the set, so they resolve by name, not by prefix. the-artifact-store was renamed on 2026-09-30
// (novox/hq ADR 0156) and resolves through the alias table on a mesh that knew it.
func isSystemSeatName(name string) bool {
return strings.HasPrefix(name, "mesh-") || strings.HasPrefix(name, "node-")
}
@@ -269,6 +280,14 @@ func CanHold(m Manifest, seat Seat) error {
return fmt.Errorf("%s claims %s, whose holder answers for %q, and %s does not provide %q at %s scope",
m.Module, seat.Name, seat.Delivers, m.Module, seat.Delivers, seat.Scope)
}
// **Serving the seat's tools is a condition of holding it** (novox/hq ADR 0132). A holder that
// does not answer what the role promises is every caller's timeout, found at registration and
// at handover instead, naming the verbs rather than the fact that something is missing.
if missing := unservedVerbs(m.Tools, seat.Serves); len(missing) > 0 {
return fmt.Errorf("%s claims %s but does not serve %s, which that seat's protocol promises "+
"(novox/hq ADR 0132) — a holder lists every verb its seat declares under tools",
m.Module, seat.Name, strings.Join(missing, ", "))
}
return nil
}
+6 -5
View File
@@ -38,8 +38,9 @@ type SeatDeclaration struct {
Accepts []string `json:"accepts,omitempty"`
// Emits are the verbs the holder publishes: 1:many, nobody obliged to act.
Emits []string `json:"emits,omitempty"`
// Serves are the verbs the holder answers: request and reply, awaited.
Serves []string `json:"serves,omitempty"`
// Serves are the verbs the holder answers: request and reply, awaited. A bare name, or the
// verb in full with its schema (novox/hq ADR 0132).
Serves []Verb `json:"serves,omitempty"`
// RetainSeconds is how long the inbound backlog survives with no holder, zero for the
// mesh's default. Retention belongs to whoever owns the namespace (design 29 §3) — a seat
@@ -62,7 +63,7 @@ func (s SeatDeclaration) At() string {
func (s SeatDeclaration) verbs() []string {
out := append([]string{}, s.Accepts...)
out = append(out, s.Emits...)
return append(out, s.Serves...)
return append(out, VerbNames(s.Serves)...)
}
// declaredSeatProblems is what one manifest can be judged on alone.
@@ -228,8 +229,8 @@ func unserved(m Manifest, s SeatDeclaration) []string {
}
var missing []string
for _, t := range s.Serves {
if !has[t] {
missing = append(missing, t)
if !has[t.Name] {
missing = append(missing, t.Name)
}
}
return missing
+1 -1
View File
@@ -13,7 +13,7 @@ func problemsFor(t *testing.T, shelf Shelf) string {
func telegram() Manifest {
return Manifest{Module: "telegram", Tools: []string{"status"}, DefinesSeats: []SeatDeclaration{{
Name: "telegram-sender", Scope: ScopeMesh,
Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}, Serves: []string{"status"},
Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}, Serves: []Verb{{Name: "status"}},
}}, Claims: []Claim{{Name: "telegram-sender", Scope: ScopeMesh}}}
}
+109
View File
@@ -0,0 +1,109 @@
package catalogue
import (
"fmt"
"regexp"
"sort"
"strings"
)
// An operator's value, where a definition needs one (novox/hq ADR 0112, ADR 0155, design 27).
//
// A mail server's domain, a site's name, the address a proxy forwards from: values that are true of
// one installation and of no other, and that a module's software must be told. They had nowhere to
// live but the definition, which is how a catalogue meant for any mesh came to name this one
// (novox/hq issues 122, 134). ADR 0112 names the operator as one of the four providers; this is the
// operator answering.
//
// `${setting:<key>}` in a file's content is filled from the module's settings layers — the mesh's,
// then this node's — the same layers a mergeable JSON file and a contribution already take, so
// `settings set <module>` is the one place a person's values go. **Refused when no layer sets it**,
// naming the key and the remedy: a definition that carried a default for a mail domain would be
// carrying the very literal this removes, and a blank written silently would be a service that
// comes up wrong somewhere that names neither the module nor the key.
// settingRef is how a definition asks for an operator's value: ${setting:<key>}.
var settingRef = regexp.MustCompile(`\$\{setting:([a-z0-9][a-z0-9_.-]*)\}`)
// settingsUsed is every key a file's content asks for, once each, in order of first use.
func settingsUsed(content string) []string {
var keys []string
seen := map[string]bool{}
for _, m := range settingRef.FindAllStringSubmatch(content, -1) {
if !seen[m[1]] {
seen[m[1]] = true
keys = append(keys, m[1])
}
}
return keys
}
// settingInto fills a file's ${setting:…} placeholders from the layers over a module.
//
// The last layer setting a key wins, which is the node's over the mesh's — the same order settle
// applies to a mergeable file. A value that is not a string is written the way a program would read
// it (a number without a trailing .000000, a boolean as true/false).
func settingInto(resource map[string]any, layers []Layer, module string) error {
if fmt.Sprint(resource["type"]) != "file" {
return nil
}
content, ok := resource["content"].(string)
if !ok {
return nil
}
for _, key := range settingsUsed(content) {
value, set := settingValue(layers, key)
if !set {
return fmt.Errorf(
"%s has a file that says ${setting:%s}, and nothing sets %q for it — an operator's "+
"value is the assignment's, never the definition's (novox/hq ADR 0112): "+
"`settings set %s <file>` with {%q: …}%s",
module, key, key, module, key, orNoSettings(layers))
}
content = strings.ReplaceAll(content, "${setting:"+key+"}", plainly(value))
}
resource["content"] = content
return nil
}
func settingValue(layers []Layer, key string) (any, bool) {
var value any
set := false
for _, layer := range layers {
if v, has := layer.Values[key]; has {
value, set = v, true
}
}
return value, set
}
func orNoSettings(layers []Layer) string {
var keys []string
for _, l := range layers {
for k := range l.Values {
keys = append(keys, k)
}
}
if len(keys) == 0 {
return "; no setting is set for this module"
}
sort.Strings(keys)
return "; set today: " + strings.Join(keys, ", ")
}
// settingKeysUsedBy is every key a module's files ask for, so a setting that lands in one is not
// called stray.
func settingKeysUsedBy(m Manifest) map[string]bool {
used := map[string]bool{}
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "file" {
continue
}
if content, ok := r["content"].(string); ok {
for _, k := range settingsUsed(content) {
used[k] = true
}
}
}
return used
}
+55
View File
@@ -0,0 +1,55 @@
package catalogue
import (
"strings"
"testing"
)
// An operator's value reaches a file from the assignment's settings, the node's layer over the
// mesh's (novox/hq ADR 0112, ADR 0155), and a value nothing set is refused by name.
func TestASettingReachesAFileFromTheLayers(t *testing.T) {
file := map[string]any{"id": "env", "type": "file", "path": "/x/mail.env",
"content": "DOMAIN=${setting:domain}\nSITENAME=${setting:sitename}\nWORKERS=${setting:workers}\n"}
layers := []Layer{
{From: "the mesh", Values: map[string]any{"domain": "example.tld", "sitename": "Mesh", "workers": float64(4)}},
{From: "this node", Values: map[string]any{"sitename": "This one"}},
}
if err := settingInto(file, layers, "mail"); err != nil {
t.Fatal(err)
}
if file["content"] != "DOMAIN=example.tld\nSITENAME=This one\nWORKERS=4\n" {
t.Fatalf("filled as %q", file["content"])
}
}
func TestASettingNothingSetIsRefusedByName(t *testing.T) {
file := map[string]any{"id": "env", "type": "file", "content": "DOMAIN=${setting:domain}\n"}
err := settingInto(file, []Layer{{From: "the mesh", Values: map[string]any{"other": "x"}}}, "mail")
if err == nil {
t.Fatal("a setting nothing set was written as something")
}
for _, want := range []string{"${setting:domain}", "settings set mail", "set today: other"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("the refusal lacks %q: %v", want, err)
}
}
// Left as it was: the literal placeholder must never reach a machine.
if file["content"] != "DOMAIN=${setting:domain}\n" {
t.Fatalf("content was changed on refusal: %q", file["content"])
}
}
// A key a file asks for is a destination, so setting it is not called stray.
func TestASettingAFileAsksForIsNotStray(t *testing.T) {
m := Manifest{Module: "mail", Resources: []map[string]any{
{"id": "env", "type": "file", "content": "DOMAIN=${setting:domain}\n"},
}}
layers := []Layer{{From: "the mesh", Values: map[string]any{"domain": "example.tld", "stray": "x"}}}
unused := strings.Join(UnusedSettings(m, layers), "; ")
if strings.Contains(unused, `"domain"`) {
t.Fatalf("a key a file asks for was called stray: %s", unused)
}
if !strings.Contains(unused, `"stray"`) {
t.Fatalf("a key nothing reads was not named: %s", unused)
}
}
+5
View File
@@ -173,9 +173,14 @@ func UnusedSettings(m Manifest, layers []Layer) []string {
return nil
}
// A key a file's content asks for with ${setting:<key>} is a destination too (ADR 0155).
asked := settingKeysUsedBy(m)
var unused []string
for _, layer := range layers {
for key := range layer.Values {
if asked[key] {
continue
}
// `expose` is a real destination for a module that listens: it overrides a port's
// source (novox/hq ADR 0046), validated in Exposure, so it is not stray here.
if key == ExposeSetting && len(m.Listens) > 0 {
+133
View File
@@ -0,0 +1,133 @@
package catalogue
import (
"bytes"
"encoding/json"
"fmt"
)
// A Verb is one tool a role serves: its name, what it does, and the schema of its arguments and of
// its answer (novox/hq ADR 0132, design 33 §2).
//
// **A name alone is not callable by something that has never seen the mesh before**, which is the
// whole population a tool surface exists for. So a seat's protocol carries the definition, in the
// form an agent protocol already uses — a JSON schema for the input — so nothing translates between
// a seat's idea of an argument and the caller's.
//
// A manifest may still write a bare verb name (`"serves": ["price"]`); that is a Verb with only a
// name, and the module's runtime answers `tools` with the rest. The two forms read into one type so
// nothing downstream cares which was written.
type Verb struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Input map[string]any `json:"input,omitempty"`
Output map[string]any `json:"output,omitempty"`
}
func (v *Verb) UnmarshalJSON(raw []byte) error {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) > 0 && trimmed[0] == '"' {
var name string
if err := json.Unmarshal(trimmed, &name); err != nil {
return err
}
*v = Verb{Name: name}
return nil
}
// Strictly, like the manifest around it: a misspelt key in a tool's definition would otherwise
// describe a tool nobody can call and refuse nothing.
type plain Verb
var p plain
decoder := json.NewDecoder(bytes.NewReader(trimmed))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&p); err != nil {
return fmt.Errorf("a served verb is a name or {name, description, input, output}: %w", err)
}
if p.Name == "" {
return fmt.Errorf("a served verb has no name: %s", trimmed)
}
*v = Verb(p)
return nil
}
// VerbNames are the names alone, for the grants and the checks that care about nothing else.
func VerbNames(verbs []Verb) []string {
out := make([]string, 0, len(verbs))
for _, v := range verbs {
out = append(out, v.Name)
}
return out
}
// ControllerSeatName is the seat the control plane holds, whose tools are the mesh's own verbs.
const ControllerSeatName = "mesh-controller"
// ControllerVerbs are the mesh's own verbs, as the `mesh-controller` seat's tools (novox/hq ADR 0154).
//
// **The same function the command line calls, and nothing the tool adds** (ADR 0035): each of these
// is a command the controller's binary already answers, run by the holder of the seat with the
// arguments below and answered with what the command printed. A verb here is a contract every future
// holder must implement, which is why the list is short and made of what an operator asks weekly.
// Additive within a version (design 33 §7); a verb that would break a caller takes a new version.
var ControllerVerbs = []Verb{
{Name: "tools", Description: "Every seat's tools, from the mesh's own records: what each role " +
"answers, whether or not its holder is up. The mesh's own verbs are the mesh-controller seat's.",
Input: schema(nil, nil)},
{Name: "status", Description: "What is wrong, what is quiet, what is out of date, and which " +
"machines are behind what the mesh would send them.",
Input: schema(nil, nil)},
{Name: "nodes", Description: "Every machine the mesh knows, with whether it is converged or adopted.",
Input: schema(nil, nil)},
{Name: "node", Description: "What one machine reported it can do, what it is assigned, and why.",
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})},
{Name: "modules", Description: "Every module the mesh holds: version, the commit it was built from, " +
"and which machines run it.",
Input: schema(nil, nil)},
{Name: "seats", Description: "Every seat the mesh defines, what it delivers, and who holds it.",
Input: schema(nil, nil)},
{Name: "builds", Description: "What has been built lately and what came of it, for every module or for one.",
Input: schema(map[string]string{"module": "one module's name; every module when absent"}, nil)},
{Name: "plan", Description: "What one machine would run, and why: the declaration the mesh would send it.",
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})},
{Name: "assign", Description: "Put a module on a machine. Refused with the mesh's own words when it cannot resolve there.",
Input: schema(map[string]string{"node": "the machine's name", "module": "the module's name"}, []string{"node", "module"})},
{Name: "unassign", Description: "Take a module off a machine.",
Input: schema(map[string]string{"node": "the machine's name", "module": "the module's name"}, []string{"node", "module"})},
{Name: "push", Description: "Send a machine everything it should be — or every machine that is behind, when no machine is named.",
Input: schema(map[string]string{"node": "the machine's name; every machine behind when absent"}, nil)},
{Name: "build", Description: "Have the build machine build a repository and record what came out.",
Input: schema(map[string]string{
"repository": "the repository's URL, or its path on the forge holding the git seat",
"path": "the module's directory inside it (optional)",
"ref": "the branch, tag or commit to build (optional)",
}, []string{"repository"})},
}
// schema is a JSON schema for an object of string properties, which is every argument the verbs
// above take. Kept small on purpose: a schema an agent cannot read is a tool it cannot call.
func schema(properties map[string]string, required []string) map[string]any {
props := map[string]any{}
for name, description := range properties {
props[name] = map[string]any{"type": "string", "description": description}
}
out := map[string]any{"type": "object", "properties": props}
if len(required) > 0 {
out["required"] = required
}
return out
}
// unservedVerbs is what a seat promises and a claimant's `tools` does not answer.
func unservedVerbs(tools []string, promised []Verb) []string {
has := map[string]bool{}
for _, t := range tools {
has[t] = true
}
var missing []string
for _, v := range promised {
if !has[v.Name] {
missing = append(missing, v.Name)
}
}
return missing
}
+72
View File
@@ -0,0 +1,72 @@
package catalogue
import (
"strings"
"testing"
)
// A served verb is written as a bare name or in full, and both read into one type (novox/hq ADR 0132).
func TestAServedVerbIsANameOrADefinition(t *testing.T) {
m, err := ParseManifest([]byte(`{"module":"till","version":"1","tools":["price","refund"],` +
`"seats":[{"name":"shop-till","serves":["price",{"name":"refund","description":"give it back",` +
`"input":{"type":"object","properties":{"order":{"type":"string"}}}}]}],` +
`"claims":[{"name":"shop-till","scope":"mesh"}]}`))
if err != nil {
t.Fatal(err)
}
got := m.DefinesSeats[0].Serves
if len(got) != 2 || got[0].Name != "price" || got[1].Name != "refund" || got[1].Description != "give it back" {
t.Fatalf("verbs not read: %+v", got)
}
if got[1].Input["type"] != "object" {
t.Fatalf("the schema did not travel with the verb: %+v", got[1].Input)
}
}
// A misspelt key inside a verb's definition is refused, like one anywhere else in the manifest.
func TestAVerbWithAnUnknownKeyIsRefused(t *testing.T) {
_, err := ParseManifest([]byte(`{"module":"till","version":"1",` +
`"seats":[{"name":"shop-till","serves":[{"name":"price","descripton":"typo"}]}]}`))
if err == nil || !strings.Contains(err.Error(), "descripton") {
t.Fatalf("a verb with a misspelt key was accepted: %v", err)
}
}
// Holding a mesh seat that serves verbs requires serving them, and the refusal names the verbs.
func TestHoldingAMeshSeatRequiresServingItsVerbs(t *testing.T) {
was := Seats()
t.Cleanup(func() { UseSeats(was) })
UseSeats([]Seat{{Name: "mesh-controller", Scope: ScopeMesh, Decision: "test",
Serves: []Verb{{Name: "status"}, {Name: "push"}}}})
seat, _ := SeatNamed("mesh-controller")
partial := Manifest{Module: "a-controller", Tools: []string{"status"},
Claims: []Claim{{Name: "mesh-controller", Scope: ScopeMesh}}}
err := CanHold(partial, seat)
if err == nil || !strings.Contains(err.Error(), "does not serve push") {
t.Fatalf("a holder missing a verb was not refused by name: %v", err)
}
whole := Manifest{Module: "a-controller", Tools: []string{"status", "push"},
Claims: []Claim{{Name: "mesh-controller", Scope: ScopeMesh}}}
if err := CanHold(whole, seat); err != nil {
t.Fatalf("a holder serving every verb was refused: %v", err)
}
}
// The mesh's own verbs are declared in full: an agent cannot call a name without a schema.
func TestEveryControllerVerbIsDescribedWithASchema(t *testing.T) {
seen := map[string]bool{}
for _, v := range ControllerVerbs {
if v.Description == "" || v.Input == nil || v.Input["type"] != "object" {
t.Errorf("%s: no description or no object schema", v.Name)
}
if seen[v.Name] {
t.Errorf("%s declared twice", v.Name)
}
seen[v.Name] = true
}
seat, _ := SeatNamed(ControllerSeatName)
if len(seat.Serves) != len(ControllerVerbs) {
t.Fatalf("the compiled mesh-controller seat serves %d verbs, the table has %d", len(seat.Serves), len(ControllerVerbs))
}
}
+3 -2
View File
@@ -137,7 +137,8 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio
}
func asSeat(s catalogue.SeatDeclaration) broker.Seat {
return broker.Seat{Name: s.Name, Accepts: s.Accepts, Emits: s.Emits, Serves: s.Serves}
return broker.Seat{Name: s.Name, Scope: s.Scope, Accepts: s.Accepts, Emits: s.Emits,
Serves: catalogue.VerbNames(s.Serves)}
}
// MeshSeats are the mesh's own seats that carry a protocol, as the bus needs them: what to make a work
@@ -146,7 +147,7 @@ func MeshSeats() []broker.DeclaredSeat {
var out []broker.DeclaredSeat
for _, s := range catalogue.SeatsWithAProtocol() {
out = append(out, broker.DeclaredSeat{
Name: s.Name, Accepts: s.Accepts, Emits: s.Emits, Serves: s.Serves,
Name: s.Name, Accepts: s.Accepts, Emits: s.Emits, Serves: catalogue.VerbNames(s.Serves),
})
}
return out
@@ -0,0 +1,12 @@
-- A seat's protocol lives in the store, not in the binary (novox/hq ADR 0129, ADR 0132, design 33 §2).
--
-- ADR 0122 moved the seat set into this table with name, scope, delivers and decision, and the
-- protocol — what a role accepts, emits and serves — stayed compiled into the control plane and was
-- merged in as a row was read. Discovery that reads a binary disagrees with the mesh the moment the
-- two are on different versions, and a tool without a schema is not something an agent can call. So
-- the three halves become columns: accepts and emits as lists of verbs, serves as the verbs in full
-- ({name, description, input, output}). Seeded from the compiled defaults where a row has none,
-- additively thereafter (a verb a release adds joins the row; nothing is taken away).
alter table seat add column accepts jsonb not null default '[]'::jsonb;
alter table seat add column emits jsonb not null default '[]'::jsonb;
alter table seat add column serves jsonb not null default '[]'::jsonb;
@@ -0,0 +1,12 @@
-- The artifact store's seat is named for its scope, like the mesh's other seats (novox/hq ADR 0121,
-- ADR 0156, issue 123).
--
-- `the-artifact-store` was the last of the mesh's own seats named for the job it happened to do rather
-- than for the mesh; ADR 0121 decided the rename and deferred it because a delivering seat that stops
-- resolving mid-flight takes a provision away from every consumer. ADR 0122 removed that risk: a seat's
-- former name is an alias that resolves to it forever, a held record follows the rename by cascade, and
-- a claim written with the old name still holds. So the rename is one update and one alias.
update seat set name = 'mesh-artifact-store' where name = 'the-artifact-store';
insert into seat_alias (alias, seat) values ('the-artifact-store', 'mesh-artifact-store')
on conflict (alias) do update set seat = excluded.seat;
update seat_alias set seat = 'mesh-artifact-store' where seat = 'the-artifact-store';
+115 -7
View File
@@ -2,6 +2,7 @@ package inventory
import (
"context"
"encoding/json"
"fmt"
"github.com/novox/mesh-controller/internal/catalogue"
@@ -17,7 +18,7 @@ import (
// Seats is every seat the mesh defines, read from the store.
func (i *Inventory) Seats(ctx context.Context) ([]catalogue.Seat, error) {
rows, err := i.store.Pool().Query(ctx,
`select name, scope, delivers, decided from seat order by name`)
`select name, scope, delivers, decided, accepts, emits, serves from seat order by name`)
if err != nil {
return nil, err
}
@@ -26,9 +27,21 @@ func (i *Inventory) Seats(ctx context.Context) ([]catalogue.Seat, error) {
var seats []catalogue.Seat
for rows.Next() {
var s catalogue.Seat
if err := rows.Scan(&s.Name, &s.Scope, &s.Delivers, &s.Decision); err != nil {
var accepts, emits, serves []byte
if err := rows.Scan(&s.Name, &s.Scope, &s.Delivers, &s.Decision, &accepts, &emits, &serves); err != nil {
return nil, err
}
// The protocol, from the row (novox/hq ADR 0132). A row that predates the columns has empty
// lists, and UseSeats keeps the compiled protocol for it until the next seeding fills them.
if err := json.Unmarshal(accepts, &s.Accepts); err != nil {
return nil, fmt.Errorf("seat %s: accepts: %w", s.Name, err)
}
if err := json.Unmarshal(emits, &s.Emits); err != nil {
return nil, fmt.Errorf("seat %s: emits: %w", s.Name, err)
}
if err := json.Unmarshal(serves, &s.Serves); err != nil {
return nil, fmt.Errorf("seat %s: serves: %w", s.Name, err)
}
seats = append(seats, s)
}
return seats, rows.Err()
@@ -41,21 +54,116 @@ func (i *Inventory) Seats(ctx context.Context) ([]catalogue.Seat, error) {
// exactly as it is, so an operator's rename in the table is not undone by the next deploy putting
// the old name back. What a release removes from the defaults is not deleted here either; retiring a
// seat is its own decision, not a silent consequence of it dropping out of the binary.
//
// **The protocol is seeded additively** (novox/hq ADR 0132, design 33 §7). A row that has none takes
// the compiled protocol whole — that is the compiled fallback becoming data, once. A row that has one
// gains any verb the defaults name and it lacks, and loses nothing: a seat's tools are an interface,
// additive within a version, and a verb an operator added to the row is theirs to keep.
func (i *Inventory) SeedSeats(ctx context.Context, defaults []catalogue.Seat) (int, error) {
var added int
for _, s := range defaults {
tag, err := i.store.Pool().Exec(ctx,
`insert into seat (name, scope, delivers, decided) values ($1, $2, $3, $4)
on conflict (name) do nothing`,
s.Name, s.Scope, s.Delivers, s.Decision)
accepts, emits, serves, err := protocolJSON(s)
if err != nil {
return added, err
}
added += int(tag.RowsAffected())
tag, err := i.store.Pool().Exec(ctx,
`insert into seat (name, scope, delivers, decided, accepts, emits, serves)
values ($1, $2, $3, $4, $5, $6, $7)
on conflict (name) do nothing`,
s.Name, s.Scope, s.Delivers, s.Decision, accepts, emits, serves)
if err != nil {
return added, err
}
if n := int(tag.RowsAffected()); n > 0 {
added += n
continue
}
if err := i.widenProtocol(ctx, s); err != nil {
return added, err
}
}
return added, nil
}
// widenProtocol adds to a seat's row whatever the defaults name and the row lacks, by verb name.
func (i *Inventory) widenProtocol(ctx context.Context, s catalogue.Seat) error {
var accepts, emits, serves []byte
if err := i.store.Pool().QueryRow(ctx,
`select accepts, emits, serves from seat where name = $1`, s.Name).Scan(&accepts, &emits, &serves); err != nil {
return err
}
var row catalogue.Seat
if err := json.Unmarshal(accepts, &row.Accepts); err != nil {
return err
}
if err := json.Unmarshal(emits, &row.Emits); err != nil {
return err
}
if err := json.Unmarshal(serves, &row.Serves); err != nil {
return err
}
changed := false
row.Accepts, changed = union(row.Accepts, s.Accepts, changed)
row.Emits, changed = union(row.Emits, s.Emits, changed)
have := map[string]bool{}
for _, v := range row.Serves {
have[v.Name] = true
}
for _, v := range s.Serves {
if !have[v.Name] {
row.Serves = append(row.Serves, v)
changed = true
}
}
if !changed {
return nil
}
a, e, sv, err := protocolJSON(row)
if err != nil {
return err
}
_, err = i.store.Pool().Exec(ctx,
`update seat set accepts = $2, emits = $3, serves = $4 where name = $1`, s.Name, a, e, sv)
return err
}
func union(have, want []string, changed bool) ([]string, bool) {
seen := map[string]bool{}
for _, h := range have {
seen[h] = true
}
for _, w := range want {
if !seen[w] {
have = append(have, w)
seen[w] = true
changed = true
}
}
return have, changed
}
func protocolJSON(s catalogue.Seat) (accepts, emits, serves []byte, err error) {
if accepts, err = json.Marshal(orEmpty(s.Accepts)); err != nil {
return
}
if emits, err = json.Marshal(orEmpty(s.Emits)); err != nil {
return
}
verbs := s.Serves
if verbs == nil {
verbs = []catalogue.Verb{}
}
serves, err = json.Marshal(verbs)
return
}
func orEmpty(s []string) []string {
if s == nil {
return []string{}
}
return s
}
// Aliases is every former seat name and the seat it now resolves to (novox/hq ADR 0122).
func (i *Inventory) Aliases(ctx context.Context) (map[string]string, error) {
rows, err := i.store.Pool().Query(ctx, `select alias, seat from seat_alias`)
+6
View File
@@ -43,6 +43,12 @@ type BuildRequest struct {
// written in a manifest the mesh has not read: it is inside the repository, and reading it is
// the build's first act.
Held map[string]string `json:"held,omitempty"`
// Seats is the clone base — `scheme://host:port` — of each seat a recipe's context may name
// (novox/hq ADR 0155): `git` for this mesh's own forge. Sent with the asking for the reason
// Held is: the context is written in a manifest the mesh has not read, and only the mesh knows
// which forge holds the seat here. A builder handed no base for a seat a context names refuses
// the build and says so.
Seats map[string]string `json:"seats,omitempty"`
}
// BuildResult is what a builder says back.
+127
View File
@@ -0,0 +1,127 @@
package link
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"github.com/nats-io/nats.go"
)
// A role's tools, served by its holder (novox/hq ADR 0132, ADR 0154).
//
// The mesh's own verbs — `status`, `push`, `assign` — are the mesh-controller seat's tools, and the
// control plane is that seat's holder. So it answers them here, on the seat's subjects, the way a
// module's runtime answers a module's: one request, one reply on the asker's own inbox, `{result}` or
// `{error}`. Nothing about the transport is the command's business; a handler is a function of its
// arguments and gets the same answer the command line prints.
// ToolHandler answers one call of a role's tool. What it returns is marshalled as the result; an
// error is the tool answering with one, which is an answer and not a timeout.
type ToolHandler func(ctx context.Context, args json.RawMessage) (any, error)
// SeatToolSubject is where a mesh-scoped seat's tool is asked (design 33 §4).
func SeatToolSubject(seat, verb string) string { return "mesh.seat." + seat + ".tool." + verb }
// HandlerTimeout bounds one answer. A verb that runs a command — a push, a build with no wait —
// answers in seconds; anything that has not in this long is said to have not answered.
const HandlerTimeout = 5 * time.Minute
// RebindAfter is how long a refused subscription waits before it is tried again.
const RebindAfter = 30 * time.Second
// ServeSeatTools binds every handler on its seat's subject until stopped. A queue group per seat, so
// a second holder during a handover shares the calls rather than both answering one.
//
// **A holder binds when it may, not only when it starts.** The grant that lets the controller
// subscribe its seat's tools is a line in the bus's user list, and that list is composed by the
// controller and delivered to the broker's machine by a push — so the first controller to serve
// these started before the list named them, the server refused every subscription, and nothing
// tried again (2026-09-30). A refused subscription is therefore retried until it holds: the server
// says so asynchronously and invalidates the subscription, which is what is checked.
func (b OverNATS) ServeSeatTools(seat string, handlers map[string]ToolHandler, logger *log.Logger) (func(), error) {
var subs []*nats.Subscription
done := make(chan struct{})
stop := func() {
close(done)
for _, s := range subs {
_ = s.Unsubscribe()
}
}
for verb, handle := range handlers {
verb, handle := verb, handle
subject := SeatToolSubject(seat, verb)
bind := func() (*nats.Subscription, error) {
return b.Conn.QueueSubscribe(subject, "seat."+seat, func(msg *nats.Msg) {
// Its own goroutine per call: a slow `push` must not hold up a `status` asked beside it,
// and the library would otherwise run handlers one after another.
go func() {
ctx, cancel := context.WithTimeout(context.Background(), HandlerTimeout)
defer cancel()
args := json.RawMessage(msg.Data)
if len(args) == 0 {
args = json.RawMessage(`{}`)
}
var reply []byte
result, err := handle(ctx, args)
if err != nil {
reply, _ = json.Marshal(map[string]any{"error": err.Error()})
} else if reply, err = json.Marshal(map[string]any{"result": result}); err != nil {
reply, _ = json.Marshal(map[string]any{"error": "the answer could not be written as JSON: " + err.Error()})
}
if err := msg.Respond(reply); err != nil && logger != nil {
logger.Printf("%s: could not answer: %v", subject, err)
}
}()
})
}
sub, err := bind()
if err != nil {
stop()
return nil, fmt.Errorf("serving %s: %w", subject, err)
}
subs = append(subs, sub)
go keepBound(sub, bind, subject, done, logger)
}
if logger != nil {
logger.Printf("serving %d tool(s) of the %s seat", len(handlers), seat)
}
return stop, nil
}
// keepBound watches one subscription and re-binds it after the server refused it, until stopped.
// A subscription the server refused is invalid a moment after it was made; one it accepted stays
// valid. Checked rather than hooked, because the connection's error handler belongs to whoever
// dialled and a second one would replace it.
func keepBound(sub *nats.Subscription, bind func() (*nats.Subscription, error), subject string,
done <-chan struct{}, logger *log.Logger) {
current := sub
for {
select {
case <-done:
return
case <-time.After(3 * time.Second):
}
if current.IsValid() {
// Settled; from here a lost subscription is a lost connection, which the client
// restores itself with every subscription it holds.
return
}
if logger != nil {
logger.Printf("%s: the bus refused the subscription; trying again in %s — the grant "+
"arrives with the next push to the machine holding mesh-broker", subject, RebindAfter)
}
select {
case <-done:
return
case <-time.After(RebindAfter):
}
again, err := bind()
if err != nil {
continue
}
current = again
}
}
+14
View File
@@ -28,6 +28,20 @@
},
"secrets-owner": "65534:65534",
"prepares": true,
"tools": [
"tools",
"status",
"nodes",
"node",
"modules",
"seats",
"builds",
"plan",
"assign",
"unassign",
"push",
"build"
],
"resources": [
{
"id": "mesh-state",