diff --git a/cmd/mesh-compose/main.go b/cmd/mesh-compose/main.go
new file mode 100644
index 0000000..22a5753
--- /dev/null
+++ b/cmd/mesh-compose/main.go
@@ -0,0 +1,154 @@
+// mesh-compose is the node-engine's composer command (novox/hq ADR 0482 clause 4, ADR 0593 §7, issue 1621): it reads a
+// composer.Request on standard input, reads every manifest, composes the machines named in each case and writes one
+// composer.Verdict on standard output. Built at a commit of the node-engine's repository, it answers what that
+// commit's composition answers, so a group that changes the node-engine's composition is judged by its own next code.
+//
+// mesh-compose < request.json > verdict.json
+// mesh-compose -request request.json > verdict.json
+// mesh-compose -catalogue
[-node ]... [-depth resolve|compose] [-declarations] [< request.json]
+//
+// -catalogue reads /modules/*/module.json and a module.json at 's root as the first case's manifests (a
+// checkout or `git archive` of the catalogue repository at a ref: the command fetches nothing). -node adds a machine to
+// compose; -declarations answers each machine's declared paths with their owners and modes.
+//
+// It opens no network connection, bus, store or file beyond its standard input, the -request file and the -catalogue
+// directory, and reads
+// no environment. Exit codes: 0 the verdict is written (a machine that does not compose is a verdict, not an error);
+// 1 the request cannot be read or run, said on standard error; 2 the flags are wrong.
+package main
+
+import (
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/novox/mesh-host/internal/composer"
+)
+
+func main() {
+ os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
+}
+
+type nodes []string
+
+func (n *nodes) String() string { return strings.Join(*n, ",") }
+func (n *nodes) Set(v string) error { *n = append(*n, v); return nil }
+
+// run is the command: its arguments, standard input, output and error; it answers the exit code.
+func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
+ fs := flag.NewFlagSet("mesh-compose", flag.ContinueOnError)
+ fs.SetOutput(stderr)
+ catalogueDir := fs.String("catalogue", "", "a catalogue checkout: /modules/*/module.json are the first case's manifests")
+ var machines nodes
+ fs.Var(&machines, "node", "a machine to compose (repeatable)")
+ depth := fs.String("depth", "", "resolve or compose (default: the request's, else compose)")
+ declarations := fs.Bool("declarations", false, "answer each composed machine's declared paths, owners and modes")
+ requestFile := fs.String("request", "", "read the request from this file instead of standard input (a run step that hands no standard input)")
+ if err := fs.Parse(args); err != nil {
+ return 2
+ }
+ if fs.NArg() > 0 {
+ fmt.Fprintf(stderr, "mesh-compose: unexpected arguments %q\n", fs.Args())
+ return 2
+ }
+ var raw []byte
+ var err error
+ if *requestFile != "" {
+ raw, err = os.ReadFile(*requestFile)
+ } else {
+ raw, err = io.ReadAll(stdin)
+ }
+ if err != nil {
+ fmt.Fprintln(stderr, "mesh-compose: the request cannot be read:", err)
+ return 1
+ }
+ var req composer.Request
+ if strings.TrimSpace(string(raw)) == "" {
+ if *catalogueDir == "" {
+ fmt.Fprintln(stderr, "mesh-compose: no request on standard input and no -catalogue")
+ return 1
+ }
+ req = composer.Request{V: composer.Version}
+ } else if req, err = composer.Decode(raw); err != nil {
+ fmt.Fprintln(stderr, "mesh-compose:", err)
+ return 1
+ }
+ if *catalogueDir != "" {
+ read, err := readCatalogue(*catalogueDir)
+ if err != nil {
+ fmt.Fprintln(stderr, "mesh-compose:", err)
+ return 1
+ }
+ if len(req.Cases) == 0 {
+ req.Cases = []composer.Case{{Name: "catalogue", Manifests: map[string]composer.Manifest{}}}
+ }
+ if req.Cases[0].Manifests == nil {
+ req.Cases[0].Manifests = map[string]composer.Manifest{}
+ }
+ for name, m := range read {
+ if _, given := req.Cases[0].Manifests[name]; !given {
+ req.Cases[0].Manifests[name] = m
+ }
+ }
+ // No list of predefined keys is read: the composer judges no manifest (novox/hq ADR 0578 §5).
+ }
+ req.Machines = append(req.Machines, machines...)
+ if *depth != "" {
+ req.Depth = *depth
+ }
+ if *declarations {
+ req.Declarations = true
+ }
+ v, err := composer.Run(req)
+ if err != nil {
+ fmt.Fprintln(stderr, "mesh-compose:", err)
+ return 1
+ }
+ enc := json.NewEncoder(stdout)
+ if err := enc.Encode(v); err != nil {
+ fmt.Fprintln(stderr, "mesh-compose: the verdict cannot be written:", err)
+ return 1
+ }
+ return 0
+}
+
+// readCatalogue reads every module.json of a catalogue checkout by its module's name: the root's and each of
+// modules/*/. A manifest whose name cannot be read is kept under its directory's name, so the reader's refusal is said.
+func readCatalogue(dir string) (map[string]composer.Manifest, error) {
+ paths, err := filepath.Glob(filepath.Join(dir, "modules", "*", "module.json"))
+ if err != nil {
+ return nil, err
+ }
+ if _, err := os.Stat(filepath.Join(dir, "module.json")); err == nil {
+ paths = append(paths, filepath.Join(dir, "module.json"))
+ }
+ if len(paths) == 0 {
+ return nil, fmt.Errorf("the catalogue %s holds no module.json", dir)
+ }
+ sort.Strings(paths)
+ out := map[string]composer.Manifest{}
+ for _, p := range paths {
+ raw, err := os.ReadFile(p)
+ if err != nil {
+ return nil, err
+ }
+ var probe struct {
+ Module string `json:"module"`
+ }
+ name := filepath.Base(filepath.Dir(p))
+ if json.Unmarshal(raw, &probe) == nil && probe.Module != "" {
+ name = probe.Module
+ }
+ if _, twice := out[name]; twice {
+ return nil, errors.New("the catalogue " + dir + " defines " + name + " twice")
+ }
+ out[name] = composer.Manifest{Raw: raw}
+ }
+ return out, nil
+}
diff --git a/cmd/mesh-compose/main_test.go b/cmd/mesh-compose/main_test.go
new file mode 100644
index 0000000..83a5faf
--- /dev/null
+++ b/cmd/mesh-compose/main_test.go
@@ -0,0 +1,339 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "go/build"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "reflect"
+ "runtime"
+ "sort"
+ "strings"
+ "testing"
+
+ "git.novox.be/novox/mesh-sdk/go/catalogue"
+ "git.novox.be/novox/mesh-sdk/go/delivery"
+ "github.com/novox/mesh-host/internal/beside"
+ "github.com/novox/mesh-host/internal/compose"
+ "github.com/novox/mesh-host/internal/composer"
+)
+
+// captured is every manifest of the catalogue captured in testdata/beside (internal/beside), raw, by module name.
+func captured(t *testing.T) map[string]composer.Manifest {
+ t.Helper()
+ read, err := readCatalogue(filepath.Dir(beside.Catalogue(t)))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(read) < 100 {
+ t.Fatalf("the captured catalogue holds %d manifests, fewer than the catalogue's", len(read))
+ }
+ return read
+}
+
+// meshOf is a mesh of three machines over the captured catalogue, each assigned real modules, the input the assigner
+// composes with (nodes and their assignments, holdings from the claims): anchor runs the bus and the store, laptop a
+// desktop's modules, ace a module whose requirement is provided elsewhere.
+func meshOf() compose.Input {
+ return compose.Input{Nodes: []compose.Node{
+ {Name: "anchor", Assigned: []string{"nats", "postgres"}},
+ {Name: "laptop", Assigned: []string{"sudo", "zsh"}},
+ {Name: "ace", Assigned: []string{"mesh-reads", "nats"}},
+ }}
+}
+
+func request(t *testing.T, depth string) composer.Request {
+ t.Helper()
+ r := composer.Request{V: composer.Version, Depth: depth, HoldingsFromClaims: true,
+ Machines: []string{"anchor", "laptop", "ace"},
+ Cases: []composer.Case{{Name: "trunk", Manifests: captured(t)}}}
+ return withInput(t, r, meshOf())
+}
+
+// withInput is r with in as its input, as a caller puts it on the wire: the request's shape carries the input as JSON.
+func withInput(t *testing.T, r composer.Request, in compose.Input) composer.Request {
+ t.Helper()
+ raw, err := json.Marshal(in)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := json.Unmarshal(raw, &r.Input); err != nil {
+ t.Fatal(err)
+ }
+ return r
+}
+
+func runJSON(t *testing.T, args []string, in []byte) (int, composer.Verdict, string) {
+ t.Helper()
+ var out, errOut bytes.Buffer
+ code := run(args, bytes.NewReader(in), &out, &errOut)
+ var v composer.Verdict
+ if code == 0 {
+ if err := json.Unmarshal(out.Bytes(), &v); err != nil {
+ t.Fatalf("the verdict is not JSON: %v\n%s", err, out.String())
+ }
+ }
+ return code, v, errOut.String()
+}
+
+func encode(t *testing.T, r composer.Request) []byte {
+ t.Helper()
+ raw, err := json.Marshal(r)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return raw
+}
+
+// 1621/AC1: the command reads the plain input and the manifests on standard input, validates every manifest, composes
+// the machines named and writes one verdict: per machine composes or the reason, per manifest accepted or the field
+// refused.
+func TestTheCommandWritesOneVerdictPerManifestAndMachine(t *testing.T) {
+ r := request(t, composer.DepthResolve)
+ r.Cases[0].Manifests["later"] = composer.Manifest{Raw: json.RawMessage(`{"module":"later","version":"1","shares":["x"]}`)}
+ r.Cases[0].Manifests["refused"] = composer.Manifest{Raw: json.RawMessage(`{"module":"refused","version":"1"}`)}
+ r.Cases[0].Verdicts = map[string]delivery.ManifestVerdict{"refused": {State: delivery.VerdictRefused, Problems: []string{"it is wrong"}}}
+ code, v, stderr := runJSON(t, nil, encode(t, r))
+ if code != 0 {
+ t.Fatalf("exit %d: %s", code, stderr)
+ }
+ if len(v.Cases) != 1 || v.Cases[0].Name != "trunk" || v.SDK == "" {
+ t.Fatalf("verdict = %+v", v)
+ }
+ c := v.Cases[0]
+ // ADR 0578 §5, §6: the composer judges nothing; it sets an unknown field aside and names it, and holds a manifest
+ // its verdict refuses, named in the indexer's words.
+ if got := c.Manifests["later"]; !got.Accepted || !strings.Contains(got.SetAside, `"shares"`) {
+ t.Errorf("the manifest with an unknown field reads %+v, want accepted naming the field set aside", got)
+ }
+ if got := c.Manifests["refused"]; got.Accepted || !strings.Contains(got.Refused, "refused the manifest of refused: it is wrong") {
+ t.Errorf("the refused manifest reads %+v, want refused in the verdict's words", got)
+ }
+ if got := c.Manifests["nats"]; !got.Accepted {
+ t.Errorf("nats reads %+v, want accepted", got)
+ }
+ if len(c.Machines) != 3 {
+ t.Fatalf("machines = %v, want the three named", c.Machines)
+ }
+ for name, m := range c.Machines {
+ if !m.Composes && m.Reason == "" {
+ t.Errorf("%s does not compose and says no reason", name)
+ }
+ }
+}
+
+// 1621/AC3: over the captured catalogue and a mesh of three machines, the command's verdict equals, machine for
+// machine and manifest for manifest, what the linked reader, resolution and composition answer, at both depths.
+func TestTheCommandAnswersWhatTheLinkedCompositionAnswers(t *testing.T) {
+ for _, depth := range []string{composer.DepthResolve, composer.DepthCompose} {
+ r := request(t, depth)
+ code, v, stderr := runJSON(t, nil, encode(t, r))
+ if code != 0 {
+ t.Fatalf("%s: exit %d: %s", depth, code, stderr)
+ }
+ in := meshOf()
+ in.Catalogue = map[string]catalogue.Manifest{}
+ for name, m := range r.Cases[0].Manifests {
+ // A request without a list of predefined keys reads a fallback as the indexer does (Request.Predefined).
+ parsed, err := catalogue.Decode(m.Raw)
+ got := v.Cases[0].Manifests[name]
+ if (err == nil) != got.Accepted || (err != nil && err.Error() != got.Refused) {
+ t.Errorf("%s: the manifest of %s: linked %v, command %+v", depth, name, err, got)
+ }
+ if err == nil {
+ in.Catalogue[name] = parsed
+ }
+ }
+ in.Holdings = composer.HoldingsOf(in.Catalogue, in.Nodes)
+ composed := 0
+ for _, node := range r.Machines {
+ got := v.Cases[0].Machines[node]
+ var want composer.Machine
+ if depth == composer.DepthResolve {
+ if _, err := compose.Resolve(in, node); err != nil {
+ want = composer.Machine{Reason: err.Error()}
+ } else {
+ want = composer.Machine{Composes: true}
+ }
+ } else {
+ d, problems, err := compose.Compose(in, node)
+ switch {
+ case err != nil:
+ want = composer.Machine{Reason: err.Error()}
+ default:
+ want = composer.Machine{Composes: len(problems) == 0, Problems: problems, Refusals: d.Refusals()}
+ if len(problems) > 0 {
+ want.Reason = "the node-engine's validator refuses the declaration"
+ }
+ }
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Errorf("%s: %s: command %+v, linked %+v", depth, node, got, want)
+ }
+ if got.Composes {
+ composed++
+ } else {
+ t.Logf("%s: %s does not compose: %s %v", depth, node, got.Reason, got.Problems)
+ }
+ }
+ t.Logf("%s: %d of %d machines compose", depth, composed, len(r.Machines))
+ }
+}
+
+// 1621 note 6: a person composes a catalogue checkout (repository@ref checked out or archived: the command fetches
+// nothing) for a node and reads its declaration: each path with its kind, owner and mode.
+func TestACatalogueCheckoutIsComposedForANode(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.MkdirAll(filepath.Join(dir, "modules", "demo"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ manifest := `{"module": "demo", "version": "1", "resources": [{"id": "state", "type": "directory", ` +
+ `"path": "/var/lib/demo", "mode": "0750", "owner": "root"}]}`
+ if err := os.WriteFile(filepath.Join(dir, "modules", "demo", "module.json"), []byte(manifest), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ in := encode(t, withInput(t, composer.Request{V: composer.Version}, compose.Input{Nodes: []compose.Node{{Name: "laptop", Assigned: []string{"demo"}}}}))
+ code, v, stderr := runJSON(t, []string{"-catalogue", dir, "-node", "laptop", "-declarations"}, in)
+ if code != 0 {
+ t.Fatalf("exit %d: %s", code, stderr)
+ }
+ m := v.Cases[0].Machines["laptop"]
+ want := []composer.Path{{Path: "/var/lib/demo", Kind: "directory", Owner: "root", Mode: "0750", Module: ""}}
+ if !m.Composes || len(m.Declared) != 1 || m.Declared[0].Path != want[0].Path || m.Declared[0].Mode != "0750" ||
+ m.Declared[0].Owner != "root" || m.Declared[0].Kind != "directory" {
+ t.Fatalf("laptop = %+v, want it composed declaring %+v", m, want)
+ }
+}
+
+// A run step hands the command no standard input: the request is read from the file -request names, and a file that is
+// not there is a request it cannot read.
+func TestTheRequestIsReadFromAFile(t *testing.T) {
+ file := filepath.Join(t.TempDir(), "request.json")
+ r := composer.Request{V: composer.Version, Cases: []composer.Case{{Name: "only", Manifests: map[string]composer.Manifest{
+ "demo": {Raw: json.RawMessage(`{"module":"demo","version":"1"}`)}}}}}
+ if err := os.WriteFile(file, encode(t, r), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ code, v, stderr := runJSON(t, []string{"-request", file}, nil)
+ if code != 0 || len(v.Cases) != 1 || !v.Cases[0].Manifests["demo"].Accepted {
+ t.Fatalf("exit %d (%s), verdict %+v", code, stderr, v)
+ }
+ if code, _, stderr := runJSON(t, []string{"-request", file + ".gone"}, nil); code != 1 || !strings.Contains(stderr, "cannot be read") {
+ t.Errorf("a request file that is not there: exit %d, %q", code, stderr)
+ }
+}
+
+// The exit codes: 1 for a request that cannot be read or run, 2 for wrong flags, each said on standard error.
+func TestTheExitCodesSayWhatFailed(t *testing.T) {
+ unfilled := encode(t, composer.Request{V: composer.Version, Cases: []composer.Case{{Name: "c", Manifests: map[string]composer.Manifest{"x": {Digest: "abc"}}}}})
+ for _, c := range []struct {
+ name string
+ args []string
+ in string
+ code int
+ says string
+ }{
+ {"not JSON", nil, "{", 1, "not JSON"},
+ {"another version", nil, `{"v":2}`, 1, "version 2"},
+ {"a digest left unfilled", nil, string(unfilled), 1, "digest"},
+ {"an unknown depth", nil, `{"v":1,"depth":"deep"}`, 1, "depth"},
+ {"nothing to read", nil, "", 1, "no request"},
+ {"a wrong flag", []string{"-nope"}, "", 2, "nope"},
+ {"an argument", []string{"extra"}, "", 2, "unexpected"},
+ } {
+ var out, errOut bytes.Buffer
+ if got := run(c.args, strings.NewReader(c.in), &out, &errOut); got != c.code || !strings.Contains(errOut.String(), c.says) {
+ t.Errorf("%s: exit %d saying %q, want %d saying %q", c.name, got, errOut.String(), c.code, c.says)
+ }
+ }
+}
+
+// ownPackages is every package of this repository the command imports, directly or not, with the packages each
+// imports: what this repository adds to the composition; mesh-sdk's packages are judged in mesh-sdk.
+func ownPackages(t *testing.T) map[string][]string {
+ t.Helper()
+ _, file, _, _ := runtime.Caller(0)
+ root := filepath.Join(filepath.Dir(file), "..", "..")
+ const module = "github.com/novox/mesh-host"
+ out := map[string][]string{}
+ var walk func(path string)
+ walk = func(path string) {
+ if _, done := out[path]; done {
+ return
+ }
+ rel := strings.TrimPrefix(strings.TrimPrefix(path, module), "/")
+ pkg, err := build.Default.ImportDir(filepath.Join(root, rel), 0)
+ if err != nil {
+ t.Fatalf("%s: %v", path, err)
+ }
+ out[path] = pkg.Imports
+ for _, imp := range pkg.Imports {
+ if strings.HasPrefix(imp, module+"/") {
+ walk(imp)
+ }
+ }
+ }
+ walk(composer.Package)
+ return out
+}
+
+// 1621/AC2 and AC4: the command imports, through every package of this repository it reaches, only the standard
+// library, mesh-sdk and this repository's own packages, and none that reaches a bus, a store, a network service or
+// another process.
+func TestTheCommandImportsNoBusStoreNetworkOrProcess(t *testing.T) {
+ forbidden := []string{"net/http", "net/rpc", "net/smtp", "os/exec", "database/sql", "plugin", "syscall"}
+ own := ownPackages(t)
+ if len(own) < 4 {
+ t.Fatalf("the command reaches %d packages of this repository; the walk found too few to judge", len(own))
+ }
+ for path, imports := range own {
+ for _, imp := range imports {
+ std := !strings.Contains(strings.SplitN(imp, "/", 2)[0], ".")
+ if !std && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-sdk/go") && !strings.HasPrefix(imp, "github.com/novox/mesh-host/") {
+ t.Errorf("%s imports %s, which is neither the standard library, mesh-sdk nor this repository", path, imp)
+ }
+ for _, f := range forbidden {
+ if imp == f {
+ t.Errorf("%s imports %s", path, imp)
+ }
+ }
+ }
+ }
+}
+
+// 1621/AC2: run with an empty network namespace, the command still writes its verdict: it needs no network. The test
+// runs its own binary again under `unshare --net` as an unprivileged user's namespace; where the kernel or the sandbox
+// allows no new namespace (a build module's run step is already one with no network), it says so and skips.
+func TestTheCommandRunsWithNoNetwork(t *testing.T) {
+ if os.Getenv("MESH_COMPOSE_HELPER") == "1" {
+ os.Exit(run(nil, os.Stdin, os.Stdout, os.Stderr))
+ }
+ unshare, err := exec.LookPath("unshare")
+ if err != nil {
+ t.Skip("no unshare on this machine, so no empty network namespace can be made here")
+ }
+ if out, err := exec.Command(unshare, "--net", "--map-root-user", "true").CombinedOutput(); err != nil {
+ t.Skipf("this machine makes no unprivileged network namespace (%v: %s); a run step's own has no network", err, out)
+ }
+ r := request(t, composer.DepthResolve)
+ cmd := exec.Command(unshare, "--net", "--map-root-user", os.Args[0], "-test.run=^TestTheCommandRunsWithNoNetwork$")
+ cmd.Env = []string{"MESH_COMPOSE_HELPER=1"}
+ cmd.Stdin = bytes.NewReader(encode(t, r))
+ var out, errOut bytes.Buffer
+ cmd.Stdout, cmd.Stderr = &out, &errOut
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("the command in an empty network namespace: %v: %s", err, errOut.String())
+ }
+ var v composer.Verdict
+ if err := json.Unmarshal(out.Bytes(), &v); err != nil || len(v.Cases) != 1 || len(v.Cases[0].Machines) != 3 {
+ t.Fatalf("no verdict from the command with no network: %v\n%s", err, out.String())
+ }
+ names := make([]string, 0, len(v.Cases[0].Machines))
+ for n := range v.Cases[0].Machines {
+ names = append(names, n)
+ }
+ sort.Strings(names)
+ t.Logf("composed with no network: %v", names)
+}
diff --git a/cmd/mesh-compose/reads-outside b/cmd/mesh-compose/reads-outside
new file mode 100644
index 0000000..f6b2d87
--- /dev/null
+++ b/cmd/mesh-compose/reads-outside
@@ -0,0 +1 @@
+These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
diff --git a/cmd/mesh-host/engineseat.go b/cmd/mesh-host/engineseat.go
index 6cba6a6..443f0fc 100644
--- a/cmd/mesh-host/engineseat.go
+++ b/cmd/mesh-host/engineseat.go
@@ -3,6 +3,7 @@ package main
import (
"bufio"
"context"
+ "encoding/json"
"errors"
"fmt"
"os"
@@ -15,6 +16,7 @@ import (
stdio "git.novox.be/novox/mesh-sdk/go"
meshasker "git.novox.be/novox/mesh-sdk/go/asker"
"git.novox.be/novox/mesh-sdk/go/asks"
+ "git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/accounts"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/link"
@@ -359,3 +361,31 @@ func packagesFrom(node string, session *link.Session, composer *atomic.Pointer[s
return v, nil
}
}
+
+// composeCandidateFrom answers the verb compose-candidate (novox/hq ADR 0593 §7): the check's candidate composed on this
+// node in shadow by the run's shadow, applying and writing nothing, within delivery.ComposeAnswerBound. The answer is
+// the seat's shape (delivery.ComposeCandidateAnswer), checked as it is encoded; a node whose shadow does not run yet
+// answers unready, which the checker takes as no answer.
+func composeCandidateFrom(node string, shadow *atomic.Pointer[selfcompose.Shadow]) func(candidate string, input bool) (any, error) {
+ return func(candidate string, input bool) (any, error) {
+ ask := delivery.ComposeCandidateAsk{Candidate: candidate, Input: input}
+ if _, err := delivery.EncodeComposeCandidateAsk(ask); err != nil {
+ return nil, err
+ }
+ var a delivery.ComposeCandidateAnswer
+ if s := shadow.Load(); s == nil {
+ // Unready, never an error the checker reads words of: its agent is starting (ADR 0593 §9).
+ a = delivery.ComposeCandidateAnswer{Node: node, Candidate: candidate, Verdict: delivery.ComposeCandidateUnready,
+ Refused: "this node's shadow does not run now (its node agent is starting), so it composes no candidate"}
+ } else {
+ ctx, cancel := context.WithTimeout(context.Background(), delivery.ComposeAnswerBound)
+ defer cancel()
+ a = s.ComposeCandidate(ctx, ask)
+ }
+ raw, err := delivery.EncodeComposeCandidateAnswer(a)
+ if err != nil {
+ return nil, fmt.Errorf("this node's answer to %s does not fit the seat's shape: %w", candidate, err)
+ }
+ return json.RawMessage(raw), nil
+ }
+}
diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go
index 3045eb9..b352d48 100644
--- a/cmd/mesh-host/main.go
+++ b/cmd/mesh-host/main.go
@@ -1001,6 +1001,7 @@ func runLink(ctx context.Context, opts options) error {
queue.Acts.Composition = compositionFrom(current)
queue.Acts.Compose = composeFrom(current, &composerNow)
queue.Acts.Packages = packagesFrom(mine.Node, queue, &composerNow)
+ queue.Acts.ComposeCandidate = composeCandidateFrom(mine.Node, &shadowNow)
setRunsHere(runsFromAgent(current))
factsOfNow := func(ctx context.Context, ag *agent.Agent) {
sayFacts(ctx, opts, mine.Membership.Signer, queue, say, ag)
diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go
index c9a140a..8001ee3 100644
--- a/cmd/mesh-host/nodeagent.go
+++ b/cmd/mesh-host/nodeagent.go
@@ -14,12 +14,12 @@ import (
"sync/atomic"
"time"
- "git.novox.be/novox/mesh-sdk/go/compose/frombus"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/overlay"
"github.com/novox/mesh-host/internal/agent"
"github.com/novox/mesh-host/internal/apply"
+ "github.com/novox/mesh-host/internal/compose/frombus"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/liveness"
"github.com/novox/mesh-host/internal/selfcompose"
@@ -623,6 +623,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
session.NoteCandidates(ctx, delivery.WithValuesDiffer(answers))
})
go shadow.Keep(run, link.Backoff) // this run's alone: a switch begins a run, and its shadow, again
+ shadowNow.Store(shadow) // what the verb compose-candidate composes a check's candidate with
// Its own composer, always (issue 1126, A7): a composition made elsewhere (the controller's, in the node's
// composition bucket) is never applied. While the seat's placement holds no key for this node it composes
// nothing and keeps running what it runs; a placement it cannot watch stops the agent, said, and it begins again.
@@ -640,6 +641,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
cancel()
current.set(nil)
composerNow.Store(nil)
+ shadowNow.Store(nil)
if ctx.Err() != nil {
return
}
@@ -766,6 +768,10 @@ var machinesNow atomic.Pointer[selfcompose.Composer]
// compositions or no run is going: what the verb compose asks a pass of (issue 774).
var composerNow atomic.Pointer[selfcompose.Composer]
+// shadowNow is the shadow of the run now, which answers placement candidates and the verb compose-candidate; nil
+// between runs.
+var shadowNow atomic.Pointer[selfcompose.Shadow]
+
// keptRetired says the kept declaration is read no more: every module the seat's placement puts on this node has a have of
// its own (placedAllHave), so what runs is the agent's alone (ADR 0550 §2, design 47's L3 s9).
var keptRetired atomic.Bool
@@ -968,6 +974,9 @@ func selfComposer(session *link.Session, node string, hooks composerHooks, say f
// And the packages its compositions declare in its own key of the state packages, which pacman reads (novox/hq ADR
// 0565 §6).
env.Packages = link.EnginePackages{Session: session}
+ // And every composition it makes in its composition state composed-, a record the provisioner, the assigner
+ // and the view read and nothing applies (novox/hq ADR 0593 §6).
+ env.ComposedState = link.EngineComposed{Session: session, Node: node}
return selfcompose.New(env)
}
diff --git a/cmd/mesh-host/nodeagent_integration_test.go b/cmd/mesh-host/nodeagent_integration_test.go
index a00bef5..68d8b5e 100644
--- a/cmd/mesh-host/nodeagent_integration_test.go
+++ b/cmd/mesh-host/nodeagent_integration_test.go
@@ -19,6 +19,7 @@ import (
"github.com/nats-io/nats.go"
"github.com/novox/mesh-host/internal/agent"
"github.com/novox/mesh-host/internal/link"
+ "github.com/novox/mesh-host/internal/nodegrant"
"github.com/novox/mesh-host/internal/selfcompose"
)
@@ -32,7 +33,7 @@ type itBed struct {
t *testing.T
conn *nats.Conn
js nats.JetStreamContext
- // nodeConn and nodeJS are the node's own, under its real grant (buscompose.PermissionsFor, KindNode): the agent runs
+ // nodeConn and nodeJS are the node's own, under its real grant (the grants part's, nodegrant.For): the agent runs
// on them, so a watch or a read the grant does not allow fails here as it fails on the bus (issue 1126, 2026-10-27
// 09:21: a watch of every have key stopped every node agent). conn is the test's, unrestricted, for setting up.
nodeConn *nats.Conn
@@ -51,13 +52,9 @@ type itBed struct {
}
// aServer is an embedded bus with JetStream and two users: the test's, unrestricted, and the node's, granted exactly what
-// buscompose.PermissionsFor gives a node (KindNode); it answers a connection of each.
-func aServer(t *testing.T, principal buscompose.Principal, refused func(error)) (admin, node *nats.Conn) {
+// the grants part gives a node (nodegrant.For); it answers a connection of each.
+func aServer(t *testing.T, perms nodegrant.Permissions, refused func(error)) (admin, node *nats.Conn) {
t.Helper()
- perms, err := buscompose.PermissionsFor(principal)
- if err != nil {
- t.Fatal(err)
- }
granted := &server.Permissions{Publish: &server.SubjectPermission{Allow: perms.Publish, Deny: perms.PublishDeny},
Subscribe: &server.SubjectPermission{Allow: perms.Subscribe}}
if perms.AllowResponses {
@@ -88,11 +85,11 @@ func aServer(t *testing.T, principal buscompose.Principal, refused func(error))
func newItBed(t *testing.T) *itBed {
t.Helper()
- return newItBedAs(t, buscompose.Principal{Kind: buscompose.KindNode, Node: itNode})
+ return newItBedAs(t, nodegrant.For(itNode))
}
-// newItBedAs is newItBed with the node's connection under the grant of principal, a node's (KindNode).
-func newItBedAs(t *testing.T, principal buscompose.Principal) *itBed {
+// newItBedAs is newItBed with the node's connection under the grant perms, a node's.
+func newItBedAs(t *testing.T, perms nodegrant.Permissions) *itBed {
t.Helper()
// Not skipped off Arch: a package manager that answers is put on PATH, so the apply of files runs on any build node.
bin := t.TempDir()
@@ -105,7 +102,7 @@ func newItBedAs(t *testing.T, principal buscompose.Principal) *itBed {
t.Cleanup(func() { builtFor = was })
b := &itBed{t: t, dir: t.TempDir()}
- b.conn, b.nodeConn = aServer(t, principal, func(err error) {
+ b.conn, b.nodeConn = aServer(t, perms, func(err error) {
b.mu.Lock()
b.refusals = append(b.refusals, err.Error())
b.mu.Unlock()
@@ -166,28 +163,6 @@ func fpIt(c string) string { return delivery.SourcePrefix2 + strings.Repeat(c, 6
func fpDigest(c string) string { return "sha256:" + strings.Repeat(c, 64) }
-// compose writes a module's signed composition of one file into the node's bucket and returns its digest.
-func (b *itBed) compose(module, fp, content string, data bool, signer ed25519.PrivateKey) string {
- b.t.Helper()
- path := filepath.Join(b.dir, module+".conf")
- res, _ := json.Marshal(map[string]any{"id": module + ".conf", "type": "file", "path": path, "content": content, "module": module})
- c := delivery.Composed{Node: itNode, Module: module, FP: fp, Data: data, Resources: []json.RawMessage{res}}
- signed, err := delivery.SignComposed(c, signer)
- if err != nil {
- b.t.Fatal(err)
- }
- raw, err := delivery.EncodeComposed(signed)
- if err != nil {
- b.t.Fatal(err)
- }
- dig, _ := delivery.ComposedDigestOf(signed)
- key, _ := delivery.ComposedKey(itNode, module, fp)
- if _, err := b.composed.Put(key, raw); err != nil {
- b.t.Fatal(err)
- }
- return dig
-}
-
func (b *itBed) want(module, fp, digest, backup string) {
b.t.Helper()
raw, err := delivery.EncodeWant(delivery.Want{FP: fp, Composed: digest, Backup: backup})
@@ -233,24 +208,10 @@ func within(t *testing.T, d time.Duration, what string, ok func() bool) {
// in the node's own key, and nothing is applied for it (ADR 0559 §4, subtask 10 of the milestone).
//
// And (issue 1126, ADR 0560) it reads, through its own session under the grant the grants part composes for it
-// (buscompose.Users, so it carries ServedReads), the build of a placed module that declares one and the one config value
+// (nodegrant.For with the served key, as the grants part grants it), the build of a placed module that declares one and the one config value
// a provider on another node serves from (a mail domain); under a grant without that served key the bus refuses the
// read and the node composes nothing.
func TestNatsANodeThePlacementNamesComposesAndAppliesItsOwn(t *testing.T) {
- users, err := buscompose.Users(buscompose.Records{Nodes: []string{itNode, "it2"},
- Assigned: map[string][]buscompose.Declared{"it2": {{Module: "mailer", Served: []string{"domain"}}}}})
- if err != nil {
- t.Fatal(err)
- }
- var principal buscompose.Principal
- for _, u := range users {
- if u.Kind == buscompose.KindNode && u.Node == itNode {
- principal = u
- }
- }
- if len(principal.ServedReads) != 1 {
- t.Fatalf("the grants part's principal of %s reads %v of the served keys", itNode, principal.ServedReads)
- }
t.Run("without the served key the bus refuses the read", func(t *testing.T) {
b := newItBed(t)
stop, _ := h8(t, b, false)
@@ -268,7 +229,7 @@ func TestNatsANodeThePlacementNamesComposesAndAppliesItsOwn(t *testing.T) {
t.Errorf("composed and applied under a grant without the served key: %q", b.file("web"))
}
})
- b := newItBedAs(t, principal)
+ b := newItBedAs(t, nodegrant.For(itNode, "it2.mailer.domain"))
stop, st := h8(t, b, true)
defer stop()
placement, resolved, candidates := st.placement, st.resolved, st.candidates
@@ -524,10 +485,22 @@ func h8rest(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, hkey str
h := b.have("web")
return h.FP == fp && h.Composed != "" && h.Phase == delivery.PhaseRunning
})
- if keys, _ := b.composed.Keys(); len(keys) != 0 {
- t.Errorf("the controller's compositions hold %v; the node composed its own", keys)
- }
+ // ADR 0593 §6: the node publishes what it composed in its composition state, under its own grant: web at the build
+ // its want names, with the digest its have names (the plan left out of it), and the node's own.
first := b.have("web").Composed
+ webKey, _ := delivery.ComposedKey(itNode, "web", fp)
+ within(t, 5*time.Second, "the node did not publish web's composition in its composition state", func() bool {
+ e, err := b.composed.Get(webKey)
+ if err != nil {
+ return false
+ }
+ c, err := delivery.DecodeComposed(e.Value())
+ if err != nil {
+ return false
+ }
+ d, err := delivery.ComposedDigestOf(c)
+ return err == nil && d == first
+ })
if _, err := resolved.Put(itNode+".web.greeting", []byte(`{"value":"salut","source":"module"}`)); err != nil {
t.Fatal(err)
}
@@ -568,4 +541,78 @@ func h8rest(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, hkey str
if h := b.have("web"); h.Composed != second || b.file("web") != "greeting=salut\n" {
t.Errorf("a candidate moved what runs: %+v, %q", h, b.file("web"))
}
+ h8candidate(t, b, fp, second)
+}
+
+// h8candidate is ADR 0593 §7: a check's candidate is composed on the node through its seat's verb compose-candidate,
+// under its own grant: the indexer's candidate changing web's manifest is answered composed, web changed at the
+// candidate's build, within the answer bound; nothing is applied and the composition state does not move.
+func h8candidate(t *testing.T, b *itBed, fp, running string) {
+ t.Helper()
+ kvOf := func(bucket string) nats.KeyValue {
+ kv, err := b.js.CreateKeyValue(&nats.KeyValueConfig{Bucket: bucket, History: 5})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return kv
+ }
+ cands, _ := kvOf(delivery.CandidatesBucket), kvOf(delivery.VerdictsBucket)
+ manifests, err := b.js.KeyValue(delivery.ManifestsBucket)
+ if err != nil {
+ t.Fatal(err)
+ }
+ path := filepath.Join(b.dir, "web.conf")
+ man := `{"module":"web","version":"2","config":{"greeting":{"type":"string","description":"what the file says","default":"hello"}},` +
+ `"resources":[{"id":"conf","type":"file","path":"` + path + `","mode":"0640","content":"greeting=${config:greeting}\n"},` +
+ `{"id":"tools","type":"package","package":"web-tools"}]}`
+ digest := delivery.ManifestDigest([]byte(man))
+ mraw, _ := delivery.EncodeManifest(delivery.Manifest{Module: "web", JSON: man})
+ mkey, _ := delivery.ManifestsKey(digest)
+ if _, err := manifests.Put(mkey, mraw); err != nil {
+ t.Fatal(err)
+ }
+ head := delivery.Head{Repository: "novox/web", Number: 3, Head: strings.Repeat("3", 40)}
+ heads, _ := delivery.HeadsHash([]delivery.Head{head})
+ pulls := []delivery.CandidatePull{{Head: head, Modules: []string{"web"},
+ Fps: map[string]delivery.CandidateModule{"web": {FP2: fpIt("c"), Manifest: digest}}}}
+ craw, err := delivery.EncodeCandidate(delivery.Candidate{Group: "novox/web#3", HeadsHash: heads, Pulls: pulls,
+ FPSet: delivery.FingerprintSet(pulls)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ key, _ := delivery.CompositionKey("novox/web#3", heads)
+ if _, err := cands.Put(key, craw); err != nil {
+ t.Fatal(err)
+ }
+ before, _ := b.composed.Status()
+ got, err := composeCandidateFrom(itNode, &shadowNow)(key, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ raw, _ := got.(json.RawMessage)
+ a, err := delivery.DecodeComposeCandidateAnswer(raw)
+ if err != nil {
+ t.Fatalf("the answer %s: %v", raw, err)
+ }
+ if a.Verdict != delivery.ComposeCandidateComposed || len(a.Modules) != 1 || a.Modules[0].Module != "web" ||
+ a.Modules[0].FP != fpIt("c") || !a.Modules[0].Changed || a.Took > delivery.ComposeAnswerBound.Milliseconds() {
+ t.Errorf("the node's answer to a candidate changing web: %+v", a)
+ }
+ after, _ := b.composed.Status()
+ if h := b.have("web"); h.Composed != running || b.file("web") != "greeting=salut\n" || before == nil || after == nil ||
+ after.Values() != before.Values() {
+ t.Errorf("composing a candidate moved what runs or the composition state: %+v, %q", h, b.file("web"))
+ }
+ if _, err := b.composed.Get(mustComposedKey(t, "web", fpIt("c"))); err == nil {
+ t.Error("the candidate's composition was written into the composition state")
+ }
+}
+
+func mustComposedKey(t *testing.T, module, fp string) string {
+ t.Helper()
+ key, err := delivery.ComposedKey(itNode, module, fp)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return key
}
diff --git a/cmd/mesh-host/nodeclare_test.go b/cmd/mesh-host/nodeclare_test.go
index 430fec3..f295ca5 100644
--- a/cmd/mesh-host/nodeclare_test.go
+++ b/cmd/mesh-host/nodeclare_test.go
@@ -22,6 +22,11 @@ func TestNoPackageHearsOrOrdersAWholeDeclaration(t *testing.T) {
if err != nil {
return err
}
+ // The composition moved in from mesh-sdk (novox/hq ADR 0593 §7) has a node's input field of the same name,
+ // ReadsModules, which says the node reads a module per resource; it is no reconciling of a declaration.
+ if d.IsDir() && path == filepath.Join(root, "internal", "compose") {
+ return filepath.SkipDir
+ }
if d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "nodeclare_test.go") {
return nil
}
diff --git a/go.mod b/go.mod
index 119dc8a..7ec0e0a 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,7 @@ module github.com/novox/mesh-host
go 1.26.0
require (
- git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
+ git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
golang.org/x/crypto v0.57.0
diff --git a/go.sum b/go.sum
index ebd4e0f..43cc4ec 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,5 @@
-git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
-git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
+git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
+git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
index 5d50426..c392947 100644
--- a/internal/agent/agent.go
+++ b/internal/agent/agent.go
@@ -15,7 +15,6 @@ package agent
import (
"bytes"
"context"
- "crypto/ed25519"
"encoding/json"
"fmt"
"sort"
@@ -26,6 +25,7 @@ import (
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/delivery"
+ "github.com/novox/mesh-host/internal/judge"
)
// Update is one change of a key in a watched bucket. Caught marks the end of the keys that were there when the watch
@@ -119,10 +119,6 @@ type Env struct {
// whose have names a composition the bucket keeps runs from that composition, never from the kept declaration
// (issue 924): the kept declaration is older than every composition applied since the switch.
KeptFilled func() map[string][]string
- // Trusted is the public keys a composition may be signed by: the controller's and the operator's (ADR 0539 §3). A
- // composition unsigned, signed by another key, or changed after it was signed is refused and nothing of it is
- // applied. Nil trusts no key, so nothing is applied.
- Trusted func() []ed25519.PublicKey
// Recorded says the engine holds a record of every resource of the composition, so it was applied and not removed
// since; nil says yes. A composition the kept declaration or a have says runs is applied again when it says no.
Recorded func(ctx context.Context, c delivery.Composed) bool
@@ -685,26 +681,14 @@ func (a *Agent) takeComposition(u Update) string {
}
now := compNow{Raw: u.Value, Rev: u.Revision}
c, err := delivery.DecodeComposed(u.Value)
- if err == nil && a.env.SelfComposed {
- // The node composed it itself: nothing crossed the bus, so nothing is signed (ADR 0547 §2).
+ if err == nil {
+ // The node composed it itself: it is applied as composed, never verified against a signer, since no composition
+ // read from the bus is applied (novox/hq ADR 0547 §2, ADR 0593 §6).
now.C = c
now.Dig, err = delivery.ComposedDigestOf(c)
if err != nil {
now.C = delivery.Composed{}
}
- } else if err == nil {
- var trusted []ed25519.PublicKey
- if a.env.Trusted != nil {
- trusted = a.env.Trusted()
- }
- // The signature before anything of the composition is used: its digest is what the want names.
- now.C = c
- now.Dig, err = delivery.VerifyComposed(c, trusted)
- if err != nil {
- now.C = delivery.Composed{}
- a.sayOnce("verify:"+u.Key, fmt.Sprintf("the composition %s is refused and nothing of it is applied: %v", u.Key, err))
- err = fmt.Errorf("refused: %w", err)
- }
} else {
a.sayOnce("decode:"+u.Key, fmt.Sprintf("the composition %s cannot be read, and nothing of it is applied: %v", u.Key, err))
}
@@ -1646,14 +1630,14 @@ func (a *Agent) waitsForYes(ctx context.Context, module string, w wantNow, comp
prior = true
}
}
- var j delivery.Judged
+ var j judge.Judged
switch {
case known:
- j = delivery.JudgeMove(&from, comp.C)
+ j = judge.JudgeMove(&from, comp.C)
case prior:
- j = delivery.Judged{Judgement: delivery.JudgedCannot, Words: "what " + module + " runs on this node cannot be read"}
+ j = judge.Judged{Judgement: delivery.JudgedCannot, Words: "what " + module + " runs on this node cannot be read"}
default:
- j = delivery.JudgeMove(nil, comp.C)
+ j = judge.JudgeMove(nil, comp.C)
}
switch j.Judgement {
case delivery.JudgedRecreates, delivery.JudgedMovesData, delivery.JudgedCannot:
diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go
index e6c128a..f2b3306 100644
--- a/internal/agent/agent_test.go
+++ b/internal/agent/agent_test.go
@@ -2,7 +2,6 @@ package agent
import (
"context"
- "crypto/ed25519"
"encoding/json"
"fmt"
"maps"
@@ -18,19 +17,6 @@ import (
const node = "n1"
-// The keys of the tests: the controller's and the operator's are trusted, a stranger's is not.
-var controllerPub, controllerKey = newKey()
-var operatorPub, operatorKey = newKey()
-var _, strangerKey = newKey()
-
-func newKey() (ed25519.PublicKey, ed25519.PrivateKey) {
- pub, key, err := ed25519.GenerateKey(nil)
- if err != nil {
- panic(err)
- }
- return pub, key
-}
-
func fpOf(c string) string { return delivery.SourcePrefix2 + strings.Repeat(c, 64) }
// A watcher the test writes to; it says Caught once the keys written before the watch began are out.
@@ -183,8 +169,7 @@ func newBed(t *testing.T) *bed {
b.settled = append(b.settled, m)
b.settledRan = append(b.settledRan, ran)
},
- Kept: func() map[string][]json.RawMessage { return b.kept },
- Trusted: func() []ed25519.PublicKey { return []ed25519.PublicKey{controllerPub, operatorPub} },
+ Kept: func() map[string][]json.RawMessage { return b.kept },
Say: func(s string) {
t.Log(s)
b.mu.Lock()
@@ -223,21 +208,17 @@ func composition(t *testing.T, module, fp string, data bool, rs ...json.RawMessa
if err != nil {
t.Fatal(err)
}
- return signedBy(t, c, key, controllerKey)
+ return encodedAs(t, c, key)
}
-// signedBy is c signed with signer, as the writer's code signs it: its key, its bytes and its digest.
-func signedBy(t *testing.T, c delivery.Composed, key string, signer ed25519.PrivateKey) (string, []byte, string) {
+// encodedAs is c as the node's composer hands it: unsigned (ADR 0593 §6), its key, its bytes and its digest.
+func encodedAs(t *testing.T, c delivery.Composed, key string) (string, []byte, string) {
t.Helper()
- signed, err := delivery.SignComposed(c, signer)
+ raw, err := delivery.EncodeComposed(c)
if err != nil {
t.Fatal(err)
}
- raw, err := delivery.EncodeComposed(signed)
- if err != nil {
- t.Fatal(err)
- }
- digest, err := delivery.ComposedDigestOf(signed)
+ digest, err := delivery.ComposedDigestOf(c)
if err != nil {
t.Fatal(err)
}
@@ -735,76 +716,6 @@ func TestTheNodesOwnCompositionComesBeforeAnyModules(t *testing.T) {
}
}
-// R6-A AC9: a composition signed by either trusted key is applied; one signed by another key, one changed after it was
-// signed and one unsigned are refused and nothing of them is applied (ADR 0539 §3).
-func TestOnlyACompositionSignedByATrustedKeyIsApplied(t *testing.T) {
- t.Run("with no trusted key", testWithNoTrustedKeyNothingIsApplied)
- b := newBed(t)
- stop := b.run()
- defer stop()
-
- plain := func(module, fp, content string) delivery.Composed {
- return delivery.Composed{Node: node, Module: module, FP: fp, Resources: []json.RawMessage{file(module, "x", content)}}
- }
- put := func(c delivery.Composed, signer ed25519.PrivateKey, tamper func([]byte) []byte) {
- key, _ := delivery.ComposedKey(node, c.Module, c.FP)
- var raw []byte
- var dig string
- if signer == nil {
- raw, _ = delivery.EncodeComposed(c)
- dig, _ = delivery.ComposedDigestOf(c)
- } else {
- _, raw, dig = signedBy(t, c, key, signer)
- }
- if tamper != nil {
- raw = tamper(raw)
- }
- b.comps.put(key, raw)
- wk, wraw := wantFor(t, c.Module, c.FP, dig, "")
- b.wants.put(wk, wraw)
- }
-
- // Refused: signed by a stranger, changed after signing, unsigned.
- put(plain("stranger", fpOf("1"), "x"), strangerKey, nil)
- put(plain("changed", fpOf("2"), "before"), controllerKey, func(raw []byte) []byte {
- return []byte(strings.Replace(string(raw), "before", "after!", 1))
- })
- put(plain("unsigned", fpOf("3"), "x"), nil, nil)
- time.Sleep(200 * time.Millisecond)
- if got := b.appliedList(); len(got) != 0 {
- t.Fatalf("a composition that is not signed by a trusted key was applied: %v", got)
- }
-
- // Applied: signed by the controller, signed by the operator.
- put(plain("bycontroller", fpOf("4"), "x"), controllerKey, nil)
- put(plain("byoperator", fpOf("5"), "x"), operatorKey, nil)
- eventually(t, "the trusted signers' compositions were not applied", func() bool { return len(b.appliedList()) == 2 })
- got := b.appliedList()
- if !(got[0] == "bycontroller" && got[1] == "byoperator") && !(got[0] == "byoperator" && got[1] == "bycontroller") {
- t.Fatalf("applied %v", got)
- }
- if h := b.haves.last("byoperator"); h.Phase != string(delivery.PhaseRunning) || h.FP != fpOf("5") {
- t.Fatalf("the operator's module has no running have: %+v", h)
- }
-}
-
-// With no key trusted, nothing is applied: a node that does not know its signers fails closed.
-func testWithNoTrustedKeyNothingIsApplied(t *testing.T) {
- b := newBed(t)
- b.env.Trusted = nil
- stop := b.run()
- defer stop()
- fp := fpOf("a")
- ck, craw, dig := composition(t, "web", fp, false, file("web", "x", "1"))
- b.comps.put(ck, craw)
- wk, wraw := wantFor(t, "web", fp, dig, "")
- b.wants.put(wk, wraw)
- time.Sleep(200 * time.Millisecond)
- if got := b.appliedList(); len(got) != 0 {
- t.Fatalf("applied with no trusted key: %v", got)
- }
-}
-
// What a module's apply must not take is every other module that runs, those the agent holds and those still running from
// the kept declaration (ADR 0429 §4): the resources of the kept declaration reach Apply as the others.
func TestApplyingOneModuleHoldsOutTheModulesStillRunningFromTheKeptDeclaration(t *testing.T) {
@@ -860,7 +771,7 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) {
// and is what runs from before the first composition is watched; an apply keeps, a removal forgets.
t.Run("what this engine applied is what runs before the node composes its own", func(t *testing.T) {
b := newBed(t)
- b.env.SelfComposed, b.env.Trusted, b.kept = true, nil, nil
+ b.env.SelfComposed, b.kept = true, nil
store := &memApplied{kept: map[string][]byte{}}
b.env.Applied = store
// A folded module's resource in the node's own composition: its id names the module, as the controller folds it.
@@ -930,7 +841,7 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) {
h.Write(context.Background(), Have{Module: NodeModule, Composed: "sha256:" + strings.Repeat("c", 64)})
firstPass := func(kept map[string][]json.RawMessage) []delivery.Composed {
b := newBed(t)
- b.env.SelfComposed, b.env.Trusted, b.kept, b.env.Applied, b.env.Haves = true, nil, kept, store, h
+ b.env.SelfComposed, b.kept, b.env.Applied, b.env.Haves = true, kept, store, h
firstCh := make(chan []delivery.Composed, 1)
comps := b.comps
var ag *Agent
@@ -1155,8 +1066,7 @@ func testARemovedModulesCompositionHoldsNothingBack(t *testing.T) {
}
}
-// The node's own composition is not applied while a module's composition in the node's bucket cannot be read or is not
-// signed by a trusted key: what that one carries may be what left the node's own, and the node's own apply would remove
+// The node's own composition is not applied while a module's composition in the node's bucket cannot be read: what that one carries may be what left the node's own, and the node's own apply would remove
// it (issue 1945, the review's gap 2). It is said, naming the composition, and applied once that one reads.
func TestTheNodesOwnWaitsWhileAModulesCompositionCannotBeRead(t *testing.T) {
b := newBed(t)
@@ -1284,11 +1194,11 @@ func TestWhatRunsBeforeTheAgentTakesAModuleNamesItsFilledPaths(t *testing.T) {
ck, _ := delivery.ComposedKey(node, "power", fp)
applied := delivery.Composed{Node: node, Module: "power", FP: fp, Filled: []string{old, added}, Data: true,
Resources: []json.RawMessage{filledFile("settings"), filledFile("bound")}}
- _, appliedRaw, appliedDig := signedBy(t, applied, ck, controllerKey)
+ _, appliedRaw, appliedDig := encodedAs(t, applied, ck)
newer := applied
newer.Resources = append([]json.RawMessage{}, applied.Resources...)
newer.Resources = append(newer.Resources, file("power", "later", "1"))
- _, newerRaw, _ := signedBy(t, newer, ck, controllerKey)
+ _, newerRaw, _ := encodedAs(t, newer, ck)
for _, tc := range []struct {
name string
// have is the module's have; bucket the value its composition key holds now, history the revisions kept.
@@ -1371,7 +1281,7 @@ func TestWhatRunsBeforeTheAgentTakesAModuleNamesItsFilledPaths(t *testing.T) {
// the yes for that composition, and is then applied.
func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(t *testing.T) {
b := newBed(t)
- b.env.SelfComposed, b.env.Trusted = true, nil
+ b.env.SelfComposed = true
b.env.Policy = func(_ context.Context, m string) string {
if m == "db" || m == "rec" {
return catalogue.PolicyRecord
@@ -1560,6 +1470,19 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
if _, err := b.ag.Composition(context.Background(), "db", "sha256:"+strings.Repeat("0", 64), false); err == nil {
t.Error("a digest no revision has was answered")
}
+ // novox/hq ADR 0593 §6: the engine publishes each composition with its plan in its composition state; the verb reads
+ // that revision by the digest its have and want name, its resources' (the plan left out of the digest).
+ planned, _ := delivery.DecodeComposed(draw)
+ planned.Plan = &catalogue.Plan{}
+ praw, err := delivery.EncodeComposed(planned)
+ if err != nil {
+ t.Fatal(err)
+ }
+ keep(dk, praw)
+ if view, err := b.ag.Composition(context.Background(), "db", ddig, false); err != nil || view.Composition.Digest != ddig ||
+ len(view.Revisions) != 3 || view.Revisions[2].Digest != ddig {
+ t.Errorf("db's published revision with its plan, asked by its digest, reads %+v (%v)", view, err)
+ }
b.comps.put(dk, draw)
eventually(t, "db's have stays waiting-yes after its composition went back to what runs", func() bool {
h := b.haves.last("db")
diff --git a/internal/agent/composition.go b/internal/agent/composition.go
index 71ea0e7..938c2aa 100644
--- a/internal/agent/composition.go
+++ b/internal/agent/composition.go
@@ -2,12 +2,12 @@ package agent
import (
"context"
- "crypto/ed25519"
"errors"
"fmt"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
+ "github.com/novox/mesh-host/internal/judge"
)
// The node-engine seat's read verb composition (issue 774): one module's composition beside what runs of it, so why a
@@ -31,7 +31,7 @@ type CompositionView struct {
// Judged is the move from what runs to the composition read, as the agent judges it before an apply.
Judged JudgedView `json:"judged"`
// Diff is every resource that differs from what runs, when asked.
- Diff []delivery.ResourceChange `json:"diff,omitempty"`
+ Diff []judge.ResourceChange `json:"diff,omitempty"`
}
// WantView is a want as the verb shows it.
@@ -171,7 +171,7 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo
}
view.Runs = rv
}
- j := delivery.JudgeMove(running, read.C)
+ j := judge.JudgeMove(running, read.C)
view.Judged = JudgedView{Judgement: string(j.Judgement), Words: j.Words}
if a.env.SelfComposed && a.env.Policy != nil && module != NodeModule && (!w.Present || w.Want.Yes != read.Dig) {
switch j.Judgement {
@@ -180,7 +180,7 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo
}
}
if diff {
- view.Diff = delivery.ChangedResources(running, read.C)
+ view.Diff = judge.ChangedResources(running, read.C)
}
return view, nil
}
@@ -193,8 +193,8 @@ func (a *Agent) compositionKey(module, fp string) (string, error) {
return delivery.ComposedKey(a.env.Node, module, fp)
}
-// revisionsOf is every revision of a composition key the bucket keeps, oldest first, each with its digest as this node
-// trusts it (its own unsigned with SelfComposed, else signed by a trusted key), or why it is refused. Nil Revisions
+// revisionsOf is every revision of a composition key the state keeps, oldest first, each with its digest (unsigned, ADR
+// 0593 §6), or why it is refused. Nil Revisions
// reads none.
func (a *Agent) revisionsOf(ctx context.Context, key string) ([]compNow, error) {
if a.env.Revisions == nil {
@@ -204,10 +204,6 @@ func (a *Agent) revisionsOf(ctx context.Context, key string) ([]compNow, error)
if err != nil {
return nil, err
}
- var trusted []ed25519.PublicKey
- if a.env.Trusted != nil {
- trusted = a.env.Trusted()
- }
var out []compNow
for _, u := range revs {
if u.Deleted || u.Key != key {
@@ -218,12 +214,7 @@ func (a *Agent) revisionsOf(ctx context.Context, key string) ([]compNow, error)
out = append(out, compNow{Raw: u.Value, Rev: u.Revision, Err: err})
continue
}
- var dig string
- if a.env.SelfComposed {
- dig, err = delivery.ComposedDigestOf(c) // the node's own: unsigned (ADR 0547 §2)
- } else {
- dig, err = delivery.VerifyComposed(c, trusted)
- }
+ dig, err := delivery.ComposedDigestOf(c) // unsigned: a record, never verified (ADR 0593 §6)
out = append(out, compNow{Raw: u.Value, C: c, Dig: dig, Rev: u.Revision, Err: err})
}
return out, nil
diff --git a/internal/beside/beside.go b/internal/beside/beside.go
new file mode 100644
index 0000000..d31ec1d
--- /dev/null
+++ b/internal/beside/beside.go
@@ -0,0 +1,134 @@
+// Package beside is where a test finds another repository of the mesh it reads: the catalogue's manifests,
+// the node-engine's genesis template (novox/hq issue 432), the SDK's conformance fixtures (issue 449).
+//
+// Copied from mesh-controller internal/beside at commit 6c2a8d62 (R4b, issue 1379); the one change is that Dir
+// lays out the captured copy of a repository once per process, not once per test (see laidOut). In mesh-sdk the
+// catalogue's tests read mesh-catalog and mesh-host only; the captured copy holds just what they read.
+//
+// A test used to read the checkout beside this one, `../../../mesh-catalog`, so its verdict depended on
+// whatever sat on the machine running it: a stale or dirty checkout failed it on a desktop, and where none
+// was beside it skipped and said nothing. Now there are two inputs, both named, and no third:
+//
+// - In a merge check, the build seat clones each core repository beside the one checked (the catalogue
+// at its main, the node-engine at the commit the mesh runs) and says where in MESH_CHECK_BESIDE. A
+// test judges against those clones, so agreement with the other repository is checked where
+// `mesh/repo-check` runs; a repository missing there fails the test, never skips it. Which clones a
+// check gets is chosen from the inventory: mesh-catalog by the source of the `nats` module, mesh-host
+// by the source of `mesh-host`, and mesh-sdk as the controller's sibling at the commit the controller's
+// go.mod pins. Each repository's tests read these siblings, and the checker places exactly these
+// (mesh-delivery's checker, besideRules, follows this list): mesh-controller's read mesh-catalog,
+// mesh-host and mesh-sdk; mesh-host's read mesh-catalog, mesh-sdk and mesh-controller (its step-nine
+// test reads the controller's own manifest, novox/hq issue 1295); mesh-sdk's read mesh-catalog and
+// mesh-host; mesh-catalog's read mesh-sdk and mesh-controller; mesh-delivery's read mesh-sdk and
+// mesh-catalog. In a mesh where either module has no source repository nothing is
+// cloned, and these tests fail loudly with "not beside this check": a cause in the setup, not in the
+// change. And in a delivery group that holds a mesh-catalog pull request, these tests read the
+// catalogue's main, not the group's head.
+// - Anywhere else, the test judges against the copy captured in this repository's testdata/beside, at the
+// commit testdata/beside/CAPTURED names. Never against a developer's own checkout: to judge one, set
+// MESH_CHECK_BESIDE to the directory holding it, as the check does.
+//
+// The captured manifests are named module.json.captured, and put back as module.json in a directory of
+// the test's own: a module.json anywhere in this repository is a module of it to the forge's announcer and
+// the planner, and a merge would build and register a hundred copies of the catalogue's.
+package beside
+
+import (
+ "io/fs"
+ "os"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "sync"
+ "testing"
+)
+
+// Env is where a merge check says the repositories cloned beside the one checked are (internal/builder's
+// EnvBeside, repeated here so a test does not import the builder).
+const Env = "MESH_CHECK_BESIDE"
+
+// CapturedSuffix is what a captured file's name carries that the repository's own does not.
+const CapturedSuffix = ".captured"
+
+// Dir is the named repository a test reads — the clone beside a merge check, or the captured copy — and
+// says which in the test's log.
+func Dir(t testing.TB, repository string) string {
+ t.Helper()
+ if root := os.Getenv(Env); root != "" {
+ dir := filepath.Join(root, repository)
+ if _, err := os.Stat(dir); err != nil {
+ t.Fatalf("%s is not beside this check in %s=%s, so its agreement with this repository cannot be "+
+ "judged here: %v", repository, Env, root, err)
+ }
+ t.Logf("judged against %s as cloned beside this check", dir)
+ return dir
+ }
+ from := filepath.Join(Captured(), repository)
+ if _, err := os.Stat(from); err != nil {
+ t.Fatalf("no captured copy of %s at %s: %v", repository, from, err)
+ }
+ dir, err := layOut(repository, from)
+ if err != nil {
+ t.Fatalf("the captured copy of %s could not be laid out: %v", repository, err)
+ }
+ t.Logf("judged against the copy of %s captured in testdata/beside (see CAPTURED); a merge check "+
+ "judges it against the repository's own clone", repository)
+ return dir
+}
+
+// laidOut is the captured copies already put back in place in this process, by repository. A copy is
+// read-only to every test, so it is laid out once per process and not once per test: with a copy per test
+// the catalogue's tests alone took 2.14 s, over mesh-sdk's unit bound of 2 s, and with one 0.81 s (measured
+// 2026-10-20). The directories are in the temp directory, which the system clears; a process cannot remove
+// them while another test may still read them.
+var (
+ laidOutMu sync.Mutex
+ laidOut = map[string]string{}
+)
+
+func layOut(repository, from string) (string, error) {
+ laidOutMu.Lock()
+ defer laidOutMu.Unlock()
+ if dir, ok := laidOut[repository]; ok {
+ return dir, nil
+ }
+ root, err := os.MkdirTemp("", "beside-")
+ if err != nil {
+ return "", err
+ }
+ dir := filepath.Join(root, repository)
+ err = filepath.WalkDir(from, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ rel, _ := filepath.Rel(from, path)
+ into := filepath.Join(dir, strings.TrimSuffix(rel, CapturedSuffix))
+ if d.IsDir() {
+ return os.MkdirAll(into, 0o755)
+ }
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(into, raw, 0o644)
+ })
+ if err != nil {
+ os.RemoveAll(root)
+ return "", err
+ }
+ laidOut[repository] = dir
+ return dir, nil
+}
+
+// Catalogue is the catalogue's modules directory, as Dir finds the catalogue.
+func Catalogue(t testing.TB) string {
+ t.Helper()
+ return filepath.Join(Dir(t, "mesh-catalog"), "modules")
+}
+
+// Captured is this repository's testdata/beside, found from this file rather than from the working
+// directory, so a test in any package reaches it.
+func Captured() string {
+ _, file, _, _ := runtime.Caller(0)
+ return filepath.Join(filepath.Dir(file), "..", "..", "testdata", "beside")
+}
diff --git a/internal/beside/reads-outside b/internal/beside/reads-outside
new file mode 100644
index 0000000..f6b2d87
--- /dev/null
+++ b/internal/beside/reads-outside
@@ -0,0 +1 @@
+These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
diff --git a/internal/bootstrap/genesis_form_test.go b/internal/bootstrap/genesis_form_test.go
index dda3852..b196c84 100644
--- a/internal/bootstrap/genesis_form_test.go
+++ b/internal/bootstrap/genesis_form_test.go
@@ -237,35 +237,3 @@ func TestAFileNothingOfTheControllerWritesIsStillRefused(t *testing.T) {
t.Fatalf("a file nothing writes was accepted, or refused without its name: %v", err)
}
}
-
-// The controller's own manifest, as the mesh runs it, passes step 9 (issue 597): in a merge check the
-// node-engine's check has the controller cloned beside it (MESH_CHECK_BESIDE), where it must be; else a
-// checkout beside this one; else it skips aloud.
-//
-// The checker places the controller beside this repository (mesh-delivery's besideRules, since R4g, issue 1295), so a
-// MESH_CHECK_BESIDE without a mesh-controller directory, or without its module.json, fails. Only a run with no
-// MESH_CHECK_BESIDE and no checkout beside this one (a developer's clone alone) skips aloud.
-func TestTheControllersOwnManifestPassesStepNine(t *testing.T) {
- var manifest []byte
- if root := os.Getenv("MESH_CHECK_BESIDE"); root != "" {
- if info, err := os.Stat(filepath.Join(root, "mesh-controller")); err != nil || !info.IsDir() {
- t.Fatalf("the controller is not beside this check in MESH_CHECK_BESIDE=%s: the checker places mesh-controller beside the node-engine's check (mesh-delivery besideRules, issue 1295), and this test judges the controller's own manifest", root)
- }
- raw, err := os.ReadFile(filepath.Join(root, "mesh-controller", "module.json"))
- if err != nil {
- t.Fatalf("the controller is not beside this check in MESH_CHECK_BESIDE=%s: %v", root, err)
- }
- manifest = raw
- } else if raw, err := os.ReadFile(filepath.Join("..", "..", "..", "mesh-controller", "module.json")); err == nil {
- manifest = raw
- } else {
- t.Skip("NOT JUDGED: no controller beside this checkout and no MESH_CHECK_BESIDE; the merge check judges it")
- }
- wanted, err := theStepNineSecretsOf(t, manifest)
- if err != nil {
- t.Fatalf("genesis's step 9 refuses the controller the mesh runs: %v", err)
- }
- if wanted["MESH_STORE_INVENTORY"] != "inventory" {
- t.Errorf("step 9 delivers %v", wanted)
- }
-}
diff --git a/internal/compose/composed.go b/internal/compose/composed.go
new file mode 100644
index 0000000..fffd0b8
--- /dev/null
+++ b/internal/compose/composed.go
@@ -0,0 +1,560 @@
+package compose
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "slices"
+ "sort"
+ "strings"
+
+ "git.novox.be/novox/mesh-sdk/go/catalogue"
+ "git.novox.be/novox/mesh-sdk/go/declaration"
+ "git.novox.be/novox/mesh-sdk/go/delivery"
+ "github.com/novox/mesh-host/internal/compose/resolution"
+ "github.com/novox/mesh-host/internal/overlay"
+)
+
+// Compositions is one node's compositions (novox/hq ADR 0539 clauses 1 and 2): its declaration composed over in, as
+// the controller composes it, split into each module's composition at the fingerprint fps gives it and the node's own
+// (`.node`), by key (delivery.ComposedKey). Each resource says its module, whatever the node reported it reads.
+// A module fps gives no fingerprint for is not composed and is named in skipped, so a caller composing one module
+// gets that one; but for a module the node runs inside its own composition (Node.InOwn, and the
+// private network's always), which fps names at no fingerprint of the new form: its resources go into the node's own,
+// their module taken off and their ids still naming it, and its filled paths with them, as the controller's composed
+// writer folds them (issue 1126). Each module's Data says whether its manifest declares data. The compositions are unsigned
+// (ADR 0593 §6) and not yet by digest (Composed.ByDigest).
+func Compositions(in Input, node string, fps map[string]string, mode Mode) (out map[string]delivery.Composed, skipped []string, err error) {
+ out, skipped, _, err = compositions(in, node, fps, mode, false)
+ return out, skipped, err
+}
+
+// ErrStoodIn is a composition that holds a stand-in for a value the input does not carry (an own secret, a grant's
+// sealed copy, a vault box, the certificate, the bus's user list) or a port it does not give: what a send would make,
+// never what may be applied.
+var ErrStoodIn = errors.New("compose: the composition stands in for what its input does not carry")
+
+// BusUsersStandIn is what a composition says it stood in for when its input carries no bus user list.
+const BusUsersStandIn = "the bus's user list"
+
+// CompositionsApplied is Compositions for a node that applies what it composed itself (novox/hq ADR 0547 §2, issue
+// 1126): a declaration that stands in for anything (Declared.StoodIn, Foreseen, ForeseenPorts) is refused with
+// ErrStoodIn, naming each, so a stand-in is never applied as if it were the value; but for the bus's user list
+// (BusUsersStandIn), which the bus node holds out of every apply of a composition it made itself.
+func CompositionsApplied(in Input, node string, fps map[string]string) (map[string]delivery.Composed, []string, error) {
+ out, skipped, _, err := compositions(in, node, fps, Reading, true)
+ return out, skipped, err
+}
+
+// CompositionsShadow is CompositionsApplied for a composition that is compared and never applied (a node's answer to a
+// placement candidate, novox/hq ADR 0559 §4): composed in the Foreseeing mode, so an own secret never issued (a module
+// the candidate adds) is a named stand-in rather than an error, and what it stands in for is answered (stood, sorted,
+// the bus's user list left out) rather than refused, so the caller judges each stand-in: a value not yet issued for a
+// module the candidate adds, or one missing for a module the node runs. A module whose every value is held composes
+// as CompositionsApplied composes it: the modes differ only in an own secret never issued.
+func CompositionsShadow(in Input, node string, fps map[string]string) (out map[string]delivery.Composed, skipped, stood []string, err error) {
+ return compositions(in, node, fps, Foreseeing, false)
+}
+
+// CompositionsIn is the node's compositions in mode with what they stand in for answered (stood, sorted, the bus's user
+// list left out) rather than refused: CompositionsShadow in Foreseeing, and in Reading what CompositionsApplied refuses
+// before it is refused, so a caller that applies refuses each stand-in to the module it is for alone (issue 1126).
+func CompositionsIn(in Input, node string, fps map[string]string, mode Mode) (out map[string]delivery.Composed, skipped, stood []string, err error) {
+ return compositions(in, node, fps, mode, false)
+}
+
+func compositions(in Input, node string, fps map[string]string, mode Mode, applied bool) (out map[string]delivery.Composed, skipped, stood []string, err error) {
+ reading := in
+ reading.Nodes = append([]Node(nil), in.Nodes...)
+ found := false
+ inOwn := map[string]bool{}
+ held := map[string]bool{}
+ for i := range reading.Nodes {
+ if reading.Nodes[i].Name == node {
+ reading.Nodes[i].ReadsModules = true
+ found = true
+ for m, ok := range reading.Nodes[i].InOwn {
+ inOwn[m] = ok
+ }
+ for m, ok := range reading.Nodes[i].Held {
+ held[m] = ok
+ }
+ }
+ }
+ // The private network's module is built by nobody: no fingerprint of the new form ever names it.
+ inOwn[overlay.Name] = true
+ folds := func(module string) bool {
+ return module != "" && inOwn[module] && !strings.HasPrefix(fps[module], delivery.SourcePrefix2)
+ }
+ if !found {
+ return nil, nil, nil, fmt.Errorf("compose: the mesh has no node %q", node)
+ }
+ // A folded module built by a build of the old form (Node.RunsOwn) is left to the node: composed from what runs of it
+ // (Carried), so what it provides and requires still shapes the rest and what the others read of it (its published
+ // ports, the paths it owns) is what runs, and its resources are then the ones the node runs.
+ carried := map[string]bool{}
+ var runsOwn *delivery.Composed
+ for i := range reading.Nodes {
+ n := &reading.Nodes[i]
+ if n.Name != node || n.RunsOwn == nil {
+ continue
+ }
+ runsOwn = n.RunsOwn
+ candidates := map[string]bool{}
+ for m := range inOwn {
+ candidates[m] = true
+ }
+ for m := range held {
+ candidates[m] = true
+ }
+ for m := range candidates {
+ // A held module is carried as it runs wherever the node's own runs it, folded or leaving the fold for a
+ // composition of its own (its want new, its names still unset): its stub is never what the node's own applies,
+ // and what the node's own applies never drops what runs of it (issue 1126, review of the apply; issue 1945).
+ heldHere := held[m] && RunsAnyOf(runsOwn, m)
+ if !folds(m) && !heldHere {
+ continue
+ }
+ man, ok := n.Kept[m]
+ if !ok {
+ man, ok = reading.Catalogue[m]
+ }
+ if !ok || (!NamesBuild(man) && !held[m]) {
+ continue
+ }
+ carried[m] = true
+ kept := make(map[string]catalogue.Manifest, len(n.Kept)+1)
+ for k, v := range n.Kept {
+ kept[k] = v
+ }
+ kept[m] = Carried(man, m, runsOwn)
+ n.Kept = kept
+ }
+ }
+ // A module composed as what runs of it contributes to the runtime what the runtime serves of it as it runs
+ // (Node.RunsTools); one of its own account is served by a runtime of its own, from its manifest as before.
+ for i := range reading.Nodes {
+ n := &reading.Nodes[i]
+ if n.Name != node || n.RunsTools == nil {
+ continue
+ }
+ kept := make(map[string]catalogue.Manifest, len(n.Kept))
+ for m, man := range n.Kept {
+ if man.Stubbed && man.RunsAs == "" {
+ man.Bundles = runningBundles(n.RunsTools[m], reading.Catalogue[m])
+ }
+ kept[m] = man
+ }
+ n.Kept = kept
+ }
+ isCarried := func(module, id string) bool {
+ if module == "" {
+ module, _ = declaration.ModuleGuess(id)
+ }
+ return carried[module]
+ }
+ d, problems, err := ComposeIn(reading, node, mode)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ if len(problems) > 0 {
+ return nil, nil, nil, fmt.Errorf("compose: %s's declaration is refused by the node-engine's validator: %v", node, problems)
+ }
+ for _, x := range d.StoodIn {
+ // The bus's user list is the bus node's own to follow, written alone and never from a composition the node
+ // made itself (ADR 0547 §7, §8): its stand-in is held out of the apply, not applied.
+ if x != BusUsersStandIn {
+ stood = append(stood, x)
+ }
+ }
+ stood = append(stood, d.Foreseen...)
+ for _, p := range d.ForeseenPorts {
+ stood = append(stood, "port "+p)
+ }
+ sort.Strings(stood)
+ stood = slices.Compact(stood) // an own secret is both stood in for and foreseen
+ if applied && len(stood) > 0 {
+ return nil, nil, nil, fmt.Errorf("%w on %s: %s", ErrStoodIn, node, strings.Join(stood, ", "))
+ }
+ body, err := d.Body()
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ var envelope struct {
+ Resources []json.RawMessage `json:"resources"`
+ Adoption *declaration.Adoption `json:"adoption"`
+ Filled []string `json:"filled_files"`
+ Provisioner string `json:"provisioner_account"`
+ }
+ if err := json.Unmarshal(body, &envelope); err != nil {
+ return nil, nil, nil, err
+ }
+ var kept []json.RawMessage
+ keptPaths := map[string]bool{}
+ foldedPaths := map[string]bool{}
+ left := map[string]bool{}
+ for _, raw := range envelope.Resources {
+ var head struct {
+ ID string `json:"id"`
+ Module string `json:"module"`
+ Type string `json:"type"`
+ Path string `json:"path"`
+ }
+ if err := json.Unmarshal(raw, &head); err != nil {
+ return nil, nil, nil, err
+ }
+ if isCarried(head.Module, head.ID) {
+ continue // what the stub composed of it: the node's own carries what runs of it instead
+ }
+ if folds(head.Module) {
+ // Into the node's own, in its place among the node's resources, its module taken off (mesh-controller's
+ // compositionsOf): the node's own composition is then the controller's, byte for byte.
+ var fields map[string]json.RawMessage
+ if err := json.Unmarshal(raw, &fields); err != nil {
+ return nil, nil, nil, err
+ }
+ delete(fields, "module")
+ if raw, err = json.Marshal(fields); err != nil {
+ return nil, nil, nil, err
+ }
+ if head.Type == string(declaration.TypeFile) {
+ foldedPaths[head.Path] = true
+ }
+ kept = append(kept, raw)
+ continue
+ }
+ if _, ok := fps[head.Module]; head.Module != "" && !ok {
+ left[head.Module] = true
+ continue
+ }
+ if head.Type == string(declaration.TypeFile) {
+ keptPaths[head.Path] = true
+ }
+ kept = append(kept, raw)
+ }
+ // What runs of each carried module, in the node's own composition, each after the resource it runs after.
+ if len(carried) > 0 {
+ var err error
+ if kept, foldedPaths, err = carry(kept, foldedPaths, runsOwn, isCarried); err != nil {
+ return nil, nil, nil, err
+ }
+ for _, p := range runsOwn.Filled {
+ if foldedPaths[p] && !slices.Contains(envelope.Filled, p) {
+ envelope.Filled = append(envelope.Filled, p)
+ }
+ }
+ }
+ // A filled file of a module's own composition goes with that module (SplitComposed); one of a folded module goes with
+ // the node's own, after them, in the declaration's order; one of a module left out is dropped.
+ var filled, ownFilled []string
+ for _, p := range envelope.Filled {
+ switch {
+ case foldedPaths[p]:
+ ownFilled = append(ownFilled, p)
+ case keptPaths[p]:
+ filled = append(filled, p)
+ }
+ }
+ out, err = delivery.SplitComposed(node, kept, fps, filled,
+ delivery.NodeOwn{Adoption: envelope.Adoption, Provisioner: envelope.Provisioner})
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ if len(ownFilled) > 0 {
+ own, err := delivery.ComposedNodeKey(node)
+ if err != nil {
+ return nil, nil, nil, err
+ }
+ c := out[own]
+ c.Filled = append(c.Filled, ownFilled...)
+ out[own] = c
+ }
+ for key, c := range out {
+ if held[c.Module] {
+ delete(out, key) // its stub's: the node keeps what it last applied of it (Node.Held)
+ continue
+ }
+ if m, ok := in.Catalogue[c.Module]; ok && c.Module != "" {
+ c.Data = len(m.DataItems()) > 0
+ out[key] = c
+ }
+ }
+ for m := range left {
+ skipped = append(skipped, m)
+ }
+ sort.Strings(skipped)
+ return out, skipped, stood, nil
+}
+
+// RunsAnyOf says runs (a composition as it runs) holds a resource of module, by its module or the module its id names.
+func RunsAnyOf(runs *delivery.Composed, module string) bool {
+ if runs == nil {
+ return false
+ }
+ for _, raw := range runs.Resources {
+ var head struct {
+ ID string `json:"id"`
+ Module string `json:"module"`
+ }
+ if json.Unmarshal(raw, &head) != nil {
+ continue
+ }
+ owner := head.Module
+ if owner == "" {
+ owner, _ = declaration.ModuleGuess(head.ID)
+ }
+ if owner == module {
+ return true
+ }
+ }
+ return false
+}
+
+// ToolsRunning is, by module, the tools bundles that run on the node (Node.RunsTools): each bundle archive a module's
+// resource unpacks at its BundlePath, with what the runtime's own process loads of it and the words it hands it
+// (catalogue.RuntimeToolModules, catalogue.RuntimeToolEnv, the mesh's own words left out). A bundle the runtime loads
+// nothing of has no Loads: the runtime dropped it, and the composition reads its loads again from the module's build
+// (issue 1126, review: shanks had already applied the drop of systemd-resolved). Nil when running holds no runtime process.
+func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle {
+ runtimeID := catalogue.RuntimeModule + "." + resolution.RuntimeProcessID()
+ type resource struct {
+ ID string `json:"id"`
+ Module string `json:"module"`
+ Type string `json:"type"`
+ Path string `json:"path"`
+ Source string `json:"source"`
+ Digest string `json:"digest"`
+ Env map[string]string `json:"env"`
+ }
+ var env map[string]string
+ out := map[string][]catalogue.Bundle{}
+ for _, c := range running {
+ for _, raw := range c.Resources {
+ var r resource
+ if json.Unmarshal(raw, &r) != nil {
+ continue
+ }
+ owner := r.Module
+ if owner == "" {
+ owner, _ = declaration.ModuleGuess(r.ID)
+ }
+ switch {
+ case r.Type == "process" && r.ID == runtimeID && owner == catalogue.RuntimeModule:
+ env = r.Env
+ case r.Type == "archive" && owner != "" && strings.HasPrefix(r.ID, owner+"."+resolution.BundleID("")):
+ name := strings.TrimPrefix(r.ID, owner+"."+resolution.BundleID(""))
+ if r.Path == resolution.BundlePath(owner, name) {
+ out[owner] = append(out[owner], catalogue.Bundle{Name: name, Source: catalogue.Recorded(r.Source), Digest: r.Digest})
+ }
+ }
+ }
+ }
+ if env == nil {
+ return nil
+ }
+ var words map[string]map[string]string
+ _ = json.Unmarshal([]byte(env[catalogue.RuntimeToolEnv]), &words)
+ for _, entry := range strings.Split(env[catalogue.RuntimeToolModules], ",") {
+ module, path, ok := strings.Cut(entry, "=")
+ if !ok || !strings.HasPrefix(path, catalogue.BundleRoot+"/"+module+"/") {
+ continue
+ }
+ name, load, ok := strings.Cut(strings.TrimPrefix(path, catalogue.BundleRoot+"/"+module+"/"), "/")
+ bundles := out[module]
+ i := slices.IndexFunc(bundles, func(b catalogue.Bundle) bool { return b.Name == name })
+ if !ok || i < 0 {
+ continue // no archive of it runs: nothing to load
+ }
+ var own map[string]string
+ for w, v := range words[module] {
+ if w == catalogue.FilesOwnedPaths || w == catalogue.ReadsSecretPlaces {
+ continue // the mesh's to say, composed again
+ }
+ if own == nil {
+ own = map[string]string{}
+ }
+ own[w] = v
+ }
+ bundles[i].Loads = append(bundles[i].Loads, load)
+ bundles[i].Env = own
+ }
+ for _, bundles := range out {
+ sort.Slice(bundles, func(i, j int) bool { return bundles[i].Name < bundles[j].Name })
+ }
+ return out
+}
+
+// runningBundles is the bundles of a module composed as what runs of it (Node.RunsTools): each that runs, with what the
+// runtime loads of it and its words; one the runtime loads nothing of takes its loads and words from the module's build
+// (man: its built bundles, else its build's artifacts, catalogue.BundleLoads), so a drop already applied is undone.
+func runningBundles(runs []catalogue.Bundle, man catalogue.Manifest) []catalogue.Bundle {
+ var out []catalogue.Bundle
+ for _, b := range runs {
+ if len(b.Loads) == 0 {
+ for _, built := range man.Bundles {
+ if built.Name == b.Name {
+ b.Loads, b.Env, b.Launchers, b.Binary = built.Loads, built.Env, built.Launchers, built.Binary
+ }
+ }
+ if man.Build != nil {
+ run := resolution.RunByAResource(man)
+ for _, a := range man.Build.Artifacts {
+ if a.Kind == catalogue.ArtifactBundle && a.Name == b.Name {
+ b.Loads, b.Env, b.Binary = catalogue.BundleLoads(man, a, run), a.Env, catalogue.BinaryOf(a)
+ }
+ }
+ }
+ }
+ out = append(out, b)
+ }
+ return out
+}
+
+// NamesBuild says a manifest names what a build makes: its build, or a resource naming an artifact. A node cannot
+// compose such a module folded into its own composition: the build of the old form it runs is held by the controller
+// alone (issue 1126).
+func NamesBuild(m catalogue.Manifest) bool {
+ if m.Build != nil {
+ return true
+ }
+ for _, r := range m.Resources {
+ if _, ok := r["artifact"]; ok {
+ return true
+ }
+ }
+ return false
+}
+
+// Stub is m with nothing to build and no resource but its directories: what it provides, requires, serves and grants
+// still shape the rest of the composition, the places its own secrets, data and grants name (${dir:…}) still resolve,
+// and nothing else of its own is composed (issue 1126: nats's ${dir:mesh-state} on novox). A module refused in a
+// node's answer is composed so; a folded one the node runs is Carried.
+func Stub(m catalogue.Manifest) catalogue.Manifest {
+ // The paths it owns stay its manifest's, as the controller tells the holder of node-files them (issue 1126, seed-8).
+ if m.OwnsPaths == nil {
+ m.OwnsPaths = resolution.DeclaredPaths(m)
+ }
+ m.Build = nil
+ // What it hands the provisioner is left out as it runs, not refused for want of a plan (catalogue.Manifest.Stubbed).
+ m.Stubbed = true
+ // Nor is a template its build kept for its own files handed: a stub carries none of them (issue 1126: novox's node
+ // refused "postgres: its build kept a template for consumers, which it does not declare" with postgres held). Its
+ // items stay, and the templates they render through: a held module's contributions to other modules' collections
+ // (seat contributions, shell blocks, seat data) still reach their collectors.
+ if m.Handed != nil {
+ h := *m.Handed
+ h.Templates = nil
+ m.Handed = &h
+ }
+ var dirs []map[string]any
+ for _, r := range m.Resources {
+ if r["type"] == "directory" {
+ dirs = append(dirs, r)
+ }
+ }
+ m.Resources = dirs
+ return m
+}
+
+// Carried is m, folded into its node's own composition at a build of the old form, as the node runs it (issue 1126,
+// C5): nothing to build; its containers and directories those the node's own composition (runs) carries of it, their
+// module's word taken off their ids, with the manifest's directories it does not run. So what other modules read of it
+// is what runs: the machine ports its containers publish (the filter's rules, the routes reaching it: MachineSide). The
+// paths it owns, which the holder of node-files is told, are its manifest's, as for its Stub: the controller composes
+// them from the manifest, not from every path a resource of it runs at (issue 1126, seed-8: f1-circuits, snake and
+// txt-game on ace, whose manifests leave their directories to the mesh). Its other resources are not taken, since the
+// composition makes some of them again from its manifest (its secrets' files) and one id twice is refused; none of them
+// is composed into what is applied either way (compositions leaves a carried module's out, and carries the ones that
+// run). What it provides, requires, listens on and grants is its manifest's. A module whose resources runs holds none
+// of is its Stub, as before.
+func Carried(m catalogue.Manifest, module string, runs *delivery.Composed) catalogue.Manifest {
+ if runs == nil {
+ return Stub(m)
+ }
+ var taken []map[string]any
+ runsAny := false
+ ids := map[string]bool{}
+ for _, raw := range runs.Resources {
+ var r map[string]any
+ if json.Unmarshal(raw, &r) != nil {
+ continue
+ }
+ id, _ := r["id"].(string)
+ owner, _ := r["module"].(string)
+ if owner == "" {
+ owner, _ = declaration.ModuleGuess(id)
+ }
+ if owner != module {
+ continue
+ }
+ runsAny = true
+ if typ := r["type"]; typ != "container" && typ != "directory" {
+ continue
+ }
+ r["id"] = strings.TrimPrefix(id, module+".")
+ delete(r, "module")
+ ids[r["id"].(string)] = true
+ taken = append(taken, r)
+ }
+ if !runsAny {
+ return Stub(m)
+ }
+ stub := Stub(m)
+ for _, r := range stub.Resources {
+ if id, _ := r["id"].(string); !ids[id] {
+ taken = append(taken, r)
+ }
+ }
+ stub.Resources = taken
+ return stub
+}
+
+// carry puts into the node's resources (kept, in the declaration's order) the resources of the node's own composition as
+// it runs (runs) that belong to a carried module, each after the resource it runs after, or first when that one is
+// not among them; a file among them is a folded path.
+func carry(kept []json.RawMessage, foldedPaths map[string]bool, runs *delivery.Composed, carried func(module, id string) bool) ([]json.RawMessage, map[string]bool, error) {
+ idOf := func(raw json.RawMessage) (string, string, string, string, error) {
+ var head struct {
+ ID string `json:"id"`
+ Module string `json:"module"`
+ Type string `json:"type"`
+ Path string `json:"path"`
+ }
+ err := json.Unmarshal(raw, &head)
+ return head.ID, head.Module, head.Type, head.Path, err
+ }
+ present := map[string]bool{}
+ for _, raw := range kept {
+ if id, module, _, _, err := idOf(raw); err == nil && module == "" {
+ present[id] = true
+ }
+ }
+ // By the id of the resource of the node's own it runs after, "" for first: the last one before it that is composed.
+ after := map[string][]json.RawMessage{}
+ anchor := ""
+ for _, raw := range runs.Resources {
+ id, module, typ, path, err := idOf(raw)
+ if err != nil {
+ return nil, nil, fmt.Errorf("compose: a resource the node runs in its own composition: %w", err)
+ }
+ if !carried(module, id) {
+ if module == "" && present[id] {
+ anchor = id
+ }
+ continue
+ }
+ after[anchor] = append(after[anchor], raw)
+ if typ == string(declaration.TypeFile) {
+ foldedPaths[path] = true
+ }
+ }
+ out := make([]json.RawMessage, 0, len(kept)+len(runs.Resources))
+ out = append(out, after[""]...)
+ for _, raw := range kept {
+ out = append(out, raw)
+ if id, module, _, _, err := idOf(raw); err == nil && module == "" {
+ out = append(out, after[id]...)
+ }
+ }
+ return out, foldedPaths, nil
+}
diff --git a/internal/compose/composed_test.go b/internal/compose/composed_test.go
new file mode 100644
index 0000000..35091c1
--- /dev/null
+++ b/internal/compose/composed_test.go
@@ -0,0 +1,201 @@
+package compose
+
+import (
+ "encoding/json"
+ "sort"
+ "strings"
+ "testing"
+
+ "git.novox.be/novox/mesh-sdk/go/catalogue"
+ "git.novox.be/novox/mesh-sdk/go/delivery"
+ "github.com/novox/mesh-host/internal/compose/resolution"
+ "github.com/novox/mesh-host/internal/overlay"
+)
+
+func fpFor(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) }
+
+// 1943/AC5: a node's compositions are its declaration as Compose gives it, split by the module each resource belongs
+// to: every resource once, each in its module's composition at the fingerprint given, the module's ownership said on
+// it; a module given no fingerprint is named as skipped and composed nowhere.
+func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
+ in := aSmallMesh(t)
+ d, _, err := Compose(in, "laptop")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var want []string
+ for _, r := range d.Resources {
+ want = append(want, r["type"].(string)+":"+r["id"].(string))
+ }
+ sort.Strings(want)
+
+ fps := map[string]string{"mesh-wireguard": fpFor('a'), "files": fpFor('b')}
+ out, skipped, err := Compositions(in, "laptop", fps, Foreseeing)
+ if err != nil || len(skipped) != 0 {
+ t.Fatalf("Compositions = %v, skipped %v", err, skipped)
+ }
+ var got []string
+ for key, c := range out {
+ node, module, fp, isNode, err := delivery.ParseComposedKey(key)
+ if err != nil || node != "laptop" || module != c.Module || fp != c.FP || (isNode != (module == "")) {
+ t.Errorf("key %s holds %s at %s (%v)", key, c.Module, c.FP, err)
+ }
+ if !isNode && fp != fps[module] {
+ t.Errorf("%s is composed at %s, given %s", module, fp, fps[module])
+ }
+ if _, err := delivery.EncodeComposed(c); err != nil {
+ t.Errorf("%s: %v", key, err)
+ }
+ for _, raw := range c.Resources {
+ var r struct{ ID, Type, Module string }
+ _ = json.Unmarshal(raw, &r)
+ if r.Module != module {
+ t.Errorf("%s holds %s of %q", key, r.ID, r.Module)
+ }
+ got = append(got, r.Type+":"+r.ID)
+ }
+ }
+ sort.Strings(got)
+ if strings.Join(got, " ") != strings.Join(want, " ") {
+ t.Errorf("the compositions hold %v, the declaration %v", got, want)
+ }
+
+ one, skipped, err := Compositions(in, "laptop", map[string]string{"mesh-wireguard": fpFor('a')}, Foreseeing)
+ if err != nil || strings.Join(skipped, ",") != "files" {
+ t.Fatalf("one module: %v, skipped %v", err, skipped)
+ }
+ for _, c := range one {
+ if c.Module == "files" {
+ t.Errorf("a module given no fingerprint was composed")
+ }
+ }
+
+ // Issue 1126: a module the node runs inside its own composition (Node.InOwn), and the private network's always,
+ // given no fingerprint of the new form, is folded into the node's own as the controller's composed writer folds it:
+ // every resource in its place, its module taken off, its id still naming it, nothing skipped.
+ folding := in
+ folding.Nodes = append([]Node(nil), in.Nodes...)
+ for i := range folding.Nodes {
+ if folding.Nodes[i].Name == "laptop" {
+ folding.Nodes[i].InOwn = map[string]bool{"files": true}
+ }
+ }
+ all, skipped, err := Compositions(folding, "laptop", nil, Foreseeing)
+ nodeKey, _ := delivery.ComposedNodeKey("laptop")
+ if err != nil || len(skipped) != 0 || len(all) != 1 || len(all[nodeKey].Resources) != len(want) {
+ t.Fatalf("folded: %v, skipped %v, %d compositions, %d of %d resources in the node's own", err, skipped, len(all), len(all[nodeKey].Resources), len(want))
+ }
+ for i, raw := range all[nodeKey].Resources {
+ var r map[string]any
+ _ = json.Unmarshal(raw, &r)
+ if _, says := r["module"]; says || r["id"] != d.Resources[i]["id"] {
+ t.Errorf("folded resource %d is %v, the declaration's %v", i, r["id"], d.Resources[i]["id"])
+ }
+ }
+ if _, err := delivery.EncodeComposed(all[nodeKey]); err != nil {
+ t.Errorf("the node's own with the folded modules: %v", err)
+ }
+ if _, _, err := Compositions(in, "nowhere", fps, Foreseeing); err == nil {
+ t.Errorf("a node the mesh does not have was composed")
+ }
+
+ // Issue 1126, 2026-10-29: a module composed as what runs of it (folded at a build of the old form, or held) still
+ // contributes to the runtime what the runtime serves of it as it runs (Node.RunsTools): its manifest at the
+ // indexer's commit has no bundles (shanks's runtime lost the folded systemd-resolved, novox's the folded nats), and a
+ // held module's new build's bundles are not delivered.
+ dg := func(c byte) string { return "sha256:" + strings.Repeat(string(c), 64) }
+ runtime := catalogue.Manifest{Module: catalogue.RuntimeModule, Version: "1",
+ OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/node-tools/broker"}},
+ Bundles: []catalogue.Bundle{{Name: "runtime", Source: catalogue.ArtifactStoreScheme + "node-tools/runtime/blobs/" + dg('a'),
+ Digest: dg('a'), Language: "go", Binary: "node-tools"}}}
+ store := catalogue.Manifest{Module: "artifacts", Version: "1",
+ Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}}
+ lamp := catalogue.Manifest{Module: "lamp", Version: "1", Tools: []string{"on"},
+ Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/lamp", "mode": "0700"}},
+ Build: &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "tools", Kind: catalogue.ArtifactBundle,
+ Language: "go", System: "arch", From: "cmd/lamp-tools", Env: map[string]string{"LAMP_CONF": "/var/lib/lamp/conf"}}}}}
+ newLamp, err := lamp.Resolve([]catalogue.Built{{Name: "tools", Kind: catalogue.ArtifactBundle,
+ Reference: catalogue.ArtifactStoreScheme + "lamp/tools/blobs/" + dg('c'), Digest: dg('c')}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ served := "lamp=" + resolution.BundlePath("lamp", "tools") + "/lamp-tools"
+ runtimeRuns := func(dropped bool) delivery.Composed {
+ env := `"MESH_TOOL_MODULES":"` + served + `","MESH_TOOL_ENV":"{\"lamp\":{\"LAMP_CONF\":\"/var/lib/lamp/conf\"}}"`
+ if dropped {
+ env = `"MESH_TOOL_MODULES":""` // the drop already applied (shanks, 2026-10-29)
+ }
+ return delivery.Composed{Node: "anchor", Module: catalogue.RuntimeModule, Resources: []json.RawMessage{json.RawMessage(
+ `{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{` + env + `}}`)}}
+ }
+ lampRuns := `{"id":"lamp.bundle-tools","type":"archive","source":"` + catalogue.ArtifactStoreScheme + `lamp/tools/blobs/` + dg('b') +
+ `","digest":"` + dg('b') + `","path":"` + resolution.BundlePath("lamp", "tools") + `"}`
+ for _, row := range []struct {
+ name string
+ set func(n *Node) delivery.Composed // and what runs of lamp
+ fps map[string]string
+ dropped bool
+ }{
+ {"folded at a build of the old form", func(n *Node) delivery.Composed {
+ n.InOwn = map[string]bool{"lamp": true}
+ n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}}
+ return *n.RunsOwn
+ }, map[string]string{}, false},
+ {"folded, the runtime already dropped it", func(n *Node) delivery.Composed {
+ n.InOwn = map[string]bool{"lamp": true}
+ n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}}
+ return *n.RunsOwn
+ }, map[string]string{}, true},
+ {"held, its new build not delivered", func(n *Node) delivery.Composed {
+ n.Held = map[string]bool{"lamp": true}
+ lr := delivery.Composed{Node: "anchor", Module: "lamp", Resources: []json.RawMessage{json.RawMessage(
+ strings.Replace(lampRuns, `"type"`, `"module":"lamp","type"`, 1))}}
+ n.Kept = map[string]catalogue.Manifest{"lamp": Carried(newLamp, "lamp", &lr)}
+ return lr
+ }, map[string]string{"lamp": fpFor('1')}, false},
+ } {
+ tools := aMesh(t)
+ tools.Catalogue[catalogue.RuntimeModule], tools.Catalogue["artifacts"], tools.Catalogue["lamp"] = runtime, store, lamp
+ anchor := nodeOf(t, &tools, "anchor")
+ anchor.Assigned = append(anchor.Assigned, "artifacts", catalogue.RuntimeModule, "lamp")
+ tools.BusMinted = []string{"anchor." + catalogue.RuntimeModule}
+ anchor.SealingKey = keyOf(t, "anchor")
+ anchor.RunsTools = ToolsRunning([]delivery.Composed{runtimeRuns(row.dropped), row.set(anchor)})
+ row.fps[overlay.Name], row.fps["artifacts"], row.fps[catalogue.RuntimeModule] = fpFor('a'), fpFor('5'), fpFor('e')
+ out, _, _, err := CompositionsIn(tools, "anchor", row.fps, Foreseeing)
+ if err != nil {
+ t.Fatalf("%s: %v", row.name, err)
+ }
+ var env map[string]any
+ for _, c := range out {
+ for _, raw := range c.Resources {
+ var r struct {
+ ID string `json:"id"`
+ Env map[string]any `json:"env"`
+ }
+ if json.Unmarshal(raw, &r) == nil && r.ID == "node-tools.runtime" {
+ env = r.Env
+ }
+ }
+ }
+ if env[catalogue.RuntimeToolModules] != served || env[catalogue.RuntimeToolEnv] != `{"lamp":{"LAMP_CONF":"/var/lib/lamp/conf"}}` {
+ t.Errorf("%s: the runtime serves %v with %v, want what runs of lamp, %s", row.name,
+ env[catalogue.RuntimeToolModules], env[catalogue.RuntimeToolEnv], served)
+ }
+ }
+}
+
+// 1943/AC1: the upgrade policies and sources a composition's value may name are the catalogue's own words.
+func TestTheComposedPoliciesAreTheCatalogues(t *testing.T) {
+ for _, p := range []string{catalogue.PolicyRoll, catalogue.PolicyTogether, catalogue.PolicyRecord} {
+ for _, from := range []string{catalogue.FromPerson, catalogue.FromModule, catalogue.FromBus, catalogue.FromData, catalogue.FromDefault} {
+ v := delivery.ComposedDigest{Digest: "sha256:" + strings.Repeat("a", 64), Judgement: delivery.JudgedNeither, Policy: p, PolicyFrom: from}
+ if _, err := delivery.EncodeComposedDigest(v); err != nil {
+ t.Errorf("%s from %s: %v", p, from, err)
+ }
+ }
+ }
+ if delivery.PolicyRecord != catalogue.PolicyRecord {
+ t.Errorf("delivery.PolicyRecord = %q, the catalogue's %q", delivery.PolicyRecord, catalogue.PolicyRecord)
+ }
+}
diff --git a/internal/compose/credentials.go b/internal/compose/credentials.go
new file mode 100644
index 0000000..374302d
--- /dev/null
+++ b/internal/compose/credentials.go
@@ -0,0 +1,499 @@
+package compose
+
+import (
+ "crypto/ecdh"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "slices"
+ "sort"
+ "strings"
+
+ "git.novox.be/novox/mesh-sdk/go/catalogue"
+ "github.com/novox/mesh-host/internal/compose/resolution"
+)
+
+// Mode says what a composition does about what a send would make. Both modes read what the Input holds and make nothing; they
+// differ as the controller's Reading and Foreseeing modes do.
+type Mode int
+
+const (
+ // Reading composes what is held and leaves out an own secret the Input holds none for: that machine cannot be running it.
+ Reading Mode = iota
+ // Foreseeing composes an own secret the next send would make with a stand-in and names it foreseen, while one the send would
+ // refuse (a bus credential nobody issued, a given secret under an old key) is refused here, in the same words.
+ Foreseeing
+ // Planning composes for a provisioning plan alone (novox/hq ADR 0570 clause 2): every own secret the Input holds none
+ // for stands in, named foreseen, and nothing is refused for a value, since a plan names secrets and never carries one.
+ // The seat mesh-provisioner's holder composes so, reading no secret; a rendering in this mode is never applied.
+ Planning
+)
+
+// ForeseenCertificate and ForeseenBusUsers stand for what the Input does not carry, in a composition asked ahead of the send.
+// Never sent.
+const (
+ ForeseenCertificate = "foreseen: issued by the next send"
+ ForeseenBusUsers = "foreseen: composed from the bus records by the next send"
+)
+
+// grantsFor is every credential this node must create, because something elsewhere uses it (plan.go grantsFor): the mirror of
+// what a consumer is given. A consumer whose identity overflows the provision's bound is left out of the grants and returned
+// beside them (novox/hq ADR 0225); one whose resolution binds it to another provider is withdrawn and returned beside them
+// (issue 274). Every other is granted.
+func (c *composer) grantsFor(node string) ([]catalogue.Grant, []catalogue.Overflow, []catalogue.Unbound, []string, error) {
+ issued, err := c.rowsFor(node)
+ if err != nil {
+ return nil, nil, nil, nil, err
+ }
+ onNetwork, err := c.whereEveryoneIs()
+ if err != nil {
+ return nil, nil, nil, nil, err
+ }
+ out := make([]catalogue.Grant, 0, len(issued))
+ var withheld []catalogue.Overflow
+ var unbound []catalogue.Unbound
+ var foreseen []string
+ for _, s := range issued {
+ resolved, err := c.planFor(s.Consumer)
+ switch {
+ case unresolvable(err):
+ // Their set does not resolve. Skipped rather than fatal: this node is not the place to report another machine's problem.
+ continue
+ case err != nil:
+ // The mesh could not be asked what they wanted, which is not the same as their wanting nothing.
+ return nil, nil, nil, nil, fmt.Errorf("what %s asked of %s cannot be read: %w", s.Consumer, s.Provision, err)
+ }
+ plan := resolved.Plan
+ values, asks, err := resolution.ContributionsFrom(plan, s.Provision, s.ConsumerModule, resolved.Settings)
+ if err != nil {
+ return nil, nil, nil, nil, err
+ }
+ // A port in there is the consumer's software port until this: the assignment that moved it is that machine's.
+ consumer, err := c.in.node(s.Consumer)
+ if err != nil {
+ return nil, nil, nil, nil, err
+ }
+ values = resolution.AtPublishedPort(values, s.ConsumerModule, consumer.Ports[s.ConsumerModule])
+ from := s.ConsumerModule
+ if !asks {
+ // That module no longer wants this: left empty, which is how a login is withdrawn rather than kept working for ever.
+ from = ""
+ }
+ if bound := resolution.BindsFrom(plan, s.Provision, s.ConsumerModule, s.Local); from != "" && !slices.Contains(bound, node) {
+ // It still asks, and not of this node: withdrawn exactly as a credential nobody asks for, and said.
+ from = ""
+ unbound = append(unbound, catalogue.Unbound{Provision: s.Provision, Provider: node,
+ Consumer: s.Consumer, Module: s.ConsumerModule, Local: s.Local, BoundTo: bound})
+ }
+ // The consumer's identity slug, from its own manifest, carried on the grant so the provider derives the same login the
+ // consumer does (novox/hq ADR 0049).
+ slug := ""
+ for _, mm := range plan.Modules {
+ if mm.Module == s.ConsumerModule {
+ slug = mm.Slug
+ break
+ }
+ }
+ if from != "" {
+ bound := boundOfGrant(plan, s, node)
+ source := catalogue.IdentitySource(slug, s.ConsumerModule)
+ if catalogue.CheckIdentityWithin(s.Consumer, source, bound) != nil {
+ withheld = append(withheld, catalogue.Overflow{Provision: s.Provision, Provider: node,
+ Consumer: s.Consumer, Module: s.ConsumerModule,
+ Identity: catalogue.ConsumerIdentity(s.Consumer, source), Bound: bound})
+ continue
+ }
+ }
+ sealed := s.ForProvider
+ switch {
+ case credentiallessRow(plan, s, node):
+ // Its offer presents no credential (form none): the grant carries the consumer and nothing sealed, and
+ // nothing stands in for it, so neither end waits for a value nobody makes (issue 1126).
+ sealed = ""
+ case sealed == "":
+ sealed = catalogue.ForeseenSealed
+ foreseen = append(foreseen, fmt.Sprintf("the grant of %s for %s on %s", s.Provision, s.ConsumerModule, s.Consumer))
+ }
+ out = append(out, catalogue.Grant{
+ Provision: s.Provision, Consumer: s.Consumer, At: onNetwork[s.Consumer],
+ From: from, Values: values, Slug: slug, Sealed: sealed, Local: s.Local})
+ }
+ return out, withheld, unbound, foreseen, nil
+}
+
+// credentialless is whether a need's provider offers it with no credential (catalogue.CredentialFormNone): the vault
+// mints nothing for it, so nothing is asked of the vault, sealed into a grant or a file, or stood in for (issue 1126).
+func credentialless(n catalogue.Needed) bool { return n.CredentialForm == catalogue.CredentialFormNone }
+
+// credentiallessRow is whether the consumer's resolution answers row's need from provider with an offer of form none.
+func credentiallessRow(consumer catalogue.Resolution, row Issued, provider string) bool {
+ return slices.ContainsFunc(consumer.Needs, func(n catalogue.Needed) bool {
+ return n.Name == row.Provision && n.For == row.ConsumerModule && n.Local == row.Local && n.From == provider &&
+ credentialless(n)
+ })
+}
+
+// rowsFor is every pair credential this node's declaration must carry a grant for, in the order the controller reads them
+// (SecretsFrom: by provision, consumer, consumer module and local name): the ones the Input says were made, and the ones the same send
+// would make before it composes this node -- every need of a node that resolves (this one too), whose provider is this node, that is not
+// answered by a record and not a credential only a person gives and nobody gave. Those have no sealed copy (ForProvider empty), so the
+// grant is composed with a stand-in and named foreseen (plan.go: planFor makes the secret in every mode before grantsFor reads it).
+func (c *composer) rowsFor(node string) ([]Issued, error) {
+ var rows []Issued
+ for _, row := range c.in.Issued {
+ if row.Provider == node {
+ rows = append(rows, row)
+ }
+ }
+ for _, other := range c.in.sorted() {
+ resolved, err := c.planFor(other.Name)
+ switch {
+ case unresolvable(err):
+ continue
+ case err != nil:
+ return nil, fmt.Errorf("what %s asked of %s cannot be read: %w", other.Name, node, err)
+ }
+ shelf := c.shelfFor(other)
+ for _, n := range resolved.Plan.Needs {
+ if n.From != node || n.ByRecord {
+ continue
+ }
+ cred, held := c.credential(n, other.Name)
+ if _, outside := issuedOutsideBy(shelf, n); cred.State == CredentialNotGiven || (outside && !(held && cred.State == CredentialHeld)) {
+ continue
+ }
+ if slices.ContainsFunc(rows, func(r Issued) bool {
+ return r.Provision == n.Name && r.Consumer == other.Name && r.ConsumerModule == n.For && r.Local == n.Local
+ }) {
+ continue
+ }
+ rows = append(rows, Issued{Provision: n.Name, Consumer: other.Name, ConsumerModule: n.For, Local: n.Local, Provider: node})
+ }
+ }
+ sort.SliceStable(rows, func(i, j int) bool {
+ a, b := rows[i], rows[j]
+ switch {
+ case a.Provision != b.Provision:
+ return a.Provision < b.Provision
+ case a.Consumer != b.Consumer:
+ return a.Consumer < b.Consumer
+ case a.ConsumerModule != b.ConsumerModule:
+ return a.ConsumerModule < b.ConsumerModule
+ }
+ return a.Local < b.Local
+ })
+ return rows, nil
+}
+
+// boundOfGrant is the identity bound the consumer's own resolution states for the requirement this grant answers. A requirement
+// not found there is held to the tightest bound the mesh knows rather than to none.
+func boundOfGrant(consumer catalogue.Resolution, s Issued, provider string) catalogue.IdentityBound {
+ for _, n := range consumer.Needs {
+ if n.Name == s.Provision && n.For == s.ConsumerModule && n.From == provider {
+ return n.Identity
+ }
+ }
+ return catalogue.DefaultIdentityBound
+}
+
+// ownSecretRow is the row for one own secret of a module on a node.
+func (c *composer) ownSecretRow(node, module, name string) (OwnSecretRow, bool) {
+ for _, r := range c.in.OwnSecrets {
+ if r.Node == node && r.Module == module && r.Name == name {
+ return r, true
+ }
+ }
+ return OwnSecretRow{}, false
+}
+
+// givenMember is one own secret a person gave on a machine and whether it is sealed to the key the machine holds now.
+type givenMember struct {
+ Name string
+ Sealed string
+ Current bool
+}
+
+// givenMembers is every member of a module's secret family given on a machine, by name (inv.GivenMembers): the mesh never
+// makes a member, so only a value a person gave is one; a machine with no sealing key holds none.
+func (c *composer) givenMembers(node Node, module, family string) []givenMember {
+ if node.SealingKey == "" {
+ return nil
+ }
+ prefix := catalogue.FamilyPrefix(family)
+ var out []givenMember
+ for _, r := range c.in.OwnSecrets {
+ if r.Node == node.Name && r.Module == module && r.Origin == "accepted" && strings.HasPrefix(r.Name, prefix) {
+ out = append(out, givenMember{Name: r.Name, Sealed: r.Sealed, Current: r.SealedTo == node.SealingKey})
+ }
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
+ return out
+}
+
+// givenOwnSecret is an own secret issued outside the mesh as a person gave it on a machine (inv.GivenOwnSecret): not given when
+// the value held was made by the mesh or the machine has no sealing key.
+func (c *composer) givenOwnSecret(node Node, module, name string) (givenMember, bool) {
+ if node.SealingKey == "" {
+ return givenMember{}, false
+ }
+ r, ok := c.ownSecretRow(node.Name, module, name)
+ if !ok || r.Origin != "accepted" {
+ return givenMember{}, false
+ }
+ return givenMember{Name: name, Sealed: r.Sealed, Current: r.SealedTo == node.SealingKey}, true
+}
+
+// moduleSecretIfIssued is the read half of the controller's SecretForModule (inv.ModuleSecretIfIssued): the secret when one is
+// held and sealed to the key the machine holds now, and otherwise not held. A key it was sealed to that the machine no longer
+// has is reported as absent rather than as an error.
+func (c *composer) moduleSecretIfIssued(node Node, module, name string) (sealed string, held bool) {
+ if node.SealingKey == "" {
+ return "", false
+ }
+ r, ok := c.ownSecretRow(node.Name, module, name)
+ if !ok || r.SealedTo != node.SealingKey {
+ return "", false
+ }
+ return r.Sealed, true
+}
+
+// wouldMakeSecretForModule says what the send would do about an own secret without doing it: nil when it would make one (or one
+// is held), and otherwise the very refusal it would meet (inv.WouldMakeSecretForModule).
+func (c *composer) wouldMakeSecretForModule(node Node, module, name string) error {
+ if node.SealingKey == "" {
+ return fmt.Errorf("%s needs a secret and %s has no sealing key, so nothing can be sealed to it", module, node.Name)
+ }
+ r, ok := c.ownSecretRow(node.Name, module, name)
+ switch {
+ case !ok:
+ return nil
+ case r.SealedTo != node.SealingKey && r.Origin == "accepted":
+ return fmt.Errorf("%s on %s holds %q, which was given to the mesh rather than made by it, and %s has "+
+ "since generated a new sealing key. The mesh cannot make another; issue it again", module, node.Name, name, node.Name)
+ }
+ return nil
+}
+
+// givenUnderAnOldKey is the refusal for a value a person gave sealed to a key the machine no longer holds.
+func givenUnderAnOldKey(module, node, name string) error {
+ return fmt.Errorf("%s on %s holds %q, which was given to the mesh rather than made by it, and %s has "+
+ "since generated a new sealing key. The mesh cannot make another; give it again", module, node, name, node)
+}
+
+// ownSecretsFor is each module's own secrets as this node composes them (plan.go renderingFor): the needed ones sealed to the
+// node, and, in the Foreseeing mode, the ones the next send would make named foreseen. A module handed to the provisioner has its
+// secrets from the vault through the client, so none of them is composed here.
+func (c *composer) ownSecretsFor(node Node, plan catalogue.Resolution, handing catalogue.Rendering, mode Mode) (
+ needed map[string]map[string]string, foreseen map[string]map[string]bool, err error) {
+ needed = map[string]map[string]string{}
+ foreseen = map[string]map[string]bool{}
+ mark := func(module, name string) {
+ if foreseen[module] == nil {
+ foreseen[module] = map[string]bool{}
+ }
+ foreseen[module][name] = true
+ }
+ for _, m := range plan.Modules {
+ if resolution.Handing(handing, m) {
+ continue
+ }
+ // A secret family is never made (novox/hq ADR 0283): each member a person gave on this machine is placed, and one not
+ // given is nothing -- the module says it waits for it.
+ for _, family := range m.OwnSecrets.Families() {
+ for _, g := range c.givenMembers(node, m.Module, family) {
+ if _, fam, ok := m.OwnSecrets.Lookup(g.Name); !ok || fam != family {
+ continue // a longer family's member, or a name no longer of this family
+ }
+ if !g.Current {
+ continue
+ }
+ if needed[m.Module] == nil {
+ needed[m.Module] = map[string]string{}
+ }
+ needed[m.Module][g.Name] = g.Sealed
+ if g.Sealed == "" {
+ // Given, and its value is not in the Input: placed with a stand-in, named.
+ needed[m.Module][g.Name] = catalogue.ForeseenSealed
+ mark(m.Module, g.Name)
+ }
+ }
+ }
+ names := make([]string, 0)
+ for name := range m.OwnSecrets.Plain() {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ for _, name := range names {
+ own := m.OwnSecrets.Plain()[name]
+ // An own secret issued outside the mesh is never made (novox/hq issue 401, ADR 0228; ADR 0283 for a family): only a
+ // value a person gave is placed. One not given is nothing, and a value the mesh made for it before is not one either.
+ if own.IssuedBy == catalogue.IssuedOutside {
+ g, given := c.givenOwnSecret(node, m.Module, name)
+ if !given || !g.Current {
+ continue
+ }
+ if g.Sealed == "" {
+ mark(m.Module, name)
+ continue
+ }
+ if needed[m.Module] == nil {
+ needed[m.Module] = map[string]string{}
+ }
+ needed[m.Module][name] = g.Sealed
+ continue
+ }
+ sealed, held := c.moduleSecretIfIssued(node, m.Module, name)
+ if !held && mode == Planning {
+ mark(m.Module, name)
+ continue
+ }
+ if !held && mode == Foreseeing {
+ // Asked ahead of the send: what the send would do about it, by the send's own rules. Made by it -- a stand-in,
+ // named; refused by it -- refused here, the same words (novox/hq issue 275). A bus account not yet issued is
+ // foreseen, never refused: the vault mints it from the grants part's wanted list once the placement is written
+ // on the operator's Approve (ADR 0561 §1, ADR 0567 §5), so a composition asked ahead of that cannot hold it
+ // (issue 774, 2026-10-30: every proposal placing mesh-conditions, mesh-self-check or mesh-healers refused).
+ if err := c.wouldMakeSecretForModule(node, m.Module, name); err != nil {
+ return nil, nil, err
+ }
+ mark(m.Module, name)
+ continue
+ }
+ if !held {
+ // Never issued, so this machine cannot be running it: left out rather than invented.
+ continue
+ }
+ if sealed == "" {
+ // Held, and its value is not in the Input: composed with a stand-in, named.
+ mark(m.Module, name)
+ continue
+ }
+ if needed[m.Module] == nil {
+ needed[m.Module] = map[string]string{}
+ }
+ needed[m.Module][name] = sealed
+ }
+ }
+ return needed, foreseen, nil
+}
+
+// CoreBoxWanted is a core credential whose box the Input does not hold for a machine: the next send asks the vault for it and
+// keeps the answer. It is returned for the caller to ask and keep; this package asks and keeps nothing.
+type CoreBoxWanted struct {
+ Node string
+ Module string
+ Name string
+}
+
+// coreModulesOf is every module of the plan on the core's list whose own secrets this machine's declaration carries, with their
+// plain own secret names, sorted: one handed to the provisioner has its secrets from the vault through the client, and one issued
+// outside the mesh is never the vault's to seal here.
+func coreModulesOf(modules []catalogue.Manifest, handed func(catalogue.Manifest) bool) map[string][]string {
+ out := map[string][]string{}
+ for _, m := range modules {
+ if !resolution.CarriesVaultSealed(m.Module) || (handed != nil && handed(m)) {
+ continue
+ }
+ var names []string
+ for name, own := range m.OwnSecrets.Plain() {
+ if own.IssuedBy != catalogue.IssuedOutside {
+ names = append(names, name)
+ }
+ }
+ if len(names) > 0 {
+ sort.Strings(names)
+ out[m.Module] = names
+ }
+ }
+ return out
+}
+
+// sealingKeyValid says a machine's sealing key, recorded raw in base64, is a raw X25519 key.
+func sealingKeyValid(raw string) error {
+ b, err := base64.StdEncoding.DecodeString(strings.TrimSpace(raw))
+ if err != nil || len(b) != 32 {
+ return errors.New("the machine's sealing key is not a raw X25519 key")
+ }
+ _, err = ecdh.X25519().NewPublicKey(b)
+ return err
+}
+
+// coreBoxesFor decides each core credential of a machine in a composition that sends nothing and asks no vault (core_box.go
+// coreBoxesOf, in the modes that are not a send): the kept box while it is sealed to the key the machine holds now, and otherwise a
+// stand-in that the next send replaces by asking the vault -- returned in wanted for the caller to ask and keep. A machine with no
+// sealing key, or one whose key is not a key, leaves its module out, said.
+func (c *composer) coreBoxesFor(node Node, modules []catalogue.Manifest, handing catalogue.Rendering) (
+ sealed map[string]map[string]json.RawMessage, notPlaced map[string]string, wanted []CoreBoxWanted) {
+ core := coreModulesOf(modules, func(m catalogue.Manifest) bool { return resolution.Handing(handing, m) })
+ sealed, notPlaced = map[string]map[string]json.RawMessage{}, map[string]string{}
+ mods := make([]string, 0, len(core))
+ for m := range core {
+ mods = append(mods, m)
+ }
+ sort.Strings(mods)
+ for _, module := range mods {
+ if node.SealingKey == "" {
+ notPlaced[module] = fmt.Sprintf("%s has no sealing key, so the vault can seal %s's credentials to "+
+ "nothing there (novox/hq ADR 0349 §16)", node.Name, module)
+ continue
+ }
+ if err := sealingKeyValid(node.SealingKey); err != nil {
+ notPlaced[module] = fmt.Sprintf("%s's credentials cannot be asked of the vault: %v", module, err)
+ continue
+ }
+ boxes := map[string]json.RawMessage{}
+ for _, name := range core[module] {
+ var kept *CoreBox
+ for i := range c.in.KeptCoreBoxes {
+ k := &c.in.KeptCoreBoxes[i]
+ if k.Node == node.Name && k.Module == module && k.Name == name {
+ kept = k
+ }
+ }
+ if kept != nil && kept.SealedTo == node.SealingKey && len(kept.Box) > 0 {
+ boxes[name] = kept.Box
+ continue
+ }
+ boxes[name] = catalogue.ForeseenVaultBox
+ wanted = append(wanted, CoreBoxWanted{Node: node.Name, Module: module, Name: name})
+ }
+ sealed[module] = boxes
+ }
+ return sealed, notPlaced, wanted
+}
+
+// certificateFor is what the mesh certifies about one machine's internal name (plan.go certificateFor): refused for a machine that
+// never told the mesh what key it serves with, and for one not on the private network, which has no name to be certified for.
+// The certificate and the authority are public values the Input may carry; without them stand-ins are composed, and named.
+func (c *composer) certificateFor(node Node) (certificate, authority string, foreseen bool, err error) {
+ if node.ServingKey == "" {
+ return "", "", false, fmt.Errorf(
+ "%s wants a certificate and has never told the mesh what key it serves with; it "+
+ "joins again to report one", node.Name)
+ }
+ where, err := c.whereEveryoneIs()
+ if err != nil {
+ return "", "", false, err
+ }
+ if where[node.Name] == "" {
+ return "", "", false, fmt.Errorf(
+ "%s wants a certificate and is not on the private network, so it has no name inside "+
+ "the mesh to be certified for", node.Name)
+ }
+ certificate, authority = node.Certificate, c.in.Authority
+ if certificate == "" || authority == "" {
+ return ForeseenCertificate, ForeseenCertificate, true, nil
+ }
+ return certificate, authority, false, nil
+}
+
+// holdsTheBus says whether a node's set includes the module that asks for the bus's user list.
+func holdsTheBus(modules []catalogue.Manifest) bool {
+ for _, m := range modules {
+ if m.BusUsers != "" && m.ClaimsSeat("mesh-broker") {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/compose/credentials_needed.go b/internal/compose/credentials_needed.go
new file mode 100644
index 0000000..ec36c71
--- /dev/null
+++ b/internal/compose/credentials_needed.go
@@ -0,0 +1,69 @@
+package compose
+
+import (
+ "fmt"
+ "sort"
+)
+
+// CredentialKey names one pair credential by the ends it joins: the provision, the consumer's node and module, the
+// provider's node, and the local name where a module keeps several (Credential's and Issued's key).
+type CredentialKey struct {
+ Provision string `json:"provision"`
+ Consumer string `json:"consumer"`
+ ConsumerModule string `json:"consumer_module"`
+ Provider string `json:"provider"`
+ Local string `json:"local,omitempty"`
+}
+
+// CredentialsNeeded is every pair credential node's composition reads (novox/hq ADR 0547 §2, ADR 0550 §5, issue 1126):
+// as a consumer, each need of its resolution a provider's credential answers (a need answered by a record is a licence's,
+// not a pair's); as a provider, each credential it must carry a grant for (rowsFor). A node that composes its own asks the
+// vault for exactly these, sealed to its key, and composes with them; one it does not get is stood in for, which the
+// node's own apply refuses (CompositionsApplied). A need whose provider offers it with no credential (form none) is
+// in neither list: the vault mints nothing for it (issue 1126).
+func CredentialsNeeded(in Input, node string) (asConsumer, asProvider []CredentialKey, err error) {
+ c := newComposer(in)
+ resolved, err := c.resolution(node)
+ if err != nil {
+ return nil, nil, err
+ }
+ for _, n := range resolved.Needs {
+ if n.ByRecord || n.From == "" || credentialless(n) {
+ continue
+ }
+ asConsumer = append(asConsumer, CredentialKey{Provision: n.Name, Consumer: node, ConsumerModule: n.For,
+ Provider: n.From, Local: n.Local})
+ }
+ rows, err := c.rowsFor(node)
+ if err != nil {
+ return nil, nil, fmt.Errorf("compose: the credentials %s provides: %w", node, err)
+ }
+ for _, r := range rows {
+ // A grant whose offer presents no credential (form none) has none to ask for (issue 1126).
+ if consumer, err := c.planFor(r.Consumer); err == nil && credentiallessRow(consumer.Plan, r, node) {
+ continue
+ }
+ asProvider = append(asProvider, CredentialKey{Provision: r.Provision, Consumer: r.Consumer,
+ ConsumerModule: r.ConsumerModule, Provider: node, Local: r.Local})
+ }
+ sortKeys(asConsumer)
+ sortKeys(asProvider)
+ return asConsumer, asProvider, nil
+}
+
+func sortKeys(ks []CredentialKey) {
+ sort.Slice(ks, func(i, j int) bool {
+ a, b := ks[i], ks[j]
+ switch {
+ case a.Provision != b.Provision:
+ return a.Provision < b.Provision
+ case a.Provider != b.Provider:
+ return a.Provider < b.Provider
+ case a.Consumer != b.Consumer:
+ return a.Consumer < b.Consumer
+ case a.ConsumerModule != b.ConsumerModule:
+ return a.ConsumerModule < b.ConsumerModule
+ }
+ return a.Local < b.Local
+ })
+}
diff --git a/internal/compose/credentials_test.go b/internal/compose/credentials_test.go
new file mode 100644
index 0000000..dde374e
--- /dev/null
+++ b/internal/compose/credentials_test.go
@@ -0,0 +1,549 @@
+package compose
+
+import (
+ "encoding/json"
+ "errors"
+ "strings"
+ "testing"
+
+ "git.novox.be/novox/mesh-sdk/go/catalogue"
+ "github.com/novox/mesh-host/internal/overlay"
+)
+
+// secretValue is a value the Input carries that must never appear in anything the composition says.
+const secretValue = "SECRET-VALUE-THAT-MUST-NOT-LEAK"
+
+func rendered(t *testing.T, in Input, node string, mode Mode) Rendered {
+ t.Helper()
+ got, err := Render(in, node, mode)
+ if err != nil {
+ t.Fatalf("%v", err)
+ }
+ return got
+}
+
+// anAssistant is mesh-controller's outside_not_given_test.go anAssistant (cmd/mesh-controller at 6c2a8d62): a module with a secret
+// the mesh makes and one only an outside party issues.
+func anAssistant() catalogue.Manifest {
+ return catalogue.Manifest{Module: "assistant", Version: "1",
+ OwnSecrets: catalogue.OwnSecrets{
+ "password": {Path: "/var/lib/mesh/assistant/password", Taken: catalogue.TakenAtStart},
+ "vendor-key": {Path: "/var/lib/mesh/assistant/vendor-key", Taken: catalogue.TakenAtStart, IssuedBy: catalogue.IssuedOutside},
+ },
+ Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/mesh/assistant", "mode": "0700"}}}
+}
+
+func withAssistant(t *testing.T) Input {
+ t.Helper()
+ in := aMesh(t)
+ in.Catalogue["assistant"] = anAssistant()
+ laptop := nodeOf(t, &in, "laptop")
+ laptop.Assigned = append(laptop.Assigned, "assistant")
+ laptop.SealingKey = keyOf(t, "laptop")
+ return in
+}
+
+// Own secrets (plan.go renderingFor, novox/hq issue 275 and 401): one held and sealed to the machine's key is carried; one not held is
+// foreseen in the Foreseeing mode and left out in the Reading mode, and nothing is made.
+func TestAnOwnSecretHeldIsCarriedAndOneNotHeldIsForeseenOrLeftOut(t *testing.T) {
+ mk := func() Input {
+ in := withAssistant(t)
+ in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made",
+ SealedTo: nodeOf(t, &in, "laptop").SealingKey, Sealed: secretValue}}
+ return in
+ }
+ in := mk()
+ got := rendered(t, in, "laptop", Foreseeing)
+ if got.Rendering.Needed["assistant"]["password"] != secretValue || len(got.Rendering.Foreseen) != 0 {
+ t.Errorf("a held secret: needed %v foreseen %v", got.Rendering.Needed, got.Rendering.Foreseen)
+ }
+ // Not held: foreseen with a stand-in (Foreseeing), left out (Reading).
+ in.OwnSecrets = nil
+ got = rendered(t, in, "laptop", Foreseeing)
+ if !got.Rendering.Foreseen["assistant"]["password"] || got.Rendering.Needed["assistant"]["password"] != "" ||
+ len(got.Foreseen) != 1 || got.Foreseen[0] != "assistant/password" {
+ t.Errorf("a secret the send makes: foreseen %v / %v needed %v", got.Rendering.Foreseen, got.Foreseen, got.Rendering.Needed)
+ }
+ got = rendered(t, in, "laptop", Reading)
+ if len(got.Rendering.Needed["assistant"]) != 0 || len(got.Foreseen) != 0 || got.Rendering.Foreseen != nil {
+ t.Errorf("Reading composed a secret that was never issued: %v %v", got.Rendering.Needed, got.Foreseen)
+ }
+ // Held but sealed to a key the machine no longer has is not held.
+ in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made", SealedTo: "an-old-key", Sealed: secretValue}}
+ if got := rendered(t, in, "laptop", Foreseeing); !got.Rendering.Foreseen["assistant"]["password"] {
+ t.Errorf("a secret sealed to an old key is held: %v", got.Rendering.Needed)
+ }
+ // Held with no value in the Input: a stand-in, named.
+ in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made", SealedTo: nodeOf(t, &in, "laptop").SealingKey}}
+ if got := rendered(t, in, "laptop", Reading); !got.Rendering.Foreseen["assistant"]["password"] {
+ t.Errorf("a held secret with no value is not named: %v", got.Rendering.Foreseen)
+ }
+ unchanged(t, mk, func(in Input) { rendered(t, in, "laptop", Foreseeing) })
+
+ // 1126 (ADR 0547 §2): a node applying what it composed itself never applies a stand-in: a held secret with no value in the
+ // Input is refused, naming it; held with its value, it composes.
+ fps := map[string]string{"assistant": "src2:" + strings.Repeat("a", 64), overlay.Name: "src2:" + strings.Repeat("b", 64)}
+ if _, _, err := CompositionsApplied(in, "laptop", fps); !errors.Is(err, ErrStoodIn) || !strings.Contains(err.Error(), "assistant/password") {
+ t.Errorf("a stand-in was not refused for a node's own apply: %v", err)
+ }
+ if out, _, err := CompositionsApplied(mk(), "laptop", fps); err != nil || len(out) == 0 {
+ t.Errorf("a held secret with its value is refused for a node's own apply: %v", err)
+ }
+}
+
+// An own secret issued outside the mesh that nobody gave is never made (novox/hq issue 401, ADR 0228): nothing is composed or
+// foreseen for it; a value the mesh made for it is not one a person gave; a value given is placed once it is sealed to the key held now.
+func TestAnOutsideSecretIsPlacedOnlyOnceAPersonGaveIt(t *testing.T) {
+ in := withAssistant(t)
+ key := nodeOf(t, &in, "laptop").SealingKey
+ for _, mode := range []Mode{Reading, Foreseeing} {
+ got := rendered(t, in, "laptop", mode)
+ if _, there := got.Rendering.Needed["assistant"]["vendor-key"]; there || got.Rendering.Foreseen["assistant"]["vendor-key"] {
+ t.Fatalf("%v: a secret nobody gave is composed: %v %v", mode, got.Rendering.Needed, got.Rendering.Foreseen)
+ }
+ }
+ in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "vendor-key", Origin: "made", SealedTo: key, Sealed: secretValue}}
+ if _, there := rendered(t, in, "laptop", Foreseeing).Rendering.Needed["assistant"]["vendor-key"]; there {
+ t.Error("a value the mesh made for a secret issued outside was placed")
+ }
+ in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "vendor-key", Origin: "accepted", SealedTo: key, Sealed: "the vendor's key, sealed"}}
+ if got := rendered(t, in, "laptop", Foreseeing).Rendering.Needed["assistant"]["vendor-key"]; got != "the vendor's key, sealed" {
+ t.Errorf("a value given is placed as %q", got)
+ }
+ in.OwnSecrets[0].SealedTo = "an-old-key"
+ if _, there := rendered(t, in, "laptop", Foreseeing).Rendering.Needed["assistant"]["vendor-key"]; there {
+ t.Error("a value given under an old key was placed")
+ }
+}
+
+// 1364/AC3 (the send's refusals, novox/hq issue 275): in the Foreseeing mode a given secret under an old key is refused in the
+// controller's words. No secret value is in an error. A bus account not yet issued is foreseen, issued or not (issue 774: the vault
+// mints it once the placement is written on the Approve, ADR 0561 §1, ADR 0567 §5; mesh-self-check's proposal on ace was refused
+// "has no bus credential: nothing was issued for ace.mesh-self-check").
+func TestTheSendsRefusalsAreRefusedInTheSendsWords(t *testing.T) {
+ broker := catalogue.Manifest{Module: "mesh-self-check", Version: "1", RunsAs: "mesh-self-check",
+ OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/mesh-self-check/broker", Taken: catalogue.TakenAtStart}}}
+ in := withAssistant(t)
+ in.Catalogue["mesh-self-check"] = broker
+ laptop := nodeOf(t, &in, "laptop")
+ laptop.Assigned = append(laptop.Assigned, "mesh-self-check")
+ for _, minted := range [][]string{nil, {"laptop.mesh-self-check"}} {
+ in.BusMinted = minted
+ got, err := Render(in, "laptop", Foreseeing)
+ if err != nil || !got.Rendering.Foreseen["mesh-self-check"]["broker"] {
+ t.Fatalf("minted %v: the bus account is not foreseen: %v", minted, err)
+ }
+ // Reading refuses nothing for it: the machine is not running it.
+ if _, err := Render(in, "laptop", Reading); err != nil {
+ t.Fatalf("Reading refused a secret the machine is not running: %v", err)
+ }
+ }
+ // A given secret under an old key.
+ in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "accepted", SealedTo: "an-old-key", Sealed: secretValue},
+ {Node: "laptop", Module: "mesh-self-check", Name: "broker", Origin: "made", SealedTo: laptop.SealingKey, Sealed: secretValue}}
+ _, err := Render(in, "laptop", Foreseeing)
+ wantOld := "assistant on laptop holds \"password\", which was given to the mesh rather than made by it, and laptop has since generated a new " +
+ "sealing key. The mesh cannot make another; issue it again"
+ if err == nil || err.Error() != wantOld {
+ t.Fatalf("got %v, want %q", err, wantOld)
+ }
+ if strings.Contains(err.Error(), secretValue) {
+ t.Error("a secret value is in an error")
+ }
+ // A machine with no sealing key cannot be sealed to.
+ laptop.SealingKey = ""
+ in.OwnSecrets = nil
+ if _, err := Render(in, "laptop", Foreseeing); err == nil || !strings.Contains(err.Error(), "laptop has no sealing key, so nothing can be sealed to it") {
+ t.Fatalf("no sealing key: %v", err)
+ }
+}
+
+// aGrantMesh is the objects provider on anchor and its consumer files on laptop, with the pair credential made.
+func aGrantMesh(t *testing.T) Input {
+ t.Helper()
+ in := theObjectsMesh(t, objects())
+ in.Issued = []Issued{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", ForProvider: "sealed-to-anchor"}}
+ return in
+}
+
+// A credential made for a consumer is a grant on its provider's machine, carrying the consumer's values and the sealed copy; without
+// the sealed copy a stand-in is composed and named (novox/hq plan.go grantsFor).
+func TestAGrantIsCreatedForEachCredentialMadeForAConsumer(t *testing.T) {
+ in := aGrantMesh(t)
+ // 1126 (ADR 0550 §5): the credentials each node's composition reads, by their ends: laptop's files consumes anchor's
+ // s3-bucket, and anchor grants it; the node composing its own asks the vault for exactly these.
+ pair := CredentialKey{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor"}
+ if consumer, provider, err := CredentialsNeeded(in, "laptop"); err != nil || len(provider) != 0 || len(consumer) != 1 || consumer[0] != pair {
+ t.Errorf("laptop needs %v as consumer and %v as provider (%v), want %v as consumer", consumer, provider, err, pair)
+ }
+ if consumer, provider, err := CredentialsNeeded(in, "anchor"); err != nil || len(consumer) != 0 || len(provider) != 1 || provider[0] != pair {
+ t.Errorf("anchor needs %v as consumer and %v as provider (%v), want %v as provider", consumer, provider, err, pair)
+ }
+ got := rendered(t, in, "anchor", Foreseeing)
+ if len(got.Rendering.Grants) != 1 {
+ t.Fatalf("grants are %+v", got.Rendering.Grants)
+ }
+ g := got.Rendering.Grants[0]
+ if g.Provision != "s3-bucket" || g.Consumer != "laptop" || g.From != "files" || g.At != "laptop.internal" || g.Sealed != "sealed-to-anchor" {
+ t.Errorf("the grant is %+v", g)
+ }
+ if len(got.Foreseen) != 0 || len(got.Withheld) != 0 || len(got.Unbound) != 0 {
+ t.Errorf("foreseen %v withheld %v unbound %v", got.Foreseen, got.Withheld, got.Unbound)
+ }
+ in.Issued[0].ForProvider = ""
+ got = rendered(t, in, "anchor", Foreseeing)
+ if got.Rendering.Grants[0].Sealed != catalogue.ForeseenSealed || len(got.Foreseen) != 1 {
+ t.Errorf("a grant with no sealed copy: %+v foreseen %v", got.Rendering.Grants, got.Foreseen)
+ }
+ // A credential made for a consumer on another provider is not this node's grant.
+ if len(rendered(t, in, "laptop", Foreseeing).Rendering.Grants) != 0 {
+ t.Error("the consumer is granted its own credential")
+ }
+ // A consumer whose set does not resolve is skipped, not fatal.
+ nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "files")
+ in.Catalogue["files"] = catalogue.Manifest{Module: "files", Version: "1", Requires: []string{"s3-bucket"},
+ Claims: []catalogue.Claim{{Name: "x", Scope: catalogue.ScopeNode}}}
+ in.Catalogue["twin"] = catalogue.Manifest{Module: "twin", Version: "1", Claims: []catalogue.Claim{{Name: "x", Scope: catalogue.ScopeNode}}}
+ nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "twin")
+ if got, err := Render(in, "anchor", Foreseeing); err != nil || len(got.Rendering.Grants) != 0 {
+ t.Errorf("a consumer that does not resolve: %v %+v", err, got.Rendering.Grants)
+ }
+}
+
+// 1364/AC2: a consumer the grants leave out because its identity overflows the provision's bound is returned beside them, naming the
+// consumer and the provision (novox/hq ADR 0225), and is not granted.
+func TestAConsumerWhoseIdentityOverflowsIsLeftOutOfTheGrantsAndNamed(t *testing.T) {
+ in := aGrantMesh(t)
+ in.Nodes[1].Name = "a-machine-with-a-very-long-name"
+ in.Issued[0].Consumer = "a-machine-with-a-very-long-name"
+ got := rendered(t, in, "anchor", Foreseeing)
+ if len(got.Rendering.Grants) != 0 || len(got.Withheld) != 1 {
+ t.Fatalf("grants %+v withheld %+v", got.Rendering.Grants, got.Withheld)
+ }
+ w := got.Withheld[0]
+ if w.Consumer != "a-machine-with-a-very-long-name" || w.Provision != "s3-bucket" || w.Provider != "anchor" || w.Module != "files" ||
+ !strings.Contains(w.String(), "a-machine-with-a-very-long-name") || !strings.Contains(w.String(), "s3-bucket") {
+ t.Errorf("the consumer left out is %+v / %s", w, w.String())
+ }
+ if len(got.Rendering.Withheld) != 1 {
+ t.Errorf("the Rendering does not carry it: %+v", got.Rendering.Withheld)
+ }
+}
+
+// 1364/AC3: a credential on record for a consumer that its own resolution binds to another provider is withdrawn, named with where it is
+// bound (novox/hq issue 274): the grant stays, with no consumer module asking.
+func TestACredentialBoundToAnotherProviderIsWithdrawnAndSaidWhereItIsBound(t *testing.T) {
+ in := aGrantMesh(t)
+ second := objects()
+ second.Module = "objects2"
+ in.Catalogue["objects2"] = second
+ in.Nodes = append(in.Nodes, Node{Name: "ace", Place: Place{Site: "there", Address: "10.77.0.3"},
+ Assigned: []string{overlay.Name, "objects2"}, Capabilities: map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true}})
+ nodeOf(t, &in, "laptop").Pins = map[string]catalogue.Chosen{"s3-bucket": {Node: "ace", Module: "objects2"}}
+ got := rendered(t, in, "anchor", Foreseeing)
+ if len(got.Unbound) != 1 {
+ t.Fatalf("unbound %+v grants %+v", got.Unbound, got.Rendering.Grants)
+ }
+ u := got.Unbound[0]
+ if u.Provision != "s3-bucket" || u.Provider != "anchor" || u.Consumer != "laptop" || u.Module != "files" ||
+ len(u.BoundTo) != 1 || u.BoundTo[0] != "ace" || !strings.Contains(u.String(), "ace") {
+ t.Errorf("unbound is %+v / %s", u, u.String())
+ }
+ if len(got.Rendering.Grants) != 1 || got.Rendering.Grants[0].From != "" {
+ t.Errorf("the withdrawn credential is still asked for: %+v", got.Rendering.Grants)
+ }
+}
+
+func aCertificateMesh(t *testing.T) Input {
+ t.Helper()
+ in := aMesh(t)
+ in.Catalogue["secure"] = catalogue.Manifest{Module: "secure", Version: "1", Certificate: &catalogue.Certificate{Into: "/etc/secure/tls.pem", Authority: "/etc/secure/ca.pem"}}
+ laptop := nodeOf(t, &in, "laptop")
+ laptop.Assigned = append(laptop.Assigned, "secure")
+ return in
+}
+
+// 1364/AC4: with no certificate and authority in the Input a stand-in is composed and named; given them, the composition carries them as
+// given. A machine that never reported a serving key, and one not on the private network, are refused in the controller's words.
+func TestACertificateIsCarriedAsGivenOrStoodInForAndNamed(t *testing.T) {
+ in := aCertificateMesh(t)
+ laptop := nodeOf(t, &in, "laptop")
+ if _, err := Render(in, "laptop", Foreseeing); err == nil || err.Error() != "laptop wants a certificate and has never told the mesh what key it serves with; it joins again to report one" {
+ t.Fatalf("no serving key: %v", err)
+ }
+ laptop.ServingKey = "serving-key"
+ got := rendered(t, in, "laptop", Foreseeing)
+ if got.Rendering.Certificate != ForeseenCertificate || got.Rendering.Authority != ForeseenCertificate || len(got.Foreseen) != 1 {
+ t.Errorf("no certificate in the Input: %q %q foreseen %v", got.Rendering.Certificate, got.Rendering.Authority, got.Foreseen)
+ }
+ laptop.Certificate, in.Authority = "the-certificate", "the-authority"
+ got = rendered(t, in, "laptop", Foreseeing)
+ if got.Rendering.Certificate != "the-certificate" || got.Rendering.Authority != "the-authority" || len(got.Foreseen) != 0 {
+ t.Errorf("a certificate given: %q %q foreseen %v", got.Rendering.Certificate, got.Rendering.Authority, got.Foreseen)
+ }
+ laptop.Place.Address = "" // placed nowhere: no name inside the mesh
+ if _, err := Render(in, "laptop", Foreseeing); err == nil || err.Error() != "laptop wants a certificate and is not on the private network, so it has no name inside the mesh to be certified for" {
+ t.Fatalf("off the network: %v", err)
+ }
+}
+
+func aValidKey(t *testing.T, name string) string { return keyOf(t, name) }
+
+// 1364/AC5 and AC6: a core credential the node needs and the Input holds no box for is composed with a stand-in and returned as one
+// to keep; a kept box sealed to the key held now is carried; a node-engine below the vault-box contract is sent none (and nothing is
+// asked of it); a machine with no sealing key has its core module left out, said.
+func TestACoreBoxTheInputHoldsNoneOfIsReturnedToKeepAndNothingIsWritten(t *testing.T) {
+ mk := func() Input {
+ in := aMesh(t)
+ in.Catalogue["nats"] = catalogue.Manifest{Module: "nats", Version: "1",
+ OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/nats/broker", Taken: catalogue.TakenAtStart}}}
+ laptop := nodeOf(t, &in, "laptop")
+ laptop.Assigned = append(laptop.Assigned, "nats")
+ laptop.SealingKey = aValidKey(t, "laptop")
+ laptop.Stated, laptop.Contract = true, VaultBoxContract
+ in.BusMinted = []string{"laptop.nats"}
+ return in
+ }
+ in := mk()
+ got := rendered(t, in, "laptop", Foreseeing)
+ box := got.Rendering.VaultSealed["nats"]["broker"]
+ if string(box) != string(catalogue.ForeseenVaultBox) || len(got.CoreBoxesWanted) != 1 ||
+ got.CoreBoxesWanted[0] != (CoreBoxWanted{Node: "laptop", Module: "nats", Name: "broker"}) {
+ t.Errorf("no box held: %s wanted %v", box, got.CoreBoxesWanted)
+ }
+ kept := json.RawMessage(`{"v":1,"eph":"e","iv":"i","tag":"t","ct":"c"}`)
+ in.KeptCoreBoxes = []CoreBox{{Node: "laptop", Module: "nats", Name: "broker", Generation: 3, SealedTo: nodeOf(t, &in, "laptop").SealingKey, Box: kept}}
+ got = rendered(t, in, "laptop", Foreseeing)
+ if string(got.Rendering.VaultSealed["nats"]["broker"]) != string(kept) || len(got.CoreBoxesWanted) != 0 {
+ t.Errorf("a kept box: %s wanted %v", got.Rendering.VaultSealed["nats"]["broker"], got.CoreBoxesWanted)
+ }
+ in.KeptCoreBoxes[0].SealedTo = "an-old-key"
+ if got = rendered(t, in, "laptop", Foreseeing); len(got.CoreBoxesWanted) != 1 {
+ t.Errorf("a box sealed to an old key is kept: %v", got.CoreBoxesWanted)
+ }
+ // Below the vault-box contract: none, and the controller's own seal as before.
+ nodeOf(t, &in, "laptop").Contract = VaultBoxContract - 1
+ if got = rendered(t, in, "laptop", Foreseeing); got.Rendering.VaultSealed != nil || len(got.CoreBoxesWanted) != 0 {
+ t.Errorf("an engine below the contract is sent boxes: %v %v", got.Rendering.VaultSealed, got.CoreBoxesWanted)
+ }
+ // No sealing key: the module is left out, said.
+ nodeOf(t, &in, "laptop").Contract, nodeOf(t, &in, "laptop").SealingKey = VaultBoxContract, ""
+ got = rendered(t, in, "laptop", Reading)
+ if why := got.Rendering.NotPlaced["nats"]; !strings.Contains(why, "laptop has no sealing key, so the vault can seal nats's credentials to nothing there") {
+ t.Errorf("not placed: %v", got.Rendering.NotPlaced)
+ }
+ unchanged(t, mk, func(in Input) { rendered(t, in, "laptop", Foreseeing) })
+}
+
+// 1364/AC1: the bus's user list is carried for the machine that runs the bus and for no other; without it a stand-in is composed and
+// named; the users left out for want of a credential are said; each machine's membership is carried. (The list is composed by the
+// controller from the bus records, which stay the controller's: see the commit message.)
+func TestTheBusUserListIsCarriedForTheMachineThatRunsTheBus(t *testing.T) {
+ in := aMesh(t)
+ in.Catalogue["bus"] = catalogue.Manifest{Module: "bus", Version: "1", BusUsers: "/etc/bus/accounts.conf",
+ Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh}}}
+ anchor := nodeOf(t, &in, "anchor")
+ anchor.Assigned = append(anchor.Assigned, "bus")
+ in.BusUsers = "accounts { anchor.bus: hash }"
+ in.BusMissing = []string{"laptop.files", "laptop.web"}
+ in.BusMemberships = map[string]string{"laptop": "sealed-membership"}
+ got := rendered(t, in, "anchor", Foreseeing)
+ if got.Rendering.BusUsers != in.BusUsers || len(got.Foreseen) != 0 {
+ t.Errorf("the list is %q foreseen %v", got.Rendering.BusUsers, got.Foreseen)
+ }
+ if len(got.Said) != 1 || got.Said[0] != "the bus's user list leaves out 2 user(s) the mesh has minted no credential for: laptop.files, laptop.web. Each is a user that cannot connect until one is issued" {
+ t.Errorf("said %v", got.Said)
+ }
+ if other := rendered(t, in, "laptop", Foreseeing); other.Rendering.BusUsers != "" || other.Rendering.BusMembership != "sealed-membership" {
+ t.Errorf("laptop: list %q membership %q", other.Rendering.BusUsers, other.Rendering.BusMembership)
+ }
+ in.BusUsers = ""
+ got = rendered(t, in, "anchor", Foreseeing)
+ if got.Rendering.BusUsers != ForeseenBusUsers || len(got.Foreseen) != 1 {
+ t.Errorf("no list in the Input: %q foreseen %v", got.Rendering.BusUsers, got.Foreseen)
+ }
+}
+
+// 1364/AC7: nothing the composition says carries a secret value the Input holds.
+func TestNoSecretValueOfTheInputIsInWhatTheCompositionSays(t *testing.T) {
+ in := aGrantMesh(t)
+ in.Issued[0].ForProvider = secretValue
+ laptop := nodeOf(t, &in, "laptop")
+ in.Catalogue["assistant"] = anAssistant()
+ laptop.Assigned = append(laptop.Assigned, "assistant")
+ laptop.SealingKey = keyOf(t, "laptop")
+ in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made", SealedTo: laptop.SealingKey, Sealed: secretValue}}
+ in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialHeld, Sealed: secretValue}}
+ for _, node := range []string{"anchor", "laptop"} {
+ got := rendered(t, in, node, Foreseeing)
+ for _, said := range append(append([]string{}, got.Foreseen...), got.Said...) {
+ if strings.Contains(said, secretValue) {
+ t.Errorf("%s: %q carries a secret value", node, said)
+ }
+ }
+ }
+}
+
+// A consumer no credential was made for yet is granted with a stand-in and named foreseen, for every form whose credential
+// the vault mints or takes in; an offer of form none has no credential (issue 1126, 2026-11-02: mesh-cli on ace held for
+// "the grant of mcp-endpoint", whose provider node-tools offers form none): its grant carries the consumer and no
+// credential, nothing stands in on either side, and neither end asks the vault for one.
+func TestAConsumerNoCredentialWasMadeForYetIsGrantedWithAStandInUnlessItsOfferHasNone(t *testing.T) {
+ for _, tc := range []struct {
+ form string
+ cred *catalogue.OfferCredential
+ }{
+ {"undeclared", nil},
+ {catalogue.CredentialFormPair, &catalogue.OfferCredential{Form: catalogue.CredentialFormPair}},
+ {catalogue.CredentialFormShared, &catalogue.OfferCredential{Own: "access-key"}},
+ {catalogue.CredentialFormNone, &catalogue.OfferCredential{Form: catalogue.CredentialFormNone}},
+ } {
+ in := aGrantMesh(t)
+ in.Issued = nil
+ provider := in.Catalogue["objects"]
+ provider.Provides[0].Credential = tc.cred
+ if tc.cred != nil && tc.cred.Own != "" {
+ provider.OwnSecrets = catalogue.OwnSecrets{"access-key": {Path: "/var/lib/mesh/objects/access-key"}}
+ }
+ in.Catalogue["objects"] = provider
+ none := tc.form == catalogue.CredentialFormNone
+ got := rendered(t, in, "anchor", Foreseeing)
+ if len(got.Rendering.Grants) != 1 {
+ t.Fatalf("%s: grants are %+v", tc.form, got.Rendering.Grants)
+ }
+ g, wantSealed := got.Rendering.Grants[0], catalogue.ForeseenSealed
+ if none {
+ wantSealed = ""
+ }
+ if g.Provision != "s3-bucket" || g.Consumer != "laptop" || g.From != "files" || g.Sealed != wantSealed {
+ t.Errorf("%s: the grant is %+v", tc.form, g)
+ }
+ if has(got.Foreseen, "the grant of s3-bucket for files on laptop") == none {
+ t.Errorf("%s: foreseen %v", tc.form, got.Foreseen)
+ }
+ consumer, asProvider, err := CredentialsNeeded(in, "laptop")
+ _, anchorProvides, err2 := CredentialsNeeded(in, "anchor")
+ if err != nil || err2 != nil || len(asProvider) != 0 || (len(consumer) == 0) != none || (len(anchorProvides) == 0) != none {
+ t.Errorf("%s: laptop asks %v, anchor provides %v (%v %v)", tc.form, consumer, anchorProvides, err, err2)
+ }
+ resolved, err := Resolve(in, "laptop")
+ if err != nil {
+ t.Fatalf("%s: %v", tc.form, err)
+ }
+ if n := theFilesNeed(t, resolved); (n.Sealed == "") != none || (len(resolved.Foreseen) == 0) != none {
+ t.Errorf("%s: the consumer's credential is %q, foreseen %v", tc.form, n.Sealed, resolved.Foreseen)
+ }
+ if lap := rendered(t, in, "laptop", Foreseeing); none && len(lap.Foreseen) != 0 {
+ t.Errorf("%s: the consumer stands in for %v", tc.form, lap.Foreseen)
+ }
+ // Only the provider's own: the consumer is granted nothing, and nothing in the Input changed.
+ if len(rendered(t, in, "laptop", Foreseeing).Rendering.Grants) != 0 {
+ t.Errorf("%s: the consumer is granted its own credential", tc.form)
+ }
+ if len(in.Issued) != 0 {
+ t.Errorf("%s: a row was added to the Input", tc.form)
+ }
+ // A row made long ago for an offer of form none (the controller's) carries no credential either.
+ in.Issued = []Issued{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", ForProvider: "sealed-to-anchor"}}
+ if g := rendered(t, in, "anchor", Foreseeing).Rendering.Grants; none && (len(g) != 1 || g[0].Sealed != "") {
+ t.Errorf("%s: an old row's grant is %+v", tc.form, g)
+ }
+ }
+ // A credential only a person gives and nobody has is no grant.
+ in := aGrantMesh(t)
+ in.Issued = nil
+ in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialNotGiven}}
+ if got := rendered(t, in, "anchor", Foreseeing); len(got.Rendering.Grants) != 0 {
+ t.Errorf("a credential nobody gave is granted: %+v", got.Rendering.Grants)
+ }
+}
+
+// And a consumer whose identity overflows the provision's bound is judged before any credential is made for it (issue 263).
+func TestAnOverflowingConsumerNoCredentialWasMadeForYetIsWithheld(t *testing.T) {
+ in := aGrantMesh(t)
+ in.Issued = nil
+ in.Catalogue["resolver"] = storeManifests()[1]
+ in.Catalogue["networkmanager"] = catalogue.Manifest{Module: "networkmanager", Version: "1", Requires: []string{"wildcard-resolution", "s3-bucket"}}
+ nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "networkmanager")
+ nodeOf(t, &in, "anchor").Assigned = append(nodeOf(t, &in, "anchor").Assigned, "resolver")
+ in.Holdings = []catalogue.Held{{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "resolver", Site: "here"}}
+ got := rendered(t, in, "anchor", Foreseeing)
+ if len(got.Withheld) != 1 || got.Withheld[0].Module != "networkmanager" {
+ t.Fatalf("withheld %+v", got.Withheld)
+ }
+}
+
+// The grants come in the order the controller reads them, whatever order the Input gives its rows.
+func TestGrantsComeInTheOrderOfProvisionConsumerModuleAndLocal(t *testing.T) {
+ in := aGrantMesh(t)
+ in.Issued = []Issued{
+ {Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Local: "b", Provider: "anchor", ForProvider: "x"},
+ {Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Local: "a", Provider: "anchor", ForProvider: "x"},
+ }
+ rows, err := newComposer(in).rowsFor("anchor")
+ // The credential the same send would make (no local name) comes first, then the two the Input gives, a before b.
+ if err != nil || len(rows) != 3 || rows[0].Local != "" || rows[1].Local != "a" || rows[2].Local != "b" {
+ t.Errorf("rows are %+v %v", rows, err)
+ }
+}
+
+// Round 1, finding 4: a member of a secret family a person gave, whose sealed value the Input does not carry, is placed with a
+// stand-in and named foreseen, not dropped as if nobody gave it.
+func TestAGivenFamilyMemberWithoutItsValueIsAStandInAndNamedForeseen(t *testing.T) {
+ in := aMesh(t)
+ in.Catalogue["mailer"] = catalogue.Manifest{Module: "mailer", Version: "1",
+ OwnSecrets: catalogue.OwnSecrets{"credential-*": {Path: "/var/lib/mailer/credential-*.secret", Taken: catalogue.TakenAtStart, IssuedBy: catalogue.IssuedOutside}}}
+ laptop := nodeOf(t, &in, "laptop")
+ laptop.Assigned = append(laptop.Assigned, "mailer")
+ laptop.SealingKey = keyOf(t, "laptop")
+ in.OwnSecrets = []OwnSecretRow{
+ {Node: "laptop", Module: "mailer", Name: "credential-a", Origin: "accepted", SealedTo: laptop.SealingKey},
+ {Node: "laptop", Module: "mailer", Name: "credential-b", Origin: "accepted", SealedTo: laptop.SealingKey, Sealed: "b-sealed"},
+ }
+ got := rendered(t, in, "laptop", Foreseeing)
+ needed := got.Rendering.Needed["mailer"]
+ if needed["credential-a"] != catalogue.ForeseenSealed || needed["credential-b"] != "b-sealed" {
+ t.Errorf("needed %v", needed)
+ }
+ if !has(got.Foreseen, "mailer/credential-a") || has(got.Foreseen, "mailer/credential-b") {
+ t.Errorf("foreseen %v", got.Foreseen)
+ }
+}
+
+// Round 1, finding 5: the suffix internal names end in is a field of the Input, and the environment is never read.
+func TestTheInternalSuffixIsTheInputsAndNeverTheEnvironments(t *testing.T) {
+ t.Setenv(overlay.SuffixVar, "from-the-environment")
+ in := aGrantMesh(t)
+ got := rendered(t, in, "anchor", Foreseeing)
+ if got.Rendering.Suffix != "internal" || got.Rendering.Grants[0].At != "laptop.internal" {
+ t.Errorf("by default: suffix %q, grant at %q", got.Rendering.Suffix, got.Rendering.Grants[0].At)
+ }
+ in.InternalSuffix = ".lan"
+ got = rendered(t, in, "anchor", Foreseeing)
+ if got.Rendering.Suffix != "lan" || got.Rendering.Grants[0].At != "laptop.lan" {
+ t.Errorf("given: suffix %q, grant at %q", got.Rendering.Suffix, got.Rendering.Grants[0].At)
+ }
+ where, err := newComposer(in).whereEveryoneIs()
+ if err != nil || where["anchor"] != "anchor.lan" {
+ t.Errorf("where everyone is: %v %v", where, err)
+ }
+ if names := newComposer(in).namesInTheMesh(); names["anchor.lan"] == "" || names["anchor.internal"] != "" {
+ t.Errorf("names: %v", names)
+ }
+}
+
+// Round 2: a need answered on the same machine is a grant too (catalogue resolve.go gives it From: the node's own name).
+func TestAConsumerOnTheProvidersOwnNodeIsGrantedWithAStandInAndNamedForeseen(t *testing.T) {
+ in := aGrantMesh(t)
+ in.Issued = nil
+ laptop, anchor := nodeOf(t, &in, "laptop"), nodeOf(t, &in, "anchor")
+ laptop.Assigned = []string{overlay.Name}
+ anchor.Assigned = append(anchor.Assigned, "files")
+ got := rendered(t, in, "anchor", Foreseeing)
+ if len(got.Rendering.Grants) != 1 || got.Rendering.Grants[0].Consumer != "anchor" || got.Rendering.Grants[0].Sealed != catalogue.ForeseenSealed {
+ t.Fatalf("grants are %+v", got.Rendering.Grants)
+ }
+ if !has(got.Foreseen, "the grant of s3-bucket for files on anchor") {
+ t.Errorf("foreseen %v", got.Foreseen)
+ }
+}
diff --git a/internal/compose/fixture_test.go b/internal/compose/fixture_test.go
new file mode 100644
index 0000000..1766852
--- /dev/null
+++ b/internal/compose/fixture_test.go
@@ -0,0 +1,90 @@
+package compose
+
+import (
+ "crypto/ecdh"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/json"
+ "reflect"
+ "testing"
+
+ "git.novox.be/novox/mesh-sdk/go/catalogue"
+ "github.com/novox/mesh-host/internal/overlay"
+)
+
+// aPublicKey is a key a machine could have reported, made as mesh-controller's mesh_for_test.go aPublicKey makes it.
+func aPublicKey(t *testing.T) string {
+ t.Helper()
+ k, err := ecdh.X25519().GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes())
+}
+
+// keys are made once per run, so two Inputs made the same way are equal.
+var keys = map[string]string{}
+
+func keyOf(t *testing.T, name string) string {
+ t.Helper()
+ if keys[name] == "" {
+ keys[name] = aPublicKey(t)
+ }
+ return keys[name]
+}
+
+// theOverlayModule is the module the mesh provides for itself, as mesh-controller's providedModules makes it
+// (cmd/mesh-controller/modules.go at 6c2a8d62).
+func theOverlayModule(t *testing.T) catalogue.Manifest {
+ t.Helper()
+ var m catalogue.Manifest
+ b, err := json.Marshal(overlay.Manifest())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := json.Unmarshal(b, &m); err != nil {
+ t.Fatal(err)
+ }
+ return m
+}
+
+// aMesh is the Input of mesh-controller's aMesh (cmd/mesh-controller/mesh_for_test.go at 6c2a8d62): two placed
+// machines, `anchor` the hub, both reporting container-runtime, wireguard and systemd, both assigned what puts them on the
+// private network, each at .example:51820 on the site `here`, anchor at 10.77.0.1 and laptop at 10.77.0.2.
+func aMesh(t *testing.T) Input {
+ t.Helper()
+ in := Input{Catalogue: map[string]catalogue.Manifest{overlay.Name: theOverlayModule(t)}}
+ for i, name := range []string{"anchor", "laptop"} {
+ in.Nodes = append(in.Nodes, Node{
+ Name: name,
+ Place: Place{Key: keyOf(t, name), Endpoint: name + ".example:51820", Site: "here",
+ Hub: name == "anchor", Address: []string{"10.77.0.1", "10.77.0.2"}[i]},
+ Assigned: []string{overlay.Name},
+ Capabilities: map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true},
+ })
+ }
+ return in
+}
+
+// nodeOf is the named node of an Input, for a test to change.
+func nodeOf(t *testing.T, in *Input, name string) *Node {
+ t.Helper()
+ for i := range in.Nodes {
+ if in.Nodes[i].Name == name {
+ return &in.Nodes[i]
+ }
+ }
+ t.Fatalf("no node %s", name)
+ return nil
+}
+
+// unchanged runs call on one Input and compares it with another made the same way: the composition changes nothing in what
+// it is given (deep equality, nothing shared).
+func unchanged(t *testing.T, mk func() Input, call func(Input)) {
+ t.Helper()
+ used, same := mk(), mk()
+ call(used)
+ if !reflect.DeepEqual(used, same) {
+ t.Fatalf("the call changed its Input")
+ }
+}
diff --git a/internal/compose/frombus/answer.go b/internal/compose/frombus/answer.go
new file mode 100644
index 0000000..d2b0162
--- /dev/null
+++ b/internal/compose/frombus/answer.go
@@ -0,0 +1,583 @@
+package frombus
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "maps"
+ "regexp"
+ "slices"
+ "sort"
+ "strings"
+
+ "git.novox.be/novox/mesh-sdk/go/catalogue"
+ "git.novox.be/novox/mesh-sdk/go/delivery"
+ "github.com/novox/mesh-host/internal/compose"
+ "github.com/novox/mesh-host/internal/compose/resolution"
+)
+
+// Answer is a node's answer to its proposal's placement candidates (novox/hq ADR 0567 §3, ADR 0559 §4): s.Node composed in shadow with
+// s.Placements laid over the trunk's placement (compose.CompositionsShadow, its own sealed values and boxes through r as
+// for its own composition), compared with what it runs (delivery.CompareComposed). It applies nothing and writes
+// nothing; the proposal and the revision are the candidate's, said back in the answer.
+//
+// The node's own composition is compared only where the node runs one. fps gives the build each module is composed at: what the node runs, and for a module the candidate places that the
+// node does not run, the build the trunk holds; a placed module with none is added, with no build of it known. What the
+// composition stands in for is judged: a value of a module the proposal adds (on this node or another: its credential, own
+// secrets and boxes are made only once it is placed, ADR 0549 §5) is not yet issued, which is no refusal; any other
+// stand-in is a value missing for a module the node runs, a refusal naming it ("node" when it is the node's own).
+func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string, running []delivery.Composed, proposal string, revision uint64) delivery.CandidateAnswer {
+ a := delivery.CandidateAnswer{Proposal: proposal, Revision: revision}
+ refuse := func(err error) delivery.CandidateAnswer {
+ a.Refused = err.Error()
+ a.Verdict = delivery.CandidateRefused
+ return a
+ }
+ trunk, err := r.Placements(ctx)
+ if err != nil {
+ return refuse(fmt.Errorf("frombus: placement: %w", err))
+ }
+ if s.Running == nil {
+ s.Running = running
+ }
+ // Every module it cannot compose is named, not the first only (issue 1126): a build it cannot read or take in, and
+ // below, a module the composition refuses, each composed as a stub so the rest is composed and compared.
+ failed := map[string]string{}
+ in, err := input(ctx, r, mesh, s, func(module string, err error) { failed[module] = err.Error() }, nil)
+ if err != nil {
+ return refuse(err)
+ }
+ asConsumer, asProvider, err := compose.CredentialsNeeded(in, s.Node)
+ if err != nil {
+ return refuse(err)
+ }
+ // What the node runs, by module. A consumer's credential is asked only for a module it runs: the vault refuses the
+ // whole ask for a consuming module not assigned to the node (mesh-vault's own), and a module the candidate adds is
+ // assigned only after the merge (ADR 0549 §5), so its credential is left missing, a stand-in answered as not yet
+ // issued under Added (review of L2b, round 1, finding 1).
+ runs := map[string]bool{}
+ for _, c := range running {
+ if c.Module != "" {
+ runs[c.Module] = true
+ }
+ }
+ var consumed []compose.CredentialKey
+ for _, k := range asConsumer {
+ if runs[k.ConsumerModule] {
+ consumed = append(consumed, k)
+ }
+ }
+ asConsumer = consumed
+ if len(asConsumer)+len(asProvider) > 0 {
+ if in.Credentials, in.Issued, err = r.Credentials(ctx, s.Node, asConsumer, asProvider); err != nil {
+ return refuse(fmt.Errorf("frombus: the credentials of %s: %w", s.Node, err))
+ }
+ }
+ composed, skipped, stood, err := composeEach(in, s.Node, fps, false, s.Running, failed, ranOf(ctx, r, mesh, s.Running, fps))
+ if err != nil {
+ // Not one module's: the node's own (a value served elsewhere, the mesh's network), said once as the node's.
+ if len(failed) == 0 {
+ return refuse(err)
+ }
+ failed[delivery.HaveNodeWord] = err.Error()
+ composed, skipped, stood = nil, nil, nil
+ }
+ // The node's own composition is judged only where the node runs one: one that runs none applies it only once a want
+ // names it (the node agent's own composer hands it on that want alone), so a candidate does not add it.
+ runsOwn := false
+ for _, c := range running {
+ runsOwn = runsOwn || c.Module == ""
+ }
+ if !runsOwn {
+ for key, c := range composed {
+ if c.Module == "" {
+ delete(composed, key)
+ }
+ }
+ }
+ if _, whole := failed[delivery.HaveNodeWord]; whole {
+ // Nothing composed to compare: every refusal is said, and nothing else.
+ for _, m := range sortedKeys(failed) {
+ a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: failed[m]})
+ }
+ heldAsToday(&a, s.HeldToday)
+ a.Verdict = delivery.VerdictOf(a)
+ return a
+ }
+ // A module the composition folds inside the node's own is judged there: the agent also says it runs by module, from
+ // the declaration it kept before the switch (agent.Everything), which is no removal (issue 1126, seed #531).
+ if len(s.InOwn) > 0 {
+ var judged []delivery.Composed
+ for _, c := range running {
+ if c.Module == "" || !slices.Contains(s.InOwn, c.Module) {
+ judged = append(judged, c)
+ }
+ }
+ running = judged
+ }
+ cmp := delivery.CompareComposedWith(composed, running, s.SameValue)
+ // The answer rides in the node's have key: its shown bodies are bounded, the rest named only.
+ budget := MaxShown
+ for i := range cmp.Differs {
+ kept := cmp.Differs[i].Bodies[:0]
+ for _, b := range cmp.Differs[i].Bodies {
+ if n := len(b.Composed) + len(b.Running); n <= budget {
+ budget -= n
+ kept = append(kept, b)
+ } else {
+ kept = append(kept, delivery.ResourceBodies{ID: b.ID, Why: b.Why})
+ a.Note = "some differing resources are named without their bodies: the answer holds at most " +
+ fmt.Sprint(MaxShown) + " bytes of them"
+ }
+ }
+ cmp.Differs[i].Bodies = kept
+ }
+ // A refused module was composed as a stub: what it composed is no difference; its refusal is said.
+ notFailed := func(m string) bool { _, ok := failed[m]; return !ok }
+ a.Same = slices.DeleteFunc(cmp.Same, func(m string) bool { return !notFailed(m) })
+ a.Removed = slices.DeleteFunc(cmp.Removed, func(m string) bool { return !notFailed(m) })
+ a.Differs = slices.DeleteFunc(cmp.Differs, func(d delivery.ModuleDiffers) bool { return !notFailed(d.Module) })
+ cmp.Added = slices.DeleteFunc(cmp.Added, func(d delivery.ModuleAdded) bool { return !notFailed(d.Module) })
+ skipped = slices.DeleteFunc(skipped, func(m string) bool { return !notFailed(m) })
+
+ // What the proposal adds, by node and module: placed by a candidate and not by the trunk on that node.
+ added := map[string]bool{}
+ for node, p := range s.Placements {
+ if p == nil {
+ continue
+ }
+ was := map[string]bool{}
+ for _, m := range trunk[node].Modules {
+ was[m.Module] = true
+ }
+ for _, m := range p.Modules {
+ if !was[m.Module] {
+ added[node+"/"+m.Module] = true
+ }
+ }
+ }
+ unissued := map[string][]string{}
+ refusals := map[string][]string{}
+ for _, x := range stood {
+ node, module := standsInFor(x, s.Node)
+ switch {
+ case node == s.Node && runs[module]:
+ // A value missing for a module the node runs is a refusal, whatever the trunk places (the seed's trunk is
+ // empty, and every module counts as the group's).
+ refusals[module] = append(refusals[module], x)
+ case module != "" && added[node+"/"+module]:
+ if node == s.Node {
+ unissued[module] = append(unissued[module], x)
+ } // another node's addition: the module here that carries it differs, which the assigner judges
+ case module == "" || node != s.Node:
+ // The node's own, or a value this node holds for a module elsewhere (a grant it provides): said as the node's.
+ refusals[delivery.HaveNodeWord] = append(refusals[delivery.HaveNodeWord], x)
+ default:
+ refusals[module] = append(refusals[module], x)
+ }
+ }
+ addedHere := map[string]delivery.ModuleAdded{}
+ for _, m := range cmp.Added {
+ addedHere[m.Module] = m
+ }
+ for _, m := range skipped { // placed, and no build given to compose it at
+ addedHere[m] = delivery.ModuleAdded{Module: m, Unissued: []string{"no build of " + m + " is known to compose it at"}}
+ }
+ for m, xs := range unissued {
+ got := addedHere[m]
+ got.Module = m
+ got.Unissued = append(got.Unissued, xs...)
+ addedHere[m] = got
+ }
+ for _, m := range sortedKeys(addedHere) {
+ a.Added = append(a.Added, addedHere[m])
+ }
+ for _, m := range sortedKeys(refusals) {
+ if _, isAdded := addedHere[m]; isAdded {
+ continue // a module the candidate adds here: what it lacks is said under Added
+ }
+ if _, isFailed := failed[m]; isFailed {
+ continue // said below, as why it could not be composed
+ }
+ a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: "stands in for " + strings.Join(refusals[m], ", ")})
+ }
+ for _, m := range sortedKeys(failed) {
+ a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: failed[m]})
+ }
+ sort.Slice(a.Refusals, func(i, j int) bool { return a.Refusals[i].Module < a.Refusals[j].Module })
+ heldAsToday(&a, s.HeldToday)
+ a.Verdict = delivery.VerdictOf(a)
+ return a
+}
+
+// heldAsToday moves into Held each refusal of a module the node keeps today as it runs (Scope.HeldToday, with how:
+// held, failed, running a build of the old form): the candidate keeps it as the node does, which is no change (issue
+// 1126: a seed reproduces today, and today keeps them so). Each says how it stands today (ModuleRefused.Today). No
+// module is held behind another (issue 1960): a module that reads from a held one composes with what it last offered.
+func heldAsToday(a *delivery.CandidateAnswer, today map[string]string) {
+ if len(today) == 0 {
+ return
+ }
+ kept := a.Refusals[:0]
+ for _, r := range a.Refusals {
+ if w, keep := today[r.Module]; keep {
+ r.Today = w
+ a.Held = append(a.Held, r)
+ } else {
+ kept = append(kept, r)
+ }
+ }
+ a.Refusals = kept
+ if len(a.Refusals) == 0 {
+ a.Refusals = nil
+ }
+}
+
+// maxCulprits bounds the modules one refusal is tried on, each a whole composition.
+const maxCulprits = 8
+
+// MaxShown bounds the bytes of shown resource bodies in one answer (delivery.ModuleDiffers.Bodies).
+const MaxShown = 256 << 10
+
+var (
+ grantStandIn = regexp.MustCompile(`^the grant of \S+ for (\S+) on (\S+)$`)
+ // grantOfStandIn is the provision a grant's stand-in is of.
+ grantOfStandIn = regexp.MustCompile(`^the grant of (\S+) for \S+ on \S+$`)
+ needsStandIn = regexp.MustCompile(`^(\S+) on (\S+) needs `)
+)
+
+// standsInFor is the node and module a stand-in of compose's words is for: an own secret `module/name`, a port
+// `port module:port`, a grant `the grant of for on `, a need ` on needs ...`;
+// the module empty when the words name none (the certificate, the authority).
+func standsInFor(x, node string) (string, string) {
+ if m := grantStandIn.FindStringSubmatch(x); m != nil {
+ return m[2], m[1]
+ }
+ if m := needsStandIn.FindStringSubmatch(x); m != nil {
+ return m[2], m[1]
+ }
+ if rest, ok := strings.CutPrefix(x, "port "); ok {
+ if module, _, ok := strings.Cut(rest, ":"); ok {
+ return node, module
+ }
+ }
+ if module, _, ok := strings.Cut(x, "/"); ok && !strings.Contains(x, " ") {
+ return node, module
+ }
+ return node, ""
+}
+
+// composeEach is the one rule, for the node that applies and for its answer to a candidate alike, by which a module's
+// own refusal refuses that module and never the node (issue 1126: seed-8 fixed it in the answer alone, and the
+// applying path still refused all of novox for de-spiegel's unset route names). The node is composed; a refusal is laid
+// on the module whose stub (compose.Stub) changes it (culprit), that module is held (compose.Node.Held) and said in
+// failed with the refusal's words, and the rest is composed again, until it composes or the refusal is no single
+// module's, which is answered as the node's (err). Applied composes as the node applies (compose.Reading) and holds the
+// module placed here each stand-in is for; one for no module placed here (the node's own, a value this node holds for a
+// module elsewhere) refuses the node (compose.ErrStoodIn). Not applied, it composes in compose.Foreseeing and answers
+// every stand-in for the caller to judge (Answer).
+//
+// **A held module is what runs of it** (review of the apply, round 1): composed from what running holds of it
+// (compose.Carried of its own composition there; the node's own carries it wherever it runs there), so what the others
+// read of it (the machine ports its containers publish, the routes reaching it) is what runs, and none of its own
+// composition is in out: the node keeps what it last applied.
+//
+// **A hold never spreads** (issue 1126, issue 1960: de-spiegel's hold for an unset value held mailu, which held
+// route-proxy, nftables and postgres on novox). No module is held because a module it reads from is held: a consumer,
+// a collector, the provider of what the held one contributes to and the filter where it listens each compose with what
+// the held module last offered. That offer (what it contributes, contributes many, listens on) is its manifest at the
+// build it runs where one is known (ran: a composition of its own running at another build than it is composed at),
+// else the manifest it is composed from. Only the part of the offer that cannot compose is left out, by provision
+// (culprit): de-spiegel's routes, its names unset, leave route-proxy without them while mailu keeps its smtp account,
+// and the hold says so.
+func composeEach(in compose.Input, node string, fps map[string]string, applied bool, running []delivery.Composed, failed map[string]string,
+ ran func(module string) (catalogue.Manifest, bool)) (out map[string]delivery.Composed, skipped, stood []string, err error) {
+ mode := compose.Foreseeing
+ if applied {
+ mode = compose.Reading
+ }
+ runs := map[string]*delivery.Composed{}
+ for i := range running {
+ if m := running[i].Module; m != "" {
+ runs[m] = &running[i]
+ }
+ }
+ src := map[string]catalogue.Manifest{} // each held module's manifest before it was held, offering what it last offered
+ left := map[string]map[string]bool{} // by held module, what of its offer is left out (leaveOut)
+ hold := func(m, why, leave string) {
+ if _, done := failed[m]; !done {
+ failed[m] = why // a held module tried again keeps the words it was first held for
+ }
+ if _, ok := src[m]; !ok {
+ man, ok := manifestOn(in, node, m)
+ if !ok {
+ return
+ }
+ if ran != nil {
+ if was, ok := ran(m); ok {
+ man.Contributes, man.ContributesMany, man.Listens = was.Contributes, was.ContributesMany, was.Listens
+ }
+ }
+ src[m] = man
+ }
+ if leave != "" {
+ if left[m] == nil {
+ left[m] = map[string]bool{}
+ }
+ left[m][leave] = true
+ if leave != leaveAll && leave != leaveOwn {
+ failed[m] += "; composed without what it contributes to " + leave + ", which does not compose"
+ }
+ }
+ in = heldAs(in, node, m, src[m], left[m], runs[m])
+ }
+ for _, m := range sortedKeys(failed) {
+ hold(m, failed[m], "")
+ }
+ for {
+ out, skipped, stood, err = compose.CompositionsIn(in, node, fps, mode)
+ if err != nil {
+ module, leave := culprit(in, node, fps, mode, failed, err)
+ if module == "" {
+ return nil, nil, nil, firstReason(err, failed)
+ }
+ hold(module, err.Error(), leave)
+ continue
+ }
+ again := false
+ if applied && len(stood) > 0 {
+ placed := map[string]bool{}
+ for _, m := range assignedOn(in, node) {
+ placed[m] = true
+ }
+ of := map[string][]string{}
+ var nodes []string
+ for _, x := range stood {
+ if n, m := standsInFor(x, node); n == node && placed[m] {
+ of[m] = append(of[m], x)
+ } else {
+ nodes = append(nodes, x)
+ }
+ }
+ for _, m := range sortedKeys(of) {
+ if _, done := failed[m]; !done {
+ hold(m, "stands in for "+strings.Join(of[m], ", "), "")
+ again = true
+ continue
+ }
+ // Already held, and still a stand-in in another module's composition: a grant not yet issued for what
+ // it contributes (a module held for its own secret keeps its contributions, leaveOwn). Its contribution
+ // to that provision is left out, so the provider never applies a stand-in (issue 774, review round 1).
+ for _, x := range of[m] {
+ if g := grantOfStandIn.FindStringSubmatch(x); g != nil && !left[m][g[1]] {
+ hold(m, failed[m], g[1])
+ again = true
+ }
+ }
+ }
+ if !again && len(nodes) > 0 {
+ return nil, nil, nil, fmt.Errorf("%w on %s: %s", compose.ErrStoodIn, node, strings.Join(nodes, ", "))
+ }
+ }
+ if !again {
+ break
+ }
+ }
+ skipped = slices.DeleteFunc(skipped, func(m string) bool { _, held := failed[m]; return held })
+ return out, skipped, stood, nil
+}
+
+// firstReason is err, a refusal no culprit is found for, led by the first reason of each held module it names: a held
+// module's stand-in refused later never replaces why it was held (issue 774, 2026-10-30: mesh-self-check on ace held for
+// "has no bus credential", then refused as "runs as its own account and declares no own secret broker", its bare stub's
+// words, which were all the proposal's refusal said).
+func firstReason(err error, failed map[string]string) error {
+ var named []string
+ for _, m := range sortedKeys(failed) {
+ if strings.Contains(err.Error(), m) {
+ named = append(named, m+" is held: "+failed[m])
+ }
+ }
+ if len(named) == 0 {
+ return err
+ }
+ return fmt.Errorf("%s; its stand-in is then refused: %w", strings.Join(named, "; "), err)
+}
+
+// blames says the composition with m's stub (again) makes m the culprit of err: it composes, or fails otherwise. The
+// runtime's stub is never a runtime (no build is taken in, so it carries no bundle) and always fails otherwise: it is
+// the culprit only where its stub composes (issue 774, 2026-10-30 19:30: node-tools held for any error naming it).
+func blames(m string, again, err error) bool {
+ if m == catalogue.RuntimeModule {
+ // Or where its stub fails only for the modules of their own account it would run (issue 774): they are held
+ // after it, each for its own words, and the node composes the rest.
+ var none *resolution.NoRuntimeToRunError
+ return again == nil || errors.As(again, &none) && none.Held
+ }
+ return again == nil || again.Error() != err.Error()
+}
+
+// leaveAll leaves out the whole of a held module's offer and its own secrets (a bare stub); leaveOwn its own secrets
+// alone, its offer kept; any other word leaves out what it contributes to that provision alone.
+//
+// leaveOwn is tried before leaveAll: a module held for an own secret never made (nextcloud's admin, issue 774,
+// 2026-10-30) keeps what it contributes, so its providers' received files keep it and their helpers never retire its
+// login while it is held.
+const (
+ leaveAll = "*"
+ leaveOwn = "own-secrets"
+)
+
+// assignedOn is the modules placed on node, in placement order.
+func assignedOn(in compose.Input, node string) []string {
+ for _, n := range in.Nodes {
+ if n.Name == node {
+ return n.Assigned
+ }
+ }
+ return nil
+}
+
+// manifestOn is the manifest node composes module from: its Kept, else the catalogue's.
+func manifestOn(in compose.Input, node, module string) (catalogue.Manifest, bool) {
+ for _, n := range in.Nodes {
+ if n.Name == node {
+ if man, ok := n.Kept[module]; ok {
+ return man, true
+ }
+ }
+ }
+ man, ok := in.Catalogue[module]
+ return man, ok
+}
+
+// culprit is the module placed on node whose composition the error is of, and what of its offer to leave out: the
+// first, in placement order, whose stub (compose.Stub) makes the composition in mode succeed or fail otherwise (leave
+// ""); else the first whose stub without what it contributes to one provision does (leave that provision: de-spiegel's
+// routes, its names unset, and not its smtp account, issue 1960); else the first whose bare stub does (leaveAll: its
+// stub contributing nothing, its own secret never issued, issue 1126 seed-8). Empty when none does (the error is not
+// one module's). Modules already refused are passed over for their stub.
+func culprit(in compose.Input, node string, fps map[string]string, mode compose.Mode, failed map[string]string, err error) (string, string) {
+ assigned := assignedOn(in, node)
+ // Only the modules the error names: the words of a module's refusal name it. Each try is a whole composition, so a
+ // refusal naming none is no module's, answered at once rather than after a composition per module (issue 1126: ace's
+ // answer took 7m46s).
+ var order []string
+ for _, m := range assigned {
+ if strings.Contains(err.Error(), m) && len(order) < maxCulprits {
+ order = append(order, m)
+ }
+ }
+ changes := func(m, leave string) bool {
+ _, _, _, again := compose.CompositionsIn(stubbed(in, node, m, leave), node, fps, mode)
+ return blames(m, again, err)
+ }
+ for _, m := range order {
+ if _, done := failed[m]; done {
+ continue
+ }
+ if changes(m, "") {
+ return m, ""
+ }
+ }
+ for _, m := range order {
+ man, ok := manifestOn(in, node, m)
+ if !ok {
+ continue
+ }
+ // One already held is tried again too: held for its build, its stub may still refuse (its own secret never
+ // issued, issue 1126). A provision named in the error is tried first, each at one more composition.
+ var to []string
+ for p := range man.Contributes {
+ to = append(to, p)
+ }
+ for p := range man.ContributesMany {
+ if _, both := man.Contributes[p]; !both {
+ to = append(to, p)
+ }
+ }
+ sort.SliceStable(to, func(i, j int) bool {
+ return strings.Contains(err.Error(), " "+to[i]) && !strings.Contains(err.Error(), " "+to[j])
+ })
+ for _, p := range to {
+ if changes(m, p) {
+ return m, p
+ }
+ }
+ if isBare(man) {
+ continue
+ }
+ if len(man.OwnSecrets) > 0 && changes(m, leaveOwn) {
+ return m, leaveOwn
+ }
+ if changes(m, leaveAll) {
+ return m, leaveAll
+ }
+ }
+ return "", ""
+}
+
+// isBare says a stub contributes nothing and declares no own secret (stubbed, leaveAll).
+func isBare(m catalogue.Manifest) bool {
+ return len(m.Contributes) == 0 && len(m.ContributesMany) == 0 && len(m.OwnSecrets) == 0
+}
+
+// stubbed is in with module held on node as its stub alone (heldAs, nothing of it running), from the manifest node
+// composes it from, leave left out of its offer: a culprit's try.
+func stubbed(in compose.Input, node, module, leave string) compose.Input {
+ man, ok := manifestOn(in, node, module)
+ if !ok {
+ return in
+ }
+ var left map[string]bool
+ if leave != "" {
+ left = map[string]bool{leave: true}
+ }
+ return heldAs(in, node, module, man, left, nil)
+}
+
+// heldAs is in with module held on node (compose.Node.Held): composed from man as what runs of it (compose.Carried of
+// runs; its stub, compose.Stub, when runs is nil or holds nothing of it), as node's Kept; with what left names out of
+// its offer: a provision's contributions, with leaveOwn its own secrets alone, or with leaveAll everything it contributes
+// and its own secrets, none of a held module's own composition being applied, so what it has not been issued (restic's
+// repository on a node it never ran on) is no refusal of the rest.
+func heldAs(in compose.Input, node, module string, man catalogue.Manifest, left map[string]bool, runs *delivery.Composed) compose.Input {
+ out := in
+ out.Nodes = append([]compose.Node(nil), in.Nodes...)
+ for i := range out.Nodes {
+ if out.Nodes[i].Name != node {
+ continue
+ }
+ if left[leaveAll] {
+ man.Contributes, man.ContributesMany, man.OwnSecrets = nil, nil, nil
+ } else if len(left) > 0 {
+ if left[leaveOwn] {
+ man.OwnSecrets = nil
+ }
+ contributes := map[string]map[string]any{}
+ for p, v := range man.Contributes {
+ if !left[p] {
+ contributes[p] = v
+ }
+ }
+ many := maps.Clone(man.ContributesMany)
+ for p := range left {
+ delete(many, p)
+ }
+ man.Contributes, man.ContributesMany = contributes, many
+ }
+ kept := make(map[string]catalogue.Manifest, len(out.Nodes[i].Kept)+1)
+ for k, v := range out.Nodes[i].Kept {
+ kept[k] = v
+ }
+ kept[module] = compose.Carried(man, module, runs)
+ out.Nodes[i].Kept = kept
+ held := make(map[string]bool, len(out.Nodes[i].Held)+1)
+ for k, v := range out.Nodes[i].Held {
+ held[k] = v
+ }
+ held[module] = true
+ out.Nodes[i].Held = held
+ }
+ return out
+}
diff --git a/internal/compose/frombus/answer_rows_test.go b/internal/compose/frombus/answer_rows_test.go
new file mode 100644
index 0000000..daee654
--- /dev/null
+++ b/internal/compose/frombus/answer_rows_test.go
@@ -0,0 +1,596 @@
+package frombus
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "reflect"
+ "slices"
+ "strings"
+ "testing"
+
+ "git.novox.be/novox/mesh-sdk/go/catalogue"
+ "git.novox.be/novox/mesh-sdk/go/delivery"
+ "github.com/novox/mesh-host/internal/compose"
+ "github.com/novox/mesh-host/internal/compose/resolution"
+ "github.com/novox/mesh-host/internal/overlay"
+)
+
+// Issue 1126 (the seed's shadow, round 2): a node's answer names every module it cannot compose, not the first only;
+// and a module folded into its own composition that names a build of the old form (de-spiegel on novox, nats, photos,
+// systemd-resolved: their haves name a build-... id and no fingerprint) is left to the node, carried as it runs.
+func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
+ ctx := context.Background()
+ image := "alpine@sha256:" + strings.Repeat("a", 64)
+ manifest := func(module string) []byte {
+ // Its data names its directory, as nats's names ${dir:mesh-state} (issue 1126: novox's own composition refused
+ // "nats says ${dir:mesh-state}, and nats declares no directory mesh-state" when its stub held no directory).
+ return []byte(`{"module":"` + module + `","version":"1","capabilities":["container-runtime"],` +
+ `"own-secrets":{"token":"${dir:state}/token"},` +
+ `"resources":[{"id":"state","type":"directory","path":"/var/lib/` + module + `","mode":"0700"},` +
+ `{"id":"server","type":"container","name":"` + module + `","artifact":"site"}],` +
+ `"build":{"artifacts":[{"name":"site","kind":"upstream","from":"` + image + `"}]}}`)
+ }
+ lemurs := []byte(`{"module":"lemurs","version":"1","resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`)
+ fp := func(c string) string { return "src2:" + strings.Repeat(c, 64) }
+ mk := func(placed ...string) *bus {
+ p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}}
+ for _, m := range placed {
+ p.Modules = append(p.Modules, delivery.PlacedModule{Module: m})
+ }
+ b := &bus{placements: map[string]delivery.Placement{"n1": p},
+ records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd", "container-runtime"}}},
+ modules: map[string]delivery.Module{"lemurs": {Manifest: delivery.ManifestDigest(lemurs)}},
+ manifests: map[string][]byte{delivery.ManifestDigest(lemurs): lemurs},
+ builds: map[string]*delivery.BuilderBuild{}}
+ for _, m := range []string{"web", "api", "spiegel"} {
+ b.own = append(b.own, compose.OwnSecretRow{Node: "n1", Module: m, Name: "token", Origin: "made", SealedTo: "sk", Sealed: `"box"`})
+ }
+ for _, m := range []string{"web", "api", "spiegel"} {
+ raw := manifest(m)
+ b.manifests[delivery.ManifestDigest(raw)] = raw
+ b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
+ }
+ // web and api are built, their keys done and waiting for the reference of their image; spiegel has no build of
+ // the new form at all.
+ for i, m := range []string{"web", "api"} {
+ mod := b.modules[m]
+ mod.FP2 = fp(string(rune('1' + i)))
+ b.modules[m] = mod
+ b.builds[m+" "+mod.FP2] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
+ Artifacts: []delivery.BuiltArtifact{{Name: "site", Kind: "image", Digest: "sha256:" + strings.Repeat("b", 64)}}}
+ }
+ return b
+ }
+ given := func(b *bus) map[string]*delivery.Placement {
+ p := b.placements["n1"]
+ return map[string]*delivery.Placement{"n1": &p}
+ }
+
+ // Every refusal: two builds taken in, and a module the composition refuses (its image never built), each named.
+ b := mk("lemurs", "web", "api", "spiegel")
+ a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b)},
+ map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3)
+ var refused []string
+ for _, r := range a.Refusals {
+ refused = append(refused, r.Module)
+ }
+ if a.Verdict != delivery.CandidateRefused || a.Refused != "" || !reflect.DeepEqual(refused, []string{"api", "spiegel", "web"}) {
+ t.Errorf("every refusal: %+v, want api, spiegel and web refused, each named", a)
+ }
+ for _, r := range a.Refusals {
+ want := map[string]string{"api": "waits for the reference of site", "web": "waits for the reference of site",
+ "spiegel": "spiegel has not been built for this mesh"}[r.Module]
+ if !strings.Contains(r.Why, want) {
+ t.Errorf("%s refused as %q, want %q", r.Module, r.Why, want)
+ }
+ }
+ if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": a}}); err != nil {
+ t.Errorf("the answer is not one a have holds: %v (%s)", err, raw)
+ }
+ if lemursOK := slices.ContainsFunc(a.Added, func(m delivery.ModuleAdded) bool { return m.Module == "lemurs" }); !lemursOK {
+ t.Errorf("lemurs, which composes, is not answered beside the refusals: %+v", a)
+ }
+ // Issue 1126: a module the node holds today that the candidate holds too is held as today, no refusal: with every
+ // refused module held today the verdict is no refusal, and each is named under Held.
+ today := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held", "spiegel": "held", "web": "held"}},
+ map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3)
+ if today.Verdict == delivery.CandidateRefused || len(today.Refusals) != 0 || len(today.Held) != 3 || today.Held[0].Why == "" {
+ t.Errorf("held as today: %+v", today)
+ }
+ // A refused module the node does not hold today stays a refusal: holding only api today leaves spiegel and web refused.
+ part := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held"}},
+ map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3)
+ if part.Verdict != delivery.CandidateRefused || len(part.Refusals) != 2 || len(part.Held) != 1 || part.Held[0].Module != "api" {
+ t.Errorf("held as today in part: %+v", part)
+ }
+ // No module is held behind one kept today (issue 1960): mailu's own refusal stays a refusal beside de-spiegel's hold.
+ if a := (delivery.CandidateAnswer{Refusals: []delivery.ModuleRefused{{Module: "de-spiegel", Why: "a value is unset"},
+ {Module: "mailu", Why: "no build"}}}); true {
+ heldAsToday(&a, map[string]string{"de-spiegel": "runs a build of the old form"})
+ if len(a.Refusals) != 1 || a.Refusals[0].Module != "mailu" || len(a.Held) != 1 {
+ t.Errorf("held as today spreads: %+v", a)
+ }
+ }
+
+ // The fold: spiegel runs inside the node's own composition at a build of the old form. Its resources are carried as
+ // they run, each after the one it runs after: the node's own is the one it runs, and the node composes itself.
+ b = mk("lemurs", overlay.Name, "spiegel")
+ p := b.placements["n1"]
+ p.Decided.Endpoint = "n1.example:51820"
+ b.placements["n1"] = p
+ fps := map[string]string{"lemurs": fp("e")}
+ stub := Scope{Node: "n1"}
+ in, err := Input(ctx, b, Mesh{}, stub)
+ if err != nil {
+ t.Fatal(err)
+ }
+ in.Nodes[0].Kept = map[string]catalogue.Manifest{"spiegel": compose.Stub(in.Catalogue["spiegel"])}
+ base, _, err := compose.Compositions(in, "n1", fps, compose.Reading)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // A refusal naming no module placed here is no module's: no compose is spent stubbing each in turn (issue 1126: ace's
+ // answer took 7m46s, one compose per module), though here a stub would compose.
+ if m, _ := culprit(in, "n1", fps, compose.Foreseeing, map[string]string{}, fmt.Errorf("nothing in this mesh provides \"artifact-store\"")); m != "" {
+ t.Errorf("a refusal naming no placed module was laid on %s", m)
+ }
+ ownKey, _ := delivery.ComposedNodeKey("n1")
+ own := base[ownKey]
+ carriedRes := []json.RawMessage{
+ json.RawMessage(`{"id":"spiegel.state","type":"directory","path":"/var/lib/spiegel","mode":"0700"}`),
+ json.RawMessage(`{"id":"spiegel.server","type":"container","name":"spiegel","image":"registry.example/spiegel@sha256:` + strings.Repeat("c", 64) + `"}`)}
+ // They run after the node's first own resource.
+ own.Resources = append(append(append([]json.RawMessage{}, own.Resources[:1]...), carriedRes...), own.Resources[1:]...)
+ var running []delivery.Composed
+ for k, c := range base {
+ if k == ownKey {
+ c = own
+ }
+ running = append(running, c)
+ }
+ scope := Scope{Node: "n1", InOwn: []string{"spiegel"}, Running: running}
+ got, _, held, err := Compose(ctx, b, Mesh{}, scope, fps)
+ if err == nil && len(held) > 0 {
+ err = fmt.Errorf("modules held: %v", held)
+ }
+ if err != nil {
+ t.Fatalf("the node does not compose itself with spiegel folded at a build of the old form: %v", err)
+ }
+ gotOwn, _ := json.Marshal(got[ownKey].Resources)
+ wantOwn, _ := json.Marshal(own.Resources)
+ if string(gotOwn) != string(wantOwn) {
+ t.Errorf("the node's own composition is\n%s\nwant what runs\n%s", gotOwn, wantOwn)
+ }
+ a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, running, "p-h1", 3)
+ if a.Verdict != delivery.CandidateSame {
+ t.Errorf("the fold's answer: %+v, want the same", a)
+ }
+ // (A) The agent also says spiegel runs by module, from the declaration kept before the switch (agent.Everything): a
+ // module the composition folds is judged inside the node's own, not as removed (issue 1126, seed #531's answer).
+ keptToo := append(append([]delivery.Composed{}, running...), delivery.Composed{Node: "n1", Module: "spiegel", Resources: carriedRes})
+ if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, keptToo, "p-h1", 3); a.Verdict != delivery.CandidateSame || len(a.Removed) != 0 {
+ t.Errorf("a folded module running from the kept declaration is answered %+v, want the same", a)
+ }
+ // (B) The node's own composition carries its bus membership as it runs it (the controller's, from its store): composed
+ // the same, not 'in what runs, not composed' (seed #531, novox).
+ membership := json.RawMessage(`{"id":"bus-membership","type":"file","path":"` + catalogue.BusMembershipPath + `","sealed":"{\"box\":\"m\"}","mode":"0600"}`)
+ withMembership := make([]delivery.Composed, 0, len(running))
+ for _, c := range running {
+ if c.Module == "" {
+ c.Resources = append(append([]json.RawMessage{}, c.Resources...), membership)
+ }
+ withMembership = append(withMembership, c)
+ }
+ if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, withMembership, "p-h1", 3); a.Verdict != delivery.CandidateSame {
+ t.Errorf("the node's own with its bus membership is answered %+v, want the same", a)
+ }
+ // Without what runs, the fold composes spiegel from its manifest, as before, and the composition refuses it.
+ if _, _, held, err := Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}}, fps); err != nil ||
+ !strings.Contains(held["spiegel"], "spiegel has not been built for this mesh") {
+ t.Errorf("composed spiegel without what runs: %v %v, want spiegel held alone", err, held)
+ }
+
+ // (C) Issue 1126, C5: what the others read of a module carried as it runs is what runs, never its stub's: the filter
+ // opens the machine port its running container publishes (de-spiegel's 20005 on novox, not its software's 8080; a
+ // stub had no container, so snake's 80 on ace was opened beside the route proxy's). And C6: the operator export's file
+ // a module that keeps it runs is carried as it runs (mesh-vault.kept on novox), since the node holds no export.
+ listening := []byte(`{"module":"spiegel","version":"1","capabilities":["container-runtime"],` +
+ `"listens":[{"port":8080,"from":"mesh","why":"the site"}],"own-secrets":{"token":"${dir:state}/token"},` +
+ `"resources":[{"id":"state","type":"directory","path":"/var/lib/spiegel","mode":"0700"},` +
+ `{"id":"server","type":"container","name":"spiegel","artifact":"site","ports":["8080"]}],` +
+ `"build":{"artifacts":[{"name":"site","kind":"upstream","from":"` + image + `"}]}}`)
+ nft := []byte(`{"module":"nft","version":"1","capabilities":["systemd"],"filtering":{"into":"/etc/nftables.conf"}}`)
+ vault := []byte(`{"module":"vault","version":"1","capabilities":["systemd"],"keeps":"/var/lib/vault/root",` +
+ `"resources":[{"id":"service","type":"service","unit":"vault.service","state":"running","boot":"enabled"}]}`)
+ b = mk("lemurs", overlay.Name, "spiegel", "nft", "vault")
+ for m, raw := range map[string][]byte{"spiegel": listening, "nft": nft, "vault": vault} {
+ b.manifests[delivery.ManifestDigest(raw)] = raw
+ b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
+ }
+ p = b.placements["n1"]
+ p.Decided.Endpoint = "n1.example:51820"
+ for i := range p.Modules {
+ if p.Modules[i].Module == "spiegel" {
+ p.Modules[i].Ports = map[string]int{"8080": 20005}
+ }
+ }
+ b.placements["n1"] = p
+ rec := b.records["n1"]
+ rec.OutwardLinks = []string{"eth0"}
+ b.records["n1"] = rec
+ fps = map[string]string{"lemurs": fp("e"), "nft": fp("f"), "vault": fp("9")}
+ if in, err = Input(ctx, b, Mesh{}, stub); err != nil {
+ t.Fatal(err)
+ }
+ in.Nodes[0].Kept = map[string]catalogue.Manifest{"spiegel": compose.Stub(in.Catalogue["spiegel"])}
+ if base, _, err = compose.Compositions(in, "n1", fps, compose.Reading); err != nil {
+ t.Fatal(err)
+ }
+ keptFile := json.RawMessage(`{"digest":"sha256:` + strings.Repeat("d", 64) + `","id":"vault.kept","mode":"0600","module":"vault","path":"/var/lib/vault/root/export.json","size":143604,"type":"file"}`)
+ running = nil
+ for _, c := range base {
+ switch c.Module {
+ case "":
+ c.Resources = append(append([]json.RawMessage{}, c.Resources...),
+ json.RawMessage(`{"id":"spiegel.state","type":"directory","path":"/var/lib/spiegel","mode":"0700"}`),
+ json.RawMessage(`{"id":"spiegel.server","type":"container","name":"spiegel","image":"registry.example/spiegel@sha256:`+strings.Repeat("c", 64)+`","ports":["10.77.0.1:20005:8080"]}`),
+ json.RawMessage(`{"id":"spiegel.server-env","type":"file","path":"/var/lib/spiegel/server.env","mode":"0600","content":"A=1\n"}`))
+ case "vault":
+ c.Resources = append(append([]json.RawMessage{}, c.Resources...), keptFile)
+ }
+ running = append(running, c)
+ }
+ got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}, Running: running}, fps)
+ if err == nil && len(held) > 0 {
+ err = fmt.Errorf("modules held: %v", held)
+ }
+ if err != nil {
+ t.Fatalf("the node does not compose itself with spiegel carried: %v", err)
+ }
+ var rules string
+ var keptComposed json.RawMessage
+ for _, c := range got {
+ for _, raw := range c.Resources {
+ var r struct {
+ ID string `json:"id"`
+ Content string `json:"content"`
+ }
+ _ = json.Unmarshal(raw, &r)
+ switch r.ID {
+ case "nft." + catalogue.FilteringID():
+ rules = r.Content
+ case "vault." + resolution.KeptID():
+ keptComposed = raw
+ }
+ }
+ }
+ if !strings.Contains(rules, "# spiegel — the site") || !strings.Contains(rules, "dport 20005 accept") || strings.Contains(rules, "dport 8080") {
+ t.Errorf("the filter does not open the machine port spiegel runs at:\n%s", rules)
+ }
+ // The paths a carried module owns are its manifest's, as the controller composes them, never what runs of it (issue
+ // 1126, seed-8 #542: on ace, the folded f1-circuits, snake and txt-game, whose manifests leave their directories and
+ // files to ${dir:…}, owned their /var/lib paths on the node and none on the controller, shifting node-tools'
+ // MESH_OWNED_PATHS). f1-circuits' manifest at the indexer's commit (c0ea2c94) is captured; what runs of it is
+ // composed from that manifest by the controller's rule (the directory at /var/lib/f1-circuits, its file, its container).
+ for _, c := range running {
+ if c.Module == "" && !reflect.DeepEqual(compose.Carried(in.Catalogue["spiegel"], "spiegel", &c).OwnsPaths, []string{"/var/lib/spiegel"}) {
+ t.Errorf("spiegel carried owns %v, want its manifest's /var/lib/spiegel alone", compose.Carried(in.Catalogue["spiegel"], "spiegel", &c).OwnsPaths)
+ }
+ }
+ f1raw, err := os.ReadFile("testdata/f1-circuits-c0ea2c94.module.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ f1, err := catalogue.Decode(f1raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ f1runs := delivery.Composed{Node: "ace", Resources: []json.RawMessage{
+ json.RawMessage(`{"id":"f1-circuits.state","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits","mode":"0700"}`),
+ json.RawMessage(`{"id":"f1-circuits.cache","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits/cache","mode":"0700"}`),
+ json.RawMessage(`{"id":"f1-circuits.site-env","module":"f1-circuits","type":"file","path":"/var/lib/f1-circuits/site.env","mode":"0644","content":"F1_CIRCUITS_BASE_URL=https://f1.example\n"}`),
+ json.RawMessage(`{"id":"f1-circuits.server","module":"f1-circuits","type":"container","name":"f1-circuits","image":"registry.example/f1@sha256:` + strings.Repeat("c", 64) + `"}`)}}
+ if got, want := resolution.DeclaredPaths(compose.Carried(f1, "f1-circuits", &f1runs)), resolution.DeclaredPaths(f1); !reflect.DeepEqual(got, want) {
+ t.Errorf("f1-circuits carried owns %v, want what the controller composes from its manifest, %v", got, want)
+ }
+ // Issue 1126: a held module hands no template its build kept for a file it no longer carries (novox's node:
+ // "postgres: its build kept a template for consumers"), but keeps its items and their templates, so what it
+ // contributes to other modules' collections still reaches them.
+ for _, row := range []struct {
+ name string
+ handed *catalogue.Handed
+ items []catalogue.Item
+ }{
+ {"templates and items", &catalogue.Handed{Templates: map[string]string{"consumers": "files/consumers.json"},
+ Items: map[string]string{"routes": "files/routes.tmpl"}}, []catalogue.Item{{Collection: "routes", Content: "x"}}},
+ {"items alone", nil, []catalogue.Item{{Collection: "routes", Content: "x"}}},
+ } {
+ kept := f1
+ kept.Handed, kept.Items = row.handed, row.items
+ st := compose.Stub(kept)
+ if st.Handed != nil && st.Handed.Templates != nil {
+ t.Errorf("%s: a stub hands templates %v", row.name, st.Handed.Templates)
+ }
+ if !reflect.DeepEqual(st.Items, row.items) {
+ t.Errorf("%s: a stub collects %+v, want its items %+v", row.name, st.Items, row.items)
+ }
+ if row.handed != nil && (st.Handed == nil || !reflect.DeepEqual(st.Handed.Items, row.handed.Items) || row.handed.Templates == nil) {
+ t.Errorf("%s: a stub hands item templates %+v, want %v and its manifest untouched", row.name, st.Handed, row.handed.Items)
+ }
+ }
+ var k1, k2 any
+ _ = json.Unmarshal(keptComposed, &k1)
+ _ = json.Unmarshal(keptFile, &k2)
+ if !reflect.DeepEqual(k1, k2) {
+ t.Errorf("the operator export is composed as %s, want the file that runs %s", keptComposed, keptFile)
+ }
+
+ // (D) Issue 1126, seed-8 (mesh-catalog #542): a module's own refusal refuses that module, never the node. novox runs
+ // de-spiegel folded, and its manifest at the indexer's commit (e25e1beb, captured) asks four required config values
+ // for its route names that nothing sets. novox answered the whole candidate refused ("de-spiegel contributing
+ // praktijk to route says ${setting:praktijk-name} ...": its stub still contributed the route). Only de-spiegel is
+ // refused, in the config form's words; the rest is judged. Issue 1960, novox's chain on 2026-10-29: the hold spread
+ // to mailu (its smtp provider) and proxy (its route provider), and from them on. Neither is held now: proxy composes
+ // without de-spiegel's routes, which do not compose, and mailu with its smtp account, which does.
+ spiegel, err := os.ReadFile("testdata/de-spiegel-e25e1beb.module.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ proxy := []byte(`{"module":"proxy","version":"1","capabilities":["systemd"],"provides":[{"name":"route","scope":"mesh"}],` +
+ `"resources":[{"id":"service","type":"service","unit":"proxy.service","state":"running","boot":"enabled"}]}`)
+ mailu := []byte(`{"module":"mailu","version":"1","capabilities":["systemd"],"provides":[{"name":"smtp","scope":"mesh"}],` +
+ `"resources":[{"id":"service","type":"service","unit":"mailu.service","state":"running","boot":"enabled"}]}`)
+ b = mk("lemurs", overlay.Name, "mailu", "proxy", "de-spiegel")
+ for m, raw := range map[string][]byte{"de-spiegel": spiegel, "proxy": proxy, "mailu": mailu} {
+ b.manifests[delivery.ManifestDigest(raw)] = raw
+ b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
+ }
+ p = b.placements["n1"]
+ p.Decided.Endpoint = "n1.example:51820"
+ b.placements["n1"] = p
+ fps = map[string]string{"lemurs": fp("e"), "proxy": fp("f"), "mailu": fp("d")}
+ a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"de-spiegel"}}, fps, nil, "p-h1", 3)
+ refused = nil
+ for _, r := range a.Refusals {
+ refused = append(refused, r.Module)
+ if r.Module == "de-spiegel" && (!strings.Contains(r.Why, "${config:praktijk-name}") || strings.Contains(r.Why, "${setting:")) {
+ t.Errorf("de-spiegel is refused in other words than its config form's: %s", r.Why)
+ }
+ }
+ if a.Refused != "" || !reflect.DeepEqual(refused, []string{"de-spiegel"}) {
+ t.Errorf("a module's own refusal: %+v, want de-spiegel refused alone, the node, mailu and proxy not", a)
+ }
+ for _, m := range []string{"lemurs", "mailu", "proxy"} {
+ if !slices.ContainsFunc(a.Added, func(x delivery.ModuleAdded) bool { return x.Module == m }) {
+ t.Errorf("%s, which composes, is not judged beside de-spiegel's refusal: %+v", m, a)
+ }
+ }
+
+ // (E) Issue 1126, the applying path (review of the seed): the composition novox applies follows the same rule. It
+ // composed with no culprit, so de-spiegel's unset route names refused the whole node and every module it runs was
+ // marked not composed. Now de-spiegel is held, its refusal said in its config form's words, with proxy, which
+ // receives its routes; lemurs composes, and the node's own composition carries de-spiegel as it runs, so applying it
+ // removes nothing of it.
+ spiegelRuns := []json.RawMessage{
+ json.RawMessage(`{"id":"de-spiegel.state","type":"directory","path":"/var/lib/de-spiegel","mode":"0755"}`),
+ json.RawMessage(`{"id":"de-spiegel.server-env","type":"file","path":"/var/lib/de-spiegel/server.env","mode":"0600","content":"A=1\n"}`),
+ json.RawMessage(`{"id":"de-spiegel.server","type":"container","name":"de-spiegel","image":"registry.example/de-spiegel@sha256:` + strings.Repeat("c", 64) + `"}`)}
+ if in, err = Input(ctx, b, Mesh{}, stub); err != nil {
+ t.Fatal(err)
+ }
+ in.Nodes[0].Kept = map[string]catalogue.Manifest{"de-spiegel": compose.Stub(in.Catalogue["de-spiegel"])}
+ in.Nodes[0].Kept["de-spiegel"] = func(m catalogue.Manifest) catalogue.Manifest {
+ m.Contributes, m.ContributesMany = nil, nil
+ return m
+ }(in.Nodes[0].Kept["de-spiegel"])
+ if base, _, err = compose.Compositions(in, "n1", fps, compose.Reading); err != nil {
+ t.Fatal(err)
+ }
+ running = nil
+ for _, c := range base {
+ if c.Module == "" {
+ c.Resources = append(append([]json.RawMessage{}, c.Resources...), spiegelRuns...)
+ }
+ running = append(running, c)
+ }
+ got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"de-spiegel"}, Running: running}, fps)
+ if err != nil {
+ t.Fatalf("de-spiegel's own refusal refuses the node's whole composition: %v", err)
+ }
+ if len(held) != 1 || !strings.Contains(held["de-spiegel"], "${config:praktijk-name}") || !strings.Contains(held["de-spiegel"], "without what it contributes to route") {
+ t.Errorf("held %v, want de-spiegel alone, in its config form's words, composed without its routes", held)
+ }
+ for _, m := range []string{"lemurs", "mailu", "proxy"} {
+ if key, _ := delivery.ComposedKey("n1", m, fps[m]); got[key].Module != m {
+ t.Errorf("%s is not composed beside de-spiegel's hold: %v", m, sortedKeys(got))
+ }
+ }
+ carries := func(c delivery.Composed, what string) {
+ t.Helper()
+ ownRes, _ := json.Marshal(c.Resources)
+ for _, raw := range spiegelRuns {
+ var r struct{ ID string }
+ _ = json.Unmarshal(raw, &r)
+ if !strings.Contains(string(ownRes), `"id":"`+r.ID+`"`) {
+ t.Errorf("%s: the node's own composition drops %s, which de-spiegel runs: %s", what, r.ID, ownRes)
+ }
+ }
+ }
+ carries(got[ownKey], "folded")
+ // (F) Leaving the fold (review of the apply, round 1, finding 1): de-spiegel's want names a build of the new form,
+ // so it is no longer folded, while its names are still unset and the node's own still runs it. Held, it is carried
+ // in the node's own as it runs until a composition of its own is handed: else the node's own apply removes its
+ // container, files and units (issue 1945 by a new path).
+ fps["de-spiegel"] = fp("7")
+ got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: running}, fps)
+ if err != nil || !strings.Contains(held["de-spiegel"], "${config:praktijk-name}") {
+ t.Fatalf("de-spiegel leaving the fold: %v, held %v", err, held)
+ }
+ spiegelKey, _ := delivery.ComposedKey("n1", "de-spiegel", fps["de-spiegel"])
+ if _, ok := got[spiegelKey]; ok {
+ t.Errorf("held de-spiegel's own composition is handed")
+ }
+ carries(got[ownKey], "leaving the fold")
+
+ // (G) A held module that runs a composition of its own (review of the apply, round 1, finding 2): what the others
+ // compose of it is what runs, never its stub's. site listens on 8080, placed at the machine's 20005, and its own
+ // secret was never issued here: held, the filter must not open 8080 and drop the 20005 that runs. The filter, which
+ // reads what site listens on, composes with it (issue 1960: a hold never spreads).
+ site := []byte(`{"module":"site","version":"1","capabilities":["container-runtime"],` +
+ `"listens":[{"port":8080,"from":"mesh","why":"the site"}],"own-secrets":{"key":"${dir:state}/key"},` +
+ `"resources":[{"id":"state","type":"directory","path":"/var/lib/site","mode":"0700"},` +
+ `{"id":"server","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}]}`)
+ b = mk("lemurs", overlay.Name, "site", "nft")
+ for m, raw := range map[string][]byte{"site": site, "nft": nft} {
+ b.manifests[delivery.ManifestDigest(raw)] = raw
+ b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
+ }
+ p = b.placements["n1"]
+ p.Decided.Endpoint = "n1.example:51820"
+ for i := range p.Modules {
+ if p.Modules[i].Module == "site" {
+ p.Modules[i].Ports = map[string]int{"8080": 20005}
+ }
+ }
+ b.placements["n1"] = p
+ rec = b.records["n1"]
+ rec.OutwardLinks = []string{"eth0"}
+ b.records["n1"] = rec
+ fps = map[string]string{"lemurs": fp("e"), "nft": fp("f"), "site": fp("8")}
+ siteRuns := delivery.Composed{Node: "n1", Module: "site", FP: fps["site"], Resources: []json.RawMessage{
+ json.RawMessage(`{"id":"site.state","module":"site","type":"directory","path":"/var/lib/site","mode":"0700"}`),
+ json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["10.77.0.1:20005:8080"]}`)}}
+ got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps)
+ nftKey, _ := delivery.ComposedKey("n1", "nft", fps["nft"])
+ if err != nil || held["site"] == "" || held["nft"] != "" || got[nftKey].Module != "nft" {
+ t.Fatalf("site, its own secret never issued: %v, held %v; want site held alone, and the filter composed", err, held)
+ }
+ if rules, _ := json.Marshal(got[nftKey].Resources); !strings.Contains(string(rules), "dport 20005") {
+ t.Errorf("the filter drops held site's opening where it runs (20005): %s", rules)
+ }
+ for key, c := range got {
+ for _, raw := range c.Resources {
+ if strings.Contains(string(raw), "dport 8080") || (c.Module == "nft" && !strings.Contains(string(raw), "dport 20005") && strings.Contains(string(raw), "# site")) {
+ t.Errorf("%s composes held site from its stub, not from what runs: %s", key, raw)
+ }
+ }
+ }
+ // (G2) Issue 1126, the spreading hold: a held module is no dependence of its neighbours. site, held, provides page, and
+ // app requires and binds it: app composes against what runs of site and is not held (novox's seed held every postgres
+ // consumer behind de-spiegel's hold, "what it composes of …, held, is not what runs of them").
+ provider := []byte(strings.Replace(string(site), `"listens":`, `"provides":[{"name":"page","scope":"node"}],"serves":{"page":{"port":8080}},"listens":`, 1))
+ app := []byte(`{"module":"app","version":"1","capabilities":["systemd"],"requires":["page"],"binds":{"page":"/etc/app/page.json"},` +
+ `"resources":[{"id":"service","type":"service","unit":"app.service","state":"running","boot":"enabled"}]}`)
+ b2 := mk("lemurs", overlay.Name, "site", "nft", "app")
+ for m, raw := range map[string][]byte{"site": provider, "nft": nft, "app": app} {
+ b2.manifests[delivery.ManifestDigest(raw)] = raw
+ b2.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
+ }
+ p2 := b2.placements["n1"]
+ p2.Decided.Endpoint = "n1.example:51820"
+ for i := range p2.Modules {
+ if p2.Modules[i].Module == "site" {
+ p2.Modules[i].Ports = map[string]int{"8080": 20005}
+ }
+ }
+ b2.placements["n1"] = p2
+ b2.records["n1"] = rec
+ b2.held = true
+ fps["app"] = fp("9")
+ got, _, held, err = Compose(ctx, b2, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps)
+ appKey, _ := delivery.ComposedKey("n1", "app", fps["app"])
+ if _, composed := got[appKey]; err != nil || held["site"] == "" || held["app"] != "" || !composed {
+ t.Errorf("a consumer of held site: %v, held %v; want site held, and app composed against what runs of it", err, held)
+ }
+ if bound, _ := json.Marshal(got[appKey].Resources); !strings.Contains(string(bound), "20005") {
+ t.Errorf("app's binding does not reach held site where it runs (20005): %s", bound)
+ }
+ delete(fps, "app")
+ // A held module's offer is left out by provision, never whole when one part composes (issue 1960): de-spiegel's
+ // routes, its names unset, leave the proxy without them, and its smtp account stays with mailu.
+ withProxy := compose.Input{Catalogue: map[string]catalogue.Manifest{},
+ Nodes: []compose.Node{{Name: "n1", Assigned: []string{"feeder"}}}}
+ feeder := catalogue.Manifest{Module: "feeder", Contributes: map[string]map[string]any{"route": {"name": "x"}, "smtp": {"account": "feeder"}},
+ OwnSecrets: catalogue.OwnSecrets{"key": {Path: "/k"}}}
+ for _, row := range []struct {
+ left map[string]bool
+ route, smtp, own bool
+ }{{nil, true, true, true}, {map[string]bool{"route": true}, false, true, true}, {map[string]bool{leaveOwn: true}, true, true, false}, {map[string]bool{leaveAll: true}, false, false, false}} {
+ k := heldAs(withProxy, "n1", "feeder", feeder, row.left, nil).Nodes[0].Kept["feeder"]
+ if _, r := k.Contributes["route"]; r != row.route || (k.Contributes["smtp"] != nil) != row.smtp || (len(k.OwnSecrets) > 0) != row.own {
+ t.Errorf("held leaving out %v offers %+v, own secrets %v", row.left, k.Contributes, k.OwnSecrets)
+ }
+ }
+ // (H) Review of the apply, round 2: a held module is composed from its new manifest, so what the others read of its
+ // manifest (its listens, what it contributes) may not be what runs. site's new manifest moves it to 9090 while the
+ // build that runs (7) listens on 8080. The filter composes with what site last offered, its manifest at the build
+ // that runs (issue 1960: a hold never spreads), so it opens 8080 and nothing applied opens 9090.
+ moved := []byte(strings.Replace(string(site), `"port":8080`, `"port":9090`, 1))
+ b.manifests[delivery.ManifestDigest(moved)] = moved
+ b.modules["site"] = delivery.Module{Manifest: delivery.ManifestDigest(moved)}
+ for i := range p.Modules {
+ p.Modules[i].Ports = nil
+ }
+ b.placements["n1"] = p
+ siteRuns.Resources[1] = json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}`)
+ siteRuns.FP = fp("7")
+ b.manifests[delivery.ManifestDigest(site)] = site
+ b.builds["site "+fp("7")] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: delivery.ManifestDigest(site)}
+ got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps)
+ if err != nil || held["site"] == "" || held["nft"] != "" {
+ t.Errorf("site moved to 9090 in its new manifest: %v, held %v; want site held alone", err, held)
+ }
+ if rules, _ := json.Marshal(got[nftKey].Resources); !strings.Contains(string(rules), "dport 8080") {
+ t.Errorf("the filter drops what held site last offered (8080): %s", rules)
+ }
+ for key, c := range got {
+ for _, raw := range c.Resources {
+ if strings.Contains(string(raw), "dport 9090") {
+ t.Errorf("%s opens 9090, which held site's new manifest names and nothing runs: %s", key, raw)
+ }
+ }
+ }
+}
+
+// A module another node cannot run in this composition offers nothing in it: the refusal names it and why
+// (issue 1126, probe #532: ace and g14 answered "nothing in this mesh provides artifact-store … assign distribution to a
+// node" while novox runs it). And a refusal no module's stub changes is answered at once, not after a compose per module
+// (ace took 7m46s).
+func otherNodeNotResolvedIsNamed(t *testing.T) {
+ ctx := context.Background()
+ prov := []byte(`{"module":"prov","version":"1","capabilities":["container-runtime"],"provides":[{"name":"thing","scope":"mesh"}]}`)
+ cons := []byte(`{"module":"cons","version":"1","requires":["thing"]}`)
+ // n2 runs prov, which needs a container runtime n2's record does not say it has: n2's set does not resolve here.
+ b := &bus{placements: map[string]delivery.Placement{
+ "n1": {Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Endpoint: "n1.example:51820"}, Modules: []delivery.PlacedModule{{Module: "cons"}, {Module: overlay.Name}}},
+ "n2": {Node: "n2", Decided: delivery.NodeDecided{Address: "10.77.0.2"}, Modules: []delivery.PlacedModule{{Module: "prov"}, {Module: overlay.Name}}}},
+ records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t)}, "n2": {Node: "n2", TunnelKey: publicKey(t)}},
+ modules: map[string]delivery.Module{"prov": {Manifest: delivery.ManifestDigest(prov)}, "cons": {Manifest: delivery.ManifestDigest(cons)}},
+ manifests: map[string][]byte{delivery.ManifestDigest(prov): prov, delivery.ManifestDigest(cons): cons}}
+ given := map[string]*delivery.Placement{}
+ for n, p := range b.placements {
+ p := p
+ given[n] = &p
+ }
+ a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given}, map[string]string{"cons": "src2:" + strings.Repeat("1", 64)}, nil, "p-h1", 1)
+ if a.Verdict != delivery.CandidateRefused || !strings.Contains(a.Refused, "prov on n2 lacks container-runtime") {
+ t.Errorf("a refusal for want of n2's provider = %+v", a)
+ }
+}
+
+// A node that reaches no artifact store says what its composition holds of one (issue 1126: ace and g14 answered
+// "reaches no artifact store", and nothing said why).
+func noStoreReachedSaysWhy(t *testing.T) {
+ in := compose.Input{Catalogue: map[string]catalogue.Manifest{
+ "distribution": {Module: "distribution", Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5000}}}},
+ Nodes: []compose.Node{{Name: "g14", Assigned: []string{"adwaita"}}, {Name: "novox", Assigned: []string{"distribution"}}}}
+ if got := storeSought(in); got != "the composition holds g14, novox; artifact-store is served by distribution on novox, none of it on the private network with a port" {
+ t.Errorf("said %q", got)
+ }
+ in.Nodes = in.Nodes[:1]
+ if got := storeSought(in); got != "the composition holds g14; no module placed there serves artifact-store" {
+ t.Errorf("said %q", got)
+ }
+}
diff --git a/internal/compose/frombus/export.go b/internal/compose/frombus/export.go
new file mode 100644
index 0000000..8726c0f
--- /dev/null
+++ b/internal/compose/frombus/export.go
@@ -0,0 +1,112 @@
+package frombus
+
+import (
+ "fmt"
+ "sort"
+ "strconv"
+
+ "git.novox.be/novox/mesh-sdk/go/catalogue"
+ "git.novox.be/novox/mesh-sdk/go/delivery"
+ "github.com/novox/mesh-host/internal/compose"
+)
+
+// Export is one node's placement as the composition input the controller fills says it (novox/hq ADR 0555 §6, ADR 0567
+// §7): the inverse of Input for what a person decides, so the controller's one read-only verb `placement-export`
+// answers, from its own store by its own code, exactly what the seat's adopt proposes as the seed. Its answer must stay
+// stable for the same store, since adopt checks its digest again at the warrant.
+// What the machine observes (its record) is not in it.
+func Export(in compose.Input, node string) (delivery.Placement, error) {
+ var n *compose.Node
+ for i := range in.Nodes {
+ if in.Nodes[i].Name == node {
+ n = &in.Nodes[i]
+ }
+ }
+ if n == nil {
+ return delivery.Placement{}, fmt.Errorf("frombus: the input has no node %q", node)
+ }
+ // At the version a placement key is read at, with a list of modules even when none is placed: the export is the
+ // seed's value, and the reader refuses a value of version 0 or without its modules.
+ p := delivery.Placement{V: delivery.Version, Node: node, Modules: []delivery.PlacedModule{}, Decided: delivery.NodeDecided{
+ Endpoint: n.Place.Endpoint, Site: n.Place.Site, Hub: n.Place.Hub, Address: n.Place.Address,
+ PublicDomain: n.PublicDomain, Account: n.Account, AccountHome: n.AccountHome, AgentAccount: n.AgentAccount,
+ AgentHome: n.AgentAccountHome, Adopted: n.Adopted, Taken: n.Taken}}
+ // The tunnel found at the node's adoption: the one its private network took over, or, on the hub, the mesh's adopted
+ // one whether or not the hub took it; and the names a person gave the hub's carried peers.
+ if t, ok := in.Tunnels[node]; ok {
+ rt := recordTunnelOf(t.Tunnel)
+ p.Decided.Tunnel = &rt
+ } else if in.Adopted != nil && in.Adopted.Hub == node {
+ rt := recordTunnelOf(in.Adopted.Tunnel)
+ p.Decided.Tunnel = &rt
+ }
+ if n.Place.Hub {
+ for _, c := range in.CarriedPeers {
+ if c.Named != "" {
+ if p.Decided.PeerNames == nil {
+ p.Decided.PeerNames = map[string]string{}
+ }
+ p.Decided.PeerNames[c.PublicKey] = c.Named
+ }
+ }
+ }
+ for _, module := range n.Assigned {
+ m := delivery.PlacedModule{Module: module}
+ for port, at := range n.Ports[module] {
+ if m.Ports == nil {
+ m.Ports = map[string]int{}
+ }
+ m.Ports[strconv.Itoa(port)] = at
+ }
+ for provision, to := range n.Bound[module] {
+ if m.Bindings == nil {
+ m.Bindings = map[string]delivery.Provider{}
+ }
+ m.Bindings[provision] = delivery.Provider{Node: to.Node, Module: to.Module}
+ }
+ // A module in the settings form keeps its config values in its layers, not in mesh-config: they are exported with
+ // the placement. A module in the config form has mesh-config's values laid into its layer, which are not.
+ settingsForm := !in.Catalogue[module].DeclaresConfig()
+ for _, layer := range n.Settings[module] {
+ if layer.From == catalogue.MeshWideLayer {
+ m.HasMesh, m.MeshWide = true, layer.Placement
+ if settingsForm && len(layer.Values) > 0 {
+ m.MeshValues = layer.Values
+ }
+ } else {
+ m.HasLayer, m.Layer = true, layer.Placement
+ if settingsForm && len(layer.Values) > 0 {
+ m.Values = layer.Values
+ }
+ }
+ }
+ p.Modules = append(p.Modules, m)
+ }
+ for provision, to := range n.Pins {
+ if p.Pins == nil {
+ p.Pins = map[string]delivery.Provider{}
+ }
+ p.Pins[provision] = delivery.Provider{Node: to.Node, Module: to.Module}
+ }
+ for _, h := range in.Holdings {
+ if h.Node == node {
+ p.Holds = append(p.Holds, delivery.Holding{Claim: h.Claim, Scope: h.Scope, Module: h.Module, Site: h.Site, Kind: h.Kind})
+ }
+ }
+ sort.SliceStable(p.Holds, func(i, j int) bool {
+ if p.Holds[i].Claim != p.Holds[j].Claim {
+ return p.Holds[i].Claim < p.Holds[j].Claim
+ }
+ return p.Holds[i].Module < p.Holds[j].Module
+ })
+ return p, nil
+}
+
+func recordTunnelOf(t compose.Tunnel) delivery.RecordTunnel {
+ out := delivery.RecordTunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU,
+ Address: t.Address, Range: t.Range, PublicKey: t.PublicKey}
+ for _, peer := range t.Peers {
+ out.Peers = append(out.Peers, delivery.RecordTunnelPeer{PublicKey: peer.PublicKey, Address: peer.Address})
+ }
+ return out
+}
diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go
new file mode 100644
index 0000000..f063f49
--- /dev/null
+++ b/internal/compose/frombus/frombus.go
@@ -0,0 +1,1168 @@
+// Package frombus fills mesh-sdk's composition input from what is on the bus, so a node agent, the composition check
+// and the grants part compose with one composer over the same inputs (novox/hq ADR 0547 §2, ADR 0549 §3; issue 1126).
+//
+// **One composer, three callers.** compose.Compositions is the composer; Input is how each caller fills its
+// compose.Input from the bus: the node agent for its own node (ADR 0547 §2), the seat mesh-placement's holder judging a
+// proposal for every node (ADR 0567 §4), and the grants part (ADR 0547 §5). Each reads the bus with its own credential
+// through a Reader, so this package holds no bus client, no store and no timer: what it is given is all it reads.
+//
+// **What it reads, and from whom.**
+// - which modules run on each node, with their pins, holdings, host ports, bindings and settings placement: the
+// seat mesh-placement's state `placement`, one key per node (ADR 0567 §1);
+// - what a person decided of each node (its endpoint, site, hub, address on the private network, public domain and
+// accounts): the same placement key, Placement.Decided;
+// - what each node observes of itself (its tunnel key, capabilities, keys, certificate, links and networks): the
+// node's own have key, `.node` (HaveNode.Record); the tunnel found at a node's adoption and the names of the hub's peers are decided,
+// in its placement, and whether the hub took the tunnel over and who enrolled under a peer's key are read from both;
+// - every module's manifest: the indexer's `modules.` and its manifests by digest; for each module placed on
+// the composing node, its built manifest instead: the builder's done key at the trunk's FP2, or at the fingerprint
+// the node's want names where it runs another build (Scope.KeptAt), resolved by the controller's own take-in rule
+// (takein.Manifest), a moved module's templates archive read in the artifact store (issue 1126);
+// - each module's config values on a node: mesh-config's resolved state, the composing node's slice alone unless the
+// caller composes every node (ADR 0547 §2, the prepare step's L4); of another node, only the keys its modules'
+// served values fill (catalogue.ServedSettings), which every consumer is told (issue 1126).
+//
+// **Own secrets and pair credentials** come from the vault sealed to the composing node's key (OwnSecrets, Credentials),
+// for that node alone: as a consumer and as a provider, exactly the keys compose.CredentialsNeeded names. **Not yet
+// read** (each a later subtask of issue 1126): licences (none is placed today) and core boxes. Without one
+// the composition stands in for it, and Compose holds the module it stands in for (or refuses the node, for a stand-in
+// of no module placed there; compose.ErrStoodIn): a node never applies a stand-in. No node is joining before ADR 0547's third milestone (ADR 0550 §6), so Joining is
+// always empty.
+package frombus
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "maps"
+ "net/netip"
+ "slices"
+ "sort"
+ "strings"
+
+ "git.novox.be/novox/mesh-sdk/go/catalogue"
+ "git.novox.be/novox/mesh-sdk/go/declaration"
+ "git.novox.be/novox/mesh-sdk/go/delivery"
+ "github.com/novox/mesh-host/internal/compose"
+ "github.com/novox/mesh-host/internal/compose/resolution"
+ "github.com/novox/mesh-host/internal/overlay"
+ "github.com/novox/mesh-host/internal/takein"
+)
+
+// Reader is the bus as a caller reads it with its own credential. Each method reads one part's state; an error is a
+// read that failed, never an absent key (an absent key is an empty answer).
+type Reader interface {
+ // Placements is every node's placement, by node (delivery.PlacementBucket).
+ Placements(ctx context.Context) (map[string]delivery.Placement, error)
+ // Records is every node's record, by node: HaveNode.Record of each node's own have key, `.node` of
+ // delivery.HaveBucket (ADR 0555 §3).
+ Records(ctx context.Context) (map[string]delivery.NodeRecord, error)
+ // Modules is every module the indexer holds, by module (delivery.ModulesBucket).
+ Modules(ctx context.Context) (map[string]delivery.Module, error)
+ // Manifest is the raw manifest of a digest (delivery.ManifestsBucket).
+ Manifest(ctx context.Context, digest string) ([]byte, error)
+ // Values is one module's config values on one node as mesh-config resolved them (mesh-config's state `resolved`,
+ // keys `..`), decoded; nil when it has none.
+ Values(ctx context.Context, node, module string) (map[string]any, error)
+ // OwnSecrets is the own secrets of the composing node's modules, each sealed to that node's key, as the vault answers
+ // the node's engine (the vault's verb `own`, ADR 0550 §5); asked only for the node composing itself.
+ OwnSecrets(ctx context.Context, node string) ([]compose.OwnSecretRow, error)
+ // Credentials is, for the composing node, each pair credential it reads as a consumer, held and sealed to its key,
+ // and each it grants as a provider, with the copy sealed to it (the vault's `own` with the keys, ADR 0550 §5). A key
+ // the vault holds no value for is left out; the composition then stands in for it, and Compose refuses it.
+ Credentials(ctx context.Context, node string, asConsumer, asProvider []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error)
+ // Build is a module's build at a fingerprint, the builder's key (delivery.BuilderBuildsBucket, BuilderBuildKey); nil
+ // when the builder holds none. The composing node composes each of its modules from its build (issue 1126).
+ Build(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error)
+ // Archive is the archive at url, a kept reference routed to the artifact store as the composing node reaches it
+ // (catalogue.Routed), checked against digest: a moved module's templates archive, read back for its texts as the
+ // controller's take-in reads it (takein.Built).
+ Archive(ctx context.Context, url, digest string) ([]byte, error)
+ // Served is, of one module's config values on another node than the composing one (mesh-config's state `resolved`,
+ // keys `..`), the keys named, decoded: the keys its served values and zone name fill
+ // (catalogue.ServedSettings), which every consumer is told and so every node may read (issue 1126). Nil when none
+ // is set.
+ Served(ctx context.Context, node, module string, keys []string) (map[string]any, error)
+}
+
+// SignersReader is a Reader that also reads the vault's public authority from its state `signers` (delivery.SignersBucket,
+// key delivery.SignersAuthorityKey; ADR 0550 §2). A Reader that has it composes the mesh's authority as a bundle, the
+// controller's and the vault's; one that has not, or that answers none, composes the controller's alone, as before.
+type SignersReader interface {
+ // VaultAuthority is the vault's authority certificate as PEM, checked (delivery.DecodeSignerAuthority); empty when the
+ // vault has published none. A read that fails is said by the reader and answered empty: a node that may not read the
+ // state yet composes as before rather than not at all.
+ VaultAuthority(ctx context.Context) string
+}
+
+// HandedReader is a Reader that also says, of the composing node's modules, which the vault judged handed to the
+// provisioner when it answered OwnSecrets (the vault's `own`, its `handed`: catalogue.AssignmentHands of the plan in
+// each module's assignment). The node's composition then hands exactly those (compose.Node.Handed, issue 774), so the
+// vault and the node never judge one module two ways. Nil, or a Reader without it (a vault older than the field),
+// composes by the manifest and the machine, as before.
+type HandedReader interface {
+ Handed(ctx context.Context, node string) map[string]bool
+}
+
+// Mesh is what is true of the whole mesh and is no part's state on the bus yet: the private network's range and the
+// internal names' suffix as genesis was told them, and the mesh's authority certificate (public). Empty fields take
+// compose's defaults.
+type Mesh struct {
+ OverlayCIDR string
+ InternalSuffix string
+ Authority string
+ // Predefined is the list of predefined keys mesh-config published. No longer read: a reader decodes a manifest and
+ // takes the indexer's verdict on it, and judges nothing (novox/hq ADR 0578 §3); kept so a caller that sets it builds.
+ Predefined catalogue.PredefinedList
+}
+
+// Scope says who composes. Node is the node composing; only its config values are read unless Every is set, as the
+// composition check sets it to compose every node (of another node's modules, only the values their served values fill
+// are read, Reader.Served). KeptAt is, by module, the fingerprint the composing node's want names where it differs from
+// the indexer's trunk: that node composes the module from its build at that fingerprint, as it runs it.
+//
+// **Composed from its build** (issue 1126): each module placed on the composing node, and each in KeptAt, composes from
+// its built manifest (takein.Manifest over the builder's done key at the trunk's FP2 or the kept fingerprint), as the
+// controller composes from the manifest its take-in resolved: so a moved module is handed to the provisioner, a resource
+// naming an artifact names what was built, and a templated file has its text. A placed module the builder holds no done
+// build of at the trunk's FP2 composes from the indexer's manifest, as before. Every leaves the catalogue as the indexer
+// read it: the check of every node judges placement, and reads no archive.
+//
+// Placements, when set, stands in for the placement of the nodes it names: a proposal's candidates
+// (delivery.PlacementCandidate), so a node composes in shadow with a proposal laid over and compares the result with
+// what it runs (ADR 0567 §3); a nil value removes the node's placement. The rest is read as always.
+//
+// InOwn is the modules the composing node runs inside its own composition (InOwn of what it runs), composed there again
+// (compose.Node.InOwn) rather than cut out as modules no build is given for (issue 1126).
+//
+// Running is what the composing node runs; of it, its own composition (`.node`) is given to the composition
+// (compose.Node.RunsOwn), so a module folded there that names a build of the old form is left to the node, carried
+// as it runs (issue 1126). Nil composes every folded module from its manifest.
+type Scope struct {
+ Node string
+ Every bool
+ KeptAt map[string]string
+ Placements map[string]*delivery.Placement
+ InOwn []string
+ Running []delivery.Composed
+ // HeldToday is, in an answer, the modules the node keeps today as they run ("node" for its own composition), each with
+ // how ("held", "failed", "runs a build of the old form"): a candidate that cannot compose one, or holds one only
+ // behind one of them, keeps it as today, which is said under CandidateAnswer.Held and is no refusal (issue 1126).
+ HeldToday map[string]string
+ // SameValue, in an answer, says whether two sealed values open to the same value (the node opens them with its own
+ // key), so a differing resource shows a value sealed again as such (delivery.CompareComposedWith). Nil shows them
+ // as differing.
+ SameValue func(a, b string) (same, known bool)
+}
+
+// InOwn is, of the modules p places on its node, those whose resources the node's own composition it runs
+// (`.node` in running) carries, by the module their ids name (declaration.ModuleGuess), sorted: the modules the
+// controller's composed writer folded there because no fingerprint of the new form names them (mesh-wireguard, a module
+// run at a build of the old form; issue 1126). A node composing itself passes it as Scope.InOwn, so its own
+// composition keeps them as it runs them until a want names a build of the new form for one.
+func InOwn(running []delivery.Composed, p delivery.Placement) []string {
+ placed := map[string]bool{}
+ for _, m := range p.Modules {
+ placed[m.Module] = true
+ }
+ in := map[string]bool{}
+ for _, c := range running {
+ if c.Module != "" {
+ continue
+ }
+ for _, raw := range c.Resources {
+ var head struct {
+ ID string `json:"id"`
+ Module string `json:"module"`
+ }
+ if json.Unmarshal(raw, &head) != nil || head.Module != "" {
+ continue
+ }
+ if m, ok := declaration.ModuleGuess(head.ID); ok && placed[m] {
+ in[m] = true
+ }
+ }
+ }
+ return sortedKeys(in)
+}
+
+// Input is the composition input for s, read through r.
+//
+// **A refused manifest never ends the read** (novox/hq ADR 0578 §3): each manifest is decoded leniently
+// (catalogue.Decode) and the indexer's verdict on it taken from its modules value (delivery.TakeManifestVerdict, a missing
+// one accepted); a module whose manifest the indexer's judge refused, or whose bytes do not decode, stands in the
+// catalogue as its stub (unusable), so what it provides and requires still shapes the rest, and is named with why in
+// the input's Refused; a field set aside is named in its SetAside. Every other module of the read goes on.
+func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, error) {
+ return input(ctx, r, mesh, s, nil, nil)
+}
+
+// taken is a module's manifest as a reader takes it (novox/hq ADR 0578 §3): decoded leniently, with the indexer's verdict
+// from its modules value. A refused one, or bytes that do not decode, stands as its stub (unusable) and why says so; a
+// manifest not read past a field it set aside holds its name alone and is held the same.
+func taken(name string, raw []byte, m delivery.Module) (man catalogue.Manifest, why string) {
+ if v := m.VerdictOf(name); v != nil {
+ if refused, _ := delivery.TakeManifestVerdict(v); refused {
+ return unusable(name, raw), v.Why()
+ }
+ }
+ man, err := catalogue.Decode(raw)
+ switch {
+ case err != nil:
+ return unusable(name, raw), fmt.Sprintf("the manifest of %s does not decode: %v", name, err)
+ case man.ReadNoFurther():
+ return unusable(name, raw), fmt.Sprintf("the manifest of %s could not be read past a field this reader does not "+
+ "know: %s", name, man.SetAside())
+ }
+ return man, ""
+}
+
+// input is Input; with refuse set, a module placed on the composing node whose build cannot be read or taken in, or whose
+// manifest the indexer refused, is also handed to refuse and composed as a stub (compose.Stub, kept for that node alone),
+// so a node's answer names every module it cannot compose (issue 1126, issue 774); a module placed elsewhere whose
+// manifest is refused is read for its offer alone (unusable). A manifest the bus does not answer for still ends the
+// read: that is the bus's, not the module's.
+func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module string, err error), wanted map[string]string) (compose.Input, error) {
+ b, err := readBase(ctx, r, mesh, s)
+ b.refuseOwn(refuse)
+ if err != nil {
+ return compose.Input{}, err
+ }
+ return b.finish(ctx, r, r.Build, mesh, s, b.in, refuse, wanted)
+}
+
+// base is the part of a node's input that the fingerprints it composes its modules at leave alone (issue 1954): read once
+// for its plans at the wanted builds and at the newest, whose builds alone differ (PlansHeldTwice).
+type base struct {
+ in compose.Input
+ placements map[string]delivery.Placement
+ records map[string]delivery.NodeRecord
+ modules map[string]delivery.Module
+ // ownRefused is each module placed on the composing node whose manifest the indexer refused, with why, in the order
+ // read: every scope holds it.
+ ownRefused [][2]string
+}
+
+// refuseOwn hands refuse each module placed on the composing node whose manifest is refused.
+func (b base) refuseOwn(refuse func(module string, err error)) {
+ if refuse == nil {
+ return
+ }
+ for _, r := range b.ownRefused {
+ refuse(r[0], errors.New(r[1]))
+ }
+}
+
+// cloned is the input with its own catalogue, nodes and kept manifests, what finishing it sets: two scopes finished over
+// one base never see each other's builds.
+func (b base) cloned() compose.Input {
+ in := b.in
+ in.Catalogue = maps.Clone(b.in.Catalogue)
+ in.Nodes = slices.Clone(b.in.Nodes)
+ for i := range in.Nodes {
+ in.Nodes[i].Kept = maps.Clone(in.Nodes[i].Kept)
+ }
+ in.KeptCoreBoxes = slices.Clone(b.in.KeptCoreBoxes)
+ in.OwnSecrets = slices.Clone(b.in.OwnSecrets)
+ return in
+}
+
+// readBase is input up to the builds: placement, records, the indexer's modules and manifests, each node's values.
+func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) {
+ var b base
+ placements, err := r.Placements(ctx)
+ if err != nil {
+ return b, fmt.Errorf("frombus: placement: %w", err)
+ }
+ if len(s.Placements) > 0 {
+ given := make(map[string]delivery.Placement, len(placements)+len(s.Placements))
+ for node, p := range placements {
+ given[node] = p
+ }
+ for node, p := range s.Placements {
+ if p == nil {
+ delete(given, node)
+ continue
+ }
+ if p.Node != node {
+ return b, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node)
+ }
+ given[node] = *p
+ }
+ placements = given
+ }
+ records, err := r.Records(ctx)
+ if err != nil {
+ return b, fmt.Errorf("frombus: node records: %w", err)
+ }
+ modules, err := r.Modules(ctx)
+ if err != nil {
+ return b, fmt.Errorf("frombus: the indexer's modules: %w", err)
+ }
+ if _, ok := placements[s.Node]; !ok && !s.Every {
+ return b, fmt.Errorf("frombus: %s has no placement in the catalogue", s.Node)
+ }
+
+ in := compose.Input{OverlayCIDR: mesh.OverlayCIDR, InternalSuffix: mesh.InternalSuffix, Authority: mesh.Authority,
+ Catalogue: map[string]catalogue.Manifest{}}
+ if in.Authority == "" {
+ // The mesh's own certificate, public, as the composing node holds it; for the check of every node, as the first
+ // node holding one does.
+ if rec, ok := records[s.Node]; ok && rec.Authority != "" {
+ in.Authority = rec.Authority
+ } else {
+ for _, node := range sortedKeys(records) {
+ if a := records[node].Authority; a != "" {
+ in.Authority = a
+ break
+ }
+ }
+ }
+ }
+ // And the vault's authority beside it, once the vault publishes one (ADR 0550 §2): a certificate either signed checks
+ // against the bundle. The controller's stays while a certificate it issued is in a node's record.
+ if sr, ok := r.(SignersReader); ok {
+ if vault := sr.VaultAuthority(ctx); vault != "" {
+ in.Authority = delivery.AuthorityBundle(in.Authority, vault)
+ }
+ }
+ named := func(field *map[string]string, module, why string) {
+ if *field == nil {
+ *field = map[string]string{}
+ }
+ (*field)[module] = why
+ }
+ ownPlaced := map[string]bool{}
+ for _, pm := range placements[s.Node].Modules {
+ ownPlaced[pm.Module] = true
+ }
+ for _, name := range sortedKeys(modules) {
+ m := modules[name]
+ if m.Manifest == "" {
+ continue // a registered module the indexer has not read: nothing to compose it from
+ }
+ raw, err := r.Manifest(ctx, m.Manifest)
+ if err != nil {
+ // Not read is no refusal of the module: a bus that does not answer is waited out by the caller.
+ return b, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", name, m.Manifest, err)
+ }
+ // One module's manifest the indexer refused refuses that module alone, by the rule a build that cannot be read
+ // follows (issue 774: power's bar item refused every node's composition, 2026-10-30; ADR 0578 §3: one refused
+ // manifest silenced disk-load for 45 hours). It stands in as its stub, so what it provides and requires still
+ // shapes the rest; it is held on the composing node where it is placed there, and elsewhere only its offer is read.
+ man, why := taken(name, raw, m)
+ if why != "" {
+ named(&in.Refused, name, why)
+ if ownPlaced[name] {
+ b.ownRefused = append(b.ownRefused, [2]string{name, why})
+ }
+ } else if aside := man.SetAside(); aside != "" {
+ named(&in.SetAside, name, aside)
+ }
+ in.Catalogue[name] = man
+ }
+
+ // The module the mesh provides itself (the private network) has no repository the indexer reads: its manifest comes
+ // with mesh-sdk and always wins, as the controller's providedModules and the grants part have it.
+ for _, name := range delivery.ProvidedModules() {
+ raw, err := json.Marshal(overlay.Manifest())
+ if err != nil {
+ return b, err
+ }
+ var private catalogue.Manifest
+ if err := json.Unmarshal(raw, &private); err != nil {
+ return b, fmt.Errorf("frombus: the manifest mesh-sdk provides for %s: %w", name, err)
+ }
+ in.Catalogue[name] = private
+ }
+
+ var missing []string
+ for _, node := range nodesOf(placements, records) {
+ rec, hasRecord := records[node]
+ p := placements[node]
+ if !hasRecord {
+ if len(p.Modules) > 0 {
+ missing = append(missing, node)
+ }
+ continue
+ }
+ n := nodeOf(rec, p)
+ withValues := s.Every || node == s.Node
+ for _, m := range p.Modules {
+ var values map[string]any
+ if withValues {
+ if values, err = r.Values(ctx, node, m.Module); err != nil {
+ return b, fmt.Errorf("frombus: %s's config values on %s: %w", m.Module, node, err)
+ }
+ } else if keys := catalogue.ServedSettings(in.Catalogue[m.Module]); len(keys) > 0 {
+ // What a provider elsewhere serves from its settings is told to every consumer: the composing node reads
+ // those keys of it, and no other (issue 1126: mailu's smtp domain, a consumer's on shanks).
+ if values, err = r.Served(ctx, node, m.Module, keys); err != nil {
+ return b, fmt.Errorf("frombus: what %s serves on %s (%s): %w", m.Module, node,
+ strings.Join(keys, ", "), err)
+ }
+ }
+ if layers := m.Layers(node, values); layers != nil {
+ if n.Settings == nil {
+ n.Settings = map[string][]catalogue.Layer{}
+ }
+ n.Settings[m.Module] = layers
+ }
+ }
+ if node == s.Node {
+ // What its runtime serves as it runs: what a module composed as what runs of it contributes there (issue 1126).
+ n.RunsTools = compose.ToolsRunning(s.Running)
+ for _, module := range s.InOwn {
+ if n.InOwn == nil {
+ n.InOwn = map[string]bool{}
+ }
+ n.InOwn[module] = true
+ }
+ for i := range s.Running {
+ // The operator export as the vault runs it: the controller composes it from its store, which the node
+ // does not read; until the export is on the bus, the node carries the file it runs (issue 1126, C6:
+ // mesh-vault.kept on novox, "in what runs, and not composed").
+ if m := s.Running[i].Module; m != "" {
+ if kept := keptOf(s.Running[i]); kept != nil {
+ if in.KeptRuns == nil {
+ in.KeptRuns = map[string]map[string]map[string]any{}
+ }
+ if in.KeptRuns[s.Node] == nil {
+ in.KeptRuns[s.Node] = map[string]map[string]any{}
+ }
+ in.KeptRuns[s.Node][m] = kept
+ }
+ continue
+ }
+ if s.Running[i].Module == "" {
+ own := s.Running[i]
+ n.RunsOwn = &own
+ // Its bus membership as it runs it: the controller composes it from its store, which the node does
+ // not read; until the grants part hands memberships sealed to the node, the node's own composition
+ // carries the one it runs (issue 1126, seed #531 on novox: "bus-membership in what runs, not
+ // composed", which applied would have taken the node's bus credential away).
+ if sealed := membershipOf(own); sealed != "" {
+ if in.BusMemberships == nil {
+ in.BusMemberships = map[string]string{}
+ }
+ in.BusMemberships[s.Node] = sealed
+ }
+ }
+ }
+ }
+ in.Nodes = append(in.Nodes, n)
+ in.Holdings = append(in.Holdings, p.Held()...)
+ // A tunnel found at adoption is the node's private network's when the node's tunnel key is the tunnel's (the
+ // controller's inv.Tunnels); the hub's found tunnel is the mesh's adopted one whether or not the hub took it over
+ // (inv.AdoptedTunnel), and its peers are carried once it did (inv.CarriedPeers).
+ if t := p.Decided.Tunnel; t != nil {
+ if rec.TunnelKey == t.PublicKey {
+ if in.Tunnels == nil {
+ in.Tunnels = map[string]compose.FoundTunnel{}
+ }
+ in.Tunnels[node] = compose.FoundTunnel{Tunnel: tunnelOf(*t), NodeAdopted: p.Decided.Adopted}
+ }
+ if p.Decided.Hub {
+ in.Adopted = &compose.AdoptedTunnel{Tunnel: tunnelOf(*t), Hub: node, Adopted: rec.TunnelKey == t.PublicKey}
+ }
+ }
+ }
+ if in.Adopted != nil && in.Adopted.Adopted {
+ in.CarriedPeers = carriedPeers(in.Adopted.Tunnel, placements[in.Adopted.Hub].Decided.PeerNames, records)
+ }
+ if len(missing) > 0 {
+ return b, fmt.Errorf("frombus: modules are placed on %s, whose agent has written no record yet",
+ strings.Join(missing, ", "))
+ }
+ b.in, b.placements, b.records, b.modules = in, placements, records, modules
+ return b, nil
+}
+
+// finish is input from its base on: the composing node's modules from their builds (build reads one), its own secrets.
+// in is the base's input, or a copy of it (base.cloned) where one base is finished more than once.
+func (b base) finish(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error),
+ mesh Mesh, s Scope, in compose.Input, refuse func(module string, err error), wanted map[string]string) (compose.Input, error) {
+ var err error
+ parse := parser(mesh)
+ read := func(module, digest string) (catalogue.Manifest, error) {
+ raw, err := r.Manifest(ctx, digest)
+ if err != nil {
+ return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", module, digest, err)
+ }
+ man, err := parse(raw)
+ if err != nil {
+ return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s) does not decode: %w", module, digest, err)
+ }
+ return man, nil
+ }
+ if !s.Every {
+ if _, ok := b.records[s.Node]; !ok {
+ return compose.Input{}, fmt.Errorf("frombus: %s has written no record of itself yet", s.Node)
+ }
+ if err := fromBuilds(ctx, r, build, &in, s, b.placements[s.Node], b.modules, read, parse, refuse, wanted); err != nil {
+ return compose.Input{}, err
+ }
+ // Its own modules' secrets, sealed to its own key by the vault: a composition carries them sealed (ADR 0550 §5).
+ // Asked only when a module placed on it declares one: a node none of whose modules has a secret composes
+ // whether or not the vault answers.
+ if declaresOwnSecrets(in, s.Node) {
+ if in.OwnSecrets, err = r.OwnSecrets(ctx, s.Node); err != nil {
+ return compose.Input{}, fmt.Errorf("frombus: the own secrets of %s's modules: %w", s.Node, err)
+ }
+ // And which of its modules the vault left out of that answer as handed: the node hands exactly those.
+ if hr, ok := r.(HandedReader); ok {
+ if handed := hr.Handed(ctx, s.Node); handed != nil {
+ for i := range in.Nodes {
+ if in.Nodes[i].Name == s.Node {
+ in.Nodes[i].Handed = handed
+ }
+ }
+ }
+ }
+ }
+ // The core's credentials travel as the vault's box sealed to the machine (ADR 0349 §16): the same values, which
+ // the composition reads for a core module as its kept boxes.
+ for _, o := range in.OwnSecrets {
+ if json.Valid([]byte(o.Sealed)) {
+ in.KeptCoreBoxes = append(in.KeptCoreBoxes, compose.CoreBox{Node: o.Node, Module: o.Module, Name: o.Name,
+ SealedTo: o.SealedTo, Box: json.RawMessage(o.Sealed)})
+ }
+ }
+ }
+ return in, nil
+}
+
+// Compose is the node's compositions over the bus (ADR 0547 §2): input for s, then composed at the fingerprints fps
+// gives each module in the Reading mode, as the controller composes them, by the one rule its answer to a candidate
+// follows too (composeEach, issue 1126): a module refused alone (its build unread or not taken in, its composition
+// refused, a stand-in for a value of its own the input does not carry, compose.ErrStoodIn) is held, named in held with
+// why, and none of its own composition is in out, so the node keeps what it last applied of it and applies the rest; a
+// node never applies a stand-in. Only a refusal that is no single module's (the node's own resources, a value served
+// elsewhere, a stand-in for the node's own) refuses the node, err.
+func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string) (out map[string]delivery.Composed, skipped []string, held map[string]string, err error) {
+ held = map[string]string{}
+ // A node refused whole says what it held on the way, since the refusal can be of a held module's stub (issue 774,
+ // 2026-10-30 19:30: node-tools held, then "its build produced 0 bundle(s)" with its own refusal unsaid).
+ defer func() {
+ if err != nil && len(held) > 0 {
+ var said []string
+ for _, m := range sortedKeys(held) {
+ said = append(said, m+": "+held[m])
+ }
+ err = fmt.Errorf("%w (held before: %s)", err, strings.Join(said, "; "))
+ }
+ }()
+ in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() }, fps)
+ if err != nil {
+ return nil, nil, held, err
+ }
+ // The pair credentials its composition reads, asked of the vault sealed to it (ADR 0550 §5).
+ asConsumer, asProvider, err := compose.CredentialsNeeded(in, s.Node)
+ if err != nil {
+ return nil, nil, held, err
+ }
+ if len(asConsumer)+len(asProvider) > 0 {
+ if in.Credentials, in.Issued, err = r.Credentials(ctx, s.Node, asConsumer, asProvider); err != nil {
+ return nil, nil, held, fmt.Errorf("frombus: the credentials of %s: %w", s.Node, err)
+ }
+ }
+ out, skipped, _, err = composeEach(in, s.Node, fps, true, s.Running, held, ranOf(ctx, r, mesh, s.Running, fps))
+ return out, skipped, held, err
+}
+
+// Plans is the provisioning plan of each module handed to the provisioner on the scope's node, composed from the bus
+// alone (novox/hq ADR 0570 clause 2): the controller's handedPlans over the same input (mesh-control
+// cmd/mesh-controller/compose_sdk.go), its rendering in the Foreseeing mode with the mesh's generators. A plan names
+// templates, files, own secrets by name, items, collections and provisions, never a value, so no value is read for it:
+// the seat mesh-provisioner's holder composes every node's plans with a Reader that reads no secret.
+func Plans(ctx context.Context, r Reader, mesh Mesh, s Scope) (map[string]catalogue.Plan, error) {
+ in, err := Input(ctx, r, mesh, s)
+ if err != nil {
+ return nil, err
+ }
+ return PlansOf(in, s.Node)
+}
+
+// PlansHeld is Plans composed module by module (issue 774: one bad module must not drop its node): a module placed on
+// the scope's node whose build cannot be read or taken in, or whose plan refuses the node's, is held, by the one rule
+// Compose follows (composeEach, culprit): composed as its stub so what it provides and requires still shapes the rest,
+// named in held with why, and none of its plan in plans. Only a refusal that is no single module's refuses the node,
+// err. The seat mesh-provisioner's holder composes so: a node with one bad manifest still has every other module's plan.
+func PlansHeld(ctx context.Context, r Reader, mesh Mesh, s Scope) (plans map[string]catalogue.Plan, held map[string]string, err error) {
+ held = map[string]string{}
+ in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() }, nil)
+ if err != nil {
+ return nil, held, err
+ }
+ plans, err = plansHeldOf(in, s.Node, held)
+ return plans, held, err
+}
+
+// plansHeldOf is the plans of node over in, every module in held held, and each module a refusal is of held too until
+// the plans compose (culprit's rule over the Planning mode: its stub first, then its bare stub).
+func plansHeldOf(in compose.Input, node string, held map[string]string) (map[string]catalogue.Plan, error) {
+ return heldOf(in, node, held, func(in compose.Input) (map[string]catalogue.Plan, error) { return plansIn(in, node, compose.Planning) })
+}
+
+// heldOf composes compose1 over in with every module in held held, and each module a refusal is of held too until it
+// composes (culprit's rule: its stub first, then its bare stub); what compose1 answers of a held module is left out.
+// Plans and data items are composed so (PlansHeld, DataItemsHeld): one module never ends the read (novox/hq ADR 0578 §3).
+func heldOf[V any](in compose.Input, node string, held map[string]string, compose1 func(compose.Input) (map[string]V, error)) (map[string]V, error) {
+ for _, m := range sortedKeys(held) {
+ in = stubbed(in, node, m, "")
+ }
+ // Each try holds one module more or leaves out all of one held module's offer: bounded by twice the modules placed.
+ for tries := 0; tries <= 2*len(assignedOn(in, node))+1; tries++ {
+ plans, err := compose1(in)
+ if err == nil {
+ for m := range held {
+ delete(plans, m)
+ }
+ return plans, nil
+ }
+ var order []string
+ for _, m := range assignedOn(in, node) {
+ if strings.Contains(err.Error(), m) && len(order) < maxCulprits {
+ order = append(order, m)
+ }
+ }
+ module, leave := "", ""
+ find:
+ for _, l := range []string{"", leaveAll} {
+ for _, m := range order {
+ if _, done := held[m]; done && l == "" {
+ continue
+ }
+ if _, again := compose1(stubbed(in, node, m, l)); again == nil || again.Error() != err.Error() {
+ module, leave = m, l
+ break find
+ }
+ }
+ }
+ if module == "" {
+ return nil, err
+ }
+ if _, done := held[module]; !done {
+ held[module] = err.Error()
+ }
+ in = stubbed(in, node, module, leave)
+ }
+ return nil, fmt.Errorf("frombus: what %s composes does not compose with %d module(s) held: %s", node, len(held),
+ strings.Join(sortedKeys(held), ", "))
+}
+
+// PlansOf is Plans over an input already filled, rendered in the Planning mode (compose.Planning): every own secret the
+// input holds none for stands in, so no value and no record of one is needed.
+func PlansOf(in compose.Input, node string) (map[string]catalogue.Plan, error) {
+ return plansIn(in, node, compose.Planning)
+}
+
+// plansIn is the controller's handedPlans in a mode: Foreseeing is the controller's own.
+func plansIn(in compose.Input, node string, mode compose.Mode) (map[string]catalogue.Plan, error) {
+ // One composition for the three, so each node of the mesh is resolved once (issue 1954).
+ resolved, rendered, gens, err := compose.Composing(in, node, mode)
+ if err != nil {
+ return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err)
+ }
+ with := rendered.Rendering
+ with.Generators = gens
+ composed, err := resolution.Compose(resolved.Plan, with)
+ if err != nil {
+ return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err)
+ }
+ return composed.Plans, nil
+}
+
+// unusable is what stands in for a module's manifest the mesh refuses: its stub (compose.Stub)
+// read from its JSON unjudged, so what it provides, requires and serves still shapes the rest, and without its items,
+// since nothing judged what they would put into another module's collection. Bytes that are no manifest of the module
+// stand in as a stub that offers nothing.
+func unusable(module string, raw []byte) catalogue.Manifest {
+ var m catalogue.Manifest
+ if json.Unmarshal(raw, &m) != nil || m.Module != module {
+ m = catalogue.Manifest{Module: module}
+ }
+ m.Items = nil
+ return compose.Stub(m)
+}
+
+// parser reads a manifest as every reader does (novox/hq ADR 0578 §3): decoded leniently, judged by nothing here.
+func parser(Mesh) func([]byte) (catalogue.Manifest, error) {
+ return catalogue.Decode
+}
+
+// ranOf is what a module last offered the others, read when it is held (composeEach, issue 1960): its manifest at the
+// build its own composition runs at (running), where that is another build than the one it is composed at (fps) and
+// the builder holds it done. False where nothing is known of it (it runs no composition of its own, it runs the build
+// it is composed at, or its build is unread): the manifest it is composed from is its offer then. Only what it says to
+// others is taken of it, which needs no take-in of its artifacts.
+func ranOf(ctx context.Context, r Reader, mesh Mesh, running []delivery.Composed, fps map[string]string) func(string) (catalogue.Manifest, bool) {
+ parse := parser(mesh)
+ return func(module string) (catalogue.Manifest, bool) {
+ for _, c := range running {
+ if c.Module != module || c.FP == "" || c.FP == fps[module] {
+ continue
+ }
+ b, err := r.Build(ctx, module, c.FP)
+ if err != nil || b == nil || b.State != delivery.BuildDone || b.Manifest == "" {
+ return catalogue.Manifest{}, false
+ }
+ raw, err := r.Manifest(ctx, b.Manifest)
+ if err != nil {
+ return catalogue.Manifest{}, false
+ }
+ man, err := parse(raw)
+ return man, err == nil
+ }
+ return catalogue.Manifest{}, false
+ }
+}
+
+// fromBuilds puts in the built manifest of each module placed on the composing node, at the trunk's FP2, and of each
+// module it keeps at another build (Scope.KeptAt, into its node's Kept), read from the builder's done key and resolved
+// by the one take-in rule (takein.Manifest), a templates archive read in the artifact store as the node reaches it
+// (issue 1126).
+func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error),
+ in *compose.Input, s Scope, p delivery.Placement, modules map[string]delivery.Module,
+ read func(module, digest string) (catalogue.Manifest, error), parse func([]byte) (catalogue.Manifest, error),
+ refuse func(module string, err error), wanted map[string]string) error {
+ // stub keeps a refused module in the composition as a stub for this node alone (compose.Stub): what it provides
+ // and requires still shapes the rest.
+ stub := func(module string, err error) {
+ refuse(module, err)
+ for i := range in.Nodes {
+ if in.Nodes[i].Name != s.Node {
+ continue
+ }
+ man, ok := in.Nodes[i].Kept[module]
+ if !ok {
+ man, ok = in.Catalogue[module]
+ }
+ if !ok {
+ return
+ }
+ if in.Nodes[i].Kept == nil {
+ in.Nodes[i].Kept = map[string]catalogue.Manifest{}
+ }
+ in.Nodes[i].Kept[module] = compose.Stub(man)
+ }
+ }
+ address, addressErr := "", error(nil)
+ addressed := false
+ archive := func(ctx context.Context, reference, digest string) ([]byte, error) {
+ if !addressed {
+ address, addressErr = compose.ArtifactStoreAddress(*in, s.Node)
+ addressed = true
+ }
+ switch {
+ case addressErr != nil:
+ return nil, addressErr
+ case address == "":
+ return nil, fmt.Errorf("%s reaches no artifact store (%s)", s.Node, storeSought(*in))
+ }
+ return r.Archive(ctx, catalogue.Routed(reference, address), digest)
+ }
+ built := func(module, fp string, b *delivery.BuilderBuild) (catalogue.Manifest, error) {
+ if b.Manifest == "" {
+ return catalogue.Manifest{}, fmt.Errorf("frombus: the build of %s at %s names no manifest", module, fp)
+ }
+ // A take-in kept by the reader is read back as below, never shared: each composition holds its own manifest.
+ keeper, keeps := r.(takenInKeeper)
+ if keeps && !addressed {
+ address, addressErr = compose.ArtifactStoreAddress(*in, s.Node)
+ addressed = true
+ }
+ // Kept by the store's address too: a node that reaches no store takes in as before, and fails where it did.
+ key, keyed := takenInKey(module, fp, address, *b)
+ keyed = keyed && addressErr == nil && address != ""
+ if keeps && keyed {
+ if raw, ok := keeper.takenIn(key); ok {
+ resolved, err := parse(raw)
+ if err != nil {
+ return catalogue.Manifest{}, fmt.Errorf("frombus: the built manifest of %s at %s: %w", module, fp, err)
+ }
+ return resolved, nil
+ }
+ }
+ src, err := read(module, b.Manifest)
+ if err != nil {
+ return catalogue.Manifest{}, err
+ }
+ resolved, err := takein.Manifest(ctx, src, *b, archive)
+ if err != nil {
+ return catalogue.Manifest{}, fmt.Errorf("frombus: %s at %s: %w", module, fp, err)
+ }
+ // Read back as the mesh holds a built manifest (the controller keeps the resolved manifest's bytes and reads them
+ // as any manifest): a template's text is then in the manifest's own words, its ${config:…} in the settings form.
+ raw, err := json.Marshal(resolved)
+ if err != nil {
+ return catalogue.Manifest{}, err
+ }
+ if resolved, err = parse(raw); err != nil {
+ return catalogue.Manifest{}, fmt.Errorf("frombus: the built manifest of %s at %s: %w", module, fp, err)
+ }
+ if keeps && keyed {
+ keeper.keepTakenIn(key, raw)
+ }
+ return resolved, nil
+ }
+ // A module wanted at another build than the trunk's is composed from the build its want names (issue 774), as one
+ // the scope keeps at another build is.
+ keptAt := maps.Clone(s.KeptAt)
+ for _, pm := range p.Modules {
+ if m, ok := modules[pm.Module]; ok && m.FP2 != "" && wanted[pm.Module] != "" && wanted[pm.Module] != m.FP2 &&
+ keptAt[pm.Module] == "" && !slices.Contains(s.InOwn, pm.Module) {
+ if keptAt == nil {
+ keptAt = map[string]string{}
+ }
+ keptAt[pm.Module] = wanted[pm.Module]
+ }
+ }
+ runs := map[string]bool{}
+ for _, c := range s.Running {
+ if c.Module != "" {
+ runs[c.Module] = true
+ }
+ }
+ for _, pm := range p.Modules {
+ m, ok := modules[pm.Module]
+ if !ok || m.FP2 == "" || keptAt[pm.Module] != "" || slices.Contains(s.InOwn, pm.Module) {
+ // A module it runs inside its own composition is composed there as before: no build of the new form is what
+ // it runs (Scope.InOwn).
+ continue
+ }
+ if _, refused := in.Refused[pm.Module]; refused {
+ continue // held as its stub: no build of a refused manifest is taken in (ADR 0578 §3)
+ }
+ if man, ok := in.Catalogue[pm.Module]; !ok || (man.Build == nil && !man.Templated()) {
+ continue // nothing is built of it: its manifest is what it composes from
+ }
+ b, err := build(ctx, pm.Module, m.FP2)
+ if err != nil {
+ err = fmt.Errorf("frombus: the build of %s at %s: %w", pm.Module, m.FP2, err)
+ if refuse == nil {
+ return err
+ }
+ stub(pm.Module, err)
+ continue
+ }
+ if b == nil || b.State != delivery.BuildDone {
+ if refuse != nil && runs[pm.Module] {
+ // A module that runs is never composed from the indexer's manifest, which names no build's artifacts (no
+ // runtime's bundle): it is held, keeping what runs, until its build lands (issue 774, 2026-10-30 19:30:
+ // node-tools on ace, g14 and shanks, "its build produced 0 bundle(s)", and every module refused).
+ stub(pm.Module, fmt.Errorf("frombus: %s is wanted at %s, of which the builder holds no done build yet; it "+
+ "keeps running what it runs", pm.Module, m.FP2))
+ }
+ continue // nothing of it runs: composed from the indexer's manifest, as before
+ }
+ resolved, err := built(pm.Module, m.FP2, b)
+ if err != nil {
+ if refuse == nil {
+ return err
+ }
+ stub(pm.Module, err)
+ continue
+ }
+ in.Catalogue[pm.Module] = resolved
+ }
+ for i := range in.Nodes {
+ if in.Nodes[i].Name != s.Node {
+ continue
+ }
+ for _, module := range sortedKeys(keptAt) {
+ fp := keptAt[module]
+ b, err := build(ctx, module, fp)
+ switch {
+ case err != nil:
+ err = fmt.Errorf("frombus: the build of %s at %s: %w", module, fp, err)
+ case b == nil || b.State != delivery.BuildDone:
+ err = fmt.Errorf("frombus: %s is wanted at %s, of which the builder holds no done build", module, fp)
+ }
+ var resolved catalogue.Manifest
+ if err == nil {
+ resolved, err = built(module, fp, b)
+ }
+ if err != nil {
+ if refuse == nil {
+ return err
+ }
+ stub(module, err)
+ continue
+ }
+ if in.Nodes[i].Kept == nil {
+ in.Nodes[i].Kept = map[string]catalogue.Manifest{}
+ }
+ in.Nodes[i].Kept[module] = resolved
+ }
+ }
+ return nil
+}
+
+// declaresOwnSecrets says a module assigned to node declares an own secret, in the manifest the node composes it from.
+func declaresOwnSecrets(in compose.Input, node string) bool {
+ for _, n := range in.Nodes {
+ if n.Name != node {
+ continue
+ }
+ for _, module := range n.Assigned {
+ m, ok := n.Kept[module]
+ if !ok {
+ m = in.Catalogue[module]
+ }
+ if len(m.OwnSecrets) > 0 {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+// nodeOf is the composition's node from its record (what the machine observes) and its placement (what a person
+// decided of it, and where its modules run).
+func nodeOf(rec delivery.NodeRecord, p delivery.Placement) compose.Node {
+ n := compose.Node{
+ Name: rec.Node,
+ Place: compose.Place{Key: rec.TunnelKey},
+ SealingKey: rec.SealingKey,
+ OutwardLinks: rec.OutwardLinks, Networks: rec.Networks, NetworksSaid: rec.NetworksSaid,
+ ServingKey: rec.ServingKey, Certificate: rec.Certificate, Contract: rec.Contract, Stated: rec.Contract > 0,
+ }
+ placeNode(&n, p)
+ if len(rec.Capabilities) > 0 {
+ n.Capabilities = make(map[string]bool, len(rec.Capabilities))
+ for _, c := range rec.Capabilities {
+ n.Capabilities[c] = true
+ }
+ }
+ return n
+}
+
+// placeNode sets what a person decided of n and where its modules run, from its placement; what the machine observes
+// (its keys, links, networks, certificate) and its settings layers are left as they are.
+func placeNode(n *compose.Node, p delivery.Placement) {
+ d := p.Decided
+ n.Place = compose.Place{Key: n.Place.Key, Endpoint: d.Endpoint, Site: d.Site, Hub: d.Hub, Address: d.Address}
+ n.Assigned, n.PublicDomain = p.Assigned(), d.PublicDomain
+ n.Account, n.AccountHome, n.AgentAccount, n.AgentAccountHome = d.Account, d.AccountHome, d.AgentAccount, d.AgentHome
+ n.Adopted, n.Taken = d.Adopted, d.Taken
+ n.Pins, n.Bound, n.Ports = p.ChosenPins(), p.Bound(), p.HostPorts()
+}
+
+// Placed is in with the placements given standing in for those of the nodes they name (issue 1126): each such node's
+// decided parts, modules, pins, bindings, host ports, settings placement and holdings are the given placement's, its
+// config values those in already carries for it (the layer of the node's own name), and what the machine observes is
+// kept. A nil placement leaves the node in the mesh with nothing placed on it. A node in does not hold is refused: it has
+// no record to compose it from. The found tunnel and the hub's peers are read from in as they are, not from the given
+// placement. It is how a check composes a change's own placement over an input already filled (composer.Case's
+// Placements).
+func Placed(in compose.Input, placements map[string]*delivery.Placement) (compose.Input, error) {
+ if len(placements) == 0 {
+ return in, nil
+ }
+ out := in
+ out.Nodes = append([]compose.Node(nil), in.Nodes...)
+ index := map[string]int{}
+ for i, n := range out.Nodes {
+ index[n.Name] = i
+ }
+ for _, node := range sortedKeys(placements) {
+ p := placements[node]
+ i, ok := index[node]
+ if !ok {
+ return compose.Input{}, fmt.Errorf("frombus: a placement is given for %s, which the input holds no node for", node)
+ }
+ if p == nil {
+ p = &delivery.Placement{Node: node}
+ }
+ if p.Node != node {
+ return compose.Input{}, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node)
+ }
+ n := out.Nodes[i]
+ placeNode(&n, *p)
+ settings := map[string][]catalogue.Layer{}
+ for _, m := range p.Modules {
+ var values map[string]any
+ for _, l := range n.Settings[m.Module] {
+ if l.From == node {
+ values = l.Values
+ }
+ }
+ if layers := m.Layers(node, values); layers != nil {
+ settings[m.Module] = layers
+ }
+ }
+ n.Settings = nil
+ if len(settings) > 0 {
+ n.Settings = settings
+ }
+ out.Nodes[i] = n
+ }
+ out.Holdings = nil
+ for _, h := range in.Holdings {
+ if _, given := placements[h.Node]; !given {
+ out.Holdings = append(out.Holdings, h)
+ }
+ }
+ for _, node := range sortedKeys(placements) {
+ if p := placements[node]; p != nil {
+ out.Holdings = append(out.Holdings, p.Held()...)
+ }
+ }
+ return out, nil
+}
+
+func tunnelOf(t delivery.RecordTunnel) compose.Tunnel {
+ out := compose.Tunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU,
+ Address: t.Address, Range: t.Range, PublicKey: t.PublicKey}
+ for _, p := range t.Peers {
+ out.Peers = append(out.Peers, compose.TunnelPeer{PublicKey: p.PublicKey, Address: p.Address})
+ }
+ return out
+}
+
+// carriedPeers is every peer of the hub's adopted tunnel, by address, each with the node that enrolled under its key
+// where one has (a node whose tunnel key is the peer's) and the name a person gave it (inv.CarriedPeers).
+func carriedPeers(t compose.Tunnel, names map[string]string, records map[string]delivery.NodeRecord) []compose.CarriedPeer {
+ byKey := map[string]string{}
+ for node, rec := range records {
+ if rec.TunnelKey != "" {
+ byKey[rec.TunnelKey] = node
+ }
+ }
+ var out []compose.CarriedPeer
+ for _, p := range t.Peers {
+ address, _, _ := strings.Cut(p.Address, "/")
+ out = append(out, compose.CarriedPeer{PublicKey: p.PublicKey, Address: address, EnrolledAs: byKey[p.PublicKey], Named: names[p.PublicKey]})
+ }
+ sort.SliceStable(out, func(i, j int) bool {
+ a, errA := netip.ParseAddr(out[i].Address)
+ b, errB := netip.ParseAddr(out[j].Address)
+ if errA != nil || errB != nil {
+ return out[i].Address < out[j].Address
+ }
+ return a.Less(b)
+ })
+ return out
+}
+
+// nodesOf is every node named by a placement or a record, by name: the order the controller reads them in.
+func nodesOf(placements map[string]delivery.Placement, records map[string]delivery.NodeRecord) []string {
+ seen := map[string]bool{}
+ for n := range placements {
+ seen[n] = true
+ }
+ for n := range records {
+ seen[n] = true
+ }
+ return sortedKeys(seen)
+}
+
+func sortedKeys[V any](m map[string]V) []string {
+ out := make([]string, 0, len(m))
+ for k := range m {
+ out = append(out, k)
+ }
+ sort.Strings(out)
+ return out
+}
+
+// storeSought says, for a node that reaches no artifact store, what the composition holds of one: the nodes it holds and
+// where each module serving the artifact store is placed, so the refusal names why (issue 1126).
+func storeSought(in compose.Input) string {
+ var nodes, where []string
+ for _, n := range in.Nodes {
+ nodes = append(nodes, n.Name)
+ for _, a := range n.Assigned {
+ if m, ok := in.Catalogue[a]; ok {
+ if _, serves := m.Serves[catalogue.ArtifactStoreProvision]; serves {
+ where = append(where, a+" on "+n.Name)
+ }
+ }
+ }
+ }
+ sort.Strings(nodes)
+ sort.Strings(where)
+ said := "the composition holds " + strings.Join(nodes, ", ")
+ if len(where) == 0 {
+ return said + "; no module placed there serves " + catalogue.ArtifactStoreProvision
+ }
+ return said + "; " + catalogue.ArtifactStoreProvision + " is served by " + strings.Join(where, ", ") +
+ ", none of it on the private network with a port"
+}
+
+// keptOf is the operator export's file (catalogue.KeptID) of a module's composition as it runs, or nil.
+func keptOf(c delivery.Composed) map[string]any {
+ for _, raw := range c.Resources {
+ var r map[string]any
+ if json.Unmarshal(raw, &r) == nil && r["id"] == c.Module+"."+resolution.KeptID() {
+ return r
+ }
+ }
+ return nil
+}
+
+// membershipOf is the sealed value of the bus membership resource (catalogue.BusMembershipID) of a node's own
+// composition, or "".
+func membershipOf(own delivery.Composed) string {
+ for _, raw := range own.Resources {
+ var r struct {
+ ID string `json:"id"`
+ Sealed string `json:"sealed"`
+ }
+ if json.Unmarshal(raw, &r) == nil && r.ID == resolution.BusMembershipID() {
+ return r.Sealed
+ }
+ }
+ return ""
+}
+
+// DataItems is every data item the modules placed on scope's node declare, placed there, composed from the bus as Plans
+// composes (novox/hq ADR 0571 clause 3): disk-load measures these. A module whose manifest the indexer refused is held
+// and its items left out; DataItemsHeld names it, and a reader that must say what it leaves out reads that.
+func DataItems(ctx context.Context, r Reader, mesh Mesh, s Scope) (map[string][]catalogue.PlacedDataItem, []string, error) {
+ items, unplaced, _, err := DataItemsHeld(ctx, r, mesh, s)
+ return items, unplaced, err
+}
+
+// DataItemsHeld is DataItems and, by module, why each module placed on scope's node is held: its manifest refused by
+// the indexer's judge or not decoded (compose.Input.Refused). One refused manifest never ends the read (novox/hq ADR
+// 0578 §3: disk-load measured nothing for 45 hours when one did).
+func DataItemsHeld(ctx context.Context, r Reader, mesh Mesh, s Scope) (items map[string][]catalogue.PlacedDataItem, unplaced []string, held map[string]string, err error) {
+ held = map[string]string{}
+ in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() }, nil)
+ if err != nil {
+ return nil, nil, held, err
+ }
+ // Held module by module, as PlansHeld holds them: a refused stub or one module's composition refusal (an item's
+ // template) holds that module and never ends the read.
+ items, err = heldOf(in, s.Node, held, func(in compose.Input) (map[string][]catalogue.PlacedDataItem, error) {
+ var err error
+ items, unplaced, err = compose.DataItems(withoutHeldData(in, s.Node), s.Node)
+ return items, err
+ })
+ return items, unplaced, held, err
+}
+
+// withoutHeldData is in with no data item of a module held on node: none of a held module's items is measured, so its
+// data never refuses the rest (its stub keeps its data for the backups the plans compose).
+func withoutHeldData(in compose.Input, node string) compose.Input {
+ out := in
+ out.Nodes = append([]compose.Node(nil), in.Nodes...)
+ for i := range out.Nodes {
+ n := &out.Nodes[i]
+ if n.Name != node || len(n.Held) == 0 {
+ continue
+ }
+ kept := maps.Clone(n.Kept)
+ for m := range n.Held {
+ if man, ok := kept[m]; ok {
+ man.Data = nil
+ kept[m] = man
+ }
+ }
+ n.Kept = kept
+ }
+ return out
+}
diff --git a/internal/compose/frombus/frombus_test.go b/internal/compose/frombus/frombus_test.go
new file mode 100644
index 0000000..cd566f5
--- /dev/null
+++ b/internal/compose/frombus/frombus_test.go
@@ -0,0 +1,869 @@
+package frombus
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "context"
+ "crypto/ecdh"
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "reflect"
+ "slices"
+ "sort"
+ "strings"
+ "testing"
+
+ "git.novox.be/novox/mesh-sdk/go/catalogue"
+ "git.novox.be/novox/mesh-sdk/go/delivery"
+ "github.com/novox/mesh-host/internal/compose"
+ "github.com/novox/mesh-host/internal/overlay"
+)
+
+// bus is a Reader over values held in memory, counting whose config values were read.
+type bus struct {
+ vaultAuthority string
+ handed map[string]bool
+ placements map[string]delivery.Placement
+ records map[string]delivery.NodeRecord
+ modules map[string]delivery.Module
+ manifests map[string][]byte
+ values map[string]map[string]any // by node.module
+ valuesRead []string
+ own []compose.OwnSecretRow
+ ownErr error
+ // asked is every consumer credential asked; the fake refuses the whole ask, as the vault's own does, for a consuming
+ // module its placement does not put on the node.
+ asked []compose.CredentialKey
+ // builds are the builder's keys, by ""; archives the artifact store's blobs, by digest, and fetched
+ // every URL asked; served another node's values by ".", and servedRead every ask with its keys.
+ builds map[string]*delivery.BuilderBuild
+ archives map[string][]byte
+ fetched []string
+ served map[string]map[string]any
+ servedRead []string
+ buildsRead []string
+ // held answers every credential asked as held, sealed to the consumer.
+ held bool
+}
+
+func (b *bus) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) {
+ b.buildsRead = append(b.buildsRead, module)
+ return b.builds[module+" "+fp], nil
+}
+
+func (b *bus) Archive(_ context.Context, url, digest string) ([]byte, error) {
+ b.fetched = append(b.fetched, url)
+ body, ok := b.archives[digest]
+ if !ok {
+ return nil, errors.New("the store holds no such blob")
+ }
+ return body, nil
+}
+
+func (b *bus) Served(_ context.Context, node, module string, keys []string) (map[string]any, error) {
+ b.servedRead = append(b.servedRead, node+"."+module+":"+strings.Join(keys, ","))
+ out := map[string]any{}
+ for _, k := range keys {
+ if v, ok := b.served[node+"."+module][k]; ok {
+ out[k] = v
+ }
+ }
+ return out, nil
+}
+
+func (b *bus) Placements(context.Context) (map[string]delivery.Placement, error) {
+ return b.placements, nil
+}
+func (b *bus) VaultAuthority(context.Context) string { return b.vaultAuthority }
+func (b *bus) Handed(context.Context, string) map[string]bool { return b.handed }
+func (b *bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.records, nil }
+func (b *bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.modules, nil }
+func (b *bus) Manifest(_ context.Context, digest string) ([]byte, error) {
+ raw, ok := b.manifests[digest]
+ if !ok {
+ return nil, errors.New("no such manifest")
+ }
+ return raw, nil
+}
+func (b *bus) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) {
+ return b.own, b.ownErr
+}
+
+func (b *bus) Credentials(_ context.Context, node string, asConsumer, asProvider []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) {
+ b.asked = append(b.asked, asConsumer...)
+ for _, k := range asConsumer {
+ placed := false
+ for _, m := range b.placements[node].Modules {
+ placed = placed || m.Module == k.ConsumerModule
+ }
+ if !placed {
+ return nil, nil, fmt.Errorf("%s is not on %s: neither assigned there nor required by a module there", k.ConsumerModule, node)
+ }
+ }
+ var out []compose.Credential
+ var issued []compose.Issued
+ for _, k := range append(append([]compose.CredentialKey{}, asConsumer...), asProvider...) {
+ if b.held {
+ out = append(out, compose.Credential{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule,
+ Provider: k.Provider, Local: k.Local, State: compose.CredentialHeld, Sealed: `"box"`})
+ issued = append(issued, compose.Issued{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule,
+ Provider: k.Provider, Local: k.Local, ForProvider: `"box"`})
+ }
+ }
+ return out, issued, nil
+}
+
+func (b *bus) Values(_ context.Context, node, module string) (map[string]any, error) {
+ b.valuesRead = append(b.valuesRead, node+"."+module)
+ return b.values[node+"."+module], nil
+}
+
+func publicKey(t *testing.T) string {
+ t.Helper()
+ k, err := ecdh.X25519().GenerateKey(rand.Reader)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes())
+}
+
+// Issue 1126 (S3, S4, S5): the bus's placement, node records, manifests and config values fill the same compose.Input the
+// controller's store fills for compose's own two-machine mesh (compose's aMesh: anchor the hub, laptop), and that Input
+// composes; a node reads only its own config values; what cannot be composed is refused, naming it.
+func TestInput(t *testing.T) {
+ // Issue 1126, the seed's shadow: every refusal named, an old-form module left to the node, and no store reached
+ // saying why (answer_rows_test.go).
+ t.Run("every refusal, and an old build left to the node", answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode)
+ t.Run("no store reached says why", noStoreReachedSaysWhy)
+ // Issue 774: a node refused whole says what it held on the way.
+ t.Run("a node refused whole says what it held", aNodeRefusedWholeSaysWhatItHeld)
+ t.Run("a refused manifest never ends the read", aRefusedManifestNeverEndsTheRead)
+ t.Run("the runtime is blamed only where its stub composes", theRuntimeIsBlamedOnlyWhereItsStubComposes)
+ t.Run("a running module waits for its build", aRunningModuleWaitsForItsBuild)
+ t.Run("a held module's first reason leads", aHeldModulesFirstReasonLeads)
+ t.Run("a held module of its own account never refuses the node", aHeldModuleOfItsOwnAccountNeverRefusesTheNode)
+ t.Run("another node not resolved is named", otherNodeNotResolvedIsNamed)
+ ctx := context.Background()
+ xterm, err := os.ReadFile("testdata/catalogue-835bf22b-xterm.module.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ digest := delivery.ManifestDigest(xterm)
+ // A module in the settings form (plex and the media modules on one node, 2026-10-26): its config values are in its
+ // layers, not mesh-config, so the placement carries them (MeshValues, Values) and mesh-config's are not read for it.
+ media := []byte(`{"module":"media","version":"1","settings":{"lines":{"kind":"preference","default":0,"why":"kept"},` +
+ `"name":{"kind":"preference","default":"x","why":"shown"}},"own-secrets":{"token":"/srv/media/token"},"resources":[{"id":"conf","type":"file",` +
+ `"path":"/srv/media/conf","mode":"0644","content":"uid=${setting:lines} name=${setting:name}\n"}]}`)
+ mediaDigest := delivery.ManifestDigest(media)
+ keys := map[string]string{"anchor": publicKey(t), "laptop": publicKey(t)}
+ caps := []string{"container-runtime", "systemd", "wireguard"}
+ tunnel := &delivery.RecordTunnel{Interface: "wg0", PublicKey: keys["anchor"], Address: "10.77.0.1/24",
+ Peers: []delivery.RecordTunnelPeer{{PublicKey: "peer", Address: "10.77.0.9/32"}, {PublicKey: keys["laptop"], Address: "10.77.0.2/32"}}}
+ mk := func() *bus {
+ return &bus{
+ placements: map[string]delivery.Placement{
+ "anchor": {Node: "anchor", Decided: delivery.NodeDecided{Endpoint: "anchor.example:51820", Site: "here", Hub: true, Address: "10.77.0.1",
+ Tunnel: tunnel, PeerNames: map[string]string{"peer": "phone"}},
+ Modules: []delivery.PlacedModule{{Module: overlay.Name}},
+ Holds: []delivery.Holding{{Claim: "mesh-private-network", Scope: "mesh", Module: overlay.Name}}},
+ "laptop": {Node: "laptop", Decided: delivery.NodeDecided{Endpoint: "laptop.example:51820", Site: "here", Address: "10.77.0.2"},
+ Modules: []delivery.PlacedModule{{Module: overlay.Name},
+ {Module: "xterm", HasLayer: true, Ports: map[string]int{"80": 8080}},
+ {Module: "media", HasMesh: true, MeshValues: map[string]any{"name": "mesh"}, HasLayer: true,
+ Layer: map[string]any{"places": map[string]any{"data": "/srv/data"}}, Values: map[string]any{"lines": float64(1000)}}}},
+ },
+ records: map[string]delivery.NodeRecord{
+ "anchor": {Node: "anchor", TunnelKey: keys["anchor"], Capabilities: caps, Contract: 2},
+ "laptop": {Node: "laptop", TunnelKey: keys["laptop"], Capabilities: caps, Authority: "the mesh's certificate"},
+ },
+ modules: map[string]delivery.Module{"xterm": {Manifest: digest}, "media": {Manifest: mediaDigest}, "unread": {}},
+ manifests: map[string][]byte{digest: xterm, mediaDigest: media},
+ values: map[string]map[string]any{"laptop.xterm": {"font": "mono"}, "anchor.xterm": {"font": "serif"},
+ "laptop.media": {"lines": "not mesh-config's"}},
+ own: []compose.OwnSecretRow{{Node: "laptop", Module: "media", Name: "token", Origin: "made", SealedTo: "sk", Sealed: "{}"}},
+ }
+ }
+
+ // What the controller's store gives for the same mesh, as compose's aMesh and SettingsFor build it.
+ var private catalogue.Manifest
+ raw, _ := json.Marshal(overlay.Manifest())
+ if err := json.Unmarshal(raw, &private); err != nil {
+ t.Fatal(err)
+ }
+ parsed, err := catalogue.Decode(xterm)
+ if err != nil {
+ t.Fatal(err)
+ }
+ parsedMedia, err := catalogue.Decode(media)
+ if err != nil {
+ t.Fatal(err)
+ }
+ capsSet := map[string]bool{"container-runtime": true, "systemd": true, "wireguard": true}
+ tun := compose.Tunnel{Interface: "wg0", PublicKey: keys["anchor"], Address: "10.77.0.1/24",
+ Peers: []compose.TunnelPeer{{PublicKey: "peer", Address: "10.77.0.9/32"}, {PublicKey: keys["laptop"], Address: "10.77.0.2/32"}}}
+ want := compose.Input{
+ Catalogue: map[string]catalogue.Manifest{overlay.Name: private, "xterm": parsed, "media": parsedMedia},
+ Nodes: []compose.Node{
+ {Name: "anchor", Place: compose.Place{Key: keys["anchor"], Endpoint: "anchor.example:51820", Site: "here", Hub: true,
+ Address: "10.77.0.1"}, Assigned: []string{overlay.Name}, Capabilities: capsSet, Contract: 2, Stated: true},
+ {Name: "laptop", Place: compose.Place{Key: keys["laptop"], Endpoint: "laptop.example:51820", Site: "here",
+ Address: "10.77.0.2"}, Assigned: []string{overlay.Name, "xterm", "media"}, Capabilities: capsSet,
+ Ports: map[string]map[int]int{"xterm": {80: 8080}},
+ Settings: map[string][]catalogue.Layer{"xterm": {{From: "laptop", Values: map[string]any{"font": "mono"}}},
+ "media": {{From: catalogue.MeshWideLayer, Values: map[string]any{"name": "mesh"}},
+ {From: "laptop", Values: map[string]any{"lines": float64(1000)}, Placement: map[string]any{"places": map[string]any{"data": "/srv/data"}}}}}},
+ },
+ Holdings: []catalogue.Held{{Claim: "mesh-private-network", Scope: "mesh", Node: "anchor", Module: overlay.Name}},
+ OwnSecrets: []compose.OwnSecretRow{{Node: "laptop", Module: "media", Name: "token", Origin: "made", SealedTo: "sk", Sealed: "{}"}},
+ // The same value as a core credential's box, for a core module (ADR 0349 §16).
+ KeptCoreBoxes: []compose.CoreBox{{Node: "laptop", Module: "media", Name: "token", SealedTo: "sk", Box: json.RawMessage("{}")}},
+ Tunnels: map[string]compose.FoundTunnel{"anchor": {Tunnel: tun}},
+ Adopted: &compose.AdoptedTunnel{Tunnel: tun, Hub: "anchor", Adopted: true},
+ // The controller's order is by address; laptop enrolled under its peer's key, and a person named the other.
+ Authority: "the mesh's certificate",
+ CarriedPeers: []compose.CarriedPeer{{PublicKey: keys["laptop"], Address: "10.77.0.2", EnrolledAs: "laptop"},
+ {PublicKey: "peer", Address: "10.77.0.9", Named: "phone"}},
+ }
+
+ b := mk()
+ got, err := Input(ctx, b, Mesh{}, Scope{Node: "laptop"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(got, want) {
+ gj, _ := json.MarshalIndent(got, "", " ")
+ wj, _ := json.MarshalIndent(want, "", " ")
+ t.Fatalf("Input from the bus differs from the store's:\n got %s\nwant %s", gj, wj)
+ }
+ // L3 s8 (ADR 0550 §2): with the vault's authority published, the mesh's authority is the bundle of the controller's
+ // and the vault's; with none, the controller's alone, as above.
+ signing := mk()
+ signing.vaultAuthority = "the vault's certificate"
+ if bundled, err := Input(ctx, signing, Mesh{}, Scope{Node: "laptop"}); err != nil ||
+ bundled.Authority != "the mesh's certificate\nthe vault's certificate\n" {
+ t.Errorf("the authority with the vault's published: %q, %v", bundled.Authority, err)
+ }
+ // Issue 774: the modules the vault judged handed when it answered own are the node's to hand, and no other node's.
+ judged := mk()
+ judged.handed = map[string]bool{"media": true, "xterm": false}
+ if in, err := Input(ctx, judged, Mesh{}, Scope{Node: "laptop"}); err != nil ||
+ !reflect.DeepEqual(in.Nodes[1].Handed, judged.handed) || in.Nodes[0].Handed != nil {
+ t.Errorf("the vault's handed verdicts: %+v, %v", in.Nodes, err)
+ }
+ if !reflect.DeepEqual(b.valuesRead, []string{"laptop." + overlay.Name, "laptop.xterm", "laptop.media"}) {
+ t.Errorf("laptop read config values %v; a node reads its own slice alone", b.valuesRead)
+ }
+
+ // The same composer over both: the compositions from the bus are the store's, byte for byte.
+ fps := map[string]string{overlay.Name: "src2:" + strings.Repeat("a", 64), "xterm": "src2:" + strings.Repeat("b", 64),
+ "media": "src2:" + strings.Repeat("c", 64)}
+ fromBus, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop"}, fps)
+ if err == nil && len(held) > 0 {
+ err = fmt.Errorf("modules held: %v", held)
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ fromStore, _, err := compose.Compositions(want, "laptop", fps, compose.Reading)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(fromBus) == 0 || !reflect.DeepEqual(fromBus, fromStore) {
+ t.Fatalf("compositions differ or are empty: %d from the bus, %d from the store", len(fromBus), len(fromStore))
+ }
+
+ // Issue 1126: a candidate placement equal to the node's composes what it runs, and CompareComposed says so; one
+ // thing changed in the group's candidate of another node (the hub's endpoint) names exactly the module it moves
+ // here. Placed puts the same placement over the store's input unchanged.
+ running := make([]delivery.Composed, 0, len(fromBus))
+ for _, c := range fromBus {
+ running = append(running, c)
+ }
+ laptop := mk().placements["laptop"]
+ asIs, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop}}, fps)
+ if err == nil && len(held) > 0 {
+ err = fmt.Errorf("modules held: %v", held)
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c := delivery.CompareComposed(asIs, running); len(c.Differs)+len(c.Added)+len(c.Removed) != 0 || len(c.Same) != len(running) {
+ t.Errorf("the candidate equal to the placement: %+v", c)
+ }
+ moved := mk().placements["anchor"]
+ moved.Decided.Endpoint = "anchor.example:51821"
+ changed, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop, "anchor": &moved}}, fps)
+ if err == nil && len(held) > 0 {
+ err = fmt.Errorf("modules held: %v", held)
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c := delivery.CompareComposed(changed, running); len(c.Differs) != 1 || c.Differs[0].Module != overlay.Name || c.Differs[0].Resource != overlay.Name+".overlay-config" {
+ t.Errorf("the hub's endpoint changed: %+v, want %s alone, naming its config", c, overlay.Name)
+ }
+ if c := delivery.CompareComposed(changed, running[:0]); len(c.Added) != len(changed) {
+ t.Errorf("nothing runs: %+v", c)
+ }
+
+ // ADR 0559 §4, the node's answer: the same; one module differing; a module the group adds, whose own secret is not
+ // yet issued (no refusal); a value missing for a module the node runs (a refusal); a module the candidate removes.
+ keeper := []byte(`{"module":"keeper","version":"1","own-secrets":{"password":"/var/lib/mesh/keeper/password"},` +
+ `"resources":[{"id":"state","type":"directory","path":"/var/lib/mesh/keeper","mode":"0700"}]}`)
+ lemurs := []byte(`{"module":"lemurs","version":"1","capabilities":["systemd"],"resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`)
+ // A provider and a consumer of a pair credential on the one node (compose's objects and files).
+ objects, _ := json.Marshal(catalogue.Manifest{Module: "objects", Version: "1",
+ Provides: []catalogue.Offer{{Name: "s3-bucket", Scope: catalogue.ScopeMesh, Identity: &catalogue.OfferIdentity{Max: 20, In: "an S3 access key"}}},
+ Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}})
+ files, _ := json.Marshal(catalogue.Manifest{Module: "files", Version: "1", Requires: []string{"s3-bucket"},
+ Resources: []map[string]any{{"id": "config", "type": "file", "path": "/etc/files/config.json", "mode": "0600", "content": "{}", "merge": "json"}}})
+ one := func(placed ...string) *bus {
+ p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}}
+ for _, m := range placed {
+ p.Modules = append(p.Modules, delivery.PlacedModule{Module: m})
+ }
+ return &bus{placements: map[string]delivery.Placement{"n1": p},
+ records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: keys["anchor"], SealingKey: "sk", Capabilities: []string{"systemd"}}},
+ modules: map[string]delivery.Module{"keeper": {Manifest: delivery.ManifestDigest(keeper)}, "lemurs": {Manifest: delivery.ManifestDigest(lemurs)},
+ "objects": {Manifest: delivery.ManifestDigest(objects)}, "files": {Manifest: delivery.ManifestDigest(files)}},
+ manifests: map[string][]byte{delivery.ManifestDigest(keeper): keeper, delivery.ManifestDigest(lemurs): lemurs,
+ delivery.ManifestDigest(objects): objects, delivery.ManifestDigest(files): files},
+ own: []compose.OwnSecretRow{{Node: "n1", Module: "keeper", Name: "password", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}}
+ }
+ oneFps := map[string]string{"keeper": "src2:" + strings.Repeat("d", 64), "lemurs": "src2:" + strings.Repeat("e", 64),
+ "objects": "src2:" + strings.Repeat("f", 64), "files": "src2:" + strings.Repeat("0", 64)}
+ runs, _, held, err := Compose(ctx, one("keeper", "lemurs"), Mesh{}, Scope{Node: "n1"}, oneFps)
+ if err == nil && len(held) > 0 {
+ err = fmt.Errorf("modules held: %v", held)
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ var runsNow []delivery.Composed
+ for _, c := range runs {
+ runsNow = append(runsNow, c)
+ }
+ objectsRun, _, held, err := Compose(ctx, one("objects"), Mesh{}, Scope{Node: "n1"}, oneFps)
+ if err == nil && len(held) > 0 {
+ err = fmt.Errorf("modules held: %v", held)
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ var consumerRuns []delivery.Composed
+ for _, c := range objectsRun {
+ consumerRuns = append(consumerRuns, c)
+ }
+ withoutOwn := func(cs []delivery.Composed) []delivery.Composed {
+ var out []delivery.Composed
+ for _, c := range cs {
+ if c.Module != "" {
+ out = append(out, c)
+ }
+ }
+ return out
+ }
+ // What the controller runs on n1 for a module no want names (lemurs, at a build of the old form) and the private
+ // network's (built by nobody): its composed writer (mesh-controller compositionsOf) folds their resources into the
+ // node's own, each in its place, its module taken off; the rest split by module.
+ tunnelled := func() *bus {
+ b := one("keeper", "lemurs", overlay.Name)
+ p := b.placements["n1"]
+ p.Decided.Endpoint = "n1.example:51820"
+ b.placements["n1"] = p
+ return b
+ }
+ tunnelledAs := func() map[string]*delivery.Placement {
+ p := tunnelled().placements["n1"]
+ return map[string]*delivery.Placement{"n1": &p}
+ }
+ folded := func() []delivery.Composed {
+ b := tunnelled()
+ in, err := Input(ctx, b, Mesh{}, Scope{Node: "n1"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ reading := in
+ reading.Nodes = append([]compose.Node(nil), in.Nodes...)
+ reading.Nodes[0].ReadsModules = true
+ d, _, err := compose.ComposeIn(reading, "n1", compose.Reading)
+ if err != nil {
+ t.Fatal(err)
+ }
+ body, err := d.Body()
+ if err != nil {
+ t.Fatal(err)
+ }
+ var envelope struct {
+ Resources []map[string]json.RawMessage `json:"resources"`
+ }
+ if err := json.Unmarshal(body, &envelope); err != nil {
+ t.Fatal(err)
+ }
+ var resources []json.RawMessage
+ for _, r := range envelope.Resources {
+ var module string
+ _ = json.Unmarshal(r["module"], &module)
+ if module == "lemurs" || module == overlay.Name {
+ delete(r, "module")
+ }
+ raw, _ := json.Marshal(r)
+ resources = append(resources, raw)
+ }
+ split, err := delivery.SplitComposed("n1", resources, map[string]string{"keeper": oneFps["keeper"]}, nil, delivery.NodeOwn{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var out []delivery.Composed
+ for _, c := range split {
+ out = append(out, c)
+ }
+ return out
+ }()
+ if got := InOwn(folded, tunnelled().placements["n1"]); !reflect.DeepEqual(got, []string{"lemurs", overlay.Name}) {
+ t.Errorf("InOwn of the controller's composition = %v, want lemurs and %s", got, overlay.Name)
+ }
+ placedAs := func(modules ...string) map[string]*delivery.Placement {
+ p := one(modules...).placements["n1"]
+ return map[string]*delivery.Placement{"n1": &p}
+ }
+ for name, c := range map[string]struct {
+ b *bus
+ given map[string]*delivery.Placement
+ running []delivery.Composed
+ verdict string
+ check func(delivery.CandidateAnswer) bool
+ }{
+ "the same": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), runsNow, delivery.CandidateSame,
+ func(a delivery.CandidateAnswer) bool {
+ return len(a.Same) == 3 && a.Proposal == "p-h1" && a.Revision == 7
+ }},
+ "the node runs no own composition": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), withoutOwn(runsNow), delivery.CandidateSame,
+ func(a delivery.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }},
+ "one removed": {one("keeper", "lemurs"), placedAs("keeper"), runsNow, delivery.CandidateDiffers,
+ func(a delivery.CandidateAnswer) bool { return reflect.DeepEqual(a.Removed, []string{"lemurs"}) }},
+ "one added, its secret not yet issued": {func() *bus { b := one("lemurs"); b.own = nil; return b }(), placedAs("keeper", "lemurs"),
+ nil, delivery.CandidateDiffers, func(a delivery.CandidateAnswer) bool {
+ for _, m := range a.Added {
+ if m.Module == "keeper" {
+ return reflect.DeepEqual(m.Unissued, []string{"keeper/password"}) && len(a.Refusals) == 0
+ }
+ }
+ return false
+ }},
+ "a value missing for a module that runs": {func() *bus { b := one("keeper", "lemurs"); b.own = nil; return b }(), placedAs("keeper", "lemurs"),
+ runsNow, delivery.CandidateRefused, func(a delivery.CandidateAnswer) bool {
+ return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && strings.Contains(a.Refusals[0].Why, "keeper/password")
+ }},
+ // Review of L2b, round 1, finding 1: a consumer the candidate adds has no credential yet, and the vault refuses an
+ // ask for a module not on the node; it is not asked, and the module is added, its credential not yet issued.
+ "a consumer added, the vault not asked for it": {one("objects"), placedAs("objects", "files"), consumerRuns, delivery.CandidateDiffers,
+ func(a delivery.CandidateAnswer) bool {
+ return len(a.Added) == 1 && a.Added[0].Module == "files" && len(a.Added[0].Unissued) > 0 && len(a.Refusals) == 0
+ }},
+ // And with no trunk at all (the seed), a stand-in of a module the node runs is a refusal, not an addition.
+ "the seed, a value missing for a module that runs": {func() *bus { b := one(); b.own = nil; return b }(), placedAs("keeper", "lemurs"),
+ runsNow, delivery.CandidateRefused, func(a delivery.CandidateAnswer) bool {
+ return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && len(a.Added) == 0
+ }},
+ "cannot compose": {one("keeper"), map[string]*delivery.Placement{"n1": {Node: "n2"}}, runsNow, delivery.CandidateRefused,
+ func(a delivery.CandidateAnswer) bool { return a.Refused != "" }},
+ // Issue 1126 (the seed's review): the private network's module and one at a build of the old form, which no want
+ // names and the controller folds into the node's own, are folded there again: the node's own is the same, and
+ // neither is added.
+ "mesh-wireguard and an old-form module, folded": {tunnelled(), tunnelledAs(),
+ folded, delivery.CandidateSame, func(a delivery.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }},
+ } {
+ fps := oneFps
+ if name == "mesh-wireguard and an old-form module, folded" {
+ fps = map[string]string{"keeper": oneFps["keeper"]} // the node's wants: no build of lemurs or the private network
+ }
+ a := Answer(ctx, c.b, Mesh{}, Scope{Node: "n1", Placements: c.given, InOwn: InOwn(c.running, *c.given["n1"])}, fps, c.running, "p-h1", 7)
+ if a.Verdict != c.verdict || !c.check(a) {
+ t.Errorf("%s: %+v", name, a)
+ }
+ if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": a}}); err != nil {
+ t.Errorf("%s: the answer is not one a have holds: %v (%s)", name, err, raw)
+ }
+ }
+ if got, err := Placed(want, map[string]*delivery.Placement{"laptop": &laptop}); err != nil || !reflect.DeepEqual(got, want) {
+ t.Errorf("Placed with the node's own placement changes the input: %v", err)
+ }
+ if got, err := Placed(want, map[string]*delivery.Placement{"laptop": nil}); err != nil || len(got.Nodes[1].Assigned) != 0 || got.Nodes[1].Place.Key != keys["laptop"] {
+ t.Errorf("Placed with no placement: %+v, %v", got.Nodes[1], err)
+ }
+ if _, err := Placed(want, map[string]*delivery.Placement{"stranger": {Node: "stranger"}}); err == nil {
+ t.Error("Placed a node the input holds no node for")
+ }
+ if _, err := Input(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": {Node: "anchor"}}}); err == nil {
+ t.Error("a given placement naming another node was taken")
+ }
+
+ // ADR 0555 §6: Export is Input's inverse for what a person decides: the store's input exported, node by node, is the
+ // placement the bus holds, and read back it is the store's input again.
+ for node, placed := range mk().placements {
+ got, err := Export(want, node)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if gj, _ := delivery.EncodePlacement(got); !delivery.SamePlacement(got, placed) {
+ pj, _ := delivery.EncodePlacement(placed)
+ t.Errorf("%s exported as %s, want %s", node, gj, pj)
+ }
+ }
+ if _, err := Export(want, "stranger"); err == nil {
+ t.Error("a node the input lacks was exported")
+ }
+
+ b = mk()
+ if every, err := Input(ctx, b, Mesh{}, Scope{Every: true}); err != nil || every.OwnSecrets != nil {
+ t.Fatalf("composing every node: %v, own secrets %v (the vault answers a node about itself alone)", err, every.OwnSecrets)
+ }
+ if !reflect.DeepEqual(b.valuesRead, []string{"anchor." + overlay.Name, "laptop." + overlay.Name, "laptop.xterm", "laptop.media"}) {
+ // Every node's placed modules, and nothing placed nowhere: anchor places no xterm.
+ t.Errorf("the check read %v", b.valuesRead)
+ }
+
+ // A node none of whose modules declares an own secret composes without asking the vault (the review of L2: a node
+ // with no secret composed nothing while the vault did not answer); one whose module does, refuses without it.
+ quiet := mk()
+ quiet.ownErr = errors.New("no responders")
+ if _, err := Input(ctx, quiet, Mesh{}, Scope{Node: "anchor"}); err != nil {
+ t.Errorf("anchor, with no own secret, needed the vault: %v", err)
+ }
+ if _, err := Input(ctx, quiet, Mesh{}, Scope{Node: "laptop"}); err == nil {
+ t.Error("laptop's media, which declares an own secret, composed without the vault")
+ }
+
+ // A module the node's want keeps at another build than the trunk's composes from that build's manifest, resolved with
+ // what it made (issue 1126), on the composing node alone.
+ kept := mk()
+ oldXterm := []byte(`{"module":"xterm","version":"1","resources":[{"id":"tools","type":"process","name":"xterm-tools",` +
+ `"artifact":"tools","run":["./xterm-tools"]}],"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/xterm-tools","binary":"xterm-tools"}]}}`)
+ kept.manifests["old"] = oldXterm
+ oldFP := "src2:" + strings.Repeat("9", 64)
+ toolsRef := "artifact-store://xterm/tools/blobs/sha256:" + strings.Repeat("1", 64)
+ kept.builds = map[string]*delivery.BuilderBuild{"xterm " + oldFP: {State: delivery.BuildDone, Manifest: "old",
+ Artifacts: []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: toolsRef, Digest: "sha256:" + strings.Repeat("1", 64)}}}}
+ parsedOld, err := catalogue.Decode(oldXterm)
+ if err != nil {
+ t.Fatal(err)
+ }
+ keptBuilt, err := parsedOld.Resolve([]catalogue.Built{{Name: "tools", Kind: "bundle", Reference: toolsRef, Digest: "sha256:" + strings.Repeat("1", 64)}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ in, err := Input(ctx, kept, Mesh{}, Scope{Node: "laptop", KeptAt: map[string]string{"xterm": oldFP}})
+ if err != nil || !reflect.DeepEqual(in.Nodes[1].Kept, map[string]catalogue.Manifest{"xterm": keptBuilt}) || in.Nodes[0].Kept != nil {
+ t.Errorf("the want's build is kept on the composing node alone: %v, %v", in.Nodes, err)
+ }
+
+ // Issue 1126, the seed's refusals. restic and the provisioner's client as the catalogue holds them (mesh-catalog
+ // c05aa5e6), restic moved with an own secret the vault leaves out (it hands it to the client, mesh-vault ownnode.go
+ // handed): composed from the indexer's manifest restic is not handed and stands in for that secret, as novox's,
+ // ace's, g14's and shanks's answers refused; composed from its build, it is handed, its templates archive read
+ // where the node reaches the artifact store, and the composition is the one the controller's take-in gives.
+ resticRaw, err := os.ReadFile("testdata/catalogue-c05aa5e6-restic.module.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ clientRaw, err := os.ReadFile("testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ store := []byte(`{"module":"store","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],` +
+ `"serves":{"artifact-store":{"port":5000}}}`)
+ texts := map[string]string{}
+ var packed bytes.Buffer
+ zw := gzip.NewWriter(&packed)
+ tw := tar.NewWriter(zw)
+ for _, f := range []string{"backups.conf", "data.conf", "retention.conf"} {
+ text, err := os.ReadFile("testdata/catalogue-c05aa5e6-restic.files/" + f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ texts[f] = string(text)
+ if err := tw.WriteHeader(&tar.Header{Name: "./" + f, Mode: 0o644, Size: int64(len(text)), Typeflag: tar.TypeReg}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := tw.Write(text); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := tw.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if err := zw.Close(); err != nil {
+ t.Fatal(err)
+ }
+ archiveDigest := delivery.ManifestDigest(packed.Bytes())
+ digestOf := func(c string) string { return "sha256:" + strings.Repeat(c, 64) }
+ movedFps := map[string]string{"restic": "src2:" + strings.Repeat("3", 64), "mesh-provisioner-client": "src2:" + strings.Repeat("4", 64),
+ "store": "src2:" + strings.Repeat("5", 64)}
+ resticArtifacts := []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: "artifact-store://restic/tools/blobs/" + digestOf("6"), Digest: digestOf("6")},
+ {Name: "files", Kind: "archive", Reference: "artifact-store://restic/files/blobs/" + archiveDigest, Digest: archiveDigest}}
+ clientArtifacts := []delivery.BuiltArtifact{{Name: "client", Kind: "bundle", Reference: "artifact-store://mesh-provisioner-client/client/blobs/" + digestOf("7"), Digest: digestOf("7")}}
+ movedMesh := func(built bool) *bus {
+ b := one()
+ p := b.placements["n1"]
+ p.Modules = []delivery.PlacedModule{{Module: "store"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}
+ p.Decided.Account, p.Decided.AccountHome = "operator", "/home/operator"
+ b.placements["n1"] = p
+ rec := b.records["n1"]
+ rec.Contract, rec.Capabilities = compose.HandedContract, []string{"systemd", "package-manager"}
+ b.records["n1"] = rec
+ b.modules = map[string]delivery.Module{}
+ b.manifests = map[string][]byte{}
+ for module, raw := range map[string][]byte{"restic": resticRaw, "mesh-provisioner-client": clientRaw, "store": store} {
+ b.modules[module] = delivery.Module{FP2: movedFps[module], Manifest: delivery.ManifestDigest(raw)}
+ b.manifests[delivery.ManifestDigest(raw)] = raw
+ }
+ // The vault's own answer: the client's own secret; restic's is the client's to ask, so own leaves it out.
+ b.own = []compose.OwnSecretRow{{Node: "n1", Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}
+ if built {
+ b.builds = map[string]*delivery.BuilderBuild{
+ "restic " + movedFps["restic"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(resticRaw), Artifacts: resticArtifacts},
+ "mesh-provisioner-client " + movedFps["mesh-provisioner-client"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(clientRaw), Artifacts: clientArtifacts},
+ "store " + movedFps["store"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(store)},
+ }
+ b.archives = map[string][]byte{archiveDigest: packed.Bytes()}
+ }
+ return b
+ }
+ if out, _, held, err := Compose(ctx, movedMesh(false), Mesh{}, Scope{Node: "n1"}, movedFps); err != nil || !strings.Contains(held["restic"], `restic needs a secret called "repository"`) {
+ t.Errorf("restic composed from the indexer's manifest: %v %v, want its own secret refusing restic alone", err, held)
+ } else {
+ for key, c := range out {
+ if c.Module == "restic" {
+ // Its stub's composition, applied, would remove what restic runs: a held module keeps its last one.
+ t.Errorf("held restic is composed as %s, want none of its own composition handed", key)
+ }
+ }
+ }
+ builtBus := movedMesh(true)
+ fromBuild, _, held, err := Compose(ctx, builtBus, Mesh{}, Scope{Node: "n1"}, movedFps)
+ if err == nil && len(held) > 0 {
+ err = fmt.Errorf("modules held: %v", held)
+ }
+ if err != nil {
+ t.Fatalf("restic composed from its build: %v", err)
+ }
+ if !reflect.DeepEqual(builtBus.fetched, []string{"http://127.0.0.1:5000/v2/restic/files/blobs/" + archiveDigest}) {
+ t.Errorf("the templates archive was read at %v", builtBus.fetched)
+ }
+ resticKey, _ := delivery.ComposedKey("n1", "restic", movedFps["restic"])
+ if c := fromBuild[resticKey]; len(c.Filled) != 4 { // its three templated files and its own secret
+ t.Errorf("restic is not handed to the provisioner: its filled files are %v", c.Filled)
+ }
+ // A done key an artifact of which names no reference is waited for, in the controller's words (ADR 0498 §8).
+ unreferenced := movedMesh(true)
+ unreferenced.builds["restic "+movedFps["restic"]].Artifacts = []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Digest: digestOf("6")}, resticArtifacts[1]}
+ if _, _, held, err := Compose(ctx, unreferenced, Mesh{}, Scope{Node: "n1"}, movedFps); err != nil || !strings.Contains(held["restic"], "the build of restic is done and waits for the reference of tools") {
+ t.Errorf("a done key with an unreferenced artifact: %v %v, want restic held alone", err, held)
+ }
+ // A module the node runs inside its own composition (the fold) composes from the indexer's manifest though a done
+ // build of it is at the trunk's FP2: its build is not read.
+ folding := movedMesh(true)
+ foldIn, err := Input(ctx, folding, Mesh{}, Scope{Node: "n1", InOwn: []string{"mesh-provisioner-client"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ clientParsed, err := catalogue.Decode(clientRaw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if slices.Contains(folding.buildsRead, "mesh-provisioner-client") || !reflect.DeepEqual(foldIn.Catalogue["mesh-provisioner-client"], clientParsed) {
+ t.Errorf("a folded module was taken from its build: builds read %v", folding.buildsRead)
+ }
+ // The controller's: its take-in resolves each manifest with the key's artifacts and the archive's texts, and composes
+ // from those over the same input.
+ storeIn, err := Input(ctx, movedMesh(false), Mesh{}, Scope{Node: "n1"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for module, built := range map[string][]catalogue.Built{
+ "restic": {{Name: "tools", Kind: "bundle", Reference: resticArtifacts[0].Reference, Digest: digestOf("6")},
+ {Name: "files", Kind: "archive", Reference: resticArtifacts[1].Reference, Digest: archiveDigest, Texts: texts}},
+ "mesh-provisioner-client": {{Name: "client", Kind: "bundle", Reference: clientArtifacts[0].Reference, Digest: digestOf("7")}},
+ } {
+ resolved, err := storeIn.Catalogue[module].Resolve(built)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Kept as its bytes and read again, as the controller's store holds a build's manifest.
+ raw, _ := json.Marshal(resolved)
+ if storeIn.Catalogue[module], err = catalogue.Decode(raw); err != nil {
+ t.Fatal(err)
+ }
+ }
+ storeIn.OwnSecrets = movedMesh(true).own
+ fromStoreMoved, _, err := compose.Compositions(storeIn, "n1", movedFps, compose.Reading)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(fromBuild, fromStoreMoved) {
+ gj, _ := json.MarshalIndent(fromBuild, "", " ")
+ wj, _ := json.MarshalIndent(fromStoreMoved, "", " ")
+ t.Errorf("composed from the build differs from the controller's take-in:\n got %s\nwant %s", gj, wj)
+ }
+ // ADR 0570 clause 2 (issue 1954 L6-P1): the seat mesh-provisioner's plans, composed from the bus alone and with no
+ // secret read (the Planning mode), are the controller's handedPlans over its own input, which holds its secrets
+ // (compose_sdk.go: Resolve, Render in Foreseeing, Generators, Plan.Compose), and restic, handed, has one.
+ noSecrets := movedMesh(true)
+ noSecrets.own = nil
+ seat, err := Plans(ctx, noSecrets, Mesh{}, Scope{Node: "n1"})
+ if err != nil {
+ t.Fatalf("the plans from the bus: %v", err)
+ }
+ controllers, err := plansIn(storeIn, "n1", compose.Foreseeing)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, handed := seat["restic"]; !handed || !reflect.DeepEqual(seat, controllers) {
+ gj, _ := json.MarshalIndent(seat, "", " ")
+ wj, _ := json.MarshalIndent(controllers, "", " ")
+ t.Errorf("the seat's plans differ from the controller's:\n got %s\nwant %s", gj, wj)
+ }
+ // ADR 0571 clause 3: the data items of n1's modules, placed, from the bus alone: what disk-load measures.
+ items, unplaced, err := DataItems(ctx, noSecrets, Mesh{}, Scope{Node: "n1"})
+ if err != nil || len(unplaced) > 0 || len(items) == 0 {
+ t.Errorf("the data items from the bus are %v, unplaced %v: %v", items, unplaced, err)
+ }
+ for module, its := range items {
+ for _, it := range its {
+ if it.Module != module || !strings.HasPrefix(it.Path, "/") || it.Measure == "" {
+ t.Errorf("%s's item %+v is not placed", module, it)
+ }
+ }
+ }
+ // And the same through StatesReader, over the fake's states as a module's runtime gives them (one reader for
+ // mesh-provisioner and disk-load, the lead 2026-10-29).
+ viaStates, _, err := DataItems(ctx, StatesReader(statesOf(t, ctx, noSecrets), func(_ context.Context, url string) ([]byte, error) {
+ for _, body := range noSecrets.archives {
+ return body, nil
+ }
+ return nil, errors.New("no archive")
+ }), Mesh{}, Scope{Node: "n1"})
+ if err != nil || !reflect.DeepEqual(viaStates, items) {
+ t.Errorf("through the states the data items are %v, %v; through the reader %v", viaStates, err, items)
+ }
+
+ // And what a provider on another node serves from its settings (mailu's smtp domain, issue 1126: shanks refused
+ // '${setting:domain} ... nothing sets "domain"'): read for that provider's served keys alone, through Served.
+ mailer := []byte(`{"module":"mailer","version":"1","config":{"domain":{"type":"string","description":"the mail domain","required":true}},` +
+ `"provides":[{"name":"smtp","scope":"mesh"}],"serves":{"smtp":{"port":587,"domain":"${config:domain}"}}}`)
+ withMailer := func(set bool) *bus {
+ b := mk()
+ p := b.placements["anchor"]
+ p.Modules = append(p.Modules, delivery.PlacedModule{Module: "mailer"})
+ b.placements["anchor"] = p
+ b.modules["mailer"] = delivery.Module{Manifest: delivery.ManifestDigest(mailer)}
+ b.manifests[delivery.ManifestDigest(mailer)] = mailer
+ if set {
+ b.served = map[string]map[string]any{"anchor.mailer": {"domain": "example.org"}}
+ }
+ return b
+ }
+ if _, _, _, err := Compose(ctx, withMailer(false), Mesh{}, Scope{Node: "laptop"}, fps); err == nil || !strings.Contains(err.Error(), `nothing sets "domain"`) {
+ t.Errorf("a provider's served setting nobody sets: %v", err)
+ }
+ served := withMailer(true)
+ if _, _, held, err := Compose(ctx, served, Mesh{}, Scope{Node: "laptop"}, fps); err != nil || len(held) > 0 {
+ t.Errorf("laptop with what anchor's mailer serves: %v %v", err, held)
+ }
+ if !reflect.DeepEqual(served.servedRead, []string{"anchor.mailer:domain"}) || slices.Contains(served.valuesRead, "anchor.mailer") {
+ t.Errorf("laptop read %v of other nodes (and %v of config values): the served keys of anchor's mailer alone", served.servedRead, served.valuesRead)
+ }
+
+ for name, c := range map[string]struct {
+ change func(*bus)
+ scope Scope
+ says string
+ }{
+ "no record": {func(b *bus) { delete(b.records, "laptop") }, Scope{Node: "anchor"}, "laptop, whose agent has written no record"},
+ "no placement": {func(b *bus) {}, Scope{Node: "stranger"}, "stranger has no placement"},
+ "a manifest unread": {func(b *bus) { delete(b.manifests, digest) }, Scope{Node: "laptop"}, "the manifest of xterm"},
+ "a kept one unbuilt": {func(b *bus) {}, Scope{Node: "laptop", KeptAt: map[string]string{"xterm": "src2:gone"}}, "holds no done build"},
+ } {
+ b := mk()
+ c.change(b)
+ if _, err := Input(ctx, b, Mesh{}, c.scope); err == nil || !strings.Contains(err.Error(), c.says) {
+ t.Errorf("%s: %v, want it to say %q", name, err, c.says)
+ }
+ }
+}
+
+// mapStates are read states by name, key to value, as a module's runtime gives them.
+type mapStates map[string]map[string][]byte
+
+func (m mapStates) Get(state, key string) ([]byte, bool, error) {
+ v, ok := m[state][key]
+ return v, ok, nil
+}
+
+func (m mapStates) Keys(state string) ([]string, error) {
+ var out []string
+ for k := range m[state] {
+ out = append(out, k)
+ }
+ sort.Strings(out)
+ return out, nil
+}
+
+// statesOf is b laid out as the states StatesReader reads.
+func statesOf(t *testing.T, ctx context.Context, b *bus) mapStates {
+ t.Helper()
+ out := mapStates{}
+ put := func(state, key string, raw []byte, err error) {
+ if err != nil {
+ t.Fatal(err)
+ }
+ if out[state] == nil {
+ out[state] = map[string][]byte{}
+ }
+ out[state][key] = raw
+ }
+ placements, _ := b.Placements(ctx)
+ for _, p := range placements {
+ values, err := delivery.PlacementValues(p)
+ for k, raw := range values {
+ put(PlacementRead, k, raw, err)
+ }
+ }
+ records, _ := b.Records(ctx)
+ for node, r := range records {
+ k, _ := delivery.HaveNodeKey(node)
+ rec := r
+ raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec})
+ put(HaveRead, k, raw, err)
+ }
+ modules, _ := b.Modules(ctx)
+ for name, m := range modules {
+ raw, err := delivery.EncodeModule(m)
+ put(ModulesRead, name, raw, err)
+ var mf struct{ Module string }
+ _ = json.Unmarshal(b.manifests[m.Manifest], &mf)
+ k, _ := delivery.ManifestsKey(m.Manifest)
+ raw, err = delivery.EncodeManifest(delivery.Manifest{Module: mf.Module, JSON: string(b.manifests[m.Manifest])})
+ put(ManifestsRead, k, raw, err)
+ }
+ for key, build := range b.builds {
+ module, fp, _ := strings.Cut(key, " ")
+ k, _ := delivery.BuilderBuildKey(module, fp)
+ raw, err := delivery.EncodeBuilderBuild(*build)
+ put(BuildsRead, k, raw, err)
+ }
+ for nm, values := range b.values {
+ for k, v := range values {
+ value, _ := json.Marshal(v)
+ raw, err := json.Marshal(map[string]any{"value": json.RawMessage(value), "source": catalogue.SourceModule})
+ put(ResolvedRead, nm+"."+k, raw, err)
+ }
+ }
+ return out
+}
diff --git a/internal/compose/frombus/frombustest/frombustest.go b/internal/compose/frombus/frombustest/frombustest.go
new file mode 100644
index 0000000..a627e30
--- /dev/null
+++ b/internal/compose/frombus/frombustest/frombustest.go
@@ -0,0 +1,183 @@
+// Package frombustest is a bus in memory for frombus's Reader, and a mesh on it whose composition hands a module to the
+// provisioner with a provision to make, so a module that composes plans from the bus (mesh-provisioner, novox/hq ADR
+// 0570 clause 2) tests against a real plan rather than one written by hand. The manifests are the catalogue's (mesh-catalog
+// c05aa5e6: restic and the provisioner's client) as frombus's own tests read them, restic given one requirement more.
+package frombustest
+
+import (
+ "archive/tar"
+ "bytes"
+ "compress/gzip"
+ "context"
+ "embed"
+ "encoding/json"
+ "errors"
+ "strings"
+
+ "git.novox.be/novox/mesh-sdk/go/delivery"
+ "github.com/novox/mesh-host/internal/compose"
+ "github.com/novox/mesh-host/internal/compose/frombus"
+)
+
+//go:embed testdata
+var testdata embed.FS
+
+// Bus is a frombus.Reader over values held in memory. A credential asked is answered held when Held is set.
+type Bus struct {
+ Placement map[string]delivery.Placement
+ Record map[string]delivery.NodeRecord
+ Module map[string]delivery.Module
+ Manifests map[string][]byte
+ Value map[string]map[string]any // by node.module
+ Own []compose.OwnSecretRow
+ Builds map[string]*delivery.BuilderBuild // by ""
+ Archives map[string][]byte // by digest
+ Serves map[string]map[string]any // another node's values, by node.module
+ Held bool
+}
+
+var _ frombus.Reader = (*Bus)(nil)
+
+func (b *Bus) Placements(context.Context) (map[string]delivery.Placement, error) {
+ return b.Placement, nil
+}
+func (b *Bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.Record, nil }
+func (b *Bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.Module, nil }
+func (b *Bus) VaultAuthority(context.Context) string { return "" }
+
+func (b *Bus) Manifest(_ context.Context, digest string) ([]byte, error) {
+ raw, ok := b.Manifests[digest]
+ if !ok {
+ return nil, errors.New("no such manifest")
+ }
+ return raw, nil
+}
+
+func (b *Bus) Values(_ context.Context, node, module string) (map[string]any, error) {
+ return b.Value[node+"."+module], nil
+}
+
+func (b *Bus) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) { return b.Own, nil }
+
+func (b *Bus) Credentials(_ context.Context, _ string, asConsumer, asProvider []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) {
+ var out []compose.Credential
+ var issued []compose.Issued
+ for _, k := range append(append([]compose.CredentialKey{}, asConsumer...), asProvider...) {
+ if b.Held {
+ out = append(out, compose.Credential{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule,
+ Provider: k.Provider, Local: k.Local, State: compose.CredentialHeld, Sealed: `"box"`})
+ issued = append(issued, compose.Issued{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule,
+ Provider: k.Provider, Local: k.Local, ForProvider: `"box"`})
+ }
+ }
+ return out, issued, nil
+}
+
+func (b *Bus) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) {
+ return b.Builds[module+" "+fp], nil
+}
+
+func (b *Bus) Archive(_ context.Context, _, digest string) ([]byte, error) {
+ body, ok := b.Archives[digest]
+ if !ok {
+ return nil, errors.New("the store holds no such blob")
+ }
+ return body, nil
+}
+
+func (b *Bus) Served(_ context.Context, node, module string, keys []string) (map[string]any, error) {
+ out := map[string]any{}
+ for _, k := range keys {
+ if v, ok := b.Serves[node+"."+module][k]; ok {
+ out[k] = v
+ }
+ }
+ return out, nil
+}
+
+// Node is the one node of the mesh Provisioned composes.
+const Node = "n1"
+
+// Provisioned is one node, n1, that runs the artifact store, a postgres providing postgres-database, the provisioner's
+// client and restic: restic built and handed to the provisioner, and requiring postgres-database. Its plans (frombus.Plans
+// with Scope{Node: Node}) hand restic, whose plan names that provision with postgres on n1 as its provider.
+func Provisioned() (*Bus, error) {
+ read := func(name string) ([]byte, error) { return testdata.ReadFile("testdata/" + name) }
+ resticRaw, err := read("catalogue-c05aa5e6-restic.module.json")
+ if err != nil {
+ return nil, err
+ }
+ var restic map[string]any
+ if err := json.Unmarshal(resticRaw, &restic); err != nil {
+ return nil, err
+ }
+ restic["requires"] = []string{"postgres-database"}
+ if resticRaw, err = json.Marshal(restic); err != nil {
+ return nil, err
+ }
+ clientRaw, err := read("catalogue-c05aa5e6-mesh-provisioner-client.module.json")
+ if err != nil {
+ return nil, err
+ }
+ store := []byte(`{"module":"store","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],` +
+ `"serves":{"artifact-store":{"port":5000}}}`)
+ postgres := []byte(`{"module":"postgres","version":"1","provides":[{"name":"postgres-database","scope":"mesh"}],` +
+ `"serves":{"postgres-database":{"port":5432}}}`)
+
+ var packed bytes.Buffer
+ zw := gzip.NewWriter(&packed)
+ tw := tar.NewWriter(zw)
+ for _, f := range []string{"backups.conf", "data.conf", "retention.conf"} {
+ text, err := read("catalogue-c05aa5e6-restic.files/" + f)
+ if err != nil {
+ return nil, err
+ }
+ if err := tw.WriteHeader(&tar.Header{Name: "./" + f, Mode: 0o644, Size: int64(len(text)), Typeflag: tar.TypeReg}); err != nil {
+ return nil, err
+ }
+ if _, err := tw.Write(text); err != nil {
+ return nil, err
+ }
+ }
+ if err := tw.Close(); err != nil {
+ return nil, err
+ }
+ if err := zw.Close(); err != nil {
+ return nil, err
+ }
+ archive := delivery.ManifestDigest(packed.Bytes())
+ digestOf := func(c string) string { return "sha256:" + strings.Repeat(c, 64) }
+ fps := FPs()
+ b := &Bus{
+ Placement: map[string]delivery.Placement{Node: {Node: Node,
+ Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Account: "operator", AccountHome: "/home/operator"},
+ Modules: []delivery.PlacedModule{{Module: "store"}, {Module: "postgres"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}}},
+ Record: map[string]delivery.NodeRecord{Node: {Node: Node, TunnelKey: "dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q=",
+ SealingKey: "sk", Contract: compose.HandedContract, Capabilities: []string{"systemd", "package-manager"}}},
+ Module: map[string]delivery.Module{}, Manifests: map[string][]byte{},
+ Own: []compose.OwnSecretRow{{Node: Node, Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}},
+ Builds: map[string]*delivery.BuilderBuild{
+ "restic " + fps["restic"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(resticRaw), Artifacts: []delivery.BuiltArtifact{
+ {Name: "tools", Kind: "bundle", Reference: "artifact-store://restic/tools/blobs/" + digestOf("6"), Digest: digestOf("6")},
+ {Name: "files", Kind: "archive", Reference: "artifact-store://restic/files/blobs/" + archive, Digest: archive}}},
+ "mesh-provisioner-client " + fps["mesh-provisioner-client"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(clientRaw),
+ Artifacts: []delivery.BuiltArtifact{{Name: "client", Kind: "bundle",
+ Reference: "artifact-store://mesh-provisioner-client/client/blobs/" + digestOf("7"), Digest: digestOf("7")}}},
+ "store " + fps["store"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(store)},
+ "postgres " + fps["postgres"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(postgres)},
+ },
+ Archives: map[string][]byte{archive: packed.Bytes()},
+ Held: true,
+ }
+ for module, raw := range map[string][]byte{"restic": resticRaw, "mesh-provisioner-client": clientRaw, "store": store, "postgres": postgres} {
+ b.Module[module] = delivery.Module{FP2: fps[module], Manifest: delivery.ManifestDigest(raw)}
+ b.Manifests[delivery.ManifestDigest(raw)] = raw
+ }
+ return b, nil
+}
+
+// FPs are the fingerprints Provisioned's modules are built at.
+func FPs() map[string]string {
+ return map[string]string{"restic": "src2:" + strings.Repeat("3", 64), "mesh-provisioner-client": "src2:" + strings.Repeat("4", 64),
+ "store": "src2:" + strings.Repeat("5", 64), "postgres": "src2:" + strings.Repeat("8", 64)}
+}
diff --git a/internal/compose/frombus/frombustest/frombustest_test.go b/internal/compose/frombus/frombustest/frombustest_test.go
new file mode 100644
index 0000000..e6a0311
--- /dev/null
+++ b/internal/compose/frombus/frombustest/frombustest_test.go
@@ -0,0 +1,272 @@
+package frombustest
+
+import (
+ "context"
+ "reflect"
+ "strings"
+ "testing"
+
+ stdio "git.novox.be/novox/mesh-sdk/go"
+ "git.novox.be/novox/mesh-sdk/go/catalogue"
+ "git.novox.be/novox/mesh-sdk/go/delivery"
+ "github.com/novox/mesh-host/internal/compose/frombus"
+ "github.com/novox/mesh-host/internal/compose/fromruntime"
+)
+
+// ADR 0570 clause 2 (issue 1954; a3's H5): the in-memory mesh composes a plan from the bus that hands restic to the
+// provisioner and names its postgres-database, provided by postgres on n1. And one bad module must not drop its node
+// (issue 774): with a module placed beside it whose built manifest cannot be used, or whose plan refuses, PlansHeld
+// still hands restic's plan and names the bad one with why, where Plans refuses the whole node. A manifest the indexer
+// holds that the mesh refuses (power's bar item with attributes its bar places by none of, mesh-catalog ca8d6f33,
+// 2026-10-30) holds that module alone on its node, in the plans and in the node's composition (Compose), and on a node
+// it is not placed on holds nothing.
+func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
+ fp := "src2:" + strings.Repeat("9", 64)
+ for _, row := range []struct {
+ name string
+ bad string // the indexer's manifest of a module "backup" placed on n1; empty places none
+ // built is the manifest backup's done build names; empty gives it no build
+ built string
+ why string // words backup's hold says
+ // elsewhere leaves backup in the indexer's modules and places it on no node: nothing is held.
+ elsewhere bool
+ // refused is the indexer's verdict on bad, a problem its judge found (novox/hq ADR 0578 §1): Plans then holds
+ // backup too, and never ends the read.
+ refused string
+ // dataHeld says the data items' read holds backup too: a refusal it meets (a refused manifest, an unusable build,
+ // an item composition refuses), not one only a plan meets.
+ dataHeld bool
+ // dataOnly checks the data items' read alone: the other reads blame restic, whose templates collect the item.
+ dataOnly bool
+ }{
+ {name: "every module composes"},
+ {name: "a built manifest that cannot be used",
+ bad: `{"module":"backup","version":"1","build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`,
+ built: `{"module":"backup","version":"1","requires":"amqp"}`, why: "backup", dataHeld: true},
+ {name: "a plan that refuses",
+ bad: `{"module":"backup","version":"1","resources":[{"id":"conf","type":"file","path":"/etc/backup.conf",` +
+ `"mode":"0600","content":"key=${secret:nope}"}]}`, why: "${secret:nope}"},
+ {name: "an indexer's manifest the mesh refuses", bad: unusableItem, why: `"shows" is none of them`,
+ refused: `"shows" is none of them`, dataHeld: true},
+ // ADR 0578 §3: an accepted manifest whose data item its composition refuses is held by the culprit rule, in every read.
+ {name: "a data item its composition refuses",
+ bad: `{"module":"backup","version":"1","data":{"own":[{"id":"d","path":"${dir:nope}","class":"cache"}]}}`,
+ why: "nope", dataHeld: true, dataOnly: true},
+ {name: "an indexer's manifest the mesh refuses, placed elsewhere", bad: unusableItem, elsewhere: true,
+ refused: `"shows" is none of them`},
+ } {
+ b, err := Provisioned()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if row.bad != "" {
+ if !row.elsewhere {
+ p := b.Placement[Node]
+ p.Modules = append(p.Modules, delivery.PlacedModule{Module: "backup"})
+ b.Placement[Node] = p
+ }
+ digest := delivery.ManifestDigest([]byte(row.bad))
+ b.Manifests[digest] = []byte(row.bad)
+ b.Module["backup"] = delivery.Module{FP2: fp, Manifest: digest}
+ if row.refused != "" {
+ b.Module["backup"] = delivery.Module{FP2: fp, Manifest: digest, Verdict: delivery.VerdictRefused,
+ Problems: []string{row.refused}}
+ }
+ if row.built != "" {
+ builtDigest := delivery.ManifestDigest([]byte(row.built))
+ b.Manifests[builtDigest] = []byte(row.built)
+ b.Builds["backup "+fp] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: builtDigest,
+ Artifacts: []delivery.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://backup/files/blobs/x", Digest: "sha256:x"}}}
+ }
+ if row.dataOnly {
+ items, _, iheld, err := frombus.DataItemsHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
+ if why, ok := iheld["backup"]; err != nil || !ok || !strings.Contains(why, row.why) || len(items["restic"]) == 0 {
+ t.Errorf("%s: data items %v held %v: %v", row.name, items, iheld, err)
+ }
+ continue
+ }
+ plans, err := frombus.Plans(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
+ switch {
+ case row.refused != "" && (err != nil || !planned(plans, "restic") || planned(plans, "backup")):
+ t.Errorf("%s: Plans did not hold the refused manifest alone: %v %v", row.name, plans, err)
+ case row.refused == "" && err == nil && !row.elsewhere:
+ t.Errorf("%s: Plans composed the node; the row does not refuse it", row.name)
+ }
+ }
+ if row.why != "" || row.bad == "" {
+ // The node's own composition holds by the same rule: every other module composed, the bad one named.
+ fps := FPs()
+ if row.bad != "" {
+ fps["backup"] = fp
+ }
+ out, _, held, err := frombus.Compose(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}, fps)
+ if err != nil {
+ t.Fatalf("%s: the node's composition: %v", row.name, err)
+ }
+ if key, _ := delivery.ComposedKey(Node, "restic", fps["restic"]); out[key].Module != "restic" {
+ t.Errorf("%s: restic is not composed (held %v)", row.name, held)
+ }
+ if why, ok := held["backup"]; (row.bad == "" && len(held) > 0) || (row.bad != "" && (!ok || !strings.Contains(why, row.why) || len(held) != 1)) {
+ t.Errorf("%s: the node's composition held %v, want backup alone, saying %q", row.name, held, row.why)
+ }
+ }
+ plans, held, err := frombus.PlansHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
+ if err != nil {
+ t.Fatalf("%s: %v", row.name, err)
+ }
+ restic, handed := plans["restic"]
+ if !handed || len(restic.Provisions) != 1 || restic.Provisions[0].Provision != "postgres-database" ||
+ restic.Provisions[0].Provider != Node || restic.Provisions[0].ProviderModule != "postgres" {
+ t.Fatalf("%s: the plans are %+v (held %v)", row.name, plans, held)
+ }
+ // Issue 1954: the plans at the wanted builds and at the newest, over one read, are what PlansHeld answers for each,
+ // and where the time went is said by phase.
+ keptAt := FPs()
+ at, newest, took := frombus.PlansHeldTwice(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node, KeptAt: keptAt})
+ wantAt, heldAt, errAt := frombus.PlansHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node, KeptAt: keptAt})
+ if !reflect.DeepEqual(at, frombus.HeldPlans{Plans: wantAt, Held: heldAt, Err: errAt}) ||
+ !reflect.DeepEqual(newest, frombus.HeldPlans{Plans: plans, Held: held}) {
+ t.Errorf("%s: twice composed %+v and %+v, want %v %v %v and %v %v", row.name, at, newest, wantAt, heldAt, errAt, plans, held)
+ }
+ if said := frombus.PhasesSaid(took); !strings.HasPrefix(said, "read ") || !strings.Contains(said, "plans at newest ") {
+ t.Errorf("%s: the phases are said as %q", row.name, said)
+ }
+ if row.bad == "" || row.elsewhere {
+ if len(held) > 0 {
+ t.Errorf("%s: held %v", row.name, held)
+ }
+ continue
+ }
+ if why, ok := held["backup"]; !ok || !strings.Contains(why, row.why) || len(held) != 1 {
+ t.Errorf("%s: held %v, want backup alone, saying %q", row.name, held, row.why)
+ }
+ if _, planned := plans["backup"]; planned {
+ t.Errorf("%s: the held module has a plan", row.name)
+ }
+ // The data items are read by the same rule (ADR 0578 §3: disk-load's read): the bad module held, the read whole.
+ items, _, iheld, err := frombus.DataItemsHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
+ // A refusal only a plan meets (a file's secret) holds nothing here: data items render no such file.
+ mustHold := row.dataHeld
+ if why, ok := iheld["backup"]; err != nil || ok != mustHold || mustHold && !strings.Contains(why, row.why) || items["backup"] != nil {
+ t.Errorf("%s: data items %v held %v: %v", row.name, items, iheld, err)
+ }
+ }
+
+ // ADR 0580 clause 1: the watched reader reads a state it composes from out of the copy, a build by key each time it is
+ // asked, and a manifest once per digest.
+ byKey := map[string]int{}
+ ws := stdio.WatchedStatesOver(func(state string, handle func(stdio.StateChange) error) error {
+ byKey[state+" watched"]++
+ return handle(stdio.StateChange{Key: "placement." + Node, Op: stdio.OpPut, Value: []byte(`{}`), Current: true})
+ }, func(state, key string) ([]byte, bool, error) { byKey[state]++; return []byte(key), true, nil })
+ w := fromruntime.Watched{WatchedStates: ws}
+ for range 2 {
+ _, _, _ = w.Get(frombus.BuildsRead, "restic."+fp)
+ _, _, _ = w.Get(frombus.ManifestsRead, "sha256-d")
+ if _, found, _ := w.Get(frombus.PlacementRead, "placement."+Node); !found {
+ t.Error("the watched reader does not answer the placement from its copy")
+ }
+ }
+ if byKey[frombus.BuildsRead] != 2 || byKey[frombus.ManifestsRead] != 1 || byKey[frombus.PlacementRead] != 0 ||
+ byKey[frombus.PlacementRead+" watched"] != 1 {
+ t.Errorf("the watched reader read %v, want a build by key each time, a manifest once, the placement watched once", byKey)
+ }
+ fetched := 0
+ keep := fromruntime.KeptFetch(func(context.Context, string) ([]byte, error) { fetched++; return []byte("tar"), nil })
+ for range 2 {
+ if body, err := keep(context.Background(), "http://store/restic/files/blobs/sha256:a"); err != nil || string(body) != "tar" {
+ t.Fatalf("the kept fetch answered %q, %v", body, err)
+ }
+ }
+ if fetched != 1 {
+ t.Errorf("a templates archive was fetched %d times, want once", fetched)
+ }
+
+ // Issue 1954: the states reader takes a build in once per its content: composed again, its archive is not read again
+ // and the plans are the same.
+ b, err := Provisioned()
+ if err != nil {
+ t.Fatal(err)
+ }
+ reader, archives := frombus.StatesReader(statesOf(t, b), func(_ context.Context, url string) ([]byte, error) {
+ fetched++
+ _, digest, _ := strings.Cut(url, "/blobs/")
+ return b.Archives[digest], nil
+ }), fetched
+ var first map[string]catalogue.Plan
+ for i := range 2 {
+ plans, held, err := frombus.PlansHeld(context.Background(), reader, frombus.Mesh{}, frombus.Scope{Node: Node})
+ if err != nil || len(held) > 0 || (i == 1 && !reflect.DeepEqual(plans, first)) || !planned(plans, "restic") {
+ t.Fatalf("composed %v (held %v): %v", plans, held, err)
+ }
+ first = plans
+ }
+ if fetched-archives != 1 {
+ t.Errorf("restic's archive was read %d times over two compositions, want once", fetched-archives)
+ }
+}
+
+// statesOf is the bus b as a module's read states hold it.
+func statesOf(t *testing.T, b *Bus) frombus.States {
+ t.Helper()
+ s := states{}
+ put := func(state, key string, raw []byte, err error) {
+ if err != nil {
+ t.Fatal(err)
+ }
+ if s[state] == nil {
+ s[state] = map[string][]byte{}
+ }
+ s[state][key] = raw
+ }
+ for node, p := range b.Placement {
+ raw, err := delivery.EncodePlacement(p)
+ put(frombus.PlacementRead, "placement."+node, raw, err)
+ }
+ for node, rec := range b.Record {
+ raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec})
+ put(frombus.HaveRead, node+".node", raw, err)
+ }
+ for module, m := range b.Module {
+ key, _ := delivery.ModulesKey(module)
+ raw, err := delivery.EncodeModule(m)
+ put(frombus.ModulesRead, key, raw, err)
+ }
+ for digest, man := range b.Manifests {
+ key, _ := delivery.ManifestsKey(digest)
+ raw, err := delivery.EncodeManifest(delivery.Manifest{Module: "m", JSON: string(man)})
+ put(frombus.ManifestsRead, key, raw, err)
+ }
+ for at, build := range b.Builds {
+ module, fp, _ := strings.Cut(at, " ")
+ key, _ := delivery.BuilderBuildKey(module, fp)
+ raw, err := delivery.EncodeBuilderBuild(*build)
+ put(frombus.BuildsRead, key, raw, err)
+ }
+ return s
+}
+
+// states are read states in memory.
+type states map[string]map[string][]byte
+
+func (s states) Get(state, key string) ([]byte, bool, error) {
+ v, ok := s[state][key]
+ return v, ok, nil
+}
+
+func (s states) Keys(state string) ([]string, error) {
+ var keys []string
+ for k := range s[state] {
+ keys = append(keys, k)
+ }
+ return keys, nil
+}
+
+func planned[P any](plans map[string]P, module string) bool { _, ok := plans[module]; return ok }
+
+// unusableItem is a manifest the mesh refuses as the node-engine did power's at mesh-catalog ca8d6f33: a bar block item
+// with attributes its bar places by none of.
+const unusableItem = `{"module":"backup","version":"1","moved":true,` +
+ `"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},` +
+ `"items":[{"collection":"contribution:node-bar:block",` +
+ `"attributes":{"bar":"bottom","place":"status","shows":"state","state":"power.draw"},"template":"files/bar"}]}`
diff --git a/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json b/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json
new file mode 100644
index 0000000..095128a
--- /dev/null
+++ b/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json
@@ -0,0 +1,60 @@
+{
+ "module": "mesh-provisioner-client",
+ "version": "1",
+ "claims": [
+ {
+ "name": "node-provisioner",
+ "scope": "node"
+ }
+ ],
+ "own-secrets": {
+ "provisioner-broker": "${dir:state}/broker"
+ },
+ "secrets-owner": "mesh-provisioner-client",
+ "resources": [
+ {
+ "id": "account",
+ "type": "user",
+ "name": "mesh-provisioner-client",
+ "shell": "/usr/bin/nologin",
+ "home": "/var/lib/mesh-provisioner-client"
+ },
+ {
+ "id": "state",
+ "type": "directory",
+ "mode": "0700",
+ "owner": "mesh-provisioner-client",
+ "place": ".",
+ "unknown": "program"
+ },
+ {
+ "id": "client",
+ "type": "process",
+ "name": "mesh-provisioner-client",
+ "artifact": "client",
+ "run": [
+ "./mesh-provisioner-client"
+ ],
+ "env": {
+ "MESH_PROVISIONER_CREDENTIAL": "${dir:state}/broker",
+ "MESH_PROVISIONER_STATE": "${dir:state}"
+ },
+ "user": "mesh-provisioner-client",
+ "health": {
+ "kind": "unit"
+ }
+ }
+ ],
+ "build": {
+ "artifacts": [
+ {
+ "name": "client",
+ "kind": "bundle",
+ "language": "go",
+ "system": "arch",
+ "from": "cmd/mesh-provisioner-client",
+ "binary": "mesh-provisioner-client"
+ }
+ ]
+ }
+}
diff --git a/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/backups.conf b/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/backups.conf
new file mode 100644
index 0000000..2f23f90
--- /dev/null
+++ b/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/backups.conf
@@ -0,0 +1,2 @@
+# What the modules on this machine back up, composed by the mesh (novox/hq to-be 43). Do not edit.
+${backup:lines}
\ No newline at end of file
diff --git a/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/data.conf b/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/data.conf
new file mode 100644
index 0000000..f19e849
--- /dev/null
+++ b/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/data.conf
@@ -0,0 +1,2 @@
+# The data the modules on this machine declare, composed by the mesh (novox/hq ADR 0233). Do not edit.
+${backup:data}
\ No newline at end of file
diff --git a/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/retention.conf b/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/retention.conf
new file mode 100644
index 0000000..cd7954c
--- /dev/null
+++ b/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/retention.conf
@@ -0,0 +1,6 @@
+# How many restore points the night keeps, written by the mesh from this machine's settings keep-daily,
+# keep-weekly, keep-monthly (novox/hq issue 416) and keep-hand-taken-days (novox/hq issue 453). Replaced on every push; change the settings, never this file.
+daily=${config:keep-daily}
+weekly=${config:keep-weekly}
+monthly=${config:keep-monthly}
+hand=${config:keep-hand-taken-days}
diff --git a/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.module.json b/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.module.json
new file mode 100644
index 0000000..ea5ec8b
--- /dev/null
+++ b/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.module.json
@@ -0,0 +1,199 @@
+{
+ "module": "restic",
+ "version": "1",
+ "moved": true,
+ "claims": [
+ {
+ "name": "node-backup",
+ "scope": "node",
+ "serves": [
+ "backed-up",
+ "now",
+ "restore"
+ ]
+ }
+ ],
+ "emits": [
+ "backed-up"
+ ],
+ "tools": [
+ "backup_health",
+ "backup_delete_retired",
+ "backup_deleted"
+ ],
+ "own-secrets": {
+ "repository": "${dir:state}/repository.secret"
+ },
+ "data": {
+ "own": [
+ {
+ "id": "repository",
+ "path": "${dir:repository}",
+ "class": "rebuildable",
+ "backup": "none",
+ "why": "the restore points themselves: a copy of data kept elsewhere on this machine, never the only copy of anything; lost, the history goes and nothing live does"
+ },
+ {
+ "id": "state",
+ "path": "${dir:state}",
+ "class": "cache",
+ "why": "what the last nights and measurements were; the next night writes it again"
+ }
+ ]
+ },
+ "config": {
+ "keep-daily": {
+ "type": "number",
+ "description": "How many nightly restore points are kept, a count from 1 to 9999: two weeks undo a mistake noticed within a fortnight. Read by the night, which thins nothing while it does not read (novox/hq issue 416); lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
+ "minimum": 1,
+ "maximum": 9999,
+ "trusted": true,
+ "default": 14
+ },
+ "keep-weekly": {
+ "type": "number",
+ "description": "How many weekly restore points are kept behind the dailies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
+ "minimum": 1,
+ "maximum": 9999,
+ "trusted": true,
+ "default": 8
+ },
+ "keep-monthly": {
+ "type": "number",
+ "description": "How many monthly restore points are kept behind the weeklies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
+ "minimum": 1,
+ "maximum": 9999,
+ "trusted": true,
+ "default": 6
+ },
+ "keep-hand-taken-days": {
+ "type": "number",
+ "description": "How many days a restore point taken by hand (node-backup.now, or the mesh's before a module moves) is kept at least, a count from 1 to 9999; it is kept until a later good night of its module exists, and this many days in any case, so the next thinning never takes the one taken against a risky change (novox/hq issue 453). Lowering it lets go of the older ones taken by hand at the next thinning, and that cannot be undone.",
+ "minimum": 1,
+ "maximum": 9999,
+ "trusted": true,
+ "default": 7
+ }
+ },
+ "resources": [
+ {
+ "id": "state",
+ "type": "directory",
+ "mode": "0700",
+ "place": ".",
+ "unknown": "program"
+ },
+ {
+ "id": "repository",
+ "type": "directory",
+ "mode": "0700",
+ "unknown": "program"
+ },
+ {
+ "id": "declared",
+ "type": "file",
+ "path": "${dir:state}/backups.conf",
+ "mode": "0600",
+ "template": "files/backups.conf"
+ },
+ {
+ "id": "data-declared",
+ "type": "file",
+ "path": "${dir:state}/data.conf",
+ "mode": "0600",
+ "template": "files/data.conf"
+ },
+ {
+ "id": "retention",
+ "type": "file",
+ "path": "${dir:state}/retention.conf",
+ "mode": "0644",
+ "trusted": true,
+ "template": "files/retention.conf"
+ },
+ {
+ "id": "tool",
+ "type": "package",
+ "package": "restic"
+ },
+ {
+ "id": "sqlite",
+ "type": "package",
+ "package": "sqlite"
+ },
+ {
+ "id": "night",
+ "type": "process",
+ "name": "restic-night",
+ "artifact": "tools",
+ "run": [
+ "./restic-backups",
+ "night"
+ ],
+ "user": "${machine:account}",
+ "env": {
+ "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
+ "MESH_BACKUP_REPOSITORY": "${dir:repository}",
+ "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
+ "MESH_BACKUP_STATE": "${dir:state}",
+ "MESH_BACKUP_DATA": "${dir:state}/data.conf"
+ },
+ "health": {
+ "kind": "tool",
+ "tool": "backup_health",
+ "interval": "60s",
+ "timeout": "20s",
+ "looks": 2,
+ "grace": "60s"
+ }
+ },
+ {
+ "id": "measure",
+ "type": "process",
+ "name": "restic-measure",
+ "artifact": "tools",
+ "run": [
+ "./restic-backups",
+ "measure"
+ ],
+ "user": "${machine:account}",
+ "env": {
+ "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
+ "MESH_BACKUP_REPOSITORY": "${dir:repository}",
+ "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
+ "MESH_BACKUP_STATE": "${dir:state}",
+ "MESH_BACKUP_DATA": "${dir:state}/data.conf"
+ },
+ "health": {
+ "kind": "unit"
+ }
+ }
+ ],
+ "build": {
+ "artifacts": [
+ {
+ "name": "tools",
+ "kind": "bundle",
+ "language": "go",
+ "system": "arch",
+ "from": "cmd/restic-backups",
+ "binary": "restic-backups",
+ "loads": [
+ "restic-backups"
+ ],
+ "env": {
+ "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
+ "MESH_BACKUP_REPOSITORY": "${dir:repository}",
+ "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
+ "MESH_BACKUP_STATE": "${dir:state}",
+ "MESH_BACKUP_DATA": "${dir:state}/data.conf"
+ }
+ },
+ {
+ "name": "files",
+ "kind": "archive",
+ "from": "files"
+ }
+ ]
+ }
+}
diff --git a/internal/compose/frombus/held_rows_test.go b/internal/compose/frombus/held_rows_test.go
new file mode 100644
index 0000000..bc8fae7
--- /dev/null
+++ b/internal/compose/frombus/held_rows_test.go
@@ -0,0 +1,299 @@
+package frombus
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+
+ "git.novox.be/novox/mesh-sdk/go/catalogue"
+ "git.novox.be/novox/mesh-sdk/go/delivery"
+ "github.com/novox/mesh-host/internal/compose"
+ "github.com/novox/mesh-host/internal/compose/resolution"
+)
+
+// aNode is a bus with n1 the hub, each of manifests placed on it and held at the indexer, in the order given.
+func aNode(t *testing.T, manifests ...string) (*bus, map[string]string) {
+ t.Helper()
+ p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}}
+ b := &bus{records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd"}}},
+ modules: map[string]delivery.Module{}, manifests: map[string][]byte{}, builds: map[string]*delivery.BuilderBuild{}}
+ fps := map[string]string{}
+ for i, raw := range manifests {
+ module := strings.SplitN(strings.TrimPrefix(raw, `{"module":"`), `"`, 2)[0]
+ p.Modules = append(p.Modules, delivery.PlacedModule{Module: module})
+ b.modules[module] = delivery.Module{Manifest: delivery.ManifestDigest([]byte(raw))}
+ b.manifests[delivery.ManifestDigest([]byte(raw))] = []byte(raw)
+ fps[module] = "src2:" + strings.Repeat(string(rune('1'+i)), 64)
+ }
+ b.placements = map[string]delivery.Placement{"n1": p}
+ return b, fps
+}
+
+// Issue 774, 2026-10-30 19:30: a node refused whole after holding a module says what it held and why, since the last
+// refusal can be of the held module's stub (node-tools held, then "its build produced 0 bundle(s)", its own why unsaid).
+func aNodeRefusedWholeSaysWhatItHeld(t *testing.T) {
+ refused := `{"module":"power","version":"1","items":[{"collection":"contribution:node-bar:block","attributes":{"shows":"state"},"template":"files/a"}]}`
+ needs := `{"module":"lamp","version":"1","requires":["nothing-provides-this"]}`
+ b, fps := aNode(t, refused, needs)
+ refuse(b, "power", `"shows" is none of them`)
+ _, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1"}, fps)
+ if err == nil || !strings.Contains(err.Error(), "nothing provides") || !strings.Contains(err.Error(), `held before: power: `) ||
+ !strings.Contains(err.Error(), `"shows" is none of them`) {
+ t.Errorf("the node refused whole with %v (held %v), want lamp's refusal and power held before, with its why", err, held)
+ }
+}
+
+// refuse writes the indexer's refusal of module's manifest into its modules value (novox/hq ADR 0578 §1).
+func refuse(b *bus, module string, problems ...string) {
+ m := b.modules[module]
+ m.Verdict, m.Problems = delivery.VerdictRefused, problems
+ b.modules[module] = m
+}
+
+// ADR 0578 §3: the bus reader never ends a whole read on a refused manifest. Input, Plans and DataItems compose the rest
+// and hold the refused module, named with the indexer's words in Refused; a field the decoder does not know is set aside
+// and named, and its module composed.
+func aRefusedManifestNeverEndsTheRead(t *testing.T) {
+ ok := `{"module":"lamp","version":"1","resources":[{"id":"d","type":"directory","path":"/srv/lamp","mode":"0700"}],` +
+ `"data":{"own":[{"id":"d","path":"${dir:d}","class":"valuable"}]}}`
+ bad := `{"module":"power","version":"1","resources":[{"id":"d","type":"directory","path":"/srv/power","mode":"0700"}],` +
+ `"data":{"own":[{"id":"d","path":"${dir:d}","class":"valuable"}]}}`
+ later := `{"module":"later","version":"1","a-field-from-later":1}`
+ b, _ := aNode(t, ok, bad, later)
+ refuse(b, "power", "removes a stream it may not")
+ ctx := context.Background()
+ in, err := Input(ctx, b, Mesh{}, Scope{Node: "n1"})
+ if err != nil {
+ t.Fatalf("one refused manifest ended the read: %v", err)
+ }
+ if why := in.Refused["power"]; !strings.Contains(why, "refused the manifest of power: removes a stream it may not") ||
+ len(in.Refused) != 1 {
+ t.Fatalf("refused: %v", in.Refused)
+ }
+ if aside := in.SetAside["later"]; !strings.Contains(aside, "a-field-from-later") {
+ t.Fatalf("set aside: %v", in.SetAside)
+ }
+ if !in.Catalogue["power"].Stubbed || in.Catalogue["lamp"].Stubbed || in.Catalogue["later"].Module != "later" {
+ t.Fatalf("the catalogue: %+v", in.Catalogue)
+ }
+ if _, err := Plans(ctx, b, Mesh{}, Scope{Node: "n1"}); err != nil {
+ t.Fatalf("plans: %v", err)
+ }
+ items, _, held, err := DataItemsHeld(ctx, b, Mesh{}, Scope{Node: "n1"})
+ if err != nil || len(items["lamp"]) != 1 || items["power"] != nil || !strings.Contains(held["power"], "removes a stream") {
+ t.Fatalf("data items %v held %v: %v", items, held, err)
+ }
+}
+
+// Issue 774: the culprit rule takes a module whose stub composes or fails otherwise; the runtime only where its stub
+// composes, since its stub always fails otherwise.
+func theRuntimeIsBlamedOnlyWhereItsStubComposes(t *testing.T) {
+ err := errors.New("lamp refuses")
+ for _, row := range []struct {
+ module string
+ again error
+ blamed bool
+ }{
+ {"lamp", nil, true},
+ {"lamp", errors.New("other"), true},
+ {"lamp", errors.New("lamp refuses"), false},
+ {catalogue.RuntimeModule, nil, true},
+ {catalogue.RuntimeModule, errors.New("node-tools is assigned to n1 and its build produced 0 bundle(s)"), false},
+ {catalogue.RuntimeModule, fmt.Errorf("compose: %w", &resolution.NoRuntimeToRunError{Held: true}), true},
+ {catalogue.RuntimeModule, &resolution.NoRuntimeToRunError{}, false},
+ } {
+ if got := blames(row.module, row.again, err); got != row.blamed {
+ t.Errorf("%s with %v: blamed %v, want %v", row.module, row.again, got, row.blamed)
+ }
+ }
+}
+
+// Issue 774, 2026-10-30 19:30: the trunk's node-tools moved to a build not yet done, and ace, g14 and shanks composed
+// it from the indexer's manifest, which names no bundle: "its build produced 0 bundle(s)", every module refused. A
+// module that runs is now held until its build lands, the runtime composed as what runs of it; and a module wanted at
+// another build than the trunk's is composed from the build its want names.
+func aRunningModuleWaitsForItsBuild(t *testing.T) {
+ dg := func(c string) string { return "sha256:" + strings.Repeat(c, 64) }
+ tools := `{"module":"node-tools","version":"1","own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` +
+ `"build":{"artifacts":[{"name":"runtime","kind":"bundle","language":"go","system":"arch","from":"cmd/node-tools"}]}}`
+ lamp := `{"module":"lamp","version":"1","resources":[{"id":"service","type":"service","unit":"lamp.service","state":"running","boot":"enabled"}]}`
+ store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}},` +
+ `"receives":{"artifact-store":"/var/lib/artifacts/received.json"}}`
+ old, next := "src2:"+strings.Repeat("a", 64), "src2:"+strings.Repeat("b", 64)
+ running := []delivery.Composed{{Node: "n1", Module: catalogue.RuntimeModule, FP: old, Resources: []json.RawMessage{
+ json.RawMessage(`{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{"MESH_TOOL_MODULES":""}}`),
+ json.RawMessage(`{"id":"node-tools.bundle-runtime","module":"node-tools","type":"archive","source":"artifact-store://node-tools/runtime/blobs/` +
+ dg("1") + `","digest":"` + dg("1") + `","path":"` + resolution.BundlePath("node-tools", "runtime") + `"}`)}}}
+ for _, row := range []struct {
+ name string
+ want string // the build node-tools' want names
+ done bool // the trunk's build is done
+ held string // words node-tools' hold says; empty: composed
+ digest string // the runtime bundle's digest composed
+ }{
+ {name: "the trunk's build not done yet", want: next, held: "no done build yet"},
+ {name: "wanted at the build that runs, the trunk's not done", want: old, digest: dg("1")},
+ {name: "the trunk's build done", want: next, done: true, digest: dg("2")},
+ } {
+ b, fps := aNode(t, store, tools, lamp)
+ b.held = true // every grant held, so a contribution's credential stands in for nothing
+ b.own = []compose.OwnSecretRow{{Node: "n1", Module: "node-tools", Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}
+ mod := b.modules["node-tools"]
+ mod.FP2 = next
+ b.modules["node-tools"] = mod
+ b.builds["node-tools "+old] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
+ Artifacts: []delivery.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("1"), Digest: dg("1")}}}
+ b.builds["node-tools "+next] = &delivery.BuilderBuild{State: "building", Manifest: mod.Manifest}
+ if row.done {
+ b.builds["node-tools "+next] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
+ Artifacts: []delivery.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("2"), Digest: dg("2")}}}
+ }
+ fps["node-tools"] = row.want
+ out, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1", Running: running}, fps)
+ if err != nil {
+ t.Errorf("%s: the node is refused: %v", row.name, err)
+ continue
+ }
+ if lampKey, _ := delivery.ComposedKey("n1", "lamp", fps["lamp"]); out[lampKey].Module != "lamp" {
+ t.Errorf("%s: lamp is not composed (held %v)", row.name, held)
+ }
+ if why := held["node-tools"]; (row.held == "") != (why == "") || !strings.Contains(why, row.held) {
+ t.Errorf("%s: node-tools held %q, want %q", row.name, why, row.held)
+ }
+ if row.digest != "" {
+ key, _ := delivery.ComposedKey("n1", "node-tools", row.want)
+ if c := out[key]; !strings.Contains(fmt.Sprint(rawStrings(c.Resources)), row.digest) {
+ t.Errorf("%s: node-tools is composed as %v, want its bundle %s", row.name, rawStrings(c.Resources), row.digest)
+ }
+ }
+ }
+}
+
+func rawStrings(rs []json.RawMessage) []string {
+ var out []string
+ for _, r := range rs {
+ out = append(out, string(r))
+ }
+ return out
+}
+
+// Issue 774: a refusal of a held module's stand-in leads with why the module was held, its first reason, as mesh-self-check's
+// on ace should have; a refusal naming no held module is as it was.
+func aHeldModulesFirstReasonLeads(t *testing.T) {
+ held := map[string]string{"mesh-self-check": "mesh-self-check on ace has no bus credential: nothing was issued for ace.mesh-self-check"}
+ for _, row := range []struct {
+ err, want string
+ }{
+ {"mesh-self-check runs as its own account and declares no own secret broker",
+ "mesh-self-check is held: mesh-self-check on ace has no bus credential: nothing was issued for ace.mesh-self-check; " +
+ "its stand-in is then refused: mesh-self-check runs as its own account and declares no own secret broker"},
+ {"ace is the hub and has no endpoint", "ace is the hub and has no endpoint"},
+ } {
+ err := errors.New(row.err)
+ got := firstReason(err, held)
+ if got.Error() != row.want || !errors.Is(got, err) {
+ t.Errorf("refused %q, want %q", got, row.want)
+ }
+ }
+}
+
+// Issue 774, 2026-10-30 21:04: mesh-conditions and mesh-healers on novox and mesh-self-check on ace were placed before their
+// bus accounts were issued. Held for "needs a secret called broker and none was made", each one's bare stub was then
+// refused as "runs as its own account and declares no own secret broker", and that refusal failed every module of the
+// node (79 on novox, 74 on ace). A held module's stand-in is never refused by a rule about its own runtime or its own
+// secrets: the node composes every other module, and the held one keeps the words it was first held for.
+func aHeldModuleOfItsOwnAccountNeverRefusesTheNode(t *testing.T) {
+ dg := "sha256:" + strings.Repeat("1", 64)
+ tools := `{"module":"node-tools","version":"1","own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` +
+ `"build":{"artifacts":[{"name":"runtime","kind":"bundle","language":"go","system":"arch","from":"cmd/node-tools"}]}}`
+ own := func(name string) string {
+ return `{"module":"` + name + `","version":"1","runs-as":"` + name + `","secrets-owner":"` + name + `","tools":["status"],` +
+ `"own-secrets":{"broker":"/var/lib/` + name + `/broker"},"contributes":{"artifact-store":{"name":"` + name + `"}},` +
+ `"resources":[{"id":"account","type":"user","name":"` + name + `"}],` +
+ `"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["tools/index.js"]}]}}`
+ }
+ lamp := `{"module":"lamp","version":"1","resources":[{"id":"service","type":"service","unit":"lamp.service","state":"running","boot":"enabled"}]}`
+ store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}},` +
+ `"receives":{"artifact-store":"/var/lib/artifacts/received.json"}}`
+ for _, row := range []struct {
+ name string
+ issued []string // the own-account modules whose broker is issued
+ building bool // node-tools' build is not done, and nothing of it runs
+ held map[string]string // the modules held, each with words of the reason it was first held for
+ // ungranted gives the provider a grants file and no consumer's grant is issued yet (a newly placed module's
+ // usual state): a held module's contribution then stands in for its grant, and is left out (review round 1).
+ ungranted bool
+ }{
+ {name: "none issued", held: map[string]string{"check": "broker", "heal": "broker"}},
+ {name: "none issued, no grant issued", ungranted: true, held: map[string]string{"check": "broker", "heal": "broker"}},
+ {name: "one issued", issued: []string{"heal"}, held: map[string]string{"check": "broker"}},
+ {name: "both issued", issued: []string{"check", "heal"}},
+ // The runtime held: its stand-in is not refused for its bundle; the modules it would run are held, not the node.
+ {name: "the runtime's build not done", issued: []string{"check", "heal"}, building: true,
+ held: map[string]string{"node-tools": "its build produced 0 bundle(s)", "check": "not here to run it from", "heal": "not here to run it from"}},
+ } {
+ provider := store
+ if row.ungranted {
+ provider = strings.Replace(store, `"receives":`, `"grants":{"artifact-store":"/var/lib/artifacts/grants"},"receives":`, 1)
+ }
+ b, fps := aNode(t, provider, tools, own("check"), lamp, own("heal"))
+ // Every grant held, so the held modules' contributions are composed whole; the row ungranted issues none.
+ b.held = !row.ungranted
+ b.own = []compose.OwnSecretRow{{Node: "n1", Module: "node-tools", Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}
+ for _, m := range row.issued {
+ b.own = append(b.own, compose.OwnSecretRow{Node: "n1", Module: m, Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`})
+ }
+ for _, m := range []string{"node-tools", "check", "heal"} {
+ name := "tools"
+ if m == "node-tools" {
+ name = "runtime"
+ }
+ mod := b.modules[m]
+ mod.FP2 = fps[m]
+ b.modules[m] = mod
+ b.builds[m+" "+fps[m]] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
+ Artifacts: []delivery.BuiltArtifact{{Name: name, Kind: "bundle", Reference: "artifact-store://" + m + "/" + name + "/blobs/" + dg, Digest: dg}}}
+ if m == "node-tools" && row.building {
+ b.builds[m+" "+fps[m]] = &delivery.BuilderBuild{State: "building", Manifest: mod.Manifest}
+ }
+ }
+ out, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1"}, fps)
+ if err != nil {
+ t.Errorf("%s: the node is refused: %v", row.name, err)
+ continue
+ }
+ // A held module keeps what it contributes (issue 774, 2026-10-31: nextcloud held for its admin secret dropped out
+ // of postgres's received file, and postgres's helper would have locked its login): only its own secrets are left
+ // out of its stand-in, never its contributions, so the provider's received file names every consumer.
+ storeKey, _ := delivery.ComposedKey("n1", "artifacts", fps["artifacts"])
+ // And never a stand-in for a grant not yet issued: the provider is applied, so a held module whose grant is
+ // not issued is composed without that contribution, never with "foreseen: made by the next send".
+ for key, c := range out {
+ if got, _ := json.Marshal(c.Resources); strings.Contains(string(got), "foreseen") {
+ t.Errorf("%s: %s applies a stand-in: %s", row.name, key, got)
+ }
+ }
+ for _, m := range []string{"check", "heal"} {
+ if row.ungranted {
+ break
+ }
+ if got, _ := json.Marshal(out[storeKey].Resources); !strings.Contains(string(got), `\"from\": \"`+m+`\"`) {
+ t.Errorf("%s: the provider's received file drops %s's contribution: %s", row.name, m, got)
+ }
+ }
+ for _, m := range []string{"node-tools", "check", "lamp", "heal"} {
+ key, _ := delivery.ComposedKey("n1", m, fps[m])
+ _, composed := out[key]
+ why, isHeld := held[m]
+ want, wantHeld := row.held[m]
+ if isHeld != wantHeld || composed == wantHeld {
+ t.Errorf("%s: %s held %v (%q), composed %v; want held %v", row.name, m, isHeld, why, composed, wantHeld)
+ } else if isHeld && !strings.Contains(why, want) {
+ t.Errorf("%s: %s is held for %q, want its first reason, %q", row.name, m, why, want)
+ }
+ }
+ }
+}
diff --git a/internal/compose/frombus/states.go b/internal/compose/frombus/states.go
new file mode 100644
index 0000000..ca932f3
--- /dev/null
+++ b/internal/compose/frombus/states.go
@@ -0,0 +1,204 @@
+package frombus
+
+// **The bus read through a module's declared `reads`** (the lead, 2026-10-29: one reader in place of the copies in
+// mesh-provisioner and disk-load): a Reader over the states a module reads, by a runtime's Get and Keys (a bundle's
+// stdio.State, a process's own), for a holder that composes plans or data items and reads no value: placement, each
+// node's record in its own have key, the indexer's modules and manifests, the builder's builds and mesh-config's resolved
+// values. No own secret and no credential is read (compose.Planning). Pure as the rest of this package: a bundle's
+// runtime states and an HTTP fetch are compose/fromruntime's.
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "strings"
+ "time"
+
+ "git.novox.be/novox/mesh-sdk/go/delivery"
+ "github.com/novox/mesh-host/internal/compose"
+ "github.com/novox/mesh-host/internal/compose/resolution"
+)
+
+// The states a module names in its manifest's `reads` to compose through StatesReader.
+const (
+ PlacementRead = delivery.PlacementRead
+ ModulesRead = delivery.IndexerModule + ".modules"
+ ManifestsRead = delivery.IndexerModule + ".manifests"
+ BuildsRead = delivery.BuilderModule + ".builds"
+ HaveRead = "node-engine.have"
+ ResolvedRead = "mesh-config.resolved"
+)
+
+// StatesReads are those reads, in a manifest's order.
+var StatesReads = []string{PlacementRead, ModulesRead, ManifestsRead, BuildsRead, HaveRead, ResolvedRead}
+
+// States is a module's read states as its runtime gives them: a key's value, false when it has none.
+type States interface {
+ Get(state, key string) (value []byte, found bool, err error)
+ Keys(state string) ([]string, error)
+}
+
+// Fetch reads an archive at a URL (fromruntime.HTTPFetch over HTTP).
+type Fetch func(ctx context.Context, url string) ([]byte, error)
+
+// StatesReader is the Reader over s; fetch reads a templates archive, nil for a holder that reads none.
+// It keeps what each build's take-in made, by the build's content (issue 1954), so a holder composing again and again
+// takes in only a build it has not seen.
+func StatesReader(s States, fetch Fetch) Reader {
+ return statesReader{s: s, fetch: fetch, kept: &keptTakeIns{}}
+}
+
+type statesReader struct {
+ s States
+ fetch Fetch
+ kept *keptTakeIns
+}
+
+func (r statesReader) takenIn(key string) ([]byte, bool) { return r.kept.takenIn(key) }
+
+func (r statesReader) keepTakenIn(key string, raw []byte) { r.kept.keepTakenIn(key, raw) }
+
+func (r statesReader) each(read string, visit func(key string, value []byte)) error {
+ keys, err := r.s.Keys(read)
+ if err != nil {
+ return fmt.Errorf("%s: %w", read, err)
+ }
+ for _, k := range keys {
+ v, found, err := r.s.Get(read, k)
+ if err != nil {
+ return fmt.Errorf("%s %s: %w", read, k, err)
+ }
+ if found {
+ visit(k, v)
+ }
+ }
+ return nil
+}
+
+func (r statesReader) Placements(context.Context) (map[string]delivery.Placement, error) {
+ keys := delivery.NewPlacementKeys()
+ err := r.each(PlacementRead, func(k string, v []byte) {
+ _, _ = keys.Put(k, v, 0, false) // a key that does not read places nothing, as before
+ })
+ return keys.All(), err
+}
+
+func (r statesReader) Records(context.Context) (map[string]delivery.NodeRecord, error) {
+ out := map[string]delivery.NodeRecord{}
+ err := r.each(HaveRead, func(k string, v []byte) {
+ if node, _, isNode, err := delivery.ParseHaveKey(k); err == nil && isNode {
+ if h, err := delivery.DecodeHaveNode(v); err == nil && h.Record != nil {
+ out[node] = *h.Record
+ }
+ }
+ })
+ return out, err
+}
+
+func (r statesReader) Modules(context.Context) (map[string]delivery.Module, error) {
+ out := map[string]delivery.Module{}
+ err := r.each(ModulesRead, func(k string, v []byte) {
+ if module, err := delivery.ParseModulesKey(k); err == nil {
+ if m, err := delivery.DecodeModule(v); err == nil {
+ out[module] = m
+ }
+ }
+ })
+ return out, err
+}
+
+func (r statesReader) Manifest(_ context.Context, digest string) ([]byte, error) {
+ key, err := delivery.ManifestsKey(digest)
+ if err != nil {
+ return nil, err
+ }
+ v, found, err := r.s.Get(ManifestsRead, key)
+ if err != nil {
+ return nil, err
+ }
+ if !found {
+ return nil, fmt.Errorf("the indexer holds no manifest %s", digest)
+ }
+ m, err := delivery.DecodeManifest(v)
+ if err != nil {
+ return nil, err
+ }
+ return []byte(m.JSON), nil
+}
+
+func (r statesReader) Values(_ context.Context, node, module string) (map[string]any, error) {
+ var out map[string]any
+ prefix := node + "." + module + "."
+ err := r.each(ResolvedRead, func(k string, v []byte) {
+ name, ok := strings.CutPrefix(k, prefix)
+ if !ok || strings.Contains(name, ".") {
+ return
+ }
+ if value, laid := resolution.LaidValue(v); laid {
+ if out == nil {
+ out = map[string]any{}
+ }
+ out[name] = value
+ }
+ })
+ return out, err
+}
+
+func (statesReader) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) {
+ return nil, nil
+}
+
+func (statesReader) Credentials(context.Context, string, []compose.CredentialKey, []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) {
+ return nil, nil, nil
+}
+
+func (r statesReader) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) {
+ key, err := delivery.BuilderBuildKey(module, fp)
+ if err != nil {
+ return nil, err
+ }
+ v, found, err := r.s.Get(BuildsRead, key)
+ if err != nil || !found {
+ return nil, err
+ }
+ b, err := delivery.DecodeBuilderBuild(v)
+ if err != nil {
+ return nil, err
+ }
+ return &b, nil
+}
+
+// archiveWithin bounds one read of a templates archive.
+const archiveWithin = 30 * time.Second
+
+func (r statesReader) Archive(ctx context.Context, url, digest string) ([]byte, error) {
+ if r.fetch == nil {
+ return nil, fmt.Errorf("this holder reads no archive (%s)", url)
+ }
+ fetching, cancel := context.WithTimeout(ctx, archiveWithin)
+ defer cancel()
+ body, err := r.fetch(fetching, url)
+ if err != nil {
+ return nil, err
+ }
+ sum := sha256.Sum256(body)
+ if got := hex.EncodeToString(sum[:]); got != strings.TrimPrefix(digest, "sha256:") {
+ return nil, fmt.Errorf("%s was recorded as %s and what arrived is %s", url, digest, got)
+ }
+ return body, nil
+}
+
+func (r statesReader) Served(ctx context.Context, node, module string, keys []string) (map[string]any, error) {
+ all, err := r.Values(ctx, node, module)
+ var out map[string]any
+ for _, k := range keys {
+ if v, ok := all[k]; ok {
+ if out == nil {
+ out = map[string]any{}
+ }
+ out[k] = v
+ }
+ }
+ return out, err
+}
diff --git a/internal/compose/frombus/testdata/catalogue-835bf22b-xterm.module.json b/internal/compose/frombus/testdata/catalogue-835bf22b-xterm.module.json
new file mode 100644
index 0000000..b65be8e
--- /dev/null
+++ b/internal/compose/frombus/testdata/catalogue-835bf22b-xterm.module.json
@@ -0,0 +1,236 @@
+{
+ "module": "xterm",
+ "version": "1",
+ "moved": true,
+ "capabilities": [
+ "package-manager"
+ ],
+ "requires": [
+ "x11-display"
+ ],
+ "claims": [
+ {
+ "name": "node-terminal-emulator",
+ "scope": "node",
+ "serves": [
+ "open"
+ ]
+ }
+ ],
+ "tools": [
+ "xterm_font",
+ "xterm_colours"
+ ],
+ "config": {
+ "face": {
+ "type": "string",
+ "description": "The font family xterm draws with, as fc-list names it: JetBrainsMono Nerd Font, because research 026/04 chose it for its glyphs the prompt uses. A family the machine does not have falls back to the nearest installed face (xterm_font says which).",
+ "pattern": "[A-Za-z0-9 ._-]{1,64}",
+ "default": "JetBrainsMono Nerd Font"
+ },
+ "size": {
+ "type": "number",
+ "description": "The size of xterm's text, in points: 12 reads well on a screen of about 100 DPI, and a denser screen needs a larger size.",
+ "minimum": 6,
+ "maximum": 72,
+ "predefined": "font-size",
+ "default": 12
+ },
+ "save-lines": {
+ "type": "number",
+ "description": "How many lines of scrollback xterm keeps per terminal: 10000 holds a long build log, and each line costs memory in every open terminal.",
+ "minimum": 0,
+ "maximum": 1000000,
+ "default": 10000
+ },
+ "term-name": {
+ "type": "string",
+ "description": "The TERM value xterm sets for the programs it runs: xterm-256color, because the palette has 256 colours and every program knows the name.",
+ "pattern": "[A-Za-z0-9._-]{1,64}",
+ "default": "xterm-256color"
+ },
+ "render-font": {
+ "type": "boolean",
+ "description": "Whether xterm draws with fontconfig faces (true) or the old X core fonts (false): true, because the face is a fontconfig family and core fonts cannot show it.",
+ "default": true
+ },
+ "bold-mode": {
+ "type": "boolean",
+ "description": "Whether xterm draws bold text by overstriking the normal face (true): false, because the face has a real bold style and overstriking blurs it.",
+ "default": false
+ },
+ "always-bold-mode": {
+ "type": "boolean",
+ "description": "Whether xterm overstrikes bold even where the face has a bold style (true): false, for the same reason as bold-mode.",
+ "default": false
+ },
+ "allow-bold-fonts": {
+ "type": "boolean",
+ "description": "Whether xterm may use the face's bold style for bold text (true): true, so bold text is drawn with the real bold weight.",
+ "default": true
+ },
+ "background": {
+ "type": "string",
+ "description": "The terminal's background colour, as #rrggbb: black, as both workstations ran it.",
+ "pattern": "#[0-9a-fA-F]{6}",
+ "default": "#000000"
+ },
+ "foreground": {
+ "type": "string",
+ "description": "The terminal's text colour, as #rrggbb: a light grey, easier on the eyes than white on black.",
+ "pattern": "#[0-9a-fA-F]{6}",
+ "default": "#a8a8a8"
+ },
+ "color0": {
+ "type": "string",
+ "description": "Palette entry 0, the normal black, as #rrggbb: #000000, as both workstations ran it.",
+ "pattern": "#[0-9a-fA-F]{6}",
+ "default": "#000000"
+ },
+ "color1": {
+ "type": "string",
+ "description": "Palette entry 1, the normal red, as #rrggbb: #9e1828, as both workstations ran it.",
+ "pattern": "#[0-9a-fA-F]{6}",
+ "default": "#9e1828"
+ },
+ "color2": {
+ "type": "string",
+ "description": "Palette entry 2, the normal green, as #rrggbb: #008800, as both workstations ran it.",
+ "pattern": "#[0-9a-fA-F]{6}",
+ "default": "#008800"
+ },
+ "color3": {
+ "type": "string",
+ "description": "Palette entry 3, the normal yellow, as #rrggbb: #d2bb4b, as both workstations ran it.",
+ "pattern": "#[0-9a-fA-F]{6}",
+ "default": "#d2bb4b"
+ },
+ "color4": {
+ "type": "string",
+ "description": "Palette entry 4, the normal blue, as #rrggbb: #414171, as both workstations ran it.",
+ "pattern": "#[0-9a-fA-F]{6}",
+ "default": "#414171"
+ },
+ "color5": {
+ "type": "string",
+ "description": "Palette entry 5, the normal magenta, as #rrggbb: #963c59, as both workstations ran it.",
+ "pattern": "#[0-9a-fA-F]{6}",
+ "default": "#963c59"
+ },
+ "color6": {
+ "type": "string",
+ "description": "Palette entry 6, the normal cyan, as #rrggbb: #418179, as both workstations ran it.",
+ "pattern": "#[0-9a-fA-F]{6}",
+ "default": "#418179"
+ },
+ "color7": {
+ "type": "string",
+ "description": "Palette entry 7, the normal white, as #rrggbb: #bebebe, as both workstations ran it.",
+ "pattern": "#[0-9a-fA-F]{6}",
+ "default": "#bebebe"
+ },
+ "color8": {
+ "type": "string",
+ "description": "Palette entry 8, the bright black, as #rrggbb: #666666, as both workstations ran it.",
+ "pattern": "#[0-9a-fA-F]{6}",
+ "default": "#666666"
+ },
+ "color9": {
+ "type": "string",
+ "description": "Palette entry 9, the bright red, as #rrggbb: #bc5766, as both workstations ran it.",
+ "pattern": "#[0-9a-fA-F]{6}",
+ "default": "#bc5766"
+ },
+ "color10": {
+ "type": "string",
+ "description": "Palette entry 10, the bright green, as #rrggbb: #61a171, as both workstations ran it.",
+ "pattern": "#[0-9a-fA-F]{6}",
+ "default": "#61a171"
+ },
+ "color11": {
+ "type": "string",
+ "description": "Palette entry 11, the bright yellow, as #rrggbb: #e7db52, as both workstations ran it.",
+ "pattern": "#[0-9a-fA-F]{6}",
+ "default": "#e7db52"
+ },
+ "color12": {
+ "type": "string",
+ "description": "Palette entry 12, the bright blue, as #rrggbb: #5085af, as both workstations ran it.",
+ "pattern": "#[0-9a-fA-F]{6}",
+ "default": "#5085af"
+ },
+ "color13": {
+ "type": "string",
+ "description": "Palette entry 13, the bright magenta, as #rrggbb: #a97a99, as both workstations ran it.",
+ "pattern": "#[0-9a-fA-F]{6}",
+ "default": "#a97a99"
+ },
+ "color14": {
+ "type": "string",
+ "description": "Palette entry 14, the bright cyan, as #rrggbb: #6ba4a4, as both workstations ran it.",
+ "pattern": "#[0-9a-fA-F]{6}",
+ "default": "#6ba4a4"
+ },
+ "color15": {
+ "type": "string",
+ "description": "Palette entry 15, the bright white, as #rrggbb: #ffffff, as both workstations ran it.",
+ "pattern": "#[0-9a-fA-F]{6}",
+ "default": "#ffffff"
+ },
+ "copy-binding": {
+ "type": "string",
+ "description": "The key combination that copies the selection to the clipboard, in X translation syntax: Ctrl Shift ~Alt C, so a plain Ctrl+C still interrupts the program. It holds no colon, backslash or line break, which would end the resource.",
+ "pattern": "[^:\\\\\\n]+",
+ "default": "Ctrl Shift ~Alt C"
+ },
+ "paste-binding": {
+ "type": "string",
+ "description": "The key combination that pastes the clipboard, in X translation syntax: Ctrl Shift ~Alt V, the pair of copy-binding. It holds no colon, backslash or line break, which would end the resource.",
+ "pattern": "[^:\\\\\\n]+",
+ "default": "Ctrl Shift ~Alt V"
+ }
+ },
+ "items": [
+ {
+ "collection": "environment",
+ "name": "terminal",
+ "attributes": {
+ "kind": "variable",
+ "name": "TERMINAL"
+ },
+ "template": "files/env-terminal"
+ },
+ {
+ "collection": "shell:xresources:normal",
+ "name": "xresources-normal",
+ "template": "files/xresources-normal"
+ }
+ ],
+ "resources": [
+ {
+ "id": "package",
+ "type": "package",
+ "package": "xterm"
+ }
+ ],
+ "build": {
+ "artifacts": [
+ {
+ "name": "tools",
+ "kind": "bundle",
+ "language": "go",
+ "system": "arch",
+ "from": "cmd/xterm-tools",
+ "binary": "xterm-tools",
+ "loads": [
+ "xterm-tools"
+ ]
+ },
+ {
+ "name": "files",
+ "kind": "archive",
+ "from": "files"
+ }
+ ]
+ }
+}
diff --git a/internal/compose/frombus/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json b/internal/compose/frombus/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json
new file mode 100644
index 0000000..095128a
--- /dev/null
+++ b/internal/compose/frombus/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json
@@ -0,0 +1,60 @@
+{
+ "module": "mesh-provisioner-client",
+ "version": "1",
+ "claims": [
+ {
+ "name": "node-provisioner",
+ "scope": "node"
+ }
+ ],
+ "own-secrets": {
+ "provisioner-broker": "${dir:state}/broker"
+ },
+ "secrets-owner": "mesh-provisioner-client",
+ "resources": [
+ {
+ "id": "account",
+ "type": "user",
+ "name": "mesh-provisioner-client",
+ "shell": "/usr/bin/nologin",
+ "home": "/var/lib/mesh-provisioner-client"
+ },
+ {
+ "id": "state",
+ "type": "directory",
+ "mode": "0700",
+ "owner": "mesh-provisioner-client",
+ "place": ".",
+ "unknown": "program"
+ },
+ {
+ "id": "client",
+ "type": "process",
+ "name": "mesh-provisioner-client",
+ "artifact": "client",
+ "run": [
+ "./mesh-provisioner-client"
+ ],
+ "env": {
+ "MESH_PROVISIONER_CREDENTIAL": "${dir:state}/broker",
+ "MESH_PROVISIONER_STATE": "${dir:state}"
+ },
+ "user": "mesh-provisioner-client",
+ "health": {
+ "kind": "unit"
+ }
+ }
+ ],
+ "build": {
+ "artifacts": [
+ {
+ "name": "client",
+ "kind": "bundle",
+ "language": "go",
+ "system": "arch",
+ "from": "cmd/mesh-provisioner-client",
+ "binary": "mesh-provisioner-client"
+ }
+ ]
+ }
+}
diff --git a/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/backups.conf b/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/backups.conf
new file mode 100644
index 0000000..2f23f90
--- /dev/null
+++ b/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/backups.conf
@@ -0,0 +1,2 @@
+# What the modules on this machine back up, composed by the mesh (novox/hq to-be 43). Do not edit.
+${backup:lines}
\ No newline at end of file
diff --git a/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/data.conf b/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/data.conf
new file mode 100644
index 0000000..f19e849
--- /dev/null
+++ b/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/data.conf
@@ -0,0 +1,2 @@
+# The data the modules on this machine declare, composed by the mesh (novox/hq ADR 0233). Do not edit.
+${backup:data}
\ No newline at end of file
diff --git a/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/retention.conf b/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/retention.conf
new file mode 100644
index 0000000..cd7954c
--- /dev/null
+++ b/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/retention.conf
@@ -0,0 +1,6 @@
+# How many restore points the night keeps, written by the mesh from this machine's settings keep-daily,
+# keep-weekly, keep-monthly (novox/hq issue 416) and keep-hand-taken-days (novox/hq issue 453). Replaced on every push; change the settings, never this file.
+daily=${config:keep-daily}
+weekly=${config:keep-weekly}
+monthly=${config:keep-monthly}
+hand=${config:keep-hand-taken-days}
diff --git a/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.module.json b/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.module.json
new file mode 100644
index 0000000..ea5ec8b
--- /dev/null
+++ b/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.module.json
@@ -0,0 +1,199 @@
+{
+ "module": "restic",
+ "version": "1",
+ "moved": true,
+ "claims": [
+ {
+ "name": "node-backup",
+ "scope": "node",
+ "serves": [
+ "backed-up",
+ "now",
+ "restore"
+ ]
+ }
+ ],
+ "emits": [
+ "backed-up"
+ ],
+ "tools": [
+ "backup_health",
+ "backup_delete_retired",
+ "backup_deleted"
+ ],
+ "own-secrets": {
+ "repository": "${dir:state}/repository.secret"
+ },
+ "data": {
+ "own": [
+ {
+ "id": "repository",
+ "path": "${dir:repository}",
+ "class": "rebuildable",
+ "backup": "none",
+ "why": "the restore points themselves: a copy of data kept elsewhere on this machine, never the only copy of anything; lost, the history goes and nothing live does"
+ },
+ {
+ "id": "state",
+ "path": "${dir:state}",
+ "class": "cache",
+ "why": "what the last nights and measurements were; the next night writes it again"
+ }
+ ]
+ },
+ "config": {
+ "keep-daily": {
+ "type": "number",
+ "description": "How many nightly restore points are kept, a count from 1 to 9999: two weeks undo a mistake noticed within a fortnight. Read by the night, which thins nothing while it does not read (novox/hq issue 416); lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
+ "minimum": 1,
+ "maximum": 9999,
+ "trusted": true,
+ "default": 14
+ },
+ "keep-weekly": {
+ "type": "number",
+ "description": "How many weekly restore points are kept behind the dailies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
+ "minimum": 1,
+ "maximum": 9999,
+ "trusted": true,
+ "default": 8
+ },
+ "keep-monthly": {
+ "type": "number",
+ "description": "How many monthly restore points are kept behind the weeklies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
+ "minimum": 1,
+ "maximum": 9999,
+ "trusted": true,
+ "default": 6
+ },
+ "keep-hand-taken-days": {
+ "type": "number",
+ "description": "How many days a restore point taken by hand (node-backup.now, or the mesh's before a module moves) is kept at least, a count from 1 to 9999; it is kept until a later good night of its module exists, and this many days in any case, so the next thinning never takes the one taken against a risky change (novox/hq issue 453). Lowering it lets go of the older ones taken by hand at the next thinning, and that cannot be undone.",
+ "minimum": 1,
+ "maximum": 9999,
+ "trusted": true,
+ "default": 7
+ }
+ },
+ "resources": [
+ {
+ "id": "state",
+ "type": "directory",
+ "mode": "0700",
+ "place": ".",
+ "unknown": "program"
+ },
+ {
+ "id": "repository",
+ "type": "directory",
+ "mode": "0700",
+ "unknown": "program"
+ },
+ {
+ "id": "declared",
+ "type": "file",
+ "path": "${dir:state}/backups.conf",
+ "mode": "0600",
+ "template": "files/backups.conf"
+ },
+ {
+ "id": "data-declared",
+ "type": "file",
+ "path": "${dir:state}/data.conf",
+ "mode": "0600",
+ "template": "files/data.conf"
+ },
+ {
+ "id": "retention",
+ "type": "file",
+ "path": "${dir:state}/retention.conf",
+ "mode": "0644",
+ "trusted": true,
+ "template": "files/retention.conf"
+ },
+ {
+ "id": "tool",
+ "type": "package",
+ "package": "restic"
+ },
+ {
+ "id": "sqlite",
+ "type": "package",
+ "package": "sqlite"
+ },
+ {
+ "id": "night",
+ "type": "process",
+ "name": "restic-night",
+ "artifact": "tools",
+ "run": [
+ "./restic-backups",
+ "night"
+ ],
+ "user": "${machine:account}",
+ "env": {
+ "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
+ "MESH_BACKUP_REPOSITORY": "${dir:repository}",
+ "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
+ "MESH_BACKUP_STATE": "${dir:state}",
+ "MESH_BACKUP_DATA": "${dir:state}/data.conf"
+ },
+ "health": {
+ "kind": "tool",
+ "tool": "backup_health",
+ "interval": "60s",
+ "timeout": "20s",
+ "looks": 2,
+ "grace": "60s"
+ }
+ },
+ {
+ "id": "measure",
+ "type": "process",
+ "name": "restic-measure",
+ "artifact": "tools",
+ "run": [
+ "./restic-backups",
+ "measure"
+ ],
+ "user": "${machine:account}",
+ "env": {
+ "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
+ "MESH_BACKUP_REPOSITORY": "${dir:repository}",
+ "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
+ "MESH_BACKUP_STATE": "${dir:state}",
+ "MESH_BACKUP_DATA": "${dir:state}/data.conf"
+ },
+ "health": {
+ "kind": "unit"
+ }
+ }
+ ],
+ "build": {
+ "artifacts": [
+ {
+ "name": "tools",
+ "kind": "bundle",
+ "language": "go",
+ "system": "arch",
+ "from": "cmd/restic-backups",
+ "binary": "restic-backups",
+ "loads": [
+ "restic-backups"
+ ],
+ "env": {
+ "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
+ "MESH_BACKUP_REPOSITORY": "${dir:repository}",
+ "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
+ "MESH_BACKUP_STATE": "${dir:state}",
+ "MESH_BACKUP_DATA": "${dir:state}/data.conf"
+ }
+ },
+ {
+ "name": "files",
+ "kind": "archive",
+ "from": "files"
+ }
+ ]
+ }
+}
diff --git a/internal/compose/frombus/testdata/de-spiegel-e25e1beb.module.json b/internal/compose/frombus/testdata/de-spiegel-e25e1beb.module.json
new file mode 100644
index 0000000..b4c4bd2
--- /dev/null
+++ b/internal/compose/frombus/testdata/de-spiegel-e25e1beb.module.json
@@ -0,0 +1,129 @@
+{
+ "module": "de-spiegel",
+ "version": "1",
+ "slug": "spiegel",
+ "capabilities": [
+ "container-runtime"
+ ],
+ "requires": [
+ "route",
+ "smtp"
+ ],
+ "contributes": {
+ "route": {
+ "site": {
+ "label": "de-spiegel",
+ "port": 35621
+ },
+ "praktijk": {
+ "name": "${config:praktijk-name}",
+ "port": 35621
+ },
+ "praktijk-www": {
+ "name": "${config:praktijk-www-name}",
+ "redirect": "https://${config:praktijk-name}"
+ },
+ "psychologie": {
+ "name": "${config:psychologie-name}",
+ "port": 35621
+ },
+ "psychologie-www": {
+ "name": "${config:psychologie-www-name}",
+ "redirect": "https://${config:psychologie-name}"
+ }
+ },
+ "smtp": {
+ "account": "de-spiegel"
+ }
+ },
+ "binds": {
+ "route": "/var/lib/de-spiegel/route.json",
+ "smtp": "/var/lib/de-spiegel/smtp.json"
+ },
+ "config": {
+ "praktijk-name": {
+ "type": "string",
+ "description": "The public name the practice's site is served on; the operator's, with no default (novox/hq ADR 0112).",
+ "trusted": true,
+ "required": true
+ },
+ "praktijk-www-name": {
+ "type": "string",
+ "description": "The public name that redirects to the practice's site (its www form); the operator's, with no default (novox/hq ADR 0112).",
+ "trusted": true,
+ "required": true
+ },
+ "psychologie-name": {
+ "type": "string",
+ "description": "The public name the psychology practice's site is served on; the operator's, with no default (novox/hq ADR 0112).",
+ "trusted": true,
+ "required": true
+ },
+ "psychologie-www-name": {
+ "type": "string",
+ "description": "The public name that redirects to the psychology practice's site (its www form); the operator's, with no default (novox/hq ADR 0112).",
+ "trusted": true,
+ "required": true
+ }
+ },
+ "listens": [
+ {
+ "port": 35621,
+ "protocol": "tcp",
+ "from": "mesh",
+ "why": "the de-spiegel site and its /contact endpoint over http; the practice's three public names are route grants, and the proxy reaches every one of them here"
+ }
+ ],
+ "resources": [
+ {
+ "id": "state",
+ "type": "directory",
+ "path": "/var/lib/de-spiegel",
+ "mode": "0700"
+ },
+ {
+ "id": "server-env",
+ "type": "file",
+ "path": "/var/lib/de-spiegel/server.env",
+ "mode": "0600",
+ "content": "SMTP_HOST=${bound:smtp:at}\nSMTP_PORT=${bound:smtp:port}\nSMTP_AUTH_USER=de-spiegel@${bound:smtp:domain}\nSMTP_AUTH_PASS=${secret:smtp}\n"
+ },
+ {
+ "id": "net",
+ "type": "network",
+ "name": "de-spiegel"
+ },
+ {
+ "id": "server",
+ "type": "container",
+ "name": "de-spiegel",
+ "artifact": "site",
+ "network": "de-spiegel",
+ "env-file": [
+ "/var/lib/de-spiegel/server.env"
+ ],
+ "ports": [
+ "35621"
+ ],
+ "secrets-in-environment": "the application's own code reads SMTP_AUTH_USER/PASS from the environment (server/index.js); converting is this repository's change"
+ }
+ ],
+ "build": {
+ "on": [
+ {
+ "arg": "NODE_BASE",
+ "image": "node@sha256:48e4b67d85f87bd551df43704e24d252f56cc5f8e9718841aace50f19948f0f9"
+ }
+ ],
+ "artifacts": [
+ {
+ "name": "site",
+ "kind": "image",
+ "from": "Dockerfile"
+ }
+ ]
+ },
+ "secrets": {
+ "smtp": "/var/lib/de-spiegel/smtp.secret"
+ }
+}
diff --git a/internal/compose/frombus/testdata/f1-circuits-c0ea2c94.module.json b/internal/compose/frombus/testdata/f1-circuits-c0ea2c94.module.json
new file mode 100644
index 0000000..8f2b612
--- /dev/null
+++ b/internal/compose/frombus/testdata/f1-circuits-c0ea2c94.module.json
@@ -0,0 +1,82 @@
+{
+ "module": "f1-circuits",
+ "slug": "f1",
+ "version": "1",
+ "capabilities": [
+ "container-runtime"
+ ],
+ "requires": [
+ "route"
+ ],
+ "contributes": {
+ "route": {
+ "label": "f1-circuits",
+ "endpoint": "web"
+ }
+ },
+ "binds": {
+ "route": "${dir:state}/route.json"
+ },
+ "listens": [
+ {
+ "name": "web",
+ "port": 8000,
+ "protocol": "tcp",
+ "from": "mesh",
+ "why": "the track-layout CDN, the race posters and the Kometa metadata over http; its public name is a route grant and the proxy reaches it here. Kometa fetches the metadata and every poster by that public name"
+ }
+ ],
+ "resources": [
+ {
+ "id": "state",
+ "type": "directory",
+ "mode": "0700",
+ "place": "."
+ },
+ {
+ "id": "cache",
+ "type": "directory",
+ "mode": "0700"
+ },
+ {
+ "id": "site-env",
+ "type": "file",
+ "path": "${dir:state}/site.env",
+ "mode": "0644",
+ "content": "F1_CIRCUITS_BASE_URL=https://${bound:route:name}\n"
+ },
+ {
+ "id": "server",
+ "type": "container",
+ "name": "f1-circuits",
+ "artifact": "cdn",
+ "ports": [
+ "8000"
+ ],
+ "env-file": [
+ "${dir:state}/site.env"
+ ],
+ "volumes": [
+ "${dir:cache}:/app/cache"
+ ],
+ "restart-on": [
+ "site-env"
+ ]
+ }
+ ],
+ "build": {
+ "on": [
+ {
+ "arg": "PYTHON_BASE",
+ "image": "python@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df"
+ }
+ ],
+ "artifacts": [
+ {
+ "name": "cdn",
+ "kind": "image",
+ "from": "service/Dockerfile"
+ }
+ ]
+ }
+}
diff --git a/internal/compose/frombus/twice.go b/internal/compose/frombus/twice.go
new file mode 100644
index 0000000..cb133df
--- /dev/null
+++ b/internal/compose/frombus/twice.go
@@ -0,0 +1,147 @@
+package frombus
+
+// **A node's plans at two sets of builds, over one read** (issue 1954; novox/hq ADR 0570 clause 2, ADR 0580 clause 2):
+// the seat mesh-provisioner's holder composes each node at the builds its wants name and at the newest build of each
+// placed module. Both are composed from one read of the bus (placement, records, the indexer's modules and manifests,
+// values: base); only the builds the node's modules are composed from differ, each build read once for both, and a
+// build taken in once per content by a reader that keeps it (StatesReader). Where the time went is said by phase.
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "sync"
+ "time"
+
+ "git.novox.be/novox/mesh-sdk/go/catalogue"
+ "git.novox.be/novox/mesh-sdk/go/delivery"
+)
+
+// HeldPlans is one scope's plans, as PlansHeld answers them: err a refusal that is no single module's.
+type HeldPlans struct {
+ Plans map[string]catalogue.Plan
+ Held map[string]string
+ Err error
+}
+
+// Phase is one step of a composition and how long it took.
+type Phase struct {
+ Name string `json:"phase"`
+ Took time.Duration `json:"took"`
+}
+
+// PlansHeldTwice is PlansHeld at s and at s with no kept builds (each placed module at its newest build), over one
+// read of the bus: what each answers is what PlansHeld answers for that scope. A read that fails is both scopes' err.
+// took names each phase in order: the read, then each scope's builds and its plans.
+func PlansHeldTwice(ctx context.Context, r Reader, mesh Mesh, s Scope) (at, newest HeldPlans, took []Phase) {
+ start := time.Now()
+ mark := func(name string) {
+ now := time.Now()
+ took = append(took, Phase{Name: name, Took: now.Sub(start)})
+ start = now
+ }
+ b, err := readBase(ctx, r, mesh, s)
+ mark("read")
+ builds := onceBuilds(r)
+ newestScope := s
+ newestScope.KeptAt = nil
+ for _, sc := range []struct {
+ scope Scope
+ into *HeldPlans
+ name string
+ }{{s, &at, "wanted"}, {newestScope, &newest, "newest"}} {
+ held := map[string]string{}
+ sc.into.Held = held
+ refuse := func(module string, err error) { held[module] = err.Error() }
+ b.refuseOwn(refuse)
+ if err != nil {
+ sc.into.Err = err
+ continue
+ }
+ in, err := b.finish(ctx, r, builds, mesh, sc.scope, b.cloned(), refuse, nil)
+ mark("builds at " + sc.name)
+ if err != nil {
+ sc.into.Err = err
+ continue
+ }
+ sc.into.Plans, sc.into.Err = plansHeldOf(in, s.Node, held)
+ mark("plans at " + sc.name)
+ }
+ return at, newest, took
+}
+
+// onceBuilds is r's Build, each module's build at a fingerprint read once: both scopes of one composition read the
+// builds of the same moment.
+func onceBuilds(r Reader) func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) {
+ type answer struct {
+ b *delivery.BuilderBuild
+ err error
+ }
+ read := map[string]answer{}
+ return func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) {
+ key := module + " " + fp
+ if a, ok := read[key]; ok {
+ return a.b, a.err
+ }
+ b, err := r.Build(ctx, module, fp)
+ read[key] = answer{b, err}
+ return b, err
+ }
+}
+
+// takenInKeeper is a Reader that keeps what a build's take-in made (StatesReader does): the resolved manifest's bytes,
+// by the content it was made from, so a composition made again on every change takes in only a build it has not seen.
+type takenInKeeper interface {
+ takenIn(key string) ([]byte, bool)
+ keepTakenIn(key string, raw []byte)
+}
+
+// takenInKey is what a take-in is made from: the artifact store's address and the build's record (its manifest's digest, its artifacts by digest), read
+// whole; an archive is checked against its digest on every read, so the same record takes in to the same bytes.
+func takenInKey(module, fp, address string, b delivery.BuilderBuild) (string, bool) {
+ raw, err := json.Marshal(b)
+ if err != nil {
+ return "", false
+ }
+ sum := sha256.Sum256(append([]byte(module+" "+fp+" "+address+" "), raw...))
+ return hex.EncodeToString(sum[:]), true
+}
+
+// takenInAtMost bounds the take-ins a reader keeps; past it they are emptied, which costs only a take-in again.
+const takenInAtMost = 512
+
+// keptTakeIns is a reader's kept take-ins.
+type keptTakeIns struct {
+ mu sync.Mutex
+ kept map[string][]byte
+}
+
+func (k *keptTakeIns) takenIn(key string) ([]byte, bool) {
+ k.mu.Lock()
+ defer k.mu.Unlock()
+ raw, ok := k.kept[key]
+ return raw, ok
+}
+
+func (k *keptTakeIns) keepTakenIn(key string, raw []byte) {
+ k.mu.Lock()
+ defer k.mu.Unlock()
+ if k.kept == nil || len(k.kept) >= takenInAtMost {
+ k.kept = map[string][]byte{}
+ }
+ k.kept[key] = raw
+}
+
+// PhasesSaid says the phases in one line, each in milliseconds.
+func PhasesSaid(took []Phase) string {
+ out := ""
+ for i, p := range took {
+ if i > 0 {
+ out += ", "
+ }
+ out += fmt.Sprintf("%s %d ms", p.Name, p.Took.Milliseconds())
+ }
+ return out
+}
diff --git a/internal/compose/fromruntime/fromruntime.go b/internal/compose/fromruntime/fromruntime.go
new file mode 100644
index 0000000..b9fab99
--- /dev/null
+++ b/internal/compose/fromruntime/fromruntime.go
@@ -0,0 +1,101 @@
+// Package fromruntime is compose/frombus's StatesReader over a bundle's runtime (the lead, 2026-10-29): the states a
+// module's manifest names in `reads` (frombus.StatesReads), read through stdio.State, and a templates archive fetched
+// over HTTP. Apart from frombus so that frombus stays pure for the mesh-compose command.
+package fromruntime
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "sync"
+
+ stdio "git.novox.be/novox/mesh-sdk/go"
+ "github.com/novox/mesh-host/internal/compose/frombus"
+)
+
+// States are a bundle's read states through its runtime.
+type States struct{}
+
+// Get is a key's value, false when it has none.
+func (States) Get(state, key string) ([]byte, bool, error) {
+ e, err := stdio.State(state).Get(key)
+ if err != nil || e == nil {
+ return nil, false, err
+ }
+ return e.Value, true, nil
+}
+
+// Keys are a state's keys.
+func (States) Keys(state string) ([]string, error) { return stdio.State(state).Keys() }
+
+// Reader is frombus's Reader over this bundle's read states key by key, fetching archives over HTTP. A holder that
+// composes again and again reads through Watched instead (novox/hq ADR 0580).
+func Reader() frombus.Reader { return frombus.StatesReader(States{}, HTTPFetch) }
+
+// Watched is frombus's States over a watched copy (novox/hq ADR 0580 clause 1): the states a composition reads whole
+// (placement, haves, modules, resolved values) from the copy, a build by key when asked, a manifest once per digest.
+type Watched struct{ *stdio.WatchedStates }
+
+// Get is a key's value: a build read now, a manifest kept once read, any other state's from the copy.
+func (w Watched) Get(state, key string) ([]byte, bool, error) {
+ switch state {
+ case frombus.BuildsRead:
+ return w.Direct(state, key)
+ case frombus.ManifestsRead:
+ return w.Once(state, key)
+ }
+ return w.WatchedStates.Get(state, key)
+}
+
+// WatchedReader is frombus's Reader over w, fetching each templates archive over HTTP once (KeptFetch): a composition
+// made again on every change reads it again otherwise.
+func WatchedReader(w *stdio.WatchedStates) frombus.Reader {
+ return frombus.StatesReader(Watched{w}, KeptFetch(HTTPFetch))
+}
+
+// archivesAtMost bounds the archives KeptFetch keeps; past it they are emptied, which costs only a fetch again.
+const archivesAtMost = 64
+
+// KeptFetch is fetch kept once per URL: an archive's URL names its blob by digest, so its body never changes under it
+// (frombus checks the digest on every read). A failed fetch is not kept.
+func KeptFetch(fetch frombus.Fetch) frombus.Fetch {
+ var mu sync.Mutex
+ kept := map[string][]byte{}
+ return func(ctx context.Context, url string) ([]byte, error) {
+ mu.Lock()
+ body, ok := kept[url]
+ mu.Unlock()
+ if ok {
+ return body, nil
+ }
+ body, err := fetch(ctx, url)
+ if err != nil {
+ return nil, err
+ }
+ mu.Lock()
+ if len(kept) >= archivesAtMost {
+ clear(kept)
+ }
+ kept[url] = body
+ mu.Unlock()
+ return body, nil
+ }
+}
+
+// HTTPFetch reads an archive over HTTP, at most 256 MiB.
+func HTTPFetch(ctx context.Context, url string) ([]byte, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("%s answered %s", url, resp.Status)
+ }
+ return io.ReadAll(io.LimitReader(resp.Body, 256<<20))
+}
diff --git a/internal/compose/input.go b/internal/compose/input.go
new file mode 100644
index 0000000..72aecf7
--- /dev/null
+++ b/internal/compose/input.go
@@ -0,0 +1,365 @@
+// Package compose is the controller's composition of a machine, as a function over a plain input (novox/hq ADR 0453
+// clause 3, ADR 0446 clause 4): what the rest of the mesh offers and holds, the private network's generator, one
+// node's resolution and, in the issues after, its rendering and its declaration.
+//
+// **It never allocates, makes, writes, logs or raises.** The controller composes with a store behind it; this
+// package is its Reading and Foreseeing modes over data. Everything the controller reads from its store is a field of
+// Input, and everything a send would make or record (a port, a credential, a seat passed on, a node left out) is
+// returned for the caller to act on, never done. Input is never changed by a call. No field holds a secret value
+// the composition needs: sealed values are optional inputs, and without them the composition uses stand-ins named
+// foreseen. The logic is the controller's, ported from mesh-controller cmd/mesh-controller at commit
+// 6c2a8d62100cb7d491f8eb9a623e1eeea5ec0de1 (plan.go, network.go, holdings.go, seat_pass.go), in the same order, with the
+// same refusals in the same words, so that a comparison node for node is a fair one.
+//
+// It imports the standard library and mesh-sdk's own packages only.
+// It reads no environment variable: the range the mesh allocates from and the suffix of its internal names are fields of Input.
+package compose
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "sort"
+ "strings"
+
+ "git.novox.be/novox/mesh-sdk/go/catalogue"
+ "git.novox.be/novox/mesh-sdk/go/delivery"
+)
+
+// Input is everything the composition reads. Each field is what one read of the controller's inventory answers, named
+// beside it; the controller's adapter fills it from its stores and the assigner from the mesh's facts and buckets
+// (ADR 0446 clause 2).
+type Input struct {
+ // Catalogue is the shelf: every module's manifest by name (inv.Catalogue), as it is to be composed. A recorded
+ // module kept at the build a machine runs (keepRecorded) is that node's Kept, applied to its own resolution only (never to the rest of the mesh).
+ Catalogue map[string]catalogue.Manifest
+ // Nodes is every node the mesh has (inv.Nodes and inv.Overlays together, one row per node).
+ Nodes []Node
+ // Holdings is every seat whose holder is on record (inv.Holdings).
+ Holdings []catalogue.Held
+ // Licences is every licence the mesh holds, with the machines that use it (open.Licences); none for a mesh with no
+ // licence.
+ Licences []Licence
+
+ // OverlayCIDR is the range the mesh allocates from when no tunnel was adopted, as genesis was told it (the
+ // controller's OverlayCIDRVar); empty means DefaultOverlayCIDR. The package reads no environment.
+ OverlayCIDR string
+ // InternalSuffix is what the mesh's internal names end in (the controller's MESH_INTERNAL_SUFFIX), with or without a leading
+ // dot; empty means overlay.DefaultSuffix. A fact of the whole mesh, so it is in the Input and never read from the environment.
+ InternalSuffix string
+ // Tunnels is, by node name, the found tunnel whose key is the node's overlay key (inv.Tunnels).
+ Tunnels map[string]FoundTunnel
+ // Adopted is the hub's found tunnel and the hub's name, whether or not the hub took it (inv.AdoptedTunnel); nil
+ // when no hub has one.
+ Adopted *AdoptedTunnel
+ // CarriedPeers is every peer of the adopted tunnel (inv.CarriedPeers).
+ CarriedPeers []CarriedPeer
+ // Joining is the nodes that hold a live enrolment token (inv.NodesWithALiveToken).
+ Joining []string
+
+ // Credentials is how each pair credential a node takes from elsewhere stands, never its value (what inv.SecretFor,
+ // SharedSecretFor and GivenPairSecret would answer): held, to be made by the next send, only a person's to give, or
+ // refused. A need with no entry here is one the next send would make.
+ Credentials []Credential
+
+ // Built is every repository the mesh has built (the keys of inv.Held): what tells a reference recorded with an address
+ // from an image a module runs straight from a public registry.
+ Built []string
+ // OperatorExport is every operator-sealed secret in the mesh, for a module that keeps them (inv.OperatorExport): sealed
+ // values the composition carries without opening. Nil when the mesh has no operator key.
+ OperatorExport *catalogue.KeptExport
+ // KeptRuns is, by node and module, the file of the export as that node runs it (its resource of a module's running
+ // composition): what a node composing its own carries where it holds no OperatorExport (catalogue.Rendering.KeptRuns,
+ // issue 1126, C6). Used only while OperatorExport is nil.
+ KeptRuns map[string]map[string]map[string]any
+
+ // Issued is the credentials made for the consumers of each provider (inv.SecretsFrom): one row for each pair credential
+ // that exists, by the provider node it is for. The grants a provider must create are read from these, so a credential not
+ // made yet has no grant until the send that makes it. An assigner fills it from the facts snapshot's Grants of each
+ // machine (go/facts, from format 2), which carry who each credential is for and never the credential.
+ Issued []Issued
+ // OwnSecrets is the own secrets held on each machine (the module_secret rows): who made each, and the key it is sealed to.
+ // Their sealed values are optional.
+ OwnSecrets []OwnSecretRow
+ // KeptCoreBoxes is the vault's box last kept for each core credential on each machine (inv.KeptCoreBoxes).
+ KeptCoreBoxes []CoreBox
+ // BusUsers is the bus's user list as the controller composes it from the bus records and the hashes it holds (composeBusUsers),
+ // for the machine that runs the bus: names and hashes, never passwords. Empty when the Input does not carry it, and then
+ // the composition uses a stand-in, named foreseen. BusMissing are the users left out of it for want of a credential.
+ BusUsers string
+ BusMissing []string
+ // BusMinted are the bus users the mesh has minted a password hash for (inv.BusUserHash): `.` for a module and
+ // `provisioner.` for a machine's provisioner client.
+ BusMinted []string
+ // BusMemberships is, by node, the membership sealed to it for the bus the mesh is moving to (inv.BusMemberships).
+ BusMemberships map[string]string
+ // Authority is the mesh's own certificate (identity.EstablishAuthority), public. Optional.
+ Authority string
+
+ // CurrentBuilds is, by module, the commit of the build the mesh holds for it (inv.CurrentBuilds, the commit it was built from);
+ // BuildIDs the build made from a commit of a module (inv.BuildOf), by module then commit. Read only to name the builds a
+ // declaration carries to a node-engine that reads them.
+ CurrentBuilds map[string]string
+ BuildIDs map[string]map[string]string
+
+ // Refused is each module whose manifest the indexer's judge refused, or whose bytes do not decode, by module, with why
+ // (novox/hq ADR 0578 §3, §5): its verdict, carried in the plain input. Such a module stands in Catalogue as its stub
+ // (compose.Stub), so it is held: nothing of its own is composed, while what it provides and requires still shapes the
+ // rest. A module not named is accepted, a missing verdict included.
+ Refused map[string]string `json:",omitempty"`
+ // SetAside is each module whose manifest has a field the reader's decoder does not know, by module, with what was set
+ // aside (catalogue.Manifest.SetAside): the rest of it is composed, and the reader names it (ADR 0578 §6).
+ SetAside map[string]string `json:",omitempty"`
+}
+
+// Issued is one pair credential made for a consumer of a provider (inv.Secret): ForProvider is its copy sealed to the
+// provider, optional.
+type Issued struct {
+ Provision string
+ Consumer string
+ ConsumerModule string
+ Local string
+ Provider string
+ ForProvider string
+}
+
+// OwnSecretRow is one own secret held on a machine: Origin is `made` (the mesh generated it) or `accepted` (a person gave it),
+// SealedTo the sealing key it is sealed to, and Sealed its value sealed to that key, optional.
+type OwnSecretRow struct {
+ Node string
+ Module string
+ Name string
+ Origin string
+ SealedTo string
+ Sealed string
+}
+
+// CoreBox is the vault's box for one core credential of one machine, as last kept (inv.CoreBox).
+type CoreBox struct {
+ Node string
+ Module string
+ Name string
+ Generation int
+ SealedTo string
+ Box json.RawMessage
+}
+
+// Node is one node of the mesh and what the composition reads about it.
+type Node struct {
+ Name string
+ // Place is where it is on the private network (inv.Overlays).
+ Place Place
+ // Assigned is the modules assigned to it, in the order the controller reads them (inv.Assigned).
+ Assigned []string
+ // Handed is, by module, whether the vault judged it handed to the provisioner when it answered this node's own
+ // secrets (catalogue.AssignmentHands of the plan in its assignment): the node's own composition hands exactly those
+ // (catalogue.Rendering.Assigned, issue 774). Nil where the vault said nothing of it, as for the controller.
+ Handed map[string]bool
+ // Capabilities are the ones present on it (inv.ProfileOf).
+ Capabilities map[string]bool
+ // PublicDomain is the domain it composes its routed names under (inv.PublicDomainOf).
+ PublicDomain string
+ // Account, AccountHome, AgentAccount and AgentAccountHome are its operator and agent logins (inv.NodeByName).
+ Account, AccountHome, AgentAccount, AgentAccountHome string
+ // Adopted is whether it is adopted rather than converged.
+ Adopted bool
+ // Pins is the provider each provision was pinned to for it (inv.PinsFor).
+ Pins map[string]catalogue.Chosen
+ // Bound is where each of its consumers of a provision that keeps data was last sent (inv.BindingsFor), by consumer
+ // module and provision. An assigner fills it from the facts snapshot's Bindings of the machine (go/facts, from format 2).
+ Bound map[string]map[string]catalogue.Chosen
+ // Settings is its settings layers by module (inv.SettingsFor); a module with none is absent.
+ Settings map[string][]catalogue.Layer
+ // Ports is the machine port each module's software port was given on it (inv.PortsFor), by module then port.
+ Ports map[string]map[int]int
+ // SealingKey is the public key its secrets are sealed to (inv.SealingKeyOf); empty when it has none. A public key, so
+ // nothing secret.
+ SealingKey string
+ // Taken is the modules taken on it, for an adopted node (inv.Taken).
+ Taken []string
+ // OutwardLinks are the links it reported as facing outside it (inv.OutwardLinksOf).
+ OutwardLinks []string
+ // Networks are the networks it reported being attached to, as link and network (inv.MachineOf); NetworksSaid is whether
+ // its node-engine said any at all, which is not the same as saying none.
+ Networks []catalogue.AttachedNetwork
+ NetworksSaid bool
+ // ServingKey is the key the machine serves with, which a certificate is issued for (identity.ServingKeyOf); empty when it has
+ // never reported one. Certificate is the certificate the mesh issued for its internal name, public and optional.
+ ServingKey string
+ Certificate string
+ // Sequence, Epoch and Generation are what the declaration is sent under (inv.Sequence, SentEpoch, SentGeneration): the order
+ // against every other send to the node, the lease epoch and the assignment generation it was last sent. Zero is not sent.
+ Sequence int64
+ Epoch uint64
+ Generation int64
+ // ReadsModules says its node-engine reads a module per resource and a build per module (inv.ReadsModules, issue 1145); only
+ // then are they sent. SentBuilds is the build of each module it was last sent, by module to commit, and SentKnown whether
+ // that is known at all (inv.SentBuilds); Runs is what each of its assignments runs (inv.AssignmentRuns), by module.
+ ReadsModules bool
+ SentBuilds map[string]string
+ SentKnown bool
+ Runs map[string]Run
+ // Kept is, by module, the manifest of a recorded or core module kept at the build this machine runs (plan.go keepRecorded:
+ // the caller reads it with recordedKept and ManifestAt), and KeptBuilds the commit of that build. They apply to this node's
+ // own resolution only, never to another node's, and a declaration carries the kept commit for the module.
+ Kept map[string]catalogue.Manifest
+ KeptBuilds map[string]string
+ // Contract is the contract of its node-engine's newest statement (inv.HealthOf); Stated is whether it ever stated one.
+ Contract int
+ Stated bool
+ // InOwn is the modules it runs inside its own composition (`.node`), not by a composition of their own, since
+ // no fingerprint of the new form names them where it runs them (novox/hq issue 1126): Compositions folds their
+ // resources into the node's own, as the controller's composed writer does, unless fps gives one of the new form.
+ // The private network's module (overlay.Name), which nobody builds, is folded whether or not it is named here.
+ InOwn map[string]bool
+ // RunsOwn is the node's own composition (`.node`) as it runs, when the node composes itself: a module folded
+ // there (InOwn) whose manifest names a build (NamesBuild) runs a build of the old form only the controller holds,
+ // so it is left to the node: Compositions composes it as a stub (Stub) and carries its resources as they run, each
+ // after the resource it runs after (issue 1126). Nil composes every folded module from its manifest, as before.
+ RunsOwn *delivery.Composed
+ // Held is the modules refused alone on this node, each composed as what runs of it (Carried, in Kept; its Stub when
+ // nothing of it runs) so the rest is composed (novox/hq issue 1126): none of a held module's own composition is in what
+ // Compositions answers, so the node keeps what it last applied of it; one whose resources the node's own runs
+ // (RunsOwn), folded or leaving the fold, is carried there as it runs, whatever its manifest names, so the node's own
+ // never drops it.
+ Held map[string]bool
+ // RunsTools is, by module, the tools bundles the node's runtime serves of it as it runs (ToolsRunning of what the
+ // node runs); nil when not known. A module composed as what runs of it (its Stub or Carried: held, or folded at a
+ // build of the old form) contributes these to the runtime, never its manifest's: a manifest no build resolved has no
+ // bundles, and a held module's new build's bundles are not delivered (issue 1126, 2026-10-29: shanks's runtime lost
+ // the folded systemd-resolved, novox's the folded nats).
+ RunsTools map[string][]catalogue.Bundle
+}
+
+// Place is where a node is, as its placement says (inv.Overlay).
+type Place struct {
+ Key string
+ Endpoint string
+ Site string
+ Hub bool
+ Address string
+}
+
+// Reachable says whether the node can be dialled.
+func (p Place) Reachable() bool { return strings.TrimSpace(p.Endpoint) != "" }
+
+// Tunnel is a tunnel found on a node (inv.Tunnel).
+type Tunnel struct {
+ Interface string
+ Unit string
+ Config string
+ Port int
+ MTU int
+ // Address is the interface's own address with its prefix length, Range the network that prefix names.
+ Address string
+ Range string
+ PublicKey string
+ Peers []TunnelPeer
+}
+
+// TunnelPeer is one peer of a found tunnel.
+type TunnelPeer struct {
+ PublicKey string
+ Address string
+}
+
+// FoundTunnel is a node's tunnel and whether the node is adopted (inv.FoundTunnel).
+type FoundTunnel struct {
+ Tunnel
+ NodeAdopted bool
+}
+
+// AdoptedTunnel is the hub's tunnel, the hub's name, and whether the hub's overlay key is the tunnel's, which is
+// whether the private network took the tunnel over.
+type AdoptedTunnel struct {
+ Tunnel Tunnel
+ Hub string
+ Adopted bool
+}
+
+// CarriedPeer is a peer of the adopted tunnel (inv.CarriedPeer).
+type CarriedPeer struct {
+ PublicKey string
+ Address string
+ EnrolledAs string
+ Named string
+}
+
+// Licence is one licence the mesh holds: its name, what a consumer may know of it, and the machines and modules
+// that use it.
+type Licence struct {
+ Name string
+ Serves map[string]any
+ Holders []Holder
+ // Keys is, by holder, the licence's key sealed to that holder's machine (held.KeyFor), empty where the mesh holds none:
+ // the plaintext was discarded when it was supplied. Optional: nil means not given, and the composition uses a stand-in named
+ // foreseen; given (even empty), a holder with no entry is one the mesh holds no key for.
+ Keys map[Holder]string
+ // Manager is the holder of a refreshable-grant licence that manages it (held.ManagerOf); the zero Holder when none.
+ Manager Holder
+}
+
+// Holder is a module on a node that uses a licence.
+type Holder struct {
+ Node string
+ Module string
+}
+
+// ErrNoSuchNode is what the controller's inventory answers for a node it does not have.
+var ErrNoSuchNode = errors.New("no node of that name")
+
+// node is the node of that name, or the refusal the controller's inventory gives for one it does not have.
+func (in Input) node(name string) (Node, error) {
+ for _, n := range in.Nodes {
+ if n.Name == name {
+ return n, nil
+ }
+ }
+ return Node{}, fmt.Errorf("%w: %s", ErrNoSuchNode, name)
+}
+
+// sorted is the nodes by name, the order the controller reads them in.
+func (in Input) sorted() []Node {
+ out := append([]Node(nil), in.Nodes...)
+ sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
+ return out
+}
+
+// CredentialState is how one pair credential stands.
+type CredentialState string
+
+const (
+ // CredentialHeld is a credential held for both ends as they stand now. Sealed, when given, is the consumer's copy; a held
+ // credential with no value is composed with a stand-in, named foreseen.
+ CredentialHeld CredentialState = "held"
+ // CredentialWouldMake is a credential none is held for: the next send makes it, and the composition uses a stand-in,
+ // named foreseen. It is also what a need with no entry is.
+ CredentialWouldMake CredentialState = "would-make"
+ // CredentialNotGiven is a credential only a person gives (novox/hq ADR 0337 clause 4) that nobody has given, or that was given
+ // under a key one end no longer has: the consumer still resolves, without it. Why is the controller's words for it.
+ CredentialNotGiven CredentialState = "not-given"
+ // CredentialRefused is a credential the send would refuse to make, for the reason Why gives in the controller's words: a
+ // value a person gave sealed to a key the machine no longer has, a machine with no sealing key.
+ CredentialRefused CredentialState = "refused"
+)
+
+// Credential is the state of one pair credential, by the ends it joins: the provision, the consumer's node and module, the
+// provider's node, and the local name where a module keeps several (inv.Secret's key).
+type Credential struct {
+ Provision string
+ Consumer string
+ ConsumerModule string
+ Provider string
+ Local string
+ State CredentialState
+ // Sealed is the consumer's copy as it is sealed to the consumer's key; optional, and only the controller's adapter passes it.
+ Sealed string
+ // Why is the words of a CredentialNotGiven or CredentialRefused.
+ Why string
+}
+
+// Run is the build an assignment names: the commit it is of and the build made from it (chain.Ref).
+type Run struct {
+ Commit string
+ Build string
+}
diff --git a/internal/compose/network.go b/internal/compose/network.go
new file mode 100644
index 0000000..4e11abf
--- /dev/null
+++ b/internal/compose/network.go
@@ -0,0 +1,308 @@
+package compose
+
+import (
+ "errors"
+ "fmt"
+ "slices"
+ "sort"
+ "strconv"
+ "strings"
+
+ "git.novox.be/novox/mesh-sdk/go/catalogue"
+ "github.com/novox/mesh-host/internal/compose/resolution"
+ "github.com/novox/mesh-host/internal/overlay"
+)
+
+// The private network: who is on it, where, and what they are called. Ported from mesh-controller
+// cmd/mesh-controller/network.go at 6c2a8d62.
+
+// DefaultOverlayCIDR is the range the mesh allocates from when nothing says another.
+const DefaultOverlayCIDR = "10.42.0.0/16"
+
+// composer is one composition over an Input: it reads Input, changes nothing in it, and keeps what the controller
+// said on its standard error as lines to return.
+type composer struct {
+ in Input
+ said []string
+ seen map[string]bool
+ // unhostable is, from the last look at the rest of the mesh, each module assigned to another node that this
+ // composition finds that node cannot run: what it would have offered is missing here (issue 1126, probe #532).
+ unhostable []string
+
+ // What one composition looks at again and again, each worked out once over its Input, which it never changes (issue
+ // 1954: a node's plans resolved every machine of the mesh some two hundred times, 11.7 s of CPU for novox): who is
+ // on the private network, and the rest of the mesh as each node sees it.
+ onNetwork []Node
+ onNetworkSet bool
+ rest map[string]restOf
+ plans map[string]planOf
+}
+
+// restOf is theRestOfTheMesh's answer for one excluded node, kept: what it found unhostable beside it.
+type restOf struct {
+ world catalogue.World
+ passes Passes
+ left []LeftOut
+ unhostable []string
+ err error
+}
+
+func newComposer(in Input) *composer { return &composer{in: in, seen: map[string]bool{}} }
+
+// say keeps a line the controller would have written to its log, once.
+func (c *composer) say(format string, args ...any) {
+ line := fmt.Sprintf(format, args...)
+ if c.seen[line] {
+ return
+ }
+ c.seen[line] = true
+ c.said = append(c.said, line)
+}
+
+// suffix is what the mesh's internal names end in: the Input's, and overlay.DefaultSuffix when it gives none. The package never calls
+// overlay.Suffix or overlay.InternalName, which read the environment, so the assigner and the controller compose the same names
+// from the same Input.
+func (c *composer) suffix() string {
+ if v := strings.TrimPrefix(strings.TrimSpace(c.in.InternalSuffix), "."); v != "" {
+ return v
+ }
+ return overlay.DefaultSuffix
+}
+
+// internalName is a node's name inside the mesh.
+func (c *composer) internalName(node string) string { return node + "." + c.suffix() }
+
+// saidBeside is the lines already said and then every line the composer kept, once each, in the order they were found.
+func (c *composer) saidBeside(lines []string) []string {
+ for _, l := range c.said {
+ if !slices.Contains(lines, l) {
+ lines = append(lines, l)
+ }
+ }
+ return lines
+}
+
+// overlayRange is the range the mesh allocates node addresses from: the adopted tunnel's first (novox/hq ADR 0105),
+// then the range genesis was told, then the default.
+func (c *composer) overlayRange() string {
+ if a := c.in.Adopted; a != nil && a.Adopted {
+ return a.Tunnel.Range
+ }
+ if v := strings.TrimSpace(c.in.OverlayCIDR); v != "" {
+ return v
+ }
+ return DefaultOverlayCIDR
+}
+
+// network builds the private network over the machines that resolved the module for it. A machine is on it because it
+// was given the module, or because it is joining with a key and an address (novox/hq ADR 0169), or because its own set
+// could not be resolved and it holds a place (a key and an address): that place is its last good one on the network, and
+// it does not change because its modules failed, so every other machine keeps its path to it -- the hub above all -- and
+// only its own declaration is refused (issue 1833). A machine refused with no place is left off.
+func (c *composer) network(on map[string]bool, refused map[string]string) (*overlay.Generator, error) {
+ tunnels := c.in.Tunnels
+ carried := c.in.CarriedPeers
+ isJoining := map[string]bool{}
+ for _, name := range c.in.Joining {
+ isJoining[name] = true
+ }
+ var nodes []overlay.Node
+ for _, node := range c.in.sorted() {
+ p := node.Place
+ placed := p.Key != "" && p.Address != ""
+ _, wasRefused := refused[node.Name]
+ if !on[node.Name] && !((isJoining[node.Name] || wasRefused) && placed) {
+ continue
+ }
+ if !on[node.Name] && wasRefused && !isJoining[node.Name] {
+ c.say("%s could not be resolved and keeps its place on the private network at %s, so the others keep their path to it",
+ node.Name, p.Address)
+ }
+ n := overlay.Node{
+ Name: node.Name, Key: p.Key, Endpoint: p.Endpoint,
+ Site: p.Site, Hub: p.Hub, Address: p.Address,
+ }
+ if t, takes := tunnels[node.Name]; takes && t.NodeAdopted {
+ // Only an adopted node is told to take the found unit over. A hub whose placement disagrees with the tunnel is
+ // refused, not composed.
+ if wrong := disagrees(p, t.Tunnel); wrong != "" {
+ return nil, fmt.Errorf("%s takes over the tunnel on %s and its placement disagrees with it: %s. "+
+ "Re-place it — `overlay place %s --hub --endpoint :%d …` — and push again; "+
+ "nothing was composed", node.Name, t.Interface, wrong, node.Name, t.Port)
+ }
+ n.TakesOver = &overlay.TakeOver{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU}
+ }
+ if p.Hub {
+ for _, cp := range carried {
+ n.Carried = append(n.Carried, overlay.Carried{Key: cp.PublicKey, Address: cp.Address})
+ }
+ }
+ nodes = append(nodes, n)
+ }
+ if len(nodes) == 0 {
+ // Nobody was given it: an empty network is a legitimate mesh, not a broken one.
+ return overlay.Empty(), nil
+ }
+ g, err := overlay.From(nodes, c.overlayRange(), "")
+ if err != nil && len(refused) > 0 {
+ // The network is missing something, and some machines could not be resolved at all: almost always the same fact.
+ var who []string
+ for name, why := range refused {
+ who = append(who, fmt.Sprintf(" %s: %s", name, why))
+ }
+ sort.Strings(who)
+ return nil, fmt.Errorf("%w\n\nand %d node(s) could not be resolved at all, which is "+
+ "probably why:\n%s", err, len(refused), strings.Join(who, "\n"))
+ }
+ return g, err
+}
+
+// Graph is the whole mesh's network, for showing it: the nodes on it and each one's peers.
+func Graph(in Input) ([]overlay.Node, overlay.Graph, error) {
+ c := newComposer(in)
+ on, refused, err := c.whoResolves(overlay.Requirement)
+ if err != nil {
+ return nil, nil, err
+ }
+ g, err := c.network(on, refused)
+ if err != nil {
+ return nil, nil, err
+ }
+ return g.Nodes(), g.Graph(), nil
+}
+
+// whoResolves is the machines whose resolution answers a requirement, and why the others did not: by what a module
+// provides, not by its name, resolved rather than read from the assignments.
+func (c *composer) whoResolves(requirement string) (map[string]bool, map[string]string, error) {
+ on := map[string]bool{}
+ // Why a node could not be resolved, kept rather than raised: one broken node must not stop the rest being described.
+ refused := map[string]string{}
+ for _, n := range c.in.sorted() {
+ resolved, err := c.planFor(n.Name)
+ switch {
+ case unresolvable(err):
+ refused[n.Name] = err.Error()
+ continue
+ case err != nil:
+ // Not a node that does not resolve: a question that went unanswered.
+ return nil, nil, fmt.Errorf("whether %s answers %q cannot be read: %w", n.Name, requirement, err)
+ }
+ for _, m := range resolved.Plan.Modules {
+ for _, offered := range m.Offers() {
+ if offered == requirement {
+ on[n.Name] = true
+ }
+ }
+ }
+ }
+ return on, refused, nil
+}
+
+// Generators is what every node's declaration is composed with, over the whole mesh: the private network's generator
+// (one generator now; the names and a resolver's zone file are facts a module asks for in its manifest).
+func Generators(in Input) (map[string]catalogue.Generator, error) {
+ c := newComposer(in)
+ return c.generators()
+}
+
+func (c *composer) generators() (map[string]catalogue.Generator, error) {
+ on, refused, err := c.whoResolves(overlay.Addressing)
+ if err != nil {
+ return nil, err
+ }
+ net, err := c.network(on, refused)
+ if err != nil {
+ return nil, err
+ }
+ return map[string]catalogue.Generator{overlay.Name: net}, nil
+}
+
+// disagrees says how a node's placement differs from the tunnel it takes over — its address not the tunnel's, its
+// endpoint not on the tunnel's port — or nothing when both agree.
+func disagrees(p Place, t Tunnel) string {
+ var wrong []string
+ want := t.Address
+ if i := strings.Index(want, "/"); i >= 0 {
+ want = want[:i]
+ }
+ if p.Address != want {
+ wrong = append(wrong, fmt.Sprintf("its address is %s and the tunnel's is %s", orNothing(p.Address), want))
+ }
+ if p.Reachable() && portOfEndpoint(p.Endpoint) != strconv.Itoa(t.Port) {
+ wrong = append(wrong, fmt.Sprintf("its endpoint %s is not on the tunnel's port %d", p.Endpoint, t.Port))
+ }
+ return strings.Join(wrong, "; ")
+}
+
+func orNothing(s string) string {
+ if s == "" {
+ return "unset"
+ }
+ return s
+}
+
+// portOfEndpoint is the port in host:port, or empty.
+func portOfEndpoint(endpoint string) string {
+ if i := strings.LastIndex(endpoint, ":"); i >= 0 {
+ return endpoint[i+1:]
+ }
+ return ""
+}
+
+// whereEveryoneIs is each machine's name on the private network, for the ones on it. Resolved without consulting the
+// rest of the mesh (it is called from that path), and refused without the catalogue.
+func (c *composer) whereEveryoneIs() (map[string]string, error) {
+ if c.in.Catalogue == nil {
+ return nil, errors.New("asked where everyone is without the catalogue, which cannot be answered")
+ }
+ out := map[string]string{}
+ for _, p := range c.onTheNetwork() {
+ out[p.Name] = c.internalName(p.Name)
+ }
+ return out, nil
+}
+
+// onTheNetwork is every placed machine that resolves the private network: has an address AND runs what puts it there
+// (novox/hq issue 079). A machine that does not resolve keeps its place when it holds one (a key and an address), by the
+// same rule as network(), so its name and its address stay where the tunnel to it does (issue 1833); one without is not
+// counted, and said.
+func (c *composer) onTheNetwork() []Node {
+ if !c.onNetworkSet {
+ c.onNetwork, c.onNetworkSet = c.onTheNetworkNow(), true
+ }
+ return slices.Clone(c.onNetwork)
+}
+
+// onTheNetworkNow is onTheNetwork worked out: every placed machine's set resolved.
+func (c *composer) onTheNetworkNow() []Node {
+ var out []Node
+ for _, n := range c.in.sorted() {
+ if n.Place.Address == "" {
+ continue
+ }
+ if len(n.Assigned) == 0 {
+ continue
+ }
+ got, err := resolution.Resolve(c.in.Catalogue, n.Assigned,
+ catalogue.Node{Name: n.Name, Site: n.Place.Site, Capabilities: n.Capabilities},
+ catalogue.World{Unchecked: true, Holdings: c.in.Holdings})
+ if err != nil {
+ if n.Place.Key != "" {
+ c.say("%s could not be resolved and keeps its place on the private network at %s, so the others keep their path to it",
+ n.Name, n.Place.Address)
+ out = append(out, n)
+ continue
+ }
+ c.say("%s is not counted as on the network: it does not resolve: %v", n.Name, err)
+ continue
+ }
+ for _, m := range got.Modules {
+ for _, offered := range m.Offers() {
+ if offered == overlay.Requirement {
+ out = append(out, n)
+ }
+ }
+ }
+ }
+ return out
+}
diff --git a/internal/compose/network_test.go b/internal/compose/network_test.go
new file mode 100644
index 0000000..2f924f9
--- /dev/null
+++ b/internal/compose/network_test.go
@@ -0,0 +1,306 @@
+package compose
+
+import (
+ "strings"
+ "testing"
+
+ "git.novox.be/novox/mesh-sdk/go/catalogue"
+ "github.com/novox/mesh-host/internal/overlay"
+)
+
+// onTheTunnel makes the hub of an Input what mesh-controller's network_test.go makes of anchor in
+// TestTheOverlaysRangeIsTheAdoptedTunnels: adopted, enrolled with the found tunnel's key, and presenting the tunnel
+// (Tunnel{wg0, wg-quick@wg0, port 51900, 192.0.2.1/24, range 192.0.2.0/24, one peer PEER-TWO}). Placed on it when
+// replaced is true: its address the tunnel's, its endpoint on the tunnel's port, as `overlay place anchor --endpoint
+// 198.51.100.10:51900 --site hosting --hub` leaves it.
+func onTheTunnel(t *testing.T, in *Input, replaced bool) {
+ t.Helper()
+ const key = "THE-TUNNELS-KEY========================="
+ tunnel := Tunnel{Interface: "wg0", Unit: "wg-quick@wg0", Config: "/etc/wireguard/wg0.conf", Port: 51900,
+ Address: "192.0.2.1/24", Range: "192.0.2.0/24", PublicKey: key,
+ Peers: []TunnelPeer{{PublicKey: "PEER-TWO", Address: "192.0.2.2"}}}
+ hub := nodeOf(t, in, "anchor")
+ hub.Adopted = true
+ hub.Place.Key = key
+ if replaced {
+ hub.Place.Address, hub.Place.Endpoint, hub.Place.Site = "192.0.2.1", "198.51.100.10:51900", "hosting"
+ }
+ in.Tunnels = map[string]FoundTunnel{"anchor": {Tunnel: tunnel, NodeAdopted: true}}
+ in.Adopted = &AdoptedTunnel{Tunnel: tunnel, Hub: "anchor", Adopted: true}
+ in.CarriedPeers = []CarriedPeer{{PublicKey: "PEER-TWO", Address: "192.0.2.2"}}
+}
+
+// novox/hq ADR 0105 (mesh-controller network_test.go TestTheOverlaysRangeIsTheAdoptedTunnels): the range every address is
+// composed from is the adopted tunnel's, read from the tunnel the hub holds, never stored anywhere else. The environment
+// value of the controller is Input.OverlayCIDR here.
+func TestTheOverlaysRangeIsTheAdoptedTunnels(t *testing.T) {
+ in := aMesh(t)
+ in.OverlayCIDR = "10.99.0.0/16"
+ if got := newComposer(in).overlayRange(); got != "10.99.0.0/16" {
+ t.Fatalf("without an adopted tunnel the range is not what genesis said: %q", got)
+ }
+ in.OverlayCIDR = ""
+ if got := newComposer(in).overlayRange(); got != DefaultOverlayCIDR {
+ t.Fatalf("with nothing said the range is %q, not the default", got)
+ }
+ in.OverlayCIDR = "10.99.0.0/16"
+ onTheTunnel(t, &in, true)
+ if got := newComposer(in).overlayRange(); got != "192.0.2.0/24" {
+ t.Fatalf("with an adopted tunnel the range is %q, not the tunnel's", got)
+ }
+ // A tunnel the hub found and did not take (its key is not the tunnel's) does not give its range.
+ in.Adopted.Adopted = false
+ if got := newComposer(in).overlayRange(); got != "10.99.0.0/16" {
+ t.Fatalf("a tunnel the hub did not take gave its range: %q", got)
+ }
+ in.Adopted.Adopted = true
+
+ // And the hub's declaration carries the peer and the takeover.
+ nodes, computed, err := Graph(in)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var hubNode overlay.Node
+ for _, n := range nodes {
+ if n.Name == "anchor" {
+ hubNode = n
+ }
+ }
+ if hubNode.TakesOver == nil || hubNode.TakesOver.Unit != "wg-quick@wg0" {
+ t.Errorf("the hub is not told to take over the found tunnel: %+v", hubNode)
+ }
+ carried := false
+ for _, p := range computed["anchor"] {
+ if p.Key == "PEER-TWO" && p.Allowed == "192.0.2.2/32" {
+ carried = true
+ }
+ }
+ if !carried {
+ t.Errorf("the hub's peer list does not carry the tunnel's peer: %+v", computed["anchor"])
+ }
+}
+
+// A takeover is composed only for a hub whose placement agrees with the tunnel (mesh-controller network_test.go
+// TestATakeoverIsNotComposedForAHubPlacedOffItsTunnel): an address or an endpoint port that differs would have the host stop
+// the found interface and raise the mesh's where no peer is listening.
+func TestATakeoverIsNotComposedForAHubPlacedOffItsTunnel(t *testing.T) {
+ in := aMesh(t)
+ onTheTunnel(t, &in, false)
+ // aMesh placed anchor at 10.77.0.1 on :51820, the record of a hub placed before it took the tunnel over.
+ _, _, err := Graph(in)
+ if err == nil {
+ t.Fatal("a takeover was composed for a hub whose address and port are not the tunnel's")
+ }
+ for _, want := range []string{"10.77.0.1", "192.0.2.1", "51820", "51900", "overlay place anchor"} {
+ if !strings.Contains(err.Error(), want) {
+ t.Errorf("the refusal does not say %q: %v", want, err)
+ }
+ }
+ // Re-placed on the tunnel, it composes.
+ onTheTunnel(t, &in, true)
+ if _, _, err := Graph(in); err != nil {
+ t.Fatalf("re-placed on the tunnel, the graph still refuses: %v", err)
+ }
+}
+
+// 1360/AC2: the generator gives the addresses, peers and names of aMesh: anchor the hub at 10.77.0.1, laptop at 10.77.0.2
+// peering through it, both written for their machines.
+func TestTheGeneratorGivesTheMeshItsAddressesAndPeers(t *testing.T) {
+ in := aMesh(t)
+ gens, err := Generators(in)
+ if err != nil {
+ t.Fatal(err)
+ }
+ g, ok := gens[overlay.Name].(*overlay.Generator)
+ if !ok {
+ t.Fatalf("the generator is %T", gens[overlay.Name])
+ }
+ addr := map[string]string{}
+ for _, n := range g.Nodes() {
+ addr[n.Name] = n.Address
+ }
+ if addr["anchor"] != "10.77.0.1" || addr["laptop"] != "10.77.0.2" || len(addr) != 2 {
+ t.Fatalf("the addresses are %v", addr)
+ }
+ peers := g.Graph()
+ if len(peers["laptop"]) != 1 || peers["laptop"][0].Name != "anchor" || peers["laptop"][0].Endpoint != "anchor.example:51820" {
+ t.Errorf("laptop's peers are %+v, want the hub", peers["laptop"])
+ }
+ if len(peers["anchor"]) != 1 || peers["anchor"][0].Name != "laptop" || peers["anchor"][0].Allowed != "10.77.0.2/32" {
+ t.Errorf("anchor's peers are %+v, want laptop", peers["anchor"])
+ }
+ for _, node := range []string{"anchor", "laptop"} {
+ res, on, err := g.Resources(node)
+ if err != nil || !on || len(res) == 0 {
+ t.Errorf("%s is given no network: %v %v %d", node, on, err, len(res))
+ }
+ }
+}
+
+// 1360/AC3: a node not on the private network (it runs nothing that puts it there) gets no address and no network, and the
+// one that stayed keeps its own.
+func TestANodeNotOnThePrivateNetworkGetsNoAddressAndNoNetwork(t *testing.T) {
+ in := aMesh(t)
+ nodeOf(t, &in, "laptop").Assigned = nil // it keeps its place and its address, and stops running the network
+ gens, err := Generators(in)
+ if err != nil {
+ t.Fatal(err)
+ }
+ g := gens[overlay.Name].(*overlay.Generator)
+ if len(g.Nodes()) != 1 || g.Nodes()[0].Name != "anchor" {
+ t.Fatalf("the network is over %+v, want anchor alone", g.Nodes())
+ }
+ if res, on, err := g.Resources("laptop"); on || err != nil || len(res) != 0 {
+ t.Errorf("a machine that left the network is given one: %v %v %v", res, on, err)
+ }
+ if _, in := g.Graph()["laptop"]; in {
+ t.Error("a machine that left the network has peers")
+ }
+}
+
+// 1360/AC3: a node whose resolution is refused is said with today's words: the network missing its hub names the
+// machine that could not be resolved, which is almost always why. The hub here holds no complete place (no key): a refused
+// machine with one keeps it on the network (issue 1833), so only one without is missing from it.
+func TestARefusedNodeIsSaidWhenTheNetworkCannotBeComposed(t *testing.T) {
+ in := aMesh(t)
+ nodeOf(t, &in, "anchor").Place.Key = ""
+ one := catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
+ two := catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
+ in.Catalogue["rival-one"], in.Catalogue["rival-two"] = one, two
+ hub := nodeOf(t, &in, "anchor")
+ hub.Assigned = append(hub.Assigned, "rival-one", "rival-two") // its own set is incoherent: the hub does not resolve
+ _, err := Generators(in)
+ if err == nil {
+ t.Fatal("a network without its hub was composed")
+ }
+ for _, want := range []string{overlay.ErrNoHub.Error(), "and 1 node(s) could not be resolved at all, which is probably why:", " anchor: "} {
+ if !strings.Contains(err.Error(), want) {
+ t.Errorf("the refusal does not say %q:\n%v", want, err)
+ }
+ }
+}
+
+// 1360/AC4: Generators changes nothing in its Input.
+func TestGeneratorsChangesNothingInItsInput(t *testing.T) {
+ unchanged(t, func() Input {
+ in := aMesh(t)
+ onTheTunnel(t, &in, true)
+ in.Joining = []string{"laptop"}
+ return in
+ }, func(in Input) {
+ if _, err := Generators(in); err != nil {
+ t.Fatal(err)
+ }
+ })
+}
+
+// Each field of Input that the network reads reaches the generator: change it and the generator changes.
+func TestEachFieldOfTheInputReachesTheGenerator(t *testing.T) {
+ addressesOf := func(in Input) map[string]string {
+ t.Helper()
+ gens, err := Generators(in)
+ if err != nil {
+ t.Fatalf("%v", err)
+ }
+ out := map[string]string{}
+ for _, n := range gens[overlay.Name].(*overlay.Generator).Nodes() {
+ out[n.Name] = n.Address
+ }
+ return out
+ }
+ base := addressesOf(aMesh(t))
+ if len(base) != 2 {
+ t.Fatalf("the base mesh has %v", base)
+ }
+ t.Run("Catalogue: without the module offering it nobody is on the network", func(t *testing.T) {
+ in := aMesh(t)
+ // The module stays known, so every machine still resolves: a machine that does not resolve keeps its place (issue 1833).
+ in.Catalogue = map[string]catalogue.Manifest{overlay.Name: {Module: overlay.Name, Version: "1"}}
+ if got := addressesOf(in); len(got) != 0 {
+ t.Errorf("got %v", got)
+ }
+ })
+ t.Run("Nodes.Place: an address", func(t *testing.T) {
+ in := aMesh(t)
+ nodeOf(t, &in, "laptop").Place.Address = "10.77.0.9"
+ if got := addressesOf(in); got["laptop"] != "10.77.0.9" {
+ t.Errorf("got %v", got)
+ }
+ })
+ t.Run("Nodes.Assigned", func(t *testing.T) {
+ in := aMesh(t)
+ nodeOf(t, &in, "laptop").Assigned = nil
+ if got := addressesOf(in); len(got) != 1 {
+ t.Errorf("got %v", got)
+ }
+ })
+ t.Run("Nodes.Capabilities: a capability the module needs", func(t *testing.T) {
+ in := aMesh(t)
+ m := in.Catalogue[overlay.Name]
+ m.Capabilities = []string{"wireguard"}
+ in.Catalogue[overlay.Name] = m
+ nodeOf(t, &in, "laptop").Capabilities = map[string]bool{}
+ got, err := Generators(in)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n := got[overlay.Name].(*overlay.Generator).Nodes(); len(n) != 1 || n[0].Name != "anchor" {
+ t.Errorf("a machine without the capability is on the network: %+v", n)
+ }
+ })
+ t.Run("OverlayCIDR", func(t *testing.T) {
+ in := aMesh(t)
+ in.OverlayCIDR = "10.99.0.0/16"
+ _, graph, err := Graph(in)
+ if err != nil {
+ t.Fatal(err)
+ }
+ found := false
+ for _, p := range graph["laptop"] {
+ found = found || p.Allowed == "10.99.0.0/16"
+ }
+ if !found {
+ t.Errorf("the hub's route is not the range given: %+v", graph["laptop"])
+ }
+ })
+ t.Run("Tunnels and Adopted and CarriedPeers", func(t *testing.T) {
+ in := aMesh(t)
+ onTheTunnel(t, &in, true)
+ nodes, graph, err := Graph(in)
+ if err != nil || len(nodes) != 2 {
+ t.Fatalf("%v %v", nodes, err)
+ }
+ if len(graph["anchor"]) < 2 {
+ t.Errorf("the hub's peers do not include the carried one: %+v", graph["anchor"])
+ }
+ })
+ t.Run("Joining: a machine with a key and an address is on the network before it is anything else", func(t *testing.T) {
+ in := aMesh(t)
+ in.Nodes = append(in.Nodes, Node{Name: "newcomer", Place: Place{Key: keyOf(t, "newcomer"), Address: "10.77.0.3"}})
+ if got := addressesOf(in); got["newcomer"] != "" {
+ t.Fatalf("a machine that runs nothing is on the network: %v", got)
+ }
+ in.Joining = []string{"newcomer"}
+ if got := addressesOf(in); got["newcomer"] != "10.77.0.3" {
+ t.Errorf("a joining machine is not on the network: %v", got)
+ }
+ })
+ t.Run("Holdings: a holder on record settles a seat two assignments could hold", func(t *testing.T) {
+ withSeat(t)
+ in := aMesh(t)
+ for _, name := range []string{"holder-one", "holder-two"} {
+ in.Catalogue[name] = aBrokerClaiming(name)
+ }
+ laptop := nodeOf(t, &in, "laptop")
+ laptop.Assigned = append(laptop.Assigned, "holder-one", "holder-two")
+ // Without a key: a machine that does not resolve and holds a complete place keeps it on the network (issue 1833).
+ laptop.Place.Key = ""
+ if got := addressesOf(in); got["laptop"] != "" {
+ t.Fatalf("a machine whose set claims one seat twice is on the network: %v", got)
+ }
+ in.Holdings = []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "laptop", Module: "holder-one"}}
+ if got := addressesOf(in); got["laptop"] != "10.77.0.2" {
+ t.Errorf("a holder on record did not settle the seat: %v", got)
+ }
+ })
+}
diff --git a/internal/compose/node.go b/internal/compose/node.go
new file mode 100644
index 0000000..0aff1dc
--- /dev/null
+++ b/internal/compose/node.go
@@ -0,0 +1,352 @@
+package compose
+
+import (
+ "encoding/json"
+ "fmt"
+ "sort"
+ "strings"
+
+ "git.novox.be/novox/mesh-sdk/go/catalogue"
+ "github.com/novox/mesh-host/internal/compose/resolution"
+ "github.com/novox/mesh-host/internal/validate"
+)
+
+// Declared is a declaration as a machine is sent it (the controller's sendable, sendable.go): its resources and, for an adopted
+// node, its mode and which modules were taken on it (novox/hq ADR 0100), with what the composition says beside it and never puts on
+// the wire.
+//
+// **Body is the only place the envelope is marshalled**, so the digest the mesh compares and the body validated are one.
+type Declared struct {
+ Resources []map[string]any
+ // Sequence, Epoch and Generation are what the Input gave the node: zero is not sent at all.
+ Sequence int64
+ Epoch uint64
+ Generation int64
+ // ReadsModules says the machine's node-engine reads a module per resource and a build per module (novox/hq issue 1145): only
+ // then are they sent. ModuleOf is the composer's owner of each resource id and BuildIDs the build each module carries.
+ ReadsModules bool
+ ModuleOf map[string]string
+ BuildIDs map[string]string
+ // Adoption is nil for a converged node, and then the body is byte for byte what it was before adoption existed.
+ Adoption *Adoption
+ // Filled is the path of every file this declaration hands to the provisioner, and Provisioner the account its client runs as
+ // (novox/hq ADR 0349): on the wire as `filled_files` and `provisioner_account`, both absent when nothing is handed.
+ Filled []string
+ Provisioner string
+ // LeftOut is every module of the machine's set left out because a stored setting cannot compose with its definition (novox/hq
+ // ADR 0163, rule 6), sorted; LeftOutWhy is why each was.
+ LeftOut []string
+ LeftOutWhy map[string]string
+
+ // Not on the wire:
+ Received map[string]map[string][]catalogue.Contribution
+ Mesh []string
+ BusUsers string
+ // Modules are the modules this declaration names, sorted.
+ Modules []string
+ // LocalNetwork is every port admitted from the local network and what it resolved to (novox/hq ADR 0340).
+ LocalNetwork []catalogue.LocalNetworkPort
+ Withheld []catalogue.Overflow
+ Unbound []catalogue.Unbound
+ Unplaced []string
+ HandedOnly []string
+ // RequiredBy is, for each handed module only pulled in by another, the module that requires it (issue 765).
+ RequiredBy map[string]string
+ // Foreseen is every own secret composed with a stand-in, as `module/name`; StoodIn is everything the composition stood in for
+ // (an own secret, a grant's sealed copy, the certificate, the bus's user list); ForeseenPorts the ports the mesh may assign that
+ // the Input holds none for. A declaration that has any is never sent: what a send would make, named, never invented.
+ Foreseen []string
+ StoodIn []string
+ ForeseenPorts []string
+ // CoreBoxesWanted are the core credentials whose box the send must ask the vault for and keep.
+ CoreBoxesWanted []CoreBoxWanted
+ // Passes are the seats the mesh would pass on, for the caller to record, and Said the lines found while composing.
+ Passes Passes
+ Said []string
+}
+
+// Adoption is what an adopted node is told about its mode. Taken is every module taken on it that it runs; Untaken is, for every
+// module it runs that is not taken, the ids of every one of that module's resources: what the host keeps as found until the module
+// is taken (ADR 0103).
+type Adoption struct {
+ Taken []string `json:"taken"`
+ Untaken map[string][]string `json:"untaken,omitempty"`
+}
+
+// Body is the declaration's bytes, as sent and as digested.
+func (d Declared) Body() ([]byte, error) {
+ envelope := map[string]any{"declaration": 1, "resources": d.Resources}
+ if d.ReadsModules {
+ // The module of each resource the composer owns, and the build of each module (novox/hq issue 1145): to a machine that
+ // said it reads them, and no other.
+ named := make([]map[string]any, len(d.Resources))
+ for n, r := range d.Resources {
+ module, owned := d.ModuleOf[fmt.Sprint(r["id"])]
+ if !owned {
+ named[n] = r
+ continue
+ }
+ with := make(map[string]any, len(r)+1)
+ for k, v := range r {
+ with[k] = v
+ }
+ with["module"] = module
+ named[n] = with
+ }
+ envelope["resources"] = named
+ if len(d.BuildIDs) > 0 {
+ envelope["builds"] = d.BuildIDs
+ }
+ }
+ if d.Adoption != nil {
+ envelope["adoption"] = d.Adoption
+ }
+ if d.Sequence > 0 {
+ envelope["sequence"] = d.Sequence
+ }
+ if d.Epoch > 0 {
+ envelope["epoch"] = d.Epoch
+ }
+ if len(d.LeftOut) > 0 {
+ envelope["left_out"] = d.LeftOut
+ }
+ if d.Generation > 0 {
+ envelope["generation"] = d.Generation
+ }
+ if len(d.Filled) > 0 {
+ envelope["filled_files"] = d.Filled
+ envelope["provisioner_account"] = d.Provisioner
+ }
+ // An empty declaration is deliberate: the node owns nothing the mesh put there (novox/hq issue 127), and the host refuses an
+ // empty body unless it is told the emptiness is meant.
+ if len(d.Resources) == 0 {
+ envelope["owns_nothing"] = true
+ }
+ return json.Marshal(envelope)
+}
+
+// Refusals are the findings a check refuses a change for, beside the validator's problems: a consumer left out of the grants
+// because its identity overflows the provision's bound, and a credential bound to another consumer (novox/hq ADR 0446 clause 1).
+// Each names the consumer and the provision.
+func (d Declared) Refusals() []string {
+ var out []string
+ for _, o := range d.Withheld {
+ out = append(out, o.String())
+ }
+ for _, u := range d.Unbound {
+ out = append(out, u.String())
+ }
+ sort.Strings(out)
+ return out
+}
+
+// Compose composes one node's declaration (the issue calls it Node; the Input already has a type of that name) over an Input -- as a push would, without the sending (the controller's
+// composedAndValidated in the Foreseeing mode) -- and runs the node-engine's own validator over its body. An error is that the
+// node did not compose (a set that does not compose satisfies IsUnresolvable); problems are what the validator refuses, in the
+// node-engine's words. It allocates, makes, keeps and writes nothing, and names what a send would do as foreseen.
+func Compose(in Input, node string) (Declared, []string, error) {
+ return ComposeIn(in, node, Foreseeing)
+}
+
+// ComposeIn is Compose in a chosen mode.
+func ComposeIn(in Input, node string, mode Mode) (Declared, []string, error) {
+ c := newComposer(in)
+ d, err := c.declaration(node, mode)
+ if err != nil {
+ return Declared{}, nil, err
+ }
+ body, err := d.Body()
+ if err != nil {
+ return Declared{}, nil, err
+ }
+ return d, validate.Declaration(body), nil
+}
+
+// declaration is the controller's declarationWith over the plain Input.
+func (c *composer) declaration(nodeName string, mode Mode) (Declared, error) {
+ resolved, err := c.planFor(nodeName)
+ if err != nil {
+ return Declared{}, err
+ }
+ gens, err := c.generators()
+ if err != nil {
+ return Declared{}, err
+ }
+ rendered, err := c.render(nodeName, mode)
+ if err != nil {
+ return Declared{}, err
+ }
+ with := rendered.Rendering
+ with.Generators = gens
+ plan := resolved.Plan
+ composed, err := resolution.Compose(plan, with)
+ if err != nil {
+ return Declared{}, err
+ }
+ // Which ports the filter admits from the local network, and from which networks (novox/hq ADR 0340 clause 8): the same rules the
+ // composition rendered, never sent.
+ rules, err := resolution.Rules(plan, with)
+ if err != nil {
+ return Declared{}, err
+ }
+ node, err := c.in.node(nodeName)
+ if err != nil {
+ return Declared{}, err
+ }
+ out := Declared{Resources: composed.Resources, Adoption: adoptionFor(plan, node, composed),
+ Received: composed.Received, Mesh: with.Mesh, BusUsers: with.BusUsers,
+ LeftOut: sortedKeysOf(composed.LeftOut), LeftOutWhy: composed.LeftOut,
+ Withheld: rendered.Withheld, Unbound: rendered.Unbound,
+ Foreseen: composed.Foreseen, StoodIn: rendered.Foreseen, ForeseenPorts: rendered.ForeseenPorts,
+ Unplaced: composed.Unplaced, HandedOnly: composed.HandedOnly, Modules: namedModules(plan, composed.LeftOut),
+ Filled: composed.Filled, LocalNetwork: resolution.LocalNetworkPorts(rules, with.LocalNetworks),
+ CoreBoxesWanted: rendered.CoreBoxesWanted, Said: rendered.Said,
+ Sequence: node.Sequence, Epoch: node.Epoch, Generation: node.Generation}
+ if len(composed.Filled) > 0 {
+ out.Provisioner = with.Provisioner
+ }
+ // A handed module only pulled in by another keeps what requires it, for its value of its own in the assignments mirror (issue 765).
+ for module := range composed.Plans {
+ if by := resolution.RequiredBy(plan, module); by != "" {
+ if out.RequiredBy == nil {
+ out.RequiredBy = map[string]string{}
+ }
+ out.RequiredBy[module] = by
+ }
+ }
+ // And the module of each resource and the build of each module, to a machine whose node-engine said it reads them (novox/hq issue
+ // 1145) and to no other.
+ if node.ReadsModules {
+ carried := c.carriedOn(node, plan, composed.LeftOut)
+ out.ReadsModules, out.ModuleOf, out.BuildIDs = true, composed.Owner, c.wireBuilds(carried, node.Runs)
+ }
+ _, passes, left, err := c.theRestOfTheMesh("")
+ if err != nil {
+ return Declared{}, err
+ }
+ out.Passes = passes
+ for _, l := range left {
+ out.Said = append(out.Said, l.String())
+ }
+ out.Said = c.saidBeside(out.Said)
+ return out, nil
+}
+
+// carriedOn is the build of each module a declaration for the node carries (plan.go carriedOn, in a question that sends nothing): the
+// module's current build for each module in it, and for a module left out of it the build it was last sent, since the machine keeps
+// that one -- or nothing, when that is not known.
+func (c *composer) carriedOn(node Node, plan catalogue.Resolution, leftOut map[string]string) map[string]string {
+ before := node.SentBuilds
+ if !node.SentKnown {
+ before = nil
+ }
+ out := map[string]string{}
+ for _, m := range plan.Modules {
+ if _, left := leftOut[m.Module]; left {
+ if was, kept := before[m.Module]; kept {
+ out[m.Module] = was
+ }
+ continue
+ }
+ out[m.Module] = c.in.CurrentBuilds[m.Module]
+ // A module kept at the build the machine runs is carried at that build (plan.go carriedOn).
+ if kept, ok := node.KeptBuilds[m.Module]; ok && kept != "" {
+ out[m.Module] = kept
+ }
+ }
+ return out
+}
+
+// wireBuilds is the build of each carried module that goes on the wire (novox/hq issue 1145): the build its assignment names when
+// that assignment is of the commit carried, else the build made from that commit; a module with neither is not named.
+func (c *composer) wireBuilds(carried map[string]string, runs map[string]Run) map[string]string {
+ out := map[string]string{}
+ for module, commit := range carried {
+ if commit == "" {
+ continue
+ }
+ if ref, ok := runs[module]; ok && ref.Commit == commit && ref.Build != "" {
+ out[module] = ref.Build
+ continue
+ }
+ if build := c.in.BuildIDs[module][commit]; build != "" {
+ out[module] = build
+ }
+ }
+ return out
+}
+
+// adoptionFor is the envelope computed from what was taken and who owns each resource: nil for a converged node. Every module the
+// node runs that is not taken is untaken -- including one pulled in by another rather than assigned.
+func adoptionFor(plan catalogue.Resolution, node Node, composed catalogue.Composed) *Adoption {
+ if !node.Adopted {
+ return nil
+ }
+ isTaken := map[string]bool{}
+ for _, m := range node.Taken {
+ isTaken[m] = true
+ }
+ out := &Adoption{Taken: []string{}}
+ runs := map[string]bool{}
+ for _, m := range plan.Modules {
+ runs[m.Module] = true
+ if isTaken[m.Module] {
+ out.Taken = append(out.Taken, m.Module)
+ }
+ }
+ sort.Strings(out.Taken)
+ for _, r := range composed.Resources {
+ id, _ := r["id"].(string)
+ module, owned := composed.Owner[id]
+ // Every kind, not only files and containers (novox/hq ADR 0103). What the mesh declares of its own is never held.
+ if !owned || !runs[module] || isTaken[module] || strings.HasPrefix(id, catalogue.AdoptionPrefix) {
+ continue
+ }
+ if out.Untaken == nil {
+ out.Untaken = map[string][]string{}
+ }
+ out.Untaken[module] = append(out.Untaken[module], id)
+ }
+ return out
+}
+
+// sortedKeysOf is a map's keys, sorted: so what a declaration says it left out does not move for a reordering nobody made.
+func sortedKeysOf(m map[string]string) []string {
+ if len(m) == 0 {
+ return nil
+ }
+ out := make([]string, 0, len(m))
+ for k := range m {
+ out = append(out, k)
+ }
+ sort.Strings(out)
+ return out
+}
+
+// namedModules are the modules a declaration names: its machine's set, less what was left out of it.
+func namedModules(plan catalogue.Resolution, leftOut map[string]string) []string {
+ out := make([]string, 0, len(plan.Modules))
+ for _, m := range plan.Modules {
+ if _, left := leftOut[m.Module]; !left {
+ out = append(out, m.Module)
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+// DataItems is every data item the modules placed on a node declare, placed there (novox/hq ADR 0571 clause 3): what the
+// node-backup holder is handed as `${backup:data}`, composed over the Input as Compose composes the node, so disk-load
+// reads the list from the bus with no composed file. Unplaced names each item left out and why.
+func DataItems(in Input, node string) (map[string][]catalogue.PlacedDataItem, []string, error) {
+ c := newComposer(in)
+ resolved, err := c.planFor(node)
+ if err != nil {
+ return nil, nil, err
+ }
+ rendered, err := c.render(node, Planning)
+ if err != nil {
+ return nil, nil, err
+ }
+ return resolution.DataItems(resolved.Plan, rendered.Rendering)
+}
diff --git a/internal/compose/node_test.go b/internal/compose/node_test.go
new file mode 100644
index 0000000..5dcbee4
--- /dev/null
+++ b/internal/compose/node_test.go
@@ -0,0 +1,424 @@
+package compose
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "git.novox.be/novox/mesh-sdk/go/catalogue"
+ "github.com/novox/mesh-host/internal/overlay"
+)
+
+func resourceIDs(d Declared) []string {
+ var ids []string
+ for _, r := range d.Resources {
+ ids = append(ids, r["type"].(string)+":"+r["id"].(string))
+ }
+ return ids
+}
+
+func has(ids []string, want string) bool {
+ for _, id := range ids {
+ if id == want {
+ return true
+ }
+ }
+ return false
+}
+
+// aSmallMesh is three nodes, a provider and its consumer, a seat and the private network: aMesh with `ace` running a database and
+// a resolver holding the mesh's resolver seat on record (the providers and consumer of mesh-controller's facts_test.go
+// aMeshWithSecrets, the seat of bindings_test.go storeManifests).
+func aSmallMesh(t *testing.T) Input {
+ t.Helper()
+ in := threeNodes(t)
+ for _, m := range storeManifests() {
+ if m.Module == "resolver" {
+ in.Catalogue[m.Module] = m
+ }
+ }
+ ace := nodeOf(t, &in, "ace")
+ ace.Assigned = append(ace.Assigned, "resolver")
+ in.Holdings = []catalogue.Held{{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "ace", Module: "resolver", Site: "there"}}
+ return in
+}
+
+// 1298/AC1: each node of a small mesh composes, its body is accepted by the node-engine's validator, and it names the resources the
+// controller composes for the same case: the private network's files on the nodes that run it, the provider's received file, the
+// consumer's file.
+func TestEachNodeOfASmallMeshComposesAndItsBodyIsAcceptedByTheValidator(t *testing.T) {
+ in := aSmallMesh(t)
+ want := map[string][]string{
+ "anchor": {"package:mesh-wireguard.overlay-tools", "file:mesh-wireguard.overlay-config", "service:mesh-wireguard.overlay-up", "file:objects.received-s3-bucket"},
+ "laptop": {"package:mesh-wireguard.overlay-tools", "file:mesh-wireguard.overlay-config", "service:mesh-wireguard.overlay-up", "file:files.config"},
+ "ace": nil,
+ }
+ for node, ids := range want {
+ d, problems, err := Compose(in, node)
+ if err != nil || len(problems) != 0 {
+ t.Fatalf("%s: %v %v", node, err, problems)
+ }
+ for _, id := range ids {
+ if !has(resourceIDs(d), id) {
+ t.Errorf("%s does not name %s: %v", node, id, resourceIDs(d))
+ }
+ }
+ body, err := d.Body()
+ if err != nil {
+ t.Fatal(err)
+ }
+ var parsed struct {
+ Declaration int `json:"declaration"`
+ Resources []map[string]any `json:"resources"`
+ }
+ if err := json.Unmarshal(body, &parsed); err != nil || parsed.Declaration != 1 || len(parsed.Resources) != len(d.Resources) {
+ t.Errorf("%s: body %s %v", node, body, err)
+ }
+ }
+ // ace runs a resolver and a database and nothing the mesh declares of its own: an empty declaration says it owns nothing.
+ d, _, _ := Compose(in, "ace")
+ if body, _ := d.Body(); !strings.Contains(string(body), `"owns_nothing":true`) {
+ t.Errorf("an empty declaration is not said to be deliberate: %s", body)
+ }
+}
+
+// 1298/AC2 (mesh-controller merge_gate_integration_test.go TestIssue236AManifestTheNodeEngineRefusesFailsThePullRequest, the manifest):
+// a service that omits its state is accepted by the catalogue's check and refused by the node-engine; Compose returns the validator's
+// problems in the node-engine's words and no error.
+func TestAManifestTheNodeEngineRefusesComesBackAsProblemsAndNoError(t *testing.T) {
+ in := aMesh(t)
+ var lemurs catalogue.Manifest
+ if err := json.Unmarshal([]byte(`{"module":"lemurs","version":"1","capabilities":["systemd"],
+ "resources":[{"id":"service","type":"service","unit":"lemurs.service","boot":"enabled"}]}`), &lemurs); err != nil {
+ t.Fatal(err)
+ }
+ in.Catalogue["lemurs"] = lemurs
+ laptop := nodeOf(t, &in, "laptop")
+ laptop.Assigned = append(laptop.Assigned, "lemurs")
+ d, problems, err := Compose(in, "laptop")
+ if err != nil {
+ t.Fatalf("a refusal of the validator is an error: %v", err)
+ }
+ if len(problems) == 0 || !strings.Contains(strings.Join(problems, "\n"), "lemurs.service") {
+ t.Fatalf("problems are %v", problems)
+ }
+ if !has(resourceIDs(d), "service:lemurs.service") {
+ t.Errorf("the declaration does not carry the resource: %v", resourceIDs(d))
+ }
+ // And the same, as the validator says it of the body.
+ body, _ := d.Body()
+ if again := strings.Join(problems, "\n"); again == "" || !strings.Contains(string(body), "lemurs.service") {
+ t.Errorf("the body does not name the service: %s", body)
+ }
+}
+
+// 1298/AC3: a node whose own set does not compose returns an error and no declaration, told apart from an unreadable Input.
+func TestANodeWhoseSetDoesNotComposeReturnsAnErrorAndNoDeclaration(t *testing.T) {
+ in := aMesh(t)
+ in.Catalogue["rival-one"] = catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
+ in.Catalogue["rival-two"] = catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
+ laptop := nodeOf(t, &in, "laptop")
+ laptop.Assigned = append(laptop.Assigned, "rival-one", "rival-two")
+ d, problems, err := Compose(in, "laptop")
+ if err == nil || !IsUnresolvable(err) || d.Resources != nil || problems != nil {
+ t.Fatalf("a set that does not compose: %v %v %v", d.Resources, problems, err)
+ }
+ if _, _, err := Compose(in, "nowhere"); err == nil || IsUnresolvable(err) {
+ t.Fatalf("a node the Input does not have: %v", err)
+ }
+}
+
+// 1298/AC4: composing the same Input twice gives byte-identical bodies, and the Input is unchanged.
+func TestComposingTwiceGivesTheSameBytesAndChangesNothing(t *testing.T) {
+ mk := func() Input {
+ in := aSmallMesh(t)
+ nodeOf(t, &in, "anchor").Account = "jochens"
+ nodeOf(t, &in, "laptop").Sequence = 7
+ return in
+ }
+ in := mk()
+ for _, node := range []string{"anchor", "laptop", "ace"} {
+ a, _, err := Compose(in, node)
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, _, err := Compose(in, node)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ab, _ := a.Body()
+ bb, _ := b.Body()
+ if string(ab) != string(bb) {
+ t.Errorf("%s: two compositions differ:\n%s\n%s", node, ab, bb)
+ }
+ }
+ unchanged(t, mk, func(in Input) {
+ for _, node := range []string{"anchor", "laptop", "ace"} {
+ if _, _, err := Compose(in, node); err != nil {
+ t.Fatal(err)
+ }
+ }
+ })
+}
+
+// 1298/AC5: what a send would make is named in the result as foreseen and never invented: an own secret, a port, a core box, a grant's
+// sealed copy.
+func TestWhatASendWouldMakeIsNamedAsForeseenAndNeverInvented(t *testing.T) {
+ in := aGrantMesh(t)
+ in.Issued[0].ForProvider = ""
+ in.Catalogue["assistant"] = anAssistant()
+ in.Catalogue["web"] = aWebModule()
+ in.Catalogue["nats"] = catalogue.Manifest{Module: "nats", Version: "1",
+ OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/nats/broker", Taken: catalogue.TakenAtStart}}}
+ anchor := nodeOf(t, &in, "anchor")
+ anchor.Assigned = append(anchor.Assigned, "assistant", "web", "nats")
+ anchor.SealingKey = keyOf(t, "anchor")
+ anchor.Stated, anchor.Contract = true, VaultBoxContract
+ in.BusMinted = []string{"anchor.nats"}
+ d, problems, err := Compose(in, "anchor")
+ if err != nil || len(problems) != 0 {
+ t.Fatalf("%v %v", err, problems)
+ }
+ if !has(d.Foreseen, "assistant/password") || !has(d.Foreseen, "nats/broker") {
+ t.Errorf("own secrets foreseen: %v", d.Foreseen)
+ }
+ if len(d.ForeseenPorts) != 1 || d.ForeseenPorts[0] != "web:8080" {
+ t.Errorf("foreseen ports: %v", d.ForeseenPorts)
+ }
+ if len(d.CoreBoxesWanted) != 1 || d.CoreBoxesWanted[0].Module != "nats" {
+ t.Errorf("core boxes wanted: %v", d.CoreBoxesWanted)
+ }
+ if !has(d.StoodIn, "the grant of s3-bucket for files on laptop") {
+ t.Errorf("stood in for: %v", d.StoodIn)
+ }
+ body, _ := d.Body()
+ if strings.Contains(string(body), secretValue) {
+ t.Error("a secret value is in the body")
+ }
+}
+
+// 1298/AC6: the body carries the sequence, epoch and generation the Input gives, and the module of each resource and the carried builds
+// only to a node whose Input says it reads modules (novox/hq issue 1145).
+func TestTheBodyCarriesTheOrderTheInputGivesAndTheBuildsOnlyToAnEngineThatReadsThem(t *testing.T) {
+ in := aSmallMesh(t)
+ laptop := nodeOf(t, &in, "laptop")
+ laptop.Sequence, laptop.Epoch, laptop.Generation = 42, 57, 9
+ in.CurrentBuilds = map[string]string{"files": "commit-files", "mesh-wireguard": "commit-wg"}
+ in.BuildIDs = map[string]map[string]string{"files": {"commit-files": "build-7"}}
+ // An assignment of another commit than the one carried does not name the build; the build made from the commit carried does.
+ laptop.Runs = map[string]Run{"mesh-wireguard": {Commit: "commit-wg", Build: "build-9"}, "files": {Commit: "an-older-commit", Build: "build-6"}}
+ d, _, err := Compose(in, "laptop")
+ if err != nil {
+ t.Fatal(err)
+ }
+ body, _ := d.Body()
+ var got map[string]any
+ if err := json.Unmarshal(body, &got); err != nil {
+ t.Fatal(err)
+ }
+ if got["sequence"] != float64(42) || got["epoch"] != float64(57) || got["generation"] != float64(9) {
+ t.Errorf("the order is %v %v %v", got["sequence"], got["epoch"], got["generation"])
+ }
+ if _, there := got["builds"]; there {
+ t.Errorf("builds are sent to an engine that does not read them: %s", body)
+ }
+ for _, r := range got["resources"].([]any) {
+ if _, there := r.(map[string]any)["module"]; there {
+ t.Errorf("a module is named on a resource of an engine that does not read them: %v", r)
+ }
+ }
+ laptop.ReadsModules = true
+ d, _, err = Compose(in, "laptop")
+ if err != nil {
+ t.Fatal(err)
+ }
+ body, _ = d.Body()
+ got = nil
+ if err := json.Unmarshal(body, &got); err != nil {
+ t.Fatal(err)
+ }
+ builds, _ := got["builds"].(map[string]any)
+ if builds["files"] != "build-7" || builds["mesh-wireguard"] != "build-9" {
+ t.Errorf("builds are %v", builds)
+ }
+ named := false
+ for _, r := range got["resources"].([]any) {
+ if r.(map[string]any)["id"] == "files.config" && r.(map[string]any)["module"] == "files" {
+ named = true
+ }
+ }
+ if !named {
+ t.Errorf("a resource is not named for its module: %s", body)
+ }
+ // Zero is not sent at all.
+ laptop.Sequence, laptop.Epoch, laptop.Generation = 0, 0, 0
+ d, _, _ = Compose(in, "laptop")
+ body, _ = d.Body()
+ for _, key := range []string{`"sequence"`, `"epoch"`, `"generation"`} {
+ if strings.Contains(string(body), key) {
+ t.Errorf("%s is sent as zero: %s", key, body)
+ }
+ }
+}
+
+// A module left out of a build set keeps the build it was last sent (novox/hq issue 259): the machine keeps that one.
+func TestAModuleLeftOutKeepsTheBuildItWasLastSent(t *testing.T) {
+ c := newComposer(Input{CurrentBuilds: map[string]string{"a": "new-a", "b": "new-b"}})
+ plan := catalogue.Resolution{Modules: []catalogue.Manifest{{Module: "a"}, {Module: "b"}, {Module: "c"}}}
+ node := Node{SentBuilds: map[string]string{"b": "old-b", "c": "old-c"}, SentKnown: true}
+ got := c.carriedOn(node, plan, map[string]string{"b": "its settings cannot compose"})
+ if got["a"] != "new-a" || got["b"] != "old-b" || got["c"] != "" {
+ t.Errorf("carried builds are %v", got)
+ }
+ node.SentKnown = false
+ if got := c.carriedOn(node, plan, map[string]string{"b": "x"}); len(got) != 2 || got["b"] != "" {
+ t.Errorf("when what was sent is not known: %v", got)
+ }
+}
+
+// An adopted node is told which modules were taken and, for every module it runs that is not, the ids of its resources
+// (novox/hq ADR 0100 and 0103); a converged node is told nothing, and its body is as it was before adoption.
+func TestAnAdoptedNodeIsToldWhatWasTakenAndWhatIsKept(t *testing.T) {
+ in := aSmallMesh(t)
+ d, _, err := Compose(in, "laptop")
+ if err != nil || d.Adoption != nil {
+ t.Fatalf("a converged node: %v %v", d.Adoption, err)
+ }
+ laptop := nodeOf(t, &in, "laptop")
+ laptop.Adopted, laptop.Taken = true, []string{"mesh-wireguard"}
+ d, problems, err := Compose(in, "laptop")
+ if err != nil || len(problems) != 0 {
+ t.Fatalf("%v %v", err, problems)
+ }
+ if d.Adoption == nil || len(d.Adoption.Taken) != 1 || d.Adoption.Taken[0] != "mesh-wireguard" ||
+ len(d.Adoption.Untaken["files"]) == 0 || d.Adoption.Untaken["mesh-wireguard"] != nil {
+ t.Fatalf("adoption is %+v", d.Adoption)
+ }
+ if body, _ := d.Body(); !strings.Contains(string(body), `"adoption"`) {
+ t.Errorf("the adoption is not on the wire: %s", body)
+ }
+}
+
+// Issue 263 (merge_gate_integration_test.go TestIssue263AnIdentityARealMachineNameOverflows...): a change makes the network manager
+// require the object store's provision; on a machine whose name is six characters its identity is 26 against a bound of 20. The
+// composition of the provider returns the refusal, naming the module and the provision.
+func TestIssue263AnIdentityARealMachineNameOverflowsIsARefusal(t *testing.T) {
+ in := aGrantMesh(t)
+ in.Catalogue["resolver"] = storeManifests()[1]
+ in.Catalogue["networkmanager"] = catalogue.Manifest{Module: "networkmanager", Version: "1", Requires: []string{"wildcard-resolution", "s3-bucket"}}
+ laptop, anchor := nodeOf(t, &in, "laptop"), nodeOf(t, &in, "anchor")
+ laptop.Assigned = append(laptop.Assigned, "networkmanager")
+ anchor.Assigned = append(anchor.Assigned, "resolver")
+ in.Holdings = []catalogue.Held{{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "resolver", Site: "here"}}
+ in.Issued = append(in.Issued, Issued{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "networkmanager", Provider: "anchor", ForProvider: "sealed"})
+ d, problems, err := Compose(in, "anchor")
+ if err != nil || len(problems) != 0 {
+ t.Fatalf("%v %v", err, problems)
+ }
+ refusals := strings.Join(d.Refusals(), "\n")
+ if !strings.Contains(refusals, "networkmanager") || !strings.Contains(refusals, "s3-bucket") || !strings.Contains(refusals, "laptop") {
+ t.Fatalf("the refusal does not name the module, the provision and the machine: %q", refusals)
+ }
+ // And the consumer that fits is granted.
+ if len(d.Withheld) != 1 || d.Withheld[0].Module != "networkmanager" {
+ t.Errorf("withheld %+v", d.Withheld)
+ }
+}
+
+// Round 1, finding 1 (plan.go keepRecorded): a recorded module kept at the build a machine runs is that machine's own resolution
+// and no other's; the commit carried for it is the kept one.
+func TestAKeptModuleChangesOnlyTheResolutionOfTheNodeThatKeepsIt(t *testing.T) {
+ in := aSmallMesh(t)
+ in.Catalogue["tool"] = catalogue.Manifest{Module: "tool", Version: "2"}
+ for _, name := range []string{"anchor", "laptop"} {
+ n := nodeOf(t, &in, name)
+ n.Assigned = append(n.Assigned, "tool")
+ }
+ anchor := nodeOf(t, &in, "anchor")
+ anchor.Kept = map[string]catalogue.Manifest{"tool": {Module: "tool", Version: "1"}}
+ versionOn := func(in Input, node string) string {
+ t.Helper()
+ got, err := Resolve(in, node)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, m := range got.Plan.Modules {
+ if m.Module == "tool" {
+ return m.Version
+ }
+ }
+ t.Fatalf("no tool on %s", node)
+ return ""
+ }
+ if v := versionOn(in, "anchor"); v != "1" {
+ t.Errorf("the node that keeps it runs version %s", v)
+ }
+ if v := versionOn(in, "laptop"); v != "2" {
+ t.Errorf("another node runs version %s: a kept build leaked", v)
+ }
+ if got := in.Catalogue["tool"].Version; got != "2" {
+ t.Errorf("the Input's catalogue was changed: %s", got)
+ }
+ // And the commit carried for it is the kept one.
+ c := newComposer(in)
+ plan := catalogue.Resolution{Modules: []catalogue.Manifest{{Module: "tool"}, {Module: "other"}}}
+ in.CurrentBuilds = map[string]string{"tool": "current", "other": "current-other"}
+ c = newComposer(in)
+ node := Node{KeptBuilds: map[string]string{"tool": "kept-commit"}}
+ if got := c.carriedOn(node, plan, nil); got["tool"] != "kept-commit" || got["other"] != "current-other" {
+ t.Errorf("carried builds are %v", got)
+ }
+}
+
+// Another node's kept build changes neither the rest of the mesh nor who is on the network: the controller reads both with the
+// current manifests, and only then keeps a recorded module for the node it composes (recorded_kept.go keepRecorded).
+func TestAnotherNodesKeptBuildDoesNotChangeTheRestOfTheMeshOrWhoIsOnTheNetwork(t *testing.T) {
+ in := threeNodes(t)
+ nodeOf(t, &in, "anchor").Kept = map[string]catalogue.Manifest{"objects": {Module: "objects", Version: "0"}, overlayName(): {Module: overlayName(), Version: "0"}}
+ w, _, _, err := TheRestOfTheMesh(in, "laptop")
+ if err != nil || len(providers(w, "s3-bucket")) != 1 {
+ t.Errorf("a kept build changed what anchor offers: %v %v", providers(w, "s3-bucket"), err)
+ }
+ where, err := newComposer(in).whereEveryoneIs()
+ if err != nil || where["anchor"] == "" {
+ t.Errorf("a kept build took anchor off the network: %v %v", where, err)
+ }
+}
+
+func overlayName() string { return overlay.Name }
+
+// Round 1, finding 6: the nodes the composition did not count as on the network are returned, said, not lost. laptop holds no
+// complete place (no key): one that does keeps it on the network (issue 1833) and is counted.
+func TestANodeNotCountedAsOnTheNetworkIsSaidInTheResult(t *testing.T) {
+ in := aMesh(t)
+ nodeOf(t, &in, "laptop").Place.Key = ""
+ in.Catalogue["rival-one"] = catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
+ in.Catalogue["rival-two"] = catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
+ laptop := nodeOf(t, &in, "laptop")
+ laptop.Assigned = append(laptop.Assigned, "rival-one", "rival-two")
+ said := func(lines []string) bool {
+ for _, l := range lines {
+ if strings.HasPrefix(l, "laptop is not counted as on the network") {
+ return true
+ }
+ }
+ return false
+ }
+ got, err := Render(in, "anchor", Foreseeing)
+ if err != nil || !said(got.Said) {
+ t.Errorf("rendered: %v %v", got.Said, err)
+ }
+ d, _, err := Compose(in, "anchor")
+ if err != nil || !said(d.Said) {
+ t.Errorf("declared: %v %v", d.Said, err)
+ }
+ n := 0
+ for _, l := range d.Said {
+ if strings.HasPrefix(l, "laptop is not counted") {
+ n++
+ }
+ }
+ if n != 1 {
+ t.Errorf("said %d times: %v", n, d.Said)
+ }
+}
diff --git a/internal/compose/one_machine_alone_test.go b/internal/compose/one_machine_alone_test.go
new file mode 100644
index 0000000..3c49abb
--- /dev/null
+++ b/internal/compose/one_machine_alone_test.go
@@ -0,0 +1,167 @@
+package compose
+
+import (
+ "slices"
+ "strings"
+ "testing"
+
+ "git.novox.be/novox/mesh-sdk/go/catalogue"
+ "github.com/novox/mesh-host/internal/overlay"
+)
+
+// fourOnTheNetwork is aMesh and two more machines on the private network at the site `there`: `ace`, dialled at
+// ace.example:51820, at 10.77.0.3, and `box`, behind no endpoint, at 10.77.0.4. A module `office` requires
+// `document-server`, which nothing in the catalogue provides: the shape of issue 1833 (nextcloud on the hub wanting a
+// document server nobody offered).
+func fourOnTheNetwork(t *testing.T) Input {
+ t.Helper()
+ in := aMesh(t)
+ in.Catalogue["office"] = catalogue.Manifest{Module: "office", Version: "1", Requires: []string{"document-server"}}
+ caps := map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true}
+ in.Nodes = append(in.Nodes,
+ Node{Name: "ace", Place: Place{Key: keyOf(t, "ace"), Endpoint: "ace.example:51820", Site: "there", Address: "10.77.0.3"},
+ Assigned: []string{overlay.Name}, Capabilities: caps},
+ Node{Name: "box", Place: Place{Key: keyOf(t, "box"), Site: "there", Address: "10.77.0.4"},
+ Assigned: []string{overlay.Name}, Capabilities: caps})
+ return in
+}
+
+// unresolvableBy makes one machine's own set fail to resolve: it is also given `office`, whose requirement nothing provides.
+func unresolvableBy(t *testing.T, in *Input, name string) {
+ t.Helper()
+ n := nodeOf(t, in, name)
+ n.Assigned = append(n.Assigned, "office")
+}
+
+// networkOf is the generator of an Input's private network, failing the test when it is not composed.
+func networkOf(t *testing.T, in Input) *overlay.Generator {
+ t.Helper()
+ gens, err := Generators(in)
+ if err != nil {
+ t.Fatalf("the private network was not composed: %v", err)
+ }
+ return gens[overlay.Name].(*overlay.Generator)
+}
+
+func placesOf(g *overlay.Generator) map[string]overlay.Node {
+ out := map[string]overlay.Node{}
+ for _, n := range g.Nodes() {
+ out[n.Name] = n
+ }
+ return out
+}
+
+// 1833/AC1: four machines, the hub's own set does not resolve; the other three compose their declarations, and the hub alone is
+// refused, with an error that is its own set failing and names what could not be resolved.
+func TestOneMachineThatDoesNotResolveIsRefusedAlone(t *testing.T) {
+ in := fourOnTheNetwork(t)
+ unresolvableBy(t, &in, "anchor")
+ for _, node := range []string{"laptop", "ace", "box"} {
+ d, problems, err := Compose(in, node)
+ if err != nil || len(problems) != 0 {
+ t.Errorf("%s does not compose because another machine does not resolve: %v %v", node, err, problems)
+ continue
+ }
+ if !has(resourceIDs(d), "file:mesh-wireguard.overlay-config") {
+ t.Errorf("%s is given no private network: %v", node, resourceIDs(d))
+ }
+ }
+ _, _, err := Compose(in, "anchor")
+ if err == nil || !IsUnresolvable(err) {
+ t.Fatalf("the machine that does not resolve is not refused as its own set failing: %v", err)
+ }
+ if !strings.Contains(err.Error(), `nothing provides "document-server", wanted by office`) {
+ t.Errorf("the refusal does not name what could not be resolved: %v", err)
+ }
+}
+
+// 1833/AC2: the private network is composed with every machine on it: the hub that does not resolve keeps its place (key,
+// address, endpoint, site, hub role), so every other machine keeps its path through it.
+func TestAMachineThatDoesNotResolveKeepsItsPlaceOnTheNetwork(t *testing.T) {
+ in := fourOnTheNetwork(t)
+ unresolvableBy(t, &in, "anchor")
+ got := placesOf(networkOf(t, in))
+ if len(got) != 4 {
+ t.Fatalf("the network is over %v, want all four machines", got)
+ }
+ hub := got["anchor"]
+ if !hub.Hub || hub.Key != keyOf(t, "anchor") || hub.Address != "10.77.0.1" || hub.Endpoint != "anchor.example:51820" || hub.Site != "here" {
+ t.Errorf("the hub did not keep its place: %+v", hub)
+ }
+ nodes, graph, err := Graph(in)
+ if err != nil || len(nodes) != 4 {
+ t.Fatalf("the graph is not shown with every machine: %v %v", nodes, err)
+ }
+ for _, node := range []string{"ace", "box"} {
+ through := false
+ for _, p := range graph[node] {
+ through = through || (p.Name == "anchor" && p.Endpoint == "anchor.example:51820")
+ }
+ if !through {
+ t.Errorf("%s has no path through the hub: %+v", node, graph[node])
+ }
+ }
+}
+
+// 1833/AC3: a machine that does not resolve and was never placed (a new machine: no key, no address) is left off the network,
+// which is composed from the rest -- here with the hub that does not resolve on its last place -- and that machine alone is refused.
+func TestANewMachineThatDoesNotResolveIsLeftOffAndRefusedAlone(t *testing.T) {
+ in := fourOnTheNetwork(t)
+ unresolvableBy(t, &in, "anchor")
+ in.Nodes = append(in.Nodes, Node{Name: "fresh", Place: Place{Site: "there"},
+ Assigned: []string{overlay.Name, "office"}, Capabilities: map[string]bool{"wireguard": true, "systemd": true}})
+ got := placesOf(networkOf(t, in))
+ if _, on := got["fresh"]; on || len(got) != 4 {
+ t.Fatalf("the network is over %v, want the four placed machines without fresh", got)
+ }
+ if _, _, err := Compose(in, "fresh"); err == nil || !IsUnresolvable(err) {
+ t.Errorf("the new machine is not refused as its own set failing: %v", err)
+ }
+ if _, problems, err := Compose(in, "box"); err != nil || len(problems) != 0 {
+ t.Errorf("another machine does not compose: %v %v", err, problems)
+ }
+}
+
+// 1833/AC4: a network that truly cannot be composed from what remains still fails: the hub does not resolve and has no
+// complete place (no key), so nothing is the hub; the refusal is today's, naming the machine that could not be resolved.
+func TestANetworkThatCannotBeComposedFromWhatRemainsStillFails(t *testing.T) {
+ in := fourOnTheNetwork(t)
+ unresolvableBy(t, &in, "anchor")
+ nodeOf(t, &in, "anchor").Place.Key = ""
+ _, err := Generators(in)
+ if err == nil {
+ t.Fatal("a network without a hub was composed")
+ }
+ for _, want := range []string{overlay.ErrNoHub.Error(), "and 1 node(s) could not be resolved at all, which is probably why:", " anchor: "} {
+ if !strings.Contains(err.Error(), want) {
+ t.Errorf("the refusal does not say %q:\n%v", want, err)
+ }
+ }
+ if _, _, err := Compose(in, "laptop"); err == nil {
+ t.Error("a declaration was composed over a network that cannot be")
+ }
+}
+
+// 1833/AC2, a failure the composition's unchecked pass refuses too (two modules claiming one seat on the hub, the 1360/AC3
+// shape): the hub keeps its place in what every other machine is sent besides the tunnel -- its internal name and its address
+// among the sources "from the mesh" -- and another machine still composes.
+func TestAMachineThatDoesNotResolveKeepsItsNameAndItsAddressInTheMesh(t *testing.T) {
+ in := fourOnTheNetwork(t)
+ in.Catalogue["rival-one"] = catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
+ in.Catalogue["rival-two"] = catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
+ hub := nodeOf(t, &in, "anchor")
+ hub.Assigned = append(hub.Assigned, "rival-one", "rival-two")
+ if _, err := Resolve(in, "anchor"); !IsUnresolvable(err) {
+ t.Fatalf("the hub is meant not to resolve: %v", err)
+ }
+ c := newComposer(in)
+ if got := c.namesInTheMesh()["anchor."+c.suffix()]; got != "10.77.0.1" {
+ t.Errorf("the hub's name is not in the mesh's names: %v", c.namesInTheMesh())
+ }
+ if got := c.onThePrivateNetwork(); !slices.Contains(got, "10.77.0.1") || len(got) != 4 {
+ t.Errorf("the sources from the mesh are %v, want all four machines with the hub", got)
+ }
+ if _, problems, err := Compose(in, "ace"); err != nil || len(problems) != 0 {
+ t.Errorf("another machine does not compose: %v %v", err, problems)
+ }
+}
diff --git a/internal/compose/render.go b/internal/compose/render.go
new file mode 100644
index 0000000..3354fad
--- /dev/null
+++ b/internal/compose/render.go
@@ -0,0 +1,150 @@
+package compose
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+
+ "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+// Rendered is everything a node's declaration is composed with (plan.go renderingFor), over an Input: the place half (Places)
+// and the credential half. Nothing was allocated, made, kept or written; what a send would do is returned for the caller to do.
+type Rendered struct {
+ Rendering catalogue.Rendering
+ // ForeseenPorts are the ports the mesh may assign that the Input holds none for (":").
+ ForeseenPorts []string
+ // Foreseen are the stand-ins the composition used for what the Input does not hold: an own secret the next send makes
+ // ("/"), a grant's sealed copy, the certificate and the bus's user list.
+ Foreseen []string
+ // CoreBoxesWanted are the core credentials whose box the send must ask the vault for and keep.
+ CoreBoxesWanted []CoreBoxWanted
+ // Withheld are the consumers the grants leave out because their identity overflows the provision's bound (novox/hq ADR 0225),
+ // and Unbound those whose credential from this node is on record and whose own resolution binds them elsewhere (issue
+ // 274). The controller says them; a check refuses a change that adds one (ADR 0446 clause 1).
+ Withheld []catalogue.Overflow
+ Unbound []catalogue.Unbound
+ // Said are the lines the controller says while composing (the bus users left out for want of a credential).
+ Said []string
+}
+
+// Render composes the rendering of a node: the grants it must create, where it puts its ports, its own secrets, the vault's boxes,
+// its certificate, the names of the mesh, the bus's user list and its membership. The refusals are the controller's, in its words;
+// where more than one fault stands, the first reported may differ from the controller's, which interleaves the halves.
+func Render(in Input, node string, mode Mode) (Rendered, error) {
+ return newComposer(in).render(node, mode)
+}
+
+// Composing is Resolve, Render and Generators of one node over one composition, which resolves each node of the mesh
+// once for all three (issue 1954: three compositions resolved the mesh again each, 11.7 s for one node's plans). Each
+// answers as it does alone; the first refusal ends it, in that order, with what follows it empty.
+func Composing(in Input, node string, mode Mode) (Resolved, Rendered, map[string]catalogue.Generator, error) {
+ c := newComposer(in)
+ resolved, err := c.planFor(node)
+ if err != nil {
+ return Resolved{}, Rendered{}, nil, err
+ }
+ rendered, err := c.render(node, mode)
+ if err != nil {
+ return resolved, Rendered{}, nil, err
+ }
+ gens, err := c.generators()
+ if err != nil {
+ return resolved, rendered, nil, err
+ }
+ return resolved, rendered, gens, nil
+}
+
+func (c *composer) render(nodeName string, mode Mode) (Rendered, error) {
+ resolved, err := c.planFor(nodeName)
+ if err != nil {
+ return Rendered{}, err
+ }
+ node, err := c.in.node(nodeName)
+ if err != nil {
+ return Rendered{}, err
+ }
+ plan := resolved.Plan
+ var out Rendered
+ foreseen := func(s string) { out.Foreseen = append(out.Foreseen, s) }
+
+ grants, withheld, unbound, grantsForeseen, err := c.grantsFor(nodeName)
+ if err != nil {
+ return Rendered{}, err
+ }
+ placed, err := c.places(nodeName, resolved)
+ if err != nil {
+ return Rendered{}, err
+ }
+ r := placed.Rendering
+ out.ForeseenPorts = placed.ForeseenPorts
+ // A plan carries its contributions' machine ports, refused where the placement assigned none (issue 774).
+ r.Planned = mode == Planning
+ r.Grants, r.Withheld, r.Unbound = grants, withheld, unbound
+ out.Withheld, out.Unbound = withheld, unbound
+ for _, g := range grantsForeseen {
+ foreseen(g)
+ }
+
+ // And each module's own secrets: a module handed to the provisioner has its secrets from the vault through the client.
+ handing := catalogue.Rendering{Hands: r.Hands, Provisioner: r.Provisioner, Assigned: r.Assigned}
+ needed, foreseenOwn, err := c.ownSecretsFor(node, plan, handing, mode)
+ if err != nil {
+ return Rendered{}, err
+ }
+ r.Needed = needed
+ if mode == Foreseeing || mode == Planning || len(foreseenOwn) > 0 {
+ r.Foreseen = foreseenOwn
+ }
+ var names []string
+ for module, secrets := range foreseenOwn {
+ for name := range secrets {
+ names = append(names, module+"/"+name)
+ }
+ }
+ sort.Strings(names)
+ for _, n := range names {
+ foreseen(n)
+ }
+
+ // The core's own credentials as the vault sealed them, for an engine that opens the vault's box.
+ if node.Stated && node.Contract >= VaultBoxContract {
+ sealed, notPlaced, wanted := c.coreBoxesFor(node, plan.Modules, handing)
+ r.VaultSealed, r.NotPlaced = sealed, notPlaced
+ out.CoreBoxesWanted = wanted
+ }
+
+ // And a certificate for this machine's name inside the mesh, when anything on it asks.
+ for _, m := range plan.Modules {
+ if m.Certificate == nil {
+ continue
+ }
+ certificate, authority, stood, err := c.certificateFor(node)
+ if err != nil {
+ return Rendered{}, err
+ }
+ r.Certificate, r.Authority = certificate, authority
+ if stood {
+ foreseen("the certificate and the authority of " + nodeName)
+ }
+ break
+ }
+
+ // The bus's user list, for the machine that runs the bus: names and hashes the Input carries, never passwords.
+ if holdsTheBus(plan.Modules) {
+ r.BusUsers = c.in.BusUsers
+ if r.BusUsers == "" {
+ r.BusUsers = ForeseenBusUsers
+ foreseen(BusUsersStandIn)
+ }
+ if len(c.in.BusMissing) > 0 {
+ out.Said = append(out.Said, fmt.Sprintf("the bus's user list leaves out %d user(s) the mesh has minted no credential "+
+ "for: %s. Each is a user that cannot connect until one is issued", len(c.in.BusMissing), strings.Join(c.in.BusMissing, ", ")))
+ }
+ }
+ r.BusMembership = c.in.BusMemberships[nodeName]
+ out.Rendering = r
+ // And what the composition found on the way, which the controller writes to its log: a machine not counted as on the network.
+ out.Said = c.saidBeside(out.Said)
+ return out, nil
+}
diff --git a/internal/compose/rendering.go b/internal/compose/rendering.go
new file mode 100644
index 0000000..c8db3e0
--- /dev/null
+++ b/internal/compose/rendering.go
@@ -0,0 +1,544 @@
+package compose
+
+import (
+ "errors"
+ "fmt"
+ "sort"
+ "strings"
+
+ "git.novox.be/novox/mesh-sdk/go/catalogue"
+ "github.com/novox/mesh-host/internal/compose/resolution"
+ "github.com/novox/mesh-host/internal/overlay"
+)
+
+// The contracts of the node-engine's statement that gate what a machine is sent, as the controller's link package names them
+// (mesh-controller internal/link/protocol.go at 6c2a8d62). An older engine parses strictly and refuses a field it does not
+// know, whole.
+const (
+ ReadinessContract = 2 // the engine reads a resource's `health` (novox/hq ADR 0240)
+ RootContract = 3 // the engine judges a user's declared `root` (ADR 0266)
+ BusyContract = 8 // the engine reads a container's `busy` (ADR 0335)
+ HandedContract = 11 // the engine writes a filled file on the provisioner client's word alone
+ VaultBoxContract = 12 // the engine opens the vault's box
+)
+
+// The provisioner client's seat and own secret (the controller's broker.NodeProvisionerSeat and ProvisionerSecret).
+const (
+ NodeProvisionerSeat = "node-provisioner"
+ ProvisionerSecret = "provisioner-broker"
+)
+
+// ErrPortTaken is the controller's refusal for a machine port held twice on one machine.
+var ErrPortTaken = errors.New("that port is already held on this machine")
+
+// Placed is the place half of a rendering (plan.go renderingFor): where a machine puts what its modules need reachable, the
+// seats and who holds them, the names of the mesh, the networks, accounts and the operator's export. It is a catalogue.Rendering
+// with those fields set, and the ports it could not read named foreseen. Nothing was allocated.
+type Placed struct {
+ Rendering catalogue.Rendering
+ // ForeseenPorts are the ports the mesh may assign that the Input holds none for, as ":": composed with the
+ // manifest's own number as the Reading mode composes them, and never invented. The controller's adapter allocates and
+ // composes again.
+ ForeseenPorts []string
+}
+
+// Places composes the place half of the rendering for a node (the controller's renderingFor in the Reading and Foreseeing modes,
+// without the credentials): over an Input, allocating nothing and changing nothing in it. The refusals are the controller's,
+// in its words.
+func Places(in Input, node string) (Placed, error) {
+ c := newComposer(in)
+ resolved, err := c.planFor(node)
+ if err != nil {
+ return Placed{}, err
+ }
+ return c.places(node, resolved)
+}
+
+func (c *composer) places(nodeName string, resolved Resolved) (Placed, error) {
+ var out Placed
+ node, err := c.in.node(nodeName)
+ if err != nil {
+ return out, err
+ }
+ plan, settings := resolved.Plan, resolved.Settings
+ r := &out.Rendering
+
+ // Where this machine puts what each module needs reachable (novox/hq ADR 0038): what it was already given, and the ports
+ // it was given over those (ADR 0100).
+ given, err := c.portsFor(node, plan, settings, r, &out.ForeseenPorts)
+ if err != nil {
+ return out, err
+ }
+
+ // Hands: whether the engine writes a filled file on the provisioner client's word alone; engines older than a contract are
+ // not sent the field they would refuse.
+ r.Hands = takesHanded(node)
+ r.Provisioner = provisionerAccount(plan)
+ r.Assigned = node.Handed
+ r.ReadsHealth = node.Stated && node.Contract >= ReadinessContract
+ r.JudgesRoot = node.Stated && node.Contract >= RootContract
+ r.ReadsBusy = node.Stated && node.Contract >= BusyContract
+
+ // And who else is on the private network, which is what a rule saying "from the mesh" resolves to.
+ r.Mesh = c.onThePrivateNetwork()
+ // The private network's range, offered to a module as ${machine:mesh-range}.
+ r.MeshRange = c.overlayRange()
+
+ // The artifact store as this node reaches it now, with what the mesh has built.
+ store, err := c.artifactStoreAddress(nodeName)
+ if err != nil {
+ return out, err
+ }
+ r.ArtifactStore = store
+ r.Built = make(map[string]bool, len(c.in.Built))
+ for _, repository := range c.in.Built {
+ r.Built[repository] = true
+ }
+
+ // And every machine's name, so a container can reach one.
+ r.Names = c.namesInTheMesh()
+
+ // Each machine's operator account, so an ssh Host block can name the login for every node (novox/hq to-be 29).
+ r.Accounts = map[string]string{}
+ for _, n := range c.in.sorted() {
+ if n.Account != "" {
+ r.Accounts[n.Name] = n.Account
+ }
+ }
+
+ // The roster is the machines and nothing else (novox/hq ADR 0191).
+ r.Machines = make(map[string]string, len(r.Names))
+ for name, at := range r.Names {
+ r.Machines[name] = at
+ }
+
+ // And every zone a module in the mesh answers itself (novox/hq ADR 0199).
+ zones, err := c.zonesInTheMesh()
+ if err != nil {
+ return out, err
+ }
+ r.Zones = zones
+
+ // And who holds each replicated seat, where (novox/hq ADR 0223).
+ r.Holders = c.replicatedHolders()
+
+ r.Suffix, r.TunnelInterface = c.suffix(), overlay.Interface
+
+ // And, for a module that keeps them, every operator-sealed secret in the mesh (novox/hq ADR 0085, amended).
+ for _, m := range plan.Modules {
+ if m.Keeps == "" {
+ continue
+ }
+ r.Kept = c.in.OperatorExport
+ if r.Kept == nil {
+ r.KeptRuns = c.in.KeptRuns[node.Name]
+ }
+ break
+ }
+
+ // Whether this node is adopted (novox/hq ADR 0100), and which modules were taken there.
+ r.Adopted = node.Adopted
+ var taken map[string]bool
+ if node.Adopted {
+ taken = map[string]bool{}
+ for _, m := range node.Taken {
+ taken[m] = true
+ }
+ }
+ r.Taken = taken
+
+ // Where this node put the foundation's servers (novox/hq 04-ISSUES/102).
+ seats, err := c.seatsOn(node, plan.Modules, node.Adopted, taken)
+ if err != nil {
+ return out, err
+ }
+ r.Seats = seats
+
+ // Which of this machine's links face outside (novox/hq ADR 0140), and which of its networks count as its local network
+ // (ADR 0340).
+ r.OutwardLinks = node.OutwardLinks
+ r.LocalNetworks = resolution.CountedLocalNetworks(localNetworksOf(node))
+
+ // Where this machine reaches each mesh seat's holder, for ${seat::reach} (novox/hq ADR 0222).
+ if store != "" {
+ r.SeatReach = map[string]string{"mesh-artifact-store": store}
+ }
+ r.Settings = settings
+ r.Given = given
+ return out, nil
+}
+
+// takesHanded says whether a machine's newest statement is of an engine that writes a filled file on the provisioner client's
+// word alone.
+func takesHanded(n Node) bool { return n.Stated && n.Contract >= HandedContract }
+
+// provisionerAccount is the account the provisioner's client runs as on this machine (novox/hq ADR 0349 clause 4): the user
+// the process of the module running it runs as, empty where no such module is on the machine.
+func provisionerAccount(plan catalogue.Resolution) string {
+ for _, m := range plan.Modules {
+ if !runsTheProvisionerClient(m) {
+ continue
+ }
+ for _, r := range m.Resources {
+ if r["type"] == "process" {
+ if user, _ := r["user"].(string); user != "" && user != "root" {
+ return user
+ }
+ }
+ }
+ }
+ return ""
+}
+
+// runsTheProvisionerClient says a module runs its machine's provisioner client: it claims the seat and declares the own
+// secret that account is delivered in.
+func runsTheProvisionerClient(m catalogue.Manifest) bool {
+ _, reads := m.OwnSecrets[ProvisionerSecret]
+ return reads && m.ClaimsSeat(NodeProvisionerSeat)
+}
+
+// portsFor is where a node puts what each module needs reachable, in the Reading mode: the ports it was given win, then the
+// ones the mesh assigned, and a port the Input holds none for is composed as the manifest says and named foreseen. One holder
+// per machine port across the node's modules: settings written before this was refused are refused here.
+func (c *composer) portsFor(node Node, plan catalogue.Resolution, settings catalogue.SettingsBy, r *catalogue.Rendering,
+ foreseen *[]string) (map[string]map[int]int, error) {
+ already := node.Ports
+
+ given := map[string]map[int]int{}
+ for _, m := range plan.Modules {
+ g, err := resolution.GivenPorts(m, settings[m.Module])
+ if err != nil {
+ // A given port its definition no longer publishes: the module is left out of the declaration, by name, when it is
+ // composed (novox/hq ADR 0163, rule 6) -- never the machine refused here for it.
+ continue
+ }
+ if g != nil {
+ given[m.Module] = g
+ }
+ }
+ holders := map[int]string{}
+ for _, m := range plan.Modules {
+ for _, at := range given[m.Module] {
+ if other, twice := holders[at]; twice && other != m.Module {
+ return nil, fmt.Errorf("%w: %s and %s are both given machine port %d on %s", ErrPortTaken, other, m.Module,
+ at, node.Name)
+ }
+ holders[at] = m.Module
+ }
+ }
+
+ ports := map[string]map[int]int{}
+ for _, m := range plan.Modules {
+ for _, l := range m.Listens {
+ if at, isGiven := given[m.Module][l.Port]; isGiven {
+ if ports[m.Module] == nil {
+ ports[m.Module] = map[int]int{}
+ }
+ ports[m.Module][l.Port] = at
+ continue
+ }
+ // Only a port the module actually publishes is the mesh's to move. Recorded either way, because this map means where
+ // this module's port is on this machine.
+ where, mayAssign := m.MachineSide(l.Port)
+ if mayAssign {
+ // Whatever was chosen last time, and nothing if there was no last time.
+ if at, known := already[m.Module][l.Port]; known {
+ where = at
+ } else {
+ *foreseen = append(*foreseen, fmt.Sprintf("%s:%d", m.Module, l.Port))
+ }
+ }
+ if ports[m.Module] == nil {
+ ports[m.Module] = map[int]int{}
+ }
+ ports[m.Module][l.Port] = where
+ }
+ }
+ r.Ports = ports
+ return given, nil
+}
+
+// onThePrivateNetwork is every node's address on the private network, sorted: the same set the names mean by it, so a rule
+// saying "from the mesh" admits exactly the machines the mesh names. A node with no address is left out, not rendered as an
+// empty source.
+func (c *composer) onThePrivateNetwork() []string {
+ var out []string
+ for _, p := range c.onTheNetwork() {
+ if strings.TrimSpace(p.Place.Address) != "" {
+ out = append(out, p.Place.Address)
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+// namesInTheMesh is every machine's internal name and the address behind it, for every machine on the private network, and
+// the carried peers the operator has named (novox/hq issue 112).
+func (c *composer) namesInTheMesh() map[string]string {
+ out := map[string]string{}
+ for _, p := range c.onTheNetwork() {
+ out[c.internalName(p.Name)] = p.Place.Address
+ }
+ for _, p := range c.in.CarriedPeers {
+ if p.Named == "" || p.EnrolledAs != "" {
+ continue
+ }
+ if _, taken := out[c.internalName(p.Named)]; taken {
+ continue // a node of the mesh owns the name; the stale statement loses
+ }
+ out[c.internalName(p.Named)] = p.Address
+ }
+ return out
+}
+
+// artifactStoreOnNetwork is the machine on this network that offers the artifact store, and the port that machine put it on.
+func (c *composer) artifactStoreOnNetwork(on map[string]bool) (node, port string, found bool, err error) {
+ providers := map[string]catalogue.Manifest{}
+ for name, m := range c.in.Catalogue {
+ if _, offers := m.Serves[catalogue.ArtifactStoreProvision]; offers {
+ providers[name] = m
+ }
+ }
+ if len(providers) == 0 {
+ return "", "", false, nil
+ }
+ // In a stated order, so two machines offering it would always answer the same one.
+ machines := make([]string, 0, len(on))
+ for machine := range on {
+ machines = append(machines, machine)
+ }
+ sort.Strings(machines)
+ for _, machine := range machines {
+ n, err := c.in.node(machine)
+ if err != nil {
+ return "", "", false, fmt.Errorf("reading what %s is assigned: %w", machine, err)
+ }
+ for _, a := range n.Assigned {
+ m, offers := providers[a]
+ if !offers {
+ continue
+ }
+ serves, _, err := c.servedOnNode(machine, m, catalogue.ArtifactStoreProvision)
+ if err != nil {
+ return "", "", false, fmt.Errorf("reading where %s puts the artifact store: %w", machine, err)
+ }
+ if p, ok := serves["port"]; ok {
+ return machine, fmt.Sprintf("%v", p), true, nil
+ }
+ }
+ }
+ return "", "", false, nil
+}
+
+// ArtifactStoreAddress is the artifact store as node reaches it, by the rule its composition routes every kept reference
+// with (artifactStoreAddress): empty when node reaches none. A node composing its own modules reads a templates archive
+// there (issue 1126).
+func ArtifactStoreAddress(in Input, node string) (string, error) {
+ return newComposer(in).artifactStoreAddress(node)
+}
+
+// artifactStoreAddress is the artifact store as `forNode` reaches it: `.internal:` over the private network, or
+// `127.0.0.1:` for the node that holds the store itself when nothing is on the network yet, and empty for any other
+// (novox/hq 04-ISSUES/102).
+func (c *composer) artifactStoreAddress(forNode string) (string, error) {
+ onNetwork, err := c.whereEveryoneIs()
+ if err != nil {
+ return "", err
+ }
+ on := map[string]bool{}
+ for name := range onNetwork {
+ on[name] = true
+ }
+ node, port, found, err := c.artifactStoreOnNetwork(on)
+ if err != nil {
+ return "", err
+ }
+ if found {
+ return c.internalName(node) + ":" + port, nil
+ }
+ all := map[string]bool{}
+ for _, n := range c.in.Nodes {
+ all[n.Name] = true
+ }
+ holder, port, found, err := c.artifactStoreOnNetwork(all)
+ if err != nil || !found || holder != forNode {
+ return "", err
+ }
+ return "127.0.0.1:" + port, nil
+}
+
+// zonesInTheMesh is every zone a module in the mesh declares, where the mesh placed it (novox/hq ADR 0199): the zone settled
+// from that node's settings, the node's private address, the port the answering listen is published on there. A node whose set
+// does not compose declares nothing and is passed over; what the mesh refuses about the zones together is refused, by name.
+func (c *composer) zonesInTheMesh() ([]catalogue.ZoneAt, error) {
+ address := map[string]string{}
+ for _, n := range c.in.sorted() {
+ if strings.TrimSpace(n.Place.Address) != "" {
+ address[n.Name] = n.Place.Address
+ }
+ }
+ var zones []catalogue.ZoneAt
+ var public []string
+ for _, n := range c.in.sorted() {
+ resolved, err := c.planFor(n.Name)
+ switch {
+ case unresolvable(err):
+ continue
+ case err != nil:
+ return nil, fmt.Errorf("the zones %s answers cannot be read: %w", n.Name, err)
+ }
+ plan := resolved.Plan
+ if plan.PublicDomain != "" {
+ public = append(public, plan.PublicDomain)
+ }
+ for _, m := range plan.Modules {
+ if m.Zone == nil {
+ continue
+ }
+ at := address[n.Name]
+ if at == "" {
+ // Not on the private network yet: nothing could reach its answerer.
+ continue
+ }
+ published, layers, err := c.portsGivenOn(n.Name, m)
+ if err != nil {
+ return nil, fmt.Errorf("the zone %s declares on %s cannot be read: %w", m.Module, n.Name, err)
+ }
+ z, err := resolution.ZoneOn(m, layers, published, n.Name, at)
+ if err != nil {
+ return nil, err
+ }
+ zones = append(zones, *z)
+ }
+ }
+ if problems := resolution.ZonesProblems(zones, c.suffix(), public); len(problems) > 0 {
+ return nil, fmt.Errorf("the mesh's zones cannot be forwarded:\n - %s", strings.Join(problems, "\n - "))
+ }
+ return zones, nil
+}
+
+// replicatedHolders is, for each replicated mesh seat, every machine on the private network holding it, by internal name at its
+// private address (novox/hq ADR 0223): the holders on record, and only the sole claimant when there are none. A holder off
+// the private network is left out.
+func (c *composer) replicatedHolders() map[string]map[string]string {
+ var replicated []catalogue.Seat
+ for _, s := range catalogue.Seats() {
+ if s.Replicated && s.Scope == catalogue.ScopeMesh {
+ replicated = append(replicated, s)
+ }
+ }
+ if len(replicated) == 0 {
+ return nil
+ }
+ address := map[string]string{}
+ for _, p := range c.onTheNetwork() {
+ address[p.Name] = p.Place.Address
+ }
+ // Every module of the catalogue with the nodes it is assigned on, sorted, as the controller's Catalogued answers.
+ on := map[string][]string{}
+ for _, n := range c.in.sorted() {
+ for _, module := range n.Assigned {
+ on[module] = append(on[module], n.Name)
+ }
+ }
+ names := make([]string, 0, len(c.in.Catalogue))
+ for name := range c.in.Catalogue {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ out := map[string]map[string]string{}
+ for _, seat := range replicated {
+ var nodes []string
+ for _, h := range c.in.Holdings {
+ if hs, ok := catalogue.SeatNamed(h.Claim); ok && hs.Name == seat.Name && h.Scope == seat.Scope {
+ nodes = append(nodes, h.Node)
+ }
+ }
+ if len(nodes) == 0 {
+ var derived []string
+ for _, name := range names {
+ for _, cl := range c.in.Catalogue[name].Claims {
+ if cs, ok := catalogue.SeatNamed(cl.Name); ok && cs.Name == seat.Name && cl.At() == seat.Scope {
+ derived = append(derived, on[name]...)
+ }
+ }
+ }
+ if len(derived) == 1 {
+ nodes = derived
+ }
+ }
+ at := map[string]string{}
+ for _, n := range nodes {
+ if address[n] != "" {
+ at[c.internalName(n)] = address[n]
+ }
+ }
+ out[seat.Name] = at
+ }
+ return out
+}
+
+// seatsOn is where a node put the holder of each mesh-scoped seat, by seat and by the port the holder's software uses: what
+// ${seat:...} answers with (novox/hq 04-ISSUES/102). Read for every module in the catalogue that claims a seat, in this node's set
+// or not; a holder in this node's set wins over one that is not.
+func (c *composer) seatsOn(node Node, inSet []catalogue.Manifest, adopted bool, taken map[string]bool) (map[string]map[int]int, error) {
+ assigned := map[string]bool{}
+ for _, m := range inSet {
+ assigned[m.Module] = true
+ }
+ names := make([]string, 0, len(c.in.Catalogue))
+ for name := range c.in.Catalogue {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ seats := map[string]map[int]int{}
+ for _, name := range names {
+ m := c.in.Catalogue[name]
+ var claims []string
+ for _, cl := range m.Claims {
+ if cl.At() == catalogue.ScopeMesh {
+ claims = append(claims, cl.Name)
+ }
+ }
+ if len(claims) == 0 {
+ continue
+ }
+ // Only a port the node was given or the mesh assigned, never the manifest's own number. And on an adopted node a holder
+ // assigned but not yet taken is the found container, so its mesh-assigned ports do not count there; a given port does.
+ ports, _, err := c.portsGivenOn(node.Name, m)
+ if err != nil {
+ return nil, err
+ }
+ if adopted && !taken[name] {
+ layers := node.Settings[m.Module]
+ ports = map[int]int{}
+ if given, err := resolution.GivenPorts(m, layers); err == nil {
+ ports = given
+ }
+ }
+ if len(ports) == 0 {
+ continue
+ }
+ for _, seat := range claims {
+ if seats[seat] == nil {
+ seats[seat] = map[int]int{}
+ }
+ for wanted, at := range ports {
+ if _, said := seats[seat][wanted]; said && !assigned[name] {
+ continue
+ }
+ seats[seat][wanted] = at
+ }
+ }
+ }
+ return seats, nil
+}
+
+// localNetworksOf is a machine's attached networks judged (novox/hq ADR 0340): which count as its local network, from what it
+// last said of itself and the links it said face outside it. A machine that has said none judges to nothing.
+func localNetworksOf(n Node) []catalogue.LocalNetwork {
+ if !n.NetworksSaid {
+ return nil
+ }
+ return resolution.JudgeLocalNetworks(n.Networks, n.OutwardLinks, overlay.Interface)
+}
diff --git a/internal/compose/rendering_test.go b/internal/compose/rendering_test.go
new file mode 100644
index 0000000..54b9c71
--- /dev/null
+++ b/internal/compose/rendering_test.go
@@ -0,0 +1,329 @@
+package compose
+
+import (
+ "errors"
+ "strings"
+ "testing"
+
+ "git.novox.be/novox/mesh-sdk/go/catalogue"
+ "github.com/novox/mesh-host/internal/overlay"
+)
+
+// aWebModule publishes one port in the short form, which the mesh is free to move (MachineSide).
+func aWebModule() catalogue.Manifest {
+ return catalogue.Manifest{Module: "web", Version: "1",
+ Listens: []catalogue.Listening{{Name: "http", Port: 8080, From: catalogue.FromMesh}},
+ Resources: []map[string]any{{"id": "server", "type": "container", "name": "web", "ports": []any{"8080"},
+ "image": "registry.example/web@sha256:" + strings.Repeat("a", 64)}}}
+}
+
+// aStoreModule is mesh-controller's sendable_integration_test.go TestConsumersAreToldTheGivenPort store (cmd/mesh-controller
+// at 6c2a8d62): publishing 5432:5432, offering a database to the mesh.
+func aStoreModule() catalogue.Manifest {
+ return catalogue.Manifest{Module: "store", Version: "1",
+ Provides: []catalogue.Offer{{Name: "database", Scope: catalogue.ScopeMesh}},
+ Listens: []catalogue.Listening{{Port: 5432, From: catalogue.FromMesh}},
+ Guards: []int{5432},
+ Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-store",
+ "ports": []any{"5432:5432"},
+ "image": "registry.example/pg@sha256:" + strings.Repeat("b", 64)}}}
+}
+
+func placed(t *testing.T, in Input, node string) Placed {
+ t.Helper()
+ got, err := Places(in, node)
+ if err != nil {
+ t.Fatalf("%v", err)
+ }
+ return got
+}
+
+// 1363/AC1 and AC2: a port the Input holds as allocated on the node is the one composed; with none, no number is invented: the
+// manifest's own is composed and the port is named foreseen, as the Foreseeing mode does.
+func TestAnAllocatedPortIsComposedAndAnUnallocatedOneIsNamedForeseenNotInvented(t *testing.T) {
+ mk := func() Input {
+ in := aMesh(t)
+ in.Catalogue["web"] = aWebModule()
+ nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "web")
+ return in
+ }
+ in := mk()
+ got := placed(t, in, "laptop")
+ if p := got.Rendering.Ports["web"][8080]; p != 8080 {
+ t.Errorf("a port nobody allocated is composed as %d, not as the manifest's own", p)
+ }
+ if len(got.ForeseenPorts) != 1 || got.ForeseenPorts[0] != "web:8080" {
+ t.Errorf("foreseen ports are %v", got.ForeseenPorts)
+ }
+ nodeOf(t, &in, "laptop").Ports = map[string]map[int]int{"web": {8080: 20123}}
+ got = placed(t, in, "laptop")
+ if p := got.Rendering.Ports["web"][8080]; p != 20123 || len(got.ForeseenPorts) != 0 {
+ t.Errorf("the allocated port is composed as %d (foreseen %v)", p, got.ForeseenPorts)
+ }
+ // A port the module does not publish is not the mesh's to move, so it is never foreseen.
+ m := aWebModule()
+ m.Resources = nil
+ in.Catalogue["web"] = m
+ nodeOf(t, &in, "laptop").Ports = nil
+ got = placed(t, in, "laptop")
+ if got.Rendering.Ports["web"][8080] != 8080 || len(got.ForeseenPorts) != 0 {
+ t.Errorf("an unpublished port: %v foreseen %v", got.Rendering.Ports, got.ForeseenPorts)
+ }
+ unchanged(t, mk, func(in Input) { placed(t, in, "laptop") })
+ // Only a composition for plans refuses a contribution whose port the module does not listen on (issue 774).
+ for _, mode := range []Mode{Reading, Foreseeing, Planning} {
+ if got := rendered(t, mk(), "laptop", mode).Rendering; got.Planned != (mode == Planning) {
+ t.Errorf("mode %d composes with Planned %v", mode, got.Planned)
+ }
+ }
+}
+
+// 1363/AC3: a port the operator gave is used, over the allocated one, and a consumer elsewhere is told it
+// (mesh-controller sendable_integration_test.go TestConsumersAreToldTheGivenPort).
+func TestAPortTheOperatorGaveIsUsedAndNotReallocated(t *testing.T) {
+ in := aMesh(t)
+ in.Catalogue["store"] = aStoreModule()
+ in.Catalogue["app"] = catalogue.Manifest{Module: "app", Version: "1", Requires: []string{"database"}}
+ anchor := nodeOf(t, &in, "anchor")
+ anchor.Assigned = append(anchor.Assigned, "store")
+ nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "app")
+ anchor.Ports = map[string]map[int]int{"store": {5432: 20999}}
+ anchor.Settings = map[string][]catalogue.Layer{"store": {{From: "anchor",
+ Placement: map[string]any{catalogue.PortsSetting: map[string]any{"5432": 5433}}}}}
+
+ got := placed(t, in, "anchor")
+ if p := got.Rendering.Ports["store"][5432]; p != 5433 {
+ t.Errorf("the store is on %d, not the port it was given", p)
+ }
+ if g := got.Rendering.Given["store"][5432]; g != 5433 {
+ t.Errorf("the given port is %d", g)
+ }
+ if len(got.ForeseenPorts) != 0 {
+ t.Errorf("a given port is foreseen: %v", got.ForeseenPorts)
+ }
+ resolved, err := Resolve(in, "laptop")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if told := need(t, resolved.Plan, "app", "database").Serves["port"]; told != 5433 {
+ t.Errorf("the consumer is told the store is on %v", told)
+ }
+
+ // One holder per machine port across the node's modules: two given the same refused, in the controller's words.
+ in.Catalogue["store2"] = func() catalogue.Manifest {
+ m := aStoreModule()
+ m.Module, m.Provides = "store2", nil
+ m.Resources = []map[string]any{{"id": "server", "type": "container", "name": "other", "ports": []any{"5432:5432"},
+ "image": "registry.example/pg@sha256:" + strings.Repeat("c", 64)}}
+ return m
+ }()
+ anchor.Assigned = append(anchor.Assigned, "store2")
+ anchor.Settings["store2"] = []catalogue.Layer{{From: "anchor",
+ Placement: map[string]any{catalogue.PortsSetting: map[string]any{"5432": 5433}}}}
+ _, err = Places(in, "anchor")
+ if err == nil || !errors.Is(err, ErrPortTaken) || !strings.Contains(err.Error(), "store and store2 are both given machine port 5433 on anchor") &&
+ !strings.Contains(err.Error(), "store2 and store are both given machine port 5433 on anchor") {
+ t.Fatalf("two modules given one machine port: %v", err)
+ }
+}
+
+// 1363/AC4: the seats held and taken reach the Rendering: where each mesh-scoped seat's holder put its ports, from the node's
+// given and assigned ports, never the manifest's own; and on an adopted node a holder assigned but not taken counts only by its
+// given ports (novox/hq ADR 0100).
+func TestSeatsHeldAndTakenReachTheRendering(t *testing.T) {
+ in := aMesh(t)
+ holder := aStoreModule()
+ holder.Claims = []catalogue.Claim{{Name: "mesh-store", Scope: catalogue.ScopeMesh}}
+ in.Catalogue["store"] = holder
+ anchor := nodeOf(t, &in, "anchor")
+ anchor.Assigned = append(anchor.Assigned, "store")
+ // The shelf is read whole, in this node's set or not: laptop, running none, is told where the anchor's store is only by the
+ // settings and ports of its own machine, which it has none of.
+ anchor.Ports = map[string]map[int]int{"store": {5432: 20999}}
+ got := placed(t, in, "anchor")
+ if at := got.Rendering.Seats["mesh-store"][5432]; at != 20999 {
+ t.Errorf("the seat's holder is told to be on %d", at)
+ }
+ if len(placed(t, in, "laptop").Rendering.Seats) != 0 {
+ t.Errorf("laptop is told of a holder it has no port for: %v", placed(t, in, "laptop").Rendering.Seats)
+ }
+ // Adopted, and the holder assigned but not taken: the found container's ports, not the mesh's assignment.
+ anchor.Adopted = true
+ if len(placed(t, in, "anchor").Rendering.Seats) != 0 {
+ t.Errorf("an untaken holder on an adopted node counts by the mesh's assignment: %v", placed(t, in, "anchor").Rendering.Seats)
+ }
+ anchor.Taken = []string{"store"}
+ got = placed(t, in, "anchor")
+ if at := got.Rendering.Seats["mesh-store"][5432]; at != 20999 || !got.Rendering.Adopted || !got.Rendering.Taken["store"] {
+ t.Errorf("a taken holder: seats %v adopted %v taken %v", got.Rendering.Seats, got.Rendering.Adopted, got.Rendering.Taken)
+ }
+}
+
+// 1363/AC4: local networks and outward links (mesh-controller local_network_test.go TestNodeShowsWhichAttachedNetworksCountAndWhy,
+// the values): a private network on a link facing outside counts, a public subnet and the private network's own link never do; a
+// machine that said none judges to nothing.
+func TestLocalNetworksAndOutwardLinksReachTheRendering(t *testing.T) {
+ in := aMesh(t)
+ laptop := nodeOf(t, &in, "laptop")
+ laptop.OutwardLinks = []string{"eth0", "eth1"}
+ laptop.Networks = []catalogue.AttachedNetwork{
+ {Link: "eth0", Network: "192.168.1.10/24"}, {Link: "eth1", Network: "203.0.113.7/24"}, {Link: overlay.Interface, Network: "10.10.0.4/24"}}
+ laptop.NetworksSaid = true
+ got := placed(t, in, "laptop").Rendering
+ if len(got.LocalNetworks) != 1 || got.LocalNetworks[0] != "192.168.1.0/24" {
+ t.Errorf("the local networks are %v", got.LocalNetworks)
+ }
+ if len(got.OutwardLinks) != 2 || got.OutwardLinks[0] != "eth0" {
+ t.Errorf("the outward links are %v", got.OutwardLinks)
+ }
+ laptop.NetworksSaid = false
+ if got := placed(t, in, "laptop").Rendering; len(got.LocalNetworks) != 0 {
+ t.Errorf("a machine that never reported has local networks %v", got.LocalNetworks)
+ }
+}
+
+// 1363/AC4: accounts, the names of the mesh, the mesh range and the operator's export reach the Rendering.
+func TestAccountsNamesAndTheOperatorsExportReachTheRendering(t *testing.T) {
+ in := aMesh(t)
+ nodeOf(t, &in, "anchor").Account = "jochens"
+ in.OperatorExport = &catalogue.KeptExport{}
+ in.OverlayCIDR = "10.99.0.0/16"
+ got := placed(t, in, "anchor").Rendering
+ if got.Accounts["anchor"] != "jochens" || len(got.Accounts) != 1 {
+ t.Errorf("accounts are %v", got.Accounts)
+ }
+ if got.Names["anchor.internal"] != "10.77.0.1" || got.Names["laptop.internal"] != "10.77.0.2" || len(got.Names) != 2 {
+ t.Errorf("names are %v", got.Names)
+ }
+ if len(got.Machines) != 2 || got.Machines["laptop.internal"] != "10.77.0.2" {
+ t.Errorf("machines are %v", got.Machines)
+ }
+ if len(got.Mesh) != 2 || got.Mesh[0] != "10.77.0.1" || got.Mesh[1] != "10.77.0.2" {
+ t.Errorf("the mesh's addresses are %v", got.Mesh)
+ }
+ if got.MeshRange != "10.99.0.0/16" || got.Suffix != "internal" || got.TunnelInterface != overlay.Interface {
+ t.Errorf("range %q suffix %q tunnel %q", got.MeshRange, got.Suffix, got.TunnelInterface)
+ }
+ if got.Kept != nil {
+ t.Error("the operator's export reaches a node where nothing keeps it")
+ }
+ keeper := catalogue.Manifest{Module: "keeper", Version: "1", Keeps: "/var/lib/keeper/export.json"}
+ in.Catalogue["keeper"] = keeper
+ nodeOf(t, &in, "anchor").Assigned = append(nodeOf(t, &in, "anchor").Assigned, "keeper")
+ if got := placed(t, in, "anchor").Rendering; got.Kept != in.OperatorExport {
+ t.Errorf("a module that keeps the export is not given it: %v", got.Kept)
+ }
+ // And the names of a machine that left the network leave with it.
+ nodeOf(t, &in, "laptop").Assigned = nil
+ if got := placed(t, in, "anchor").Rendering; len(got.Names) != 1 || got.Names["laptop.internal"] != "" {
+ t.Errorf("a machine off the network is named: %v", got.Names)
+ }
+}
+
+// The names the operator gave to peers of an adopted tunnel answer until they enrol (novox/hq issue 112); a node of the mesh owns
+// its name.
+func TestACarriedPeerIsNamedUntilItEnrols(t *testing.T) {
+ in := aMesh(t)
+ in.CarriedPeers = []CarriedPeer{{PublicKey: "K1", Address: "192.0.2.5", Named: "printer"},
+ {PublicKey: "K2", Address: "192.0.2.6", Named: "laptop"}, {PublicKey: "K3", Address: "192.0.2.7", Named: "late", EnrolledAs: "late"}}
+ got := placed(t, in, "anchor").Rendering
+ if got.Names["printer.internal"] != "192.0.2.5" || got.Names["laptop.internal"] != "10.77.0.2" || got.Names["late.internal"] != "" {
+ t.Errorf("names are %v", got.Names)
+ }
+}
+
+// A node-engine older than a contract is not sent the field it would refuse (novox/hq ADR 0240 and after).
+func TestTheNodeEnginesContractGatesWhatItIsSent(t *testing.T) {
+ in := aMesh(t)
+ laptop := nodeOf(t, &in, "laptop")
+ if r := placed(t, in, "laptop").Rendering; r.ReadsHealth || r.JudgesRoot || r.ReadsBusy || r.Hands {
+ t.Errorf("an engine that never stated anything reads %+v", r)
+ }
+ laptop.Stated, laptop.Contract = true, ReadinessContract
+ if r := placed(t, in, "laptop").Rendering; !r.ReadsHealth || r.JudgesRoot || r.ReadsBusy || r.Hands {
+ t.Errorf("an engine at the readiness contract reads %+v", r)
+ }
+ laptop.Contract = HandedContract
+ if r := placed(t, in, "laptop").Rendering; !r.ReadsHealth || !r.JudgesRoot || !r.ReadsBusy || !r.Hands {
+ t.Errorf("an engine at the handed contract reads %+v", r)
+ }
+}
+
+// The artifact store as a node reaches it: over the private network by its holder's name and the port it put it on, or on loopback
+// for the holder alone while nothing is on the network (novox/hq 04-ISSUES/102).
+func TestTheArtifactStoreIsReachedOverTheNetworkOrOnLoopbackForItsHolder(t *testing.T) {
+ in := aMesh(t)
+ in.Catalogue["artifacts"] = catalogue.Manifest{Module: "artifacts", Version: "1",
+ Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}}
+ anchor := nodeOf(t, &in, "anchor")
+ anchor.Assigned = append(anchor.Assigned, "artifacts")
+ if got := placed(t, in, "laptop").Rendering.ArtifactStore; got != "anchor.internal:5100" {
+ t.Errorf("laptop reaches the store at %q", got)
+ }
+ nodeOf(t, &in, "laptop").Assigned = nil
+ anchor.Assigned = []string{"artifacts"} // off the network: the holder alone reaches it
+ if got := placed(t, in, "anchor").Rendering.ArtifactStore; got != "127.0.0.1:5100" {
+ t.Errorf("the holder reaches the store at %q", got)
+ }
+ if got := placed(t, in, "laptop").Rendering.ArtifactStore; got != "" {
+ t.Errorf("a machine off the network reaches the store at %q", got)
+ }
+ in.Built = []string{"mesh-sdk"}
+ if got := placed(t, in, "anchor").Rendering.Built; !got["mesh-sdk"] {
+ t.Errorf("built is %v", got)
+ }
+}
+
+// A zone a module declares is placed where the mesh put it: settled from that node's settings, at the node's private address, on
+// the port the answering listen is published on there (novox/hq ADR 0199); the same zone declared twice is refused, by name.
+func TestAZoneIsPlacedWhereTheMeshPutItAndTheSameZoneTwiceIsRefused(t *testing.T) {
+ in := aMesh(t)
+ lab := catalogue.Manifest{Module: "lab", Version: "1", Zone: &catalogue.Zone{Name: "${setting:zone}", Listen: "dns"},
+ Listens: []catalogue.Listening{{Name: "dns", Port: 53, Protocol: "udp", From: catalogue.FromMesh}}}
+ in.Catalogue["lab"] = lab
+ anchor := nodeOf(t, &in, "anchor")
+ anchor.Assigned = append(anchor.Assigned, "lab")
+ anchor.Settings = map[string][]catalogue.Layer{"lab": {{From: "anchor", Values: map[string]any{"zone": "Lab.Example."}}}}
+ anchor.Ports = map[string]map[int]int{"lab": {53: 5353}}
+ zones := placed(t, in, "laptop").Rendering.Zones
+ if len(zones) != 1 || zones[0] != (catalogue.ZoneAt{Zone: "lab.example", Node: "anchor", Module: "lab", Address: "10.77.0.1", Port: 5353}) {
+ t.Fatalf("zones are %+v", zones)
+ }
+ laptop := nodeOf(t, &in, "laptop")
+ laptop.Assigned = append(laptop.Assigned, "lab")
+ laptop.Settings = map[string][]catalogue.Layer{"lab": {{From: "laptop", Values: map[string]any{"zone": "lab.example"}}}}
+ if _, err := Places(in, "laptop"); err == nil || !strings.Contains(err.Error(), "the mesh's zones cannot be forwarded") {
+ t.Fatalf("one zone declared twice: %v", err)
+ }
+}
+
+// Every holder of a replicated seat is listed by internal name at its private address (novox/hq ADR 0223): the holders on record,
+// and only the sole claimant when there are none; a holder off the private network is left out.
+func TestEveryHolderOfAReplicatedSeatIsListedOnTheNetwork(t *testing.T) {
+ in := aMesh(t)
+ resolver := catalogue.Manifest{Module: "resolver", Version: "1",
+ Provides: []catalogue.Offer{{Name: "wildcard-resolution", Scope: catalogue.ScopeMesh}},
+ Claims: []catalogue.Claim{{Name: "mesh-dns-resolver", Scope: catalogue.ScopeMesh}}}
+ in.Catalogue["resolver"] = resolver
+ anchor, laptop := nodeOf(t, &in, "anchor"), nodeOf(t, &in, "laptop")
+ anchor.Assigned = append(anchor.Assigned, "resolver")
+ // One claimant and nothing on record: the sole claimant is listed.
+ if got := placed(t, in, "laptop").Rendering.Holders["mesh-dns-resolver"]; len(got) != 1 || got["anchor.internal"] != "10.77.0.1" {
+ t.Errorf("the sole claimant: %v", got)
+ }
+ // Both on record: both are listed (two claimants with nothing on record would be refused at resolution).
+ laptop.Assigned = append(laptop.Assigned, "resolver")
+ in.Holdings = []catalogue.Held{
+ {Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "resolver"},
+ {Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "laptop", Module: "resolver"}}
+ got := placed(t, in, "laptop").Rendering.Holders["mesh-dns-resolver"]
+ if len(got) != 2 || got["anchor.internal"] != "10.77.0.1" || got["laptop.internal"] != "10.77.0.2" {
+ t.Errorf("both holders on record: %v", got)
+ }
+ // A holder off the private network is left out: a resolver named at an address nothing answers waits out its timeout.
+ in.Nodes = append(in.Nodes, Node{Name: "ace", Place: Place{Address: "10.77.0.3"}, Assigned: []string{"resolver"}})
+ in.Holdings = append(in.Holdings, catalogue.Held{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "ace", Module: "resolver"})
+ if got := placed(t, in, "laptop").Rendering.Holders["mesh-dns-resolver"]; len(got) != 2 || got["ace.internal"] != "" {
+ t.Errorf("a holder off the network is listed: %v", got)
+ }
+}
diff --git a/internal/compose/resolution/a_service_process_test.go b/internal/compose/resolution/a_service_process_test.go
new file mode 100644
index 0000000..de7b3ac
--- /dev/null
+++ b/internal/compose/resolution/a_service_process_test.go
@@ -0,0 +1,10 @@
+package resolution
+
+func indexOf(out []map[string]any, id string) int {
+ for i, r := range out {
+ if r["id"] == id {
+ return i
+ }
+ }
+ return -1
+}
diff --git a/internal/compose/resolution/accesses_test.go b/internal/compose/resolution/accesses_test.go
new file mode 100644
index 0000000..5bfc9f3
--- /dev/null
+++ b/internal/compose/resolution/accesses_test.go
@@ -0,0 +1,80 @@
+package resolution
+
+import (
+ "strings"
+ "testing"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+// The fault 04-ISSUES/036 records: a media stack is several modules — a library server, the
+// acquisition managers, a download client — that must share directories on one machine. Written
+// as owned `directory` resources, two of them on one node are refused as rivalrous owners, which
+// is right in general and wrong here: sharing those directories is the whole point of the stack.
+//
+// Declared as accesses to an operator-owned path (novox/hq ADR 0051), they co-resolve. An access
+// is not a resource and never enters the duplicate-owner map, so this is the exact case the
+// resolver refuses above when the same path is owned — and does not, when it is merely accessed.
+func TestTwoModulesAccessingOnePathCoResolve(t *testing.T) {
+ a := mod("a", nil, nil, nil)
+ a.Accesses = []mcat.Access{{Path: "/services/media/downloads", Mode: mcat.AccessReadWrite}}
+ b := mod("b", nil, nil, nil)
+ b.Accesses = []mcat.Access{{Path: "/services/media/downloads", Mode: mcat.AccessRead}}
+
+ got, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), mcat.World{})
+ if err != nil {
+ t.Fatalf("two modules accessing one operator-owned path were refused: %v", err)
+ }
+
+ // And each accessing module's grant reaches the machine as its own `access` resource, so the
+ // host can find the path present or refuse — both naming the one operator-owned path.
+ var accesses int
+ for _, r := range mustDeclare(t, got) {
+ if r["type"] == "access" && r["path"] == "/services/media/downloads" {
+ accesses++
+ }
+ }
+ if accesses != 2 {
+ t.Errorf("expected both modules' accesses in the declaration, got %d", accesses)
+ }
+}
+
+// The mirror of the case above, so the sharing vocabulary does not weaken the rule it sits beside:
+// two modules OWNING one path is still refused, exactly as before accesses existed.
+func TestTwoModulesOwningOnePathAreStillRefused(t *testing.T) {
+ a := mod("a", nil, nil, nil)
+ a.Resources = []map[string]any{
+ {"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}}
+ b := mod("b", nil, nil, nil)
+ b.Resources = []map[string]any{
+ {"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}}
+
+ _, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), mcat.World{})
+ if err == nil {
+ t.Fatal("two modules owning the same directory were both assigned")
+ }
+ if !strings.Contains(err.Error(), "/services/media/movies") {
+ t.Errorf("the refusal does not name the path: %v", err)
+ }
+}
+
+// Shared data is the operator's, owned by no module (novox/hq ADR 0051). A module owning the path
+// another module was told to expect the operator to provide would create and chown it — and the
+// two intentions cannot both hold, so it is refused by name. This is what keeps the distinction
+// machine-checkable rather than a convention nobody enforces.
+func TestAModuleMayNotOwnWhatAnotherAccesses(t *testing.T) {
+ owns := mod("owns", nil, nil, nil)
+ owns.Resources = []map[string]any{
+ {"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}}
+ uses := mod("uses", nil, nil, nil)
+ uses.Accesses = []mcat.Access{{Path: "/services/media/movies", Mode: mcat.AccessRead}}
+
+ _, err := Resolve(shelf(owns, uses), []string{"owns", "uses"}, workstation(), mcat.World{})
+ if err == nil {
+ t.Fatal("a module owning a path another accesses was allowed")
+ }
+ if !strings.Contains(err.Error(), "/services/media/movies") ||
+ !strings.Contains(err.Error(), "the operator's") {
+ t.Errorf("the refusal does not explain the ownership conflict: %v", err)
+ }
+}
diff --git a/internal/compose/resolution/adoption.go b/internal/compose/resolution/adoption.go
new file mode 100644
index 0000000..5017ab9
--- /dev/null
+++ b/internal/compose/resolution/adoption.go
@@ -0,0 +1,177 @@
+package resolution
+
+import (
+ "fmt"
+ "sort"
+ "strconv"
+ "strings"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+// GuardID, GuardUnitID and GuardRunningID are the guard's resource identities. The installer
+// raises the same three on an adopted genesis, so the first push finds them already there.
+func GuardID() string { return mcat.AdoptionPrefix + "guard" }
+
+func GuardUnitID() string { return mcat.AdoptionPrefix + "guard-unit" }
+
+func GuardRunningID() string { return mcat.AdoptionPrefix + "guard-running" }
+
+// GuardPackageID is the tool that loads the guard, declared first: a node joining adopted has
+// no filter module and may have no nft at all, and a table nothing can load guards nothing.
+func GuardPackageID() string { return mcat.AdoptionPrefix + "guard-package" }
+
+// OpeningID is an opening's resource identity: its protocol, port and path say what it is.
+func OpeningID(protocol string, port int, path string) string {
+ return fmt.Sprintf("%sopening-%s-%d-%s", mcat.AdoptionPrefix, protocol, port, path)
+}
+
+// Openings are what the mesh needs reachable on an adopted node, from the same inputs as the
+// filter it would load were the node converged, each from where that filter would admit it.
+//
+// `rules` is Filtering's answer — every module's listens, the hub's port, the per-node exposure —
+// and `foundation` is the ports the mesh itself needs, from everywhere. A rule for this machine
+// only opens nothing. `published` maps a machine port a container publishes to the container's
+// port: a published port is forwarded, not received, so its opening names the forwarded path and
+// the port the packet is forwarded to.
+func Openings(rules []mcat.Rule, foundation []int, published map[string]map[int]int) []map[string]any {
+ type key struct {
+ protocol string
+ port int
+ }
+ from := map[key]string{}
+ var order []key
+ widen := func(k key, f string) {
+ was, seen := from[k]
+ if !seen {
+ order = append(order, k)
+ }
+ if !seen || was != mcat.OpeningFromEverywhere {
+ from[k] = f
+ }
+ }
+ for _, rule := range rules {
+ switch rule.From {
+ case mcat.FromEverywhere:
+ widen(key{rule.Protocol, rule.Port}, mcat.OpeningFromEverywhere)
+ case mcat.FromMesh:
+ widen(key{rule.Protocol, rule.Port}, mcat.OpeningFromMesh)
+ case mcat.FromLocalNetwork:
+ // The private network's part only (novox/hq ADR 0340). An adopted node's found firewall
+ // stays, and it is what admits the machine's local network; an opening has no word for a
+ // local network, and widening it to everywhere would open the port to the internet.
+ widen(key{rule.Protocol, rule.Port}, mcat.OpeningFromMesh)
+ }
+ }
+ for _, port := range foundation {
+ widen(key{"tcp", port}, mcat.OpeningFromEverywhere)
+ }
+ sort.Slice(order, func(a, b int) bool {
+ if order[a].port != order[b].port {
+ return order[a].port < order[b].port
+ }
+ return order[a].protocol < order[b].protocol
+ })
+ out := make([]map[string]any, 0, len(order))
+ for _, k := range order {
+ opening := map[string]any{"type": "opening", "port": k.port, "protocol": k.protocol,
+ "from": from[k]}
+ if to, forwarded := published[k.protocol][k.port]; forwarded {
+ opening["id"] = OpeningID(k.protocol, k.port, mcat.PathForwarded)
+ opening["path"] = mcat.PathForwarded
+ opening["to"] = to
+ } else {
+ opening["id"] = OpeningID(k.protocol, k.port, mcat.PathIncoming)
+ opening["path"] = mcat.PathIncoming
+ }
+ out = append(out, opening)
+ }
+ return out
+}
+
+// AsGuard renders the mesh's refusal-only table for the given machine ports.
+//
+// It passes everything by default and holds nothing but a refusal, so it cannot close anything
+// the machine serves; and it is the mesh's own table, so the found firewall reloading does not
+// touch it. It refuses only packets addressed to this machine, and the ports except from the
+// machine itself — its loopback and the container runtime's own networks — and from the private
+// network, known by the interface a packet arrives on and never by its source address. At
+// prerouting, ahead of the runtime's destination translation, so it matches the port the packet
+// was sent to; in the inet family, so both address families.
+//
+// **The machine's own interfaces are named, where the derived filter names address ranges.** The
+// filter accepts the container runtime's networks by CIDR; this excludes its bridges by name — lo,
+// docker0, the br-* a compose network gets, and the mesh's own mesh0. A runtime whose bridge is
+// named anything else (a podman or libvirt bridge, or a docker network created with a fixed name)
+// would have its containers' traffic to a guarded port refused, which reads as the port being
+// down. Names rather than addresses is deliberate: a source address can be claimed by whoever
+// sends the packet, and this table exists to refuse what the found firewall never sees. Widening
+// it means adding names here and in the installer's copy together, which the golden test holds to
+// one text.
+//
+// The same text the installer raises on an adopted genesis; a test holds both to it.
+func AsGuard(ports []int) string {
+ sorted := append([]int{}, ports...)
+ sort.Ints(sorted)
+ listed := make([]string, len(sorted))
+ for i, p := range sorted {
+ listed[i] = strconv.Itoa(p)
+ }
+ var b strings.Builder
+ b.WriteString("table inet mesh_guard {}\n")
+ b.WriteString("delete table inet mesh_guard\n")
+ b.WriteString("table inet mesh_guard {\n")
+ b.WriteString("\tchain prerouting {\n")
+ b.WriteString("\t\ttype filter hook prerouting priority raw; policy accept;\n")
+ // Only packets addressed to this machine: traffic it routes for others — a predecessor's hub,
+ // say — is never the guard's business (novox/hq ADR 0103).
+ fmt.Fprintf(&b, "\t\tfib daddr type local iifname != \"lo\" iifname != \"docker0\" "+
+ "iifname != \"br-*\" iifname != \"mesh0\" tcp dport { %s } drop\n",
+ strings.Join(listed, ", "))
+ b.WriteString("\t}\n")
+ b.WriteString("}\n")
+ return b.String()
+}
+
+// GuardUnitText is the unit that loads the guard. Stopping it deletes only its own table: never
+// a flush, which would take the container runtime's rules and the found firewall with it.
+func GuardUnitText() string {
+ return "[Unit]\n" +
+ "Description=The mesh's guard: refuses its own ports from outside (novox/hq ADR 0100)\n" +
+ // Early, before the network is up, and without the default dependencies that would
+ // order it after the network; stopped at shutdown like any unit.
+ "DefaultDependencies=no\n" +
+ "Wants=network-pre.target\n" +
+ "Before=network-pre.target shutdown.target\n" +
+ "Conflicts=shutdown.target\n" +
+ "\n" +
+ "[Service]\n" +
+ "Type=oneshot\n" +
+ "RemainAfterExit=yes\n" +
+ "ExecStart=nft -f " + mcat.GuardPath + "\n" +
+ "ExecReload=nft -f " + mcat.GuardPath + "\n" +
+ "ExecStop=nft delete table inet mesh_guard\n" +
+ "\n" +
+ "[Install]\n" +
+ "WantedBy=multi-user.target\n"
+}
+
+// GuardResources are the guard as four resources of the existing kinds: the tool that loads it,
+// the table, the unit, and the unit running — reloaded when the table changes, so the new table
+// replaces the old in one `nft -f` through the unit's ExecReload with no moment unguarded, and
+// restarted only when the unit itself changes. Nothing when there is nothing to guard: an empty
+// set is not a table nft loads.
+func GuardResources(ports []int) []map[string]any {
+ if len(ports) == 0 {
+ return nil
+ }
+ return []map[string]any{
+ {"id": GuardPackageID(), "type": "package", "package": mcat.GuardPackage},
+ {"id": GuardID(), "type": "file", "path": mcat.GuardPath, "content": AsGuard(ports),
+ "mode": "0644"},
+ {"id": GuardUnitID(), "type": "file", "path": mcat.GuardUnitPath, "content": GuardUnitText(),
+ "mode": "0644"},
+ {"id": GuardRunningID(), "type": "service", "unit": mcat.GuardUnit, "state": "running",
+ "boot": "enabled", "restart-on": []any{GuardUnitID()}, "reload-on": []any{GuardID()}},
+ }
+}
diff --git a/internal/compose/resolution/adoption_test.go b/internal/compose/resolution/adoption_test.go
new file mode 100644
index 0000000..d704884
--- /dev/null
+++ b/internal/compose/resolution/adoption_test.go
@@ -0,0 +1,629 @@
+package resolution
+
+import (
+ "encoding/json"
+ "reflect"
+ "slices"
+ "strings"
+ "testing"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+// hub is the private network's generator on the hub: it opens the hub's port from anywhere.
+type hub struct{}
+
+func (hub) Resources(string) ([]map[string]any, bool, error) {
+ return []map[string]any{{"id": "config", "type": "file", "path": "/etc/wireguard/mesh0.conf",
+ "content": "[Interface]\n"}}, true, nil
+}
+
+func (hub) Listens(string) ([]mcat.Listening, error) {
+ return []mcat.Listening{{Port: 51820, Protocol: "udp", From: mcat.FromEverywhere}}, nil
+}
+
+// anAdoptedAnchor is the control-node's set: the store, the bus, the registry, the private network,
+// a served module and the filter module.
+func anAdoptedAnchor() mcat.Resolution {
+ return mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{
+ {Module: "network", Computed: "overlay"},
+ {Module: "postgres", Guards: []int{5432},
+ Listens: []mcat.Listening{{Port: 5432, From: mcat.FromMesh}},
+ Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-store",
+ "ports": []any{"5432:5432"}}}},
+ {Module: "lavinmq", Guards: []int{15672},
+ Listens: []mcat.Listening{{Port: 5671, From: mcat.FromMesh}, {Port: 5672, From: mcat.FromMesh}},
+ Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-broker",
+ "ports": []any{"5671:5671", "5672:5672", "127.0.0.1:15672:15672"}}}},
+ {Module: "distribution",
+ Listens: []mcat.Listening{{Port: 5000, From: mcat.FromMesh}},
+ Resources: []map[string]any{{"id": "store", "type": "container", "name": "registry",
+ "ports": []any{"5000"}}}},
+ {Module: "hello-web",
+ Listens: []mcat.Listening{{Port: 8080, From: mcat.FromEverywhere}},
+ Resources: []map[string]any{{"id": "server", "type": "container", "name": "hello-web",
+ "ports": []any{"8080"}}}},
+ {Module: "helper", Listens: []mcat.Listening{{Port: 9000, From: mcat.FromMachine}}},
+ {Module: "nftables", Filtering: &mcat.Filtering{Into: "/etc/nftables.conf"},
+ Resources: []map[string]any{{"id": "load", "type": "service", "unit": "mesh-filter.service",
+ "state": "running", "restart-on": []any{"filtering"}}}},
+ }}
+}
+
+func anchorRendering(adopted bool) mcat.Rendering {
+ return mcat.Rendering{
+ Generators: map[string]mcat.Generator{"overlay": hub{}},
+ Ports: map[string]map[int]int{"distribution": {5000: 5000}, "hello-web": {8080: 20001}},
+ Settings: mcat.SettingsBy{"distribution": {{From: "node anchor",
+ Placement: map[string]any{mcat.ExposeSetting: map[string]any{"5000": mcat.FromEverywhere}}}}},
+ Mesh: []string{"10.42.0.1"},
+ Foundation: []int{5671},
+ // What the machine reported faces outside, which every rule in the filter is written
+ // around (novox/hq ADR 0140).
+ OutwardLinks: []string{"eth0"},
+ TunnelInterface: "mesh0",
+ Adopted: adopted,
+ // Genesis takes the foundation's modules.
+ Taken: map[string]bool{"postgres": true, "lavinmq": true},
+ }
+}
+
+func byID(resources []map[string]any) map[string]map[string]any {
+ out := map[string]map[string]any{}
+ for _, r := range resources {
+ out[r["id"].(string)] = r
+ }
+ return out
+}
+
+func TestAnAdoptedNodeIsDeclaredOpeningsFromTheSameInputsAsTheFilter(t *testing.T) {
+ composed, err := Compose(anAdoptedAnchor(), anchorRendering(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := byID(composed.Resources)
+ want := map[string]map[string]any{
+ // The hub's port, from anywhere, received.
+ "adoption.opening-udp-51820-incoming": {"port": 51820, "protocol": "udp",
+ "from": "everywhere", "path": "incoming"},
+ // The store's port from the private network only, and forwarded: a container publishes it.
+ "adoption.opening-tcp-5432-forwarded": {"port": 5432, "protocol": "tcp", "from": "mesh",
+ "path": "forwarded", "to": 5432},
+ // The bus from anywhere: a node enrols over it before it has a private address.
+ "adoption.opening-tcp-5671-forwarded": {"port": 5671, "protocol": "tcp",
+ "from": "everywhere", "path": "forwarded", "to": 5671},
+ "adoption.opening-tcp-5672-forwarded": {"port": 5672, "protocol": "tcp", "from": "mesh",
+ "path": "forwarded", "to": 5672},
+ // The registry from anywhere, by its node's exposure setting.
+ "adoption.opening-tcp-5000-forwarded": {"port": 5000, "protocol": "tcp",
+ "from": "everywhere", "path": "forwarded", "to": 5000},
+ // A published port names the machine port and the container port it is forwarded to.
+ "adoption.opening-tcp-20001-forwarded": {"port": 20001, "protocol": "tcp",
+ "from": "everywhere", "path": "forwarded", "to": 8080},
+ }
+ for id, fields := range want {
+ opening, ok := got[id]
+ if !ok {
+ t.Errorf("no %s among %v", id, keys(got))
+ continue
+ }
+ if opening["type"] != "opening" {
+ t.Errorf("%s is a %v", id, opening["type"])
+ }
+ for k, v := range fields {
+ if opening[k] != v {
+ t.Errorf("%s: %s is %v, want %v", id, k, opening[k], v)
+ }
+ }
+ }
+ for id := range got {
+ if strings.HasPrefix(id, "adoption.opening-") && want[id] == nil {
+ t.Errorf("an opening nothing asked for: %s", id)
+ }
+ }
+ // A port for this machine only opens nothing, and the management port is not opened at all.
+ for id := range got {
+ if strings.Contains(id, "-9000-") || strings.Contains(id, "-15672-") {
+ t.Errorf("%s is opened", id)
+ }
+ }
+
+ // And openings come first, in the order the machine applies them.
+ if !strings.HasPrefix(composed.Resources[0]["id"].(string), "adoption.opening-") {
+ t.Errorf("openings are not first: %v", composed.Resources[0]["id"])
+ }
+}
+
+func TestAnAdoptedNodeLoadsNoFilterOfTheMeshs(t *testing.T) {
+ composed, err := Compose(anAdoptedAnchor(), anchorRendering(true))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, r := range composed.Resources {
+ if r["path"] == "/etc/nftables.conf" || strings.HasPrefix(r["id"].(string), "nftables.") {
+ t.Fatalf("an adopted node is declared the filter module's %v", r["id"])
+ }
+ if content, _ := r["content"].(string); strings.Contains(content, "policy drop") {
+ t.Fatalf("an adopted node is declared a table that drops by default: %v", r["id"])
+ }
+ // Nothing but refusals: the only accept in the guard is its policy.
+ if content, _ := r["content"].(string); r["id"] == GuardID() &&
+ strings.Count(content, "accept") != 1 {
+ t.Fatalf("the guard holds an accept:\n%s", content)
+ }
+ }
+ got := byID(composed.Resources)
+ guard := got[GuardID()]
+ if guard == nil || got[GuardUnitID()] == nil || got[GuardRunningID()] == nil {
+ t.Fatalf("no guard: %v", keys(got))
+ }
+ if guard["content"] != AsGuard([]int{5432, 5672, 15672}) {
+ t.Fatalf("the guard does not guard the store, the broker and its management port:\n%s",
+ guard["content"])
+ }
+ // The tool that loads it comes first, and the table after it: a node joining adopted has no
+ // filter module and may have no nft.
+ pkg, table := -1, -1
+ for i, r := range composed.Resources {
+ switch r["id"] {
+ case GuardPackageID():
+ pkg = i
+ if r["type"] != "package" || r["package"] != "nftables" {
+ t.Fatalf("the guard's package is %v", r)
+ }
+ case GuardID():
+ table = i
+ }
+ }
+ if pkg < 0 || pkg > table {
+ t.Fatalf("nftables is not declared before the guard's table (%d, %d)", pkg, table)
+ }
+ // A changed table is reloaded — one `nft -f`, atomic — never restarted, which would delete the
+ // table and leave the ports unguarded until it is loaded again. Only a changed unit restarts.
+ if !reflect.DeepEqual(got[GuardRunningID()]["reload-on"], []any{GuardID()}) ||
+ !reflect.DeepEqual(got[GuardRunningID()]["restart-on"], []any{GuardUnitID()}) {
+ t.Fatalf("the guard is not reloaded on its table and restarted on its unit: %v",
+ got[GuardRunningID()])
+ }
+ // Nothing of the mesh's own is anybody's to hold.
+ for id, module := range composed.Owner {
+ if strings.HasPrefix(id, mcat.AdoptionPrefix) {
+ t.Fatalf("%s is owned by %s", id, module)
+ }
+ }
+}
+
+func TestAConvergedNodeIsDeclaredItsFilterAndNoOpenings(t *testing.T) {
+ composed, err := Compose(anAdoptedAnchor(), anchorRendering(false))
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := byID(composed.Resources)
+ if got["nftables.filtering"] == nil || got["nftables.load"] == nil {
+ t.Fatalf("a converged node lost its filter: %v", keys(got))
+ }
+ for id := range got {
+ if strings.HasPrefix(id, mcat.AdoptionPrefix) {
+ t.Fatalf("a converged node is declared %s", id)
+ }
+ }
+ plain, err := Declaration(anAdoptedAnchor(), anchorRendering(false))
+ if err != nil {
+ t.Fatal(err)
+ }
+ a, _ := json.Marshal(plain)
+ b, _ := json.Marshal(composed.Resources)
+ if string(a) != string(b) {
+ t.Fatal("Compose and Declaration disagree on a converged node")
+ }
+}
+
+// The table the installer raises and the controller declares, character for character.
+func TestTheGuardIsExactlyThisTable(t *testing.T) {
+ const golden = `table inet mesh_guard {}
+delete table inet mesh_guard
+table inet mesh_guard {
+ chain prerouting {
+ type filter hook prerouting priority raw; policy accept;
+ fib daddr type local iifname != "lo" iifname != "docker0" iifname != "br-*" iifname != "mesh0" tcp dport { 5432, 15672 } drop
+ }
+}
+`
+ if got := AsGuard([]int{15672, 5432}); got != golden {
+ t.Fatalf("the guard changed:\n%s", got)
+ }
+ const unit = `[Unit]
+Description=The mesh's guard: refuses its own ports from outside (novox/hq ADR 0100)
+DefaultDependencies=no
+Wants=network-pre.target
+Before=network-pre.target shutdown.target
+Conflicts=shutdown.target
+
+[Service]
+Type=oneshot
+RemainAfterExit=yes
+ExecStart=nft -f /etc/mesh/guard.nft
+ExecReload=nft -f /etc/mesh/guard.nft
+ExecStop=nft delete table inet mesh_guard
+
+[Install]
+WantedBy=multi-user.target
+`
+ if got := GuardUnitText(); got != unit {
+ t.Fatalf("the guard's unit changed:\n%s", got)
+ }
+ if GuardResources(nil) != nil {
+ t.Fatal("a guard with nothing to guard is an empty set nft refuses to load")
+ }
+}
+
+func keys[V any](m map[string]V) []string {
+ return sortedKeys(m)
+}
+
+// novox/hq ADR 0100: the foundation's ports are the node's. Given 5433 for the store, every place
+// that uses the port reads it from there: the container, the filter, the openings, the guard.
+func TestAGivenPortIsUsedEverywhereThePortIs(t *testing.T) {
+ given := map[string]map[int]int{"postgres": {5432: 5433}, "lavinmq": {15672: 15673}}
+ for _, adopted := range []bool{true, false} {
+ with := anchorRendering(adopted)
+ with.Given = given
+ with.Ports["postgres"] = map[int]int{5432: 5433}
+ composed, err := Compose(anAdoptedAnchor(), with)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := byID(composed.Resources)
+ if ports := got["postgres.server"]["ports"]; !reflect.DeepEqual(ports, []any{"5433:5432"}) {
+ t.Fatalf("the store's container publishes %v", ports)
+ }
+ if ports := got["lavinmq.server"]["ports"]; !reflect.DeepEqual(ports,
+ []any{"5671:5671", "5672:5672", "127.0.0.1:15673:15672"}) {
+ t.Fatalf("the broker's container publishes %v", ports)
+ }
+ if !adopted {
+ filter, _ := got["nftables.filtering"]["content"].(string)
+ if !strings.Contains(filter, "tcp dport 5433 accept") || strings.Contains(filter, "5432") {
+ t.Fatalf("the filter does not use the given port:\n%s", filter)
+ }
+ continue
+ }
+ if o := got["adoption.opening-tcp-5433-forwarded"]; o == nil || o["to"] != 5432 {
+ t.Fatalf("no opening for the given port: %v", keys(got))
+ }
+ if guard := got[GuardID()]["content"]; guard != AsGuard([]int{5433, 5672, 15673}) {
+ t.Fatalf("the guard does not guard the given ports:\n%s", guard)
+ }
+ }
+}
+
+// novox/hq ADR 0103: the guard is derived, and from taken modules only — every machine port a taken
+// module publishes that the filter admits from the private network only, and the ports its
+// manifest guards. A module assigned but not taken is not guarded: its port may still be the
+// predecessor's.
+func TestTheGuardIsDerivedFromTakenModulesOnly(t *testing.T) {
+ guardOf := func(with mcat.Rendering) string {
+ t.Helper()
+ composed, err := Compose(anAdoptedAnchor(), with)
+ if err != nil {
+ t.Fatal(err)
+ }
+ content, _ := byID(composed.Resources)[GuardID()]["content"].(string)
+ return content
+ }
+
+ // The broker taken, the store not: the broker's plain port follows from its listens (from
+ // the mesh, published), its management port from its manifest; the store is not guarded, and
+ // neither is the bus, which the mesh needs from everywhere.
+ with := anchorRendering(true)
+ with.Taken = map[string]bool{"lavinmq": true}
+ if got := guardOf(with); got != AsGuard([]int{5672, 15672}) {
+ t.Fatalf("the guard is not the taken broker's ports:\n%s", got)
+ }
+
+ // A taken module publishing a port admitted from everywhere is not guarded; one admitted from
+ // the mesh is. The registry is exposed everywhere on this node, and hello-web listens from
+ // everywhere.
+ with.Taken = map[string]bool{"distribution": true, "hello-web": true}
+ if got := guardOf(with); got != "" {
+ t.Fatalf("a port admitted from everywhere is guarded:\n%s", got)
+ }
+ with.Settings = nil
+ if got := guardOf(with); got != AsGuard([]int{5000}) {
+ t.Fatalf("the registry, from the mesh only, is not guarded:\n%s", got)
+ }
+
+ // Nothing taken, nothing guarded — and no guard at all rather than an empty set.
+ with = anchorRendering(true)
+ with.Taken = nil
+ if got := guardOf(with); got != "" {
+ t.Fatalf("an untaken store is guarded:\n%s", got)
+ }
+
+ // A given port is followed: where the machine put it is what is refused.
+ with = anchorRendering(true)
+ with.Given = map[string]map[int]int{"lavinmq": {5672: 5682, 15672: 15673}}
+ with.Ports["lavinmq"] = map[int]int{5671: 5671, 5672: 5682}
+ if got := guardOf(with); got != AsGuard([]int{5432, 5682, 15673}) {
+ t.Fatalf("the guard does not follow the given ports:\n%s", got)
+ }
+}
+
+// novox/hq ADR 0038: only a published port is the mesh's to move. A module that binds the machine
+// itself listens where its software was told to, so giving it a machine port is refused.
+func TestAGivenPortIsRefusedForAPortNoContainerPublishes(t *testing.T) {
+ onTheMachine := mcat.Manifest{Module: "daemon",
+ Listens: []mcat.Listening{{Port: 9000, From: mcat.FromMesh}}, Guards: []int{9000}}
+ layers := []mcat.Layer{{From: "node anchor",
+ Placement: map[string]any{mcat.PortsSetting: map[string]any{"9000": float64(9100)}}}}
+ _, err := GivenPorts(onTheMachine, layers)
+ if err == nil || !strings.Contains(err.Error(), "does not publish") {
+ t.Fatalf("a port no container publishes was given: %v", err)
+ }
+}
+
+// novox/hq ADR 0103: a guarded port this node is told to open to everyone is opened, not guarded.
+// An opening from everywhere beside a guard dropping it is one statement refusing the other.
+func TestAGuardedPortOpenedToEveryoneIsNotGuarded(t *testing.T) {
+ with := anchorRendering(true)
+ with.Settings["postgres"] = []mcat.Layer{{From: "node anchor",
+ Placement: map[string]any{mcat.ExposeSetting: map[string]any{"5432": mcat.FromEverywhere}}}}
+ composed, err := Compose(anAdoptedAnchor(), with)
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := byID(composed.Resources)
+ if o := got["adoption.opening-tcp-5432-forwarded"]; o == nil || o["from"] != mcat.OpeningFromEverywhere {
+ t.Fatalf("the store's port is not opened to everyone: %v", o)
+ }
+ if guard, _ := got[GuardID()]["content"].(string); guard != AsGuard([]int{5672, 15672}) {
+ t.Fatalf("a port opened to everyone is still guarded:\n%s", guard)
+ }
+}
+
+// A module that publishes its ssh port the long way — `2222:22`, because the machine's own daemon
+// holds 22 — and says it listens on the machine side of that mapping, which is what anything
+// reaching it dials.
+func aForge() mcat.Manifest {
+ return mcat.Manifest{Module: "forge",
+ Provides: []mcat.Offer{{Name: "git-over-ssh", Scope: mcat.ScopeMesh}},
+ Serves: map[string]map[string]any{"git-over-ssh": {"port": 2222}},
+ Listens: []mcat.Listening{{Port: 3000, From: mcat.FromMesh}, {Port: 2222, From: mcat.FromMesh}},
+ Resources: []map[string]any{{"id": "server", "type": "container", "name": "forge",
+ "ports": []any{"3000", "2222:22"}}}}
+}
+
+// portsAsThePlanWould is where this machine puts each of a module's ports, derived the way
+// cmd/mesh-controller/plan.go derives it: a given port first, looked up by the port the module
+// says it listens on, and otherwise wherever the manifest's own mapping already put it. Written
+// here because everything below — the filter, the openings, the guard, what a consumer is told —
+// reads that map, and a given port that the lookup does not find moves the container's mapping
+// and nothing else.
+//
+// It stands in for the plan only where the plan does not allocate: a port the manifest already
+// placed, or one a node was given. For a short form with no given port the real plan asks the
+// inventory for a machine port and may come back with one from the pool, which needs a store and
+// is what cmd/mesh-controller's own tests exercise. So an assertion here about such a port asserts
+// this helper, not the mesh; keep the assertions to the ports under test.
+func portsAsThePlanWould(m mcat.Manifest, given map[int]int) map[int]int {
+ out := map[int]int{}
+ for _, l := range m.Listens {
+ if at, is := given[l.Port]; is {
+ out[l.Port] = at
+ continue
+ }
+ at, _ := m.MachineSide(l.Port)
+ out[l.Port] = at
+ }
+ return out
+}
+
+// novox/hq ADR 0100 and 0038: the machine side of a long-form mapping is a port the module
+// publishes, so a node may move it — and a module may name a mapping by either end.
+func TestAGivenPortNamesEitherEndOfWhatTheModulePublishes(t *testing.T) {
+ forge := aForge()
+ node := func(v any) []mcat.Layer {
+ return []mcat.Layer{{From: "node anchor", Placement: map[string]any{mcat.PortsSetting: v}}}
+ }
+
+ // The machine side — the number this module says it listens on, and the one the predecessor
+ // had somewhere else. Answered under 2222, the end the module itself names, which is what
+ // every reader of this map asks for. One entry, not two: a second key for the same answer is
+ // an entry another mapping's reader could find instead.
+ given, err := GivenPorts(forge, node(map[string]any{"2222": float64(222)}))
+ if err != nil {
+ t.Fatalf("the machine side of a mapping cannot be given a port: %v", err)
+ }
+ if want := map[int]int{2222: 222}; !reflect.DeepEqual(given, want) {
+ t.Fatalf("the forge was given %v; the mapping it names is filed under %v", given, want)
+ }
+
+ // The container's own port names the same mapping and means the same thing — and is filed
+ // under the same name, because the module's name for it has not changed.
+ if inside, err := GivenPorts(forge, node(map[string]any{"22": float64(222)})); err != nil ||
+ !reflect.DeepEqual(inside, map[int]int{2222: 222}) {
+ t.Fatalf("the container's end of the mapping was given %v: %v", inside, err)
+ }
+
+ // A short form is published on the number it names, and is unchanged by any of this.
+ if short, err := GivenPorts(forge, node(map[string]any{"3000": float64(2999)})); err != nil ||
+ short[3000] != 2999 {
+ t.Fatalf("the short form was given %v: %v", short, err)
+ }
+
+ // A port no container publishes is still refused, in the same words.
+ if _, err := GivenPorts(forge, node(map[string]any{"9000": float64(9100)})); err == nil ||
+ !strings.Contains(err.Error(), "does not publish") {
+ t.Fatalf("a port the forge does not publish was given: %v", err)
+ }
+
+ // And the two ends of one mapping given two different numbers is one setting contradicting
+ // the other: the machine publishes it once.
+ if _, err := GivenPorts(forge, node(map[string]any{
+ "2222": float64(222), "22": float64(300)})); err == nil ||
+ !strings.Contains(err.Error(), "one mapping") {
+ t.Fatalf("the two ends of one mapping were given different ports: %v", err)
+ }
+ // Said at both ends with the same number, it is still said twice, and refused where every
+ // other repeated machine port is — as the inventory refuses it when the setting is stored,
+ // which is the layer that sees it first.
+ if _, err := GivenPorts(forge, node(map[string]any{
+ "2222": float64(222), "22": float64(222)})); err == nil ||
+ !strings.Contains(err.Error(), "to both its 22 and its 2222") {
+ t.Fatalf("one mapping given one machine port at both ends: %v", err)
+ }
+ // A number that names two different mappings names neither: which one to move is not said.
+ twice := aForge()
+ twice.Resources[0]["ports"] = []any{"22", "2222:22"}
+ if _, err := GivenPorts(twice, node(map[string]any{"22": float64(222)})); err == nil ||
+ !strings.Contains(err.Error(), "twice") {
+ t.Fatalf("a number naming two of the module's mappings was accepted: %v", err)
+ }
+
+ // And the same refusal when the number naming two mappings is not the one the setting used
+ // but the one the answer would be filed under. Here `80` is the module's own name for a
+ // mapping, and two mappings wear it; filing an answer there is one container's port standing
+ // where the other's is read, and both containers then publish it.
+ shared := mcat.Manifest{Module: "gallery",
+ Listens: []mcat.Listening{{Port: 80, From: mcat.FromMesh}},
+ Resources: []map[string]any{
+ {"id": "a", "type": "container", "name": "a", "ports": []any{"4001:80"}},
+ {"id": "b", "type": "container", "name": "b", "ports": []any{"4002:80"}}}}
+ if _, err := GivenPorts(shared, node(map[string]any{"4001": float64(1234)})); err == nil ||
+ !strings.Contains(err.Error(), "twice") {
+ t.Fatalf("two containers were put on one machine port: %v", err)
+ }
+
+ // A module whose mappings chain — one's machine side is another's container port — keeps them
+ // apart, because each is filed under the port the module names it by and neither name is
+ // shared. Given both, each moves on its own and neither overwrites the other.
+ chained := mcat.Manifest{Module: "chain",
+ Listens: []mcat.Listening{{Port: 80, From: mcat.FromMesh}, {Port: 9090, From: mcat.FromMesh}},
+ Resources: []map[string]any{{"id": "server", "type": "container", "name": "chain",
+ "ports": []any{"8080:80", "9090:8080"}}}}
+ both, err := GivenPorts(chained, node(map[string]any{"80": float64(1234), "9090": float64(5678)}))
+ if err != nil || !reflect.DeepEqual(both, map[int]int{80: 1234, 9090: 5678}) {
+ t.Fatalf("chained mappings were given %v: %v", both, err)
+ }
+ if moved := givenOuter("8080:80", both); moved != "1234:80" {
+ t.Fatalf("the first mapping moved to %q, and it was given 1234", moved)
+ }
+ if moved := givenOuter("9090:8080", both); moved != "5678:8080" {
+ t.Fatalf("the second mapping moved to %q, and it was given 5678", moved)
+ }
+}
+
+// And the number reaches everything derived from it. The fault this is written against moved the
+// container's mapping alone: the filter opened the port the software had left, the adopted node's
+// opening named it too, the guard refused it, and a consumer was sent to it.
+func TestAGivenMachineSideReachesTheFilterTheOpeningAndTheConsumer(t *testing.T) {
+ forge := aForge()
+ given, err := GivenPorts(forge, []mcat.Layer{{From: "node anchor",
+ Placement: map[string]any{mcat.PortsSetting: map[string]any{"2222": float64(222)}}}})
+ if err != nil {
+ t.Fatalf("the machine side of a mapping cannot be given a port: %v", err)
+ }
+ r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{forge}}
+ with := mcat.Rendering{
+ Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, given)},
+ Given: map[string]map[int]int{"forge": given},
+ Mesh: []string{"10.77.0.1"},
+ Adopted: true,
+ OutwardLinks: []string{"eth0"},
+ TunnelInterface: "mesh0",
+ Taken: map[string]bool{"forge": true},
+ }
+
+ // What the runtime is handed: the machine's own port on the outside, the container's within.
+ composed, err := Compose(r, with)
+ if err != nil {
+ t.Fatalf("the forge does not compose: %v", err)
+ }
+ got := byID(composed.Resources)
+ if ports := got["forge.server"]["ports"]; !reflect.DeepEqual(ports, []any{"3000:3000", "222:22"}) {
+ t.Fatalf("the forge's container publishes %v", ports)
+ }
+
+ // What the filter would open, were the node converged.
+ rules, err := Rules(r, with)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var opened []int
+ for _, rule := range rules {
+ opened = append(opened, rule.Port)
+ }
+ // Only the moved port is asserted: the forge's other port is a short form the real plan would
+ // allocate rather than read off the manifest, so its number here is portsAsThePlanWould's and
+ // not the mesh's.
+ if !slices.Contains(opened, 222) || slices.Contains(opened, 2222) {
+ t.Fatalf("the filter opens %v, not where the machine puts the forge", opened)
+ }
+
+ // And what it is declared instead, adopted: an opening on the machine's port, naming the
+ // container's port on the forwarded path — a published port is forwarded, never received.
+ opening := got[OpeningID("tcp", 222, mcat.PathForwarded)]
+ if opening == nil || opening["to"] != 22 || opening["from"] != mcat.OpeningFromMesh {
+ t.Fatalf("no opening for the port this node gave the forge: %v", keys(got))
+ }
+ for id := range got {
+ if strings.HasPrefix(id, "adoption.opening-tcp-2222-") {
+ t.Errorf("an opening for the port the forge was moved off: %s", id)
+ }
+ }
+
+ // The guard refuses it where the machine put it, and nothing where it used to be.
+ guard, _ := got[GuardID()]["content"].(string)
+ if !strings.Contains(guard, "222") || strings.Contains(guard, "2222") {
+ t.Fatalf("the guard does not follow the given port:\n%s", guard)
+ }
+
+ // And a consumer is sent to the same number, which is read from what the module serves.
+ if told := ServedOn(forge, "git-over-ssh", with.Ports["forge"])["port"]; told != 222 {
+ t.Fatalf("a consumer is told the forge answers on %v", told)
+ }
+}
+
+// And the mapping itself moves under either name, because Rendering.Given is a map anybody
+// composing a declaration hands in: keyed by the machine side, which is what a module declaring
+// 2222 calls its port, only the outside moves and the container's own port stays as written.
+func TestAMappingIsMovedUnderEitherOfItsNames(t *testing.T) {
+ for _, c := range []struct {
+ written string
+ given map[int]int
+ want string
+ }{
+ {"2222:22", map[int]int{2222: 222}, "222:22"},
+ {"2222:22", map[int]int{22: 222}, "222:22"},
+ {"127.0.0.1:15672:15672/tcp", map[int]int{15672: 15673}, "127.0.0.1:15673:15672/tcp"},
+ {"2222:22", map[int]int{3000: 2999}, "2222:22"},
+ {"2222:22", nil, "2222:22"},
+ } {
+ if got := givenOuter(c.written, c.given); got != c.want {
+ t.Errorf("%s given %v is published as %s, want %s", c.written, c.given, got, c.want)
+ }
+ }
+}
+
+// The same on a node that was given nothing, which is where the derivation was never at fault:
+// a long-form mapping is published where the manifest put it, and an adopted node opens that port
+// on the forwarded path like any other. What broke it was the number reaching only the mapping.
+func TestALongFormPortIsOpenedWhereTheManifestPublishesIt(t *testing.T) {
+ forge := aForge()
+ r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{forge}}
+ composed, err := Compose(r, mcat.Rendering{
+ Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, nil)},
+ Mesh: []string{"10.77.0.1"},
+ Adopted: true,
+ OutwardLinks: []string{"eth0"},
+ TunnelInterface: "mesh0",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := byID(composed.Resources)
+ opening := got[OpeningID("tcp", 2222, mcat.PathForwarded)]
+ if opening == nil || opening["to"] != 22 {
+ t.Fatalf("no opening for the forge's published ssh port: %v", keys(got))
+ }
+}
diff --git a/internal/compose/resolution/agent_account_test.go b/internal/compose/resolution/agent_account_test.go
new file mode 100644
index 0000000..a54e628
--- /dev/null
+++ b/internal/compose/resolution/agent_account_test.go
@@ -0,0 +1,83 @@
+package resolution
+
+import (
+ "testing"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+func TestTheAgentAccountFactFallsBackToTheOperatorAndIsNeverRootOnlyWhenItsOwn(t *testing.T) {
+ facts := machineFacts(mcat.Resolution{Node: "anchor", Account: "ops"}, nil, "")
+ if facts["agent-account"] != "ops" || facts["agent-home"] != "/home/ops" || facts["agent-root"] != "" {
+ t.Errorf("with no agent account named, agents run as the operator: %v", facts)
+ }
+ facts = machineFacts(mcat.Resolution{Node: "anchor", Account: "ops", AccountHome: "/srv/ops"}, nil, "")
+ if facts["agent-home"] != "/srv/ops" {
+ t.Errorf("the operator's stated home is the agent's home when they are one account: %v", facts)
+ }
+ facts = machineFacts(mcat.Resolution{Node: "anchor", Account: "ops", AgentAccount: "agent"}, nil, "")
+ if facts["agent-account"] != "agent" || facts["agent-home"] != "/home/agent" || facts["agent-root"] != mcat.RootNever {
+ t.Errorf("a named agent account is the agents', never root: %v", facts)
+ }
+ if facts["account"] != "ops" {
+ t.Errorf("the operator account is still the operator's: %v", facts)
+ }
+ facts = machineFacts(mcat.Resolution{Node: "anchor", AgentAccount: "agent", AgentAccountHome: "/var/lib/agent"}, nil, "")
+ if facts["agent-home"] != "/var/lib/agent" || facts["agent-root"] != mcat.RootNever {
+ t.Errorf("an agent account with a stated home on a machine with no operator: %v", facts)
+ }
+ if _, has := machineFacts(mcat.Resolution{Node: "anchor"}, nil, "")["agent-account"]; has {
+ t.Error("a machine with no account at all names an agent account")
+ }
+}
+
+// The agent's module, in the shape the catalogue's declares it: the account, never root where it is its
+// own; its directory under that home, owned by it.
+const agentModule = `{"module": "agent", "version": "1", "resources": [
+ {"id": "account", "type": "user", "name": "${machine:agent-account}", "home": "${machine:agent-home}",
+ "root": "${machine:agent-root}"},
+ {"id": "home", "type": "directory", "path": "${machine:agent-home}/.agent", "mode": "0700",
+ "owner": "${machine:agent-account}"}
+]}`
+
+func TestTheAgentAccountIsDeclaredNeverRootOnlyToAnEngineThatJudgesIt(t *testing.T) {
+ m, err := decodeForTest([]byte(agentModule))
+ if err != nil {
+ t.Fatal(err)
+ }
+ compose := func(r mcat.Resolution, with mcat.Rendering) (user, home map[string]any) {
+ t.Helper()
+ r.Node, r.Modules = "anchor", []mcat.Manifest{m}
+ out, err := Declaration(r, with)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return fileNamed(out, "agent.account"), fileNamed(out, "agent.home")
+ }
+
+ user, home := compose(mcat.Resolution{Account: "ops", AgentAccount: "agent"}, mcat.Rendering{JudgesRoot: true})
+ if user["name"] != "agent" || user[mcat.RootField] != mcat.RootNever || user["home"] != "/home/agent" {
+ t.Errorf("an engine that judges root is sent the agent account never to become root: %v", user)
+ }
+ if home["path"] != "/home/agent/.agent" || home["owner"] != "agent" {
+ t.Errorf("the agent's directory is under its own home, its own: %v", home)
+ }
+
+ user, _ = compose(mcat.Resolution{Account: "ops", AgentAccount: "agent", AgentAccountHome: "/srv/agent"}, mcat.Rendering{JudgesRoot: true})
+ if user["home"] != "/srv/agent" {
+ t.Errorf("an agent account named with a home of its own is made there: %v", user)
+ }
+
+ user, _ = compose(mcat.Resolution{Account: "ops", AgentAccount: "agent"}, mcat.Rendering{})
+ if _, sent := user[mcat.RootField]; sent || user["name"] != "agent" {
+ t.Errorf("an older engine, which parses strictly, is sent root: %v", user)
+ }
+
+ user, home = compose(mcat.Resolution{Account: "ops"}, mcat.Rendering{JudgesRoot: true})
+ if _, sent := user[mcat.RootField]; sent || user["name"] != "ops" {
+ t.Errorf("where agents run as the operator, root asserts nothing and is not sent: %v", user)
+ }
+ if home["path"] != "/home/ops/.agent" || home["owner"] != "ops" {
+ t.Errorf("with no agent account, the agent's directory is the operator's: %v", home)
+ }
+}
diff --git a/internal/compose/resolution/artifact_store_seat_test.go b/internal/compose/resolution/artifact_store_seat_test.go
new file mode 100644
index 0000000..ebe2ce2
--- /dev/null
+++ b/internal/compose/resolution/artifact_store_seat_test.go
@@ -0,0 +1,40 @@
+package resolution
+
+import (
+ "strings"
+ "testing"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+// The artifact store's seat is one per mesh, read from the catalogue beside this checkout.
+//
+// **A second store anywhere is refused by name, not discovered as a consumer failure.** The seat
+// was node-scoped, so a second `distribution` on another machine resolved cleanly there — and a
+// node-scoped requirement with one candidate installs that candidate on the node, so anything that
+// required the store's presence beside it would have raised a fresh, empty store on the wrong
+// machine. Only afterwards did the mesh notice: `artifact-store` offered by two nodes, and every
+// consumer elsewhere refusing to choose. The claim says it first, where the second store is
+// assigned.
+func TestASecondArtifactStoreAnywhereIsRefusedByName(t *testing.T) {
+ store := catalogueManifest(t, "distribution")
+
+ // The first store resolves as it always has.
+ if _, err := Resolve(shelf(store), []string{"distribution"}, workstation(), mcat.World{}); err != nil {
+ t.Fatalf("the store alone does not resolve: %v", err)
+ }
+
+ // A second one, on any other machine, is refused — and the refusal names the seat.
+ elsewhere := mcat.World{Held: []mcat.Held{{Claim: "mesh-artifact-store", Scope: mcat.ScopeMesh,
+ Node: "anchor", Module: "distribution"}}}
+ other := workstation()
+ other.Name = "laptop"
+ _, err := Resolve(shelf(store), []string{"distribution"}, other, elsewhere)
+ if err == nil {
+ 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(), "mesh-artifact-store") || !strings.Contains(err.Error(), "one per mesh") {
+ t.Fatalf("refused without naming the seat: %v", err)
+ }
+}
diff --git a/internal/compose/resolution/artifact_sweep_seat.go b/internal/compose/resolution/artifact_sweep_seat.go
new file mode 100644
index 0000000..98888e7
--- /dev/null
+++ b/internal/compose/resolution/artifact_sweep_seat.go
@@ -0,0 +1,43 @@
+package resolution
+
+import (
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+// artifactSweepVerbs are the seat's verbs, with the controller's arguments so a caller changes only the seat it asks.
+func artifactSweepVerbs() []mcat.Verb {
+ return []mcat.Verb{
+ {Name: "artifacts", Description: "Every artifact the mesh made that lives in its artifact store, each with its " +
+ "repository, digest, kind (image or archive) and state: kept, and why (a want names it, or one of the five " +
+ "newest builds of its module in the builder's log), eligible (made, kept for no reason, not yet let go), or " +
+ "unrecorded (in the store and named by nothing the mesh made: never removed). Reads only.",
+ Input: schema(map[string]string{
+ "repository": "only this repository's references, as / (the counts stay whole)",
+ "collected": "\"true\": list the collected references too (counted always)",
+ }, nil, "collected")},
+ {Name: "held", Description: "Whether the artifact store holds each reference given, asked with one HEAD each: " +
+ "held, gone (the store answered 404) or unknown with why. The builder asks it before it reuses a done key. " +
+ "Reads only.",
+ Input: listed(schema(map[string]string{
+ "references": "the artifacts' references, artifact-store:////blobs/sha256:: a list, " +
+ "or one text separated by commas",
+ }, []string{"references"}), "references")},
+ {Name: "collect", Description: "The artifact store's sweep, now: what is eligible is let go of. Without confirm a " +
+ "dry run: what would be let go, nothing held or deleted. With confirm and why: every kept archive held first, " +
+ "then each eligible artifact let go of, oldest first, an index with the platform manifests no kept index " +
+ "names, each deletion recorded in the hand-act log. Bounded by most (500 by default, at most 5000) and 45 " +
+ "seconds; what is left is said. Never touches a digest the mesh did not make.",
+ Input: schema(map[string]string{
+ "confirm": "\"true\": let go of what is eligible (needs why); without it nothing is done",
+ "why": "with confirm: why, required and recorded",
+ "most": "let go of at most this many (500 by default, at most 5000)",
+ }, nil, "confirm")},
+ {Name: "mirrors", Description: "The bases the mesh copied into its artifact store, each kept (a kept build stood on " +
+ "it), eligible (only builds no longer kept stood on it) or unrecorded (no build the builder's log holds stood on " +
+ "it: never removed), by what the builder's log says each build stood on. Records nothing by hand.",
+ Input: schema(map[string]string{}, nil)},
+ {Name: "images", Description: "Every container image one machine's want and have name, each with the resources " +
+ "naming it: what the machine keeps when it prunes its images. Reads only.",
+ Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})},
+ }
+}
diff --git a/internal/compose/resolution/artifacts.go b/internal/compose/resolution/artifacts.go
new file mode 100644
index 0000000..559ac1b
--- /dev/null
+++ b/internal/compose/resolution/artifacts.go
@@ -0,0 +1,65 @@
+package resolution
+
+import (
+ "fmt"
+ "strings"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+// artifactsInto composes the artifact store's address into a resource's `image` and `source`.
+//
+// **Composed here, at the last moment before a machine, and stored nowhere.** A kept reference is
+// routed through the store as this network reaches it now. A reference recorded with an address
+// before references were kept without one is re-routed the same way — but only when the mesh
+// built it (`with.Built` names every `/` it has), because a module may run an
+// image from a public registry under its own name and that one is exactly where it says.
+//
+// A kept reference with no store to route it through is refused: sent as it is, the runtime would
+// refuse the scheme on the machine, one push away from the reason.
+//
+// **What this does not reach: the images genesis pinned.** The installer builds the control plane
+// and the builder before the mesh exists, pushes them itself and pins their manifests to
+// `:/mesh-controller@…` and `:/mesh-builder@…` — single-segment
+// repositories with no build record, so `with.Built` does not name them and they are left as
+// written until each is rebuilt through the mesh, which records it by digest and path. Until then
+// a registry that moves strands exactly those two on a recreate, and the control plane's is the
+// one that cannot be repaired through the mesh. Rebuild both through `build` before moving the
+// store (novox/hq 04-ISSUES/102, finding F4).
+func artifactsInto(resource map[string]any, module string, with mcat.Rendering) error {
+ for _, key := range []string{"image", "source"} {
+ written, ok := resource[key].(string)
+ if !ok {
+ continue
+ }
+ if _, kept := mcat.InArtifactStore(written); kept {
+ if with.ArtifactStore == "" {
+ return fmt.Errorf(
+ "%s's %v names %s, which is in the mesh's artifact store, and this mesh has no "+
+ "artifact store on its network to fetch it from — nothing assigned offers "+
+ "%s, or the machine offering it is not on the private network",
+ module, resource["id"], written, mcat.ArtifactStoreProvision)
+ }
+ resource[key] = mcat.Routed(written, with.ArtifactStore)
+ continue
+ }
+ if with.ArtifactStore == "" {
+ continue
+ }
+ recorded := mcat.Recorded(written)
+ if recorded == written {
+ continue
+ }
+ path, _ := mcat.InArtifactStore(recorded)
+ repository := path
+ if at := strings.IndexAny(path, "@"); at >= 0 {
+ repository = path[:at]
+ } else if blobs := strings.Index(path, "/blobs/"); blobs >= 0 {
+ repository = path[:blobs]
+ }
+ if with.Built[repository] {
+ resource[key] = mcat.Routed(recorded, with.ArtifactStore)
+ }
+ }
+ return nil
+}
diff --git a/internal/compose/resolution/artifacts_test.go b/internal/compose/resolution/artifacts_test.go
new file mode 100644
index 0000000..e9ab076
--- /dev/null
+++ b/internal/compose/resolution/artifacts_test.go
@@ -0,0 +1,102 @@
+package resolution
+
+import (
+ "strings"
+ "testing"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+var digest = "sha256:" + strings.Repeat("d", 64)
+
+// **The address is composed into a declaration, and the record never carries it.**
+func TestAnImageTheMeshBuiltIsRoutedThroughTheStoreWhenDeclared(t *testing.T) {
+ m := mcat.Manifest{Module: "gitea", Version: "1", Resources: []map[string]any{
+ {"id": "server", "type": "container", "name": "mesh-gitea", "artifact": "server"},
+ {"id": "config", "type": "archive", "path": "/etc/gitea", "artifact": "config"},
+ {"id": "cache", "type": "container", "name": "mesh-gitea-cache", "image": "valkey/valkey@" + digest},
+ }, Build: &mcat.Build{Artifacts: []mcat.Artifact{
+ {Name: "server", Kind: mcat.ArtifactImage, From: "Dockerfile"},
+ {Name: "config", Kind: mcat.ArtifactArchive, From: "config"},
+ }}}
+ resolved, err := m.Resolve([]mcat.Built{
+ {Name: "server", Kind: mcat.ArtifactImage, Reference: mcat.ArtifactStoreScheme + "gitea/server@" + digest},
+ {Name: "config", Kind: mcat.ArtifactArchive, Reference: mcat.ArtifactStoreScheme + "gitea/config/blobs/" + digest, Digest: digest},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{resolved}}
+
+ out, err := Declaration(r, mcat.Rendering{ArtifactStore: "anchor.internal:5101"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := fileNamed(out, "gitea.server")["image"]; got != "anchor.internal:5101/gitea/server@"+digest {
+ t.Errorf("the image the mesh built is fetched as %v", got)
+ }
+ if got := fileNamed(out, "gitea.config")["source"]; got != "http://anchor.internal:5101/v2/gitea/config/blobs/"+digest {
+ t.Errorf("the archive the mesh built is fetched from %v", got)
+ }
+ if got := fileNamed(out, "gitea.cache")["image"]; got != "valkey/valkey@"+digest {
+ t.Errorf("an image from a public registry was routed through the store: %v", got)
+ }
+ // The manifest the mesh holds still says what it is, not where it was fetched from.
+ if got := resolved.Resources[0]["image"]; got != mcat.ArtifactStoreScheme+"gitea/server@"+digest {
+ t.Errorf("composing wrote the address into the catalogue's copy: %v", got)
+ }
+
+ // And the store moves: the same record, another address, without a rebuild.
+ out, err = Declaration(r, mcat.Rendering{ArtifactStore: "laptop.internal:5000"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := fileNamed(out, "gitea.server")["image"]; got != "laptop.internal:5000/gitea/server@"+digest {
+ t.Errorf("after the store moved, the image is still fetched as %v", got)
+ }
+}
+
+func TestAnImageInTheStoreWithNoStoreToFetchItFromIsRefused(t *testing.T) {
+ m := mcat.Manifest{Module: "gitea", Version: "1", Resources: []map[string]any{
+ {"id": "server", "type": "container", "name": "mesh-gitea",
+ "image": mcat.ArtifactStoreScheme + "gitea/server@" + digest},
+ }}
+ _, err := Declaration(mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{m}}, mcat.Rendering{})
+ if err == nil || !strings.Contains(err.Error(), "no artifact store") {
+ t.Fatalf("a reference nothing can fetch was sent to a machine: %v", err)
+ }
+}
+
+// **A reference recorded with an address before this follows the store too** — when the mesh
+// built it. A module running an image straight from a public registry under its own name is left
+// exactly where it says: `quay.io/keycloak/keycloak` is not the mesh's, whatever it is called.
+func TestAReferenceRecordedWithAnAddressFollowsTheStoreWhenTheMeshBuiltIt(t *testing.T) {
+ m := mcat.Manifest{Module: "keycloak", Version: "1", Resources: []map[string]any{
+ {"id": "server", "type": "container", "name": "mesh-keycloak",
+ "image": "anchor.internal:5100/keycloak/server@" + digest},
+ {"id": "upstream", "type": "container", "name": "mesh-keycloak-upstream",
+ "image": "quay.io/keycloak/keycloak@" + digest},
+ }}
+ r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{m}}
+ out, err := Declaration(r, mcat.Rendering{
+ ArtifactStore: "anchor.internal:5101",
+ Built: map[string]bool{"keycloak/server": true},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := fileNamed(out, "keycloak.server")["image"]; got != "anchor.internal:5101/keycloak/server@"+digest {
+ t.Errorf("an image the mesh built, recorded with the old address, is fetched as %v", got)
+ }
+ if got := fileNamed(out, "keycloak.upstream")["image"]; got != "quay.io/keycloak/keycloak@"+digest {
+ t.Errorf("a public image was re-routed through the store: %v", got)
+ }
+ // Nothing known to be built: nothing re-routed, nothing refused.
+ out, err = Declaration(r, mcat.Rendering{ArtifactStore: "anchor.internal:5101"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := fileNamed(out, "keycloak.server")["image"]; got != "anchor.internal:5100/keycloak/server@"+digest {
+ t.Errorf("with no build record, a reference was rewritten: %v", got)
+ }
+}
diff --git a/internal/compose/resolution/backup_placement.go b/internal/compose/resolution/backup_placement.go
new file mode 100644
index 0000000..46156a9
--- /dev/null
+++ b/internal/compose/resolution/backup_placement.go
@@ -0,0 +1,46 @@
+package resolution
+
+import (
+ "fmt"
+ "regexp"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+// ofBackup is where a holder places them: ${backup:}. Loose inside the braces, so a misspelt key is found and
+// refused rather than written out as text.
+var ofBackup = regexp.MustCompile(`\$\{backup:([^}]*)\}`)
+
+// backupKinds is the contribution kind each key of the backup placement renders.
+var backupKinds = map[string]string{"lines": mcat.BackupKindBackup, "data": mcat.BackupKindData}
+
+// backupPlacer is the machine's modules the backup placement is composed over, as the old contributions are.
+type backupPlacer struct {
+ modules []mcat.Manifest
+ with mcat.Rendering
+ facts map[string]string
+ caps map[string]bool
+ unplaced *[]string
+}
+
+// placed is the text of one key for a holder, or what is wrong with naming it.
+func (b backupPlacer) placed(holder mcat.Manifest, key string, item bool) (string, string) {
+ kind, known := backupKinds[key]
+ switch {
+ case item:
+ return "", "an item is kept per item; the machine's backup lines are placed only in a file"
+ case !known:
+ return "", fmt.Sprintf("the backup placement has `lines` and `data`, not %q", key)
+ case !holder.ClaimsSeat(mcat.BackupSeat):
+ return "", fmt.Sprintf("%s does not claim %s; the machine's backup lines are placed by its holder alone "+
+ "(novox/hq ADR 0212)", holder.Module, mcat.BackupSeat)
+ }
+ text, left, err := seatContributions(b.modules, holder, mcat.BackupSeat+":"+kind, b.with, b.facts, b.caps)
+ if b.unplaced != nil {
+ *b.unplaced = append(*b.unplaced, left...)
+ }
+ if err != nil {
+ return "", err.Error()
+ }
+ return text, ""
+}
diff --git a/internal/compose/resolution/backup_placement_test.go b/internal/compose/resolution/backup_placement_test.go
new file mode 100644
index 0000000..9035fc1
--- /dev/null
+++ b/internal/compose/resolution/backup_placement_test.go
@@ -0,0 +1,114 @@
+package resolution
+
+import (
+ "strings"
+ "testing"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+const oldBackupText = "# lines\n${contribution:node-backup:backup}"
+
+const oldDataText = "# data\n${contribution:node-backup:data}"
+
+const placedBackupTxt = "# lines\n${backup:lines}"
+
+const placedDataTxt = "# data\n${backup:data}"
+
+const backupHolder = `{"module":"restic","version":"1","moved":true,"claims":[{"name":"node-backup","scope":"node"}],
+ "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
+ "resources":[{"id":"declared","type":"file","path":"/etc/backups.conf","template":"files/backups"},
+ {"id":"data-declared","type":"file","path":"/etc/data.conf","template":"files/data"}]}`
+
+func dataModule(name string) string {
+ return `{"module":"` + name + `","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"}],
+ "data":{"own":[{"id":"d","path":"${dir:d}","class":"irreplaceable","why":"the ` + name + ` data"}]}}`
+}
+
+// backupsOn is a machine of a holder whose files say these texts and the modules that declare data.
+func backupsOn(t *testing.T, lines, data string, modules ...string) mcat.Resolution {
+ t.Helper()
+ all := []mcat.Manifest{held(t, backupHolder, builtWith("restic", map[string]string{"backups": lines, "data": data}))}
+ names := []string{"restic"}
+ for _, name := range modules {
+ all = append(all, mustParse(t, dataModule(name)))
+ names = append(names, name)
+ }
+ r, err := Resolve(shelf(all...), names, workstation(), mcat.World{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ r.Account = "op"
+ return r
+}
+
+var backupModules = []string{"alpha", "beta", "gamma"}
+
+// gammaLeftOut is a machine whose gamma does not place: its lines are kept, best effort.
+func gammaLeftOut(with mcat.Rendering) mcat.Rendering {
+ with.NotPlaced = map[string]string{"gamma": "its placement setting does not read"}
+ return with
+}
+
+func TestTheBackupPlacementIsTheTextTheOldPlaceholdersRender(t *testing.T) {
+ // The old form is an unmoved holder: its placeholders are the controller's alone.
+ oldHolder := strings.Replace(backupHolder, `"moved":true,`, "", 1)
+ all := []mcat.Manifest{held(t, oldHolder, builtWith("restic", map[string]string{"backups": oldBackupText, "data": oldDataText}))}
+ for _, name := range backupModules {
+ all = append(all, mustParse(t, dataModule(name)))
+ }
+ old, err := Resolve(shelf(all...), append([]string{"restic"}, backupModules...), workstation(), mcat.World{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ old.Account = "op"
+ wantLines, wantData := fileOf(t, old, gammaLeftOut(mcat.Rendering{}), "restic.declared"), fileOf(t, old, gammaLeftOut(mcat.Rendering{}), "restic.data-declared")
+ if !strings.Contains(wantLines, "# alpha\npath ") || !strings.Contains(wantLines, "# gamma\n") || !strings.Contains(wantData, "item d") {
+ t.Fatalf("the old form renders\n%s\n%s", wantLines, wantData)
+ }
+ moved := backupsOn(t, placedBackupTxt, placedDataTxt, backupModules...)
+ // Not handed: the controller fills the new placeholders with the old text.
+ if got := fileOf(t, moved, gammaLeftOut(mcat.Rendering{}), "restic.declared"); got != wantLines {
+ t.Errorf("not handed, the lines are\n%s\nwant\n%s", got, wantLines)
+ }
+ if got := fileOf(t, moved, gammaLeftOut(mcat.Rendering{}), "restic.data-declared"); got != wantData {
+ t.Errorf("not handed, the data is\n%s\nwant\n%s", got, wantData)
+ }
+ // Handed: the plan carries the same text as placement, and the files are the client's.
+ c, _ := composeWith(t, moved, gammaLeftOut(handingTo("store:5000")))
+ p := c.Plans["restic"].Placement
+ if got := "# lines\n" + p["backup:lines"]; got != wantLines {
+ t.Errorf("handed, the plan's lines are\n%s\nwant\n%s", got, wantLines)
+ }
+ if got := "# data\n" + p["backup:data"]; got != wantData {
+ t.Errorf("handed, the plan's data is\n%s\nwant\n%s", got, wantData)
+ }
+ if len(p) != 2 {
+ t.Errorf("the plan's placement is %v", p)
+ }
+}
+
+func TestAnUnknownBackupKeyIsRefused(t *testing.T) {
+ r := backupsOn(t, "${backup:rows}", placedDataTxt, backupModules...)
+ if _, err := Compose(r, handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "backup:rows") {
+ t.Errorf("handed, an unknown key is %v", err)
+ }
+ if _, err := Declaration(r, mcat.Rendering{}); err == nil || !strings.Contains(err.Error(), "backup:rows") {
+ t.Errorf("not handed, an unknown key is %v", err)
+ }
+}
+
+func fileOf(t *testing.T, r mcat.Resolution, with mcat.Rendering, id string) string {
+ t.Helper()
+ out, err := Declaration(r, with)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, res := range out {
+ if res["id"] == id {
+ return res["content"].(string)
+ }
+ }
+ t.Fatalf("%s was not composed", id)
+ return ""
+}
diff --git a/internal/compose/resolution/backup_test.go b/internal/compose/resolution/backup_test.go
new file mode 100644
index 0000000..6583285
--- /dev/null
+++ b/internal/compose/resolution/backup_test.go
@@ -0,0 +1,37 @@
+package resolution
+
+import (
+ "strings"
+ "testing"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+// A module on the shelf from before ADR 0233, which writes its backup lines by hand and declares no
+// data, is still backed up: its lines are placed as written, each module's under a comment naming it.
+func TestHandWrittenBackupLinesOfAnOlderModuleArePlaced(t *testing.T) {
+ pg, err := decodeForTest([]byte(`{"module":"pg","version":"1",
+ "resources":[{"id":"dumps","type":"directory","path":"/srv/pg/dumps","mode":"0700"}],
+ "contributions":[{"seat":"node-backup","kind":"backup","content":"run pg-dump-all\npath ${dir:dumps}"}]}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ mail, err := decodeForTest([]byte(`{"module":"mail","version":"1",
+ "resources":[{"id":"spool","type":"directory","mode":"0700"}],
+ "contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:spool}"}]}`))
+ if err != nil {
+ t.Fatal(err)
+ }
+ placed, _, err := seatContributions([]mcat.Manifest{pg, mail}, mcat.Manifest{}, mcat.BackupSeat+":"+"backup", mcat.Rendering{}, nil, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{"# pg\nrun pg-dump-all\npath /srv/pg/dumps\n", "# mail\npath /var/lib/mail/spool\n"} {
+ if !strings.Contains(placed, want) {
+ t.Errorf("placed contributions lack %q:\n%s", want, placed)
+ }
+ }
+ if strings.Contains(placed, "${dir:") {
+ t.Errorf("a directory reached the holder unfilled:\n%s", placed)
+ }
+}
diff --git a/internal/compose/resolution/bound.go b/internal/compose/resolution/bound.go
new file mode 100644
index 0000000..bf7b620
--- /dev/null
+++ b/internal/compose/resolution/bound.go
@@ -0,0 +1,117 @@
+package resolution
+
+import (
+ "fmt"
+ "slices"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+// keepBound holds every need for a provision that keeps its consumers' data at the provider its
+// consumer was bound to, where the resolution chose another.
+//
+// A need with no record is a binding being made, and is left as resolved: it is recorded when it is
+// first sent. A pin naming the provider the resolution chose is a person moving it, and is left too.
+// Otherwise the recorded provider answers, if it still provides the provision — beside the consumer,
+// or offered from elsewhere — and the move is returned as kept. **One that no longer does is refused,
+// never answered by the provider chosen**: answering it there is exactly the silent move this exists
+// to stop, and the consumer's data is still wherever it was.
+func keepBound(needs []mcat.Needed, catalogue map[string]mcat.Manifest, node mcat.Node, world mcat.World,
+ keeps map[string]bool, here func(string) bool) ([]mcat.Needed, []mcat.KeptBinding, []string) {
+ var kept []mcat.KeptBinding
+ var problems []string
+ refused := map[[2]string]bool{}
+ out := make([]mcat.Needed, 0, len(needs))
+ for _, n := range needs {
+ if n.ByRecord || !keeps[n.Name] {
+ out = append(out, n)
+ continue
+ }
+ n.KeepsData = true
+ bound, recorded := world.Bound[n.For][n.Name]
+ chose := mcat.Chosen{Node: n.From, Module: n.Module}
+ if !recorded || sameProvider(bound, chose) {
+ out = append(out, n)
+ continue
+ }
+ if pin, pinned := world.Pinned[n.Name]; pinned && chosenMatches(pin, mcat.Provider{Node: chose.Node, Module: chose.Module}) {
+ out = append(out, n)
+ continue
+ }
+ held, ok, why := boundNeed(n, bound, catalogue, node, world, here)
+ if !ok {
+ if key := [2]string{n.For, n.Name}; !refused[key] {
+ refused[key] = true
+ problems = append(problems, fmt.Sprintf(
+ "%s on %s is bound to %s for %q, which keeps its data, and %s — it would move to %s, "+
+ "which holds none of it. Move its data and say so with `pin %s %s %s %s`, or give "+
+ "%s back what it provided",
+ n.For, node.Name, bound, n.Name, why, chose, node.Name, n.Name, chose.Node, chose.Module,
+ bound.Node))
+ }
+ continue
+ }
+ out = append(out, held)
+ kept = append(kept, mcat.KeptBinding{Machine: node.Name, Consumer: n.For, Provision: n.Name, Bound: bound, Would: chose})
+ }
+ return out, kept, problems
+}
+
+// sameProvider is whether a recorded provider is the one chosen. A record naming no module (none
+// is written without one, but a person's hand might) matches any module on its node.
+func sameProvider(bound, chose mcat.Chosen) bool {
+ return bound.Node == chose.Node && (bound.Module == "" || bound.Module == chose.Module)
+}
+
+// boundNeed is the need answered by the recorded provider, or why it cannot be.
+func boundNeed(n mcat.Needed, bound mcat.Chosen, catalogue map[string]mcat.Manifest, node mcat.Node, world mcat.World,
+ here func(string) bool) (mcat.Needed, bool, string) {
+ held := mcat.Needed{Name: n.Name, For: n.For, Local: n.Local, KeepsData: true}
+ if bound.Node == node.Name {
+ m, known := catalogue[bound.Module]
+ if !known || !here(bound.Module) || !providesAt(m, n.Name, mcat.ScopeMesh) {
+ return mcat.Needed{}, false, fmt.Sprintf("%s no longer runs on %s", bound.Module, node.Name)
+ }
+ at := node.At
+ if at == "" {
+ at = "127.0.0.1"
+ }
+ held.From, held.At, held.Module = node.Name, at, m.Module
+ held.Serves, held.SharedOwn, held.Identity = servedByOne(m, n.Name), sharedByOne(m, n.Name), m.IdentityBoundOf(n.Name)
+ held.CredentialForm, held.CredentialTool = m.CredentialFormOf(n.Name), m.CredentialToolOf(n.Name)
+ return held, true, ""
+ }
+ matching := chosenAmong(bound, world.Offered[n.Name])
+ if len(matching) != 1 {
+ return mcat.Needed{}, false, fmt.Sprintf("%s no longer provides it", bound)
+ }
+ p := matching[0]
+ if node.At == "" || p.At == "" {
+ return mcat.Needed{}, false, fmt.Sprintf("%s and %s are not both on the private network", node.Name, p.Node)
+ }
+ identity := mcat.DefaultIdentityBound
+ if pm, known := catalogue[p.Module]; known {
+ held.SharedOwn, _ = pm.SharedCredentialOf(n.Name)
+ held.CredentialForm, held.CredentialTool = pm.CredentialFormOf(n.Name), pm.CredentialToolOf(n.Name)
+ identity = pm.IdentityBoundOf(n.Name)
+ }
+ held.From, held.At, held.Module, held.Serves, held.Identity = p.Node, p.At, p.Module, p.Serves, identity
+ held.LeftOut = p.LeftOut
+ return held, true, ""
+}
+
+// BindsFrom is the providers this resolution binds a pair credential's need to — the provision, the
+// module that requires it and the credential's local name — answered by a machine rather than by a
+// record. None means this machine states no such binding.
+func BindsFrom(r mcat.Resolution, provision, module, local string) []string {
+ var from []string
+ for _, n := range r.Needs {
+ if n.ByRecord || n.Name != provision || n.For != module || n.Local != local {
+ continue
+ }
+ if !slices.Contains(from, n.From) {
+ from = append(from, n.From)
+ }
+ }
+ return from
+}
diff --git a/internal/compose/resolution/bound_into_files.go b/internal/compose/resolution/bound_into_files.go
new file mode 100644
index 0000000..d27401e
--- /dev/null
+++ b/internal/compose/resolution/bound_into_files.go
@@ -0,0 +1,176 @@
+package resolution
+
+import (
+ "fmt"
+ "regexp"
+ "sort"
+ "strings"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+// bound is where a module says a value from one of its bindings belongs:
+// ${bound::}, and for an optional requirement the value it takes while absent,
+// ${bound::|default:} (novox/hq ADR 0330 §4). A default is never empty: an
+// absent requirement is never rendered as an empty string.
+var bound = regexp.MustCompile(`\$\{bound:([a-z0-9][a-z0-9.-]*[a-z0-9]):([a-z0-9][a-z0-9_-]*)(?:\|default:([^}\n]+))?\}`)
+
+// knownFor is everything a module may name from one of its bindings.
+//
+// Three facts the mesh states about any provision, plus whatever the provider said it serves. A
+// module may not reach a binding it does not have — the same boundary as a secret, for the same
+// reason.
+func knownFor(m mcat.Manifest, needs []mcat.Needed, node string) (map[string]map[string]string, error) {
+ out := map[string]map[string]string{}
+ for _, want := range m.Wants() {
+ for i := range needs {
+ n := needs[i]
+ if n.Name != want || n.For != m.Module {
+ continue
+ }
+ as := mcat.ConsumerIdentity(node, mcat.IdentitySource(m.Slug, m.Module))
+ values := map[string]string{
+ "at": n.At,
+ "from": n.From,
+ "as": as,
+ }
+ // What the provider derives for this consumer rather than for all of them
+ // (novox/hq ADR 0202). Filled here, the one place a provision and the module
+ // requiring it are both in hand.
+ served, err := ServedTo(n.Serves, as)
+ if err != nil {
+ return nil, fmt.Errorf("%s requires %s: %w", m.Module, want, err)
+ }
+ for key, value := range served {
+ // The provider's own vocabulary. Rendered plainly: a port is 5432, not 5432.000000,
+ // which is what a float would write and what a connection string would refuse.
+ values[key] = plainly(value)
+ }
+ out[want] = values
+ }
+ }
+ return out, nil
+}
+
+// withOwnNames adds a module's own composed names to what it may name from one binding:
+// `${bound::name}` and `:internal-name`, and for several contributions to one requirement
+// `:name-` / `:internal-name-`. Set over anything the provider serves under those keys:
+// what the module is called is the mesh's statement, not the provider's.
+func withOwnNames(values map[string]string, own map[string]any) {
+ for _, key := range []string{"name", "internal-name"} {
+ if v, ok := own[key].(string); ok {
+ values[key] = v
+ }
+ }
+ many, _ := own["names"].(map[string]any)
+ for local, raw := range many {
+ names, _ := raw.(map[string]any)
+ for _, key := range []string{"name", "internal-name"} {
+ if v, ok := names[key].(string); ok {
+ values[key+"-"+local] = v
+ }
+ }
+ }
+}
+
+// plainly renders a served value as a program would expect to read it.
+func plainly(value any) string {
+ switch v := value.(type) {
+ case string:
+ return v
+ case float64:
+ if v == float64(int64(v)) {
+ return fmt.Sprintf("%d", int64(v))
+ }
+ return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%f", v), "0"), ".")
+ case bool:
+ return fmt.Sprintf("%t", v)
+ case nil:
+ return ""
+ default:
+ return fmt.Sprint(v)
+ }
+}
+
+// boundInto replaces a file's ${bound:…} placeholders with what the mesh knows.
+//
+// A placeholder naming something the module does not require, or a key the provider does not
+// serve, is refused. Left as it was, the literal `${bound:x:y}` would be written into a
+// configuration file and read as a value — a connection to a host called `${bound:x:y}`, failing
+// somewhere that names neither the module nor the mesh.
+//
+// **An optional requirement left absent takes the default its placeholder names** (novox/hq ADR
+// 0330 §4), and one that names none is refused rather than rendered empty. The module check refuses
+// that already; this is the same rule where the file is made, for a manifest registered before it.
+func boundInto(resource map[string]any, known map[string]map[string]string, module string, absent map[string]bool) error {
+ if fmt.Sprint(resource["type"]) != "file" {
+ return nil
+ }
+ content, ok := resource["content"].(string)
+ if !ok {
+ return nil
+ }
+ var refused error
+ filled := bound.ReplaceAllStringFunc(content, func(token string) string {
+ if refused != nil {
+ return token
+ }
+ m := bound.FindStringSubmatch(token)
+ provision, key, fallback := m[1], m[2], m[3]
+ values, has := known[provision]
+ if !has {
+ if absent[provision] {
+ if fallback != "" {
+ return fallback
+ }
+ refused = fmt.Errorf(
+ "%s has a file that says ${bound:%s:%s}, and %q is optional and absent here: it names no "+
+ "value for then (${bound:%s:%s|default:}), so it would be written empty "+
+ "(novox/hq ADR 0330 §4)", module, provision, key, provision, provision, key)
+ return token
+ }
+ refused = fmt.Errorf(
+ "%s has a file that says ${bound:%s:%s}, and %s does not require %q. It may name %s",
+ module, provision, key, module, provision, orNothing(namesOfBindings(known)))
+ return token
+ }
+ value, said := values[key]
+ if !said {
+ refused = fmt.Errorf(
+ "%s asks its %s binding for %q, and what answers it says %s",
+ module, provision, key, orNothing(namesOfKeys(values)))
+ return token
+ }
+ return value
+ })
+ if refused != nil {
+ return refused
+ }
+ resource["content"] = filled
+ return nil
+}
+
+func namesOfBindings(known map[string]map[string]string) []string {
+ var names []string
+ for name := range known {
+ names = append(names, fmt.Sprintf("%q", name))
+ }
+ sort.Strings(names)
+ return names
+}
+
+func namesOfKeys(values map[string]string) []string {
+ var names []string
+ for key := range values {
+ names = append(names, fmt.Sprintf("%q", key))
+ }
+ sort.Strings(names)
+ return names
+}
+
+func orNothing(names []string) string {
+ if len(names) == 0 {
+ return "nothing"
+ }
+ return join(names)
+}
diff --git a/internal/compose/resolution/bound_into_files_test.go b/internal/compose/resolution/bound_into_files_test.go
new file mode 100644
index 0000000..0da5176
--- /dev/null
+++ b/internal/compose/resolution/bound_into_files_test.go
@@ -0,0 +1,246 @@
+package resolution
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+// A consumer writes its own connection string (novox/hq 04-ISSUES/023).
+//
+// Everything needed for one now reaches the file that needs it: the address and port from what the
+// provider serves, the name to present from what the mesh decided, the database name from what the
+// module itself contributed, and the password through the sealed hole the host fills.
+func TestAConsumerCanWriteAConnectionString(t *testing.T) {
+ r := mcat.Resolution{
+ Node: "workstation",
+ Modules: []mcat.Manifest{{
+ Module: "keycloak",
+ Requires: []string{"postgres-database"},
+ Secrets: map[string]string{"postgres-database": "/var/lib/keycloak/db.secret"},
+ Resources: []map[string]any{{
+ "id": "dbenv", "type": "file", "path": "/var/lib/keycloak/database.env",
+ "mode": "0600",
+ "content": "KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:" +
+ "${bound:postgres-database:port}/keycloak\n" +
+ "KC_DB_USERNAME=${bound:postgres-database:as}\n" +
+ "KC_DB_PASSWORD=${secret:postgres-database}\n",
+ }},
+ }},
+ Needs: []mcat.Needed{{
+ Name: "postgres-database", From: "anchor", At: "anchor.internal", For: "keycloak",
+ Sealed: "sealed-db", Serves: map[string]any{"port": float64(5432)},
+ }},
+ }
+ out, err := Declaration(r, mcat.Rendering{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ file := fileNamed(out, "keycloak.dbenv")
+ if file == nil {
+ t.Fatalf("no file in %v", out)
+ }
+ got := file["content"].(string)
+
+ want := "KC_DB_URL=jdbc:postgresql://anchor.internal:5432/keycloak\n" +
+ "KC_DB_USERNAME=mesh_workstation_keycloak\n" +
+ "KC_DB_PASSWORD=${secret:postgres-database}\n"
+ if got != want {
+ t.Errorf("the consumer cannot connect with what it was given:\n got %q\nwant %q", got, want)
+ }
+ // The password is still a hole. Only the host may fill that one, and only on the machine.
+ if sealed, _ := file["secrets"].(map[string]any); sealed["postgres-database"] != "sealed-db" {
+ t.Errorf("the credential did not travel with the file: %v", file)
+ }
+}
+
+// A provider answered on its consumer's own machine is announced at the port the machine PUBLISHES
+// it on, not the port the module declared (novox/hq 04-ISSUES/038).
+//
+// Issue 018 made the same-node provider announced at all; this is the promise after it — that the
+// port a co-located consumer is told is the one actually listening at the address it is handed. The
+// mesh assigns a bare `ports` mapping a host port (here the container's 5432 becomes 15432 on this
+// machine), and it must announce 15432, or the consumer dials workstation.internal:5432 where the
+// provider is not published. Cross-node consumers already got this (the plan re-derives the served
+// facts with the provider's assignment); the same-node need was settled before the assignment and
+// carried the declared port until now.
+func TestASameNodeProviderIsAnnouncedAtThePortItIsPublishedOn(t *testing.T) {
+ provider := mcat.Manifest{
+ Module: "postgres", Version: "1",
+ Provides: mcat.FromAnywhere("postgres-database"),
+ Listens: []mcat.Listening{{Port: 5432, Protocol: "tcp", From: "mesh"}},
+ Serves: map[string]map[string]any{"postgres-database": {"port": 5432}},
+ Resources: []map[string]any{{
+ "id": "server", "type": "container", "name": "postgres",
+ "ports": []any{"5432"},
+ }},
+ }
+ consumer := mcat.Manifest{
+ Module: "keycloak", Version: "1", Requires: []string{"postgres-database"},
+ Resources: []map[string]any{{
+ "id": "dbenv", "type": "file", "path": "/var/lib/keycloak/database.env", "mode": "0600",
+ "content": "KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:" +
+ "${bound:postgres-database:port}/keycloak\n",
+ }},
+ }
+ r, err := Resolve(shelf(provider, consumer),
+ []string{"postgres", "keycloak"}, reachable(), mcat.World{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ // The mesh assigned the container's 5432 to 15432 on this machine.
+ out, err := Declaration(r, mcat.Rendering{Ports: map[string]map[int]int{"postgres": {5432: 15432}}})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Where the provider is actually published: the assigned host port, on the machine at large.
+ server := fileNamed(out, "postgres.server")
+ if server == nil {
+ t.Fatalf("the provider container is not in the declaration: %v", out)
+ }
+ if published := fmt.Sprint(server["ports"]); !strings.Contains(published, "15432:5432") {
+ t.Fatalf("the provider was not published on the assigned port: %v", server["ports"])
+ }
+
+ // What the consumer was told to dial: the SAME port, at the announced address — not the 5432 the
+ // module declared, which is closed on that address.
+ dbenv := fileNamed(out, "keycloak.dbenv")
+ if dbenv == nil {
+ t.Fatalf("the consumer's binding file is not in the declaration: %v", out)
+ }
+ got := dbenv["content"].(string)
+ want := "KC_DB_URL=jdbc:postgresql://workstation.internal:15432/keycloak\n"
+ if got != want {
+ t.Errorf("the consumer dials a port the provider does not listen on:\n got %q\nwant %q",
+ got, want)
+ }
+}
+
+// A port is 5432, not 5432.000000 — which is what a number decoded from JSON would write, and what
+// every connection string in the world would refuse.
+func TestAPortIsWrittenAsAPort(t *testing.T) {
+ if got := plainly(float64(5432)); got != "5432" {
+ t.Errorf("a port was rendered as %q", got)
+ }
+ if got := plainly("postgres"); got != "postgres" {
+ t.Errorf("a string was rendered as %q", got)
+ }
+}
+
+// The name the provider will create is the name the consumer is told to present. One derivation,
+// so the two agree by construction rather than by two conventions written on the same day.
+func TestTheProviderAndTheConsumerAgreeOnTheName(t *testing.T) {
+ provider, err := Resolve(shelf(mcat.Manifest{
+ Module: "postgres", Version: "1", Provides: mcat.FromAnywhere("postgres-database"),
+ Grants: map[string]string{"postgres-database": "/var/lib/postgres/grants"},
+ Receives: map[string]string{"postgres-database": "/var/lib/postgres/grants/mesh.json"},
+ }), []string{"postgres"}, reachable(), mcat.World{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ out, err := Declaration(provider, mcat.Rendering{Grants: []mcat.Grant{{
+ Provision: "postgres-database", Consumer: "workstation", From: "keycloak",
+ Values: map[string]any{"name": "keycloak"}, Sealed: "sealed",
+ }}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var told string
+ for _, res := range out {
+ if res["path"] == "/var/lib/postgres/grants/mesh.json" {
+ told = res["content"].(string)
+ }
+ }
+ if !strings.Contains(told, `"as": "mesh_workstation_keycloak"`) {
+ t.Errorf("the provider was not told what to call the login: %s", told)
+ }
+ if told == "" {
+ t.Fatal("the provider was told nothing at all")
+ }
+ // Which is exactly what the consumer, on its own machine, is told to present.
+ if mcat.ConsumerIdentity("workstation", "keycloak") != "mesh_workstation_keycloak" {
+ t.Errorf("got %q", mcat.ConsumerIdentity("workstation", "keycloak"))
+ }
+}
+
+// A binding the module does not have is a typo, refused where it was written.
+func TestAFileCannotNameABindingTheModuleDoesNotHave(t *testing.T) {
+ r := mcat.Resolution{
+ Node: "workstation",
+ Modules: []mcat.Manifest{{
+ Module: "keycloak", Requires: []string{"postgres-database"},
+ Resources: []map[string]any{{
+ "id": "conf", "type": "file", "path": "/etc/keycloak.conf",
+ "content": "host=${bound:s3-bucket:at}\n",
+ }},
+ }},
+ Needs: []mcat.Needed{{Name: "postgres-database", From: "anchor", At: "a", For: "keycloak"}},
+ }
+ _, err := Declaration(r, mcat.Rendering{})
+ if err == nil {
+ t.Fatal("a module read a binding it does not have")
+ }
+ if !strings.Contains(err.Error(), "postgres-database") {
+ t.Errorf("the refusal does not say what it could have named: %v", err)
+ }
+}
+
+// A key the provider does not serve is refused too. Written through, the literal placeholder
+// becomes a hostname, and the failure names neither the module nor the mesh.
+func TestAKeyTheProviderDoesNotServeIsRefused(t *testing.T) {
+ r := mcat.Resolution{
+ Node: "workstation",
+ Modules: []mcat.Manifest{{
+ Module: "keycloak", Requires: []string{"postgres-database"},
+ Resources: []map[string]any{{
+ "id": "conf", "type": "file", "path": "/etc/keycloak.conf",
+ "content": "sock=${bound:postgres-database:socket}\n",
+ }},
+ }},
+ Needs: []mcat.Needed{{
+ Name: "postgres-database", From: "anchor", At: "a", For: "keycloak",
+ Serves: map[string]any{"port": float64(5432)},
+ }},
+ }
+ _, err := Declaration(r, mcat.Rendering{})
+ if err == nil {
+ t.Fatal("a module read a key nothing serves, and the placeholder became a value")
+ }
+ if !strings.Contains(err.Error(), `"port"`) {
+ t.Errorf("the refusal does not say what is served: %v", err)
+ }
+}
+
+// A machine's resolver configuration must name its resolver by address — it cannot resolve the name
+// of the thing it resolves names with (novox/hq ADR 0194). So a binding offers the providing
+// machine's private address beside its name, and only when the machine has one.
+func TestABindingOffersTheProvidersAddress(t *testing.T) {
+ consumer := func() mcat.Resolution {
+ return mcat.Resolution{
+ Node: "workstation",
+ Modules: []mcat.Manifest{{
+ Module: "resolv-conf",
+ Requires: []string{"wildcard-resolution"},
+ Resources: []map[string]any{{
+ "id": "resolv", "type": "file", "path": "/etc/resolv.conf", "mode": "0644",
+ "content": "nameserver ${bound:wildcard-resolution:address}\n",
+ }},
+ }},
+ Needs: []mcat.Needed{{Name: "wildcard-resolution", From: "anchor", At: "anchor.internal", For: "resolv-conf"}},
+ }
+ }
+ out, err := Declaration(consumer(), mcat.Rendering{Machines: map[string]string{"anchor.internal": "10.77.0.1"}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := fileNamed(out, "resolv-conf.resolv")["content"]; got != "nameserver 10.77.0.1\n" {
+ t.Fatalf("the resolver is not named by its address: %q", got)
+ }
+ // A machine with no address yet: refused, never written with a blank where the address belongs.
+ if _, err := Declaration(consumer(), mcat.Rendering{}); err == nil {
+ t.Fatal("a file naming an address the mesh does not have was composed")
+ }
+}
diff --git a/internal/compose/resolution/bound_test.go b/internal/compose/resolution/bound_test.go
new file mode 100644
index 0000000..98df9cb
--- /dev/null
+++ b/internal/compose/resolution/bound_test.go
@@ -0,0 +1,219 @@
+package resolution
+
+import (
+ "strings"
+ "testing"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+var incidentConsumers = []string{"board", "listings", "workflows", "agents", "game"}
+
+func storeMesh() map[string]mcat.Manifest {
+ shelf := map[string]mcat.Manifest{
+ "store": {Module: "store", Version: "1",
+ Provides: []mcat.Offer{{Name: "postgres-database", Scope: mcat.ScopeMesh}},
+ Claims: []mcat.Claim{{Name: "mesh-store", Scope: mcat.ScopeMesh}},
+ Serves: map[string]map[string]any{"postgres-database": {"port": 5432}},
+ Grants: map[string]string{"postgres-database": "/grants"}},
+ "resolver": {Module: "resolver", Version: "1",
+ Provides: []mcat.Offer{{Name: "wildcard-resolution", Scope: mcat.ScopeMesh}},
+ Claims: []mcat.Claim{{Name: "mesh-dns-resolver", Scope: mcat.ScopeMesh}}},
+ "network": {Module: "network", Version: "1", Requires: []string{"wildcard-resolution"}},
+ }
+ for _, c := range incidentConsumers {
+ shelf[c] = mcat.Manifest{Module: c, Version: "1", Requires: []string{"postgres-database"}}
+ }
+ return shelf
+}
+
+// storeWorld is the rest of the mesh as the home server's plan sees it: the anchor runs a store and a
+// resolver and holds both seats; the home server runs its own of each.
+func storeWorld() mcat.World {
+ w := mcat.World{Offered: map[string][]mcat.Provider{
+ "postgres-database": {
+ {Node: "anchor", At: "anchor.internal", Module: "store", Serves: map[string]any{"port": 5432}},
+ {Node: "home", At: "home.internal", Module: "store", Serves: map[string]any{"port": 5434}},
+ },
+ "wildcard-resolution": {
+ {Node: "anchor", At: "anchor.internal", Module: "resolver"},
+ {Node: "home", At: "home.internal", Module: "resolver"},
+ },
+ }}
+ w.Held = []mcat.Held{
+ {Claim: "mesh-store", Scope: mcat.ScopeMesh, Node: "anchor", Module: "store"},
+ {Claim: "mesh-dns-resolver", Scope: mcat.ScopeMesh, Node: "anchor", Module: "resolver"},
+ }
+ w.Holdings = w.Held
+ return w
+}
+
+var home = mcat.Node{Name: "home", At: "home.internal"}
+
+func homeAssigned() []string {
+ return append([]string{"store", "resolver", "network"}, incidentConsumers...)
+}
+
+func boundFrom(t *testing.T, r mcat.Resolution, consumer, provision string) mcat.Needed {
+ t.Helper()
+ for _, n := range r.Needs {
+ if n.For == consumer && n.Name == provision {
+ return n
+ }
+ }
+ t.Fatalf("no binding of %s for %s: %+v", consumer, provision, r.Needs)
+ return mcat.Needed{}
+}
+
+func TestTheIncidentAMachinesOwnStoreKeepsItsConsumersWhenTheSeatIsHeldElsewhere(t *testing.T) {
+ got, err := Resolve(storeMesh(), homeAssigned(), home, storeWorld())
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, c := range incidentConsumers {
+ if n := boundFrom(t, got, c, "postgres-database"); n.From != "home" || n.Module != "store" {
+ t.Errorf("%s bound to %s/%s; its data is on the store beside it (issue 273)", c, n.From, n.Module)
+ }
+ }
+ // And the resolver, which keeps nothing of anybody's, still answers from the seat's holder (258).
+ if n := boundFrom(t, got, "network", "wildcard-resolution"); n.From != "anchor" {
+ t.Errorf("the resolver bound to %s; the seat is held on anchor (issue 258)", n.From)
+ }
+ if len(got.Kept) != 0 {
+ t.Errorf("nothing was moved, and %d kept: %+v", len(got.Kept), got.Kept)
+ }
+}
+
+func TestTheIncidentWithEveryConsumerOnRecordStillMovesNothing(t *testing.T) {
+ w := storeWorld()
+ w.Bound = map[string]map[string]mcat.Chosen{}
+ for _, c := range incidentConsumers {
+ w.Bound[c] = map[string]mcat.Chosen{"postgres-database": {Node: "home", Module: "store"}}
+ }
+ got, err := Resolve(storeMesh(), homeAssigned(), home, w)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, c := range incidentConsumers {
+ if n := boundFrom(t, got, c, "postgres-database"); n.From != "home" {
+ t.Errorf("%s bound to %s", c, n.From)
+ }
+ }
+ if len(got.Kept) != 0 {
+ t.Errorf("kept %+v", got.Kept)
+ }
+}
+
+func TestAPinElsewhereStillMovesAStoresConsumers(t *testing.T) {
+ w := storeWorld()
+ w.Pinned = map[string]mcat.Chosen{"postgres-database": {Node: "anchor", Module: "store"}}
+ w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}}
+ got, err := Resolve(storeMesh(), homeAssigned(), home, w)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n := boundFrom(t, got, "board", "postgres-database"); n.From != "anchor" || n.Module != "store" {
+ t.Errorf("bound to %s/%s; a person pinned it to anchor", n.From, n.Module)
+ }
+ if len(got.Kept) != 0 {
+ t.Errorf("a pin is a person's move, not one to keep: %+v", got.Kept)
+ }
+}
+
+// A consumer on a machine with no store of its own, bound to one store, when the seat moves to
+// another: the holder would answer, and the binding stays.
+func laptopWorld(holder string) mcat.World {
+ w := storeWorld()
+ w.Held = []mcat.Held{{Claim: "mesh-store", Scope: mcat.ScopeMesh, Node: holder, Module: "store"}}
+ w.Holdings = w.Held
+ return w
+}
+
+var laptop = mcat.Node{Name: "laptop", At: "laptop.internal"}
+
+func TestABoundConsumerStaysWhenTheSeatsHolderChanges(t *testing.T) {
+ w := laptopWorld("home")
+ w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "anchor", Module: "store"}}}
+ got, err := Resolve(storeMesh(), []string{"board"}, laptop, w)
+ if err != nil {
+ t.Fatal(err)
+ }
+ n := boundFrom(t, got, "board", "postgres-database")
+ if n.From != "anchor" || n.At != "anchor.internal" || n.Serves["port"] != 5432 {
+ t.Fatalf("bound to %s at %s %v; its data is on anchor", n.From, n.At, n.Serves)
+ }
+ if len(got.Kept) != 1 {
+ t.Fatalf("the move was not said: %+v", got.Kept)
+ }
+ k := got.Kept[0]
+ if k.Bound != (mcat.Chosen{Node: "anchor", Module: "store"}) || k.Would != (mcat.Chosen{Node: "home", Module: "store"}) || k.Consumer != "board" {
+ t.Fatalf("kept %+v", k)
+ }
+ for _, want := range []string{"would move board's postgres-database from anchor/store to home/store",
+ "its data is on anchor/store", "pin laptop postgres-database home store", "move the data first"} {
+ if !strings.Contains(k.String(), want) {
+ t.Errorf("%q does not say %q", k.String(), want)
+ }
+ }
+ // Without a record it is a binding being made, and the holder answers it as before.
+ w.Bound = nil
+ got, err = Resolve(storeMesh(), []string{"board"}, laptop, w)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n := boundFrom(t, got, "board", "postgres-database"); n.From != "home" {
+ t.Errorf("a new consumer bound to %s; the seat is held on home", n.From)
+ }
+}
+
+func TestABoundConsumerWhoseProviderIsGoneIsRefusedNotMoved(t *testing.T) {
+ w := laptopWorld("anchor")
+ w.Offered["postgres-database"] = w.Offered["postgres-database"][:1] // only anchor's store is left
+ w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}}
+ _, err := Resolve(storeMesh(), []string{"board"}, laptop, w)
+ if err == nil {
+ t.Fatal("bound to home's store, which is gone, and answered by anchor's — the silent move")
+ }
+ for _, want := range []string{"board on laptop is bound to home/store", "home/store no longer provides it",
+ "pin laptop postgres-database anchor store"} {
+ if !strings.Contains(err.Error(), want) {
+ t.Errorf("%q does not say %q", err, want)
+ }
+ }
+}
+
+func TestAMachinesOwnStoreUnassignedRefusesItsBoundConsumers(t *testing.T) {
+ w := storeWorld()
+ w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}}
+ _, err := Resolve(storeMesh(), []string{"board"}, home, w)
+ if err == nil || !strings.Contains(err.Error(), "store no longer runs on home") {
+ t.Fatalf("got %v", err)
+ }
+}
+
+// The first pass asks only what a machine offers, and is never refused by a binding.
+func TestTheFirstPassKeepsNoBinding(t *testing.T) {
+ w := storeWorld()
+ w.Unchecked = true
+ w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "gone", Module: "store"}}}
+ if _, err := Resolve(storeMesh(), []string{"board"}, laptop, w); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// An offer saying it keeps nothing is answered by the seat's holder as the resolver is, even where it
+// grants a credential.
+func TestAStoreSayingItKeepsNothingFollowsTheSeat(t *testing.T) {
+ shelf := storeMesh()
+ no := false
+ s := shelf["store"]
+ s.Provides = []mcat.Offer{{Name: "postgres-database", Scope: mcat.ScopeMesh, KeepsConsumerData: &no}}
+ shelf["store"] = s
+ got, err := Resolve(shelf, homeAssigned(), home, storeWorld())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n := boundFrom(t, got, "board", "postgres-database"); n.From != "anchor" {
+ t.Errorf("bound to %s; it keeps nothing, and the seat is held on anchor", n.From)
+ }
+}
diff --git a/internal/compose/resolution/broker_seat_test.go b/internal/compose/resolution/broker_seat_test.go
new file mode 100644
index 0000000..524f390
--- /dev/null
+++ b/internal/compose/resolution/broker_seat_test.go
@@ -0,0 +1,49 @@
+package resolution
+
+import (
+ "strings"
+ "testing"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+// The mesh's bus is one per mesh, read from the catalogue beside this checkout.
+//
+// **This is step 2's claim, and it is checked here rather than in a bed** (novox/hq ADR 0116):
+// adoption puts the NATS server into the `mesh-broker` seat on a mesh that is already running,
+// and the property that matters is that a second one anywhere is refused *when it is assigned*,
+// not discovered later as two servers holding different halves of the mesh's traffic. A second
+// bus is not a degraded mesh; it is two meshes that both believe they are the one.
+func TestASecondMeshBusAnywhereIsRefusedByName(t *testing.T) {
+ nats := catalogueManifest(t, "nats")
+
+ if _, err := Resolve(shelf(nats), []string{"nats"}, workstation(), mcat.World{}); err != nil {
+ t.Fatalf("the bus alone does not resolve: %v", err)
+ }
+
+ elsewhere := mcat.World{Held: []mcat.Held{{Claim: "mesh-broker", Scope: mcat.ScopeMesh,
+ Node: "anchor", Module: "nats"}}}
+ other := workstation()
+ other.Name = "laptop"
+ _, err := Resolve(shelf(nats), []string{"nats"}, other, elsewhere)
+ if err == nil {
+ t.Fatal("a second bus was accepted on another machine")
+ }
+ if !strings.Contains(err.Error(), "mesh-broker") || !strings.Contains(err.Error(), "one per mesh") {
+ t.Fatalf("refused without naming the seat: %v", err)
+ }
+}
+
+// The seat is the server's role, not the product's name (novox/hq ADR 0079). A different
+// implementation of the bus claims the same seat, and the mesh refuses it for the same reason —
+// which is the property that lets the bus be replaced at all.
+func TestTheSeatRefusesADifferentBusToo(t *testing.T) {
+ nats := catalogueManifest(t, "nats")
+ held := mcat.World{Held: []mcat.Held{{Claim: "mesh-broker", Scope: mcat.ScopeMesh,
+ Node: "anchor", Module: "some-other-broker"}}}
+ other := workstation()
+ other.Name = "laptop"
+ if _, err := Resolve(shelf(nats), []string{"nats"}, other, held); err == nil {
+ t.Fatal("the seat admitted a second holder because the module's name differed")
+ }
+}
diff --git a/internal/compose/resolution/brokered_test.go b/internal/compose/resolution/brokered_test.go
new file mode 100644
index 0000000..2932a08
--- /dev/null
+++ b/internal/compose/resolution/brokered_test.go
@@ -0,0 +1,255 @@
+package resolution
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+// onNetwork is a set of providers, all of them reachable. Written as a helper because a machine
+// that cannot reach the one answering its requirement is its own case, tested separately.
+// reachable is this machine, on the private network.
+func reachable() mcat.Node {
+ n := workstation()
+ n.At = "workstation.internal"
+ return n
+}
+
+func onNetwork(nodes ...string) map[string][]mcat.Provider {
+ out := make([]mcat.Provider, 0, len(nodes))
+ for _, n := range nodes {
+ out = append(out, mcat.Provider{Node: n, At: n + ".internal", Module: "postgres"})
+ }
+ return map[string][]mcat.Provider{"postgres-database": out}
+}
+
+func brokeredShelf() map[string]mcat.Manifest {
+ return shelf(mcat.Manifest{Module: "postgres", Version: "1", Provides: mcat.FromAnywhere("postgres-database")}, mcat.Manifest{Module: "meshboard", Version: "1", Requires: []string{"postgres-database"}})
+}
+
+func TestADatabaseIsNotInstalledOnEveryMachineThatUsesOne(t *testing.T) {
+ // The fault this whole distinction exists for.
+ got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: onNetwork("anchor")})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if have := strings.Join(names(got), " "); strings.Contains(have, "postgres") {
+ t.Fatalf("using a database installed one here: %s", have)
+ }
+}
+
+func TestWhatAMachineTakesFromElsewhereIsRecorded(t *testing.T) {
+ // It is the only part of a node's set that stops working when a *different* machine goes
+ // away, and it is where a credential will have to be handed back.
+ got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: onNetwork("anchor")})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got.Needs) != 1 {
+ t.Fatalf("got %v", got.Needs)
+ }
+ if got.Needs[0].Name != "postgres-database" || got.Needs[0].From != "anchor" {
+ t.Fatalf("got %v", got.Needs[0])
+ }
+ if got.Needs[0].For != "meshboard" {
+ t.Fatalf("it does not say what wanted it: %v", got.Needs[0])
+ }
+}
+
+func TestNothingInTheMeshProvidingItIsRefusedWithSomewhereToPutIt(t *testing.T) {
+ // Refused rather than installed here. Choosing a machine to put a database on is a decision
+ // with consequences, and nothing resolving a web application should make it silently.
+ _, err := Resolve(brokeredShelf(), []string{"meshboard"}, workstation(), mcat.World{})
+ if err == nil {
+ t.Fatal("a database was found in a mesh that has none")
+ }
+ if !strings.Contains(err.Error(), "assign") || !strings.Contains(err.Error(), "postgres") {
+ t.Fatalf("the refusal does not say what to do: %v", err)
+ }
+}
+
+func TestTwoNodesProvidingItIsRefusedRatherThanPicked(t *testing.T) {
+ // Same rule as everywhere else. Picking one would be a guess about which database a person
+ // meant, and the wrong guess is somebody's data in the wrong place.
+ _, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: onNetwork("anchor", "archive")})
+ if err == nil {
+ t.Fatal("one of two databases was picked silently")
+ }
+ for _, want := range []string{"anchor", "archive", "pin"} {
+ if !strings.Contains(err.Error(), want) {
+ t.Fatalf("the refusal does not name %s: %v", want, err)
+ }
+ }
+}
+
+func TestSayingWhichOneSettlesIt(t *testing.T) {
+ got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{
+ Offered: onNetwork("anchor", "archive"),
+ Pinned: map[string]mcat.Chosen{"postgres-database": {Node: "archive", Module: "postgres"}},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got.Needs) != 1 || got.Needs[0].From != "archive" {
+ t.Fatalf("the choice was not taken: %v", got.Needs)
+ }
+}
+
+func TestBeingPointedAtAMachineThatDoesNotProvideItIsRefused(t *testing.T) {
+ // Rather than falling back to one that does. A fallback would quietly move somebody's data to
+ // a machine they did not choose, which is the whole reason the question is asked.
+ _, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{
+ Offered: onNetwork("anchor", "archive"),
+ Pinned: map[string]mcat.Chosen{"postgres-database": {Node: "somewhere-else", Module: "postgres"}},
+ })
+ if err == nil {
+ t.Fatal("a machine was silently given a different database from the one chosen")
+ }
+ if !strings.Contains(err.Error(), "somewhere-else") {
+ t.Fatalf("the refusal does not say what was chosen: %v", err)
+ }
+}
+
+func TestOneProviderDoesNotOverruleAChoice(t *testing.T) {
+ // A single answer is normally taken silently. Not when somebody said they wanted a different
+ // one -- that is the mesh overruling a person, which it does nowhere else.
+ _, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{
+ Offered: onNetwork("anchor"),
+ Pinned: map[string]mcat.Chosen{"postgres-database": {Node: "archive", Module: "postgres"}},
+ })
+ if err == nil {
+ t.Fatal("the only database was used although another was chosen")
+ }
+ if !strings.Contains(err.Error(), "only anchor/postgres provides it") {
+ t.Fatalf("the refusal does not say what is available: %v", err)
+ }
+}
+
+func TestACatalogueThatDisagreesAboutScopeIsRefused(t *testing.T) {
+ // If one module says a database is local and another says it is anywhere, the same
+ // requirement means two things depending on which one happens to answer it.
+ _, err := Resolve(shelf(mcat.Manifest{Module: "postgres", Version: "1", Provides: mcat.FromAnywhere("postgres-database")}, mcat.Manifest{Module: "sqlite", Version: "1", Provides: mcat.Offers("postgres-database")}, mcat.Manifest{Module: "meshboard", Version: "1", Requires: []string{"postgres-database"}}), []string{"meshboard"}, workstation(), mcat.World{})
+ if err == nil {
+ t.Fatal("a catalogue that disagrees about where a database lives was accepted")
+ }
+ if !strings.Contains(err.Error(), "two things") {
+ t.Fatalf("unhelpful refusal: %v", err)
+ }
+}
+
+func TestALocalRequirementIsStillAnsweredLocally(t *testing.T) {
+ // The change must not have made everything brokered. A shell is still installed here.
+ got, err := Resolve(shelf(mcat.Manifest{Module: "zsh", Version: "1", Provides: mcat.Offers("shell")}, mcat.Manifest{Module: "tools", Version: "1", Requires: []string{"shell"}}), []string{"tools"}, workstation(), mcat.World{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if have := strings.Join(names(got), " "); !strings.Contains(have, "zsh") {
+ t.Fatalf("a shell was not installed on the machine that needs one: %s", have)
+ }
+}
+
+func boundShelf() map[string]mcat.Manifest {
+ return shelf(mcat.Manifest{Module: "postgres", Version: "1", Provides: mcat.FromAnywhere("postgres-database"),
+ Serves: map[string]map[string]any{"postgres-database": {"port": 5432, "driver": "postgres"}}}, mcat.Manifest{Module: "meshboard", Version: "1", Requires: []string{"postgres-database"},
+ Binds: map[string]string{"postgres-database": "/etc/meshboard/database.json"}},
+ )
+}
+
+func binding(t *testing.T, out []map[string]any) map[string]any {
+ t.Helper()
+ for _, r := range out {
+ if r["path"] != "/etc/meshboard/database.json" {
+ continue
+ }
+ var parsed map[string]any
+ if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil {
+ t.Fatalf("what the app is told is not readable: %v", err)
+ }
+ return parsed
+ }
+ t.Fatalf("the app was told nothing: %v", out)
+ return nil
+}
+
+func TestAnAppIsToldWhereItsDatabaseIs(t *testing.T) {
+ // Knowing it needs the anchor's database is useless to the program that needs it unless the
+ // program is told. This is the whole point of the field.
+ got, err := Resolve(boundShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: map[string][]mcat.Provider{"postgres-database": {{Node: "anchor", At: "anchor.internal",
+ Serves: map[string]any{"port": 5432, "driver": "postgres"}}}}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ told := binding(t, mustDeclare(t, got))
+ if told["from"] != "anchor" || told["at"] != "anchor.internal" {
+ t.Fatalf("it was not told where: %v", told)
+ }
+ serves, _ := told["serves"].(map[string]any)
+ if serves["port"] != float64(5432) || serves["driver"] != "postgres" {
+ t.Fatalf("it was not told how: %v", serves)
+ }
+}
+
+func TestItSaysWhereTheCredentialIsInstead(t *testing.T) {
+ // A missing field looks like a bug; a stated absence looks like a boundary. Somebody wiring
+ // this up must not spend an afternoon looking for the password field.
+ //
+ // It used to say only that the mesh had no way to issue one, which stopped being true when
+ // 021 was fixed. A stated absence is only useful while it is accurate — once it is not, it
+ // sends the reader somewhere there is nothing to find.
+ got, _ := Resolve(boundShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: map[string][]mcat.Provider{"postgres-database": {{Node: "anchor", At: "anchor.internal"}}}})
+ told := binding(t, mustDeclare(t, got))
+ note, _ := told["generated"].(string)
+ if !strings.Contains(note, "not here") || !strings.Contains(note, "secrets") {
+ t.Fatalf("the file does not say where the credential is instead: %v", note)
+ }
+ for key := range told {
+ if strings.Contains(key, "password") || strings.Contains(key, "secret") {
+ t.Fatalf("something that looks like a credential appeared: %q", key)
+ }
+ }
+}
+
+func TestTwoMachinesThatCannotReachEachOtherAreRefused(t *testing.T) {
+ // An app on one machine and a database on another that share no private network is a mesh
+ // that reports itself configured and does not work. Said here rather than discovered as a
+ // connection timing out.
+ _, err := Resolve(boundShelf(), []string{"meshboard"}, workstation(), mcat. // not on the network
+ World{Offered: map[string][]mcat.Provider{"postgres-database": {{Node: "anchor", At: "anchor.internal"}}}})
+ if err == nil {
+ t.Fatal("an app was pointed at a database it has no path to")
+ }
+ if !strings.Contains(err.Error(), "private network") {
+ t.Fatalf("the refusal does not say what is wrong: %v", err)
+ }
+ if !strings.Contains(err.Error(), meshNetwork) {
+ t.Fatalf("the refusal does not say what to assign: %v", err)
+ }
+}
+
+func TestTheProviderBeingOffTheNetworkIsAlsoRefused(t *testing.T) {
+ // Both directions, because the failure is identical from either end and the remedy differs.
+ _, err := Resolve(boundShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: map[string][]mcat.Provider{"postgres-database": {{Node: "anchor"}}}})
+ if err == nil {
+ t.Fatal("an app was pointed at a database that is not on the private network")
+ }
+ if !strings.Contains(err.Error(), "anchor") {
+ t.Fatalf("the refusal does not name the unreachable end: %v", err)
+ }
+}
+
+func TestBindingSomethingAnsweredHereWritesNothing(t *testing.T) {
+ // A file saying "it is on this node" is a fact nobody needs and one more thing to keep true.
+ got, err := Resolve(shelf(mcat.Manifest{Module: "zsh", Version: "1", Provides: mcat.Offers("shell")}, mcat.Manifest{Module: "tools", Version: "1", Requires: []string{"shell"},
+ Binds: map[string]string{"shell": "/etc/tools/shell.json"}},
+ ), []string{"tools"}, reachable(), mcat.World{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, r := range mustDeclare(t, got) {
+ if r["path"] == "/etc/tools/shell.json" {
+ t.Fatalf("a binding was written for something on this machine: %v", r)
+ }
+ }
+}
diff --git a/internal/compose/resolution/bucket_shares.go b/internal/compose/resolution/bucket_shares.go
new file mode 100644
index 0000000..0ada52a
--- /dev/null
+++ b/internal/compose/resolution/bucket_shares.go
@@ -0,0 +1,48 @@
+package resolution
+
+import (
+ "fmt"
+ "sort"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+// sortedShares is a requirement's shares as they are handed to the provider: sorted by `to` (a file whose lines move
+// looks changed), each entry's methods sorted, and a copy so the manifest is not reordered.
+func sortedShares(shares []mcat.BucketShare) []mcat.BucketShare {
+ out := make([]mcat.BucketShare, 0, len(shares))
+ for _, s := range shares {
+ methods := append([]string(nil), s.Methods...)
+ sort.Strings(methods)
+ out = append(out, mcat.BucketShare{To: s.To, Methods: methods})
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].To < out[j].To })
+ return out
+}
+
+// withShares adds this module's shares of the requirement to the values it gives the provider. A module whose own
+// `contributes` already names `shares` is refused: the field is the only way to say it (ADR 0468 §8).
+func withShares(m mcat.Manifest, requirement string, values map[string]any) (map[string]any, error) {
+ shares := m.RequireShares[requirement]
+ if _, named := values[mcat.SharesValue]; named && requirement == mcat.BucketRequirement {
+ return nil, fmt.Errorf("%s contributing to %s names `shares`; write it on its requirement instead (novox/hq ADR 0468 §8)",
+ m.Module, requirement)
+ }
+ if len(shares) == 0 {
+ return values, nil
+ }
+ out := make(map[string]any, len(values)+1)
+ for k, v := range values {
+ out[k] = v
+ }
+ listed := []any{}
+ for _, s := range sortedShares(shares) {
+ methods := []any{}
+ for _, method := range s.Methods {
+ methods = append(methods, method)
+ }
+ listed = append(listed, map[string]any{"to": s.To, "methods": methods})
+ }
+ out[mcat.SharesValue] = listed
+ return out, nil
+}
diff --git a/internal/compose/resolution/bucket_shares_test.go b/internal/compose/resolution/bucket_shares_test.go
new file mode 100644
index 0000000..4c5a2cc
--- /dev/null
+++ b/internal/compose/resolution/bucket_shares_test.go
@@ -0,0 +1,105 @@
+package resolution
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+const filesWithShares = `{"module":"files","version":"1","requires":["shell",
+ {"name":"s3-bucket","shares":[{"to":"seat:node-files","methods":["put"]},{"to":"print-and-scan","methods":["get","put"]}]}]}`
+
+func minioLike() mcat.Manifest {
+ return mcat.Manifest{Module: "minio", Version: "1", Provides: mcat.Offers("s3-bucket"),
+ Receives: map[string]string{"s3-bucket": "/grants/mesh.json"}}
+}
+
+func givenToMinio(t *testing.T, out []map[string]any) []mcat.Contribution {
+ t.Helper()
+ for _, r := range out {
+ if r["path"] != "/grants/mesh.json" {
+ continue
+ }
+ var parsed struct {
+ Given []mcat.Contribution `json:"given"`
+ }
+ if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil {
+ t.Fatal(err)
+ }
+ return parsed.Given
+ }
+ t.Fatalf("minio was given no file: %v", out)
+ return nil
+}
+
+func sharesOf(t *testing.T, values map[string]any) string {
+ t.Helper()
+ raw, err := json.Marshal(values["shares"])
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(raw)
+}
+
+const sortedSharesJSON = `[{"methods":["get","put"],"to":"print-and-scan"},{"methods":["put"],"to":"seat:node-files"}]`
+
+// 1591/AC3: the values a consumer gives carry the shares sorted by `to`, remote (grant) and co-located alike.
+func TestTheGrantCarriesTheSharesSorted(t *testing.T) {
+ m := mustParse(t, filesWithShares)
+ remote := mcat.Resolution{Node: "laptop", Modules: []mcat.Manifest{m}}
+ values, asks, err := ContributionsFrom(remote, "s3-bucket", "files", mcat.SettingsBy{})
+ if err != nil || !asks {
+ t.Fatalf("asks %v, err %v", asks, err)
+ }
+ if got := sharesOf(t, values); got != sortedSharesJSON {
+ t.Fatalf("the grant values carry %s", got)
+ }
+ // As the provider's file shows it, through a grant.
+ provider, err := Resolve(shelf(minioLike()), []string{"minio"}, workstation(), mcat.World{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ out, err := Declaration(provider, mcat.Rendering{Grants: []mcat.Grant{{Provision: "s3-bucket", Consumer: "laptop", From: "files", Values: values}}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ given := givenToMinio(t, out)
+ if len(given) != 1 || sharesOf(t, given[0].Values) != sortedSharesJSON {
+ t.Fatalf("the remote consumer's shares did not reach the file: %+v", given)
+ }
+
+ // Co-located: the consumer contributes to s3-bucket too, and no grant is passed.
+ co := mustParse(t, `{"module":"files","version":"1","contributes":{"s3-bucket":{"name":"files"}},
+ "requires":[{"name":"s3-bucket","shares":[{"to":"seat:node-files","methods":["put"]},{"to":"print-and-scan","methods":["put","get"]}]}]}`)
+ both, err := Resolve(shelf(minioLike(), co), []string{"files"}, workstation(), mcat.World{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ out, err = Declaration(both, mcat.Rendering{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ given = givenToMinio(t, out)
+ if len(given) != 1 || sharesOf(t, given[0].Values) != sortedSharesJSON || given[0].Values["name"] != "files" {
+ t.Fatalf("the co-located consumer's shares did not reach the file: %+v", given)
+ }
+}
+
+// 1591/AC5: a consumer whose contributes to s3-bucket names `shares` itself is refused.
+func TestAConsumerCannotContributeSharesItself(t *testing.T) {
+ _, err := decodeForTest([]byte(`{"module":"files","version":"1",
+ "contributes":{"s3-bucket":{"shares":"x"}},
+ "requires":[{"name":"s3-bucket","shares":[{"to":"a","methods":["get"]}]}]}`))
+ if err == nil || !strings.Contains(err.Error(), "ADR 0468 §8") {
+ t.Errorf("both written, accepted: %v", err)
+ }
+ // Without the field the grant is still refused, so the provider is never told something the manifest did not say.
+ m := mcat.Manifest{Module: "files", Requires: []string{"s3-bucket"},
+ Contributes: map[string]map[string]any{"s3-bucket": {"shares": "x"}}}
+ r := mcat.Resolution{Node: "laptop", Modules: []mcat.Manifest{m}}
+ if _, _, err := ContributionsFrom(r, "s3-bucket", "files", mcat.SettingsBy{}); err == nil {
+ t.Error("a contributed `shares` reached the grant")
+ }
+}
diff --git a/internal/compose/resolution/build.go b/internal/compose/resolution/build.go
new file mode 100644
index 0000000..08bfe76
--- /dev/null
+++ b/internal/compose/resolution/build.go
@@ -0,0 +1,20 @@
+package resolution
+
+import (
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+// runByAResource is the artifacts one of a module's own resources names — a process that runs it,
+// a step, an archive that unpacks it — by name.
+func runByAResource(m mcat.Manifest) map[string]bool {
+ named := map[string]bool{}
+ for _, r := range m.Resources {
+ if a, ok := r["artifact"].(string); ok && a != "" {
+ named[a] = true
+ }
+ }
+ return named
+}
+
+// RunByAResource is the bundles of m one of its own resources runs (its service, not its tools).
+func RunByAResource(m mcat.Manifest) map[string]bool { return runByAResource(m) }
diff --git a/internal/compose/resolution/busy.go b/internal/compose/resolution/busy.go
new file mode 100644
index 0000000..d27ba08
--- /dev/null
+++ b/internal/compose/resolution/busy.go
@@ -0,0 +1,20 @@
+package resolution
+
+import (
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+// busyInto composes a container's `busy` into the node-engine's words, or takes it away: for an engine that
+// reads it, the command and its timeout written out; for one that does not, the field is not sent, and the
+// container is replaced at once, as before.
+func busyInto(resource map[string]any, with mcat.Rendering) {
+ if _, has := resource[mcat.BusyField]; !has {
+ return
+ }
+ if !with.ReadsBusy {
+ delete(resource, mcat.BusyField)
+ return
+ }
+ b, _, _ := mcat.ReadBusy(resource)
+ resource[mcat.BusyField] = map[string]any{"command": b.Command, "timeout": b.Timeout.String()}
+}
diff --git a/internal/compose/resolution/busy_test.go b/internal/compose/resolution/busy_test.go
new file mode 100644
index 0000000..8940b78
--- /dev/null
+++ b/internal/compose/resolution/busy_test.go
@@ -0,0 +1,54 @@
+package resolution
+
+import (
+ "encoding/json"
+ "testing"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+func busyManifest(t *testing.T, on string, busy any) []byte {
+ t.Helper()
+ extra := map[string]any{"id": "agent", "type": "container", "name": "app-agent",
+ "image": "registry.example/app" + healthDigest}
+ var m map[string]any
+ if err := json.Unmarshal(healthManifest(t, "", nil, extra), &m); err != nil {
+ t.Fatal(err)
+ }
+ for _, r := range m["resources"].([]any) {
+ if r := r.(map[string]any); r["id"] == on {
+ r[mcat.BusyField] = busy
+ }
+ }
+ out, err := json.Marshal(m)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return out
+}
+
+func TestABusyIsSentOnlyToAnEngineThatReadsIt(t *testing.T) {
+ m, err := decodeForTest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"}))
+ if err != nil {
+ t.Fatal(err)
+ }
+ compose := func(with mcat.Rendering) []map[string]any {
+ out, err := Declaration(mcat.Resolution{Node: "laptop", Modules: []mcat.Manifest{m}}, with)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return out
+ }
+ older := compose(mcat.Rendering{})
+ if b, sent := older[indexOfID(older, "app.agent")][mcat.BusyField]; sent {
+ t.Fatalf("busy was sent to an engine that does not read it: %v", b)
+ }
+ newer := compose(mcat.Rendering{ReadsBusy: true})
+ got, _ := newer[indexOfID(newer, "app.agent")][mcat.BusyField].(map[string]any)
+ if got["command"] != "mesh-builder busy" || got["timeout"] != "10s" {
+ t.Fatalf("busy composed as %v", got)
+ }
+ if _, has := m.Resources[len(m.Resources)-1][mcat.BusyField].(map[string]any)["timeout"]; has {
+ t.Error("composing wrote the timeout into the catalogue's own manifest")
+ }
+}
diff --git a/internal/compose/resolution/catalogue_check_test.go b/internal/compose/resolution/catalogue_check_test.go
new file mode 100644
index 0000000..3e75f08
--- /dev/null
+++ b/internal/compose/resolution/catalogue_check_test.go
@@ -0,0 +1,46 @@
+package resolution
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/novox/mesh-host/internal/beside"
+)
+
+// TestEveryCatalogueManifestParses runs the real catalogue through the real gate.
+//
+// Not a fixture: the point is whether the manifests as written are accepted by the control plane that
+// will read them, and a copy of one manifest proves nothing about the other seventy-one.
+// catalogueRoot is the catalogue these checks run over: in a merge check the clone the build seat put
+// beside this one, elsewhere the copy captured in testdata/beside (internal/beside). A check that only
+// ran when somebody remembered a variable was a check nobody ran (novox/hq issue 134), and one that read
+// whatever checkout sat beside judged the machine, not the change (novox/hq issue 432): it never skips.
+func catalogueRoot(t *testing.T) string {
+ t.Helper()
+ return beside.Dir(t, "mesh-catalog")
+}
+
+func TestEveryCatalogueManifestParses(t *testing.T) {
+ _, found := catalogueManifests(t)
+ named, routed := 0, 0
+ for _, r := range found {
+ m, err := r.manifest, r.err
+ if err != nil {
+ t.Errorf("%s: %v", filepath.Base(filepath.Dir(r.path)), err)
+ continue
+ }
+ for _, l := range m.Listens {
+ if l.Name != "" {
+ named++
+ }
+ }
+ for port := range RoutedPorts(m) {
+ _ = port
+ routed++
+ }
+ }
+ t.Logf("%d manifests, %d named endpoints, %d routed endpoints resolved", len(found), named, routed)
+ if named == 0 {
+ t.Fatal("no endpoint in the catalogue is named, so this proved nothing")
+ }
+}
diff --git a/internal/compose/resolution/catalogue_read_test.go b/internal/compose/resolution/catalogue_read_test.go
new file mode 100644
index 0000000..08bd7c2
--- /dev/null
+++ b/internal/compose/resolution/catalogue_read_test.go
@@ -0,0 +1,46 @@
+package resolution
+
+import (
+ "path/filepath"
+ "sync"
+ "testing"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+// readManifest is one manifest of the catalogue as ReadTreeManifest read it.
+type readManifest struct {
+ path string // the module.json
+ manifest mcat.Manifest
+ err error
+}
+
+var readOnce sync.Once
+
+var readAll []readManifest
+
+var readRoot string
+
+// catalogueManifests is every manifest of the catalogue (catalogueRoot), read through ReadTreeManifest once
+// per test binary and not once per test: the checks over the real catalogue each read and parsed every
+// manifest again, and together took 1.5 s of mesh-sdk's unit bound of 2 s (issue 1756). What it returns is
+// shared and read-only: a test that changes a manifest copies it first.
+func catalogueManifests(t *testing.T) (string, []readManifest) {
+ t.Helper()
+ root := catalogueRoot(t)
+ readOnce.Do(func() {
+ readRoot = root
+ found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
+ if err != nil {
+ return
+ }
+ for _, p := range found {
+ m, err := mcat.ReadTreeManifest(root, filepath.Dir(p))
+ readAll = append(readAll, readManifest{path: p, manifest: m, err: err})
+ }
+ })
+ if len(readAll) == 0 {
+ t.Fatalf("no manifests under %s", root)
+ }
+ return readRoot, readAll
+}
diff --git a/internal/compose/resolution/catrust_manifest_test.go b/internal/compose/resolution/catrust_manifest_test.go
new file mode 100644
index 0000000..67a3a8c
--- /dev/null
+++ b/internal/compose/resolution/catrust_manifest_test.go
@@ -0,0 +1,70 @@
+package resolution
+
+import (
+ "path/filepath"
+ "strings"
+ "testing"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+ "github.com/novox/mesh-host/internal/beside"
+)
+
+// **A machine trusts the mesh's authority because a module put its root there** (novox/hq ADR
+// 0147, issue 129). The module carries a shell script and a unit, and both are worthless unless
+// the mesh fills in where the authority is — which is the one thing about it the manifest cannot
+// state, because the authority's address is a fact about the mesh and not about the module.
+//
+// So what is checked here is the rendering, not the parsing: the script the machine will run
+// names the authority it was bound to, and the unit runs that script both ways. The verification
+// itself — a plain client trusting an internal name on a machine holding this, and failing on one
+// that does not — is the lab's, and cannot be had here.
+func TestCaTrustRendersTheAuthorityItWasBoundTo(t *testing.T) {
+ m, err := mcat.ReadTreeManifest(beside.Dir(t, "mesh-catalog"), filepath.Join(beside.Catalogue(t), "ca-trust"))
+ if err != nil {
+ t.Fatalf("the trust module does not parse:\n%v", err)
+ }
+
+ r := mcat.Resolution{
+ Node: "workstation",
+ Modules: []mcat.Manifest{m},
+ Needs: []mcat.Needed{{
+ Name: "internal-acme-ca", From: "anchor", At: "anchor.internal", For: "ca-trust",
+ Serves: map[string]any{
+ "port": float64(9000), "path": "/acme/acme/directory", "roots": "/roots.pem",
+ },
+ }},
+ }
+ out, err := Declaration(r, mcat.Rendering{})
+ if err != nil {
+ t.Fatalf("the trust module could not be composed for a machine: %v", err)
+ }
+
+ script := fileNamed(out, "ca-trust.anchor")
+ if script == nil {
+ t.Fatalf("nothing writes the script the unit runs: %v", out)
+ }
+ body, _ := script["content"].(string)
+ if !strings.Contains(body, "https://anchor.internal:9000/roots.pem") {
+ t.Errorf("the script does not fetch from the authority it was bound to:\n%s", body)
+ }
+ if script["mode"] != "0755" {
+ t.Errorf("the script is written %v, which systemd cannot execute", script["mode"])
+ }
+
+ unit := fileNamed(out, "ca-trust.unit")
+ if unit == nil {
+ t.Fatalf("no unit: %v", out)
+ }
+ text, _ := unit["content"].(string)
+ // Both halves. A unit that only installs the anchor leaves a machine trusting an authority
+ // nobody assigned it to any more, which is the half issue 129 asked for by name.
+ for _, want := range []string{
+ "ExecStart=" + script["path"].(string) + " install",
+ "ExecStop=" + script["path"].(string) + " remove",
+ "RemainAfterExit=yes",
+ } {
+ if !strings.Contains(text, want) {
+ t.Errorf("the unit does not say %q:\n%s", want, text)
+ }
+ }
+}
diff --git a/internal/compose/resolution/checker_seat.go b/internal/compose/resolution/checker_seat.go
new file mode 100644
index 0000000..337ffa9
--- /dev/null
+++ b/internal/compose/resolution/checker_seat.go
@@ -0,0 +1,33 @@
+package resolution
+
+import (
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+// checkerVerbs are the checker seat's verbs: ask a try, read one, end one by hand. Optional until the checker serves them
+// everywhere.
+func checkerVerbs() []mcat.Verb {
+ return []mcat.Verb{
+ {Name: "try", Optional: true, Description: "Try a pushed head before its pull request is opened: the checker " +
+ "judges it as it judges a pull request's head, merged onto its trunk's head, its unit and integration " +
+ "levels through the build modules, and owes the forge nothing. Answers the try's id at once; try-read " +
+ "reads where it stands. A try of the same head that waits or runs is the answer to a second ask; at most " +
+ "4 wait or run at once, mesh-wide.",
+ Input: schema(map[string]string{"repository": "/",
+ "head": "the full commit to try, 40 or 64 hex digits",
+ "trunk": "the trunk commit to merge it onto, or the trunk's branch for its head now; the trunk's head when absent",
+ "tests": "a go test -run pattern: only those tests run, and nothing is kept",
+ "level": "unit or integration: only this level; both when absent"},
+ []string{"repository", "head"}),
+ Replaces: []string{"sh merge-check.sh", "docker run postgres"}},
+ {Name: "try-read", Optional: true, Description: "Where a try stands, as stored: queued, running, or its end " +
+ "(pass, fail, or error when it could not run), with what was said of it, the head and the trunk commit " +
+ "it was merged onto, the time it took and what failed first. Read-only.",
+ Input: schema(map[string]string{"id": "the try's id, try-"}, []string{"id"})},
+ {Name: "try-end", Optional: true, Description: "End a try that waits or runs, as error, by hand: say why (kept in " +
+ "what it says). Its place among the tries is free at once and the next try of its head asks anew with a new " +
+ "id. A try that ended already is refused.",
+ Input: schema(map[string]string{"id": "the try's id, try-", "why": "why it is ended, in words"},
+ []string{"id", "why"})},
+ }
+}
diff --git a/internal/compose/resolution/chosen.go b/internal/compose/resolution/chosen.go
new file mode 100644
index 0000000..fffdd19
--- /dev/null
+++ b/internal/compose/resolution/chosen.go
@@ -0,0 +1,86 @@
+package resolution
+
+import (
+ "sort"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+// matches is whether this provider is the one chosen.
+func chosenMatches(c mcat.Chosen, p mcat.Provider) bool {
+ return p.Node == c.Node && (c.Module == "" || p.Module == c.Module)
+}
+
+// among is every offered provider the choice names — one, when the choice is whole.
+func chosenAmong(c mcat.Chosen, where []mcat.Provider) []mcat.Provider {
+ var out []mcat.Provider
+ for _, p := range where {
+ if chosenMatches(c, p) {
+ out = append(out, p)
+ }
+ }
+ return out
+}
+
+// nameOf is how a refusal names a provider: the node and the module on it.
+func nameOf(p mcat.Provider) string {
+ return mcat.Chosen{Node: p.Node, Module: p.Module}.String()
+}
+
+// providerNames is every provider named, sorted, for a refusal to list.
+func providerNames(where []mcat.Provider) []string {
+ out := make([]string, 0, len(where))
+ for _, p := range where {
+ out = append(out, nameOf(p))
+ }
+ sort.Strings(out)
+ return out
+}
+
+// providersHere is which modules in this node's own set offer a provision, sorted.
+func providersHere(catalogue map[string]mcat.Manifest, here func(string) bool, want string) []string {
+ var out []string
+ for name, m := range catalogue {
+ if !here(name) {
+ continue
+ }
+ for _, o := range m.Offers() {
+ if o == want {
+ out = append(out, name)
+ break
+ }
+ }
+ }
+ sort.Strings(out)
+ return out
+}
+
+// servedByOne is what one provider beside the consumer says a consumer needs to know, or nothing.
+//
+// Serving is *whether* a need is created at all when the provider is on this same machine (novox/hq
+// 04-ISSUES/038's sibling): a need never created is a binding the consumer never gets. The manifest
+// alone answers that; the values are settled later, with the node's settings.
+func servedByOne(m mcat.Manifest, want string) map[string]any {
+ if _, ok := m.Serves[want]; ok {
+ return ServedOn(m, want, nil)
+ }
+ return nil
+}
+
+// sharedByOne is the own secret that provider names as its credential (ADR 0158), or "" when it
+// gives each consumer its own.
+func sharedByOne(m mcat.Manifest, want string) string {
+ if own, shared := m.SharedCredentialOf(want); shared {
+ return own
+ }
+ return ""
+}
+
+func oneOf(list []string, s string) bool {
+ for _, x := range list {
+ if x == s {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/compose/resolution/co_located_test.go b/internal/compose/resolution/co_located_test.go
new file mode 100644
index 0000000..0b39a17
--- /dev/null
+++ b/internal/compose/resolution/co_located_test.go
@@ -0,0 +1,306 @@
+package resolution
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+// A root certificate, as a certificate authority serves one: a real, self-signed one made for these tests (its key
+// thrown away), because the one setting that may hold lines must parse as a certificate (novox/hq issue 339).
+const servedRoot = `-----BEGIN CERTIFICATE-----
+MIIBPjCB8aADAgECAhRZG3p93hUUB5bz00uxhYo/nJnTQjAFBgMrZXAwFDESMBAG
+A1UEAwwJdGVzdCByb290MCAXDTI2MTAwODIyMjE0NloYDzIxMjYwOTE0MjIyMTQ2
+WjAUMRIwEAYDVQQDDAl0ZXN0IHJvb3QwKjAFBgMrZXADIQA0pt/ld+W0MXwBhPfO
+cuAt56kIW6Qcn+4vqWpuvHTiqaNTMFEwHQYDVR0OBBYEFIjgaLk4OVGIOeZQiqnN
+p9vhciFjMB8GA1UdIwQYMBaAFIjgaLk4OVGIOeZQiqnNp9vhciFjMA8GA1UdEwEB
+/wQFMAMBAf8wBQYDK2VwA0EAl9uWeSM2XAV8u0reyV3BLRxNVik+4FCRO1QKPs2k
+IlB1rK9oAOYManH+VFuBMI/JJ31ajSti81q4E0CSw8r5DQ==
+-----END CERTIFICATE-----
+`
+
+// stepCA is an internal ACME authority: it answers `acme-ca` from anywhere in the mesh, and what a
+// consumer must know is the directory URL and the root to trust. **The root is not knowable when
+// the module is written** — it exists only once the CA has been initialised — so the manifest
+// declares the key and the operator supplies the value as a setting, which is exactly the shape the
+// cross-node path settles and the same-node path did not.
+func stepCA() mcat.Manifest {
+ return mcat.Manifest{
+ Module: "step-ca", Version: "1",
+ Provides: mcat.FromAnywhere("acme-ca"),
+ Listens: []mcat.Listening{{Port: 9000, Protocol: "tcp", From: mcat.FromMesh}},
+ Serves: map[string]map[string]any{"acme-ca": {
+ "directory": "https://anchor.internal/acme/acme/directory",
+ // Declared empty: a manifest is the same on every mesh, and this mesh's root is not.
+ "root": "",
+ }},
+ Resources: []map[string]any{{
+ "id": "server", "type": "container", "name": "step-ca", "ports": []any{"9000"},
+ }},
+ }
+}
+
+// stepCAWithItsRoot is the authority whose root the operator supplies: the served key asks for it as
+// `${setting:root}` (ADR 0155), the only way a setting reaches what a provider serves since a layer
+// stopped replacing a served key (novox/hq issue 696; before it, the empty `root` was replaced whole).
+func stepCAWithItsRoot() mcat.Manifest {
+ m := stepCA()
+ m.Serves["acme-ca"]["root"] = "${setting:root}"
+ return m
+}
+
+// routeProxy issues from that authority, so it must be told the root to verify it with.
+func routeProxy() mcat.Manifest {
+ return mcat.Manifest{
+ Module: "route-proxy", Version: "1",
+ Requires: []string{"acme-ca"},
+ Provides: mcat.FromAnywhere("route"),
+ Binds: map[string]string{"acme-ca": "/var/lib/route-proxy/acme-ca.json"},
+ Receives: map[string]string{"route": "/var/lib/route-proxy/routes.json"},
+ Resources: []map[string]any{{
+ "id": "bundle", "type": "file", "path": "/var/lib/route-proxy/ca.pem", "mode": "0644",
+ "content": "${bound:acme-ca:root}",
+ }},
+ }
+}
+
+// A co-located provider's served VALUES reach its consumer, not just its served keys.
+//
+// The mesh walks a provider on ANOTHER machine and settles what it serves with that node's settings
+// layers before offering it (cmd/mesh-controller plan.go, theRestOfTheMesh). A provider on the
+// consumer's own machine was never settled at all: resolve.go's servedHere and declaration.go's
+// here() both read the manifest and stop there. So a served value the operator supplied — the one
+// kind of value a manifest cannot carry, because it is different on every mesh — arrived as the
+// manifest's empty default.
+//
+// The consequence is silent and total: route-proxy wrote an empty CA bundle, fell back to the
+// system trust store, could not verify the internal authority, and no certificate was ever issued.
+func TestACoLocatedProvidersServedValuesReachItsConsumer(t *testing.T) {
+ r, err := Resolve(shelf(stepCAWithItsRoot(), routeProxy()),
+ []string{"step-ca", "route-proxy"}, reachable(), mcat.World{})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // What the operator set on the provider, on this node — the CA's root, which only exists once
+ // the CA has been initialised.
+ out, err := Declaration(r, mcat.Rendering{Settings: mcat.SettingsBy{
+ "step-ca": {{From: "the operator", Values: map[string]any{"root": servedRoot}}},
+ }})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ bundle := fileNamed(out, "route-proxy.bundle")
+ if bundle == nil {
+ t.Fatalf("the consumer was given no bundle at all: %v", out)
+ }
+ if got := bundle["content"]; got != servedRoot {
+ t.Errorf("the co-located consumer was not told the root it must trust:\n got %q\nwant %q",
+ got, servedRoot)
+ }
+}
+
+// And the binding file says the same thing, for a consumer that reads the binding rather than a
+// substituted placeholder. The two are one fact and must not be able to disagree.
+func TestACoLocatedConsumersBindingCarriesTheSettledValues(t *testing.T) {
+ r, err := Resolve(shelf(stepCAWithItsRoot(), routeProxy()),
+ []string{"step-ca", "route-proxy"}, reachable(), mcat.World{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ out, err := Declaration(r, mcat.Rendering{
+ Settings: mcat.SettingsBy{
+ "step-ca": {{From: "the operator", Values: map[string]any{"root": servedRoot}}},
+ },
+ // And the machine moved the provider's port while it was at it, so both halves of the
+ // cross-node derivation are exercised at once.
+ Ports: map[string]map[int]int{"step-ca": {9000: 19000}},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ binding := fileNamed(out, "route-proxy.bound-acme-ca")
+ if binding == nil {
+ t.Fatalf("the consumer was given no binding at all: %v", out)
+ }
+ var said struct {
+ Serves map[string]any `json:"serves"`
+ }
+ if err := json.Unmarshal([]byte(binding["content"].(string)), &said); err != nil {
+ t.Fatal(err)
+ }
+ if said.Serves["root"] != servedRoot {
+ t.Errorf("the binding does not carry the settled root: %q", said.Serves["root"])
+ }
+ if port, _ := asPort(said.Serves["port"]); port != 19000 {
+ t.Errorf("the binding does not carry the port the machine publishes: %v", said.Serves["port"])
+ }
+}
+
+// A provider PULLED IN rather than assigned is settled the same way.
+//
+// The resolver's same-node need is built during the walk, from whichever modules had been chosen by
+// the time the requirement came up — so which path a co-located binding took depended on the order
+// a person happened to assign things in. Settling after the closure is known removes that: both
+// orders now produce the same file.
+func TestACoLocatedProviderIsSettledWhicheverWayItWasPulledIn(t *testing.T) {
+ for _, assigned := range [][]string{
+ {"step-ca", "route-proxy"},
+ {"route-proxy", "step-ca"},
+ } {
+ r, err := Resolve(shelf(stepCAWithItsRoot(), routeProxy()), assigned, reachable(), mcat.World{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ out, err := Declaration(r, mcat.Rendering{Settings: mcat.SettingsBy{
+ "step-ca": {{From: "the operator", Values: map[string]any{"root": servedRoot}}},
+ }})
+ if err != nil {
+ t.Fatal(err)
+ }
+ bundle := fileNamed(out, "route-proxy.bundle")
+ if bundle == nil || bundle["content"] != servedRoot {
+ t.Errorf("assigned %v: the consumer was not told the root", assigned)
+ }
+ }
+}
+
+// A same-node contribution names the port the MACHINE publishes, not the one the module declared.
+//
+// 04-ISSUES/038 fixed this for what a consumer is TOLD about a provider. This is the other
+// direction: what a workload TELLS the provider about itself. gitea declares a bare container port
+// 3000, the mesh publishes it as 20000:3000 — and gitea's route contribution still said 3000, so
+// the proxy beside it dialled a port nothing listens on and answered 502 for every request.
+//
+// The redirect uses the CONTRIBUTING module's assignment: the port belongs to the workload, and
+// using the provider's map would move it to wherever the proxy happens to be published.
+func TestASameNodeContributionNamesThePortTheMachinePublishes(t *testing.T) {
+ gitea := mcat.Manifest{
+ Module: "gitea", Version: "1",
+ Listens: []mcat.Listening{{Port: 3000, Protocol: "tcp", From: mcat.FromMesh}},
+ Contributes: map[string]map[string]any{"route": {"label": "git", "port": 3000}},
+ Resources: []map[string]any{{
+ "id": "server", "type": "container", "name": "gitea", "ports": []any{"3000"},
+ }},
+ }
+ r, err := Resolve(shelf(gitea, routeProxy(), stepCA()),
+ []string{"gitea", "route-proxy", "step-ca"}, reachable(), mcat.World{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ out, err := Declaration(r, mcat.Rendering{
+ // The machine put gitea's 3000 on 20000, and published it that way.
+ Ports: map[string]map[int]int{"gitea": {3000: 20000}},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Where the workload is actually published.
+ server := fileNamed(out, "gitea.server")
+ if published := strings.Join(asStrings(server["ports"]), ","); published != "20000:3000" {
+ t.Fatalf("the workload was not published on the assigned port: %v", server["ports"])
+ }
+
+ // What the proxy beside it was told to dial: the SAME port.
+ routes := fileNamed(out, "route-proxy.received-route")
+ if routes == nil {
+ t.Fatalf("the proxy was given no routes file at all: %v", out)
+ }
+ var given struct {
+ Given []mcat.Contribution `json:"given"`
+ }
+ if err := json.Unmarshal([]byte(routes["content"].(string)), &given); err != nil {
+ t.Fatal(err)
+ }
+ if len(given.Given) != 1 {
+ t.Fatalf("expected one route, got %v", given.Given)
+ }
+ port, ok := asPort(given.Given[0].Values["port"])
+ if !ok || port != 20000 {
+ t.Errorf("the proxy was told to dial a port nothing listens on: %v",
+ given.Given[0].Values["port"])
+ }
+}
+
+// A contribution from ANOTHER machine is left exactly as it was.
+//
+// Its port belongs to that machine's assignment, which this node's map knows nothing about —
+// applying this node's map to it would move a remote workload's port to wherever a local module of
+// the same name happens to be published, which is worse than the fault being fixed.
+func TestAContributionFromAnotherMachineKeepsItsOwnPort(t *testing.T) {
+ r, err := Resolve(shelf(routeProxy(), stepCA()),
+ []string{"route-proxy", "step-ca"}, reachable(), mcat.World{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ out, err := Declaration(r, mcat.Rendering{
+ // A same-named module on this machine, moved. The grant below is a laptop's, and must not
+ // be dragged along with it.
+ Ports: map[string]map[int]int{"gitea": {3000: 20000}},
+ Grants: []mcat.Grant{{
+ Provision: "route", Consumer: "laptop", From: "gitea", At: "laptop.internal",
+ Values: map[string]any{"name": "git.example", "port": 3000},
+ }},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ routes := fileNamed(out, "route-proxy.received-route")
+ var given struct {
+ Given []mcat.Contribution `json:"given"`
+ }
+ if err := json.Unmarshal([]byte(routes["content"].(string)), &given); err != nil {
+ t.Fatal(err)
+ }
+ if len(given.Given) != 1 {
+ t.Fatalf("expected one route, got %v", given.Given)
+ }
+ if port, _ := asPort(given.Given[0].Values["port"]); port != 3000 {
+ t.Errorf("another machine's contribution was rewritten with this machine's ports: %v",
+ given.Given[0].Values["port"])
+ }
+}
+
+// The cross-node half of the same fault: a grant assembled on the providing machine carries the
+// consumer's declared port until the CONSUMER's machine's assignments are applied to it. The
+// declaration layer cannot do it — those assignments are not this machine's, which is the line the
+// test above holds — so the grant layer is handed them and does it there.
+func TestAContributionIsRedirectedToWhereItsOwnMachinePublishedIt(t *testing.T) {
+ published := map[int]int{3000: 20000}
+
+ got := AtPublishedPort(map[string]any{"name": "git.example.tld", "port": 3000}, "forge", published)
+ if port, _ := asPort(got["port"]); port != 20000 {
+ t.Errorf("the proxy would dial a port that machine never published: %v", got["port"])
+ }
+ if got["name"] != "git.example.tld" {
+ t.Errorf("redirecting the port disturbed the rest of the contribution: %v", got)
+ }
+
+ // Idempotent, and silent about a port nobody moved: a module that pinned its own mapping has no
+ // assignment, and must come back exactly as it was written.
+ again := AtPublishedPort(got, "forge", published)
+ if port, _ := asPort(again["port"]); port != 20000 {
+ t.Errorf("applying it twice moved the port again: %v", again["port"])
+ }
+ pinned := AtPublishedPort(map[string]any{"port": 9070}, "office", published)
+ if port, _ := asPort(pinned["port"]); port != 9070 {
+ t.Errorf("a port the mesh never assigned was rewritten: %v", pinned["port"])
+ }
+}
+
+func asStrings(v any) []string {
+ listed, ok := v.([]any)
+ if !ok {
+ return nil
+ }
+ out := make([]string, 0, len(listed))
+ for _, one := range listed {
+ out = append(out, strings.TrimSpace(plainly(one)))
+ }
+ return out
+}
diff --git a/internal/compose/resolution/collections.go b/internal/compose/resolution/collections.go
new file mode 100644
index 0000000..472fc46
--- /dev/null
+++ b/internal/compose/resolution/collections.go
@@ -0,0 +1,312 @@
+package resolution
+
+import (
+ "fmt"
+ "regexp"
+ "slices"
+ "sort"
+ "strconv"
+ "strings"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+func itemKey(it mcat.Item) string { return mcat.ItemKey(it.Collection, it.Name) }
+
+// order is the item's place among the items of one placeholder: its `order` attribute, or OrderDefault.
+func itemOrder(it mcat.Item) int {
+ if n, err := strconv.Atoi(it.Attributes[mcat.OrderAttribute]); err == nil && n >= 0 && n <= 99 {
+ return n
+ }
+ return mcat.OrderDefault
+}
+
+// matches is whether the item's attributes carry every pair of the selector.
+func itemMatches(it mcat.Item, where map[string]string) bool {
+ for k, v := range where {
+ if got, has := it.Attributes[k]; !has || got != v {
+ return false
+ }
+ }
+ return true
+}
+
+// itemsInOrder is the module's items by order, then name; the order the old form placed a module's pieces in.
+func itemsInOrder(items []mcat.Item) []mcat.Item {
+ out := append([]mcat.Item(nil), items...)
+ sort.SliceStable(out, func(a, b int) bool {
+ if oa, ob := itemOrder(out[a]), itemOrder(out[b]); oa != ob {
+ return oa < ob
+ }
+ return out[a].Name < out[b].Name
+ })
+ return out
+}
+
+// oldCollectionNamespaces are the namespaces read as collections of their own name.
+var oldCollectionNamespaces = []string{"shell", "environment", "contribution"}
+
+// ofCollected is where a collector places a collection. Loose inside the braces, so a misspelt name is refused.
+var ofCollected = regexp.MustCompile(`\$\{collected:([^}]*)\}`)
+
+var attributeKey = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
+
+var attributeValue = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`)
+
+// splitSelector is a placeholder's name without its selector, and the selector: `widgets:bar=top,place=left` is
+// `widgets` and `bar=top,place=left`. A collection's name holds no `=`, so the selector begins at the last colon
+// before the first one.
+func splitSelector(name string) (collection, selector string) {
+ eq := strings.Index(name, "=")
+ if eq < 0 {
+ return name, ""
+ }
+ colon := strings.LastIndex(name[:eq], ":")
+ if colon < 0 {
+ return name, ""
+ }
+ return name[:colon], name[colon+1:]
+}
+
+// selectorOf is a selector's pairs, or why it is none.
+func selectorOf(selector string) (map[string]string, error) {
+ out := map[string]string{}
+ if selector == "" {
+ return out, nil
+ }
+ for _, pair := range strings.Split(selector, ",") {
+ k, v, ok := strings.Cut(pair, "=")
+ switch {
+ case !ok || !attributeKey.MatchString(k) || !attributeValue.MatchString(v):
+ return nil, fmt.Errorf("%q in %q is not =", pair, selector)
+ case out[k] != "":
+ return nil, fmt.Errorf("%q names %s twice", selector, k)
+ }
+ out[k] = v
+ }
+ return out, nil
+}
+
+// collectionOfPlaceholder is the collection a placeholder's namespace and key name, and whether they name one.
+func collectionOfPlaceholder(namespace, key string) (string, bool) {
+ key, _ = splitSelector(key)
+ switch {
+ case namespace == mcat.CollectedNamespace && strings.HasPrefix(key, mcat.EnvironmentCollection+":"):
+ // The environment is one collection, and the rest of the name picks the rendering (issue 795).
+ return mcat.EnvironmentCollection, true
+ case namespace == mcat.CollectedNamespace:
+ return key, true
+ case namespace == "environment":
+ return mcat.EnvironmentCollection, true
+ case slices.Contains(oldCollectionNamespaces, namespace):
+ return namespace + ":" + key, true
+ }
+ return "", false
+}
+
+// oldCollection is an old namespace's collection by its name: its comment, or why no item may be contributed to it.
+// ok is false for a name that is not an old namespace's.
+//
+// forItem is whether an item is to be contributed to it: an item may be contributed to a kind received as data,
+// which a collector may place as a collection only where its shape says its pieces are text (design 57 L3).
+func oldCollection(name string, forItem bool) (comment string, old bool, wrong error) {
+ namespace, key, _ := strings.Cut(name, ":")
+ if !slices.Contains(oldCollectionNamespaces, namespace) {
+ return "", false, nil
+ }
+ switch namespace {
+ case "shell":
+ target, slot, two := strings.Cut(key, ":")
+ if !two || !oneOf(contributionTargets(), target) || !oneOf(knownSlots, slot) {
+ return "", true, fmt.Errorf("%s is no slot: shell code is shell::, the shell one of %s, "+
+ "the session's file one of %s or the power moment one of %s, and the slot one of %s", name,
+ strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", "), strings.Join(powerMoments, ", "),
+ strings.Join(knownSlots, ", "))
+ }
+ // shellCode's line before each module's code.
+ return "#", true, nil
+ case "environment":
+ if key == "" {
+ // The collection itself, which an item is contributed to; a placeholder names a rendering.
+ return "#", true, nil
+ }
+ if key != mcat.EnvironmentPOSIX && key != mcat.EnvironmentSystemd {
+ return "", true, fmt.Errorf("%s is no format: the environment is environment:%s or environment:%s",
+ name, mcat.EnvironmentPOSIX, mcat.EnvironmentSystemd)
+ }
+ return "#", true, nil
+ }
+ seat, kind, _ := strings.Cut(key, ":")
+ s, r, ok := receivable(seat, kind)
+ switch {
+ case !ok && s.Name == "":
+ return "", true, fmt.Errorf("%s names the seat %q, which the mesh does not define", name, seat)
+ case !ok:
+ return "", true, fmt.Errorf("%s: %s; %s", name, s.Name, kindsOf(s))
+ case r.Shape != nil && (forItem || collectableAsText(r)):
+ return r.Comment, true, nil
+ case r.Shape != nil:
+ return "", true, fmt.Errorf("%s is received by %s as data in its own shape, which no item of text is "+
+ "(novox/hq ADR 0255)", name, s.Name)
+ case r.Dirs:
+ return "", true, fmt.Errorf("%s names its contributors' directories, which the holder reads as paths "+
+ "the controller fills; it is not yet a collection (design 57 L1)", name)
+ case s.Name != canonicalSeat(seat):
+ return "", true, fmt.Errorf("%s names the seat by another name; a collection names it %s", name, s.Name)
+ }
+ return r.Comment, true, nil
+}
+
+// isOld is whether a collection's name is an old namespace's.
+func isOld(name string) bool {
+ _, old, _ := oldCollection(name, true)
+ return old
+}
+
+// collectedPlaceholderProblems is what is wrong with one resource's ${collected:…}: placed only in a file's content,
+// and naming a collection the module declares it collects.
+func collectedPlaceholderProblems(m mcat.Manifest, r map[string]any) []string {
+ var problems []string
+ for _, field := range sortedKeys(r) {
+ v, ok := r[field].(string)
+ if !ok {
+ continue
+ }
+ for _, f := range ofCollected.FindAllStringSubmatch(v, -1) {
+ base, selector := splitSelector(f[1])
+ if _, err := selectorOf(selector); err != nil {
+ problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], err))
+ continue
+ }
+ switch {
+ case field != "content":
+ problems = append(problems, fmt.Sprintf("%s's resource %v names %s in its %s; a collection is placed "+
+ "only in a file's content", m.Module, r["id"], f[0], field))
+ case isOld(base):
+ if _, _, wrong := oldCollection(base, false); wrong != nil {
+ problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], wrong))
+ }
+ case !slices.ContainsFunc(m.Collections, func(c mcat.Collection) bool { return c.Name == base }):
+ problems = append(problems, fmt.Sprintf("%s's resource %v names %s, and %s does not collect %q: "+
+ "declare it under collections (novox/hq ADR 0349 §13)", m.Module, r["id"], f[0], m.Module, base))
+ }
+ }
+ }
+ return problems
+}
+
+// collectionComment is the comment of a collection a module's file names: declared, or the old namespace's.
+func collectionComment(m mcat.Manifest, name string) (string, error) {
+ if comment, old, wrong := oldCollection(name, false); old {
+ return comment, wrong
+ }
+ for _, c := range m.Collections {
+ if c.Name == name {
+ return c.Comment, nil
+ }
+ }
+ return "", fmt.Errorf("%s names ${collected:%s} and does not collect it", m.Module, name)
+}
+
+// itemsLeftOutFor is itemsLeftOut for a placeholder that selects by attributes (where), on a machine with these
+// capabilities: an item the selector does not take, or whose capability the machine lacks, is not left out but not
+// wanted here.
+func itemsLeftOutFor(modules []mcat.Manifest, holder mcat.Manifest, collection string, where map[string]string,
+ facts map[string]string, caps map[string]bool) []string {
+ var out []string
+ for _, m := range inModuleOrder(modules) {
+ for _, it := range itemsInOrder(m.Items) {
+ if it.Collection != collection || !itemMatches(it, where) || (it.IfCapability != "" && !caps[it.IfCapability]) {
+ continue
+ }
+ if _, rendered := heldText(it, facts); rendered && renderedOldWay(collection) {
+ continue
+ }
+ out = append(out, fmt.Sprintf("%s's item of %s is not placed: %s is filled the old way on this "+
+ "machine, from contributions, and an item only by the provisioner's client (design 57 L1)",
+ m.Module, itemKey(it), holder.Module))
+ }
+ }
+ return out
+}
+
+// oldFormsLeftOut says each piece of an old form a handed collector's collections would have held: the collector is
+// filled from items alone, and the piece's module contributes none (ADR 0349 clause 17).
+func oldFormsLeftOut(modules []mcat.Manifest, holder mcat.Manifest, collections []mcat.PlanCollection) []string {
+ var out []string
+ for _, c := range collections {
+ namespace, key, _ := strings.Cut(c.Name, ":")
+ for _, m := range inModuleOrder(modules) {
+ old := false
+ switch namespace {
+ case "shell":
+ target, slot, _ := strings.Cut(key, ":")
+ old = slices.ContainsFunc(m.Shell, func(s mcat.ShellCode) bool { return s.For == target && s.Slot == slot })
+ case "environment":
+ old = m.Environment != nil && (len(m.Environment.Variables) > 0 || len(m.Environment.Path) > 0)
+ case "contribution":
+ seat, kind, _ := strings.Cut(key, ":")
+ old = slices.ContainsFunc(manifestAllContributions(m), func(s mcat.SeatContribution) bool {
+ return canonicalSeat(s.Seat) == canonicalSeat(seat) && s.Kind == kind
+ })
+ }
+ if old {
+ out = append(out, fmt.Sprintf("%s's %s is not placed: %s is filled by the provisioner's client on "+
+ "this machine, from items, and %s contributes it the old way (design 57 L1)", m.Module, c.Name,
+ holder.Module, m.Module))
+ }
+ }
+ }
+ return out
+}
+
+// renderedOldWay is whether the controller renders a collection's items into a collector not handed.
+func renderedOldWay(collection string) bool {
+ namespace, _, _ := strings.Cut(collection, ":")
+ return namespace == "shell" || namespace == "contribution" || namespace == mcat.EnvironmentCollection
+}
+
+// heldItem is one item of a module rendered the old way.
+type heldItem struct {
+ item mcat.Item
+ text string
+}
+
+// heldItems is a module's items of the collection that the selector takes and the machine's capabilities allow, by
+// order then name, each rendered as heldText does; the ones it cannot render are left out (itemsLeftOutFor says so).
+func heldItems(m mcat.Manifest, collection string, where map[string]string, facts map[string]string,
+ caps map[string]bool) []heldItem {
+ if !renderedOldWay(collection) {
+ return nil
+ }
+ var out []heldItem
+ for _, it := range itemsInOrder(m.Items) {
+ if it.Collection != collection || !itemMatches(it, where) || (it.IfCapability != "" && !caps[it.IfCapability]) {
+ continue
+ }
+ if text, ok := heldText(it, facts); ok {
+ out = append(out, heldItem{item: it, text: text})
+ }
+ }
+ return out
+}
+
+// heldText is an item's text with the machine's facts filled, ending its line; false for an item whose text the mesh
+// does not hold or which names anything but a machine fact this machine has.
+func heldText(it mcat.Item, facts map[string]string) (string, bool) {
+ if it.Content == "" {
+ return "", false
+ }
+ text := it.Content
+ for _, f := range templatePlaceholder.FindAllStringSubmatch(text, -1) {
+ value, has := facts[f[2]]
+ if f[1] != "machine" || !has {
+ return "", false
+ }
+ text = strings.ReplaceAll(text, f[0], value)
+ }
+ if !strings.HasSuffix(text, "\n") {
+ text += "\n"
+ }
+ return text, true
+}
diff --git a/internal/compose/resolution/collections_bar_test.go b/internal/compose/resolution/collections_bar_test.go
new file mode 100644
index 0000000..c00debd
--- /dev/null
+++ b/internal/compose/resolution/collections_bar_test.go
@@ -0,0 +1,110 @@
+package resolution
+
+import (
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+const collectedBarsText = "top\n${collected:contribution:node-bar:block:bar=top,place=status}" +
+ "bottom\n${collected:contribution:node-bar:block:bar=bottom,place=status}"
+
+const collectedBars = `{"module":"bars","version":"1","moved":true,
+ "claims":[{"name":"node-bar","scope":"node","renders":{"block":"{{.shows}}"}}],
+ "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
+ "resources":[{"id":"conf","type":"file","path":"/etc/bars.conf","template":"files/bars"}]}`
+
+const itemsOfPower = `{"module":"power","version":"1","moved":true,
+ "items":[{"collection":"contribution:node-bar:block","name":"battery","if-capability":"battery","attributes":{"bar":"bottom","place":"status","order":"40"},"template":"files/battery"},
+ {"collection":"contribution:node-bar:block","name":"draw","attributes":{"bar":"bottom","place":"status","order":"45"},"template":"files/draw"}],
+ "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`
+
+func collectedBarsOn(t *testing.T, caps map[string]bool) mcat.Resolution {
+ t.Helper()
+ bars := held(t, collectedBars, builtWith("bars", map[string]string{"bars": collectedBarsText}))
+ power := held(t, itemsOfPower, builtWith("power", map[string]string{"battery": "B\n", "draw": "D ${machine:name}\n"}))
+ r, err := Resolve(shelf(bars, power), []string{"bars", "power"}, workstation(), mcat.World{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ r.Account, r.Capabilities = "op", caps
+ return r
+}
+
+func TestACollectorMayNameTheBarsBlocksAsACollectionAndNoOtherDataKind(t *testing.T) {
+ bars := held(t, collectedBars, builtWith("bars", map[string]string{"bars": collectedBarsText}))
+ if _, err := Compose(collectedBarsOn(t, nil), handingTo("store:5000")); err != nil {
+ t.Fatalf("the handed collector is refused: %v", err)
+ }
+ if got := collectedPlaceholderProblems(bars, map[string]any{"id": "x",
+ "content": "${collected:contribution:node-bar:block:bar=top,place=status}"}); len(got) != 0 {
+ t.Fatalf("the check refuses it: %v", got)
+ }
+ // A data kind whose shape does not say its items are text stays refused.
+ _, plain, _ := receivable(mcat.BarSeat, mcat.BarKindBlock)
+ plain.Shape = &mcat.Shape{}
+ if collectableAsText(plain) {
+ t.Fatal("a shape not marked as text is collectable")
+ }
+ if _, _, err := oldCollection("contribution:node-bar:block", false); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// The bars of the catalogue's i3status-rust, with the blocks of power's items, compose to the files the old shaped
+// contribution made (issue 781). testdata/bars holds the catalogue's bars and block texts as of the move and the bars as
+// composed before it, for a machine with neither capability, the battery, the power meter and both. Where the
+// collector is not handed the controller fills the files; where it is, it names the items for the client, which
+// fills the same files (mesh-catalog's provisioner client holds that half against the same golden files).
+func TestTheBarsOfTheCatalogueAreTheSameHandedOrNot(t *testing.T) {
+ read := func(name string) string {
+ b, err := os.ReadFile(filepath.Join("testdata", "bars", name))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(b)
+ }
+ collector := `{"module":"bars","version":"1","moved":true,"claims":[{"name":"node-bar","scope":"node"}],
+ "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
+ "resources":[{"id":"top","type":"file","path":"/t","template":"files/top"},
+ {"id":"bottom","type":"file","path":"/b","template":"files/bottom"}]}`
+ power := `{"module":"power","version":"1","moved":true,
+ "items":[{"collection":"contribution:node-bar:block","name":"battery","if-capability":"battery","attributes":{"bar":"bottom","place":"status","order":"40"},"template":"files/battery"},
+ {"collection":"contribution:node-bar:block","name":"power-draw","if-capability":"power-meter","attributes":{"bar":"bottom","place":"status","order":"45"},"template":"files/draw"}],
+ "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`
+ for name, caps := range map[string]map[string]bool{"none": nil, "battery": {"battery": true},
+ "power-meter": {"power-meter": true}, "both": {"battery": true, "power-meter": true}} {
+ bars := held(t, collector, builtWith("bars", map[string]string{"top": read("top-bar.toml"), "bottom": read("bottom-bar.toml")}))
+ blocks := held(t, power, builtWith("power", map[string]string{"battery": read("bar-battery"), "draw": read("bar-power-draw")}))
+ r, err := Resolve(shelf(bars, blocks), []string{"bars", "power"}, workstation(), mcat.World{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ r.Account, r.Capabilities = "op", caps
+ // Not handed: the controller fills them.
+ for id, which := range map[string]string{"bars.top": "top", "bars.bottom": "bottom"} {
+ if got := composedFile(t, r, id); got != read(name+"."+which+"-bar.toml") {
+ t.Errorf("%s, %s bar, not handed:\n%s\nwant\n%s", name, which, got, read(name+"."+which+"-bar.toml"))
+ }
+ }
+ // Handed: the plan names the items the machine's capabilities allow, for the client to fill.
+ c, _ := composeWith(t, r, handingTo("store:5000"))
+ var names []string
+ for _, it := range c.Plans["power"].Items {
+ names = append(names, it.Name)
+ }
+ want := []string{}
+ if caps["battery"] {
+ want = append(want, "battery")
+ }
+ if caps["power-meter"] {
+ want = append(want, "power-draw")
+ }
+ if !reflect.DeepEqual(append([]string{}, names...), want) {
+ t.Errorf("%s: the plan names %v, want %v", name, names, want)
+ }
+ }
+}
diff --git a/internal/compose/resolution/collections_l2_test.go b/internal/compose/resolution/collections_l2_test.go
new file mode 100644
index 0000000..cda4d55
--- /dev/null
+++ b/internal/compose/resolution/collections_l2_test.go
@@ -0,0 +1,57 @@
+package resolution
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+func l2Moved(t *testing.T, parts ...string) string {
+ t.Helper()
+ raw, err := os.ReadFile(filepath.Join(append([]string{"testdata", "l2-moved"}, parts...)...))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(raw)
+}
+
+func TestTheCollectorsL2MovedComposeAsBeforeWhereNothingIsHanded(t *testing.T) {
+ // Each moved contributor, its items read back from its templates archive as the mesh holds them.
+ items := map[string][]string{
+ "asus-zephyrus-g14": {"shell:after-wake:normal", "contribution:node-hotkeys:trigger"},
+ "claude-code": {"shell:zsh:normal"},
+ "mesh-cli": {"shell:zsh:normal"},
+ "powerlevel10k": {"shell:zsh:normal"},
+ "zsh-autosuggestions": {"shell:zsh:normal"},
+ "zsh-syntax-highlighting": {"shell:zsh:last"},
+ }
+ var modules []mcat.Manifest
+ for _, module := range sortedKeys(items) {
+ m := mcat.Manifest{Module: module, Moved: true}
+ for _, collection := range items[module] {
+ m.Items = append(m.Items, mcat.Item{Collection: collection,
+ Content: l2Moved(t, "items", module+"."+strings.ReplaceAll(collection, ":", "-"))})
+ }
+ modules = append(modules, m)
+ }
+ for _, c := range []struct{ module, seat string }{{"zsh", mcat.LoginShellSeat}, {"triggerhappy", mcat.HotkeysSeat},
+ {"power", mcat.PowerSeat}} {
+ holder := mcat.Manifest{Module: c.module, Moved: true, Claims: []mcat.Claim{{Name: c.seat}}}
+ resource := map[string]any{"id": "file", "type": "file", "path": "/etc/" + c.module,
+ "content": l2Moved(t, "templates", c.module)}
+ var unplaced []string
+ if err := contributionsInto(resource, holder, append([]mcat.Manifest{holder}, modules...),
+ map[string]string{"name": "g14"}, mcat.Rendering{}, map[string]bool{}, &unplaced); err != nil {
+ t.Fatalf("%s: %v", c.module, err)
+ }
+ if got, want := resource["content"], l2Moved(t, "want", c.module); got != want {
+ t.Errorf("%s's file is not what the old forms made:\n%s\nwant:\n%s", c.module, got, want)
+ }
+ if len(unplaced) != 0 {
+ t.Errorf("%s: unplaced %v", c.module, unplaced)
+ }
+ }
+}
diff --git a/internal/compose/resolution/collections_l3_test.go b/internal/compose/resolution/collections_l3_test.go
new file mode 100644
index 0000000..8b66048
--- /dev/null
+++ b/internal/compose/resolution/collections_l3_test.go
@@ -0,0 +1,36 @@
+package resolution
+
+import (
+ "strings"
+ "testing"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+const barCollector = `{"module":"bars","version":"1","moved":true,
+ "collections":[{"name":"blocks","comment":"#"}],
+ "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
+ "resources":[{"id":"conf","type":"file","path":"/etc/bars.conf","template":"files/bars"}]}`
+
+func TestASelectorIsRefusedWhenMalformedOrNamingACollectionNotCollected(t *testing.T) {
+ for placeholder, want := range map[string]string{
+ "${collected:blocks:bar}": "", // no equals sign: a collection named blocks:bar
+ "${collected:blocks:bar=top,bar=bottom}": "names bar twice",
+ "${collected:blocks:bar=}": "is not =",
+ "${collected:other:bar=top}": `does not collect "other"`,
+ } {
+ raw := strings.Replace(barCollector, `"template":"files/bars"`, `"content":"`+placeholder+`"`, 1)
+ raw = strings.Replace(raw, `"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},`, "", 1)
+ raw = strings.Replace(raw, `"moved":true,`, "", 1)
+ m, err := decodeForTest([]byte(raw))
+ if err == nil {
+ _, err = Declaration((mcat.Resolution{Node: "n", Modules: []mcat.Manifest{m}}), mcat.Rendering{})
+ }
+ if want == "" {
+ continue
+ }
+ if err == nil || !strings.Contains(err.Error(), want) {
+ t.Errorf("%s: %v, want %q", placeholder, err, want)
+ }
+ }
+}
diff --git a/internal/compose/resolution/collections_test.go b/internal/compose/resolution/collections_test.go
new file mode 100644
index 0000000..006624d
--- /dev/null
+++ b/internal/compose/resolution/collections_test.go
@@ -0,0 +1,13 @@
+package resolution
+
+import (
+ "strings"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+func builtWith(module string, texts map[string]string) []mcat.Built {
+ return []mcat.Built{{Name: "files", Kind: mcat.ArtifactArchive,
+ Reference: mcat.ArtifactStoreScheme + module + "/files/blobs/sha256:" + strings.Repeat("e", 64),
+ Digest: "sha256:" + strings.Repeat("e", 64), Texts: texts}}
+}
diff --git a/internal/compose/resolution/computed_test.go b/internal/compose/resolution/computed_test.go
new file mode 100644
index 0000000..ed7e4f7
--- /dev/null
+++ b/internal/compose/resolution/computed_test.go
@@ -0,0 +1,184 @@
+package resolution
+
+import (
+ "fmt"
+ "strings"
+ "testing"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+type fake struct {
+ on map[string]bool
+ asked []string
+ err error
+}
+
+func (f *fake) Resources(node string) ([]map[string]any, bool, error) {
+ f.asked = append(f.asked, node)
+ if f.err != nil {
+ return nil, false, f.err
+ }
+ if !f.on[node] {
+ return nil, false, nil
+ }
+ return []map[string]any{{"id": "peers", "type": "file", "path": "/etc/x.conf",
+ "merge": mcat.MergeJSON, "content": `{"peer":"` + node + `"}`}}, true, nil
+}
+
+func computedShelf() map[string]mcat.Manifest {
+ return shelf(mcat.Manifest{Module: "mesh-network", Computed: "mesh-network",
+ Provides: mcat.Offers("private-network")})
+}
+
+func TestAComputedModuleIsAskedAboutTheNodeItIsFor(t *testing.T) {
+ // The generator gets a node name, not a plan. Everything it needs is the whole mesh, which
+ // it was built with — this is the one thing a node could never work out for itself.
+ got, err := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), mcat.World{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ gen := &fake{on: map[string]bool{"workstation": true}}
+ out, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": gen}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(gen.asked) != 1 || gen.asked[0] != "workstation" {
+ t.Fatalf("asked about %v, wanted workstation once", gen.asked)
+ }
+ if len(out) != 1 || !strings.Contains(out[0]["content"].(string), `"peer": "workstation"`) {
+ t.Fatalf("got %v", out)
+ }
+}
+
+func TestAMachineNobodyGaveItGetsNothing(t *testing.T) {
+ // The whole point of making the network a module. Before this, every machine with an address
+ // was on the private network and there was no way to say one should stay off.
+ got, err := Resolve(computedShelf(), nil, workstation(), mcat.World{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ gen := &fake{on: map[string]bool{"workstation": true}}
+ out, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": gen}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(out) != 0 {
+ t.Fatalf("a machine that was assigned nothing got %d resource(s)", len(out))
+ }
+ if len(gen.asked) != 0 {
+ t.Fatalf("the generator was asked about %v, and nobody had asked for it", gen.asked)
+ }
+}
+
+func TestAssignedAndNotYetPartOfItIsNotAFailure(t *testing.T) {
+ // A machine given the network module before it has a place on it. Brief and ordinary — the
+ // answer is "nothing yet", and treating it as an error would make an ordering a fault.
+ got, _ := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), mcat.World{})
+ gen := &fake{on: map[string]bool{}}
+ out, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": gen}})
+ if err != nil {
+ t.Fatalf("refused a node that is not on the network yet: %v", err)
+ }
+ if len(out) != 0 {
+ t.Fatalf("got %v", out)
+ }
+}
+
+func TestAGeneratorThisControlPlaneDoesNotHaveIsRefused(t *testing.T) {
+ // Sending a machine a module with no files would look like it worked. Named in the message,
+ // because the only fix is a control plane that has it.
+ got, _ := Resolve(shelf(mcat.Manifest{Module: "weather", Computed: "the-weather"}), []string{"weather"}, workstation(), mcat.World{})
+
+ _, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": &fake{}}})
+ if err == nil {
+ t.Fatal("a module computed by nothing was accepted")
+ }
+ if !strings.Contains(err.Error(), "the-weather") {
+ t.Fatalf("the refusal does not name what is missing: %v", err)
+ }
+}
+
+func TestSettingsApplyToAComputedModuleToo(t *testing.T) {
+ // Being computed is about where the files come from, not about whether they are configurable.
+ // A line drawn there would be arbitrary and nobody could predict it.
+ got, _ := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), mcat.World{})
+ gen := &fake{on: map[string]bool{"workstation": true}}
+ out, err := Declaration(got, mcat.Rendering{
+ Generators: map[string]mcat.Generator{"mesh-network": gen},
+ Settings: mcat.SettingsBy{"mesh-network": {{From: "the mesh",
+ Values: map[string]any{"keepalive": 25}}}},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ content := out[0]["content"].(string)
+ if !strings.Contains(content, `"keepalive": 25`) {
+ t.Fatalf("the setting did not reach a computed file: %s", content)
+ }
+ if !strings.Contains(content, `"peer": "workstation"`) {
+ t.Fatalf("the setting replaced what the generator computed: %s", content)
+ }
+}
+
+func TestTwoWaysToBeOnAPrivateNetworkRefuseAndNameBoth(t *testing.T) {
+ // WireGuard is one way. The refusing rule is what makes a second one safe to add: assigning
+ // both is caught rather than producing a machine on two networks that each half-work.
+ _, err := Resolve(shelf(mcat.Manifest{Module: "mesh-network", Computed: "mesh-network",
+ Provides: mcat.Offers("private-network")}, mcat.Manifest{Module: "tailscale", Provides: mcat.Offers("private-network")}, mcat.Manifest{Module: "backups", Requires: []string{"private-network"}},
+ ), []string{"backups"}, workstation(), mcat.World{})
+
+ if err == nil {
+ t.Fatal("two answers to one requirement were taken silently")
+ }
+ for _, want := range []string{"mesh-network", "tailscale", "private-network"} {
+ if !strings.Contains(err.Error(), want) {
+ t.Fatalf("the refusal does not name %s: %v", want, err)
+ }
+ }
+}
+
+// reloading answers the runtime's trust as the networking module once did (novox/hq ADR 0102; it no
+// longer does, ADR 0222 — and beside the runtime's module it is refused, generated_collision_test): a file
+// written into, and the runtime reloaded on it.
+type reloading struct{}
+
+func (reloading) Resources(node string) ([]map[string]any, bool, error) {
+ return []map[string]any{
+ {"id": "registry-trust", "type": "file", "path": "/etc/docker/daemon.json",
+ "merge": mcat.MergeJSON, "into": "json", "content": `{"insecure-registries":["r:5000"]}`},
+ {"id": "registry-trust-reload", "type": "service", "unit": "docker.service",
+ "state": "running", "reload-on": []string{"registry-trust"}},
+ }, true, nil
+}
+
+func TestWhatAServiceIsReloadedOnIsNamedAsTheHostWillSeeIt(t *testing.T) {
+ // Ids are prefixed with their module on the way out. An unprefixed reload-on would name a
+ // resource the host never sees, and the runtime would never be reloaded for its trust.
+ got, err := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), mcat.World{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ out, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": reloading{}}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var file, service map[string]any
+ for _, r := range out {
+ switch r["type"] {
+ case "file":
+ file = r
+ case "service":
+ service = r
+ }
+ }
+ if file == nil || service == nil {
+ t.Fatalf("got %v", out)
+ }
+ if file["into"] != "json" {
+ t.Errorf("into did not reach the host: %v", file)
+ }
+ if want := "[" + file["id"].(string) + "]"; fmt.Sprint(service["reload-on"]) != want {
+ t.Errorf("reload-on names %v, the file is %v", service["reload-on"], file["id"])
+ }
+}
diff --git a/internal/compose/resolution/config_test.go b/internal/compose/resolution/config_test.go
new file mode 100644
index 0000000..d731c9d
--- /dev/null
+++ b/internal/compose/resolution/config_test.go
@@ -0,0 +1,181 @@
+package resolution
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "strings"
+ "testing"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+// inConfigForm is a module in the config form that registers: every value typed, described, defaulted or
+// required, placed in a file or read at runtime, and its mergeable file naming its keys.
+func inConfigForm() map[string]any {
+ return map[string]any{
+ "module": "notifier",
+ "version": "1",
+ "config": map[string]any{
+ "font-size": map[string]any{"type": "number", "description": "the notification's font size, in points",
+ "minimum": 6, "maximum": 40, "default": 10},
+ "width": map[string]any{"type": "number", "description": "the notification's width, in pixels",
+ "default": 250},
+ "position": map[string]any{"type": "string", "description": "the corner it shows in, a word",
+ "enum": []any{"top-right", "top-left"}, "default": "top-right"},
+ "theme": map[string]any{"type": "string", "description": "the colour theme, a lower-case name",
+ "pattern": "[a-z]+", "default": "dark"},
+ "history": map[string]any{"type": "number", "description": "how many notifications it keeps, a count",
+ "default": 20, "runtime": true},
+ "sender": map[string]any{"type": "string", "description": "the address it mails a digest from",
+ "required": true},
+ },
+ "resources": []any{
+ map[string]any{"id": "configuration", "type": "file", "path": "/x/notifierrc",
+ "content": "font = Inter ${config:font-size}\nwidth = ${config:width}\nfrom = ${config:sender}\n"},
+ map[string]any{"id": "rules", "type": "file", "path": "/x/rules.json", "merge": "json",
+ "takes-config": []any{"position", "theme"}, "content": "{\"position\": \"top-right\"}\n"},
+ map[string]any{"id": "notifier", "type": "service", "unit": "notifier.service",
+ "reload-on": []any{"configuration"}, "restart-on": []any{"rules"}},
+ },
+ }
+}
+
+func parseRaw(t *testing.T, m map[string]any) (mcat.Manifest, error) {
+ t.Helper()
+ raw, err := json.Marshal(m)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return decodeForTest(raw)
+}
+
+// settingsDunst is the dunst module as it is written today; configDunst the same module rewritten from settings to
+// config.
+const settingsDunst = `{"module": "dunst", "version": "1",
+ "settings": {
+ "font-size": {"kind": "preference", "default": 10, "why": "10 points of Inter reads well on a screen of about 100 DPI"},
+ "width": {"kind": "preference", "default": 250, "why": "250 pixels fits a title of about forty characters at 10 points"}
+ },
+ "resources": [
+ {"id": "package", "type": "package", "package": "dunst"},
+ {"id": "configuration", "type": "file", "path": "/home/me/.config/dunst/dunstrc", "mode": "0644",
+ "content": "[global]\n font = Inter ${setting:font-size}\n width = ${setting:width}\n"},
+ {"id": "rules", "type": "file", "path": "/home/me/.config/dunst/rules.json", "merge": "json",
+ "takes-settings": [], "content": "{\"urgency\": \"low\"}\n"},
+ {"id": "notifier", "type": "service", "unit": "dunst.service", "reload-on": ["configuration"]}
+ ]}`
+
+const configDunst = `{"module": "dunst", "version": "1",
+ "config": {
+ "font-size": {"type": "number", "description": "the font size of a notification, in points", "minimum": 6,
+ "maximum": 40, "default": 10},
+ "width": {"type": "number", "description": "the width of a notification, in pixels", "minimum": 100,
+ "default": 250}
+ },
+ "resources": [
+ {"id": "package", "type": "package", "package": "dunst"},
+ {"id": "configuration", "type": "file", "path": "/home/me/.config/dunst/dunstrc", "mode": "0644",
+ "content": "[global]\n font = Inter ${config:font-size}\n width = ${config:width}\n"},
+ {"id": "rules", "type": "file", "path": "/home/me/.config/dunst/rules.json", "merge": "json",
+ "takes-config": [], "content": "{\"urgency\": \"low\"}\n"},
+ {"id": "notifier", "type": "service", "unit": "dunst.service", "reload-on": ["configuration"]}
+ ]}`
+
+// ${config:} fills exactly as ${setting:} does: the module rewritten from settings to config composes
+// to the same bytes, with no layer, with the node's, and with the mesh's under the node's.
+func TestAModuleRewrittenFromSettingsToConfigComposesByteIdentically(t *testing.T) {
+ composed := func(manifest string, layers []mcat.Layer) []byte {
+ t.Helper()
+ m, err := decodeForTest([]byte(manifest))
+ if err != nil {
+ t.Fatal(err)
+ }
+ r := anAdoptedAnchor()
+ r.Modules = append(r.Modules, m)
+ with := anchorRendering(false)
+ if layers != nil {
+ with.Settings["dunst"] = layers
+ }
+ c, err := Compose(r, with)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if why, left := c.LeftOut["dunst"]; left {
+ t.Fatalf("dunst was left out: %s", why)
+ }
+ raw, err := json.Marshal(c)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return raw
+ }
+ for _, layers := range [][]mcat.Layer{
+ nil,
+ {{From: "anchor", Values: map[string]any{"font-size": float64(13)}}},
+ {{From: mcat.MeshWideLayer, Values: map[string]any{"width": float64(300)}},
+ {From: "anchor", Values: map[string]any{"font-size": float64(12), "width": float64(320)}}},
+ } {
+ was, is := composed(settingsDunst, layers), composed(configDunst, layers)
+ if !bytes.Equal(was, is) {
+ t.Fatalf("composed differently with %v:\nsettings: %s\nconfig: %s", layers, was, is)
+ }
+ if !strings.Contains(string(is), "font = Inter 1") || strings.Contains(string(is), "${config:") {
+ t.Fatalf("not filled: %s", is)
+ }
+ }
+}
+
+// A required value nothing sets is refused at composition in the config form's words: ${config:} and the
+// config verb, never `settings set` (review of mesh-controller#320). The settings form's words are unchanged.
+func TestAnUnsetRequiredValueIsRefusedInTheConfigFormsWords(t *testing.T) {
+ m, err := parseRaw(t, inConfigForm())
+ if err != nil {
+ t.Fatal(err)
+ }
+ r := anAdoptedAnchor()
+ r.Modules = append(r.Modules, m)
+ composed, err := Compose(r, anchorRendering(false))
+ if err != nil {
+ t.Fatal(err)
+ }
+ why, left := composed.LeftOut["notifier"]
+ if !left {
+ t.Fatal("a module whose required value nothing sets was not left out")
+ }
+ for _, said := range []string{"${config:sender}", `required config value "sender"`, "config verb"} {
+ if !strings.Contains(why, said) {
+ t.Fatalf("left out without %q: %s", said, why)
+ }
+ }
+ for _, unsaid := range []string{"${setting:", "settings set"} {
+ if strings.Contains(why, unsaid) {
+ t.Fatalf("left out in the settings form's words (%q): %s", unsaid, why)
+ }
+ }
+ var unset *UnsetSettingError
+ if err := unsetInConfigWords(settingInto(map[string]any{"type": "file", "content": "${setting:sender}"}, nil,
+ "notifier"), m); !errors.As(err, &unset) || unset.Setting != "sender" {
+ t.Fatalf("not typed as a value nobody gave: %v", err)
+ }
+
+ // A contribution's value too (issue 1126, seed-8: de-spiegel's route names were refused with ${setting:…}).
+ withRoute := inConfigForm()
+ withRoute["contributes"] = map[string]any{"route": map[string]any{"site": map[string]any{"name": "${config:sender}", "port": 80}}}
+ routed, err := parseRaw(t, withRoute)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = resolutionComposed(mcat.Resolution{}, routed, "route", routed.ContributesMany["route"]["site"], nil, "notifier contributing site to route")
+ if !errors.As(err, &unset) || unset.Module != "notifier" || !strings.Contains(err.Error(),
+ `notifier has a contribution site to route, in "name", that says ${config:sender}`) || strings.Contains(err.Error(), "${setting:") {
+ t.Fatalf("a contribution's unset value is not said in the config form's words: %v", err)
+ }
+
+ settingsForm := notifier()
+ settingsForm.Resources[0]["content"] = "from = ${setting:domain}\n"
+ err = unsetInConfigWords(settingInto(settingsForm.Resources[0], nil, "notifier"), settingsForm)
+ if err == nil || !strings.Contains(err.Error(), "`settings set notifier `") {
+ t.Fatalf("the settings form's refusal changed: %v", err)
+ }
+}
diff --git a/internal/compose/resolution/consumer_into_serves.go b/internal/compose/resolution/consumer_into_serves.go
new file mode 100644
index 0000000..abaaf55
--- /dev/null
+++ b/internal/compose/resolution/consumer_into_serves.go
@@ -0,0 +1,267 @@
+package resolution
+
+import (
+ "fmt"
+ "regexp"
+ "sort"
+ "strings"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+// consumerFact is `${consumer:}` or `${consumer::}`.
+var consumerFact = regexp.MustCompile(`\$\{consumer:([a-z][a-z0-9-]*)(?::([a-z][a-z0-9-]*))?\}`)
+
+// consumerFacts are what a served value may name about the consumer it is being derived for.
+// One entry, deliberately: the identity is the one thing about a consumer the mesh itself chose,
+// so it is the one thing the mesh can hand to a provider without either end guessing.
+var consumerFacts = []string{"as"}
+
+// consumerAlphabets are the ways the mesh will write that identity. `dns` is the mesh's own
+// identifier with its separator written `-` instead of `_` — the whole of the difference between
+// the alphabet the mesh mints in and the one buckets, vhosts and hostnames accept.
+var consumerAlphabets = []string{"dns"}
+
+// ServedTo fills a provider's served values for one consumer.
+//
+// `as` is the identity the mesh minted for that consumer — the same string it is told to present
+// as a login. Values with no placeholder are returned exactly as they were, and a block with no
+// placeholder at all is returned unchanged, so this costs nothing for the providers that derive
+// nothing.
+//
+// Only strings carry placeholders. A number, a boolean or a nested object is a value the provider
+// stated outright, and is left alone.
+func ServedTo(serves map[string]any, as string) (map[string]any, error) {
+ if len(serves) == 0 {
+ return serves, nil
+ }
+ var out map[string]any
+ for _, key := range sortedAnyKeys(serves) {
+ text, ok := serves[key].(string)
+ if !ok || !strings.Contains(text, "${consumer:") {
+ continue
+ }
+ filled, err := consumerInto(text, as)
+ if err != nil {
+ return nil, fmt.Errorf("the value served as %q: %w", key, err)
+ }
+ if out == nil {
+ // Copied only once something actually changes: the caller's map is the manifest's,
+ // and a provider that derives nothing must not have it rewritten underneath it.
+ out = make(map[string]any, len(serves))
+ for k, v := range serves {
+ out[k] = v
+ }
+ }
+ out[key] = filled
+ }
+ if out == nil {
+ return serves, nil
+ }
+ return out, nil
+}
+
+// consumerInto replaces every `${consumer:…}` in one value.
+//
+// **A fact or an alphabet the mesh does not have is refused, not left standing.** Written through,
+// the literal `${consumer:as}` would reach a configuration file and be read as a bucket name,
+// failing somewhere that names neither the module nor the mesh — the same reasoning `${bound:…}`
+// is refused by (boundInto).
+func consumerInto(value, as string) (string, error) {
+ var failed error
+ out := consumerFact.ReplaceAllStringFunc(value, func(match string) string {
+ parts := consumerFact.FindStringSubmatch(match)
+ fact, alphabet := parts[1], parts[2]
+ if fact != "as" {
+ if failed == nil {
+ failed = fmt.Errorf(
+ "says %s, and the mesh states %s about a consumer", match, orNothing(consumerFacts))
+ }
+ return match
+ }
+ switch alphabet {
+ case "":
+ return as
+ case "dns":
+ return asDNSLabel(as)
+ default:
+ if failed == nil {
+ failed = fmt.Errorf(
+ "says %s, and the mesh writes an identity as %s", match, orNothing(consumerAlphabets))
+ }
+ return match
+ }
+ })
+ if failed != nil {
+ return "", failed
+ }
+ return out, nil
+}
+
+// asDNSLabel writes a minted identity as a DNS label.
+//
+// The mesh's identities are already lower-case letters, digits and `_` (ConsumerIdentity), and
+// already inside the bound of the provision serving them: a provider that serves one as a DNS label
+// bounds it at 63 or less (CheckServes, novox/hq ADR 0225), and one that serves it at all without
+// saying is held to twenty (IdentityBoundOf). So
+// this is the separator and nothing else — no lower-casing of what is already lower case, no
+// truncation to a limit the identity is already inside, no padding of a name that is already long
+// enough. Each of those would be the mesh guessing at a rule it has not been given.
+func asDNSLabel(as string) string {
+ return strings.ReplaceAll(as, "_", "-")
+}
+
+func sortedAnyKeys(values map[string]any) []string {
+ out := make([]string, 0, len(values))
+ for k := range values {
+ out = append(out, k)
+ }
+ sort.Strings(out)
+ return out
+}
+
+// derivedFor is what the provider on this machine derives for one consumer of one provision
+// (novox/hq ADR 0202).
+//
+// Settled first, then derived: an operator may set a prefix on what the provider serves and the
+// mesh still fills the consumer's half of it ([ADR 0174]). Only the keys that actually name the
+// consumer are returned — the rest of a `serves` block is the same for every consumer and is
+// already in the provider's own definition, so repeating it here would be a second copy to go
+// stale.
+//
+// The first module in the resolved order that says it serves the provision answers, which is the
+// choice servedOnThisMachine makes for the consumer's half. Nothing serving it on this machine is
+// not an error: a contribution can reach a machine whose provider is a record or an adapter, and
+// then there is nothing derived to tell.
+func resolutionDerivedFor(r mcat.Resolution, provision, as, consumer, local string, settings mcat.SettingsBy) (map[string]any, error) {
+ for _, m := range r.Modules {
+ serves, said := m.Serves[provision]
+ if !said {
+ continue
+ }
+ var names map[string]any
+ for key, value := range serves {
+ if text, ok := value.(string); ok && strings.Contains(text, "${consumer:") {
+ if names == nil {
+ names = map[string]any{}
+ }
+ names[key] = value
+ }
+ }
+ if names == nil {
+ return nil, nil
+ }
+ // **A consumer that keeps several holders of this provision is refused** — this is issue
+ // 124's own failure one case to the side, and it would be just as quiet.
+ //
+ // Each holder gets its own login, `…_` (ADR 0094), and a provider derives from the
+ // login, so it would make one resource per holder. The consumer's side has no such
+ // dimension: one binding file per provision, one `${bound::}`, both
+ // derived from the un-suffixed identity. So the provider would create the holder's
+ // resource and the consumer would be configured against a name nothing made — it would
+ // authenticate successfully and be refused on every object, which reads like a credential
+ // fault and is not one.
+ //
+ // Lifting this means giving the consumer's side a local dimension. That is a decision,
+ // not an omission, and until it is taken the mesh says so rather than guessing.
+ if local != "" {
+ return nil, fmt.Errorf(
+ "%s keeps several holders of %s (this one is %q), and %s derives %s for each "+
+ "consumer from the login the mesh minted. Each holder has its own login, and a "+
+ "consumer is told one value per requirement — so the two ends would name "+
+ "different things and nothing would compare them (novox/hq ADR 0202)",
+ consumer, local, provision, m.Module, orNothing(sortedAnyKeys(names)))
+ }
+ settled, err := Settle(names, WithDefaults(m, settings[m.Module]))
+ if err != nil {
+ return nil, fmt.Errorf("%s serving %s: %w", m.Module, provision, err)
+ }
+ derived, err := ServedTo(settled, as)
+ if err != nil {
+ return nil, fmt.Errorf("%s serving %s to %s: %w", m.Module, provision, as, err)
+ }
+ return derived, nil
+ }
+ return nil, nil
+}
+
+// notTranscribed refuses a consumer's file that writes out the value its provider derives for it,
+// instead of asking for it (novox/hq ADR 0202, issue 124).
+//
+// **What would have caught the one wrong instance.** The object store's three consumers each wrote
+// their bucket into their own configuration by hand. One of them named a predecessor's bucket, and
+// nothing compared it to what the provider would actually create: the module would have
+// authenticated successfully and been refused on every object, which reads like a credential fault
+// and is not one. It looked authoritative for months.
+//
+// The test is exact and costs one string search: a definition whose file already contains the
+// value the mesh is about to derive for it has written down somebody else's rule. It cannot be a
+// coincidence — a derived value carries the identity the mesh minted for this very consumer on
+// this very machine, which nothing else would spell out — and it cannot be checked afterwards,
+// because after substitution every consumer's file contains it legitimately.
+//
+// Only values that actually name the consumer are judged. A provider that serves a constant under
+// the same key serves the same constant to everyone, and a consumer repeating it is redundant
+// rather than wrong.
+func notTranscribed(resource map[string]any, known map[string]map[string]string, module string) error {
+ if fmt.Sprint(resource["type"]) != "file" {
+ return nil
+ }
+ content, ok := resource["content"].(string)
+ if !ok || content == "" {
+ return nil
+ }
+ for _, provision := range sortedKnown(known) {
+ values := known[provision]
+ identity := values["as"]
+ if identity == "" {
+ continue
+ }
+ for _, key := range sortedStringKeys(values) {
+ if key == "as" {
+ // The login is not derived from itself, and a consumer that must present it in a
+ // connection string legitimately has it from `${bound:…}` — which is what it will
+ // be after substitution, so this would judge the substitution, not the module.
+ continue
+ }
+ value := values[key]
+ if value == "" || !namesTheConsumer(value, identity) {
+ continue
+ }
+ if !strings.Contains(content, value) {
+ continue
+ }
+ return fmt.Errorf(
+ "%s writes %q into %v, and that is exactly what %s derives for it — a definition "+
+ "keeping its own copy of somebody else's naming rule is one that can disagree "+
+ "with it, silently. Say ${bound:%s:%s} and be told",
+ module, value, resource["id"], provision, provision, key)
+ }
+ }
+ return nil
+}
+
+// namesTheConsumer is whether a derived value was built from this consumer's identity — in the
+// alphabet it was minted in, or as a DNS label. A value that does not contain it was not derived
+// from it, whatever else it may be.
+func namesTheConsumer(value, identity string) bool {
+ return strings.Contains(value, identity) || strings.Contains(value, asDNSLabel(identity))
+}
+
+func sortedKnown(known map[string]map[string]string) []string {
+ out := make([]string, 0, len(known))
+ for k := range known {
+ out = append(out, k)
+ }
+ sort.Strings(out)
+ return out
+}
+
+func sortedStringKeys(values map[string]string) []string {
+ out := make([]string, 0, len(values))
+ for k := range values {
+ out = append(out, k)
+ }
+ sort.Strings(out)
+ return out
+}
diff --git a/internal/compose/resolution/consumers.go b/internal/compose/resolution/consumers.go
new file mode 100644
index 0000000..b74e74f
--- /dev/null
+++ b/internal/compose/resolution/consumers.go
@@ -0,0 +1,100 @@
+package resolution
+
+import (
+ "fmt"
+ "regexp"
+ "slices"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+// ofConsumers is where a provider places its consumers' credentials. Loose inside the braces, so a misspelt name is
+// refused rather than passed over.
+var ofConsumers = regexp.MustCompile(`\$\{consumers:([^}]*)\}`)
+
+// consumersPlaceholderProblems is what is wrong with one resource's ${consumers:…}: placed only in a file's content,
+// for a provision the module provides, in a file only the provider reads and the client writes back.
+func consumersPlaceholderProblems(m mcat.Manifest, r map[string]any) []string {
+ var problems []string
+ for _, field := range sortedKeys(r) {
+ v, ok := r[field].(string)
+ if !ok {
+ continue
+ }
+ for _, f := range ofConsumers.FindAllStringSubmatch(v, -1) {
+ switch {
+ case field != "content" || fmt.Sprint(r["type"]) != "file":
+ problems = append(problems, fmt.Sprintf("%s's resource %v names %s in its %s; a provider's consumers "+
+ "are placed only in a file's content (novox/hq ADR 0373)", m.Module, r["id"], f[0], field))
+ continue
+ case !slices.ContainsFunc(m.Provides, func(o mcat.Offer) bool { return o.Name == f[1] }):
+ problems = append(problems, fmt.Sprintf("%s's resource %v names %s, and %s does not provide %q: only a "+
+ "provider's template holds its consumers' credentials (novox/hq ADR 0373)", m.Module, r["id"], f[0],
+ m.Module, f[1]))
+ }
+ if mode := fmt.Sprint(r["mode"]); mode != "0600" && mode != "0400" {
+ problems = append(problems, fmt.Sprintf("%s's resource %v holds its consumers' credentials (%s) with "+
+ "mode %q: the provider alone reads it, 0600 or 0400 (novox/hq ADR 0373)", m.Module, r["id"], f[0],
+ r["mode"]))
+ }
+ if owner := ownershipOf(r); owner != mcat.RuntimeOwned {
+ problems = append(problems, fmt.Sprintf("%s's resource %v holds its consumers' credentials (%s) and is "+
+ "%s at runtime: it is owned, written back whenever it differs (novox/hq ADR 0373)", m.Module,
+ r["id"], f[0], owner))
+ }
+ }
+ }
+ return problems
+}
+
+// consumersHandedOnly says what a file naming ${consumers:…} is where it would be written the old way: the provisioner's
+// client alone fills it, so on a machine that does not hand the module it is **left out** of the declaration, never
+// written as that text or as an empty list the provider would read as no consumers, and never the reason the module is
+// refused: a provider not handed there keeps working as it did, from its grant files (novox/hq ADR 0373, issue 780).
+// A moved module judged from its repository's tree (WithTemplatesFrom: `module check`), on a machine
+// that would hand it, has no templates archive to hand and is judged as it composes; there the placeholder is judged
+// and taken out of the text, since what is composed so is never sent (judged answers whether the file is so judged).
+func consumersHandedOnly(m mcat.Manifest, r map[string]any, with mcat.Rendering) (judged, leftOut bool) {
+ content, _ := r["content"].(string)
+ switch {
+ case ofConsumers.FindString(content) == "":
+ return false, false
+ case m.Moved && m.Handed == nil && with.Hands && with.Provisioner != "":
+ return true, false
+ }
+ return false, true
+}
+
+// consumersLeftOutLine is the plan's one line for a file left out because the module is not handed.
+func consumersLeftOutLine(m mcat.Manifest, r map[string]any) string {
+ return fmt.Sprintf("%s's %v names ${consumers:…}, which the provisioner's client alone fills: left out, handed only "+
+ "(novox/hq ADR 0373)", m.Module, r["id"])
+}
+
+// consumersNotHanded is consumersHandedOnly as a refusal, for a caller that has no way to leave the file out.
+func consumersNotHanded(m mcat.Manifest, r map[string]any, with mcat.Rendering) (judged bool, err error) {
+ judged, left := consumersHandedOnly(m, r, with)
+ if left {
+ return false, fmt.Errorf("%s's resource %v names %s, which the provisioner's client alone fills, and %s is not "+
+ "handed to it on this machine: it would be written as that text, or empty, and the provider would read no "+
+ "consumers (novox/hq ADR 0373)", m.Module, r["id"], ofConsumers.FindString(fmt.Sprint(r["content"])), m.Module)
+ }
+ return judged, nil
+}
+
+// consumersOfTemplate is what is wrong with a handed file's template naming ${consumers:…}, judged on the file's
+// resource as if its text were inline.
+func consumersOfTemplate(m mcat.Manifest, id, text string) []string {
+ for _, r := range m.Resources {
+ if fmt.Sprint(r["id"]) != id {
+ continue
+ }
+ withText := map[string]any{}
+ for k, v := range r {
+ withText[k] = v
+ }
+ withText["content"] = text
+ return consumersPlaceholderProblems(m, withText)
+ }
+ return nil
+}
diff --git a/internal/compose/resolution/contributes_test.go b/internal/compose/resolution/contributes_test.go
new file mode 100644
index 0000000..44f9b9c
--- /dev/null
+++ b/internal/compose/resolution/contributes_test.go
@@ -0,0 +1,311 @@
+package resolution
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+func published(module, host string, port int) mcat.Manifest {
+ return mcat.Manifest{Module: module, Version: "1",
+ Contributes: map[string]map[string]any{
+ "reverse-proxy": {"host": host, "port": port},
+ }}
+}
+
+func proxy() mcat.Manifest {
+ return mcat.Manifest{Module: "traefik", Version: "1",
+ Provides: mcat.Offers("reverse-proxy"),
+ Receives: map[string]string{"reverse-proxy": "/etc/traefik/mesh.json"},
+ Resources: []map[string]any{
+ {"id": "up", "type": "service", "unit": "traefik", "state": "running",
+ "restart-on": []any{mcat.ReceivedID("reverse-proxy")}},
+ }}
+}
+
+// received digs the delivered contributions back out of a declaration.
+func received(t *testing.T, out []map[string]any) []mcat.Contribution {
+ t.Helper()
+ for _, r := range out {
+ if r["path"] != "/etc/traefik/mesh.json" {
+ continue
+ }
+ body := r["content"].(string)
+ var parsed struct {
+ Requirement string `json:"requirement"`
+ Given []mcat.Contribution `json:"given"`
+ }
+ if err := json.Unmarshal([]byte(body), &parsed); err != nil {
+ t.Fatalf("the file the proxy is given is not readable: %v\n%s", err, body)
+ }
+ if parsed.Requirement != "reverse-proxy" {
+ t.Fatalf("the file does not say what it is about: %q", parsed.Requirement)
+ }
+ return parsed.Given
+ }
+ t.Fatalf("the provider was given no file at all: %v", out)
+ return nil
+}
+
+func TestTheFileTheProviderGetsIsMachineReadable(t *testing.T) {
+ // It is written for a program, and the first version put a `//` header above the JSON — a
+ // file that says "do not edit" to a person and fails to parse for the thing meant to read it.
+ got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{})
+
+ for _, r := range mustDeclare(t, got) {
+ if r["path"] != "/etc/traefik/mesh.json" {
+ continue
+ }
+ var any map[string]any
+ if err := json.Unmarshal([]byte(r["content"].(string)), &any); err != nil {
+ t.Fatalf("the file is not parseable: %v\n%s", err, r["content"])
+ }
+ if note, _ := any["generated"].(string); !strings.Contains(note, "do not edit") {
+ // Inside the document rather than above it, so it reaches a person without
+ // breaking the parse.
+ t.Fatalf("the file does not say it is generated: %v", any["generated"])
+ }
+ return
+ }
+ t.Fatal("no received file")
+}
+
+func TestAModuleTellsItsProviderWhatItNeeds(t *testing.T) {
+ got, err := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{})
+
+ if err != nil {
+ t.Fatal(err)
+ }
+ out, err := Declaration(got, mcat.Rendering{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ given := received(t, out)
+ if len(given) != 1 || given[0].From != "board" {
+ t.Fatalf("the proxy was not told about board: %v", given)
+ }
+ if given[0].Values["host"] != "board" || given[0].Values["port"] != float64(8080) {
+ t.Fatalf("the contribution did not survive: %v", given[0].Values)
+ }
+}
+
+func TestContributingToSomethingIsRequiringIt(t *testing.T) {
+ // Asking to be published means a publisher must exist. A module that had to say both would
+ // eventually say one, and the failure would be a machine where nothing serves the route.
+ got, err := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{})
+
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(strings.Join(names(got), " "), "traefik") {
+ t.Fatalf("contributing to reverse-proxy did not bring one in: %v", names(got))
+ }
+}
+
+func TestNothingToContributeToIsRefused(t *testing.T) {
+ _, err := Resolve(shelf(published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{})
+
+ if err == nil {
+ t.Fatal("a module was published through a proxy that does not exist")
+ }
+ if !strings.Contains(err.Error(), "reverse-proxy") {
+ t.Fatalf("the refusal does not name what is missing: %v", err)
+ }
+}
+
+func TestEveryPublisherOnTheMachineIsInOneFile(t *testing.T) {
+ got, err := Resolve(shelf(proxy(),
+ published("board", "board", 8080),
+ published("archive", "archive", 9000),
+ ), []string{"board", "archive"}, workstation(), mcat.World{})
+
+ if err != nil {
+ t.Fatal(err)
+ }
+ given := received(t, mustDeclare(t, got))
+ if len(given) != 2 {
+ t.Fatalf("the proxy was told about %d of 2: %v", len(given), given)
+ }
+ // Ordered by module, because this becomes a file and a file whose lines move about looks
+ // changed when nothing changed — which would restart the proxy for ever.
+ if given[0].From != "archive" || given[1].From != "board" {
+ t.Fatalf("the order is not stable: %v", given)
+ }
+}
+
+func TestAProviderWithNoConsumersStillGetsTheFile(t *testing.T) {
+ // Empty rather than absent. A provider that finds no file cannot tell "nothing asked for me"
+ // from "the mesh never wrote it", and those want completely different responses — the same
+ // rule the host follows about a service that does not exist.
+ got, err := Resolve(shelf(proxy()), []string{"traefik"}, workstation(), mcat.World{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if given := received(t, mustDeclare(t, got)); len(given) != 0 {
+ t.Fatalf("got %v", given)
+ }
+}
+
+func TestTheProviderCanReloadWhenTheRoutesChange(t *testing.T) {
+ // A proxy that got a new route and did not reload is a route that silently does not work.
+ // The same fault the private network had when a peer list changed under a running interface,
+ // which is why the received file has a name a module can point at.
+ got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{})
+
+ out := mustDeclare(t, got)
+ for _, r := range out {
+ if r["type"] != "service" {
+ continue
+ }
+ reflects, ok := r["restart-on"].([]any)
+ if !ok || len(reflects) != 1 {
+ t.Fatalf("the proxy does not reflect anything: %v", r)
+ }
+ if reflects[0] != "traefik."+mcat.ReceivedID("reverse-proxy") {
+ t.Fatalf("it reflects %v, which is not the file it was given", reflects[0])
+ }
+ return
+ }
+ t.Fatal("no service in the declaration")
+}
+
+func TestARouteCanBeSetPerMesh(t *testing.T) {
+ // The hostname is exactly the kind of thing that differs between one mesh and the next. A
+ // module whose route could not be set would have to be edited to be reused anywhere.
+ got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{})
+
+ out, err := Declaration(got, mcat.Rendering{Settings: mcat.SettingsBy{
+ "board": {{From: "the mesh", Values: map[string]any{"host": "dashboard"}}},
+ }})
+ if err != nil {
+ t.Fatal(err)
+ }
+ given := received(t, out)
+ if given[0].Values["host"] != "dashboard" {
+ t.Fatalf("the setting did not reach the route: %v", given[0].Values)
+ }
+ if given[0].Values["port"] != float64(8080) {
+ t.Fatalf("setting the host dropped the port: %v", given[0].Values)
+ }
+}
+
+func TestASettingReachesAContributionOnlyWhereItDeclaresTheKey(t *testing.T) {
+ // novox/hq 04-ISSUES/173: the mail module's site name, set so its environment file could read
+ // it, arrived in every route it contributed. A setting overrides a key the contribution
+ // declares and adds none — the provider reads the contribution as a contract.
+ got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{})
+
+ out, err := Declaration(got, mcat.Rendering{Settings: mcat.SettingsBy{
+ "board": {{From: "the mesh", Values: map[string]any{"host": "dashboard", "sitename": "Board"}}},
+ }})
+ if err != nil {
+ t.Fatal(err)
+ }
+ given := received(t, out)
+ if given[0].Values["host"] != "dashboard" {
+ t.Fatalf("the setting did not override the route's host: %v", given[0].Values)
+ }
+ if _, leaked := given[0].Values["sitename"]; leaked {
+ t.Fatalf("a setting the route never declared reached the proxy: %v", given[0].Values)
+ }
+}
+
+func provider() mcat.Manifest {
+ return mcat.Manifest{Module: "postgres", Version: "1",
+ Provides: mcat.FromAnywhere("postgres-database"),
+ Receives: map[string]string{"postgres-database": "/var/lib/postgres/grants/mesh.json"},
+ Grants: map[string]string{"postgres-database": "/var/lib/postgres/grants"},
+ }
+}
+
+func grantedTo(t *testing.T, out []map[string]any) []mcat.Contribution {
+ t.Helper()
+ for _, r := range out {
+ if r["path"] != "/var/lib/postgres/grants/mesh.json" {
+ continue
+ }
+ var parsed struct {
+ Given []mcat.Contribution `json:"given"`
+ }
+ if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil {
+ t.Fatal(err)
+ }
+ return parsed.Given
+ }
+ t.Fatalf("the provider was given no manifest: %v", out)
+ return nil
+}
+
+func TestAConsumersOwnContributionsAreStillThere(t *testing.T) {
+ // Cross-node grants are merged in with this machine's own, because from the provider's side
+ // they are the same thing — somebody wanting something — and a provider that had to read two
+ // lists would read one of them.
+ got, err := Resolve(shelf(proxy(), published("board", "board", 8080)),
+ []string{"board"}, reachable(), mcat.World{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ given := received(t, mustDeclare(t, got))
+ if len(given) != 1 || given[0].Node != "" {
+ t.Fatalf("a local contribution grew a node: %v", given)
+ }
+}
+
+func TestARequireOnlyConsumerOfAParameterlessProvisionStillAsks(t *testing.T) {
+ // A consumer that requires a parameterless provision (one whose serves names no consumer key —
+ // redis-cache, amqp) contributes no payload, but it still ASKS for the provision and must be
+ // granted a credential. Keying "asks" on contributions alone gave its grant From="" — read as
+ // withdrawn — so the provider never created the account and the consumer authenticated nowhere.
+ r := mcat.Resolution{
+ Node: "laptop",
+ Modules: []mcat.Manifest{{
+ Module: "amqp-ping",
+ Requires: []string{"amqp"},
+ }},
+ }
+ values, asks, err := ContributionsFrom(r, "amqp", "amqp-ping", mcat.SettingsBy{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !asks {
+ t.Fatal("a require-only consumer was treated as not asking; its grant would be withdrawn and it would never be provisioned")
+ }
+ if values == nil {
+ t.Fatalf("asks is true but values is nil; expected an empty contribution, got %v", values)
+ }
+}
+
+func TestAConsumerThatStoppedAskingIsWithdrawn(t *testing.T) {
+ // Withdrawal is how a credential is taken away. The provisioner removes what nobody asks for,
+ // and it can only do that if the mesh stops asking — a consumer that was unassigned would
+ // otherwise keep a working login for ever, and nothing would say so.
+ //
+ // A grant with no asking module is exactly that state: the mesh still holds the secret,
+ // because it is sealed and unusable to the mesh anyway, and the machine no longer wants it.
+ got, err := Resolve(shelf(provider()), []string{"postgres"}, reachable(), mcat.World{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ out, err := Declaration(got, mcat.Rendering{Grants: []mcat.Grant{
+ {Provision: "postgres-database", Consumer: "still-here", From: "meshboard",
+ Values: map[string]any{"name": "meshboard"}, Sealed: "c2VhbGVk"},
+ {Provision: "postgres-database", Consumer: "gone-away", Sealed: "c3RhbGU="},
+ }})
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ given := grantedTo(t, out)
+ if len(given) != 1 || given[0].Node != "still-here" {
+ t.Fatalf("the provider is still told about a machine that stopped asking: %v", given)
+ }
+ // And no credential is written for it either, or the provisioner would find a file for a
+ // consumer its manifest does not mention and have to guess what that means.
+ for _, r := range out {
+ if path, _ := r["path"].(string); strings.Contains(path, "gone-away") {
+ t.Fatalf("a withdrawn consumer's credential is still delivered: %v", r)
+ }
+ }
+}
diff --git a/internal/compose/resolution/contribution_dependencies_test.go b/internal/compose/resolution/contribution_dependencies_test.go
new file mode 100644
index 0000000..10fd91d
--- /dev/null
+++ b/internal/compose/resolution/contribution_dependencies_test.go
@@ -0,0 +1,58 @@
+package resolution
+
+import (
+ "reflect"
+ "testing"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+func TestAContributionDependsOnTheSeatThatPlacesIt(t *testing.T) {
+ env := mod("theme", nil, nil, nil)
+ env.Environment = &mcat.Environment{Variables: map[string]string{"GTK_THEME": "Adwaita:dark"}}
+ path := mod("toolchain", nil, nil, nil)
+ path.Environment = &mcat.Environment{Path: []mcat.PathEntry{{Entry: "/opt/x/bin"}}}
+ shell := mod("prompt", nil, nil, nil)
+ shell.Shell = []mcat.ShellCode{{For: "zsh", Slot: "first", Code: "true"}}
+ session := mod("wallpaper", nil, nil, nil)
+ session.Shell = []mcat.ShellCode{{For: "xinitrc", Slot: "normal", Code: "true"}}
+ resources := mod("bar", nil, nil, nil)
+ resources.Shell = []mcat.ShellCode{{For: "xresources", Slot: "normal", Code: "x: y"}}
+ empty := mod("nothing", nil, nil, nil)
+ empty.Environment = &mcat.Environment{}
+
+ cases := map[string]struct {
+ m mcat.Manifest
+ want []string
+ }{
+ "a variable": {env, []string{mcat.EnvironmentSeat}},
+ "a path entry": {path, []string{mcat.EnvironmentSeat}},
+ "shell code": {shell, []string{mcat.LoginShellSeat}},
+ "the session's start": {session, []string{mcat.DisplayServerSeat}},
+ "the session's X resources": {resources, []string{mcat.DisplayServerSeat}},
+ "an empty environment": {empty, nil},
+ }
+ for name, c := range cases {
+ got := DependsOn(nil, c.m)
+ if len(got) == 0 {
+ got = nil
+ }
+ if !reflect.DeepEqual(got, c.want) {
+ t.Errorf("%s depends on %v, want %v", name, got, c.want)
+ }
+ }
+}
+
+func TestAHolderMeetsItsOwnContribution(t *testing.T) {
+ // The display server's module contributes nothing to its own seat today, but the zsh module's
+ // environment contributions do go to another seat: a claim meets only the seat it names.
+ zsh := mod("zsh", nil, nil, nil, mcat.Claim{Name: mcat.LoginShellSeat})
+ zsh.Shell = []mcat.ShellCode{{For: "zsh", Slot: "normal", Code: "true"}}
+ zsh.Environment = &mcat.Environment{Variables: map[string]string{"EDITOR": "vim"}}
+ catalogue := map[string]mcat.Manifest{"zsh": zsh,
+ "node-env": mod("node-env", nil, nil, nil, mcat.Claim{Name: mcat.EnvironmentSeat})}
+ unheld := UnheldDependencies(catalogue, "laptop", []mcat.Manifest{zsh}, nil)
+ if len(unheld) != 1 || unheld[0].Seat != mcat.EnvironmentSeat {
+ t.Errorf("zsh alone: unheld %v, want only %s (its shell code is its own seat's)", unheld, mcat.EnvironmentSeat)
+ }
+}
diff --git a/internal/compose/resolution/control_records_seats.go b/internal/compose/resolution/control_records_seats.go
new file mode 100644
index 0000000..d3b53f1
--- /dev/null
+++ b/internal/compose/resolution/control_records_seats.go
@@ -0,0 +1,52 @@
+package resolution
+
+import (
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+// controlRecordsDecision is the record that made the two seats.
+const controlRecordsDecision = "novox/hq ADR 0565"
+
+// wantsVerbs are the assigner's verbs on mesh-wants (ADR 0565 clauses 7, 8, 9, 15).
+func wantsVerbs() []mcat.Verb {
+ return []mcat.Verb{
+ {Name: "upgrade", Description: "Whether each module's new builds wait for a person: its policy (roll, together or record) " +
+ "and where it comes from (a person, the module, the bus, its irreplaceable data, the default); the bus is never " +
+ "rolled. With module, that one; with module, policy and why, a person's choice for it, recorded as a hand act; " +
+ "with policy default, the choice taken back. With compare, each node's own composition and the assigner's findings.",
+ Input: schema(map[string]string{
+ "module": "one module",
+ "policy": "with module: roll, together, record, or default to take a person's choice back",
+ "why": "with policy: why, required and kept",
+ "compare": "\"true\": each node's own composition, by its digest, and the assigner's findings",
+ }, nil, "compare")},
+ {Name: "bus", Description: "What a bus upgrade would do: the fingerprint of the bus module each node runs against the " +
+ "indexer's newest, and the last bus steps from the hand-act log and the assigner's puts of the bus. Read-only: " +
+ "the step itself is the put on the operator's warrant.",
+ Input: schema(map[string]string{}, nil)},
+ }
+}
+
+// handActsVerbs are mesh-hand-acts' verbs (ADR 0565 clause 10), as the controller's took them.
+func handActsVerbs() []mcat.Verb {
+ return []mcat.Verb{
+ {Name: "hand-act", Description: "Record one act done by hand: what, why, its cause in a word or a condition's kind " +
+ "(the word a second act for the same reason uses) and the condition it addresses. Who did it is the caller the " +
+ "runtime proves, never said by the caller. Said as act-recorded.",
+ Input: schema(map[string]string{
+ "what": "what was done, in a line",
+ "why": "why it had to be done by hand",
+ "cause": "the cause in a word, or a condition's kind",
+ "condition": "the key of the condition it addressed, if any (optional)",
+ "verb": "the act's verb in the log: a repair when absent, `hand-act drill` for a drill, or a recorder's own",
+ }, []string{"what", "why"})},
+ {Name: "hand-acts", Description: "What was done by hand lately, over this log and the controller's: who, why and the " +
+ "cause of each, and the causes recorded twice within a fortnight (a healer is wanted).",
+ Input: schema(map[string]string{"days": "how many days back (default 14)", "cause": "only this cause",
+ "who": "only acts whose who contains this", "warrants": "\"true\": every warrant recorded"}, nil, "warrants")},
+ {Name: "warranted", Description: "Record what a module did on the operator's warrant: who chose, through which channel, " +
+ "with which proofs and which answer read from the router's own record of that module's ask, never from the " +
+ "caller; once per ask. Refused for an ask still open, ended without a choice, or not that module's.",
+ Input: schema(map[string]string{"asker": "the module that asked", "ask": "its ask's id"}, []string{"asker", "ask"})},
+ }
+}
diff --git a/internal/compose/resolution/core_credentials.go b/internal/compose/resolution/core_credentials.go
new file mode 100644
index 0000000..6b667f3
--- /dev/null
+++ b/internal/compose/resolution/core_credentials.go
@@ -0,0 +1,19 @@
+package resolution
+
+import (
+ "encoding/json"
+ "slices"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+// CarriesVaultSealed says whether module's own secrets are the core's credentials.
+func CarriesVaultSealed(module string) bool {
+ return slices.Contains(mcat.CoreCredentialModules, module)
+}
+
+// vaultSealedOf is the vault's box for one of module's own secrets, if the rendering was given one.
+func renderingVaultSealedOf(with mcat.Rendering, module, name string) (json.RawMessage, bool) {
+ box, ok := with.VaultSealed[module][name]
+ return box, ok && len(box) > 0
+}
diff --git a/internal/compose/resolution/core_credentials_test.go b/internal/compose/resolution/core_credentials_test.go
new file mode 100644
index 0000000..6c7ffd2
--- /dev/null
+++ b/internal/compose/resolution/core_credentials_test.go
@@ -0,0 +1,24 @@
+package resolution
+
+import (
+ "reflect"
+ "testing"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+func TestTheCoresOwnCredentialsAreTheBusTheProvisionerAndItsClient(t *testing.T) {
+ if want := []string{"mesh-provisioner", "mesh-provisioner-client", "nats"}; !reflect.DeepEqual(mcat.CoreCredentialModules, want) {
+ t.Fatalf("the core's modules are %v, want %v (mesh-vault's CoreModules is the same list)", mcat.CoreCredentialModules, want)
+ }
+ for _, m := range []string{"nats", "mesh-provisioner", "mesh-provisioner-client"} {
+ if !CarriesVaultSealed(m) {
+ t.Errorf("%s is not on the list", m)
+ }
+ }
+ for _, m := range []string{"postgres", "mesh-vault", "mesh-controller", ""} {
+ if CarriesVaultSealed(m) {
+ t.Errorf("%s is on the list", m)
+ }
+ }
+}
diff --git a/internal/compose/resolution/data.go b/internal/compose/resolution/data.go
new file mode 100644
index 0000000..1d81163
--- /dev/null
+++ b/internal/compose/resolution/data.go
@@ -0,0 +1,77 @@
+package resolution
+
+import (
+ "fmt"
+ "strings"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+// derivedContributions is what a module's data section gives the backup holder: its backup lines and
+// its items. Every item is listed, so a valuable one on a machine is measured whether or not it is
+// copied; a cache is listed and not measured.
+func manifestDerivedContributions(m mcat.Manifest) []mcat.SeatContribution {
+ items := m.DataItems()
+ if len(items) == 0 {
+ return nil
+ }
+ var lines []string
+ kept := map[string]bool{}
+ keep := func(path string) {
+ if !kept[path] {
+ kept[path] = true
+ lines = append(lines, "path "+path)
+ }
+ }
+ for _, it := range items {
+ if !it.BackedUp() {
+ continue
+ }
+ if it.Backup.IsDump() {
+ lines = append(lines, "run "+strings.TrimSpace(it.Backup.Dump))
+ if into, ok := m.DataItem(it.Backup.Into); ok {
+ keep(into.Path)
+ }
+ continue
+ }
+ keep(it.Path)
+ }
+ var described []string
+ for _, it := range items {
+ covered := "-"
+ switch {
+ case it.Backup.IsDump():
+ if into, ok := m.DataItem(it.Backup.Into); ok {
+ covered = into.Path
+ }
+ case it.BackedUp():
+ covered = it.Path
+ }
+ described = append(described, fmt.Sprintf("item %s %s %s %s %s %s", it.ID, it.Class, it.Path, covered,
+ it.Protection(), it.MeasuredBy()))
+ }
+ var out []mcat.SeatContribution
+ if len(lines) > 0 {
+ out = append(out, mcat.SeatContribution{Seat: mcat.BackupSeat, Kind: mcat.BackupKindBackup, Content: strings.Join(lines, "\n") + "\n"})
+ }
+ return append(out, mcat.SeatContribution{Seat: mcat.BackupSeat, Kind: mcat.BackupKindData, Content: strings.Join(described, "\n") + "\n"})
+}
+
+// allContributions is every contribution a module makes to a seat's holder: what it wrote, and what
+// its data section derives. **One list**: a module that declares its data has its backup lines
+// composed from it, and a backup line it still writes by hand is not placed — the catalogue check
+// refuses it — so the holder never copies two lists that disagree.
+func manifestAllContributions(m mcat.Manifest) []mcat.SeatContribution {
+ if m.Data == nil {
+ return m.Contributions
+ }
+ derived := manifestDerivedContributions(m)
+ out := make([]mcat.SeatContribution, 0, len(m.Contributions)+len(derived))
+ for _, c := range m.Contributions {
+ if s, known := mcat.SeatNamed(c.Seat); known && s.Name == mcat.BackupSeat {
+ continue
+ }
+ out = append(out, c)
+ }
+ return append(out, derived...)
+}
diff --git a/internal/compose/resolution/data_placed.go b/internal/compose/resolution/data_placed.go
new file mode 100644
index 0000000..d1a17ee
--- /dev/null
+++ b/internal/compose/resolution/data_placed.go
@@ -0,0 +1,31 @@
+package resolution
+
+import (
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+// DataItems is every data item the resolution's modules declare, placed on its node with the rendering given: the text
+// the node-backup holder is handed, read. A module left out of the node's declaration keeps its items, as its data is
+// still on the node (ADR 0262); an item that cannot be placed is said in unplaced and left out.
+func DataItems(r mcat.Resolution, with mcat.Rendering) (items map[string][]mcat.PlacedDataItem, unplaced []string, err error) {
+ left := LeftOut(r, with.Settings, with.Adopted)
+ for module, why := range with.NotPlaced {
+ if _, already := left[module]; !already {
+ left[module] = why
+ }
+ }
+ modules := make([]mcat.Manifest, 0, len(r.Modules))
+ for _, m := range r.Modules {
+ if _, isLeft := left[m.Module]; isLeft {
+ m = backupView(m)
+ }
+ modules = append(modules, m)
+ }
+ text, unplaced, err := seatContributions(modules, mcat.Manifest{}, mcat.BackupSeat+":"+mcat.BackupKindData, with,
+ machineFacts(r, with.Names, with.MeshRange), r.Capabilities)
+ if err != nil {
+ return nil, unplaced, err
+ }
+ items, err = mcat.ParseDataItems(text)
+ return items, unplaced, err
+}
diff --git a/internal/compose/resolution/data_test.go b/internal/compose/resolution/data_test.go
new file mode 100644
index 0000000..29731f7
--- /dev/null
+++ b/internal/compose/resolution/data_test.go
@@ -0,0 +1,16 @@
+package resolution
+
+import (
+ "testing"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+func mustParse(t *testing.T, raw string) mcat.Manifest {
+ t.Helper()
+ m, err := decodeForTest([]byte(raw))
+ if err != nil {
+ t.Fatalf("refused: %v", err)
+ }
+ return m
+}
diff --git a/internal/compose/resolution/declaration.go b/internal/compose/resolution/declaration.go
new file mode 100644
index 0000000..3a774e3
--- /dev/null
+++ b/internal/compose/resolution/declaration.go
@@ -0,0 +1,2522 @@
+package resolution
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "sort"
+ "strconv"
+ "strings"
+
+ mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
+)
+
+// rootInto composes a user's `root` for the node-engine: taken away when it asserts nothing (empty — a
+// machine where agents run as the operator) or when the engine is older than the field and parses
+// strictly; kept as "never" otherwise.
+func rootInto(resource map[string]any, with mcat.Rendering) {
+ if resource["type"] != "user" {
+ return
+ }
+ value, has := resource[mcat.RootField]
+ if !has {
+ return
+ }
+ if s, _ := value.(string); s == "" || !with.JudgesRoot {
+ delete(resource, mcat.RootField)
+ }
+}
+
+// machinePort is where a module's port lives on this machine, or the port itself when the mesh has
+// not been asked. Unassigned is not an error here: a module with no `listens` never needed one,
+// and a caller composing a declaration without a store still gets something coherent.
+func renderingMachinePort(r mcat.Rendering, module string, wanted int) int {
+ if at, given := r.Given[module][wanted]; given {
+ return at
+ }
+ if at, known := r.Ports[module][wanted]; known {
+ return at
+ }
+ return wanted
+}
+
+// Declaration is everything the resolved modules put on the node, with settings applied.
+//
+// Resource identities are prefixed with the module they came from. Two modules may reasonably
+// both call something "config", and without this the second would silently replace the first —
+// the node applying one of them and reporting success.
+func Declaration(r mcat.Resolution, with mcat.Rendering) ([]map[string]any, error) {
+ composed, err := Compose(r, with)
+ if err != nil {
+ return nil, err
+ }
+ return composed.Resources, nil
+}
+
+// NotMadeError is a module's own secret the composition was given no value for (novox/hq issue 275):
+// typed, so that a caller can tell "a send would make this, and none has yet" from a composition that
+// fails for any other reason without reading the words.
+type NotMadeError struct {
+ Module, Name string
+}
+
+func (e *NotMadeError) Error() string {
+ return fmt.Sprintf("%s needs a secret called %q and none was made for it", e.Module, e.Name)
+}
+
+// LeftOut is which of this machine's modules a declaration composed with these settings leaves
+// out, and why (novox/hq ADR 0163, rule 6): each whose stored settings its definition can no longer
+// compose. Empty when every module composes. The same judgement SetSettings makes before storing.
+func LeftOut(r mcat.Resolution, settings mcat.SettingsBy, adopted bool) map[string]string {
+ out := map[string]string{}
+ for module, why := range LeftOutBecause(r, settings, adopted) {
+ out[module] = why.Error()
+ }
+ return out
+}
+
+// LeftOutBecause is LeftOut with each reason as the error it was, so a reader can tell a setting nobody
+// gave (an *UnsetSettingError) from any other cause and say it as the operator's to give (novox/hq issue
+// 380). An UnreadManifestError for a stored manifest this controller cannot read whole.
+func LeftOutBecause(r mcat.Resolution, settings mcat.SettingsBy, adopted bool) map[string]error {
+ out := map[string]error{}
+ for _, m := range r.Modules {
+ if why := UnknownFieldReason(m); why != "" {
+ out[m.Module] = &UnreadManifestError{Module: m.Module, said: why}
+ continue
+ }
+ if err := JudgeSettings(m, settings[m.Module], adopted); err != nil {
+ out[m.Module] = err
+ }
+ }
+ return out
+}
+
+// UnreadManifestError is a module left out because its stored manifest has a key this controller does not know
+// (novox/hq ADR 0262): said once for the whole mesh, by the catalogue's own condition, not per machine.
+type UnreadManifestError struct {
+ Module string
+ said string
+}
+
+func (e *UnreadManifestError) Error() string { return e.said }
+
+// Compose is Declaration with the owner of every resource said.
+func Compose(r mcat.Resolution, with mcat.Rendering) (mcat.Composed, error) {
+ owner := map[string]string{}
+ received := map[string]map[string][]mcat.Contribution{}
+ leftOut := map[string]string{}
+ var foreseen, unplaced, handedOnly []string
+ handed := &handedFiles{plans: map[string]mcat.Plan{}}
+ resources, err := resolutionCompose(r, with, owner, received, leftOut, &foreseen, &unplaced, &handedOnly, handed)
+ if err != nil {
+ return mcat.Composed{}, err
+ }
+ sort.Strings(foreseen)
+ if with.BusMembership != "" {
+ // The machine's own, not any module's: how it reaches the mesh from now on. Sealed like a
+ // secret and placed where the host looks for exactly this (design 28, task 5.2).
+ resources = append(resources, map[string]any{
+ "id": BusMembershipID(), "type": "file", "path": mcat.BusMembershipPath,
+ "sealed": with.BusMembership, "mode": "0600",
+ })
+ }
+ sort.Strings(unplaced)
+ sort.Strings(handedOnly)
+ out := mcat.Composed{Resources: resources, Owner: owner, Received: received, LeftOut: leftOut,
+ Foreseen: foreseen, Unplaced: unplaced, HandedOnly: handedOnly, Filled: handed.paths}
+ if plans := handed.finished(r.Needs); len(plans) > 0 {
+ out.Plans = plans
+ }
+ return out, nil
+}
+
+// BusMembershipID names the resource carrying a machine's membership for the new bus, and
+// BusMembershipPath is where the host reads it — the same constant on both sides.
+func BusMembershipID() string { return "bus-membership" }
+
+func resolutionCompose(r mcat.Resolution, with mcat.Rendering, owner map[string]string,
+ received map[string]map[string][]mcat.Contribution, leftOut map[string]string,
+ foreseen, unplaced, handedOnly *[]string, handed *handedFiles) ([]map[string]any, error) {
+ // **A setting is judged where it is stored, and an impossible one costs a module, not a
+ // machine** (novox/hq ADR 0163, rule 6). A definition that moved under a stored setting makes
+ // this module uncomposable; it is left out of the declaration — its held things kept, its
+ // containers untouched, the machine told so by name — and the machine is told everything else.
+ // Before placing, because a placement is a setting too.
+ left := LeftOut(r, with.Settings, with.Adopted)
+ kept := make([]mcat.Manifest, 0, len(r.Modules))
+ var stillBackedUp []mcat.Manifest
+ for module, why := range with.NotPlaced {
+ if _, already := left[module]; !already {
+ left[module] = why
+ }
+ }
+ for _, m := range r.Modules {
+ if why, isLeft := left[m.Module]; isLeft {
+ if leftOut != nil {
+ leftOut[m.Module] = why
+ }
+ // **Its data is still copied** (novox/hq ADR 0262): a module left out runs nothing new, and
+ // the data it already holds on the machine is the reason to keep copying it. Only its data,
+ // as the backup holder's lines are derived from it, and the directories they name.
+ stillBackedUp = append(stillBackedUp, backupView(m))
+ continue
+ }
+ kept = append(kept, m)
+ }
+ r.Modules = kept
+
+ // Every manifest is placed first (novox/hq ADR 0112): the maps naming where its bindings,
+ // credentials and contributions land are resolved against this node's directories, so every
+ // reader below — the binding files, the sealed secrets, the grant paths a contribution
+ // names — sees a concrete place and none learns the vocabulary.
+ // Into a fresh slice, never the caller's: one resolution may compose for many nodes, and a
+ // slice element written in place would carry the first node's places into the second's.
+ placed := make([]mcat.Manifest, len(r.Modules))
+ for i, m := range r.Modules {
+ var err error
+ if placed[i], err = placedManifest(m, with); err != nil {
+ return nil, err
+ }
+ }
+ r.Modules = placed
+
+ // Where each provision's credentials land, so a contribution can name the file rather than
+ // carry a value the mesh does not have.
+ directories := map[string]string{}
+ for _, m := range r.Modules {
+ for provision, where := range m.Grants {
+ directories[provision] = where
+ }
+ }
+ given, err := resolutionContributions(r, with.Settings, with.Grants, directories)
+ if err != nil {
+ return nil, err
+ }
+
+ // A workload on THIS machine contributes the port it declared, and the machine may have
+ // published it somewhere else (novox/hq ADR 0066). The mirror of the redirect below: 038 fixed
+ // what a consumer is TOLD about a provider, and this is what a workload TELLS the provider about
+ // itself — gitea declaring 3000, published as 20000:3000, and the co-located proxy dialling 3000,
+ // where nothing listens, for every request.
+ //
+ // **The contributing module's assignment, never the provider's.** The port belongs to the
+ // workload; using the map of whatever answers the requirement would move a route to wherever the
+ // proxy happens to be published. A contribution carried here from another machine is left exactly
+ // as it is — its port is that machine's to assign, and this map knows nothing about it.
+ for provision := range given {
+ for i := range given[provision] {
+ said := &given[provision][i]
+ if said.Node != "" && said.Node != r.Node {
+ continue
+ }
+ said.Values = atMachinePort(said.Values, said.From, with.Ports)
+ }
+ }
+
+ // A provider answered on this same machine never passed through the walk that works out what a
+ // provider on ANOTHER machine serves (novox/hq 04-ISSUES/038, ADR 0066). That walk does two
+ // things — it derives the served facts with the provider node's port assignments, and it settles
+ // them with that node's settings layers — and the same-node paths (resolve.go's servedHere, and
+ // here() below) did neither, because both run while resolving, before either is known.
+ //
+ // The port was the first half to be noticed (038). The second half is worse and quieter: a served
+ // value the OPERATOR supplied — a certificate authority's root, which is the one kind of value a
+ // manifest cannot carry, because it differs on every mesh — arrived as the manifest's empty
+ // default. A proxy then wrote an empty CA bundle, fell back to the system trust store, could not
+ // verify the internal authority, and stopped issuing with nothing saying why.
+ //
+ // So it is re-derived here, in full, exactly as the cross-node path derives it. **After the
+ // closure is known**, which also removes an order dependence: the resolver built these needs
+ // mid-walk from whichever modules had been chosen by then, so what a co-located binding carried
+ // depended on the order somebody happened to assign things in.
+ for i := range r.Needs {
+ if r.Needs[i].ByRecord || r.Needs[i].From != r.Node {
+ continue
+ }
+ serves, answered, err := resolutionServedOnThisMachine(r, r.Needs[i].Name, with)
+ if err != nil {
+ return nil, err
+ }
+ if answered {
+ r.Needs[i].Serves = serves
+ }
+ }
+
+ // Once, from every module's listens -- not per module. A module receiving only its own ports
+ // would write a rule set that closed every other module on the machine. Each module's per-node
+ // exposure settings override its listens' source first (novox/hq ADR 0046).
+ rules, err := Rules(r, with)
+ if err != nil {
+ return nil, err
+ }
+ // **A machine that has not said which links face outside is sent no filter** (novox/hq ADR
+ // 0140). The whole chain is written around those links: with none, the rule that lets this
+ // machine's own guests keep working would name an empty set, which nftables refuses, and a rule
+ // set that does not load is a machine filtering nothing while its unit reports success. Refused
+ // here, where a person reads it, rather than on the machine — and the machine keeps the filter
+ // it already has.
+ if filters := resolutionFiltersHere(r); filters != "" && len(with.OutwardLinks) == 0 {
+ return nil, fmt.Errorf(
+ "%s cannot be sent a filter: it has not reported which of its links face outside, and "+
+ "every rule in the chain is written around them. It reports that on each apply; "+
+ "`node show %s` says whether it has. Until then %s is not sent, and the machine "+
+ "keeps the filter it has", r.Node, r.Node, filters)
+ }
+ filtering := AsNftablesWithLocal(rules, with.Mesh, r.PublicDomain != "", with.Foundation,
+ with.OutwardLinks, with.TunnelInterface, with.LocalNetworks)
+
+ // **A variable two modules set is refused whether or not anything places it** (novox/hq ADR
+ // 0203 §5): the account has one environment, and a machine whose holder arrives later should not
+ // be the moment two modules are found to disagree about it.
+ if err := variablesSetOnce(r.Modules); err != nil {
+ return nil, err
+ }
+
+ generated, err := resolutionGeneratedHere(r, with)
+ if err != nil {
+ return nil, err
+ }
+
+ var out []map[string]any
+ for _, m := range r.Modules {
+ if with.Adopted && m.Filtering != nil {
+ // Nothing of a module that loads a filter, on an adopted node: its table would drop
+ // by default and hold accepts, and the found firewall stays in force. Every resource,
+ // not only the rule set — its service must not run, and a node returned to adopted
+ // stops it by the ordinary removal of what is no longer declared.
+ continue
+ }
+ resources := m.Resources
+
+ // What the mesh computes for this module goes FIRST, before the module's own resources.
+ //
+ // **Order is stated, not derived — the host does not sort** (novox/hq ADR 0005), so
+ // whatever the mesh writes down is the order a machine applies. A module's service or
+ // container routinely depends on one of these files; nothing here ever depends on a
+ // module's resources, because none of it is computed from them.
+ //
+ // Appended, this was wrong in a way that only showed on the first apply and then healed:
+ // the service started before its certificate or its rule set existed, failed, and the next
+ // reconcile fixed it. A fault that repairs itself on the second attempt is worse than one
+ // that does not, because what gets remembered is that it works.
+ var first []map[string]any
+ if f := m.Filtering; f != nil {
+ first = append(first, map[string]any{
+ "id": mcat.FilteringID(), "type": "file", "path": f.Into,
+ "content": filtering, "mode": "0600",
+ })
+ }
+ // The node's fail2ban jails, composed from every module it runs (novox/hq to-be 31), written
+ // where the intrusion-prevention holder owns them. Like the rule set above: gathered from all
+ // modules, written by the one that holds the role.
+ if j := m.Jailing; j != nil {
+ first = append(first, jailsInto(r.Modules, j)...)
+ }
+ if c := m.Certificate; c != nil {
+ if with.Certificate == "" {
+ // Asked for and not issued. Refused rather than skipped: a module that serves TLS
+ // with no certificate does not start, and the reason is somewhere else entirely.
+ return nil, fmt.Errorf(
+ "%s wants a certificate for this machine and none was issued", m.Module)
+ }
+ first = append(first, map[string]any{
+ "id": mcat.CertificateID(), "type": "file", "path": c.Into,
+ // Public. It travels in the open like any other file, because it is a statement
+ // about a key rather than the key.
+ "content": with.Certificate, "mode": "0644",
+ })
+ if c.Authority != "" {
+ first = append(first, map[string]any{
+ "id": mcat.AuthorityID(), "type": "file", "path": c.Authority,
+ "content": with.Authority, "mode": "0644",
+ })
+ }
+ }
+ if m.BusUsers != "" {
+ // **The claim authorises it, not the field.** This file holds every user's password
+ // hash, so a module that could ask for it could read every credential on the bus.
+ // Checked from this manifest alone, which is the cheapest check there is: whether some
+ // other module also claims the seat is resolution's business elsewhere, and one holder
+ // mesh-wide is already guaranteed.
+ if !m.ClaimsSeat("mesh-broker") {
+ return nil, fmt.Errorf(
+ "%s asks for the mesh's user list and does not claim mesh-broker. That file "+
+ "holds every user's password hash, so the seat is what authorises it",
+ m.Module)
+ }
+ if with.BusUsers == "" {
+ // Asked for and not composed. Refused rather than skipped, for the reason a
+ // certificate is: a bus with no user list refuses every connection in the mesh, and
+ // an empty file would look like a configuration problem on the machine.
+ return nil, fmt.Errorf(
+ "%s holds mesh-broker and the mesh composed no user list, so the bus would "+
+ "refuse every connection", m.Module)
+ }
+ first = append(first, map[string]any{
+ "id": mcat.BusUsersID(), "type": "file", "path": m.BusUsers,
+ "content": with.BusUsers,
+ // Readable by the server and nothing else. Hashes rather than passwords, so this is
+ // not a set of working credentials — but a list of every user in the mesh is worth
+ // keeping to the one process that needs it.
+ "mode": "0600",
+ })
+ }
+ // **A secret family's members, as given** (novox/hq ADR 0283): one file each at the family's path, and
+ // nothing for a member not given — the mesh never makes one, and the module says it waits for it.
+ for _, name := range sortedKeys(with.Needed[m.Module]) {
+ own, family, ok := m.OwnSecrets.Lookup(name)
+ if !ok || family == "" || with.Needed[m.Module][name] == "" {
+ continue
+ }
+ // A per-secret owner (novox/hq ADR 0436 §1) stands in for the module's secrets-owner, when
+ // the manifest named one for this secret (or the family it belongs to).
+ owner := m.SecretsOwner
+ if own.Owner != "" {
+ owner = own.Owner
+ }
+ first = append(first, ownedBy(owner, map[string]any{
+ "id": mcat.NeedID(name), "type": "file", "path": own.Path, "sealed": with.Needed[m.Module][name],
+ }))
+ }
+ // **A handed module's own secrets are the vault's** (design 57 V4, issue 647): each file holding one is
+ // declared placed and without a value, filled by the machine's provisioner client from the vault; the
+ // controller makes and seals none of them here.
+ handsSecrets := Handing(with, m)
+ for _, name := range sortedKeys(m.OwnSecrets.Plain()) {
+ if handsSecrets {
+ owner := resolutionOwnSecretOwner(r, m, name)
+ first = append(first, ownedBy(owner, map[string]any{
+ "id": mcat.NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, mcat.HandedSecretField: name,
+ }))
+ continue
+ }
+ // **A core credential is the vault's box** (ADR 0349 §16, issue 755): carried as the vault sealed it to this
+ // machine, which the controller cannot open; the controller's own sealed value is not sent with it.
+ if box, ok := renderingVaultSealedOf(with, m.Module, name); ok {
+ if string(box) == string(mcat.ForeseenVaultBox) {
+ *foreseen = append(*foreseen, m.Module+"/"+name)
+ }
+ owner := resolutionOwnSecretOwner(r, m, name)
+ first = append(first, ownedBy(owner, map[string]any{
+ "id": mcat.NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, mcat.VaultSealedField: box,
+ }))
+ continue
+ }
+ sealed := with.Needed[m.Module][name]
+ if sealed == "" && with.Foreseen[m.Module][name] {
+ // Not made, and the next send makes it: composed with a stand-in so that whatever
+ // else this composition would refuse is still found (novox/hq issue 275).
+ sealed = mcat.ForeseenSealed
+ *foreseen = append(*foreseen, m.Module+"/"+name)
+ }
+ if sealed == "" && m.OwnSecrets[name].IssuedBy == mcat.IssuedOutside {
+ // **Not given, and the mesh never makes it** (novox/hq issue 401, ADR 0228): no file, as for a
+ // family's member (ADR 0283), so the module reads its absence as not given.
+ continue
+ }
+ if sealed == "" {
+ // Declared and not made. Refused rather than skipped: a module whose own
+ // credential is silently absent starts, fails to authenticate, and the reason is
+ // three layers away from the machine reporting it.
+ return nil, &NotMadeError{Module: m.Module, Name: name}
+ }
+ // The runtime's credential belongs to the account the runtime runs as (novox/hq ADR 0175,
+ // to-be 38 WP3): its process is composed `user: ` where the node has one, and a
+ // root-owned 0600 file is one that process cannot read. Composed here rather than said in
+ // the manifest, because a manifest cannot say ${machine:account} safely — a node with no
+ // account has nothing to resolve it to, and then the runtime runs as root and the file
+ // stays root's.
+ first = append(first, ownedBy(resolutionOwnSecretOwner(r, m, name), map[string]any{
+ "id": mcat.NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, "sealed": sealed,
+ }))
+ }
+ // This module's tools bundles, where the machine runs the node's tool runtime (novox/hq
+ // ADR 0175, to-be 38 WP2). Mesh-computed like everything above it, and before the module's
+ // own resources for the same reason: the runtime's process names the files inside these
+ // and is restarted when one changes, so they are on the machine before it is.
+ if resolutionRuntimeHere(r) {
+ first = append(first, bundleArchives(m)...)
+ }
+ // Operator-owned paths this module is granted use of (novox/hq ADR 0051). Written before
+ // the module's own resources, and so before the container that mounts them: the host must
+ // find each present — refusing clearly if the operator has not provided it — before it
+ // starts anything that depends on it. The mesh creates, chowns and reconciles none of it;
+ // an `access` resource says only *this path must exist, and this module reaches it*.
+ // Where each is on THIS machine is the assignment's (novox/hq issue 153): placed by id
+ // where the operator said, the definition's default otherwise, refused where neither.
+ accesses, accessPaths, err := accessesFor(m, with.Settings[m.Module])
+ if err != nil {
+ return nil, err
+ }
+ for _, a := range accesses {
+ first = append(first, map[string]any{
+ "id": mcat.AccessID(a.Path), "type": "access", "path": a.Path, "mode": a.Mode,
+ })
+ }
+ for _, to := range m.SecretRequirements() {
+ for _, file := range m.SecretFiles(to) {
+ var found *mcat.Needed
+ for i, n := range r.Needs {
+ // **This module's need, not the provision's** (novox/hq 04-ISSUES/022). Matching
+ // on the name alone, every consumer of a provision took whichever credential
+ // happened to be last in the list — so on a node with two of them, one module
+ // would be given the other's password and fail to authenticate with a valid
+ // credential belonging to somebody else. And this file's local name, where the
+ // module keeps several (ADR 0094).
+ if n.Name == to && n.For == m.Module && n.Local == file.Local {
+ found = &r.Needs[i]
+ }
+ }
+ // **A credential the vault keeps for this holder alone** (issue 757): where the module is handed, its
+ // file is declared placed and without a value, and the machine's client fills it from the vault.
+ if found != nil && found.IssuedToHolder && Handing(with, m) {
+ first = append(first, ownedBy(m.SecretsOwner, map[string]any{
+ "id": mcat.SecretID(mcat.SecretLocal(to, file.Local)), "type": "file", "path": file.Path, mcat.HandedProvisionField: mcat.PlanProvision{Name: mcat.SecretLocal(to, file.Local), Provision: to,
+ Local: file.Local, Provider: found.From, ProviderModule: found.Module},
+ }))
+ continue
+ }
+ if found != nil && found.ByRecord && found.Sealed == "" && !found.Manager {
+ // Answered by a record whose key has not been supplied since this consumer was
+ // put on it. **Refused, not skipped.** The mesh discarded the plaintext when the
+ // key was accepted and cannot seal another, so a machine that resolved cleanly
+ // would receive no file at all and fail at whatever tried to read it — which is
+ // the outcome ADR 0024 exists to avoid, arrived at politely.
+ //
+ // The manager holder is the one exception (novox/hq ADR 0050): an empty refresh token
+ // is a licence whose manager has not adopted one yet, a real waiting state rather than
+ // a lost key. It falls through to the skip below — its bound facts (carrying the
+ // manager's public key) are still delivered, which is what adoption needs to seal the
+ // first refresh token.
+ return nil, fmt.Errorf(
+ "%s on this machine uses the licence %q and no key has been sealed to it. "+
+ "The mesh cannot make one; supply it again with `licence key %s`",
+ m.Module, found.From, found.From)
+ }
+ if found == nil || found.Sealed == "" {
+ // Answered on this machine, or answered by a node the mesh could not seal to.
+ // Nothing to write either way, and writing an empty credential file would be
+ // worse than none: something would read it and fail authenticating.
+ continue
+ }
+ first = append(first, ownedBy(m.SecretsOwner, map[string]any{
+ "id": mcat.SecretID(mcat.SecretLocal(to, file.Local)), "type": "file", "path": file.Path,
+ "sealed": found.Sealed,
+ }))
+ }
+ }
+ for _, to := range sortedKeys(m.Grants) {
+ for _, g := range with.Grants {
+ if g.Provision != to {
+ continue
+ }
+ if g.From == "" {
+ // Nothing on that machine asks for this any more. Skipped here rather than
+ // where grants are gathered, so the rule holds whoever gathers them.
+ //
+ // **This is how a credential is withdrawn.** The provisioner removes what
+ // nobody asks for, and it can only do that if the mesh stops asking — a
+ // consumer that was unassigned would otherwise keep a working login for ever,
+ // and nothing would say so.
+ continue
+ }
+ if g.Sealed == "" {
+ // Its offer presents no credential (form none, issue 1126): nothing to write, and an empty credential
+ // file would be read as one that failed. Who asked still reaches the provider in its contributions.
+ continue
+ }
+ first = append(first, ownedBy(resolutionProvisionsAs(r, m), map[string]any{
+ // One file per holder — the consumer's module with its local name after it
+ // where it keeps several (ADR 0094); the lab found two files with one id.
+ "id": mcat.GrantID(to, g.Consumer+"."+holderAs(g.From, g.Local)),
+ "type": "file",
+ "path": grantPath(m.Grants[to], g.Consumer, holderAs(g.From, g.Local)),
+ "sealed": g.Sealed,
+ }))
+ }
+ }
+ for _, to := range sortedKeys(m.Binds) {
+ var found *mcat.Needed
+ for i, n := range r.Needs {
+ if n.Name == to && n.For == m.Module {
+ found = &r.Needs[i]
+ }
+ }
+ if found == nil {
+ // Answered on this same machine rather than from elsewhere in the mesh.
+ //
+ // **Still written.** It used to be skipped, reasoning that "it is on this node"
+ // is a fact nobody needs — and that is true of the *location* and false of
+ // everything beside it. A binding also carries what the provider said a consumer
+ // must know, which is the port; a consumer cannot invent that, and got an absent
+ // file with no explanation. A build machine sharing a node with the registry it
+ // pushes to sat in a loop saying it could not read its own binding.
+ here, err := here(r, to, with)
+ if err != nil {
+ return nil, err
+ }
+ if here == nil {
+ // Nothing in this node's set offers it either, so there is genuinely nothing
+ // to say. Resolution has already refused anything unanswerable, so this is a
+ // requirement met by the module itself.
+ continue
+ }
+ found = here
+ }
+ own, err := resolutionOwnNames(r, m, to, with.Settings[m.Module])
+ if err != nil {
+ return nil, err
+ }
+ as := mcat.ConsumerIdentity(r.Node, mcat.IdentitySource(m.Slug, m.Module))
+ // What the provider derives for THIS consumer, filled here where the consumer is
+ // known (novox/hq ADR 0202). The same fill knownFor does below, so the binding file
+ // and the module's `${bound:…}` substitutions cannot say different things.
+ told := *found
+ told.Serves, err = ServedTo(told.Serves, as)
+ if err != nil {
+ return nil, fmt.Errorf("%s is told about %s: %w", m.Module, to, err)
+ }
+ file, err := boundFile(told, m.Binds[to], as, own)
+ if err != nil {
+ return nil, err
+ }
+ first = append(first, file)
+ }
+ for _, to := range sortedKeys(m.Receives) {
+ file, err := receivedFile(to, m.Receives[to], given[to])
+ if err != nil {
+ return nil, err
+ }
+ first = append(first, file)
+ if received[m.Module] == nil {
+ received[m.Module] = map[string][]mcat.Contribution{}
+ }
+ // Empty rather than absent when nobody contributed, for the reason the file is
+ // written empty: "nothing asked" and "never told" want different responses.
+ received[m.Module][to] = append([]mcat.Contribution{}, given[to]...)
+ }
+ if m.Keeps != "" && with.Kept != nil {
+ file, err := keptFile(m.Keeps, with.Kept)
+ if err != nil {
+ return nil, err
+ }
+ // Read by the module as its secrets' owner when it names one: the vault, as its own account (issue 723).
+ first = append(first, ownedBy(m.SecretsOwner, file))
+ } else if runs, ok := with.KeptRuns[m.Module]; m.Keeps != "" && ok {
+ file := make(map[string]any, len(runs))
+ for k, v := range runs {
+ file[k] = v
+ }
+ file["id"] = KeptID()
+ delete(file, "module")
+ first = append(first, file)
+ }
+ if m.Computed != "" {
+ mine, part := generated[m.Module]
+ if !part {
+ // Assigned, and not yet part of what this generates. Nothing to put on the
+ // machine, which is different from an error: a node given the network module
+ // before it has an address is in exactly that state, briefly.
+ continue
+ }
+ resources = mine
+ }
+
+ // Now, and not before: a module whose resources are computed replaces them wholesale, and
+ // merging earlier would throw away the files it still needs.
+ //
+ // **Except the module's own accounts, which go before even those** (novox/hq issue 213). What
+ // the mesh computes may belong to one: a module whose code runs as an account it declares has
+ // its secrets written owned by that account, and a file given to a user the machine does not
+ // have yet fails — so on the first apply the secrets were refused, the process started without
+ // them, and the second apply healed it, which is the fault the paragraph above describes.
+ // An account depends on nothing the mesh computes.
+ accounts, rest := accountsFirst(resources)
+ resources = append(append(accounts, first...), rest...)
+ // **A file delivered only while its optional requirement is bound is left out while it is
+ // absent** (novox/hq ADR 0330 §5), and the field is dropped from the rest: the node-engine
+ // parses strictly and knows no such field.
+ absent := AbsentFor(r, m.Module)
+ resources = withoutAbsent(resources, absent)
+
+ // No container is given the mesh's names (novox/hq ADR 0148). It used to be: every
+ // container got the whole roster as `--add-host` entries at creation, and a name that
+ // moved afterwards was wrong inside it for as long as it ran (issues 109, 135) — and once
+ // the roster was made part of a container's identity so that could be caught, one name
+ // moving anywhere replaced every container in the mesh (issue 151). A container resolves a
+ // mesh name through its machine's resolver at the moment it asks, which the runtime is
+ // told once per machine, as a file, by the resolver's own module. The names a module
+ // declares for itself are its own and stay exactly as written: they are part of what the
+ // module is, and the mesh does not know what they mean.
+ // What this module may name from inside one of its own files. Gathered once per module
+ // rather than per file, because it is a fact about the module.
+ sealed, err := sealedFor(m, r.Needs, with)
+ if err != nil {
+ return nil, err
+ }
+ // And what its bindings say, for the half of a connection that is not secret.
+ known, err := knownFor(m, r.Needs, r.Node)
+ if err != nil {
+ return nil, err
+ }
+ // A requirement answered on this same machine is not in r.Needs — its binding file is
+ // written from `here` (above) — and so `${bound:…}` could not name it, though the file
+ // beside it said the same facts. Filled from the same answer, so the two cannot disagree.
+ for _, want := range m.Wants() {
+ if _, has := known[want]; has {
+ continue
+ }
+ answered, err := here(r, want, with)
+ if err != nil {
+ return nil, err
+ }
+ if answered == nil {
+ continue
+ }
+ local := *answered
+ local.For = m.Module
+ here, err := knownFor(m, []mcat.Needed{local}, r.Node)
+ if err != nil {
+ return nil, err
+ }
+ for provision, values := range here {
+ known[provision] = values
+ }
+ }
+ // And the providing machine's private address, beside its name (novox/hq ADR 0194). A name is
+ // what nearly every consumer wants; the one that cannot use it is a machine's resolver
+ // configuration, which must reach the resolver before it can resolve anything — the resolver's
+ // own name included. Absent when the machine has no address yet, so a file naming it is refused
+ // rather than written with a blank where an address belongs.
+ for _, values := range known {
+ if address := with.Machines[values["at"]]; address != "" {
+ values["address"] = address
+ }
+ }
+ // And what the module is called through each requirement it contributes to (novox/hq
+ // 04-ISSUES/122) — the same composition its binding file carries.
+ for provision, values := range known {
+ own, err := resolutionOwnNames(r, m, provision, with.Settings[m.Module])
+ if err != nil {
+ return nil, err
+ }
+ withOwnNames(values, own)
+ }
+ // And where this node places the directories the module declared without a path
+ // (novox/hq ADR 0112) — resolved once per module, named by ${dir:…} from any resource.
+ // — and, on an adopted machine, where the assignment says they already are, with the
+ // owner the data already has (novox/hq issue 153). Malformed placements are refused here.
+ placed, err := mcat.Places(m, with.Settings[m.Module])
+ if err != nil {
+ return nil, err
+ }
+ dirs := dirsFor(m, with)
+ // And the machine underneath, which no binding of its own can tell it.
+ thisMachine := machineFacts(r, with.Names, with.MeshRange)
+
+ // **A definition that already holds the answer transcribed it** (novox/hq ADR 0202).
+ // Judged over what the module itself declares, and before anything is substituted: the
+ // mesh's own generated files — the binding, the contributions — legitimately carry the
+ // derived value, and after substitution so does every consumer's file, so this is the one
+ // moment the two can be told apart.
+ for _, own := range m.Resources {
+ if err := notTranscribed(own, known, m.Module); err != nil {
+ return nil, err
+ }
+ }
+ // **And every placeholder no pass fills where it stands is refused** (novox/hq issue 231):
+ // judged here, over the definition as written and before any pass, by the function the
+ // catalogue check runs — so a manifest registered by an older binary is judged too, and
+ // what a setting or a binding later puts into a file is its software's text, never swept.
+ for _, own := range m.Resources {
+ if problems := unconsumedPlaceholders(m.Module, own); len(problems) > 0 {
+ return nil, fmt.Errorf("%s", problems[0])
+ }
+ }
+
+ // Which of this module's files carry a secret, for the rule that a container may not read
+ // one of them as its environment without saying so (ADR 0086, issue 041).
+ secretFiles := secretFilesOf(resources)
+
+ // Which of this module's resources its preparation runs before, if it prepares anything.
+ prepareBefore := preparationTarget(m)
+
+ // Which found networks this machine's setting keeps for each of its containers (novox/hq
+ // ADR 0163, rule 4); judged above, so an invalid one is not here.
+ keptNetworks, _ := KeptNetworks(m, with.Settings[m.Module], with.Adopted)
+
+ // **A moved module's templated files are the provisioner's** (novox/hq ADR 0349 §4, §6, design 57 P6): on a
+ // machine that can take them, each is declared placed and without its text, and no pass below fills it.
+ handing, err := handed.begin(r.Node, m, dirs, accessPaths, thisMachine, with, r.Capabilities, r.Needs,
+ backupPlacer{append(append([]mcat.Manifest(nil), r.Modules...), stillBackedUp...), with, thisMachine, r.Capabilities,
+ unplaced})
+ if err != nil {
+ return nil, err
+ }
+ // **A plan for what the seat keys of it all the same** (catalogue.Keyed, issue 774): a module not handed whose
+ // provider's helper answers a provision of it, its route the commonest, has that provision in a plan of its own,
+ // so the seat keys it and the helper hears it.
+ keyedOnly := handing == nil && handed.keyed(r.Node, m, r.Needs)
+ if _, ok := handed.plans[m.Module]; ok && (handing != nil || keyedOnly) {
+ for _, to := range requirementsContributedTo(m) {
+ own, err := resolutionOwnNames(r, m, to, with.Settings[m.Module])
+ if err != nil {
+ return nil, err
+ }
+ handed.name(m.Module, to, own)
+ contributed, err := resolutionContributedOf(r, m, to, with)
+ if err != nil {
+ return nil, err
+ }
+ handed.contribute(m.Module, to, contributed)
+ }
+ }
+ // A handed collector is filled from items alone: each old form its collections would have held is said
+ // (design 57 L1).
+ if plan, ok := handed.plans[m.Module]; ok && handing != nil && len(plan.Collections) > 0 {
+ *unplaced = append(*unplaced, oldFormsLeftOut(append(append([]mcat.Manifest(nil), r.Modules...),
+ stillBackedUp...), m, plan.Collections)...)
+ }
+ for _, unsettled := range resources {
+ if pr, ok := unsettled[mcat.HandedProvisionField].(mcat.PlanProvision); ok {
+ if handing == nil && m.Stubbed {
+ continue // held: its credential file is what runs of it, left out (issue 1126)
+ }
+ if handing == nil {
+ return nil, fmt.Errorf("%s: its credential of %s is handed to the provisioner and the module is "+
+ "not: nothing would fill it", m.Module, pr.Provision)
+ }
+ marked := map[string]any{}
+ for k, v := range unsettled {
+ marked[k] = v
+ }
+ delete(marked, mcat.HandedProvisionField)
+ marked[mcat.HandedSecretField] = pr.Name
+ file, sec, err := handedSecretFile(marked, m, dirs, accessPaths, thisMachine, placed)
+ if err != nil {
+ return nil, err
+ }
+ file["id"] = m.Module + "." + fmt.Sprint(unsettled["id"])
+ owner[fmt.Sprint(file["id"])] = m.Module
+ pr.Path = sec.Path
+ if err := handed.addProvisionFile(m.Module, pr); err != nil {
+ return nil, err
+ }
+ out = append(out, file)
+ continue
+ }
+ if unsettled[mcat.HandedSecretField] != nil {
+ if handing == nil && m.Stubbed {
+ continue // held: its own secret's file is what runs of it, left out (issue 1126)
+ }
+ if handing == nil {
+ return nil, fmt.Errorf("%s: its own secret %v is handed to the provisioner and the module is "+
+ "not: nothing would fill it", m.Module, unsettled[mcat.HandedSecretField])
+ }
+ file, sec, err := handedSecretFile(unsettled, m, dirs, accessPaths, thisMachine, placed)
+ if err != nil {
+ return nil, err
+ }
+ file["id"] = m.Module + "." + fmt.Sprint(unsettled["id"])
+ owner[fmt.Sprint(file["id"])] = m.Module
+ if err := handed.addSecretFile(m.Module, sec); err != nil {
+ return nil, err
+ }
+ out = append(out, file)
+ continue
+ }
+ if handing != nil && fmt.Sprint(unsettled["type"]) == "file" {
+ if text, _ := unsettled["content"].(string); len(secretsUsed(text)) > 0 &&
+ !handing[fmt.Sprint(unsettled["id"])] {
+ return nil, fmt.Errorf("%s: its file %v names a secret in its text, and a handed module's "+
+ "secrets reach only the files its provisioner client fills: keep it in a template "+
+ "(design 57 V4)", m.Module, unsettled["id"])
+ }
+ }
+ if handing[fmt.Sprint(unsettled["id"])] {
+ file, line, err := handedFile(unsettled, m, dirs, accessPaths, thisMachine, placed, with)
+ if err != nil {
+ return nil, err
+ }
+ file["id"] = m.Module + "." + fmt.Sprint(unsettled["id"])
+ owner[fmt.Sprint(file["id"])] = m.Module
+ if err := handed.add(m.Module, line); err != nil {
+ return nil, err
+ }
+ out = append(out, file)
+ continue
+ }
+ if fmt.Sprint(unsettled["type"]) == "file" {
+ if _, left := consumersHandedOnly(m, unsettled, with); left {
+ *handedOnly = append(*handedOnly, consumersLeftOutLine(m, unsettled))
+ continue
+ }
+ }
+ resource, err := ApplySettings(unsettled, with.Settings[m.Module])
+ if err != nil {
+ return nil, err
+ }
+ copied := map[string]any{}
+ for k, v := range resource {
+ copied[k] = v
+ }
+ if networks, keeps := keptNetworks[fmt.Sprint(copied["id"])]; keeps {
+ // The container also joins the found network the setting names, so a neighbour
+ // that resolves it there keeps resolving it. Passed to the host as its own field,
+ // which it joins after the container is made.
+ joins := make([]any, 0, len(networks))
+ for _, n := range networks {
+ joins = append(joins, n)
+ }
+ copied["networks"] = joins
+ }
+ if err := refuseSecretsInEnvironment(copied, secretFiles, m.Module); err != nil {
+ return nil, err
+ }
+ // 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, mcat.SecretsInEnvironment)
+ delete(copied, mcat.NamesOnPurpose)
+ delete(copied, mcat.TrustedField)
+ // A process's bus stays controller-side (novox/hq ADR 0436 §1): never composed onto the
+ // node-engine's process resource, whose wire declaration is mesh-host's own, vendored, with a
+ // strict decoder that refuses an unknown key.
+ delete(copied, mcat.ProcessBusField)
+ // **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 := unsetInConfigWords(settingInto(copied, WithDefaults(m, with.Settings[m.Module]), m.Module), m); 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
+ // (novox/hq ADR 0112). The host receives paths exactly as it always has.
+ if err := dirInto(copied, dirs, m.Module); err != nil {
+ return nil, err
+ }
+ // The operator's data the same way: ${access:…} becomes where this node keeps it,
+ // and a placed directory takes the owner the assignment said (issue 153).
+ if err := accessInto(copied, accessPaths, m.Module); err != nil {
+ return nil, err
+ }
+ ownerInto(copied, placed)
+ // **After settings, and that is the whole reason it is here.** A module's file
+ // content is where a setting lands, so a placeholder may only exist once the setting
+ // has been put in — filling secrets first would look at content that is not yet what
+ // the machine receives.
+ if err := intoFile(copied, sealed, outsideNotGiven(m, with.Needed[m.Module]), m.Module); err != nil {
+ return nil, err
+ }
+ // **Substituted here, not on the machine.** A bound value is not secret — the mesh
+ // holds it in the clear — so there is nothing for the host to be the only witness of,
+ // and sending it already filled in means the host learns no new field.
+ if err := boundInto(copied, known, m.Module, absent); err != nil {
+ return nil, err
+ }
+ // And what the module could not have written: the machine it turned out to be
+ // assigned to. Beside the bound values because it is the same kind of fact — the
+ // mesh's own, held in the clear — and because a module that must name itself to
+ // something else has no binding to learn it from (novox/hq ADR 0066).
+ // Which port this machine gave it, for a module that binds directly rather than
+ // through a runtime that can remap (ADR 0038). Applied before the machine's facts so
+ // a refusal names the port rather than whatever came after it.
+ if err := portInto(copied, m.Module, m.Listens, with); err != nil {
+ return nil, err
+ }
+ // And where this machine put the foundation's servers, for the one module that
+ // reaches them by seat rather than by binding (novox/hq 04-ISSUES/102).
+ if err := seatInto(copied, m.Module, with); err != nil {
+ return nil, err
+ }
+ if err := machineInto(copied, thisMachine, m.Module); err != nil {
+ return nil, err
+ }
+ if err := pinned(copied, m.Module); err != nil {
+ return nil, err
+ }
+ // And the other half of the same question: an image nobody has published yet is named
+ // by an artifact rather than by a placeholder digest, and is just as unrunnable.
+ if err := built(copied, m.Module); err != nil {
+ return nil, err
+ }
+ // What the mesh built is kept by digest and path; the store's address is this
+ // network's now, composed here and never recorded (novox/hq 04-ISSUES/102).
+ if err := artifactsInto(copied, m.Module, with); err != nil {
+ return nil, err
+ }
+ publishedOn(copied, m.Module, with)
+ // How it is ready, in the node-engine's words: its endpoint as the port this machine
+ // published it on — or not sent at all to an engine older than the field (ADR 0240).
+ healthInto(copied, m, with)
+ // And whether it is busy (novox/hq ADR 0335): sent only to an engine that asks it.
+ busyInto(copied, with)
+ // And a user's `root` (novox/hq ADR 0266): sent only when it asserts something, to an engine
+ // that judges it.
+ rootInto(copied, with)
+ // The account's environment and every module's shell code, where this module holds the
+ // seat that places them (novox/hq ADR 0203, ADR 0204). Gathered from every module on
+ // the node, as the jails are, and **last of every placeholder pass**: shell code is a
+ // shell's own syntax, full of `${…}` no pass above should ever be shown.
+ contributing := append(append([]mcat.Manifest(nil), r.Modules...), stillBackedUp...)
+ if err := contributionsInto(copied, m, contributing, thisMachine, with, r.Capabilities, unplaced); err != nil {
+ return nil, err
+ }
+ copied["id"] = m.Module + "." + fmt.Sprint(resource["id"])
+ // A service saying what it reflects names resources within its own module, so those
+ // are prefixed too or they would point at nothing.
+ //
+ // **Unless it already names one.** A module may reflect a file another module put on
+ // the machine — the case this exists for is a resolver restarting when the mesh
+ // rewrites the names, which are computed by the mesh and belong to its module, not to
+ // the daemon's. Written `.`, and a dot is what marks it as already
+ // answered: prefixing it again would point at nothing, silently, and the daemon would
+ // serve the old names for ever while everything reported success.
+ if renamed := reflectsRenamed(m.Module, resource["restart-on"]); renamed != nil {
+ copied["restart-on"] = renamed
+ }
+ // And what it is reloaded on, by the same rule (novox/hq ADR 0102): an id left
+ // unprefixed matches nothing, and the service is never reloaded.
+ if renamed := reflectsRenamed(m.Module, resource["reload-on"]); renamed != nil {
+ copied["reload-on"] = renamed
+ }
+ // And which of its module's containers a scheduled step holds still (novox/hq ADR 0189).
+ // **The loudest of the three when it is missed.** An unprefixed `restart-on` matches
+ // nothing and a service quietly never restarts; an unprefixed `while-stopped` names a
+ // container the declaration does not contain, and the host refuses the whole
+ // declaration — so the machine takes nothing at all, for every push, until this is
+ // right. That is what it did on the control node (2026-10-04).
+ if renamed := reflectsRenamed(m.Module, resource[mcat.WhileStopped]); renamed != nil {
+ copied[mcat.WhileStopped] = renamed
+ }
+ // And what a process replaces (novox/hq issue 213): a resource of this module's that it
+ // no longer declares, named as the host recorded it, or the host hands nothing over and
+ // removes it first.
+ if renamed := reflectsRenamed(m.Module, resource["replaces"]); renamed != nil {
+ copied["replaces"] = renamed
+ }
+ // **What reads one of this module's own secrets is restarted when it changes** (novox/hq
+ // issue 203, issue 206). A credential is re-issued by the mesh, and a container that
+ // mounted the old file keeps the old one open: the build machine ran for an hour on a
+ // credential the mesh had replaced, because its manifest restarted it on its
+ // environment file and nobody had thought to name the credential too. Composed here so
+ // no manifest has to say it, for a container or a daemon that names the secret's path.
+ if reads := secretsReadBy(copied, m, with.Needed[m.Module]); len(reads) > 0 {
+ copied["restart-on"] = withRestartOn(copied["restart-on"], reads)
+ }
+ // **A version prepares its state before it runs** (novox/hq ADR 0135). Derived from the
+ // module's own resource rather than declared beside it: what prepares the state is the
+ // module's own code, so what it is given has to be what that code is given — and a
+ // second resource written by hand is a second copy to drift from the first. Placed
+ // immediately before it, because a run-once step stops everything the declaration
+ // places after it (ADR 0052), which is how a version whose preparation failed does not
+ // serve.
+ if prepareBefore != "" && fmt.Sprint(resource["id"]) == prepareBefore {
+ step := prepared(copied)
+ owner[fmt.Sprint(step["id"])] = m.Module
+ out = append(out, step)
+ }
+ owner[fmt.Sprint(copied["id"])] = m.Module
+ out = append(out, copied)
+ }
+
+ // **What only the mesh knows, where this module asked for it.** The graph is the control
+ // plane's; making a name resolve is the module's software. Emitted as ordinary files under
+ // this module's name, so they are applied, reported and removed exactly as anything else
+ // it declares.
+ // The resolver file is the node-resolver holder's where one is held here, and the uplink's
+ // holder steps back from it (novox/hq ADR 0247).
+ given, err := FactsFrom(stepsBack(m, r.Modules), r, with)
+ if err != nil {
+ return nil, err
+ }
+ for _, fact := range given {
+ fact["id"] = m.Module + "." + fmt.Sprint(fact["id"])
+ owner[fmt.Sprint(fact["id"])] = m.Module
+ out = append(out, fact)
+ }
+ }
+ // The node's tool runtime, last (novox/hq ADR 0175, to-be 38 WP2.3): one process loading every
+ // bundle delivered above and holding the credential sealed above, so both exist before it starts
+ // — the order written here is the order the machine applies.
+ if resolutionRuntimeHere(r) {
+ process, err := resolutionRuntimeProcess(r, with)
+ if err != nil {
+ return nil, err
+ }
+ if process != nil { // none for a held runtime's stand-in: what runs of it is carried
+ owner[fmt.Sprint(process["id"])] = mcat.RuntimeModule
+ out = append(out, process)
+ }
+ // And a runtime of its own for each module of its own account, after it (novox/hq ADR 0259 §8).
+ owns, err := resolutionOwnRuntimes(r, with)
+ if err != nil {
+ return nil, err
+ }
+ for _, p := range owns {
+ id := fmt.Sprint(p["id"])
+ owner[id] = strings.TrimSuffix(id, "."+mcat.OwnRuntimeID())
+ out = append(out, p)
+ }
+ // What each module's bundles are given is read as the account the runtime runs as — not what a
+ // module of its own account is given, which its own account reads.
+ words := map[string]map[string]string{}
+ for _, m := range r.Modules {
+ if m.RunsAs != "" {
+ continue
+ }
+ w, err := bundleWords(m, with)
+ if err != nil {
+ return nil, err
+ }
+ words[m.Module] = w
+ }
+ // And a file a module's words name is one the runtime is restarted for when it changes.
+ if named := givenTo(out, owner, words, r.Account); len(named) > 0 && process != nil {
+ restarts, _ := process["restart-on"].([]any)
+ seen := map[string]bool{}
+ for _, id := range restarts {
+ seen[fmt.Sprint(id)] = true
+ }
+ for _, id := range named {
+ if !seen[id] {
+ restarts = append(restarts, id)
+ seen[id] = true
+ }
+ }
+ process["restart-on"] = restarts
+ }
+ // **And what a module of its own account is given, its own account reads** (issue 723): the files and
+ // directories its words name that say no owner, given to the account it runs as, never the operator's.
+ for _, m := range r.Modules {
+ if m.RunsAs == "" {
+ continue
+ }
+ w, err := bundleWords(m, with)
+ if err != nil {
+ return nil, err
+ }
+ givenTo(out, owner, map[string]map[string]string{m.Module: w}, m.RunsAs)
+ }
+ }
+ if with.Adopted {
+ // First, before anything a module declares: what the mesh needs reachable, then its guard.
+ // The order a machine applies is the order written here.
+ ours := Openings(rules, with.Foundation, mcat.Published(out))
+ ours = append(ours, GuardResources(resolutionGuarded(r, out, owner, rules, with))...)
+ out = append(ours, out...)
+ }
+ return out, nil
+}
+
+// guarded is what the mesh's guard refuses on an adopted node (novox/hq ADR 0103): derived, and
+// for taken modules only.
+//
+// For each taken module, every machine port its containers publish that the filter would admit
+// from the private network only — a published port is forwarded, not received, so a found
+// firewall filtering only what it receives never sees it — together with the ports the module's
+// manifest guards explicitly (the store's port, the broker's management port), wherever the
+// machine put them. A port of a module assigned but not taken is not guarded: it may still be the
+// predecessor's, serving the predecessor's other machines. The foundation's ports are admitted
+// from everywhere and are never guarded.
+func resolutionGuarded(r mcat.Resolution, out []map[string]any, owner map[string]string, rules []mcat.Rule,
+ with mcat.Rendering) []int {
+ meshOnly := map[int]bool{}
+ fromEverywhere := map[int]bool{}
+ for _, rule := range rules {
+ if rule.Protocol != "tcp" {
+ continue
+ }
+ switch rule.From {
+ case mcat.FromMesh:
+ meshOnly[rule.Port] = true
+ case mcat.FromEverywhere:
+ fromEverywhere[rule.Port] = true
+ }
+ }
+ for _, port := range with.Foundation {
+ delete(meshOnly, port)
+ fromEverywhere[port] = true
+ }
+ seen := map[int]bool{}
+ var ports []int
+ guard := func(at int) {
+ if !seen[at] {
+ seen[at] = true
+ ports = append(ports, at)
+ }
+ }
+ for _, m := range r.Modules {
+ if !with.Taken[m.Module] {
+ continue
+ }
+ var mine []map[string]any
+ for _, resource := range out {
+ if owner[fmt.Sprint(resource["id"])] == m.Module {
+ mine = append(mine, resource)
+ }
+ }
+ for outer := range mcat.Published(mine)["tcp"] {
+ if meshOnly[outer] {
+ guard(outer)
+ }
+ }
+ for _, want := range m.Guards {
+ at := renderingMachinePort(with, m.Module, want)
+ for _, resource := range mine {
+ if fmt.Sprint(resource["type"]) != "container" {
+ continue
+ }
+ listed, _ := resource["ports"].([]any)
+ for _, entry := range listed {
+ outer, inner, _, ok := mapping(fmt.Sprint(entry))
+ if ok && inner == want {
+ at = outer
+ }
+ }
+ }
+ // Not what this node is told to open to everyone: a per-node exposure setting that
+ // widens a guarded port is the operator saying so, and declaring an opening for it
+ // and a guard dropping it would be one statement refusing the other.
+ if !fromEverywhere[at] {
+ guard(at)
+ }
+ }
+ }
+ sort.Ints(ports)
+ return ports
+}
+
+// mapping reads a container's port mapping — `[address:]outer:inner[/protocol]`, the address
+// possibly an IPv6 one in brackets — indexing from the end, so an address's own colons never
+// shift the ports. Not ok for a short form or anything that is not a mapping.
+func mapping(written string) (outer, inner int, address string, ok bool) {
+ written = strings.TrimSpace(written)
+ if cut := strings.LastIndex(written, "/"); cut >= 0 {
+ written = written[:cut]
+ }
+ parts := strings.Split(written, ":")
+ if len(parts) < 2 {
+ return 0, 0, "", false
+ }
+ inner, err := strconv.Atoi(parts[len(parts)-1])
+ if err != nil {
+ return 0, 0, "", false
+ }
+ outer, err = strconv.Atoi(parts[len(parts)-2])
+ if err != nil {
+ return 0, 0, "", false
+ }
+ return outer, inner, strings.Join(parts[:len(parts)-2], ":"), true
+}
+
+// filtersHere is the module on this node that loads the machine's packet filter, or empty when none
+// does. Named rather than counted: a refusal that says which module is one step from acted on.
+func resolutionFiltersHere(r mcat.Resolution) string {
+ for _, m := range r.Modules {
+ if m.Filtering != nil {
+ return m.Module
+ }
+ }
+ return ""
+}
+
+// Rules is the rule set this node's filter is derived from: every module's listens, what was
+// computed for this machine, and each module's per-node exposure. The same answer whether the node
+// is adopted or converged — the one loads it as a filter, the other declares it as openings.
+func Rules(r mcat.Resolution, with mcat.Rendering) ([]mcat.Rule, error) {
+ // A module whose settings cannot compose is left out of the declaration (novox/hq ADR 0163,
+ // rule 6), and out of the filter with it: nothing of it is declared, so nothing of it is let
+ // through.
+ left := LeftOut(r, with.Settings, with.Adopted)
+ exposure := map[string]map[int]string{}
+ for _, m := range r.Modules {
+ if _, isLeft := left[m.Module]; isLeft {
+ continue
+ }
+ e, err := Exposure(m, with.Settings[m.Module])
+ if err != nil {
+ return nil, err
+ }
+ // And how far each endpoint reaches, which says the same thing to the filter and more
+ // besides (novox/hq ADR 0138). Folded in here rather than beside: the filter has one
+ // question — from where — and a reach answers it, so giving it two inputs would let them
+ // disagree. Reaches refuses a port that both name, so this cannot silently prefer one.
+ reaches, err := Reaches(m, with.Settings[m.Module])
+ if err != nil {
+ return nil, err
+ }
+ // **Only for an endpoint the proxy does not serve.** A routed endpoint's port is how the
+ // proxy reaches it and nothing else (ADR 0045), so `public` there asks for a public name and
+ // says nothing about the port — opening it to the world as well would undo the arrangement
+ // the proxy exists for, and would silently reopen a port an operator had narrowed.
+ //
+ // Found by trying to express a real module: one whose routed name must be public and whose
+ // machine-side port must not be. Under one value for both, there was no way to say it.
+ routed := RoutedPorts(m)
+ for port, reach := range reaches {
+ if routed[port] {
+ continue
+ }
+ source, ok := FilterSource(reach)
+ if !ok {
+ return nil, fmt.Errorf("%s: %q is not a reach the filter can read", m.Module, reach)
+ }
+ if e == nil {
+ e = map[int]string{}
+ }
+ e[port] = source
+ }
+ if e != nil {
+ exposure[m.Module] = e
+ }
+ }
+ return Filtering(r, with.Generators, with.Ports, exposure)
+}
+
+// provisionsAs is the account that reads what the mesh writes for this provider: the one secret
+// per consumer it must open to set that consumer's password (novox/hq issue 225).
+//
+// **A root-owned 0600 file is one that process cannot read**, which is the same sentence already
+// written above for a module's own secrets — and the grant secret is the other kind of secret
+// the mesh writes for a module, so it is the same rule.
+//
+// Which account depends on where the module's code runs. A module whose code is a bundle is run
+// by the node's tool runtime, as the node's account ([ADR 0198](0198)); one still in a container
+// is whatever it declares as its secrets owner. Nothing names these paths, so the rule that
+// claims a bundle's other files by the words that name them (givenTo) cannot reach them: the
+// harness composes a grant secret's path from the contributions file, not from a word.
+//
+// Empty is root, which is what it was and what a module with no bundle and no declared owner
+// still wants.
+func resolutionProvisionsAs(r mcat.Resolution, m mcat.Manifest) string {
+ // A module of its own account reads them as that account (ADR 0259 §8, issue 723).
+ if m.RunsAs != "" {
+ return m.RunsAs
+ }
+ if len(m.Bundles) > 0 && r.Account != "" {
+ return r.Account
+ }
+ return m.SecretsOwner
+}
+
+// ownSecretOwner is who one of a module's own secret files belongs to: the account the manifest names for it, else the
+// runtime's account for the runtime's own credential and for a tools bundle's bus account (ToolsBusSecret; novox/hq ADR
+// 0564 Limits 4), which the runtime runs that bundle as, else the module's secrets-owner (root when none).
+func resolutionOwnSecretOwner(r mcat.Resolution, m mcat.Manifest, name string) string {
+ if o := m.OwnSecrets[name].Owner; o != "" {
+ return o
+ }
+ if r.Account != "" && (m.Module == mcat.RuntimeModule || ToolsBusSecret(m, name)) {
+ return r.Account
+ }
+ return m.SecretsOwner
+}
+
+// grantPath is where one consumer's sealed credential lands on the providing machine.
+//
+// Suffixed, so the directory can also hold whatever the module writing it keeps there and so a
+// node named like something else in that directory cannot collide with it.
+// One file per consumer, and a consumer is a module on a machine (novox/hq 04-ISSUES/022).
+//
+// Named after both. Named after the machine alone, two modules on one node wrote to one path: the
+// second overwrote the first, and the provisioner — reading a directory — saw one consumer where
+// there were two.
+// reflectsRenamed is a resource's restart-on list under the module's prefix, or nil when it has
+// none. It reads both the shape JSON parsing produces ([]any) and the shape code composing
+// resources natively produces ([]string): a reference that was skipped because its list arrived
+// in the other shape would point at nothing — silently, with the service never restarting and
+// every check passing, which is how a runtime kept serving without the registry trust its
+// daemon file already carried.
+func reflectsRenamed(module string, reflects any) []any {
+ var names []string
+ switch v := reflects.(type) {
+ case []any:
+ for _, id := range v {
+ names = append(names, fmt.Sprint(id))
+ }
+ case []string:
+ names = v
+ }
+ if names == nil {
+ return nil
+ }
+ var renamed []any
+ for _, named := range names {
+ if strings.Contains(named, ".") {
+ renamed = append(renamed, named)
+ continue
+ }
+ renamed = append(renamed, module+"."+named)
+ }
+ return renamed
+}
+
+func grantPath(directory, consumer, module string) string {
+ return strings.TrimRight(directory, "/") + "/" + consumer + "." + module + ".secret"
+}
+
+// holderAs is a consumer's name at the provider with a local name after it, where it keeps several
+// credentials for one provision (ADR 0094); the name alone otherwise.
+func holderAs(as, local string) string {
+ if local == "" {
+ return as
+ }
+ return as + "_" + local
+}
+
+// contributions collects what every module in this set contributes, by requirement.
+//
+// Ordered by contributing module, because the result becomes a file on a machine and a file whose
+// lines move about is a file that looks changed when nothing changed.
+func resolutionContributions(r mcat.Resolution, settings mcat.SettingsBy, grants []mcat.Grant,
+ directories map[string]string) (map[string][]mcat.Contribution, error) {
+ out := map[string][]mcat.Contribution{}
+ modules := append([]mcat.Manifest{}, r.Modules...)
+ sort.Slice(modules, func(i, j int) bool { return modules[i].Module < modules[j].Module })
+
+ // What consumers on other machines asked for. Merged in with this machine's own, because from
+ // the provider's side they are the same thing — somebody wanting something — and a provider
+ // that had to read two lists would be a provider that reads one of them.
+ sorted := append([]mcat.Grant{}, grants...)
+ sort.Slice(sorted, func(i, j int) bool {
+ if sorted[i].Provision != sorted[j].Provision {
+ return sorted[i].Provision < sorted[j].Provision
+ }
+ if sorted[i].Consumer != sorted[j].Consumer {
+ return sorted[i].Consumer < sorted[j].Consumer
+ }
+ if sorted[i].From != sorted[j].From {
+ return sorted[i].From < sorted[j].From
+ }
+ return sorted[i].Local < sorted[j].Local
+ })
+ // A consumer already carried by the grants loop, keyed (provision, module). When provider and
+ // consumer are co-located, `grantsFor` enumerates the same-node consumer too, so without this the
+ // module would be emitted a second time by the m.Contributes loop below — once full (with the
+ // grant's secret/as) and once partial — which is the duplicate seen in a co-located mesh.json.
+ granted := map[string]map[string]bool{}
+ for _, g := range sorted {
+ if g.From == "" {
+ // As above: nothing on that machine asks for this any more, so the provider is not
+ // told about it and withdraws the login on its next pass.
+ continue
+ }
+ as := holderAs(mcat.ConsumerIdentity(g.Consumer, mcat.IdentitySource(g.Slug, g.From)), g.Local)
+ derived, err := resolutionDerivedFor(r, g.Provision, as, g.From, g.Local, settings)
+ if err != nil {
+ return nil, err
+ }
+ out[g.Provision] = append(out[g.Provision], mcat.Contribution{
+ From: g.From, Node: g.Consumer, At: g.At, Values: g.Values, Derived: derived,
+ // One holder per local name: the identity the consumer is known by, and the local name
+ // after it where the module keeps several (ADR 0094). Not a login any backend checks —
+ // a secret is not a login — so the identity limit does not apply to the suffix.
+ As: as,
+ Secret: grantPath(directories[g.Provision], g.Consumer, holderAs(g.From, g.Local)),
+ })
+ if granted[g.Provision] == nil {
+ granted[g.Provision] = map[string]bool{}
+ }
+ granted[g.Provision][g.From] = true
+ }
+ for _, m := range modules {
+ for _, to := range sortedKeys(m.Contributes) {
+ // The grants loop already emitted this module's contribution to this provision, with its
+ // minted secret — emitting the partial copy again would duplicate it. A contribution with
+ // no grant (a reverse-proxy route names a host, not a credential) is not in `granted`, so
+ // it still reaches the provider from here.
+ if granted[to][m.Module] {
+ continue
+ }
+ // Settings reach a contribution the same way they reach a file. A route's hostname is
+ // exactly the kind of thing that differs between one mesh and the next, and a module
+ // that could not have it set would have to be edited to be reused.
+ values, err := resolutionComposed(r, m, to, m.Contributes[to], settings[m.Module],
+ m.Module+" contributing to "+to)
+ if err != nil {
+ return nil, err
+ }
+ // What the module shares of its bucket reaches the provider with the rest (novox/hq ADR 0468 §8).
+ if values, err = withShares(m, to, values); err != nil {
+ return nil, err
+ }
+ out[to] = append(out[to], mcat.Contribution{From: m.Module, Values: values})
+ }
+ // Several contributions to one requirement (ADR 0094's sibling for `contributes`): an
+ // object store's data API and its console are two different public names from one module,
+ // not one. Never in `granted` — a route names a host, not a credential — so every local
+ // name always reaches the provider from here.
+ for _, to := range sortedKeys(m.ContributesMany) {
+ for _, local := range sortedKeys(m.ContributesMany[to]) {
+ values, err := resolutionComposed(r, m, to, m.ContributesMany[to][local], settings[m.Module],
+ m.Module+" contributing "+local+" to "+to)
+ if err != nil {
+ return nil, err
+ }
+ out[to] = append(out[to], mcat.Contribution{From: m.Module, Values: values})
+ }
+ }
+ }
+ return out, nil
+}
+
+// contributedOf is each contribution m makes to one requirement, composed as its provider receives it in a received
+// file (contributions: the one of `contributes` with what the module shares of its bucket, then each of several by
+// local name), with where m's machine is, encoded for PlanProvision.Contributed (issue 774): one composition, so a
+// plan and a file cannot say different things. Nil where m contributes nothing to it.
+//
+// **Its port is the one the machine published** (atAssignedPort): a plan is read on whatever machine its provider runs,
+// which cannot redirect the number as a received file on this machine is redirected (atMachinePort), so the plan
+// carries the machine port already. route-proxy dialled gitea's 3000 for git (published at 20000) and grafana's 3000
+// on another machine (published at 20012) until it did (2026-10-31).
+func resolutionContributedOf(r mcat.Resolution, m mcat.Manifest, to string, with mcat.Rendering) (json.RawMessage, error) {
+ layers := with.Settings[m.Module]
+ form := mcat.ContributedForm{At: r.At}
+ add := func(local string, values map[string]any) error {
+ values, err := resolutionAtAssignedPort(r, m, to, values, with)
+ if err != nil {
+ return err
+ }
+ raw, err := json.Marshal(values)
+ if err != nil {
+ return fmt.Errorf("%s contributing to %s: %w", m.Module, to, err)
+ }
+ form.Each = append(form.Each, mcat.ContributedOne{Local: local, Values: raw})
+ return nil
+ }
+ if raw, ok := m.Contributes[to]; ok {
+ values, err := resolutionComposed(r, m, to, raw, layers, m.Module+" contributing to "+to)
+ if err != nil {
+ return nil, err
+ }
+ if values, err = withShares(m, to, values); err != nil {
+ return nil, err
+ }
+ if err := add("", values); err != nil {
+ return nil, err
+ }
+ }
+ for _, local := range sortedKeys(m.ContributesMany[to]) {
+ values, err := resolutionComposed(r, m, to, m.ContributesMany[to][local], layers, m.Module+" contributing "+local+" to "+to)
+ if err != nil {
+ return nil, err
+ }
+ if err := add(local, values); err != nil {
+ return nil, err
+ }
+ }
+ if len(form.Each) == 0 {
+ return nil, nil
+ }
+ return EncodeContributed(form)
+}
+
+// atAssignedPort is a contribution of m with its port where this machine publishes it for m: the number
+// `${port:}` gives m itself and its declaration publishes (machinePort, port_into.go), the placement's
+// assignment where it holds one and the software's own number where it holds none (a bare port not yet moved, a fixed
+// mapping, the host's network). What its provider dials, on this machine over loopback and from another at m's address.
+//
+// **Refused where a plan carries a port m does not listen on** (with.Planned): no machine port stands for it, so the
+// provider would dial a number nothing on the machine is known to publish. Elsewhere (a declaration, a composition
+// with no ports) such a port is left as it is, as machinePort leaves it.
+func resolutionAtAssignedPort(r mcat.Resolution, m mcat.Manifest, to string, values map[string]any, with mcat.Rendering) (map[string]any, error) {
+ number, ok := asPort(values["port"])
+ if !ok {
+ return values, nil
+ }
+ _, given := with.Given[m.Module][number]
+ _, placed := with.Ports[m.Module][number]
+ if !given && !placed {
+ if with.Planned {
+ return nil, fmt.Errorf("%s contributing to %s: its port %d on %s is none of the ports it listens on, so no "+
+ "machine port stands for it", m.Module, to, number, r.Node)
+ }
+ return values, nil
+ }
+ return atMachinePort(values, m.Module, map[string]map[int]int{m.Module: {number: renderingMachinePort(with, m.Module, number)}}), nil
+}
+
+// composed is one contribution as its provider receives it: settled with this node's settings, its
+// endpoint's port filled in, and its names composed from the label.
+//
+// **One function, because two readers must agree.** The provider is told the names in its received
+// file; the contributing module is told the same names in its own binding (novox/hq 04-ISSUES/122).
+// Composing them twice, in two places, is how the proxy would come to serve one name while the
+// module wrote another into its configuration.
+func resolutionComposed(r mcat.Resolution, m mcat.Manifest, to string, raw map[string]any, layers []mcat.Layer, what string) (
+ map[string]any, error) {
+ // Overridden, not merged: a setting changes a key the contribution declares and adds none.
+ // The provider reads the contribution as a contract, and a setting made for one of this
+ // module's files is no part of it (novox/hq 04-ISSUES/173).
+ values, err := overridden(raw, WithDefaults(m, layers), what)
+ if err != nil {
+ // A required value nothing sets is said in the words of the module's form, as a file's, a unit's and an owner's
+ // are (issue 1126, seed-8: de-spiegel in the config form was refused with ${setting:…} and `settings set`).
+ var unset *UnsetSettingError
+ if errors.As(err, &unset) && unset.Module == "" {
+ unset.Module = m.Module
+ unset.where = fmt.Sprintf("a contribution%s, in %q,", strings.TrimPrefix(what, m.Module+" contributing"), unset.field)
+ }
+ return nil, unsetInConfigWords(fmt.Errorf("%s: %w", what, err), m)
+ }
+ reaches, err := Reaches(m, layers)
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", what, err)
+ }
+ blocks, err := mcat.Endpoints(m, layers)
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", what, err)
+ }
+ portOfEndpoint(values, endpointPorts(m))
+ composeName(values, r.PublicDomain, servingAt(r, to), reaches, endpointPorts(m), blocks)
+ return values, nil
+}
+
+// ownNames is what a module is known by through what it contributes to one requirement — the names
+// the mesh composed for it, and nothing else of the contribution.
+//
+// **The half a module could not learn** (novox/hq 04-ISSUES/122). A module contributes a label, the
+// mesh joins it with this node's domains, and the provider serves the result — and the module itself
+// was never told. Software that must know its own address (a login redirect, a canonical URL, an
+// issuer) had it written into the manifest as a literal, which is a domain in a definition and wrong
+// on every other machine. `${bound::name}` is the answer, from the same composition the
+// provider receives.
+//
+// Several contributions to one requirement are keyed by their local name under `names`.
+func resolutionOwnNames(r mcat.Resolution, m mcat.Manifest, to string, layers []mcat.Layer) (map[string]any, error) {
+ pick := func(values map[string]any) map[string]any {
+ names := map[string]any{}
+ for _, key := range []string{"name", "internal-name"} {
+ if v, ok := values[key].(string); ok && v != "" {
+ names[key] = v
+ }
+ }
+ return names
+ }
+ out := map[string]any{}
+ if raw, ok := m.Contributes[to]; ok {
+ values, err := resolutionComposed(r, m, to, raw, layers, m.Module+" contributing to "+to)
+ if err != nil {
+ return nil, err
+ }
+ for k, v := range pick(values) {
+ out[k] = v
+ }
+ }
+ if locals := m.ContributesMany[to]; len(locals) > 0 {
+ many := map[string]any{}
+ for _, local := range sortedKeys(locals) {
+ values, err := resolutionComposed(r, m, to, locals[local], layers,
+ m.Module+" contributing "+local+" to "+to)
+ if err != nil {
+ return nil, err
+ }
+ if names := pick(values); len(names) > 0 {
+ many[local] = names
+ }
+ }
+ if len(many) > 0 {
+ out["names"] = many
+ }
+ }
+ return out, nil
+}
+
+// composeName joins a contribution's label with a node's public domain, and separately with its
+// private one, in place (novox/hq ADR 0056).
+//
+// **The whole of what the mesh does with a route's name: join two given strings — twice.** A
+// contribution carries a `label` — the subdomain its operator chose — and the node carries its
+// public domain and its own private-network address; the granted names are `