diff --git a/go/catalogue/busy_test.go b/go/catalogue/busy_test.go
index 42ca6d4..fbaa553 100644
--- a/go/catalogue/busy_test.go
+++ b/go/catalogue/busy_test.go
@@ -3,8 +3,6 @@ package catalogue
import (
"encoding/json"
"testing"
-
- "git.novox.be/novox/mesh-sdk/go/validate"
)
// A long-running container may say it is busy (novox/hq ADR 0335 §1): `module check` holds the field to its
@@ -52,14 +50,6 @@ func TestABusyIsSentOnlyToAnEngineThatReadsIt(t *testing.T) {
if got["command"] != "mesh-builder busy" || got["timeout"] != "10s" {
t.Fatalf("busy composed as %v", got)
}
- // And the engine's own validator takes the container with it.
- body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": newer})
- if err != nil {
- t.Fatal(err)
- }
- if problems := validate.Declaration(body); len(problems) > 0 {
- t.Fatalf("the node-engine's validator refuses the composed busy: %v", problems)
- }
if _, has := m.Resources[len(m.Resources)-1][BusyField].(map[string]any)["timeout"]; has {
t.Error("composing wrote the timeout into the catalogue's own manifest")
}
diff --git a/go/catalogue/health_test.go b/go/catalogue/health_test.go
index 3b50ec9..0914830 100644
--- a/go/catalogue/health_test.go
+++ b/go/catalogue/health_test.go
@@ -4,8 +4,6 @@ import (
"encoding/json"
"strings"
"testing"
-
- "git.novox.be/novox/mesh-sdk/go/validate"
)
// A module says how each long-running resource is ready (novox/hq ADR 0240 rule 2, to-be 48 §8): `module
@@ -147,17 +145,9 @@ func TestEveryComposedHealthIsOneTheNodeEngineTakes(t *testing.T) {
t.Fatal(err)
}
m.Resources[1]["health"] = map[string]any{"kind": "unit"}
- out, err := Resolution{Node: "laptop", Modules: []Manifest{m}}.Declaration(Rendering{ReadsHealth: true,
- Ports: map[string]map[int]int{"app": {8080: 31001}}})
- if err != nil {
+ if _, err := (Resolution{Node: "laptop", Modules: []Manifest{m}}).Declaration(Rendering{ReadsHealth: true,
+ Ports: map[string]map[int]int{"app": {8080: 31001}}}); err != nil {
t.Fatal(err)
}
- body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": out})
- if err != nil {
- t.Fatal(err)
- }
- if problems := validate.Declaration(body); len(problems) > 0 {
- t.Errorf("%v composed into something the node-engine refuses: %v", h, problems)
- }
}
}
diff --git a/go/catalogue/runtime_health_test.go b/go/catalogue/runtime_health_test.go
index d2b075f..3d50cc2 100644
--- a/go/catalogue/runtime_health_test.go
+++ b/go/catalogue/runtime_health_test.go
@@ -3,8 +3,6 @@ package catalogue
import (
"encoding/json"
"testing"
-
- "git.novox.be/novox/mesh-sdk/go/validate"
)
// novox/hq ADR 0366: a module that runs as its own account declares its runtime's health under one manifest
@@ -88,13 +86,6 @@ func TestARuntimeHealthIsComposedOntoTheModulesOwnRuntime(t *testing.T) {
if machine := fileNamed(out, RuntimeModule+"."+RuntimeProcessID()); machine[HealthField] != nil {
t.Errorf("the machine's runtime was given the module's health: %v", machine[HealthField])
}
- body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": out})
- if err != nil {
- t.Fatal(err)
- }
- if problems := validate.Declaration(body); len(problems) > 0 {
- t.Errorf("composed into something the node-engine refuses: %v", problems)
- }
}
// The catalogue's own manifest is not written into.
if _, ok := keeper.RuntimeHealth.(map[string]any)["grace"]; ok {
diff --git a/go/cmd/mesh-compose/main.go b/go/cmd/mesh-compose/main.go
deleted file mode 100644
index 9c38a8f..0000000
--- a/go/cmd/mesh-compose/main.go
+++ /dev/null
@@ -1,154 +0,0 @@
-// mesh-compose is mesh-sdk's composer command (novox/hq ADR 0482 clause 4, issue 1621): it reads a composer.Request on
-// standard input, validates every manifest with this mesh-sdk's reader, composes the machines named in each case and
-// writes one composer.Verdict on standard output. Built at any commit of mesh-sdk (or at the mesh-sdk a module pins),
-// it answers what that commit's composition answers, so a program that links one mesh-sdk can have another's judged.
-//
-// 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"
-
- "git.novox.be/novox/mesh-sdk/go/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/go/cmd/mesh-compose/main_test.go b/go/cmd/mesh-compose/main_test.go
deleted file mode 100644
index 48d4b77..0000000
--- a/go/cmd/mesh-compose/main_test.go
+++ /dev/null
@@ -1,329 +0,0 @@
-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/compose"
- "git.novox.be/novox/mesh-sdk/go/composer"
- "git.novox.be/novox/mesh-sdk/go/delivery"
- "git.novox.be/novox/mesh-sdk/go/internal/beside"
-)
-
-// 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()
- return composer.Request{V: composer.Version, Input: rawInput(meshOf()), Depth: depth, HoldingsFromClaims: true,
- Machines: []string{"anchor", "laptop", "ace"},
- Cases: []composer.Case{{Name: "trunk", Manifests: captured(t)}}}
-}
-
-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, composer.Request{V: composer.Version, Input: rawInput(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)
- }
- }
-}
-
-// sdkPackages is every package of this module the command imports, directly or not, with the packages each imports.
-func sdkPackages(t *testing.T) map[string][]string {
- t.Helper()
- _, file, _, _ := runtime.Caller(0)
- root := filepath.Join(filepath.Dir(file), "..", "..")
- const module = "git.novox.be/novox/mesh-sdk/go"
- 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 mesh-sdk it reaches, only the standard library and
-// mesh-sdk's own packages (so `go build` from mesh-sdk's module needs nothing beyond its lock), 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"}
- for path, imports := range sdkPackages(t) {
- for _, imp := range imports {
- std := !strings.Contains(strings.SplitN(imp, "/", 2)[0], ".")
- if !std && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-sdk/go/") {
- t.Errorf("%s imports %s, which is neither the standard library nor mesh-sdk", 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)
-}
-
-// rawInput is a composition input as a request carries it.
-func rawInput(in compose.Input) json.RawMessage {
- raw, err := json.Marshal(in)
- if err != nil {
- panic(err)
- }
- return raw
-}
diff --git a/go/cmd/mesh-compose/reads-outside b/go/cmd/mesh-compose/reads-outside
deleted file mode 100644
index f6b2d87..0000000
--- a/go/cmd/mesh-compose/reads-outside
+++ /dev/null
@@ -1 +0,0 @@
-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/go/compose/composed.go b/go/compose/composed.go
deleted file mode 100644
index 07258d3..0000000
--- a/go/compose/composed.go
+++ /dev/null
@@ -1,559 +0,0 @@
-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"
- "git.novox.be/novox/mesh-sdk/go/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 not
-// signed and not yet by digest: the writer does both (delivery.SignComposed, 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 + "." + catalogue.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+"."+catalogue.BundleID("")):
- name := strings.TrimPrefix(r.ID, owner+"."+catalogue.BundleID(""))
- if r.Path == catalogue.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 := catalogue.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 = catalogue.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/go/compose/composed_test.go b/go/compose/composed_test.go
deleted file mode 100644
index a96ea85..0000000
--- a/go/compose/composed_test.go
+++ /dev/null
@@ -1,200 +0,0 @@
-package compose
-
-import (
- "encoding/json"
- "sort"
- "strings"
- "testing"
-
- "git.novox.be/novox/mesh-sdk/go/catalogue"
- "git.novox.be/novox/mesh-sdk/go/delivery"
- "git.novox.be/novox/mesh-sdk/go/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=" + catalogue.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":"` + catalogue.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/go/compose/credentials.go b/go/compose/credentials.go
deleted file mode 100644
index 480c988..0000000
--- a/go/compose/credentials.go
+++ /dev/null
@@ -1,498 +0,0 @@
-package compose
-
-import (
- "crypto/ecdh"
- "encoding/base64"
- "encoding/json"
- "errors"
- "fmt"
- "slices"
- "sort"
- "strings"
-
- "git.novox.be/novox/mesh-sdk/go/catalogue"
-)
-
-// 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 := plan.ContributionsFrom(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 = catalogue.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 := plan.BindsFrom(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 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 !catalogue.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 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/go/compose/credentials_needed.go b/go/compose/credentials_needed.go
deleted file mode 100644
index ec36c71..0000000
--- a/go/compose/credentials_needed.go
+++ /dev/null
@@ -1,69 +0,0 @@
-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/go/compose/credentials_test.go b/go/compose/credentials_test.go
deleted file mode 100644
index 94cd354..0000000
--- a/go/compose/credentials_test.go
+++ /dev/null
@@ -1,549 +0,0 @@
-package compose
-
-import (
- "encoding/json"
- "errors"
- "strings"
- "testing"
-
- "git.novox.be/novox/mesh-sdk/go/catalogue"
- "git.novox.be/novox/mesh-sdk/go/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/go/compose/fixture_test.go b/go/compose/fixture_test.go
deleted file mode 100644
index c6f1188..0000000
--- a/go/compose/fixture_test.go
+++ /dev/null
@@ -1,90 +0,0 @@
-package compose
-
-import (
- "crypto/ecdh"
- "crypto/rand"
- "encoding/base64"
- "encoding/json"
- "reflect"
- "testing"
-
- "git.novox.be/novox/mesh-sdk/go/catalogue"
- "git.novox.be/novox/mesh-sdk/go/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/go/compose/frombus/answer.go b/go/compose/frombus/answer.go
deleted file mode 100644
index 64eb6d2..0000000
--- a/go/compose/frombus/answer.go
+++ /dev/null
@@ -1,582 +0,0 @@
-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/compose"
- "git.novox.be/novox/mesh-sdk/go/delivery"
-)
-
-// 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 *catalogue.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/go/compose/frombus/answer_rows_test.go b/go/compose/frombus/answer_rows_test.go
deleted file mode 100644
index 167bb27..0000000
--- a/go/compose/frombus/answer_rows_test.go
+++ /dev/null
@@ -1,595 +0,0 @@
-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/compose"
- "git.novox.be/novox/mesh-sdk/go/delivery"
- "git.novox.be/novox/mesh-sdk/go/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." + catalogue.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 := catalogue.DeclaredPaths(compose.Carried(f1, "f1-circuits", &f1runs)), catalogue.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/go/compose/frombus/export.go b/go/compose/frombus/export.go
deleted file mode 100644
index d28f95c..0000000
--- a/go/compose/frombus/export.go
+++ /dev/null
@@ -1,112 +0,0 @@
-package frombus
-
-import (
- "fmt"
- "sort"
- "strconv"
-
- "git.novox.be/novox/mesh-sdk/go/catalogue"
- "git.novox.be/novox/mesh-sdk/go/compose"
- "git.novox.be/novox/mesh-sdk/go/delivery"
-)
-
-// 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/go/compose/frombus/frombus.go b/go/compose/frombus/frombus.go
deleted file mode 100644
index 2960221..0000000
--- a/go/compose/frombus/frombus.go
+++ /dev/null
@@ -1,1167 +0,0 @@
-// 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/compose"
- "git.novox.be/novox/mesh-sdk/go/declaration"
- "git.novox.be/novox/mesh-sdk/go/delivery"
- "git.novox.be/novox/mesh-sdk/go/overlay"
- "git.novox.be/novox/mesh-sdk/go/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 := resolved.Plan.Compose(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+"."+catalogue.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 == catalogue.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/go/compose/frombus/frombus_test.go b/go/compose/frombus/frombus_test.go
deleted file mode 100644
index 9ea9fdc..0000000
--- a/go/compose/frombus/frombus_test.go
+++ /dev/null
@@ -1,869 +0,0 @@
-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/compose"
- "git.novox.be/novox/mesh-sdk/go/delivery"
- "git.novox.be/novox/mesh-sdk/go/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("../../catalogue/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/go/compose/frombus/frombustest/frombustest.go b/go/compose/frombus/frombustest/frombustest.go
deleted file mode 100644
index 3bfc971..0000000
--- a/go/compose/frombus/frombustest/frombustest.go
+++ /dev/null
@@ -1,183 +0,0 @@
-// 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/compose"
- "git.novox.be/novox/mesh-sdk/go/compose/frombus"
- "git.novox.be/novox/mesh-sdk/go/delivery"
-)
-
-//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/go/compose/frombus/frombustest/frombustest_test.go b/go/compose/frombus/frombustest/frombustest_test.go
deleted file mode 100644
index 97c71b4..0000000
--- a/go/compose/frombus/frombustest/frombustest_test.go
+++ /dev/null
@@ -1,272 +0,0 @@
-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/compose/frombus"
- "git.novox.be/novox/mesh-sdk/go/compose/fromruntime"
- "git.novox.be/novox/mesh-sdk/go/delivery"
-)
-
-// 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/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json b/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json
deleted file mode 100644
index 095128a..0000000
--- a/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json
+++ /dev/null
@@ -1,60 +0,0 @@
-{
- "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/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/backups.conf b/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/backups.conf
deleted file mode 100644
index 2f23f90..0000000
--- a/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/backups.conf
+++ /dev/null
@@ -1,2 +0,0 @@
-# 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/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/data.conf b/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/data.conf
deleted file mode 100644
index f19e849..0000000
--- a/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/data.conf
+++ /dev/null
@@ -1,2 +0,0 @@
-# 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/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/retention.conf b/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/retention.conf
deleted file mode 100644
index cd7954c..0000000
--- a/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/retention.conf
+++ /dev/null
@@ -1,6 +0,0 @@
-# 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/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.module.json b/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.module.json
deleted file mode 100644
index ea5ec8b..0000000
--- a/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.module.json
+++ /dev/null
@@ -1,199 +0,0 @@
-{
- "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/go/compose/frombus/held_rows_test.go b/go/compose/frombus/held_rows_test.go
deleted file mode 100644
index acb0907..0000000
--- a/go/compose/frombus/held_rows_test.go
+++ /dev/null
@@ -1,298 +0,0 @@
-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/compose"
- "git.novox.be/novox/mesh-sdk/go/delivery"
-)
-
-// 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", &catalogue.NoRuntimeToRunError{Held: true}), true},
- {catalogue.RuntimeModule, &catalogue.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":"` + catalogue.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/go/compose/frombus/states.go b/go/compose/frombus/states.go
deleted file mode 100644
index b26c314..0000000
--- a/go/compose/frombus/states.go
+++ /dev/null
@@ -1,204 +0,0 @@
-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/catalogue"
- "git.novox.be/novox/mesh-sdk/go/compose"
- "git.novox.be/novox/mesh-sdk/go/delivery"
-)
-
-// 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 := catalogue.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/go/compose/frombus/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json b/go/compose/frombus/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json
deleted file mode 100644
index 095128a..0000000
--- a/go/compose/frombus/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json
+++ /dev/null
@@ -1,60 +0,0 @@
-{
- "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/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/backups.conf b/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/backups.conf
deleted file mode 100644
index 2f23f90..0000000
--- a/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/backups.conf
+++ /dev/null
@@ -1,2 +0,0 @@
-# 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/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/data.conf b/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/data.conf
deleted file mode 100644
index f19e849..0000000
--- a/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/data.conf
+++ /dev/null
@@ -1,2 +0,0 @@
-# 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/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/retention.conf b/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/retention.conf
deleted file mode 100644
index cd7954c..0000000
--- a/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/retention.conf
+++ /dev/null
@@ -1,6 +0,0 @@
-# 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/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.module.json b/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.module.json
deleted file mode 100644
index ea5ec8b..0000000
--- a/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.module.json
+++ /dev/null
@@ -1,199 +0,0 @@
-{
- "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/go/compose/frombus/testdata/de-spiegel-e25e1beb.module.json b/go/compose/frombus/testdata/de-spiegel-e25e1beb.module.json
deleted file mode 100644
index b4c4bd2..0000000
--- a/go/compose/frombus/testdata/de-spiegel-e25e1beb.module.json
+++ /dev/null
@@ -1,129 +0,0 @@
-{
- "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/go/compose/frombus/testdata/f1-circuits-c0ea2c94.module.json b/go/compose/frombus/testdata/f1-circuits-c0ea2c94.module.json
deleted file mode 100644
index 8f2b612..0000000
--- a/go/compose/frombus/testdata/f1-circuits-c0ea2c94.module.json
+++ /dev/null
@@ -1,82 +0,0 @@
-{
- "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/go/compose/frombus/twice.go b/go/compose/frombus/twice.go
deleted file mode 100644
index cb133df..0000000
--- a/go/compose/frombus/twice.go
+++ /dev/null
@@ -1,147 +0,0 @@
-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/go/compose/fromruntime/fromruntime.go b/go/compose/fromruntime/fromruntime.go
deleted file mode 100644
index 997fa5a..0000000
--- a/go/compose/fromruntime/fromruntime.go
+++ /dev/null
@@ -1,101 +0,0 @@
-// 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"
- "git.novox.be/novox/mesh-sdk/go/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/go/compose/input.go b/go/compose/input.go
deleted file mode 100644
index 72aecf7..0000000
--- a/go/compose/input.go
+++ /dev/null
@@ -1,365 +0,0 @@
-// 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/go/compose/judge/judge.go b/go/compose/judge/judge.go
deleted file mode 100644
index 8d68a39..0000000
--- a/go/compose/judge/judge.go
+++ /dev/null
@@ -1,372 +0,0 @@
-// Package judge is how the seat mesh-placement's holder judges a proposal (novox/hq ADR 0567 §3, §4): which nodes must
-// answer it and what it changes on each (Intended), and the verdict of their answers (JudgeAnswers). Moved here from
-// the assigner, where it judged a catalogue pull request's placement (ADR 0559 §5), with a proposal in place of a pull
-// request's change. It holds no bus client and no timer: the holder gives it what it read.
-package judge
-
-import (
- "encoding/json"
- "fmt"
- "reflect"
- "sort"
- "strconv"
- "strings"
- "time"
-
- "git.novox.be/novox/mesh-sdk/go/compose"
- "git.novox.be/novox/mesh-sdk/go/delivery"
-)
-
-// AnswerBound is how long the holder waits for a node's answer after its candidate was written (ADR 0567 §4): the time to
-// live of the node's pulse key (delivery.PulseAnswerKey), whose expiry names it silent, never passed. A variable so a test
-// on the bus can shorten it.
-var AnswerBound = 10 * time.Minute
-
-// Intended is, by node, the modules whose composition the proposal changes, and the nodes that must answer it (§3).
-// current is the composition input with the current placement and laidInput the same with the proposal's placements
-// (laid, by node; nil removes the node's key) laid over it; nodes is every node to look at (every node with a record).
-// A node's set holds the modules the proposal adds, removes or whose placed entry differs on that node, and those whose
-// composition differs between the two inputs (delivery.CompareComposed), the node's own named "node". Must is every node
-// whose key the proposal changes and every other whose set is not empty, sorted. A node that has no key now gets an
-// empty set (§7: the seed is answered as the same). A node whose compositions cannot be made on either side keeps the
-// modules its entries name, said in notes; such a node is in Must only when its key changes.
-func Intended(current, laidInput *compose.Input, currentPlaced map[string]delivery.Placement, laid map[string]*delivery.Placement,
- nodes []string, fps map[string]string) (intended map[string]map[string]bool, must []string, notes []string) {
- intended = map[string]map[string]bool{}
- inMust := map[string]bool{}
- all := map[string]bool{}
- for _, n := range nodes {
- all[n] = true
- }
- for n := range laid {
- all[n] = true
- inMust[n] = true
- }
- names := make([]string, 0, len(all))
- for n := range all {
- names = append(names, n)
- }
- sort.Strings(names)
- for _, node := range names {
- before, placed := currentPlaced[node]
- set := map[string]bool{}
- after, changesKey := laid[node]
- if changesKey && !placed {
- intended[node] = set // a node new to placement: every answer must be the same
- continue
- }
- if changesKey {
- var afterModules []delivery.PlacedModule
- if after != nil {
- afterModules = after.Modules
- }
- entries := map[string]delivery.PlacedModule{}
- for _, m := range before.Modules {
- entries[m.Module] = m
- }
- seen := map[string]bool{}
- for _, m := range afterModules {
- seen[m.Module] = true
- if e, ok := entries[m.Module]; !ok || !reflect.DeepEqual(e, m) {
- set[m.Module] = true
- }
- }
- for name := range entries {
- if !seen[name] {
- set[name] = true
- }
- }
- }
- if placed && current != nil && laidInput != nil {
- was, _, _, errB := compose.CompositionsShadow(*current, node, fps)
- now, _, _, errA := compose.CompositionsShadow(*laidInput, node, fps)
- if errA == nil && errB == nil {
- running := make([]delivery.Composed, 0, len(was))
- for _, c := range was {
- running = append(running, c)
- }
- cmp := delivery.CompareComposed(now, running)
- for _, d := range cmp.Differs {
- set[d.Module] = true
- }
- for _, d := range cmp.Added {
- set[d.Module] = true
- }
- for _, m := range cmp.Removed {
- set[m] = true
- }
- } else if changesKey {
- notes = append(notes, fmt.Sprintf("what the proposal changes on %s is read from its placement alone: its "+
- "compositions cannot be made (now: %v; proposed: %v)", node, errB, errA))
- }
- }
- if len(set) > 0 {
- inMust[node] = true
- }
- if inMust[node] {
- intended[node] = set
- }
- }
- for n := range inMust {
- must = append(must, n)
- }
- sort.Strings(must)
- return intended, must, notes
-}
-
-// JudgeAnswers is the verdict of a proposal's answers (§4). answers is by node its HaveNode.Candidates entry for the
-// proposal (nil when it has none) and revisions by node the revision of its candidate key: an answer counts when it
-// names the proposal and that revision. It fails on a refusal by a node that must answer, and on every node of Must with
-// no answer that counts once its pulse key has ended (pulsing false: the bound's expiry, heard on the bus); while it
-// stands it waits for them.
-// A difference fails nothing: one Intended computed is listed under Intended, one it did not under Unintended, both
-// shown in the ask's brief. The seed alone fails on any difference.
-func JudgeAnswers(p delivery.PlacementProposal, intended map[string]map[string]bool, answers map[string]*delivery.CandidateAnswer,
- revisions map[string]uint64, online map[string]bool, pulsing map[string]bool, now time.Time) delivery.PlacementVerdict {
- v := delivery.PlacementVerdict{At: now.UTC(), Intended: map[string][]string{}, Unintended: map[string][]string{}, Carried: map[string][]string{},
- Answers: map[string]delivery.CandidateAnswer{}}
- for node, set := range intended {
- for m := range set {
- v.Intended[node] = append(v.Intended[node], m)
- }
- sort.Strings(v.Intended[node])
- }
- must := p.Must
- if len(must) == 0 {
- must = p.Nodes()
- }
- for _, node := range must {
- a := answers[node]
- if a == nil || a.Proposal != p.ID || a.Revision == 0 || a.Revision != revisions[node] {
- v.Silent = append(v.Silent, node)
- continue
- }
- v.Answers[node] = *a
- if a.Refused != "" {
- v.Fails = append(v.Fails, fmt.Sprintf("%s cannot compose the proposal: %s", node, a.Refused))
- continue
- }
- for _, r := range a.Refusals {
- v.Fails = append(v.Fails, fmt.Sprintf("%s refuses the proposal for %s: %s", node, r.Module, r.Why))
- }
- want := intended[node]
- differs := func(module, words string) {
- if p.Seed {
- // The seed carries what its nodes found, each named in the brief for the operator who approves it.
- v.Carried[node] = append(v.Carried[node], words)
- return
- }
- if !want[module] {
- v.Unintended[node] = append(v.Unintended[node], module)
- }
- }
- for _, d := range a.Differs {
- differs(d.Module, differenceWords(d))
- }
- for _, d := range a.Added {
- differs(d.Module, "would run "+d.Module)
- }
- for _, m := range a.Removed {
- differs(m, "would stop running "+m)
- }
- sort.Strings(v.Unintended[node])
- }
- if len(v.Intended) == 0 {
- v.Intended = nil
- }
- if len(v.Unintended) == 0 {
- v.Unintended = nil
- }
- if len(v.Carried) == 0 {
- v.Carried = nil
- }
- if len(v.Answers) == 0 {
- v.Answers = nil
- }
- var waiting, gone []string
- for _, node := range v.Silent {
- named := node
- if online != nil && !online[node] {
- named += " (offline)"
- }
- if pulsing[node] {
- waiting = append(waiting, named)
- } else {
- gone = append(gone, named)
- }
- }
- if len(waiting) > 0 {
- v.Notes = append(v.Notes, fmt.Sprintf("waits for the answers of %s, each for %s from its candidate's write",
- strings.Join(waiting, ", "), AnswerBound))
- }
- if len(gone) > 0 {
- v.Fails = append(v.Fails, fmt.Sprintf("%s did not answer within %s of its candidate's write; judged again when it "+
- "answers", strings.Join(gone, ", "), AnswerBound))
- }
- switch {
- case len(v.Fails) > 0:
- v.State = delivery.VerdictFail
- case len(waiting) > 0:
- v.State = delivery.VerdictWaits
- default:
- v.State = delivery.VerdictPass
- }
- return v
-}
-
-// Brief is the verdict as the ask's brief shows it, by node (§5): what the proposal changes and what each node answered
-// beside it, marked, and what stays held as the node holds it today, with the proposal's why.
-func Brief(p delivery.PlacementProposal, v delivery.PlacementVerdict) string {
- var b strings.Builder
- fmt.Fprintf(&b, "%s (proposed by %s)", p.Why, p.By)
- nodes := map[string]bool{}
- for n := range v.Intended {
- nodes[n] = true
- }
- for n := range v.Unintended {
- nodes[n] = true
- }
- for _, n := range p.Nodes() {
- nodes[n] = true
- }
- for n, a := range v.Answers {
- if len(a.Held) > 0 {
- nodes[n] = true
- }
- }
- for n := range v.Carried {
- nodes[n] = true
- }
- names := make([]string, 0, len(nodes))
- for n := range nodes {
- names = append(names, n)
- }
- sort.Strings(names)
- for _, n := range names {
- fmt.Fprintf(&b, "; %s:", n)
- if m := v.Intended[n]; len(m) > 0 {
- fmt.Fprintf(&b, " changes %s", strings.Join(m, ", "))
- } else {
- b.WriteString(" composes the same")
- }
- if m := v.Unintended[n]; len(m) > 0 {
- fmt.Fprintf(&b, "; also differs, not computed: %s", strings.Join(m, ", "))
- }
- if m := v.Carried[n]; len(m) > 0 {
- fmt.Fprintf(&b, "; the seed carries: %s", strings.Join(m, "; "))
- }
- if held := v.Answers[n].Held; len(held) > 0 {
- var names []string
- for _, h := range held {
- today := h.Today
- if today == "" {
- today = "held"
- }
- names = append(names, h.Module+" ("+today+")")
- }
- fmt.Fprintf(&b, "; stays as today: %s", strings.Join(names, ", "))
- }
- }
- return b.String()
-}
-
-// differenceWords names one module's difference: the module, the first field that differs in it with its value as it
-// runs and as the seed composes it, each cut to about 60 characters ("(value cut)" where a body shown is cut before the
-// field), and how many fields more; or the resource and why when no body is shown.
-func differenceWords(d delivery.ModuleDiffers) string {
- for _, b := range d.Bodies {
- if len(b.Paths) == 0 {
- continue
- }
- path := b.Paths[0]
- words := fmt.Sprintf("%s: %s %s → %s", d.Module, fieldName(b.Running, b.Composed, path),
- valueAt(b.Running, path), valueAt(b.Composed, path))
- if n := len(b.Paths) - 1; n > 0 {
- words += fmt.Sprintf(" (and %d more fields)", n)
- }
- return words
- }
- if d.Resource != "" {
- return fmt.Sprintf("%s: %s (%s)", d.Module, d.Resource, d.Why)
- }
- return fmt.Sprintf("%s: %s", d.Module, d.Why)
-}
-
-// shownValue bounds a value named in a brief.
-const shownValue = 60
-
-// walk follows a differing path (delivery.DifferingPaths: keys joined by dots, a key may hold dots, and a JSON value
-// written in a string is read as JSON) through a body, answering the keys it passed and the value at its end.
-func walk(body, path string) (keys []string, value any, ok bool) {
- var v any
- if json.Unmarshal([]byte(body), &v) != nil {
- return nil, nil, false
- }
- rest := path
- for rest != "" {
- if s, isString := v.(string); isString {
- var inner any
- if json.Unmarshal([]byte(s), &inner) != nil {
- return nil, nil, false
- }
- v = inner
- }
- switch t := v.(type) {
- case map[string]any:
- best := ""
- for k := range t {
- if (rest == k || strings.HasPrefix(rest, k+".")) && len(k) > len(best) {
- best = k
- }
- }
- if best == "" {
- return nil, nil, false
- }
- keys, v, rest = append(keys, best), t[best], strings.TrimPrefix(strings.TrimPrefix(rest, best), ".")
- case []any:
- seg, after, _ := strings.Cut(rest, ".")
- n, err := strconv.Atoi(seg)
- if err != nil || n < 0 || n >= len(t) {
- return nil, nil, false
- }
- keys, v, rest = append(keys, seg), t[n], after
- default:
- return nil, nil, false
- }
- }
- return keys, v, true
-}
-
-// valueAt is the value at path in a shown body, cut to shownValue, or why it is not shown.
-func valueAt(body, path string) string {
- if body == "" {
- return "(none)"
- }
- _, v, ok := walk(body, path)
- if !ok {
- return "(value cut)"
- }
- s, isString := v.(string)
- if !isString {
- raw, _ := json.Marshal(v)
- s = string(raw)
- }
- if r := []rune(s); len(r) > shownValue {
- s = string(r[:shownValue-1]) + "…"
- }
- return s
-}
-
-// fieldName is the differing field as a person reads it: its parent's name and its own, `PARENT[key]`, from whichever
-// body reaches it; the path's end when neither does.
-func fieldName(running, composed, path string) string {
- for _, body := range []string{running, composed} {
- if keys, _, ok := walk(body, path); ok && len(keys) > 0 {
- if len(keys) == 1 {
- return keys[0]
- }
- return keys[len(keys)-2] + "[" + keys[len(keys)-1] + "]"
- }
- }
- return path
-}
diff --git a/go/compose/judge/judge_test.go b/go/compose/judge/judge_test.go
deleted file mode 100644
index 701f197..0000000
--- a/go/compose/judge/judge_test.go
+++ /dev/null
@@ -1,89 +0,0 @@
-package judge
-
-import (
- "slices"
- "strings"
- "testing"
- "time"
-
- "git.novox.be/novox/mesh-sdk/go/delivery"
-)
-
-// ADR 0567 §3, §4 (rows moved from the assigner's check of a pull request's placement, ADR 0559 §5): what a proposal
-// changes by node, and the verdict of the answers: differences shown and failing nothing, a refusal and a silent node
-// failing, a later answer judging again, the seed failing on any difference.
-func TestJudge(t *testing.T) {
- current := map[string]delivery.Placement{"anchor": {Node: "anchor", Modules: []delivery.PlacedModule{{Module: "mail"}}}, "server": {Node: "server"}}
- laid := map[string]*delivery.Placement{
- "anchor": {Node: "anchor", Modules: []delivery.PlacedModule{{Module: "mail", Ports: map[string]int{"25": 2525}}, {Module: "web"}}},
- "laptop": {Node: "laptop", Modules: []delivery.PlacedModule{{Module: "web"}}}}
- got, must, _ := Intended(nil, nil, current, laid, []string{"anchor", "laptop", "server"}, nil)
- if !got["anchor"]["mail"] || !got["anchor"]["web"] || len(got["laptop"]) != 0 || !slices.Equal(must, []string{"anchor", "laptop"}) {
- t.Errorf("intended = %v, must %v: want anchor's mail and web, nothing on the laptop that has no key, server untouched", got, must)
- }
-
- at := time.Date(2026, 10, 27, 2, 0, 0, 0, time.UTC)
- p := delivery.PlacementProposal{ID: "p-one", By: "op", Why: "move web", Written: at, Base: map[string]uint64{"anchor": 3, "laptop": 0},
- Must: []string{"anchor", "laptop"}}
- revisions := map[string]uint64{"anchor": 7, "laptop": 8}
- intended := map[string]map[string]bool{"anchor": {"web": true}}
- same := func(rev uint64) *delivery.CandidateAnswer {
- return &delivery.CandidateAnswer{Proposal: "p-one", Revision: rev, Verdict: delivery.CandidateSame, Same: []string{"mail"}}
- }
- differs := func(rev uint64, module string) *delivery.CandidateAnswer {
- return &delivery.CandidateAnswer{Proposal: "p-one", Revision: rev, Verdict: delivery.CandidateDiffers,
- Differs: []delivery.ModuleDiffers{{Module: module, Why: "the port differs"}}}
- }
- for _, row := range []struct {
- name string
- seed bool
- answers map[string]*delivery.CandidateAnswer
- pulsing map[string]bool
- state string
- words string
- marked string
- }{
- {"all the same", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": same(8)}, nil, delivery.VerdictPass, "", ""},
- {"a module added where intended", false, map[string]*delivery.CandidateAnswer{"anchor": {Proposal: "p-one", Revision: 7, Verdict: delivery.CandidateDiffers,
- Added: []delivery.ModuleAdded{{Module: "web", Unissued: []string{"its own secret"}}}}, "laptop": same(8)}, nil, delivery.VerdictPass, "", ""},
- {"a difference not computed passes, marked", false, map[string]*delivery.CandidateAnswer{"anchor": differs(7, "mail"), "laptop": same(8)}, nil, delivery.VerdictPass, "", "mail"},
- {"the seed carries a difference, named", true, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": {Proposal: "p-one", Revision: 8,
- Verdict: delivery.CandidateDiffers, Differs: []delivery.ModuleDiffers{{Module: "node-tools", Resource: "node-tools.runtime", Why: "the resource differs",
- Bodies: []delivery.ResourceBodies{{ID: "node-tools.runtime", Paths: []string{"env.MESH_OWNED_PATHS./etc/resolv.conf", "env.MESH_TOOL_MODULES"},
- Running: `{"env":{"MESH_OWNED_PATHS":"{\"/etc/hosts\":\"hostname\",\"/etc/resolv.conf\":\"networkmanager (node-uplink)\"}"}}`,
- Composed: `{"env":{"MESH_OWNED_PATHS":"{\"/etc/hosts\":\"hostname\",\"/etc/resolv.conf\":\"systemd-resolved (node-resolver)\"}","MESH_TOOL_MODULES":"… (cut at 8192 bytes)`}}}}}}, nil, delivery.VerdictPass, "", ""},
- {"a refusal", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": {Proposal: "p-one", Revision: 8, Verdict: delivery.CandidateRefused,
- Refusals: []delivery.ModuleRefused{{Module: "web", Why: "no own secret"}}}}, nil, delivery.VerdictFail, "laptop refuses", ""},
- {"a node not answering, within its bound", false, map[string]*delivery.CandidateAnswer{"anchor": same(7)}, map[string]bool{"laptop": true}, delivery.VerdictWaits, "laptop (offline)", ""},
- {"an answer of an earlier revision, past its bound", false, map[string]*delivery.CandidateAnswer{"anchor": same(6), "laptop": same(8)}, nil, delivery.VerdictFail, "anchor did not answer", ""},
- {"a module held as today passes, shown in the brief", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": {Proposal: "p-one", Revision: 8,
- Verdict: delivery.CandidateSame, Held: []delivery.ModuleRefused{{Module: "spiegel", Why: "a required value is unset"}}}}, nil, delivery.VerdictPass, "", ""},
- {"its later answer", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": same(8)}, nil, delivery.VerdictPass, "", ""},
- } {
- q := p
- q.Seed = row.seed
- v := JudgeAnswers(q, intended, row.answers, revisions, map[string]bool{"anchor": true}, row.pulsing, at)
- all := strings.Join(append(append([]string{}, v.Fails...), v.Notes...), "; ")
- if v.State != row.state || !strings.Contains(all, row.words) {
- t.Errorf("%s: %s, %q", row.name, v.State, all)
- }
- if row.seed && row.state == delivery.VerdictPass && !strings.Contains(Brief(q, v), "the seed carries: node-tools: MESH_OWNED_PATHS[/etc/resolv.conf] networkmanager (node-uplink) → (value cut) (and 1 more fields)") {
- t.Errorf("%s: the brief does not name it: %s", row.name, Brief(q, v))
- }
- if row.seed && row.state == delivery.VerdictPass {
- whole := row.answers["laptop"].Differs[0]
- whole.Bodies = []delivery.ResourceBodies{{ID: "r", Paths: []string{"env.MESH_OWNED_PATHS./etc/resolv.conf"},
- Running: `{"env":{"MESH_OWNED_PATHS":"{\"/etc/resolv.conf\":\"networkmanager (node-uplink)\"}"}}`,
- Composed: `{"env":{"MESH_OWNED_PATHS":"{\"/etc/resolv.conf\":\"systemd-resolved (node-resolver)\"}"}}`}}
- if got := differenceWords(whole); got != "node-tools: MESH_OWNED_PATHS[/etc/resolv.conf] networkmanager (node-uplink) → systemd-resolved (node-resolver)" {
- t.Errorf("the difference is named %q", got)
- }
- }
- if strings.HasPrefix(row.name, "a module held as today") && !strings.Contains(Brief(q, v), "stays as today: spiegel (held)") {
- t.Errorf("%s: the brief does not show it: %s", row.name, Brief(q, v))
- }
- if row.marked != "" && (!slices.Contains(v.Unintended["anchor"], row.marked) || !strings.Contains(Brief(q, v), "not computed: "+row.marked)) {
- t.Errorf("%s: the difference is not marked: %+v, %s", row.name, v.Unintended, Brief(q, v))
- }
- }
-}
diff --git a/go/compose/network.go b/go/compose/network.go
deleted file mode 100644
index 6998bb8..0000000
--- a/go/compose/network.go
+++ /dev/null
@@ -1,307 +0,0 @@
-package compose
-
-import (
- "errors"
- "fmt"
- "slices"
- "sort"
- "strconv"
- "strings"
-
- "git.novox.be/novox/mesh-sdk/go/catalogue"
- "git.novox.be/novox/mesh-sdk/go/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 := catalogue.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/go/compose/network_test.go b/go/compose/network_test.go
deleted file mode 100644
index ff73e08..0000000
--- a/go/compose/network_test.go
+++ /dev/null
@@ -1,306 +0,0 @@
-package compose
-
-import (
- "strings"
- "testing"
-
- "git.novox.be/novox/mesh-sdk/go/catalogue"
- "git.novox.be/novox/mesh-sdk/go/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/go/compose/node.go b/go/compose/node.go
deleted file mode 100644
index 81e8557..0000000
--- a/go/compose/node.go
+++ /dev/null
@@ -1,351 +0,0 @@
-package compose
-
-import (
- "encoding/json"
- "fmt"
- "sort"
- "strings"
-
- "git.novox.be/novox/mesh-sdk/go/catalogue"
- "git.novox.be/novox/mesh-sdk/go/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 := plan.Compose(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 := plan.Rules(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: catalogue.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 := plan.RequiredBy(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 resolved.Plan.DataItems(rendered.Rendering)
-}
diff --git a/go/compose/node_test.go b/go/compose/node_test.go
deleted file mode 100644
index c2485dc..0000000
--- a/go/compose/node_test.go
+++ /dev/null
@@ -1,424 +0,0 @@
-package compose
-
-import (
- "encoding/json"
- "strings"
- "testing"
-
- "git.novox.be/novox/mesh-sdk/go/catalogue"
- "git.novox.be/novox/mesh-sdk/go/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/go/compose/one_machine_alone_test.go b/go/compose/one_machine_alone_test.go
deleted file mode 100644
index aa6b4c9..0000000
--- a/go/compose/one_machine_alone_test.go
+++ /dev/null
@@ -1,167 +0,0 @@
-package compose
-
-import (
- "slices"
- "strings"
- "testing"
-
- "git.novox.be/novox/mesh-sdk/go/catalogue"
- "git.novox.be/novox/mesh-sdk/go/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/go/compose/render.go b/go/compose/render.go
deleted file mode 100644
index 3354fad..0000000
--- a/go/compose/render.go
+++ /dev/null
@@ -1,150 +0,0 @@
-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/go/compose/rendering.go b/go/compose/rendering.go
deleted file mode 100644
index d942bc6..0000000
--- a/go/compose/rendering.go
+++ /dev/null
@@ -1,543 +0,0 @@
-package compose
-
-import (
- "errors"
- "fmt"
- "sort"
- "strings"
-
- "git.novox.be/novox/mesh-sdk/go/catalogue"
- "git.novox.be/novox/mesh-sdk/go/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 = catalogue.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 := catalogue.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 := catalogue.ZoneOn(m, layers, published, n.Name, at)
- if err != nil {
- return nil, err
- }
- zones = append(zones, *z)
- }
- }
- if problems := catalogue.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 := catalogue.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 catalogue.JudgeLocalNetworks(n.Networks, n.OutwardLinks, overlay.Interface)
-}
diff --git a/go/compose/rendering_test.go b/go/compose/rendering_test.go
deleted file mode 100644
index 7ba4a88..0000000
--- a/go/compose/rendering_test.go
+++ /dev/null
@@ -1,329 +0,0 @@
-package compose
-
-import (
- "errors"
- "strings"
- "testing"
-
- "git.novox.be/novox/mesh-sdk/go/catalogue"
- "git.novox.be/novox/mesh-sdk/go/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/go/compose/resolve.go b/go/compose/resolve.go
deleted file mode 100644
index 6931adc..0000000
--- a/go/compose/resolve.go
+++ /dev/null
@@ -1,347 +0,0 @@
-package compose
-
-import (
- "fmt"
- "slices"
- "strings"
-
- "git.novox.be/novox/mesh-sdk/go/catalogue"
-)
-
-// LicenceProvision is what a module requires in order to be given a licence (the controller's licences.Provision).
-const LicenceProvision = "model-access"
-
-// Resolved is what one node should run, from what was assigned to it, with the settings of everything that resolved.
-type Resolved struct {
- Plan catalogue.Resolution
- Settings catalogue.SettingsBy
- // Foreseen are the needs composed with a stand-in for a credential the next send makes (or holds with no value given
- // here), as " on needs from ". Nothing was made.
- Foreseen []string
- // NotGiven are the words of each credential only a person gives that nobody has: the consumer still resolves without it
- // (novox/hq ADR 0337 clause 4).
- NotGiven []string
-}
-
-// Resolve works out everything a node should run, from what was assigned to it (the controller's planFor, plan.go): the
-// resolution over the rest of the mesh, the credential of each thing the node takes from elsewhere, and the settings of
-// every module that resolved. It makes and writes nothing: a credential not held is composed with a stand-in and named
-// foreseen, one only a person gives and nobody has is left out and said, one the send would refuse is refused with the
-// controller's words. A failure to compose the node's own modules is an error that satisfies IsUnresolvable, told apart
-// from an unreadable Input (novox/hq 04-ISSUES/152).
-func Resolve(in Input, node string) (Resolved, error) {
- return newComposer(in).planFor(node)
-}
-
-// IsUnresolvable reports whether err is a node's own set failing to compose, rather than the mesh being unable to answer.
-func IsUnresolvable(err error) bool { return unresolvable(err) }
-
-// planFor is the controller's planFor, worked out once per node in one composition (issue 1954): each caller has its
-// own copy of what a caller may set (a need's fields, as Resolution.Compose sets its Serves), the rest only read.
-func (c *composer) planFor(nodeName string) (Resolved, error) {
- p, ok := c.plans[nodeName]
- if !ok {
- p.resolved, p.err = c.planForNow(nodeName)
- if c.plans == nil {
- c.plans = map[string]planOf{}
- }
- c.plans[nodeName] = p
- }
- if p.err != nil {
- return Resolved{}, p.err
- }
- return p.resolved.copied(), nil
-}
-
-// planOf is planFor's answer for one node, kept.
-type planOf struct {
- resolved Resolved
- err error
-}
-
-// copied is r with its own needs, words and settings lists: what a caller may change of it.
-func (r Resolved) copied() Resolved {
- out := r
- out.Plan.Needs = slices.Clone(r.Plan.Needs)
- out.Foreseen = slices.Clone(r.Foreseen)
- out.NotGiven = slices.Clone(r.NotGiven)
- if r.Settings != nil {
- out.Settings = make(catalogue.SettingsBy, len(r.Settings))
- for m, layers := range r.Settings {
- out.Settings[m] = slices.Clone(layers)
- }
- }
- return out
-}
-
-// planForNow is planFor worked out.
-func (c *composer) planForNow(nodeName string) (Resolved, error) {
- resolved, err := c.resolution(nodeName)
- if err != nil {
- return Resolved{}, err
- }
- node, err := c.in.node(nodeName)
- if err != nil {
- return Resolved{}, err
- }
- shelf := c.shelfFor(node)
- var out Resolved
-
- // The credential for each thing this node takes from elsewhere.
- for i, n := range resolved.Needs {
- // Issued for this holder alone and kept by the vault (issue 757).
- resolved.Needs[i].IssuedToHolder = issuedToHolder(shelf, n)
- resolved.Needs[i].ProviderMoved = providerMoved(shelf, n)
- resolved.Needs[i].AnsweredByHelper = answeredByHelper(shelf, n)
- if n.ByRecord {
- // Answered by something the mesh holds, so there is no pair-wise secret between two machines.
- licence, _ := c.licence(n.From)
- if licence.Keys == nil {
- // The keys are not given: composed with a stand-in and named, not failed for a value only a send reads.
- resolved.Needs[i].Sealed = catalogue.ForeseenSealed
- out.Foreseen = append(out.Foreseen, fmt.Sprintf("%s on %s needs %s from %s", n.For, nodeName, n.Name, n.From))
- } else {
- // Given, and none for this holder: the mesh holds none.
- resolved.Needs[i].Sealed = licence.Keys[Holder{Node: nodeName, Module: n.For}]
- }
- // If this holder is the licence's manager, hand it the manager node's PUBLIC sealing key in its bound facts
- // (novox/hq ADR 0050).
- if pub := c.managerPublicKeyFor(licence, nodeName, n.For); pub != "" {
- serves := map[string]any{}
- for k, v := range resolved.Needs[i].Serves {
- serves[k] = v
- }
- serves["manager_public_key"] = pub
- resolved.Needs[i].Serves = serves
- // The manager holder: its empty pre-adoption refresh token is a waiting state, not a missing consumer key.
- resolved.Needs[i].Manager = true
- }
- continue
- }
- if credentialless(n) {
- // Its provider's offer presents no credential (form none): nothing is sealed, made or stood in for (issue 1126).
- continue
- }
- cred, held := c.credential(n, nodeName)
- _, outside := issuedOutsideBy(shelf, n)
- switch {
- case cred.State == CredentialRefused:
- // Said rather than skipped. A machine that resolves cleanly and receives no credential is one that will fail to
- // authenticate at some later, less obvious moment.
- return Resolved{}, fmt.Errorf("%s on %s needs %s from %s and no credential could be made for it: %s",
- n.For, nodeName, n.Name, n.From, cred.Why)
- case cred.State == CredentialNotGiven, outside && !(held && cred.State == CredentialHeld):
- // Not given, and the rest of the consumer runs (ADR 0337 clause 4): no credential file, so the module can tell
- // it was not given; nothing the mesh made stands in for it.
- why := cred.Why
- if why == "" {
- why = "nobody gave it"
- }
- out.NotGiven = append(out.NotGiven, fmt.Sprintf("%s on %s holds no %s from %s: %s", n.For, nodeName, n.Name, n.From, why))
- continue
- case cred.State == CredentialHeld && cred.Sealed != "":
- resolved.Needs[i].Sealed = cred.Sealed
- default:
- // Held with no value here, or none held: the next send makes it (or reads it), and a stand-in is composed.
- resolved.Needs[i].Sealed = catalogue.ForeseenSealed
- out.Foreseen = append(out.Foreseen, fmt.Sprintf("%s on %s needs %s from %s", n.For, nodeName, n.Name, n.From))
- }
- }
-
- // Settings for everything that resolved, including modules nobody assigned directly: a requirement pulled in by
- // something else is still configurable.
- settings := catalogue.SettingsBy{}
- for _, m := range resolved.Modules {
- layers := node.Settings[m.Module]
- if len(layers) == 0 {
- continue
- }
- settings[m.Module] = append([]catalogue.Layer(nil), layers...)
- }
- out.Plan, out.Settings = resolved, settings
- return out, nil
-}
-
-// credential is how the pair credential of one need stands in the Input, and whether the Input names it at all.
-func (c *composer) credential(n catalogue.Needed, consumer string) (Credential, bool) {
- for _, cr := range c.in.Credentials {
- if cr.Provision == n.Name && cr.Consumer == consumer && cr.ConsumerModule == n.For && cr.Provider == n.From && cr.Local == n.Local {
- return cr, true
- }
- }
- return Credential{State: CredentialWouldMake}, false
-}
-
-// licence is the licence of that name.
-func (c *composer) licence(name string) (Licence, bool) {
- for _, l := range c.in.Licences {
- if l.Name == name {
- return l, true
- }
- }
- return Licence{}, false
-}
-
-// managerPublicKeyFor is the manager node's public sealing key, but only when (node, module) is the licence's manager holder
-// and empty otherwise (plan.go managerPublicKeyFor): delivered to the manager module in its bound facts so it can re-seal a
-// rotated refresh token to this node (novox/hq ADR 0050), and to no consumer.
-func (c *composer) managerPublicKeyFor(l Licence, node, module string) string {
- if l.Manager == (Holder{}) || node != l.Manager.Node || module != l.Manager.Module {
- return ""
- }
- n, err := c.in.node(node)
- if err != nil {
- return ""
- }
- return n.SealingKey
-}
-
-// issuedOutsideBy is whether a need's provision is offered by its provider with a credential issued outside the mesh
-// (novox/hq ADR 0337), and that provider module: the one answering it when known, else any that offers it so.
-func issuedOutsideBy(shelf map[string]catalogue.Manifest, n catalogue.Needed) (string, bool) {
- if n.Module != "" {
- if m, known := shelf[n.Module]; known {
- return n.Module, m.IssuedOutsideOf(n.Name)
- }
- }
- return catalogue.OffersIssuedOutside(shelf, n.Name)
-}
-
-// issuedToHolder is whether the vault keeps a need's credential for its holder alone (issues 749, 757 and 779).
-func issuedToHolder(shelf map[string]catalogue.Manifest, n catalogue.Needed) bool {
- if n.ByRecord || n.Module == "" {
- return false
- }
- provider, known := shelf[n.Module]
- if !known || !movedProvider(provider) {
- return false
- }
- if _, issued := provider.IssuedCredentialOf(n.Name); issued {
- return true
- }
- return provider.CredentialFormOf(n.Name) == catalogue.CredentialFormPair
-}
-
-// providerMoved is whether a need is answered by a module marked moved (issue 788), or by one whose offer of the provision
-// says the provider helper answers it (issue 792).
-func providerMoved(shelf map[string]catalogue.Manifest, n catalogue.Needed) bool {
- if n.ByRecord || n.Module == "" {
- return false
- }
- provider, known := shelf[n.Module]
- return known && (movedProvider(provider) || provider.AnsweredByHelperFor(n.Name))
-}
-
-// answeredByHelper is whether a need's provider offers it answered by the provider helper (issue 792): keyed by the seat
-// mesh-provisioner for every consumer, handed or not (catalogue.Keyed, issue 774).
-func answeredByHelper(shelf map[string]catalogue.Manifest, n catalogue.Needed) bool {
- if n.ByRecord || n.Module == "" {
- return false
- }
- provider, known := shelf[n.Module]
- return known && provider.AnsweredByHelperFor(n.Name)
-}
-
-// movedProvider is whether a provider's credentials are the vault's: the module is marked moved, or it holds the
-// mesh-vault seat (issue 793).
-func movedProvider(m catalogue.Manifest) bool {
- return m.Moved || slices.ContainsFunc(m.Claims, func(c catalogue.Claim) bool { return c.Name == "mesh-vault" })
-}
-
-// resolution works out what one node should run, from what was assigned to it: the controller's planFor up to and
-// including the resolution (plan.go planFor), and no further. A failure to compose the node's own modules is wrapped as
-// notResolvable; every other failure is returned as it is.
-func (c *composer) resolution(nodeName string) (catalogue.Resolution, error) {
- node, err := c.in.node(nodeName)
- if err != nil {
- return catalogue.Resolution{}, err
- }
- shelf := c.shelfFor(node)
- world, _, left, err := c.theRestOfTheMesh(nodeName)
- if err != nil {
- return catalogue.Resolution{}, err
- }
- world.Pinned = node.Pins
- // Where each of its consumers of a provision that keeps data was last sent (novox/hq ADR 0232).
- world.Bound = node.Bound
-
- onNetwork, err := c.whereEveryoneIs()
- if err != nil {
- return catalogue.Resolution{}, err
- }
-
- // What this mesh can answer with a record rather than a machine, and which record each of this node's modules was
- // put on.
- world.Licences, world.Using = c.licencesFor(nodeName)
-
- resolved, err := catalogue.Resolve(shelf, node.Assigned,
- catalogue.Node{Name: nodeName, Site: node.Place.Site, Capabilities: node.Capabilities,
- At: onNetwork[nodeName], PublicDomain: node.PublicDomain,
- Account: node.Account, AccountHome: node.AccountHome,
- AgentAccount: node.AgentAccount, AgentAccountHome: node.AgentAccountHome}, world)
- if err != nil {
- // What another node's set would have offered is missing when that set does not resolve here: said with the
- // refusal, so "nothing provides" is read beside why (issue 1126, probe #532).
- if len(left) > 0 {
- var why []string
- for _, l := range left {
- why = append(why, l.String())
- }
- err = fmt.Errorf("%w\n\nand %d node(s) offer nothing here, as they could not be resolved: %s", err, len(left), strings.Join(why, "; "))
- }
- if len(c.unhostable) > 0 {
- err = fmt.Errorf("%w\n\nand these modules offer nothing here, as their node cannot run them in this composition: %s",
- err, strings.Join(c.unhostable, "; "))
- }
- // The node's own set does not compose: the only failure a mesh-wide gatherer may pass over.
- return catalogue.Resolution{}, notResolvable{err}
- }
- return resolved, nil
-}
-
-// shelfFor is the catalogue as one node composes its own set: the Input's, with each recorded or core module the node keeps at the
-// build it runs put in place of the current one (plan.go keepRecorded). A copy: the Input's catalogue is untouched. Only the node's own planFor and
-// resolution use it; the rest of the mesh and who is on the network are read with the current manifests, as the controller reads them
-// before it keeps a recorded module.
-func (c *composer) shelfFor(node Node) map[string]catalogue.Manifest {
- if len(node.Kept) == 0 {
- return c.in.Catalogue
- }
- shelf := make(map[string]catalogue.Manifest, len(c.in.Catalogue)+len(node.Kept))
- for name, m := range c.in.Catalogue {
- shelf[name] = m
- }
- for name, m := range node.Kept {
- shelf[name] = m
- }
- return shelf
-}
-
-// licencesFor is what this node can be answered with by record, and what it was put on. A mesh with no licences at all
-// is the ordinary case and not an error.
-func (c *composer) licencesFor(node string) (map[string][]catalogue.Record, map[string]map[string]catalogue.Record) {
- all := c.in.Licences
- if len(all) == 0 {
- return nil, nil
- }
- offered := map[string][]catalogue.Record{}
- byName := map[string]catalogue.Record{}
- for _, one := range all {
- record := catalogue.Record{Name: one.Name, Serves: one.Serves}
- offered[LicenceProvision] = append(offered[LicenceProvision], record)
- byName[one.Name] = record
- }
- using := map[string]map[string]catalogue.Record{}
- for _, one := range all {
- for _, h := range one.Holders {
- if h.Node != node {
- continue
- }
- if using[h.Module] == nil {
- using[h.Module] = map[string]catalogue.Record{}
- }
- using[h.Module][LicenceProvision] = byName[one.Name]
- }
- }
- return offered, using
-}
diff --git a/go/compose/resolve_test.go b/go/compose/resolve_test.go
deleted file mode 100644
index 8bb30b8..0000000
--- a/go/compose/resolve_test.go
+++ /dev/null
@@ -1,388 +0,0 @@
-package compose
-
-import (
- "errors"
- "strings"
- "testing"
-
- "git.novox.be/novox/mesh-sdk/go/catalogue"
- "git.novox.be/novox/mesh-sdk/go/overlay"
-)
-
-// storeManifests is mesh-controller's bindings_test.go storeManifests (cmd/mesh-controller at 6c2a8d62): a store holding the
-// mesh-store seat, a resolver holding mesh-dns-resolver, a network that requires wildcard resolution, and a board that
-// requires a database.
-func storeManifests() []catalogue.Manifest {
- return []catalogue.Manifest{
- {Module: "store", Version: "1",
- Provides: []catalogue.Offer{{Name: "postgres-database", Scope: catalogue.ScopeMesh}},
- Claims: []catalogue.Claim{{Name: "mesh-store", Scope: catalogue.ScopeMesh}},
- Serves: map[string]map[string]any{"postgres-database": {"port": 5432}},
- Grants: map[string]string{"postgres-database": "/var/lib/mesh/store/grants"}},
- {Module: "resolver", Version: "1",
- Provides: []catalogue.Offer{{Name: "wildcard-resolution", Scope: catalogue.ScopeMesh}},
- Claims: []catalogue.Claim{{Name: "mesh-dns-resolver", Scope: catalogue.ScopeMesh}}},
- {Module: "network", Version: "1", Requires: []string{"wildcard-resolution"}},
- {Module: "board", Version: "1", Requires: []string{"postgres-database"}},
- }
-}
-
-func need(t *testing.T, plan catalogue.Resolution, consumer, provision string) catalogue.Needed {
- t.Helper()
- for _, n := range plan.Needs {
- if n.For == consumer && n.Name == provision {
- return n
- }
- }
- t.Fatalf("no %s for %s: %+v", provision, consumer, plan.Needs)
- return catalogue.Needed{}
-}
-
-// theStoreMesh is the mesh of mesh-controller's bindings_integration_test.go
-// TestTheIncidentAConsumerStaysBesideItsDataUntilAPersonPinsIt: the anchor's store and resolver hold the mesh's seats on
-// record; the laptop runs its own of each, and a network and a board.
-func theStoreMesh(t *testing.T) Input {
- t.Helper()
- in := aMesh(t)
- for _, m := range storeManifests() {
- in.Catalogue[m.Module] = m
- }
- anchor, laptop := nodeOf(t, &in, "anchor"), nodeOf(t, &in, "laptop")
- anchor.Assigned = append(anchor.Assigned, "store", "resolver")
- laptop.Assigned = append(laptop.Assigned, "store", "resolver", "network", "board")
- in.Holdings = []catalogue.Held{
- {Claim: "mesh-store", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "store", Site: "here"},
- {Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "resolver", Site: "here"},
- }
- return in
-}
-
-// 1362/AC7 and AC4 (bindings_integration_test.go): a consumer of a provision that keeps its data stays beside its data, one that
-// does not follows the seat; the laptop's store taken away, the consumer bound there on record is refused, not moved to the
-// anchor's empty one; a person's pin moves it.
-func TestAConsumerStaysBesideItsDataUntilAPersonPinsIt(t *testing.T) {
- in := theStoreMesh(t)
- got, err := Resolve(in, "laptop")
- if err != nil {
- t.Fatal(err)
- }
- if n := need(t, got.Plan, "board", "postgres-database"); n.From != "laptop" || n.Module != "store" || !n.KeepsData {
- t.Fatalf("the consumer was bound to %s/%s (keeps data: %v); its data is beside it", n.From, n.Module, n.KeepsData)
- }
- if n := need(t, got.Plan, "network", "wildcard-resolution"); n.From != "anchor" || n.KeepsData {
- t.Fatalf("the resolver was bound to %s (keeps data: %v); its seat is held on anchor (issue 258)", n.From, n.KeepsData)
- }
-
- // Sent, and recorded: the binding to data. The laptop's store taken away: refused, not moved to the anchor's empty one.
- laptop := nodeOf(t, &in, "laptop")
- laptop.Bound = map[string]map[string]catalogue.Chosen{"board": {"postgres-database": {Node: "laptop", Module: "store"}}}
- laptop.Assigned = []string{overlay.Name, "resolver", "network", "board"}
- _, err = Resolve(in, "laptop")
- if err == nil || !IsUnresolvable(err) ||
- !strings.Contains(err.Error(), "board on laptop is bound to laptop/store") ||
- !strings.Contains(err.Error(), "pin laptop postgres-database anchor store") {
- t.Fatalf("the consumer's store went and it was answered elsewhere: %v", err)
- }
-
- // A person pins the anchor's: it moves, and the resolution says where it was bound (Kept is empty, as it moves).
- laptop.Pins = map[string]catalogue.Chosen{"postgres-database": {Node: "anchor", Module: "store"}}
- got, err = Resolve(in, "laptop")
- if err != nil {
- t.Fatal(err)
- }
- if n := need(t, got.Plan, "board", "postgres-database"); n.From != "anchor" {
- t.Fatalf("pinned to the anchor and bound to %s", n.From)
- }
-}
-
-// 1362/AC4: a data-keeping consumer bound on record to a provider elsewhere is kept on that provider, and the resolution says
-// so (Resolution.Kept) when the mesh would answer it otherwise.
-func TestADataKeepingConsumerBoundElsewhereIsKeptThereAndSaid(t *testing.T) {
- in := theStoreMesh(t)
- laptop := nodeOf(t, &in, "laptop")
- laptop.Assigned = []string{overlay.Name, "resolver", "network", "board"} // no store of its own
- laptop.Bound = map[string]map[string]catalogue.Chosen{"board": {"postgres-database": {Node: "anchor", Module: "store"}}}
- got, err := Resolve(in, "laptop")
- if err != nil {
- t.Fatal(err)
- }
- if n := need(t, got.Plan, "board", "postgres-database"); n.From != "anchor" || !n.KeepsData {
- t.Fatalf("the consumer is bound to %s (keeps data: %v), not where it was sent", n.From, n.KeepsData)
- }
- if len(got.Plan.Kept) != 0 {
- t.Errorf("a binding that was kept where it is is said as a kept move: %+v", got.Plan.Kept)
- }
- // And bound somewhere else on record while the mesh would answer from the anchor: kept, and said.
- laptop.Bound = map[string]map[string]catalogue.Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}}
- home := Node{Name: "home", Place: Place{Site: "there", Address: "10.77.0.7"}, Assigned: []string{overlay.Name, "store"},
- Capabilities: map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true}}
- in.Nodes = append(in.Nodes, home)
- got, err = Resolve(in, "laptop")
- if err != nil {
- t.Fatal(err)
- }
- if n := need(t, got.Plan, "board", "postgres-database"); n.From != "home" {
- t.Errorf("the consumer was moved from where it was sent: %s", n.From)
- }
-}
-
-// A provider module whose offer says only a person gives its credential (ADR 0337), and a consumer of it.
-func outsideObjects() catalogue.Manifest {
- m := objects()
- m.Provides[0].Credential = &catalogue.OfferCredential{IssuedBy: catalogue.IssuedOutside}
- return m
-}
-
-func theObjectsMesh(t *testing.T, provider catalogue.Manifest) Input {
- t.Helper()
- in := aMesh(t)
- in.Catalogue[provider.Module], in.Catalogue["files"] = provider, files()
- nodeOf(t, &in, "anchor").Assigned = append(nodeOf(t, &in, "anchor").Assigned, provider.Module)
- nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "files")
- nodeOf(t, &in, "anchor").SealingKey = "anchor-key"
- nodeOf(t, &in, "laptop").SealingKey = "laptop-key"
- return in
-}
-
-func theFilesNeed(t *testing.T, got Resolved) catalogue.Needed {
- t.Helper()
- return need(t, got.Plan, "files", "s3-bucket")
-}
-
-// 1362/AC1: a pair credential the Input names as existing with no value, and one it names not at all, are composed with a
-// stand-in and named foreseen, and nothing is made; one with a value is composed with it and foreseen is empty.
-func TestAPairCredentialNotHeldIsComposedWithAStandInAndNamedForeseen(t *testing.T) {
- mk := func() Input { return theObjectsMesh(t, objects()) }
- for name, creds := range map[string][]Credential{
- "no entry": nil,
- "held with no value": {{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialHeld}},
- "to be made": {{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialWouldMake}},
- } {
- in := mk()
- in.Credentials = creds
- got, err := Resolve(in, "laptop")
- if err != nil {
- t.Fatalf("%s: %v", name, err)
- }
- if n := theFilesNeed(t, got); n.Sealed != catalogue.ForeseenSealed {
- t.Errorf("%s: the credential is %q, not the stand-in", name, n.Sealed)
- }
- if len(got.Foreseen) != 1 || got.Foreseen[0] != "files on laptop needs s3-bucket from anchor" {
- t.Errorf("%s: foreseen is %v", name, got.Foreseen)
- }
- }
- in := mk()
- in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor",
- State: CredentialHeld, Sealed: "sealed-to-laptop"}}
- got, err := Resolve(in, "laptop")
- if err != nil {
- t.Fatal(err)
- }
- if n := theFilesNeed(t, got); n.Sealed != "sealed-to-laptop" || len(got.Foreseen) != 0 {
- t.Errorf("a held credential: %q foreseen %v", n.Sealed, got.Foreseen)
- }
- unchanged(t, mk, func(in Input) {
- if _, err := Resolve(in, "laptop"); err != nil {
- t.Fatal(err)
- }
- })
-}
-
-// 1362/AC2: a credential only a person gives and nobody has is not given and not made: the consumer still resolves without it
-// (novox/hq ADR 0337 clause 4), said with the controller's words; one given is composed with it.
-func TestACredentialOnlyAPersonGivesIsNotMadeAndTheConsumerStillResolves(t *testing.T) {
- for name, creds := range map[string][]Credential{
- "no entry": nil,
- "to be made (never, for one issued outside)": {{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialWouldMake}},
- "not given": {{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialNotGiven,
- Why: "the value held was made by the mesh, which objects's software would refuse"}},
- } {
- in := theObjectsMesh(t, outsideObjects())
- in.Credentials = creds
- got, err := Resolve(in, "laptop")
- if err != nil {
- t.Fatalf("%s: %v", name, err)
- }
- if n := theFilesNeed(t, got); n.Sealed != "" {
- t.Errorf("%s: a value stands in for what nobody gave: %q", name, n.Sealed)
- }
- if len(got.Foreseen) != 0 || len(got.NotGiven) != 1 || !strings.Contains(got.NotGiven[0], "files on laptop holds no s3-bucket from anchor: ") {
- t.Errorf("%s: foreseen %v not given %v", name, got.Foreseen, got.NotGiven)
- }
- if len(got.Plan.Modules) == 0 {
- t.Errorf("%s: the machine did not resolve", name)
- }
- }
- in := theObjectsMesh(t, outsideObjects())
- in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor",
- State: CredentialHeld, Sealed: "given-and-sealed"}}
- got, err := Resolve(in, "laptop")
- if err != nil || theFilesNeed(t, got).Sealed != "given-and-sealed" || len(got.NotGiven) != 0 {
- t.Fatalf("a value a person gave: %+v %v", got.NotGiven, err)
- }
-}
-
-// 1362/AC3: a credential the send would refuse, a given value sealed to a key a machine no longer has, refuses the resolution with
-// today's words, and is not a node whose set does not compose.
-func TestACredentialTheSendWouldRefuseRefusesTheResolutionInTheSendsWords(t *testing.T) {
- why := "files's \"s3-bucket\" credential from anchor was accepted from a person, and a sealing key at one end has changed since. " +
- "The mesh cannot re-seal a value it does not hold: accept it again with `secret accept laptop files s3-bucket --provider anchor`"
- in := theObjectsMesh(t, objects())
- in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor",
- State: CredentialRefused, Why: why}}
- _, err := Resolve(in, "laptop")
- want := "files on laptop needs s3-bucket from anchor and no credential could be made for it: " + why
- if err == nil || err.Error() != want {
- t.Fatalf("got %v, want %q", err, want)
- }
- if IsUnresolvable(err) {
- t.Error("a refused credential is a node whose set does not compose")
- }
-}
-
-// A record-answered need is filled with the licence key sealed to this holder, and the manager holder is handed the manager node's
-// public sealing key (novox/hq ADR 0050) and no other.
-func TestARecordAnsweredNeedGetsItsKeyAndTheManagerItsPublicKey(t *testing.T) {
- in := aMesh(t)
- in.Catalogue["assistant"] = catalogue.Manifest{Module: "assistant", Version: "1", Requires: []string{LicenceProvision}}
- laptop := nodeOf(t, &in, "laptop")
- laptop.Assigned = append(laptop.Assigned, "assistant")
- laptop.SealingKey = "laptop-key"
- in.Licences = []Licence{{Name: "personal", Serves: map[string]any{"model": "x"},
- Holders: []Holder{{Node: "laptop", Module: "assistant"}},
- Keys: map[Holder]string{{Node: "laptop", Module: "assistant"}: "key-sealed-to-laptop"}}}
- got, err := Resolve(in, "laptop")
- if err != nil {
- t.Fatal(err)
- }
- n := need(t, got.Plan, "assistant", LicenceProvision)
- if !n.ByRecord || n.Sealed != "key-sealed-to-laptop" || n.Manager || n.Serves["manager_public_key"] != nil {
- t.Fatalf("the record-answered need is %+v", n)
- }
- in.Licences[0].Manager = Holder{Node: "laptop", Module: "assistant"}
- got, err = Resolve(in, "laptop")
- if err != nil {
- t.Fatal(err)
- }
- n = need(t, got.Plan, "assistant", LicenceProvision)
- if !n.Manager || n.Serves["manager_public_key"] != "laptop-key" || n.Serves["model"] != "x" {
- t.Fatalf("the manager is %+v", n)
- }
- if len(got.Foreseen) != 0 || len(got.NotGiven) != 0 {
- t.Errorf("a record-answered need needs no pair credential: %v %v", got.Foreseen, got.NotGiven)
- }
-}
-
-// 1362/AC5: a node whose own set does not compose returns an error callers can tell from an unreadable Input.
-func TestANodeWhoseOwnSetDoesNotComposeIsToldApartFromAnUnreadableInput(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")
- if _, err := Resolve(in, "laptop"); err == nil || !IsUnresolvable(err) {
- t.Fatalf("a set that does not compose: %v", err)
- }
- if _, err := Resolve(in, "nowhere"); err == nil || IsUnresolvable(err) || !errors.Is(err, ErrNoSuchNode) {
- t.Fatalf("a node the mesh does not have: %v", err)
- }
- in.Catalogue = nil
- if _, err := Resolve(in, "anchor"); err == nil || IsUnresolvable(err) {
- t.Fatalf("an Input without the catalogue: %v", err)
- }
-}
-
-// 1362/AC6: settings are returned for every resolved module that has layers in the Input, including one pulled in by another.
-func TestSettingsAreReturnedForEveryResolvedModuleIncludingOneThatWasPulledIn(t *testing.T) {
- in := aMesh(t)
- in.Catalogue["lib"] = catalogue.Manifest{Module: "lib", Version: "1", Provides: catalogue.Offers("blob")}
- in.Catalogue["app"] = catalogue.Manifest{Module: "app", Version: "1", Requires: []string{"blob"}}
- laptop := nodeOf(t, &in, "laptop")
- laptop.Assigned = append(laptop.Assigned, "app")
- laptop.Settings = map[string][]catalogue.Layer{
- "lib": {{From: "mesh", Values: map[string]any{"x": 1}}},
- "app": {{From: "laptop", Values: map[string]any{"y": 2}}},
- "other": {{From: "laptop", Values: map[string]any{"z": 3}}}, // not resolved here: no settings come of it
- "empty": nil,
- }
- got, err := Resolve(in, "laptop")
- if err != nil {
- t.Fatal(err)
- }
- pulled := false
- for _, m := range got.Plan.Modules {
- pulled = pulled || m.Module == "lib"
- }
- if !pulled {
- t.Fatalf("lib was not pulled in: %+v", got.Plan.Modules)
- }
- if len(got.Settings) != 2 || got.Settings["lib"][0].Values["x"] != 1 || got.Settings["app"][0].Values["y"] != 2 {
- t.Errorf("settings are %+v", got.Settings)
- }
- // The settings returned are copies: changing them does not change the Input.
- got.Settings["app"][0].From = "changed"
- if laptop.Settings["app"][0].From != "laptop" {
- t.Error("the settings returned share the Input's layers")
- }
-}
-
-// Round 1, finding 3: a licence key the Input does not carry (Keys not given) is composed with a stand-in and named foreseen, so the
-// node still composes; Keys given that lack the holder is the mesh holding none, and the need stays empty.
-func TestALicenceKeyTheInputDoesNotCarryIsAStandInAndNamedForeseen(t *testing.T) {
- in := aMesh(t)
- in.Catalogue["assistant"] = catalogue.Manifest{Module: "assistant", Version: "1", Requires: []string{LicenceProvision}}
- laptop := nodeOf(t, &in, "laptop")
- laptop.Assigned = append(laptop.Assigned, "assistant")
- in.Licences = []Licence{{Name: "personal", Serves: map[string]any{"model": "x"}, Holders: []Holder{{Node: "laptop", Module: "assistant"}}}}
- got, err := Resolve(in, "laptop")
- if err != nil {
- t.Fatal(err)
- }
- if n := need(t, got.Plan, "assistant", LicenceProvision); n.Sealed != catalogue.ForeseenSealed {
- t.Errorf("a key not given is composed as %q", n.Sealed)
- }
- if len(got.Foreseen) != 1 || !strings.Contains(got.Foreseen[0], "assistant on laptop needs model-access") {
- t.Errorf("foreseen %v", got.Foreseen)
- }
- // And a whole node composes with it.
- if _, problems, err := Compose(in, "laptop"); err != nil || len(problems) != 0 {
- t.Errorf("a node using a licence whose key is not carried: %v %v", err, problems)
- }
- // Keys given, and none for this holder: the mesh holds none.
- in.Licences[0].Keys = map[Holder]string{}
- got, err = Resolve(in, "laptop")
- if err != nil {
- t.Fatal(err)
- }
- if n := need(t, got.Plan, "assistant", LicenceProvision); n.Sealed != "" || len(got.Foreseen) != 0 {
- t.Errorf("a key the mesh holds none of: %q %v", n.Sealed, got.Foreseen)
- }
-}
-
-// A consumer not handed of a provision whose provider's offer says the helper answers it has that provision in a plan of
-// its own (issue 774: grafana's route): the composition marks the need answered by the helper, and the plan keys it.
-func TestANeedAnsweredByTheHelperIsInTheConsumersPlan(t *testing.T) {
- in := theStoreMesh(t)
- store := in.Catalogue["store"]
- store.Provides = []catalogue.Offer{{Name: "postgres-database", Scope: catalogue.ScopeMesh, AnsweredBy: catalogue.AnsweredByHelper}}
- in.Catalogue["store"] = store
- got, err := Resolve(in, "laptop")
- if err != nil {
- t.Fatal(err)
- }
- if n := need(t, got.Plan, "board", "postgres-database"); !n.AnsweredByHelper {
- t.Fatalf("the need is not marked answered by the helper: %+v", n)
- }
- with := rendered(t, in, "laptop", Foreseeing).Rendering
- with.Hands, with.Provisioner = true, "mesh-provisioner-client"
- if with.Generators, err = newComposer(in).generators(); err != nil {
- t.Fatal(err)
- }
- c, err := got.Plan.Compose(with)
- if err != nil {
- t.Fatal(err)
- }
- if p := c.Plans["board"].Provisions; len(p) != 1 || p[0].Provision != "postgres-database" || p[0].ProviderModule != "store" {
- t.Fatalf("board's plan is %+v", c.Plans["board"])
- }
-}
diff --git a/go/compose/rest.go b/go/compose/rest.go
deleted file mode 100644
index 7ddb928..0000000
--- a/go/compose/rest.go
+++ /dev/null
@@ -1,255 +0,0 @@
-package compose
-
-import (
- "errors"
- "fmt"
- "slices"
- "sort"
- "strings"
-
- "git.novox.be/novox/mesh-sdk/go/catalogue"
-)
-
-// notResolvable marks the one failure in a node's resolution that is a statement about the node: its assigned modules
-// do not compose. Every other failure means the mesh could not be asked. Whoever gathers across the mesh skips a node
-// that cannot resolve and never a node that could not be read (novox/hq 04-ISSUES/152).
-type notResolvable struct{ err error }
-
-func (n notResolvable) Error() string { return n.err.Error() }
-func (n notResolvable) Unwrap() error { return n.err }
-
-// unresolvable reports whether err is a node's own set failing to compose.
-func unresolvable(err error) bool {
- var n notResolvable
- return errors.As(err, &n)
-}
-
-// Passes is what a composition found that the mesh must record, and did not: each seat held once whose holder on record
-// is assigned nowhere that exactly one claimant takes over (novox/hq ADR 0449 clause 4), and the lines that say what was
-// found. The caller records a pass and writes its hand act; this package does neither.
-type Passes struct {
- // Passes are the seats to pass, in the order they were found.
- Passes []catalogue.SeatPass
- // Said are the lines the finding says (a seat two claimants could take, one nothing can).
- Said []string
-}
-
-// LeftOut is a node that does not resolve in the view of the rest of the mesh, with why: it offers nothing and holds
-// nothing as far as every other machine's plan can tell (novox/hq issue 188).
-type LeftOut struct {
- Node string
- Err error
-}
-
-// String is the line the controller says of it.
-func (l LeftOut) String() string {
- return fmt.Sprintf("%s is left out of the rest of the mesh: it does not resolve: %v", l.Node, l.Err)
-}
-
-// TheRestOfTheMesh is what every other node holds and offers, as the controller works it out (plan.go theRestOfTheMesh):
-// two passes over the others, because what a node offers needs it resolved and resolving it may need what the mesh
-// offers. The first takes brokered requirements on trust and answers only what each node offers; the second answers
-// everything with that in hand. Nothing is declared from the first.
-//
-// It writes nothing. A seat the mesh passes on is returned in Passes for the caller to record; the World carries the
-// holdings as they stand after the passes, and Input's own are unchanged. A node left out is returned with its reason, in
-// the order the controller said them (the first pass's, then the second's when no node is excluded).
-func TheRestOfTheMesh(in Input, exclude string) (catalogue.World, Passes, []LeftOut, error) {
- c := newComposer(in)
- return c.theRestOfTheMesh(exclude)
-}
-
-func (c *composer) theRestOfTheMesh(exclude string) (catalogue.World, Passes, []LeftOut, error) {
- r, ok := c.rest[exclude]
- if !ok {
- r.world, r.passes, r.left, r.err = c.theRestOfTheMeshNow(exclude)
- r.unhostable = c.unhostable
- if c.rest == nil {
- c.rest = map[string]restOf{}
- }
- c.rest[exclude] = r
- }
- // Each caller has its own copy of what it may set or add to; the maps and the slices within are only read.
- c.unhostable = slices.Clone(r.unhostable)
- return r.world, r.passes, slices.Clone(r.left), r.err
-}
-
-// theRestOfTheMeshNow is theRestOfTheMesh worked out: every other machine's set resolved, twice.
-func (c *composer) theRestOfTheMeshNow(exclude string) (catalogue.World, Passes, []LeftOut, error) {
- shelf := c.in.Catalogue
- holdings := append([]catalogue.Held(nil), c.in.Holdings...)
- nodes := c.in.sorted()
-
- onNetwork, err := c.whereEveryoneIs()
- if err != nil {
- return catalogue.World{}, Passes{}, nil, err
- }
-
- type candidate struct {
- node catalogue.Node
- assigned []string
- pins map[string]catalogue.Chosen
- }
- var others []candidate
- // Every machine's assignments, before the holdings are read by any resolution (novox/hq ADR 0449 clause 4): a seat
- // held once whose holder on record is assigned on no machine passes to its only claimant here.
- everyAssigned := map[string][]string{}
- for _, n := range nodes {
- if len(n.Assigned) > 0 {
- everyAssigned[n.Name] = n.Assigned
- }
- }
- var passes Passes
- holdings, passes = passSeatsOn(holdings, assignmentsOf(shelf, everyAssigned))
- for _, n := range nodes {
- if n.Name == exclude {
- continue
- }
- theirs := everyAssigned[n.Name]
- if len(theirs) == 0 {
- continue
- }
- others = append(others, candidate{
- node: catalogue.Node{Name: n.Name, Site: n.Place.Site, Capabilities: n.Capabilities,
- At: onNetwork[n.Name], PublicDomain: n.PublicDomain},
- assigned: theirs, pins: n.Pins})
- }
-
- var left []LeftOut
- offered := map[string][]catalogue.Provider{}
- var firstHeld []catalogue.Held
- c.unhostable = nil
- for _, o := range others {
- got, err := catalogue.Resolve(shelf, o.assigned, o.node, catalogue.World{Unchecked: true, Holdings: holdings})
- if err != nil {
- // Said, not skipped: a machine dropped here offers nothing and holds nothing.
- left = append(left, LeftOut{Node: o.node.Name, Err: err})
- continue
- }
- for _, u := range got.Unhostable {
- c.unhostable = append(c.unhostable, fmt.Sprintf("%s on %s lacks %s", u.Module, o.node.Name, strings.Join(u.Missing, ", ")))
- }
- firstHeld = append(firstHeld, got.Claims...)
- for _, m := range got.Modules {
- for _, name := range m.OffersAt(catalogue.ScopeMesh) {
- // What that module says a consumer needs to know, with that node's settings on it.
- serves, layers, err := c.servedOnNode(o.node.Name, m, name)
- if err != nil {
- return catalogue.World{}, Passes{}, nil, err
- }
- // And its own composed names, as that node composes them (novox/hq ADR 0346).
- serves, leftOut, err := got.ServedOwnNames(m, name, serves, catalogue.WithDefaults(m, layers))
- if err != nil {
- return catalogue.World{}, Passes{}, nil, err
- }
- offered[name] = append(offered[name], catalogue.Provider{
- Node: o.node.Name, At: o.node.At, Serves: serves, Module: m.Module, LeftOut: leftOut})
- }
- }
- }
- for k := range offered {
- sort.Slice(offered[k], func(i, j int) bool {
- return offered[k][i].Node < offered[k][j].Node
- })
- }
-
- // The second pass is given the first pass's holdings (novox/hq ADR 0110).
- world := catalogue.World{Offered: offered, Held: firstHeld, Holdings: holdings}
- var held []catalogue.Held
- for _, o := range others {
- // Each machine is resolved with its own pins, as its plan is.
- theirs := world
- theirs.Pinned = o.pins
- got, err := catalogue.Resolve(shelf, o.assigned, o.node, theirs)
- if err != nil {
- // Said only for the whole-mesh view: with one machine excluded, the view is partial by design.
- if exclude == "" {
- left = append(left, LeftOut{Node: o.node.Name, Err: err})
- }
- continue
- }
- held = append(held, got.Claims...)
- }
- world.Held = held
- return world, passes, left, nil
-}
-
-// passSeatsOn finds, in the composition that finds it, the new holder of each seat held once whose holder on record is
-// assigned nowhere and which has exactly one claimant able to hold it (novox/hq ADR 0449 clause 4), and answers the
-// holdings as they are after it, with the passes. Nothing is recorded.
-func passSeatsOn(holdings []catalogue.Held, assignments []catalogue.Assignment) ([]catalogue.Held, Passes) {
- passes, said := catalogue.SeatsToPassOn(holdings, assignments)
- out := Passes{Said: said}
- for _, p := range passes {
- out.Passes = append(out.Passes, p)
- var next []catalogue.Held
- for _, h := range holdings {
- if hs, ok := catalogue.SeatNamed(h.Claim); ok && hs.Name == p.Seat.Name {
- continue
- }
- next = append(next, h)
- }
- p.To.Site = p.From.Site
- holdings = append(next, p.To)
- }
- return holdings, out
-}
-
-// assignmentsOf are the assignments of every machine, as the passing of a seat reads them. A module assigned and
-// missing from the shelf is still assigned: an assignment with no claims, so its holder is not taken for gone.
-func assignmentsOf(shelf map[string]catalogue.Manifest, assigned map[string][]string) []catalogue.Assignment {
- var out []catalogue.Assignment
- names := make([]string, 0, len(assigned))
- for node := range assigned {
- names = append(names, node)
- }
- sort.Strings(names)
- for _, node := range names {
- for _, module := range assigned[node] {
- m, ok := shelf[module]
- if !ok {
- m = catalogue.Manifest{Module: module}
- }
- out = append(out, catalogue.Assignment{Node: node, Manifest: m})
- }
- }
- return out
-}
-
-// servedOnNode is what a module on a node tells a consumer of one of its provisions, with THAT node's ports on it: the
-// port the node was given over the one the mesh assigned over the manifest's own, settled with the node's settings
-// layers. The one derivation for every reader of a provider's address.
-func (c *composer) servedOnNode(node string, m catalogue.Manifest, provision string) (map[string]any, []catalogue.Layer, error) {
- ports, layers, err := c.portsGivenOn(node, m)
- if err != nil {
- return nil, nil, err
- }
- serves := catalogue.ServedOn(m, provision, ports)
- if len(serves) > 0 {
- serves, err = catalogue.Settle(serves, catalogue.WithDefaults(m, layers))
- if err != nil {
- return nil, nil, err
- }
- }
- return serves, layers, nil
-}
-
-// portsGivenOn is where a node puts a module's ports — assigned, then given over them — and the node's settings layers
-// for the module.
-func (c *composer) portsGivenOn(node string, m catalogue.Manifest) (map[int]int, []catalogue.Layer, error) {
- n, err := c.in.node(node)
- if err != nil {
- return nil, nil, err
- }
- ports := map[int]int{}
- for wanted, at := range n.Ports[m.Module] {
- ports[wanted] = at
- }
- layers := n.Settings[m.Module]
- if given, err := catalogue.GivenPorts(m, layers); err == nil {
- for wanted, at := range given {
- ports[wanted] = at
- }
- }
- return ports, layers, nil
-}
diff --git a/go/compose/rest_test.go b/go/compose/rest_test.go
deleted file mode 100644
index fa1e627..0000000
--- a/go/compose/rest_test.go
+++ /dev/null
@@ -1,231 +0,0 @@
-package compose
-
-import (
- "strings"
- "testing"
-
- "git.novox.be/novox/mesh-sdk/go/catalogue"
- "git.novox.be/novox/mesh-sdk/go/overlay"
-)
-
-// objects and files are the provider and consumer of mesh-controller's facts_test.go aMeshWithSecrets (cmd/mesh-controller at
-// 6c2a8d62): a module providing s3-bucket at mesh scope with an identity bound, and one requiring it.
-func objects() catalogue.Manifest {
- return 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"}}
-}
-
-func files() catalogue.Manifest {
- return 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"}}}
-}
-
-// threeNodes is aMesh and a third node `ace` running a database, so the mesh has three nodes that each offer something:
-// anchor objects, ace a database, laptop a consumer of the first.
-func threeNodes(t *testing.T) Input {
- t.Helper()
- in := aMesh(t)
- db := catalogue.Manifest{Module: "pg", Version: "1", Provides: catalogue.FromAnywhere("postgres-database")}
- in.Catalogue["objects"], in.Catalogue["files"], in.Catalogue["pg"] = objects(), files(), db
- anchor, laptop := nodeOf(t, &in, "anchor"), nodeOf(t, &in, "laptop")
- anchor.Assigned = append(anchor.Assigned, "objects")
- laptop.Assigned = append(laptop.Assigned, "files")
- in.Nodes = append(in.Nodes, Node{Name: "ace", Place: Place{Site: "there", Address: "10.77.0.3"},
- Assigned: []string{"pg"}, Capabilities: map[string]bool{}})
- return in
-}
-
-func providers(w catalogue.World, provision string) []string {
- var out []string
- for _, p := range w.Offered[provision] {
- out = append(out, p.Node+"/"+p.Module)
- }
- return out
-}
-
-// 1361/AC1: three nodes each offering a provision; the rest of the mesh, for one of them, offers every other node's and none of its
-// own, and holds what they hold.
-func TestTheRestOfTheMeshOffersEveryOtherNodesProvisionsAndNoneOfTheExcluded(t *testing.T) {
- in := threeNodes(t)
- w, passes, left, err := TheRestOfTheMesh(in, "laptop")
- if err != nil || len(left) != 0 || len(passes.Passes) != 0 {
- t.Fatalf("%v %v %+v", err, left, passes)
- }
- if got := providers(w, "s3-bucket"); len(got) != 1 || got[0] != "anchor/objects" {
- t.Errorf("s3-bucket is offered by %v", got)
- }
- if got := providers(w, "postgres-database"); len(got) != 1 || got[0] != "ace/pg" {
- t.Errorf("postgres-database is offered by %v", got)
- }
- if p := w.Offered["s3-bucket"][0]; p.At != "anchor.internal" {
- t.Errorf("anchor is at %q on the private network", p.At)
- }
- // Excluding anchor takes its offer away and keeps the others'.
- w, _, _, err = TheRestOfTheMesh(in, "anchor")
- if err != nil {
- t.Fatal(err)
- }
- if got := providers(w, "s3-bucket"); len(got) != 0 {
- t.Errorf("the excluded node's offer is in the world: %v", got)
- }
- if got := providers(w, "postgres-database"); len(got) != 1 {
- t.Errorf("another node's offer is missing: %v", got)
- }
- // With nobody excluded, every offer is there, and everyone's claims are held.
- w, _, _, err = TheRestOfTheMesh(in, "")
- if err != nil || len(providers(w, "s3-bucket")) != 1 || len(providers(w, "postgres-database")) != 1 {
- t.Fatalf("%v %v", providers(w, "s3-bucket"), err)
- }
- // Each machine is resolved with its own pins, as its plan is: the world is the same for any pin of the excluded node.
- nodeOf(t, &in, "laptop").Pins = map[string]catalogue.Chosen{"s3-bucket": {Node: "anchor", Module: "objects"}}
- if _, _, _, err := TheRestOfTheMesh(in, "laptop"); err != nil {
- t.Fatal(err)
- }
-}
-
-// 1361/AC2: a node whose assigned set does not resolve is left out and named with today's reason, and the others still offer.
-func TestANodeWhoseSetDoesNotResolveIsLeftOutAndTheOthersStillOffer(t *testing.T) {
- in := threeNodes(t)
- rivalOne := catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
- rivalTwo := catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
- in.Catalogue["rival-one"], in.Catalogue["rival-two"] = rivalOne, rivalTwo
- ace := nodeOf(t, &in, "ace")
- ace.Assigned = append(ace.Assigned, "rival-one", "rival-two")
- w, _, left, err := TheRestOfTheMesh(in, "laptop")
- if err != nil {
- t.Fatal(err)
- }
- if len(left) == 0 {
- t.Fatal("a node whose set does not resolve was not left out")
- }
- for _, l := range left {
- if l.Node != "ace" || !strings.Contains(l.String(), "ace is left out of the rest of the mesh: it does not resolve: ") {
- t.Errorf("left out %q", l.String())
- }
- }
- if got := providers(w, "s3-bucket"); len(got) != 1 {
- t.Errorf("the others do not offer: %v", got)
- }
- if got := providers(w, "postgres-database"); len(got) != 0 {
- t.Errorf("the node left out offers: %v", got)
- }
- // Said for the whole-mesh view in both passes (as the controller says it), and for a view with one machine excluded in the first only.
- _, _, whole, _ := TheRestOfTheMesh(in, "")
- if len(whole) != 2 {
- t.Errorf("the whole-mesh view says %d line(s), want the two passes'", len(whole))
- }
- if len(left) != 1 {
- t.Errorf("a view with one excluded says %d line(s), want the first pass's", len(left))
- }
-}
-
-// 1361/AC3 and AC4 (mesh-controller seat_pass_test.go TestACompositionPassesTheSeatAndWritesOneHandActByTheController, in
-// the part that is not a write): a seat held once whose holder on record is assigned nowhere and one claimant: the seat
-// passes to that claimant in the World, and is returned as a pass; Input's holdings are unchanged.
-func TestASeatWhoseHolderIsAssignedNowhereGoesToItsOnlyClaimant(t *testing.T) {
- withSeat(t)
- mk := func() Input {
- return Input{
- Catalogue: map[string]catalogue.Manifest{"gate": aBrokerClaiming("gate")},
- Nodes: []Node{{Name: "novox", Place: Place{Site: "here"}, Assigned: []string{"gate"}}},
- Holdings: []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "novox", Module: "old-broker", Site: "here"}},
- }
- }
- in := mk()
- w, passes, _, err := TheRestOfTheMesh(in, "")
- if err != nil {
- t.Fatal(err)
- }
- if len(w.Holdings) != 1 || w.Holdings[0].Module != "gate" || w.Holdings[0].Node != "novox" {
- t.Fatalf("the holdings after the composition do not name the gate: %+v", w.Holdings)
- }
- if len(passes.Passes) != 1 {
- t.Fatalf("passes=%d, want one", len(passes.Passes))
- }
- p := passes.Passes[0]
- if p.Seat.Name != "mesh-broker" || p.From.Module != "old-broker" || p.To.Module != "gate" || p.To.Node != "novox" {
- t.Errorf("the pass is %+v", p)
- }
- unchanged(t, mk, func(in Input) {
- if _, _, _, err := TheRestOfTheMesh(in, ""); err != nil {
- t.Fatal(err)
- }
- })
-}
-
-// 1317/AC2 (seat_pass_test.go TestACompositionWithTwoClaimantsRecordsNoneAndSaysSo): two claimants, no pass, the refusal said with
-// both and the remedy. 1317/AC3 and AC4 (TestACompositionChangesNothingWhileTheHolderIsAssignedOrNobodyClaims): nothing changes while
-// the holder is assigned, nor with no claimant.
-func TestNoSeatIsPassedForTwoClaimantsAnAssignedHolderOrNoClaimant(t *testing.T) {
- withSeat(t)
- held := []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "novox", Module: "old-broker"}}
- _, passes := passSeatsOn(held, []catalogue.Assignment{
- {Node: "novox", Manifest: aBrokerClaiming("gate")}, {Node: "ace", Manifest: aBrokerClaiming("other")}})
- said := strings.Join(passes.Said, "\n")
- if len(passes.Passes) != 0 || !strings.Contains(said, "gate") || !strings.Contains(said, "other") ||
- !strings.Contains(said, "seat mesh-broker --to") {
- t.Fatalf("passes %+v said %q", passes.Passes, said)
- }
- got, passes := passSeatsOn(held, []catalogue.Assignment{
- {Node: "novox", Manifest: aBrokerClaiming("old-broker")}, {Node: "novox", Manifest: aBrokerClaiming("gate")}})
- if len(passes.Passes) != 0 || len(got) != 1 || got[0].Module != "old-broker" {
- t.Fatalf("holder assigned: %+v %+v", got, passes)
- }
- got, passes = passSeatsOn(held, nil)
- if len(passes.Passes) != 0 || len(got) != 1 || got[0].Module != "old-broker" {
- t.Fatalf("no claimant: %+v %+v", got, passes)
- }
-}
-
-// seat_pass_test.go TestAHolderAssignedButNotOnTheShelfIsNotTakenForGone.
-func TestAHolderAssignedButNotOnTheShelfIsNotTakenForGone(t *testing.T) {
- withSeat(t)
- as := assignmentsOf(map[string]catalogue.Manifest{"gate": aBrokerClaiming("gate")},
- map[string][]string{"novox": {"gate", "old-broker"}})
- passes, _ := catalogue.SeatsToPassOn(
- []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "novox", Module: "old-broker"}}, as)
- if len(passes) != 0 {
- t.Fatalf("a holder missing from the shelf lost its seat: %+v", passes)
- }
-}
-
-// The not-resolving node of a view with a holder on record: holdings reach the first pass (novox/hq ADR 0131), so a node running
-// the next holder beside the current one is not refused as a second holder.
-func TestAHolderOnRecordSettlesTheSeatInTheRestOfTheMesh(t *testing.T) {
- withSeat(t)
- in := Input{
- Catalogue: map[string]catalogue.Manifest{"old-broker": aBrokerClaiming("old-broker"), "gate": aBrokerClaiming("gate")},
- Nodes: []Node{
- {Name: "novox", Assigned: []string{"old-broker", "gate"}},
- {Name: "ace", Assigned: []string{}}},
- Holdings: []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "novox", Module: "old-broker"}},
- }
- _, passes, left, err := TheRestOfTheMesh(in, "ace")
- if err != nil || len(left) != 0 || len(passes.Passes) != 0 {
- t.Fatalf("%v %v %+v", err, left, passes)
- }
- in.Holdings = nil
- _, _, left, err = TheRestOfTheMesh(in, "ace")
- if err != nil || len(left) == 0 {
- t.Fatalf("two claimants with nothing on record were not refused: %v %v", err, left)
- }
-}
-
-// Each machine is resolved in the second pass with its own pins, as its plan is (novox/hq issue 188): a pin to a provider that
-// does not answer is its own refusal, so the machine is left out for it; the same machine without the pin is not.
-func TestEachNodeIsResolvedWithItsOwnPinsInTheRestOfTheMesh(t *testing.T) {
- in := threeNodes(t)
- in.Nodes = append(in.Nodes, Node{Name: "newt", Place: Place{Site: "there", Address: "10.77.0.4"}, Assigned: []string{overlay.Name, "files"}, Capabilities: map[string]bool{}})
- _, _, left, err := TheRestOfTheMesh(in, "")
- if err != nil || len(left) != 0 {
- t.Fatalf("without a pin the consumer is left out: %v %v", left, err)
- }
- nodeOf(t, &in, "newt").Pins = map[string]catalogue.Chosen{"s3-bucket": {Node: "ace", Module: "nothing"}}
- _, _, left, err = TheRestOfTheMesh(in, "")
- if err != nil || len(left) != 1 || left[0].Node != "newt" || !strings.Contains(left[0].String(), "was told to get \"s3-bucket\" from") {
- t.Fatalf("a pin to a provider that does not answer did not leave the consumer out: %v %v", left, err)
- }
-}
diff --git a/go/compose/seats_test.go b/go/compose/seats_test.go
deleted file mode 100644
index a07e0c2..0000000
--- a/go/compose/seats_test.go
+++ /dev/null
@@ -1,22 +0,0 @@
-package compose
-
-import (
- "testing"
-
- "git.novox.be/novox/mesh-sdk/go/catalogue"
-)
-
-// aBrokerClaiming is mesh-controller's seat_pass_test.go helper of the same name.
-func aBrokerClaiming(module string) catalogue.Manifest {
- return catalogue.Manifest{Module: module,
- Provides: []catalogue.Offer{{Name: "mesh-bus", Scope: catalogue.ScopeMesh}},
- Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh}}}
-}
-
-// withSeat is mesh-controller's withBusSeat: a single mesh seat, mesh-broker, for the test.
-func withSeat(t *testing.T) {
- t.Helper()
- was := catalogue.Seats()
- t.Cleanup(func() { catalogue.UseSeats(was) })
- catalogue.UseSeats([]catalogue.Seat{{Name: "mesh-broker", Scope: catalogue.ScopeMesh, Delivers: "mesh-bus", Decision: "test"}})
-}
diff --git a/go/composer/composer.go b/go/composer/composer.go
index 566f6ac..69b729e 100644
--- a/go/composer/composer.go
+++ b/go/composer/composer.go
@@ -1,6 +1,7 @@
-// Package composer is mesh-sdk's composition as one function over a plain request (novox/hq ADR 0482
-// clause 4, issue 1621): what the command mesh-compose runs, so a program that links any one mesh-sdk can have the
-// composition of another commit judged by building that commit's command and reading its verdict.
+// Package composer is the shape of a composition over a plain request (novox/hq ADR 0482 clause 4, issue 1621, as ADR
+// 0593 §7 changes it): the request the command mesh-compose reads and the verdict it answers. The command and the
+// composer that runs it are the node-engine's (mesh-host cmd/mesh-compose), built from its repository at a group's
+// head, so a change to its composition code is judged by that code.
//
// **It reads its request and nothing else.** No bus, store, network, file or environment: the manifests come inside the
// request (raw, as their repository holds them), the rest of the mesh is a compose.Input, and the answer is a value.
@@ -13,21 +14,17 @@ import (
"errors"
"fmt"
"slices"
- "sort"
"git.novox.be/novox/mesh-sdk/go/catalogue"
- "git.novox.be/novox/mesh-sdk/go/compose"
- "git.novox.be/novox/mesh-sdk/go/compose/frombus"
"git.novox.be/novox/mesh-sdk/go/delivery"
- "git.novox.be/novox/mesh-sdk/go/sdkversion"
)
// Version is the version of a Request and of a Verdict; a reader refuses another.
const Version = 1
-// Package is the import path of the command, which a compose job builds at a commit (in mesh-sdk's own module, or in
-// a module that requires mesh-sdk, at the version it pins).
-const Package = "git.novox.be/novox/mesh-sdk/go/cmd/mesh-compose"
+// Package is the import path of the command, which a compose job builds at a commit of the node-engine's repository
+// (novox/hq ADR 0593 §7).
+const Package = "github.com/novox/mesh-host/cmd/mesh-compose"
// The depths of a composition.
const (
@@ -201,173 +198,3 @@ func Fill(r *Request, get func(digest string) ([]byte, error)) error {
}
return nil
}
-
-// Run composes every case of r and answers the verdict, its SDK this binary's mesh-sdk. An error is a request it
-// cannot run (an unknown depth, a case named twice, a manifest left as a digest); a machine that does not compose is a
-// verdict.
-func Run(r Request) (Verdict, error) {
- depth := r.Depth
- if depth == "" {
- depth = DepthCompose
- }
- if depth != DepthResolve && depth != DepthCompose {
- return Verdict{}, fmt.Errorf("composer: the depth %q is not resolve or compose", r.Depth)
- }
- v := Verdict{V: Version, SDK: sdkversion.Version()}
- seen := map[string]bool{}
- for _, c := range r.Cases {
- if seen[c.Name] {
- return Verdict{}, fmt.Errorf("composer: the case %q is named twice", c.Name)
- }
- seen[c.Name] = true
- cv, err := runCase(r, c, depth)
- if err != nil {
- return Verdict{}, err
- }
- v.Cases = append(v.Cases, cv)
- }
- return v, nil
-}
-
-// parse decodes every manifest leniently and takes its verdict (novox/hq ADR 0578 §3, §5): the composer judges none. A
-// manifest refused by its verdict is held, composed as its stub without its items (as the bus reader holds one); bytes
-// that do not decode are left out. Both are named in reads.
-func parse(m map[string]Manifest, reads map[string]ManifestRead, verdicts map[string]delivery.ManifestVerdict) (map[string]catalogue.Manifest, error) {
- out := map[string]catalogue.Manifest{}
- for name, man := range m {
- if len(man.Raw) == 0 {
- return nil, fmt.Errorf("%w: %s (digest %s)", ErrUnfilled, name, man.Digest)
- }
- decoded, err := catalogue.Decode(man.Raw)
- read := ManifestRead{Accepted: err == nil, SetAside: decoded.SetAside()}
- if err != nil {
- read.Refused = fmt.Sprintf("the manifest of %s does not decode: %v", name, err)
- } else if v, ok := verdicts[name]; ok {
- if refused, _ := delivery.TakeManifestVerdict(&v); refused {
- if v.Module == "" {
- v.Module = name
- }
- read = ManifestRead{Refused: v.Why()}
- decoded.Items = nil
- decoded = compose.Stub(decoded)
- }
- }
- if reads != nil {
- reads[name] = read
- }
- if err == nil {
- out[name] = decoded
- }
- }
- return out, nil
-}
-
-func runCase(r Request, c Case, depth string) (CaseVerdict, error) {
- cv := CaseVerdict{Name: c.Name, Manifests: map[string]ManifestRead{}}
- shelf, err := parse(c.Manifests, cv.Manifests, c.Verdicts)
- if err != nil {
- return CaseVerdict{}, err
- }
- var input compose.Input
- if len(r.Input) > 0 {
- if err := json.Unmarshal(r.Input, &input); err != nil {
- return CaseVerdict{}, fmt.Errorf("composer: the request's input is not a composition input: %w", err)
- }
- }
- in, err := frombus.Placed(input, c.Placements)
- if err != nil {
- return CaseVerdict{}, fmt.Errorf("composer: the case %q: %w", c.Name, err)
- }
- in.Catalogue = shelf
- in.Nodes = slices.Clone(in.Nodes)
- in.Holdings = slices.Clone(in.Holdings)
- for i := range in.Nodes {
- in.Nodes[i].Kept = nil
- if kept := c.Kept[in.Nodes[i].Name]; len(kept) > 0 {
- k, err := parse(kept, nil, nil)
- if err != nil {
- return CaseVerdict{}, err
- }
- in.Nodes[i].Kept = k
- }
- }
- if r.HoldingsFromClaims {
- in.Holdings = append(in.Holdings, HoldingsOf(shelf, in.Nodes)...)
- }
- if len(r.Machines) == 0 {
- return cv, nil
- }
- cv.Machines = map[string]Machine{}
- for _, node := range r.Machines {
- cv.Machines[node] = machine(in, node, depth, r.Declarations)
- }
- return cv, nil
-}
-
-// HoldingsOf is every seat an assigned module claims, held on the node it is assigned to, in node order: the holdings
-// of an input that has none of record (the assigner's, mesh-delivery-assigner internal/assign input).
-func HoldingsOf(shelf map[string]catalogue.Manifest, nodes []compose.Node) []catalogue.Held {
- var out []catalogue.Held
- sorted := slices.Clone(nodes)
- sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
- for _, n := range sorted {
- for _, mod := range n.Assigned {
- for _, cl := range shelf[mod].Claims {
- scope := cl.Scope
- if seat, ok := catalogue.SeatNamed(cl.Name); ok && scope == "" {
- scope = seat.Scope
- }
- out = append(out, catalogue.Held{Claim: cl.Name, Scope: scope, Node: n.Name, Module: mod})
- }
- }
- }
- return out
-}
-
-func machine(in compose.Input, node, depth string, declarations bool) Machine {
- if depth == DepthResolve {
- if _, err := compose.Resolve(in, node); err != nil {
- return Machine{Reason: err.Error()}
- }
- return Machine{Composes: true}
- }
- d, problems, err := compose.Compose(in, node)
- if err != nil {
- return Machine{Reason: err.Error()}
- }
- m := Machine{Composes: len(problems) == 0, Problems: problems, Refusals: d.Refusals()}
- if len(problems) > 0 {
- m.Reason = "the node-engine's validator refuses the declaration"
- }
- if declarations {
- m.Declared = pathsOf(d)
- }
- return m
-}
-
-// pathsOf is every resource of a declaration that names a path, with its owner, group and mode as declared, sorted by
-// path.
-func pathsOf(d compose.Declared) []Path {
- var out []Path
- str := func(r map[string]any, key string) string {
- if v, ok := r[key]; ok && v != nil {
- return fmt.Sprint(v)
- }
- return ""
- }
- for _, r := range d.Resources {
- p := str(r, "path")
- if p == "" {
- continue
- }
- out = append(out, Path{Path: p, Kind: str(r, "type"), Owner: str(r, "owner"), Group: str(r, "group"),
- Mode: str(r, "mode"), Module: d.ModuleOf[str(r, "id")]})
- }
- sort.Slice(out, func(i, j int) bool {
- if out[i].Path != out[j].Path {
- return out[i].Path < out[j].Path
- }
- return out[i].Kind < out[j].Kind
- })
- return out
-}
diff --git a/go/composer/composer_test.go b/go/composer/composer_test.go
index 82a3de0..108e3c7 100644
--- a/go/composer/composer_test.go
+++ b/go/composer/composer_test.go
@@ -3,14 +3,8 @@ package composer
import (
"encoding/json"
"errors"
- "os"
- "reflect"
"strings"
"testing"
-
- "git.novox.be/novox/mesh-sdk/go/catalogue"
- "git.novox.be/novox/mesh-sdk/go/compose"
- "git.novox.be/novox/mesh-sdk/go/delivery"
)
const demo = `{"module": "demo", "version": "1", "claims": [{"name": "node-files"}]}`
@@ -32,9 +26,6 @@ func TestAManifestIsItsBytesOrItsDigest(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if _, err := Run(back); !errors.Is(err, ErrUnfilled) {
- t.Fatalf("a digest left unfilled runs: %v", err)
- }
asked := 0
if err := Fill(&back, func(d string) ([]byte, error) {
asked++
@@ -53,112 +44,3 @@ func TestAManifestIsItsBytesOrItsDigest(t *testing.T) {
t.Fatalf("a digest the indexer does not hold: %v", err)
}
}
-
-// The input travels as JSON and comes back equal, so the command composes what its caller meant.
-func TestTheInputTravelsAsJSON(t *testing.T) {
- in := compose.Input{
- Nodes: []compose.Node{{Name: "anchor", Assigned: []string{"nats"}, Capabilities: map[string]bool{"systemd": true},
- Place: compose.Place{Endpoint: "anchor.example:51820", Site: "here", Hub: true, Address: "10.77.0.1"},
- Pins: map[string]catalogue.Chosen{"bus": {Node: "anchor", Module: "nats"}},
- Settings: map[string][]catalogue.Layer{"nats": {{Values: map[string]any{"port": float64(4222)}}}}}},
- Holdings: []catalogue.Held{{Claim: "mesh-broker", Scope: "mesh", Node: "anchor", Module: "nats"}},
- Issued: []compose.Issued{{Provision: "bus", Consumer: "laptop", ConsumerModule: "x", Provider: "anchor"}},
- }
- raw, err := json.Marshal(Request{V: Version, Input: rawInput(in)})
- if err != nil {
- t.Fatal(err)
- }
- back, err := Decode(raw)
- if err != nil {
- t.Fatal(err)
- }
- var got compose.Input
- if err := json.Unmarshal(back.Input, &got); err != nil || !reflect.DeepEqual(got, in) {
- t.Fatalf("the input came back as\n%+v (%v)\nwant\n%+v", got, err, in)
- }
-}
-
-// The seats a node's assigned modules claim are held there when the request says the holdings come from the claims.
-func TestHoldingsComeFromTheClaims(t *testing.T) {
- m, err := catalogue.Decode([]byte(demo))
- if err != nil {
- t.Fatal(err)
- }
- got := HoldingsOf(map[string]catalogue.Manifest{"demo": m}, []compose.Node{{Name: "b", Assigned: []string{"demo"}}, {Name: "a", Assigned: []string{"demo"}}})
- if len(got) != 2 || got[0].Node != "a" || got[0].Claim != "node-files" || got[0].Scope != "node" {
- t.Fatalf("holdings = %+v", got)
- }
-}
-
-// A request the command cannot run is refused, never answered as a verdict: another version, a case named twice, an
-// unknown depth.
-func TestARequestItCannotRunIsRefused(t *testing.T) {
- if _, err := Decode([]byte(`{"v": 2}`)); err == nil {
- t.Error("a request of version 2 is read")
- }
- if _, err := Run(Request{V: Version, Cases: []Case{{Name: "a"}, {Name: "a"}}}); err == nil {
- t.Error("a case named twice runs")
- }
- if _, err := Run(Request{V: Version, Depth: "deep"}); err == nil {
- t.Error("an unknown depth runs")
- }
- // A case's own placement (issue 1126) composes over a node the input holds, and is refused for one it does not.
- placed := Request{V: Version, Input: rawInput(compose.Input{Nodes: []compose.Node{{Name: "a"}}}), Machines: []string{"a"}, Depth: DepthResolve,
- Cases: []Case{{Name: "trunk", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}},
- {Name: "change", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}},
- Placements: map[string]*delivery.Placement{"a": {Node: "a", Modules: []delivery.PlacedModule{{Module: "ghost"}}}}}}}
- if v, err := Run(placed); err != nil || !v.Cases[0].Machines["a"].Composes || v.Cases[1].Machines["a"].Composes ||
- !strings.Contains(v.Cases[1].Machines["a"].Reason, "ghost") {
- t.Errorf("a case composes its own placement: %+v, %v", v, err)
- }
- placed.Cases[1].Placements = map[string]*delivery.Placement{"z": {Node: "z"}}
- if _, err := Run(placed); err == nil || !strings.Contains(err.Error(), "holds no node for") {
- t.Errorf("a case placing a node the input lacks: %v", err)
- }
- v, err := Run(Request{V: Version, Cases: []Case{{Name: "only-read", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}}}})
- if err != nil || len(v.Cases) != 1 || !v.Cases[0].Manifests["demo"].Accepted || v.Cases[0].Machines != nil || v.SDK == "" {
- t.Fatalf("a request with no machines: %+v, %v", v, err)
- }
-}
-
-// The composer judges no manifest (novox/hq ADR 0578 §5): xterm's module.json from mesh-catalog at 835bf22b, whose
-// config value falls back to "font-size", is read whatever list of predefined keys a request carries (a group touching
-// xterm failed its composition on this, group check 18df81380db3); a module whose verdict in the input refuses it is
-// held, composed as its stub, and named in the verdict's words.
-func TestTheComposerTakesTheVerdictsOfItsInput(t *testing.T) {
- raw, err := os.ReadFile("../catalogue/testdata/catalogue-835bf22b-xterm.module.json")
- if err != nil {
- t.Fatal(err)
- }
- r := Request{V: Version, Predefined: catalogue.PredefinedList{"something-else": {Type: "string", Description: "x"}},
- Cases: []Case{{Name: "change", Manifests: map[string]Manifest{"xterm": {Raw: raw}}}}}
- v, err := Run(r)
- if err != nil {
- t.Fatal(err)
- }
- if got := v.Cases[0].Manifests["xterm"]; !got.Accepted {
- t.Fatalf("xterm: %+v, want accepted", got)
- }
- r.Cases[0].Verdicts = map[string]delivery.ManifestVerdict{"xterm": {Module: "xterm", State: delivery.VerdictRefused,
- Problems: []string{"falls back to a key not listed"}, Judge: "src2:j"}}
- shelf, err := parse(r.Cases[0].Manifests, map[string]ManifestRead{}, r.Cases[0].Verdicts)
- if err != nil || !shelf["xterm"].Stubbed {
- t.Fatalf("the refused module is not held as its stub: %+v %v", shelf["xterm"], err)
- }
- if v, err = Run(r); err != nil {
- t.Fatal(err)
- }
- if got := v.Cases[0].Manifests["xterm"]; got.Accepted ||
- !strings.Contains(got.Refused, "the indexer's judge (src2:j) refused the manifest of xterm: falls back to a key not listed") {
- t.Fatalf("xterm refused by its verdict: %+v, want refused in the verdict's words", got)
- }
-}
-
-// rawInput is a composition input as a request carries it.
-func rawInput(in compose.Input) json.RawMessage {
- raw, err := json.Marshal(in)
- if err != nil {
- panic(err)
- }
- return raw
-}
diff --git a/go/delivery/answer_paths.go b/go/delivery/answer_paths.go
index da4cb42..f797166 100644
--- a/go/delivery/answer_paths.go
+++ b/go/delivery/answer_paths.go
@@ -134,44 +134,3 @@ func sameAny(a, b any) bool {
y, errB := json.Marshal(b)
return errA == nil && errB == nil && bytes.Equal(x, y)
}
-
-// FitAnswer is a with its shown bodies cut until its JSON is within limit bytes (MaxAnswerBytes for the node's key):
-// first the bodies' texts, from the last differing module back, keeping their paths; then the paths too. What is cut
-// is said in the answer's note. The verdict and every list of modules are never cut.
-func FitAnswer(a CandidateAnswer, limit int) CandidateAnswer {
- size := func() int {
- raw, err := json.Marshal(a)
- if err != nil {
- return 0
- }
- return len(raw)
- }
- if size() <= limit {
- return a
- }
- a.Differs = append([]ModuleDiffers(nil), a.Differs...)
- for i := range a.Differs {
- a.Differs[i].Bodies = append([]ResourceBodies(nil), a.Differs[i].Bodies...)
- }
- cutTexts, cutPaths := false, false
- for i := len(a.Differs) - 1; i >= 0 && size() > limit; i-- {
- for j := range a.Differs[i].Bodies {
- a.Differs[i].Bodies[j].Composed, a.Differs[i].Bodies[j].Running = "", ""
- }
- cutTexts = true
- }
- for i := len(a.Differs) - 1; i >= 0 && size() > limit; i-- {
- for j := range a.Differs[i].Bodies {
- a.Differs[i].Bodies[j].Paths = nil
- }
- cutPaths = true
- }
- why := "the bodies shown were cut to keep the answer within " + strconv.Itoa(limit>>10) + " KiB"
- if cutPaths {
- why += ", their paths too"
- }
- if cutTexts {
- a.Note = strings.TrimPrefix(a.Note+"; ", "; ") + why
- }
- return a
-}
diff --git a/go/delivery/judge.go b/go/delivery/judge.go
deleted file mode 100644
index a060fc7..0000000
--- a/go/delivery/judge.go
+++ /dev/null
@@ -1,365 +0,0 @@
-package delivery
-
-import (
- "bytes"
- "encoding/json"
- "fmt"
- "path"
- "sort"
- "strings"
-
- "git.novox.be/novox/mesh-sdk/go/declaration"
-)
-
-// The judgement of a move (ADR 0377 §3), as a pure function over two compositions of one module, so the node agent that
-// composes its own modules judges what it would apply (novox/hq ADR 0547 §3; issue 1126's S8). Moved from
-// mesh-controller cmd/mesh-controller/composed_judge.go at origin/main (the controller keeps its copy, unchanged, until
-// it loses the lease, ADR 0547 §1): judged, onlyTheUserList and sameResources, with their helpers, are its code.
-//
-// ADR 0377 §3 over two compositions of one module: **recreates** when a container resource is changed, added or
-// removed; **moves data** when, with no container changed, a directory or file resource is changed, added or removed
-// that a container of the module mounts or that holds or lies within one of its directories (where a module keeps its
-// data), when its `data` declaration changes, or, for a module that declares data, when one of its long-running
-// processes changes; **neither** otherwise, and for a module the node has never run.
-
-// Judged is one move's judgement: what it does, its words, and whether only the bus's user list changes (ADR 0539 §7:
-// a file change and a reload of the bus, no move of it, never asked).
-type Judged struct {
- Judgement Judgement
- Words string
- UserListOnly bool
-}
-
-// JudgeMove judges moving a node's module from the composition it runs to the one composed for it. Running is nil when the
-// node runs no composition of the module. A composition equal to the one running (resource for resource, each
-// canonical) is unchanged: no move, and no ask (ADR 0547 §3).
-func JudgeMove(running *Composed, composed Composed) Judged {
- if composed.Module == "" {
- return Judged{Judgement: JudgedNeither, Words: "the node's own composition"}
- }
- if running == nil {
- return Judged{Judgement: JudgedNeither, Words: "the node runs no composition of " + composed.Module}
- }
- if running.Data == composed.Data && SameComposedResources(*running, composed) {
- return Judged{Judgement: JudgedUnchanged, Words: "equal to the composition the node runs"}
- }
- if onlyTheUserList(*running, composed) {
- return Judged{Judgement: JudgedNeither, Words: "only the bus's user list changes", UserListOnly: true}
- }
- j, words := judged(*running, composed)
- return Judged{Judgement: j, Words: words}
-}
-
-// busUsersSuffix ends the id of the bus module's user list as the composer writes it: `.bus-users`.
-const busUsersSuffix = ".bus-users"
-
-// onlyTheUserList says two compositions of a module differ, and only in its file of the bus's user list.
-func onlyTheUserList(from, to Composed) bool {
- if from.Data != to.Data {
- return false
- }
- before, after := judgedResourcesByID(from), judgedResourcesByID(to)
- if len(before) != len(after) {
- return false
- }
- changed := false
- for id, now := range after {
- was, ok := before[id]
- if !ok {
- return false
- }
- if bytes.Equal(was.raw, now.raw) {
- continue
- }
- if !strings.HasSuffix(id, busUsersSuffix) || now.kind != string(declaration.TypeFile) {
- return false
- }
- changed = true
- }
- return changed
-}
-
-// judged applies ADR 0377 §3 to two compositions of one module.
-func judged(from, to Composed) (Judgement, string) {
- before, after := judgedResourcesByID(from), judgedResourcesByID(to)
- if ids := changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeContainer) }); len(ids) > 0 {
- return JudgedRecreates, "it recreates " + strings.Join(ids, ", ")
- }
- places := dataPlaces(before, after)
- held := func(kind string) bool {
- return kind == string(declaration.TypeDirectory) || kind == string(declaration.TypeFile)
- }
- var moved []string
- for _, id := range changedOf(before, after, held) {
- r := before[id]
- if r == nil {
- r = after[id]
- }
- if withinPlaces(r.path, places) {
- moved = append(moved, id)
- }
- }
- if to.Data || from.Data {
- moved = append(moved, changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeProcess) })...)
- }
- // A data flag that differs is no move of data by itself: data moves only when a data item does (issue 1126, 2026-10-29:
- // a running composition read back from the kept declaration carries no flag, and mongodb's toolchain move asked a yes for
- // moving its data). It is said loudly in the words, and never hides an item that moved.
- if len(moved) > 0 {
- sort.Strings(moved)
- // A file the mesh makes for the module's provisions (what its consumers contribute, what it is bound to, the
- // secret it is given) is said as the consumers that join or leave, not as its data moving (issue 774,
- // 2026-10-31: postgres's yes said "where its data is kept" when nextcloud left its grants). Still a move: the
- // yes it asks is what keeps a consumer's login while that consumer is held.
- var kept, made []string
- for _, id := range moved {
- if who := consumersOf(id, before[id], after[id]); who != nil {
- made = append(made, who...)
- } else {
- kept = append(kept, id)
- }
- }
- var parts []string
- if len(kept) > 0 {
- parts = append(parts, "it changes where its data is kept: "+strings.Join(kept, ", "))
- }
- if len(made) > 0 {
- parts = append(parts, "consumers join or leave: "+strings.Join(made, ", "))
- }
- words := strings.Join(parts, "; ")
- if from.Data != to.Data {
- words += fmt.Sprintf("; its data flag differs too (running %v, composed %v)", from.Data, to.Data)
- }
- return JudgedMovesData, words
- }
- if from.Data != to.Data {
- return JudgedNeither, fmt.Sprintf("it recreates no container and moves no data; WARNING: its data flag differs "+
- "(running %v, composed %v) while no data item does, so one side's flag is wrong", from.Data, to.Data)
- }
- return JudgedNeither, "it recreates no container and moves no data"
-}
-
-// ResourceChange is one resource that differs between two compositions of a module: its id, its kind, whether it is
-// added, removed or changed, and for a file the mesh makes for a provision (received-, bound-, secret-) the consumers
-// that join or leave, in words.
-type ResourceChange struct {
- ID string `json:"id"`
- Kind string `json:"kind"`
- Change string `json:"change"`
- Consumers []string `json:"consumers,omitempty"`
-}
-
-// ChangedResources is every resource added, removed or changed from running to composed, by id, each compared
-// canonical (issue 774: the engine's verb composition shows what a move waiting for a yes would change). Running nil
-// is every resource added.
-func ChangedResources(running *Composed, composed Composed) []ResourceChange {
- var from Composed
- if running != nil {
- from = *running
- }
- before, after := judgedResourcesByID(from), judgedResourcesByID(composed)
- var out []ResourceChange
- for _, id := range changedOf(before, after, func(string) bool { return true }) {
- was, now := before[id], after[id]
- c := ResourceChange{ID: id, Change: "changed"}
- switch {
- case was == nil:
- c.Change, c.Kind = "added", now.kind
- case now == nil:
- c.Change, c.Kind = "removed", was.kind
- default:
- c.Kind = now.kind
- }
- c.Consumers = consumersOf(id, was, now)
- out = append(out, c)
- }
- return out
-}
-
-// consumersOf is, for a changed file the mesh makes for a provision (received-, bound- or secret-, catalogue.ReceivedID,
-// BoundID, SecretID), what changes of it in words: each consumer that joins or leaves a received file, by the given
-// list's from and node; else that what it is given for the provision changes. Nil for any other resource.
-func consumersOf(id string, before, after *judgedResource) []string {
- _, local, _ := strings.Cut(id, ".")
- r := after
- if r == nil {
- r = before
- }
- if r == nil || r.kind != string(declaration.TypeFile) {
- return nil
- }
- if provision, ok := strings.CutPrefix(local, "received-"); ok {
- was, now := givenIn(before), givenIn(after)
- var out []string
- for _, c := range sortedGiven(was) {
- if !now[c] {
- out = append(out, c+" leaves "+provision)
- }
- }
- for _, c := range sortedGiven(now) {
- if !was[c] {
- out = append(out, c+" joins "+provision)
- }
- }
- if len(out) == 0 {
- out = append(out, "what "+provision+"'s consumers are given changes")
- }
- return out
- }
- for _, prefix := range []string{"bound-", "secret-"} {
- if provision, ok := strings.CutPrefix(local, prefix); ok {
- return []string{"what it is given for " + provision + " changes"}
- }
- }
- return nil
-}
-
-// givenIn is the consumers a received file's content names, each as " on ".
-func givenIn(r *judgedResource) map[string]bool {
- out := map[string]bool{}
- if r == nil {
- return out
- }
- var file struct {
- Content string `json:"content"`
- }
- var received struct {
- Given []struct {
- From string `json:"from"`
- Node string `json:"node"`
- } `json:"given"`
- }
- if json.Unmarshal(r.raw, &file) != nil || json.Unmarshal([]byte(file.Content), &received) != nil {
- return out
- }
- for _, g := range received.Given {
- out[g.From+" on "+g.Node] = true
- }
- return out
-}
-
-func sortedGiven(m map[string]bool) []string {
- out := make([]string, 0, len(m))
- for k := range m {
- out = append(out, k)
- }
- sort.Strings(out)
- return out
-}
-
-// judgedResource is a resource as the judgement reads it: its kind, the path it acts on, what its containers mount,
-// and its canonical bytes.
-type judgedResource struct {
- kind string
- path string
- mounts []string
- raw []byte
-}
-
-func judgedResourcesByID(c Composed) map[string]*judgedResource {
- out := map[string]*judgedResource{}
- for _, raw := range c.Resources {
- var head struct {
- ID string `json:"id"`
- Type string `json:"type"`
- Path string `json:"path"`
- // A container's volumes, `:[:]`.
- Volumes []string `json:"volumes"`
- }
- if err := json.Unmarshal(raw, &head); err != nil {
- continue
- }
- r := &judgedResource{kind: head.Type, path: head.Path, raw: compactResource(raw)}
- for _, v := range head.Volumes {
- source, _, _ := strings.Cut(v, ":")
- r.mounts = append(r.mounts, source)
- }
- out[head.ID] = r
- }
- return out
-}
-
-// compactResource is a resource's bytes canonical: its keys sorted, its spacing dropped, its numbers as written, so two
-// compositions that say the same compare equal however each was written.
-func compactResource(raw []byte) []byte {
- var v any
- d := json.NewDecoder(bytes.NewReader(raw))
- d.UseNumber()
- if err := d.Decode(&v); err != nil {
- return raw
- }
- out, err := json.Marshal(v)
- if err != nil {
- return raw
- }
- return out
-}
-
-// changedOf are the ids of the resources of the kinds counted that are changed, added or removed, sorted.
-func changedOf(before, after map[string]*judgedResource, counted func(kind string) bool) []string {
- var out []string
- for id, now := range after {
- if !counted(now.kind) {
- continue
- }
- if was, ok := before[id]; !ok || !bytes.Equal(was.raw, now.raw) {
- out = append(out, id)
- }
- }
- for id, was := range before {
- if _, ok := after[id]; !ok && counted(was.kind) {
- out = append(out, id)
- }
- }
- sort.Strings(out)
- return out
-}
-
-// dataPlaces are the paths where the module keeps what a move must not lose: what its containers mount, and its
-// directories, before and after.
-func dataPlaces(before, after map[string]*judgedResource) []string {
- var out []string
- for _, rs := range []map[string]*judgedResource{before, after} {
- for _, r := range rs {
- if r.kind == string(declaration.TypeContainer) {
- out = append(out, r.mounts...)
- }
- if r.kind == string(declaration.TypeDirectory) && r.path != "" {
- out = append(out, r.path)
- }
- }
- }
- return out
-}
-
-// withinPlaces says p is one of places, or lies within one, or holds one.
-func withinPlaces(p string, places []string) bool {
- if p == "" {
- return false
- }
- p = path.Clean(p)
- for _, place := range places {
- if place == "" || !strings.HasPrefix(place, "/") {
- continue
- }
- place = path.Clean(place)
- if p == place || strings.HasPrefix(p, place+"/") || strings.HasPrefix(place, p+"/") {
- return true
- }
- }
- return false
-}
-
-// SameComposedResources says two compositions of a module hold the same resources in the same order, each compared
-// canonical.
-func SameComposedResources(a, b Composed) bool {
- if len(a.Resources) != len(b.Resources) {
- return false
- }
- for i := range a.Resources {
- if !bytes.Equal(compactResource(a.Resources[i]), compactResource(b.Resources[i])) {
- return false
- }
- }
- return true
-}
diff --git a/go/delivery/judge_test.go b/go/delivery/judge_test.go
deleted file mode 100644
index ad17be4..0000000
--- a/go/delivery/judge_test.go
+++ /dev/null
@@ -1,94 +0,0 @@
-package delivery
-
-import (
- "encoding/json"
- "fmt"
- "strings"
- "testing"
-)
-
-func composedOf(module string, c byte, data bool, resources ...string) Composed {
- out := Composed{Node: "ace", Module: module, FP: SourcePrefix2 + strings.Repeat(string(c), 64), Data: data}
- for _, r := range resources {
- out.Resources = append(out.Resources, json.RawMessage(r))
- }
- return out
-}
-
-// The pairs mesh-controller's composed_judge_test.go captured (issue 1945 AC12), and the bus's user list, judged as the
-// controller judges them (ADR 0377 §3, ADR 0539 §7), by the node agent's function (ADR 0547 §3, issue 1126 S8).
-func TestJudgeMove(t *testing.T) {
- const (
- container = `{"id":"mesh-mail.server","type":"container","module":"mesh-mail","name":"mail","image":"mail@sha256:1111111111111111111111111111111111111111111111111111111111111111","volumes":["/var/lib/mesh-mail/spool:/spool"]}`
- container2 = `{"id":"mesh-mail.server","type":"container","module":"mesh-mail","name":"mail","image":"mail@sha256:2222222222222222222222222222222222222222222222222222222222222222","volumes":["/var/lib/mesh-mail/spool:/spool"]}`
- spool = `{"id":"mesh-mail.spool","type":"directory","module":"mesh-mail","path":"/var/lib/mesh-mail/spool"}`
- spoolMoved = `{"id":"mesh-mail.spool","type":"directory","module":"mesh-mail","path":"/srv/mesh-mail/spool"}`
- tools = `{"id":"mesh-mail.tools","type":"file","module":"mesh-mail","path":"/usr/lib/mesh-mail/tools.json","content":"a"}`
- tools2 = `{"id":"mesh-mail.tools","type":"file","module":"mesh-mail","path":"/usr/lib/mesh-mail/tools.json","content":"b"}`
- config = `{"id":"mesh-mail.conf","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/conf","content":"a"}`
- config2 = `{"id":"mesh-mail.conf","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/conf","content":"b"}`
- process = `{"id":"mesh-mail.daemon","type":"process","module":"mesh-mail","exec":"/usr/bin/a"}`
- process2 = `{"id":"mesh-mail.daemon","type":"process","module":"mesh-mail","exec":"/usr/bin/b"}`
- received = `{"id":"mesh-mail.received-smtp","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/received.json","content":"{\"given\":[{\"from\":\"cloud\",\"node\":\"novox\"},{\"from\":\"photos\",\"node\":\"novox\"}]}"}`
- received2 = `{"id":"mesh-mail.received-smtp","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/received.json","content":"{\"given\":[{\"from\":\"photos\",\"node\":\"novox\"},{\"from\":\"wiki\",\"node\":\"ace\"}]}"}`
- users = `{"id":"nats.bus-users","type":"file","module":"nats","path":"/etc/nats/users","content":"a"}`
- users2 = `{"id":"nats.bus-users","type":"file","module":"nats","path":"/etc/nats/users","content":"b"}`
- natsConf = `{"id":"nats.conf","type":"file","module":"nats","path":"/etc/nats/conf","content":"a"}`
- natsConf2 = `{"id":"nats.conf","type":"file","module":"nats","path":"/etc/nats/conf","content":"b"}`
- )
- mail := func(c byte, data bool, rs ...string) *Composed {
- m := composedOf("mesh-mail", c, data, rs...)
- return &m
- }
- bus := func(rs ...string) *Composed { m := composedOf("nats", 'b', false, rs...); return &m }
- for _, c := range []struct {
- name string
- from, to *Composed
- want Judgement
- userList bool
- words string // what the words must say, when given
- }{
- {"a new image", mail('a', true, container, spool, tools), mail('b', true, container2, spool, tools), JudgedRecreates, false, ""},
- {"a container gone", mail('a', true, container, spool), mail('b', true, spool), JudgedRecreates, false, ""},
- {"its spool moved", mail('a', true, container, spool), mail('b', true, container, spoolMoved), JudgedMovesData, false, ""},
- {"a file in its spool", mail('a', true, container, spool, config), mail('b', true, container, spool, config2), JudgedMovesData, false, "it changes where its data is kept: mesh-mail.conf"},
- // A received file the mesh makes, in its data's place, names its consumers joining or leaving, not its data moving
- // (issue 774, 2026-10-31: postgres asked a yes for "where its data is kept" when nextcloud left its grants).
- {"a consumer leaves", mail('a', true, container, spool, received), mail('b', true, container, spool, received2), JudgedMovesData, false,
- "consumers join or leave: cloud on novox leaves smtp, wiki on ace joins smtp"},
- {"a consumer leaves and a file moves", mail('a', true, container, spool, received, config), mail('b', true, container, spool, received2, config2), JudgedMovesData, false,
- "it changes where its data is kept: mesh-mail.conf; consumers join or leave: cloud on novox leaves smtp, wiki on ace joins smtp"},
- {"its data flag alone", mail('a', false, container, spool), mail('b', true, container, spool), JudgedNeither, false, ""},
- {"its data flag and its spool", mail('a', false, container, spool), mail('b', true, container, spoolMoved), JudgedMovesData, false, ""},
- {"a process of a module with data", mail('a', true, spool, process), mail('b', true, spool, process2), JudgedMovesData, false, ""},
- {"a process of a module without", mail('a', false, process), mail('b', false, process2), JudgedNeither, false, ""},
- {"its tools only", mail('a', true, container, spool, tools), mail('b', true, container, spool, tools2), JudgedNeither, false, ""},
- {"never ran", nil, mail('b', true, container), JudgedNeither, false, ""},
- {"equal, written otherwise", mail('a', true, `{"type":"directory","id":"mesh-mail.spool","module":"mesh-mail","path":"/var/lib/mesh-mail/spool"}`), mail('b', true, spool), JudgedUnchanged, false, ""},
- {"only the user list", bus(natsConf, users), bus(natsConf, users2), JudgedNeither, true, ""},
- {"the user list and more", bus(natsConf, users), bus(natsConf2, users2), JudgedNeither, false, ""},
- } {
- got := JudgeMove(c.from, *c.to)
- if got.Judgement != c.want || got.UserListOnly != c.userList || got.Words == "" {
- t.Errorf("%s: %+v, want %s (user list only %v)", c.name, got, c.want, c.userList)
- }
- if c.words != "" && got.Words != c.words {
- t.Errorf("%s: says %q, want %q", c.name, got.Words, c.words)
- }
- if strings.Contains(c.name, "data flag alone") && !strings.Contains(got.Words, "WARNING: its data flag differs") {
- t.Errorf("%s: the flag's difference is not said: %q", c.name, got.Words)
- }
- }
- // The resources that differ, as the engine's verb composition lists them (issue 774): a provider's received file
- // names its consumers, a file changed and one added are said as such, and nothing equal is listed.
- changes := ChangedResources(mail('a', true, container, spool, received, config), *mail('b', true, container, spool, received2, config2, tools))
- if want := `[{mesh-mail.conf file changed []} {mesh-mail.received-smtp file changed [cloud on novox leaves smtp wiki on ace joins smtp]} {mesh-mail.tools file added []}]`; fmt.Sprint(changes) != want {
- t.Errorf("changed resources: %v, want %s", changes, want)
- }
- if got := ChangedResources(nil, *mail('b', true, spool)); len(got) != 1 || got[0].Change != "added" {
- t.Errorf("never ran: %v, want its one resource added", got)
- }
- if got := JudgeMove(nil, Composed{Node: "ace"}); got.Judgement != JudgedNeither {
- t.Errorf("the node's own composition: %+v", got)
- }
-}
diff --git a/go/delivery/placement_test.go b/go/delivery/placement_test.go
index 6e662c4..7dd7855 100644
--- a/go/delivery/placement_test.go
+++ b/go/delivery/placement_test.go
@@ -555,12 +555,6 @@ func TestPlacement(t *testing.T) {
!strings.Contains(far.Differs[0].Bodies[0].Composed, "cut at") || strings.Contains(fmt.Sprint(far), "hunter") {
t.Errorf("a difference past the cut: %+v", far.Differs)
}
- // And an answer too large for the node's key is fitted: the texts go first, the verdict and the paths stay.
- big := CandidateAnswer{Proposal: "p-x1", Revision: 1, Verdict: CandidateDiffers, Differs: far.Differs}
- if fitted := FitAnswer(big, 4<<10); len(fitted.Differs[0].Bodies[0].Composed) != 0 || len(fitted.Differs[0].Bodies[0].Paths) != 2 ||
- fitted.Verdict != CandidateDiffers || !strings.Contains(fitted.Note, "cut") || big.Differs[0].Bodies[0].Composed == "" {
- t.Errorf("fitted as %+v", fitted)
- }
if c, r := MaskedPair(json.RawMessage(`{"env":{"DB_PASSWORD":"hunter2","PORT":"80"},"list":[{"token":"t1"}]}`),
json.RawMessage(`{"env":{"DB_PASSWORD":"hunter3","PORT":"81"},"list":[{"token":"t1"}]}`), nil); strings.Contains(c+r, "hunter") ||
strings.Contains(c+r, `"t1"`) || !strings.Contains(r, `"PORT":"81"`) || !strings.Contains(c, "") ||
diff --git a/go/overlay/declaration.go b/go/overlay/declaration.go
deleted file mode 100644
index 7c100af..0000000
--- a/go/overlay/declaration.go
+++ /dev/null
@@ -1,197 +0,0 @@
-package overlay
-
-import (
- "encoding/json"
- "fmt"
- "strings"
-)
-
-// The overlay is delivered as an ordinary declaration.
-//
-// novox/hq 08-connectivity: what the host receives is an interface configuration and a peer list,
-// as files. It does not compute them and it does not know what a private network is — the shapes
-// it already has are enough, which is why connectivity needs nothing new from tier 0.
-//
-// **No private key travels.** The configuration points at a file the node wrote from a key the
-// mesh has never seen, using WireGuard's own ability to set one after the interface is up. So the
-// control plane composes a complete configuration for a node it cannot impersonate.
-
-// Interface is what the private network is called on a machine, and where the node's own key
-// lives. A constant rather than a setting: two nodes disagreeing about the name would produce a
-// mesh where each is configured correctly and nothing meets.
-const (
- Interface = "mesh0"
- ConfigPath = "/etc/wireguard/" + Interface + ".conf"
- Unit = "wg-quick@" + Interface
- DefaultKeyPath = "/var/lib/mesh-host/overlay.key"
-)
-
-// Resource is one entry in a declaration, built here and read by the host.
-type Resource map[string]any
-
-// Declaration is what the mesh sends a node to put it on the network.
-//
-// Three resources and nothing clever: the tools, the configuration, and the interface running. A
-// person can read it, which is the point — this is the first thing a node is ever told, and if it
-// is wrong the node is unreachable and the mistake has to be findable by eye.
-func Declaration(node Node, peers []Peer, keyPath string) ([]byte, error) {
- if node.Address == "" {
- return nil, fmt.Errorf("%s has no address on the overlay, so there is nothing to configure",
- node.Name)
- }
- if keyPath == "" {
- keyPath = DefaultKeyPath
- }
-
- up := Resource{
- "id": "overlay-up", "type": "service", "unit": Unit,
- "state": "running",
- // Enabled, so the node comes back onto the network after a reboot without waiting to
- // be told again. A node whose overlay only exists while something is watching is not
- // a node that survives being switched off and on.
- "boot": "enabled",
- // And restarted when the peer list changes, because a running interface does not
- // re-read its configuration.
- //
- // This is the whole of it: a node joins, every existing node's peer list changes,
- // each file is replaced — and without this the service is already running, nothing
- // reloads it, and every node keeps a network that no longer matches the mesh. It
- // reports complete success. The lab found it the moment a third node arrived.
- //
- // Declared state rather than a command. The service must reflect the file; the host
- // works out that it does not. A command to restart would be an action, and the link
- // may not carry one (novox/hq ADR 0005) — the host refused exactly that, correctly,
- // which is how this shape was arrived at.
- "restart-on": []string{"overlay-config"},
- }
- if node.TakesOver != nil {
- // The private network takes over the tunnel it found (novox/hq ADR 0105): before this
- // unit starts, the host stops and disables the found one — never flushing it — and keeps
- // its configuration like any held file. The key is already the found one: the node took
- // it as its own overlay key when it enrolled, which is why the mesh's peer list for it
- // carries the found peers under the key they know.
- up["takes-over"] = map[string]any{
- "interface": node.TakesOver.Interface,
- "unit": node.TakesOver.Unit,
- "config": node.TakesOver.Config,
- }
- }
-
- resources := []Resource{
- {
- "id": "overlay-tools", "type": "package", "package": "wireguard-tools",
- },
- {
- "id": "overlay-config", "type": "file", "path": ConfigPath,
- // Readable only by root: it lists every peer's key and endpoint, which is a map of
- // the mesh. Not secret in the way a private key is, and not something to leave
- // world-readable on a machine somebody else also uses.
- "mode": "0600",
- "content": config(node, peers, keyPath),
- },
- up,
- }
-
- // The names used to be appended here, on the argument that a node with peers and no names is
- // half on the network. True, and the wrong place to fix it: names would be identical over a
- // different private network, so bundling them with WireGuard made one module out of two
- // things. They are their own module now, requiring this one — which is what keeps them
- // arriving together without pretending they are the same concern.
- return json.Marshal(map[string]any{"declaration": 1, "resources": resources})
-}
-
-// config writes the interface file.
-//
-// Deliberately in the order a person would read it: who I am, then who I talk to, each with a
-// line saying why it is there. A generated file that cannot be understood by the person it
-// confuses is a generated file that gets edited by hand.
-func config(node Node, peers []Peer, keyPath string) string {
- var b strings.Builder
- b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever the\n")
- b.WriteString("# peer graph changes, and an edit would survive until the next change and\n")
- b.WriteString("# then vanish, which is worse than not being applied at all.\n")
- fmt.Fprintf(&b, "#\n# node %s", node.Name)
- if node.Site != "" {
- fmt.Fprintf(&b, ", at %s", node.Site)
- }
- if !node.Reachable() {
- b.WriteString(", not dialable — it opens every path itself")
- }
- b.WriteString("\n\n[Interface]\n")
- fmt.Fprintf(&b, "Address = %s/32\n", node.Address)
- if node.Reachable() {
- if port := portOf(node.Endpoint); port != "" {
- fmt.Fprintf(&b, "ListenPort = %s\n", port)
- }
- } else if node.TakesOver != nil && node.TakesOver.Port != 0 {
- // Not dialable from the hub, but a LAN peer dials this node on the tunnel it took over,
- // so the mesh's interface must listen on that same port (novox/hq: a taken tunnel brings
- // its port). Without this the takeover guard refuses overlay-up, and re-placing the node
- // with an endpoint — the guard's suggested remedy — breaks a NAT'd node's path.
- fmt.Fprintf(&b, "ListenPort = %d\n", node.TakesOver.Port)
- }
- // The MTU the found tunnel carried, when it set one: a path tuned to 1380 (say) stalls TLS
- // and hangs transfers if the mesh's interface comes up at the 1420 default, and no ping shows
- // it (novox/hq: a taken tunnel carries its MTU).
- if node.TakesOver != nil && node.TakesOver.MTU != 0 {
- fmt.Fprintf(&b, "MTU = %d\n", node.TakesOver.MTU)
- }
- // The private key is set from a file the node wrote, so it never appears here and never
- // travelled. Everything else in this file came from the mesh; this one line is the node's.
- fmt.Fprintf(&b, "PostUp = wg set %%i private-key %s\n", keyPath)
-
- if node.Hub {
- // A hub carries traffic *between* its spokes, and a Linux machine does not forward
- // packets unless it is told to. Without this every spoke reaches the hub perfectly and
- // no spoke reaches any other — which is exactly how it failed in the lab, and it looks
- // like a peering problem rather than a kernel setting.
- //
- // Here rather than in a separate resource because it is part of what being a hub means,
- // and because it should last exactly as long as the interface does: a machine that stops
- // being the hub should stop forwarding, and PostDown below is how that happens.
- b.WriteString("PostUp = sysctl -q -w net.ipv4.ip_forward=1\n")
- b.WriteString("PostDown = sysctl -q -w net.ipv4.ip_forward=0\n")
-
- // And past the machine's own firewall, which on any node with a container runtime is
- // closed. Docker sets the FORWARD policy to DROP and inserts its chains, so the foundation
- // this mesh installs at tier 1 silently breaks the network it builds at tier 2: every
- // spoke reaches the hub, no spoke reaches any other, and every part of it reports
- // success. Found in the lab; nothing about it is visible from the mesh's own state.
- //
- // Inserted at the top so it precedes those chains, and removed on the way down so a
- // machine that stops being the hub stops carrying other people's traffic. Guarded on
- // iptables existing: a machine without it has no policy to get past.
- for _, direction := range []string{"-i", "-o"} {
- fmt.Fprintf(&b,
- "PostUp = command -v iptables >/dev/null && iptables -I FORWARD 1 %s %%i -j ACCEPT || true\n",
- direction)
- }
- for _, direction := range []string{"-i", "-o"} {
- fmt.Fprintf(&b,
- "PostDown = command -v iptables >/dev/null && iptables -D FORWARD %s %%i -j ACCEPT || true\n",
- direction)
- }
- }
-
- for _, p := range peers {
- fmt.Fprintf(&b, "\n# %s — %s\n[Peer]\n", p.Name, p.Why)
- fmt.Fprintf(&b, "PublicKey = %s\n", p.Key)
- fmt.Fprintf(&b, "AllowedIPs = %s\n", p.Allowed)
- if p.Endpoint != "" {
- fmt.Fprintf(&b, "Endpoint = %s\n", p.Endpoint)
- } else {
- b.WriteString("# no endpoint: this peer cannot be dialled and opens the path itself\n")
- }
- if p.Keepalive {
- b.WriteString("PersistentKeepalive = 25\n")
- }
- }
- return b.String()
-}
-
-func portOf(endpoint string) string {
- if i := strings.LastIndex(endpoint, ":"); i >= 0 {
- return endpoint[i+1:]
- }
- return ""
-}
diff --git a/go/overlay/declaration_test.go b/go/overlay/declaration_test.go
deleted file mode 100644
index 64fabd9..0000000
--- a/go/overlay/declaration_test.go
+++ /dev/null
@@ -1,311 +0,0 @@
-package overlay
-
-import (
- "encoding/json"
- "fmt"
- "strings"
- "testing"
-)
-
-func declarationFor(t *testing.T, node Node, peers []Peer) (string, []map[string]any) {
- t.Helper()
- raw, err := Declaration(node, peers, "")
- if err != nil {
- t.Fatal(err)
- }
- var d struct {
- Declaration int `json:"declaration"`
- Resources []map[string]any `json:"resources"`
- }
- if err := json.Unmarshal(raw, &d); err != nil {
- t.Fatal(err)
- }
- if d.Declaration != 1 {
- t.Fatalf("declaration version %d", d.Declaration)
- }
- for _, r := range d.Resources {
- if r["type"] == "file" {
- return r["content"].(string), d.Resources
- }
- }
- t.Fatal("the declaration has no configuration file in it")
- return "", nil
-}
-
-func TestNoPrivateKeyEverTravels(t *testing.T) {
- // The property the whole design rests on: the node generated its keypair and kept the private
- // half, so the mesh composes a configuration for a node it cannot impersonate. A private key
- // appearing here would mean the control plane had one — and a copy of its database would then
- // be every node's network identity.
- config, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"},
- []Peer{{Name: "anchor", Key: "HUB", Allowed: "10.42.0.0/16", Endpoint: "198.51.100.1:51820"}})
-
- if strings.Contains(config, "PrivateKey") {
- t.Error("the configuration carries a PrivateKey line; the mesh must never hold one")
- }
- if !strings.Contains(config, "private-key "+DefaultKeyPath) {
- t.Error("the configuration does not point at the key file the node wrote, so the " +
- "interface would come up with no key at all")
- }
-}
-
-func TestTheDeclarationUsesOnlyShapesTheHostAlreadyHas(t *testing.T) {
- // Connectivity needs nothing new from tier 0, and that is worth holding: the host does not
- // know what a private network is, and should not learn.
- _, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil)
-
- allowed := map[string]bool{"package": true, "file": true, "service": true,
- "directory": true, "container": true, "action": true}
- for _, r := range resources {
- if !allowed[r["type"].(string)] {
- t.Errorf("the overlay declaration uses %q, which the host does not have", r["type"])
- }
- }
-}
-
-func TestTheInterfaceComesBackAfterAReboot(t *testing.T) {
- // A node whose overlay only exists while something is watching is not a node that survives
- // being switched off and on — and it would come back unreachable, which is the worst way to
- // come back.
- _, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil)
- for _, r := range resources {
- if r["type"] == "service" {
- if r["boot"] != "enabled" {
- t.Error("the overlay interface is not enabled at boot")
- }
- if r["state"] != "running" {
- t.Error("the overlay interface is not asked to be running")
- }
- return
- }
- }
- t.Error("nothing in the declaration brings the interface up")
-}
-
-func TestTheConfigurationIsNotWorldReadable(t *testing.T) {
- // It lists every peer's key and endpoint, which is a map of the mesh. Not secret the way a
- // private key is, and not something to leave readable on a machine somebody else also uses.
- // The peer list specifically, not every file. `/etc/hosts` is in here too and must be
- // world-readable, or nothing on the machine resolves anything — a check that swept all files
- // would force it to 0600 and break the machine to protect a file that is not secret.
- _, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil)
- for _, r := range resources {
- if r["id"] == "overlay-config" && r["mode"] != "0600" {
- t.Errorf("the peer list is mode %v", r["mode"])
- }
- if r["id"] == "mesh-names" && r["mode"] != "0644" {
- t.Errorf("the name file is mode %v; nothing on the machine could read it", r["mode"])
- }
- }
-}
-
-func TestAPeerThatCannotBeDialledSaysSo(t *testing.T) {
- // A [Peer] with no Endpoint is correct and looks like a mistake. Saying why stops somebody
- // helpfully adding one that cannot work.
- config, _ := declarationFor(t, Node{Name: "anchor", Key: "HUB", Address: "10.42.0.1",
- Endpoint: "198.51.100.1:51820", Hub: true},
- []Peer{{Name: "laptop", Key: "PUB", Allowed: "10.42.0.2/32", Why: "routes through this hub"}})
-
- if strings.Contains(config, "Endpoint =") {
- t.Error("an endpoint was written for a peer that has none")
- }
- if !strings.Contains(config, "cannot be dialled") {
- t.Error("the file does not say why that peer has no endpoint")
- }
-}
-
-func TestTheFileSaysNotToEditIt(t *testing.T) {
- // It is replaced whenever the graph changes. An edit survives until then and vanishes, which
- // is worse than never being applied — the machine works, then stops, and nothing changed
- // that anybody remembers.
- config, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil)
- if !strings.Contains(config, "Do not edit") {
- t.Error("a generated file does not say it is generated")
- }
-}
-
-func TestANodeWithNoAddressIsRefused(t *testing.T) {
- // Rather than a configuration with a blank address, which wg-quick would reject on the
- // machine, at boot, where the failure is much harder to see.
- if _, err := Declaration(Node{Name: "laptop", Key: "PUB"}, nil, ""); err == nil {
- t.Fatal("a node with no overlay address was given a configuration")
- }
-}
-
-func TestOnlyAReachableNodeListens(t *testing.T) {
- // A ListenPort on a node nothing can dial is a port open for no reason.
- config, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil)
- if strings.Contains(config, "ListenPort") {
- t.Error("a node that cannot be dialled was told to listen")
- }
- config, _ = declarationFor(t, Node{Name: "anchor", Key: "HUB", Address: "10.42.0.1",
- Endpoint: "198.51.100.1:51820"}, nil)
- if !strings.Contains(config, "ListenPort = 51820") {
- t.Error("a reachable node does not listen on the port its endpoint names")
- }
-}
-
-func TestTheServiceIsRestartedWhenThePeerListChanges(t *testing.T) {
- // The fault this exists to catch, found in the lab the moment a third node arrived: a running
- // WireGuard interface does not re-read its configuration. The file was replaced, the service
- // was already running so nothing reloaded it, and every existing node kept a network that no
- // longer matched the mesh — while looking entirely successful.
- //
- // So the declaration has to verify what the interface is *carrying*, not what the file says.
- //
- // And it must be declared state rather than a command: the host refuses an action arriving
- // over the link (novox/hq ADR 0005), correctly, which is how this shape was arrived at. There
- // is a test below that no action is ever in here.
- _, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"},
- []Peer{{Name: "anchor", Key: "HUBKEY", Allowed: "10.42.0.0/16"}})
-
- for _, r := range resources {
- if r["type"] != "service" {
- continue
- }
- reflects := fmt.Sprint(r["restart-on"])
- if !strings.Contains(reflects, "overlay-config") {
- t.Errorf("the interface does not restart when its configuration changes: %v", reflects)
- }
- return
- }
- t.Error("nothing in the declaration brings the interface up")
-}
-
-func TestTheOverlayDeclarationCarriesNoAction(t *testing.T) {
- // The link may not carry an action, and the host refuses a declaration containing one — whole,
- // not in part. An overlay declaration with an action in it does not half-apply: it leaves the
- // node with no network at all.
- _, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil)
- for _, r := range resources {
- if r["type"] == "action" {
- t.Errorf("the declaration contains an action (%v); the host will refuse the whole "+
- "thing and the node will have no network", r["id"])
- }
- }
-}
-
-func TestTheHubForwardsAndNobodyElseDoes(t *testing.T) {
- // A hub carries traffic between its spokes, and Linux does not forward packets unless told
- // to. Without it every spoke reaches the hub perfectly and no spoke reaches any other — which
- // is how it failed in the lab, and it presents as a peering problem rather than a kernel
- // setting, so it is worth being sure of.
- hub, _ := declarationFor(t, Node{Name: "anchor", Key: "HUB", Address: "10.42.0.1",
- Endpoint: "198.51.100.1:51820", Hub: true}, nil)
- if !strings.Contains(hub, "ip_forward=1") {
- t.Error("the hub does not enable forwarding, so its spokes cannot reach each other")
- }
- if !strings.Contains(hub, "ip_forward=0") {
- t.Error("the hub never stops forwarding; a machine that stops being the hub would keep " +
- "passing traffic it is no longer part of")
- }
-
- // And past the machine's own firewall. Any node with a container runtime has FORWARD set to
- // DROP by Docker, so enabling ip_forward alone changes nothing — which is exactly how it
- // failed, with every spoke reaching the hub and no spoke reaching any other.
- // Checked as PostUp specifically. "contains FORWARD" passes on the PostDown line alone,
- // which would leave a hub that tears down rules it never put up.
- if !strings.Contains(hub, "PostUp = command -v iptables") {
- t.Error("the hub does not open its own firewall, so a container runtime's DROP policy " +
- "silently eats everything it was supposed to carry")
- }
- if !strings.Contains(hub, "PostDown = command -v iptables") {
- t.Error("the hub never removes those rules, so a machine that stops being the hub keeps " +
- "passing traffic it is no longer part of")
- }
-
- spoke, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil)
- if strings.Contains(spoke, "FORWARD") {
- t.Error("a spoke was given forwarding rules it has no use for")
- }
- if strings.Contains(spoke, "ip_forward") {
- t.Error("a spoke was told to forward packets, which is not its job and widens what a " +
- "compromised one could do")
- }
-}
-
-// novox/hq ADR 0105: a node whose private network takes over the tunnel it found is told so on
-// the interface's service, and nothing else about the declaration changes — the key is already
-// the found one, taken at enrolment.
-func TestTakingOverAFoundTunnelIsSaidOnTheInterfacesService(t *testing.T) {
- node := Node{Name: "anchor", Key: "PUB", Address: "192.0.2.1", Hub: true,
- Endpoint: "198.51.100.1:51900",
- TakesOver: &TakeOver{Interface: "wg0", Unit: "wg-quick@wg0", Config: "/etc/wireguard/wg0.conf"}}
- config, resources := declarationFor(t, node, nil)
-
- var up map[string]any
- for _, r := range resources {
- if r["type"] == "service" {
- up = r
- }
- }
- takes, ok := up["takes-over"].(map[string]any)
- if !ok {
- t.Fatalf("the interface's service does not say what it takes over: %+v", up)
- }
- if takes["unit"] != "wg-quick@wg0" || takes["config"] != "/etc/wireguard/wg0.conf" || takes["interface"] != "wg0" {
- t.Errorf("the takeover names the wrong tunnel: %+v", takes)
- }
- if !strings.Contains(config, "ListenPort = 51900") {
- t.Errorf("the hub's interface does not listen on the tunnel's port:\n%s", config)
- }
- if strings.Contains(config, "PrivateKey") {
- t.Error("the found key travelled in the configuration; it is the node's own, set from its key file")
- }
-
- _, plain := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "192.0.2.4"}, nil)
- for _, r := range plain {
- if _, says := r["takes-over"]; says {
- t.Error("a node taking over nothing was told to take something over")
- }
- }
-}
-
-func TestATakenTunnelBringsItsListenPortEvenWhenNotDialable(t *testing.T) {
- // A home node behind NAT (no Endpoint, so not Reachable) that took over a tunnel must still
- // listen on that tunnel's port, because its LAN peers dial it there (novox/hq: a taken tunnel
- // brings its port). Without this the takeover guard refuses overlay-up.
- config, _ := declarationFor(t, Node{
- Name: "shanks", Key: "SPOKE", Address: "10.10.0.3",
- TakesOver: &TakeOver{Interface: "wg0", Unit: "wg-quick@wg0", Port: 51820},
- }, nil)
-
- if !strings.Contains(config, "ListenPort = 51820") {
- t.Fatalf("a taken tunnel's port must be the mesh interface's ListenPort:\n%s", config)
- }
- if strings.Contains(config, "Endpoint =") {
- t.Error("a node that only listens for LAN peers must not advertise an endpoint")
- }
-}
-
-func TestANodeWithNoTunnelAndNoEndpointStillListensOnNothing(t *testing.T) {
- // The guard against over-emitting: a plain spoke with neither an endpoint nor a taken tunnel
- // writes no ListenPort — it purely dials out.
- config, _ := declarationFor(t, Node{Name: "laptop", Key: "K", Address: "10.10.0.9"}, nil)
- if strings.Contains(config, "ListenPort") {
- t.Fatalf("a dial-only node needs no ListenPort:\n%s", config)
- }
-}
-
-func TestATakenTunnelCarriesItsMTU(t *testing.T) {
- // A path tuned to a smaller MTU (1380 here) must survive the takeover — the mesh interface
- // comes up with the same MTU, or transfers hang silently (novox/hq: a taken tunnel carries
- // its MTU).
- config, _ := declarationFor(t, Node{
- Name: "shanks", Key: "SPOKE", Address: "10.10.0.3",
- TakesOver: &TakeOver{Interface: "wg0", Port: 51820, MTU: 1380},
- }, nil)
- if !strings.Contains(config, "MTU = 1380") {
- t.Fatalf("the tuned MTU was dropped:\n%s", config)
- }
-}
-
-func TestNoMTULineWhenTheTunnelSetNone(t *testing.T) {
- config, _ := declarationFor(t, Node{
- Name: "shanks", Key: "SPOKE", Address: "10.10.0.3",
- TakesOver: &TakeOver{Interface: "wg0", Port: 51820},
- }, nil)
- if strings.Contains(config, "MTU") {
- t.Fatalf("no MTU was found, so none should be written:\n%s", config)
- }
-}
diff --git a/go/overlay/doc.go b/go/overlay/doc.go
index 8a9f1b7..47879cb 100644
--- a/go/overlay/doc.go
+++ b/go/overlay/doc.go
@@ -1,5 +1,5 @@
-// Package overlay is the private network's generator, copied from mesh-controller internal/overlay at commit
-// 6c2a8d62100cb7d491f8eb9a623e1eeea5ec0de1 (novox/hq ADR 0453 clause 3, ADR 0446 clause 4): the files are unchanged but
-// for the import path of the catalogue, which is mesh-sdk's go/catalogue, and this note. mesh-controller keeps its own copy
-// until release 4b.
+// Package overlay is the private network's names and its manifest: the module the mesh provides for itself, the
+// requirement and seat it answers, the interface's name and files, and the mesh's internal names. Its generator, the
+// peer graph and the declaration it renders are the node-engine's own (novox/hq ADR 0593 §1, §7), in mesh-host's
+// internal/overlay.
package overlay
diff --git a/go/overlay/generator.go b/go/overlay/generator.go
deleted file mode 100644
index d74087d..0000000
--- a/go/overlay/generator.go
+++ /dev/null
@@ -1,195 +0,0 @@
-package overlay
-
-import (
- "encoding/json"
- "fmt"
- "strconv"
-
- "git.novox.be/novox/mesh-sdk/go/catalogue"
-)
-
-// The private network as a module rather than as code beside the module system.
-//
-// A machine's peer list is derived from every other machine, so it differs on each one and
-// changes when any of them changes — there is nothing that could be written in a manifest. So the
-// module says its resources are computed, and this computes them.
-//
-// What that buys, beyond one mechanism instead of two: **a machine is on the private network
-// because it was assigned the module.** Before this, every machine with a key and an address was
-// on it, and there was no way to say a machine should stay off.
-//
-// It is worth saying what is *not* here, because networking looks like it should be foundational.
-// The host needs none of this. It has an address and a route to the broker before the mesh exists
-// — that is the machine's own networking — and the broker's address is carried in the enrolment
-// token rather than resolved. So the private network is something the mesh installs on top, like
-// anything else, and a node without it is an ordinary node that nothing reaches directly.
-
-// What a module asks for when it needs machines to reach each other. **This is the name that
-// matters** — WireGuard is one way to answer it, and naming the requirement after the answer is
-// how a mesh ends up unable to have a second one.
-const Requirement = "private-network"
-
-// Name is the module that answers it with WireGuard.
-//
-// **It writes no names.** A machine's mesh names are answered by the mesh's one resolver (novox/hq
-// ADR 0194), and /etc/hosts is the file of one module, the holder of `node-hostname` (ADR 0199,
-// ADR 0223): the controller writes into no file another seat's holder owns. If the mesh ever needs a line there, it
-// asks that holder to register it. This module asked for a `node-names` fact written into /etc/hosts
-// until 2026-10-05; the host gives that region back at the first push without it.
-const Name = "mesh-wireguard"
-
-// Addressing is the mesh handing out addresses on the private network itself.
-//
-// The mesh's resolver answers names from it, which is why it requires this rather than a private
-// network in general. A different VPN that hands out its own addresses would come with its own
-// names — the mesh has nothing to answer about a machine whose address it did not choose.
-const Addressing = "mesh-addressing"
-
-// TheNetwork is what a machine can only have one of.
-//
-// Providing a private network is not the singular part — a machine could reasonably run two VPNs
-// for two different purposes. Being **the** one the mesh runs over is singular, and without
-// saying so a person who chose a different VPN can still end up with this one dragged back in by
-// something that needed the mesh's own addresses. Which is exactly what happened, once.
-const TheNetwork = "the-private-network"
-
-// **A resolver's data went the same way as the names.** Two constants used to sit here — a
-// `mesh-resolver` module that would write the machines as wildcards, and a `resolver-data`
-// requirement a daemon would ask for. Nothing ever provided or consumed either: a module that
-// answers names asks for the `node-zones` fact in its own manifest (catalogue.FactsInto) and
-// requires Addressing, since the file is made of the mesh's addresses and means nothing off the
-// network. A requirement nothing provides is refused at resolution, so leaving the names here
-// would only have documented a mechanism that does not exist.
-
-// **There is no bundle any more** (novox/hq ADR 0226). A `networking` module requiring this one and
-// nothing else used to be what a machine was assigned, so that "get the network working" was one
-// word and a second VPN could be chosen by assigning it instead. The mesh has one private network,
-// every machine is on it, and the bundle was a second name for this module that every machine
-// carried. A machine is assigned Name directly; another VPN is still chosen by assigning it in its
-// place, which is all the bundle ever did.
-
-// Generator answers what one node's network configuration is.
-type Generator struct {
- nodes []Node
- graph Graph
- // keyPath is where each node keeps the private half it generated. Named rather than carried:
- // the mesh has never seen it and never will.
- keyPath string
- // No registry. Being on the network is still what grants a machine the right to pull from the
- // mesh's artifact store in the clear (novox/hq ADR 0082), but the runtime's file is the runtime's
- // module's: the private network writes nothing into it, and that module states the registry
- // itself, told where the store is reached by ${seat:mesh-artifact-store:reach} (novox/hq ADR
- // 0222, issue 190).
-}
-
-// From builds a generator over the machines that are part of the network.
-//
-// The nodes given are the ones assigned the module — not every node the mesh knows. A machine
-// that was never given it is absent from everybody's peer list and from the names, which is what
-// "not on the network" has to mean.
-func From(nodes []Node, cidr, keyPath string) (*Generator, error) {
- graph, err := Compute(nodes, cidr)
- if err != nil {
- return nil, err
- }
- return &Generator{nodes: nodes, graph: graph, keyPath: keyPath}, nil
-}
-
-// Resources is one node's interface, peers and names.
-func (g *Generator) Resources(node string) ([]map[string]any, bool, error) {
- peers, part := g.graph[node]
- if !part {
- // Assigned and not yet placed on the network. Ordinary and brief, so it is an answer
- // rather than an error.
- return nil, false, nil
- }
- var self Node
- for _, n := range g.nodes {
- if n.Name == node {
- self = n
- }
- }
-
- raw, err := Declaration(self, peers, g.keyPath)
- if err != nil {
- return nil, false, err
- }
- var parsed struct {
- Resources []map[string]any `json:"resources"`
- }
- if err := json.Unmarshal(raw, &parsed); err != nil {
- return nil, false, err
- }
- return parsed.Resources, true, nil
-}
-
-// Nodes are the machines this generator was built over, so a caller can say who is on the network.
-func (g *Generator) Nodes() []Node { return g.nodes }
-
-// Graph is the peer list per node, for showing.
-func (g *Generator) Graph() Graph { return g.graph }
-
-// Manifest is the module the mesh provides for itself.
-//
-// It ships with the control plane rather than coming from a repository, because the thing that
-// computes it ships with the control plane. That is the only way it is unusual: it is assigned,
-// unassigned, resolved and settled exactly like a module somebody wrote.
-func Manifest() map[string]any {
- return map[string]any{
- "module": Name,
- "version": "1",
- "computed": Name,
- "provides": []string{Requirement, Addressing},
- "claims": []map[string]any{{"name": TheNetwork, "scope": "node"}},
- }
-}
-
-// Empty is a network nobody is on.
-//
-// A mesh where no machine was given the module. Legitimate rather than broken — every node still
-// reaches the broker, which is what being in the mesh is — so it answers "not part of this" for
-// everyone instead of refusing for want of a hub.
-func Empty() *Generator { return &Generator{graph: Graph{}} }
-
-// Listens is the port this node accepts the private network on, which only a hub has.
-//
-// **A fact about this machine's place in the mesh, not about the module.** Every machine on the
-// network runs the same module; a hub is dialled by every node at other sites and needs its port
-// open, and a machine that is not a hub dials out and needs nothing open at all. A static field in
-// a manifest is one answer for every machine that runs it, so it cannot say this — and the machine
-// it would get wrong is the one facing the public internet, which is the machine that most needs
-// filtering.
-//
-// The port is the one in the endpoint, which is also where the interface takes its ListenPort
-// from. One source, so a rule set cannot open a port the interface is not on.
-func (g *Generator) Listens(node string) ([]catalogue.Listening, error) {
- for _, n := range g.nodes {
- if n.Name != node {
- continue
- }
- if !n.Reachable() {
- // It dials out and nothing dials it. Opening a port here would be opening one on a
- // machine nothing connects to, which is not harmless — it is a rule with no source
- // that somebody later has to work out the reason for.
- return nil, nil
- }
- port := portOf(n.Endpoint)
- if port == "" {
- return nil, fmt.Errorf(
- "%s is reachable at %q and no port can be read from it, so what it must accept "+
- "the private network on is unknown", node, n.Endpoint)
- }
- number, err := strconv.Atoi(port)
- if err != nil {
- return nil, fmt.Errorf("%s is reachable at %q, and %q is not a port", node, n.Endpoint, port)
- }
- return []catalogue.Listening{{
- Port: number, Protocol: "udp", From: catalogue.FromEverywhere,
- // From everywhere, and deliberately: a node at another site is not on the private
- // network until this port lets it on, so restricting this to the mesh would be a
- // rule that can never be satisfied by the thing it exists for.
- Why: "the private network — a node at another site has no other way in",
- }}, nil
- }
- return nil, nil
-}
diff --git a/go/overlay/generator_test.go b/go/overlay/generator_test.go
deleted file mode 100644
index 569322b..0000000
--- a/go/overlay/generator_test.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package overlay
-
-import (
- "testing"
-)
-
-// novox/hq ADR 0222, issue 190: the runtime's file and service are the runtime's module's. The
-// private network writes nothing into either — being on it still grants the right to pull from the
-// mesh's store in the clear (ADR 0082), and the runtime's module states that trust itself.
-func TestTheNetworkWritesNothingOfTheRuntimes(t *testing.T) {
- nodes := []Node{
- {Name: "anchor", Site: "lab", Hub: true, Endpoint: "192.0.2.10:51820", Key: "k1", Address: "10.42.0.1"},
- {Name: "node2", Site: "lab", Key: "k2", Address: "10.42.0.2"},
- }
- g, err := From(nodes, "10.42.0.0/16", "")
- if err != nil {
- t.Fatal(err)
- }
- for _, node := range []string{"anchor", "node2"} {
- resources, part, err := g.Resources(node)
- if err != nil || !part {
- t.Fatalf("%s: resources: %v part=%v", node, err, part)
- }
- for _, r := range resources {
- if r["path"] == "/etc/docker/daemon.json" || r["unit"] == "docker.service" {
- t.Errorf("%s: the private network declares the runtime's %v", node, r)
- }
- }
- }
-}
diff --git a/go/overlay/graph.go b/go/overlay/graph.go
deleted file mode 100644
index 4be1a43..0000000
--- a/go/overlay/graph.go
+++ /dev/null
@@ -1,231 +0,0 @@
-// Package overlay computes the private network every node runs on.
-//
-// novox/hq 08-connectivity. This is control-plane work by definition: a peer list is derived from
-// every node at once, and no node has that. A node computes nothing about the mesh — it generates
-// a keypair, publishes the public half, and receives the rest.
-//
-// The shape is a hub, with direct peering between nodes at the same site. Not a full mesh, and
-// the reason is a property of WireGuard rather than a preference: there is no failover. A more
-// specific route to a dead endpoint blackholes; it does not fall back to the general one. So a
-// node gets exactly one path to any peer, because two would mean one of them silently swallowing
-// traffic.
-package overlay
-
-import (
- "errors"
- "fmt"
- "sort"
- "strings"
-)
-
-// Node is one machine's place on the network, as the mesh holds it.
-type Node struct {
- Name string
- Key string
- Endpoint string
- Site string
- Hub bool
- Address string
-
- // Carried are the peers of the tunnel this node took over (novox/hq ADR 0105): machines the
- // mesh has no record of, each known by the public key and the address the found tunnel routed
- // to it. Only a hub has any. They stay in its peer list until a node enrols with that key —
- // from then on the node is the peer.
- Carried []Carried
- // TakesOver names the found tunnel this node's private network replaces: its unit is stopped
- // and disabled, never flushed, and its configuration kept, before the mesh's interface comes
- // up with the found key. Nil on a node that raises the mesh's interface beside whatever it has.
- TakesOver *TakeOver
-}
-
-// Carried is one peer of an adopted tunnel that has not enrolled: a peer of the tunnel, not a
-// node of the mesh.
-type Carried struct {
- Key string
- Address string
-}
-
-// TakeOver is the found tunnel a node's private network takes over, as the host is told it.
-type TakeOver struct {
- Interface string
- Unit string
- Config string
- // MTU is the found tunnel's, when it set one — emitted so a tuned path keeps its MTU.
- MTU int
- // Port is the port the found tunnel listened on. The mesh's interface must listen on it too,
- // even on a node that is not dialable from the hub: a home node's LAN peers dial it there
- // (novox/hq: a taken tunnel brings its port). Listening is "a peer dials me here"; it is not
- // "the hub can dial me", which is Reachable — the two were conflated.
- Port int
-}
-
-// HostPrefix is one address as a route: /32 for IPv4, /128 for IPv6.
-func HostPrefix(address string) string {
- if strings.Contains(address, ":") {
- return address + "/128"
- }
- return address + "/32"
-}
-
-// CarriedName is how a carried peer is named in a peer list: it has no node name, so it is named
-// by the key its packets arrive under.
-func CarriedName(key string) string {
- short := key
- if len(short) > 8 {
- short = short[:8] + "…"
- }
- return "a peer of the tunnel (" + short + ")"
-}
-
-// Reachable reports whether other nodes can dial this one. Declared, never inferred.
-func (n Node) Reachable() bool { return strings.TrimSpace(n.Endpoint) != "" }
-
-// Peer is one entry in a node's peer list.
-type Peer struct {
- Name string
- Key string
- // Endpoint is empty when this peer cannot be dialled — it must dial us instead.
- Endpoint string
- // Allowed is what traffic goes down this tunnel. A single address for a direct peer; the
- // whole overlay for the hub, which is what makes it the route of last resort.
- Allowed string
- // Keepalive matters only on the side behind NAT: a node that cannot be dialled has to keep
- // the path open from its end, or the peer's first packet arrives at a mapping that has
- // already expired.
- Keepalive bool
- // Why this peer is in the list, for a person reading a generated file and wondering.
- Why string
-}
-
-// Graph is every node's peer list.
-type Graph map[string][]Peer
-
-// ErrNoHub means nobody has said which node is the hub.
-//
-// Its own error rather than an empty graph: a mesh with no hub has no path between sites, and
-// answering with "no peers" would look like a working mesh where nothing can reach anything.
-var ErrNoHub = errors.New("this mesh has no hub, so there is no path between sites")
-
-// Compute derives every node's peer list.
-//
-// Nodes without a key or an address are skipped rather than refused: a node that has enrolled and
-// not yet been given a place on the network is an ordinary in-between state, and failing the whole
-// graph because one node is half-configured would mean no node gets a network.
-func Compute(nodes []Node, overlayCIDR string) (Graph, error) {
- var hub *Node
- usable := make([]Node, 0, len(nodes))
- for i := range nodes {
- n := nodes[i]
- if n.Key == "" || n.Address == "" {
- continue
- }
- usable = append(usable, n)
- if n.Hub {
- hub = &usable[len(usable)-1]
- }
- }
- if len(usable) == 0 {
- return Graph{}, nil
- }
- if hub == nil {
- return nil, ErrNoHub
- }
- if !hub.Reachable() {
- return nil, fmt.Errorf(
- "%s is the hub and has no endpoint, so nothing can dial it. The hub is the one node "+
- "that must be reachable from wherever the others are", hub.Name)
- }
-
- graph := Graph{}
- for _, self := range usable {
- var peers []Peer
-
- // A node may share a site with the hub, and then the hub is one peer rather than two.
- // Written before the loop because it changes what that loop may emit: WireGuard takes one
- // entry per public key, so a hub appearing twice is a configuration it refuses — and the
- // mesh would have produced it silently. The lab found this on the first two machines that
- // shared a site with their hub.
- hubIsHere := !self.Hub && self.Site != "" && self.Site == hub.Site
-
- for _, other := range usable {
- if other.Name == self.Name || (hubIsHere && other.Name == hub.Name) {
- continue
- }
- // Two nodes at the same site peer directly — but only if one of them can be
- // dialled. If neither can, nobody opens the path, and the direct route is more
- // specific than the hub's, so it wins and blackholes. That is this design's own
- // stated hazard arriving in it: *a more specific route to a dead endpoint
- // blackholes; it does not fall back to the general one.*
- //
- // Found in the lab with two machines at one site behind no reachable address, which
- // is the ordinary shape of a home: they were given each other as peers, neither
- // could start, and they could not reach each other at all while both reached the hub
- // perfectly.
- if self.Site != "" && self.Site == other.Site && (self.Reachable() || other.Reachable()) {
- peers = append(peers, Peer{
- Name: other.Name, Key: other.Key,
- Endpoint: other.Endpoint,
- Allowed: other.Address + "/32",
- Keepalive: !self.Reachable(),
- Why: "at the same site",
- })
- }
- }
-
- if !self.Hub {
- // Everything else goes through the hub, including a node that roams. AllowedIPs is
- // the whole overlay, so this is the route of last resort — and because direct peers
- // above are single addresses, they win on specificity without either being ambiguous.
- why := "the hub — everything not at this site"
- if hubIsHere {
- // One entry doing both jobs: the direct path to a machine that happens to be
- // here, and the route to everywhere else. Splitting them would need two entries
- // for one key, which is the thing being avoided.
- why = "the hub, which is also at this site — everything goes here"
- }
- peers = append(peers, Peer{
- Name: hub.Name, Key: hub.Key,
- Endpoint: hub.Endpoint,
- Allowed: overlayCIDR,
- Keepalive: !self.Reachable(),
- Why: why,
- })
- } else {
- // The hub holds every node that does not share a site with it, because those nodes
- // route through it and it must know where to send the replies. Ones it cannot dial
- // will dial it.
- enrolled := map[string]bool{}
- for _, other := range usable {
- enrolled[other.Key] = true
- if other.Name == self.Name || (self.Site != "" && self.Site == other.Site) {
- continue
- }
- peers = append(peers, Peer{
- Name: other.Name, Key: other.Key,
- Endpoint: other.Endpoint,
- Allowed: other.Address + "/32",
- Why: "routes through this hub",
- })
- }
- // And every peer of the tunnel it took over that has not enrolled (novox/hq ADR
- // 0105): the same key and the same address the found tunnel had for it, so the
- // machine behind it cannot tell the tunnel changed hands. No endpoint — it dials in,
- // as it always did. Once a node enrols with that key, the node's entry above is the
- // peer, and WireGuard takes one entry per key.
- for _, c := range self.Carried {
- if enrolled[c.Key] {
- continue
- }
- peers = append(peers, Peer{
- Name: CarriedName(c.Key), Key: c.Key,
- Allowed: HostPrefix(c.Address),
- Why: "carried from the tunnel this hub took over — a peer of the tunnel, not yet a node of the mesh",
- })
- }
- }
-
- sort.Slice(peers, func(i, j int) bool { return peers[i].Name < peers[j].Name })
- graph[self.Name] = peers
- }
- return graph, nil
-}
diff --git a/go/overlay/graph_test.go b/go/overlay/graph_test.go
deleted file mode 100644
index 5cfe6ea..0000000
--- a/go/overlay/graph_test.go
+++ /dev/null
@@ -1,342 +0,0 @@
-package overlay
-
-import (
- "errors"
- "strings"
- "testing"
-)
-
-const cidr = "10.42.0.0/16"
-
-func at(name, site, address, endpoint string, hub bool) Node {
- return Node{Name: name, Key: "key-" + name, Site: site, Address: address,
- Endpoint: endpoint, Hub: hub}
-}
-
-func peersOf(t *testing.T, g Graph, node string) map[string]Peer {
- t.Helper()
- out := map[string]Peer{}
- for _, p := range g[node] {
- out[p.Name] = p
- }
- return out
-}
-
-func TestEveryNodeReachesTheHub(t *testing.T) {
- // The property that makes this a network at all. Without it a node has no route to anything
- // it does not share a site with.
- g, err := Compute([]Node{
- at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true),
- at("laptop", "", "10.42.0.2", "", false),
- at("home", "house", "10.42.0.3", "", false),
- }, cidr)
- if err != nil {
- t.Fatal(err)
- }
- for _, node := range []string{"laptop", "home"} {
- hub, ok := peersOf(t, g, node)["anchor"]
- if !ok {
- t.Fatalf("%s has no route to the hub", node)
- }
- if hub.Allowed != cidr {
- t.Errorf("%s routes %s through the hub; it must be the whole overlay or the hub is "+
- "not a route of last resort", node, hub.Allowed)
- }
- }
-}
-
-func TestNodesAtOneSitePeerDirectly(t *testing.T) {
- // Machines that share a site have a path that does not need the hub, and using it keeps their
- // traffic off a link that may be on another continent.
- g, err := Compute([]Node{
- at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true),
- at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false),
- at("server", "house", "10.42.0.3", "192.0.2.3:51820", false),
- }, cidr)
- if err != nil {
- t.Fatal(err)
- }
-
- direct, ok := peersOf(t, g, "desk")["server"]
- if !ok {
- t.Fatal("two machines at one site do not peer directly")
- }
- if direct.Allowed != "10.42.0.3/32" {
- t.Errorf("the direct peer allows %s; a single address is what makes it win on "+
- "specificity over the hub's whole-overlay route", direct.Allowed)
- }
-}
-
-func TestARoamingNodeGetsExactlyOnePath(t *testing.T) {
- // Hub-only, and not as a simplification. WireGuard has no failover: a more specific route to
- // a dead endpoint blackholes rather than falling back, so two paths would mean one of them
- // silently swallowing traffic.
- g, err := Compute([]Node{
- at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true),
- at("laptop", "", "10.42.0.2", "", false),
- at("other", "", "10.42.0.3", "", false),
- }, cidr)
- if err != nil {
- t.Fatal(err)
- }
- if got := len(g["laptop"]); got != 1 {
- t.Fatalf("a roaming node has %d peers; it gets exactly one path", got)
- }
- if g["laptop"][0].Name != "anchor" {
- t.Errorf("a roaming node's single path is %s, not the hub", g["laptop"][0].Name)
- }
-}
-
-func TestTwoRoamingNodesDoNotPeerWithEachOther(t *testing.T) {
- // An empty site is not a site. Nodes that roam have no shared location, and treating "" as
- // one would have every roaming machine try to dial every other, none of which can be dialled.
- g, err := Compute([]Node{
- at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true),
- at("laptop", "", "10.42.0.2", "", false),
- at("phone", "", "10.42.0.3", "", false),
- }, cidr)
- if err != nil {
- t.Fatal(err)
- }
- if _, ok := peersOf(t, g, "laptop")["phone"]; ok {
- t.Error("two nodes with no site peered as though they shared one")
- }
-}
-
-func TestOnlyTheSideThatCannotBeDialledKeepsThePathOpen(t *testing.T) {
- // Keepalive matters exactly once: on the node behind NAT. Without it the peer's first packet
- // arrives at a mapping that has already expired. On the reachable side it is pointless
- // traffic for ever.
- g, err := Compute([]Node{
- at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true),
- at("laptop", "", "10.42.0.2", "", false),
- }, cidr)
- if err != nil {
- t.Fatal(err)
- }
- if !peersOf(t, g, "laptop")["anchor"].Keepalive {
- t.Error("a node that cannot be dialled does not keep its path open")
- }
- if peersOf(t, g, "anchor")["laptop"].Keepalive {
- t.Error("a reachable node sends keepalives it does not need")
- }
-
- // And between two direct peers at one site, where whether to keep the path open depends on
- // which end you are. Checked here because the hub's own peer list happens not to set the
- // field at all, so asserting only against the hub tests an absence rather than the rule.
- same, err := Compute([]Node{
- at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true),
- at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false),
- at("server", "house", "10.42.0.3", "", false),
- }, cidr)
- if err != nil {
- t.Fatal(err)
- }
- if !peersOf(t, same, "server")["desk"].Keepalive {
- t.Error("the peer that cannot be dialled does not keep the path open")
- }
- if peersOf(t, same, "desk")["server"].Keepalive {
- t.Error("the peer that can be dialled sends keepalives it does not need")
- }
-}
-
-func TestTheHubKnowsEveryoneItRoutesFor(t *testing.T) {
- // The replies have to get back. A hub that does not hold a peer cannot answer it.
- g, err := Compute([]Node{
- at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true),
- at("laptop", "", "10.42.0.2", "", false),
- at("home", "house", "10.42.0.3", "", false),
- }, cidr)
- if err != nil {
- t.Fatal(err)
- }
- hub := peersOf(t, g, "anchor")
- for _, want := range []string{"laptop", "home"} {
- if _, ok := hub[want]; !ok {
- t.Errorf("the hub does not hold %s, so replies to it have nowhere to go", want)
- }
- }
-}
-
-func TestAMeshWithNoHubIsRefused(t *testing.T) {
- // Not an empty graph. A mesh with no hub has no path between sites, and answering "no peers"
- // would look like a working network in which nothing can reach anything — which is how the
- // old arrangement failed, silently, when nobody knew the address convention.
- _, err := Compute([]Node{
- at("a", "", "10.42.0.1", "", false),
- at("b", "", "10.42.0.2", "", false),
- }, cidr)
- if !errors.Is(err, ErrNoHub) {
- t.Fatalf("a mesh with no hub gave %v", err)
- }
-}
-
-func TestAnUnreachableHubIsRefused(t *testing.T) {
- // The hub is the one node that must be dialable from wherever the others are. Left
- // unchecked, every node would be given a peer it can never reach and the mesh would look
- // configured and be silent.
- _, err := Compute([]Node{
- at("anchor", "datacentre", "10.42.0.1", "", true),
- at("laptop", "", "10.42.0.2", "", false),
- }, cidr)
- if err == nil {
- t.Fatal("a hub with no endpoint was accepted")
- }
- if !strings.Contains(err.Error(), "must be reachable") {
- t.Errorf("the refusal does not say why: %v", err)
- }
-}
-
-func TestANodeWithNoPlaceYetIsSkippedRatherThanFatal(t *testing.T) {
- // A node that has enrolled and not yet been given an address is an ordinary in-between state.
- // Failing the whole graph over it would mean no node gets a network because one is half done.
- g, err := Compute([]Node{
- at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true),
- at("laptop", "", "10.42.0.2", "", false),
- {Name: "newcomer", Key: "", Address: ""},
- }, cidr)
- if err != nil {
- t.Fatal(err)
- }
- if _, ok := g["newcomer"]; ok {
- t.Error("a node with no key or address was given a peer list")
- }
- if _, ok := peersOf(t, g, "laptop")["newcomer"]; ok {
- t.Error("a node with no key was put in somebody's peer list")
- }
-}
-
-func TestNobodyPeersWithThemselves(t *testing.T) {
- g, err := Compute([]Node{
- at("anchor", "house", "10.42.0.1", "198.51.100.1:51820", true),
- at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false),
- }, cidr)
- if err != nil {
- t.Fatal(err)
- }
- for node, peers := range g {
- for _, p := range peers {
- if p.Name == node {
- t.Errorf("%s peers with itself", node)
- }
- }
- }
-}
-
-func TestAHubAtYourOwnSiteAppearsOnceNotTwice(t *testing.T) {
- // WireGuard takes one entry per public key. A hub that shares a site with a spoke was being
- // emitted twice — once as a direct peer and once as the route of last resort — producing a
- // configuration the interface refuses, from a mesh that thought it had succeeded.
- //
- // Found in the lab on the first two machines that shared a site with their hub, which is the
- // ordinary case for a small mesh and was in none of the tests above.
- g, err := Compute([]Node{
- at("anchor", "lab", "10.42.0.1", "192.0.2.10:51820", true),
- at("laptop", "lab", "10.42.0.2", "", false),
- }, cidr)
- if err != nil {
- t.Fatal(err)
- }
-
- seen := map[string]int{}
- for _, p := range g["laptop"] {
- seen[p.Key]++
- }
- for key, count := range seen {
- if count > 1 {
- t.Errorf("the key %s appears %d times; WireGuard takes one entry per key", key, count)
- }
- }
-
- if len(g["laptop"]) != 1 {
- t.Fatalf("laptop has %d peers; the hub at its own site is one peer, not two", len(g["laptop"]))
- }
- // And that single entry has to carry everything, or the node keeps a direct path to the hub
- // and loses its route to everywhere else.
- if got := g["laptop"][0].Allowed; got != cidr {
- t.Errorf("the single entry allows %s; it is both the direct path and the route of last "+
- "resort, so it carries the whole overlay", got)
- }
-}
-
-func TestTwoUnreachableNodesAtOneSiteDoNotPeerDirectly(t *testing.T) {
- // Nobody would open the path, and the direct route is more specific than the hub's — so it
- // wins and blackholes. Both nodes would reach the hub perfectly and be unable to reach each
- // other, which is the worst arrangement available: it looks configured and is not.
- //
- // This is the design's own warning arriving in its implementation, and the lab found it with
- // two machines at one site behind no reachable address — the ordinary shape of a house.
- g, err := Compute([]Node{
- at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true),
- at("desk", "house", "10.42.0.2", "", false),
- at("server", "house", "10.42.0.3", "", false),
- }, cidr)
- if err != nil {
- t.Fatal(err)
- }
-
- if _, ok := peersOf(t, g, "desk")["server"]; ok {
- t.Error("two nodes that neither can be dialled were peered directly; neither could open " +
- "the path and the route is more specific than the hub's, so it blackholes")
- }
- // And they must still be able to reach each other — through the hub.
- if hub := peersOf(t, g, "desk")["anchor"]; hub.Allowed != cidr {
- t.Errorf("desk's route to everything else allows %s", hub.Allowed)
- }
-}
-
-func TestOneReachableNodeIsEnoughToPeerDirectly(t *testing.T) {
- // The other side of it: if either end can be dialled, the path can be opened, and using it
- // keeps their traffic off a hub that may be on another continent.
- g, err := Compute([]Node{
- at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true),
- at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false),
- at("server", "house", "10.42.0.3", "", false),
- }, cidr)
- if err != nil {
- t.Fatal(err)
- }
- if _, ok := peersOf(t, g, "server")["desk"]; !ok {
- t.Error("a node did not peer with a reachable neighbour at its own site")
- }
- if _, ok := peersOf(t, g, "desk")["server"]; !ok {
- t.Error("the reachable node did not hold its unreachable neighbour, so replies have " +
- "nowhere to go")
- }
-}
-
-// novox/hq ADR 0105: a hub that took over the predecessor's tunnel carries every peer that tunnel
-// had, under the key and at the address the peer knows, until a node enrols with that key.
-func TestTheHubCarriesTheTunnelsPeersUntilTheyEnrol(t *testing.T) {
- hub := at("anchor", "hosting", "192.0.2.1", "198.51.100.1:51900", true)
- hub.Carried = []Carried{
- {Key: "key-home", Address: "192.0.2.2"},
- {Key: "key-workstation", Address: "192.0.2.3"},
- }
- // The machine behind key-home enrolled: it is a node now, at the address it kept.
- home := at("home", "house", "192.0.2.2", "", false)
- home.Key = "key-home"
-
- g, err := Compute([]Node{hub, home}, "192.0.2.0/24")
- if err != nil {
- t.Fatal(err)
- }
- peers := peersOf(t, g, "anchor")
- carried, ok := peers[CarriedName("key-workstation")]
- if !ok {
- t.Fatalf("the hub does not carry the peer that has not enrolled: %+v", g["anchor"])
- }
- if carried.Allowed != "192.0.2.3/32" || carried.Endpoint != "" || carried.Key != "key-workstation" {
- t.Errorf("a carried peer is not the tunnel's own entry — same key, its one address, no endpoint: %+v", carried)
- }
- if _, twice := peers[CarriedName("key-home")]; twice {
- t.Error("a peer that enrolled is carried as well as listed as a node: WireGuard takes one entry per key")
- }
- if node, ok := peers["home"]; !ok || node.Key != "key-home" || node.Allowed != "192.0.2.2/32" {
- t.Errorf("the enrolled peer is not the node it became: %+v", node)
- }
- // Carried peers are the hub's business only: a spoke routes everything through the hub.
- if _, leaked := peersOf(t, g, "home")[CarriedName("key-workstation")]; leaked {
- t.Error("a carried peer appeared in a spoke's peer list")
- }
-}
diff --git a/go/overlay/opens_test.go b/go/overlay/opens_test.go
deleted file mode 100644
index 7abf1bd..0000000
--- a/go/overlay/opens_test.go
+++ /dev/null
@@ -1,113 +0,0 @@
-package overlay
-
-import (
- "strings"
- "testing"
-
- "git.novox.be/novox/mesh-sdk/go/catalogue"
-)
-
-func networkOf(t *testing.T, nodes []Node) *Generator {
- t.Helper()
- made, err := From(nodes, "10.42.0.0/16", "/var/lib/mesh-host/overlay.key")
- if err != nil {
- t.Fatal(err)
- }
- return made
-}
-
-// A hub is dialled by every node at other sites, and needs its port open. A machine that is not a
-// hub dials out and needs nothing open at all.
-//
-// They are the same module, which is why a static field in a manifest cannot say it — and the
-// machine it gets wrong is the one facing the public internet, which is the machine that most
-// needs filtering.
-func TestOnlyAMachineThatCanBeDialledOpensTheOverlaysPort(t *testing.T) {
- network := networkOf(t, []Node{
- {Name: "anchor", Key: "a", Endpoint: "198.51.100.10:51820", Site: "one", Hub: true},
- {Name: "laptop", Key: "b", Site: "one"},
- })
-
- opens, err := network.Listens("anchor")
- if err != nil {
- t.Fatal(err)
- }
- if len(opens) != 1 {
- t.Fatalf("the machine every other one dials opens %d ports", len(opens))
- }
- if opens[0].Port != 51820 || opens[0].At() != "udp" {
- t.Fatalf("the port is not the one the interface listens on: %+v", opens[0])
- }
- // From everywhere, and deliberately: a node at another site is not on the private network
- // until this port lets it on, so restricting it to the mesh would be a rule that can never be
- // satisfied by the thing it exists for.
- if opens[0].From != catalogue.FromEverywhere {
- t.Fatalf("the way onto the private network is restricted to the private network: %+v", opens[0])
- }
- if opens[0].Why == "" {
- t.Fatal("a port is opened and nothing says why, which is what makes a rule set unreadable")
- }
-
- // And the machine nothing dials opens nothing. Not harmless to get wrong: a rule with no
- // reason is one somebody later has to work out the reason for.
- quiet, err := network.Listens("laptop")
- if err != nil {
- t.Fatal(err)
- }
- if len(quiet) != 0 {
- t.Fatalf("a machine nothing dials opened %d port(s)", len(quiet))
- }
-}
-
-// The port comes from the endpoint, which is where the interface takes its ListenPort from. One
-// source, so a rule set cannot open a port the interface is not on.
-func TestTheOpenedPortIsTheOneTheInterfaceListensOn(t *testing.T) {
- network := networkOf(t, []Node{
- {Name: "anchor", Key: "a", Endpoint: "198.51.100.10:60000", Site: "one", Hub: true},
- })
- opens, err := network.Listens("anchor")
- if err != nil {
- t.Fatal(err)
- }
- if len(opens) != 1 || opens[0].Port != 60000 {
- t.Fatalf("the rule set would open a port the interface is not on: %+v", opens)
- }
-
- written, err := Declaration(Node{Name: "anchor", Key: "a", Address: "10.42.0.1",
- Endpoint: "198.51.100.10:60000", Hub: true}, nil, "/k")
- if err != nil {
- t.Fatal(err)
- }
- if !strings.Contains(string(written), "ListenPort = 60000") {
- t.Fatalf("the interface and the rule set disagree about the port:\n%s", written)
- }
-}
-
-// An endpoint with no port is refused rather than treated as a machine that opens nothing.
-//
-// A generator that cannot say what a machine opens is not one that says it opens nothing, and
-// closing a port on the evidence of a failure to look is how a machine is severed by a fault
-// somewhere else entirely.
-func TestAnEndpointWithNoPortIsRefusedRatherThanTakenAsSilence(t *testing.T) {
- network := networkOf(t, []Node{
- {Name: "anchor", Key: "a", Endpoint: "198.51.100.10", Site: "one", Hub: true},
- })
- if _, err := network.Listens("anchor"); err == nil {
- t.Fatal("a machine whose port could not be read was treated as opening nothing")
- }
-}
-
-// A machine the network has never heard of opens nothing, and that is an answer rather than an
-// error: a node assigned the module before it is placed is in exactly that state.
-func TestAMachineNotOnTheNetworkOpensNothing(t *testing.T) {
- network := networkOf(t, []Node{
- {Name: "anchor", Key: "a", Endpoint: "198.51.100.10:51820", Site: "one", Hub: true},
- })
- opens, err := network.Listens("a-stranger")
- if err != nil {
- t.Fatal(err)
- }
- if len(opens) != 0 {
- t.Fatalf("a machine not on the network opened %d port(s)", len(opens))
- }
-}
diff --git a/go/overlay/overlay.go b/go/overlay/overlay.go
new file mode 100644
index 0000000..c2dc32a
--- /dev/null
+++ b/go/overlay/overlay.go
@@ -0,0 +1,79 @@
+package overlay
+
+// The private network as a module rather than as code beside the module system.
+//
+// A machine's peer list is derived from every other machine, so it differs on each one and
+// changes when any of them changes — there is nothing that could be written in a manifest. So the
+// module says its resources are computed, and this computes them.
+//
+// What that buys, beyond one mechanism instead of two: **a machine is on the private network
+// because it was assigned the module.** Before this, every machine with a key and an address was
+// on it, and there was no way to say a machine should stay off.
+//
+// It is worth saying what is *not* here, because networking looks like it should be foundational.
+// The host needs none of this. It has an address and a route to the broker before the mesh exists
+// — that is the machine's own networking — and the broker's address is carried in the enrolment
+// token rather than resolved. So the private network is something the mesh installs on top, like
+// anything else, and a node without it is an ordinary node that nothing reaches directly.
+
+// What a module asks for when it needs machines to reach each other. **This is the name that
+// matters** — WireGuard is one way to answer it, and naming the requirement after the answer is
+// how a mesh ends up unable to have a second one.
+const Requirement = "private-network"
+
+// Name is the module that answers it with WireGuard.
+//
+// **It writes no names.** A machine's mesh names are answered by the mesh's one resolver (novox/hq
+// ADR 0194), and /etc/hosts is the file of one module, the holder of `node-hostname` (ADR 0199,
+// ADR 0223): the controller writes into no file another seat's holder owns. If the mesh ever needs a line there, it
+// asks that holder to register it. This module asked for a `node-names` fact written into /etc/hosts
+// until 2026-10-05; the host gives that region back at the first push without it.
+const Name = "mesh-wireguard"
+
+// Addressing is the mesh handing out addresses on the private network itself.
+//
+// The mesh's resolver answers names from it, which is why it requires this rather than a private
+// network in general. A different VPN that hands out its own addresses would come with its own
+// names — the mesh has nothing to answer about a machine whose address it did not choose.
+const Addressing = "mesh-addressing"
+
+// TheNetwork is what a machine can only have one of.
+//
+// Providing a private network is not the singular part — a machine could reasonably run two VPNs
+// for two different purposes. Being **the** one the mesh runs over is singular, and without
+// saying so a person who chose a different VPN can still end up with this one dragged back in by
+// something that needed the mesh's own addresses. Which is exactly what happened, once.
+const TheNetwork = "the-private-network"
+
+// **A resolver's data went the same way as the names.** Two constants used to sit here — a
+// `mesh-resolver` module that would write the machines as wildcards, and a `resolver-data`
+// requirement a daemon would ask for. Nothing ever provided or consumed either: a module that
+// answers names asks for the `node-zones` fact in its own manifest (catalogue.FactsInto) and
+// requires Addressing, since the file is made of the mesh's addresses and means nothing off the
+// network. A requirement nothing provides is refused at resolution, so leaving the names here
+// would only have documented a mechanism that does not exist.
+
+// Manifest is the module the mesh provides for itself.
+//
+// It ships with the control plane rather than coming from a repository, because the thing that
+// computes it ships with the control plane. That is the only way it is unusual: it is assigned,
+// unassigned, resolved and settled exactly like a module somebody wrote.
+func Manifest() map[string]any {
+ return map[string]any{
+ "module": Name,
+ "version": "1",
+ "computed": Name,
+ "provides": []string{Requirement, Addressing},
+ "claims": []map[string]any{{"name": TheNetwork, "scope": "node"}},
+ }
+}
+
+// Interface is what the private network is called on a machine, and where the node's own key
+// lives. A constant rather than a setting: two nodes disagreeing about the name would produce a
+// mesh where each is configured correctly and nothing meets.
+const (
+ Interface = "mesh0"
+ ConfigPath = "/etc/wireguard/" + Interface + ".conf"
+ Unit = "wg-quick@" + Interface
+ DefaultKeyPath = "/var/lib/mesh-host/overlay.key"
+)
diff --git a/go/takein/takein.go b/go/takein/takein.go
deleted file mode 100644
index 657ab45..0000000
--- a/go/takein/takein.go
+++ /dev/null
@@ -1,134 +0,0 @@
-// Package takein is the one rule that turns a builder's done key into the manifest the mesh composes a module from
-// (issue 1126): the module's manifest resolved with what its build made. It is mesh-controller's take-in of a done key
-// (cmd/mesh-controller/key_takein.go builtOfKey, unpackTexts and Manifest.Resolve) moved into the SDK, so a node agent
-// composing its own modules (novox/hq ADR 0547 §2) composes from the same built manifest the controller composes from:
-// every artifact a resource names resolved to its reference and digest, a TypeScript bundle's launchers, and, for a
-// moved module, each template's text read back from its templates archive and what its build keeps for the
-// provisioner (catalogue.Handed). Composing from the manifest the indexer read instead hands no moved module and
-// leaves every artifact unbuilt.
-//
-// It reads nothing itself: the archive is read through the caller's Archive, from wherever the caller reaches the
-// artifact store.
-package takein
-
-import (
- "archive/tar"
- "bytes"
- "compress/gzip"
- "context"
- "errors"
- "fmt"
- "io"
- "strings"
-
- "git.novox.be/novox/mesh-sdk/go/catalogue"
- "git.novox.be/novox/mesh-sdk/go/delivery"
-)
-
-// Archive reads the archive a recorded reference names (catalogue.Recorded: the artifact store's address left out),
-// checked against its digest.
-type Archive func(ctx context.Context, reference, digest string) ([]byte, error)
-
-// Manifest is m, as its manifest at b's fingerprint reads, resolved with what b made: the built manifest. b must be done
-// and every artifact must name its reference.
-// A templated module's templates archive is read through archive; a module without templates reads none, and archive
-// may then be nil.
-func Manifest(ctx context.Context, m catalogue.Manifest, b delivery.BuilderBuild, archive Archive) (catalogue.Manifest, error) {
- if b.State != delivery.BuildDone {
- return catalogue.Manifest{}, fmt.Errorf("takein: the build of %s is %s, not done: nothing to resolve it with", m.Module, b.State)
- }
- // **A done key whose artifact names no reference is waited for, never composed from** (novox/hq ADR 0498 §8), in the
- // controller's words (mesh-controller internal/builderkeys Key.Says): a key done before R4e, or a candidate's image or
- // package not yet built again for the trunk.
- var missing []string
- for _, a := range b.Artifacts {
- if strings.TrimSpace(a.Reference) == "" {
- missing = append(missing, a.Name)
- }
- }
- if len(missing) > 0 {
- return catalogue.Manifest{}, fmt.Errorf("takein: the build of %s is done and waits for the reference of %s",
- m.Module, strings.Join(missing, ", "))
- }
- built, err := Built(ctx, m, b.Artifacts, archive)
- if err != nil {
- return catalogue.Manifest{}, err
- }
- resolved, err := m.Resolve(built)
- if err != nil {
- return catalogue.Manifest{}, fmt.Errorf("takein: the build of %s does not resolve: %w", m.Module, err)
- }
- return resolved, nil
-}
-
-// Built is each of a done key's artifacts as catalogue.Manifest.Resolve reads it: its recorded reference and digest, a
-// TypeScript bundle's launchers by the build modules' one rule (catalogue.Launchers), and, for the archive a module packs
-// its templates into, each template's text read back from that archive.
-func Built(ctx context.Context, m catalogue.Manifest, artifacts []delivery.BuiltArtifact, archive Archive) ([]catalogue.Built, error) {
- declared := map[string]catalogue.Artifact{}
- if m.Build != nil {
- for _, a := range m.Build.Artifacts {
- declared[a.Name] = a
- }
- }
- templates := ""
- if m.Templated() {
- templates = catalogue.TemplatesArtifact(m)
- }
- var out []catalogue.Built
- for _, a := range artifacts {
- d := declared[a.Name]
- kind := a.Kind
- if kind == "" {
- kind = d.Kind
- }
- b := catalogue.Built{Name: a.Name, Kind: kind, Reference: catalogue.Recorded(a.Reference), Digest: a.Digest}
- if launchers := catalogue.Launchers(d.Language, d.Entrypoints); len(launchers) > 0 {
- b.Launchers = launchers
- }
- if a.Name == templates && templates != "" {
- if archive == nil {
- return nil, fmt.Errorf("takein: %s packs its templates into %q, and nothing here reads the artifact store",
- m.Module, a.Name)
- }
- body, err := archive(ctx, b.Reference, b.Digest)
- if err != nil {
- return nil, fmt.Errorf("takein: the templates archive %q of %s cannot be read from the artifact store: %w",
- a.Name, m.Module, err)
- }
- if b.Texts, err = UnpackTexts(body); err != nil {
- return nil, fmt.Errorf("takein: the templates archive %q of %s cannot be read back: %w", a.Name, m.Module, err)
- }
- }
- out = append(out, b)
- }
- return out, nil
-}
-
-// UnpackTexts is every regular file of a packed templates archive (gzip over tar), by its path inside it.
-func UnpackTexts(body []byte) (map[string]string, error) {
- zipped, err := gzip.NewReader(bytes.NewReader(body))
- if err != nil {
- return nil, err
- }
- defer zipped.Close()
- reader := tar.NewReader(zipped)
- texts := map[string]string{}
- for {
- header, err := reader.Next()
- if errors.Is(err, io.EOF) {
- return texts, nil
- }
- if err != nil {
- return nil, err
- }
- if header.Typeflag != tar.TypeReg {
- continue
- }
- text, err := io.ReadAll(io.LimitReader(reader, 16<<20))
- if err != nil {
- return nil, err
- }
- texts[strings.TrimPrefix(header.Name, "./")] = string(text)
- }
-}
diff --git a/go/validate/sdk_test.go b/go/validate/sdk_test.go
deleted file mode 100644
index d946fff..0000000
--- a/go/validate/sdk_test.go
+++ /dev/null
@@ -1,31 +0,0 @@
-package validate
-
-import (
- "errors"
- "strings"
- "testing"
-
- "git.novox.be/novox/mesh-sdk/go/declaration"
-)
-
-// The SDK's validator speaks the copied parser's vocabulary version (issue 1353 AC3).
-func TestVersionIsTheParsersVersion(t *testing.T) {
- if Version != declaration.Version {
- t.Fatalf("validate.Version = %d, declaration.Version = %d", Version, declaration.Version)
- }
-}
-
-// A body of another declaration version is refused whole, by both the validator and the parser (AC3). The body is
-// the shape mesh-host's validate_test.go uses for its good declaration, with the version changed.
-func TestAnotherVersionIsRefusedWhole(t *testing.T) {
- body := `{"declaration": 99, "resources": [{"id": "d", "type": "directory", "path": "/var/lib/x", "mode": "0755"}]}`
- problems := Declaration([]byte(body))
- if len(problems) == 0 {
- t.Fatal("a declaration of another version was taken")
- }
- _, err := declaration.Parse([]byte(body))
- var refused *declaration.RefusalError
- if !errors.As(err, &refused) || strings.Join(refused.Problems, "\n") != strings.Join(problems, "\n") {
- t.Fatalf("the parser and the validator disagree: %v / %v", err, problems)
- }
-}
diff --git a/go/validate/validate.go b/go/validate/validate.go
deleted file mode 100644
index 1291866..0000000
--- a/go/validate/validate.go
+++ /dev/null
@@ -1,42 +0,0 @@
-// Package validate is the node-engine's own judgement of a declaration, for whoever must know before
-// a declaration is sent whether a machine would take it (novox/hq to-be 45 §4, D1; §9, the merge gate).
-//
-// **One validator.** The controller composed declarations the host then refused whole — a manifest
-// the catalogue check passed (novox/hq issue 236), a consumer's identity too long for the machine's
-// names (issue 263) — because the only validator was the one the host runs as it applies. A second,
-// written in the controller from the host's rules, would drift from them the first time either
-// changed. So the host's own parsing is exported here, unchanged: what the controller's self-check and
-// the merge gate run is what every machine runs.
-//
-// Copied from mesh-host at commit 619e14aa8e572c5056e3d2a2412c7f8b364bf840 (validate/validate.go), unchanged but for
-// the import path of the parser, which is mesh-sdk's go/declaration (novox/hq ADR 0453 clause 3, ADR 0446 clause 4).
-// mesh-host keeps its own copy until release 4b.
-//
-// It judges a declaration as it arrives over the link: actions are refused, as the host refuses them
-// from the link (novox/hq ADR 0005). Nothing here touches a machine.
-package validate
-
-import (
- "errors"
-
- "git.novox.be/novox/mesh-sdk/go/declaration"
-)
-
-// Declaration is every problem the node-engine would refuse a declaration for, each in its own words;
-// nil when it would take it. The body is the declaration as the controller composes it — the bytes a
-// signature is made over — not the signed envelope.
-func Declaration(raw []byte) []string {
- _, err := declaration.Parse(raw)
- if err == nil {
- return nil
- }
- var refused *declaration.RefusalError
- if errors.As(err, &refused) {
- return refused.Problems
- }
- return []string{err.Error()}
-}
-
-// Version is the declaration vocabulary this validator speaks: a declaration of another version is
-// refused whole by Declaration, as by the host.
-const Version = declaration.Version
diff --git a/go/validate/validate_test.go b/go/validate/validate_test.go
deleted file mode 100644
index 51ade73..0000000
--- a/go/validate/validate_test.go
+++ /dev/null
@@ -1,50 +0,0 @@
-package validate
-
-import (
- "strings"
- "testing"
-)
-
-// **The validator a controller imports is the host's**: a declaration the host takes passes, and one
-// it refuses is refused with the host's own words, every problem at once.
-func TestTheValidatorIsTheHosts(t *testing.T) {
- good := `{"declaration": 1, "resources": [{"id": "d", "type": "directory", "path": "/var/lib/x", "mode": "0755"}]}`
- if problems := Declaration([]byte(good)); problems != nil {
- t.Fatalf("a declaration the host takes was refused: %v", problems)
- }
- // And one carrying the lease epoch and sequence the controller sends it under (novox/hq to-be 45
- // §6): a controller validating with this package may send them.
- ordered := `{"declaration": 1, "epoch": 57, "sequence": 3, "resources": [{"id": "d", "type": "directory", "path": "/var/lib/x", "mode": "0755"}]}`
- if problems := Declaration([]byte(ordered)); problems != nil {
- t.Fatalf("a declaration carrying its order was refused: %v", problems)
- }
- // And one carrying a file's owner-at-runtime and the files the provisioner fills (novox/hq ADR 0349 §5): the
- // reader takes them before any controller sends them.
- owned := `{"declaration": 1, "filled_files": ["/etc/x"], "resources": [{"id": "f", "type": "file", "path": "/etc/x", "content": "a", "owner-at-runtime": "owned"}]}`
- if problems := Declaration([]byte(owned)); problems != nil {
- t.Fatalf("a declaration saying who owns a file at runtime was refused: %v", problems)
- }
- // And one naming the provisioner client's account (novox/hq ADR 0349 §4).
- named := `{"declaration": 1, "provisioner_account": "mesh-node-provisioner", "filled_files": ["/etc/x"], "resources": [{"id": "f", "type": "file", "path": "/etc/x", "content": "a", "owner-at-runtime": "owned"}]}`
- if problems := Declaration([]byte(named)); problems != nil {
- t.Fatalf("a declaration naming the provisioner client's account was refused: %v", problems)
- }
- for _, bad := range []string{
- `{"declaration": 1, "provisioner_account": "root", "resources": [{"id": "f", "type": "file", "path": "/etc/x", "content": "a"}]}`,
- `{"declaration": 1, "filled_files": ["/etc/none"], "resources": [{"id": "f", "type": "file", "path": "/etc/x", "content": "a", "owner-at-runtime": "owned"}]}`,
- `{"declaration": 1, "resources": []}`,
- `{"declaration": 99, "resources": [{"id": "d", "type": "directory", "path": "/x"}]}`,
- `{"declaration": 1, "resources": [{"id": "d", "type": "nothing-the-host-knows"}]}`,
- `not json`,
- } {
- problems := Declaration([]byte(bad))
- if len(problems) == 0 {
- t.Errorf("a declaration the host refuses was passed: %s", bad)
- }
- for _, p := range problems {
- if strings.TrimSpace(p) == "" {
- t.Errorf("a problem said nothing, for %s", bad)
- }
- }
- }
-}