diff --git a/cmd/mesh-compose/main.go b/cmd/mesh-compose/main.go new file mode 100644 index 0000000..22a5753 --- /dev/null +++ b/cmd/mesh-compose/main.go @@ -0,0 +1,154 @@ +// mesh-compose is the node-engine's composer command (novox/hq ADR 0482 clause 4, ADR 0593 §7, issue 1621): it reads a +// composer.Request on standard input, reads every manifest, composes the machines named in each case and writes one +// composer.Verdict on standard output. Built at a commit of the node-engine's repository, it answers what that +// commit's composition answers, so a group that changes the node-engine's composition is judged by its own next code. +// +// mesh-compose < request.json > verdict.json +// mesh-compose -request request.json > verdict.json +// mesh-compose -catalogue [-node ]... [-depth resolve|compose] [-declarations] [< request.json] +// +// -catalogue reads /modules/*/module.json and a module.json at 's root as the first case's manifests (a +// checkout or `git archive` of the catalogue repository at a ref: the command fetches nothing). -node adds a machine to +// compose; -declarations answers each machine's declared paths with their owners and modes. +// +// It opens no network connection, bus, store or file beyond its standard input, the -request file and the -catalogue +// directory, and reads +// no environment. Exit codes: 0 the verdict is written (a machine that does not compose is a verdict, not an error); +// 1 the request cannot be read or run, said on standard error; 2 the flags are wrong. +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/novox/mesh-host/internal/composer" +) + +func main() { + os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) +} + +type nodes []string + +func (n *nodes) String() string { return strings.Join(*n, ",") } +func (n *nodes) Set(v string) error { *n = append(*n, v); return nil } + +// run is the command: its arguments, standard input, output and error; it answers the exit code. +func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { + fs := flag.NewFlagSet("mesh-compose", flag.ContinueOnError) + fs.SetOutput(stderr) + catalogueDir := fs.String("catalogue", "", "a catalogue checkout: /modules/*/module.json are the first case's manifests") + var machines nodes + fs.Var(&machines, "node", "a machine to compose (repeatable)") + depth := fs.String("depth", "", "resolve or compose (default: the request's, else compose)") + declarations := fs.Bool("declarations", false, "answer each composed machine's declared paths, owners and modes") + requestFile := fs.String("request", "", "read the request from this file instead of standard input (a run step that hands no standard input)") + if err := fs.Parse(args); err != nil { + return 2 + } + if fs.NArg() > 0 { + fmt.Fprintf(stderr, "mesh-compose: unexpected arguments %q\n", fs.Args()) + return 2 + } + var raw []byte + var err error + if *requestFile != "" { + raw, err = os.ReadFile(*requestFile) + } else { + raw, err = io.ReadAll(stdin) + } + if err != nil { + fmt.Fprintln(stderr, "mesh-compose: the request cannot be read:", err) + return 1 + } + var req composer.Request + if strings.TrimSpace(string(raw)) == "" { + if *catalogueDir == "" { + fmt.Fprintln(stderr, "mesh-compose: no request on standard input and no -catalogue") + return 1 + } + req = composer.Request{V: composer.Version} + } else if req, err = composer.Decode(raw); err != nil { + fmt.Fprintln(stderr, "mesh-compose:", err) + return 1 + } + if *catalogueDir != "" { + read, err := readCatalogue(*catalogueDir) + if err != nil { + fmt.Fprintln(stderr, "mesh-compose:", err) + return 1 + } + if len(req.Cases) == 0 { + req.Cases = []composer.Case{{Name: "catalogue", Manifests: map[string]composer.Manifest{}}} + } + if req.Cases[0].Manifests == nil { + req.Cases[0].Manifests = map[string]composer.Manifest{} + } + for name, m := range read { + if _, given := req.Cases[0].Manifests[name]; !given { + req.Cases[0].Manifests[name] = m + } + } + // No list of predefined keys is read: the composer judges no manifest (novox/hq ADR 0578 §5). + } + req.Machines = append(req.Machines, machines...) + if *depth != "" { + req.Depth = *depth + } + if *declarations { + req.Declarations = true + } + v, err := composer.Run(req) + if err != nil { + fmt.Fprintln(stderr, "mesh-compose:", err) + return 1 + } + enc := json.NewEncoder(stdout) + if err := enc.Encode(v); err != nil { + fmt.Fprintln(stderr, "mesh-compose: the verdict cannot be written:", err) + return 1 + } + return 0 +} + +// readCatalogue reads every module.json of a catalogue checkout by its module's name: the root's and each of +// modules/*/. A manifest whose name cannot be read is kept under its directory's name, so the reader's refusal is said. +func readCatalogue(dir string) (map[string]composer.Manifest, error) { + paths, err := filepath.Glob(filepath.Join(dir, "modules", "*", "module.json")) + if err != nil { + return nil, err + } + if _, err := os.Stat(filepath.Join(dir, "module.json")); err == nil { + paths = append(paths, filepath.Join(dir, "module.json")) + } + if len(paths) == 0 { + return nil, fmt.Errorf("the catalogue %s holds no module.json", dir) + } + sort.Strings(paths) + out := map[string]composer.Manifest{} + for _, p := range paths { + raw, err := os.ReadFile(p) + if err != nil { + return nil, err + } + var probe struct { + Module string `json:"module"` + } + name := filepath.Base(filepath.Dir(p)) + if json.Unmarshal(raw, &probe) == nil && probe.Module != "" { + name = probe.Module + } + if _, twice := out[name]; twice { + return nil, errors.New("the catalogue " + dir + " defines " + name + " twice") + } + out[name] = composer.Manifest{Raw: raw} + } + return out, nil +} diff --git a/cmd/mesh-compose/main_test.go b/cmd/mesh-compose/main_test.go new file mode 100644 index 0000000..83a5faf --- /dev/null +++ b/cmd/mesh-compose/main_test.go @@ -0,0 +1,339 @@ +package main + +import ( + "bytes" + "encoding/json" + "go/build" + "os" + "os/exec" + "path/filepath" + "reflect" + "runtime" + "sort" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/beside" + "github.com/novox/mesh-host/internal/compose" + "github.com/novox/mesh-host/internal/composer" +) + +// captured is every manifest of the catalogue captured in testdata/beside (internal/beside), raw, by module name. +func captured(t *testing.T) map[string]composer.Manifest { + t.Helper() + read, err := readCatalogue(filepath.Dir(beside.Catalogue(t))) + if err != nil { + t.Fatal(err) + } + if len(read) < 100 { + t.Fatalf("the captured catalogue holds %d manifests, fewer than the catalogue's", len(read)) + } + return read +} + +// meshOf is a mesh of three machines over the captured catalogue, each assigned real modules, the input the assigner +// composes with (nodes and their assignments, holdings from the claims): anchor runs the bus and the store, laptop a +// desktop's modules, ace a module whose requirement is provided elsewhere. +func meshOf() compose.Input { + return compose.Input{Nodes: []compose.Node{ + {Name: "anchor", Assigned: []string{"nats", "postgres"}}, + {Name: "laptop", Assigned: []string{"sudo", "zsh"}}, + {Name: "ace", Assigned: []string{"mesh-reads", "nats"}}, + }} +} + +func request(t *testing.T, depth string) composer.Request { + t.Helper() + r := composer.Request{V: composer.Version, Depth: depth, HoldingsFromClaims: true, + Machines: []string{"anchor", "laptop", "ace"}, + Cases: []composer.Case{{Name: "trunk", Manifests: captured(t)}}} + return withInput(t, r, meshOf()) +} + +// withInput is r with in as its input, as a caller puts it on the wire: the request's shape carries the input as JSON. +func withInput(t *testing.T, r composer.Request, in compose.Input) composer.Request { + t.Helper() + raw, err := json.Marshal(in) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(raw, &r.Input); err != nil { + t.Fatal(err) + } + return r +} + +func runJSON(t *testing.T, args []string, in []byte) (int, composer.Verdict, string) { + t.Helper() + var out, errOut bytes.Buffer + code := run(args, bytes.NewReader(in), &out, &errOut) + var v composer.Verdict + if code == 0 { + if err := json.Unmarshal(out.Bytes(), &v); err != nil { + t.Fatalf("the verdict is not JSON: %v\n%s", err, out.String()) + } + } + return code, v, errOut.String() +} + +func encode(t *testing.T, r composer.Request) []byte { + t.Helper() + raw, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + return raw +} + +// 1621/AC1: the command reads the plain input and the manifests on standard input, validates every manifest, composes +// the machines named and writes one verdict: per machine composes or the reason, per manifest accepted or the field +// refused. +func TestTheCommandWritesOneVerdictPerManifestAndMachine(t *testing.T) { + r := request(t, composer.DepthResolve) + r.Cases[0].Manifests["later"] = composer.Manifest{Raw: json.RawMessage(`{"module":"later","version":"1","shares":["x"]}`)} + r.Cases[0].Manifests["refused"] = composer.Manifest{Raw: json.RawMessage(`{"module":"refused","version":"1"}`)} + r.Cases[0].Verdicts = map[string]delivery.ManifestVerdict{"refused": {State: delivery.VerdictRefused, Problems: []string{"it is wrong"}}} + code, v, stderr := runJSON(t, nil, encode(t, r)) + if code != 0 { + t.Fatalf("exit %d: %s", code, stderr) + } + if len(v.Cases) != 1 || v.Cases[0].Name != "trunk" || v.SDK == "" { + t.Fatalf("verdict = %+v", v) + } + c := v.Cases[0] + // ADR 0578 §5, §6: the composer judges nothing; it sets an unknown field aside and names it, and holds a manifest + // its verdict refuses, named in the indexer's words. + if got := c.Manifests["later"]; !got.Accepted || !strings.Contains(got.SetAside, `"shares"`) { + t.Errorf("the manifest with an unknown field reads %+v, want accepted naming the field set aside", got) + } + if got := c.Manifests["refused"]; got.Accepted || !strings.Contains(got.Refused, "refused the manifest of refused: it is wrong") { + t.Errorf("the refused manifest reads %+v, want refused in the verdict's words", got) + } + if got := c.Manifests["nats"]; !got.Accepted { + t.Errorf("nats reads %+v, want accepted", got) + } + if len(c.Machines) != 3 { + t.Fatalf("machines = %v, want the three named", c.Machines) + } + for name, m := range c.Machines { + if !m.Composes && m.Reason == "" { + t.Errorf("%s does not compose and says no reason", name) + } + } +} + +// 1621/AC3: over the captured catalogue and a mesh of three machines, the command's verdict equals, machine for +// machine and manifest for manifest, what the linked reader, resolution and composition answer, at both depths. +func TestTheCommandAnswersWhatTheLinkedCompositionAnswers(t *testing.T) { + for _, depth := range []string{composer.DepthResolve, composer.DepthCompose} { + r := request(t, depth) + code, v, stderr := runJSON(t, nil, encode(t, r)) + if code != 0 { + t.Fatalf("%s: exit %d: %s", depth, code, stderr) + } + in := meshOf() + in.Catalogue = map[string]catalogue.Manifest{} + for name, m := range r.Cases[0].Manifests { + // A request without a list of predefined keys reads a fallback as the indexer does (Request.Predefined). + parsed, err := catalogue.Decode(m.Raw) + got := v.Cases[0].Manifests[name] + if (err == nil) != got.Accepted || (err != nil && err.Error() != got.Refused) { + t.Errorf("%s: the manifest of %s: linked %v, command %+v", depth, name, err, got) + } + if err == nil { + in.Catalogue[name] = parsed + } + } + in.Holdings = composer.HoldingsOf(in.Catalogue, in.Nodes) + composed := 0 + for _, node := range r.Machines { + got := v.Cases[0].Machines[node] + var want composer.Machine + if depth == composer.DepthResolve { + if _, err := compose.Resolve(in, node); err != nil { + want = composer.Machine{Reason: err.Error()} + } else { + want = composer.Machine{Composes: true} + } + } else { + d, problems, err := compose.Compose(in, node) + switch { + case err != nil: + want = composer.Machine{Reason: err.Error()} + default: + want = composer.Machine{Composes: len(problems) == 0, Problems: problems, Refusals: d.Refusals()} + if len(problems) > 0 { + want.Reason = "the node-engine's validator refuses the declaration" + } + } + } + if !reflect.DeepEqual(got, want) { + t.Errorf("%s: %s: command %+v, linked %+v", depth, node, got, want) + } + if got.Composes { + composed++ + } else { + t.Logf("%s: %s does not compose: %s %v", depth, node, got.Reason, got.Problems) + } + } + t.Logf("%s: %d of %d machines compose", depth, composed, len(r.Machines)) + } +} + +// 1621 note 6: a person composes a catalogue checkout (repository@ref checked out or archived: the command fetches +// nothing) for a node and reads its declaration: each path with its kind, owner and mode. +func TestACatalogueCheckoutIsComposedForANode(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "modules", "demo"), 0o755); err != nil { + t.Fatal(err) + } + manifest := `{"module": "demo", "version": "1", "resources": [{"id": "state", "type": "directory", ` + + `"path": "/var/lib/demo", "mode": "0750", "owner": "root"}]}` + if err := os.WriteFile(filepath.Join(dir, "modules", "demo", "module.json"), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + in := encode(t, withInput(t, composer.Request{V: composer.Version}, compose.Input{Nodes: []compose.Node{{Name: "laptop", Assigned: []string{"demo"}}}})) + code, v, stderr := runJSON(t, []string{"-catalogue", dir, "-node", "laptop", "-declarations"}, in) + if code != 0 { + t.Fatalf("exit %d: %s", code, stderr) + } + m := v.Cases[0].Machines["laptop"] + want := []composer.Path{{Path: "/var/lib/demo", Kind: "directory", Owner: "root", Mode: "0750", Module: ""}} + if !m.Composes || len(m.Declared) != 1 || m.Declared[0].Path != want[0].Path || m.Declared[0].Mode != "0750" || + m.Declared[0].Owner != "root" || m.Declared[0].Kind != "directory" { + t.Fatalf("laptop = %+v, want it composed declaring %+v", m, want) + } +} + +// A run step hands the command no standard input: the request is read from the file -request names, and a file that is +// not there is a request it cannot read. +func TestTheRequestIsReadFromAFile(t *testing.T) { + file := filepath.Join(t.TempDir(), "request.json") + r := composer.Request{V: composer.Version, Cases: []composer.Case{{Name: "only", Manifests: map[string]composer.Manifest{ + "demo": {Raw: json.RawMessage(`{"module":"demo","version":"1"}`)}}}}} + if err := os.WriteFile(file, encode(t, r), 0o600); err != nil { + t.Fatal(err) + } + code, v, stderr := runJSON(t, []string{"-request", file}, nil) + if code != 0 || len(v.Cases) != 1 || !v.Cases[0].Manifests["demo"].Accepted { + t.Fatalf("exit %d (%s), verdict %+v", code, stderr, v) + } + if code, _, stderr := runJSON(t, []string{"-request", file + ".gone"}, nil); code != 1 || !strings.Contains(stderr, "cannot be read") { + t.Errorf("a request file that is not there: exit %d, %q", code, stderr) + } +} + +// The exit codes: 1 for a request that cannot be read or run, 2 for wrong flags, each said on standard error. +func TestTheExitCodesSayWhatFailed(t *testing.T) { + unfilled := encode(t, composer.Request{V: composer.Version, Cases: []composer.Case{{Name: "c", Manifests: map[string]composer.Manifest{"x": {Digest: "abc"}}}}}) + for _, c := range []struct { + name string + args []string + in string + code int + says string + }{ + {"not JSON", nil, "{", 1, "not JSON"}, + {"another version", nil, `{"v":2}`, 1, "version 2"}, + {"a digest left unfilled", nil, string(unfilled), 1, "digest"}, + {"an unknown depth", nil, `{"v":1,"depth":"deep"}`, 1, "depth"}, + {"nothing to read", nil, "", 1, "no request"}, + {"a wrong flag", []string{"-nope"}, "", 2, "nope"}, + {"an argument", []string{"extra"}, "", 2, "unexpected"}, + } { + var out, errOut bytes.Buffer + if got := run(c.args, strings.NewReader(c.in), &out, &errOut); got != c.code || !strings.Contains(errOut.String(), c.says) { + t.Errorf("%s: exit %d saying %q, want %d saying %q", c.name, got, errOut.String(), c.code, c.says) + } + } +} + +// ownPackages is every package of this repository the command imports, directly or not, with the packages each +// imports: what this repository adds to the composition; mesh-sdk's packages are judged in mesh-sdk. +func ownPackages(t *testing.T) map[string][]string { + t.Helper() + _, file, _, _ := runtime.Caller(0) + root := filepath.Join(filepath.Dir(file), "..", "..") + const module = "github.com/novox/mesh-host" + out := map[string][]string{} + var walk func(path string) + walk = func(path string) { + if _, done := out[path]; done { + return + } + rel := strings.TrimPrefix(strings.TrimPrefix(path, module), "/") + pkg, err := build.Default.ImportDir(filepath.Join(root, rel), 0) + if err != nil { + t.Fatalf("%s: %v", path, err) + } + out[path] = pkg.Imports + for _, imp := range pkg.Imports { + if strings.HasPrefix(imp, module+"/") { + walk(imp) + } + } + } + walk(composer.Package) + return out +} + +// 1621/AC2 and AC4: the command imports, through every package of this repository it reaches, only the standard +// library, mesh-sdk and this repository's own packages, and none that reaches a bus, a store, a network service or +// another process. +func TestTheCommandImportsNoBusStoreNetworkOrProcess(t *testing.T) { + forbidden := []string{"net/http", "net/rpc", "net/smtp", "os/exec", "database/sql", "plugin", "syscall"} + own := ownPackages(t) + if len(own) < 4 { + t.Fatalf("the command reaches %d packages of this repository; the walk found too few to judge", len(own)) + } + for path, imports := range own { + for _, imp := range imports { + std := !strings.Contains(strings.SplitN(imp, "/", 2)[0], ".") + if !std && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-sdk/go") && !strings.HasPrefix(imp, "github.com/novox/mesh-host/") { + t.Errorf("%s imports %s, which is neither the standard library, mesh-sdk nor this repository", path, imp) + } + for _, f := range forbidden { + if imp == f { + t.Errorf("%s imports %s", path, imp) + } + } + } + } +} + +// 1621/AC2: run with an empty network namespace, the command still writes its verdict: it needs no network. The test +// runs its own binary again under `unshare --net` as an unprivileged user's namespace; where the kernel or the sandbox +// allows no new namespace (a build module's run step is already one with no network), it says so and skips. +func TestTheCommandRunsWithNoNetwork(t *testing.T) { + if os.Getenv("MESH_COMPOSE_HELPER") == "1" { + os.Exit(run(nil, os.Stdin, os.Stdout, os.Stderr)) + } + unshare, err := exec.LookPath("unshare") + if err != nil { + t.Skip("no unshare on this machine, so no empty network namespace can be made here") + } + if out, err := exec.Command(unshare, "--net", "--map-root-user", "true").CombinedOutput(); err != nil { + t.Skipf("this machine makes no unprivileged network namespace (%v: %s); a run step's own has no network", err, out) + } + r := request(t, composer.DepthResolve) + cmd := exec.Command(unshare, "--net", "--map-root-user", os.Args[0], "-test.run=^TestTheCommandRunsWithNoNetwork$") + cmd.Env = []string{"MESH_COMPOSE_HELPER=1"} + cmd.Stdin = bytes.NewReader(encode(t, r)) + var out, errOut bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &errOut + if err := cmd.Run(); err != nil { + t.Fatalf("the command in an empty network namespace: %v: %s", err, errOut.String()) + } + var v composer.Verdict + if err := json.Unmarshal(out.Bytes(), &v); err != nil || len(v.Cases) != 1 || len(v.Cases[0].Machines) != 3 { + t.Fatalf("no verdict from the command with no network: %v\n%s", err, out.String()) + } + names := make([]string, 0, len(v.Cases[0].Machines)) + for n := range v.Cases[0].Machines { + names = append(names, n) + } + sort.Strings(names) + t.Logf("composed with no network: %v", names) +} diff --git a/cmd/mesh-compose/reads-outside b/cmd/mesh-compose/reads-outside new file mode 100644 index 0000000..f6b2d87 --- /dev/null +++ b/cmd/mesh-compose/reads-outside @@ -0,0 +1 @@ +These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935). diff --git a/cmd/mesh-host/engineseat.go b/cmd/mesh-host/engineseat.go index 6cba6a6..443f0fc 100644 --- a/cmd/mesh-host/engineseat.go +++ b/cmd/mesh-host/engineseat.go @@ -3,6 +3,7 @@ package main import ( "bufio" "context" + "encoding/json" "errors" "fmt" "os" @@ -15,6 +16,7 @@ import ( stdio "git.novox.be/novox/mesh-sdk/go" meshasker "git.novox.be/novox/mesh-sdk/go/asker" "git.novox.be/novox/mesh-sdk/go/asks" + "git.novox.be/novox/mesh-sdk/go/delivery" "github.com/novox/mesh-host/internal/accounts" "github.com/novox/mesh-host/internal/apply" "github.com/novox/mesh-host/internal/link" @@ -359,3 +361,31 @@ func packagesFrom(node string, session *link.Session, composer *atomic.Pointer[s return v, nil } } + +// composeCandidateFrom answers the verb compose-candidate (novox/hq ADR 0593 §7): the check's candidate composed on this +// node in shadow by the run's shadow, applying and writing nothing, within delivery.ComposeAnswerBound. The answer is +// the seat's shape (delivery.ComposeCandidateAnswer), checked as it is encoded; a node whose shadow does not run yet +// answers unready, which the checker takes as no answer. +func composeCandidateFrom(node string, shadow *atomic.Pointer[selfcompose.Shadow]) func(candidate string, input bool) (any, error) { + return func(candidate string, input bool) (any, error) { + ask := delivery.ComposeCandidateAsk{Candidate: candidate, Input: input} + if _, err := delivery.EncodeComposeCandidateAsk(ask); err != nil { + return nil, err + } + var a delivery.ComposeCandidateAnswer + if s := shadow.Load(); s == nil { + // Unready, never an error the checker reads words of: its agent is starting (ADR 0593 §9). + a = delivery.ComposeCandidateAnswer{Node: node, Candidate: candidate, Verdict: delivery.ComposeCandidateUnready, + Refused: "this node's shadow does not run now (its node agent is starting), so it composes no candidate"} + } else { + ctx, cancel := context.WithTimeout(context.Background(), delivery.ComposeAnswerBound) + defer cancel() + a = s.ComposeCandidate(ctx, ask) + } + raw, err := delivery.EncodeComposeCandidateAnswer(a) + if err != nil { + return nil, fmt.Errorf("this node's answer to %s does not fit the seat's shape: %w", candidate, err) + } + return json.RawMessage(raw), nil + } +} diff --git a/cmd/mesh-host/main.go b/cmd/mesh-host/main.go index 3045eb9..b352d48 100644 --- a/cmd/mesh-host/main.go +++ b/cmd/mesh-host/main.go @@ -1001,6 +1001,7 @@ func runLink(ctx context.Context, opts options) error { queue.Acts.Composition = compositionFrom(current) queue.Acts.Compose = composeFrom(current, &composerNow) queue.Acts.Packages = packagesFrom(mine.Node, queue, &composerNow) + queue.Acts.ComposeCandidate = composeCandidateFrom(mine.Node, &shadowNow) setRunsHere(runsFromAgent(current)) factsOfNow := func(ctx context.Context, ag *agent.Agent) { sayFacts(ctx, opts, mine.Membership.Signer, queue, say, ag) diff --git a/cmd/mesh-host/nodeagent.go b/cmd/mesh-host/nodeagent.go index c9a140a..8001ee3 100644 --- a/cmd/mesh-host/nodeagent.go +++ b/cmd/mesh-host/nodeagent.go @@ -14,12 +14,12 @@ import ( "sync/atomic" "time" - "git.novox.be/novox/mesh-sdk/go/compose/frombus" "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/delivery" "git.novox.be/novox/mesh-sdk/go/overlay" "github.com/novox/mesh-host/internal/agent" "github.com/novox/mesh-host/internal/apply" + "github.com/novox/mesh-host/internal/compose/frombus" "github.com/novox/mesh-host/internal/link" "github.com/novox/mesh-host/internal/liveness" "github.com/novox/mesh-host/internal/selfcompose" @@ -623,6 +623,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus session.NoteCandidates(ctx, delivery.WithValuesDiffer(answers)) }) go shadow.Keep(run, link.Backoff) // this run's alone: a switch begins a run, and its shadow, again + shadowNow.Store(shadow) // what the verb compose-candidate composes a check's candidate with // Its own composer, always (issue 1126, A7): a composition made elsewhere (the controller's, in the node's // composition bucket) is never applied. While the seat's placement holds no key for this node it composes // nothing and keeps running what it runs; a placement it cannot watch stops the agent, said, and it begins again. @@ -640,6 +641,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus cancel() current.set(nil) composerNow.Store(nil) + shadowNow.Store(nil) if ctx.Err() != nil { return } @@ -766,6 +768,10 @@ var machinesNow atomic.Pointer[selfcompose.Composer] // compositions or no run is going: what the verb compose asks a pass of (issue 774). var composerNow atomic.Pointer[selfcompose.Composer] +// shadowNow is the shadow of the run now, which answers placement candidates and the verb compose-candidate; nil +// between runs. +var shadowNow atomic.Pointer[selfcompose.Shadow] + // keptRetired says the kept declaration is read no more: every module the seat's placement puts on this node has a have of // its own (placedAllHave), so what runs is the agent's alone (ADR 0550 §2, design 47's L3 s9). var keptRetired atomic.Bool @@ -968,6 +974,9 @@ func selfComposer(session *link.Session, node string, hooks composerHooks, say f // And the packages its compositions declare in its own key of the state packages, which pacman reads (novox/hq ADR // 0565 §6). env.Packages = link.EnginePackages{Session: session} + // And every composition it makes in its composition state composed-, a record the provisioner, the assigner + // and the view read and nothing applies (novox/hq ADR 0593 §6). + env.ComposedState = link.EngineComposed{Session: session, Node: node} return selfcompose.New(env) } diff --git a/cmd/mesh-host/nodeagent_integration_test.go b/cmd/mesh-host/nodeagent_integration_test.go index a00bef5..68d8b5e 100644 --- a/cmd/mesh-host/nodeagent_integration_test.go +++ b/cmd/mesh-host/nodeagent_integration_test.go @@ -19,6 +19,7 @@ import ( "github.com/nats-io/nats.go" "github.com/novox/mesh-host/internal/agent" "github.com/novox/mesh-host/internal/link" + "github.com/novox/mesh-host/internal/nodegrant" "github.com/novox/mesh-host/internal/selfcompose" ) @@ -32,7 +33,7 @@ type itBed struct { t *testing.T conn *nats.Conn js nats.JetStreamContext - // nodeConn and nodeJS are the node's own, under its real grant (buscompose.PermissionsFor, KindNode): the agent runs + // nodeConn and nodeJS are the node's own, under its real grant (the grants part's, nodegrant.For): the agent runs // on them, so a watch or a read the grant does not allow fails here as it fails on the bus (issue 1126, 2026-10-27 // 09:21: a watch of every have key stopped every node agent). conn is the test's, unrestricted, for setting up. nodeConn *nats.Conn @@ -51,13 +52,9 @@ type itBed struct { } // aServer is an embedded bus with JetStream and two users: the test's, unrestricted, and the node's, granted exactly what -// buscompose.PermissionsFor gives a node (KindNode); it answers a connection of each. -func aServer(t *testing.T, principal buscompose.Principal, refused func(error)) (admin, node *nats.Conn) { +// the grants part gives a node (nodegrant.For); it answers a connection of each. +func aServer(t *testing.T, perms nodegrant.Permissions, refused func(error)) (admin, node *nats.Conn) { t.Helper() - perms, err := buscompose.PermissionsFor(principal) - if err != nil { - t.Fatal(err) - } granted := &server.Permissions{Publish: &server.SubjectPermission{Allow: perms.Publish, Deny: perms.PublishDeny}, Subscribe: &server.SubjectPermission{Allow: perms.Subscribe}} if perms.AllowResponses { @@ -88,11 +85,11 @@ func aServer(t *testing.T, principal buscompose.Principal, refused func(error)) func newItBed(t *testing.T) *itBed { t.Helper() - return newItBedAs(t, buscompose.Principal{Kind: buscompose.KindNode, Node: itNode}) + return newItBedAs(t, nodegrant.For(itNode)) } -// newItBedAs is newItBed with the node's connection under the grant of principal, a node's (KindNode). -func newItBedAs(t *testing.T, principal buscompose.Principal) *itBed { +// newItBedAs is newItBed with the node's connection under the grant perms, a node's. +func newItBedAs(t *testing.T, perms nodegrant.Permissions) *itBed { t.Helper() // Not skipped off Arch: a package manager that answers is put on PATH, so the apply of files runs on any build node. bin := t.TempDir() @@ -105,7 +102,7 @@ func newItBedAs(t *testing.T, principal buscompose.Principal) *itBed { t.Cleanup(func() { builtFor = was }) b := &itBed{t: t, dir: t.TempDir()} - b.conn, b.nodeConn = aServer(t, principal, func(err error) { + b.conn, b.nodeConn = aServer(t, perms, func(err error) { b.mu.Lock() b.refusals = append(b.refusals, err.Error()) b.mu.Unlock() @@ -166,28 +163,6 @@ func fpIt(c string) string { return delivery.SourcePrefix2 + strings.Repeat(c, 6 func fpDigest(c string) string { return "sha256:" + strings.Repeat(c, 64) } -// compose writes a module's signed composition of one file into the node's bucket and returns its digest. -func (b *itBed) compose(module, fp, content string, data bool, signer ed25519.PrivateKey) string { - b.t.Helper() - path := filepath.Join(b.dir, module+".conf") - res, _ := json.Marshal(map[string]any{"id": module + ".conf", "type": "file", "path": path, "content": content, "module": module}) - c := delivery.Composed{Node: itNode, Module: module, FP: fp, Data: data, Resources: []json.RawMessage{res}} - signed, err := delivery.SignComposed(c, signer) - if err != nil { - b.t.Fatal(err) - } - raw, err := delivery.EncodeComposed(signed) - if err != nil { - b.t.Fatal(err) - } - dig, _ := delivery.ComposedDigestOf(signed) - key, _ := delivery.ComposedKey(itNode, module, fp) - if _, err := b.composed.Put(key, raw); err != nil { - b.t.Fatal(err) - } - return dig -} - func (b *itBed) want(module, fp, digest, backup string) { b.t.Helper() raw, err := delivery.EncodeWant(delivery.Want{FP: fp, Composed: digest, Backup: backup}) @@ -233,24 +208,10 @@ func within(t *testing.T, d time.Duration, what string, ok func() bool) { // in the node's own key, and nothing is applied for it (ADR 0559 §4, subtask 10 of the milestone). // // And (issue 1126, ADR 0560) it reads, through its own session under the grant the grants part composes for it -// (buscompose.Users, so it carries ServedReads), the build of a placed module that declares one and the one config value +// (nodegrant.For with the served key, as the grants part grants it), the build of a placed module that declares one and the one config value // a provider on another node serves from (a mail domain); under a grant without that served key the bus refuses the // read and the node composes nothing. func TestNatsANodeThePlacementNamesComposesAndAppliesItsOwn(t *testing.T) { - users, err := buscompose.Users(buscompose.Records{Nodes: []string{itNode, "it2"}, - Assigned: map[string][]buscompose.Declared{"it2": {{Module: "mailer", Served: []string{"domain"}}}}}) - if err != nil { - t.Fatal(err) - } - var principal buscompose.Principal - for _, u := range users { - if u.Kind == buscompose.KindNode && u.Node == itNode { - principal = u - } - } - if len(principal.ServedReads) != 1 { - t.Fatalf("the grants part's principal of %s reads %v of the served keys", itNode, principal.ServedReads) - } t.Run("without the served key the bus refuses the read", func(t *testing.T) { b := newItBed(t) stop, _ := h8(t, b, false) @@ -268,7 +229,7 @@ func TestNatsANodeThePlacementNamesComposesAndAppliesItsOwn(t *testing.T) { t.Errorf("composed and applied under a grant without the served key: %q", b.file("web")) } }) - b := newItBedAs(t, principal) + b := newItBedAs(t, nodegrant.For(itNode, "it2.mailer.domain")) stop, st := h8(t, b, true) defer stop() placement, resolved, candidates := st.placement, st.resolved, st.candidates @@ -524,10 +485,22 @@ func h8rest(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, hkey str h := b.have("web") return h.FP == fp && h.Composed != "" && h.Phase == delivery.PhaseRunning }) - if keys, _ := b.composed.Keys(); len(keys) != 0 { - t.Errorf("the controller's compositions hold %v; the node composed its own", keys) - } + // ADR 0593 §6: the node publishes what it composed in its composition state, under its own grant: web at the build + // its want names, with the digest its have names (the plan left out of it), and the node's own. first := b.have("web").Composed + webKey, _ := delivery.ComposedKey(itNode, "web", fp) + within(t, 5*time.Second, "the node did not publish web's composition in its composition state", func() bool { + e, err := b.composed.Get(webKey) + if err != nil { + return false + } + c, err := delivery.DecodeComposed(e.Value()) + if err != nil { + return false + } + d, err := delivery.ComposedDigestOf(c) + return err == nil && d == first + }) if _, err := resolved.Put(itNode+".web.greeting", []byte(`{"value":"salut","source":"module"}`)); err != nil { t.Fatal(err) } @@ -568,4 +541,78 @@ func h8rest(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, hkey str if h := b.have("web"); h.Composed != second || b.file("web") != "greeting=salut\n" { t.Errorf("a candidate moved what runs: %+v, %q", h, b.file("web")) } + h8candidate(t, b, fp, second) +} + +// h8candidate is ADR 0593 §7: a check's candidate is composed on the node through its seat's verb compose-candidate, +// under its own grant: the indexer's candidate changing web's manifest is answered composed, web changed at the +// candidate's build, within the answer bound; nothing is applied and the composition state does not move. +func h8candidate(t *testing.T, b *itBed, fp, running string) { + t.Helper() + kvOf := func(bucket string) nats.KeyValue { + kv, err := b.js.CreateKeyValue(&nats.KeyValueConfig{Bucket: bucket, History: 5}) + if err != nil { + t.Fatal(err) + } + return kv + } + cands, _ := kvOf(delivery.CandidatesBucket), kvOf(delivery.VerdictsBucket) + manifests, err := b.js.KeyValue(delivery.ManifestsBucket) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(b.dir, "web.conf") + man := `{"module":"web","version":"2","config":{"greeting":{"type":"string","description":"what the file says","default":"hello"}},` + + `"resources":[{"id":"conf","type":"file","path":"` + path + `","mode":"0640","content":"greeting=${config:greeting}\n"},` + + `{"id":"tools","type":"package","package":"web-tools"}]}` + digest := delivery.ManifestDigest([]byte(man)) + mraw, _ := delivery.EncodeManifest(delivery.Manifest{Module: "web", JSON: man}) + mkey, _ := delivery.ManifestsKey(digest) + if _, err := manifests.Put(mkey, mraw); err != nil { + t.Fatal(err) + } + head := delivery.Head{Repository: "novox/web", Number: 3, Head: strings.Repeat("3", 40)} + heads, _ := delivery.HeadsHash([]delivery.Head{head}) + pulls := []delivery.CandidatePull{{Head: head, Modules: []string{"web"}, + Fps: map[string]delivery.CandidateModule{"web": {FP2: fpIt("c"), Manifest: digest}}}} + craw, err := delivery.EncodeCandidate(delivery.Candidate{Group: "novox/web#3", HeadsHash: heads, Pulls: pulls, + FPSet: delivery.FingerprintSet(pulls)}) + if err != nil { + t.Fatal(err) + } + key, _ := delivery.CompositionKey("novox/web#3", heads) + if _, err := cands.Put(key, craw); err != nil { + t.Fatal(err) + } + before, _ := b.composed.Status() + got, err := composeCandidateFrom(itNode, &shadowNow)(key, false) + if err != nil { + t.Fatal(err) + } + raw, _ := got.(json.RawMessage) + a, err := delivery.DecodeComposeCandidateAnswer(raw) + if err != nil { + t.Fatalf("the answer %s: %v", raw, err) + } + if a.Verdict != delivery.ComposeCandidateComposed || len(a.Modules) != 1 || a.Modules[0].Module != "web" || + a.Modules[0].FP != fpIt("c") || !a.Modules[0].Changed || a.Took > delivery.ComposeAnswerBound.Milliseconds() { + t.Errorf("the node's answer to a candidate changing web: %+v", a) + } + after, _ := b.composed.Status() + if h := b.have("web"); h.Composed != running || b.file("web") != "greeting=salut\n" || before == nil || after == nil || + after.Values() != before.Values() { + t.Errorf("composing a candidate moved what runs or the composition state: %+v, %q", h, b.file("web")) + } + if _, err := b.composed.Get(mustComposedKey(t, "web", fpIt("c"))); err == nil { + t.Error("the candidate's composition was written into the composition state") + } +} + +func mustComposedKey(t *testing.T, module, fp string) string { + t.Helper() + key, err := delivery.ComposedKey(itNode, module, fp) + if err != nil { + t.Fatal(err) + } + return key } diff --git a/cmd/mesh-host/nodeclare_test.go b/cmd/mesh-host/nodeclare_test.go index 430fec3..f295ca5 100644 --- a/cmd/mesh-host/nodeclare_test.go +++ b/cmd/mesh-host/nodeclare_test.go @@ -22,6 +22,11 @@ func TestNoPackageHearsOrOrdersAWholeDeclaration(t *testing.T) { if err != nil { return err } + // The composition moved in from mesh-sdk (novox/hq ADR 0593 §7) has a node's input field of the same name, + // ReadsModules, which says the node reads a module per resource; it is no reconciling of a declaration. + if d.IsDir() && path == filepath.Join(root, "internal", "compose") { + return filepath.SkipDir + } if d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "nodeclare_test.go") { return nil } diff --git a/go.mod b/go.mod index 119dc8a..7ec0e0a 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/novox/mesh-host go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 diff --git a/go.sum b/go.sum index ebd4e0f..43cc4ec 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 5d50426..c392947 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -15,7 +15,6 @@ package agent import ( "bytes" "context" - "crypto/ed25519" "encoding/json" "fmt" "sort" @@ -26,6 +25,7 @@ import ( "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/declaration" "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/judge" ) // Update is one change of a key in a watched bucket. Caught marks the end of the keys that were there when the watch @@ -119,10 +119,6 @@ type Env struct { // whose have names a composition the bucket keeps runs from that composition, never from the kept declaration // (issue 924): the kept declaration is older than every composition applied since the switch. KeptFilled func() map[string][]string - // Trusted is the public keys a composition may be signed by: the controller's and the operator's (ADR 0539 §3). A - // composition unsigned, signed by another key, or changed after it was signed is refused and nothing of it is - // applied. Nil trusts no key, so nothing is applied. - Trusted func() []ed25519.PublicKey // Recorded says the engine holds a record of every resource of the composition, so it was applied and not removed // since; nil says yes. A composition the kept declaration or a have says runs is applied again when it says no. Recorded func(ctx context.Context, c delivery.Composed) bool @@ -685,26 +681,14 @@ func (a *Agent) takeComposition(u Update) string { } now := compNow{Raw: u.Value, Rev: u.Revision} c, err := delivery.DecodeComposed(u.Value) - if err == nil && a.env.SelfComposed { - // The node composed it itself: nothing crossed the bus, so nothing is signed (ADR 0547 §2). + if err == nil { + // The node composed it itself: it is applied as composed, never verified against a signer, since no composition + // read from the bus is applied (novox/hq ADR 0547 §2, ADR 0593 §6). now.C = c now.Dig, err = delivery.ComposedDigestOf(c) if err != nil { now.C = delivery.Composed{} } - } else if err == nil { - var trusted []ed25519.PublicKey - if a.env.Trusted != nil { - trusted = a.env.Trusted() - } - // The signature before anything of the composition is used: its digest is what the want names. - now.C = c - now.Dig, err = delivery.VerifyComposed(c, trusted) - if err != nil { - now.C = delivery.Composed{} - a.sayOnce("verify:"+u.Key, fmt.Sprintf("the composition %s is refused and nothing of it is applied: %v", u.Key, err)) - err = fmt.Errorf("refused: %w", err) - } } else { a.sayOnce("decode:"+u.Key, fmt.Sprintf("the composition %s cannot be read, and nothing of it is applied: %v", u.Key, err)) } @@ -1646,14 +1630,14 @@ func (a *Agent) waitsForYes(ctx context.Context, module string, w wantNow, comp prior = true } } - var j delivery.Judged + var j judge.Judged switch { case known: - j = delivery.JudgeMove(&from, comp.C) + j = judge.JudgeMove(&from, comp.C) case prior: - j = delivery.Judged{Judgement: delivery.JudgedCannot, Words: "what " + module + " runs on this node cannot be read"} + j = judge.Judged{Judgement: delivery.JudgedCannot, Words: "what " + module + " runs on this node cannot be read"} default: - j = delivery.JudgeMove(nil, comp.C) + j = judge.JudgeMove(nil, comp.C) } switch j.Judgement { case delivery.JudgedRecreates, delivery.JudgedMovesData, delivery.JudgedCannot: diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index e6c128a..f2b3306 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -2,7 +2,6 @@ package agent import ( "context" - "crypto/ed25519" "encoding/json" "fmt" "maps" @@ -18,19 +17,6 @@ import ( const node = "n1" -// The keys of the tests: the controller's and the operator's are trusted, a stranger's is not. -var controllerPub, controllerKey = newKey() -var operatorPub, operatorKey = newKey() -var _, strangerKey = newKey() - -func newKey() (ed25519.PublicKey, ed25519.PrivateKey) { - pub, key, err := ed25519.GenerateKey(nil) - if err != nil { - panic(err) - } - return pub, key -} - func fpOf(c string) string { return delivery.SourcePrefix2 + strings.Repeat(c, 64) } // A watcher the test writes to; it says Caught once the keys written before the watch began are out. @@ -183,8 +169,7 @@ func newBed(t *testing.T) *bed { b.settled = append(b.settled, m) b.settledRan = append(b.settledRan, ran) }, - Kept: func() map[string][]json.RawMessage { return b.kept }, - Trusted: func() []ed25519.PublicKey { return []ed25519.PublicKey{controllerPub, operatorPub} }, + Kept: func() map[string][]json.RawMessage { return b.kept }, Say: func(s string) { t.Log(s) b.mu.Lock() @@ -223,21 +208,17 @@ func composition(t *testing.T, module, fp string, data bool, rs ...json.RawMessa if err != nil { t.Fatal(err) } - return signedBy(t, c, key, controllerKey) + return encodedAs(t, c, key) } -// signedBy is c signed with signer, as the writer's code signs it: its key, its bytes and its digest. -func signedBy(t *testing.T, c delivery.Composed, key string, signer ed25519.PrivateKey) (string, []byte, string) { +// encodedAs is c as the node's composer hands it: unsigned (ADR 0593 §6), its key, its bytes and its digest. +func encodedAs(t *testing.T, c delivery.Composed, key string) (string, []byte, string) { t.Helper() - signed, err := delivery.SignComposed(c, signer) + raw, err := delivery.EncodeComposed(c) if err != nil { t.Fatal(err) } - raw, err := delivery.EncodeComposed(signed) - if err != nil { - t.Fatal(err) - } - digest, err := delivery.ComposedDigestOf(signed) + digest, err := delivery.ComposedDigestOf(c) if err != nil { t.Fatal(err) } @@ -735,76 +716,6 @@ func TestTheNodesOwnCompositionComesBeforeAnyModules(t *testing.T) { } } -// R6-A AC9: a composition signed by either trusted key is applied; one signed by another key, one changed after it was -// signed and one unsigned are refused and nothing of them is applied (ADR 0539 §3). -func TestOnlyACompositionSignedByATrustedKeyIsApplied(t *testing.T) { - t.Run("with no trusted key", testWithNoTrustedKeyNothingIsApplied) - b := newBed(t) - stop := b.run() - defer stop() - - plain := func(module, fp, content string) delivery.Composed { - return delivery.Composed{Node: node, Module: module, FP: fp, Resources: []json.RawMessage{file(module, "x", content)}} - } - put := func(c delivery.Composed, signer ed25519.PrivateKey, tamper func([]byte) []byte) { - key, _ := delivery.ComposedKey(node, c.Module, c.FP) - var raw []byte - var dig string - if signer == nil { - raw, _ = delivery.EncodeComposed(c) - dig, _ = delivery.ComposedDigestOf(c) - } else { - _, raw, dig = signedBy(t, c, key, signer) - } - if tamper != nil { - raw = tamper(raw) - } - b.comps.put(key, raw) - wk, wraw := wantFor(t, c.Module, c.FP, dig, "") - b.wants.put(wk, wraw) - } - - // Refused: signed by a stranger, changed after signing, unsigned. - put(plain("stranger", fpOf("1"), "x"), strangerKey, nil) - put(plain("changed", fpOf("2"), "before"), controllerKey, func(raw []byte) []byte { - return []byte(strings.Replace(string(raw), "before", "after!", 1)) - }) - put(plain("unsigned", fpOf("3"), "x"), nil, nil) - time.Sleep(200 * time.Millisecond) - if got := b.appliedList(); len(got) != 0 { - t.Fatalf("a composition that is not signed by a trusted key was applied: %v", got) - } - - // Applied: signed by the controller, signed by the operator. - put(plain("bycontroller", fpOf("4"), "x"), controllerKey, nil) - put(plain("byoperator", fpOf("5"), "x"), operatorKey, nil) - eventually(t, "the trusted signers' compositions were not applied", func() bool { return len(b.appliedList()) == 2 }) - got := b.appliedList() - if !(got[0] == "bycontroller" && got[1] == "byoperator") && !(got[0] == "byoperator" && got[1] == "bycontroller") { - t.Fatalf("applied %v", got) - } - if h := b.haves.last("byoperator"); h.Phase != string(delivery.PhaseRunning) || h.FP != fpOf("5") { - t.Fatalf("the operator's module has no running have: %+v", h) - } -} - -// With no key trusted, nothing is applied: a node that does not know its signers fails closed. -func testWithNoTrustedKeyNothingIsApplied(t *testing.T) { - b := newBed(t) - b.env.Trusted = nil - stop := b.run() - defer stop() - fp := fpOf("a") - ck, craw, dig := composition(t, "web", fp, false, file("web", "x", "1")) - b.comps.put(ck, craw) - wk, wraw := wantFor(t, "web", fp, dig, "") - b.wants.put(wk, wraw) - time.Sleep(200 * time.Millisecond) - if got := b.appliedList(); len(got) != 0 { - t.Fatalf("applied with no trusted key: %v", got) - } -} - // What a module's apply must not take is every other module that runs, those the agent holds and those still running from // the kept declaration (ADR 0429 §4): the resources of the kept declaration reach Apply as the others. func TestApplyingOneModuleHoldsOutTheModulesStillRunningFromTheKeptDeclaration(t *testing.T) { @@ -860,7 +771,7 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) { // and is what runs from before the first composition is watched; an apply keeps, a removal forgets. t.Run("what this engine applied is what runs before the node composes its own", func(t *testing.T) { b := newBed(t) - b.env.SelfComposed, b.env.Trusted, b.kept = true, nil, nil + b.env.SelfComposed, b.kept = true, nil store := &memApplied{kept: map[string][]byte{}} b.env.Applied = store // A folded module's resource in the node's own composition: its id names the module, as the controller folds it. @@ -930,7 +841,7 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) { h.Write(context.Background(), Have{Module: NodeModule, Composed: "sha256:" + strings.Repeat("c", 64)}) firstPass := func(kept map[string][]json.RawMessage) []delivery.Composed { b := newBed(t) - b.env.SelfComposed, b.env.Trusted, b.kept, b.env.Applied, b.env.Haves = true, nil, kept, store, h + b.env.SelfComposed, b.kept, b.env.Applied, b.env.Haves = true, kept, store, h firstCh := make(chan []delivery.Composed, 1) comps := b.comps var ag *Agent @@ -1155,8 +1066,7 @@ func testARemovedModulesCompositionHoldsNothingBack(t *testing.T) { } } -// The node's own composition is not applied while a module's composition in the node's bucket cannot be read or is not -// signed by a trusted key: what that one carries may be what left the node's own, and the node's own apply would remove +// The node's own composition is not applied while a module's composition in the node's bucket cannot be read: what that one carries may be what left the node's own, and the node's own apply would remove // it (issue 1945, the review's gap 2). It is said, naming the composition, and applied once that one reads. func TestTheNodesOwnWaitsWhileAModulesCompositionCannotBeRead(t *testing.T) { b := newBed(t) @@ -1284,11 +1194,11 @@ func TestWhatRunsBeforeTheAgentTakesAModuleNamesItsFilledPaths(t *testing.T) { ck, _ := delivery.ComposedKey(node, "power", fp) applied := delivery.Composed{Node: node, Module: "power", FP: fp, Filled: []string{old, added}, Data: true, Resources: []json.RawMessage{filledFile("settings"), filledFile("bound")}} - _, appliedRaw, appliedDig := signedBy(t, applied, ck, controllerKey) + _, appliedRaw, appliedDig := encodedAs(t, applied, ck) newer := applied newer.Resources = append([]json.RawMessage{}, applied.Resources...) newer.Resources = append(newer.Resources, file("power", "later", "1")) - _, newerRaw, _ := signedBy(t, newer, ck, controllerKey) + _, newerRaw, _ := encodedAs(t, newer, ck) for _, tc := range []struct { name string // have is the module's have; bucket the value its composition key holds now, history the revisions kept. @@ -1371,7 +1281,7 @@ func TestWhatRunsBeforeTheAgentTakesAModuleNamesItsFilledPaths(t *testing.T) { // the yes for that composition, and is then applied. func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(t *testing.T) { b := newBed(t) - b.env.SelfComposed, b.env.Trusted = true, nil + b.env.SelfComposed = true b.env.Policy = func(_ context.Context, m string) string { if m == "db" || m == "rec" { return catalogue.PolicyRecord @@ -1560,6 +1470,19 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes( if _, err := b.ag.Composition(context.Background(), "db", "sha256:"+strings.Repeat("0", 64), false); err == nil { t.Error("a digest no revision has was answered") } + // novox/hq ADR 0593 §6: the engine publishes each composition with its plan in its composition state; the verb reads + // that revision by the digest its have and want name, its resources' (the plan left out of the digest). + planned, _ := delivery.DecodeComposed(draw) + planned.Plan = &catalogue.Plan{} + praw, err := delivery.EncodeComposed(planned) + if err != nil { + t.Fatal(err) + } + keep(dk, praw) + if view, err := b.ag.Composition(context.Background(), "db", ddig, false); err != nil || view.Composition.Digest != ddig || + len(view.Revisions) != 3 || view.Revisions[2].Digest != ddig { + t.Errorf("db's published revision with its plan, asked by its digest, reads %+v (%v)", view, err) + } b.comps.put(dk, draw) eventually(t, "db's have stays waiting-yes after its composition went back to what runs", func() bool { h := b.haves.last("db") diff --git a/internal/agent/composition.go b/internal/agent/composition.go index 71ea0e7..938c2aa 100644 --- a/internal/agent/composition.go +++ b/internal/agent/composition.go @@ -2,12 +2,12 @@ package agent import ( "context" - "crypto/ed25519" "errors" "fmt" "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/judge" ) // The node-engine seat's read verb composition (issue 774): one module's composition beside what runs of it, so why a @@ -31,7 +31,7 @@ type CompositionView struct { // Judged is the move from what runs to the composition read, as the agent judges it before an apply. Judged JudgedView `json:"judged"` // Diff is every resource that differs from what runs, when asked. - Diff []delivery.ResourceChange `json:"diff,omitempty"` + Diff []judge.ResourceChange `json:"diff,omitempty"` } // WantView is a want as the verb shows it. @@ -171,7 +171,7 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo } view.Runs = rv } - j := delivery.JudgeMove(running, read.C) + j := judge.JudgeMove(running, read.C) view.Judged = JudgedView{Judgement: string(j.Judgement), Words: j.Words} if a.env.SelfComposed && a.env.Policy != nil && module != NodeModule && (!w.Present || w.Want.Yes != read.Dig) { switch j.Judgement { @@ -180,7 +180,7 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo } } if diff { - view.Diff = delivery.ChangedResources(running, read.C) + view.Diff = judge.ChangedResources(running, read.C) } return view, nil } @@ -193,8 +193,8 @@ func (a *Agent) compositionKey(module, fp string) (string, error) { return delivery.ComposedKey(a.env.Node, module, fp) } -// revisionsOf is every revision of a composition key the bucket keeps, oldest first, each with its digest as this node -// trusts it (its own unsigned with SelfComposed, else signed by a trusted key), or why it is refused. Nil Revisions +// revisionsOf is every revision of a composition key the state keeps, oldest first, each with its digest (unsigned, ADR +// 0593 §6), or why it is refused. Nil Revisions // reads none. func (a *Agent) revisionsOf(ctx context.Context, key string) ([]compNow, error) { if a.env.Revisions == nil { @@ -204,10 +204,6 @@ func (a *Agent) revisionsOf(ctx context.Context, key string) ([]compNow, error) if err != nil { return nil, err } - var trusted []ed25519.PublicKey - if a.env.Trusted != nil { - trusted = a.env.Trusted() - } var out []compNow for _, u := range revs { if u.Deleted || u.Key != key { @@ -218,12 +214,7 @@ func (a *Agent) revisionsOf(ctx context.Context, key string) ([]compNow, error) out = append(out, compNow{Raw: u.Value, Rev: u.Revision, Err: err}) continue } - var dig string - if a.env.SelfComposed { - dig, err = delivery.ComposedDigestOf(c) // the node's own: unsigned (ADR 0547 §2) - } else { - dig, err = delivery.VerifyComposed(c, trusted) - } + dig, err := delivery.ComposedDigestOf(c) // unsigned: a record, never verified (ADR 0593 §6) out = append(out, compNow{Raw: u.Value, C: c, Dig: dig, Rev: u.Revision, Err: err}) } return out, nil diff --git a/internal/beside/beside.go b/internal/beside/beside.go new file mode 100644 index 0000000..d31ec1d --- /dev/null +++ b/internal/beside/beside.go @@ -0,0 +1,134 @@ +// Package beside is where a test finds another repository of the mesh it reads: the catalogue's manifests, +// the node-engine's genesis template (novox/hq issue 432), the SDK's conformance fixtures (issue 449). +// +// Copied from mesh-controller internal/beside at commit 6c2a8d62 (R4b, issue 1379); the one change is that Dir +// lays out the captured copy of a repository once per process, not once per test (see laidOut). In mesh-sdk the +// catalogue's tests read mesh-catalog and mesh-host only; the captured copy holds just what they read. +// +// A test used to read the checkout beside this one, `../../../mesh-catalog`, so its verdict depended on +// whatever sat on the machine running it: a stale or dirty checkout failed it on a desktop, and where none +// was beside it skipped and said nothing. Now there are two inputs, both named, and no third: +// +// - In a merge check, the build seat clones each core repository beside the one checked (the catalogue +// at its main, the node-engine at the commit the mesh runs) and says where in MESH_CHECK_BESIDE. A +// test judges against those clones, so agreement with the other repository is checked where +// `mesh/repo-check` runs; a repository missing there fails the test, never skips it. Which clones a +// check gets is chosen from the inventory: mesh-catalog by the source of the `nats` module, mesh-host +// by the source of `mesh-host`, and mesh-sdk as the controller's sibling at the commit the controller's +// go.mod pins. Each repository's tests read these siblings, and the checker places exactly these +// (mesh-delivery's checker, besideRules, follows this list): mesh-controller's read mesh-catalog, +// mesh-host and mesh-sdk; mesh-host's read mesh-catalog, mesh-sdk and mesh-controller (its step-nine +// test reads the controller's own manifest, novox/hq issue 1295); mesh-sdk's read mesh-catalog and +// mesh-host; mesh-catalog's read mesh-sdk and mesh-controller; mesh-delivery's read mesh-sdk and +// mesh-catalog. In a mesh where either module has no source repository nothing is +// cloned, and these tests fail loudly with "not beside this check": a cause in the setup, not in the +// change. And in a delivery group that holds a mesh-catalog pull request, these tests read the +// catalogue's main, not the group's head. +// - Anywhere else, the test judges against the copy captured in this repository's testdata/beside, at the +// commit testdata/beside/CAPTURED names. Never against a developer's own checkout: to judge one, set +// MESH_CHECK_BESIDE to the directory holding it, as the check does. +// +// The captured manifests are named module.json.captured, and put back as module.json in a directory of +// the test's own: a module.json anywhere in this repository is a module of it to the forge's announcer and +// the planner, and a merge would build and register a hundred copies of the catalogue's. +package beside + +import ( + "io/fs" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "testing" +) + +// Env is where a merge check says the repositories cloned beside the one checked are (internal/builder's +// EnvBeside, repeated here so a test does not import the builder). +const Env = "MESH_CHECK_BESIDE" + +// CapturedSuffix is what a captured file's name carries that the repository's own does not. +const CapturedSuffix = ".captured" + +// Dir is the named repository a test reads — the clone beside a merge check, or the captured copy — and +// says which in the test's log. +func Dir(t testing.TB, repository string) string { + t.Helper() + if root := os.Getenv(Env); root != "" { + dir := filepath.Join(root, repository) + if _, err := os.Stat(dir); err != nil { + t.Fatalf("%s is not beside this check in %s=%s, so its agreement with this repository cannot be "+ + "judged here: %v", repository, Env, root, err) + } + t.Logf("judged against %s as cloned beside this check", dir) + return dir + } + from := filepath.Join(Captured(), repository) + if _, err := os.Stat(from); err != nil { + t.Fatalf("no captured copy of %s at %s: %v", repository, from, err) + } + dir, err := layOut(repository, from) + if err != nil { + t.Fatalf("the captured copy of %s could not be laid out: %v", repository, err) + } + t.Logf("judged against the copy of %s captured in testdata/beside (see CAPTURED); a merge check "+ + "judges it against the repository's own clone", repository) + return dir +} + +// laidOut is the captured copies already put back in place in this process, by repository. A copy is +// read-only to every test, so it is laid out once per process and not once per test: with a copy per test +// the catalogue's tests alone took 2.14 s, over mesh-sdk's unit bound of 2 s, and with one 0.81 s (measured +// 2026-10-20). The directories are in the temp directory, which the system clears; a process cannot remove +// them while another test may still read them. +var ( + laidOutMu sync.Mutex + laidOut = map[string]string{} +) + +func layOut(repository, from string) (string, error) { + laidOutMu.Lock() + defer laidOutMu.Unlock() + if dir, ok := laidOut[repository]; ok { + return dir, nil + } + root, err := os.MkdirTemp("", "beside-") + if err != nil { + return "", err + } + dir := filepath.Join(root, repository) + err = filepath.WalkDir(from, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(from, path) + into := filepath.Join(dir, strings.TrimSuffix(rel, CapturedSuffix)) + if d.IsDir() { + return os.MkdirAll(into, 0o755) + } + raw, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(into, raw, 0o644) + }) + if err != nil { + os.RemoveAll(root) + return "", err + } + laidOut[repository] = dir + return dir, nil +} + +// Catalogue is the catalogue's modules directory, as Dir finds the catalogue. +func Catalogue(t testing.TB) string { + t.Helper() + return filepath.Join(Dir(t, "mesh-catalog"), "modules") +} + +// Captured is this repository's testdata/beside, found from this file rather than from the working +// directory, so a test in any package reaches it. +func Captured() string { + _, file, _, _ := runtime.Caller(0) + return filepath.Join(filepath.Dir(file), "..", "..", "testdata", "beside") +} diff --git a/internal/beside/reads-outside b/internal/beside/reads-outside new file mode 100644 index 0000000..f6b2d87 --- /dev/null +++ b/internal/beside/reads-outside @@ -0,0 +1 @@ +These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935). diff --git a/internal/bootstrap/genesis_form_test.go b/internal/bootstrap/genesis_form_test.go index dda3852..b196c84 100644 --- a/internal/bootstrap/genesis_form_test.go +++ b/internal/bootstrap/genesis_form_test.go @@ -237,35 +237,3 @@ func TestAFileNothingOfTheControllerWritesIsStillRefused(t *testing.T) { t.Fatalf("a file nothing writes was accepted, or refused without its name: %v", err) } } - -// The controller's own manifest, as the mesh runs it, passes step 9 (issue 597): in a merge check the -// node-engine's check has the controller cloned beside it (MESH_CHECK_BESIDE), where it must be; else a -// checkout beside this one; else it skips aloud. -// -// The checker places the controller beside this repository (mesh-delivery's besideRules, since R4g, issue 1295), so a -// MESH_CHECK_BESIDE without a mesh-controller directory, or without its module.json, fails. Only a run with no -// MESH_CHECK_BESIDE and no checkout beside this one (a developer's clone alone) skips aloud. -func TestTheControllersOwnManifestPassesStepNine(t *testing.T) { - var manifest []byte - if root := os.Getenv("MESH_CHECK_BESIDE"); root != "" { - if info, err := os.Stat(filepath.Join(root, "mesh-controller")); err != nil || !info.IsDir() { - t.Fatalf("the controller is not beside this check in MESH_CHECK_BESIDE=%s: the checker places mesh-controller beside the node-engine's check (mesh-delivery besideRules, issue 1295), and this test judges the controller's own manifest", root) - } - raw, err := os.ReadFile(filepath.Join(root, "mesh-controller", "module.json")) - if err != nil { - t.Fatalf("the controller is not beside this check in MESH_CHECK_BESIDE=%s: %v", root, err) - } - manifest = raw - } else if raw, err := os.ReadFile(filepath.Join("..", "..", "..", "mesh-controller", "module.json")); err == nil { - manifest = raw - } else { - t.Skip("NOT JUDGED: no controller beside this checkout and no MESH_CHECK_BESIDE; the merge check judges it") - } - wanted, err := theStepNineSecretsOf(t, manifest) - if err != nil { - t.Fatalf("genesis's step 9 refuses the controller the mesh runs: %v", err) - } - if wanted["MESH_STORE_INVENTORY"] != "inventory" { - t.Errorf("step 9 delivers %v", wanted) - } -} diff --git a/internal/compose/composed.go b/internal/compose/composed.go new file mode 100644 index 0000000..fffd0b8 --- /dev/null +++ b/internal/compose/composed.go @@ -0,0 +1,560 @@ +package compose + +import ( + "encoding/json" + "errors" + "fmt" + "slices" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/declaration" + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/compose/resolution" + "github.com/novox/mesh-host/internal/overlay" +) + +// Compositions is one node's compositions (novox/hq ADR 0539 clauses 1 and 2): its declaration composed over in, as +// the controller composes it, split into each module's composition at the fingerprint fps gives it and the node's own +// (`.node`), by key (delivery.ComposedKey). Each resource says its module, whatever the node reported it reads. +// A module fps gives no fingerprint for is not composed and is named in skipped, so a caller composing one module +// gets that one; but for a module the node runs inside its own composition (Node.InOwn, and the +// private network's always), which fps names at no fingerprint of the new form: its resources go into the node's own, +// their module taken off and their ids still naming it, and its filled paths with them, as the controller's composed +// writer folds them (issue 1126). Each module's Data says whether its manifest declares data. The compositions are unsigned +// (ADR 0593 §6) and not yet by digest (Composed.ByDigest). +func Compositions(in Input, node string, fps map[string]string, mode Mode) (out map[string]delivery.Composed, skipped []string, err error) { + out, skipped, _, err = compositions(in, node, fps, mode, false) + return out, skipped, err +} + +// ErrStoodIn is a composition that holds a stand-in for a value the input does not carry (an own secret, a grant's +// sealed copy, a vault box, the certificate, the bus's user list) or a port it does not give: what a send would make, +// never what may be applied. +var ErrStoodIn = errors.New("compose: the composition stands in for what its input does not carry") + +// BusUsersStandIn is what a composition says it stood in for when its input carries no bus user list. +const BusUsersStandIn = "the bus's user list" + +// CompositionsApplied is Compositions for a node that applies what it composed itself (novox/hq ADR 0547 §2, issue +// 1126): a declaration that stands in for anything (Declared.StoodIn, Foreseen, ForeseenPorts) is refused with +// ErrStoodIn, naming each, so a stand-in is never applied as if it were the value; but for the bus's user list +// (BusUsersStandIn), which the bus node holds out of every apply of a composition it made itself. +func CompositionsApplied(in Input, node string, fps map[string]string) (map[string]delivery.Composed, []string, error) { + out, skipped, _, err := compositions(in, node, fps, Reading, true) + return out, skipped, err +} + +// CompositionsShadow is CompositionsApplied for a composition that is compared and never applied (a node's answer to a +// placement candidate, novox/hq ADR 0559 §4): composed in the Foreseeing mode, so an own secret never issued (a module +// the candidate adds) is a named stand-in rather than an error, and what it stands in for is answered (stood, sorted, +// the bus's user list left out) rather than refused, so the caller judges each stand-in: a value not yet issued for a +// module the candidate adds, or one missing for a module the node runs. A module whose every value is held composes +// as CompositionsApplied composes it: the modes differ only in an own secret never issued. +func CompositionsShadow(in Input, node string, fps map[string]string) (out map[string]delivery.Composed, skipped, stood []string, err error) { + return compositions(in, node, fps, Foreseeing, false) +} + +// CompositionsIn is the node's compositions in mode with what they stand in for answered (stood, sorted, the bus's user +// list left out) rather than refused: CompositionsShadow in Foreseeing, and in Reading what CompositionsApplied refuses +// before it is refused, so a caller that applies refuses each stand-in to the module it is for alone (issue 1126). +func CompositionsIn(in Input, node string, fps map[string]string, mode Mode) (out map[string]delivery.Composed, skipped, stood []string, err error) { + return compositions(in, node, fps, mode, false) +} + +func compositions(in Input, node string, fps map[string]string, mode Mode, applied bool) (out map[string]delivery.Composed, skipped, stood []string, err error) { + reading := in + reading.Nodes = append([]Node(nil), in.Nodes...) + found := false + inOwn := map[string]bool{} + held := map[string]bool{} + for i := range reading.Nodes { + if reading.Nodes[i].Name == node { + reading.Nodes[i].ReadsModules = true + found = true + for m, ok := range reading.Nodes[i].InOwn { + inOwn[m] = ok + } + for m, ok := range reading.Nodes[i].Held { + held[m] = ok + } + } + } + // The private network's module is built by nobody: no fingerprint of the new form ever names it. + inOwn[overlay.Name] = true + folds := func(module string) bool { + return module != "" && inOwn[module] && !strings.HasPrefix(fps[module], delivery.SourcePrefix2) + } + if !found { + return nil, nil, nil, fmt.Errorf("compose: the mesh has no node %q", node) + } + // A folded module built by a build of the old form (Node.RunsOwn) is left to the node: composed from what runs of it + // (Carried), so what it provides and requires still shapes the rest and what the others read of it (its published + // ports, the paths it owns) is what runs, and its resources are then the ones the node runs. + carried := map[string]bool{} + var runsOwn *delivery.Composed + for i := range reading.Nodes { + n := &reading.Nodes[i] + if n.Name != node || n.RunsOwn == nil { + continue + } + runsOwn = n.RunsOwn + candidates := map[string]bool{} + for m := range inOwn { + candidates[m] = true + } + for m := range held { + candidates[m] = true + } + for m := range candidates { + // A held module is carried as it runs wherever the node's own runs it, folded or leaving the fold for a + // composition of its own (its want new, its names still unset): its stub is never what the node's own applies, + // and what the node's own applies never drops what runs of it (issue 1126, review of the apply; issue 1945). + heldHere := held[m] && RunsAnyOf(runsOwn, m) + if !folds(m) && !heldHere { + continue + } + man, ok := n.Kept[m] + if !ok { + man, ok = reading.Catalogue[m] + } + if !ok || (!NamesBuild(man) && !held[m]) { + continue + } + carried[m] = true + kept := make(map[string]catalogue.Manifest, len(n.Kept)+1) + for k, v := range n.Kept { + kept[k] = v + } + kept[m] = Carried(man, m, runsOwn) + n.Kept = kept + } + } + // A module composed as what runs of it contributes to the runtime what the runtime serves of it as it runs + // (Node.RunsTools); one of its own account is served by a runtime of its own, from its manifest as before. + for i := range reading.Nodes { + n := &reading.Nodes[i] + if n.Name != node || n.RunsTools == nil { + continue + } + kept := make(map[string]catalogue.Manifest, len(n.Kept)) + for m, man := range n.Kept { + if man.Stubbed && man.RunsAs == "" { + man.Bundles = runningBundles(n.RunsTools[m], reading.Catalogue[m]) + } + kept[m] = man + } + n.Kept = kept + } + isCarried := func(module, id string) bool { + if module == "" { + module, _ = declaration.ModuleGuess(id) + } + return carried[module] + } + d, problems, err := ComposeIn(reading, node, mode) + if err != nil { + return nil, nil, nil, err + } + if len(problems) > 0 { + return nil, nil, nil, fmt.Errorf("compose: %s's declaration is refused by the node-engine's validator: %v", node, problems) + } + for _, x := range d.StoodIn { + // The bus's user list is the bus node's own to follow, written alone and never from a composition the node + // made itself (ADR 0547 §7, §8): its stand-in is held out of the apply, not applied. + if x != BusUsersStandIn { + stood = append(stood, x) + } + } + stood = append(stood, d.Foreseen...) + for _, p := range d.ForeseenPorts { + stood = append(stood, "port "+p) + } + sort.Strings(stood) + stood = slices.Compact(stood) // an own secret is both stood in for and foreseen + if applied && len(stood) > 0 { + return nil, nil, nil, fmt.Errorf("%w on %s: %s", ErrStoodIn, node, strings.Join(stood, ", ")) + } + body, err := d.Body() + if err != nil { + return nil, nil, nil, err + } + var envelope struct { + Resources []json.RawMessage `json:"resources"` + Adoption *declaration.Adoption `json:"adoption"` + Filled []string `json:"filled_files"` + Provisioner string `json:"provisioner_account"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, nil, nil, err + } + var kept []json.RawMessage + keptPaths := map[string]bool{} + foldedPaths := map[string]bool{} + left := map[string]bool{} + for _, raw := range envelope.Resources { + var head struct { + ID string `json:"id"` + Module string `json:"module"` + Type string `json:"type"` + Path string `json:"path"` + } + if err := json.Unmarshal(raw, &head); err != nil { + return nil, nil, nil, err + } + if isCarried(head.Module, head.ID) { + continue // what the stub composed of it: the node's own carries what runs of it instead + } + if folds(head.Module) { + // Into the node's own, in its place among the node's resources, its module taken off (mesh-controller's + // compositionsOf): the node's own composition is then the controller's, byte for byte. + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + return nil, nil, nil, err + } + delete(fields, "module") + if raw, err = json.Marshal(fields); err != nil { + return nil, nil, nil, err + } + if head.Type == string(declaration.TypeFile) { + foldedPaths[head.Path] = true + } + kept = append(kept, raw) + continue + } + if _, ok := fps[head.Module]; head.Module != "" && !ok { + left[head.Module] = true + continue + } + if head.Type == string(declaration.TypeFile) { + keptPaths[head.Path] = true + } + kept = append(kept, raw) + } + // What runs of each carried module, in the node's own composition, each after the resource it runs after. + if len(carried) > 0 { + var err error + if kept, foldedPaths, err = carry(kept, foldedPaths, runsOwn, isCarried); err != nil { + return nil, nil, nil, err + } + for _, p := range runsOwn.Filled { + if foldedPaths[p] && !slices.Contains(envelope.Filled, p) { + envelope.Filled = append(envelope.Filled, p) + } + } + } + // A filled file of a module's own composition goes with that module (SplitComposed); one of a folded module goes with + // the node's own, after them, in the declaration's order; one of a module left out is dropped. + var filled, ownFilled []string + for _, p := range envelope.Filled { + switch { + case foldedPaths[p]: + ownFilled = append(ownFilled, p) + case keptPaths[p]: + filled = append(filled, p) + } + } + out, err = delivery.SplitComposed(node, kept, fps, filled, + delivery.NodeOwn{Adoption: envelope.Adoption, Provisioner: envelope.Provisioner}) + if err != nil { + return nil, nil, nil, err + } + if len(ownFilled) > 0 { + own, err := delivery.ComposedNodeKey(node) + if err != nil { + return nil, nil, nil, err + } + c := out[own] + c.Filled = append(c.Filled, ownFilled...) + out[own] = c + } + for key, c := range out { + if held[c.Module] { + delete(out, key) // its stub's: the node keeps what it last applied of it (Node.Held) + continue + } + if m, ok := in.Catalogue[c.Module]; ok && c.Module != "" { + c.Data = len(m.DataItems()) > 0 + out[key] = c + } + } + for m := range left { + skipped = append(skipped, m) + } + sort.Strings(skipped) + return out, skipped, stood, nil +} + +// RunsAnyOf says runs (a composition as it runs) holds a resource of module, by its module or the module its id names. +func RunsAnyOf(runs *delivery.Composed, module string) bool { + if runs == nil { + return false + } + for _, raw := range runs.Resources { + var head struct { + ID string `json:"id"` + Module string `json:"module"` + } + if json.Unmarshal(raw, &head) != nil { + continue + } + owner := head.Module + if owner == "" { + owner, _ = declaration.ModuleGuess(head.ID) + } + if owner == module { + return true + } + } + return false +} + +// ToolsRunning is, by module, the tools bundles that run on the node (Node.RunsTools): each bundle archive a module's +// resource unpacks at its BundlePath, with what the runtime's own process loads of it and the words it hands it +// (catalogue.RuntimeToolModules, catalogue.RuntimeToolEnv, the mesh's own words left out). A bundle the runtime loads +// nothing of has no Loads: the runtime dropped it, and the composition reads its loads again from the module's build +// (issue 1126, review: shanks had already applied the drop of systemd-resolved). Nil when running holds no runtime process. +func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle { + runtimeID := catalogue.RuntimeModule + "." + resolution.RuntimeProcessID() + type resource struct { + ID string `json:"id"` + Module string `json:"module"` + Type string `json:"type"` + Path string `json:"path"` + Source string `json:"source"` + Digest string `json:"digest"` + Env map[string]string `json:"env"` + } + var env map[string]string + out := map[string][]catalogue.Bundle{} + for _, c := range running { + for _, raw := range c.Resources { + var r resource + if json.Unmarshal(raw, &r) != nil { + continue + } + owner := r.Module + if owner == "" { + owner, _ = declaration.ModuleGuess(r.ID) + } + switch { + case r.Type == "process" && r.ID == runtimeID && owner == catalogue.RuntimeModule: + env = r.Env + case r.Type == "archive" && owner != "" && strings.HasPrefix(r.ID, owner+"."+resolution.BundleID("")): + name := strings.TrimPrefix(r.ID, owner+"."+resolution.BundleID("")) + if r.Path == resolution.BundlePath(owner, name) { + out[owner] = append(out[owner], catalogue.Bundle{Name: name, Source: catalogue.Recorded(r.Source), Digest: r.Digest}) + } + } + } + } + if env == nil { + return nil + } + var words map[string]map[string]string + _ = json.Unmarshal([]byte(env[catalogue.RuntimeToolEnv]), &words) + for _, entry := range strings.Split(env[catalogue.RuntimeToolModules], ",") { + module, path, ok := strings.Cut(entry, "=") + if !ok || !strings.HasPrefix(path, catalogue.BundleRoot+"/"+module+"/") { + continue + } + name, load, ok := strings.Cut(strings.TrimPrefix(path, catalogue.BundleRoot+"/"+module+"/"), "/") + bundles := out[module] + i := slices.IndexFunc(bundles, func(b catalogue.Bundle) bool { return b.Name == name }) + if !ok || i < 0 { + continue // no archive of it runs: nothing to load + } + var own map[string]string + for w, v := range words[module] { + if w == catalogue.FilesOwnedPaths || w == catalogue.ReadsSecretPlaces { + continue // the mesh's to say, composed again + } + if own == nil { + own = map[string]string{} + } + own[w] = v + } + bundles[i].Loads = append(bundles[i].Loads, load) + bundles[i].Env = own + } + for _, bundles := range out { + sort.Slice(bundles, func(i, j int) bool { return bundles[i].Name < bundles[j].Name }) + } + return out +} + +// runningBundles is the bundles of a module composed as what runs of it (Node.RunsTools): each that runs, with what the +// runtime loads of it and its words; one the runtime loads nothing of takes its loads and words from the module's build +// (man: its built bundles, else its build's artifacts, catalogue.BundleLoads), so a drop already applied is undone. +func runningBundles(runs []catalogue.Bundle, man catalogue.Manifest) []catalogue.Bundle { + var out []catalogue.Bundle + for _, b := range runs { + if len(b.Loads) == 0 { + for _, built := range man.Bundles { + if built.Name == b.Name { + b.Loads, b.Env, b.Launchers, b.Binary = built.Loads, built.Env, built.Launchers, built.Binary + } + } + if man.Build != nil { + run := resolution.RunByAResource(man) + for _, a := range man.Build.Artifacts { + if a.Kind == catalogue.ArtifactBundle && a.Name == b.Name { + b.Loads, b.Env, b.Binary = catalogue.BundleLoads(man, a, run), a.Env, catalogue.BinaryOf(a) + } + } + } + } + out = append(out, b) + } + return out +} + +// NamesBuild says a manifest names what a build makes: its build, or a resource naming an artifact. A node cannot +// compose such a module folded into its own composition: the build of the old form it runs is held by the controller +// alone (issue 1126). +func NamesBuild(m catalogue.Manifest) bool { + if m.Build != nil { + return true + } + for _, r := range m.Resources { + if _, ok := r["artifact"]; ok { + return true + } + } + return false +} + +// Stub is m with nothing to build and no resource but its directories: what it provides, requires, serves and grants +// still shape the rest of the composition, the places its own secrets, data and grants name (${dir:…}) still resolve, +// and nothing else of its own is composed (issue 1126: nats's ${dir:mesh-state} on novox). A module refused in a +// node's answer is composed so; a folded one the node runs is Carried. +func Stub(m catalogue.Manifest) catalogue.Manifest { + // The paths it owns stay its manifest's, as the controller tells the holder of node-files them (issue 1126, seed-8). + if m.OwnsPaths == nil { + m.OwnsPaths = resolution.DeclaredPaths(m) + } + m.Build = nil + // What it hands the provisioner is left out as it runs, not refused for want of a plan (catalogue.Manifest.Stubbed). + m.Stubbed = true + // Nor is a template its build kept for its own files handed: a stub carries none of them (issue 1126: novox's node + // refused "postgres: its build kept a template for consumers, which it does not declare" with postgres held). Its + // items stay, and the templates they render through: a held module's contributions to other modules' collections + // (seat contributions, shell blocks, seat data) still reach their collectors. + if m.Handed != nil { + h := *m.Handed + h.Templates = nil + m.Handed = &h + } + var dirs []map[string]any + for _, r := range m.Resources { + if r["type"] == "directory" { + dirs = append(dirs, r) + } + } + m.Resources = dirs + return m +} + +// Carried is m, folded into its node's own composition at a build of the old form, as the node runs it (issue 1126, +// C5): nothing to build; its containers and directories those the node's own composition (runs) carries of it, their +// module's word taken off their ids, with the manifest's directories it does not run. So what other modules read of it +// is what runs: the machine ports its containers publish (the filter's rules, the routes reaching it: MachineSide). The +// paths it owns, which the holder of node-files is told, are its manifest's, as for its Stub: the controller composes +// them from the manifest, not from every path a resource of it runs at (issue 1126, seed-8: f1-circuits, snake and +// txt-game on ace, whose manifests leave their directories to the mesh). Its other resources are not taken, since the +// composition makes some of them again from its manifest (its secrets' files) and one id twice is refused; none of them +// is composed into what is applied either way (compositions leaves a carried module's out, and carries the ones that +// run). What it provides, requires, listens on and grants is its manifest's. A module whose resources runs holds none +// of is its Stub, as before. +func Carried(m catalogue.Manifest, module string, runs *delivery.Composed) catalogue.Manifest { + if runs == nil { + return Stub(m) + } + var taken []map[string]any + runsAny := false + ids := map[string]bool{} + for _, raw := range runs.Resources { + var r map[string]any + if json.Unmarshal(raw, &r) != nil { + continue + } + id, _ := r["id"].(string) + owner, _ := r["module"].(string) + if owner == "" { + owner, _ = declaration.ModuleGuess(id) + } + if owner != module { + continue + } + runsAny = true + if typ := r["type"]; typ != "container" && typ != "directory" { + continue + } + r["id"] = strings.TrimPrefix(id, module+".") + delete(r, "module") + ids[r["id"].(string)] = true + taken = append(taken, r) + } + if !runsAny { + return Stub(m) + } + stub := Stub(m) + for _, r := range stub.Resources { + if id, _ := r["id"].(string); !ids[id] { + taken = append(taken, r) + } + } + stub.Resources = taken + return stub +} + +// carry puts into the node's resources (kept, in the declaration's order) the resources of the node's own composition as +// it runs (runs) that belong to a carried module, each after the resource it runs after, or first when that one is +// not among them; a file among them is a folded path. +func carry(kept []json.RawMessage, foldedPaths map[string]bool, runs *delivery.Composed, carried func(module, id string) bool) ([]json.RawMessage, map[string]bool, error) { + idOf := func(raw json.RawMessage) (string, string, string, string, error) { + var head struct { + ID string `json:"id"` + Module string `json:"module"` + Type string `json:"type"` + Path string `json:"path"` + } + err := json.Unmarshal(raw, &head) + return head.ID, head.Module, head.Type, head.Path, err + } + present := map[string]bool{} + for _, raw := range kept { + if id, module, _, _, err := idOf(raw); err == nil && module == "" { + present[id] = true + } + } + // By the id of the resource of the node's own it runs after, "" for first: the last one before it that is composed. + after := map[string][]json.RawMessage{} + anchor := "" + for _, raw := range runs.Resources { + id, module, typ, path, err := idOf(raw) + if err != nil { + return nil, nil, fmt.Errorf("compose: a resource the node runs in its own composition: %w", err) + } + if !carried(module, id) { + if module == "" && present[id] { + anchor = id + } + continue + } + after[anchor] = append(after[anchor], raw) + if typ == string(declaration.TypeFile) { + foldedPaths[path] = true + } + } + out := make([]json.RawMessage, 0, len(kept)+len(runs.Resources)) + out = append(out, after[""]...) + for _, raw := range kept { + out = append(out, raw) + if id, module, _, _, err := idOf(raw); err == nil && module == "" { + out = append(out, after[id]...) + } + } + return out, foldedPaths, nil +} diff --git a/internal/compose/composed_test.go b/internal/compose/composed_test.go new file mode 100644 index 0000000..35091c1 --- /dev/null +++ b/internal/compose/composed_test.go @@ -0,0 +1,201 @@ +package compose + +import ( + "encoding/json" + "sort" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/compose/resolution" + "github.com/novox/mesh-host/internal/overlay" +) + +func fpFor(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) } + +// 1943/AC5: a node's compositions are its declaration as Compose gives it, split by the module each resource belongs +// to: every resource once, each in its module's composition at the fingerprint given, the module's ownership said on +// it; a module given no fingerprint is named as skipped and composed nowhere. +func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) { + in := aSmallMesh(t) + d, _, err := Compose(in, "laptop") + if err != nil { + t.Fatal(err) + } + var want []string + for _, r := range d.Resources { + want = append(want, r["type"].(string)+":"+r["id"].(string)) + } + sort.Strings(want) + + fps := map[string]string{"mesh-wireguard": fpFor('a'), "files": fpFor('b')} + out, skipped, err := Compositions(in, "laptop", fps, Foreseeing) + if err != nil || len(skipped) != 0 { + t.Fatalf("Compositions = %v, skipped %v", err, skipped) + } + var got []string + for key, c := range out { + node, module, fp, isNode, err := delivery.ParseComposedKey(key) + if err != nil || node != "laptop" || module != c.Module || fp != c.FP || (isNode != (module == "")) { + t.Errorf("key %s holds %s at %s (%v)", key, c.Module, c.FP, err) + } + if !isNode && fp != fps[module] { + t.Errorf("%s is composed at %s, given %s", module, fp, fps[module]) + } + if _, err := delivery.EncodeComposed(c); err != nil { + t.Errorf("%s: %v", key, err) + } + for _, raw := range c.Resources { + var r struct{ ID, Type, Module string } + _ = json.Unmarshal(raw, &r) + if r.Module != module { + t.Errorf("%s holds %s of %q", key, r.ID, r.Module) + } + got = append(got, r.Type+":"+r.ID) + } + } + sort.Strings(got) + if strings.Join(got, " ") != strings.Join(want, " ") { + t.Errorf("the compositions hold %v, the declaration %v", got, want) + } + + one, skipped, err := Compositions(in, "laptop", map[string]string{"mesh-wireguard": fpFor('a')}, Foreseeing) + if err != nil || strings.Join(skipped, ",") != "files" { + t.Fatalf("one module: %v, skipped %v", err, skipped) + } + for _, c := range one { + if c.Module == "files" { + t.Errorf("a module given no fingerprint was composed") + } + } + + // Issue 1126: a module the node runs inside its own composition (Node.InOwn), and the private network's always, + // given no fingerprint of the new form, is folded into the node's own as the controller's composed writer folds it: + // every resource in its place, its module taken off, its id still naming it, nothing skipped. + folding := in + folding.Nodes = append([]Node(nil), in.Nodes...) + for i := range folding.Nodes { + if folding.Nodes[i].Name == "laptop" { + folding.Nodes[i].InOwn = map[string]bool{"files": true} + } + } + all, skipped, err := Compositions(folding, "laptop", nil, Foreseeing) + nodeKey, _ := delivery.ComposedNodeKey("laptop") + if err != nil || len(skipped) != 0 || len(all) != 1 || len(all[nodeKey].Resources) != len(want) { + t.Fatalf("folded: %v, skipped %v, %d compositions, %d of %d resources in the node's own", err, skipped, len(all), len(all[nodeKey].Resources), len(want)) + } + for i, raw := range all[nodeKey].Resources { + var r map[string]any + _ = json.Unmarshal(raw, &r) + if _, says := r["module"]; says || r["id"] != d.Resources[i]["id"] { + t.Errorf("folded resource %d is %v, the declaration's %v", i, r["id"], d.Resources[i]["id"]) + } + } + if _, err := delivery.EncodeComposed(all[nodeKey]); err != nil { + t.Errorf("the node's own with the folded modules: %v", err) + } + if _, _, err := Compositions(in, "nowhere", fps, Foreseeing); err == nil { + t.Errorf("a node the mesh does not have was composed") + } + + // Issue 1126, 2026-10-29: a module composed as what runs of it (folded at a build of the old form, or held) still + // contributes to the runtime what the runtime serves of it as it runs (Node.RunsTools): its manifest at the + // indexer's commit has no bundles (shanks's runtime lost the folded systemd-resolved, novox's the folded nats), and a + // held module's new build's bundles are not delivered. + dg := func(c byte) string { return "sha256:" + strings.Repeat(string(c), 64) } + runtime := catalogue.Manifest{Module: catalogue.RuntimeModule, Version: "1", + OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/node-tools/broker"}}, + Bundles: []catalogue.Bundle{{Name: "runtime", Source: catalogue.ArtifactStoreScheme + "node-tools/runtime/blobs/" + dg('a'), + Digest: dg('a'), Language: "go", Binary: "node-tools"}}} + store := catalogue.Manifest{Module: "artifacts", Version: "1", + Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}} + lamp := catalogue.Manifest{Module: "lamp", Version: "1", Tools: []string{"on"}, + Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/lamp", "mode": "0700"}}, + Build: &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "tools", Kind: catalogue.ArtifactBundle, + Language: "go", System: "arch", From: "cmd/lamp-tools", Env: map[string]string{"LAMP_CONF": "/var/lib/lamp/conf"}}}}} + newLamp, err := lamp.Resolve([]catalogue.Built{{Name: "tools", Kind: catalogue.ArtifactBundle, + Reference: catalogue.ArtifactStoreScheme + "lamp/tools/blobs/" + dg('c'), Digest: dg('c')}}) + if err != nil { + t.Fatal(err) + } + served := "lamp=" + resolution.BundlePath("lamp", "tools") + "/lamp-tools" + runtimeRuns := func(dropped bool) delivery.Composed { + env := `"MESH_TOOL_MODULES":"` + served + `","MESH_TOOL_ENV":"{\"lamp\":{\"LAMP_CONF\":\"/var/lib/lamp/conf\"}}"` + if dropped { + env = `"MESH_TOOL_MODULES":""` // the drop already applied (shanks, 2026-10-29) + } + return delivery.Composed{Node: "anchor", Module: catalogue.RuntimeModule, Resources: []json.RawMessage{json.RawMessage( + `{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{` + env + `}}`)}} + } + lampRuns := `{"id":"lamp.bundle-tools","type":"archive","source":"` + catalogue.ArtifactStoreScheme + `lamp/tools/blobs/` + dg('b') + + `","digest":"` + dg('b') + `","path":"` + resolution.BundlePath("lamp", "tools") + `"}` + for _, row := range []struct { + name string + set func(n *Node) delivery.Composed // and what runs of lamp + fps map[string]string + dropped bool + }{ + {"folded at a build of the old form", func(n *Node) delivery.Composed { + n.InOwn = map[string]bool{"lamp": true} + n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}} + return *n.RunsOwn + }, map[string]string{}, false}, + {"folded, the runtime already dropped it", func(n *Node) delivery.Composed { + n.InOwn = map[string]bool{"lamp": true} + n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}} + return *n.RunsOwn + }, map[string]string{}, true}, + {"held, its new build not delivered", func(n *Node) delivery.Composed { + n.Held = map[string]bool{"lamp": true} + lr := delivery.Composed{Node: "anchor", Module: "lamp", Resources: []json.RawMessage{json.RawMessage( + strings.Replace(lampRuns, `"type"`, `"module":"lamp","type"`, 1))}} + n.Kept = map[string]catalogue.Manifest{"lamp": Carried(newLamp, "lamp", &lr)} + return lr + }, map[string]string{"lamp": fpFor('1')}, false}, + } { + tools := aMesh(t) + tools.Catalogue[catalogue.RuntimeModule], tools.Catalogue["artifacts"], tools.Catalogue["lamp"] = runtime, store, lamp + anchor := nodeOf(t, &tools, "anchor") + anchor.Assigned = append(anchor.Assigned, "artifacts", catalogue.RuntimeModule, "lamp") + tools.BusMinted = []string{"anchor." + catalogue.RuntimeModule} + anchor.SealingKey = keyOf(t, "anchor") + anchor.RunsTools = ToolsRunning([]delivery.Composed{runtimeRuns(row.dropped), row.set(anchor)}) + row.fps[overlay.Name], row.fps["artifacts"], row.fps[catalogue.RuntimeModule] = fpFor('a'), fpFor('5'), fpFor('e') + out, _, _, err := CompositionsIn(tools, "anchor", row.fps, Foreseeing) + if err != nil { + t.Fatalf("%s: %v", row.name, err) + } + var env map[string]any + for _, c := range out { + for _, raw := range c.Resources { + var r struct { + ID string `json:"id"` + Env map[string]any `json:"env"` + } + if json.Unmarshal(raw, &r) == nil && r.ID == "node-tools.runtime" { + env = r.Env + } + } + } + if env[catalogue.RuntimeToolModules] != served || env[catalogue.RuntimeToolEnv] != `{"lamp":{"LAMP_CONF":"/var/lib/lamp/conf"}}` { + t.Errorf("%s: the runtime serves %v with %v, want what runs of lamp, %s", row.name, + env[catalogue.RuntimeToolModules], env[catalogue.RuntimeToolEnv], served) + } + } +} + +// 1943/AC1: the upgrade policies and sources a composition's value may name are the catalogue's own words. +func TestTheComposedPoliciesAreTheCatalogues(t *testing.T) { + for _, p := range []string{catalogue.PolicyRoll, catalogue.PolicyTogether, catalogue.PolicyRecord} { + for _, from := range []string{catalogue.FromPerson, catalogue.FromModule, catalogue.FromBus, catalogue.FromData, catalogue.FromDefault} { + v := delivery.ComposedDigest{Digest: "sha256:" + strings.Repeat("a", 64), Judgement: delivery.JudgedNeither, Policy: p, PolicyFrom: from} + if _, err := delivery.EncodeComposedDigest(v); err != nil { + t.Errorf("%s from %s: %v", p, from, err) + } + } + } + if delivery.PolicyRecord != catalogue.PolicyRecord { + t.Errorf("delivery.PolicyRecord = %q, the catalogue's %q", delivery.PolicyRecord, catalogue.PolicyRecord) + } +} diff --git a/internal/compose/credentials.go b/internal/compose/credentials.go new file mode 100644 index 0000000..374302d --- /dev/null +++ b/internal/compose/credentials.go @@ -0,0 +1,499 @@ +package compose + +import ( + "crypto/ecdh" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "slices" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/compose/resolution" +) + +// Mode says what a composition does about what a send would make. Both modes read what the Input holds and make nothing; they +// differ as the controller's Reading and Foreseeing modes do. +type Mode int + +const ( + // Reading composes what is held and leaves out an own secret the Input holds none for: that machine cannot be running it. + Reading Mode = iota + // Foreseeing composes an own secret the next send would make with a stand-in and names it foreseen, while one the send would + // refuse (a bus credential nobody issued, a given secret under an old key) is refused here, in the same words. + Foreseeing + // Planning composes for a provisioning plan alone (novox/hq ADR 0570 clause 2): every own secret the Input holds none + // for stands in, named foreseen, and nothing is refused for a value, since a plan names secrets and never carries one. + // The seat mesh-provisioner's holder composes so, reading no secret; a rendering in this mode is never applied. + Planning +) + +// ForeseenCertificate and ForeseenBusUsers stand for what the Input does not carry, in a composition asked ahead of the send. +// Never sent. +const ( + ForeseenCertificate = "foreseen: issued by the next send" + ForeseenBusUsers = "foreseen: composed from the bus records by the next send" +) + +// grantsFor is every credential this node must create, because something elsewhere uses it (plan.go grantsFor): the mirror of +// what a consumer is given. A consumer whose identity overflows the provision's bound is left out of the grants and returned +// beside them (novox/hq ADR 0225); one whose resolution binds it to another provider is withdrawn and returned beside them +// (issue 274). Every other is granted. +func (c *composer) grantsFor(node string) ([]catalogue.Grant, []catalogue.Overflow, []catalogue.Unbound, []string, error) { + issued, err := c.rowsFor(node) + if err != nil { + return nil, nil, nil, nil, err + } + onNetwork, err := c.whereEveryoneIs() + if err != nil { + return nil, nil, nil, nil, err + } + out := make([]catalogue.Grant, 0, len(issued)) + var withheld []catalogue.Overflow + var unbound []catalogue.Unbound + var foreseen []string + for _, s := range issued { + resolved, err := c.planFor(s.Consumer) + switch { + case unresolvable(err): + // Their set does not resolve. Skipped rather than fatal: this node is not the place to report another machine's problem. + continue + case err != nil: + // The mesh could not be asked what they wanted, which is not the same as their wanting nothing. + return nil, nil, nil, nil, fmt.Errorf("what %s asked of %s cannot be read: %w", s.Consumer, s.Provision, err) + } + plan := resolved.Plan + values, asks, err := resolution.ContributionsFrom(plan, s.Provision, s.ConsumerModule, resolved.Settings) + if err != nil { + return nil, nil, nil, nil, err + } + // A port in there is the consumer's software port until this: the assignment that moved it is that machine's. + consumer, err := c.in.node(s.Consumer) + if err != nil { + return nil, nil, nil, nil, err + } + values = resolution.AtPublishedPort(values, s.ConsumerModule, consumer.Ports[s.ConsumerModule]) + from := s.ConsumerModule + if !asks { + // That module no longer wants this: left empty, which is how a login is withdrawn rather than kept working for ever. + from = "" + } + if bound := resolution.BindsFrom(plan, s.Provision, s.ConsumerModule, s.Local); from != "" && !slices.Contains(bound, node) { + // It still asks, and not of this node: withdrawn exactly as a credential nobody asks for, and said. + from = "" + unbound = append(unbound, catalogue.Unbound{Provision: s.Provision, Provider: node, + Consumer: s.Consumer, Module: s.ConsumerModule, Local: s.Local, BoundTo: bound}) + } + // The consumer's identity slug, from its own manifest, carried on the grant so the provider derives the same login the + // consumer does (novox/hq ADR 0049). + slug := "" + for _, mm := range plan.Modules { + if mm.Module == s.ConsumerModule { + slug = mm.Slug + break + } + } + if from != "" { + bound := boundOfGrant(plan, s, node) + source := catalogue.IdentitySource(slug, s.ConsumerModule) + if catalogue.CheckIdentityWithin(s.Consumer, source, bound) != nil { + withheld = append(withheld, catalogue.Overflow{Provision: s.Provision, Provider: node, + Consumer: s.Consumer, Module: s.ConsumerModule, + Identity: catalogue.ConsumerIdentity(s.Consumer, source), Bound: bound}) + continue + } + } + sealed := s.ForProvider + switch { + case credentiallessRow(plan, s, node): + // Its offer presents no credential (form none): the grant carries the consumer and nothing sealed, and + // nothing stands in for it, so neither end waits for a value nobody makes (issue 1126). + sealed = "" + case sealed == "": + sealed = catalogue.ForeseenSealed + foreseen = append(foreseen, fmt.Sprintf("the grant of %s for %s on %s", s.Provision, s.ConsumerModule, s.Consumer)) + } + out = append(out, catalogue.Grant{ + Provision: s.Provision, Consumer: s.Consumer, At: onNetwork[s.Consumer], + From: from, Values: values, Slug: slug, Sealed: sealed, Local: s.Local}) + } + return out, withheld, unbound, foreseen, nil +} + +// credentialless is whether a need's provider offers it with no credential (catalogue.CredentialFormNone): the vault +// mints nothing for it, so nothing is asked of the vault, sealed into a grant or a file, or stood in for (issue 1126). +func credentialless(n catalogue.Needed) bool { return n.CredentialForm == catalogue.CredentialFormNone } + +// credentiallessRow is whether the consumer's resolution answers row's need from provider with an offer of form none. +func credentiallessRow(consumer catalogue.Resolution, row Issued, provider string) bool { + return slices.ContainsFunc(consumer.Needs, func(n catalogue.Needed) bool { + return n.Name == row.Provision && n.For == row.ConsumerModule && n.Local == row.Local && n.From == provider && + credentialless(n) + }) +} + +// rowsFor is every pair credential this node's declaration must carry a grant for, in the order the controller reads them +// (SecretsFrom: by provision, consumer, consumer module and local name): the ones the Input says were made, and the ones the same send +// would make before it composes this node -- every need of a node that resolves (this one too), whose provider is this node, that is not +// answered by a record and not a credential only a person gives and nobody gave. Those have no sealed copy (ForProvider empty), so the +// grant is composed with a stand-in and named foreseen (plan.go: planFor makes the secret in every mode before grantsFor reads it). +func (c *composer) rowsFor(node string) ([]Issued, error) { + var rows []Issued + for _, row := range c.in.Issued { + if row.Provider == node { + rows = append(rows, row) + } + } + for _, other := range c.in.sorted() { + resolved, err := c.planFor(other.Name) + switch { + case unresolvable(err): + continue + case err != nil: + return nil, fmt.Errorf("what %s asked of %s cannot be read: %w", other.Name, node, err) + } + shelf := c.shelfFor(other) + for _, n := range resolved.Plan.Needs { + if n.From != node || n.ByRecord { + continue + } + cred, held := c.credential(n, other.Name) + if _, outside := issuedOutsideBy(shelf, n); cred.State == CredentialNotGiven || (outside && !(held && cred.State == CredentialHeld)) { + continue + } + if slices.ContainsFunc(rows, func(r Issued) bool { + return r.Provision == n.Name && r.Consumer == other.Name && r.ConsumerModule == n.For && r.Local == n.Local + }) { + continue + } + rows = append(rows, Issued{Provision: n.Name, Consumer: other.Name, ConsumerModule: n.For, Local: n.Local, Provider: node}) + } + } + sort.SliceStable(rows, func(i, j int) bool { + a, b := rows[i], rows[j] + switch { + case a.Provision != b.Provision: + return a.Provision < b.Provision + case a.Consumer != b.Consumer: + return a.Consumer < b.Consumer + case a.ConsumerModule != b.ConsumerModule: + return a.ConsumerModule < b.ConsumerModule + } + return a.Local < b.Local + }) + return rows, nil +} + +// boundOfGrant is the identity bound the consumer's own resolution states for the requirement this grant answers. A requirement +// not found there is held to the tightest bound the mesh knows rather than to none. +func boundOfGrant(consumer catalogue.Resolution, s Issued, provider string) catalogue.IdentityBound { + for _, n := range consumer.Needs { + if n.Name == s.Provision && n.For == s.ConsumerModule && n.From == provider { + return n.Identity + } + } + return catalogue.DefaultIdentityBound +} + +// ownSecretRow is the row for one own secret of a module on a node. +func (c *composer) ownSecretRow(node, module, name string) (OwnSecretRow, bool) { + for _, r := range c.in.OwnSecrets { + if r.Node == node && r.Module == module && r.Name == name { + return r, true + } + } + return OwnSecretRow{}, false +} + +// givenMember is one own secret a person gave on a machine and whether it is sealed to the key the machine holds now. +type givenMember struct { + Name string + Sealed string + Current bool +} + +// givenMembers is every member of a module's secret family given on a machine, by name (inv.GivenMembers): the mesh never +// makes a member, so only a value a person gave is one; a machine with no sealing key holds none. +func (c *composer) givenMembers(node Node, module, family string) []givenMember { + if node.SealingKey == "" { + return nil + } + prefix := catalogue.FamilyPrefix(family) + var out []givenMember + for _, r := range c.in.OwnSecrets { + if r.Node == node.Name && r.Module == module && r.Origin == "accepted" && strings.HasPrefix(r.Name, prefix) { + out = append(out, givenMember{Name: r.Name, Sealed: r.Sealed, Current: r.SealedTo == node.SealingKey}) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// givenOwnSecret is an own secret issued outside the mesh as a person gave it on a machine (inv.GivenOwnSecret): not given when +// the value held was made by the mesh or the machine has no sealing key. +func (c *composer) givenOwnSecret(node Node, module, name string) (givenMember, bool) { + if node.SealingKey == "" { + return givenMember{}, false + } + r, ok := c.ownSecretRow(node.Name, module, name) + if !ok || r.Origin != "accepted" { + return givenMember{}, false + } + return givenMember{Name: name, Sealed: r.Sealed, Current: r.SealedTo == node.SealingKey}, true +} + +// moduleSecretIfIssued is the read half of the controller's SecretForModule (inv.ModuleSecretIfIssued): the secret when one is +// held and sealed to the key the machine holds now, and otherwise not held. A key it was sealed to that the machine no longer +// has is reported as absent rather than as an error. +func (c *composer) moduleSecretIfIssued(node Node, module, name string) (sealed string, held bool) { + if node.SealingKey == "" { + return "", false + } + r, ok := c.ownSecretRow(node.Name, module, name) + if !ok || r.SealedTo != node.SealingKey { + return "", false + } + return r.Sealed, true +} + +// wouldMakeSecretForModule says what the send would do about an own secret without doing it: nil when it would make one (or one +// is held), and otherwise the very refusal it would meet (inv.WouldMakeSecretForModule). +func (c *composer) wouldMakeSecretForModule(node Node, module, name string) error { + if node.SealingKey == "" { + return fmt.Errorf("%s needs a secret and %s has no sealing key, so nothing can be sealed to it", module, node.Name) + } + r, ok := c.ownSecretRow(node.Name, module, name) + switch { + case !ok: + return nil + case r.SealedTo != node.SealingKey && r.Origin == "accepted": + return fmt.Errorf("%s on %s holds %q, which was given to the mesh rather than made by it, and %s has "+ + "since generated a new sealing key. The mesh cannot make another; issue it again", module, node.Name, name, node.Name) + } + return nil +} + +// givenUnderAnOldKey is the refusal for a value a person gave sealed to a key the machine no longer holds. +func givenUnderAnOldKey(module, node, name string) error { + return fmt.Errorf("%s on %s holds %q, which was given to the mesh rather than made by it, and %s has "+ + "since generated a new sealing key. The mesh cannot make another; give it again", module, node, name, node) +} + +// ownSecretsFor is each module's own secrets as this node composes them (plan.go renderingFor): the needed ones sealed to the +// node, and, in the Foreseeing mode, the ones the next send would make named foreseen. A module handed to the provisioner has its +// secrets from the vault through the client, so none of them is composed here. +func (c *composer) ownSecretsFor(node Node, plan catalogue.Resolution, handing catalogue.Rendering, mode Mode) ( + needed map[string]map[string]string, foreseen map[string]map[string]bool, err error) { + needed = map[string]map[string]string{} + foreseen = map[string]map[string]bool{} + mark := func(module, name string) { + if foreseen[module] == nil { + foreseen[module] = map[string]bool{} + } + foreseen[module][name] = true + } + for _, m := range plan.Modules { + if resolution.Handing(handing, m) { + continue + } + // A secret family is never made (novox/hq ADR 0283): each member a person gave on this machine is placed, and one not + // given is nothing -- the module says it waits for it. + for _, family := range m.OwnSecrets.Families() { + for _, g := range c.givenMembers(node, m.Module, family) { + if _, fam, ok := m.OwnSecrets.Lookup(g.Name); !ok || fam != family { + continue // a longer family's member, or a name no longer of this family + } + if !g.Current { + continue + } + if needed[m.Module] == nil { + needed[m.Module] = map[string]string{} + } + needed[m.Module][g.Name] = g.Sealed + if g.Sealed == "" { + // Given, and its value is not in the Input: placed with a stand-in, named. + needed[m.Module][g.Name] = catalogue.ForeseenSealed + mark(m.Module, g.Name) + } + } + } + names := make([]string, 0) + for name := range m.OwnSecrets.Plain() { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + own := m.OwnSecrets.Plain()[name] + // An own secret issued outside the mesh is never made (novox/hq issue 401, ADR 0228; ADR 0283 for a family): only a + // value a person gave is placed. One not given is nothing, and a value the mesh made for it before is not one either. + if own.IssuedBy == catalogue.IssuedOutside { + g, given := c.givenOwnSecret(node, m.Module, name) + if !given || !g.Current { + continue + } + if g.Sealed == "" { + mark(m.Module, name) + continue + } + if needed[m.Module] == nil { + needed[m.Module] = map[string]string{} + } + needed[m.Module][name] = g.Sealed + continue + } + sealed, held := c.moduleSecretIfIssued(node, m.Module, name) + if !held && mode == Planning { + mark(m.Module, name) + continue + } + if !held && mode == Foreseeing { + // Asked ahead of the send: what the send would do about it, by the send's own rules. Made by it -- a stand-in, + // named; refused by it -- refused here, the same words (novox/hq issue 275). A bus account not yet issued is + // foreseen, never refused: the vault mints it from the grants part's wanted list once the placement is written + // on the operator's Approve (ADR 0561 §1, ADR 0567 §5), so a composition asked ahead of that cannot hold it + // (issue 774, 2026-10-30: every proposal placing mesh-conditions, mesh-self-check or mesh-healers refused). + if err := c.wouldMakeSecretForModule(node, m.Module, name); err != nil { + return nil, nil, err + } + mark(m.Module, name) + continue + } + if !held { + // Never issued, so this machine cannot be running it: left out rather than invented. + continue + } + if sealed == "" { + // Held, and its value is not in the Input: composed with a stand-in, named. + mark(m.Module, name) + continue + } + if needed[m.Module] == nil { + needed[m.Module] = map[string]string{} + } + needed[m.Module][name] = sealed + } + } + return needed, foreseen, nil +} + +// CoreBoxWanted is a core credential whose box the Input does not hold for a machine: the next send asks the vault for it and +// keeps the answer. It is returned for the caller to ask and keep; this package asks and keeps nothing. +type CoreBoxWanted struct { + Node string + Module string + Name string +} + +// coreModulesOf is every module of the plan on the core's list whose own secrets this machine's declaration carries, with their +// plain own secret names, sorted: one handed to the provisioner has its secrets from the vault through the client, and one issued +// outside the mesh is never the vault's to seal here. +func coreModulesOf(modules []catalogue.Manifest, handed func(catalogue.Manifest) bool) map[string][]string { + out := map[string][]string{} + for _, m := range modules { + if !resolution.CarriesVaultSealed(m.Module) || (handed != nil && handed(m)) { + continue + } + var names []string + for name, own := range m.OwnSecrets.Plain() { + if own.IssuedBy != catalogue.IssuedOutside { + names = append(names, name) + } + } + if len(names) > 0 { + sort.Strings(names) + out[m.Module] = names + } + } + return out +} + +// sealingKeyValid says a machine's sealing key, recorded raw in base64, is a raw X25519 key. +func sealingKeyValid(raw string) error { + b, err := base64.StdEncoding.DecodeString(strings.TrimSpace(raw)) + if err != nil || len(b) != 32 { + return errors.New("the machine's sealing key is not a raw X25519 key") + } + _, err = ecdh.X25519().NewPublicKey(b) + return err +} + +// coreBoxesFor decides each core credential of a machine in a composition that sends nothing and asks no vault (core_box.go +// coreBoxesOf, in the modes that are not a send): the kept box while it is sealed to the key the machine holds now, and otherwise a +// stand-in that the next send replaces by asking the vault -- returned in wanted for the caller to ask and keep. A machine with no +// sealing key, or one whose key is not a key, leaves its module out, said. +func (c *composer) coreBoxesFor(node Node, modules []catalogue.Manifest, handing catalogue.Rendering) ( + sealed map[string]map[string]json.RawMessage, notPlaced map[string]string, wanted []CoreBoxWanted) { + core := coreModulesOf(modules, func(m catalogue.Manifest) bool { return resolution.Handing(handing, m) }) + sealed, notPlaced = map[string]map[string]json.RawMessage{}, map[string]string{} + mods := make([]string, 0, len(core)) + for m := range core { + mods = append(mods, m) + } + sort.Strings(mods) + for _, module := range mods { + if node.SealingKey == "" { + notPlaced[module] = fmt.Sprintf("%s has no sealing key, so the vault can seal %s's credentials to "+ + "nothing there (novox/hq ADR 0349 §16)", node.Name, module) + continue + } + if err := sealingKeyValid(node.SealingKey); err != nil { + notPlaced[module] = fmt.Sprintf("%s's credentials cannot be asked of the vault: %v", module, err) + continue + } + boxes := map[string]json.RawMessage{} + for _, name := range core[module] { + var kept *CoreBox + for i := range c.in.KeptCoreBoxes { + k := &c.in.KeptCoreBoxes[i] + if k.Node == node.Name && k.Module == module && k.Name == name { + kept = k + } + } + if kept != nil && kept.SealedTo == node.SealingKey && len(kept.Box) > 0 { + boxes[name] = kept.Box + continue + } + boxes[name] = catalogue.ForeseenVaultBox + wanted = append(wanted, CoreBoxWanted{Node: node.Name, Module: module, Name: name}) + } + sealed[module] = boxes + } + return sealed, notPlaced, wanted +} + +// certificateFor is what the mesh certifies about one machine's internal name (plan.go certificateFor): refused for a machine that +// never told the mesh what key it serves with, and for one not on the private network, which has no name to be certified for. +// The certificate and the authority are public values the Input may carry; without them stand-ins are composed, and named. +func (c *composer) certificateFor(node Node) (certificate, authority string, foreseen bool, err error) { + if node.ServingKey == "" { + return "", "", false, fmt.Errorf( + "%s wants a certificate and has never told the mesh what key it serves with; it "+ + "joins again to report one", node.Name) + } + where, err := c.whereEveryoneIs() + if err != nil { + return "", "", false, err + } + if where[node.Name] == "" { + return "", "", false, fmt.Errorf( + "%s wants a certificate and is not on the private network, so it has no name inside "+ + "the mesh to be certified for", node.Name) + } + certificate, authority = node.Certificate, c.in.Authority + if certificate == "" || authority == "" { + return ForeseenCertificate, ForeseenCertificate, true, nil + } + return certificate, authority, false, nil +} + +// holdsTheBus says whether a node's set includes the module that asks for the bus's user list. +func holdsTheBus(modules []catalogue.Manifest) bool { + for _, m := range modules { + if m.BusUsers != "" && m.ClaimsSeat("mesh-broker") { + return true + } + } + return false +} diff --git a/internal/compose/credentials_needed.go b/internal/compose/credentials_needed.go new file mode 100644 index 0000000..ec36c71 --- /dev/null +++ b/internal/compose/credentials_needed.go @@ -0,0 +1,69 @@ +package compose + +import ( + "fmt" + "sort" +) + +// CredentialKey names one pair credential by the ends it joins: the provision, the consumer's node and module, the +// provider's node, and the local name where a module keeps several (Credential's and Issued's key). +type CredentialKey struct { + Provision string `json:"provision"` + Consumer string `json:"consumer"` + ConsumerModule string `json:"consumer_module"` + Provider string `json:"provider"` + Local string `json:"local,omitempty"` +} + +// CredentialsNeeded is every pair credential node's composition reads (novox/hq ADR 0547 §2, ADR 0550 §5, issue 1126): +// as a consumer, each need of its resolution a provider's credential answers (a need answered by a record is a licence's, +// not a pair's); as a provider, each credential it must carry a grant for (rowsFor). A node that composes its own asks the +// vault for exactly these, sealed to its key, and composes with them; one it does not get is stood in for, which the +// node's own apply refuses (CompositionsApplied). A need whose provider offers it with no credential (form none) is +// in neither list: the vault mints nothing for it (issue 1126). +func CredentialsNeeded(in Input, node string) (asConsumer, asProvider []CredentialKey, err error) { + c := newComposer(in) + resolved, err := c.resolution(node) + if err != nil { + return nil, nil, err + } + for _, n := range resolved.Needs { + if n.ByRecord || n.From == "" || credentialless(n) { + continue + } + asConsumer = append(asConsumer, CredentialKey{Provision: n.Name, Consumer: node, ConsumerModule: n.For, + Provider: n.From, Local: n.Local}) + } + rows, err := c.rowsFor(node) + if err != nil { + return nil, nil, fmt.Errorf("compose: the credentials %s provides: %w", node, err) + } + for _, r := range rows { + // A grant whose offer presents no credential (form none) has none to ask for (issue 1126). + if consumer, err := c.planFor(r.Consumer); err == nil && credentiallessRow(consumer.Plan, r, node) { + continue + } + asProvider = append(asProvider, CredentialKey{Provision: r.Provision, Consumer: r.Consumer, + ConsumerModule: r.ConsumerModule, Provider: node, Local: r.Local}) + } + sortKeys(asConsumer) + sortKeys(asProvider) + return asConsumer, asProvider, nil +} + +func sortKeys(ks []CredentialKey) { + sort.Slice(ks, func(i, j int) bool { + a, b := ks[i], ks[j] + switch { + case a.Provision != b.Provision: + return a.Provision < b.Provision + case a.Provider != b.Provider: + return a.Provider < b.Provider + case a.Consumer != b.Consumer: + return a.Consumer < b.Consumer + case a.ConsumerModule != b.ConsumerModule: + return a.ConsumerModule < b.ConsumerModule + } + return a.Local < b.Local + }) +} diff --git a/internal/compose/credentials_test.go b/internal/compose/credentials_test.go new file mode 100644 index 0000000..dde374e --- /dev/null +++ b/internal/compose/credentials_test.go @@ -0,0 +1,549 @@ +package compose + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/overlay" +) + +// secretValue is a value the Input carries that must never appear in anything the composition says. +const secretValue = "SECRET-VALUE-THAT-MUST-NOT-LEAK" + +func rendered(t *testing.T, in Input, node string, mode Mode) Rendered { + t.Helper() + got, err := Render(in, node, mode) + if err != nil { + t.Fatalf("%v", err) + } + return got +} + +// anAssistant is mesh-controller's outside_not_given_test.go anAssistant (cmd/mesh-controller at 6c2a8d62): a module with a secret +// the mesh makes and one only an outside party issues. +func anAssistant() catalogue.Manifest { + return catalogue.Manifest{Module: "assistant", Version: "1", + OwnSecrets: catalogue.OwnSecrets{ + "password": {Path: "/var/lib/mesh/assistant/password", Taken: catalogue.TakenAtStart}, + "vendor-key": {Path: "/var/lib/mesh/assistant/vendor-key", Taken: catalogue.TakenAtStart, IssuedBy: catalogue.IssuedOutside}, + }, + Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/mesh/assistant", "mode": "0700"}}} +} + +func withAssistant(t *testing.T) Input { + t.Helper() + in := aMesh(t) + in.Catalogue["assistant"] = anAssistant() + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = append(laptop.Assigned, "assistant") + laptop.SealingKey = keyOf(t, "laptop") + return in +} + +// Own secrets (plan.go renderingFor, novox/hq issue 275 and 401): one held and sealed to the machine's key is carried; one not held is +// foreseen in the Foreseeing mode and left out in the Reading mode, and nothing is made. +func TestAnOwnSecretHeldIsCarriedAndOneNotHeldIsForeseenOrLeftOut(t *testing.T) { + mk := func() Input { + in := withAssistant(t) + in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made", + SealedTo: nodeOf(t, &in, "laptop").SealingKey, Sealed: secretValue}} + return in + } + in := mk() + got := rendered(t, in, "laptop", Foreseeing) + if got.Rendering.Needed["assistant"]["password"] != secretValue || len(got.Rendering.Foreseen) != 0 { + t.Errorf("a held secret: needed %v foreseen %v", got.Rendering.Needed, got.Rendering.Foreseen) + } + // Not held: foreseen with a stand-in (Foreseeing), left out (Reading). + in.OwnSecrets = nil + got = rendered(t, in, "laptop", Foreseeing) + if !got.Rendering.Foreseen["assistant"]["password"] || got.Rendering.Needed["assistant"]["password"] != "" || + len(got.Foreseen) != 1 || got.Foreseen[0] != "assistant/password" { + t.Errorf("a secret the send makes: foreseen %v / %v needed %v", got.Rendering.Foreseen, got.Foreseen, got.Rendering.Needed) + } + got = rendered(t, in, "laptop", Reading) + if len(got.Rendering.Needed["assistant"]) != 0 || len(got.Foreseen) != 0 || got.Rendering.Foreseen != nil { + t.Errorf("Reading composed a secret that was never issued: %v %v", got.Rendering.Needed, got.Foreseen) + } + // Held but sealed to a key the machine no longer has is not held. + in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made", SealedTo: "an-old-key", Sealed: secretValue}} + if got := rendered(t, in, "laptop", Foreseeing); !got.Rendering.Foreseen["assistant"]["password"] { + t.Errorf("a secret sealed to an old key is held: %v", got.Rendering.Needed) + } + // Held with no value in the Input: a stand-in, named. + in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made", SealedTo: nodeOf(t, &in, "laptop").SealingKey}} + if got := rendered(t, in, "laptop", Reading); !got.Rendering.Foreseen["assistant"]["password"] { + t.Errorf("a held secret with no value is not named: %v", got.Rendering.Foreseen) + } + unchanged(t, mk, func(in Input) { rendered(t, in, "laptop", Foreseeing) }) + + // 1126 (ADR 0547 §2): a node applying what it composed itself never applies a stand-in: a held secret with no value in the + // Input is refused, naming it; held with its value, it composes. + fps := map[string]string{"assistant": "src2:" + strings.Repeat("a", 64), overlay.Name: "src2:" + strings.Repeat("b", 64)} + if _, _, err := CompositionsApplied(in, "laptop", fps); !errors.Is(err, ErrStoodIn) || !strings.Contains(err.Error(), "assistant/password") { + t.Errorf("a stand-in was not refused for a node's own apply: %v", err) + } + if out, _, err := CompositionsApplied(mk(), "laptop", fps); err != nil || len(out) == 0 { + t.Errorf("a held secret with its value is refused for a node's own apply: %v", err) + } +} + +// An own secret issued outside the mesh that nobody gave is never made (novox/hq issue 401, ADR 0228): nothing is composed or +// foreseen for it; a value the mesh made for it is not one a person gave; a value given is placed once it is sealed to the key held now. +func TestAnOutsideSecretIsPlacedOnlyOnceAPersonGaveIt(t *testing.T) { + in := withAssistant(t) + key := nodeOf(t, &in, "laptop").SealingKey + for _, mode := range []Mode{Reading, Foreseeing} { + got := rendered(t, in, "laptop", mode) + if _, there := got.Rendering.Needed["assistant"]["vendor-key"]; there || got.Rendering.Foreseen["assistant"]["vendor-key"] { + t.Fatalf("%v: a secret nobody gave is composed: %v %v", mode, got.Rendering.Needed, got.Rendering.Foreseen) + } + } + in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "vendor-key", Origin: "made", SealedTo: key, Sealed: secretValue}} + if _, there := rendered(t, in, "laptop", Foreseeing).Rendering.Needed["assistant"]["vendor-key"]; there { + t.Error("a value the mesh made for a secret issued outside was placed") + } + in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "vendor-key", Origin: "accepted", SealedTo: key, Sealed: "the vendor's key, sealed"}} + if got := rendered(t, in, "laptop", Foreseeing).Rendering.Needed["assistant"]["vendor-key"]; got != "the vendor's key, sealed" { + t.Errorf("a value given is placed as %q", got) + } + in.OwnSecrets[0].SealedTo = "an-old-key" + if _, there := rendered(t, in, "laptop", Foreseeing).Rendering.Needed["assistant"]["vendor-key"]; there { + t.Error("a value given under an old key was placed") + } +} + +// 1364/AC3 (the send's refusals, novox/hq issue 275): in the Foreseeing mode a given secret under an old key is refused in the +// controller's words. No secret value is in an error. A bus account not yet issued is foreseen, issued or not (issue 774: the vault +// mints it once the placement is written on the Approve, ADR 0561 §1, ADR 0567 §5; mesh-self-check's proposal on ace was refused +// "has no bus credential: nothing was issued for ace.mesh-self-check"). +func TestTheSendsRefusalsAreRefusedInTheSendsWords(t *testing.T) { + broker := catalogue.Manifest{Module: "mesh-self-check", Version: "1", RunsAs: "mesh-self-check", + OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/mesh-self-check/broker", Taken: catalogue.TakenAtStart}}} + in := withAssistant(t) + in.Catalogue["mesh-self-check"] = broker + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = append(laptop.Assigned, "mesh-self-check") + for _, minted := range [][]string{nil, {"laptop.mesh-self-check"}} { + in.BusMinted = minted + got, err := Render(in, "laptop", Foreseeing) + if err != nil || !got.Rendering.Foreseen["mesh-self-check"]["broker"] { + t.Fatalf("minted %v: the bus account is not foreseen: %v", minted, err) + } + // Reading refuses nothing for it: the machine is not running it. + if _, err := Render(in, "laptop", Reading); err != nil { + t.Fatalf("Reading refused a secret the machine is not running: %v", err) + } + } + // A given secret under an old key. + in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "accepted", SealedTo: "an-old-key", Sealed: secretValue}, + {Node: "laptop", Module: "mesh-self-check", Name: "broker", Origin: "made", SealedTo: laptop.SealingKey, Sealed: secretValue}} + _, err := Render(in, "laptop", Foreseeing) + wantOld := "assistant on laptop holds \"password\", which was given to the mesh rather than made by it, and laptop has since generated a new " + + "sealing key. The mesh cannot make another; issue it again" + if err == nil || err.Error() != wantOld { + t.Fatalf("got %v, want %q", err, wantOld) + } + if strings.Contains(err.Error(), secretValue) { + t.Error("a secret value is in an error") + } + // A machine with no sealing key cannot be sealed to. + laptop.SealingKey = "" + in.OwnSecrets = nil + if _, err := Render(in, "laptop", Foreseeing); err == nil || !strings.Contains(err.Error(), "laptop has no sealing key, so nothing can be sealed to it") { + t.Fatalf("no sealing key: %v", err) + } +} + +// aGrantMesh is the objects provider on anchor and its consumer files on laptop, with the pair credential made. +func aGrantMesh(t *testing.T) Input { + t.Helper() + in := theObjectsMesh(t, objects()) + in.Issued = []Issued{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", ForProvider: "sealed-to-anchor"}} + return in +} + +// A credential made for a consumer is a grant on its provider's machine, carrying the consumer's values and the sealed copy; without +// the sealed copy a stand-in is composed and named (novox/hq plan.go grantsFor). +func TestAGrantIsCreatedForEachCredentialMadeForAConsumer(t *testing.T) { + in := aGrantMesh(t) + // 1126 (ADR 0550 §5): the credentials each node's composition reads, by their ends: laptop's files consumes anchor's + // s3-bucket, and anchor grants it; the node composing its own asks the vault for exactly these. + pair := CredentialKey{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor"} + if consumer, provider, err := CredentialsNeeded(in, "laptop"); err != nil || len(provider) != 0 || len(consumer) != 1 || consumer[0] != pair { + t.Errorf("laptop needs %v as consumer and %v as provider (%v), want %v as consumer", consumer, provider, err, pair) + } + if consumer, provider, err := CredentialsNeeded(in, "anchor"); err != nil || len(consumer) != 0 || len(provider) != 1 || provider[0] != pair { + t.Errorf("anchor needs %v as consumer and %v as provider (%v), want %v as provider", consumer, provider, err, pair) + } + got := rendered(t, in, "anchor", Foreseeing) + if len(got.Rendering.Grants) != 1 { + t.Fatalf("grants are %+v", got.Rendering.Grants) + } + g := got.Rendering.Grants[0] + if g.Provision != "s3-bucket" || g.Consumer != "laptop" || g.From != "files" || g.At != "laptop.internal" || g.Sealed != "sealed-to-anchor" { + t.Errorf("the grant is %+v", g) + } + if len(got.Foreseen) != 0 || len(got.Withheld) != 0 || len(got.Unbound) != 0 { + t.Errorf("foreseen %v withheld %v unbound %v", got.Foreseen, got.Withheld, got.Unbound) + } + in.Issued[0].ForProvider = "" + got = rendered(t, in, "anchor", Foreseeing) + if got.Rendering.Grants[0].Sealed != catalogue.ForeseenSealed || len(got.Foreseen) != 1 { + t.Errorf("a grant with no sealed copy: %+v foreseen %v", got.Rendering.Grants, got.Foreseen) + } + // A credential made for a consumer on another provider is not this node's grant. + if len(rendered(t, in, "laptop", Foreseeing).Rendering.Grants) != 0 { + t.Error("the consumer is granted its own credential") + } + // A consumer whose set does not resolve is skipped, not fatal. + nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "files") + in.Catalogue["files"] = catalogue.Manifest{Module: "files", Version: "1", Requires: []string{"s3-bucket"}, + Claims: []catalogue.Claim{{Name: "x", Scope: catalogue.ScopeNode}}} + in.Catalogue["twin"] = catalogue.Manifest{Module: "twin", Version: "1", Claims: []catalogue.Claim{{Name: "x", Scope: catalogue.ScopeNode}}} + nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "twin") + if got, err := Render(in, "anchor", Foreseeing); err != nil || len(got.Rendering.Grants) != 0 { + t.Errorf("a consumer that does not resolve: %v %+v", err, got.Rendering.Grants) + } +} + +// 1364/AC2: a consumer the grants leave out because its identity overflows the provision's bound is returned beside them, naming the +// consumer and the provision (novox/hq ADR 0225), and is not granted. +func TestAConsumerWhoseIdentityOverflowsIsLeftOutOfTheGrantsAndNamed(t *testing.T) { + in := aGrantMesh(t) + in.Nodes[1].Name = "a-machine-with-a-very-long-name" + in.Issued[0].Consumer = "a-machine-with-a-very-long-name" + got := rendered(t, in, "anchor", Foreseeing) + if len(got.Rendering.Grants) != 0 || len(got.Withheld) != 1 { + t.Fatalf("grants %+v withheld %+v", got.Rendering.Grants, got.Withheld) + } + w := got.Withheld[0] + if w.Consumer != "a-machine-with-a-very-long-name" || w.Provision != "s3-bucket" || w.Provider != "anchor" || w.Module != "files" || + !strings.Contains(w.String(), "a-machine-with-a-very-long-name") || !strings.Contains(w.String(), "s3-bucket") { + t.Errorf("the consumer left out is %+v / %s", w, w.String()) + } + if len(got.Rendering.Withheld) != 1 { + t.Errorf("the Rendering does not carry it: %+v", got.Rendering.Withheld) + } +} + +// 1364/AC3: a credential on record for a consumer that its own resolution binds to another provider is withdrawn, named with where it is +// bound (novox/hq issue 274): the grant stays, with no consumer module asking. +func TestACredentialBoundToAnotherProviderIsWithdrawnAndSaidWhereItIsBound(t *testing.T) { + in := aGrantMesh(t) + second := objects() + second.Module = "objects2" + in.Catalogue["objects2"] = second + in.Nodes = append(in.Nodes, Node{Name: "ace", Place: Place{Site: "there", Address: "10.77.0.3"}, + Assigned: []string{overlay.Name, "objects2"}, Capabilities: map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true}}) + nodeOf(t, &in, "laptop").Pins = map[string]catalogue.Chosen{"s3-bucket": {Node: "ace", Module: "objects2"}} + got := rendered(t, in, "anchor", Foreseeing) + if len(got.Unbound) != 1 { + t.Fatalf("unbound %+v grants %+v", got.Unbound, got.Rendering.Grants) + } + u := got.Unbound[0] + if u.Provision != "s3-bucket" || u.Provider != "anchor" || u.Consumer != "laptop" || u.Module != "files" || + len(u.BoundTo) != 1 || u.BoundTo[0] != "ace" || !strings.Contains(u.String(), "ace") { + t.Errorf("unbound is %+v / %s", u, u.String()) + } + if len(got.Rendering.Grants) != 1 || got.Rendering.Grants[0].From != "" { + t.Errorf("the withdrawn credential is still asked for: %+v", got.Rendering.Grants) + } +} + +func aCertificateMesh(t *testing.T) Input { + t.Helper() + in := aMesh(t) + in.Catalogue["secure"] = catalogue.Manifest{Module: "secure", Version: "1", Certificate: &catalogue.Certificate{Into: "/etc/secure/tls.pem", Authority: "/etc/secure/ca.pem"}} + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = append(laptop.Assigned, "secure") + return in +} + +// 1364/AC4: with no certificate and authority in the Input a stand-in is composed and named; given them, the composition carries them as +// given. A machine that never reported a serving key, and one not on the private network, are refused in the controller's words. +func TestACertificateIsCarriedAsGivenOrStoodInForAndNamed(t *testing.T) { + in := aCertificateMesh(t) + laptop := nodeOf(t, &in, "laptop") + if _, err := Render(in, "laptop", Foreseeing); err == nil || err.Error() != "laptop wants a certificate and has never told the mesh what key it serves with; it joins again to report one" { + t.Fatalf("no serving key: %v", err) + } + laptop.ServingKey = "serving-key" + got := rendered(t, in, "laptop", Foreseeing) + if got.Rendering.Certificate != ForeseenCertificate || got.Rendering.Authority != ForeseenCertificate || len(got.Foreseen) != 1 { + t.Errorf("no certificate in the Input: %q %q foreseen %v", got.Rendering.Certificate, got.Rendering.Authority, got.Foreseen) + } + laptop.Certificate, in.Authority = "the-certificate", "the-authority" + got = rendered(t, in, "laptop", Foreseeing) + if got.Rendering.Certificate != "the-certificate" || got.Rendering.Authority != "the-authority" || len(got.Foreseen) != 0 { + t.Errorf("a certificate given: %q %q foreseen %v", got.Rendering.Certificate, got.Rendering.Authority, got.Foreseen) + } + laptop.Place.Address = "" // placed nowhere: no name inside the mesh + if _, err := Render(in, "laptop", Foreseeing); err == nil || err.Error() != "laptop wants a certificate and is not on the private network, so it has no name inside the mesh to be certified for" { + t.Fatalf("off the network: %v", err) + } +} + +func aValidKey(t *testing.T, name string) string { return keyOf(t, name) } + +// 1364/AC5 and AC6: a core credential the node needs and the Input holds no box for is composed with a stand-in and returned as one +// to keep; a kept box sealed to the key held now is carried; a node-engine below the vault-box contract is sent none (and nothing is +// asked of it); a machine with no sealing key has its core module left out, said. +func TestACoreBoxTheInputHoldsNoneOfIsReturnedToKeepAndNothingIsWritten(t *testing.T) { + mk := func() Input { + in := aMesh(t) + in.Catalogue["nats"] = catalogue.Manifest{Module: "nats", Version: "1", + OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/nats/broker", Taken: catalogue.TakenAtStart}}} + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = append(laptop.Assigned, "nats") + laptop.SealingKey = aValidKey(t, "laptop") + laptop.Stated, laptop.Contract = true, VaultBoxContract + in.BusMinted = []string{"laptop.nats"} + return in + } + in := mk() + got := rendered(t, in, "laptop", Foreseeing) + box := got.Rendering.VaultSealed["nats"]["broker"] + if string(box) != string(catalogue.ForeseenVaultBox) || len(got.CoreBoxesWanted) != 1 || + got.CoreBoxesWanted[0] != (CoreBoxWanted{Node: "laptop", Module: "nats", Name: "broker"}) { + t.Errorf("no box held: %s wanted %v", box, got.CoreBoxesWanted) + } + kept := json.RawMessage(`{"v":1,"eph":"e","iv":"i","tag":"t","ct":"c"}`) + in.KeptCoreBoxes = []CoreBox{{Node: "laptop", Module: "nats", Name: "broker", Generation: 3, SealedTo: nodeOf(t, &in, "laptop").SealingKey, Box: kept}} + got = rendered(t, in, "laptop", Foreseeing) + if string(got.Rendering.VaultSealed["nats"]["broker"]) != string(kept) || len(got.CoreBoxesWanted) != 0 { + t.Errorf("a kept box: %s wanted %v", got.Rendering.VaultSealed["nats"]["broker"], got.CoreBoxesWanted) + } + in.KeptCoreBoxes[0].SealedTo = "an-old-key" + if got = rendered(t, in, "laptop", Foreseeing); len(got.CoreBoxesWanted) != 1 { + t.Errorf("a box sealed to an old key is kept: %v", got.CoreBoxesWanted) + } + // Below the vault-box contract: none, and the controller's own seal as before. + nodeOf(t, &in, "laptop").Contract = VaultBoxContract - 1 + if got = rendered(t, in, "laptop", Foreseeing); got.Rendering.VaultSealed != nil || len(got.CoreBoxesWanted) != 0 { + t.Errorf("an engine below the contract is sent boxes: %v %v", got.Rendering.VaultSealed, got.CoreBoxesWanted) + } + // No sealing key: the module is left out, said. + nodeOf(t, &in, "laptop").Contract, nodeOf(t, &in, "laptop").SealingKey = VaultBoxContract, "" + got = rendered(t, in, "laptop", Reading) + if why := got.Rendering.NotPlaced["nats"]; !strings.Contains(why, "laptop has no sealing key, so the vault can seal nats's credentials to nothing there") { + t.Errorf("not placed: %v", got.Rendering.NotPlaced) + } + unchanged(t, mk, func(in Input) { rendered(t, in, "laptop", Foreseeing) }) +} + +// 1364/AC1: the bus's user list is carried for the machine that runs the bus and for no other; without it a stand-in is composed and +// named; the users left out for want of a credential are said; each machine's membership is carried. (The list is composed by the +// controller from the bus records, which stay the controller's: see the commit message.) +func TestTheBusUserListIsCarriedForTheMachineThatRunsTheBus(t *testing.T) { + in := aMesh(t) + in.Catalogue["bus"] = catalogue.Manifest{Module: "bus", Version: "1", BusUsers: "/etc/bus/accounts.conf", + Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh}}} + anchor := nodeOf(t, &in, "anchor") + anchor.Assigned = append(anchor.Assigned, "bus") + in.BusUsers = "accounts { anchor.bus: hash }" + in.BusMissing = []string{"laptop.files", "laptop.web"} + in.BusMemberships = map[string]string{"laptop": "sealed-membership"} + got := rendered(t, in, "anchor", Foreseeing) + if got.Rendering.BusUsers != in.BusUsers || len(got.Foreseen) != 0 { + t.Errorf("the list is %q foreseen %v", got.Rendering.BusUsers, got.Foreseen) + } + if len(got.Said) != 1 || got.Said[0] != "the bus's user list leaves out 2 user(s) the mesh has minted no credential for: laptop.files, laptop.web. Each is a user that cannot connect until one is issued" { + t.Errorf("said %v", got.Said) + } + if other := rendered(t, in, "laptop", Foreseeing); other.Rendering.BusUsers != "" || other.Rendering.BusMembership != "sealed-membership" { + t.Errorf("laptop: list %q membership %q", other.Rendering.BusUsers, other.Rendering.BusMembership) + } + in.BusUsers = "" + got = rendered(t, in, "anchor", Foreseeing) + if got.Rendering.BusUsers != ForeseenBusUsers || len(got.Foreseen) != 1 { + t.Errorf("no list in the Input: %q foreseen %v", got.Rendering.BusUsers, got.Foreseen) + } +} + +// 1364/AC7: nothing the composition says carries a secret value the Input holds. +func TestNoSecretValueOfTheInputIsInWhatTheCompositionSays(t *testing.T) { + in := aGrantMesh(t) + in.Issued[0].ForProvider = secretValue + laptop := nodeOf(t, &in, "laptop") + in.Catalogue["assistant"] = anAssistant() + laptop.Assigned = append(laptop.Assigned, "assistant") + laptop.SealingKey = keyOf(t, "laptop") + in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made", SealedTo: laptop.SealingKey, Sealed: secretValue}} + in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialHeld, Sealed: secretValue}} + for _, node := range []string{"anchor", "laptop"} { + got := rendered(t, in, node, Foreseeing) + for _, said := range append(append([]string{}, got.Foreseen...), got.Said...) { + if strings.Contains(said, secretValue) { + t.Errorf("%s: %q carries a secret value", node, said) + } + } + } +} + +// A consumer no credential was made for yet is granted with a stand-in and named foreseen, for every form whose credential +// the vault mints or takes in; an offer of form none has no credential (issue 1126, 2026-11-02: mesh-cli on ace held for +// "the grant of mcp-endpoint", whose provider node-tools offers form none): its grant carries the consumer and no +// credential, nothing stands in on either side, and neither end asks the vault for one. +func TestAConsumerNoCredentialWasMadeForYetIsGrantedWithAStandInUnlessItsOfferHasNone(t *testing.T) { + for _, tc := range []struct { + form string + cred *catalogue.OfferCredential + }{ + {"undeclared", nil}, + {catalogue.CredentialFormPair, &catalogue.OfferCredential{Form: catalogue.CredentialFormPair}}, + {catalogue.CredentialFormShared, &catalogue.OfferCredential{Own: "access-key"}}, + {catalogue.CredentialFormNone, &catalogue.OfferCredential{Form: catalogue.CredentialFormNone}}, + } { + in := aGrantMesh(t) + in.Issued = nil + provider := in.Catalogue["objects"] + provider.Provides[0].Credential = tc.cred + if tc.cred != nil && tc.cred.Own != "" { + provider.OwnSecrets = catalogue.OwnSecrets{"access-key": {Path: "/var/lib/mesh/objects/access-key"}} + } + in.Catalogue["objects"] = provider + none := tc.form == catalogue.CredentialFormNone + got := rendered(t, in, "anchor", Foreseeing) + if len(got.Rendering.Grants) != 1 { + t.Fatalf("%s: grants are %+v", tc.form, got.Rendering.Grants) + } + g, wantSealed := got.Rendering.Grants[0], catalogue.ForeseenSealed + if none { + wantSealed = "" + } + if g.Provision != "s3-bucket" || g.Consumer != "laptop" || g.From != "files" || g.Sealed != wantSealed { + t.Errorf("%s: the grant is %+v", tc.form, g) + } + if has(got.Foreseen, "the grant of s3-bucket for files on laptop") == none { + t.Errorf("%s: foreseen %v", tc.form, got.Foreseen) + } + consumer, asProvider, err := CredentialsNeeded(in, "laptop") + _, anchorProvides, err2 := CredentialsNeeded(in, "anchor") + if err != nil || err2 != nil || len(asProvider) != 0 || (len(consumer) == 0) != none || (len(anchorProvides) == 0) != none { + t.Errorf("%s: laptop asks %v, anchor provides %v (%v %v)", tc.form, consumer, anchorProvides, err, err2) + } + resolved, err := Resolve(in, "laptop") + if err != nil { + t.Fatalf("%s: %v", tc.form, err) + } + if n := theFilesNeed(t, resolved); (n.Sealed == "") != none || (len(resolved.Foreseen) == 0) != none { + t.Errorf("%s: the consumer's credential is %q, foreseen %v", tc.form, n.Sealed, resolved.Foreseen) + } + if lap := rendered(t, in, "laptop", Foreseeing); none && len(lap.Foreseen) != 0 { + t.Errorf("%s: the consumer stands in for %v", tc.form, lap.Foreseen) + } + // Only the provider's own: the consumer is granted nothing, and nothing in the Input changed. + if len(rendered(t, in, "laptop", Foreseeing).Rendering.Grants) != 0 { + t.Errorf("%s: the consumer is granted its own credential", tc.form) + } + if len(in.Issued) != 0 { + t.Errorf("%s: a row was added to the Input", tc.form) + } + // A row made long ago for an offer of form none (the controller's) carries no credential either. + in.Issued = []Issued{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", ForProvider: "sealed-to-anchor"}} + if g := rendered(t, in, "anchor", Foreseeing).Rendering.Grants; none && (len(g) != 1 || g[0].Sealed != "") { + t.Errorf("%s: an old row's grant is %+v", tc.form, g) + } + } + // A credential only a person gives and nobody has is no grant. + in := aGrantMesh(t) + in.Issued = nil + in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialNotGiven}} + if got := rendered(t, in, "anchor", Foreseeing); len(got.Rendering.Grants) != 0 { + t.Errorf("a credential nobody gave is granted: %+v", got.Rendering.Grants) + } +} + +// And a consumer whose identity overflows the provision's bound is judged before any credential is made for it (issue 263). +func TestAnOverflowingConsumerNoCredentialWasMadeForYetIsWithheld(t *testing.T) { + in := aGrantMesh(t) + in.Issued = nil + in.Catalogue["resolver"] = storeManifests()[1] + in.Catalogue["networkmanager"] = catalogue.Manifest{Module: "networkmanager", Version: "1", Requires: []string{"wildcard-resolution", "s3-bucket"}} + nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "networkmanager") + nodeOf(t, &in, "anchor").Assigned = append(nodeOf(t, &in, "anchor").Assigned, "resolver") + in.Holdings = []catalogue.Held{{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "resolver", Site: "here"}} + got := rendered(t, in, "anchor", Foreseeing) + if len(got.Withheld) != 1 || got.Withheld[0].Module != "networkmanager" { + t.Fatalf("withheld %+v", got.Withheld) + } +} + +// The grants come in the order the controller reads them, whatever order the Input gives its rows. +func TestGrantsComeInTheOrderOfProvisionConsumerModuleAndLocal(t *testing.T) { + in := aGrantMesh(t) + in.Issued = []Issued{ + {Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Local: "b", Provider: "anchor", ForProvider: "x"}, + {Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Local: "a", Provider: "anchor", ForProvider: "x"}, + } + rows, err := newComposer(in).rowsFor("anchor") + // The credential the same send would make (no local name) comes first, then the two the Input gives, a before b. + if err != nil || len(rows) != 3 || rows[0].Local != "" || rows[1].Local != "a" || rows[2].Local != "b" { + t.Errorf("rows are %+v %v", rows, err) + } +} + +// Round 1, finding 4: a member of a secret family a person gave, whose sealed value the Input does not carry, is placed with a +// stand-in and named foreseen, not dropped as if nobody gave it. +func TestAGivenFamilyMemberWithoutItsValueIsAStandInAndNamedForeseen(t *testing.T) { + in := aMesh(t) + in.Catalogue["mailer"] = catalogue.Manifest{Module: "mailer", Version: "1", + OwnSecrets: catalogue.OwnSecrets{"credential-*": {Path: "/var/lib/mailer/credential-*.secret", Taken: catalogue.TakenAtStart, IssuedBy: catalogue.IssuedOutside}}} + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = append(laptop.Assigned, "mailer") + laptop.SealingKey = keyOf(t, "laptop") + in.OwnSecrets = []OwnSecretRow{ + {Node: "laptop", Module: "mailer", Name: "credential-a", Origin: "accepted", SealedTo: laptop.SealingKey}, + {Node: "laptop", Module: "mailer", Name: "credential-b", Origin: "accepted", SealedTo: laptop.SealingKey, Sealed: "b-sealed"}, + } + got := rendered(t, in, "laptop", Foreseeing) + needed := got.Rendering.Needed["mailer"] + if needed["credential-a"] != catalogue.ForeseenSealed || needed["credential-b"] != "b-sealed" { + t.Errorf("needed %v", needed) + } + if !has(got.Foreseen, "mailer/credential-a") || has(got.Foreseen, "mailer/credential-b") { + t.Errorf("foreseen %v", got.Foreseen) + } +} + +// Round 1, finding 5: the suffix internal names end in is a field of the Input, and the environment is never read. +func TestTheInternalSuffixIsTheInputsAndNeverTheEnvironments(t *testing.T) { + t.Setenv(overlay.SuffixVar, "from-the-environment") + in := aGrantMesh(t) + got := rendered(t, in, "anchor", Foreseeing) + if got.Rendering.Suffix != "internal" || got.Rendering.Grants[0].At != "laptop.internal" { + t.Errorf("by default: suffix %q, grant at %q", got.Rendering.Suffix, got.Rendering.Grants[0].At) + } + in.InternalSuffix = ".lan" + got = rendered(t, in, "anchor", Foreseeing) + if got.Rendering.Suffix != "lan" || got.Rendering.Grants[0].At != "laptop.lan" { + t.Errorf("given: suffix %q, grant at %q", got.Rendering.Suffix, got.Rendering.Grants[0].At) + } + where, err := newComposer(in).whereEveryoneIs() + if err != nil || where["anchor"] != "anchor.lan" { + t.Errorf("where everyone is: %v %v", where, err) + } + if names := newComposer(in).namesInTheMesh(); names["anchor.lan"] == "" || names["anchor.internal"] != "" { + t.Errorf("names: %v", names) + } +} + +// Round 2: a need answered on the same machine is a grant too (catalogue resolve.go gives it From: the node's own name). +func TestAConsumerOnTheProvidersOwnNodeIsGrantedWithAStandInAndNamedForeseen(t *testing.T) { + in := aGrantMesh(t) + in.Issued = nil + laptop, anchor := nodeOf(t, &in, "laptop"), nodeOf(t, &in, "anchor") + laptop.Assigned = []string{overlay.Name} + anchor.Assigned = append(anchor.Assigned, "files") + got := rendered(t, in, "anchor", Foreseeing) + if len(got.Rendering.Grants) != 1 || got.Rendering.Grants[0].Consumer != "anchor" || got.Rendering.Grants[0].Sealed != catalogue.ForeseenSealed { + t.Fatalf("grants are %+v", got.Rendering.Grants) + } + if !has(got.Foreseen, "the grant of s3-bucket for files on anchor") { + t.Errorf("foreseen %v", got.Foreseen) + } +} diff --git a/internal/compose/fixture_test.go b/internal/compose/fixture_test.go new file mode 100644 index 0000000..1766852 --- /dev/null +++ b/internal/compose/fixture_test.go @@ -0,0 +1,90 @@ +package compose + +import ( + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "encoding/json" + "reflect" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/overlay" +) + +// aPublicKey is a key a machine could have reported, made as mesh-controller's mesh_for_test.go aPublicKey makes it. +func aPublicKey(t *testing.T) string { + t.Helper() + k, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes()) +} + +// keys are made once per run, so two Inputs made the same way are equal. +var keys = map[string]string{} + +func keyOf(t *testing.T, name string) string { + t.Helper() + if keys[name] == "" { + keys[name] = aPublicKey(t) + } + return keys[name] +} + +// theOverlayModule is the module the mesh provides for itself, as mesh-controller's providedModules makes it +// (cmd/mesh-controller/modules.go at 6c2a8d62). +func theOverlayModule(t *testing.T) catalogue.Manifest { + t.Helper() + var m catalogue.Manifest + b, err := json.Marshal(overlay.Manifest()) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + return m +} + +// aMesh is the Input of mesh-controller's aMesh (cmd/mesh-controller/mesh_for_test.go at 6c2a8d62): two placed +// machines, `anchor` the hub, both reporting container-runtime, wireguard and systemd, both assigned what puts them on the +// private network, each at .example:51820 on the site `here`, anchor at 10.77.0.1 and laptop at 10.77.0.2. +func aMesh(t *testing.T) Input { + t.Helper() + in := Input{Catalogue: map[string]catalogue.Manifest{overlay.Name: theOverlayModule(t)}} + for i, name := range []string{"anchor", "laptop"} { + in.Nodes = append(in.Nodes, Node{ + Name: name, + Place: Place{Key: keyOf(t, name), Endpoint: name + ".example:51820", Site: "here", + Hub: name == "anchor", Address: []string{"10.77.0.1", "10.77.0.2"}[i]}, + Assigned: []string{overlay.Name}, + Capabilities: map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true}, + }) + } + return in +} + +// nodeOf is the named node of an Input, for a test to change. +func nodeOf(t *testing.T, in *Input, name string) *Node { + t.Helper() + for i := range in.Nodes { + if in.Nodes[i].Name == name { + return &in.Nodes[i] + } + } + t.Fatalf("no node %s", name) + return nil +} + +// unchanged runs call on one Input and compares it with another made the same way: the composition changes nothing in what +// it is given (deep equality, nothing shared). +func unchanged(t *testing.T, mk func() Input, call func(Input)) { + t.Helper() + used, same := mk(), mk() + call(used) + if !reflect.DeepEqual(used, same) { + t.Fatalf("the call changed its Input") + } +} diff --git a/internal/compose/frombus/answer.go b/internal/compose/frombus/answer.go new file mode 100644 index 0000000..d2b0162 --- /dev/null +++ b/internal/compose/frombus/answer.go @@ -0,0 +1,583 @@ +package frombus + +import ( + "context" + "errors" + "fmt" + "maps" + "regexp" + "slices" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/compose" + "github.com/novox/mesh-host/internal/compose/resolution" +) + +// Answer is a node's answer to its proposal's placement candidates (novox/hq ADR 0567 §3, ADR 0559 §4): s.Node composed in shadow with +// s.Placements laid over the trunk's placement (compose.CompositionsShadow, its own sealed values and boxes through r as +// for its own composition), compared with what it runs (delivery.CompareComposed). It applies nothing and writes +// nothing; the proposal and the revision are the candidate's, said back in the answer. +// +// The node's own composition is compared only where the node runs one. fps gives the build each module is composed at: what the node runs, and for a module the candidate places that the +// node does not run, the build the trunk holds; a placed module with none is added, with no build of it known. What the +// composition stands in for is judged: a value of a module the proposal adds (on this node or another: its credential, own +// secrets and boxes are made only once it is placed, ADR 0549 §5) is not yet issued, which is no refusal; any other +// stand-in is a value missing for a module the node runs, a refusal naming it ("node" when it is the node's own). +func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string, running []delivery.Composed, proposal string, revision uint64) delivery.CandidateAnswer { + a := delivery.CandidateAnswer{Proposal: proposal, Revision: revision} + refuse := func(err error) delivery.CandidateAnswer { + a.Refused = err.Error() + a.Verdict = delivery.CandidateRefused + return a + } + trunk, err := r.Placements(ctx) + if err != nil { + return refuse(fmt.Errorf("frombus: placement: %w", err)) + } + if s.Running == nil { + s.Running = running + } + // Every module it cannot compose is named, not the first only (issue 1126): a build it cannot read or take in, and + // below, a module the composition refuses, each composed as a stub so the rest is composed and compared. + failed := map[string]string{} + in, err := input(ctx, r, mesh, s, func(module string, err error) { failed[module] = err.Error() }, nil) + if err != nil { + return refuse(err) + } + asConsumer, asProvider, err := compose.CredentialsNeeded(in, s.Node) + if err != nil { + return refuse(err) + } + // What the node runs, by module. A consumer's credential is asked only for a module it runs: the vault refuses the + // whole ask for a consuming module not assigned to the node (mesh-vault's own), and a module the candidate adds is + // assigned only after the merge (ADR 0549 §5), so its credential is left missing, a stand-in answered as not yet + // issued under Added (review of L2b, round 1, finding 1). + runs := map[string]bool{} + for _, c := range running { + if c.Module != "" { + runs[c.Module] = true + } + } + var consumed []compose.CredentialKey + for _, k := range asConsumer { + if runs[k.ConsumerModule] { + consumed = append(consumed, k) + } + } + asConsumer = consumed + if len(asConsumer)+len(asProvider) > 0 { + if in.Credentials, in.Issued, err = r.Credentials(ctx, s.Node, asConsumer, asProvider); err != nil { + return refuse(fmt.Errorf("frombus: the credentials of %s: %w", s.Node, err)) + } + } + composed, skipped, stood, err := composeEach(in, s.Node, fps, false, s.Running, failed, ranOf(ctx, r, mesh, s.Running, fps)) + if err != nil { + // Not one module's: the node's own (a value served elsewhere, the mesh's network), said once as the node's. + if len(failed) == 0 { + return refuse(err) + } + failed[delivery.HaveNodeWord] = err.Error() + composed, skipped, stood = nil, nil, nil + } + // The node's own composition is judged only where the node runs one: one that runs none applies it only once a want + // names it (the node agent's own composer hands it on that want alone), so a candidate does not add it. + runsOwn := false + for _, c := range running { + runsOwn = runsOwn || c.Module == "" + } + if !runsOwn { + for key, c := range composed { + if c.Module == "" { + delete(composed, key) + } + } + } + if _, whole := failed[delivery.HaveNodeWord]; whole { + // Nothing composed to compare: every refusal is said, and nothing else. + for _, m := range sortedKeys(failed) { + a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: failed[m]}) + } + heldAsToday(&a, s.HeldToday) + a.Verdict = delivery.VerdictOf(a) + return a + } + // A module the composition folds inside the node's own is judged there: the agent also says it runs by module, from + // the declaration it kept before the switch (agent.Everything), which is no removal (issue 1126, seed #531). + if len(s.InOwn) > 0 { + var judged []delivery.Composed + for _, c := range running { + if c.Module == "" || !slices.Contains(s.InOwn, c.Module) { + judged = append(judged, c) + } + } + running = judged + } + cmp := delivery.CompareComposedWith(composed, running, s.SameValue) + // The answer rides in the node's have key: its shown bodies are bounded, the rest named only. + budget := MaxShown + for i := range cmp.Differs { + kept := cmp.Differs[i].Bodies[:0] + for _, b := range cmp.Differs[i].Bodies { + if n := len(b.Composed) + len(b.Running); n <= budget { + budget -= n + kept = append(kept, b) + } else { + kept = append(kept, delivery.ResourceBodies{ID: b.ID, Why: b.Why}) + a.Note = "some differing resources are named without their bodies: the answer holds at most " + + fmt.Sprint(MaxShown) + " bytes of them" + } + } + cmp.Differs[i].Bodies = kept + } + // A refused module was composed as a stub: what it composed is no difference; its refusal is said. + notFailed := func(m string) bool { _, ok := failed[m]; return !ok } + a.Same = slices.DeleteFunc(cmp.Same, func(m string) bool { return !notFailed(m) }) + a.Removed = slices.DeleteFunc(cmp.Removed, func(m string) bool { return !notFailed(m) }) + a.Differs = slices.DeleteFunc(cmp.Differs, func(d delivery.ModuleDiffers) bool { return !notFailed(d.Module) }) + cmp.Added = slices.DeleteFunc(cmp.Added, func(d delivery.ModuleAdded) bool { return !notFailed(d.Module) }) + skipped = slices.DeleteFunc(skipped, func(m string) bool { return !notFailed(m) }) + + // What the proposal adds, by node and module: placed by a candidate and not by the trunk on that node. + added := map[string]bool{} + for node, p := range s.Placements { + if p == nil { + continue + } + was := map[string]bool{} + for _, m := range trunk[node].Modules { + was[m.Module] = true + } + for _, m := range p.Modules { + if !was[m.Module] { + added[node+"/"+m.Module] = true + } + } + } + unissued := map[string][]string{} + refusals := map[string][]string{} + for _, x := range stood { + node, module := standsInFor(x, s.Node) + switch { + case node == s.Node && runs[module]: + // A value missing for a module the node runs is a refusal, whatever the trunk places (the seed's trunk is + // empty, and every module counts as the group's). + refusals[module] = append(refusals[module], x) + case module != "" && added[node+"/"+module]: + if node == s.Node { + unissued[module] = append(unissued[module], x) + } // another node's addition: the module here that carries it differs, which the assigner judges + case module == "" || node != s.Node: + // The node's own, or a value this node holds for a module elsewhere (a grant it provides): said as the node's. + refusals[delivery.HaveNodeWord] = append(refusals[delivery.HaveNodeWord], x) + default: + refusals[module] = append(refusals[module], x) + } + } + addedHere := map[string]delivery.ModuleAdded{} + for _, m := range cmp.Added { + addedHere[m.Module] = m + } + for _, m := range skipped { // placed, and no build given to compose it at + addedHere[m] = delivery.ModuleAdded{Module: m, Unissued: []string{"no build of " + m + " is known to compose it at"}} + } + for m, xs := range unissued { + got := addedHere[m] + got.Module = m + got.Unissued = append(got.Unissued, xs...) + addedHere[m] = got + } + for _, m := range sortedKeys(addedHere) { + a.Added = append(a.Added, addedHere[m]) + } + for _, m := range sortedKeys(refusals) { + if _, isAdded := addedHere[m]; isAdded { + continue // a module the candidate adds here: what it lacks is said under Added + } + if _, isFailed := failed[m]; isFailed { + continue // said below, as why it could not be composed + } + a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: "stands in for " + strings.Join(refusals[m], ", ")}) + } + for _, m := range sortedKeys(failed) { + a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: failed[m]}) + } + sort.Slice(a.Refusals, func(i, j int) bool { return a.Refusals[i].Module < a.Refusals[j].Module }) + heldAsToday(&a, s.HeldToday) + a.Verdict = delivery.VerdictOf(a) + return a +} + +// heldAsToday moves into Held each refusal of a module the node keeps today as it runs (Scope.HeldToday, with how: +// held, failed, running a build of the old form): the candidate keeps it as the node does, which is no change (issue +// 1126: a seed reproduces today, and today keeps them so). Each says how it stands today (ModuleRefused.Today). No +// module is held behind another (issue 1960): a module that reads from a held one composes with what it last offered. +func heldAsToday(a *delivery.CandidateAnswer, today map[string]string) { + if len(today) == 0 { + return + } + kept := a.Refusals[:0] + for _, r := range a.Refusals { + if w, keep := today[r.Module]; keep { + r.Today = w + a.Held = append(a.Held, r) + } else { + kept = append(kept, r) + } + } + a.Refusals = kept + if len(a.Refusals) == 0 { + a.Refusals = nil + } +} + +// maxCulprits bounds the modules one refusal is tried on, each a whole composition. +const maxCulprits = 8 + +// MaxShown bounds the bytes of shown resource bodies in one answer (delivery.ModuleDiffers.Bodies). +const MaxShown = 256 << 10 + +var ( + grantStandIn = regexp.MustCompile(`^the grant of \S+ for (\S+) on (\S+)$`) + // grantOfStandIn is the provision a grant's stand-in is of. + grantOfStandIn = regexp.MustCompile(`^the grant of (\S+) for \S+ on \S+$`) + needsStandIn = regexp.MustCompile(`^(\S+) on (\S+) needs `) +) + +// standsInFor is the node and module a stand-in of compose's words is for: an own secret `module/name`, a port +// `port module:port`, a grant `the grant of for on `, a need ` on needs ...`; +// the module empty when the words name none (the certificate, the authority). +func standsInFor(x, node string) (string, string) { + if m := grantStandIn.FindStringSubmatch(x); m != nil { + return m[2], m[1] + } + if m := needsStandIn.FindStringSubmatch(x); m != nil { + return m[2], m[1] + } + if rest, ok := strings.CutPrefix(x, "port "); ok { + if module, _, ok := strings.Cut(rest, ":"); ok { + return node, module + } + } + if module, _, ok := strings.Cut(x, "/"); ok && !strings.Contains(x, " ") { + return node, module + } + return node, "" +} + +// composeEach is the one rule, for the node that applies and for its answer to a candidate alike, by which a module's +// own refusal refuses that module and never the node (issue 1126: seed-8 fixed it in the answer alone, and the +// applying path still refused all of novox for de-spiegel's unset route names). The node is composed; a refusal is laid +// on the module whose stub (compose.Stub) changes it (culprit), that module is held (compose.Node.Held) and said in +// failed with the refusal's words, and the rest is composed again, until it composes or the refusal is no single +// module's, which is answered as the node's (err). Applied composes as the node applies (compose.Reading) and holds the +// module placed here each stand-in is for; one for no module placed here (the node's own, a value this node holds for a +// module elsewhere) refuses the node (compose.ErrStoodIn). Not applied, it composes in compose.Foreseeing and answers +// every stand-in for the caller to judge (Answer). +// +// **A held module is what runs of it** (review of the apply, round 1): composed from what running holds of it +// (compose.Carried of its own composition there; the node's own carries it wherever it runs there), so what the others +// read of it (the machine ports its containers publish, the routes reaching it) is what runs, and none of its own +// composition is in out: the node keeps what it last applied. +// +// **A hold never spreads** (issue 1126, issue 1960: de-spiegel's hold for an unset value held mailu, which held +// route-proxy, nftables and postgres on novox). No module is held because a module it reads from is held: a consumer, +// a collector, the provider of what the held one contributes to and the filter where it listens each compose with what +// the held module last offered. That offer (what it contributes, contributes many, listens on) is its manifest at the +// build it runs where one is known (ran: a composition of its own running at another build than it is composed at), +// else the manifest it is composed from. Only the part of the offer that cannot compose is left out, by provision +// (culprit): de-spiegel's routes, its names unset, leave route-proxy without them while mailu keeps its smtp account, +// and the hold says so. +func composeEach(in compose.Input, node string, fps map[string]string, applied bool, running []delivery.Composed, failed map[string]string, + ran func(module string) (catalogue.Manifest, bool)) (out map[string]delivery.Composed, skipped, stood []string, err error) { + mode := compose.Foreseeing + if applied { + mode = compose.Reading + } + runs := map[string]*delivery.Composed{} + for i := range running { + if m := running[i].Module; m != "" { + runs[m] = &running[i] + } + } + src := map[string]catalogue.Manifest{} // each held module's manifest before it was held, offering what it last offered + left := map[string]map[string]bool{} // by held module, what of its offer is left out (leaveOut) + hold := func(m, why, leave string) { + if _, done := failed[m]; !done { + failed[m] = why // a held module tried again keeps the words it was first held for + } + if _, ok := src[m]; !ok { + man, ok := manifestOn(in, node, m) + if !ok { + return + } + if ran != nil { + if was, ok := ran(m); ok { + man.Contributes, man.ContributesMany, man.Listens = was.Contributes, was.ContributesMany, was.Listens + } + } + src[m] = man + } + if leave != "" { + if left[m] == nil { + left[m] = map[string]bool{} + } + left[m][leave] = true + if leave != leaveAll && leave != leaveOwn { + failed[m] += "; composed without what it contributes to " + leave + ", which does not compose" + } + } + in = heldAs(in, node, m, src[m], left[m], runs[m]) + } + for _, m := range sortedKeys(failed) { + hold(m, failed[m], "") + } + for { + out, skipped, stood, err = compose.CompositionsIn(in, node, fps, mode) + if err != nil { + module, leave := culprit(in, node, fps, mode, failed, err) + if module == "" { + return nil, nil, nil, firstReason(err, failed) + } + hold(module, err.Error(), leave) + continue + } + again := false + if applied && len(stood) > 0 { + placed := map[string]bool{} + for _, m := range assignedOn(in, node) { + placed[m] = true + } + of := map[string][]string{} + var nodes []string + for _, x := range stood { + if n, m := standsInFor(x, node); n == node && placed[m] { + of[m] = append(of[m], x) + } else { + nodes = append(nodes, x) + } + } + for _, m := range sortedKeys(of) { + if _, done := failed[m]; !done { + hold(m, "stands in for "+strings.Join(of[m], ", "), "") + again = true + continue + } + // Already held, and still a stand-in in another module's composition: a grant not yet issued for what + // it contributes (a module held for its own secret keeps its contributions, leaveOwn). Its contribution + // to that provision is left out, so the provider never applies a stand-in (issue 774, review round 1). + for _, x := range of[m] { + if g := grantOfStandIn.FindStringSubmatch(x); g != nil && !left[m][g[1]] { + hold(m, failed[m], g[1]) + again = true + } + } + } + if !again && len(nodes) > 0 { + return nil, nil, nil, fmt.Errorf("%w on %s: %s", compose.ErrStoodIn, node, strings.Join(nodes, ", ")) + } + } + if !again { + break + } + } + skipped = slices.DeleteFunc(skipped, func(m string) bool { _, held := failed[m]; return held }) + return out, skipped, stood, nil +} + +// firstReason is err, a refusal no culprit is found for, led by the first reason of each held module it names: a held +// module's stand-in refused later never replaces why it was held (issue 774, 2026-10-30: mesh-self-check on ace held for +// "has no bus credential", then refused as "runs as its own account and declares no own secret broker", its bare stub's +// words, which were all the proposal's refusal said). +func firstReason(err error, failed map[string]string) error { + var named []string + for _, m := range sortedKeys(failed) { + if strings.Contains(err.Error(), m) { + named = append(named, m+" is held: "+failed[m]) + } + } + if len(named) == 0 { + return err + } + return fmt.Errorf("%s; its stand-in is then refused: %w", strings.Join(named, "; "), err) +} + +// blames says the composition with m's stub (again) makes m the culprit of err: it composes, or fails otherwise. The +// runtime's stub is never a runtime (no build is taken in, so it carries no bundle) and always fails otherwise: it is +// the culprit only where its stub composes (issue 774, 2026-10-30 19:30: node-tools held for any error naming it). +func blames(m string, again, err error) bool { + if m == catalogue.RuntimeModule { + // Or where its stub fails only for the modules of their own account it would run (issue 774): they are held + // after it, each for its own words, and the node composes the rest. + var none *resolution.NoRuntimeToRunError + return again == nil || errors.As(again, &none) && none.Held + } + return again == nil || again.Error() != err.Error() +} + +// leaveAll leaves out the whole of a held module's offer and its own secrets (a bare stub); leaveOwn its own secrets +// alone, its offer kept; any other word leaves out what it contributes to that provision alone. +// +// leaveOwn is tried before leaveAll: a module held for an own secret never made (nextcloud's admin, issue 774, +// 2026-10-30) keeps what it contributes, so its providers' received files keep it and their helpers never retire its +// login while it is held. +const ( + leaveAll = "*" + leaveOwn = "own-secrets" +) + +// assignedOn is the modules placed on node, in placement order. +func assignedOn(in compose.Input, node string) []string { + for _, n := range in.Nodes { + if n.Name == node { + return n.Assigned + } + } + return nil +} + +// manifestOn is the manifest node composes module from: its Kept, else the catalogue's. +func manifestOn(in compose.Input, node, module string) (catalogue.Manifest, bool) { + for _, n := range in.Nodes { + if n.Name == node { + if man, ok := n.Kept[module]; ok { + return man, true + } + } + } + man, ok := in.Catalogue[module] + return man, ok +} + +// culprit is the module placed on node whose composition the error is of, and what of its offer to leave out: the +// first, in placement order, whose stub (compose.Stub) makes the composition in mode succeed or fail otherwise (leave +// ""); else the first whose stub without what it contributes to one provision does (leave that provision: de-spiegel's +// routes, its names unset, and not its smtp account, issue 1960); else the first whose bare stub does (leaveAll: its +// stub contributing nothing, its own secret never issued, issue 1126 seed-8). Empty when none does (the error is not +// one module's). Modules already refused are passed over for their stub. +func culprit(in compose.Input, node string, fps map[string]string, mode compose.Mode, failed map[string]string, err error) (string, string) { + assigned := assignedOn(in, node) + // Only the modules the error names: the words of a module's refusal name it. Each try is a whole composition, so a + // refusal naming none is no module's, answered at once rather than after a composition per module (issue 1126: ace's + // answer took 7m46s). + var order []string + for _, m := range assigned { + if strings.Contains(err.Error(), m) && len(order) < maxCulprits { + order = append(order, m) + } + } + changes := func(m, leave string) bool { + _, _, _, again := compose.CompositionsIn(stubbed(in, node, m, leave), node, fps, mode) + return blames(m, again, err) + } + for _, m := range order { + if _, done := failed[m]; done { + continue + } + if changes(m, "") { + return m, "" + } + } + for _, m := range order { + man, ok := manifestOn(in, node, m) + if !ok { + continue + } + // One already held is tried again too: held for its build, its stub may still refuse (its own secret never + // issued, issue 1126). A provision named in the error is tried first, each at one more composition. + var to []string + for p := range man.Contributes { + to = append(to, p) + } + for p := range man.ContributesMany { + if _, both := man.Contributes[p]; !both { + to = append(to, p) + } + } + sort.SliceStable(to, func(i, j int) bool { + return strings.Contains(err.Error(), " "+to[i]) && !strings.Contains(err.Error(), " "+to[j]) + }) + for _, p := range to { + if changes(m, p) { + return m, p + } + } + if isBare(man) { + continue + } + if len(man.OwnSecrets) > 0 && changes(m, leaveOwn) { + return m, leaveOwn + } + if changes(m, leaveAll) { + return m, leaveAll + } + } + return "", "" +} + +// isBare says a stub contributes nothing and declares no own secret (stubbed, leaveAll). +func isBare(m catalogue.Manifest) bool { + return len(m.Contributes) == 0 && len(m.ContributesMany) == 0 && len(m.OwnSecrets) == 0 +} + +// stubbed is in with module held on node as its stub alone (heldAs, nothing of it running), from the manifest node +// composes it from, leave left out of its offer: a culprit's try. +func stubbed(in compose.Input, node, module, leave string) compose.Input { + man, ok := manifestOn(in, node, module) + if !ok { + return in + } + var left map[string]bool + if leave != "" { + left = map[string]bool{leave: true} + } + return heldAs(in, node, module, man, left, nil) +} + +// heldAs is in with module held on node (compose.Node.Held): composed from man as what runs of it (compose.Carried of +// runs; its stub, compose.Stub, when runs is nil or holds nothing of it), as node's Kept; with what left names out of +// its offer: a provision's contributions, with leaveOwn its own secrets alone, or with leaveAll everything it contributes +// and its own secrets, none of a held module's own composition being applied, so what it has not been issued (restic's +// repository on a node it never ran on) is no refusal of the rest. +func heldAs(in compose.Input, node, module string, man catalogue.Manifest, left map[string]bool, runs *delivery.Composed) compose.Input { + out := in + out.Nodes = append([]compose.Node(nil), in.Nodes...) + for i := range out.Nodes { + if out.Nodes[i].Name != node { + continue + } + if left[leaveAll] { + man.Contributes, man.ContributesMany, man.OwnSecrets = nil, nil, nil + } else if len(left) > 0 { + if left[leaveOwn] { + man.OwnSecrets = nil + } + contributes := map[string]map[string]any{} + for p, v := range man.Contributes { + if !left[p] { + contributes[p] = v + } + } + many := maps.Clone(man.ContributesMany) + for p := range left { + delete(many, p) + } + man.Contributes, man.ContributesMany = contributes, many + } + kept := make(map[string]catalogue.Manifest, len(out.Nodes[i].Kept)+1) + for k, v := range out.Nodes[i].Kept { + kept[k] = v + } + kept[module] = compose.Carried(man, module, runs) + out.Nodes[i].Kept = kept + held := make(map[string]bool, len(out.Nodes[i].Held)+1) + for k, v := range out.Nodes[i].Held { + held[k] = v + } + held[module] = true + out.Nodes[i].Held = held + } + return out +} diff --git a/internal/compose/frombus/answer_rows_test.go b/internal/compose/frombus/answer_rows_test.go new file mode 100644 index 0000000..daee654 --- /dev/null +++ b/internal/compose/frombus/answer_rows_test.go @@ -0,0 +1,596 @@ +package frombus + +import ( + "context" + "encoding/json" + "fmt" + "os" + "reflect" + "slices" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/compose" + "github.com/novox/mesh-host/internal/compose/resolution" + "github.com/novox/mesh-host/internal/overlay" +) + +// Issue 1126 (the seed's shadow, round 2): a node's answer names every module it cannot compose, not the first only; +// and a module folded into its own composition that names a build of the old form (de-spiegel on novox, nats, photos, +// systemd-resolved: their haves name a build-... id and no fingerprint) is left to the node, carried as it runs. +func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { + ctx := context.Background() + image := "alpine@sha256:" + strings.Repeat("a", 64) + manifest := func(module string) []byte { + // Its data names its directory, as nats's names ${dir:mesh-state} (issue 1126: novox's own composition refused + // "nats says ${dir:mesh-state}, and nats declares no directory mesh-state" when its stub held no directory). + return []byte(`{"module":"` + module + `","version":"1","capabilities":["container-runtime"],` + + `"own-secrets":{"token":"${dir:state}/token"},` + + `"resources":[{"id":"state","type":"directory","path":"/var/lib/` + module + `","mode":"0700"},` + + `{"id":"server","type":"container","name":"` + module + `","artifact":"site"}],` + + `"build":{"artifacts":[{"name":"site","kind":"upstream","from":"` + image + `"}]}}`) + } + lemurs := []byte(`{"module":"lemurs","version":"1","resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`) + fp := func(c string) string { return "src2:" + strings.Repeat(c, 64) } + mk := func(placed ...string) *bus { + p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}} + for _, m := range placed { + p.Modules = append(p.Modules, delivery.PlacedModule{Module: m}) + } + b := &bus{placements: map[string]delivery.Placement{"n1": p}, + records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd", "container-runtime"}}}, + modules: map[string]delivery.Module{"lemurs": {Manifest: delivery.ManifestDigest(lemurs)}}, + manifests: map[string][]byte{delivery.ManifestDigest(lemurs): lemurs}, + builds: map[string]*delivery.BuilderBuild{}} + for _, m := range []string{"web", "api", "spiegel"} { + b.own = append(b.own, compose.OwnSecretRow{Node: "n1", Module: m, Name: "token", Origin: "made", SealedTo: "sk", Sealed: `"box"`}) + } + for _, m := range []string{"web", "api", "spiegel"} { + raw := manifest(m) + b.manifests[delivery.ManifestDigest(raw)] = raw + b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)} + } + // web and api are built, their keys done and waiting for the reference of their image; spiegel has no build of + // the new form at all. + for i, m := range []string{"web", "api"} { + mod := b.modules[m] + mod.FP2 = fp(string(rune('1' + i))) + b.modules[m] = mod + b.builds[m+" "+mod.FP2] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest, + Artifacts: []delivery.BuiltArtifact{{Name: "site", Kind: "image", Digest: "sha256:" + strings.Repeat("b", 64)}}} + } + return b + } + given := func(b *bus) map[string]*delivery.Placement { + p := b.placements["n1"] + return map[string]*delivery.Placement{"n1": &p} + } + + // Every refusal: two builds taken in, and a module the composition refuses (its image never built), each named. + b := mk("lemurs", "web", "api", "spiegel") + a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b)}, + map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3) + var refused []string + for _, r := range a.Refusals { + refused = append(refused, r.Module) + } + if a.Verdict != delivery.CandidateRefused || a.Refused != "" || !reflect.DeepEqual(refused, []string{"api", "spiegel", "web"}) { + t.Errorf("every refusal: %+v, want api, spiegel and web refused, each named", a) + } + for _, r := range a.Refusals { + want := map[string]string{"api": "waits for the reference of site", "web": "waits for the reference of site", + "spiegel": "spiegel has not been built for this mesh"}[r.Module] + if !strings.Contains(r.Why, want) { + t.Errorf("%s refused as %q, want %q", r.Module, r.Why, want) + } + } + if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": a}}); err != nil { + t.Errorf("the answer is not one a have holds: %v (%s)", err, raw) + } + if lemursOK := slices.ContainsFunc(a.Added, func(m delivery.ModuleAdded) bool { return m.Module == "lemurs" }); !lemursOK { + t.Errorf("lemurs, which composes, is not answered beside the refusals: %+v", a) + } + // Issue 1126: a module the node holds today that the candidate holds too is held as today, no refusal: with every + // refused module held today the verdict is no refusal, and each is named under Held. + today := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held", "spiegel": "held", "web": "held"}}, + map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3) + if today.Verdict == delivery.CandidateRefused || len(today.Refusals) != 0 || len(today.Held) != 3 || today.Held[0].Why == "" { + t.Errorf("held as today: %+v", today) + } + // A refused module the node does not hold today stays a refusal: holding only api today leaves spiegel and web refused. + part := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held"}}, + map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3) + if part.Verdict != delivery.CandidateRefused || len(part.Refusals) != 2 || len(part.Held) != 1 || part.Held[0].Module != "api" { + t.Errorf("held as today in part: %+v", part) + } + // No module is held behind one kept today (issue 1960): mailu's own refusal stays a refusal beside de-spiegel's hold. + if a := (delivery.CandidateAnswer{Refusals: []delivery.ModuleRefused{{Module: "de-spiegel", Why: "a value is unset"}, + {Module: "mailu", Why: "no build"}}}); true { + heldAsToday(&a, map[string]string{"de-spiegel": "runs a build of the old form"}) + if len(a.Refusals) != 1 || a.Refusals[0].Module != "mailu" || len(a.Held) != 1 { + t.Errorf("held as today spreads: %+v", a) + } + } + + // The fold: spiegel runs inside the node's own composition at a build of the old form. Its resources are carried as + // they run, each after the one it runs after: the node's own is the one it runs, and the node composes itself. + b = mk("lemurs", overlay.Name, "spiegel") + p := b.placements["n1"] + p.Decided.Endpoint = "n1.example:51820" + b.placements["n1"] = p + fps := map[string]string{"lemurs": fp("e")} + stub := Scope{Node: "n1"} + in, err := Input(ctx, b, Mesh{}, stub) + if err != nil { + t.Fatal(err) + } + in.Nodes[0].Kept = map[string]catalogue.Manifest{"spiegel": compose.Stub(in.Catalogue["spiegel"])} + base, _, err := compose.Compositions(in, "n1", fps, compose.Reading) + if err != nil { + t.Fatal(err) + } + // A refusal naming no module placed here is no module's: no compose is spent stubbing each in turn (issue 1126: ace's + // answer took 7m46s, one compose per module), though here a stub would compose. + if m, _ := culprit(in, "n1", fps, compose.Foreseeing, map[string]string{}, fmt.Errorf("nothing in this mesh provides \"artifact-store\"")); m != "" { + t.Errorf("a refusal naming no placed module was laid on %s", m) + } + ownKey, _ := delivery.ComposedNodeKey("n1") + own := base[ownKey] + carriedRes := []json.RawMessage{ + json.RawMessage(`{"id":"spiegel.state","type":"directory","path":"/var/lib/spiegel","mode":"0700"}`), + json.RawMessage(`{"id":"spiegel.server","type":"container","name":"spiegel","image":"registry.example/spiegel@sha256:` + strings.Repeat("c", 64) + `"}`)} + // They run after the node's first own resource. + own.Resources = append(append(append([]json.RawMessage{}, own.Resources[:1]...), carriedRes...), own.Resources[1:]...) + var running []delivery.Composed + for k, c := range base { + if k == ownKey { + c = own + } + running = append(running, c) + } + scope := Scope{Node: "n1", InOwn: []string{"spiegel"}, Running: running} + got, _, held, err := Compose(ctx, b, Mesh{}, scope, fps) + if err == nil && len(held) > 0 { + err = fmt.Errorf("modules held: %v", held) + } + if err != nil { + t.Fatalf("the node does not compose itself with spiegel folded at a build of the old form: %v", err) + } + gotOwn, _ := json.Marshal(got[ownKey].Resources) + wantOwn, _ := json.Marshal(own.Resources) + if string(gotOwn) != string(wantOwn) { + t.Errorf("the node's own composition is\n%s\nwant what runs\n%s", gotOwn, wantOwn) + } + a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, running, "p-h1", 3) + if a.Verdict != delivery.CandidateSame { + t.Errorf("the fold's answer: %+v, want the same", a) + } + // (A) The agent also says spiegel runs by module, from the declaration kept before the switch (agent.Everything): a + // module the composition folds is judged inside the node's own, not as removed (issue 1126, seed #531's answer). + keptToo := append(append([]delivery.Composed{}, running...), delivery.Composed{Node: "n1", Module: "spiegel", Resources: carriedRes}) + if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, keptToo, "p-h1", 3); a.Verdict != delivery.CandidateSame || len(a.Removed) != 0 { + t.Errorf("a folded module running from the kept declaration is answered %+v, want the same", a) + } + // (B) The node's own composition carries its bus membership as it runs it (the controller's, from its store): composed + // the same, not 'in what runs, not composed' (seed #531, novox). + membership := json.RawMessage(`{"id":"bus-membership","type":"file","path":"` + catalogue.BusMembershipPath + `","sealed":"{\"box\":\"m\"}","mode":"0600"}`) + withMembership := make([]delivery.Composed, 0, len(running)) + for _, c := range running { + if c.Module == "" { + c.Resources = append(append([]json.RawMessage{}, c.Resources...), membership) + } + withMembership = append(withMembership, c) + } + if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, withMembership, "p-h1", 3); a.Verdict != delivery.CandidateSame { + t.Errorf("the node's own with its bus membership is answered %+v, want the same", a) + } + // Without what runs, the fold composes spiegel from its manifest, as before, and the composition refuses it. + if _, _, held, err := Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}}, fps); err != nil || + !strings.Contains(held["spiegel"], "spiegel has not been built for this mesh") { + t.Errorf("composed spiegel without what runs: %v %v, want spiegel held alone", err, held) + } + + // (C) Issue 1126, C5: what the others read of a module carried as it runs is what runs, never its stub's: the filter + // opens the machine port its running container publishes (de-spiegel's 20005 on novox, not its software's 8080; a + // stub had no container, so snake's 80 on ace was opened beside the route proxy's). And C6: the operator export's file + // a module that keeps it runs is carried as it runs (mesh-vault.kept on novox), since the node holds no export. + listening := []byte(`{"module":"spiegel","version":"1","capabilities":["container-runtime"],` + + `"listens":[{"port":8080,"from":"mesh","why":"the site"}],"own-secrets":{"token":"${dir:state}/token"},` + + `"resources":[{"id":"state","type":"directory","path":"/var/lib/spiegel","mode":"0700"},` + + `{"id":"server","type":"container","name":"spiegel","artifact":"site","ports":["8080"]}],` + + `"build":{"artifacts":[{"name":"site","kind":"upstream","from":"` + image + `"}]}}`) + nft := []byte(`{"module":"nft","version":"1","capabilities":["systemd"],"filtering":{"into":"/etc/nftables.conf"}}`) + vault := []byte(`{"module":"vault","version":"1","capabilities":["systemd"],"keeps":"/var/lib/vault/root",` + + `"resources":[{"id":"service","type":"service","unit":"vault.service","state":"running","boot":"enabled"}]}`) + b = mk("lemurs", overlay.Name, "spiegel", "nft", "vault") + for m, raw := range map[string][]byte{"spiegel": listening, "nft": nft, "vault": vault} { + b.manifests[delivery.ManifestDigest(raw)] = raw + b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)} + } + p = b.placements["n1"] + p.Decided.Endpoint = "n1.example:51820" + for i := range p.Modules { + if p.Modules[i].Module == "spiegel" { + p.Modules[i].Ports = map[string]int{"8080": 20005} + } + } + b.placements["n1"] = p + rec := b.records["n1"] + rec.OutwardLinks = []string{"eth0"} + b.records["n1"] = rec + fps = map[string]string{"lemurs": fp("e"), "nft": fp("f"), "vault": fp("9")} + if in, err = Input(ctx, b, Mesh{}, stub); err != nil { + t.Fatal(err) + } + in.Nodes[0].Kept = map[string]catalogue.Manifest{"spiegel": compose.Stub(in.Catalogue["spiegel"])} + if base, _, err = compose.Compositions(in, "n1", fps, compose.Reading); err != nil { + t.Fatal(err) + } + keptFile := json.RawMessage(`{"digest":"sha256:` + strings.Repeat("d", 64) + `","id":"vault.kept","mode":"0600","module":"vault","path":"/var/lib/vault/root/export.json","size":143604,"type":"file"}`) + running = nil + for _, c := range base { + switch c.Module { + case "": + c.Resources = append(append([]json.RawMessage{}, c.Resources...), + json.RawMessage(`{"id":"spiegel.state","type":"directory","path":"/var/lib/spiegel","mode":"0700"}`), + json.RawMessage(`{"id":"spiegel.server","type":"container","name":"spiegel","image":"registry.example/spiegel@sha256:`+strings.Repeat("c", 64)+`","ports":["10.77.0.1:20005:8080"]}`), + json.RawMessage(`{"id":"spiegel.server-env","type":"file","path":"/var/lib/spiegel/server.env","mode":"0600","content":"A=1\n"}`)) + case "vault": + c.Resources = append(append([]json.RawMessage{}, c.Resources...), keptFile) + } + running = append(running, c) + } + got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}, Running: running}, fps) + if err == nil && len(held) > 0 { + err = fmt.Errorf("modules held: %v", held) + } + if err != nil { + t.Fatalf("the node does not compose itself with spiegel carried: %v", err) + } + var rules string + var keptComposed json.RawMessage + for _, c := range got { + for _, raw := range c.Resources { + var r struct { + ID string `json:"id"` + Content string `json:"content"` + } + _ = json.Unmarshal(raw, &r) + switch r.ID { + case "nft." + catalogue.FilteringID(): + rules = r.Content + case "vault." + resolution.KeptID(): + keptComposed = raw + } + } + } + if !strings.Contains(rules, "# spiegel — the site") || !strings.Contains(rules, "dport 20005 accept") || strings.Contains(rules, "dport 8080") { + t.Errorf("the filter does not open the machine port spiegel runs at:\n%s", rules) + } + // The paths a carried module owns are its manifest's, as the controller composes them, never what runs of it (issue + // 1126, seed-8 #542: on ace, the folded f1-circuits, snake and txt-game, whose manifests leave their directories and + // files to ${dir:…}, owned their /var/lib paths on the node and none on the controller, shifting node-tools' + // MESH_OWNED_PATHS). f1-circuits' manifest at the indexer's commit (c0ea2c94) is captured; what runs of it is + // composed from that manifest by the controller's rule (the directory at /var/lib/f1-circuits, its file, its container). + for _, c := range running { + if c.Module == "" && !reflect.DeepEqual(compose.Carried(in.Catalogue["spiegel"], "spiegel", &c).OwnsPaths, []string{"/var/lib/spiegel"}) { + t.Errorf("spiegel carried owns %v, want its manifest's /var/lib/spiegel alone", compose.Carried(in.Catalogue["spiegel"], "spiegel", &c).OwnsPaths) + } + } + f1raw, err := os.ReadFile("testdata/f1-circuits-c0ea2c94.module.json") + if err != nil { + t.Fatal(err) + } + f1, err := catalogue.Decode(f1raw) + if err != nil { + t.Fatal(err) + } + f1runs := delivery.Composed{Node: "ace", Resources: []json.RawMessage{ + json.RawMessage(`{"id":"f1-circuits.state","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits","mode":"0700"}`), + json.RawMessage(`{"id":"f1-circuits.cache","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits/cache","mode":"0700"}`), + json.RawMessage(`{"id":"f1-circuits.site-env","module":"f1-circuits","type":"file","path":"/var/lib/f1-circuits/site.env","mode":"0644","content":"F1_CIRCUITS_BASE_URL=https://f1.example\n"}`), + json.RawMessage(`{"id":"f1-circuits.server","module":"f1-circuits","type":"container","name":"f1-circuits","image":"registry.example/f1@sha256:` + strings.Repeat("c", 64) + `"}`)}} + if got, want := resolution.DeclaredPaths(compose.Carried(f1, "f1-circuits", &f1runs)), resolution.DeclaredPaths(f1); !reflect.DeepEqual(got, want) { + t.Errorf("f1-circuits carried owns %v, want what the controller composes from its manifest, %v", got, want) + } + // Issue 1126: a held module hands no template its build kept for a file it no longer carries (novox's node: + // "postgres: its build kept a template for consumers"), but keeps its items and their templates, so what it + // contributes to other modules' collections still reaches them. + for _, row := range []struct { + name string + handed *catalogue.Handed + items []catalogue.Item + }{ + {"templates and items", &catalogue.Handed{Templates: map[string]string{"consumers": "files/consumers.json"}, + Items: map[string]string{"routes": "files/routes.tmpl"}}, []catalogue.Item{{Collection: "routes", Content: "x"}}}, + {"items alone", nil, []catalogue.Item{{Collection: "routes", Content: "x"}}}, + } { + kept := f1 + kept.Handed, kept.Items = row.handed, row.items + st := compose.Stub(kept) + if st.Handed != nil && st.Handed.Templates != nil { + t.Errorf("%s: a stub hands templates %v", row.name, st.Handed.Templates) + } + if !reflect.DeepEqual(st.Items, row.items) { + t.Errorf("%s: a stub collects %+v, want its items %+v", row.name, st.Items, row.items) + } + if row.handed != nil && (st.Handed == nil || !reflect.DeepEqual(st.Handed.Items, row.handed.Items) || row.handed.Templates == nil) { + t.Errorf("%s: a stub hands item templates %+v, want %v and its manifest untouched", row.name, st.Handed, row.handed.Items) + } + } + var k1, k2 any + _ = json.Unmarshal(keptComposed, &k1) + _ = json.Unmarshal(keptFile, &k2) + if !reflect.DeepEqual(k1, k2) { + t.Errorf("the operator export is composed as %s, want the file that runs %s", keptComposed, keptFile) + } + + // (D) Issue 1126, seed-8 (mesh-catalog #542): a module's own refusal refuses that module, never the node. novox runs + // de-spiegel folded, and its manifest at the indexer's commit (e25e1beb, captured) asks four required config values + // for its route names that nothing sets. novox answered the whole candidate refused ("de-spiegel contributing + // praktijk to route says ${setting:praktijk-name} ...": its stub still contributed the route). Only de-spiegel is + // refused, in the config form's words; the rest is judged. Issue 1960, novox's chain on 2026-10-29: the hold spread + // to mailu (its smtp provider) and proxy (its route provider), and from them on. Neither is held now: proxy composes + // without de-spiegel's routes, which do not compose, and mailu with its smtp account, which does. + spiegel, err := os.ReadFile("testdata/de-spiegel-e25e1beb.module.json") + if err != nil { + t.Fatal(err) + } + proxy := []byte(`{"module":"proxy","version":"1","capabilities":["systemd"],"provides":[{"name":"route","scope":"mesh"}],` + + `"resources":[{"id":"service","type":"service","unit":"proxy.service","state":"running","boot":"enabled"}]}`) + mailu := []byte(`{"module":"mailu","version":"1","capabilities":["systemd"],"provides":[{"name":"smtp","scope":"mesh"}],` + + `"resources":[{"id":"service","type":"service","unit":"mailu.service","state":"running","boot":"enabled"}]}`) + b = mk("lemurs", overlay.Name, "mailu", "proxy", "de-spiegel") + for m, raw := range map[string][]byte{"de-spiegel": spiegel, "proxy": proxy, "mailu": mailu} { + b.manifests[delivery.ManifestDigest(raw)] = raw + b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)} + } + p = b.placements["n1"] + p.Decided.Endpoint = "n1.example:51820" + b.placements["n1"] = p + fps = map[string]string{"lemurs": fp("e"), "proxy": fp("f"), "mailu": fp("d")} + a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"de-spiegel"}}, fps, nil, "p-h1", 3) + refused = nil + for _, r := range a.Refusals { + refused = append(refused, r.Module) + if r.Module == "de-spiegel" && (!strings.Contains(r.Why, "${config:praktijk-name}") || strings.Contains(r.Why, "${setting:")) { + t.Errorf("de-spiegel is refused in other words than its config form's: %s", r.Why) + } + } + if a.Refused != "" || !reflect.DeepEqual(refused, []string{"de-spiegel"}) { + t.Errorf("a module's own refusal: %+v, want de-spiegel refused alone, the node, mailu and proxy not", a) + } + for _, m := range []string{"lemurs", "mailu", "proxy"} { + if !slices.ContainsFunc(a.Added, func(x delivery.ModuleAdded) bool { return x.Module == m }) { + t.Errorf("%s, which composes, is not judged beside de-spiegel's refusal: %+v", m, a) + } + } + + // (E) Issue 1126, the applying path (review of the seed): the composition novox applies follows the same rule. It + // composed with no culprit, so de-spiegel's unset route names refused the whole node and every module it runs was + // marked not composed. Now de-spiegel is held, its refusal said in its config form's words, with proxy, which + // receives its routes; lemurs composes, and the node's own composition carries de-spiegel as it runs, so applying it + // removes nothing of it. + spiegelRuns := []json.RawMessage{ + json.RawMessage(`{"id":"de-spiegel.state","type":"directory","path":"/var/lib/de-spiegel","mode":"0755"}`), + json.RawMessage(`{"id":"de-spiegel.server-env","type":"file","path":"/var/lib/de-spiegel/server.env","mode":"0600","content":"A=1\n"}`), + json.RawMessage(`{"id":"de-spiegel.server","type":"container","name":"de-spiegel","image":"registry.example/de-spiegel@sha256:` + strings.Repeat("c", 64) + `"}`)} + if in, err = Input(ctx, b, Mesh{}, stub); err != nil { + t.Fatal(err) + } + in.Nodes[0].Kept = map[string]catalogue.Manifest{"de-spiegel": compose.Stub(in.Catalogue["de-spiegel"])} + in.Nodes[0].Kept["de-spiegel"] = func(m catalogue.Manifest) catalogue.Manifest { + m.Contributes, m.ContributesMany = nil, nil + return m + }(in.Nodes[0].Kept["de-spiegel"]) + if base, _, err = compose.Compositions(in, "n1", fps, compose.Reading); err != nil { + t.Fatal(err) + } + running = nil + for _, c := range base { + if c.Module == "" { + c.Resources = append(append([]json.RawMessage{}, c.Resources...), spiegelRuns...) + } + running = append(running, c) + } + got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"de-spiegel"}, Running: running}, fps) + if err != nil { + t.Fatalf("de-spiegel's own refusal refuses the node's whole composition: %v", err) + } + if len(held) != 1 || !strings.Contains(held["de-spiegel"], "${config:praktijk-name}") || !strings.Contains(held["de-spiegel"], "without what it contributes to route") { + t.Errorf("held %v, want de-spiegel alone, in its config form's words, composed without its routes", held) + } + for _, m := range []string{"lemurs", "mailu", "proxy"} { + if key, _ := delivery.ComposedKey("n1", m, fps[m]); got[key].Module != m { + t.Errorf("%s is not composed beside de-spiegel's hold: %v", m, sortedKeys(got)) + } + } + carries := func(c delivery.Composed, what string) { + t.Helper() + ownRes, _ := json.Marshal(c.Resources) + for _, raw := range spiegelRuns { + var r struct{ ID string } + _ = json.Unmarshal(raw, &r) + if !strings.Contains(string(ownRes), `"id":"`+r.ID+`"`) { + t.Errorf("%s: the node's own composition drops %s, which de-spiegel runs: %s", what, r.ID, ownRes) + } + } + } + carries(got[ownKey], "folded") + // (F) Leaving the fold (review of the apply, round 1, finding 1): de-spiegel's want names a build of the new form, + // so it is no longer folded, while its names are still unset and the node's own still runs it. Held, it is carried + // in the node's own as it runs until a composition of its own is handed: else the node's own apply removes its + // container, files and units (issue 1945 by a new path). + fps["de-spiegel"] = fp("7") + got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: running}, fps) + if err != nil || !strings.Contains(held["de-spiegel"], "${config:praktijk-name}") { + t.Fatalf("de-spiegel leaving the fold: %v, held %v", err, held) + } + spiegelKey, _ := delivery.ComposedKey("n1", "de-spiegel", fps["de-spiegel"]) + if _, ok := got[spiegelKey]; ok { + t.Errorf("held de-spiegel's own composition is handed") + } + carries(got[ownKey], "leaving the fold") + + // (G) A held module that runs a composition of its own (review of the apply, round 1, finding 2): what the others + // compose of it is what runs, never its stub's. site listens on 8080, placed at the machine's 20005, and its own + // secret was never issued here: held, the filter must not open 8080 and drop the 20005 that runs. The filter, which + // reads what site listens on, composes with it (issue 1960: a hold never spreads). + site := []byte(`{"module":"site","version":"1","capabilities":["container-runtime"],` + + `"listens":[{"port":8080,"from":"mesh","why":"the site"}],"own-secrets":{"key":"${dir:state}/key"},` + + `"resources":[{"id":"state","type":"directory","path":"/var/lib/site","mode":"0700"},` + + `{"id":"server","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}]}`) + b = mk("lemurs", overlay.Name, "site", "nft") + for m, raw := range map[string][]byte{"site": site, "nft": nft} { + b.manifests[delivery.ManifestDigest(raw)] = raw + b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)} + } + p = b.placements["n1"] + p.Decided.Endpoint = "n1.example:51820" + for i := range p.Modules { + if p.Modules[i].Module == "site" { + p.Modules[i].Ports = map[string]int{"8080": 20005} + } + } + b.placements["n1"] = p + rec = b.records["n1"] + rec.OutwardLinks = []string{"eth0"} + b.records["n1"] = rec + fps = map[string]string{"lemurs": fp("e"), "nft": fp("f"), "site": fp("8")} + siteRuns := delivery.Composed{Node: "n1", Module: "site", FP: fps["site"], Resources: []json.RawMessage{ + json.RawMessage(`{"id":"site.state","module":"site","type":"directory","path":"/var/lib/site","mode":"0700"}`), + json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["10.77.0.1:20005:8080"]}`)}} + got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps) + nftKey, _ := delivery.ComposedKey("n1", "nft", fps["nft"]) + if err != nil || held["site"] == "" || held["nft"] != "" || got[nftKey].Module != "nft" { + t.Fatalf("site, its own secret never issued: %v, held %v; want site held alone, and the filter composed", err, held) + } + if rules, _ := json.Marshal(got[nftKey].Resources); !strings.Contains(string(rules), "dport 20005") { + t.Errorf("the filter drops held site's opening where it runs (20005): %s", rules) + } + for key, c := range got { + for _, raw := range c.Resources { + if strings.Contains(string(raw), "dport 8080") || (c.Module == "nft" && !strings.Contains(string(raw), "dport 20005") && strings.Contains(string(raw), "# site")) { + t.Errorf("%s composes held site from its stub, not from what runs: %s", key, raw) + } + } + } + // (G2) Issue 1126, the spreading hold: a held module is no dependence of its neighbours. site, held, provides page, and + // app requires and binds it: app composes against what runs of site and is not held (novox's seed held every postgres + // consumer behind de-spiegel's hold, "what it composes of …, held, is not what runs of them"). + provider := []byte(strings.Replace(string(site), `"listens":`, `"provides":[{"name":"page","scope":"node"}],"serves":{"page":{"port":8080}},"listens":`, 1)) + app := []byte(`{"module":"app","version":"1","capabilities":["systemd"],"requires":["page"],"binds":{"page":"/etc/app/page.json"},` + + `"resources":[{"id":"service","type":"service","unit":"app.service","state":"running","boot":"enabled"}]}`) + b2 := mk("lemurs", overlay.Name, "site", "nft", "app") + for m, raw := range map[string][]byte{"site": provider, "nft": nft, "app": app} { + b2.manifests[delivery.ManifestDigest(raw)] = raw + b2.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)} + } + p2 := b2.placements["n1"] + p2.Decided.Endpoint = "n1.example:51820" + for i := range p2.Modules { + if p2.Modules[i].Module == "site" { + p2.Modules[i].Ports = map[string]int{"8080": 20005} + } + } + b2.placements["n1"] = p2 + b2.records["n1"] = rec + b2.held = true + fps["app"] = fp("9") + got, _, held, err = Compose(ctx, b2, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps) + appKey, _ := delivery.ComposedKey("n1", "app", fps["app"]) + if _, composed := got[appKey]; err != nil || held["site"] == "" || held["app"] != "" || !composed { + t.Errorf("a consumer of held site: %v, held %v; want site held, and app composed against what runs of it", err, held) + } + if bound, _ := json.Marshal(got[appKey].Resources); !strings.Contains(string(bound), "20005") { + t.Errorf("app's binding does not reach held site where it runs (20005): %s", bound) + } + delete(fps, "app") + // A held module's offer is left out by provision, never whole when one part composes (issue 1960): de-spiegel's + // routes, its names unset, leave the proxy without them, and its smtp account stays with mailu. + withProxy := compose.Input{Catalogue: map[string]catalogue.Manifest{}, + Nodes: []compose.Node{{Name: "n1", Assigned: []string{"feeder"}}}} + feeder := catalogue.Manifest{Module: "feeder", Contributes: map[string]map[string]any{"route": {"name": "x"}, "smtp": {"account": "feeder"}}, + OwnSecrets: catalogue.OwnSecrets{"key": {Path: "/k"}}} + for _, row := range []struct { + left map[string]bool + route, smtp, own bool + }{{nil, true, true, true}, {map[string]bool{"route": true}, false, true, true}, {map[string]bool{leaveOwn: true}, true, true, false}, {map[string]bool{leaveAll: true}, false, false, false}} { + k := heldAs(withProxy, "n1", "feeder", feeder, row.left, nil).Nodes[0].Kept["feeder"] + if _, r := k.Contributes["route"]; r != row.route || (k.Contributes["smtp"] != nil) != row.smtp || (len(k.OwnSecrets) > 0) != row.own { + t.Errorf("held leaving out %v offers %+v, own secrets %v", row.left, k.Contributes, k.OwnSecrets) + } + } + // (H) Review of the apply, round 2: a held module is composed from its new manifest, so what the others read of its + // manifest (its listens, what it contributes) may not be what runs. site's new manifest moves it to 9090 while the + // build that runs (7) listens on 8080. The filter composes with what site last offered, its manifest at the build + // that runs (issue 1960: a hold never spreads), so it opens 8080 and nothing applied opens 9090. + moved := []byte(strings.Replace(string(site), `"port":8080`, `"port":9090`, 1)) + b.manifests[delivery.ManifestDigest(moved)] = moved + b.modules["site"] = delivery.Module{Manifest: delivery.ManifestDigest(moved)} + for i := range p.Modules { + p.Modules[i].Ports = nil + } + b.placements["n1"] = p + siteRuns.Resources[1] = json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}`) + siteRuns.FP = fp("7") + b.manifests[delivery.ManifestDigest(site)] = site + b.builds["site "+fp("7")] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: delivery.ManifestDigest(site)} + got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps) + if err != nil || held["site"] == "" || held["nft"] != "" { + t.Errorf("site moved to 9090 in its new manifest: %v, held %v; want site held alone", err, held) + } + if rules, _ := json.Marshal(got[nftKey].Resources); !strings.Contains(string(rules), "dport 8080") { + t.Errorf("the filter drops what held site last offered (8080): %s", rules) + } + for key, c := range got { + for _, raw := range c.Resources { + if strings.Contains(string(raw), "dport 9090") { + t.Errorf("%s opens 9090, which held site's new manifest names and nothing runs: %s", key, raw) + } + } + } +} + +// A module another node cannot run in this composition offers nothing in it: the refusal names it and why +// (issue 1126, probe #532: ace and g14 answered "nothing in this mesh provides artifact-store … assign distribution to a +// node" while novox runs it). And a refusal no module's stub changes is answered at once, not after a compose per module +// (ace took 7m46s). +func otherNodeNotResolvedIsNamed(t *testing.T) { + ctx := context.Background() + prov := []byte(`{"module":"prov","version":"1","capabilities":["container-runtime"],"provides":[{"name":"thing","scope":"mesh"}]}`) + cons := []byte(`{"module":"cons","version":"1","requires":["thing"]}`) + // n2 runs prov, which needs a container runtime n2's record does not say it has: n2's set does not resolve here. + b := &bus{placements: map[string]delivery.Placement{ + "n1": {Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Endpoint: "n1.example:51820"}, Modules: []delivery.PlacedModule{{Module: "cons"}, {Module: overlay.Name}}}, + "n2": {Node: "n2", Decided: delivery.NodeDecided{Address: "10.77.0.2"}, Modules: []delivery.PlacedModule{{Module: "prov"}, {Module: overlay.Name}}}}, + records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t)}, "n2": {Node: "n2", TunnelKey: publicKey(t)}}, + modules: map[string]delivery.Module{"prov": {Manifest: delivery.ManifestDigest(prov)}, "cons": {Manifest: delivery.ManifestDigest(cons)}}, + manifests: map[string][]byte{delivery.ManifestDigest(prov): prov, delivery.ManifestDigest(cons): cons}} + given := map[string]*delivery.Placement{} + for n, p := range b.placements { + p := p + given[n] = &p + } + a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given}, map[string]string{"cons": "src2:" + strings.Repeat("1", 64)}, nil, "p-h1", 1) + if a.Verdict != delivery.CandidateRefused || !strings.Contains(a.Refused, "prov on n2 lacks container-runtime") { + t.Errorf("a refusal for want of n2's provider = %+v", a) + } +} + +// A node that reaches no artifact store says what its composition holds of one (issue 1126: ace and g14 answered +// "reaches no artifact store", and nothing said why). +func noStoreReachedSaysWhy(t *testing.T) { + in := compose.Input{Catalogue: map[string]catalogue.Manifest{ + "distribution": {Module: "distribution", Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5000}}}}, + Nodes: []compose.Node{{Name: "g14", Assigned: []string{"adwaita"}}, {Name: "novox", Assigned: []string{"distribution"}}}} + if got := storeSought(in); got != "the composition holds g14, novox; artifact-store is served by distribution on novox, none of it on the private network with a port" { + t.Errorf("said %q", got) + } + in.Nodes = in.Nodes[:1] + if got := storeSought(in); got != "the composition holds g14; no module placed there serves artifact-store" { + t.Errorf("said %q", got) + } +} diff --git a/internal/compose/frombus/export.go b/internal/compose/frombus/export.go new file mode 100644 index 0000000..8726c0f --- /dev/null +++ b/internal/compose/frombus/export.go @@ -0,0 +1,112 @@ +package frombus + +import ( + "fmt" + "sort" + "strconv" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/compose" +) + +// Export is one node's placement as the composition input the controller fills says it (novox/hq ADR 0555 §6, ADR 0567 +// §7): the inverse of Input for what a person decides, so the controller's one read-only verb `placement-export` +// answers, from its own store by its own code, exactly what the seat's adopt proposes as the seed. Its answer must stay +// stable for the same store, since adopt checks its digest again at the warrant. +// What the machine observes (its record) is not in it. +func Export(in compose.Input, node string) (delivery.Placement, error) { + var n *compose.Node + for i := range in.Nodes { + if in.Nodes[i].Name == node { + n = &in.Nodes[i] + } + } + if n == nil { + return delivery.Placement{}, fmt.Errorf("frombus: the input has no node %q", node) + } + // At the version a placement key is read at, with a list of modules even when none is placed: the export is the + // seed's value, and the reader refuses a value of version 0 or without its modules. + p := delivery.Placement{V: delivery.Version, Node: node, Modules: []delivery.PlacedModule{}, Decided: delivery.NodeDecided{ + Endpoint: n.Place.Endpoint, Site: n.Place.Site, Hub: n.Place.Hub, Address: n.Place.Address, + PublicDomain: n.PublicDomain, Account: n.Account, AccountHome: n.AccountHome, AgentAccount: n.AgentAccount, + AgentHome: n.AgentAccountHome, Adopted: n.Adopted, Taken: n.Taken}} + // The tunnel found at the node's adoption: the one its private network took over, or, on the hub, the mesh's adopted + // one whether or not the hub took it; and the names a person gave the hub's carried peers. + if t, ok := in.Tunnels[node]; ok { + rt := recordTunnelOf(t.Tunnel) + p.Decided.Tunnel = &rt + } else if in.Adopted != nil && in.Adopted.Hub == node { + rt := recordTunnelOf(in.Adopted.Tunnel) + p.Decided.Tunnel = &rt + } + if n.Place.Hub { + for _, c := range in.CarriedPeers { + if c.Named != "" { + if p.Decided.PeerNames == nil { + p.Decided.PeerNames = map[string]string{} + } + p.Decided.PeerNames[c.PublicKey] = c.Named + } + } + } + for _, module := range n.Assigned { + m := delivery.PlacedModule{Module: module} + for port, at := range n.Ports[module] { + if m.Ports == nil { + m.Ports = map[string]int{} + } + m.Ports[strconv.Itoa(port)] = at + } + for provision, to := range n.Bound[module] { + if m.Bindings == nil { + m.Bindings = map[string]delivery.Provider{} + } + m.Bindings[provision] = delivery.Provider{Node: to.Node, Module: to.Module} + } + // A module in the settings form keeps its config values in its layers, not in mesh-config: they are exported with + // the placement. A module in the config form has mesh-config's values laid into its layer, which are not. + settingsForm := !in.Catalogue[module].DeclaresConfig() + for _, layer := range n.Settings[module] { + if layer.From == catalogue.MeshWideLayer { + m.HasMesh, m.MeshWide = true, layer.Placement + if settingsForm && len(layer.Values) > 0 { + m.MeshValues = layer.Values + } + } else { + m.HasLayer, m.Layer = true, layer.Placement + if settingsForm && len(layer.Values) > 0 { + m.Values = layer.Values + } + } + } + p.Modules = append(p.Modules, m) + } + for provision, to := range n.Pins { + if p.Pins == nil { + p.Pins = map[string]delivery.Provider{} + } + p.Pins[provision] = delivery.Provider{Node: to.Node, Module: to.Module} + } + for _, h := range in.Holdings { + if h.Node == node { + p.Holds = append(p.Holds, delivery.Holding{Claim: h.Claim, Scope: h.Scope, Module: h.Module, Site: h.Site, Kind: h.Kind}) + } + } + sort.SliceStable(p.Holds, func(i, j int) bool { + if p.Holds[i].Claim != p.Holds[j].Claim { + return p.Holds[i].Claim < p.Holds[j].Claim + } + return p.Holds[i].Module < p.Holds[j].Module + }) + return p, nil +} + +func recordTunnelOf(t compose.Tunnel) delivery.RecordTunnel { + out := delivery.RecordTunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU, + Address: t.Address, Range: t.Range, PublicKey: t.PublicKey} + for _, peer := range t.Peers { + out.Peers = append(out.Peers, delivery.RecordTunnelPeer{PublicKey: peer.PublicKey, Address: peer.Address}) + } + return out +} diff --git a/internal/compose/frombus/frombus.go b/internal/compose/frombus/frombus.go new file mode 100644 index 0000000..f063f49 --- /dev/null +++ b/internal/compose/frombus/frombus.go @@ -0,0 +1,1168 @@ +// Package frombus fills mesh-sdk's composition input from what is on the bus, so a node agent, the composition check +// and the grants part compose with one composer over the same inputs (novox/hq ADR 0547 §2, ADR 0549 §3; issue 1126). +// +// **One composer, three callers.** compose.Compositions is the composer; Input is how each caller fills its +// compose.Input from the bus: the node agent for its own node (ADR 0547 §2), the seat mesh-placement's holder judging a +// proposal for every node (ADR 0567 §4), and the grants part (ADR 0547 §5). Each reads the bus with its own credential +// through a Reader, so this package holds no bus client, no store and no timer: what it is given is all it reads. +// +// **What it reads, and from whom.** +// - which modules run on each node, with their pins, holdings, host ports, bindings and settings placement: the +// seat mesh-placement's state `placement`, one key per node (ADR 0567 §1); +// - what a person decided of each node (its endpoint, site, hub, address on the private network, public domain and +// accounts): the same placement key, Placement.Decided; +// - what each node observes of itself (its tunnel key, capabilities, keys, certificate, links and networks): the +// node's own have key, `.node` (HaveNode.Record); the tunnel found at a node's adoption and the names of the hub's peers are decided, +// in its placement, and whether the hub took the tunnel over and who enrolled under a peer's key are read from both; +// - every module's manifest: the indexer's `modules.` and its manifests by digest; for each module placed on +// the composing node, its built manifest instead: the builder's done key at the trunk's FP2, or at the fingerprint +// the node's want names where it runs another build (Scope.KeptAt), resolved by the controller's own take-in rule +// (takein.Manifest), a moved module's templates archive read in the artifact store (issue 1126); +// - each module's config values on a node: mesh-config's resolved state, the composing node's slice alone unless the +// caller composes every node (ADR 0547 §2, the prepare step's L4); of another node, only the keys its modules' +// served values fill (catalogue.ServedSettings), which every consumer is told (issue 1126). +// +// **Own secrets and pair credentials** come from the vault sealed to the composing node's key (OwnSecrets, Credentials), +// for that node alone: as a consumer and as a provider, exactly the keys compose.CredentialsNeeded names. **Not yet +// read** (each a later subtask of issue 1126): licences (none is placed today) and core boxes. Without one +// the composition stands in for it, and Compose holds the module it stands in for (or refuses the node, for a stand-in +// of no module placed there; compose.ErrStoodIn): a node never applies a stand-in. No node is joining before ADR 0547's third milestone (ADR 0550 §6), so Joining is +// always empty. +package frombus + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "net/netip" + "slices" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/declaration" + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/compose" + "github.com/novox/mesh-host/internal/compose/resolution" + "github.com/novox/mesh-host/internal/overlay" + "github.com/novox/mesh-host/internal/takein" +) + +// Reader is the bus as a caller reads it with its own credential. Each method reads one part's state; an error is a +// read that failed, never an absent key (an absent key is an empty answer). +type Reader interface { + // Placements is every node's placement, by node (delivery.PlacementBucket). + Placements(ctx context.Context) (map[string]delivery.Placement, error) + // Records is every node's record, by node: HaveNode.Record of each node's own have key, `.node` of + // delivery.HaveBucket (ADR 0555 §3). + Records(ctx context.Context) (map[string]delivery.NodeRecord, error) + // Modules is every module the indexer holds, by module (delivery.ModulesBucket). + Modules(ctx context.Context) (map[string]delivery.Module, error) + // Manifest is the raw manifest of a digest (delivery.ManifestsBucket). + Manifest(ctx context.Context, digest string) ([]byte, error) + // Values is one module's config values on one node as mesh-config resolved them (mesh-config's state `resolved`, + // keys `..`), decoded; nil when it has none. + Values(ctx context.Context, node, module string) (map[string]any, error) + // OwnSecrets is the own secrets of the composing node's modules, each sealed to that node's key, as the vault answers + // the node's engine (the vault's verb `own`, ADR 0550 §5); asked only for the node composing itself. + OwnSecrets(ctx context.Context, node string) ([]compose.OwnSecretRow, error) + // Credentials is, for the composing node, each pair credential it reads as a consumer, held and sealed to its key, + // and each it grants as a provider, with the copy sealed to it (the vault's `own` with the keys, ADR 0550 §5). A key + // the vault holds no value for is left out; the composition then stands in for it, and Compose refuses it. + Credentials(ctx context.Context, node string, asConsumer, asProvider []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) + // Build is a module's build at a fingerprint, the builder's key (delivery.BuilderBuildsBucket, BuilderBuildKey); nil + // when the builder holds none. The composing node composes each of its modules from its build (issue 1126). + Build(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) + // Archive is the archive at url, a kept reference routed to the artifact store as the composing node reaches it + // (catalogue.Routed), checked against digest: a moved module's templates archive, read back for its texts as the + // controller's take-in reads it (takein.Built). + Archive(ctx context.Context, url, digest string) ([]byte, error) + // Served is, of one module's config values on another node than the composing one (mesh-config's state `resolved`, + // keys `..`), the keys named, decoded: the keys its served values and zone name fill + // (catalogue.ServedSettings), which every consumer is told and so every node may read (issue 1126). Nil when none + // is set. + Served(ctx context.Context, node, module string, keys []string) (map[string]any, error) +} + +// SignersReader is a Reader that also reads the vault's public authority from its state `signers` (delivery.SignersBucket, +// key delivery.SignersAuthorityKey; ADR 0550 §2). A Reader that has it composes the mesh's authority as a bundle, the +// controller's and the vault's; one that has not, or that answers none, composes the controller's alone, as before. +type SignersReader interface { + // VaultAuthority is the vault's authority certificate as PEM, checked (delivery.DecodeSignerAuthority); empty when the + // vault has published none. A read that fails is said by the reader and answered empty: a node that may not read the + // state yet composes as before rather than not at all. + VaultAuthority(ctx context.Context) string +} + +// HandedReader is a Reader that also says, of the composing node's modules, which the vault judged handed to the +// provisioner when it answered OwnSecrets (the vault's `own`, its `handed`: catalogue.AssignmentHands of the plan in +// each module's assignment). The node's composition then hands exactly those (compose.Node.Handed, issue 774), so the +// vault and the node never judge one module two ways. Nil, or a Reader without it (a vault older than the field), +// composes by the manifest and the machine, as before. +type HandedReader interface { + Handed(ctx context.Context, node string) map[string]bool +} + +// Mesh is what is true of the whole mesh and is no part's state on the bus yet: the private network's range and the +// internal names' suffix as genesis was told them, and the mesh's authority certificate (public). Empty fields take +// compose's defaults. +type Mesh struct { + OverlayCIDR string + InternalSuffix string + Authority string + // Predefined is the list of predefined keys mesh-config published. No longer read: a reader decodes a manifest and + // takes the indexer's verdict on it, and judges nothing (novox/hq ADR 0578 §3); kept so a caller that sets it builds. + Predefined catalogue.PredefinedList +} + +// Scope says who composes. Node is the node composing; only its config values are read unless Every is set, as the +// composition check sets it to compose every node (of another node's modules, only the values their served values fill +// are read, Reader.Served). KeptAt is, by module, the fingerprint the composing node's want names where it differs from +// the indexer's trunk: that node composes the module from its build at that fingerprint, as it runs it. +// +// **Composed from its build** (issue 1126): each module placed on the composing node, and each in KeptAt, composes from +// its built manifest (takein.Manifest over the builder's done key at the trunk's FP2 or the kept fingerprint), as the +// controller composes from the manifest its take-in resolved: so a moved module is handed to the provisioner, a resource +// naming an artifact names what was built, and a templated file has its text. A placed module the builder holds no done +// build of at the trunk's FP2 composes from the indexer's manifest, as before. Every leaves the catalogue as the indexer +// read it: the check of every node judges placement, and reads no archive. +// +// Placements, when set, stands in for the placement of the nodes it names: a proposal's candidates +// (delivery.PlacementCandidate), so a node composes in shadow with a proposal laid over and compares the result with +// what it runs (ADR 0567 §3); a nil value removes the node's placement. The rest is read as always. +// +// InOwn is the modules the composing node runs inside its own composition (InOwn of what it runs), composed there again +// (compose.Node.InOwn) rather than cut out as modules no build is given for (issue 1126). +// +// Running is what the composing node runs; of it, its own composition (`.node`) is given to the composition +// (compose.Node.RunsOwn), so a module folded there that names a build of the old form is left to the node, carried +// as it runs (issue 1126). Nil composes every folded module from its manifest. +type Scope struct { + Node string + Every bool + KeptAt map[string]string + Placements map[string]*delivery.Placement + InOwn []string + Running []delivery.Composed + // HeldToday is, in an answer, the modules the node keeps today as they run ("node" for its own composition), each with + // how ("held", "failed", "runs a build of the old form"): a candidate that cannot compose one, or holds one only + // behind one of them, keeps it as today, which is said under CandidateAnswer.Held and is no refusal (issue 1126). + HeldToday map[string]string + // SameValue, in an answer, says whether two sealed values open to the same value (the node opens them with its own + // key), so a differing resource shows a value sealed again as such (delivery.CompareComposedWith). Nil shows them + // as differing. + SameValue func(a, b string) (same, known bool) +} + +// InOwn is, of the modules p places on its node, those whose resources the node's own composition it runs +// (`.node` in running) carries, by the module their ids name (declaration.ModuleGuess), sorted: the modules the +// controller's composed writer folded there because no fingerprint of the new form names them (mesh-wireguard, a module +// run at a build of the old form; issue 1126). A node composing itself passes it as Scope.InOwn, so its own +// composition keeps them as it runs them until a want names a build of the new form for one. +func InOwn(running []delivery.Composed, p delivery.Placement) []string { + placed := map[string]bool{} + for _, m := range p.Modules { + placed[m.Module] = true + } + in := map[string]bool{} + for _, c := range running { + if c.Module != "" { + continue + } + for _, raw := range c.Resources { + var head struct { + ID string `json:"id"` + Module string `json:"module"` + } + if json.Unmarshal(raw, &head) != nil || head.Module != "" { + continue + } + if m, ok := declaration.ModuleGuess(head.ID); ok && placed[m] { + in[m] = true + } + } + } + return sortedKeys(in) +} + +// Input is the composition input for s, read through r. +// +// **A refused manifest never ends the read** (novox/hq ADR 0578 §3): each manifest is decoded leniently +// (catalogue.Decode) and the indexer's verdict on it taken from its modules value (delivery.TakeManifestVerdict, a missing +// one accepted); a module whose manifest the indexer's judge refused, or whose bytes do not decode, stands in the +// catalogue as its stub (unusable), so what it provides and requires still shapes the rest, and is named with why in +// the input's Refused; a field set aside is named in its SetAside. Every other module of the read goes on. +func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, error) { + return input(ctx, r, mesh, s, nil, nil) +} + +// taken is a module's manifest as a reader takes it (novox/hq ADR 0578 §3): decoded leniently, with the indexer's verdict +// from its modules value. A refused one, or bytes that do not decode, stands as its stub (unusable) and why says so; a +// manifest not read past a field it set aside holds its name alone and is held the same. +func taken(name string, raw []byte, m delivery.Module) (man catalogue.Manifest, why string) { + if v := m.VerdictOf(name); v != nil { + if refused, _ := delivery.TakeManifestVerdict(v); refused { + return unusable(name, raw), v.Why() + } + } + man, err := catalogue.Decode(raw) + switch { + case err != nil: + return unusable(name, raw), fmt.Sprintf("the manifest of %s does not decode: %v", name, err) + case man.ReadNoFurther(): + return unusable(name, raw), fmt.Sprintf("the manifest of %s could not be read past a field this reader does not "+ + "know: %s", name, man.SetAside()) + } + return man, "" +} + +// input is Input; with refuse set, a module placed on the composing node whose build cannot be read or taken in, or whose +// manifest the indexer refused, is also handed to refuse and composed as a stub (compose.Stub, kept for that node alone), +// so a node's answer names every module it cannot compose (issue 1126, issue 774); a module placed elsewhere whose +// manifest is refused is read for its offer alone (unusable). A manifest the bus does not answer for still ends the +// read: that is the bus's, not the module's. +func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module string, err error), wanted map[string]string) (compose.Input, error) { + b, err := readBase(ctx, r, mesh, s) + b.refuseOwn(refuse) + if err != nil { + return compose.Input{}, err + } + return b.finish(ctx, r, r.Build, mesh, s, b.in, refuse, wanted) +} + +// base is the part of a node's input that the fingerprints it composes its modules at leave alone (issue 1954): read once +// for its plans at the wanted builds and at the newest, whose builds alone differ (PlansHeldTwice). +type base struct { + in compose.Input + placements map[string]delivery.Placement + records map[string]delivery.NodeRecord + modules map[string]delivery.Module + // ownRefused is each module placed on the composing node whose manifest the indexer refused, with why, in the order + // read: every scope holds it. + ownRefused [][2]string +} + +// refuseOwn hands refuse each module placed on the composing node whose manifest is refused. +func (b base) refuseOwn(refuse func(module string, err error)) { + if refuse == nil { + return + } + for _, r := range b.ownRefused { + refuse(r[0], errors.New(r[1])) + } +} + +// cloned is the input with its own catalogue, nodes and kept manifests, what finishing it sets: two scopes finished over +// one base never see each other's builds. +func (b base) cloned() compose.Input { + in := b.in + in.Catalogue = maps.Clone(b.in.Catalogue) + in.Nodes = slices.Clone(b.in.Nodes) + for i := range in.Nodes { + in.Nodes[i].Kept = maps.Clone(in.Nodes[i].Kept) + } + in.KeptCoreBoxes = slices.Clone(b.in.KeptCoreBoxes) + in.OwnSecrets = slices.Clone(b.in.OwnSecrets) + return in +} + +// readBase is input up to the builds: placement, records, the indexer's modules and manifests, each node's values. +func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) { + var b base + placements, err := r.Placements(ctx) + if err != nil { + return b, fmt.Errorf("frombus: placement: %w", err) + } + if len(s.Placements) > 0 { + given := make(map[string]delivery.Placement, len(placements)+len(s.Placements)) + for node, p := range placements { + given[node] = p + } + for node, p := range s.Placements { + if p == nil { + delete(given, node) + continue + } + if p.Node != node { + return b, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node) + } + given[node] = *p + } + placements = given + } + records, err := r.Records(ctx) + if err != nil { + return b, fmt.Errorf("frombus: node records: %w", err) + } + modules, err := r.Modules(ctx) + if err != nil { + return b, fmt.Errorf("frombus: the indexer's modules: %w", err) + } + if _, ok := placements[s.Node]; !ok && !s.Every { + return b, fmt.Errorf("frombus: %s has no placement in the catalogue", s.Node) + } + + in := compose.Input{OverlayCIDR: mesh.OverlayCIDR, InternalSuffix: mesh.InternalSuffix, Authority: mesh.Authority, + Catalogue: map[string]catalogue.Manifest{}} + if in.Authority == "" { + // The mesh's own certificate, public, as the composing node holds it; for the check of every node, as the first + // node holding one does. + if rec, ok := records[s.Node]; ok && rec.Authority != "" { + in.Authority = rec.Authority + } else { + for _, node := range sortedKeys(records) { + if a := records[node].Authority; a != "" { + in.Authority = a + break + } + } + } + } + // And the vault's authority beside it, once the vault publishes one (ADR 0550 §2): a certificate either signed checks + // against the bundle. The controller's stays while a certificate it issued is in a node's record. + if sr, ok := r.(SignersReader); ok { + if vault := sr.VaultAuthority(ctx); vault != "" { + in.Authority = delivery.AuthorityBundle(in.Authority, vault) + } + } + named := func(field *map[string]string, module, why string) { + if *field == nil { + *field = map[string]string{} + } + (*field)[module] = why + } + ownPlaced := map[string]bool{} + for _, pm := range placements[s.Node].Modules { + ownPlaced[pm.Module] = true + } + for _, name := range sortedKeys(modules) { + m := modules[name] + if m.Manifest == "" { + continue // a registered module the indexer has not read: nothing to compose it from + } + raw, err := r.Manifest(ctx, m.Manifest) + if err != nil { + // Not read is no refusal of the module: a bus that does not answer is waited out by the caller. + return b, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", name, m.Manifest, err) + } + // One module's manifest the indexer refused refuses that module alone, by the rule a build that cannot be read + // follows (issue 774: power's bar item refused every node's composition, 2026-10-30; ADR 0578 §3: one refused + // manifest silenced disk-load for 45 hours). It stands in as its stub, so what it provides and requires still + // shapes the rest; it is held on the composing node where it is placed there, and elsewhere only its offer is read. + man, why := taken(name, raw, m) + if why != "" { + named(&in.Refused, name, why) + if ownPlaced[name] { + b.ownRefused = append(b.ownRefused, [2]string{name, why}) + } + } else if aside := man.SetAside(); aside != "" { + named(&in.SetAside, name, aside) + } + in.Catalogue[name] = man + } + + // The module the mesh provides itself (the private network) has no repository the indexer reads: its manifest comes + // with mesh-sdk and always wins, as the controller's providedModules and the grants part have it. + for _, name := range delivery.ProvidedModules() { + raw, err := json.Marshal(overlay.Manifest()) + if err != nil { + return b, err + } + var private catalogue.Manifest + if err := json.Unmarshal(raw, &private); err != nil { + return b, fmt.Errorf("frombus: the manifest mesh-sdk provides for %s: %w", name, err) + } + in.Catalogue[name] = private + } + + var missing []string + for _, node := range nodesOf(placements, records) { + rec, hasRecord := records[node] + p := placements[node] + if !hasRecord { + if len(p.Modules) > 0 { + missing = append(missing, node) + } + continue + } + n := nodeOf(rec, p) + withValues := s.Every || node == s.Node + for _, m := range p.Modules { + var values map[string]any + if withValues { + if values, err = r.Values(ctx, node, m.Module); err != nil { + return b, fmt.Errorf("frombus: %s's config values on %s: %w", m.Module, node, err) + } + } else if keys := catalogue.ServedSettings(in.Catalogue[m.Module]); len(keys) > 0 { + // What a provider elsewhere serves from its settings is told to every consumer: the composing node reads + // those keys of it, and no other (issue 1126: mailu's smtp domain, a consumer's on shanks). + if values, err = r.Served(ctx, node, m.Module, keys); err != nil { + return b, fmt.Errorf("frombus: what %s serves on %s (%s): %w", m.Module, node, + strings.Join(keys, ", "), err) + } + } + if layers := m.Layers(node, values); layers != nil { + if n.Settings == nil { + n.Settings = map[string][]catalogue.Layer{} + } + n.Settings[m.Module] = layers + } + } + if node == s.Node { + // What its runtime serves as it runs: what a module composed as what runs of it contributes there (issue 1126). + n.RunsTools = compose.ToolsRunning(s.Running) + for _, module := range s.InOwn { + if n.InOwn == nil { + n.InOwn = map[string]bool{} + } + n.InOwn[module] = true + } + for i := range s.Running { + // The operator export as the vault runs it: the controller composes it from its store, which the node + // does not read; until the export is on the bus, the node carries the file it runs (issue 1126, C6: + // mesh-vault.kept on novox, "in what runs, and not composed"). + if m := s.Running[i].Module; m != "" { + if kept := keptOf(s.Running[i]); kept != nil { + if in.KeptRuns == nil { + in.KeptRuns = map[string]map[string]map[string]any{} + } + if in.KeptRuns[s.Node] == nil { + in.KeptRuns[s.Node] = map[string]map[string]any{} + } + in.KeptRuns[s.Node][m] = kept + } + continue + } + if s.Running[i].Module == "" { + own := s.Running[i] + n.RunsOwn = &own + // Its bus membership as it runs it: the controller composes it from its store, which the node does + // not read; until the grants part hands memberships sealed to the node, the node's own composition + // carries the one it runs (issue 1126, seed #531 on novox: "bus-membership in what runs, not + // composed", which applied would have taken the node's bus credential away). + if sealed := membershipOf(own); sealed != "" { + if in.BusMemberships == nil { + in.BusMemberships = map[string]string{} + } + in.BusMemberships[s.Node] = sealed + } + } + } + } + in.Nodes = append(in.Nodes, n) + in.Holdings = append(in.Holdings, p.Held()...) + // A tunnel found at adoption is the node's private network's when the node's tunnel key is the tunnel's (the + // controller's inv.Tunnels); the hub's found tunnel is the mesh's adopted one whether or not the hub took it over + // (inv.AdoptedTunnel), and its peers are carried once it did (inv.CarriedPeers). + if t := p.Decided.Tunnel; t != nil { + if rec.TunnelKey == t.PublicKey { + if in.Tunnels == nil { + in.Tunnels = map[string]compose.FoundTunnel{} + } + in.Tunnels[node] = compose.FoundTunnel{Tunnel: tunnelOf(*t), NodeAdopted: p.Decided.Adopted} + } + if p.Decided.Hub { + in.Adopted = &compose.AdoptedTunnel{Tunnel: tunnelOf(*t), Hub: node, Adopted: rec.TunnelKey == t.PublicKey} + } + } + } + if in.Adopted != nil && in.Adopted.Adopted { + in.CarriedPeers = carriedPeers(in.Adopted.Tunnel, placements[in.Adopted.Hub].Decided.PeerNames, records) + } + if len(missing) > 0 { + return b, fmt.Errorf("frombus: modules are placed on %s, whose agent has written no record yet", + strings.Join(missing, ", ")) + } + b.in, b.placements, b.records, b.modules = in, placements, records, modules + return b, nil +} + +// finish is input from its base on: the composing node's modules from their builds (build reads one), its own secrets. +// in is the base's input, or a copy of it (base.cloned) where one base is finished more than once. +func (b base) finish(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error), + mesh Mesh, s Scope, in compose.Input, refuse func(module string, err error), wanted map[string]string) (compose.Input, error) { + var err error + parse := parser(mesh) + read := func(module, digest string) (catalogue.Manifest, error) { + raw, err := r.Manifest(ctx, digest) + if err != nil { + return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", module, digest, err) + } + man, err := parse(raw) + if err != nil { + return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s) does not decode: %w", module, digest, err) + } + return man, nil + } + if !s.Every { + if _, ok := b.records[s.Node]; !ok { + return compose.Input{}, fmt.Errorf("frombus: %s has written no record of itself yet", s.Node) + } + if err := fromBuilds(ctx, r, build, &in, s, b.placements[s.Node], b.modules, read, parse, refuse, wanted); err != nil { + return compose.Input{}, err + } + // Its own modules' secrets, sealed to its own key by the vault: a composition carries them sealed (ADR 0550 §5). + // Asked only when a module placed on it declares one: a node none of whose modules has a secret composes + // whether or not the vault answers. + if declaresOwnSecrets(in, s.Node) { + if in.OwnSecrets, err = r.OwnSecrets(ctx, s.Node); err != nil { + return compose.Input{}, fmt.Errorf("frombus: the own secrets of %s's modules: %w", s.Node, err) + } + // And which of its modules the vault left out of that answer as handed: the node hands exactly those. + if hr, ok := r.(HandedReader); ok { + if handed := hr.Handed(ctx, s.Node); handed != nil { + for i := range in.Nodes { + if in.Nodes[i].Name == s.Node { + in.Nodes[i].Handed = handed + } + } + } + } + } + // The core's credentials travel as the vault's box sealed to the machine (ADR 0349 §16): the same values, which + // the composition reads for a core module as its kept boxes. + for _, o := range in.OwnSecrets { + if json.Valid([]byte(o.Sealed)) { + in.KeptCoreBoxes = append(in.KeptCoreBoxes, compose.CoreBox{Node: o.Node, Module: o.Module, Name: o.Name, + SealedTo: o.SealedTo, Box: json.RawMessage(o.Sealed)}) + } + } + } + return in, nil +} + +// Compose is the node's compositions over the bus (ADR 0547 §2): input for s, then composed at the fingerprints fps +// gives each module in the Reading mode, as the controller composes them, by the one rule its answer to a candidate +// follows too (composeEach, issue 1126): a module refused alone (its build unread or not taken in, its composition +// refused, a stand-in for a value of its own the input does not carry, compose.ErrStoodIn) is held, named in held with +// why, and none of its own composition is in out, so the node keeps what it last applied of it and applies the rest; a +// node never applies a stand-in. Only a refusal that is no single module's (the node's own resources, a value served +// elsewhere, a stand-in for the node's own) refuses the node, err. +func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string) (out map[string]delivery.Composed, skipped []string, held map[string]string, err error) { + held = map[string]string{} + // A node refused whole says what it held on the way, since the refusal can be of a held module's stub (issue 774, + // 2026-10-30 19:30: node-tools held, then "its build produced 0 bundle(s)" with its own refusal unsaid). + defer func() { + if err != nil && len(held) > 0 { + var said []string + for _, m := range sortedKeys(held) { + said = append(said, m+": "+held[m]) + } + err = fmt.Errorf("%w (held before: %s)", err, strings.Join(said, "; ")) + } + }() + in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() }, fps) + if err != nil { + return nil, nil, held, err + } + // The pair credentials its composition reads, asked of the vault sealed to it (ADR 0550 §5). + asConsumer, asProvider, err := compose.CredentialsNeeded(in, s.Node) + if err != nil { + return nil, nil, held, err + } + if len(asConsumer)+len(asProvider) > 0 { + if in.Credentials, in.Issued, err = r.Credentials(ctx, s.Node, asConsumer, asProvider); err != nil { + return nil, nil, held, fmt.Errorf("frombus: the credentials of %s: %w", s.Node, err) + } + } + out, skipped, _, err = composeEach(in, s.Node, fps, true, s.Running, held, ranOf(ctx, r, mesh, s.Running, fps)) + return out, skipped, held, err +} + +// Plans is the provisioning plan of each module handed to the provisioner on the scope's node, composed from the bus +// alone (novox/hq ADR 0570 clause 2): the controller's handedPlans over the same input (mesh-control +// cmd/mesh-controller/compose_sdk.go), its rendering in the Foreseeing mode with the mesh's generators. A plan names +// templates, files, own secrets by name, items, collections and provisions, never a value, so no value is read for it: +// the seat mesh-provisioner's holder composes every node's plans with a Reader that reads no secret. +func Plans(ctx context.Context, r Reader, mesh Mesh, s Scope) (map[string]catalogue.Plan, error) { + in, err := Input(ctx, r, mesh, s) + if err != nil { + return nil, err + } + return PlansOf(in, s.Node) +} + +// PlansHeld is Plans composed module by module (issue 774: one bad module must not drop its node): a module placed on +// the scope's node whose build cannot be read or taken in, or whose plan refuses the node's, is held, by the one rule +// Compose follows (composeEach, culprit): composed as its stub so what it provides and requires still shapes the rest, +// named in held with why, and none of its plan in plans. Only a refusal that is no single module's refuses the node, +// err. The seat mesh-provisioner's holder composes so: a node with one bad manifest still has every other module's plan. +func PlansHeld(ctx context.Context, r Reader, mesh Mesh, s Scope) (plans map[string]catalogue.Plan, held map[string]string, err error) { + held = map[string]string{} + in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() }, nil) + if err != nil { + return nil, held, err + } + plans, err = plansHeldOf(in, s.Node, held) + return plans, held, err +} + +// plansHeldOf is the plans of node over in, every module in held held, and each module a refusal is of held too until +// the plans compose (culprit's rule over the Planning mode: its stub first, then its bare stub). +func plansHeldOf(in compose.Input, node string, held map[string]string) (map[string]catalogue.Plan, error) { + return heldOf(in, node, held, func(in compose.Input) (map[string]catalogue.Plan, error) { return plansIn(in, node, compose.Planning) }) +} + +// heldOf composes compose1 over in with every module in held held, and each module a refusal is of held too until it +// composes (culprit's rule: its stub first, then its bare stub); what compose1 answers of a held module is left out. +// Plans and data items are composed so (PlansHeld, DataItemsHeld): one module never ends the read (novox/hq ADR 0578 §3). +func heldOf[V any](in compose.Input, node string, held map[string]string, compose1 func(compose.Input) (map[string]V, error)) (map[string]V, error) { + for _, m := range sortedKeys(held) { + in = stubbed(in, node, m, "") + } + // Each try holds one module more or leaves out all of one held module's offer: bounded by twice the modules placed. + for tries := 0; tries <= 2*len(assignedOn(in, node))+1; tries++ { + plans, err := compose1(in) + if err == nil { + for m := range held { + delete(plans, m) + } + return plans, nil + } + var order []string + for _, m := range assignedOn(in, node) { + if strings.Contains(err.Error(), m) && len(order) < maxCulprits { + order = append(order, m) + } + } + module, leave := "", "" + find: + for _, l := range []string{"", leaveAll} { + for _, m := range order { + if _, done := held[m]; done && l == "" { + continue + } + if _, again := compose1(stubbed(in, node, m, l)); again == nil || again.Error() != err.Error() { + module, leave = m, l + break find + } + } + } + if module == "" { + return nil, err + } + if _, done := held[module]; !done { + held[module] = err.Error() + } + in = stubbed(in, node, module, leave) + } + return nil, fmt.Errorf("frombus: what %s composes does not compose with %d module(s) held: %s", node, len(held), + strings.Join(sortedKeys(held), ", ")) +} + +// PlansOf is Plans over an input already filled, rendered in the Planning mode (compose.Planning): every own secret the +// input holds none for stands in, so no value and no record of one is needed. +func PlansOf(in compose.Input, node string) (map[string]catalogue.Plan, error) { + return plansIn(in, node, compose.Planning) +} + +// plansIn is the controller's handedPlans in a mode: Foreseeing is the controller's own. +func plansIn(in compose.Input, node string, mode compose.Mode) (map[string]catalogue.Plan, error) { + // One composition for the three, so each node of the mesh is resolved once (issue 1954). + resolved, rendered, gens, err := compose.Composing(in, node, mode) + if err != nil { + return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err) + } + with := rendered.Rendering + with.Generators = gens + composed, err := resolution.Compose(resolved.Plan, with) + if err != nil { + return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err) + } + return composed.Plans, nil +} + +// unusable is what stands in for a module's manifest the mesh refuses: its stub (compose.Stub) +// read from its JSON unjudged, so what it provides, requires and serves still shapes the rest, and without its items, +// since nothing judged what they would put into another module's collection. Bytes that are no manifest of the module +// stand in as a stub that offers nothing. +func unusable(module string, raw []byte) catalogue.Manifest { + var m catalogue.Manifest + if json.Unmarshal(raw, &m) != nil || m.Module != module { + m = catalogue.Manifest{Module: module} + } + m.Items = nil + return compose.Stub(m) +} + +// parser reads a manifest as every reader does (novox/hq ADR 0578 §3): decoded leniently, judged by nothing here. +func parser(Mesh) func([]byte) (catalogue.Manifest, error) { + return catalogue.Decode +} + +// ranOf is what a module last offered the others, read when it is held (composeEach, issue 1960): its manifest at the +// build its own composition runs at (running), where that is another build than the one it is composed at (fps) and +// the builder holds it done. False where nothing is known of it (it runs no composition of its own, it runs the build +// it is composed at, or its build is unread): the manifest it is composed from is its offer then. Only what it says to +// others is taken of it, which needs no take-in of its artifacts. +func ranOf(ctx context.Context, r Reader, mesh Mesh, running []delivery.Composed, fps map[string]string) func(string) (catalogue.Manifest, bool) { + parse := parser(mesh) + return func(module string) (catalogue.Manifest, bool) { + for _, c := range running { + if c.Module != module || c.FP == "" || c.FP == fps[module] { + continue + } + b, err := r.Build(ctx, module, c.FP) + if err != nil || b == nil || b.State != delivery.BuildDone || b.Manifest == "" { + return catalogue.Manifest{}, false + } + raw, err := r.Manifest(ctx, b.Manifest) + if err != nil { + return catalogue.Manifest{}, false + } + man, err := parse(raw) + return man, err == nil + } + return catalogue.Manifest{}, false + } +} + +// fromBuilds puts in the built manifest of each module placed on the composing node, at the trunk's FP2, and of each +// module it keeps at another build (Scope.KeptAt, into its node's Kept), read from the builder's done key and resolved +// by the one take-in rule (takein.Manifest), a templates archive read in the artifact store as the node reaches it +// (issue 1126). +func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error), + in *compose.Input, s Scope, p delivery.Placement, modules map[string]delivery.Module, + read func(module, digest string) (catalogue.Manifest, error), parse func([]byte) (catalogue.Manifest, error), + refuse func(module string, err error), wanted map[string]string) error { + // stub keeps a refused module in the composition as a stub for this node alone (compose.Stub): what it provides + // and requires still shapes the rest. + stub := func(module string, err error) { + refuse(module, err) + for i := range in.Nodes { + if in.Nodes[i].Name != s.Node { + continue + } + man, ok := in.Nodes[i].Kept[module] + if !ok { + man, ok = in.Catalogue[module] + } + if !ok { + return + } + if in.Nodes[i].Kept == nil { + in.Nodes[i].Kept = map[string]catalogue.Manifest{} + } + in.Nodes[i].Kept[module] = compose.Stub(man) + } + } + address, addressErr := "", error(nil) + addressed := false + archive := func(ctx context.Context, reference, digest string) ([]byte, error) { + if !addressed { + address, addressErr = compose.ArtifactStoreAddress(*in, s.Node) + addressed = true + } + switch { + case addressErr != nil: + return nil, addressErr + case address == "": + return nil, fmt.Errorf("%s reaches no artifact store (%s)", s.Node, storeSought(*in)) + } + return r.Archive(ctx, catalogue.Routed(reference, address), digest) + } + built := func(module, fp string, b *delivery.BuilderBuild) (catalogue.Manifest, error) { + if b.Manifest == "" { + return catalogue.Manifest{}, fmt.Errorf("frombus: the build of %s at %s names no manifest", module, fp) + } + // A take-in kept by the reader is read back as below, never shared: each composition holds its own manifest. + keeper, keeps := r.(takenInKeeper) + if keeps && !addressed { + address, addressErr = compose.ArtifactStoreAddress(*in, s.Node) + addressed = true + } + // Kept by the store's address too: a node that reaches no store takes in as before, and fails where it did. + key, keyed := takenInKey(module, fp, address, *b) + keyed = keyed && addressErr == nil && address != "" + if keeps && keyed { + if raw, ok := keeper.takenIn(key); ok { + resolved, err := parse(raw) + if err != nil { + return catalogue.Manifest{}, fmt.Errorf("frombus: the built manifest of %s at %s: %w", module, fp, err) + } + return resolved, nil + } + } + src, err := read(module, b.Manifest) + if err != nil { + return catalogue.Manifest{}, err + } + resolved, err := takein.Manifest(ctx, src, *b, archive) + if err != nil { + return catalogue.Manifest{}, fmt.Errorf("frombus: %s at %s: %w", module, fp, err) + } + // Read back as the mesh holds a built manifest (the controller keeps the resolved manifest's bytes and reads them + // as any manifest): a template's text is then in the manifest's own words, its ${config:…} in the settings form. + raw, err := json.Marshal(resolved) + if err != nil { + return catalogue.Manifest{}, err + } + if resolved, err = parse(raw); err != nil { + return catalogue.Manifest{}, fmt.Errorf("frombus: the built manifest of %s at %s: %w", module, fp, err) + } + if keeps && keyed { + keeper.keepTakenIn(key, raw) + } + return resolved, nil + } + // A module wanted at another build than the trunk's is composed from the build its want names (issue 774), as one + // the scope keeps at another build is. + keptAt := maps.Clone(s.KeptAt) + for _, pm := range p.Modules { + if m, ok := modules[pm.Module]; ok && m.FP2 != "" && wanted[pm.Module] != "" && wanted[pm.Module] != m.FP2 && + keptAt[pm.Module] == "" && !slices.Contains(s.InOwn, pm.Module) { + if keptAt == nil { + keptAt = map[string]string{} + } + keptAt[pm.Module] = wanted[pm.Module] + } + } + runs := map[string]bool{} + for _, c := range s.Running { + if c.Module != "" { + runs[c.Module] = true + } + } + for _, pm := range p.Modules { + m, ok := modules[pm.Module] + if !ok || m.FP2 == "" || keptAt[pm.Module] != "" || slices.Contains(s.InOwn, pm.Module) { + // A module it runs inside its own composition is composed there as before: no build of the new form is what + // it runs (Scope.InOwn). + continue + } + if _, refused := in.Refused[pm.Module]; refused { + continue // held as its stub: no build of a refused manifest is taken in (ADR 0578 §3) + } + if man, ok := in.Catalogue[pm.Module]; !ok || (man.Build == nil && !man.Templated()) { + continue // nothing is built of it: its manifest is what it composes from + } + b, err := build(ctx, pm.Module, m.FP2) + if err != nil { + err = fmt.Errorf("frombus: the build of %s at %s: %w", pm.Module, m.FP2, err) + if refuse == nil { + return err + } + stub(pm.Module, err) + continue + } + if b == nil || b.State != delivery.BuildDone { + if refuse != nil && runs[pm.Module] { + // A module that runs is never composed from the indexer's manifest, which names no build's artifacts (no + // runtime's bundle): it is held, keeping what runs, until its build lands (issue 774, 2026-10-30 19:30: + // node-tools on ace, g14 and shanks, "its build produced 0 bundle(s)", and every module refused). + stub(pm.Module, fmt.Errorf("frombus: %s is wanted at %s, of which the builder holds no done build yet; it "+ + "keeps running what it runs", pm.Module, m.FP2)) + } + continue // nothing of it runs: composed from the indexer's manifest, as before + } + resolved, err := built(pm.Module, m.FP2, b) + if err != nil { + if refuse == nil { + return err + } + stub(pm.Module, err) + continue + } + in.Catalogue[pm.Module] = resolved + } + for i := range in.Nodes { + if in.Nodes[i].Name != s.Node { + continue + } + for _, module := range sortedKeys(keptAt) { + fp := keptAt[module] + b, err := build(ctx, module, fp) + switch { + case err != nil: + err = fmt.Errorf("frombus: the build of %s at %s: %w", module, fp, err) + case b == nil || b.State != delivery.BuildDone: + err = fmt.Errorf("frombus: %s is wanted at %s, of which the builder holds no done build", module, fp) + } + var resolved catalogue.Manifest + if err == nil { + resolved, err = built(module, fp, b) + } + if err != nil { + if refuse == nil { + return err + } + stub(module, err) + continue + } + if in.Nodes[i].Kept == nil { + in.Nodes[i].Kept = map[string]catalogue.Manifest{} + } + in.Nodes[i].Kept[module] = resolved + } + } + return nil +} + +// declaresOwnSecrets says a module assigned to node declares an own secret, in the manifest the node composes it from. +func declaresOwnSecrets(in compose.Input, node string) bool { + for _, n := range in.Nodes { + if n.Name != node { + continue + } + for _, module := range n.Assigned { + m, ok := n.Kept[module] + if !ok { + m = in.Catalogue[module] + } + if len(m.OwnSecrets) > 0 { + return true + } + } + } + return false +} + +// nodeOf is the composition's node from its record (what the machine observes) and its placement (what a person +// decided of it, and where its modules run). +func nodeOf(rec delivery.NodeRecord, p delivery.Placement) compose.Node { + n := compose.Node{ + Name: rec.Node, + Place: compose.Place{Key: rec.TunnelKey}, + SealingKey: rec.SealingKey, + OutwardLinks: rec.OutwardLinks, Networks: rec.Networks, NetworksSaid: rec.NetworksSaid, + ServingKey: rec.ServingKey, Certificate: rec.Certificate, Contract: rec.Contract, Stated: rec.Contract > 0, + } + placeNode(&n, p) + if len(rec.Capabilities) > 0 { + n.Capabilities = make(map[string]bool, len(rec.Capabilities)) + for _, c := range rec.Capabilities { + n.Capabilities[c] = true + } + } + return n +} + +// placeNode sets what a person decided of n and where its modules run, from its placement; what the machine observes +// (its keys, links, networks, certificate) and its settings layers are left as they are. +func placeNode(n *compose.Node, p delivery.Placement) { + d := p.Decided + n.Place = compose.Place{Key: n.Place.Key, Endpoint: d.Endpoint, Site: d.Site, Hub: d.Hub, Address: d.Address} + n.Assigned, n.PublicDomain = p.Assigned(), d.PublicDomain + n.Account, n.AccountHome, n.AgentAccount, n.AgentAccountHome = d.Account, d.AccountHome, d.AgentAccount, d.AgentHome + n.Adopted, n.Taken = d.Adopted, d.Taken + n.Pins, n.Bound, n.Ports = p.ChosenPins(), p.Bound(), p.HostPorts() +} + +// Placed is in with the placements given standing in for those of the nodes they name (issue 1126): each such node's +// decided parts, modules, pins, bindings, host ports, settings placement and holdings are the given placement's, its +// config values those in already carries for it (the layer of the node's own name), and what the machine observes is +// kept. A nil placement leaves the node in the mesh with nothing placed on it. A node in does not hold is refused: it has +// no record to compose it from. The found tunnel and the hub's peers are read from in as they are, not from the given +// placement. It is how a check composes a change's own placement over an input already filled (composer.Case's +// Placements). +func Placed(in compose.Input, placements map[string]*delivery.Placement) (compose.Input, error) { + if len(placements) == 0 { + return in, nil + } + out := in + out.Nodes = append([]compose.Node(nil), in.Nodes...) + index := map[string]int{} + for i, n := range out.Nodes { + index[n.Name] = i + } + for _, node := range sortedKeys(placements) { + p := placements[node] + i, ok := index[node] + if !ok { + return compose.Input{}, fmt.Errorf("frombus: a placement is given for %s, which the input holds no node for", node) + } + if p == nil { + p = &delivery.Placement{Node: node} + } + if p.Node != node { + return compose.Input{}, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node) + } + n := out.Nodes[i] + placeNode(&n, *p) + settings := map[string][]catalogue.Layer{} + for _, m := range p.Modules { + var values map[string]any + for _, l := range n.Settings[m.Module] { + if l.From == node { + values = l.Values + } + } + if layers := m.Layers(node, values); layers != nil { + settings[m.Module] = layers + } + } + n.Settings = nil + if len(settings) > 0 { + n.Settings = settings + } + out.Nodes[i] = n + } + out.Holdings = nil + for _, h := range in.Holdings { + if _, given := placements[h.Node]; !given { + out.Holdings = append(out.Holdings, h) + } + } + for _, node := range sortedKeys(placements) { + if p := placements[node]; p != nil { + out.Holdings = append(out.Holdings, p.Held()...) + } + } + return out, nil +} + +func tunnelOf(t delivery.RecordTunnel) compose.Tunnel { + out := compose.Tunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU, + Address: t.Address, Range: t.Range, PublicKey: t.PublicKey} + for _, p := range t.Peers { + out.Peers = append(out.Peers, compose.TunnelPeer{PublicKey: p.PublicKey, Address: p.Address}) + } + return out +} + +// carriedPeers is every peer of the hub's adopted tunnel, by address, each with the node that enrolled under its key +// where one has (a node whose tunnel key is the peer's) and the name a person gave it (inv.CarriedPeers). +func carriedPeers(t compose.Tunnel, names map[string]string, records map[string]delivery.NodeRecord) []compose.CarriedPeer { + byKey := map[string]string{} + for node, rec := range records { + if rec.TunnelKey != "" { + byKey[rec.TunnelKey] = node + } + } + var out []compose.CarriedPeer + for _, p := range t.Peers { + address, _, _ := strings.Cut(p.Address, "/") + out = append(out, compose.CarriedPeer{PublicKey: p.PublicKey, Address: address, EnrolledAs: byKey[p.PublicKey], Named: names[p.PublicKey]}) + } + sort.SliceStable(out, func(i, j int) bool { + a, errA := netip.ParseAddr(out[i].Address) + b, errB := netip.ParseAddr(out[j].Address) + if errA != nil || errB != nil { + return out[i].Address < out[j].Address + } + return a.Less(b) + }) + return out +} + +// nodesOf is every node named by a placement or a record, by name: the order the controller reads them in. +func nodesOf(placements map[string]delivery.Placement, records map[string]delivery.NodeRecord) []string { + seen := map[string]bool{} + for n := range placements { + seen[n] = true + } + for n := range records { + seen[n] = true + } + return sortedKeys(seen) +} + +func sortedKeys[V any](m map[string]V) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// storeSought says, for a node that reaches no artifact store, what the composition holds of one: the nodes it holds and +// where each module serving the artifact store is placed, so the refusal names why (issue 1126). +func storeSought(in compose.Input) string { + var nodes, where []string + for _, n := range in.Nodes { + nodes = append(nodes, n.Name) + for _, a := range n.Assigned { + if m, ok := in.Catalogue[a]; ok { + if _, serves := m.Serves[catalogue.ArtifactStoreProvision]; serves { + where = append(where, a+" on "+n.Name) + } + } + } + } + sort.Strings(nodes) + sort.Strings(where) + said := "the composition holds " + strings.Join(nodes, ", ") + if len(where) == 0 { + return said + "; no module placed there serves " + catalogue.ArtifactStoreProvision + } + return said + "; " + catalogue.ArtifactStoreProvision + " is served by " + strings.Join(where, ", ") + + ", none of it on the private network with a port" +} + +// keptOf is the operator export's file (catalogue.KeptID) of a module's composition as it runs, or nil. +func keptOf(c delivery.Composed) map[string]any { + for _, raw := range c.Resources { + var r map[string]any + if json.Unmarshal(raw, &r) == nil && r["id"] == c.Module+"."+resolution.KeptID() { + return r + } + } + return nil +} + +// membershipOf is the sealed value of the bus membership resource (catalogue.BusMembershipID) of a node's own +// composition, or "". +func membershipOf(own delivery.Composed) string { + for _, raw := range own.Resources { + var r struct { + ID string `json:"id"` + Sealed string `json:"sealed"` + } + if json.Unmarshal(raw, &r) == nil && r.ID == resolution.BusMembershipID() { + return r.Sealed + } + } + return "" +} + +// DataItems is every data item the modules placed on scope's node declare, placed there, composed from the bus as Plans +// composes (novox/hq ADR 0571 clause 3): disk-load measures these. A module whose manifest the indexer refused is held +// and its items left out; DataItemsHeld names it, and a reader that must say what it leaves out reads that. +func DataItems(ctx context.Context, r Reader, mesh Mesh, s Scope) (map[string][]catalogue.PlacedDataItem, []string, error) { + items, unplaced, _, err := DataItemsHeld(ctx, r, mesh, s) + return items, unplaced, err +} + +// DataItemsHeld is DataItems and, by module, why each module placed on scope's node is held: its manifest refused by +// the indexer's judge or not decoded (compose.Input.Refused). One refused manifest never ends the read (novox/hq ADR +// 0578 §3: disk-load measured nothing for 45 hours when one did). +func DataItemsHeld(ctx context.Context, r Reader, mesh Mesh, s Scope) (items map[string][]catalogue.PlacedDataItem, unplaced []string, held map[string]string, err error) { + held = map[string]string{} + in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() }, nil) + if err != nil { + return nil, nil, held, err + } + // Held module by module, as PlansHeld holds them: a refused stub or one module's composition refusal (an item's + // template) holds that module and never ends the read. + items, err = heldOf(in, s.Node, held, func(in compose.Input) (map[string][]catalogue.PlacedDataItem, error) { + var err error + items, unplaced, err = compose.DataItems(withoutHeldData(in, s.Node), s.Node) + return items, err + }) + return items, unplaced, held, err +} + +// withoutHeldData is in with no data item of a module held on node: none of a held module's items is measured, so its +// data never refuses the rest (its stub keeps its data for the backups the plans compose). +func withoutHeldData(in compose.Input, node string) compose.Input { + out := in + out.Nodes = append([]compose.Node(nil), in.Nodes...) + for i := range out.Nodes { + n := &out.Nodes[i] + if n.Name != node || len(n.Held) == 0 { + continue + } + kept := maps.Clone(n.Kept) + for m := range n.Held { + if man, ok := kept[m]; ok { + man.Data = nil + kept[m] = man + } + } + n.Kept = kept + } + return out +} diff --git a/internal/compose/frombus/frombus_test.go b/internal/compose/frombus/frombus_test.go new file mode 100644 index 0000000..cd566f5 --- /dev/null +++ b/internal/compose/frombus/frombus_test.go @@ -0,0 +1,869 @@ +package frombus + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "reflect" + "slices" + "sort" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/compose" + "github.com/novox/mesh-host/internal/overlay" +) + +// bus is a Reader over values held in memory, counting whose config values were read. +type bus struct { + vaultAuthority string + handed map[string]bool + placements map[string]delivery.Placement + records map[string]delivery.NodeRecord + modules map[string]delivery.Module + manifests map[string][]byte + values map[string]map[string]any // by node.module + valuesRead []string + own []compose.OwnSecretRow + ownErr error + // asked is every consumer credential asked; the fake refuses the whole ask, as the vault's own does, for a consuming + // module its placement does not put on the node. + asked []compose.CredentialKey + // builds are the builder's keys, by " "; archives the artifact store's blobs, by digest, and fetched + // every URL asked; served another node's values by ".", and servedRead every ask with its keys. + builds map[string]*delivery.BuilderBuild + archives map[string][]byte + fetched []string + served map[string]map[string]any + servedRead []string + buildsRead []string + // held answers every credential asked as held, sealed to the consumer. + held bool +} + +func (b *bus) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) { + b.buildsRead = append(b.buildsRead, module) + return b.builds[module+" "+fp], nil +} + +func (b *bus) Archive(_ context.Context, url, digest string) ([]byte, error) { + b.fetched = append(b.fetched, url) + body, ok := b.archives[digest] + if !ok { + return nil, errors.New("the store holds no such blob") + } + return body, nil +} + +func (b *bus) Served(_ context.Context, node, module string, keys []string) (map[string]any, error) { + b.servedRead = append(b.servedRead, node+"."+module+":"+strings.Join(keys, ",")) + out := map[string]any{} + for _, k := range keys { + if v, ok := b.served[node+"."+module][k]; ok { + out[k] = v + } + } + return out, nil +} + +func (b *bus) Placements(context.Context) (map[string]delivery.Placement, error) { + return b.placements, nil +} +func (b *bus) VaultAuthority(context.Context) string { return b.vaultAuthority } +func (b *bus) Handed(context.Context, string) map[string]bool { return b.handed } +func (b *bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.records, nil } +func (b *bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.modules, nil } +func (b *bus) Manifest(_ context.Context, digest string) ([]byte, error) { + raw, ok := b.manifests[digest] + if !ok { + return nil, errors.New("no such manifest") + } + return raw, nil +} +func (b *bus) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) { + return b.own, b.ownErr +} + +func (b *bus) Credentials(_ context.Context, node string, asConsumer, asProvider []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) { + b.asked = append(b.asked, asConsumer...) + for _, k := range asConsumer { + placed := false + for _, m := range b.placements[node].Modules { + placed = placed || m.Module == k.ConsumerModule + } + if !placed { + return nil, nil, fmt.Errorf("%s is not on %s: neither assigned there nor required by a module there", k.ConsumerModule, node) + } + } + var out []compose.Credential + var issued []compose.Issued + for _, k := range append(append([]compose.CredentialKey{}, asConsumer...), asProvider...) { + if b.held { + out = append(out, compose.Credential{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule, + Provider: k.Provider, Local: k.Local, State: compose.CredentialHeld, Sealed: `"box"`}) + issued = append(issued, compose.Issued{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule, + Provider: k.Provider, Local: k.Local, ForProvider: `"box"`}) + } + } + return out, issued, nil +} + +func (b *bus) Values(_ context.Context, node, module string) (map[string]any, error) { + b.valuesRead = append(b.valuesRead, node+"."+module) + return b.values[node+"."+module], nil +} + +func publicKey(t *testing.T) string { + t.Helper() + k, err := ecdh.X25519().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes()) +} + +// Issue 1126 (S3, S4, S5): the bus's placement, node records, manifests and config values fill the same compose.Input the +// controller's store fills for compose's own two-machine mesh (compose's aMesh: anchor the hub, laptop), and that Input +// composes; a node reads only its own config values; what cannot be composed is refused, naming it. +func TestInput(t *testing.T) { + // Issue 1126, the seed's shadow: every refusal named, an old-form module left to the node, and no store reached + // saying why (answer_rows_test.go). + t.Run("every refusal, and an old build left to the node", answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode) + t.Run("no store reached says why", noStoreReachedSaysWhy) + // Issue 774: a node refused whole says what it held on the way. + t.Run("a node refused whole says what it held", aNodeRefusedWholeSaysWhatItHeld) + t.Run("a refused manifest never ends the read", aRefusedManifestNeverEndsTheRead) + t.Run("the runtime is blamed only where its stub composes", theRuntimeIsBlamedOnlyWhereItsStubComposes) + t.Run("a running module waits for its build", aRunningModuleWaitsForItsBuild) + t.Run("a held module's first reason leads", aHeldModulesFirstReasonLeads) + t.Run("a held module of its own account never refuses the node", aHeldModuleOfItsOwnAccountNeverRefusesTheNode) + t.Run("another node not resolved is named", otherNodeNotResolvedIsNamed) + ctx := context.Background() + xterm, err := os.ReadFile("testdata/catalogue-835bf22b-xterm.module.json") + if err != nil { + t.Fatal(err) + } + digest := delivery.ManifestDigest(xterm) + // A module in the settings form (plex and the media modules on one node, 2026-10-26): its config values are in its + // layers, not mesh-config, so the placement carries them (MeshValues, Values) and mesh-config's are not read for it. + media := []byte(`{"module":"media","version":"1","settings":{"lines":{"kind":"preference","default":0,"why":"kept"},` + + `"name":{"kind":"preference","default":"x","why":"shown"}},"own-secrets":{"token":"/srv/media/token"},"resources":[{"id":"conf","type":"file",` + + `"path":"/srv/media/conf","mode":"0644","content":"uid=${setting:lines} name=${setting:name}\n"}]}`) + mediaDigest := delivery.ManifestDigest(media) + keys := map[string]string{"anchor": publicKey(t), "laptop": publicKey(t)} + caps := []string{"container-runtime", "systemd", "wireguard"} + tunnel := &delivery.RecordTunnel{Interface: "wg0", PublicKey: keys["anchor"], Address: "10.77.0.1/24", + Peers: []delivery.RecordTunnelPeer{{PublicKey: "peer", Address: "10.77.0.9/32"}, {PublicKey: keys["laptop"], Address: "10.77.0.2/32"}}} + mk := func() *bus { + return &bus{ + placements: map[string]delivery.Placement{ + "anchor": {Node: "anchor", Decided: delivery.NodeDecided{Endpoint: "anchor.example:51820", Site: "here", Hub: true, Address: "10.77.0.1", + Tunnel: tunnel, PeerNames: map[string]string{"peer": "phone"}}, + Modules: []delivery.PlacedModule{{Module: overlay.Name}}, + Holds: []delivery.Holding{{Claim: "mesh-private-network", Scope: "mesh", Module: overlay.Name}}}, + "laptop": {Node: "laptop", Decided: delivery.NodeDecided{Endpoint: "laptop.example:51820", Site: "here", Address: "10.77.0.2"}, + Modules: []delivery.PlacedModule{{Module: overlay.Name}, + {Module: "xterm", HasLayer: true, Ports: map[string]int{"80": 8080}}, + {Module: "media", HasMesh: true, MeshValues: map[string]any{"name": "mesh"}, HasLayer: true, + Layer: map[string]any{"places": map[string]any{"data": "/srv/data"}}, Values: map[string]any{"lines": float64(1000)}}}}, + }, + records: map[string]delivery.NodeRecord{ + "anchor": {Node: "anchor", TunnelKey: keys["anchor"], Capabilities: caps, Contract: 2}, + "laptop": {Node: "laptop", TunnelKey: keys["laptop"], Capabilities: caps, Authority: "the mesh's certificate"}, + }, + modules: map[string]delivery.Module{"xterm": {Manifest: digest}, "media": {Manifest: mediaDigest}, "unread": {}}, + manifests: map[string][]byte{digest: xterm, mediaDigest: media}, + values: map[string]map[string]any{"laptop.xterm": {"font": "mono"}, "anchor.xterm": {"font": "serif"}, + "laptop.media": {"lines": "not mesh-config's"}}, + own: []compose.OwnSecretRow{{Node: "laptop", Module: "media", Name: "token", Origin: "made", SealedTo: "sk", Sealed: "{}"}}, + } + } + + // What the controller's store gives for the same mesh, as compose's aMesh and SettingsFor build it. + var private catalogue.Manifest + raw, _ := json.Marshal(overlay.Manifest()) + if err := json.Unmarshal(raw, &private); err != nil { + t.Fatal(err) + } + parsed, err := catalogue.Decode(xterm) + if err != nil { + t.Fatal(err) + } + parsedMedia, err := catalogue.Decode(media) + if err != nil { + t.Fatal(err) + } + capsSet := map[string]bool{"container-runtime": true, "systemd": true, "wireguard": true} + tun := compose.Tunnel{Interface: "wg0", PublicKey: keys["anchor"], Address: "10.77.0.1/24", + Peers: []compose.TunnelPeer{{PublicKey: "peer", Address: "10.77.0.9/32"}, {PublicKey: keys["laptop"], Address: "10.77.0.2/32"}}} + want := compose.Input{ + Catalogue: map[string]catalogue.Manifest{overlay.Name: private, "xterm": parsed, "media": parsedMedia}, + Nodes: []compose.Node{ + {Name: "anchor", Place: compose.Place{Key: keys["anchor"], Endpoint: "anchor.example:51820", Site: "here", Hub: true, + Address: "10.77.0.1"}, Assigned: []string{overlay.Name}, Capabilities: capsSet, Contract: 2, Stated: true}, + {Name: "laptop", Place: compose.Place{Key: keys["laptop"], Endpoint: "laptop.example:51820", Site: "here", + Address: "10.77.0.2"}, Assigned: []string{overlay.Name, "xterm", "media"}, Capabilities: capsSet, + Ports: map[string]map[int]int{"xterm": {80: 8080}}, + Settings: map[string][]catalogue.Layer{"xterm": {{From: "laptop", Values: map[string]any{"font": "mono"}}}, + "media": {{From: catalogue.MeshWideLayer, Values: map[string]any{"name": "mesh"}}, + {From: "laptop", Values: map[string]any{"lines": float64(1000)}, Placement: map[string]any{"places": map[string]any{"data": "/srv/data"}}}}}}, + }, + Holdings: []catalogue.Held{{Claim: "mesh-private-network", Scope: "mesh", Node: "anchor", Module: overlay.Name}}, + OwnSecrets: []compose.OwnSecretRow{{Node: "laptop", Module: "media", Name: "token", Origin: "made", SealedTo: "sk", Sealed: "{}"}}, + // The same value as a core credential's box, for a core module (ADR 0349 §16). + KeptCoreBoxes: []compose.CoreBox{{Node: "laptop", Module: "media", Name: "token", SealedTo: "sk", Box: json.RawMessage("{}")}}, + Tunnels: map[string]compose.FoundTunnel{"anchor": {Tunnel: tun}}, + Adopted: &compose.AdoptedTunnel{Tunnel: tun, Hub: "anchor", Adopted: true}, + // The controller's order is by address; laptop enrolled under its peer's key, and a person named the other. + Authority: "the mesh's certificate", + CarriedPeers: []compose.CarriedPeer{{PublicKey: keys["laptop"], Address: "10.77.0.2", EnrolledAs: "laptop"}, + {PublicKey: "peer", Address: "10.77.0.9", Named: "phone"}}, + } + + b := mk() + got, err := Input(ctx, b, Mesh{}, Scope{Node: "laptop"}) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, want) { + gj, _ := json.MarshalIndent(got, "", " ") + wj, _ := json.MarshalIndent(want, "", " ") + t.Fatalf("Input from the bus differs from the store's:\n got %s\nwant %s", gj, wj) + } + // L3 s8 (ADR 0550 §2): with the vault's authority published, the mesh's authority is the bundle of the controller's + // and the vault's; with none, the controller's alone, as above. + signing := mk() + signing.vaultAuthority = "the vault's certificate" + if bundled, err := Input(ctx, signing, Mesh{}, Scope{Node: "laptop"}); err != nil || + bundled.Authority != "the mesh's certificate\nthe vault's certificate\n" { + t.Errorf("the authority with the vault's published: %q, %v", bundled.Authority, err) + } + // Issue 774: the modules the vault judged handed when it answered own are the node's to hand, and no other node's. + judged := mk() + judged.handed = map[string]bool{"media": true, "xterm": false} + if in, err := Input(ctx, judged, Mesh{}, Scope{Node: "laptop"}); err != nil || + !reflect.DeepEqual(in.Nodes[1].Handed, judged.handed) || in.Nodes[0].Handed != nil { + t.Errorf("the vault's handed verdicts: %+v, %v", in.Nodes, err) + } + if !reflect.DeepEqual(b.valuesRead, []string{"laptop." + overlay.Name, "laptop.xterm", "laptop.media"}) { + t.Errorf("laptop read config values %v; a node reads its own slice alone", b.valuesRead) + } + + // The same composer over both: the compositions from the bus are the store's, byte for byte. + fps := map[string]string{overlay.Name: "src2:" + strings.Repeat("a", 64), "xterm": "src2:" + strings.Repeat("b", 64), + "media": "src2:" + strings.Repeat("c", 64)} + fromBus, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop"}, fps) + if err == nil && len(held) > 0 { + err = fmt.Errorf("modules held: %v", held) + } + if err != nil { + t.Fatal(err) + } + fromStore, _, err := compose.Compositions(want, "laptop", fps, compose.Reading) + if err != nil { + t.Fatal(err) + } + if len(fromBus) == 0 || !reflect.DeepEqual(fromBus, fromStore) { + t.Fatalf("compositions differ or are empty: %d from the bus, %d from the store", len(fromBus), len(fromStore)) + } + + // Issue 1126: a candidate placement equal to the node's composes what it runs, and CompareComposed says so; one + // thing changed in the group's candidate of another node (the hub's endpoint) names exactly the module it moves + // here. Placed puts the same placement over the store's input unchanged. + running := make([]delivery.Composed, 0, len(fromBus)) + for _, c := range fromBus { + running = append(running, c) + } + laptop := mk().placements["laptop"] + asIs, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop}}, fps) + if err == nil && len(held) > 0 { + err = fmt.Errorf("modules held: %v", held) + } + if err != nil { + t.Fatal(err) + } + if c := delivery.CompareComposed(asIs, running); len(c.Differs)+len(c.Added)+len(c.Removed) != 0 || len(c.Same) != len(running) { + t.Errorf("the candidate equal to the placement: %+v", c) + } + moved := mk().placements["anchor"] + moved.Decided.Endpoint = "anchor.example:51821" + changed, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop, "anchor": &moved}}, fps) + if err == nil && len(held) > 0 { + err = fmt.Errorf("modules held: %v", held) + } + if err != nil { + t.Fatal(err) + } + if c := delivery.CompareComposed(changed, running); len(c.Differs) != 1 || c.Differs[0].Module != overlay.Name || c.Differs[0].Resource != overlay.Name+".overlay-config" { + t.Errorf("the hub's endpoint changed: %+v, want %s alone, naming its config", c, overlay.Name) + } + if c := delivery.CompareComposed(changed, running[:0]); len(c.Added) != len(changed) { + t.Errorf("nothing runs: %+v", c) + } + + // ADR 0559 §4, the node's answer: the same; one module differing; a module the group adds, whose own secret is not + // yet issued (no refusal); a value missing for a module the node runs (a refusal); a module the candidate removes. + keeper := []byte(`{"module":"keeper","version":"1","own-secrets":{"password":"/var/lib/mesh/keeper/password"},` + + `"resources":[{"id":"state","type":"directory","path":"/var/lib/mesh/keeper","mode":"0700"}]}`) + lemurs := []byte(`{"module":"lemurs","version":"1","capabilities":["systemd"],"resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`) + // A provider and a consumer of a pair credential on the one node (compose's objects and files). + objects, _ := json.Marshal(catalogue.Manifest{Module: "objects", Version: "1", + Provides: []catalogue.Offer{{Name: "s3-bucket", Scope: catalogue.ScopeMesh, Identity: &catalogue.OfferIdentity{Max: 20, In: "an S3 access key"}}}, + Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}}) + files, _ := json.Marshal(catalogue.Manifest{Module: "files", Version: "1", Requires: []string{"s3-bucket"}, + Resources: []map[string]any{{"id": "config", "type": "file", "path": "/etc/files/config.json", "mode": "0600", "content": "{}", "merge": "json"}}}) + one := func(placed ...string) *bus { + p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}} + for _, m := range placed { + p.Modules = append(p.Modules, delivery.PlacedModule{Module: m}) + } + return &bus{placements: map[string]delivery.Placement{"n1": p}, + records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: keys["anchor"], SealingKey: "sk", Capabilities: []string{"systemd"}}}, + modules: map[string]delivery.Module{"keeper": {Manifest: delivery.ManifestDigest(keeper)}, "lemurs": {Manifest: delivery.ManifestDigest(lemurs)}, + "objects": {Manifest: delivery.ManifestDigest(objects)}, "files": {Manifest: delivery.ManifestDigest(files)}}, + manifests: map[string][]byte{delivery.ManifestDigest(keeper): keeper, delivery.ManifestDigest(lemurs): lemurs, + delivery.ManifestDigest(objects): objects, delivery.ManifestDigest(files): files}, + own: []compose.OwnSecretRow{{Node: "n1", Module: "keeper", Name: "password", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}} + } + oneFps := map[string]string{"keeper": "src2:" + strings.Repeat("d", 64), "lemurs": "src2:" + strings.Repeat("e", 64), + "objects": "src2:" + strings.Repeat("f", 64), "files": "src2:" + strings.Repeat("0", 64)} + runs, _, held, err := Compose(ctx, one("keeper", "lemurs"), Mesh{}, Scope{Node: "n1"}, oneFps) + if err == nil && len(held) > 0 { + err = fmt.Errorf("modules held: %v", held) + } + if err != nil { + t.Fatal(err) + } + var runsNow []delivery.Composed + for _, c := range runs { + runsNow = append(runsNow, c) + } + objectsRun, _, held, err := Compose(ctx, one("objects"), Mesh{}, Scope{Node: "n1"}, oneFps) + if err == nil && len(held) > 0 { + err = fmt.Errorf("modules held: %v", held) + } + if err != nil { + t.Fatal(err) + } + var consumerRuns []delivery.Composed + for _, c := range objectsRun { + consumerRuns = append(consumerRuns, c) + } + withoutOwn := func(cs []delivery.Composed) []delivery.Composed { + var out []delivery.Composed + for _, c := range cs { + if c.Module != "" { + out = append(out, c) + } + } + return out + } + // What the controller runs on n1 for a module no want names (lemurs, at a build of the old form) and the private + // network's (built by nobody): its composed writer (mesh-controller compositionsOf) folds their resources into the + // node's own, each in its place, its module taken off; the rest split by module. + tunnelled := func() *bus { + b := one("keeper", "lemurs", overlay.Name) + p := b.placements["n1"] + p.Decided.Endpoint = "n1.example:51820" + b.placements["n1"] = p + return b + } + tunnelledAs := func() map[string]*delivery.Placement { + p := tunnelled().placements["n1"] + return map[string]*delivery.Placement{"n1": &p} + } + folded := func() []delivery.Composed { + b := tunnelled() + in, err := Input(ctx, b, Mesh{}, Scope{Node: "n1"}) + if err != nil { + t.Fatal(err) + } + reading := in + reading.Nodes = append([]compose.Node(nil), in.Nodes...) + reading.Nodes[0].ReadsModules = true + d, _, err := compose.ComposeIn(reading, "n1", compose.Reading) + if err != nil { + t.Fatal(err) + } + body, err := d.Body() + if err != nil { + t.Fatal(err) + } + var envelope struct { + Resources []map[string]json.RawMessage `json:"resources"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + t.Fatal(err) + } + var resources []json.RawMessage + for _, r := range envelope.Resources { + var module string + _ = json.Unmarshal(r["module"], &module) + if module == "lemurs" || module == overlay.Name { + delete(r, "module") + } + raw, _ := json.Marshal(r) + resources = append(resources, raw) + } + split, err := delivery.SplitComposed("n1", resources, map[string]string{"keeper": oneFps["keeper"]}, nil, delivery.NodeOwn{}) + if err != nil { + t.Fatal(err) + } + var out []delivery.Composed + for _, c := range split { + out = append(out, c) + } + return out + }() + if got := InOwn(folded, tunnelled().placements["n1"]); !reflect.DeepEqual(got, []string{"lemurs", overlay.Name}) { + t.Errorf("InOwn of the controller's composition = %v, want lemurs and %s", got, overlay.Name) + } + placedAs := func(modules ...string) map[string]*delivery.Placement { + p := one(modules...).placements["n1"] + return map[string]*delivery.Placement{"n1": &p} + } + for name, c := range map[string]struct { + b *bus + given map[string]*delivery.Placement + running []delivery.Composed + verdict string + check func(delivery.CandidateAnswer) bool + }{ + "the same": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), runsNow, delivery.CandidateSame, + func(a delivery.CandidateAnswer) bool { + return len(a.Same) == 3 && a.Proposal == "p-h1" && a.Revision == 7 + }}, + "the node runs no own composition": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), withoutOwn(runsNow), delivery.CandidateSame, + func(a delivery.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }}, + "one removed": {one("keeper", "lemurs"), placedAs("keeper"), runsNow, delivery.CandidateDiffers, + func(a delivery.CandidateAnswer) bool { return reflect.DeepEqual(a.Removed, []string{"lemurs"}) }}, + "one added, its secret not yet issued": {func() *bus { b := one("lemurs"); b.own = nil; return b }(), placedAs("keeper", "lemurs"), + nil, delivery.CandidateDiffers, func(a delivery.CandidateAnswer) bool { + for _, m := range a.Added { + if m.Module == "keeper" { + return reflect.DeepEqual(m.Unissued, []string{"keeper/password"}) && len(a.Refusals) == 0 + } + } + return false + }}, + "a value missing for a module that runs": {func() *bus { b := one("keeper", "lemurs"); b.own = nil; return b }(), placedAs("keeper", "lemurs"), + runsNow, delivery.CandidateRefused, func(a delivery.CandidateAnswer) bool { + return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && strings.Contains(a.Refusals[0].Why, "keeper/password") + }}, + // Review of L2b, round 1, finding 1: a consumer the candidate adds has no credential yet, and the vault refuses an + // ask for a module not on the node; it is not asked, and the module is added, its credential not yet issued. + "a consumer added, the vault not asked for it": {one("objects"), placedAs("objects", "files"), consumerRuns, delivery.CandidateDiffers, + func(a delivery.CandidateAnswer) bool { + return len(a.Added) == 1 && a.Added[0].Module == "files" && len(a.Added[0].Unissued) > 0 && len(a.Refusals) == 0 + }}, + // And with no trunk at all (the seed), a stand-in of a module the node runs is a refusal, not an addition. + "the seed, a value missing for a module that runs": {func() *bus { b := one(); b.own = nil; return b }(), placedAs("keeper", "lemurs"), + runsNow, delivery.CandidateRefused, func(a delivery.CandidateAnswer) bool { + return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && len(a.Added) == 0 + }}, + "cannot compose": {one("keeper"), map[string]*delivery.Placement{"n1": {Node: "n2"}}, runsNow, delivery.CandidateRefused, + func(a delivery.CandidateAnswer) bool { return a.Refused != "" }}, + // Issue 1126 (the seed's review): the private network's module and one at a build of the old form, which no want + // names and the controller folds into the node's own, are folded there again: the node's own is the same, and + // neither is added. + "mesh-wireguard and an old-form module, folded": {tunnelled(), tunnelledAs(), + folded, delivery.CandidateSame, func(a delivery.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }}, + } { + fps := oneFps + if name == "mesh-wireguard and an old-form module, folded" { + fps = map[string]string{"keeper": oneFps["keeper"]} // the node's wants: no build of lemurs or the private network + } + a := Answer(ctx, c.b, Mesh{}, Scope{Node: "n1", Placements: c.given, InOwn: InOwn(c.running, *c.given["n1"])}, fps, c.running, "p-h1", 7) + if a.Verdict != c.verdict || !c.check(a) { + t.Errorf("%s: %+v", name, a) + } + if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": a}}); err != nil { + t.Errorf("%s: the answer is not one a have holds: %v (%s)", name, err, raw) + } + } + if got, err := Placed(want, map[string]*delivery.Placement{"laptop": &laptop}); err != nil || !reflect.DeepEqual(got, want) { + t.Errorf("Placed with the node's own placement changes the input: %v", err) + } + if got, err := Placed(want, map[string]*delivery.Placement{"laptop": nil}); err != nil || len(got.Nodes[1].Assigned) != 0 || got.Nodes[1].Place.Key != keys["laptop"] { + t.Errorf("Placed with no placement: %+v, %v", got.Nodes[1], err) + } + if _, err := Placed(want, map[string]*delivery.Placement{"stranger": {Node: "stranger"}}); err == nil { + t.Error("Placed a node the input holds no node for") + } + if _, err := Input(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": {Node: "anchor"}}}); err == nil { + t.Error("a given placement naming another node was taken") + } + + // ADR 0555 §6: Export is Input's inverse for what a person decides: the store's input exported, node by node, is the + // placement the bus holds, and read back it is the store's input again. + for node, placed := range mk().placements { + got, err := Export(want, node) + if err != nil { + t.Fatal(err) + } + if gj, _ := delivery.EncodePlacement(got); !delivery.SamePlacement(got, placed) { + pj, _ := delivery.EncodePlacement(placed) + t.Errorf("%s exported as %s, want %s", node, gj, pj) + } + } + if _, err := Export(want, "stranger"); err == nil { + t.Error("a node the input lacks was exported") + } + + b = mk() + if every, err := Input(ctx, b, Mesh{}, Scope{Every: true}); err != nil || every.OwnSecrets != nil { + t.Fatalf("composing every node: %v, own secrets %v (the vault answers a node about itself alone)", err, every.OwnSecrets) + } + if !reflect.DeepEqual(b.valuesRead, []string{"anchor." + overlay.Name, "laptop." + overlay.Name, "laptop.xterm", "laptop.media"}) { + // Every node's placed modules, and nothing placed nowhere: anchor places no xterm. + t.Errorf("the check read %v", b.valuesRead) + } + + // A node none of whose modules declares an own secret composes without asking the vault (the review of L2: a node + // with no secret composed nothing while the vault did not answer); one whose module does, refuses without it. + quiet := mk() + quiet.ownErr = errors.New("no responders") + if _, err := Input(ctx, quiet, Mesh{}, Scope{Node: "anchor"}); err != nil { + t.Errorf("anchor, with no own secret, needed the vault: %v", err) + } + if _, err := Input(ctx, quiet, Mesh{}, Scope{Node: "laptop"}); err == nil { + t.Error("laptop's media, which declares an own secret, composed without the vault") + } + + // A module the node's want keeps at another build than the trunk's composes from that build's manifest, resolved with + // what it made (issue 1126), on the composing node alone. + kept := mk() + oldXterm := []byte(`{"module":"xterm","version":"1","resources":[{"id":"tools","type":"process","name":"xterm-tools",` + + `"artifact":"tools","run":["./xterm-tools"]}],"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/xterm-tools","binary":"xterm-tools"}]}}`) + kept.manifests["old"] = oldXterm + oldFP := "src2:" + strings.Repeat("9", 64) + toolsRef := "artifact-store://xterm/tools/blobs/sha256:" + strings.Repeat("1", 64) + kept.builds = map[string]*delivery.BuilderBuild{"xterm " + oldFP: {State: delivery.BuildDone, Manifest: "old", + Artifacts: []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: toolsRef, Digest: "sha256:" + strings.Repeat("1", 64)}}}} + parsedOld, err := catalogue.Decode(oldXterm) + if err != nil { + t.Fatal(err) + } + keptBuilt, err := parsedOld.Resolve([]catalogue.Built{{Name: "tools", Kind: "bundle", Reference: toolsRef, Digest: "sha256:" + strings.Repeat("1", 64)}}) + if err != nil { + t.Fatal(err) + } + in, err := Input(ctx, kept, Mesh{}, Scope{Node: "laptop", KeptAt: map[string]string{"xterm": oldFP}}) + if err != nil || !reflect.DeepEqual(in.Nodes[1].Kept, map[string]catalogue.Manifest{"xterm": keptBuilt}) || in.Nodes[0].Kept != nil { + t.Errorf("the want's build is kept on the composing node alone: %v, %v", in.Nodes, err) + } + + // Issue 1126, the seed's refusals. restic and the provisioner's client as the catalogue holds them (mesh-catalog + // c05aa5e6), restic moved with an own secret the vault leaves out (it hands it to the client, mesh-vault ownnode.go + // handed): composed from the indexer's manifest restic is not handed and stands in for that secret, as novox's, + // ace's, g14's and shanks's answers refused; composed from its build, it is handed, its templates archive read + // where the node reaches the artifact store, and the composition is the one the controller's take-in gives. + resticRaw, err := os.ReadFile("testdata/catalogue-c05aa5e6-restic.module.json") + if err != nil { + t.Fatal(err) + } + clientRaw, err := os.ReadFile("testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json") + if err != nil { + t.Fatal(err) + } + store := []byte(`{"module":"store","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],` + + `"serves":{"artifact-store":{"port":5000}}}`) + texts := map[string]string{} + var packed bytes.Buffer + zw := gzip.NewWriter(&packed) + tw := tar.NewWriter(zw) + for _, f := range []string{"backups.conf", "data.conf", "retention.conf"} { + text, err := os.ReadFile("testdata/catalogue-c05aa5e6-restic.files/" + f) + if err != nil { + t.Fatal(err) + } + texts[f] = string(text) + if err := tw.WriteHeader(&tar.Header{Name: "./" + f, Mode: 0o644, Size: int64(len(text)), Typeflag: tar.TypeReg}); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(text); err != nil { + t.Fatal(err) + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + archiveDigest := delivery.ManifestDigest(packed.Bytes()) + digestOf := func(c string) string { return "sha256:" + strings.Repeat(c, 64) } + movedFps := map[string]string{"restic": "src2:" + strings.Repeat("3", 64), "mesh-provisioner-client": "src2:" + strings.Repeat("4", 64), + "store": "src2:" + strings.Repeat("5", 64)} + resticArtifacts := []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: "artifact-store://restic/tools/blobs/" + digestOf("6"), Digest: digestOf("6")}, + {Name: "files", Kind: "archive", Reference: "artifact-store://restic/files/blobs/" + archiveDigest, Digest: archiveDigest}} + clientArtifacts := []delivery.BuiltArtifact{{Name: "client", Kind: "bundle", Reference: "artifact-store://mesh-provisioner-client/client/blobs/" + digestOf("7"), Digest: digestOf("7")}} + movedMesh := func(built bool) *bus { + b := one() + p := b.placements["n1"] + p.Modules = []delivery.PlacedModule{{Module: "store"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}} + p.Decided.Account, p.Decided.AccountHome = "operator", "/home/operator" + b.placements["n1"] = p + rec := b.records["n1"] + rec.Contract, rec.Capabilities = compose.HandedContract, []string{"systemd", "package-manager"} + b.records["n1"] = rec + b.modules = map[string]delivery.Module{} + b.manifests = map[string][]byte{} + for module, raw := range map[string][]byte{"restic": resticRaw, "mesh-provisioner-client": clientRaw, "store": store} { + b.modules[module] = delivery.Module{FP2: movedFps[module], Manifest: delivery.ManifestDigest(raw)} + b.manifests[delivery.ManifestDigest(raw)] = raw + } + // The vault's own answer: the client's own secret; restic's is the client's to ask, so own leaves it out. + b.own = []compose.OwnSecretRow{{Node: "n1", Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}} + if built { + b.builds = map[string]*delivery.BuilderBuild{ + "restic " + movedFps["restic"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(resticRaw), Artifacts: resticArtifacts}, + "mesh-provisioner-client " + movedFps["mesh-provisioner-client"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(clientRaw), Artifacts: clientArtifacts}, + "store " + movedFps["store"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(store)}, + } + b.archives = map[string][]byte{archiveDigest: packed.Bytes()} + } + return b + } + if out, _, held, err := Compose(ctx, movedMesh(false), Mesh{}, Scope{Node: "n1"}, movedFps); err != nil || !strings.Contains(held["restic"], `restic needs a secret called "repository"`) { + t.Errorf("restic composed from the indexer's manifest: %v %v, want its own secret refusing restic alone", err, held) + } else { + for key, c := range out { + if c.Module == "restic" { + // Its stub's composition, applied, would remove what restic runs: a held module keeps its last one. + t.Errorf("held restic is composed as %s, want none of its own composition handed", key) + } + } + } + builtBus := movedMesh(true) + fromBuild, _, held, err := Compose(ctx, builtBus, Mesh{}, Scope{Node: "n1"}, movedFps) + if err == nil && len(held) > 0 { + err = fmt.Errorf("modules held: %v", held) + } + if err != nil { + t.Fatalf("restic composed from its build: %v", err) + } + if !reflect.DeepEqual(builtBus.fetched, []string{"http://127.0.0.1:5000/v2/restic/files/blobs/" + archiveDigest}) { + t.Errorf("the templates archive was read at %v", builtBus.fetched) + } + resticKey, _ := delivery.ComposedKey("n1", "restic", movedFps["restic"]) + if c := fromBuild[resticKey]; len(c.Filled) != 4 { // its three templated files and its own secret + t.Errorf("restic is not handed to the provisioner: its filled files are %v", c.Filled) + } + // A done key an artifact of which names no reference is waited for, in the controller's words (ADR 0498 §8). + unreferenced := movedMesh(true) + unreferenced.builds["restic "+movedFps["restic"]].Artifacts = []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Digest: digestOf("6")}, resticArtifacts[1]} + if _, _, held, err := Compose(ctx, unreferenced, Mesh{}, Scope{Node: "n1"}, movedFps); err != nil || !strings.Contains(held["restic"], "the build of restic is done and waits for the reference of tools") { + t.Errorf("a done key with an unreferenced artifact: %v %v, want restic held alone", err, held) + } + // A module the node runs inside its own composition (the fold) composes from the indexer's manifest though a done + // build of it is at the trunk's FP2: its build is not read. + folding := movedMesh(true) + foldIn, err := Input(ctx, folding, Mesh{}, Scope{Node: "n1", InOwn: []string{"mesh-provisioner-client"}}) + if err != nil { + t.Fatal(err) + } + clientParsed, err := catalogue.Decode(clientRaw) + if err != nil { + t.Fatal(err) + } + if slices.Contains(folding.buildsRead, "mesh-provisioner-client") || !reflect.DeepEqual(foldIn.Catalogue["mesh-provisioner-client"], clientParsed) { + t.Errorf("a folded module was taken from its build: builds read %v", folding.buildsRead) + } + // The controller's: its take-in resolves each manifest with the key's artifacts and the archive's texts, and composes + // from those over the same input. + storeIn, err := Input(ctx, movedMesh(false), Mesh{}, Scope{Node: "n1"}) + if err != nil { + t.Fatal(err) + } + for module, built := range map[string][]catalogue.Built{ + "restic": {{Name: "tools", Kind: "bundle", Reference: resticArtifacts[0].Reference, Digest: digestOf("6")}, + {Name: "files", Kind: "archive", Reference: resticArtifacts[1].Reference, Digest: archiveDigest, Texts: texts}}, + "mesh-provisioner-client": {{Name: "client", Kind: "bundle", Reference: clientArtifacts[0].Reference, Digest: digestOf("7")}}, + } { + resolved, err := storeIn.Catalogue[module].Resolve(built) + if err != nil { + t.Fatal(err) + } + // Kept as its bytes and read again, as the controller's store holds a build's manifest. + raw, _ := json.Marshal(resolved) + if storeIn.Catalogue[module], err = catalogue.Decode(raw); err != nil { + t.Fatal(err) + } + } + storeIn.OwnSecrets = movedMesh(true).own + fromStoreMoved, _, err := compose.Compositions(storeIn, "n1", movedFps, compose.Reading) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(fromBuild, fromStoreMoved) { + gj, _ := json.MarshalIndent(fromBuild, "", " ") + wj, _ := json.MarshalIndent(fromStoreMoved, "", " ") + t.Errorf("composed from the build differs from the controller's take-in:\n got %s\nwant %s", gj, wj) + } + // ADR 0570 clause 2 (issue 1954 L6-P1): the seat mesh-provisioner's plans, composed from the bus alone and with no + // secret read (the Planning mode), are the controller's handedPlans over its own input, which holds its secrets + // (compose_sdk.go: Resolve, Render in Foreseeing, Generators, Plan.Compose), and restic, handed, has one. + noSecrets := movedMesh(true) + noSecrets.own = nil + seat, err := Plans(ctx, noSecrets, Mesh{}, Scope{Node: "n1"}) + if err != nil { + t.Fatalf("the plans from the bus: %v", err) + } + controllers, err := plansIn(storeIn, "n1", compose.Foreseeing) + if err != nil { + t.Fatal(err) + } + if _, handed := seat["restic"]; !handed || !reflect.DeepEqual(seat, controllers) { + gj, _ := json.MarshalIndent(seat, "", " ") + wj, _ := json.MarshalIndent(controllers, "", " ") + t.Errorf("the seat's plans differ from the controller's:\n got %s\nwant %s", gj, wj) + } + // ADR 0571 clause 3: the data items of n1's modules, placed, from the bus alone: what disk-load measures. + items, unplaced, err := DataItems(ctx, noSecrets, Mesh{}, Scope{Node: "n1"}) + if err != nil || len(unplaced) > 0 || len(items) == 0 { + t.Errorf("the data items from the bus are %v, unplaced %v: %v", items, unplaced, err) + } + for module, its := range items { + for _, it := range its { + if it.Module != module || !strings.HasPrefix(it.Path, "/") || it.Measure == "" { + t.Errorf("%s's item %+v is not placed", module, it) + } + } + } + // And the same through StatesReader, over the fake's states as a module's runtime gives them (one reader for + // mesh-provisioner and disk-load, the lead 2026-10-29). + viaStates, _, err := DataItems(ctx, StatesReader(statesOf(t, ctx, noSecrets), func(_ context.Context, url string) ([]byte, error) { + for _, body := range noSecrets.archives { + return body, nil + } + return nil, errors.New("no archive") + }), Mesh{}, Scope{Node: "n1"}) + if err != nil || !reflect.DeepEqual(viaStates, items) { + t.Errorf("through the states the data items are %v, %v; through the reader %v", viaStates, err, items) + } + + // And what a provider on another node serves from its settings (mailu's smtp domain, issue 1126: shanks refused + // '${setting:domain} ... nothing sets "domain"'): read for that provider's served keys alone, through Served. + mailer := []byte(`{"module":"mailer","version":"1","config":{"domain":{"type":"string","description":"the mail domain","required":true}},` + + `"provides":[{"name":"smtp","scope":"mesh"}],"serves":{"smtp":{"port":587,"domain":"${config:domain}"}}}`) + withMailer := func(set bool) *bus { + b := mk() + p := b.placements["anchor"] + p.Modules = append(p.Modules, delivery.PlacedModule{Module: "mailer"}) + b.placements["anchor"] = p + b.modules["mailer"] = delivery.Module{Manifest: delivery.ManifestDigest(mailer)} + b.manifests[delivery.ManifestDigest(mailer)] = mailer + if set { + b.served = map[string]map[string]any{"anchor.mailer": {"domain": "example.org"}} + } + return b + } + if _, _, _, err := Compose(ctx, withMailer(false), Mesh{}, Scope{Node: "laptop"}, fps); err == nil || !strings.Contains(err.Error(), `nothing sets "domain"`) { + t.Errorf("a provider's served setting nobody sets: %v", err) + } + served := withMailer(true) + if _, _, held, err := Compose(ctx, served, Mesh{}, Scope{Node: "laptop"}, fps); err != nil || len(held) > 0 { + t.Errorf("laptop with what anchor's mailer serves: %v %v", err, held) + } + if !reflect.DeepEqual(served.servedRead, []string{"anchor.mailer:domain"}) || slices.Contains(served.valuesRead, "anchor.mailer") { + t.Errorf("laptop read %v of other nodes (and %v of config values): the served keys of anchor's mailer alone", served.servedRead, served.valuesRead) + } + + for name, c := range map[string]struct { + change func(*bus) + scope Scope + says string + }{ + "no record": {func(b *bus) { delete(b.records, "laptop") }, Scope{Node: "anchor"}, "laptop, whose agent has written no record"}, + "no placement": {func(b *bus) {}, Scope{Node: "stranger"}, "stranger has no placement"}, + "a manifest unread": {func(b *bus) { delete(b.manifests, digest) }, Scope{Node: "laptop"}, "the manifest of xterm"}, + "a kept one unbuilt": {func(b *bus) {}, Scope{Node: "laptop", KeptAt: map[string]string{"xterm": "src2:gone"}}, "holds no done build"}, + } { + b := mk() + c.change(b) + if _, err := Input(ctx, b, Mesh{}, c.scope); err == nil || !strings.Contains(err.Error(), c.says) { + t.Errorf("%s: %v, want it to say %q", name, err, c.says) + } + } +} + +// mapStates are read states by name, key to value, as a module's runtime gives them. +type mapStates map[string]map[string][]byte + +func (m mapStates) Get(state, key string) ([]byte, bool, error) { + v, ok := m[state][key] + return v, ok, nil +} + +func (m mapStates) Keys(state string) ([]string, error) { + var out []string + for k := range m[state] { + out = append(out, k) + } + sort.Strings(out) + return out, nil +} + +// statesOf is b laid out as the states StatesReader reads. +func statesOf(t *testing.T, ctx context.Context, b *bus) mapStates { + t.Helper() + out := mapStates{} + put := func(state, key string, raw []byte, err error) { + if err != nil { + t.Fatal(err) + } + if out[state] == nil { + out[state] = map[string][]byte{} + } + out[state][key] = raw + } + placements, _ := b.Placements(ctx) + for _, p := range placements { + values, err := delivery.PlacementValues(p) + for k, raw := range values { + put(PlacementRead, k, raw, err) + } + } + records, _ := b.Records(ctx) + for node, r := range records { + k, _ := delivery.HaveNodeKey(node) + rec := r + raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec}) + put(HaveRead, k, raw, err) + } + modules, _ := b.Modules(ctx) + for name, m := range modules { + raw, err := delivery.EncodeModule(m) + put(ModulesRead, name, raw, err) + var mf struct{ Module string } + _ = json.Unmarshal(b.manifests[m.Manifest], &mf) + k, _ := delivery.ManifestsKey(m.Manifest) + raw, err = delivery.EncodeManifest(delivery.Manifest{Module: mf.Module, JSON: string(b.manifests[m.Manifest])}) + put(ManifestsRead, k, raw, err) + } + for key, build := range b.builds { + module, fp, _ := strings.Cut(key, " ") + k, _ := delivery.BuilderBuildKey(module, fp) + raw, err := delivery.EncodeBuilderBuild(*build) + put(BuildsRead, k, raw, err) + } + for nm, values := range b.values { + for k, v := range values { + value, _ := json.Marshal(v) + raw, err := json.Marshal(map[string]any{"value": json.RawMessage(value), "source": catalogue.SourceModule}) + put(ResolvedRead, nm+"."+k, raw, err) + } + } + return out +} diff --git a/internal/compose/frombus/frombustest/frombustest.go b/internal/compose/frombus/frombustest/frombustest.go new file mode 100644 index 0000000..a627e30 --- /dev/null +++ b/internal/compose/frombus/frombustest/frombustest.go @@ -0,0 +1,183 @@ +// Package frombustest is a bus in memory for frombus's Reader, and a mesh on it whose composition hands a module to the +// provisioner with a provision to make, so a module that composes plans from the bus (mesh-provisioner, novox/hq ADR +// 0570 clause 2) tests against a real plan rather than one written by hand. The manifests are the catalogue's (mesh-catalog +// c05aa5e6: restic and the provisioner's client) as frombus's own tests read them, restic given one requirement more. +package frombustest + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "embed" + "encoding/json" + "errors" + "strings" + + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/compose" + "github.com/novox/mesh-host/internal/compose/frombus" +) + +//go:embed testdata +var testdata embed.FS + +// Bus is a frombus.Reader over values held in memory. A credential asked is answered held when Held is set. +type Bus struct { + Placement map[string]delivery.Placement + Record map[string]delivery.NodeRecord + Module map[string]delivery.Module + Manifests map[string][]byte + Value map[string]map[string]any // by node.module + Own []compose.OwnSecretRow + Builds map[string]*delivery.BuilderBuild // by " " + Archives map[string][]byte // by digest + Serves map[string]map[string]any // another node's values, by node.module + Held bool +} + +var _ frombus.Reader = (*Bus)(nil) + +func (b *Bus) Placements(context.Context) (map[string]delivery.Placement, error) { + return b.Placement, nil +} +func (b *Bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.Record, nil } +func (b *Bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.Module, nil } +func (b *Bus) VaultAuthority(context.Context) string { return "" } + +func (b *Bus) Manifest(_ context.Context, digest string) ([]byte, error) { + raw, ok := b.Manifests[digest] + if !ok { + return nil, errors.New("no such manifest") + } + return raw, nil +} + +func (b *Bus) Values(_ context.Context, node, module string) (map[string]any, error) { + return b.Value[node+"."+module], nil +} + +func (b *Bus) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) { return b.Own, nil } + +func (b *Bus) Credentials(_ context.Context, _ string, asConsumer, asProvider []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) { + var out []compose.Credential + var issued []compose.Issued + for _, k := range append(append([]compose.CredentialKey{}, asConsumer...), asProvider...) { + if b.Held { + out = append(out, compose.Credential{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule, + Provider: k.Provider, Local: k.Local, State: compose.CredentialHeld, Sealed: `"box"`}) + issued = append(issued, compose.Issued{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule, + Provider: k.Provider, Local: k.Local, ForProvider: `"box"`}) + } + } + return out, issued, nil +} + +func (b *Bus) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) { + return b.Builds[module+" "+fp], nil +} + +func (b *Bus) Archive(_ context.Context, _, digest string) ([]byte, error) { + body, ok := b.Archives[digest] + if !ok { + return nil, errors.New("the store holds no such blob") + } + return body, nil +} + +func (b *Bus) Served(_ context.Context, node, module string, keys []string) (map[string]any, error) { + out := map[string]any{} + for _, k := range keys { + if v, ok := b.Serves[node+"."+module][k]; ok { + out[k] = v + } + } + return out, nil +} + +// Node is the one node of the mesh Provisioned composes. +const Node = "n1" + +// Provisioned is one node, n1, that runs the artifact store, a postgres providing postgres-database, the provisioner's +// client and restic: restic built and handed to the provisioner, and requiring postgres-database. Its plans (frombus.Plans +// with Scope{Node: Node}) hand restic, whose plan names that provision with postgres on n1 as its provider. +func Provisioned() (*Bus, error) { + read := func(name string) ([]byte, error) { return testdata.ReadFile("testdata/" + name) } + resticRaw, err := read("catalogue-c05aa5e6-restic.module.json") + if err != nil { + return nil, err + } + var restic map[string]any + if err := json.Unmarshal(resticRaw, &restic); err != nil { + return nil, err + } + restic["requires"] = []string{"postgres-database"} + if resticRaw, err = json.Marshal(restic); err != nil { + return nil, err + } + clientRaw, err := read("catalogue-c05aa5e6-mesh-provisioner-client.module.json") + if err != nil { + return nil, err + } + store := []byte(`{"module":"store","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],` + + `"serves":{"artifact-store":{"port":5000}}}`) + postgres := []byte(`{"module":"postgres","version":"1","provides":[{"name":"postgres-database","scope":"mesh"}],` + + `"serves":{"postgres-database":{"port":5432}}}`) + + var packed bytes.Buffer + zw := gzip.NewWriter(&packed) + tw := tar.NewWriter(zw) + for _, f := range []string{"backups.conf", "data.conf", "retention.conf"} { + text, err := read("catalogue-c05aa5e6-restic.files/" + f) + if err != nil { + return nil, err + } + if err := tw.WriteHeader(&tar.Header{Name: "./" + f, Mode: 0o644, Size: int64(len(text)), Typeflag: tar.TypeReg}); err != nil { + return nil, err + } + if _, err := tw.Write(text); err != nil { + return nil, err + } + } + if err := tw.Close(); err != nil { + return nil, err + } + if err := zw.Close(); err != nil { + return nil, err + } + archive := delivery.ManifestDigest(packed.Bytes()) + digestOf := func(c string) string { return "sha256:" + strings.Repeat(c, 64) } + fps := FPs() + b := &Bus{ + Placement: map[string]delivery.Placement{Node: {Node: Node, + Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Account: "operator", AccountHome: "/home/operator"}, + Modules: []delivery.PlacedModule{{Module: "store"}, {Module: "postgres"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}}}, + Record: map[string]delivery.NodeRecord{Node: {Node: Node, TunnelKey: "dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q=", + SealingKey: "sk", Contract: compose.HandedContract, Capabilities: []string{"systemd", "package-manager"}}}, + Module: map[string]delivery.Module{}, Manifests: map[string][]byte{}, + Own: []compose.OwnSecretRow{{Node: Node, Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}, + Builds: map[string]*delivery.BuilderBuild{ + "restic " + fps["restic"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(resticRaw), Artifacts: []delivery.BuiltArtifact{ + {Name: "tools", Kind: "bundle", Reference: "artifact-store://restic/tools/blobs/" + digestOf("6"), Digest: digestOf("6")}, + {Name: "files", Kind: "archive", Reference: "artifact-store://restic/files/blobs/" + archive, Digest: archive}}}, + "mesh-provisioner-client " + fps["mesh-provisioner-client"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(clientRaw), + Artifacts: []delivery.BuiltArtifact{{Name: "client", Kind: "bundle", + Reference: "artifact-store://mesh-provisioner-client/client/blobs/" + digestOf("7"), Digest: digestOf("7")}}}, + "store " + fps["store"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(store)}, + "postgres " + fps["postgres"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(postgres)}, + }, + Archives: map[string][]byte{archive: packed.Bytes()}, + Held: true, + } + for module, raw := range map[string][]byte{"restic": resticRaw, "mesh-provisioner-client": clientRaw, "store": store, "postgres": postgres} { + b.Module[module] = delivery.Module{FP2: fps[module], Manifest: delivery.ManifestDigest(raw)} + b.Manifests[delivery.ManifestDigest(raw)] = raw + } + return b, nil +} + +// FPs are the fingerprints Provisioned's modules are built at. +func FPs() map[string]string { + return map[string]string{"restic": "src2:" + strings.Repeat("3", 64), "mesh-provisioner-client": "src2:" + strings.Repeat("4", 64), + "store": "src2:" + strings.Repeat("5", 64), "postgres": "src2:" + strings.Repeat("8", 64)} +} diff --git a/internal/compose/frombus/frombustest/frombustest_test.go b/internal/compose/frombus/frombustest/frombustest_test.go new file mode 100644 index 0000000..e6a0311 --- /dev/null +++ b/internal/compose/frombus/frombustest/frombustest_test.go @@ -0,0 +1,272 @@ +package frombustest + +import ( + "context" + "reflect" + "strings" + "testing" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/compose/frombus" + "github.com/novox/mesh-host/internal/compose/fromruntime" +) + +// ADR 0570 clause 2 (issue 1954; a3's H5): the in-memory mesh composes a plan from the bus that hands restic to the +// provisioner and names its postgres-database, provided by postgres on n1. And one bad module must not drop its node +// (issue 774): with a module placed beside it whose built manifest cannot be used, or whose plan refuses, PlansHeld +// still hands restic's plan and names the bad one with why, where Plans refuses the whole node. A manifest the indexer +// holds that the mesh refuses (power's bar item with attributes its bar places by none of, mesh-catalog ca8d6f33, +// 2026-10-30) holds that module alone on its node, in the plans and in the node's composition (Compose), and on a node +// it is not placed on holds nothing. +func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) { + fp := "src2:" + strings.Repeat("9", 64) + for _, row := range []struct { + name string + bad string // the indexer's manifest of a module "backup" placed on n1; empty places none + // built is the manifest backup's done build names; empty gives it no build + built string + why string // words backup's hold says + // elsewhere leaves backup in the indexer's modules and places it on no node: nothing is held. + elsewhere bool + // refused is the indexer's verdict on bad, a problem its judge found (novox/hq ADR 0578 §1): Plans then holds + // backup too, and never ends the read. + refused string + // dataHeld says the data items' read holds backup too: a refusal it meets (a refused manifest, an unusable build, + // an item composition refuses), not one only a plan meets. + dataHeld bool + // dataOnly checks the data items' read alone: the other reads blame restic, whose templates collect the item. + dataOnly bool + }{ + {name: "every module composes"}, + {name: "a built manifest that cannot be used", + bad: `{"module":"backup","version":"1","build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, + built: `{"module":"backup","version":"1","requires":"amqp"}`, why: "backup", dataHeld: true}, + {name: "a plan that refuses", + bad: `{"module":"backup","version":"1","resources":[{"id":"conf","type":"file","path":"/etc/backup.conf",` + + `"mode":"0600","content":"key=${secret:nope}"}]}`, why: "${secret:nope}"}, + {name: "an indexer's manifest the mesh refuses", bad: unusableItem, why: `"shows" is none of them`, + refused: `"shows" is none of them`, dataHeld: true}, + // ADR 0578 §3: an accepted manifest whose data item its composition refuses is held by the culprit rule, in every read. + {name: "a data item its composition refuses", + bad: `{"module":"backup","version":"1","data":{"own":[{"id":"d","path":"${dir:nope}","class":"cache"}]}}`, + why: "nope", dataHeld: true, dataOnly: true}, + {name: "an indexer's manifest the mesh refuses, placed elsewhere", bad: unusableItem, elsewhere: true, + refused: `"shows" is none of them`}, + } { + b, err := Provisioned() + if err != nil { + t.Fatal(err) + } + if row.bad != "" { + if !row.elsewhere { + p := b.Placement[Node] + p.Modules = append(p.Modules, delivery.PlacedModule{Module: "backup"}) + b.Placement[Node] = p + } + digest := delivery.ManifestDigest([]byte(row.bad)) + b.Manifests[digest] = []byte(row.bad) + b.Module["backup"] = delivery.Module{FP2: fp, Manifest: digest} + if row.refused != "" { + b.Module["backup"] = delivery.Module{FP2: fp, Manifest: digest, Verdict: delivery.VerdictRefused, + Problems: []string{row.refused}} + } + if row.built != "" { + builtDigest := delivery.ManifestDigest([]byte(row.built)) + b.Manifests[builtDigest] = []byte(row.built) + b.Builds["backup "+fp] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: builtDigest, + Artifacts: []delivery.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://backup/files/blobs/x", Digest: "sha256:x"}}} + } + if row.dataOnly { + items, _, iheld, err := frombus.DataItemsHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}) + if why, ok := iheld["backup"]; err != nil || !ok || !strings.Contains(why, row.why) || len(items["restic"]) == 0 { + t.Errorf("%s: data items %v held %v: %v", row.name, items, iheld, err) + } + continue + } + plans, err := frombus.Plans(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}) + switch { + case row.refused != "" && (err != nil || !planned(plans, "restic") || planned(plans, "backup")): + t.Errorf("%s: Plans did not hold the refused manifest alone: %v %v", row.name, plans, err) + case row.refused == "" && err == nil && !row.elsewhere: + t.Errorf("%s: Plans composed the node; the row does not refuse it", row.name) + } + } + if row.why != "" || row.bad == "" { + // The node's own composition holds by the same rule: every other module composed, the bad one named. + fps := FPs() + if row.bad != "" { + fps["backup"] = fp + } + out, _, held, err := frombus.Compose(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}, fps) + if err != nil { + t.Fatalf("%s: the node's composition: %v", row.name, err) + } + if key, _ := delivery.ComposedKey(Node, "restic", fps["restic"]); out[key].Module != "restic" { + t.Errorf("%s: restic is not composed (held %v)", row.name, held) + } + if why, ok := held["backup"]; (row.bad == "" && len(held) > 0) || (row.bad != "" && (!ok || !strings.Contains(why, row.why) || len(held) != 1)) { + t.Errorf("%s: the node's composition held %v, want backup alone, saying %q", row.name, held, row.why) + } + } + plans, held, err := frombus.PlansHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}) + if err != nil { + t.Fatalf("%s: %v", row.name, err) + } + restic, handed := plans["restic"] + if !handed || len(restic.Provisions) != 1 || restic.Provisions[0].Provision != "postgres-database" || + restic.Provisions[0].Provider != Node || restic.Provisions[0].ProviderModule != "postgres" { + t.Fatalf("%s: the plans are %+v (held %v)", row.name, plans, held) + } + // Issue 1954: the plans at the wanted builds and at the newest, over one read, are what PlansHeld answers for each, + // and where the time went is said by phase. + keptAt := FPs() + at, newest, took := frombus.PlansHeldTwice(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node, KeptAt: keptAt}) + wantAt, heldAt, errAt := frombus.PlansHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node, KeptAt: keptAt}) + if !reflect.DeepEqual(at, frombus.HeldPlans{Plans: wantAt, Held: heldAt, Err: errAt}) || + !reflect.DeepEqual(newest, frombus.HeldPlans{Plans: plans, Held: held}) { + t.Errorf("%s: twice composed %+v and %+v, want %v %v %v and %v %v", row.name, at, newest, wantAt, heldAt, errAt, plans, held) + } + if said := frombus.PhasesSaid(took); !strings.HasPrefix(said, "read ") || !strings.Contains(said, "plans at newest ") { + t.Errorf("%s: the phases are said as %q", row.name, said) + } + if row.bad == "" || row.elsewhere { + if len(held) > 0 { + t.Errorf("%s: held %v", row.name, held) + } + continue + } + if why, ok := held["backup"]; !ok || !strings.Contains(why, row.why) || len(held) != 1 { + t.Errorf("%s: held %v, want backup alone, saying %q", row.name, held, row.why) + } + if _, planned := plans["backup"]; planned { + t.Errorf("%s: the held module has a plan", row.name) + } + // The data items are read by the same rule (ADR 0578 §3: disk-load's read): the bad module held, the read whole. + items, _, iheld, err := frombus.DataItemsHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}) + // A refusal only a plan meets (a file's secret) holds nothing here: data items render no such file. + mustHold := row.dataHeld + if why, ok := iheld["backup"]; err != nil || ok != mustHold || mustHold && !strings.Contains(why, row.why) || items["backup"] != nil { + t.Errorf("%s: data items %v held %v: %v", row.name, items, iheld, err) + } + } + + // ADR 0580 clause 1: the watched reader reads a state it composes from out of the copy, a build by key each time it is + // asked, and a manifest once per digest. + byKey := map[string]int{} + ws := stdio.WatchedStatesOver(func(state string, handle func(stdio.StateChange) error) error { + byKey[state+" watched"]++ + return handle(stdio.StateChange{Key: "placement." + Node, Op: stdio.OpPut, Value: []byte(`{}`), Current: true}) + }, func(state, key string) ([]byte, bool, error) { byKey[state]++; return []byte(key), true, nil }) + w := fromruntime.Watched{WatchedStates: ws} + for range 2 { + _, _, _ = w.Get(frombus.BuildsRead, "restic."+fp) + _, _, _ = w.Get(frombus.ManifestsRead, "sha256-d") + if _, found, _ := w.Get(frombus.PlacementRead, "placement."+Node); !found { + t.Error("the watched reader does not answer the placement from its copy") + } + } + if byKey[frombus.BuildsRead] != 2 || byKey[frombus.ManifestsRead] != 1 || byKey[frombus.PlacementRead] != 0 || + byKey[frombus.PlacementRead+" watched"] != 1 { + t.Errorf("the watched reader read %v, want a build by key each time, a manifest once, the placement watched once", byKey) + } + fetched := 0 + keep := fromruntime.KeptFetch(func(context.Context, string) ([]byte, error) { fetched++; return []byte("tar"), nil }) + for range 2 { + if body, err := keep(context.Background(), "http://store/restic/files/blobs/sha256:a"); err != nil || string(body) != "tar" { + t.Fatalf("the kept fetch answered %q, %v", body, err) + } + } + if fetched != 1 { + t.Errorf("a templates archive was fetched %d times, want once", fetched) + } + + // Issue 1954: the states reader takes a build in once per its content: composed again, its archive is not read again + // and the plans are the same. + b, err := Provisioned() + if err != nil { + t.Fatal(err) + } + reader, archives := frombus.StatesReader(statesOf(t, b), func(_ context.Context, url string) ([]byte, error) { + fetched++ + _, digest, _ := strings.Cut(url, "/blobs/") + return b.Archives[digest], nil + }), fetched + var first map[string]catalogue.Plan + for i := range 2 { + plans, held, err := frombus.PlansHeld(context.Background(), reader, frombus.Mesh{}, frombus.Scope{Node: Node}) + if err != nil || len(held) > 0 || (i == 1 && !reflect.DeepEqual(plans, first)) || !planned(plans, "restic") { + t.Fatalf("composed %v (held %v): %v", plans, held, err) + } + first = plans + } + if fetched-archives != 1 { + t.Errorf("restic's archive was read %d times over two compositions, want once", fetched-archives) + } +} + +// statesOf is the bus b as a module's read states hold it. +func statesOf(t *testing.T, b *Bus) frombus.States { + t.Helper() + s := states{} + put := func(state, key string, raw []byte, err error) { + if err != nil { + t.Fatal(err) + } + if s[state] == nil { + s[state] = map[string][]byte{} + } + s[state][key] = raw + } + for node, p := range b.Placement { + raw, err := delivery.EncodePlacement(p) + put(frombus.PlacementRead, "placement."+node, raw, err) + } + for node, rec := range b.Record { + raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec}) + put(frombus.HaveRead, node+".node", raw, err) + } + for module, m := range b.Module { + key, _ := delivery.ModulesKey(module) + raw, err := delivery.EncodeModule(m) + put(frombus.ModulesRead, key, raw, err) + } + for digest, man := range b.Manifests { + key, _ := delivery.ManifestsKey(digest) + raw, err := delivery.EncodeManifest(delivery.Manifest{Module: "m", JSON: string(man)}) + put(frombus.ManifestsRead, key, raw, err) + } + for at, build := range b.Builds { + module, fp, _ := strings.Cut(at, " ") + key, _ := delivery.BuilderBuildKey(module, fp) + raw, err := delivery.EncodeBuilderBuild(*build) + put(frombus.BuildsRead, key, raw, err) + } + return s +} + +// states are read states in memory. +type states map[string]map[string][]byte + +func (s states) Get(state, key string) ([]byte, bool, error) { + v, ok := s[state][key] + return v, ok, nil +} + +func (s states) Keys(state string) ([]string, error) { + var keys []string + for k := range s[state] { + keys = append(keys, k) + } + return keys, nil +} + +func planned[P any](plans map[string]P, module string) bool { _, ok := plans[module]; return ok } + +// unusableItem is a manifest the mesh refuses as the node-engine did power's at mesh-catalog ca8d6f33: a bar block item +// with attributes its bar places by none of. +const unusableItem = `{"module":"backup","version":"1","moved":true,` + + `"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},` + + `"items":[{"collection":"contribution:node-bar:block",` + + `"attributes":{"bar":"bottom","place":"status","shows":"state","state":"power.draw"},"template":"files/bar"}]}` diff --git a/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json b/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json new file mode 100644 index 0000000..095128a --- /dev/null +++ b/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json @@ -0,0 +1,60 @@ +{ + "module": "mesh-provisioner-client", + "version": "1", + "claims": [ + { + "name": "node-provisioner", + "scope": "node" + } + ], + "own-secrets": { + "provisioner-broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-provisioner-client", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-provisioner-client", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-provisioner-client" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-provisioner-client", + "place": ".", + "unknown": "program" + }, + { + "id": "client", + "type": "process", + "name": "mesh-provisioner-client", + "artifact": "client", + "run": [ + "./mesh-provisioner-client" + ], + "env": { + "MESH_PROVISIONER_CREDENTIAL": "${dir:state}/broker", + "MESH_PROVISIONER_STATE": "${dir:state}" + }, + "user": "mesh-provisioner-client", + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "client", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-provisioner-client", + "binary": "mesh-provisioner-client" + } + ] + } +} diff --git a/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/backups.conf b/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/backups.conf new file mode 100644 index 0000000..2f23f90 --- /dev/null +++ b/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/backups.conf @@ -0,0 +1,2 @@ +# What the modules on this machine back up, composed by the mesh (novox/hq to-be 43). Do not edit. +${backup:lines} \ No newline at end of file diff --git a/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/data.conf b/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/data.conf new file mode 100644 index 0000000..f19e849 --- /dev/null +++ b/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/data.conf @@ -0,0 +1,2 @@ +# The data the modules on this machine declare, composed by the mesh (novox/hq ADR 0233). Do not edit. +${backup:data} \ No newline at end of file diff --git a/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/retention.conf b/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/retention.conf new file mode 100644 index 0000000..cd7954c --- /dev/null +++ b/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/retention.conf @@ -0,0 +1,6 @@ +# How many restore points the night keeps, written by the mesh from this machine's settings keep-daily, +# keep-weekly, keep-monthly (novox/hq issue 416) and keep-hand-taken-days (novox/hq issue 453). Replaced on every push; change the settings, never this file. +daily=${config:keep-daily} +weekly=${config:keep-weekly} +monthly=${config:keep-monthly} +hand=${config:keep-hand-taken-days} diff --git a/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.module.json b/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.module.json new file mode 100644 index 0000000..ea5ec8b --- /dev/null +++ b/internal/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.module.json @@ -0,0 +1,199 @@ +{ + "module": "restic", + "version": "1", + "moved": true, + "claims": [ + { + "name": "node-backup", + "scope": "node", + "serves": [ + "backed-up", + "now", + "restore" + ] + } + ], + "emits": [ + "backed-up" + ], + "tools": [ + "backup_health", + "backup_delete_retired", + "backup_deleted" + ], + "own-secrets": { + "repository": "${dir:state}/repository.secret" + }, + "data": { + "own": [ + { + "id": "repository", + "path": "${dir:repository}", + "class": "rebuildable", + "backup": "none", + "why": "the restore points themselves: a copy of data kept elsewhere on this machine, never the only copy of anything; lost, the history goes and nothing live does" + }, + { + "id": "state", + "path": "${dir:state}", + "class": "cache", + "why": "what the last nights and measurements were; the next night writes it again" + } + ] + }, + "config": { + "keep-daily": { + "type": "number", + "description": "How many nightly restore points are kept, a count from 1 to 9999: two weeks undo a mistake noticed within a fortnight. Read by the night, which thins nothing while it does not read (novox/hq issue 416); lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 14 + }, + "keep-weekly": { + "type": "number", + "description": "How many weekly restore points are kept behind the dailies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 8 + }, + "keep-monthly": { + "type": "number", + "description": "How many monthly restore points are kept behind the weeklies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 6 + }, + "keep-hand-taken-days": { + "type": "number", + "description": "How many days a restore point taken by hand (node-backup.now, or the mesh's before a module moves) is kept at least, a count from 1 to 9999; it is kept until a later good night of its module exists, and this many days in any case, so the next thinning never takes the one taken against a risky change (novox/hq issue 453). Lowering it lets go of the older ones taken by hand at the next thinning, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 7 + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "repository", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "declared", + "type": "file", + "path": "${dir:state}/backups.conf", + "mode": "0600", + "template": "files/backups.conf" + }, + { + "id": "data-declared", + "type": "file", + "path": "${dir:state}/data.conf", + "mode": "0600", + "template": "files/data.conf" + }, + { + "id": "retention", + "type": "file", + "path": "${dir:state}/retention.conf", + "mode": "0644", + "trusted": true, + "template": "files/retention.conf" + }, + { + "id": "tool", + "type": "package", + "package": "restic" + }, + { + "id": "sqlite", + "type": "package", + "package": "sqlite" + }, + { + "id": "night", + "type": "process", + "name": "restic-night", + "artifact": "tools", + "run": [ + "./restic-backups", + "night" + ], + "user": "${machine:account}", + "env": { + "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf", + "MESH_BACKUP_REPOSITORY": "${dir:repository}", + "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret", + "MESH_BACKUP_STATE": "${dir:state}", + "MESH_BACKUP_DATA": "${dir:state}/data.conf" + }, + "health": { + "kind": "tool", + "tool": "backup_health", + "interval": "60s", + "timeout": "20s", + "looks": 2, + "grace": "60s" + } + }, + { + "id": "measure", + "type": "process", + "name": "restic-measure", + "artifact": "tools", + "run": [ + "./restic-backups", + "measure" + ], + "user": "${machine:account}", + "env": { + "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf", + "MESH_BACKUP_REPOSITORY": "${dir:repository}", + "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret", + "MESH_BACKUP_STATE": "${dir:state}", + "MESH_BACKUP_DATA": "${dir:state}/data.conf" + }, + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/restic-backups", + "binary": "restic-backups", + "loads": [ + "restic-backups" + ], + "env": { + "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf", + "MESH_BACKUP_REPOSITORY": "${dir:repository}", + "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret", + "MESH_BACKUP_STATE": "${dir:state}", + "MESH_BACKUP_DATA": "${dir:state}/data.conf" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/internal/compose/frombus/held_rows_test.go b/internal/compose/frombus/held_rows_test.go new file mode 100644 index 0000000..bc8fae7 --- /dev/null +++ b/internal/compose/frombus/held_rows_test.go @@ -0,0 +1,299 @@ +package frombus + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/compose" + "github.com/novox/mesh-host/internal/compose/resolution" +) + +// aNode is a bus with n1 the hub, each of manifests placed on it and held at the indexer, in the order given. +func aNode(t *testing.T, manifests ...string) (*bus, map[string]string) { + t.Helper() + p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}} + b := &bus{records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd"}}}, + modules: map[string]delivery.Module{}, manifests: map[string][]byte{}, builds: map[string]*delivery.BuilderBuild{}} + fps := map[string]string{} + for i, raw := range manifests { + module := strings.SplitN(strings.TrimPrefix(raw, `{"module":"`), `"`, 2)[0] + p.Modules = append(p.Modules, delivery.PlacedModule{Module: module}) + b.modules[module] = delivery.Module{Manifest: delivery.ManifestDigest([]byte(raw))} + b.manifests[delivery.ManifestDigest([]byte(raw))] = []byte(raw) + fps[module] = "src2:" + strings.Repeat(string(rune('1'+i)), 64) + } + b.placements = map[string]delivery.Placement{"n1": p} + return b, fps +} + +// Issue 774, 2026-10-30 19:30: a node refused whole after holding a module says what it held and why, since the last +// refusal can be of the held module's stub (node-tools held, then "its build produced 0 bundle(s)", its own why unsaid). +func aNodeRefusedWholeSaysWhatItHeld(t *testing.T) { + refused := `{"module":"power","version":"1","items":[{"collection":"contribution:node-bar:block","attributes":{"shows":"state"},"template":"files/a"}]}` + needs := `{"module":"lamp","version":"1","requires":["nothing-provides-this"]}` + b, fps := aNode(t, refused, needs) + refuse(b, "power", `"shows" is none of them`) + _, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1"}, fps) + if err == nil || !strings.Contains(err.Error(), "nothing provides") || !strings.Contains(err.Error(), `held before: power: `) || + !strings.Contains(err.Error(), `"shows" is none of them`) { + t.Errorf("the node refused whole with %v (held %v), want lamp's refusal and power held before, with its why", err, held) + } +} + +// refuse writes the indexer's refusal of module's manifest into its modules value (novox/hq ADR 0578 §1). +func refuse(b *bus, module string, problems ...string) { + m := b.modules[module] + m.Verdict, m.Problems = delivery.VerdictRefused, problems + b.modules[module] = m +} + +// ADR 0578 §3: the bus reader never ends a whole read on a refused manifest. Input, Plans and DataItems compose the rest +// and hold the refused module, named with the indexer's words in Refused; a field the decoder does not know is set aside +// and named, and its module composed. +func aRefusedManifestNeverEndsTheRead(t *testing.T) { + ok := `{"module":"lamp","version":"1","resources":[{"id":"d","type":"directory","path":"/srv/lamp","mode":"0700"}],` + + `"data":{"own":[{"id":"d","path":"${dir:d}","class":"valuable"}]}}` + bad := `{"module":"power","version":"1","resources":[{"id":"d","type":"directory","path":"/srv/power","mode":"0700"}],` + + `"data":{"own":[{"id":"d","path":"${dir:d}","class":"valuable"}]}}` + later := `{"module":"later","version":"1","a-field-from-later":1}` + b, _ := aNode(t, ok, bad, later) + refuse(b, "power", "removes a stream it may not") + ctx := context.Background() + in, err := Input(ctx, b, Mesh{}, Scope{Node: "n1"}) + if err != nil { + t.Fatalf("one refused manifest ended the read: %v", err) + } + if why := in.Refused["power"]; !strings.Contains(why, "refused the manifest of power: removes a stream it may not") || + len(in.Refused) != 1 { + t.Fatalf("refused: %v", in.Refused) + } + if aside := in.SetAside["later"]; !strings.Contains(aside, "a-field-from-later") { + t.Fatalf("set aside: %v", in.SetAside) + } + if !in.Catalogue["power"].Stubbed || in.Catalogue["lamp"].Stubbed || in.Catalogue["later"].Module != "later" { + t.Fatalf("the catalogue: %+v", in.Catalogue) + } + if _, err := Plans(ctx, b, Mesh{}, Scope{Node: "n1"}); err != nil { + t.Fatalf("plans: %v", err) + } + items, _, held, err := DataItemsHeld(ctx, b, Mesh{}, Scope{Node: "n1"}) + if err != nil || len(items["lamp"]) != 1 || items["power"] != nil || !strings.Contains(held["power"], "removes a stream") { + t.Fatalf("data items %v held %v: %v", items, held, err) + } +} + +// Issue 774: the culprit rule takes a module whose stub composes or fails otherwise; the runtime only where its stub +// composes, since its stub always fails otherwise. +func theRuntimeIsBlamedOnlyWhereItsStubComposes(t *testing.T) { + err := errors.New("lamp refuses") + for _, row := range []struct { + module string + again error + blamed bool + }{ + {"lamp", nil, true}, + {"lamp", errors.New("other"), true}, + {"lamp", errors.New("lamp refuses"), false}, + {catalogue.RuntimeModule, nil, true}, + {catalogue.RuntimeModule, errors.New("node-tools is assigned to n1 and its build produced 0 bundle(s)"), false}, + {catalogue.RuntimeModule, fmt.Errorf("compose: %w", &resolution.NoRuntimeToRunError{Held: true}), true}, + {catalogue.RuntimeModule, &resolution.NoRuntimeToRunError{}, false}, + } { + if got := blames(row.module, row.again, err); got != row.blamed { + t.Errorf("%s with %v: blamed %v, want %v", row.module, row.again, got, row.blamed) + } + } +} + +// Issue 774, 2026-10-30 19:30: the trunk's node-tools moved to a build not yet done, and ace, g14 and shanks composed +// it from the indexer's manifest, which names no bundle: "its build produced 0 bundle(s)", every module refused. A +// module that runs is now held until its build lands, the runtime composed as what runs of it; and a module wanted at +// another build than the trunk's is composed from the build its want names. +func aRunningModuleWaitsForItsBuild(t *testing.T) { + dg := func(c string) string { return "sha256:" + strings.Repeat(c, 64) } + tools := `{"module":"node-tools","version":"1","own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` + + `"build":{"artifacts":[{"name":"runtime","kind":"bundle","language":"go","system":"arch","from":"cmd/node-tools"}]}}` + lamp := `{"module":"lamp","version":"1","resources":[{"id":"service","type":"service","unit":"lamp.service","state":"running","boot":"enabled"}]}` + store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}},` + + `"receives":{"artifact-store":"/var/lib/artifacts/received.json"}}` + old, next := "src2:"+strings.Repeat("a", 64), "src2:"+strings.Repeat("b", 64) + running := []delivery.Composed{{Node: "n1", Module: catalogue.RuntimeModule, FP: old, Resources: []json.RawMessage{ + json.RawMessage(`{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{"MESH_TOOL_MODULES":""}}`), + json.RawMessage(`{"id":"node-tools.bundle-runtime","module":"node-tools","type":"archive","source":"artifact-store://node-tools/runtime/blobs/` + + dg("1") + `","digest":"` + dg("1") + `","path":"` + resolution.BundlePath("node-tools", "runtime") + `"}`)}}} + for _, row := range []struct { + name string + want string // the build node-tools' want names + done bool // the trunk's build is done + held string // words node-tools' hold says; empty: composed + digest string // the runtime bundle's digest composed + }{ + {name: "the trunk's build not done yet", want: next, held: "no done build yet"}, + {name: "wanted at the build that runs, the trunk's not done", want: old, digest: dg("1")}, + {name: "the trunk's build done", want: next, done: true, digest: dg("2")}, + } { + b, fps := aNode(t, store, tools, lamp) + b.held = true // every grant held, so a contribution's credential stands in for nothing + b.own = []compose.OwnSecretRow{{Node: "n1", Module: "node-tools", Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}} + mod := b.modules["node-tools"] + mod.FP2 = next + b.modules["node-tools"] = mod + b.builds["node-tools "+old] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest, + Artifacts: []delivery.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("1"), Digest: dg("1")}}} + b.builds["node-tools "+next] = &delivery.BuilderBuild{State: "building", Manifest: mod.Manifest} + if row.done { + b.builds["node-tools "+next] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest, + Artifacts: []delivery.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("2"), Digest: dg("2")}}} + } + fps["node-tools"] = row.want + out, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1", Running: running}, fps) + if err != nil { + t.Errorf("%s: the node is refused: %v", row.name, err) + continue + } + if lampKey, _ := delivery.ComposedKey("n1", "lamp", fps["lamp"]); out[lampKey].Module != "lamp" { + t.Errorf("%s: lamp is not composed (held %v)", row.name, held) + } + if why := held["node-tools"]; (row.held == "") != (why == "") || !strings.Contains(why, row.held) { + t.Errorf("%s: node-tools held %q, want %q", row.name, why, row.held) + } + if row.digest != "" { + key, _ := delivery.ComposedKey("n1", "node-tools", row.want) + if c := out[key]; !strings.Contains(fmt.Sprint(rawStrings(c.Resources)), row.digest) { + t.Errorf("%s: node-tools is composed as %v, want its bundle %s", row.name, rawStrings(c.Resources), row.digest) + } + } + } +} + +func rawStrings(rs []json.RawMessage) []string { + var out []string + for _, r := range rs { + out = append(out, string(r)) + } + return out +} + +// Issue 774: a refusal of a held module's stand-in leads with why the module was held, its first reason, as mesh-self-check's +// on ace should have; a refusal naming no held module is as it was. +func aHeldModulesFirstReasonLeads(t *testing.T) { + held := map[string]string{"mesh-self-check": "mesh-self-check on ace has no bus credential: nothing was issued for ace.mesh-self-check"} + for _, row := range []struct { + err, want string + }{ + {"mesh-self-check runs as its own account and declares no own secret broker", + "mesh-self-check is held: mesh-self-check on ace has no bus credential: nothing was issued for ace.mesh-self-check; " + + "its stand-in is then refused: mesh-self-check runs as its own account and declares no own secret broker"}, + {"ace is the hub and has no endpoint", "ace is the hub and has no endpoint"}, + } { + err := errors.New(row.err) + got := firstReason(err, held) + if got.Error() != row.want || !errors.Is(got, err) { + t.Errorf("refused %q, want %q", got, row.want) + } + } +} + +// Issue 774, 2026-10-30 21:04: mesh-conditions and mesh-healers on novox and mesh-self-check on ace were placed before their +// bus accounts were issued. Held for "needs a secret called broker and none was made", each one's bare stub was then +// refused as "runs as its own account and declares no own secret broker", and that refusal failed every module of the +// node (79 on novox, 74 on ace). A held module's stand-in is never refused by a rule about its own runtime or its own +// secrets: the node composes every other module, and the held one keeps the words it was first held for. +func aHeldModuleOfItsOwnAccountNeverRefusesTheNode(t *testing.T) { + dg := "sha256:" + strings.Repeat("1", 64) + tools := `{"module":"node-tools","version":"1","own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` + + `"build":{"artifacts":[{"name":"runtime","kind":"bundle","language":"go","system":"arch","from":"cmd/node-tools"}]}}` + own := func(name string) string { + return `{"module":"` + name + `","version":"1","runs-as":"` + name + `","secrets-owner":"` + name + `","tools":["status"],` + + `"own-secrets":{"broker":"/var/lib/` + name + `/broker"},"contributes":{"artifact-store":{"name":"` + name + `"}},` + + `"resources":[{"id":"account","type":"user","name":"` + name + `"}],` + + `"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["tools/index.js"]}]}}` + } + lamp := `{"module":"lamp","version":"1","resources":[{"id":"service","type":"service","unit":"lamp.service","state":"running","boot":"enabled"}]}` + store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}},` + + `"receives":{"artifact-store":"/var/lib/artifacts/received.json"}}` + for _, row := range []struct { + name string + issued []string // the own-account modules whose broker is issued + building bool // node-tools' build is not done, and nothing of it runs + held map[string]string // the modules held, each with words of the reason it was first held for + // ungranted gives the provider a grants file and no consumer's grant is issued yet (a newly placed module's + // usual state): a held module's contribution then stands in for its grant, and is left out (review round 1). + ungranted bool + }{ + {name: "none issued", held: map[string]string{"check": "broker", "heal": "broker"}}, + {name: "none issued, no grant issued", ungranted: true, held: map[string]string{"check": "broker", "heal": "broker"}}, + {name: "one issued", issued: []string{"heal"}, held: map[string]string{"check": "broker"}}, + {name: "both issued", issued: []string{"check", "heal"}}, + // The runtime held: its stand-in is not refused for its bundle; the modules it would run are held, not the node. + {name: "the runtime's build not done", issued: []string{"check", "heal"}, building: true, + held: map[string]string{"node-tools": "its build produced 0 bundle(s)", "check": "not here to run it from", "heal": "not here to run it from"}}, + } { + provider := store + if row.ungranted { + provider = strings.Replace(store, `"receives":`, `"grants":{"artifact-store":"/var/lib/artifacts/grants"},"receives":`, 1) + } + b, fps := aNode(t, provider, tools, own("check"), lamp, own("heal")) + // Every grant held, so the held modules' contributions are composed whole; the row ungranted issues none. + b.held = !row.ungranted + b.own = []compose.OwnSecretRow{{Node: "n1", Module: "node-tools", Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}} + for _, m := range row.issued { + b.own = append(b.own, compose.OwnSecretRow{Node: "n1", Module: m, Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}) + } + for _, m := range []string{"node-tools", "check", "heal"} { + name := "tools" + if m == "node-tools" { + name = "runtime" + } + mod := b.modules[m] + mod.FP2 = fps[m] + b.modules[m] = mod + b.builds[m+" "+fps[m]] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest, + Artifacts: []delivery.BuiltArtifact{{Name: name, Kind: "bundle", Reference: "artifact-store://" + m + "/" + name + "/blobs/" + dg, Digest: dg}}} + if m == "node-tools" && row.building { + b.builds[m+" "+fps[m]] = &delivery.BuilderBuild{State: "building", Manifest: mod.Manifest} + } + } + out, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1"}, fps) + if err != nil { + t.Errorf("%s: the node is refused: %v", row.name, err) + continue + } + // A held module keeps what it contributes (issue 774, 2026-10-31: nextcloud held for its admin secret dropped out + // of postgres's received file, and postgres's helper would have locked its login): only its own secrets are left + // out of its stand-in, never its contributions, so the provider's received file names every consumer. + storeKey, _ := delivery.ComposedKey("n1", "artifacts", fps["artifacts"]) + // And never a stand-in for a grant not yet issued: the provider is applied, so a held module whose grant is + // not issued is composed without that contribution, never with "foreseen: made by the next send". + for key, c := range out { + if got, _ := json.Marshal(c.Resources); strings.Contains(string(got), "foreseen") { + t.Errorf("%s: %s applies a stand-in: %s", row.name, key, got) + } + } + for _, m := range []string{"check", "heal"} { + if row.ungranted { + break + } + if got, _ := json.Marshal(out[storeKey].Resources); !strings.Contains(string(got), `\"from\": \"`+m+`\"`) { + t.Errorf("%s: the provider's received file drops %s's contribution: %s", row.name, m, got) + } + } + for _, m := range []string{"node-tools", "check", "lamp", "heal"} { + key, _ := delivery.ComposedKey("n1", m, fps[m]) + _, composed := out[key] + why, isHeld := held[m] + want, wantHeld := row.held[m] + if isHeld != wantHeld || composed == wantHeld { + t.Errorf("%s: %s held %v (%q), composed %v; want held %v", row.name, m, isHeld, why, composed, wantHeld) + } else if isHeld && !strings.Contains(why, want) { + t.Errorf("%s: %s is held for %q, want its first reason, %q", row.name, m, why, want) + } + } + } +} diff --git a/internal/compose/frombus/states.go b/internal/compose/frombus/states.go new file mode 100644 index 0000000..ca932f3 --- /dev/null +++ b/internal/compose/frombus/states.go @@ -0,0 +1,204 @@ +package frombus + +// **The bus read through a module's declared `reads`** (the lead, 2026-10-29: one reader in place of the copies in +// mesh-provisioner and disk-load): a Reader over the states a module reads, by a runtime's Get and Keys (a bundle's +// stdio.State, a process's own), for a holder that composes plans or data items and reads no value: placement, each +// node's record in its own have key, the indexer's modules and manifests, the builder's builds and mesh-config's resolved +// values. No own secret and no credential is read (compose.Planning). Pure as the rest of this package: a bundle's +// runtime states and an HTTP fetch are compose/fromruntime's. + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" + "github.com/novox/mesh-host/internal/compose" + "github.com/novox/mesh-host/internal/compose/resolution" +) + +// The states a module names in its manifest's `reads` to compose through StatesReader. +const ( + PlacementRead = delivery.PlacementRead + ModulesRead = delivery.IndexerModule + ".modules" + ManifestsRead = delivery.IndexerModule + ".manifests" + BuildsRead = delivery.BuilderModule + ".builds" + HaveRead = "node-engine.have" + ResolvedRead = "mesh-config.resolved" +) + +// StatesReads are those reads, in a manifest's order. +var StatesReads = []string{PlacementRead, ModulesRead, ManifestsRead, BuildsRead, HaveRead, ResolvedRead} + +// States is a module's read states as its runtime gives them: a key's value, false when it has none. +type States interface { + Get(state, key string) (value []byte, found bool, err error) + Keys(state string) ([]string, error) +} + +// Fetch reads an archive at a URL (fromruntime.HTTPFetch over HTTP). +type Fetch func(ctx context.Context, url string) ([]byte, error) + +// StatesReader is the Reader over s; fetch reads a templates archive, nil for a holder that reads none. +// It keeps what each build's take-in made, by the build's content (issue 1954), so a holder composing again and again +// takes in only a build it has not seen. +func StatesReader(s States, fetch Fetch) Reader { + return statesReader{s: s, fetch: fetch, kept: &keptTakeIns{}} +} + +type statesReader struct { + s States + fetch Fetch + kept *keptTakeIns +} + +func (r statesReader) takenIn(key string) ([]byte, bool) { return r.kept.takenIn(key) } + +func (r statesReader) keepTakenIn(key string, raw []byte) { r.kept.keepTakenIn(key, raw) } + +func (r statesReader) each(read string, visit func(key string, value []byte)) error { + keys, err := r.s.Keys(read) + if err != nil { + return fmt.Errorf("%s: %w", read, err) + } + for _, k := range keys { + v, found, err := r.s.Get(read, k) + if err != nil { + return fmt.Errorf("%s %s: %w", read, k, err) + } + if found { + visit(k, v) + } + } + return nil +} + +func (r statesReader) Placements(context.Context) (map[string]delivery.Placement, error) { + keys := delivery.NewPlacementKeys() + err := r.each(PlacementRead, func(k string, v []byte) { + _, _ = keys.Put(k, v, 0, false) // a key that does not read places nothing, as before + }) + return keys.All(), err +} + +func (r statesReader) Records(context.Context) (map[string]delivery.NodeRecord, error) { + out := map[string]delivery.NodeRecord{} + err := r.each(HaveRead, func(k string, v []byte) { + if node, _, isNode, err := delivery.ParseHaveKey(k); err == nil && isNode { + if h, err := delivery.DecodeHaveNode(v); err == nil && h.Record != nil { + out[node] = *h.Record + } + } + }) + return out, err +} + +func (r statesReader) Modules(context.Context) (map[string]delivery.Module, error) { + out := map[string]delivery.Module{} + err := r.each(ModulesRead, func(k string, v []byte) { + if module, err := delivery.ParseModulesKey(k); err == nil { + if m, err := delivery.DecodeModule(v); err == nil { + out[module] = m + } + } + }) + return out, err +} + +func (r statesReader) Manifest(_ context.Context, digest string) ([]byte, error) { + key, err := delivery.ManifestsKey(digest) + if err != nil { + return nil, err + } + v, found, err := r.s.Get(ManifestsRead, key) + if err != nil { + return nil, err + } + if !found { + return nil, fmt.Errorf("the indexer holds no manifest %s", digest) + } + m, err := delivery.DecodeManifest(v) + if err != nil { + return nil, err + } + return []byte(m.JSON), nil +} + +func (r statesReader) Values(_ context.Context, node, module string) (map[string]any, error) { + var out map[string]any + prefix := node + "." + module + "." + err := r.each(ResolvedRead, func(k string, v []byte) { + name, ok := strings.CutPrefix(k, prefix) + if !ok || strings.Contains(name, ".") { + return + } + if value, laid := resolution.LaidValue(v); laid { + if out == nil { + out = map[string]any{} + } + out[name] = value + } + }) + return out, err +} + +func (statesReader) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) { + return nil, nil +} + +func (statesReader) Credentials(context.Context, string, []compose.CredentialKey, []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) { + return nil, nil, nil +} + +func (r statesReader) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) { + key, err := delivery.BuilderBuildKey(module, fp) + if err != nil { + return nil, err + } + v, found, err := r.s.Get(BuildsRead, key) + if err != nil || !found { + return nil, err + } + b, err := delivery.DecodeBuilderBuild(v) + if err != nil { + return nil, err + } + return &b, nil +} + +// archiveWithin bounds one read of a templates archive. +const archiveWithin = 30 * time.Second + +func (r statesReader) Archive(ctx context.Context, url, digest string) ([]byte, error) { + if r.fetch == nil { + return nil, fmt.Errorf("this holder reads no archive (%s)", url) + } + fetching, cancel := context.WithTimeout(ctx, archiveWithin) + defer cancel() + body, err := r.fetch(fetching, url) + if err != nil { + return nil, err + } + sum := sha256.Sum256(body) + if got := hex.EncodeToString(sum[:]); got != strings.TrimPrefix(digest, "sha256:") { + return nil, fmt.Errorf("%s was recorded as %s and what arrived is %s", url, digest, got) + } + return body, nil +} + +func (r statesReader) Served(ctx context.Context, node, module string, keys []string) (map[string]any, error) { + all, err := r.Values(ctx, node, module) + var out map[string]any + for _, k := range keys { + if v, ok := all[k]; ok { + if out == nil { + out = map[string]any{} + } + out[k] = v + } + } + return out, err +} diff --git a/internal/compose/frombus/testdata/catalogue-835bf22b-xterm.module.json b/internal/compose/frombus/testdata/catalogue-835bf22b-xterm.module.json new file mode 100644 index 0000000..b65be8e --- /dev/null +++ b/internal/compose/frombus/testdata/catalogue-835bf22b-xterm.module.json @@ -0,0 +1,236 @@ +{ + "module": "xterm", + "version": "1", + "moved": true, + "capabilities": [ + "package-manager" + ], + "requires": [ + "x11-display" + ], + "claims": [ + { + "name": "node-terminal-emulator", + "scope": "node", + "serves": [ + "open" + ] + } + ], + "tools": [ + "xterm_font", + "xterm_colours" + ], + "config": { + "face": { + "type": "string", + "description": "The font family xterm draws with, as fc-list names it: JetBrainsMono Nerd Font, because research 026/04 chose it for its glyphs the prompt uses. A family the machine does not have falls back to the nearest installed face (xterm_font says which).", + "pattern": "[A-Za-z0-9 ._-]{1,64}", + "default": "JetBrainsMono Nerd Font" + }, + "size": { + "type": "number", + "description": "The size of xterm's text, in points: 12 reads well on a screen of about 100 DPI, and a denser screen needs a larger size.", + "minimum": 6, + "maximum": 72, + "predefined": "font-size", + "default": 12 + }, + "save-lines": { + "type": "number", + "description": "How many lines of scrollback xterm keeps per terminal: 10000 holds a long build log, and each line costs memory in every open terminal.", + "minimum": 0, + "maximum": 1000000, + "default": 10000 + }, + "term-name": { + "type": "string", + "description": "The TERM value xterm sets for the programs it runs: xterm-256color, because the palette has 256 colours and every program knows the name.", + "pattern": "[A-Za-z0-9._-]{1,64}", + "default": "xterm-256color" + }, + "render-font": { + "type": "boolean", + "description": "Whether xterm draws with fontconfig faces (true) or the old X core fonts (false): true, because the face is a fontconfig family and core fonts cannot show it.", + "default": true + }, + "bold-mode": { + "type": "boolean", + "description": "Whether xterm draws bold text by overstriking the normal face (true): false, because the face has a real bold style and overstriking blurs it.", + "default": false + }, + "always-bold-mode": { + "type": "boolean", + "description": "Whether xterm overstrikes bold even where the face has a bold style (true): false, for the same reason as bold-mode.", + "default": false + }, + "allow-bold-fonts": { + "type": "boolean", + "description": "Whether xterm may use the face's bold style for bold text (true): true, so bold text is drawn with the real bold weight.", + "default": true + }, + "background": { + "type": "string", + "description": "The terminal's background colour, as #rrggbb: black, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#000000" + }, + "foreground": { + "type": "string", + "description": "The terminal's text colour, as #rrggbb: a light grey, easier on the eyes than white on black.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#a8a8a8" + }, + "color0": { + "type": "string", + "description": "Palette entry 0, the normal black, as #rrggbb: #000000, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#000000" + }, + "color1": { + "type": "string", + "description": "Palette entry 1, the normal red, as #rrggbb: #9e1828, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#9e1828" + }, + "color2": { + "type": "string", + "description": "Palette entry 2, the normal green, as #rrggbb: #008800, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#008800" + }, + "color3": { + "type": "string", + "description": "Palette entry 3, the normal yellow, as #rrggbb: #d2bb4b, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#d2bb4b" + }, + "color4": { + "type": "string", + "description": "Palette entry 4, the normal blue, as #rrggbb: #414171, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#414171" + }, + "color5": { + "type": "string", + "description": "Palette entry 5, the normal magenta, as #rrggbb: #963c59, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#963c59" + }, + "color6": { + "type": "string", + "description": "Palette entry 6, the normal cyan, as #rrggbb: #418179, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#418179" + }, + "color7": { + "type": "string", + "description": "Palette entry 7, the normal white, as #rrggbb: #bebebe, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#bebebe" + }, + "color8": { + "type": "string", + "description": "Palette entry 8, the bright black, as #rrggbb: #666666, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#666666" + }, + "color9": { + "type": "string", + "description": "Palette entry 9, the bright red, as #rrggbb: #bc5766, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#bc5766" + }, + "color10": { + "type": "string", + "description": "Palette entry 10, the bright green, as #rrggbb: #61a171, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#61a171" + }, + "color11": { + "type": "string", + "description": "Palette entry 11, the bright yellow, as #rrggbb: #e7db52, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#e7db52" + }, + "color12": { + "type": "string", + "description": "Palette entry 12, the bright blue, as #rrggbb: #5085af, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#5085af" + }, + "color13": { + "type": "string", + "description": "Palette entry 13, the bright magenta, as #rrggbb: #a97a99, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#a97a99" + }, + "color14": { + "type": "string", + "description": "Palette entry 14, the bright cyan, as #rrggbb: #6ba4a4, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#6ba4a4" + }, + "color15": { + "type": "string", + "description": "Palette entry 15, the bright white, as #rrggbb: #ffffff, as both workstations ran it.", + "pattern": "#[0-9a-fA-F]{6}", + "default": "#ffffff" + }, + "copy-binding": { + "type": "string", + "description": "The key combination that copies the selection to the clipboard, in X translation syntax: Ctrl Shift ~Alt C, so a plain Ctrl+C still interrupts the program. It holds no colon, backslash or line break, which would end the resource.", + "pattern": "[^:\\\\\\n]+", + "default": "Ctrl Shift ~Alt C" + }, + "paste-binding": { + "type": "string", + "description": "The key combination that pastes the clipboard, in X translation syntax: Ctrl Shift ~Alt V, the pair of copy-binding. It holds no colon, backslash or line break, which would end the resource.", + "pattern": "[^:\\\\\\n]+", + "default": "Ctrl Shift ~Alt V" + } + }, + "items": [ + { + "collection": "environment", + "name": "terminal", + "attributes": { + "kind": "variable", + "name": "TERMINAL" + }, + "template": "files/env-terminal" + }, + { + "collection": "shell:xresources:normal", + "name": "xresources-normal", + "template": "files/xresources-normal" + } + ], + "resources": [ + { + "id": "package", + "type": "package", + "package": "xterm" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/xterm-tools", + "binary": "xterm-tools", + "loads": [ + "xterm-tools" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/internal/compose/frombus/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json b/internal/compose/frombus/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json new file mode 100644 index 0000000..095128a --- /dev/null +++ b/internal/compose/frombus/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json @@ -0,0 +1,60 @@ +{ + "module": "mesh-provisioner-client", + "version": "1", + "claims": [ + { + "name": "node-provisioner", + "scope": "node" + } + ], + "own-secrets": { + "provisioner-broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-provisioner-client", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-provisioner-client", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-provisioner-client" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-provisioner-client", + "place": ".", + "unknown": "program" + }, + { + "id": "client", + "type": "process", + "name": "mesh-provisioner-client", + "artifact": "client", + "run": [ + "./mesh-provisioner-client" + ], + "env": { + "MESH_PROVISIONER_CREDENTIAL": "${dir:state}/broker", + "MESH_PROVISIONER_STATE": "${dir:state}" + }, + "user": "mesh-provisioner-client", + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "client", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-provisioner-client", + "binary": "mesh-provisioner-client" + } + ] + } +} diff --git a/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/backups.conf b/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/backups.conf new file mode 100644 index 0000000..2f23f90 --- /dev/null +++ b/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/backups.conf @@ -0,0 +1,2 @@ +# What the modules on this machine back up, composed by the mesh (novox/hq to-be 43). Do not edit. +${backup:lines} \ No newline at end of file diff --git a/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/data.conf b/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/data.conf new file mode 100644 index 0000000..f19e849 --- /dev/null +++ b/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/data.conf @@ -0,0 +1,2 @@ +# The data the modules on this machine declare, composed by the mesh (novox/hq ADR 0233). Do not edit. +${backup:data} \ No newline at end of file diff --git a/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/retention.conf b/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/retention.conf new file mode 100644 index 0000000..cd7954c --- /dev/null +++ b/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/retention.conf @@ -0,0 +1,6 @@ +# How many restore points the night keeps, written by the mesh from this machine's settings keep-daily, +# keep-weekly, keep-monthly (novox/hq issue 416) and keep-hand-taken-days (novox/hq issue 453). Replaced on every push; change the settings, never this file. +daily=${config:keep-daily} +weekly=${config:keep-weekly} +monthly=${config:keep-monthly} +hand=${config:keep-hand-taken-days} diff --git a/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.module.json b/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.module.json new file mode 100644 index 0000000..ea5ec8b --- /dev/null +++ b/internal/compose/frombus/testdata/catalogue-c05aa5e6-restic.module.json @@ -0,0 +1,199 @@ +{ + "module": "restic", + "version": "1", + "moved": true, + "claims": [ + { + "name": "node-backup", + "scope": "node", + "serves": [ + "backed-up", + "now", + "restore" + ] + } + ], + "emits": [ + "backed-up" + ], + "tools": [ + "backup_health", + "backup_delete_retired", + "backup_deleted" + ], + "own-secrets": { + "repository": "${dir:state}/repository.secret" + }, + "data": { + "own": [ + { + "id": "repository", + "path": "${dir:repository}", + "class": "rebuildable", + "backup": "none", + "why": "the restore points themselves: a copy of data kept elsewhere on this machine, never the only copy of anything; lost, the history goes and nothing live does" + }, + { + "id": "state", + "path": "${dir:state}", + "class": "cache", + "why": "what the last nights and measurements were; the next night writes it again" + } + ] + }, + "config": { + "keep-daily": { + "type": "number", + "description": "How many nightly restore points are kept, a count from 1 to 9999: two weeks undo a mistake noticed within a fortnight. Read by the night, which thins nothing while it does not read (novox/hq issue 416); lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 14 + }, + "keep-weekly": { + "type": "number", + "description": "How many weekly restore points are kept behind the dailies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 8 + }, + "keep-monthly": { + "type": "number", + "description": "How many monthly restore points are kept behind the weeklies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 6 + }, + "keep-hand-taken-days": { + "type": "number", + "description": "How many days a restore point taken by hand (node-backup.now, or the mesh's before a module moves) is kept at least, a count from 1 to 9999; it is kept until a later good night of its module exists, and this many days in any case, so the next thinning never takes the one taken against a risky change (novox/hq issue 453). Lowering it lets go of the older ones taken by hand at the next thinning, and that cannot be undone.", + "minimum": 1, + "maximum": 9999, + "trusted": true, + "default": 7 + } + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": ".", + "unknown": "program" + }, + { + "id": "repository", + "type": "directory", + "mode": "0700", + "unknown": "program" + }, + { + "id": "declared", + "type": "file", + "path": "${dir:state}/backups.conf", + "mode": "0600", + "template": "files/backups.conf" + }, + { + "id": "data-declared", + "type": "file", + "path": "${dir:state}/data.conf", + "mode": "0600", + "template": "files/data.conf" + }, + { + "id": "retention", + "type": "file", + "path": "${dir:state}/retention.conf", + "mode": "0644", + "trusted": true, + "template": "files/retention.conf" + }, + { + "id": "tool", + "type": "package", + "package": "restic" + }, + { + "id": "sqlite", + "type": "package", + "package": "sqlite" + }, + { + "id": "night", + "type": "process", + "name": "restic-night", + "artifact": "tools", + "run": [ + "./restic-backups", + "night" + ], + "user": "${machine:account}", + "env": { + "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf", + "MESH_BACKUP_REPOSITORY": "${dir:repository}", + "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret", + "MESH_BACKUP_STATE": "${dir:state}", + "MESH_BACKUP_DATA": "${dir:state}/data.conf" + }, + "health": { + "kind": "tool", + "tool": "backup_health", + "interval": "60s", + "timeout": "20s", + "looks": 2, + "grace": "60s" + } + }, + { + "id": "measure", + "type": "process", + "name": "restic-measure", + "artifact": "tools", + "run": [ + "./restic-backups", + "measure" + ], + "user": "${machine:account}", + "env": { + "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf", + "MESH_BACKUP_REPOSITORY": "${dir:repository}", + "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret", + "MESH_BACKUP_STATE": "${dir:state}", + "MESH_BACKUP_DATA": "${dir:state}/data.conf" + }, + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/restic-backups", + "binary": "restic-backups", + "loads": [ + "restic-backups" + ], + "env": { + "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf", + "MESH_BACKUP_REPOSITORY": "${dir:repository}", + "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret", + "MESH_BACKUP_STATE": "${dir:state}", + "MESH_BACKUP_DATA": "${dir:state}/data.conf" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/internal/compose/frombus/testdata/de-spiegel-e25e1beb.module.json b/internal/compose/frombus/testdata/de-spiegel-e25e1beb.module.json new file mode 100644 index 0000000..b4c4bd2 --- /dev/null +++ b/internal/compose/frombus/testdata/de-spiegel-e25e1beb.module.json @@ -0,0 +1,129 @@ +{ + "module": "de-spiegel", + "version": "1", + "slug": "spiegel", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "route", + "smtp" + ], + "contributes": { + "route": { + "site": { + "label": "de-spiegel", + "port": 35621 + }, + "praktijk": { + "name": "${config:praktijk-name}", + "port": 35621 + }, + "praktijk-www": { + "name": "${config:praktijk-www-name}", + "redirect": "https://${config:praktijk-name}" + }, + "psychologie": { + "name": "${config:psychologie-name}", + "port": 35621 + }, + "psychologie-www": { + "name": "${config:psychologie-www-name}", + "redirect": "https://${config:psychologie-name}" + } + }, + "smtp": { + "account": "de-spiegel" + } + }, + "binds": { + "route": "/var/lib/de-spiegel/route.json", + "smtp": "/var/lib/de-spiegel/smtp.json" + }, + "config": { + "praktijk-name": { + "type": "string", + "description": "The public name the practice's site is served on; the operator's, with no default (novox/hq ADR 0112).", + "trusted": true, + "required": true + }, + "praktijk-www-name": { + "type": "string", + "description": "The public name that redirects to the practice's site (its www form); the operator's, with no default (novox/hq ADR 0112).", + "trusted": true, + "required": true + }, + "psychologie-name": { + "type": "string", + "description": "The public name the psychology practice's site is served on; the operator's, with no default (novox/hq ADR 0112).", + "trusted": true, + "required": true + }, + "psychologie-www-name": { + "type": "string", + "description": "The public name that redirects to the psychology practice's site (its www form); the operator's, with no default (novox/hq ADR 0112).", + "trusted": true, + "required": true + } + }, + "listens": [ + { + "port": 35621, + "protocol": "tcp", + "from": "mesh", + "why": "the de-spiegel site and its /contact endpoint over http; the practice's three public names are route grants, and the proxy reaches every one of them here" + } + ], + "resources": [ + { + "id": "state", + "type": "directory", + "path": "/var/lib/de-spiegel", + "mode": "0700" + }, + { + "id": "server-env", + "type": "file", + "path": "/var/lib/de-spiegel/server.env", + "mode": "0600", + "content": "SMTP_HOST=${bound:smtp:at}\nSMTP_PORT=${bound:smtp:port}\nSMTP_AUTH_USER=de-spiegel@${bound:smtp:domain}\nSMTP_AUTH_PASS=${secret:smtp}\n" + }, + { + "id": "net", + "type": "network", + "name": "de-spiegel" + }, + { + "id": "server", + "type": "container", + "name": "de-spiegel", + "artifact": "site", + "network": "de-spiegel", + "env-file": [ + "/var/lib/de-spiegel/server.env" + ], + "ports": [ + "35621" + ], + "secrets-in-environment": "the application's own code reads SMTP_AUTH_USER/PASS from the environment (server/index.js); converting is this repository's change" + } + ], + "build": { + "on": [ + { + "arg": "NODE_BASE", + "image": "node@sha256:48e4b67d85f87bd551df43704e24d252f56cc5f8e9718841aace50f19948f0f9" + } + ], + "artifacts": [ + { + "name": "site", + "kind": "image", + "from": "Dockerfile" + } + ] + }, + "secrets": { + "smtp": "/var/lib/de-spiegel/smtp.secret" + } +} diff --git a/internal/compose/frombus/testdata/f1-circuits-c0ea2c94.module.json b/internal/compose/frombus/testdata/f1-circuits-c0ea2c94.module.json new file mode 100644 index 0000000..8f2b612 --- /dev/null +++ b/internal/compose/frombus/testdata/f1-circuits-c0ea2c94.module.json @@ -0,0 +1,82 @@ +{ + "module": "f1-circuits", + "slug": "f1", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "route" + ], + "contributes": { + "route": { + "label": "f1-circuits", + "endpoint": "web" + } + }, + "binds": { + "route": "${dir:state}/route.json" + }, + "listens": [ + { + "name": "web", + "port": 8000, + "protocol": "tcp", + "from": "mesh", + "why": "the track-layout CDN, the race posters and the Kometa metadata over http; its public name is a route grant and the proxy reaches it here. Kometa fetches the metadata and every poster by that public name" + } + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": "." + }, + { + "id": "cache", + "type": "directory", + "mode": "0700" + }, + { + "id": "site-env", + "type": "file", + "path": "${dir:state}/site.env", + "mode": "0644", + "content": "F1_CIRCUITS_BASE_URL=https://${bound:route:name}\n" + }, + { + "id": "server", + "type": "container", + "name": "f1-circuits", + "artifact": "cdn", + "ports": [ + "8000" + ], + "env-file": [ + "${dir:state}/site.env" + ], + "volumes": [ + "${dir:cache}:/app/cache" + ], + "restart-on": [ + "site-env" + ] + } + ], + "build": { + "on": [ + { + "arg": "PYTHON_BASE", + "image": "python@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df" + } + ], + "artifacts": [ + { + "name": "cdn", + "kind": "image", + "from": "service/Dockerfile" + } + ] + } +} diff --git a/internal/compose/frombus/twice.go b/internal/compose/frombus/twice.go new file mode 100644 index 0000000..cb133df --- /dev/null +++ b/internal/compose/frombus/twice.go @@ -0,0 +1,147 @@ +package frombus + +// **A node's plans at two sets of builds, over one read** (issue 1954; novox/hq ADR 0570 clause 2, ADR 0580 clause 2): +// the seat mesh-provisioner's holder composes each node at the builds its wants name and at the newest build of each +// placed module. Both are composed from one read of the bus (placement, records, the indexer's modules and manifests, +// values: base); only the builds the node's modules are composed from differ, each build read once for both, and a +// build taken in once per content by a reader that keeps it (StatesReader). Where the time went is said by phase. + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sync" + "time" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// HeldPlans is one scope's plans, as PlansHeld answers them: err a refusal that is no single module's. +type HeldPlans struct { + Plans map[string]catalogue.Plan + Held map[string]string + Err error +} + +// Phase is one step of a composition and how long it took. +type Phase struct { + Name string `json:"phase"` + Took time.Duration `json:"took"` +} + +// PlansHeldTwice is PlansHeld at s and at s with no kept builds (each placed module at its newest build), over one +// read of the bus: what each answers is what PlansHeld answers for that scope. A read that fails is both scopes' err. +// took names each phase in order: the read, then each scope's builds and its plans. +func PlansHeldTwice(ctx context.Context, r Reader, mesh Mesh, s Scope) (at, newest HeldPlans, took []Phase) { + start := time.Now() + mark := func(name string) { + now := time.Now() + took = append(took, Phase{Name: name, Took: now.Sub(start)}) + start = now + } + b, err := readBase(ctx, r, mesh, s) + mark("read") + builds := onceBuilds(r) + newestScope := s + newestScope.KeptAt = nil + for _, sc := range []struct { + scope Scope + into *HeldPlans + name string + }{{s, &at, "wanted"}, {newestScope, &newest, "newest"}} { + held := map[string]string{} + sc.into.Held = held + refuse := func(module string, err error) { held[module] = err.Error() } + b.refuseOwn(refuse) + if err != nil { + sc.into.Err = err + continue + } + in, err := b.finish(ctx, r, builds, mesh, sc.scope, b.cloned(), refuse, nil) + mark("builds at " + sc.name) + if err != nil { + sc.into.Err = err + continue + } + sc.into.Plans, sc.into.Err = plansHeldOf(in, s.Node, held) + mark("plans at " + sc.name) + } + return at, newest, took +} + +// onceBuilds is r's Build, each module's build at a fingerprint read once: both scopes of one composition read the +// builds of the same moment. +func onceBuilds(r Reader) func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) { + type answer struct { + b *delivery.BuilderBuild + err error + } + read := map[string]answer{} + return func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) { + key := module + " " + fp + if a, ok := read[key]; ok { + return a.b, a.err + } + b, err := r.Build(ctx, module, fp) + read[key] = answer{b, err} + return b, err + } +} + +// takenInKeeper is a Reader that keeps what a build's take-in made (StatesReader does): the resolved manifest's bytes, +// by the content it was made from, so a composition made again on every change takes in only a build it has not seen. +type takenInKeeper interface { + takenIn(key string) ([]byte, bool) + keepTakenIn(key string, raw []byte) +} + +// takenInKey is what a take-in is made from: the artifact store's address and the build's record (its manifest's digest, its artifacts by digest), read +// whole; an archive is checked against its digest on every read, so the same record takes in to the same bytes. +func takenInKey(module, fp, address string, b delivery.BuilderBuild) (string, bool) { + raw, err := json.Marshal(b) + if err != nil { + return "", false + } + sum := sha256.Sum256(append([]byte(module+" "+fp+" "+address+" "), raw...)) + return hex.EncodeToString(sum[:]), true +} + +// takenInAtMost bounds the take-ins a reader keeps; past it they are emptied, which costs only a take-in again. +const takenInAtMost = 512 + +// keptTakeIns is a reader's kept take-ins. +type keptTakeIns struct { + mu sync.Mutex + kept map[string][]byte +} + +func (k *keptTakeIns) takenIn(key string) ([]byte, bool) { + k.mu.Lock() + defer k.mu.Unlock() + raw, ok := k.kept[key] + return raw, ok +} + +func (k *keptTakeIns) keepTakenIn(key string, raw []byte) { + k.mu.Lock() + defer k.mu.Unlock() + if k.kept == nil || len(k.kept) >= takenInAtMost { + k.kept = map[string][]byte{} + } + k.kept[key] = raw +} + +// PhasesSaid says the phases in one line, each in milliseconds. +func PhasesSaid(took []Phase) string { + out := "" + for i, p := range took { + if i > 0 { + out += ", " + } + out += fmt.Sprintf("%s %d ms", p.Name, p.Took.Milliseconds()) + } + return out +} diff --git a/internal/compose/fromruntime/fromruntime.go b/internal/compose/fromruntime/fromruntime.go new file mode 100644 index 0000000..b9fab99 --- /dev/null +++ b/internal/compose/fromruntime/fromruntime.go @@ -0,0 +1,101 @@ +// Package fromruntime is compose/frombus's StatesReader over a bundle's runtime (the lead, 2026-10-29): the states a +// module's manifest names in `reads` (frombus.StatesReads), read through stdio.State, and a templates archive fetched +// over HTTP. Apart from frombus so that frombus stays pure for the mesh-compose command. +package fromruntime + +import ( + "context" + "fmt" + "io" + "net/http" + "sync" + + stdio "git.novox.be/novox/mesh-sdk/go" + "github.com/novox/mesh-host/internal/compose/frombus" +) + +// States are a bundle's read states through its runtime. +type States struct{} + +// Get is a key's value, false when it has none. +func (States) Get(state, key string) ([]byte, bool, error) { + e, err := stdio.State(state).Get(key) + if err != nil || e == nil { + return nil, false, err + } + return e.Value, true, nil +} + +// Keys are a state's keys. +func (States) Keys(state string) ([]string, error) { return stdio.State(state).Keys() } + +// Reader is frombus's Reader over this bundle's read states key by key, fetching archives over HTTP. A holder that +// composes again and again reads through Watched instead (novox/hq ADR 0580). +func Reader() frombus.Reader { return frombus.StatesReader(States{}, HTTPFetch) } + +// Watched is frombus's States over a watched copy (novox/hq ADR 0580 clause 1): the states a composition reads whole +// (placement, haves, modules, resolved values) from the copy, a build by key when asked, a manifest once per digest. +type Watched struct{ *stdio.WatchedStates } + +// Get is a key's value: a build read now, a manifest kept once read, any other state's from the copy. +func (w Watched) Get(state, key string) ([]byte, bool, error) { + switch state { + case frombus.BuildsRead: + return w.Direct(state, key) + case frombus.ManifestsRead: + return w.Once(state, key) + } + return w.WatchedStates.Get(state, key) +} + +// WatchedReader is frombus's Reader over w, fetching each templates archive over HTTP once (KeptFetch): a composition +// made again on every change reads it again otherwise. +func WatchedReader(w *stdio.WatchedStates) frombus.Reader { + return frombus.StatesReader(Watched{w}, KeptFetch(HTTPFetch)) +} + +// archivesAtMost bounds the archives KeptFetch keeps; past it they are emptied, which costs only a fetch again. +const archivesAtMost = 64 + +// KeptFetch is fetch kept once per URL: an archive's URL names its blob by digest, so its body never changes under it +// (frombus checks the digest on every read). A failed fetch is not kept. +func KeptFetch(fetch frombus.Fetch) frombus.Fetch { + var mu sync.Mutex + kept := map[string][]byte{} + return func(ctx context.Context, url string) ([]byte, error) { + mu.Lock() + body, ok := kept[url] + mu.Unlock() + if ok { + return body, nil + } + body, err := fetch(ctx, url) + if err != nil { + return nil, err + } + mu.Lock() + if len(kept) >= archivesAtMost { + clear(kept) + } + kept[url] = body + mu.Unlock() + return body, nil + } +} + +// HTTPFetch reads an archive over HTTP, at most 256 MiB. +func HTTPFetch(ctx context.Context, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s answered %s", url, resp.Status) + } + return io.ReadAll(io.LimitReader(resp.Body, 256<<20)) +} diff --git a/internal/compose/input.go b/internal/compose/input.go new file mode 100644 index 0000000..72aecf7 --- /dev/null +++ b/internal/compose/input.go @@ -0,0 +1,365 @@ +// Package compose is the controller's composition of a machine, as a function over a plain input (novox/hq ADR 0453 +// clause 3, ADR 0446 clause 4): what the rest of the mesh offers and holds, the private network's generator, one +// node's resolution and, in the issues after, its rendering and its declaration. +// +// **It never allocates, makes, writes, logs or raises.** The controller composes with a store behind it; this +// package is its Reading and Foreseeing modes over data. Everything the controller reads from its store is a field of +// Input, and everything a send would make or record (a port, a credential, a seat passed on, a node left out) is +// returned for the caller to act on, never done. Input is never changed by a call. No field holds a secret value +// the composition needs: sealed values are optional inputs, and without them the composition uses stand-ins named +// foreseen. The logic is the controller's, ported from mesh-controller cmd/mesh-controller at commit +// 6c2a8d62100cb7d491f8eb9a623e1eeea5ec0de1 (plan.go, network.go, holdings.go, seat_pass.go), in the same order, with the +// same refusals in the same words, so that a comparison node for node is a fair one. +// +// It imports the standard library and mesh-sdk's own packages only. +// It reads no environment variable: the range the mesh allocates from and the suffix of its internal names are fields of Input. +package compose + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/delivery" +) + +// Input is everything the composition reads. Each field is what one read of the controller's inventory answers, named +// beside it; the controller's adapter fills it from its stores and the assigner from the mesh's facts and buckets +// (ADR 0446 clause 2). +type Input struct { + // Catalogue is the shelf: every module's manifest by name (inv.Catalogue), as it is to be composed. A recorded + // module kept at the build a machine runs (keepRecorded) is that node's Kept, applied to its own resolution only (never to the rest of the mesh). + Catalogue map[string]catalogue.Manifest + // Nodes is every node the mesh has (inv.Nodes and inv.Overlays together, one row per node). + Nodes []Node + // Holdings is every seat whose holder is on record (inv.Holdings). + Holdings []catalogue.Held + // Licences is every licence the mesh holds, with the machines that use it (open.Licences); none for a mesh with no + // licence. + Licences []Licence + + // OverlayCIDR is the range the mesh allocates from when no tunnel was adopted, as genesis was told it (the + // controller's OverlayCIDRVar); empty means DefaultOverlayCIDR. The package reads no environment. + OverlayCIDR string + // InternalSuffix is what the mesh's internal names end in (the controller's MESH_INTERNAL_SUFFIX), with or without a leading + // dot; empty means overlay.DefaultSuffix. A fact of the whole mesh, so it is in the Input and never read from the environment. + InternalSuffix string + // Tunnels is, by node name, the found tunnel whose key is the node's overlay key (inv.Tunnels). + Tunnels map[string]FoundTunnel + // Adopted is the hub's found tunnel and the hub's name, whether or not the hub took it (inv.AdoptedTunnel); nil + // when no hub has one. + Adopted *AdoptedTunnel + // CarriedPeers is every peer of the adopted tunnel (inv.CarriedPeers). + CarriedPeers []CarriedPeer + // Joining is the nodes that hold a live enrolment token (inv.NodesWithALiveToken). + Joining []string + + // Credentials is how each pair credential a node takes from elsewhere stands, never its value (what inv.SecretFor, + // SharedSecretFor and GivenPairSecret would answer): held, to be made by the next send, only a person's to give, or + // refused. A need with no entry here is one the next send would make. + Credentials []Credential + + // Built is every repository the mesh has built (the keys of inv.Held): what tells a reference recorded with an address + // from an image a module runs straight from a public registry. + Built []string + // OperatorExport is every operator-sealed secret in the mesh, for a module that keeps them (inv.OperatorExport): sealed + // values the composition carries without opening. Nil when the mesh has no operator key. + OperatorExport *catalogue.KeptExport + // KeptRuns is, by node and module, the file of the export as that node runs it (its resource of a module's running + // composition): what a node composing its own carries where it holds no OperatorExport (catalogue.Rendering.KeptRuns, + // issue 1126, C6). Used only while OperatorExport is nil. + KeptRuns map[string]map[string]map[string]any + + // Issued is the credentials made for the consumers of each provider (inv.SecretsFrom): one row for each pair credential + // that exists, by the provider node it is for. The grants a provider must create are read from these, so a credential not + // made yet has no grant until the send that makes it. An assigner fills it from the facts snapshot's Grants of each + // machine (go/facts, from format 2), which carry who each credential is for and never the credential. + Issued []Issued + // OwnSecrets is the own secrets held on each machine (the module_secret rows): who made each, and the key it is sealed to. + // Their sealed values are optional. + OwnSecrets []OwnSecretRow + // KeptCoreBoxes is the vault's box last kept for each core credential on each machine (inv.KeptCoreBoxes). + KeptCoreBoxes []CoreBox + // BusUsers is the bus's user list as the controller composes it from the bus records and the hashes it holds (composeBusUsers), + // for the machine that runs the bus: names and hashes, never passwords. Empty when the Input does not carry it, and then + // the composition uses a stand-in, named foreseen. BusMissing are the users left out of it for want of a credential. + BusUsers string + BusMissing []string + // BusMinted are the bus users the mesh has minted a password hash for (inv.BusUserHash): `.` for a module and + // `provisioner.` for a machine's provisioner client. + BusMinted []string + // BusMemberships is, by node, the membership sealed to it for the bus the mesh is moving to (inv.BusMemberships). + BusMemberships map[string]string + // Authority is the mesh's own certificate (identity.EstablishAuthority), public. Optional. + Authority string + + // CurrentBuilds is, by module, the commit of the build the mesh holds for it (inv.CurrentBuilds, the commit it was built from); + // BuildIDs the build made from a commit of a module (inv.BuildOf), by module then commit. Read only to name the builds a + // declaration carries to a node-engine that reads them. + CurrentBuilds map[string]string + BuildIDs map[string]map[string]string + + // Refused is each module whose manifest the indexer's judge refused, or whose bytes do not decode, by module, with why + // (novox/hq ADR 0578 §3, §5): its verdict, carried in the plain input. Such a module stands in Catalogue as its stub + // (compose.Stub), so it is held: nothing of its own is composed, while what it provides and requires still shapes the + // rest. A module not named is accepted, a missing verdict included. + Refused map[string]string `json:",omitempty"` + // SetAside is each module whose manifest has a field the reader's decoder does not know, by module, with what was set + // aside (catalogue.Manifest.SetAside): the rest of it is composed, and the reader names it (ADR 0578 §6). + SetAside map[string]string `json:",omitempty"` +} + +// Issued is one pair credential made for a consumer of a provider (inv.Secret): ForProvider is its copy sealed to the +// provider, optional. +type Issued struct { + Provision string + Consumer string + ConsumerModule string + Local string + Provider string + ForProvider string +} + +// OwnSecretRow is one own secret held on a machine: Origin is `made` (the mesh generated it) or `accepted` (a person gave it), +// SealedTo the sealing key it is sealed to, and Sealed its value sealed to that key, optional. +type OwnSecretRow struct { + Node string + Module string + Name string + Origin string + SealedTo string + Sealed string +} + +// CoreBox is the vault's box for one core credential of one machine, as last kept (inv.CoreBox). +type CoreBox struct { + Node string + Module string + Name string + Generation int + SealedTo string + Box json.RawMessage +} + +// Node is one node of the mesh and what the composition reads about it. +type Node struct { + Name string + // Place is where it is on the private network (inv.Overlays). + Place Place + // Assigned is the modules assigned to it, in the order the controller reads them (inv.Assigned). + Assigned []string + // Handed is, by module, whether the vault judged it handed to the provisioner when it answered this node's own + // secrets (catalogue.AssignmentHands of the plan in its assignment): the node's own composition hands exactly those + // (catalogue.Rendering.Assigned, issue 774). Nil where the vault said nothing of it, as for the controller. + Handed map[string]bool + // Capabilities are the ones present on it (inv.ProfileOf). + Capabilities map[string]bool + // PublicDomain is the domain it composes its routed names under (inv.PublicDomainOf). + PublicDomain string + // Account, AccountHome, AgentAccount and AgentAccountHome are its operator and agent logins (inv.NodeByName). + Account, AccountHome, AgentAccount, AgentAccountHome string + // Adopted is whether it is adopted rather than converged. + Adopted bool + // Pins is the provider each provision was pinned to for it (inv.PinsFor). + Pins map[string]catalogue.Chosen + // Bound is where each of its consumers of a provision that keeps data was last sent (inv.BindingsFor), by consumer + // module and provision. An assigner fills it from the facts snapshot's Bindings of the machine (go/facts, from format 2). + Bound map[string]map[string]catalogue.Chosen + // Settings is its settings layers by module (inv.SettingsFor); a module with none is absent. + Settings map[string][]catalogue.Layer + // Ports is the machine port each module's software port was given on it (inv.PortsFor), by module then port. + Ports map[string]map[int]int + // SealingKey is the public key its secrets are sealed to (inv.SealingKeyOf); empty when it has none. A public key, so + // nothing secret. + SealingKey string + // Taken is the modules taken on it, for an adopted node (inv.Taken). + Taken []string + // OutwardLinks are the links it reported as facing outside it (inv.OutwardLinksOf). + OutwardLinks []string + // Networks are the networks it reported being attached to, as link and network (inv.MachineOf); NetworksSaid is whether + // its node-engine said any at all, which is not the same as saying none. + Networks []catalogue.AttachedNetwork + NetworksSaid bool + // ServingKey is the key the machine serves with, which a certificate is issued for (identity.ServingKeyOf); empty when it has + // never reported one. Certificate is the certificate the mesh issued for its internal name, public and optional. + ServingKey string + Certificate string + // Sequence, Epoch and Generation are what the declaration is sent under (inv.Sequence, SentEpoch, SentGeneration): the order + // against every other send to the node, the lease epoch and the assignment generation it was last sent. Zero is not sent. + Sequence int64 + Epoch uint64 + Generation int64 + // ReadsModules says its node-engine reads a module per resource and a build per module (inv.ReadsModules, issue 1145); only + // then are they sent. SentBuilds is the build of each module it was last sent, by module to commit, and SentKnown whether + // that is known at all (inv.SentBuilds); Runs is what each of its assignments runs (inv.AssignmentRuns), by module. + ReadsModules bool + SentBuilds map[string]string + SentKnown bool + Runs map[string]Run + // Kept is, by module, the manifest of a recorded or core module kept at the build this machine runs (plan.go keepRecorded: + // the caller reads it with recordedKept and ManifestAt), and KeptBuilds the commit of that build. They apply to this node's + // own resolution only, never to another node's, and a declaration carries the kept commit for the module. + Kept map[string]catalogue.Manifest + KeptBuilds map[string]string + // Contract is the contract of its node-engine's newest statement (inv.HealthOf); Stated is whether it ever stated one. + Contract int + Stated bool + // InOwn is the modules it runs inside its own composition (`.node`), not by a composition of their own, since + // no fingerprint of the new form names them where it runs them (novox/hq issue 1126): Compositions folds their + // resources into the node's own, as the controller's composed writer does, unless fps gives one of the new form. + // The private network's module (overlay.Name), which nobody builds, is folded whether or not it is named here. + InOwn map[string]bool + // RunsOwn is the node's own composition (`.node`) as it runs, when the node composes itself: a module folded + // there (InOwn) whose manifest names a build (NamesBuild) runs a build of the old form only the controller holds, + // so it is left to the node: Compositions composes it as a stub (Stub) and carries its resources as they run, each + // after the resource it runs after (issue 1126). Nil composes every folded module from its manifest, as before. + RunsOwn *delivery.Composed + // Held is the modules refused alone on this node, each composed as what runs of it (Carried, in Kept; its Stub when + // nothing of it runs) so the rest is composed (novox/hq issue 1126): none of a held module's own composition is in what + // Compositions answers, so the node keeps what it last applied of it; one whose resources the node's own runs + // (RunsOwn), folded or leaving the fold, is carried there as it runs, whatever its manifest names, so the node's own + // never drops it. + Held map[string]bool + // RunsTools is, by module, the tools bundles the node's runtime serves of it as it runs (ToolsRunning of what the + // node runs); nil when not known. A module composed as what runs of it (its Stub or Carried: held, or folded at a + // build of the old form) contributes these to the runtime, never its manifest's: a manifest no build resolved has no + // bundles, and a held module's new build's bundles are not delivered (issue 1126, 2026-10-29: shanks's runtime lost + // the folded systemd-resolved, novox's the folded nats). + RunsTools map[string][]catalogue.Bundle +} + +// Place is where a node is, as its placement says (inv.Overlay). +type Place struct { + Key string + Endpoint string + Site string + Hub bool + Address string +} + +// Reachable says whether the node can be dialled. +func (p Place) Reachable() bool { return strings.TrimSpace(p.Endpoint) != "" } + +// Tunnel is a tunnel found on a node (inv.Tunnel). +type Tunnel struct { + Interface string + Unit string + Config string + Port int + MTU int + // Address is the interface's own address with its prefix length, Range the network that prefix names. + Address string + Range string + PublicKey string + Peers []TunnelPeer +} + +// TunnelPeer is one peer of a found tunnel. +type TunnelPeer struct { + PublicKey string + Address string +} + +// FoundTunnel is a node's tunnel and whether the node is adopted (inv.FoundTunnel). +type FoundTunnel struct { + Tunnel + NodeAdopted bool +} + +// AdoptedTunnel is the hub's tunnel, the hub's name, and whether the hub's overlay key is the tunnel's, which is +// whether the private network took the tunnel over. +type AdoptedTunnel struct { + Tunnel Tunnel + Hub string + Adopted bool +} + +// CarriedPeer is a peer of the adopted tunnel (inv.CarriedPeer). +type CarriedPeer struct { + PublicKey string + Address string + EnrolledAs string + Named string +} + +// Licence is one licence the mesh holds: its name, what a consumer may know of it, and the machines and modules +// that use it. +type Licence struct { + Name string + Serves map[string]any + Holders []Holder + // Keys is, by holder, the licence's key sealed to that holder's machine (held.KeyFor), empty where the mesh holds none: + // the plaintext was discarded when it was supplied. Optional: nil means not given, and the composition uses a stand-in named + // foreseen; given (even empty), a holder with no entry is one the mesh holds no key for. + Keys map[Holder]string + // Manager is the holder of a refreshable-grant licence that manages it (held.ManagerOf); the zero Holder when none. + Manager Holder +} + +// Holder is a module on a node that uses a licence. +type Holder struct { + Node string + Module string +} + +// ErrNoSuchNode is what the controller's inventory answers for a node it does not have. +var ErrNoSuchNode = errors.New("no node of that name") + +// node is the node of that name, or the refusal the controller's inventory gives for one it does not have. +func (in Input) node(name string) (Node, error) { + for _, n := range in.Nodes { + if n.Name == name { + return n, nil + } + } + return Node{}, fmt.Errorf("%w: %s", ErrNoSuchNode, name) +} + +// sorted is the nodes by name, the order the controller reads them in. +func (in Input) sorted() []Node { + out := append([]Node(nil), in.Nodes...) + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// CredentialState is how one pair credential stands. +type CredentialState string + +const ( + // CredentialHeld is a credential held for both ends as they stand now. Sealed, when given, is the consumer's copy; a held + // credential with no value is composed with a stand-in, named foreseen. + CredentialHeld CredentialState = "held" + // CredentialWouldMake is a credential none is held for: the next send makes it, and the composition uses a stand-in, + // named foreseen. It is also what a need with no entry is. + CredentialWouldMake CredentialState = "would-make" + // CredentialNotGiven is a credential only a person gives (novox/hq ADR 0337 clause 4) that nobody has given, or that was given + // under a key one end no longer has: the consumer still resolves, without it. Why is the controller's words for it. + CredentialNotGiven CredentialState = "not-given" + // CredentialRefused is a credential the send would refuse to make, for the reason Why gives in the controller's words: a + // value a person gave sealed to a key the machine no longer has, a machine with no sealing key. + CredentialRefused CredentialState = "refused" +) + +// Credential is the state of one pair credential, by the ends it joins: the provision, the consumer's node and module, the +// provider's node, and the local name where a module keeps several (inv.Secret's key). +type Credential struct { + Provision string + Consumer string + ConsumerModule string + Provider string + Local string + State CredentialState + // Sealed is the consumer's copy as it is sealed to the consumer's key; optional, and only the controller's adapter passes it. + Sealed string + // Why is the words of a CredentialNotGiven or CredentialRefused. + Why string +} + +// Run is the build an assignment names: the commit it is of and the build made from it (chain.Ref). +type Run struct { + Commit string + Build string +} diff --git a/internal/compose/network.go b/internal/compose/network.go new file mode 100644 index 0000000..4e11abf --- /dev/null +++ b/internal/compose/network.go @@ -0,0 +1,308 @@ +package compose + +import ( + "errors" + "fmt" + "slices" + "sort" + "strconv" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/compose/resolution" + "github.com/novox/mesh-host/internal/overlay" +) + +// The private network: who is on it, where, and what they are called. Ported from mesh-controller +// cmd/mesh-controller/network.go at 6c2a8d62. + +// DefaultOverlayCIDR is the range the mesh allocates from when nothing says another. +const DefaultOverlayCIDR = "10.42.0.0/16" + +// composer is one composition over an Input: it reads Input, changes nothing in it, and keeps what the controller +// said on its standard error as lines to return. +type composer struct { + in Input + said []string + seen map[string]bool + // unhostable is, from the last look at the rest of the mesh, each module assigned to another node that this + // composition finds that node cannot run: what it would have offered is missing here (issue 1126, probe #532). + unhostable []string + + // What one composition looks at again and again, each worked out once over its Input, which it never changes (issue + // 1954: a node's plans resolved every machine of the mesh some two hundred times, 11.7 s of CPU for novox): who is + // on the private network, and the rest of the mesh as each node sees it. + onNetwork []Node + onNetworkSet bool + rest map[string]restOf + plans map[string]planOf +} + +// restOf is theRestOfTheMesh's answer for one excluded node, kept: what it found unhostable beside it. +type restOf struct { + world catalogue.World + passes Passes + left []LeftOut + unhostable []string + err error +} + +func newComposer(in Input) *composer { return &composer{in: in, seen: map[string]bool{}} } + +// say keeps a line the controller would have written to its log, once. +func (c *composer) say(format string, args ...any) { + line := fmt.Sprintf(format, args...) + if c.seen[line] { + return + } + c.seen[line] = true + c.said = append(c.said, line) +} + +// suffix is what the mesh's internal names end in: the Input's, and overlay.DefaultSuffix when it gives none. The package never calls +// overlay.Suffix or overlay.InternalName, which read the environment, so the assigner and the controller compose the same names +// from the same Input. +func (c *composer) suffix() string { + if v := strings.TrimPrefix(strings.TrimSpace(c.in.InternalSuffix), "."); v != "" { + return v + } + return overlay.DefaultSuffix +} + +// internalName is a node's name inside the mesh. +func (c *composer) internalName(node string) string { return node + "." + c.suffix() } + +// saidBeside is the lines already said and then every line the composer kept, once each, in the order they were found. +func (c *composer) saidBeside(lines []string) []string { + for _, l := range c.said { + if !slices.Contains(lines, l) { + lines = append(lines, l) + } + } + return lines +} + +// overlayRange is the range the mesh allocates node addresses from: the adopted tunnel's first (novox/hq ADR 0105), +// then the range genesis was told, then the default. +func (c *composer) overlayRange() string { + if a := c.in.Adopted; a != nil && a.Adopted { + return a.Tunnel.Range + } + if v := strings.TrimSpace(c.in.OverlayCIDR); v != "" { + return v + } + return DefaultOverlayCIDR +} + +// network builds the private network over the machines that resolved the module for it. A machine is on it because it +// was given the module, or because it is joining with a key and an address (novox/hq ADR 0169), or because its own set +// could not be resolved and it holds a place (a key and an address): that place is its last good one on the network, and +// it does not change because its modules failed, so every other machine keeps its path to it -- the hub above all -- and +// only its own declaration is refused (issue 1833). A machine refused with no place is left off. +func (c *composer) network(on map[string]bool, refused map[string]string) (*overlay.Generator, error) { + tunnels := c.in.Tunnels + carried := c.in.CarriedPeers + isJoining := map[string]bool{} + for _, name := range c.in.Joining { + isJoining[name] = true + } + var nodes []overlay.Node + for _, node := range c.in.sorted() { + p := node.Place + placed := p.Key != "" && p.Address != "" + _, wasRefused := refused[node.Name] + if !on[node.Name] && !((isJoining[node.Name] || wasRefused) && placed) { + continue + } + if !on[node.Name] && wasRefused && !isJoining[node.Name] { + c.say("%s could not be resolved and keeps its place on the private network at %s, so the others keep their path to it", + node.Name, p.Address) + } + n := overlay.Node{ + Name: node.Name, Key: p.Key, Endpoint: p.Endpoint, + Site: p.Site, Hub: p.Hub, Address: p.Address, + } + if t, takes := tunnels[node.Name]; takes && t.NodeAdopted { + // Only an adopted node is told to take the found unit over. A hub whose placement disagrees with the tunnel is + // refused, not composed. + if wrong := disagrees(p, t.Tunnel); wrong != "" { + return nil, fmt.Errorf("%s takes over the tunnel on %s and its placement disagrees with it: %s. "+ + "Re-place it — `overlay place %s --hub --endpoint :%d …` — and push again; "+ + "nothing was composed", node.Name, t.Interface, wrong, node.Name, t.Port) + } + n.TakesOver = &overlay.TakeOver{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU} + } + if p.Hub { + for _, cp := range carried { + n.Carried = append(n.Carried, overlay.Carried{Key: cp.PublicKey, Address: cp.Address}) + } + } + nodes = append(nodes, n) + } + if len(nodes) == 0 { + // Nobody was given it: an empty network is a legitimate mesh, not a broken one. + return overlay.Empty(), nil + } + g, err := overlay.From(nodes, c.overlayRange(), "") + if err != nil && len(refused) > 0 { + // The network is missing something, and some machines could not be resolved at all: almost always the same fact. + var who []string + for name, why := range refused { + who = append(who, fmt.Sprintf(" %s: %s", name, why)) + } + sort.Strings(who) + return nil, fmt.Errorf("%w\n\nand %d node(s) could not be resolved at all, which is "+ + "probably why:\n%s", err, len(refused), strings.Join(who, "\n")) + } + return g, err +} + +// Graph is the whole mesh's network, for showing it: the nodes on it and each one's peers. +func Graph(in Input) ([]overlay.Node, overlay.Graph, error) { + c := newComposer(in) + on, refused, err := c.whoResolves(overlay.Requirement) + if err != nil { + return nil, nil, err + } + g, err := c.network(on, refused) + if err != nil { + return nil, nil, err + } + return g.Nodes(), g.Graph(), nil +} + +// whoResolves is the machines whose resolution answers a requirement, and why the others did not: by what a module +// provides, not by its name, resolved rather than read from the assignments. +func (c *composer) whoResolves(requirement string) (map[string]bool, map[string]string, error) { + on := map[string]bool{} + // Why a node could not be resolved, kept rather than raised: one broken node must not stop the rest being described. + refused := map[string]string{} + for _, n := range c.in.sorted() { + resolved, err := c.planFor(n.Name) + switch { + case unresolvable(err): + refused[n.Name] = err.Error() + continue + case err != nil: + // Not a node that does not resolve: a question that went unanswered. + return nil, nil, fmt.Errorf("whether %s answers %q cannot be read: %w", n.Name, requirement, err) + } + for _, m := range resolved.Plan.Modules { + for _, offered := range m.Offers() { + if offered == requirement { + on[n.Name] = true + } + } + } + } + return on, refused, nil +} + +// Generators is what every node's declaration is composed with, over the whole mesh: the private network's generator +// (one generator now; the names and a resolver's zone file are facts a module asks for in its manifest). +func Generators(in Input) (map[string]catalogue.Generator, error) { + c := newComposer(in) + return c.generators() +} + +func (c *composer) generators() (map[string]catalogue.Generator, error) { + on, refused, err := c.whoResolves(overlay.Addressing) + if err != nil { + return nil, err + } + net, err := c.network(on, refused) + if err != nil { + return nil, err + } + return map[string]catalogue.Generator{overlay.Name: net}, nil +} + +// disagrees says how a node's placement differs from the tunnel it takes over — its address not the tunnel's, its +// endpoint not on the tunnel's port — or nothing when both agree. +func disagrees(p Place, t Tunnel) string { + var wrong []string + want := t.Address + if i := strings.Index(want, "/"); i >= 0 { + want = want[:i] + } + if p.Address != want { + wrong = append(wrong, fmt.Sprintf("its address is %s and the tunnel's is %s", orNothing(p.Address), want)) + } + if p.Reachable() && portOfEndpoint(p.Endpoint) != strconv.Itoa(t.Port) { + wrong = append(wrong, fmt.Sprintf("its endpoint %s is not on the tunnel's port %d", p.Endpoint, t.Port)) + } + return strings.Join(wrong, "; ") +} + +func orNothing(s string) string { + if s == "" { + return "unset" + } + return s +} + +// portOfEndpoint is the port in host:port, or empty. +func portOfEndpoint(endpoint string) string { + if i := strings.LastIndex(endpoint, ":"); i >= 0 { + return endpoint[i+1:] + } + return "" +} + +// whereEveryoneIs is each machine's name on the private network, for the ones on it. Resolved without consulting the +// rest of the mesh (it is called from that path), and refused without the catalogue. +func (c *composer) whereEveryoneIs() (map[string]string, error) { + if c.in.Catalogue == nil { + return nil, errors.New("asked where everyone is without the catalogue, which cannot be answered") + } + out := map[string]string{} + for _, p := range c.onTheNetwork() { + out[p.Name] = c.internalName(p.Name) + } + return out, nil +} + +// onTheNetwork is every placed machine that resolves the private network: has an address AND runs what puts it there +// (novox/hq issue 079). A machine that does not resolve keeps its place when it holds one (a key and an address), by the +// same rule as network(), so its name and its address stay where the tunnel to it does (issue 1833); one without is not +// counted, and said. +func (c *composer) onTheNetwork() []Node { + if !c.onNetworkSet { + c.onNetwork, c.onNetworkSet = c.onTheNetworkNow(), true + } + return slices.Clone(c.onNetwork) +} + +// onTheNetworkNow is onTheNetwork worked out: every placed machine's set resolved. +func (c *composer) onTheNetworkNow() []Node { + var out []Node + for _, n := range c.in.sorted() { + if n.Place.Address == "" { + continue + } + if len(n.Assigned) == 0 { + continue + } + got, err := resolution.Resolve(c.in.Catalogue, n.Assigned, + catalogue.Node{Name: n.Name, Site: n.Place.Site, Capabilities: n.Capabilities}, + catalogue.World{Unchecked: true, Holdings: c.in.Holdings}) + if err != nil { + if n.Place.Key != "" { + c.say("%s could not be resolved and keeps its place on the private network at %s, so the others keep their path to it", + n.Name, n.Place.Address) + out = append(out, n) + continue + } + c.say("%s is not counted as on the network: it does not resolve: %v", n.Name, err) + continue + } + for _, m := range got.Modules { + for _, offered := range m.Offers() { + if offered == overlay.Requirement { + out = append(out, n) + } + } + } + } + return out +} diff --git a/internal/compose/network_test.go b/internal/compose/network_test.go new file mode 100644 index 0000000..2f924f9 --- /dev/null +++ b/internal/compose/network_test.go @@ -0,0 +1,306 @@ +package compose + +import ( + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/overlay" +) + +// onTheTunnel makes the hub of an Input what mesh-controller's network_test.go makes of anchor in +// TestTheOverlaysRangeIsTheAdoptedTunnels: adopted, enrolled with the found tunnel's key, and presenting the tunnel +// (Tunnel{wg0, wg-quick@wg0, port 51900, 192.0.2.1/24, range 192.0.2.0/24, one peer PEER-TWO}). Placed on it when +// replaced is true: its address the tunnel's, its endpoint on the tunnel's port, as `overlay place anchor --endpoint +// 198.51.100.10:51900 --site hosting --hub` leaves it. +func onTheTunnel(t *testing.T, in *Input, replaced bool) { + t.Helper() + const key = "THE-TUNNELS-KEY=========================" + tunnel := Tunnel{Interface: "wg0", Unit: "wg-quick@wg0", Config: "/etc/wireguard/wg0.conf", Port: 51900, + Address: "192.0.2.1/24", Range: "192.0.2.0/24", PublicKey: key, + Peers: []TunnelPeer{{PublicKey: "PEER-TWO", Address: "192.0.2.2"}}} + hub := nodeOf(t, in, "anchor") + hub.Adopted = true + hub.Place.Key = key + if replaced { + hub.Place.Address, hub.Place.Endpoint, hub.Place.Site = "192.0.2.1", "198.51.100.10:51900", "hosting" + } + in.Tunnels = map[string]FoundTunnel{"anchor": {Tunnel: tunnel, NodeAdopted: true}} + in.Adopted = &AdoptedTunnel{Tunnel: tunnel, Hub: "anchor", Adopted: true} + in.CarriedPeers = []CarriedPeer{{PublicKey: "PEER-TWO", Address: "192.0.2.2"}} +} + +// novox/hq ADR 0105 (mesh-controller network_test.go TestTheOverlaysRangeIsTheAdoptedTunnels): the range every address is +// composed from is the adopted tunnel's, read from the tunnel the hub holds, never stored anywhere else. The environment +// value of the controller is Input.OverlayCIDR here. +func TestTheOverlaysRangeIsTheAdoptedTunnels(t *testing.T) { + in := aMesh(t) + in.OverlayCIDR = "10.99.0.0/16" + if got := newComposer(in).overlayRange(); got != "10.99.0.0/16" { + t.Fatalf("without an adopted tunnel the range is not what genesis said: %q", got) + } + in.OverlayCIDR = "" + if got := newComposer(in).overlayRange(); got != DefaultOverlayCIDR { + t.Fatalf("with nothing said the range is %q, not the default", got) + } + in.OverlayCIDR = "10.99.0.0/16" + onTheTunnel(t, &in, true) + if got := newComposer(in).overlayRange(); got != "192.0.2.0/24" { + t.Fatalf("with an adopted tunnel the range is %q, not the tunnel's", got) + } + // A tunnel the hub found and did not take (its key is not the tunnel's) does not give its range. + in.Adopted.Adopted = false + if got := newComposer(in).overlayRange(); got != "10.99.0.0/16" { + t.Fatalf("a tunnel the hub did not take gave its range: %q", got) + } + in.Adopted.Adopted = true + + // And the hub's declaration carries the peer and the takeover. + nodes, computed, err := Graph(in) + if err != nil { + t.Fatal(err) + } + var hubNode overlay.Node + for _, n := range nodes { + if n.Name == "anchor" { + hubNode = n + } + } + if hubNode.TakesOver == nil || hubNode.TakesOver.Unit != "wg-quick@wg0" { + t.Errorf("the hub is not told to take over the found tunnel: %+v", hubNode) + } + carried := false + for _, p := range computed["anchor"] { + if p.Key == "PEER-TWO" && p.Allowed == "192.0.2.2/32" { + carried = true + } + } + if !carried { + t.Errorf("the hub's peer list does not carry the tunnel's peer: %+v", computed["anchor"]) + } +} + +// A takeover is composed only for a hub whose placement agrees with the tunnel (mesh-controller network_test.go +// TestATakeoverIsNotComposedForAHubPlacedOffItsTunnel): an address or an endpoint port that differs would have the host stop +// the found interface and raise the mesh's where no peer is listening. +func TestATakeoverIsNotComposedForAHubPlacedOffItsTunnel(t *testing.T) { + in := aMesh(t) + onTheTunnel(t, &in, false) + // aMesh placed anchor at 10.77.0.1 on :51820, the record of a hub placed before it took the tunnel over. + _, _, err := Graph(in) + if err == nil { + t.Fatal("a takeover was composed for a hub whose address and port are not the tunnel's") + } + for _, want := range []string{"10.77.0.1", "192.0.2.1", "51820", "51900", "overlay place anchor"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not say %q: %v", want, err) + } + } + // Re-placed on the tunnel, it composes. + onTheTunnel(t, &in, true) + if _, _, err := Graph(in); err != nil { + t.Fatalf("re-placed on the tunnel, the graph still refuses: %v", err) + } +} + +// 1360/AC2: the generator gives the addresses, peers and names of aMesh: anchor the hub at 10.77.0.1, laptop at 10.77.0.2 +// peering through it, both written for their machines. +func TestTheGeneratorGivesTheMeshItsAddressesAndPeers(t *testing.T) { + in := aMesh(t) + gens, err := Generators(in) + if err != nil { + t.Fatal(err) + } + g, ok := gens[overlay.Name].(*overlay.Generator) + if !ok { + t.Fatalf("the generator is %T", gens[overlay.Name]) + } + addr := map[string]string{} + for _, n := range g.Nodes() { + addr[n.Name] = n.Address + } + if addr["anchor"] != "10.77.0.1" || addr["laptop"] != "10.77.0.2" || len(addr) != 2 { + t.Fatalf("the addresses are %v", addr) + } + peers := g.Graph() + if len(peers["laptop"]) != 1 || peers["laptop"][0].Name != "anchor" || peers["laptop"][0].Endpoint != "anchor.example:51820" { + t.Errorf("laptop's peers are %+v, want the hub", peers["laptop"]) + } + if len(peers["anchor"]) != 1 || peers["anchor"][0].Name != "laptop" || peers["anchor"][0].Allowed != "10.77.0.2/32" { + t.Errorf("anchor's peers are %+v, want laptop", peers["anchor"]) + } + for _, node := range []string{"anchor", "laptop"} { + res, on, err := g.Resources(node) + if err != nil || !on || len(res) == 0 { + t.Errorf("%s is given no network: %v %v %d", node, on, err, len(res)) + } + } +} + +// 1360/AC3: a node not on the private network (it runs nothing that puts it there) gets no address and no network, and the +// one that stayed keeps its own. +func TestANodeNotOnThePrivateNetworkGetsNoAddressAndNoNetwork(t *testing.T) { + in := aMesh(t) + nodeOf(t, &in, "laptop").Assigned = nil // it keeps its place and its address, and stops running the network + gens, err := Generators(in) + if err != nil { + t.Fatal(err) + } + g := gens[overlay.Name].(*overlay.Generator) + if len(g.Nodes()) != 1 || g.Nodes()[0].Name != "anchor" { + t.Fatalf("the network is over %+v, want anchor alone", g.Nodes()) + } + if res, on, err := g.Resources("laptop"); on || err != nil || len(res) != 0 { + t.Errorf("a machine that left the network is given one: %v %v %v", res, on, err) + } + if _, in := g.Graph()["laptop"]; in { + t.Error("a machine that left the network has peers") + } +} + +// 1360/AC3: a node whose resolution is refused is said with today's words: the network missing its hub names the +// machine that could not be resolved, which is almost always why. The hub here holds no complete place (no key): a refused +// machine with one keeps it on the network (issue 1833), so only one without is missing from it. +func TestARefusedNodeIsSaidWhenTheNetworkCannotBeComposed(t *testing.T) { + in := aMesh(t) + nodeOf(t, &in, "anchor").Place.Key = "" + one := catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} + two := catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} + in.Catalogue["rival-one"], in.Catalogue["rival-two"] = one, two + hub := nodeOf(t, &in, "anchor") + hub.Assigned = append(hub.Assigned, "rival-one", "rival-two") // its own set is incoherent: the hub does not resolve + _, err := Generators(in) + if err == nil { + t.Fatal("a network without its hub was composed") + } + for _, want := range []string{overlay.ErrNoHub.Error(), "and 1 node(s) could not be resolved at all, which is probably why:", " anchor: "} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not say %q:\n%v", want, err) + } + } +} + +// 1360/AC4: Generators changes nothing in its Input. +func TestGeneratorsChangesNothingInItsInput(t *testing.T) { + unchanged(t, func() Input { + in := aMesh(t) + onTheTunnel(t, &in, true) + in.Joining = []string{"laptop"} + return in + }, func(in Input) { + if _, err := Generators(in); err != nil { + t.Fatal(err) + } + }) +} + +// Each field of Input that the network reads reaches the generator: change it and the generator changes. +func TestEachFieldOfTheInputReachesTheGenerator(t *testing.T) { + addressesOf := func(in Input) map[string]string { + t.Helper() + gens, err := Generators(in) + if err != nil { + t.Fatalf("%v", err) + } + out := map[string]string{} + for _, n := range gens[overlay.Name].(*overlay.Generator).Nodes() { + out[n.Name] = n.Address + } + return out + } + base := addressesOf(aMesh(t)) + if len(base) != 2 { + t.Fatalf("the base mesh has %v", base) + } + t.Run("Catalogue: without the module offering it nobody is on the network", func(t *testing.T) { + in := aMesh(t) + // The module stays known, so every machine still resolves: a machine that does not resolve keeps its place (issue 1833). + in.Catalogue = map[string]catalogue.Manifest{overlay.Name: {Module: overlay.Name, Version: "1"}} + if got := addressesOf(in); len(got) != 0 { + t.Errorf("got %v", got) + } + }) + t.Run("Nodes.Place: an address", func(t *testing.T) { + in := aMesh(t) + nodeOf(t, &in, "laptop").Place.Address = "10.77.0.9" + if got := addressesOf(in); got["laptop"] != "10.77.0.9" { + t.Errorf("got %v", got) + } + }) + t.Run("Nodes.Assigned", func(t *testing.T) { + in := aMesh(t) + nodeOf(t, &in, "laptop").Assigned = nil + if got := addressesOf(in); len(got) != 1 { + t.Errorf("got %v", got) + } + }) + t.Run("Nodes.Capabilities: a capability the module needs", func(t *testing.T) { + in := aMesh(t) + m := in.Catalogue[overlay.Name] + m.Capabilities = []string{"wireguard"} + in.Catalogue[overlay.Name] = m + nodeOf(t, &in, "laptop").Capabilities = map[string]bool{} + got, err := Generators(in) + if err != nil { + t.Fatal(err) + } + if n := got[overlay.Name].(*overlay.Generator).Nodes(); len(n) != 1 || n[0].Name != "anchor" { + t.Errorf("a machine without the capability is on the network: %+v", n) + } + }) + t.Run("OverlayCIDR", func(t *testing.T) { + in := aMesh(t) + in.OverlayCIDR = "10.99.0.0/16" + _, graph, err := Graph(in) + if err != nil { + t.Fatal(err) + } + found := false + for _, p := range graph["laptop"] { + found = found || p.Allowed == "10.99.0.0/16" + } + if !found { + t.Errorf("the hub's route is not the range given: %+v", graph["laptop"]) + } + }) + t.Run("Tunnels and Adopted and CarriedPeers", func(t *testing.T) { + in := aMesh(t) + onTheTunnel(t, &in, true) + nodes, graph, err := Graph(in) + if err != nil || len(nodes) != 2 { + t.Fatalf("%v %v", nodes, err) + } + if len(graph["anchor"]) < 2 { + t.Errorf("the hub's peers do not include the carried one: %+v", graph["anchor"]) + } + }) + t.Run("Joining: a machine with a key and an address is on the network before it is anything else", func(t *testing.T) { + in := aMesh(t) + in.Nodes = append(in.Nodes, Node{Name: "newcomer", Place: Place{Key: keyOf(t, "newcomer"), Address: "10.77.0.3"}}) + if got := addressesOf(in); got["newcomer"] != "" { + t.Fatalf("a machine that runs nothing is on the network: %v", got) + } + in.Joining = []string{"newcomer"} + if got := addressesOf(in); got["newcomer"] != "10.77.0.3" { + t.Errorf("a joining machine is not on the network: %v", got) + } + }) + t.Run("Holdings: a holder on record settles a seat two assignments could hold", func(t *testing.T) { + withSeat(t) + in := aMesh(t) + for _, name := range []string{"holder-one", "holder-two"} { + in.Catalogue[name] = aBrokerClaiming(name) + } + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = append(laptop.Assigned, "holder-one", "holder-two") + // Without a key: a machine that does not resolve and holds a complete place keeps it on the network (issue 1833). + laptop.Place.Key = "" + if got := addressesOf(in); got["laptop"] != "" { + t.Fatalf("a machine whose set claims one seat twice is on the network: %v", got) + } + in.Holdings = []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "laptop", Module: "holder-one"}} + if got := addressesOf(in); got["laptop"] != "10.77.0.2" { + t.Errorf("a holder on record did not settle the seat: %v", got) + } + }) +} diff --git a/internal/compose/node.go b/internal/compose/node.go new file mode 100644 index 0000000..0aff1dc --- /dev/null +++ b/internal/compose/node.go @@ -0,0 +1,352 @@ +package compose + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/compose/resolution" + "github.com/novox/mesh-host/internal/validate" +) + +// Declared is a declaration as a machine is sent it (the controller's sendable, sendable.go): its resources and, for an adopted +// node, its mode and which modules were taken on it (novox/hq ADR 0100), with what the composition says beside it and never puts on +// the wire. +// +// **Body is the only place the envelope is marshalled**, so the digest the mesh compares and the body validated are one. +type Declared struct { + Resources []map[string]any + // Sequence, Epoch and Generation are what the Input gave the node: zero is not sent at all. + Sequence int64 + Epoch uint64 + Generation int64 + // ReadsModules says the machine's node-engine reads a module per resource and a build per module (novox/hq issue 1145): only + // then are they sent. ModuleOf is the composer's owner of each resource id and BuildIDs the build each module carries. + ReadsModules bool + ModuleOf map[string]string + BuildIDs map[string]string + // Adoption is nil for a converged node, and then the body is byte for byte what it was before adoption existed. + Adoption *Adoption + // Filled is the path of every file this declaration hands to the provisioner, and Provisioner the account its client runs as + // (novox/hq ADR 0349): on the wire as `filled_files` and `provisioner_account`, both absent when nothing is handed. + Filled []string + Provisioner string + // LeftOut is every module of the machine's set left out because a stored setting cannot compose with its definition (novox/hq + // ADR 0163, rule 6), sorted; LeftOutWhy is why each was. + LeftOut []string + LeftOutWhy map[string]string + + // Not on the wire: + Received map[string]map[string][]catalogue.Contribution + Mesh []string + BusUsers string + // Modules are the modules this declaration names, sorted. + Modules []string + // LocalNetwork is every port admitted from the local network and what it resolved to (novox/hq ADR 0340). + LocalNetwork []catalogue.LocalNetworkPort + Withheld []catalogue.Overflow + Unbound []catalogue.Unbound + Unplaced []string + HandedOnly []string + // RequiredBy is, for each handed module only pulled in by another, the module that requires it (issue 765). + RequiredBy map[string]string + // Foreseen is every own secret composed with a stand-in, as `module/name`; StoodIn is everything the composition stood in for + // (an own secret, a grant's sealed copy, the certificate, the bus's user list); ForeseenPorts the ports the mesh may assign that + // the Input holds none for. A declaration that has any is never sent: what a send would make, named, never invented. + Foreseen []string + StoodIn []string + ForeseenPorts []string + // CoreBoxesWanted are the core credentials whose box the send must ask the vault for and keep. + CoreBoxesWanted []CoreBoxWanted + // Passes are the seats the mesh would pass on, for the caller to record, and Said the lines found while composing. + Passes Passes + Said []string +} + +// Adoption is what an adopted node is told about its mode. Taken is every module taken on it that it runs; Untaken is, for every +// module it runs that is not taken, the ids of every one of that module's resources: what the host keeps as found until the module +// is taken (ADR 0103). +type Adoption struct { + Taken []string `json:"taken"` + Untaken map[string][]string `json:"untaken,omitempty"` +} + +// Body is the declaration's bytes, as sent and as digested. +func (d Declared) Body() ([]byte, error) { + envelope := map[string]any{"declaration": 1, "resources": d.Resources} + if d.ReadsModules { + // The module of each resource the composer owns, and the build of each module (novox/hq issue 1145): to a machine that + // said it reads them, and no other. + named := make([]map[string]any, len(d.Resources)) + for n, r := range d.Resources { + module, owned := d.ModuleOf[fmt.Sprint(r["id"])] + if !owned { + named[n] = r + continue + } + with := make(map[string]any, len(r)+1) + for k, v := range r { + with[k] = v + } + with["module"] = module + named[n] = with + } + envelope["resources"] = named + if len(d.BuildIDs) > 0 { + envelope["builds"] = d.BuildIDs + } + } + if d.Adoption != nil { + envelope["adoption"] = d.Adoption + } + if d.Sequence > 0 { + envelope["sequence"] = d.Sequence + } + if d.Epoch > 0 { + envelope["epoch"] = d.Epoch + } + if len(d.LeftOut) > 0 { + envelope["left_out"] = d.LeftOut + } + if d.Generation > 0 { + envelope["generation"] = d.Generation + } + if len(d.Filled) > 0 { + envelope["filled_files"] = d.Filled + envelope["provisioner_account"] = d.Provisioner + } + // An empty declaration is deliberate: the node owns nothing the mesh put there (novox/hq issue 127), and the host refuses an + // empty body unless it is told the emptiness is meant. + if len(d.Resources) == 0 { + envelope["owns_nothing"] = true + } + return json.Marshal(envelope) +} + +// Refusals are the findings a check refuses a change for, beside the validator's problems: a consumer left out of the grants +// because its identity overflows the provision's bound, and a credential bound to another consumer (novox/hq ADR 0446 clause 1). +// Each names the consumer and the provision. +func (d Declared) Refusals() []string { + var out []string + for _, o := range d.Withheld { + out = append(out, o.String()) + } + for _, u := range d.Unbound { + out = append(out, u.String()) + } + sort.Strings(out) + return out +} + +// Compose composes one node's declaration (the issue calls it Node; the Input already has a type of that name) over an Input -- as a push would, without the sending (the controller's +// composedAndValidated in the Foreseeing mode) -- and runs the node-engine's own validator over its body. An error is that the +// node did not compose (a set that does not compose satisfies IsUnresolvable); problems are what the validator refuses, in the +// node-engine's words. It allocates, makes, keeps and writes nothing, and names what a send would do as foreseen. +func Compose(in Input, node string) (Declared, []string, error) { + return ComposeIn(in, node, Foreseeing) +} + +// ComposeIn is Compose in a chosen mode. +func ComposeIn(in Input, node string, mode Mode) (Declared, []string, error) { + c := newComposer(in) + d, err := c.declaration(node, mode) + if err != nil { + return Declared{}, nil, err + } + body, err := d.Body() + if err != nil { + return Declared{}, nil, err + } + return d, validate.Declaration(body), nil +} + +// declaration is the controller's declarationWith over the plain Input. +func (c *composer) declaration(nodeName string, mode Mode) (Declared, error) { + resolved, err := c.planFor(nodeName) + if err != nil { + return Declared{}, err + } + gens, err := c.generators() + if err != nil { + return Declared{}, err + } + rendered, err := c.render(nodeName, mode) + if err != nil { + return Declared{}, err + } + with := rendered.Rendering + with.Generators = gens + plan := resolved.Plan + composed, err := resolution.Compose(plan, with) + if err != nil { + return Declared{}, err + } + // Which ports the filter admits from the local network, and from which networks (novox/hq ADR 0340 clause 8): the same rules the + // composition rendered, never sent. + rules, err := resolution.Rules(plan, with) + if err != nil { + return Declared{}, err + } + node, err := c.in.node(nodeName) + if err != nil { + return Declared{}, err + } + out := Declared{Resources: composed.Resources, Adoption: adoptionFor(plan, node, composed), + Received: composed.Received, Mesh: with.Mesh, BusUsers: with.BusUsers, + LeftOut: sortedKeysOf(composed.LeftOut), LeftOutWhy: composed.LeftOut, + Withheld: rendered.Withheld, Unbound: rendered.Unbound, + Foreseen: composed.Foreseen, StoodIn: rendered.Foreseen, ForeseenPorts: rendered.ForeseenPorts, + Unplaced: composed.Unplaced, HandedOnly: composed.HandedOnly, Modules: namedModules(plan, composed.LeftOut), + Filled: composed.Filled, LocalNetwork: resolution.LocalNetworkPorts(rules, with.LocalNetworks), + CoreBoxesWanted: rendered.CoreBoxesWanted, Said: rendered.Said, + Sequence: node.Sequence, Epoch: node.Epoch, Generation: node.Generation} + if len(composed.Filled) > 0 { + out.Provisioner = with.Provisioner + } + // A handed module only pulled in by another keeps what requires it, for its value of its own in the assignments mirror (issue 765). + for module := range composed.Plans { + if by := resolution.RequiredBy(plan, module); by != "" { + if out.RequiredBy == nil { + out.RequiredBy = map[string]string{} + } + out.RequiredBy[module] = by + } + } + // And the module of each resource and the build of each module, to a machine whose node-engine said it reads them (novox/hq issue + // 1145) and to no other. + if node.ReadsModules { + carried := c.carriedOn(node, plan, composed.LeftOut) + out.ReadsModules, out.ModuleOf, out.BuildIDs = true, composed.Owner, c.wireBuilds(carried, node.Runs) + } + _, passes, left, err := c.theRestOfTheMesh("") + if err != nil { + return Declared{}, err + } + out.Passes = passes + for _, l := range left { + out.Said = append(out.Said, l.String()) + } + out.Said = c.saidBeside(out.Said) + return out, nil +} + +// carriedOn is the build of each module a declaration for the node carries (plan.go carriedOn, in a question that sends nothing): the +// module's current build for each module in it, and for a module left out of it the build it was last sent, since the machine keeps +// that one -- or nothing, when that is not known. +func (c *composer) carriedOn(node Node, plan catalogue.Resolution, leftOut map[string]string) map[string]string { + before := node.SentBuilds + if !node.SentKnown { + before = nil + } + out := map[string]string{} + for _, m := range plan.Modules { + if _, left := leftOut[m.Module]; left { + if was, kept := before[m.Module]; kept { + out[m.Module] = was + } + continue + } + out[m.Module] = c.in.CurrentBuilds[m.Module] + // A module kept at the build the machine runs is carried at that build (plan.go carriedOn). + if kept, ok := node.KeptBuilds[m.Module]; ok && kept != "" { + out[m.Module] = kept + } + } + return out +} + +// wireBuilds is the build of each carried module that goes on the wire (novox/hq issue 1145): the build its assignment names when +// that assignment is of the commit carried, else the build made from that commit; a module with neither is not named. +func (c *composer) wireBuilds(carried map[string]string, runs map[string]Run) map[string]string { + out := map[string]string{} + for module, commit := range carried { + if commit == "" { + continue + } + if ref, ok := runs[module]; ok && ref.Commit == commit && ref.Build != "" { + out[module] = ref.Build + continue + } + if build := c.in.BuildIDs[module][commit]; build != "" { + out[module] = build + } + } + return out +} + +// adoptionFor is the envelope computed from what was taken and who owns each resource: nil for a converged node. Every module the +// node runs that is not taken is untaken -- including one pulled in by another rather than assigned. +func adoptionFor(plan catalogue.Resolution, node Node, composed catalogue.Composed) *Adoption { + if !node.Adopted { + return nil + } + isTaken := map[string]bool{} + for _, m := range node.Taken { + isTaken[m] = true + } + out := &Adoption{Taken: []string{}} + runs := map[string]bool{} + for _, m := range plan.Modules { + runs[m.Module] = true + if isTaken[m.Module] { + out.Taken = append(out.Taken, m.Module) + } + } + sort.Strings(out.Taken) + for _, r := range composed.Resources { + id, _ := r["id"].(string) + module, owned := composed.Owner[id] + // Every kind, not only files and containers (novox/hq ADR 0103). What the mesh declares of its own is never held. + if !owned || !runs[module] || isTaken[module] || strings.HasPrefix(id, catalogue.AdoptionPrefix) { + continue + } + if out.Untaken == nil { + out.Untaken = map[string][]string{} + } + out.Untaken[module] = append(out.Untaken[module], id) + } + return out +} + +// sortedKeysOf is a map's keys, sorted: so what a declaration says it left out does not move for a reordering nobody made. +func sortedKeysOf(m map[string]string) []string { + if len(m) == 0 { + return nil + } + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// namedModules are the modules a declaration names: its machine's set, less what was left out of it. +func namedModules(plan catalogue.Resolution, leftOut map[string]string) []string { + out := make([]string, 0, len(plan.Modules)) + for _, m := range plan.Modules { + if _, left := leftOut[m.Module]; !left { + out = append(out, m.Module) + } + } + sort.Strings(out) + return out +} + +// DataItems is every data item the modules placed on a node declare, placed there (novox/hq ADR 0571 clause 3): what the +// node-backup holder is handed as `${backup:data}`, composed over the Input as Compose composes the node, so disk-load +// reads the list from the bus with no composed file. Unplaced names each item left out and why. +func DataItems(in Input, node string) (map[string][]catalogue.PlacedDataItem, []string, error) { + c := newComposer(in) + resolved, err := c.planFor(node) + if err != nil { + return nil, nil, err + } + rendered, err := c.render(node, Planning) + if err != nil { + return nil, nil, err + } + return resolution.DataItems(resolved.Plan, rendered.Rendering) +} diff --git a/internal/compose/node_test.go b/internal/compose/node_test.go new file mode 100644 index 0000000..5dcbee4 --- /dev/null +++ b/internal/compose/node_test.go @@ -0,0 +1,424 @@ +package compose + +import ( + "encoding/json" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/overlay" +) + +func resourceIDs(d Declared) []string { + var ids []string + for _, r := range d.Resources { + ids = append(ids, r["type"].(string)+":"+r["id"].(string)) + } + return ids +} + +func has(ids []string, want string) bool { + for _, id := range ids { + if id == want { + return true + } + } + return false +} + +// aSmallMesh is three nodes, a provider and its consumer, a seat and the private network: aMesh with `ace` running a database and +// a resolver holding the mesh's resolver seat on record (the providers and consumer of mesh-controller's facts_test.go +// aMeshWithSecrets, the seat of bindings_test.go storeManifests). +func aSmallMesh(t *testing.T) Input { + t.Helper() + in := threeNodes(t) + for _, m := range storeManifests() { + if m.Module == "resolver" { + in.Catalogue[m.Module] = m + } + } + ace := nodeOf(t, &in, "ace") + ace.Assigned = append(ace.Assigned, "resolver") + in.Holdings = []catalogue.Held{{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "ace", Module: "resolver", Site: "there"}} + return in +} + +// 1298/AC1: each node of a small mesh composes, its body is accepted by the node-engine's validator, and it names the resources the +// controller composes for the same case: the private network's files on the nodes that run it, the provider's received file, the +// consumer's file. +func TestEachNodeOfASmallMeshComposesAndItsBodyIsAcceptedByTheValidator(t *testing.T) { + in := aSmallMesh(t) + want := map[string][]string{ + "anchor": {"package:mesh-wireguard.overlay-tools", "file:mesh-wireguard.overlay-config", "service:mesh-wireguard.overlay-up", "file:objects.received-s3-bucket"}, + "laptop": {"package:mesh-wireguard.overlay-tools", "file:mesh-wireguard.overlay-config", "service:mesh-wireguard.overlay-up", "file:files.config"}, + "ace": nil, + } + for node, ids := range want { + d, problems, err := Compose(in, node) + if err != nil || len(problems) != 0 { + t.Fatalf("%s: %v %v", node, err, problems) + } + for _, id := range ids { + if !has(resourceIDs(d), id) { + t.Errorf("%s does not name %s: %v", node, id, resourceIDs(d)) + } + } + body, err := d.Body() + if err != nil { + t.Fatal(err) + } + var parsed struct { + Declaration int `json:"declaration"` + Resources []map[string]any `json:"resources"` + } + if err := json.Unmarshal(body, &parsed); err != nil || parsed.Declaration != 1 || len(parsed.Resources) != len(d.Resources) { + t.Errorf("%s: body %s %v", node, body, err) + } + } + // ace runs a resolver and a database and nothing the mesh declares of its own: an empty declaration says it owns nothing. + d, _, _ := Compose(in, "ace") + if body, _ := d.Body(); !strings.Contains(string(body), `"owns_nothing":true`) { + t.Errorf("an empty declaration is not said to be deliberate: %s", body) + } +} + +// 1298/AC2 (mesh-controller merge_gate_integration_test.go TestIssue236AManifestTheNodeEngineRefusesFailsThePullRequest, the manifest): +// a service that omits its state is accepted by the catalogue's check and refused by the node-engine; Compose returns the validator's +// problems in the node-engine's words and no error. +func TestAManifestTheNodeEngineRefusesComesBackAsProblemsAndNoError(t *testing.T) { + in := aMesh(t) + var lemurs catalogue.Manifest + if err := json.Unmarshal([]byte(`{"module":"lemurs","version":"1","capabilities":["systemd"], + "resources":[{"id":"service","type":"service","unit":"lemurs.service","boot":"enabled"}]}`), &lemurs); err != nil { + t.Fatal(err) + } + in.Catalogue["lemurs"] = lemurs + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = append(laptop.Assigned, "lemurs") + d, problems, err := Compose(in, "laptop") + if err != nil { + t.Fatalf("a refusal of the validator is an error: %v", err) + } + if len(problems) == 0 || !strings.Contains(strings.Join(problems, "\n"), "lemurs.service") { + t.Fatalf("problems are %v", problems) + } + if !has(resourceIDs(d), "service:lemurs.service") { + t.Errorf("the declaration does not carry the resource: %v", resourceIDs(d)) + } + // And the same, as the validator says it of the body. + body, _ := d.Body() + if again := strings.Join(problems, "\n"); again == "" || !strings.Contains(string(body), "lemurs.service") { + t.Errorf("the body does not name the service: %s", body) + } +} + +// 1298/AC3: a node whose own set does not compose returns an error and no declaration, told apart from an unreadable Input. +func TestANodeWhoseSetDoesNotComposeReturnsAnErrorAndNoDeclaration(t *testing.T) { + in := aMesh(t) + in.Catalogue["rival-one"] = catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} + in.Catalogue["rival-two"] = catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = append(laptop.Assigned, "rival-one", "rival-two") + d, problems, err := Compose(in, "laptop") + if err == nil || !IsUnresolvable(err) || d.Resources != nil || problems != nil { + t.Fatalf("a set that does not compose: %v %v %v", d.Resources, problems, err) + } + if _, _, err := Compose(in, "nowhere"); err == nil || IsUnresolvable(err) { + t.Fatalf("a node the Input does not have: %v", err) + } +} + +// 1298/AC4: composing the same Input twice gives byte-identical bodies, and the Input is unchanged. +func TestComposingTwiceGivesTheSameBytesAndChangesNothing(t *testing.T) { + mk := func() Input { + in := aSmallMesh(t) + nodeOf(t, &in, "anchor").Account = "jochens" + nodeOf(t, &in, "laptop").Sequence = 7 + return in + } + in := mk() + for _, node := range []string{"anchor", "laptop", "ace"} { + a, _, err := Compose(in, node) + if err != nil { + t.Fatal(err) + } + b, _, err := Compose(in, node) + if err != nil { + t.Fatal(err) + } + ab, _ := a.Body() + bb, _ := b.Body() + if string(ab) != string(bb) { + t.Errorf("%s: two compositions differ:\n%s\n%s", node, ab, bb) + } + } + unchanged(t, mk, func(in Input) { + for _, node := range []string{"anchor", "laptop", "ace"} { + if _, _, err := Compose(in, node); err != nil { + t.Fatal(err) + } + } + }) +} + +// 1298/AC5: what a send would make is named in the result as foreseen and never invented: an own secret, a port, a core box, a grant's +// sealed copy. +func TestWhatASendWouldMakeIsNamedAsForeseenAndNeverInvented(t *testing.T) { + in := aGrantMesh(t) + in.Issued[0].ForProvider = "" + in.Catalogue["assistant"] = anAssistant() + in.Catalogue["web"] = aWebModule() + in.Catalogue["nats"] = catalogue.Manifest{Module: "nats", Version: "1", + OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/nats/broker", Taken: catalogue.TakenAtStart}}} + anchor := nodeOf(t, &in, "anchor") + anchor.Assigned = append(anchor.Assigned, "assistant", "web", "nats") + anchor.SealingKey = keyOf(t, "anchor") + anchor.Stated, anchor.Contract = true, VaultBoxContract + in.BusMinted = []string{"anchor.nats"} + d, problems, err := Compose(in, "anchor") + if err != nil || len(problems) != 0 { + t.Fatalf("%v %v", err, problems) + } + if !has(d.Foreseen, "assistant/password") || !has(d.Foreseen, "nats/broker") { + t.Errorf("own secrets foreseen: %v", d.Foreseen) + } + if len(d.ForeseenPorts) != 1 || d.ForeseenPorts[0] != "web:8080" { + t.Errorf("foreseen ports: %v", d.ForeseenPorts) + } + if len(d.CoreBoxesWanted) != 1 || d.CoreBoxesWanted[0].Module != "nats" { + t.Errorf("core boxes wanted: %v", d.CoreBoxesWanted) + } + if !has(d.StoodIn, "the grant of s3-bucket for files on laptop") { + t.Errorf("stood in for: %v", d.StoodIn) + } + body, _ := d.Body() + if strings.Contains(string(body), secretValue) { + t.Error("a secret value is in the body") + } +} + +// 1298/AC6: the body carries the sequence, epoch and generation the Input gives, and the module of each resource and the carried builds +// only to a node whose Input says it reads modules (novox/hq issue 1145). +func TestTheBodyCarriesTheOrderTheInputGivesAndTheBuildsOnlyToAnEngineThatReadsThem(t *testing.T) { + in := aSmallMesh(t) + laptop := nodeOf(t, &in, "laptop") + laptop.Sequence, laptop.Epoch, laptop.Generation = 42, 57, 9 + in.CurrentBuilds = map[string]string{"files": "commit-files", "mesh-wireguard": "commit-wg"} + in.BuildIDs = map[string]map[string]string{"files": {"commit-files": "build-7"}} + // An assignment of another commit than the one carried does not name the build; the build made from the commit carried does. + laptop.Runs = map[string]Run{"mesh-wireguard": {Commit: "commit-wg", Build: "build-9"}, "files": {Commit: "an-older-commit", Build: "build-6"}} + d, _, err := Compose(in, "laptop") + if err != nil { + t.Fatal(err) + } + body, _ := d.Body() + var got map[string]any + if err := json.Unmarshal(body, &got); err != nil { + t.Fatal(err) + } + if got["sequence"] != float64(42) || got["epoch"] != float64(57) || got["generation"] != float64(9) { + t.Errorf("the order is %v %v %v", got["sequence"], got["epoch"], got["generation"]) + } + if _, there := got["builds"]; there { + t.Errorf("builds are sent to an engine that does not read them: %s", body) + } + for _, r := range got["resources"].([]any) { + if _, there := r.(map[string]any)["module"]; there { + t.Errorf("a module is named on a resource of an engine that does not read them: %v", r) + } + } + laptop.ReadsModules = true + d, _, err = Compose(in, "laptop") + if err != nil { + t.Fatal(err) + } + body, _ = d.Body() + got = nil + if err := json.Unmarshal(body, &got); err != nil { + t.Fatal(err) + } + builds, _ := got["builds"].(map[string]any) + if builds["files"] != "build-7" || builds["mesh-wireguard"] != "build-9" { + t.Errorf("builds are %v", builds) + } + named := false + for _, r := range got["resources"].([]any) { + if r.(map[string]any)["id"] == "files.config" && r.(map[string]any)["module"] == "files" { + named = true + } + } + if !named { + t.Errorf("a resource is not named for its module: %s", body) + } + // Zero is not sent at all. + laptop.Sequence, laptop.Epoch, laptop.Generation = 0, 0, 0 + d, _, _ = Compose(in, "laptop") + body, _ = d.Body() + for _, key := range []string{`"sequence"`, `"epoch"`, `"generation"`} { + if strings.Contains(string(body), key) { + t.Errorf("%s is sent as zero: %s", key, body) + } + } +} + +// A module left out of a build set keeps the build it was last sent (novox/hq issue 259): the machine keeps that one. +func TestAModuleLeftOutKeepsTheBuildItWasLastSent(t *testing.T) { + c := newComposer(Input{CurrentBuilds: map[string]string{"a": "new-a", "b": "new-b"}}) + plan := catalogue.Resolution{Modules: []catalogue.Manifest{{Module: "a"}, {Module: "b"}, {Module: "c"}}} + node := Node{SentBuilds: map[string]string{"b": "old-b", "c": "old-c"}, SentKnown: true} + got := c.carriedOn(node, plan, map[string]string{"b": "its settings cannot compose"}) + if got["a"] != "new-a" || got["b"] != "old-b" || got["c"] != "" { + t.Errorf("carried builds are %v", got) + } + node.SentKnown = false + if got := c.carriedOn(node, plan, map[string]string{"b": "x"}); len(got) != 2 || got["b"] != "" { + t.Errorf("when what was sent is not known: %v", got) + } +} + +// An adopted node is told which modules were taken and, for every module it runs that is not, the ids of its resources +// (novox/hq ADR 0100 and 0103); a converged node is told nothing, and its body is as it was before adoption. +func TestAnAdoptedNodeIsToldWhatWasTakenAndWhatIsKept(t *testing.T) { + in := aSmallMesh(t) + d, _, err := Compose(in, "laptop") + if err != nil || d.Adoption != nil { + t.Fatalf("a converged node: %v %v", d.Adoption, err) + } + laptop := nodeOf(t, &in, "laptop") + laptop.Adopted, laptop.Taken = true, []string{"mesh-wireguard"} + d, problems, err := Compose(in, "laptop") + if err != nil || len(problems) != 0 { + t.Fatalf("%v %v", err, problems) + } + if d.Adoption == nil || len(d.Adoption.Taken) != 1 || d.Adoption.Taken[0] != "mesh-wireguard" || + len(d.Adoption.Untaken["files"]) == 0 || d.Adoption.Untaken["mesh-wireguard"] != nil { + t.Fatalf("adoption is %+v", d.Adoption) + } + if body, _ := d.Body(); !strings.Contains(string(body), `"adoption"`) { + t.Errorf("the adoption is not on the wire: %s", body) + } +} + +// Issue 263 (merge_gate_integration_test.go TestIssue263AnIdentityARealMachineNameOverflows...): a change makes the network manager +// require the object store's provision; on a machine whose name is six characters its identity is 26 against a bound of 20. The +// composition of the provider returns the refusal, naming the module and the provision. +func TestIssue263AnIdentityARealMachineNameOverflowsIsARefusal(t *testing.T) { + in := aGrantMesh(t) + in.Catalogue["resolver"] = storeManifests()[1] + in.Catalogue["networkmanager"] = catalogue.Manifest{Module: "networkmanager", Version: "1", Requires: []string{"wildcard-resolution", "s3-bucket"}} + laptop, anchor := nodeOf(t, &in, "laptop"), nodeOf(t, &in, "anchor") + laptop.Assigned = append(laptop.Assigned, "networkmanager") + anchor.Assigned = append(anchor.Assigned, "resolver") + in.Holdings = []catalogue.Held{{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "resolver", Site: "here"}} + in.Issued = append(in.Issued, Issued{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "networkmanager", Provider: "anchor", ForProvider: "sealed"}) + d, problems, err := Compose(in, "anchor") + if err != nil || len(problems) != 0 { + t.Fatalf("%v %v", err, problems) + } + refusals := strings.Join(d.Refusals(), "\n") + if !strings.Contains(refusals, "networkmanager") || !strings.Contains(refusals, "s3-bucket") || !strings.Contains(refusals, "laptop") { + t.Fatalf("the refusal does not name the module, the provision and the machine: %q", refusals) + } + // And the consumer that fits is granted. + if len(d.Withheld) != 1 || d.Withheld[0].Module != "networkmanager" { + t.Errorf("withheld %+v", d.Withheld) + } +} + +// Round 1, finding 1 (plan.go keepRecorded): a recorded module kept at the build a machine runs is that machine's own resolution +// and no other's; the commit carried for it is the kept one. +func TestAKeptModuleChangesOnlyTheResolutionOfTheNodeThatKeepsIt(t *testing.T) { + in := aSmallMesh(t) + in.Catalogue["tool"] = catalogue.Manifest{Module: "tool", Version: "2"} + for _, name := range []string{"anchor", "laptop"} { + n := nodeOf(t, &in, name) + n.Assigned = append(n.Assigned, "tool") + } + anchor := nodeOf(t, &in, "anchor") + anchor.Kept = map[string]catalogue.Manifest{"tool": {Module: "tool", Version: "1"}} + versionOn := func(in Input, node string) string { + t.Helper() + got, err := Resolve(in, node) + if err != nil { + t.Fatal(err) + } + for _, m := range got.Plan.Modules { + if m.Module == "tool" { + return m.Version + } + } + t.Fatalf("no tool on %s", node) + return "" + } + if v := versionOn(in, "anchor"); v != "1" { + t.Errorf("the node that keeps it runs version %s", v) + } + if v := versionOn(in, "laptop"); v != "2" { + t.Errorf("another node runs version %s: a kept build leaked", v) + } + if got := in.Catalogue["tool"].Version; got != "2" { + t.Errorf("the Input's catalogue was changed: %s", got) + } + // And the commit carried for it is the kept one. + c := newComposer(in) + plan := catalogue.Resolution{Modules: []catalogue.Manifest{{Module: "tool"}, {Module: "other"}}} + in.CurrentBuilds = map[string]string{"tool": "current", "other": "current-other"} + c = newComposer(in) + node := Node{KeptBuilds: map[string]string{"tool": "kept-commit"}} + if got := c.carriedOn(node, plan, nil); got["tool"] != "kept-commit" || got["other"] != "current-other" { + t.Errorf("carried builds are %v", got) + } +} + +// Another node's kept build changes neither the rest of the mesh nor who is on the network: the controller reads both with the +// current manifests, and only then keeps a recorded module for the node it composes (recorded_kept.go keepRecorded). +func TestAnotherNodesKeptBuildDoesNotChangeTheRestOfTheMeshOrWhoIsOnTheNetwork(t *testing.T) { + in := threeNodes(t) + nodeOf(t, &in, "anchor").Kept = map[string]catalogue.Manifest{"objects": {Module: "objects", Version: "0"}, overlayName(): {Module: overlayName(), Version: "0"}} + w, _, _, err := TheRestOfTheMesh(in, "laptop") + if err != nil || len(providers(w, "s3-bucket")) != 1 { + t.Errorf("a kept build changed what anchor offers: %v %v", providers(w, "s3-bucket"), err) + } + where, err := newComposer(in).whereEveryoneIs() + if err != nil || where["anchor"] == "" { + t.Errorf("a kept build took anchor off the network: %v %v", where, err) + } +} + +func overlayName() string { return overlay.Name } + +// Round 1, finding 6: the nodes the composition did not count as on the network are returned, said, not lost. laptop holds no +// complete place (no key): one that does keeps it on the network (issue 1833) and is counted. +func TestANodeNotCountedAsOnTheNetworkIsSaidInTheResult(t *testing.T) { + in := aMesh(t) + nodeOf(t, &in, "laptop").Place.Key = "" + in.Catalogue["rival-one"] = catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} + in.Catalogue["rival-two"] = catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = append(laptop.Assigned, "rival-one", "rival-two") + said := func(lines []string) bool { + for _, l := range lines { + if strings.HasPrefix(l, "laptop is not counted as on the network") { + return true + } + } + return false + } + got, err := Render(in, "anchor", Foreseeing) + if err != nil || !said(got.Said) { + t.Errorf("rendered: %v %v", got.Said, err) + } + d, _, err := Compose(in, "anchor") + if err != nil || !said(d.Said) { + t.Errorf("declared: %v %v", d.Said, err) + } + n := 0 + for _, l := range d.Said { + if strings.HasPrefix(l, "laptop is not counted") { + n++ + } + } + if n != 1 { + t.Errorf("said %d times: %v", n, d.Said) + } +} diff --git a/internal/compose/one_machine_alone_test.go b/internal/compose/one_machine_alone_test.go new file mode 100644 index 0000000..3c49abb --- /dev/null +++ b/internal/compose/one_machine_alone_test.go @@ -0,0 +1,167 @@ +package compose + +import ( + "slices" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/overlay" +) + +// fourOnTheNetwork is aMesh and two more machines on the private network at the site `there`: `ace`, dialled at +// ace.example:51820, at 10.77.0.3, and `box`, behind no endpoint, at 10.77.0.4. A module `office` requires +// `document-server`, which nothing in the catalogue provides: the shape of issue 1833 (nextcloud on the hub wanting a +// document server nobody offered). +func fourOnTheNetwork(t *testing.T) Input { + t.Helper() + in := aMesh(t) + in.Catalogue["office"] = catalogue.Manifest{Module: "office", Version: "1", Requires: []string{"document-server"}} + caps := map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true} + in.Nodes = append(in.Nodes, + Node{Name: "ace", Place: Place{Key: keyOf(t, "ace"), Endpoint: "ace.example:51820", Site: "there", Address: "10.77.0.3"}, + Assigned: []string{overlay.Name}, Capabilities: caps}, + Node{Name: "box", Place: Place{Key: keyOf(t, "box"), Site: "there", Address: "10.77.0.4"}, + Assigned: []string{overlay.Name}, Capabilities: caps}) + return in +} + +// unresolvableBy makes one machine's own set fail to resolve: it is also given `office`, whose requirement nothing provides. +func unresolvableBy(t *testing.T, in *Input, name string) { + t.Helper() + n := nodeOf(t, in, name) + n.Assigned = append(n.Assigned, "office") +} + +// networkOf is the generator of an Input's private network, failing the test when it is not composed. +func networkOf(t *testing.T, in Input) *overlay.Generator { + t.Helper() + gens, err := Generators(in) + if err != nil { + t.Fatalf("the private network was not composed: %v", err) + } + return gens[overlay.Name].(*overlay.Generator) +} + +func placesOf(g *overlay.Generator) map[string]overlay.Node { + out := map[string]overlay.Node{} + for _, n := range g.Nodes() { + out[n.Name] = n + } + return out +} + +// 1833/AC1: four machines, the hub's own set does not resolve; the other three compose their declarations, and the hub alone is +// refused, with an error that is its own set failing and names what could not be resolved. +func TestOneMachineThatDoesNotResolveIsRefusedAlone(t *testing.T) { + in := fourOnTheNetwork(t) + unresolvableBy(t, &in, "anchor") + for _, node := range []string{"laptop", "ace", "box"} { + d, problems, err := Compose(in, node) + if err != nil || len(problems) != 0 { + t.Errorf("%s does not compose because another machine does not resolve: %v %v", node, err, problems) + continue + } + if !has(resourceIDs(d), "file:mesh-wireguard.overlay-config") { + t.Errorf("%s is given no private network: %v", node, resourceIDs(d)) + } + } + _, _, err := Compose(in, "anchor") + if err == nil || !IsUnresolvable(err) { + t.Fatalf("the machine that does not resolve is not refused as its own set failing: %v", err) + } + if !strings.Contains(err.Error(), `nothing provides "document-server", wanted by office`) { + t.Errorf("the refusal does not name what could not be resolved: %v", err) + } +} + +// 1833/AC2: the private network is composed with every machine on it: the hub that does not resolve keeps its place (key, +// address, endpoint, site, hub role), so every other machine keeps its path through it. +func TestAMachineThatDoesNotResolveKeepsItsPlaceOnTheNetwork(t *testing.T) { + in := fourOnTheNetwork(t) + unresolvableBy(t, &in, "anchor") + got := placesOf(networkOf(t, in)) + if len(got) != 4 { + t.Fatalf("the network is over %v, want all four machines", got) + } + hub := got["anchor"] + if !hub.Hub || hub.Key != keyOf(t, "anchor") || hub.Address != "10.77.0.1" || hub.Endpoint != "anchor.example:51820" || hub.Site != "here" { + t.Errorf("the hub did not keep its place: %+v", hub) + } + nodes, graph, err := Graph(in) + if err != nil || len(nodes) != 4 { + t.Fatalf("the graph is not shown with every machine: %v %v", nodes, err) + } + for _, node := range []string{"ace", "box"} { + through := false + for _, p := range graph[node] { + through = through || (p.Name == "anchor" && p.Endpoint == "anchor.example:51820") + } + if !through { + t.Errorf("%s has no path through the hub: %+v", node, graph[node]) + } + } +} + +// 1833/AC3: a machine that does not resolve and was never placed (a new machine: no key, no address) is left off the network, +// which is composed from the rest -- here with the hub that does not resolve on its last place -- and that machine alone is refused. +func TestANewMachineThatDoesNotResolveIsLeftOffAndRefusedAlone(t *testing.T) { + in := fourOnTheNetwork(t) + unresolvableBy(t, &in, "anchor") + in.Nodes = append(in.Nodes, Node{Name: "fresh", Place: Place{Site: "there"}, + Assigned: []string{overlay.Name, "office"}, Capabilities: map[string]bool{"wireguard": true, "systemd": true}}) + got := placesOf(networkOf(t, in)) + if _, on := got["fresh"]; on || len(got) != 4 { + t.Fatalf("the network is over %v, want the four placed machines without fresh", got) + } + if _, _, err := Compose(in, "fresh"); err == nil || !IsUnresolvable(err) { + t.Errorf("the new machine is not refused as its own set failing: %v", err) + } + if _, problems, err := Compose(in, "box"); err != nil || len(problems) != 0 { + t.Errorf("another machine does not compose: %v %v", err, problems) + } +} + +// 1833/AC4: a network that truly cannot be composed from what remains still fails: the hub does not resolve and has no +// complete place (no key), so nothing is the hub; the refusal is today's, naming the machine that could not be resolved. +func TestANetworkThatCannotBeComposedFromWhatRemainsStillFails(t *testing.T) { + in := fourOnTheNetwork(t) + unresolvableBy(t, &in, "anchor") + nodeOf(t, &in, "anchor").Place.Key = "" + _, err := Generators(in) + if err == nil { + t.Fatal("a network without a hub was composed") + } + for _, want := range []string{overlay.ErrNoHub.Error(), "and 1 node(s) could not be resolved at all, which is probably why:", " anchor: "} { + if !strings.Contains(err.Error(), want) { + t.Errorf("the refusal does not say %q:\n%v", want, err) + } + } + if _, _, err := Compose(in, "laptop"); err == nil { + t.Error("a declaration was composed over a network that cannot be") + } +} + +// 1833/AC2, a failure the composition's unchecked pass refuses too (two modules claiming one seat on the hub, the 1360/AC3 +// shape): the hub keeps its place in what every other machine is sent besides the tunnel -- its internal name and its address +// among the sources "from the mesh" -- and another machine still composes. +func TestAMachineThatDoesNotResolveKeepsItsNameAndItsAddressInTheMesh(t *testing.T) { + in := fourOnTheNetwork(t) + in.Catalogue["rival-one"] = catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} + in.Catalogue["rival-two"] = catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} + hub := nodeOf(t, &in, "anchor") + hub.Assigned = append(hub.Assigned, "rival-one", "rival-two") + if _, err := Resolve(in, "anchor"); !IsUnresolvable(err) { + t.Fatalf("the hub is meant not to resolve: %v", err) + } + c := newComposer(in) + if got := c.namesInTheMesh()["anchor."+c.suffix()]; got != "10.77.0.1" { + t.Errorf("the hub's name is not in the mesh's names: %v", c.namesInTheMesh()) + } + if got := c.onThePrivateNetwork(); !slices.Contains(got, "10.77.0.1") || len(got) != 4 { + t.Errorf("the sources from the mesh are %v, want all four machines with the hub", got) + } + if _, problems, err := Compose(in, "ace"); err != nil || len(problems) != 0 { + t.Errorf("another machine does not compose: %v %v", err, problems) + } +} diff --git a/internal/compose/render.go b/internal/compose/render.go new file mode 100644 index 0000000..3354fad --- /dev/null +++ b/internal/compose/render.go @@ -0,0 +1,150 @@ +package compose + +import ( + "fmt" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// Rendered is everything a node's declaration is composed with (plan.go renderingFor), over an Input: the place half (Places) +// and the credential half. Nothing was allocated, made, kept or written; what a send would do is returned for the caller to do. +type Rendered struct { + Rendering catalogue.Rendering + // ForeseenPorts are the ports the mesh may assign that the Input holds none for (":"). + ForeseenPorts []string + // Foreseen are the stand-ins the composition used for what the Input does not hold: an own secret the next send makes + // ("/"), a grant's sealed copy, the certificate and the bus's user list. + Foreseen []string + // CoreBoxesWanted are the core credentials whose box the send must ask the vault for and keep. + CoreBoxesWanted []CoreBoxWanted + // Withheld are the consumers the grants leave out because their identity overflows the provision's bound (novox/hq ADR 0225), + // and Unbound those whose credential from this node is on record and whose own resolution binds them elsewhere (issue + // 274). The controller says them; a check refuses a change that adds one (ADR 0446 clause 1). + Withheld []catalogue.Overflow + Unbound []catalogue.Unbound + // Said are the lines the controller says while composing (the bus users left out for want of a credential). + Said []string +} + +// Render composes the rendering of a node: the grants it must create, where it puts its ports, its own secrets, the vault's boxes, +// its certificate, the names of the mesh, the bus's user list and its membership. The refusals are the controller's, in its words; +// where more than one fault stands, the first reported may differ from the controller's, which interleaves the halves. +func Render(in Input, node string, mode Mode) (Rendered, error) { + return newComposer(in).render(node, mode) +} + +// Composing is Resolve, Render and Generators of one node over one composition, which resolves each node of the mesh +// once for all three (issue 1954: three compositions resolved the mesh again each, 11.7 s for one node's plans). Each +// answers as it does alone; the first refusal ends it, in that order, with what follows it empty. +func Composing(in Input, node string, mode Mode) (Resolved, Rendered, map[string]catalogue.Generator, error) { + c := newComposer(in) + resolved, err := c.planFor(node) + if err != nil { + return Resolved{}, Rendered{}, nil, err + } + rendered, err := c.render(node, mode) + if err != nil { + return resolved, Rendered{}, nil, err + } + gens, err := c.generators() + if err != nil { + return resolved, rendered, nil, err + } + return resolved, rendered, gens, nil +} + +func (c *composer) render(nodeName string, mode Mode) (Rendered, error) { + resolved, err := c.planFor(nodeName) + if err != nil { + return Rendered{}, err + } + node, err := c.in.node(nodeName) + if err != nil { + return Rendered{}, err + } + plan := resolved.Plan + var out Rendered + foreseen := func(s string) { out.Foreseen = append(out.Foreseen, s) } + + grants, withheld, unbound, grantsForeseen, err := c.grantsFor(nodeName) + if err != nil { + return Rendered{}, err + } + placed, err := c.places(nodeName, resolved) + if err != nil { + return Rendered{}, err + } + r := placed.Rendering + out.ForeseenPorts = placed.ForeseenPorts + // A plan carries its contributions' machine ports, refused where the placement assigned none (issue 774). + r.Planned = mode == Planning + r.Grants, r.Withheld, r.Unbound = grants, withheld, unbound + out.Withheld, out.Unbound = withheld, unbound + for _, g := range grantsForeseen { + foreseen(g) + } + + // And each module's own secrets: a module handed to the provisioner has its secrets from the vault through the client. + handing := catalogue.Rendering{Hands: r.Hands, Provisioner: r.Provisioner, Assigned: r.Assigned} + needed, foreseenOwn, err := c.ownSecretsFor(node, plan, handing, mode) + if err != nil { + return Rendered{}, err + } + r.Needed = needed + if mode == Foreseeing || mode == Planning || len(foreseenOwn) > 0 { + r.Foreseen = foreseenOwn + } + var names []string + for module, secrets := range foreseenOwn { + for name := range secrets { + names = append(names, module+"/"+name) + } + } + sort.Strings(names) + for _, n := range names { + foreseen(n) + } + + // The core's own credentials as the vault sealed them, for an engine that opens the vault's box. + if node.Stated && node.Contract >= VaultBoxContract { + sealed, notPlaced, wanted := c.coreBoxesFor(node, plan.Modules, handing) + r.VaultSealed, r.NotPlaced = sealed, notPlaced + out.CoreBoxesWanted = wanted + } + + // And a certificate for this machine's name inside the mesh, when anything on it asks. + for _, m := range plan.Modules { + if m.Certificate == nil { + continue + } + certificate, authority, stood, err := c.certificateFor(node) + if err != nil { + return Rendered{}, err + } + r.Certificate, r.Authority = certificate, authority + if stood { + foreseen("the certificate and the authority of " + nodeName) + } + break + } + + // The bus's user list, for the machine that runs the bus: names and hashes the Input carries, never passwords. + if holdsTheBus(plan.Modules) { + r.BusUsers = c.in.BusUsers + if r.BusUsers == "" { + r.BusUsers = ForeseenBusUsers + foreseen(BusUsersStandIn) + } + if len(c.in.BusMissing) > 0 { + out.Said = append(out.Said, fmt.Sprintf("the bus's user list leaves out %d user(s) the mesh has minted no credential "+ + "for: %s. Each is a user that cannot connect until one is issued", len(c.in.BusMissing), strings.Join(c.in.BusMissing, ", "))) + } + } + r.BusMembership = c.in.BusMemberships[nodeName] + out.Rendering = r + // And what the composition found on the way, which the controller writes to its log: a machine not counted as on the network. + out.Said = c.saidBeside(out.Said) + return out, nil +} diff --git a/internal/compose/rendering.go b/internal/compose/rendering.go new file mode 100644 index 0000000..c8db3e0 --- /dev/null +++ b/internal/compose/rendering.go @@ -0,0 +1,544 @@ +package compose + +import ( + "errors" + "fmt" + "sort" + "strings" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/compose/resolution" + "github.com/novox/mesh-host/internal/overlay" +) + +// The contracts of the node-engine's statement that gate what a machine is sent, as the controller's link package names them +// (mesh-controller internal/link/protocol.go at 6c2a8d62). An older engine parses strictly and refuses a field it does not +// know, whole. +const ( + ReadinessContract = 2 // the engine reads a resource's `health` (novox/hq ADR 0240) + RootContract = 3 // the engine judges a user's declared `root` (ADR 0266) + BusyContract = 8 // the engine reads a container's `busy` (ADR 0335) + HandedContract = 11 // the engine writes a filled file on the provisioner client's word alone + VaultBoxContract = 12 // the engine opens the vault's box +) + +// The provisioner client's seat and own secret (the controller's broker.NodeProvisionerSeat and ProvisionerSecret). +const ( + NodeProvisionerSeat = "node-provisioner" + ProvisionerSecret = "provisioner-broker" +) + +// ErrPortTaken is the controller's refusal for a machine port held twice on one machine. +var ErrPortTaken = errors.New("that port is already held on this machine") + +// Placed is the place half of a rendering (plan.go renderingFor): where a machine puts what its modules need reachable, the +// seats and who holds them, the names of the mesh, the networks, accounts and the operator's export. It is a catalogue.Rendering +// with those fields set, and the ports it could not read named foreseen. Nothing was allocated. +type Placed struct { + Rendering catalogue.Rendering + // ForeseenPorts are the ports the mesh may assign that the Input holds none for, as ":": composed with the + // manifest's own number as the Reading mode composes them, and never invented. The controller's adapter allocates and + // composes again. + ForeseenPorts []string +} + +// Places composes the place half of the rendering for a node (the controller's renderingFor in the Reading and Foreseeing modes, +// without the credentials): over an Input, allocating nothing and changing nothing in it. The refusals are the controller's, +// in its words. +func Places(in Input, node string) (Placed, error) { + c := newComposer(in) + resolved, err := c.planFor(node) + if err != nil { + return Placed{}, err + } + return c.places(node, resolved) +} + +func (c *composer) places(nodeName string, resolved Resolved) (Placed, error) { + var out Placed + node, err := c.in.node(nodeName) + if err != nil { + return out, err + } + plan, settings := resolved.Plan, resolved.Settings + r := &out.Rendering + + // Where this machine puts what each module needs reachable (novox/hq ADR 0038): what it was already given, and the ports + // it was given over those (ADR 0100). + given, err := c.portsFor(node, plan, settings, r, &out.ForeseenPorts) + if err != nil { + return out, err + } + + // Hands: whether the engine writes a filled file on the provisioner client's word alone; engines older than a contract are + // not sent the field they would refuse. + r.Hands = takesHanded(node) + r.Provisioner = provisionerAccount(plan) + r.Assigned = node.Handed + r.ReadsHealth = node.Stated && node.Contract >= ReadinessContract + r.JudgesRoot = node.Stated && node.Contract >= RootContract + r.ReadsBusy = node.Stated && node.Contract >= BusyContract + + // And who else is on the private network, which is what a rule saying "from the mesh" resolves to. + r.Mesh = c.onThePrivateNetwork() + // The private network's range, offered to a module as ${machine:mesh-range}. + r.MeshRange = c.overlayRange() + + // The artifact store as this node reaches it now, with what the mesh has built. + store, err := c.artifactStoreAddress(nodeName) + if err != nil { + return out, err + } + r.ArtifactStore = store + r.Built = make(map[string]bool, len(c.in.Built)) + for _, repository := range c.in.Built { + r.Built[repository] = true + } + + // And every machine's name, so a container can reach one. + r.Names = c.namesInTheMesh() + + // Each machine's operator account, so an ssh Host block can name the login for every node (novox/hq to-be 29). + r.Accounts = map[string]string{} + for _, n := range c.in.sorted() { + if n.Account != "" { + r.Accounts[n.Name] = n.Account + } + } + + // The roster is the machines and nothing else (novox/hq ADR 0191). + r.Machines = make(map[string]string, len(r.Names)) + for name, at := range r.Names { + r.Machines[name] = at + } + + // And every zone a module in the mesh answers itself (novox/hq ADR 0199). + zones, err := c.zonesInTheMesh() + if err != nil { + return out, err + } + r.Zones = zones + + // And who holds each replicated seat, where (novox/hq ADR 0223). + r.Holders = c.replicatedHolders() + + r.Suffix, r.TunnelInterface = c.suffix(), overlay.Interface + + // And, for a module that keeps them, every operator-sealed secret in the mesh (novox/hq ADR 0085, amended). + for _, m := range plan.Modules { + if m.Keeps == "" { + continue + } + r.Kept = c.in.OperatorExport + if r.Kept == nil { + r.KeptRuns = c.in.KeptRuns[node.Name] + } + break + } + + // Whether this node is adopted (novox/hq ADR 0100), and which modules were taken there. + r.Adopted = node.Adopted + var taken map[string]bool + if node.Adopted { + taken = map[string]bool{} + for _, m := range node.Taken { + taken[m] = true + } + } + r.Taken = taken + + // Where this node put the foundation's servers (novox/hq 04-ISSUES/102). + seats, err := c.seatsOn(node, plan.Modules, node.Adopted, taken) + if err != nil { + return out, err + } + r.Seats = seats + + // Which of this machine's links face outside (novox/hq ADR 0140), and which of its networks count as its local network + // (ADR 0340). + r.OutwardLinks = node.OutwardLinks + r.LocalNetworks = resolution.CountedLocalNetworks(localNetworksOf(node)) + + // Where this machine reaches each mesh seat's holder, for ${seat::reach} (novox/hq ADR 0222). + if store != "" { + r.SeatReach = map[string]string{"mesh-artifact-store": store} + } + r.Settings = settings + r.Given = given + return out, nil +} + +// takesHanded says whether a machine's newest statement is of an engine that writes a filled file on the provisioner client's +// word alone. +func takesHanded(n Node) bool { return n.Stated && n.Contract >= HandedContract } + +// provisionerAccount is the account the provisioner's client runs as on this machine (novox/hq ADR 0349 clause 4): the user +// the process of the module running it runs as, empty where no such module is on the machine. +func provisionerAccount(plan catalogue.Resolution) string { + for _, m := range plan.Modules { + if !runsTheProvisionerClient(m) { + continue + } + for _, r := range m.Resources { + if r["type"] == "process" { + if user, _ := r["user"].(string); user != "" && user != "root" { + return user + } + } + } + } + return "" +} + +// runsTheProvisionerClient says a module runs its machine's provisioner client: it claims the seat and declares the own +// secret that account is delivered in. +func runsTheProvisionerClient(m catalogue.Manifest) bool { + _, reads := m.OwnSecrets[ProvisionerSecret] + return reads && m.ClaimsSeat(NodeProvisionerSeat) +} + +// portsFor is where a node puts what each module needs reachable, in the Reading mode: the ports it was given win, then the +// ones the mesh assigned, and a port the Input holds none for is composed as the manifest says and named foreseen. One holder +// per machine port across the node's modules: settings written before this was refused are refused here. +func (c *composer) portsFor(node Node, plan catalogue.Resolution, settings catalogue.SettingsBy, r *catalogue.Rendering, + foreseen *[]string) (map[string]map[int]int, error) { + already := node.Ports + + given := map[string]map[int]int{} + for _, m := range plan.Modules { + g, err := resolution.GivenPorts(m, settings[m.Module]) + if err != nil { + // A given port its definition no longer publishes: the module is left out of the declaration, by name, when it is + // composed (novox/hq ADR 0163, rule 6) -- never the machine refused here for it. + continue + } + if g != nil { + given[m.Module] = g + } + } + holders := map[int]string{} + for _, m := range plan.Modules { + for _, at := range given[m.Module] { + if other, twice := holders[at]; twice && other != m.Module { + return nil, fmt.Errorf("%w: %s and %s are both given machine port %d on %s", ErrPortTaken, other, m.Module, + at, node.Name) + } + holders[at] = m.Module + } + } + + ports := map[string]map[int]int{} + for _, m := range plan.Modules { + for _, l := range m.Listens { + if at, isGiven := given[m.Module][l.Port]; isGiven { + if ports[m.Module] == nil { + ports[m.Module] = map[int]int{} + } + ports[m.Module][l.Port] = at + continue + } + // Only a port the module actually publishes is the mesh's to move. Recorded either way, because this map means where + // this module's port is on this machine. + where, mayAssign := m.MachineSide(l.Port) + if mayAssign { + // Whatever was chosen last time, and nothing if there was no last time. + if at, known := already[m.Module][l.Port]; known { + where = at + } else { + *foreseen = append(*foreseen, fmt.Sprintf("%s:%d", m.Module, l.Port)) + } + } + if ports[m.Module] == nil { + ports[m.Module] = map[int]int{} + } + ports[m.Module][l.Port] = where + } + } + r.Ports = ports + return given, nil +} + +// onThePrivateNetwork is every node's address on the private network, sorted: the same set the names mean by it, so a rule +// saying "from the mesh" admits exactly the machines the mesh names. A node with no address is left out, not rendered as an +// empty source. +func (c *composer) onThePrivateNetwork() []string { + var out []string + for _, p := range c.onTheNetwork() { + if strings.TrimSpace(p.Place.Address) != "" { + out = append(out, p.Place.Address) + } + } + sort.Strings(out) + return out +} + +// namesInTheMesh is every machine's internal name and the address behind it, for every machine on the private network, and +// the carried peers the operator has named (novox/hq issue 112). +func (c *composer) namesInTheMesh() map[string]string { + out := map[string]string{} + for _, p := range c.onTheNetwork() { + out[c.internalName(p.Name)] = p.Place.Address + } + for _, p := range c.in.CarriedPeers { + if p.Named == "" || p.EnrolledAs != "" { + continue + } + if _, taken := out[c.internalName(p.Named)]; taken { + continue // a node of the mesh owns the name; the stale statement loses + } + out[c.internalName(p.Named)] = p.Address + } + return out +} + +// artifactStoreOnNetwork is the machine on this network that offers the artifact store, and the port that machine put it on. +func (c *composer) artifactStoreOnNetwork(on map[string]bool) (node, port string, found bool, err error) { + providers := map[string]catalogue.Manifest{} + for name, m := range c.in.Catalogue { + if _, offers := m.Serves[catalogue.ArtifactStoreProvision]; offers { + providers[name] = m + } + } + if len(providers) == 0 { + return "", "", false, nil + } + // In a stated order, so two machines offering it would always answer the same one. + machines := make([]string, 0, len(on)) + for machine := range on { + machines = append(machines, machine) + } + sort.Strings(machines) + for _, machine := range machines { + n, err := c.in.node(machine) + if err != nil { + return "", "", false, fmt.Errorf("reading what %s is assigned: %w", machine, err) + } + for _, a := range n.Assigned { + m, offers := providers[a] + if !offers { + continue + } + serves, _, err := c.servedOnNode(machine, m, catalogue.ArtifactStoreProvision) + if err != nil { + return "", "", false, fmt.Errorf("reading where %s puts the artifact store: %w", machine, err) + } + if p, ok := serves["port"]; ok { + return machine, fmt.Sprintf("%v", p), true, nil + } + } + } + return "", "", false, nil +} + +// ArtifactStoreAddress is the artifact store as node reaches it, by the rule its composition routes every kept reference +// with (artifactStoreAddress): empty when node reaches none. A node composing its own modules reads a templates archive +// there (issue 1126). +func ArtifactStoreAddress(in Input, node string) (string, error) { + return newComposer(in).artifactStoreAddress(node) +} + +// artifactStoreAddress is the artifact store as `forNode` reaches it: `.internal:` over the private network, or +// `127.0.0.1:` for the node that holds the store itself when nothing is on the network yet, and empty for any other +// (novox/hq 04-ISSUES/102). +func (c *composer) artifactStoreAddress(forNode string) (string, error) { + onNetwork, err := c.whereEveryoneIs() + if err != nil { + return "", err + } + on := map[string]bool{} + for name := range onNetwork { + on[name] = true + } + node, port, found, err := c.artifactStoreOnNetwork(on) + if err != nil { + return "", err + } + if found { + return c.internalName(node) + ":" + port, nil + } + all := map[string]bool{} + for _, n := range c.in.Nodes { + all[n.Name] = true + } + holder, port, found, err := c.artifactStoreOnNetwork(all) + if err != nil || !found || holder != forNode { + return "", err + } + return "127.0.0.1:" + port, nil +} + +// zonesInTheMesh is every zone a module in the mesh declares, where the mesh placed it (novox/hq ADR 0199): the zone settled +// from that node's settings, the node's private address, the port the answering listen is published on there. A node whose set +// does not compose declares nothing and is passed over; what the mesh refuses about the zones together is refused, by name. +func (c *composer) zonesInTheMesh() ([]catalogue.ZoneAt, error) { + address := map[string]string{} + for _, n := range c.in.sorted() { + if strings.TrimSpace(n.Place.Address) != "" { + address[n.Name] = n.Place.Address + } + } + var zones []catalogue.ZoneAt + var public []string + for _, n := range c.in.sorted() { + resolved, err := c.planFor(n.Name) + switch { + case unresolvable(err): + continue + case err != nil: + return nil, fmt.Errorf("the zones %s answers cannot be read: %w", n.Name, err) + } + plan := resolved.Plan + if plan.PublicDomain != "" { + public = append(public, plan.PublicDomain) + } + for _, m := range plan.Modules { + if m.Zone == nil { + continue + } + at := address[n.Name] + if at == "" { + // Not on the private network yet: nothing could reach its answerer. + continue + } + published, layers, err := c.portsGivenOn(n.Name, m) + if err != nil { + return nil, fmt.Errorf("the zone %s declares on %s cannot be read: %w", m.Module, n.Name, err) + } + z, err := resolution.ZoneOn(m, layers, published, n.Name, at) + if err != nil { + return nil, err + } + zones = append(zones, *z) + } + } + if problems := resolution.ZonesProblems(zones, c.suffix(), public); len(problems) > 0 { + return nil, fmt.Errorf("the mesh's zones cannot be forwarded:\n - %s", strings.Join(problems, "\n - ")) + } + return zones, nil +} + +// replicatedHolders is, for each replicated mesh seat, every machine on the private network holding it, by internal name at its +// private address (novox/hq ADR 0223): the holders on record, and only the sole claimant when there are none. A holder off +// the private network is left out. +func (c *composer) replicatedHolders() map[string]map[string]string { + var replicated []catalogue.Seat + for _, s := range catalogue.Seats() { + if s.Replicated && s.Scope == catalogue.ScopeMesh { + replicated = append(replicated, s) + } + } + if len(replicated) == 0 { + return nil + } + address := map[string]string{} + for _, p := range c.onTheNetwork() { + address[p.Name] = p.Place.Address + } + // Every module of the catalogue with the nodes it is assigned on, sorted, as the controller's Catalogued answers. + on := map[string][]string{} + for _, n := range c.in.sorted() { + for _, module := range n.Assigned { + on[module] = append(on[module], n.Name) + } + } + names := make([]string, 0, len(c.in.Catalogue)) + for name := range c.in.Catalogue { + names = append(names, name) + } + sort.Strings(names) + out := map[string]map[string]string{} + for _, seat := range replicated { + var nodes []string + for _, h := range c.in.Holdings { + if hs, ok := catalogue.SeatNamed(h.Claim); ok && hs.Name == seat.Name && h.Scope == seat.Scope { + nodes = append(nodes, h.Node) + } + } + if len(nodes) == 0 { + var derived []string + for _, name := range names { + for _, cl := range c.in.Catalogue[name].Claims { + if cs, ok := catalogue.SeatNamed(cl.Name); ok && cs.Name == seat.Name && cl.At() == seat.Scope { + derived = append(derived, on[name]...) + } + } + } + if len(derived) == 1 { + nodes = derived + } + } + at := map[string]string{} + for _, n := range nodes { + if address[n] != "" { + at[c.internalName(n)] = address[n] + } + } + out[seat.Name] = at + } + return out +} + +// seatsOn is where a node put the holder of each mesh-scoped seat, by seat and by the port the holder's software uses: what +// ${seat:...} answers with (novox/hq 04-ISSUES/102). Read for every module in the catalogue that claims a seat, in this node's set +// or not; a holder in this node's set wins over one that is not. +func (c *composer) seatsOn(node Node, inSet []catalogue.Manifest, adopted bool, taken map[string]bool) (map[string]map[int]int, error) { + assigned := map[string]bool{} + for _, m := range inSet { + assigned[m.Module] = true + } + names := make([]string, 0, len(c.in.Catalogue)) + for name := range c.in.Catalogue { + names = append(names, name) + } + sort.Strings(names) + seats := map[string]map[int]int{} + for _, name := range names { + m := c.in.Catalogue[name] + var claims []string + for _, cl := range m.Claims { + if cl.At() == catalogue.ScopeMesh { + claims = append(claims, cl.Name) + } + } + if len(claims) == 0 { + continue + } + // Only a port the node was given or the mesh assigned, never the manifest's own number. And on an adopted node a holder + // assigned but not yet taken is the found container, so its mesh-assigned ports do not count there; a given port does. + ports, _, err := c.portsGivenOn(node.Name, m) + if err != nil { + return nil, err + } + if adopted && !taken[name] { + layers := node.Settings[m.Module] + ports = map[int]int{} + if given, err := resolution.GivenPorts(m, layers); err == nil { + ports = given + } + } + if len(ports) == 0 { + continue + } + for _, seat := range claims { + if seats[seat] == nil { + seats[seat] = map[int]int{} + } + for wanted, at := range ports { + if _, said := seats[seat][wanted]; said && !assigned[name] { + continue + } + seats[seat][wanted] = at + } + } + } + return seats, nil +} + +// localNetworksOf is a machine's attached networks judged (novox/hq ADR 0340): which count as its local network, from what it +// last said of itself and the links it said face outside it. A machine that has said none judges to nothing. +func localNetworksOf(n Node) []catalogue.LocalNetwork { + if !n.NetworksSaid { + return nil + } + return resolution.JudgeLocalNetworks(n.Networks, n.OutwardLinks, overlay.Interface) +} diff --git a/internal/compose/rendering_test.go b/internal/compose/rendering_test.go new file mode 100644 index 0000000..54b9c71 --- /dev/null +++ b/internal/compose/rendering_test.go @@ -0,0 +1,329 @@ +package compose + +import ( + "errors" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/overlay" +) + +// aWebModule publishes one port in the short form, which the mesh is free to move (MachineSide). +func aWebModule() catalogue.Manifest { + return catalogue.Manifest{Module: "web", Version: "1", + Listens: []catalogue.Listening{{Name: "http", Port: 8080, From: catalogue.FromMesh}}, + Resources: []map[string]any{{"id": "server", "type": "container", "name": "web", "ports": []any{"8080"}, + "image": "registry.example/web@sha256:" + strings.Repeat("a", 64)}}} +} + +// aStoreModule is mesh-controller's sendable_integration_test.go TestConsumersAreToldTheGivenPort store (cmd/mesh-controller +// at 6c2a8d62): publishing 5432:5432, offering a database to the mesh. +func aStoreModule() catalogue.Manifest { + return catalogue.Manifest{Module: "store", Version: "1", + Provides: []catalogue.Offer{{Name: "database", Scope: catalogue.ScopeMesh}}, + Listens: []catalogue.Listening{{Port: 5432, From: catalogue.FromMesh}}, + Guards: []int{5432}, + Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-store", + "ports": []any{"5432:5432"}, + "image": "registry.example/pg@sha256:" + strings.Repeat("b", 64)}}} +} + +func placed(t *testing.T, in Input, node string) Placed { + t.Helper() + got, err := Places(in, node) + if err != nil { + t.Fatalf("%v", err) + } + return got +} + +// 1363/AC1 and AC2: a port the Input holds as allocated on the node is the one composed; with none, no number is invented: the +// manifest's own is composed and the port is named foreseen, as the Foreseeing mode does. +func TestAnAllocatedPortIsComposedAndAnUnallocatedOneIsNamedForeseenNotInvented(t *testing.T) { + mk := func() Input { + in := aMesh(t) + in.Catalogue["web"] = aWebModule() + nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "web") + return in + } + in := mk() + got := placed(t, in, "laptop") + if p := got.Rendering.Ports["web"][8080]; p != 8080 { + t.Errorf("a port nobody allocated is composed as %d, not as the manifest's own", p) + } + if len(got.ForeseenPorts) != 1 || got.ForeseenPorts[0] != "web:8080" { + t.Errorf("foreseen ports are %v", got.ForeseenPorts) + } + nodeOf(t, &in, "laptop").Ports = map[string]map[int]int{"web": {8080: 20123}} + got = placed(t, in, "laptop") + if p := got.Rendering.Ports["web"][8080]; p != 20123 || len(got.ForeseenPorts) != 0 { + t.Errorf("the allocated port is composed as %d (foreseen %v)", p, got.ForeseenPorts) + } + // A port the module does not publish is not the mesh's to move, so it is never foreseen. + m := aWebModule() + m.Resources = nil + in.Catalogue["web"] = m + nodeOf(t, &in, "laptop").Ports = nil + got = placed(t, in, "laptop") + if got.Rendering.Ports["web"][8080] != 8080 || len(got.ForeseenPorts) != 0 { + t.Errorf("an unpublished port: %v foreseen %v", got.Rendering.Ports, got.ForeseenPorts) + } + unchanged(t, mk, func(in Input) { placed(t, in, "laptop") }) + // Only a composition for plans refuses a contribution whose port the module does not listen on (issue 774). + for _, mode := range []Mode{Reading, Foreseeing, Planning} { + if got := rendered(t, mk(), "laptop", mode).Rendering; got.Planned != (mode == Planning) { + t.Errorf("mode %d composes with Planned %v", mode, got.Planned) + } + } +} + +// 1363/AC3: a port the operator gave is used, over the allocated one, and a consumer elsewhere is told it +// (mesh-controller sendable_integration_test.go TestConsumersAreToldTheGivenPort). +func TestAPortTheOperatorGaveIsUsedAndNotReallocated(t *testing.T) { + in := aMesh(t) + in.Catalogue["store"] = aStoreModule() + in.Catalogue["app"] = catalogue.Manifest{Module: "app", Version: "1", Requires: []string{"database"}} + anchor := nodeOf(t, &in, "anchor") + anchor.Assigned = append(anchor.Assigned, "store") + nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "app") + anchor.Ports = map[string]map[int]int{"store": {5432: 20999}} + anchor.Settings = map[string][]catalogue.Layer{"store": {{From: "anchor", + Placement: map[string]any{catalogue.PortsSetting: map[string]any{"5432": 5433}}}}} + + got := placed(t, in, "anchor") + if p := got.Rendering.Ports["store"][5432]; p != 5433 { + t.Errorf("the store is on %d, not the port it was given", p) + } + if g := got.Rendering.Given["store"][5432]; g != 5433 { + t.Errorf("the given port is %d", g) + } + if len(got.ForeseenPorts) != 0 { + t.Errorf("a given port is foreseen: %v", got.ForeseenPorts) + } + resolved, err := Resolve(in, "laptop") + if err != nil { + t.Fatal(err) + } + if told := need(t, resolved.Plan, "app", "database").Serves["port"]; told != 5433 { + t.Errorf("the consumer is told the store is on %v", told) + } + + // One holder per machine port across the node's modules: two given the same refused, in the controller's words. + in.Catalogue["store2"] = func() catalogue.Manifest { + m := aStoreModule() + m.Module, m.Provides = "store2", nil + m.Resources = []map[string]any{{"id": "server", "type": "container", "name": "other", "ports": []any{"5432:5432"}, + "image": "registry.example/pg@sha256:" + strings.Repeat("c", 64)}} + return m + }() + anchor.Assigned = append(anchor.Assigned, "store2") + anchor.Settings["store2"] = []catalogue.Layer{{From: "anchor", + Placement: map[string]any{catalogue.PortsSetting: map[string]any{"5432": 5433}}}} + _, err = Places(in, "anchor") + if err == nil || !errors.Is(err, ErrPortTaken) || !strings.Contains(err.Error(), "store and store2 are both given machine port 5433 on anchor") && + !strings.Contains(err.Error(), "store2 and store are both given machine port 5433 on anchor") { + t.Fatalf("two modules given one machine port: %v", err) + } +} + +// 1363/AC4: the seats held and taken reach the Rendering: where each mesh-scoped seat's holder put its ports, from the node's +// given and assigned ports, never the manifest's own; and on an adopted node a holder assigned but not taken counts only by its +// given ports (novox/hq ADR 0100). +func TestSeatsHeldAndTakenReachTheRendering(t *testing.T) { + in := aMesh(t) + holder := aStoreModule() + holder.Claims = []catalogue.Claim{{Name: "mesh-store", Scope: catalogue.ScopeMesh}} + in.Catalogue["store"] = holder + anchor := nodeOf(t, &in, "anchor") + anchor.Assigned = append(anchor.Assigned, "store") + // The shelf is read whole, in this node's set or not: laptop, running none, is told where the anchor's store is only by the + // settings and ports of its own machine, which it has none of. + anchor.Ports = map[string]map[int]int{"store": {5432: 20999}} + got := placed(t, in, "anchor") + if at := got.Rendering.Seats["mesh-store"][5432]; at != 20999 { + t.Errorf("the seat's holder is told to be on %d", at) + } + if len(placed(t, in, "laptop").Rendering.Seats) != 0 { + t.Errorf("laptop is told of a holder it has no port for: %v", placed(t, in, "laptop").Rendering.Seats) + } + // Adopted, and the holder assigned but not taken: the found container's ports, not the mesh's assignment. + anchor.Adopted = true + if len(placed(t, in, "anchor").Rendering.Seats) != 0 { + t.Errorf("an untaken holder on an adopted node counts by the mesh's assignment: %v", placed(t, in, "anchor").Rendering.Seats) + } + anchor.Taken = []string{"store"} + got = placed(t, in, "anchor") + if at := got.Rendering.Seats["mesh-store"][5432]; at != 20999 || !got.Rendering.Adopted || !got.Rendering.Taken["store"] { + t.Errorf("a taken holder: seats %v adopted %v taken %v", got.Rendering.Seats, got.Rendering.Adopted, got.Rendering.Taken) + } +} + +// 1363/AC4: local networks and outward links (mesh-controller local_network_test.go TestNodeShowsWhichAttachedNetworksCountAndWhy, +// the values): a private network on a link facing outside counts, a public subnet and the private network's own link never do; a +// machine that said none judges to nothing. +func TestLocalNetworksAndOutwardLinksReachTheRendering(t *testing.T) { + in := aMesh(t) + laptop := nodeOf(t, &in, "laptop") + laptop.OutwardLinks = []string{"eth0", "eth1"} + laptop.Networks = []catalogue.AttachedNetwork{ + {Link: "eth0", Network: "192.168.1.10/24"}, {Link: "eth1", Network: "203.0.113.7/24"}, {Link: overlay.Interface, Network: "10.10.0.4/24"}} + laptop.NetworksSaid = true + got := placed(t, in, "laptop").Rendering + if len(got.LocalNetworks) != 1 || got.LocalNetworks[0] != "192.168.1.0/24" { + t.Errorf("the local networks are %v", got.LocalNetworks) + } + if len(got.OutwardLinks) != 2 || got.OutwardLinks[0] != "eth0" { + t.Errorf("the outward links are %v", got.OutwardLinks) + } + laptop.NetworksSaid = false + if got := placed(t, in, "laptop").Rendering; len(got.LocalNetworks) != 0 { + t.Errorf("a machine that never reported has local networks %v", got.LocalNetworks) + } +} + +// 1363/AC4: accounts, the names of the mesh, the mesh range and the operator's export reach the Rendering. +func TestAccountsNamesAndTheOperatorsExportReachTheRendering(t *testing.T) { + in := aMesh(t) + nodeOf(t, &in, "anchor").Account = "jochens" + in.OperatorExport = &catalogue.KeptExport{} + in.OverlayCIDR = "10.99.0.0/16" + got := placed(t, in, "anchor").Rendering + if got.Accounts["anchor"] != "jochens" || len(got.Accounts) != 1 { + t.Errorf("accounts are %v", got.Accounts) + } + if got.Names["anchor.internal"] != "10.77.0.1" || got.Names["laptop.internal"] != "10.77.0.2" || len(got.Names) != 2 { + t.Errorf("names are %v", got.Names) + } + if len(got.Machines) != 2 || got.Machines["laptop.internal"] != "10.77.0.2" { + t.Errorf("machines are %v", got.Machines) + } + if len(got.Mesh) != 2 || got.Mesh[0] != "10.77.0.1" || got.Mesh[1] != "10.77.0.2" { + t.Errorf("the mesh's addresses are %v", got.Mesh) + } + if got.MeshRange != "10.99.0.0/16" || got.Suffix != "internal" || got.TunnelInterface != overlay.Interface { + t.Errorf("range %q suffix %q tunnel %q", got.MeshRange, got.Suffix, got.TunnelInterface) + } + if got.Kept != nil { + t.Error("the operator's export reaches a node where nothing keeps it") + } + keeper := catalogue.Manifest{Module: "keeper", Version: "1", Keeps: "/var/lib/keeper/export.json"} + in.Catalogue["keeper"] = keeper + nodeOf(t, &in, "anchor").Assigned = append(nodeOf(t, &in, "anchor").Assigned, "keeper") + if got := placed(t, in, "anchor").Rendering; got.Kept != in.OperatorExport { + t.Errorf("a module that keeps the export is not given it: %v", got.Kept) + } + // And the names of a machine that left the network leave with it. + nodeOf(t, &in, "laptop").Assigned = nil + if got := placed(t, in, "anchor").Rendering; len(got.Names) != 1 || got.Names["laptop.internal"] != "" { + t.Errorf("a machine off the network is named: %v", got.Names) + } +} + +// The names the operator gave to peers of an adopted tunnel answer until they enrol (novox/hq issue 112); a node of the mesh owns +// its name. +func TestACarriedPeerIsNamedUntilItEnrols(t *testing.T) { + in := aMesh(t) + in.CarriedPeers = []CarriedPeer{{PublicKey: "K1", Address: "192.0.2.5", Named: "printer"}, + {PublicKey: "K2", Address: "192.0.2.6", Named: "laptop"}, {PublicKey: "K3", Address: "192.0.2.7", Named: "late", EnrolledAs: "late"}} + got := placed(t, in, "anchor").Rendering + if got.Names["printer.internal"] != "192.0.2.5" || got.Names["laptop.internal"] != "10.77.0.2" || got.Names["late.internal"] != "" { + t.Errorf("names are %v", got.Names) + } +} + +// A node-engine older than a contract is not sent the field it would refuse (novox/hq ADR 0240 and after). +func TestTheNodeEnginesContractGatesWhatItIsSent(t *testing.T) { + in := aMesh(t) + laptop := nodeOf(t, &in, "laptop") + if r := placed(t, in, "laptop").Rendering; r.ReadsHealth || r.JudgesRoot || r.ReadsBusy || r.Hands { + t.Errorf("an engine that never stated anything reads %+v", r) + } + laptop.Stated, laptop.Contract = true, ReadinessContract + if r := placed(t, in, "laptop").Rendering; !r.ReadsHealth || r.JudgesRoot || r.ReadsBusy || r.Hands { + t.Errorf("an engine at the readiness contract reads %+v", r) + } + laptop.Contract = HandedContract + if r := placed(t, in, "laptop").Rendering; !r.ReadsHealth || !r.JudgesRoot || !r.ReadsBusy || !r.Hands { + t.Errorf("an engine at the handed contract reads %+v", r) + } +} + +// The artifact store as a node reaches it: over the private network by its holder's name and the port it put it on, or on loopback +// for the holder alone while nothing is on the network (novox/hq 04-ISSUES/102). +func TestTheArtifactStoreIsReachedOverTheNetworkOrOnLoopbackForItsHolder(t *testing.T) { + in := aMesh(t) + in.Catalogue["artifacts"] = catalogue.Manifest{Module: "artifacts", Version: "1", + Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}} + anchor := nodeOf(t, &in, "anchor") + anchor.Assigned = append(anchor.Assigned, "artifacts") + if got := placed(t, in, "laptop").Rendering.ArtifactStore; got != "anchor.internal:5100" { + t.Errorf("laptop reaches the store at %q", got) + } + nodeOf(t, &in, "laptop").Assigned = nil + anchor.Assigned = []string{"artifacts"} // off the network: the holder alone reaches it + if got := placed(t, in, "anchor").Rendering.ArtifactStore; got != "127.0.0.1:5100" { + t.Errorf("the holder reaches the store at %q", got) + } + if got := placed(t, in, "laptop").Rendering.ArtifactStore; got != "" { + t.Errorf("a machine off the network reaches the store at %q", got) + } + in.Built = []string{"mesh-sdk"} + if got := placed(t, in, "anchor").Rendering.Built; !got["mesh-sdk"] { + t.Errorf("built is %v", got) + } +} + +// A zone a module declares is placed where the mesh put it: settled from that node's settings, at the node's private address, on +// the port the answering listen is published on there (novox/hq ADR 0199); the same zone declared twice is refused, by name. +func TestAZoneIsPlacedWhereTheMeshPutItAndTheSameZoneTwiceIsRefused(t *testing.T) { + in := aMesh(t) + lab := catalogue.Manifest{Module: "lab", Version: "1", Zone: &catalogue.Zone{Name: "${setting:zone}", Listen: "dns"}, + Listens: []catalogue.Listening{{Name: "dns", Port: 53, Protocol: "udp", From: catalogue.FromMesh}}} + in.Catalogue["lab"] = lab + anchor := nodeOf(t, &in, "anchor") + anchor.Assigned = append(anchor.Assigned, "lab") + anchor.Settings = map[string][]catalogue.Layer{"lab": {{From: "anchor", Values: map[string]any{"zone": "Lab.Example."}}}} + anchor.Ports = map[string]map[int]int{"lab": {53: 5353}} + zones := placed(t, in, "laptop").Rendering.Zones + if len(zones) != 1 || zones[0] != (catalogue.ZoneAt{Zone: "lab.example", Node: "anchor", Module: "lab", Address: "10.77.0.1", Port: 5353}) { + t.Fatalf("zones are %+v", zones) + } + laptop := nodeOf(t, &in, "laptop") + laptop.Assigned = append(laptop.Assigned, "lab") + laptop.Settings = map[string][]catalogue.Layer{"lab": {{From: "laptop", Values: map[string]any{"zone": "lab.example"}}}} + if _, err := Places(in, "laptop"); err == nil || !strings.Contains(err.Error(), "the mesh's zones cannot be forwarded") { + t.Fatalf("one zone declared twice: %v", err) + } +} + +// Every holder of a replicated seat is listed by internal name at its private address (novox/hq ADR 0223): the holders on record, +// and only the sole claimant when there are none; a holder off the private network is left out. +func TestEveryHolderOfAReplicatedSeatIsListedOnTheNetwork(t *testing.T) { + in := aMesh(t) + resolver := catalogue.Manifest{Module: "resolver", Version: "1", + Provides: []catalogue.Offer{{Name: "wildcard-resolution", Scope: catalogue.ScopeMesh}}, + Claims: []catalogue.Claim{{Name: "mesh-dns-resolver", Scope: catalogue.ScopeMesh}}} + in.Catalogue["resolver"] = resolver + anchor, laptop := nodeOf(t, &in, "anchor"), nodeOf(t, &in, "laptop") + anchor.Assigned = append(anchor.Assigned, "resolver") + // One claimant and nothing on record: the sole claimant is listed. + if got := placed(t, in, "laptop").Rendering.Holders["mesh-dns-resolver"]; len(got) != 1 || got["anchor.internal"] != "10.77.0.1" { + t.Errorf("the sole claimant: %v", got) + } + // Both on record: both are listed (two claimants with nothing on record would be refused at resolution). + laptop.Assigned = append(laptop.Assigned, "resolver") + in.Holdings = []catalogue.Held{ + {Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "resolver"}, + {Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "laptop", Module: "resolver"}} + got := placed(t, in, "laptop").Rendering.Holders["mesh-dns-resolver"] + if len(got) != 2 || got["anchor.internal"] != "10.77.0.1" || got["laptop.internal"] != "10.77.0.2" { + t.Errorf("both holders on record: %v", got) + } + // A holder off the private network is left out: a resolver named at an address nothing answers waits out its timeout. + in.Nodes = append(in.Nodes, Node{Name: "ace", Place: Place{Address: "10.77.0.3"}, Assigned: []string{"resolver"}}) + in.Holdings = append(in.Holdings, catalogue.Held{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "ace", Module: "resolver"}) + if got := placed(t, in, "laptop").Rendering.Holders["mesh-dns-resolver"]; len(got) != 2 || got["ace.internal"] != "" { + t.Errorf("a holder off the network is listed: %v", got) + } +} diff --git a/internal/compose/resolution/a_service_process_test.go b/internal/compose/resolution/a_service_process_test.go new file mode 100644 index 0000000..de7b3ac --- /dev/null +++ b/internal/compose/resolution/a_service_process_test.go @@ -0,0 +1,10 @@ +package resolution + +func indexOf(out []map[string]any, id string) int { + for i, r := range out { + if r["id"] == id { + return i + } + } + return -1 +} diff --git a/internal/compose/resolution/accesses_test.go b/internal/compose/resolution/accesses_test.go new file mode 100644 index 0000000..5bfc9f3 --- /dev/null +++ b/internal/compose/resolution/accesses_test.go @@ -0,0 +1,80 @@ +package resolution + +import ( + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// The fault 04-ISSUES/036 records: a media stack is several modules — a library server, the +// acquisition managers, a download client — that must share directories on one machine. Written +// as owned `directory` resources, two of them on one node are refused as rivalrous owners, which +// is right in general and wrong here: sharing those directories is the whole point of the stack. +// +// Declared as accesses to an operator-owned path (novox/hq ADR 0051), they co-resolve. An access +// is not a resource and never enters the duplicate-owner map, so this is the exact case the +// resolver refuses above when the same path is owned — and does not, when it is merely accessed. +func TestTwoModulesAccessingOnePathCoResolve(t *testing.T) { + a := mod("a", nil, nil, nil) + a.Accesses = []mcat.Access{{Path: "/services/media/downloads", Mode: mcat.AccessReadWrite}} + b := mod("b", nil, nil, nil) + b.Accesses = []mcat.Access{{Path: "/services/media/downloads", Mode: mcat.AccessRead}} + + got, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), mcat.World{}) + if err != nil { + t.Fatalf("two modules accessing one operator-owned path were refused: %v", err) + } + + // And each accessing module's grant reaches the machine as its own `access` resource, so the + // host can find the path present or refuse — both naming the one operator-owned path. + var accesses int + for _, r := range mustDeclare(t, got) { + if r["type"] == "access" && r["path"] == "/services/media/downloads" { + accesses++ + } + } + if accesses != 2 { + t.Errorf("expected both modules' accesses in the declaration, got %d", accesses) + } +} + +// The mirror of the case above, so the sharing vocabulary does not weaken the rule it sits beside: +// two modules OWNING one path is still refused, exactly as before accesses existed. +func TestTwoModulesOwningOnePathAreStillRefused(t *testing.T) { + a := mod("a", nil, nil, nil) + a.Resources = []map[string]any{ + {"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}} + b := mod("b", nil, nil, nil) + b.Resources = []map[string]any{ + {"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}} + + _, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), mcat.World{}) + if err == nil { + t.Fatal("two modules owning the same directory were both assigned") + } + if !strings.Contains(err.Error(), "/services/media/movies") { + t.Errorf("the refusal does not name the path: %v", err) + } +} + +// Shared data is the operator's, owned by no module (novox/hq ADR 0051). A module owning the path +// another module was told to expect the operator to provide would create and chown it — and the +// two intentions cannot both hold, so it is refused by name. This is what keeps the distinction +// machine-checkable rather than a convention nobody enforces. +func TestAModuleMayNotOwnWhatAnotherAccesses(t *testing.T) { + owns := mod("owns", nil, nil, nil) + owns.Resources = []map[string]any{ + {"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}} + uses := mod("uses", nil, nil, nil) + uses.Accesses = []mcat.Access{{Path: "/services/media/movies", Mode: mcat.AccessRead}} + + _, err := Resolve(shelf(owns, uses), []string{"owns", "uses"}, workstation(), mcat.World{}) + if err == nil { + t.Fatal("a module owning a path another accesses was allowed") + } + if !strings.Contains(err.Error(), "/services/media/movies") || + !strings.Contains(err.Error(), "the operator's") { + t.Errorf("the refusal does not explain the ownership conflict: %v", err) + } +} diff --git a/internal/compose/resolution/adoption.go b/internal/compose/resolution/adoption.go new file mode 100644 index 0000000..5017ab9 --- /dev/null +++ b/internal/compose/resolution/adoption.go @@ -0,0 +1,177 @@ +package resolution + +import ( + "fmt" + "sort" + "strconv" + "strings" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// GuardID, GuardUnitID and GuardRunningID are the guard's resource identities. The installer +// raises the same three on an adopted genesis, so the first push finds them already there. +func GuardID() string { return mcat.AdoptionPrefix + "guard" } + +func GuardUnitID() string { return mcat.AdoptionPrefix + "guard-unit" } + +func GuardRunningID() string { return mcat.AdoptionPrefix + "guard-running" } + +// GuardPackageID is the tool that loads the guard, declared first: a node joining adopted has +// no filter module and may have no nft at all, and a table nothing can load guards nothing. +func GuardPackageID() string { return mcat.AdoptionPrefix + "guard-package" } + +// OpeningID is an opening's resource identity: its protocol, port and path say what it is. +func OpeningID(protocol string, port int, path string) string { + return fmt.Sprintf("%sopening-%s-%d-%s", mcat.AdoptionPrefix, protocol, port, path) +} + +// Openings are what the mesh needs reachable on an adopted node, from the same inputs as the +// filter it would load were the node converged, each from where that filter would admit it. +// +// `rules` is Filtering's answer — every module's listens, the hub's port, the per-node exposure — +// and `foundation` is the ports the mesh itself needs, from everywhere. A rule for this machine +// only opens nothing. `published` maps a machine port a container publishes to the container's +// port: a published port is forwarded, not received, so its opening names the forwarded path and +// the port the packet is forwarded to. +func Openings(rules []mcat.Rule, foundation []int, published map[string]map[int]int) []map[string]any { + type key struct { + protocol string + port int + } + from := map[key]string{} + var order []key + widen := func(k key, f string) { + was, seen := from[k] + if !seen { + order = append(order, k) + } + if !seen || was != mcat.OpeningFromEverywhere { + from[k] = f + } + } + for _, rule := range rules { + switch rule.From { + case mcat.FromEverywhere: + widen(key{rule.Protocol, rule.Port}, mcat.OpeningFromEverywhere) + case mcat.FromMesh: + widen(key{rule.Protocol, rule.Port}, mcat.OpeningFromMesh) + case mcat.FromLocalNetwork: + // The private network's part only (novox/hq ADR 0340). An adopted node's found firewall + // stays, and it is what admits the machine's local network; an opening has no word for a + // local network, and widening it to everywhere would open the port to the internet. + widen(key{rule.Protocol, rule.Port}, mcat.OpeningFromMesh) + } + } + for _, port := range foundation { + widen(key{"tcp", port}, mcat.OpeningFromEverywhere) + } + sort.Slice(order, func(a, b int) bool { + if order[a].port != order[b].port { + return order[a].port < order[b].port + } + return order[a].protocol < order[b].protocol + }) + out := make([]map[string]any, 0, len(order)) + for _, k := range order { + opening := map[string]any{"type": "opening", "port": k.port, "protocol": k.protocol, + "from": from[k]} + if to, forwarded := published[k.protocol][k.port]; forwarded { + opening["id"] = OpeningID(k.protocol, k.port, mcat.PathForwarded) + opening["path"] = mcat.PathForwarded + opening["to"] = to + } else { + opening["id"] = OpeningID(k.protocol, k.port, mcat.PathIncoming) + opening["path"] = mcat.PathIncoming + } + out = append(out, opening) + } + return out +} + +// AsGuard renders the mesh's refusal-only table for the given machine ports. +// +// It passes everything by default and holds nothing but a refusal, so it cannot close anything +// the machine serves; and it is the mesh's own table, so the found firewall reloading does not +// touch it. It refuses only packets addressed to this machine, and the ports except from the +// machine itself — its loopback and the container runtime's own networks — and from the private +// network, known by the interface a packet arrives on and never by its source address. At +// prerouting, ahead of the runtime's destination translation, so it matches the port the packet +// was sent to; in the inet family, so both address families. +// +// **The machine's own interfaces are named, where the derived filter names address ranges.** The +// filter accepts the container runtime's networks by CIDR; this excludes its bridges by name — lo, +// docker0, the br-* a compose network gets, and the mesh's own mesh0. A runtime whose bridge is +// named anything else (a podman or libvirt bridge, or a docker network created with a fixed name) +// would have its containers' traffic to a guarded port refused, which reads as the port being +// down. Names rather than addresses is deliberate: a source address can be claimed by whoever +// sends the packet, and this table exists to refuse what the found firewall never sees. Widening +// it means adding names here and in the installer's copy together, which the golden test holds to +// one text. +// +// The same text the installer raises on an adopted genesis; a test holds both to it. +func AsGuard(ports []int) string { + sorted := append([]int{}, ports...) + sort.Ints(sorted) + listed := make([]string, len(sorted)) + for i, p := range sorted { + listed[i] = strconv.Itoa(p) + } + var b strings.Builder + b.WriteString("table inet mesh_guard {}\n") + b.WriteString("delete table inet mesh_guard\n") + b.WriteString("table inet mesh_guard {\n") + b.WriteString("\tchain prerouting {\n") + b.WriteString("\t\ttype filter hook prerouting priority raw; policy accept;\n") + // Only packets addressed to this machine: traffic it routes for others — a predecessor's hub, + // say — is never the guard's business (novox/hq ADR 0103). + fmt.Fprintf(&b, "\t\tfib daddr type local iifname != \"lo\" iifname != \"docker0\" "+ + "iifname != \"br-*\" iifname != \"mesh0\" tcp dport { %s } drop\n", + strings.Join(listed, ", ")) + b.WriteString("\t}\n") + b.WriteString("}\n") + return b.String() +} + +// GuardUnitText is the unit that loads the guard. Stopping it deletes only its own table: never +// a flush, which would take the container runtime's rules and the found firewall with it. +func GuardUnitText() string { + return "[Unit]\n" + + "Description=The mesh's guard: refuses its own ports from outside (novox/hq ADR 0100)\n" + + // Early, before the network is up, and without the default dependencies that would + // order it after the network; stopped at shutdown like any unit. + "DefaultDependencies=no\n" + + "Wants=network-pre.target\n" + + "Before=network-pre.target shutdown.target\n" + + "Conflicts=shutdown.target\n" + + "\n" + + "[Service]\n" + + "Type=oneshot\n" + + "RemainAfterExit=yes\n" + + "ExecStart=nft -f " + mcat.GuardPath + "\n" + + "ExecReload=nft -f " + mcat.GuardPath + "\n" + + "ExecStop=nft delete table inet mesh_guard\n" + + "\n" + + "[Install]\n" + + "WantedBy=multi-user.target\n" +} + +// GuardResources are the guard as four resources of the existing kinds: the tool that loads it, +// the table, the unit, and the unit running — reloaded when the table changes, so the new table +// replaces the old in one `nft -f` through the unit's ExecReload with no moment unguarded, and +// restarted only when the unit itself changes. Nothing when there is nothing to guard: an empty +// set is not a table nft loads. +func GuardResources(ports []int) []map[string]any { + if len(ports) == 0 { + return nil + } + return []map[string]any{ + {"id": GuardPackageID(), "type": "package", "package": mcat.GuardPackage}, + {"id": GuardID(), "type": "file", "path": mcat.GuardPath, "content": AsGuard(ports), + "mode": "0644"}, + {"id": GuardUnitID(), "type": "file", "path": mcat.GuardUnitPath, "content": GuardUnitText(), + "mode": "0644"}, + {"id": GuardRunningID(), "type": "service", "unit": mcat.GuardUnit, "state": "running", + "boot": "enabled", "restart-on": []any{GuardUnitID()}, "reload-on": []any{GuardID()}}, + } +} diff --git a/internal/compose/resolution/adoption_test.go b/internal/compose/resolution/adoption_test.go new file mode 100644 index 0000000..d704884 --- /dev/null +++ b/internal/compose/resolution/adoption_test.go @@ -0,0 +1,629 @@ +package resolution + +import ( + "encoding/json" + "reflect" + "slices" + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// hub is the private network's generator on the hub: it opens the hub's port from anywhere. +type hub struct{} + +func (hub) Resources(string) ([]map[string]any, bool, error) { + return []map[string]any{{"id": "config", "type": "file", "path": "/etc/wireguard/mesh0.conf", + "content": "[Interface]\n"}}, true, nil +} + +func (hub) Listens(string) ([]mcat.Listening, error) { + return []mcat.Listening{{Port: 51820, Protocol: "udp", From: mcat.FromEverywhere}}, nil +} + +// anAdoptedAnchor is the control-node's set: the store, the bus, the registry, the private network, +// a served module and the filter module. +func anAdoptedAnchor() mcat.Resolution { + return mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{ + {Module: "network", Computed: "overlay"}, + {Module: "postgres", Guards: []int{5432}, + Listens: []mcat.Listening{{Port: 5432, From: mcat.FromMesh}}, + Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-store", + "ports": []any{"5432:5432"}}}}, + {Module: "lavinmq", Guards: []int{15672}, + Listens: []mcat.Listening{{Port: 5671, From: mcat.FromMesh}, {Port: 5672, From: mcat.FromMesh}}, + Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-broker", + "ports": []any{"5671:5671", "5672:5672", "127.0.0.1:15672:15672"}}}}, + {Module: "distribution", + Listens: []mcat.Listening{{Port: 5000, From: mcat.FromMesh}}, + Resources: []map[string]any{{"id": "store", "type": "container", "name": "registry", + "ports": []any{"5000"}}}}, + {Module: "hello-web", + Listens: []mcat.Listening{{Port: 8080, From: mcat.FromEverywhere}}, + Resources: []map[string]any{{"id": "server", "type": "container", "name": "hello-web", + "ports": []any{"8080"}}}}, + {Module: "helper", Listens: []mcat.Listening{{Port: 9000, From: mcat.FromMachine}}}, + {Module: "nftables", Filtering: &mcat.Filtering{Into: "/etc/nftables.conf"}, + Resources: []map[string]any{{"id": "load", "type": "service", "unit": "mesh-filter.service", + "state": "running", "restart-on": []any{"filtering"}}}}, + }} +} + +func anchorRendering(adopted bool) mcat.Rendering { + return mcat.Rendering{ + Generators: map[string]mcat.Generator{"overlay": hub{}}, + Ports: map[string]map[int]int{"distribution": {5000: 5000}, "hello-web": {8080: 20001}}, + Settings: mcat.SettingsBy{"distribution": {{From: "node anchor", + Placement: map[string]any{mcat.ExposeSetting: map[string]any{"5000": mcat.FromEverywhere}}}}}, + Mesh: []string{"10.42.0.1"}, + Foundation: []int{5671}, + // What the machine reported faces outside, which every rule in the filter is written + // around (novox/hq ADR 0140). + OutwardLinks: []string{"eth0"}, + TunnelInterface: "mesh0", + Adopted: adopted, + // Genesis takes the foundation's modules. + Taken: map[string]bool{"postgres": true, "lavinmq": true}, + } +} + +func byID(resources []map[string]any) map[string]map[string]any { + out := map[string]map[string]any{} + for _, r := range resources { + out[r["id"].(string)] = r + } + return out +} + +func TestAnAdoptedNodeIsDeclaredOpeningsFromTheSameInputsAsTheFilter(t *testing.T) { + composed, err := Compose(anAdoptedAnchor(), anchorRendering(true)) + if err != nil { + t.Fatal(err) + } + got := byID(composed.Resources) + want := map[string]map[string]any{ + // The hub's port, from anywhere, received. + "adoption.opening-udp-51820-incoming": {"port": 51820, "protocol": "udp", + "from": "everywhere", "path": "incoming"}, + // The store's port from the private network only, and forwarded: a container publishes it. + "adoption.opening-tcp-5432-forwarded": {"port": 5432, "protocol": "tcp", "from": "mesh", + "path": "forwarded", "to": 5432}, + // The bus from anywhere: a node enrols over it before it has a private address. + "adoption.opening-tcp-5671-forwarded": {"port": 5671, "protocol": "tcp", + "from": "everywhere", "path": "forwarded", "to": 5671}, + "adoption.opening-tcp-5672-forwarded": {"port": 5672, "protocol": "tcp", "from": "mesh", + "path": "forwarded", "to": 5672}, + // The registry from anywhere, by its node's exposure setting. + "adoption.opening-tcp-5000-forwarded": {"port": 5000, "protocol": "tcp", + "from": "everywhere", "path": "forwarded", "to": 5000}, + // A published port names the machine port and the container port it is forwarded to. + "adoption.opening-tcp-20001-forwarded": {"port": 20001, "protocol": "tcp", + "from": "everywhere", "path": "forwarded", "to": 8080}, + } + for id, fields := range want { + opening, ok := got[id] + if !ok { + t.Errorf("no %s among %v", id, keys(got)) + continue + } + if opening["type"] != "opening" { + t.Errorf("%s is a %v", id, opening["type"]) + } + for k, v := range fields { + if opening[k] != v { + t.Errorf("%s: %s is %v, want %v", id, k, opening[k], v) + } + } + } + for id := range got { + if strings.HasPrefix(id, "adoption.opening-") && want[id] == nil { + t.Errorf("an opening nothing asked for: %s", id) + } + } + // A port for this machine only opens nothing, and the management port is not opened at all. + for id := range got { + if strings.Contains(id, "-9000-") || strings.Contains(id, "-15672-") { + t.Errorf("%s is opened", id) + } + } + + // And openings come first, in the order the machine applies them. + if !strings.HasPrefix(composed.Resources[0]["id"].(string), "adoption.opening-") { + t.Errorf("openings are not first: %v", composed.Resources[0]["id"]) + } +} + +func TestAnAdoptedNodeLoadsNoFilterOfTheMeshs(t *testing.T) { + composed, err := Compose(anAdoptedAnchor(), anchorRendering(true)) + if err != nil { + t.Fatal(err) + } + for _, r := range composed.Resources { + if r["path"] == "/etc/nftables.conf" || strings.HasPrefix(r["id"].(string), "nftables.") { + t.Fatalf("an adopted node is declared the filter module's %v", r["id"]) + } + if content, _ := r["content"].(string); strings.Contains(content, "policy drop") { + t.Fatalf("an adopted node is declared a table that drops by default: %v", r["id"]) + } + // Nothing but refusals: the only accept in the guard is its policy. + if content, _ := r["content"].(string); r["id"] == GuardID() && + strings.Count(content, "accept") != 1 { + t.Fatalf("the guard holds an accept:\n%s", content) + } + } + got := byID(composed.Resources) + guard := got[GuardID()] + if guard == nil || got[GuardUnitID()] == nil || got[GuardRunningID()] == nil { + t.Fatalf("no guard: %v", keys(got)) + } + if guard["content"] != AsGuard([]int{5432, 5672, 15672}) { + t.Fatalf("the guard does not guard the store, the broker and its management port:\n%s", + guard["content"]) + } + // The tool that loads it comes first, and the table after it: a node joining adopted has no + // filter module and may have no nft. + pkg, table := -1, -1 + for i, r := range composed.Resources { + switch r["id"] { + case GuardPackageID(): + pkg = i + if r["type"] != "package" || r["package"] != "nftables" { + t.Fatalf("the guard's package is %v", r) + } + case GuardID(): + table = i + } + } + if pkg < 0 || pkg > table { + t.Fatalf("nftables is not declared before the guard's table (%d, %d)", pkg, table) + } + // A changed table is reloaded — one `nft -f`, atomic — never restarted, which would delete the + // table and leave the ports unguarded until it is loaded again. Only a changed unit restarts. + if !reflect.DeepEqual(got[GuardRunningID()]["reload-on"], []any{GuardID()}) || + !reflect.DeepEqual(got[GuardRunningID()]["restart-on"], []any{GuardUnitID()}) { + t.Fatalf("the guard is not reloaded on its table and restarted on its unit: %v", + got[GuardRunningID()]) + } + // Nothing of the mesh's own is anybody's to hold. + for id, module := range composed.Owner { + if strings.HasPrefix(id, mcat.AdoptionPrefix) { + t.Fatalf("%s is owned by %s", id, module) + } + } +} + +func TestAConvergedNodeIsDeclaredItsFilterAndNoOpenings(t *testing.T) { + composed, err := Compose(anAdoptedAnchor(), anchorRendering(false)) + if err != nil { + t.Fatal(err) + } + got := byID(composed.Resources) + if got["nftables.filtering"] == nil || got["nftables.load"] == nil { + t.Fatalf("a converged node lost its filter: %v", keys(got)) + } + for id := range got { + if strings.HasPrefix(id, mcat.AdoptionPrefix) { + t.Fatalf("a converged node is declared %s", id) + } + } + plain, err := Declaration(anAdoptedAnchor(), anchorRendering(false)) + if err != nil { + t.Fatal(err) + } + a, _ := json.Marshal(plain) + b, _ := json.Marshal(composed.Resources) + if string(a) != string(b) { + t.Fatal("Compose and Declaration disagree on a converged node") + } +} + +// The table the installer raises and the controller declares, character for character. +func TestTheGuardIsExactlyThisTable(t *testing.T) { + const golden = `table inet mesh_guard {} +delete table inet mesh_guard +table inet mesh_guard { + chain prerouting { + type filter hook prerouting priority raw; policy accept; + fib daddr type local iifname != "lo" iifname != "docker0" iifname != "br-*" iifname != "mesh0" tcp dport { 5432, 15672 } drop + } +} +` + if got := AsGuard([]int{15672, 5432}); got != golden { + t.Fatalf("the guard changed:\n%s", got) + } + const unit = `[Unit] +Description=The mesh's guard: refuses its own ports from outside (novox/hq ADR 0100) +DefaultDependencies=no +Wants=network-pre.target +Before=network-pre.target shutdown.target +Conflicts=shutdown.target + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=nft -f /etc/mesh/guard.nft +ExecReload=nft -f /etc/mesh/guard.nft +ExecStop=nft delete table inet mesh_guard + +[Install] +WantedBy=multi-user.target +` + if got := GuardUnitText(); got != unit { + t.Fatalf("the guard's unit changed:\n%s", got) + } + if GuardResources(nil) != nil { + t.Fatal("a guard with nothing to guard is an empty set nft refuses to load") + } +} + +func keys[V any](m map[string]V) []string { + return sortedKeys(m) +} + +// novox/hq ADR 0100: the foundation's ports are the node's. Given 5433 for the store, every place +// that uses the port reads it from there: the container, the filter, the openings, the guard. +func TestAGivenPortIsUsedEverywhereThePortIs(t *testing.T) { + given := map[string]map[int]int{"postgres": {5432: 5433}, "lavinmq": {15672: 15673}} + for _, adopted := range []bool{true, false} { + with := anchorRendering(adopted) + with.Given = given + with.Ports["postgres"] = map[int]int{5432: 5433} + composed, err := Compose(anAdoptedAnchor(), with) + if err != nil { + t.Fatal(err) + } + got := byID(composed.Resources) + if ports := got["postgres.server"]["ports"]; !reflect.DeepEqual(ports, []any{"5433:5432"}) { + t.Fatalf("the store's container publishes %v", ports) + } + if ports := got["lavinmq.server"]["ports"]; !reflect.DeepEqual(ports, + []any{"5671:5671", "5672:5672", "127.0.0.1:15673:15672"}) { + t.Fatalf("the broker's container publishes %v", ports) + } + if !adopted { + filter, _ := got["nftables.filtering"]["content"].(string) + if !strings.Contains(filter, "tcp dport 5433 accept") || strings.Contains(filter, "5432") { + t.Fatalf("the filter does not use the given port:\n%s", filter) + } + continue + } + if o := got["adoption.opening-tcp-5433-forwarded"]; o == nil || o["to"] != 5432 { + t.Fatalf("no opening for the given port: %v", keys(got)) + } + if guard := got[GuardID()]["content"]; guard != AsGuard([]int{5433, 5672, 15673}) { + t.Fatalf("the guard does not guard the given ports:\n%s", guard) + } + } +} + +// novox/hq ADR 0103: the guard is derived, and from taken modules only — every machine port a taken +// module publishes that the filter admits from the private network only, and the ports its +// manifest guards. A module assigned but not taken is not guarded: its port may still be the +// predecessor's. +func TestTheGuardIsDerivedFromTakenModulesOnly(t *testing.T) { + guardOf := func(with mcat.Rendering) string { + t.Helper() + composed, err := Compose(anAdoptedAnchor(), with) + if err != nil { + t.Fatal(err) + } + content, _ := byID(composed.Resources)[GuardID()]["content"].(string) + return content + } + + // The broker taken, the store not: the broker's plain port follows from its listens (from + // the mesh, published), its management port from its manifest; the store is not guarded, and + // neither is the bus, which the mesh needs from everywhere. + with := anchorRendering(true) + with.Taken = map[string]bool{"lavinmq": true} + if got := guardOf(with); got != AsGuard([]int{5672, 15672}) { + t.Fatalf("the guard is not the taken broker's ports:\n%s", got) + } + + // A taken module publishing a port admitted from everywhere is not guarded; one admitted from + // the mesh is. The registry is exposed everywhere on this node, and hello-web listens from + // everywhere. + with.Taken = map[string]bool{"distribution": true, "hello-web": true} + if got := guardOf(with); got != "" { + t.Fatalf("a port admitted from everywhere is guarded:\n%s", got) + } + with.Settings = nil + if got := guardOf(with); got != AsGuard([]int{5000}) { + t.Fatalf("the registry, from the mesh only, is not guarded:\n%s", got) + } + + // Nothing taken, nothing guarded — and no guard at all rather than an empty set. + with = anchorRendering(true) + with.Taken = nil + if got := guardOf(with); got != "" { + t.Fatalf("an untaken store is guarded:\n%s", got) + } + + // A given port is followed: where the machine put it is what is refused. + with = anchorRendering(true) + with.Given = map[string]map[int]int{"lavinmq": {5672: 5682, 15672: 15673}} + with.Ports["lavinmq"] = map[int]int{5671: 5671, 5672: 5682} + if got := guardOf(with); got != AsGuard([]int{5432, 5682, 15673}) { + t.Fatalf("the guard does not follow the given ports:\n%s", got) + } +} + +// novox/hq ADR 0038: only a published port is the mesh's to move. A module that binds the machine +// itself listens where its software was told to, so giving it a machine port is refused. +func TestAGivenPortIsRefusedForAPortNoContainerPublishes(t *testing.T) { + onTheMachine := mcat.Manifest{Module: "daemon", + Listens: []mcat.Listening{{Port: 9000, From: mcat.FromMesh}}, Guards: []int{9000}} + layers := []mcat.Layer{{From: "node anchor", + Placement: map[string]any{mcat.PortsSetting: map[string]any{"9000": float64(9100)}}}} + _, err := GivenPorts(onTheMachine, layers) + if err == nil || !strings.Contains(err.Error(), "does not publish") { + t.Fatalf("a port no container publishes was given: %v", err) + } +} + +// novox/hq ADR 0103: a guarded port this node is told to open to everyone is opened, not guarded. +// An opening from everywhere beside a guard dropping it is one statement refusing the other. +func TestAGuardedPortOpenedToEveryoneIsNotGuarded(t *testing.T) { + with := anchorRendering(true) + with.Settings["postgres"] = []mcat.Layer{{From: "node anchor", + Placement: map[string]any{mcat.ExposeSetting: map[string]any{"5432": mcat.FromEverywhere}}}} + composed, err := Compose(anAdoptedAnchor(), with) + if err != nil { + t.Fatal(err) + } + got := byID(composed.Resources) + if o := got["adoption.opening-tcp-5432-forwarded"]; o == nil || o["from"] != mcat.OpeningFromEverywhere { + t.Fatalf("the store's port is not opened to everyone: %v", o) + } + if guard, _ := got[GuardID()]["content"].(string); guard != AsGuard([]int{5672, 15672}) { + t.Fatalf("a port opened to everyone is still guarded:\n%s", guard) + } +} + +// A module that publishes its ssh port the long way — `2222:22`, because the machine's own daemon +// holds 22 — and says it listens on the machine side of that mapping, which is what anything +// reaching it dials. +func aForge() mcat.Manifest { + return mcat.Manifest{Module: "forge", + Provides: []mcat.Offer{{Name: "git-over-ssh", Scope: mcat.ScopeMesh}}, + Serves: map[string]map[string]any{"git-over-ssh": {"port": 2222}}, + Listens: []mcat.Listening{{Port: 3000, From: mcat.FromMesh}, {Port: 2222, From: mcat.FromMesh}}, + Resources: []map[string]any{{"id": "server", "type": "container", "name": "forge", + "ports": []any{"3000", "2222:22"}}}} +} + +// portsAsThePlanWould is where this machine puts each of a module's ports, derived the way +// cmd/mesh-controller/plan.go derives it: a given port first, looked up by the port the module +// says it listens on, and otherwise wherever the manifest's own mapping already put it. Written +// here because everything below — the filter, the openings, the guard, what a consumer is told — +// reads that map, and a given port that the lookup does not find moves the container's mapping +// and nothing else. +// +// It stands in for the plan only where the plan does not allocate: a port the manifest already +// placed, or one a node was given. For a short form with no given port the real plan asks the +// inventory for a machine port and may come back with one from the pool, which needs a store and +// is what cmd/mesh-controller's own tests exercise. So an assertion here about such a port asserts +// this helper, not the mesh; keep the assertions to the ports under test. +func portsAsThePlanWould(m mcat.Manifest, given map[int]int) map[int]int { + out := map[int]int{} + for _, l := range m.Listens { + if at, is := given[l.Port]; is { + out[l.Port] = at + continue + } + at, _ := m.MachineSide(l.Port) + out[l.Port] = at + } + return out +} + +// novox/hq ADR 0100 and 0038: the machine side of a long-form mapping is a port the module +// publishes, so a node may move it — and a module may name a mapping by either end. +func TestAGivenPortNamesEitherEndOfWhatTheModulePublishes(t *testing.T) { + forge := aForge() + node := func(v any) []mcat.Layer { + return []mcat.Layer{{From: "node anchor", Placement: map[string]any{mcat.PortsSetting: v}}} + } + + // The machine side — the number this module says it listens on, and the one the predecessor + // had somewhere else. Answered under 2222, the end the module itself names, which is what + // every reader of this map asks for. One entry, not two: a second key for the same answer is + // an entry another mapping's reader could find instead. + given, err := GivenPorts(forge, node(map[string]any{"2222": float64(222)})) + if err != nil { + t.Fatalf("the machine side of a mapping cannot be given a port: %v", err) + } + if want := map[int]int{2222: 222}; !reflect.DeepEqual(given, want) { + t.Fatalf("the forge was given %v; the mapping it names is filed under %v", given, want) + } + + // The container's own port names the same mapping and means the same thing — and is filed + // under the same name, because the module's name for it has not changed. + if inside, err := GivenPorts(forge, node(map[string]any{"22": float64(222)})); err != nil || + !reflect.DeepEqual(inside, map[int]int{2222: 222}) { + t.Fatalf("the container's end of the mapping was given %v: %v", inside, err) + } + + // A short form is published on the number it names, and is unchanged by any of this. + if short, err := GivenPorts(forge, node(map[string]any{"3000": float64(2999)})); err != nil || + short[3000] != 2999 { + t.Fatalf("the short form was given %v: %v", short, err) + } + + // A port no container publishes is still refused, in the same words. + if _, err := GivenPorts(forge, node(map[string]any{"9000": float64(9100)})); err == nil || + !strings.Contains(err.Error(), "does not publish") { + t.Fatalf("a port the forge does not publish was given: %v", err) + } + + // And the two ends of one mapping given two different numbers is one setting contradicting + // the other: the machine publishes it once. + if _, err := GivenPorts(forge, node(map[string]any{ + "2222": float64(222), "22": float64(300)})); err == nil || + !strings.Contains(err.Error(), "one mapping") { + t.Fatalf("the two ends of one mapping were given different ports: %v", err) + } + // Said at both ends with the same number, it is still said twice, and refused where every + // other repeated machine port is — as the inventory refuses it when the setting is stored, + // which is the layer that sees it first. + if _, err := GivenPorts(forge, node(map[string]any{ + "2222": float64(222), "22": float64(222)})); err == nil || + !strings.Contains(err.Error(), "to both its 22 and its 2222") { + t.Fatalf("one mapping given one machine port at both ends: %v", err) + } + // A number that names two different mappings names neither: which one to move is not said. + twice := aForge() + twice.Resources[0]["ports"] = []any{"22", "2222:22"} + if _, err := GivenPorts(twice, node(map[string]any{"22": float64(222)})); err == nil || + !strings.Contains(err.Error(), "twice") { + t.Fatalf("a number naming two of the module's mappings was accepted: %v", err) + } + + // And the same refusal when the number naming two mappings is not the one the setting used + // but the one the answer would be filed under. Here `80` is the module's own name for a + // mapping, and two mappings wear it; filing an answer there is one container's port standing + // where the other's is read, and both containers then publish it. + shared := mcat.Manifest{Module: "gallery", + Listens: []mcat.Listening{{Port: 80, From: mcat.FromMesh}}, + Resources: []map[string]any{ + {"id": "a", "type": "container", "name": "a", "ports": []any{"4001:80"}}, + {"id": "b", "type": "container", "name": "b", "ports": []any{"4002:80"}}}} + if _, err := GivenPorts(shared, node(map[string]any{"4001": float64(1234)})); err == nil || + !strings.Contains(err.Error(), "twice") { + t.Fatalf("two containers were put on one machine port: %v", err) + } + + // A module whose mappings chain — one's machine side is another's container port — keeps them + // apart, because each is filed under the port the module names it by and neither name is + // shared. Given both, each moves on its own and neither overwrites the other. + chained := mcat.Manifest{Module: "chain", + Listens: []mcat.Listening{{Port: 80, From: mcat.FromMesh}, {Port: 9090, From: mcat.FromMesh}}, + Resources: []map[string]any{{"id": "server", "type": "container", "name": "chain", + "ports": []any{"8080:80", "9090:8080"}}}} + both, err := GivenPorts(chained, node(map[string]any{"80": float64(1234), "9090": float64(5678)})) + if err != nil || !reflect.DeepEqual(both, map[int]int{80: 1234, 9090: 5678}) { + t.Fatalf("chained mappings were given %v: %v", both, err) + } + if moved := givenOuter("8080:80", both); moved != "1234:80" { + t.Fatalf("the first mapping moved to %q, and it was given 1234", moved) + } + if moved := givenOuter("9090:8080", both); moved != "5678:8080" { + t.Fatalf("the second mapping moved to %q, and it was given 5678", moved) + } +} + +// And the number reaches everything derived from it. The fault this is written against moved the +// container's mapping alone: the filter opened the port the software had left, the adopted node's +// opening named it too, the guard refused it, and a consumer was sent to it. +func TestAGivenMachineSideReachesTheFilterTheOpeningAndTheConsumer(t *testing.T) { + forge := aForge() + given, err := GivenPorts(forge, []mcat.Layer{{From: "node anchor", + Placement: map[string]any{mcat.PortsSetting: map[string]any{"2222": float64(222)}}}}) + if err != nil { + t.Fatalf("the machine side of a mapping cannot be given a port: %v", err) + } + r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{forge}} + with := mcat.Rendering{ + Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, given)}, + Given: map[string]map[int]int{"forge": given}, + Mesh: []string{"10.77.0.1"}, + Adopted: true, + OutwardLinks: []string{"eth0"}, + TunnelInterface: "mesh0", + Taken: map[string]bool{"forge": true}, + } + + // What the runtime is handed: the machine's own port on the outside, the container's within. + composed, err := Compose(r, with) + if err != nil { + t.Fatalf("the forge does not compose: %v", err) + } + got := byID(composed.Resources) + if ports := got["forge.server"]["ports"]; !reflect.DeepEqual(ports, []any{"3000:3000", "222:22"}) { + t.Fatalf("the forge's container publishes %v", ports) + } + + // What the filter would open, were the node converged. + rules, err := Rules(r, with) + if err != nil { + t.Fatal(err) + } + var opened []int + for _, rule := range rules { + opened = append(opened, rule.Port) + } + // Only the moved port is asserted: the forge's other port is a short form the real plan would + // allocate rather than read off the manifest, so its number here is portsAsThePlanWould's and + // not the mesh's. + if !slices.Contains(opened, 222) || slices.Contains(opened, 2222) { + t.Fatalf("the filter opens %v, not where the machine puts the forge", opened) + } + + // And what it is declared instead, adopted: an opening on the machine's port, naming the + // container's port on the forwarded path — a published port is forwarded, never received. + opening := got[OpeningID("tcp", 222, mcat.PathForwarded)] + if opening == nil || opening["to"] != 22 || opening["from"] != mcat.OpeningFromMesh { + t.Fatalf("no opening for the port this node gave the forge: %v", keys(got)) + } + for id := range got { + if strings.HasPrefix(id, "adoption.opening-tcp-2222-") { + t.Errorf("an opening for the port the forge was moved off: %s", id) + } + } + + // The guard refuses it where the machine put it, and nothing where it used to be. + guard, _ := got[GuardID()]["content"].(string) + if !strings.Contains(guard, "222") || strings.Contains(guard, "2222") { + t.Fatalf("the guard does not follow the given port:\n%s", guard) + } + + // And a consumer is sent to the same number, which is read from what the module serves. + if told := ServedOn(forge, "git-over-ssh", with.Ports["forge"])["port"]; told != 222 { + t.Fatalf("a consumer is told the forge answers on %v", told) + } +} + +// And the mapping itself moves under either name, because Rendering.Given is a map anybody +// composing a declaration hands in: keyed by the machine side, which is what a module declaring +// 2222 calls its port, only the outside moves and the container's own port stays as written. +func TestAMappingIsMovedUnderEitherOfItsNames(t *testing.T) { + for _, c := range []struct { + written string + given map[int]int + want string + }{ + {"2222:22", map[int]int{2222: 222}, "222:22"}, + {"2222:22", map[int]int{22: 222}, "222:22"}, + {"127.0.0.1:15672:15672/tcp", map[int]int{15672: 15673}, "127.0.0.1:15673:15672/tcp"}, + {"2222:22", map[int]int{3000: 2999}, "2222:22"}, + {"2222:22", nil, "2222:22"}, + } { + if got := givenOuter(c.written, c.given); got != c.want { + t.Errorf("%s given %v is published as %s, want %s", c.written, c.given, got, c.want) + } + } +} + +// The same on a node that was given nothing, which is where the derivation was never at fault: +// a long-form mapping is published where the manifest put it, and an adopted node opens that port +// on the forwarded path like any other. What broke it was the number reaching only the mapping. +func TestALongFormPortIsOpenedWhereTheManifestPublishesIt(t *testing.T) { + forge := aForge() + r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{forge}} + composed, err := Compose(r, mcat.Rendering{ + Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, nil)}, + Mesh: []string{"10.77.0.1"}, + Adopted: true, + OutwardLinks: []string{"eth0"}, + TunnelInterface: "mesh0", + }) + if err != nil { + t.Fatal(err) + } + got := byID(composed.Resources) + opening := got[OpeningID("tcp", 2222, mcat.PathForwarded)] + if opening == nil || opening["to"] != 22 { + t.Fatalf("no opening for the forge's published ssh port: %v", keys(got)) + } +} diff --git a/internal/compose/resolution/agent_account_test.go b/internal/compose/resolution/agent_account_test.go new file mode 100644 index 0000000..a54e628 --- /dev/null +++ b/internal/compose/resolution/agent_account_test.go @@ -0,0 +1,83 @@ +package resolution + +import ( + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +func TestTheAgentAccountFactFallsBackToTheOperatorAndIsNeverRootOnlyWhenItsOwn(t *testing.T) { + facts := machineFacts(mcat.Resolution{Node: "anchor", Account: "ops"}, nil, "") + if facts["agent-account"] != "ops" || facts["agent-home"] != "/home/ops" || facts["agent-root"] != "" { + t.Errorf("with no agent account named, agents run as the operator: %v", facts) + } + facts = machineFacts(mcat.Resolution{Node: "anchor", Account: "ops", AccountHome: "/srv/ops"}, nil, "") + if facts["agent-home"] != "/srv/ops" { + t.Errorf("the operator's stated home is the agent's home when they are one account: %v", facts) + } + facts = machineFacts(mcat.Resolution{Node: "anchor", Account: "ops", AgentAccount: "agent"}, nil, "") + if facts["agent-account"] != "agent" || facts["agent-home"] != "/home/agent" || facts["agent-root"] != mcat.RootNever { + t.Errorf("a named agent account is the agents', never root: %v", facts) + } + if facts["account"] != "ops" { + t.Errorf("the operator account is still the operator's: %v", facts) + } + facts = machineFacts(mcat.Resolution{Node: "anchor", AgentAccount: "agent", AgentAccountHome: "/var/lib/agent"}, nil, "") + if facts["agent-home"] != "/var/lib/agent" || facts["agent-root"] != mcat.RootNever { + t.Errorf("an agent account with a stated home on a machine with no operator: %v", facts) + } + if _, has := machineFacts(mcat.Resolution{Node: "anchor"}, nil, "")["agent-account"]; has { + t.Error("a machine with no account at all names an agent account") + } +} + +// The agent's module, in the shape the catalogue's declares it: the account, never root where it is its +// own; its directory under that home, owned by it. +const agentModule = `{"module": "agent", "version": "1", "resources": [ + {"id": "account", "type": "user", "name": "${machine:agent-account}", "home": "${machine:agent-home}", + "root": "${machine:agent-root}"}, + {"id": "home", "type": "directory", "path": "${machine:agent-home}/.agent", "mode": "0700", + "owner": "${machine:agent-account}"} +]}` + +func TestTheAgentAccountIsDeclaredNeverRootOnlyToAnEngineThatJudgesIt(t *testing.T) { + m, err := decodeForTest([]byte(agentModule)) + if err != nil { + t.Fatal(err) + } + compose := func(r mcat.Resolution, with mcat.Rendering) (user, home map[string]any) { + t.Helper() + r.Node, r.Modules = "anchor", []mcat.Manifest{m} + out, err := Declaration(r, with) + if err != nil { + t.Fatal(err) + } + return fileNamed(out, "agent.account"), fileNamed(out, "agent.home") + } + + user, home := compose(mcat.Resolution{Account: "ops", AgentAccount: "agent"}, mcat.Rendering{JudgesRoot: true}) + if user["name"] != "agent" || user[mcat.RootField] != mcat.RootNever || user["home"] != "/home/agent" { + t.Errorf("an engine that judges root is sent the agent account never to become root: %v", user) + } + if home["path"] != "/home/agent/.agent" || home["owner"] != "agent" { + t.Errorf("the agent's directory is under its own home, its own: %v", home) + } + + user, _ = compose(mcat.Resolution{Account: "ops", AgentAccount: "agent", AgentAccountHome: "/srv/agent"}, mcat.Rendering{JudgesRoot: true}) + if user["home"] != "/srv/agent" { + t.Errorf("an agent account named with a home of its own is made there: %v", user) + } + + user, _ = compose(mcat.Resolution{Account: "ops", AgentAccount: "agent"}, mcat.Rendering{}) + if _, sent := user[mcat.RootField]; sent || user["name"] != "agent" { + t.Errorf("an older engine, which parses strictly, is sent root: %v", user) + } + + user, home = compose(mcat.Resolution{Account: "ops"}, mcat.Rendering{JudgesRoot: true}) + if _, sent := user[mcat.RootField]; sent || user["name"] != "ops" { + t.Errorf("where agents run as the operator, root asserts nothing and is not sent: %v", user) + } + if home["path"] != "/home/ops/.agent" || home["owner"] != "ops" { + t.Errorf("with no agent account, the agent's directory is the operator's: %v", home) + } +} diff --git a/internal/compose/resolution/artifact_store_seat_test.go b/internal/compose/resolution/artifact_store_seat_test.go new file mode 100644 index 0000000..ebe2ce2 --- /dev/null +++ b/internal/compose/resolution/artifact_store_seat_test.go @@ -0,0 +1,40 @@ +package resolution + +import ( + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// The artifact store's seat is one per mesh, read from the catalogue beside this checkout. +// +// **A second store anywhere is refused by name, not discovered as a consumer failure.** The seat +// was node-scoped, so a second `distribution` on another machine resolved cleanly there — and a +// node-scoped requirement with one candidate installs that candidate on the node, so anything that +// required the store's presence beside it would have raised a fresh, empty store on the wrong +// machine. Only afterwards did the mesh notice: `artifact-store` offered by two nodes, and every +// consumer elsewhere refusing to choose. The claim says it first, where the second store is +// assigned. +func TestASecondArtifactStoreAnywhereIsRefusedByName(t *testing.T) { + store := catalogueManifest(t, "distribution") + + // The first store resolves as it always has. + if _, err := Resolve(shelf(store), []string{"distribution"}, workstation(), mcat.World{}); err != nil { + t.Fatalf("the store alone does not resolve: %v", err) + } + + // A second one, on any other machine, is refused — and the refusal names the seat. + elsewhere := mcat.World{Held: []mcat.Held{{Claim: "mesh-artifact-store", Scope: mcat.ScopeMesh, + Node: "anchor", Module: "distribution"}}} + other := workstation() + other.Name = "laptop" + _, err := Resolve(shelf(store), []string{"distribution"}, other, elsewhere) + if err == nil { + t.Fatal("a second store was accepted on another machine; it would offer artifact-store a " + + "second time and every consumer elsewhere would refuse to choose") + } + if !strings.Contains(err.Error(), "mesh-artifact-store") || !strings.Contains(err.Error(), "one per mesh") { + t.Fatalf("refused without naming the seat: %v", err) + } +} diff --git a/internal/compose/resolution/artifact_sweep_seat.go b/internal/compose/resolution/artifact_sweep_seat.go new file mode 100644 index 0000000..98888e7 --- /dev/null +++ b/internal/compose/resolution/artifact_sweep_seat.go @@ -0,0 +1,43 @@ +package resolution + +import ( + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// artifactSweepVerbs are the seat's verbs, with the controller's arguments so a caller changes only the seat it asks. +func artifactSweepVerbs() []mcat.Verb { + return []mcat.Verb{ + {Name: "artifacts", Description: "Every artifact the mesh made that lives in its artifact store, each with its " + + "repository, digest, kind (image or archive) and state: kept, and why (a want names it, or one of the five " + + "newest builds of its module in the builder's log), eligible (made, kept for no reason, not yet let go), or " + + "unrecorded (in the store and named by nothing the mesh made: never removed). Reads only.", + Input: schema(map[string]string{ + "repository": "only this repository's references, as / (the counts stay whole)", + "collected": "\"true\": list the collected references too (counted always)", + }, nil, "collected")}, + {Name: "held", Description: "Whether the artifact store holds each reference given, asked with one HEAD each: " + + "held, gone (the store answered 404) or unknown with why. The builder asks it before it reuses a done key. " + + "Reads only.", + Input: listed(schema(map[string]string{ + "references": "the artifacts' references, artifact-store:////blobs/sha256:: a list, " + + "or one text separated by commas", + }, []string{"references"}), "references")}, + {Name: "collect", Description: "The artifact store's sweep, now: what is eligible is let go of. Without confirm a " + + "dry run: what would be let go, nothing held or deleted. With confirm and why: every kept archive held first, " + + "then each eligible artifact let go of, oldest first, an index with the platform manifests no kept index " + + "names, each deletion recorded in the hand-act log. Bounded by most (500 by default, at most 5000) and 45 " + + "seconds; what is left is said. Never touches a digest the mesh did not make.", + Input: schema(map[string]string{ + "confirm": "\"true\": let go of what is eligible (needs why); without it nothing is done", + "why": "with confirm: why, required and recorded", + "most": "let go of at most this many (500 by default, at most 5000)", + }, nil, "confirm")}, + {Name: "mirrors", Description: "The bases the mesh copied into its artifact store, each kept (a kept build stood on " + + "it), eligible (only builds no longer kept stood on it) or unrecorded (no build the builder's log holds stood on " + + "it: never removed), by what the builder's log says each build stood on. Records nothing by hand.", + Input: schema(map[string]string{}, nil)}, + {Name: "images", Description: "Every container image one machine's want and have name, each with the resources " + + "naming it: what the machine keeps when it prunes its images. Reads only.", + Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})}, + } +} diff --git a/internal/compose/resolution/artifacts.go b/internal/compose/resolution/artifacts.go new file mode 100644 index 0000000..559ac1b --- /dev/null +++ b/internal/compose/resolution/artifacts.go @@ -0,0 +1,65 @@ +package resolution + +import ( + "fmt" + "strings" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// artifactsInto composes the artifact store's address into a resource's `image` and `source`. +// +// **Composed here, at the last moment before a machine, and stored nowhere.** A kept reference is +// routed through the store as this network reaches it now. A reference recorded with an address +// before references were kept without one is re-routed the same way — but only when the mesh +// built it (`with.Built` names every `/` it has), because a module may run an +// image from a public registry under its own name and that one is exactly where it says. +// +// A kept reference with no store to route it through is refused: sent as it is, the runtime would +// refuse the scheme on the machine, one push away from the reason. +// +// **What this does not reach: the images genesis pinned.** The installer builds the control plane +// and the builder before the mesh exists, pushes them itself and pins their manifests to +// `:/mesh-controller@…` and `:/mesh-builder@…` — single-segment +// repositories with no build record, so `with.Built` does not name them and they are left as +// written until each is rebuilt through the mesh, which records it by digest and path. Until then +// a registry that moves strands exactly those two on a recreate, and the control plane's is the +// one that cannot be repaired through the mesh. Rebuild both through `build` before moving the +// store (novox/hq 04-ISSUES/102, finding F4). +func artifactsInto(resource map[string]any, module string, with mcat.Rendering) error { + for _, key := range []string{"image", "source"} { + written, ok := resource[key].(string) + if !ok { + continue + } + if _, kept := mcat.InArtifactStore(written); kept { + if with.ArtifactStore == "" { + return fmt.Errorf( + "%s's %v names %s, which is in the mesh's artifact store, and this mesh has no "+ + "artifact store on its network to fetch it from — nothing assigned offers "+ + "%s, or the machine offering it is not on the private network", + module, resource["id"], written, mcat.ArtifactStoreProvision) + } + resource[key] = mcat.Routed(written, with.ArtifactStore) + continue + } + if with.ArtifactStore == "" { + continue + } + recorded := mcat.Recorded(written) + if recorded == written { + continue + } + path, _ := mcat.InArtifactStore(recorded) + repository := path + if at := strings.IndexAny(path, "@"); at >= 0 { + repository = path[:at] + } else if blobs := strings.Index(path, "/blobs/"); blobs >= 0 { + repository = path[:blobs] + } + if with.Built[repository] { + resource[key] = mcat.Routed(recorded, with.ArtifactStore) + } + } + return nil +} diff --git a/internal/compose/resolution/artifacts_test.go b/internal/compose/resolution/artifacts_test.go new file mode 100644 index 0000000..e9ab076 --- /dev/null +++ b/internal/compose/resolution/artifacts_test.go @@ -0,0 +1,102 @@ +package resolution + +import ( + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +var digest = "sha256:" + strings.Repeat("d", 64) + +// **The address is composed into a declaration, and the record never carries it.** +func TestAnImageTheMeshBuiltIsRoutedThroughTheStoreWhenDeclared(t *testing.T) { + m := mcat.Manifest{Module: "gitea", Version: "1", Resources: []map[string]any{ + {"id": "server", "type": "container", "name": "mesh-gitea", "artifact": "server"}, + {"id": "config", "type": "archive", "path": "/etc/gitea", "artifact": "config"}, + {"id": "cache", "type": "container", "name": "mesh-gitea-cache", "image": "valkey/valkey@" + digest}, + }, Build: &mcat.Build{Artifacts: []mcat.Artifact{ + {Name: "server", Kind: mcat.ArtifactImage, From: "Dockerfile"}, + {Name: "config", Kind: mcat.ArtifactArchive, From: "config"}, + }}} + resolved, err := m.Resolve([]mcat.Built{ + {Name: "server", Kind: mcat.ArtifactImage, Reference: mcat.ArtifactStoreScheme + "gitea/server@" + digest}, + {Name: "config", Kind: mcat.ArtifactArchive, Reference: mcat.ArtifactStoreScheme + "gitea/config/blobs/" + digest, Digest: digest}, + }) + if err != nil { + t.Fatal(err) + } + r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{resolved}} + + out, err := Declaration(r, mcat.Rendering{ArtifactStore: "anchor.internal:5101"}) + if err != nil { + t.Fatal(err) + } + if got := fileNamed(out, "gitea.server")["image"]; got != "anchor.internal:5101/gitea/server@"+digest { + t.Errorf("the image the mesh built is fetched as %v", got) + } + if got := fileNamed(out, "gitea.config")["source"]; got != "http://anchor.internal:5101/v2/gitea/config/blobs/"+digest { + t.Errorf("the archive the mesh built is fetched from %v", got) + } + if got := fileNamed(out, "gitea.cache")["image"]; got != "valkey/valkey@"+digest { + t.Errorf("an image from a public registry was routed through the store: %v", got) + } + // The manifest the mesh holds still says what it is, not where it was fetched from. + if got := resolved.Resources[0]["image"]; got != mcat.ArtifactStoreScheme+"gitea/server@"+digest { + t.Errorf("composing wrote the address into the catalogue's copy: %v", got) + } + + // And the store moves: the same record, another address, without a rebuild. + out, err = Declaration(r, mcat.Rendering{ArtifactStore: "laptop.internal:5000"}) + if err != nil { + t.Fatal(err) + } + if got := fileNamed(out, "gitea.server")["image"]; got != "laptop.internal:5000/gitea/server@"+digest { + t.Errorf("after the store moved, the image is still fetched as %v", got) + } +} + +func TestAnImageInTheStoreWithNoStoreToFetchItFromIsRefused(t *testing.T) { + m := mcat.Manifest{Module: "gitea", Version: "1", Resources: []map[string]any{ + {"id": "server", "type": "container", "name": "mesh-gitea", + "image": mcat.ArtifactStoreScheme + "gitea/server@" + digest}, + }} + _, err := Declaration(mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{m}}, mcat.Rendering{}) + if err == nil || !strings.Contains(err.Error(), "no artifact store") { + t.Fatalf("a reference nothing can fetch was sent to a machine: %v", err) + } +} + +// **A reference recorded with an address before this follows the store too** — when the mesh +// built it. A module running an image straight from a public registry under its own name is left +// exactly where it says: `quay.io/keycloak/keycloak` is not the mesh's, whatever it is called. +func TestAReferenceRecordedWithAnAddressFollowsTheStoreWhenTheMeshBuiltIt(t *testing.T) { + m := mcat.Manifest{Module: "keycloak", Version: "1", Resources: []map[string]any{ + {"id": "server", "type": "container", "name": "mesh-keycloak", + "image": "anchor.internal:5100/keycloak/server@" + digest}, + {"id": "upstream", "type": "container", "name": "mesh-keycloak-upstream", + "image": "quay.io/keycloak/keycloak@" + digest}, + }} + r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{m}} + out, err := Declaration(r, mcat.Rendering{ + ArtifactStore: "anchor.internal:5101", + Built: map[string]bool{"keycloak/server": true}, + }) + if err != nil { + t.Fatal(err) + } + if got := fileNamed(out, "keycloak.server")["image"]; got != "anchor.internal:5101/keycloak/server@"+digest { + t.Errorf("an image the mesh built, recorded with the old address, is fetched as %v", got) + } + if got := fileNamed(out, "keycloak.upstream")["image"]; got != "quay.io/keycloak/keycloak@"+digest { + t.Errorf("a public image was re-routed through the store: %v", got) + } + // Nothing known to be built: nothing re-routed, nothing refused. + out, err = Declaration(r, mcat.Rendering{ArtifactStore: "anchor.internal:5101"}) + if err != nil { + t.Fatal(err) + } + if got := fileNamed(out, "keycloak.server")["image"]; got != "anchor.internal:5100/keycloak/server@"+digest { + t.Errorf("with no build record, a reference was rewritten: %v", got) + } +} diff --git a/internal/compose/resolution/backup_placement.go b/internal/compose/resolution/backup_placement.go new file mode 100644 index 0000000..46156a9 --- /dev/null +++ b/internal/compose/resolution/backup_placement.go @@ -0,0 +1,46 @@ +package resolution + +import ( + "fmt" + "regexp" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// ofBackup is where a holder places them: ${backup:}. Loose inside the braces, so a misspelt key is found and +// refused rather than written out as text. +var ofBackup = regexp.MustCompile(`\$\{backup:([^}]*)\}`) + +// backupKinds is the contribution kind each key of the backup placement renders. +var backupKinds = map[string]string{"lines": mcat.BackupKindBackup, "data": mcat.BackupKindData} + +// backupPlacer is the machine's modules the backup placement is composed over, as the old contributions are. +type backupPlacer struct { + modules []mcat.Manifest + with mcat.Rendering + facts map[string]string + caps map[string]bool + unplaced *[]string +} + +// placed is the text of one key for a holder, or what is wrong with naming it. +func (b backupPlacer) placed(holder mcat.Manifest, key string, item bool) (string, string) { + kind, known := backupKinds[key] + switch { + case item: + return "", "an item is kept per item; the machine's backup lines are placed only in a file" + case !known: + return "", fmt.Sprintf("the backup placement has `lines` and `data`, not %q", key) + case !holder.ClaimsSeat(mcat.BackupSeat): + return "", fmt.Sprintf("%s does not claim %s; the machine's backup lines are placed by its holder alone "+ + "(novox/hq ADR 0212)", holder.Module, mcat.BackupSeat) + } + text, left, err := seatContributions(b.modules, holder, mcat.BackupSeat+":"+kind, b.with, b.facts, b.caps) + if b.unplaced != nil { + *b.unplaced = append(*b.unplaced, left...) + } + if err != nil { + return "", err.Error() + } + return text, "" +} diff --git a/internal/compose/resolution/backup_placement_test.go b/internal/compose/resolution/backup_placement_test.go new file mode 100644 index 0000000..9035fc1 --- /dev/null +++ b/internal/compose/resolution/backup_placement_test.go @@ -0,0 +1,114 @@ +package resolution + +import ( + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +const oldBackupText = "# lines\n${contribution:node-backup:backup}" + +const oldDataText = "# data\n${contribution:node-backup:data}" + +const placedBackupTxt = "# lines\n${backup:lines}" + +const placedDataTxt = "# data\n${backup:data}" + +const backupHolder = `{"module":"restic","version":"1","moved":true,"claims":[{"name":"node-backup","scope":"node"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[{"id":"declared","type":"file","path":"/etc/backups.conf","template":"files/backups"}, + {"id":"data-declared","type":"file","path":"/etc/data.conf","template":"files/data"}]}` + +func dataModule(name string) string { + return `{"module":"` + name + `","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"}], + "data":{"own":[{"id":"d","path":"${dir:d}","class":"irreplaceable","why":"the ` + name + ` data"}]}}` +} + +// backupsOn is a machine of a holder whose files say these texts and the modules that declare data. +func backupsOn(t *testing.T, lines, data string, modules ...string) mcat.Resolution { + t.Helper() + all := []mcat.Manifest{held(t, backupHolder, builtWith("restic", map[string]string{"backups": lines, "data": data}))} + names := []string{"restic"} + for _, name := range modules { + all = append(all, mustParse(t, dataModule(name))) + names = append(names, name) + } + r, err := Resolve(shelf(all...), names, workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + r.Account = "op" + return r +} + +var backupModules = []string{"alpha", "beta", "gamma"} + +// gammaLeftOut is a machine whose gamma does not place: its lines are kept, best effort. +func gammaLeftOut(with mcat.Rendering) mcat.Rendering { + with.NotPlaced = map[string]string{"gamma": "its placement setting does not read"} + return with +} + +func TestTheBackupPlacementIsTheTextTheOldPlaceholdersRender(t *testing.T) { + // The old form is an unmoved holder: its placeholders are the controller's alone. + oldHolder := strings.Replace(backupHolder, `"moved":true,`, "", 1) + all := []mcat.Manifest{held(t, oldHolder, builtWith("restic", map[string]string{"backups": oldBackupText, "data": oldDataText}))} + for _, name := range backupModules { + all = append(all, mustParse(t, dataModule(name))) + } + old, err := Resolve(shelf(all...), append([]string{"restic"}, backupModules...), workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + old.Account = "op" + wantLines, wantData := fileOf(t, old, gammaLeftOut(mcat.Rendering{}), "restic.declared"), fileOf(t, old, gammaLeftOut(mcat.Rendering{}), "restic.data-declared") + if !strings.Contains(wantLines, "# alpha\npath ") || !strings.Contains(wantLines, "# gamma\n") || !strings.Contains(wantData, "item d") { + t.Fatalf("the old form renders\n%s\n%s", wantLines, wantData) + } + moved := backupsOn(t, placedBackupTxt, placedDataTxt, backupModules...) + // Not handed: the controller fills the new placeholders with the old text. + if got := fileOf(t, moved, gammaLeftOut(mcat.Rendering{}), "restic.declared"); got != wantLines { + t.Errorf("not handed, the lines are\n%s\nwant\n%s", got, wantLines) + } + if got := fileOf(t, moved, gammaLeftOut(mcat.Rendering{}), "restic.data-declared"); got != wantData { + t.Errorf("not handed, the data is\n%s\nwant\n%s", got, wantData) + } + // Handed: the plan carries the same text as placement, and the files are the client's. + c, _ := composeWith(t, moved, gammaLeftOut(handingTo("store:5000"))) + p := c.Plans["restic"].Placement + if got := "# lines\n" + p["backup:lines"]; got != wantLines { + t.Errorf("handed, the plan's lines are\n%s\nwant\n%s", got, wantLines) + } + if got := "# data\n" + p["backup:data"]; got != wantData { + t.Errorf("handed, the plan's data is\n%s\nwant\n%s", got, wantData) + } + if len(p) != 2 { + t.Errorf("the plan's placement is %v", p) + } +} + +func TestAnUnknownBackupKeyIsRefused(t *testing.T) { + r := backupsOn(t, "${backup:rows}", placedDataTxt, backupModules...) + if _, err := Compose(r, handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "backup:rows") { + t.Errorf("handed, an unknown key is %v", err) + } + if _, err := Declaration(r, mcat.Rendering{}); err == nil || !strings.Contains(err.Error(), "backup:rows") { + t.Errorf("not handed, an unknown key is %v", err) + } +} + +func fileOf(t *testing.T, r mcat.Resolution, with mcat.Rendering, id string) string { + t.Helper() + out, err := Declaration(r, with) + if err != nil { + t.Fatal(err) + } + for _, res := range out { + if res["id"] == id { + return res["content"].(string) + } + } + t.Fatalf("%s was not composed", id) + return "" +} diff --git a/internal/compose/resolution/backup_test.go b/internal/compose/resolution/backup_test.go new file mode 100644 index 0000000..6583285 --- /dev/null +++ b/internal/compose/resolution/backup_test.go @@ -0,0 +1,37 @@ +package resolution + +import ( + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// A module on the shelf from before ADR 0233, which writes its backup lines by hand and declares no +// data, is still backed up: its lines are placed as written, each module's under a comment naming it. +func TestHandWrittenBackupLinesOfAnOlderModuleArePlaced(t *testing.T) { + pg, err := decodeForTest([]byte(`{"module":"pg","version":"1", + "resources":[{"id":"dumps","type":"directory","path":"/srv/pg/dumps","mode":"0700"}], + "contributions":[{"seat":"node-backup","kind":"backup","content":"run pg-dump-all\npath ${dir:dumps}"}]}`)) + if err != nil { + t.Fatal(err) + } + mail, err := decodeForTest([]byte(`{"module":"mail","version":"1", + "resources":[{"id":"spool","type":"directory","mode":"0700"}], + "contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:spool}"}]}`)) + if err != nil { + t.Fatal(err) + } + placed, _, err := seatContributions([]mcat.Manifest{pg, mail}, mcat.Manifest{}, mcat.BackupSeat+":"+"backup", mcat.Rendering{}, nil, nil) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"# pg\nrun pg-dump-all\npath /srv/pg/dumps\n", "# mail\npath /var/lib/mail/spool\n"} { + if !strings.Contains(placed, want) { + t.Errorf("placed contributions lack %q:\n%s", want, placed) + } + } + if strings.Contains(placed, "${dir:") { + t.Errorf("a directory reached the holder unfilled:\n%s", placed) + } +} diff --git a/internal/compose/resolution/bound.go b/internal/compose/resolution/bound.go new file mode 100644 index 0000000..bf7b620 --- /dev/null +++ b/internal/compose/resolution/bound.go @@ -0,0 +1,117 @@ +package resolution + +import ( + "fmt" + "slices" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// keepBound holds every need for a provision that keeps its consumers' data at the provider its +// consumer was bound to, where the resolution chose another. +// +// A need with no record is a binding being made, and is left as resolved: it is recorded when it is +// first sent. A pin naming the provider the resolution chose is a person moving it, and is left too. +// Otherwise the recorded provider answers, if it still provides the provision — beside the consumer, +// or offered from elsewhere — and the move is returned as kept. **One that no longer does is refused, +// never answered by the provider chosen**: answering it there is exactly the silent move this exists +// to stop, and the consumer's data is still wherever it was. +func keepBound(needs []mcat.Needed, catalogue map[string]mcat.Manifest, node mcat.Node, world mcat.World, + keeps map[string]bool, here func(string) bool) ([]mcat.Needed, []mcat.KeptBinding, []string) { + var kept []mcat.KeptBinding + var problems []string + refused := map[[2]string]bool{} + out := make([]mcat.Needed, 0, len(needs)) + for _, n := range needs { + if n.ByRecord || !keeps[n.Name] { + out = append(out, n) + continue + } + n.KeepsData = true + bound, recorded := world.Bound[n.For][n.Name] + chose := mcat.Chosen{Node: n.From, Module: n.Module} + if !recorded || sameProvider(bound, chose) { + out = append(out, n) + continue + } + if pin, pinned := world.Pinned[n.Name]; pinned && chosenMatches(pin, mcat.Provider{Node: chose.Node, Module: chose.Module}) { + out = append(out, n) + continue + } + held, ok, why := boundNeed(n, bound, catalogue, node, world, here) + if !ok { + if key := [2]string{n.For, n.Name}; !refused[key] { + refused[key] = true + problems = append(problems, fmt.Sprintf( + "%s on %s is bound to %s for %q, which keeps its data, and %s — it would move to %s, "+ + "which holds none of it. Move its data and say so with `pin %s %s %s %s`, or give "+ + "%s back what it provided", + n.For, node.Name, bound, n.Name, why, chose, node.Name, n.Name, chose.Node, chose.Module, + bound.Node)) + } + continue + } + out = append(out, held) + kept = append(kept, mcat.KeptBinding{Machine: node.Name, Consumer: n.For, Provision: n.Name, Bound: bound, Would: chose}) + } + return out, kept, problems +} + +// sameProvider is whether a recorded provider is the one chosen. A record naming no module (none +// is written without one, but a person's hand might) matches any module on its node. +func sameProvider(bound, chose mcat.Chosen) bool { + return bound.Node == chose.Node && (bound.Module == "" || bound.Module == chose.Module) +} + +// boundNeed is the need answered by the recorded provider, or why it cannot be. +func boundNeed(n mcat.Needed, bound mcat.Chosen, catalogue map[string]mcat.Manifest, node mcat.Node, world mcat.World, + here func(string) bool) (mcat.Needed, bool, string) { + held := mcat.Needed{Name: n.Name, For: n.For, Local: n.Local, KeepsData: true} + if bound.Node == node.Name { + m, known := catalogue[bound.Module] + if !known || !here(bound.Module) || !providesAt(m, n.Name, mcat.ScopeMesh) { + return mcat.Needed{}, false, fmt.Sprintf("%s no longer runs on %s", bound.Module, node.Name) + } + at := node.At + if at == "" { + at = "127.0.0.1" + } + held.From, held.At, held.Module = node.Name, at, m.Module + held.Serves, held.SharedOwn, held.Identity = servedByOne(m, n.Name), sharedByOne(m, n.Name), m.IdentityBoundOf(n.Name) + held.CredentialForm, held.CredentialTool = m.CredentialFormOf(n.Name), m.CredentialToolOf(n.Name) + return held, true, "" + } + matching := chosenAmong(bound, world.Offered[n.Name]) + if len(matching) != 1 { + return mcat.Needed{}, false, fmt.Sprintf("%s no longer provides it", bound) + } + p := matching[0] + if node.At == "" || p.At == "" { + return mcat.Needed{}, false, fmt.Sprintf("%s and %s are not both on the private network", node.Name, p.Node) + } + identity := mcat.DefaultIdentityBound + if pm, known := catalogue[p.Module]; known { + held.SharedOwn, _ = pm.SharedCredentialOf(n.Name) + held.CredentialForm, held.CredentialTool = pm.CredentialFormOf(n.Name), pm.CredentialToolOf(n.Name) + identity = pm.IdentityBoundOf(n.Name) + } + held.From, held.At, held.Module, held.Serves, held.Identity = p.Node, p.At, p.Module, p.Serves, identity + held.LeftOut = p.LeftOut + return held, true, "" +} + +// BindsFrom is the providers this resolution binds a pair credential's need to — the provision, the +// module that requires it and the credential's local name — answered by a machine rather than by a +// record. None means this machine states no such binding. +func BindsFrom(r mcat.Resolution, provision, module, local string) []string { + var from []string + for _, n := range r.Needs { + if n.ByRecord || n.Name != provision || n.For != module || n.Local != local { + continue + } + if !slices.Contains(from, n.From) { + from = append(from, n.From) + } + } + return from +} diff --git a/internal/compose/resolution/bound_into_files.go b/internal/compose/resolution/bound_into_files.go new file mode 100644 index 0000000..d27401e --- /dev/null +++ b/internal/compose/resolution/bound_into_files.go @@ -0,0 +1,176 @@ +package resolution + +import ( + "fmt" + "regexp" + "sort" + "strings" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// bound is where a module says a value from one of its bindings belongs: +// ${bound::}, and for an optional requirement the value it takes while absent, +// ${bound::|default:} (novox/hq ADR 0330 §4). A default is never empty: an +// absent requirement is never rendered as an empty string. +var bound = regexp.MustCompile(`\$\{bound:([a-z0-9][a-z0-9.-]*[a-z0-9]):([a-z0-9][a-z0-9_-]*)(?:\|default:([^}\n]+))?\}`) + +// knownFor is everything a module may name from one of its bindings. +// +// Three facts the mesh states about any provision, plus whatever the provider said it serves. A +// module may not reach a binding it does not have — the same boundary as a secret, for the same +// reason. +func knownFor(m mcat.Manifest, needs []mcat.Needed, node string) (map[string]map[string]string, error) { + out := map[string]map[string]string{} + for _, want := range m.Wants() { + for i := range needs { + n := needs[i] + if n.Name != want || n.For != m.Module { + continue + } + as := mcat.ConsumerIdentity(node, mcat.IdentitySource(m.Slug, m.Module)) + values := map[string]string{ + "at": n.At, + "from": n.From, + "as": as, + } + // What the provider derives for this consumer rather than for all of them + // (novox/hq ADR 0202). Filled here, the one place a provision and the module + // requiring it are both in hand. + served, err := ServedTo(n.Serves, as) + if err != nil { + return nil, fmt.Errorf("%s requires %s: %w", m.Module, want, err) + } + for key, value := range served { + // The provider's own vocabulary. Rendered plainly: a port is 5432, not 5432.000000, + // which is what a float would write and what a connection string would refuse. + values[key] = plainly(value) + } + out[want] = values + } + } + return out, nil +} + +// withOwnNames adds a module's own composed names to what it may name from one binding: +// `${bound::name}` and `:internal-name`, and for several contributions to one requirement +// `:name-` / `:internal-name-`. Set over anything the provider serves under those keys: +// what the module is called is the mesh's statement, not the provider's. +func withOwnNames(values map[string]string, own map[string]any) { + for _, key := range []string{"name", "internal-name"} { + if v, ok := own[key].(string); ok { + values[key] = v + } + } + many, _ := own["names"].(map[string]any) + for local, raw := range many { + names, _ := raw.(map[string]any) + for _, key := range []string{"name", "internal-name"} { + if v, ok := names[key].(string); ok { + values[key+"-"+local] = v + } + } + } +} + +// plainly renders a served value as a program would expect to read it. +func plainly(value any) string { + switch v := value.(type) { + case string: + return v + case float64: + if v == float64(int64(v)) { + return fmt.Sprintf("%d", int64(v)) + } + return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%f", v), "0"), ".") + case bool: + return fmt.Sprintf("%t", v) + case nil: + return "" + default: + return fmt.Sprint(v) + } +} + +// boundInto replaces a file's ${bound:…} placeholders with what the mesh knows. +// +// A placeholder naming something the module does not require, or a key the provider does not +// serve, is refused. Left as it was, the literal `${bound:x:y}` would be written into a +// configuration file and read as a value — a connection to a host called `${bound:x:y}`, failing +// somewhere that names neither the module nor the mesh. +// +// **An optional requirement left absent takes the default its placeholder names** (novox/hq ADR +// 0330 §4), and one that names none is refused rather than rendered empty. The module check refuses +// that already; this is the same rule where the file is made, for a manifest registered before it. +func boundInto(resource map[string]any, known map[string]map[string]string, module string, absent map[string]bool) error { + if fmt.Sprint(resource["type"]) != "file" { + return nil + } + content, ok := resource["content"].(string) + if !ok { + return nil + } + var refused error + filled := bound.ReplaceAllStringFunc(content, func(token string) string { + if refused != nil { + return token + } + m := bound.FindStringSubmatch(token) + provision, key, fallback := m[1], m[2], m[3] + values, has := known[provision] + if !has { + if absent[provision] { + if fallback != "" { + return fallback + } + refused = fmt.Errorf( + "%s has a file that says ${bound:%s:%s}, and %q is optional and absent here: it names no "+ + "value for then (${bound:%s:%s|default:}), so it would be written empty "+ + "(novox/hq ADR 0330 §4)", module, provision, key, provision, provision, key) + return token + } + refused = fmt.Errorf( + "%s has a file that says ${bound:%s:%s}, and %s does not require %q. It may name %s", + module, provision, key, module, provision, orNothing(namesOfBindings(known))) + return token + } + value, said := values[key] + if !said { + refused = fmt.Errorf( + "%s asks its %s binding for %q, and what answers it says %s", + module, provision, key, orNothing(namesOfKeys(values))) + return token + } + return value + }) + if refused != nil { + return refused + } + resource["content"] = filled + return nil +} + +func namesOfBindings(known map[string]map[string]string) []string { + var names []string + for name := range known { + names = append(names, fmt.Sprintf("%q", name)) + } + sort.Strings(names) + return names +} + +func namesOfKeys(values map[string]string) []string { + var names []string + for key := range values { + names = append(names, fmt.Sprintf("%q", key)) + } + sort.Strings(names) + return names +} + +func orNothing(names []string) string { + if len(names) == 0 { + return "nothing" + } + return join(names) +} diff --git a/internal/compose/resolution/bound_into_files_test.go b/internal/compose/resolution/bound_into_files_test.go new file mode 100644 index 0000000..0da5176 --- /dev/null +++ b/internal/compose/resolution/bound_into_files_test.go @@ -0,0 +1,246 @@ +package resolution + +import ( + "fmt" + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// A consumer writes its own connection string (novox/hq 04-ISSUES/023). +// +// Everything needed for one now reaches the file that needs it: the address and port from what the +// provider serves, the name to present from what the mesh decided, the database name from what the +// module itself contributed, and the password through the sealed hole the host fills. +func TestAConsumerCanWriteAConnectionString(t *testing.T) { + r := mcat.Resolution{ + Node: "workstation", + Modules: []mcat.Manifest{{ + Module: "keycloak", + Requires: []string{"postgres-database"}, + Secrets: map[string]string{"postgres-database": "/var/lib/keycloak/db.secret"}, + Resources: []map[string]any{{ + "id": "dbenv", "type": "file", "path": "/var/lib/keycloak/database.env", + "mode": "0600", + "content": "KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:" + + "${bound:postgres-database:port}/keycloak\n" + + "KC_DB_USERNAME=${bound:postgres-database:as}\n" + + "KC_DB_PASSWORD=${secret:postgres-database}\n", + }}, + }}, + Needs: []mcat.Needed{{ + Name: "postgres-database", From: "anchor", At: "anchor.internal", For: "keycloak", + Sealed: "sealed-db", Serves: map[string]any{"port": float64(5432)}, + }}, + } + out, err := Declaration(r, mcat.Rendering{}) + if err != nil { + t.Fatal(err) + } + file := fileNamed(out, "keycloak.dbenv") + if file == nil { + t.Fatalf("no file in %v", out) + } + got := file["content"].(string) + + want := "KC_DB_URL=jdbc:postgresql://anchor.internal:5432/keycloak\n" + + "KC_DB_USERNAME=mesh_workstation_keycloak\n" + + "KC_DB_PASSWORD=${secret:postgres-database}\n" + if got != want { + t.Errorf("the consumer cannot connect with what it was given:\n got %q\nwant %q", got, want) + } + // The password is still a hole. Only the host may fill that one, and only on the machine. + if sealed, _ := file["secrets"].(map[string]any); sealed["postgres-database"] != "sealed-db" { + t.Errorf("the credential did not travel with the file: %v", file) + } +} + +// A provider answered on its consumer's own machine is announced at the port the machine PUBLISHES +// it on, not the port the module declared (novox/hq 04-ISSUES/038). +// +// Issue 018 made the same-node provider announced at all; this is the promise after it — that the +// port a co-located consumer is told is the one actually listening at the address it is handed. The +// mesh assigns a bare `ports` mapping a host port (here the container's 5432 becomes 15432 on this +// machine), and it must announce 15432, or the consumer dials workstation.internal:5432 where the +// provider is not published. Cross-node consumers already got this (the plan re-derives the served +// facts with the provider's assignment); the same-node need was settled before the assignment and +// carried the declared port until now. +func TestASameNodeProviderIsAnnouncedAtThePortItIsPublishedOn(t *testing.T) { + provider := mcat.Manifest{ + Module: "postgres", Version: "1", + Provides: mcat.FromAnywhere("postgres-database"), + Listens: []mcat.Listening{{Port: 5432, Protocol: "tcp", From: "mesh"}}, + Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}, + Resources: []map[string]any{{ + "id": "server", "type": "container", "name": "postgres", + "ports": []any{"5432"}, + }}, + } + consumer := mcat.Manifest{ + Module: "keycloak", Version: "1", Requires: []string{"postgres-database"}, + Resources: []map[string]any{{ + "id": "dbenv", "type": "file", "path": "/var/lib/keycloak/database.env", "mode": "0600", + "content": "KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:" + + "${bound:postgres-database:port}/keycloak\n", + }}, + } + r, err := Resolve(shelf(provider, consumer), + []string{"postgres", "keycloak"}, reachable(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + // The mesh assigned the container's 5432 to 15432 on this machine. + out, err := Declaration(r, mcat.Rendering{Ports: map[string]map[int]int{"postgres": {5432: 15432}}}) + if err != nil { + t.Fatal(err) + } + + // Where the provider is actually published: the assigned host port, on the machine at large. + server := fileNamed(out, "postgres.server") + if server == nil { + t.Fatalf("the provider container is not in the declaration: %v", out) + } + if published := fmt.Sprint(server["ports"]); !strings.Contains(published, "15432:5432") { + t.Fatalf("the provider was not published on the assigned port: %v", server["ports"]) + } + + // What the consumer was told to dial: the SAME port, at the announced address — not the 5432 the + // module declared, which is closed on that address. + dbenv := fileNamed(out, "keycloak.dbenv") + if dbenv == nil { + t.Fatalf("the consumer's binding file is not in the declaration: %v", out) + } + got := dbenv["content"].(string) + want := "KC_DB_URL=jdbc:postgresql://workstation.internal:15432/keycloak\n" + if got != want { + t.Errorf("the consumer dials a port the provider does not listen on:\n got %q\nwant %q", + got, want) + } +} + +// A port is 5432, not 5432.000000 — which is what a number decoded from JSON would write, and what +// every connection string in the world would refuse. +func TestAPortIsWrittenAsAPort(t *testing.T) { + if got := plainly(float64(5432)); got != "5432" { + t.Errorf("a port was rendered as %q", got) + } + if got := plainly("postgres"); got != "postgres" { + t.Errorf("a string was rendered as %q", got) + } +} + +// The name the provider will create is the name the consumer is told to present. One derivation, +// so the two agree by construction rather than by two conventions written on the same day. +func TestTheProviderAndTheConsumerAgreeOnTheName(t *testing.T) { + provider, err := Resolve(shelf(mcat.Manifest{ + Module: "postgres", Version: "1", Provides: mcat.FromAnywhere("postgres-database"), + Grants: map[string]string{"postgres-database": "/var/lib/postgres/grants"}, + Receives: map[string]string{"postgres-database": "/var/lib/postgres/grants/mesh.json"}, + }), []string{"postgres"}, reachable(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + out, err := Declaration(provider, mcat.Rendering{Grants: []mcat.Grant{{ + Provision: "postgres-database", Consumer: "workstation", From: "keycloak", + Values: map[string]any{"name": "keycloak"}, Sealed: "sealed", + }}}) + if err != nil { + t.Fatal(err) + } + var told string + for _, res := range out { + if res["path"] == "/var/lib/postgres/grants/mesh.json" { + told = res["content"].(string) + } + } + if !strings.Contains(told, `"as": "mesh_workstation_keycloak"`) { + t.Errorf("the provider was not told what to call the login: %s", told) + } + if told == "" { + t.Fatal("the provider was told nothing at all") + } + // Which is exactly what the consumer, on its own machine, is told to present. + if mcat.ConsumerIdentity("workstation", "keycloak") != "mesh_workstation_keycloak" { + t.Errorf("got %q", mcat.ConsumerIdentity("workstation", "keycloak")) + } +} + +// A binding the module does not have is a typo, refused where it was written. +func TestAFileCannotNameABindingTheModuleDoesNotHave(t *testing.T) { + r := mcat.Resolution{ + Node: "workstation", + Modules: []mcat.Manifest{{ + Module: "keycloak", Requires: []string{"postgres-database"}, + Resources: []map[string]any{{ + "id": "conf", "type": "file", "path": "/etc/keycloak.conf", + "content": "host=${bound:s3-bucket:at}\n", + }}, + }}, + Needs: []mcat.Needed{{Name: "postgres-database", From: "anchor", At: "a", For: "keycloak"}}, + } + _, err := Declaration(r, mcat.Rendering{}) + if err == nil { + t.Fatal("a module read a binding it does not have") + } + if !strings.Contains(err.Error(), "postgres-database") { + t.Errorf("the refusal does not say what it could have named: %v", err) + } +} + +// A key the provider does not serve is refused too. Written through, the literal placeholder +// becomes a hostname, and the failure names neither the module nor the mesh. +func TestAKeyTheProviderDoesNotServeIsRefused(t *testing.T) { + r := mcat.Resolution{ + Node: "workstation", + Modules: []mcat.Manifest{{ + Module: "keycloak", Requires: []string{"postgres-database"}, + Resources: []map[string]any{{ + "id": "conf", "type": "file", "path": "/etc/keycloak.conf", + "content": "sock=${bound:postgres-database:socket}\n", + }}, + }}, + Needs: []mcat.Needed{{ + Name: "postgres-database", From: "anchor", At: "a", For: "keycloak", + Serves: map[string]any{"port": float64(5432)}, + }}, + } + _, err := Declaration(r, mcat.Rendering{}) + if err == nil { + t.Fatal("a module read a key nothing serves, and the placeholder became a value") + } + if !strings.Contains(err.Error(), `"port"`) { + t.Errorf("the refusal does not say what is served: %v", err) + } +} + +// A machine's resolver configuration must name its resolver by address — it cannot resolve the name +// of the thing it resolves names with (novox/hq ADR 0194). So a binding offers the providing +// machine's private address beside its name, and only when the machine has one. +func TestABindingOffersTheProvidersAddress(t *testing.T) { + consumer := func() mcat.Resolution { + return mcat.Resolution{ + Node: "workstation", + Modules: []mcat.Manifest{{ + Module: "resolv-conf", + Requires: []string{"wildcard-resolution"}, + Resources: []map[string]any{{ + "id": "resolv", "type": "file", "path": "/etc/resolv.conf", "mode": "0644", + "content": "nameserver ${bound:wildcard-resolution:address}\n", + }}, + }}, + Needs: []mcat.Needed{{Name: "wildcard-resolution", From: "anchor", At: "anchor.internal", For: "resolv-conf"}}, + } + } + out, err := Declaration(consumer(), mcat.Rendering{Machines: map[string]string{"anchor.internal": "10.77.0.1"}}) + if err != nil { + t.Fatal(err) + } + if got := fileNamed(out, "resolv-conf.resolv")["content"]; got != "nameserver 10.77.0.1\n" { + t.Fatalf("the resolver is not named by its address: %q", got) + } + // A machine with no address yet: refused, never written with a blank where the address belongs. + if _, err := Declaration(consumer(), mcat.Rendering{}); err == nil { + t.Fatal("a file naming an address the mesh does not have was composed") + } +} diff --git a/internal/compose/resolution/bound_test.go b/internal/compose/resolution/bound_test.go new file mode 100644 index 0000000..98df9cb --- /dev/null +++ b/internal/compose/resolution/bound_test.go @@ -0,0 +1,219 @@ +package resolution + +import ( + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +var incidentConsumers = []string{"board", "listings", "workflows", "agents", "game"} + +func storeMesh() map[string]mcat.Manifest { + shelf := map[string]mcat.Manifest{ + "store": {Module: "store", Version: "1", + Provides: []mcat.Offer{{Name: "postgres-database", Scope: mcat.ScopeMesh}}, + Claims: []mcat.Claim{{Name: "mesh-store", Scope: mcat.ScopeMesh}}, + Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}, + Grants: map[string]string{"postgres-database": "/grants"}}, + "resolver": {Module: "resolver", Version: "1", + Provides: []mcat.Offer{{Name: "wildcard-resolution", Scope: mcat.ScopeMesh}}, + Claims: []mcat.Claim{{Name: "mesh-dns-resolver", Scope: mcat.ScopeMesh}}}, + "network": {Module: "network", Version: "1", Requires: []string{"wildcard-resolution"}}, + } + for _, c := range incidentConsumers { + shelf[c] = mcat.Manifest{Module: c, Version: "1", Requires: []string{"postgres-database"}} + } + return shelf +} + +// storeWorld is the rest of the mesh as the home server's plan sees it: the anchor runs a store and a +// resolver and holds both seats; the home server runs its own of each. +func storeWorld() mcat.World { + w := mcat.World{Offered: map[string][]mcat.Provider{ + "postgres-database": { + {Node: "anchor", At: "anchor.internal", Module: "store", Serves: map[string]any{"port": 5432}}, + {Node: "home", At: "home.internal", Module: "store", Serves: map[string]any{"port": 5434}}, + }, + "wildcard-resolution": { + {Node: "anchor", At: "anchor.internal", Module: "resolver"}, + {Node: "home", At: "home.internal", Module: "resolver"}, + }, + }} + w.Held = []mcat.Held{ + {Claim: "mesh-store", Scope: mcat.ScopeMesh, Node: "anchor", Module: "store"}, + {Claim: "mesh-dns-resolver", Scope: mcat.ScopeMesh, Node: "anchor", Module: "resolver"}, + } + w.Holdings = w.Held + return w +} + +var home = mcat.Node{Name: "home", At: "home.internal"} + +func homeAssigned() []string { + return append([]string{"store", "resolver", "network"}, incidentConsumers...) +} + +func boundFrom(t *testing.T, r mcat.Resolution, consumer, provision string) mcat.Needed { + t.Helper() + for _, n := range r.Needs { + if n.For == consumer && n.Name == provision { + return n + } + } + t.Fatalf("no binding of %s for %s: %+v", consumer, provision, r.Needs) + return mcat.Needed{} +} + +func TestTheIncidentAMachinesOwnStoreKeepsItsConsumersWhenTheSeatIsHeldElsewhere(t *testing.T) { + got, err := Resolve(storeMesh(), homeAssigned(), home, storeWorld()) + if err != nil { + t.Fatal(err) + } + for _, c := range incidentConsumers { + if n := boundFrom(t, got, c, "postgres-database"); n.From != "home" || n.Module != "store" { + t.Errorf("%s bound to %s/%s; its data is on the store beside it (issue 273)", c, n.From, n.Module) + } + } + // And the resolver, which keeps nothing of anybody's, still answers from the seat's holder (258). + if n := boundFrom(t, got, "network", "wildcard-resolution"); n.From != "anchor" { + t.Errorf("the resolver bound to %s; the seat is held on anchor (issue 258)", n.From) + } + if len(got.Kept) != 0 { + t.Errorf("nothing was moved, and %d kept: %+v", len(got.Kept), got.Kept) + } +} + +func TestTheIncidentWithEveryConsumerOnRecordStillMovesNothing(t *testing.T) { + w := storeWorld() + w.Bound = map[string]map[string]mcat.Chosen{} + for _, c := range incidentConsumers { + w.Bound[c] = map[string]mcat.Chosen{"postgres-database": {Node: "home", Module: "store"}} + } + got, err := Resolve(storeMesh(), homeAssigned(), home, w) + if err != nil { + t.Fatal(err) + } + for _, c := range incidentConsumers { + if n := boundFrom(t, got, c, "postgres-database"); n.From != "home" { + t.Errorf("%s bound to %s", c, n.From) + } + } + if len(got.Kept) != 0 { + t.Errorf("kept %+v", got.Kept) + } +} + +func TestAPinElsewhereStillMovesAStoresConsumers(t *testing.T) { + w := storeWorld() + w.Pinned = map[string]mcat.Chosen{"postgres-database": {Node: "anchor", Module: "store"}} + w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}} + got, err := Resolve(storeMesh(), homeAssigned(), home, w) + if err != nil { + t.Fatal(err) + } + if n := boundFrom(t, got, "board", "postgres-database"); n.From != "anchor" || n.Module != "store" { + t.Errorf("bound to %s/%s; a person pinned it to anchor", n.From, n.Module) + } + if len(got.Kept) != 0 { + t.Errorf("a pin is a person's move, not one to keep: %+v", got.Kept) + } +} + +// A consumer on a machine with no store of its own, bound to one store, when the seat moves to +// another: the holder would answer, and the binding stays. +func laptopWorld(holder string) mcat.World { + w := storeWorld() + w.Held = []mcat.Held{{Claim: "mesh-store", Scope: mcat.ScopeMesh, Node: holder, Module: "store"}} + w.Holdings = w.Held + return w +} + +var laptop = mcat.Node{Name: "laptop", At: "laptop.internal"} + +func TestABoundConsumerStaysWhenTheSeatsHolderChanges(t *testing.T) { + w := laptopWorld("home") + w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "anchor", Module: "store"}}} + got, err := Resolve(storeMesh(), []string{"board"}, laptop, w) + if err != nil { + t.Fatal(err) + } + n := boundFrom(t, got, "board", "postgres-database") + if n.From != "anchor" || n.At != "anchor.internal" || n.Serves["port"] != 5432 { + t.Fatalf("bound to %s at %s %v; its data is on anchor", n.From, n.At, n.Serves) + } + if len(got.Kept) != 1 { + t.Fatalf("the move was not said: %+v", got.Kept) + } + k := got.Kept[0] + if k.Bound != (mcat.Chosen{Node: "anchor", Module: "store"}) || k.Would != (mcat.Chosen{Node: "home", Module: "store"}) || k.Consumer != "board" { + t.Fatalf("kept %+v", k) + } + for _, want := range []string{"would move board's postgres-database from anchor/store to home/store", + "its data is on anchor/store", "pin laptop postgres-database home store", "move the data first"} { + if !strings.Contains(k.String(), want) { + t.Errorf("%q does not say %q", k.String(), want) + } + } + // Without a record it is a binding being made, and the holder answers it as before. + w.Bound = nil + got, err = Resolve(storeMesh(), []string{"board"}, laptop, w) + if err != nil { + t.Fatal(err) + } + if n := boundFrom(t, got, "board", "postgres-database"); n.From != "home" { + t.Errorf("a new consumer bound to %s; the seat is held on home", n.From) + } +} + +func TestABoundConsumerWhoseProviderIsGoneIsRefusedNotMoved(t *testing.T) { + w := laptopWorld("anchor") + w.Offered["postgres-database"] = w.Offered["postgres-database"][:1] // only anchor's store is left + w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}} + _, err := Resolve(storeMesh(), []string{"board"}, laptop, w) + if err == nil { + t.Fatal("bound to home's store, which is gone, and answered by anchor's — the silent move") + } + for _, want := range []string{"board on laptop is bound to home/store", "home/store no longer provides it", + "pin laptop postgres-database anchor store"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("%q does not say %q", err, want) + } + } +} + +func TestAMachinesOwnStoreUnassignedRefusesItsBoundConsumers(t *testing.T) { + w := storeWorld() + w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}} + _, err := Resolve(storeMesh(), []string{"board"}, home, w) + if err == nil || !strings.Contains(err.Error(), "store no longer runs on home") { + t.Fatalf("got %v", err) + } +} + +// The first pass asks only what a machine offers, and is never refused by a binding. +func TestTheFirstPassKeepsNoBinding(t *testing.T) { + w := storeWorld() + w.Unchecked = true + w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "gone", Module: "store"}}} + if _, err := Resolve(storeMesh(), []string{"board"}, laptop, w); err != nil { + t.Fatal(err) + } +} + +// An offer saying it keeps nothing is answered by the seat's holder as the resolver is, even where it +// grants a credential. +func TestAStoreSayingItKeepsNothingFollowsTheSeat(t *testing.T) { + shelf := storeMesh() + no := false + s := shelf["store"] + s.Provides = []mcat.Offer{{Name: "postgres-database", Scope: mcat.ScopeMesh, KeepsConsumerData: &no}} + shelf["store"] = s + got, err := Resolve(shelf, homeAssigned(), home, storeWorld()) + if err != nil { + t.Fatal(err) + } + if n := boundFrom(t, got, "board", "postgres-database"); n.From != "anchor" { + t.Errorf("bound to %s; it keeps nothing, and the seat is held on anchor", n.From) + } +} diff --git a/internal/compose/resolution/broker_seat_test.go b/internal/compose/resolution/broker_seat_test.go new file mode 100644 index 0000000..524f390 --- /dev/null +++ b/internal/compose/resolution/broker_seat_test.go @@ -0,0 +1,49 @@ +package resolution + +import ( + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// The mesh's bus is one per mesh, read from the catalogue beside this checkout. +// +// **This is step 2's claim, and it is checked here rather than in a bed** (novox/hq ADR 0116): +// adoption puts the NATS server into the `mesh-broker` seat on a mesh that is already running, +// and the property that matters is that a second one anywhere is refused *when it is assigned*, +// not discovered later as two servers holding different halves of the mesh's traffic. A second +// bus is not a degraded mesh; it is two meshes that both believe they are the one. +func TestASecondMeshBusAnywhereIsRefusedByName(t *testing.T) { + nats := catalogueManifest(t, "nats") + + if _, err := Resolve(shelf(nats), []string{"nats"}, workstation(), mcat.World{}); err != nil { + t.Fatalf("the bus alone does not resolve: %v", err) + } + + elsewhere := mcat.World{Held: []mcat.Held{{Claim: "mesh-broker", Scope: mcat.ScopeMesh, + Node: "anchor", Module: "nats"}}} + other := workstation() + other.Name = "laptop" + _, err := Resolve(shelf(nats), []string{"nats"}, other, elsewhere) + if err == nil { + t.Fatal("a second bus was accepted on another machine") + } + if !strings.Contains(err.Error(), "mesh-broker") || !strings.Contains(err.Error(), "one per mesh") { + t.Fatalf("refused without naming the seat: %v", err) + } +} + +// The seat is the server's role, not the product's name (novox/hq ADR 0079). A different +// implementation of the bus claims the same seat, and the mesh refuses it for the same reason — +// which is the property that lets the bus be replaced at all. +func TestTheSeatRefusesADifferentBusToo(t *testing.T) { + nats := catalogueManifest(t, "nats") + held := mcat.World{Held: []mcat.Held{{Claim: "mesh-broker", Scope: mcat.ScopeMesh, + Node: "anchor", Module: "some-other-broker"}}} + other := workstation() + other.Name = "laptop" + if _, err := Resolve(shelf(nats), []string{"nats"}, other, held); err == nil { + t.Fatal("the seat admitted a second holder because the module's name differed") + } +} diff --git a/internal/compose/resolution/brokered_test.go b/internal/compose/resolution/brokered_test.go new file mode 100644 index 0000000..2932a08 --- /dev/null +++ b/internal/compose/resolution/brokered_test.go @@ -0,0 +1,255 @@ +package resolution + +import ( + "encoding/json" + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// onNetwork is a set of providers, all of them reachable. Written as a helper because a machine +// that cannot reach the one answering its requirement is its own case, tested separately. +// reachable is this machine, on the private network. +func reachable() mcat.Node { + n := workstation() + n.At = "workstation.internal" + return n +} + +func onNetwork(nodes ...string) map[string][]mcat.Provider { + out := make([]mcat.Provider, 0, len(nodes)) + for _, n := range nodes { + out = append(out, mcat.Provider{Node: n, At: n + ".internal", Module: "postgres"}) + } + return map[string][]mcat.Provider{"postgres-database": out} +} + +func brokeredShelf() map[string]mcat.Manifest { + return shelf(mcat.Manifest{Module: "postgres", Version: "1", Provides: mcat.FromAnywhere("postgres-database")}, mcat.Manifest{Module: "meshboard", Version: "1", Requires: []string{"postgres-database"}}) +} + +func TestADatabaseIsNotInstalledOnEveryMachineThatUsesOne(t *testing.T) { + // The fault this whole distinction exists for. + got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: onNetwork("anchor")}) + if err != nil { + t.Fatal(err) + } + if have := strings.Join(names(got), " "); strings.Contains(have, "postgres") { + t.Fatalf("using a database installed one here: %s", have) + } +} + +func TestWhatAMachineTakesFromElsewhereIsRecorded(t *testing.T) { + // It is the only part of a node's set that stops working when a *different* machine goes + // away, and it is where a credential will have to be handed back. + got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: onNetwork("anchor")}) + if err != nil { + t.Fatal(err) + } + if len(got.Needs) != 1 { + t.Fatalf("got %v", got.Needs) + } + if got.Needs[0].Name != "postgres-database" || got.Needs[0].From != "anchor" { + t.Fatalf("got %v", got.Needs[0]) + } + if got.Needs[0].For != "meshboard" { + t.Fatalf("it does not say what wanted it: %v", got.Needs[0]) + } +} + +func TestNothingInTheMeshProvidingItIsRefusedWithSomewhereToPutIt(t *testing.T) { + // Refused rather than installed here. Choosing a machine to put a database on is a decision + // with consequences, and nothing resolving a web application should make it silently. + _, err := Resolve(brokeredShelf(), []string{"meshboard"}, workstation(), mcat.World{}) + if err == nil { + t.Fatal("a database was found in a mesh that has none") + } + if !strings.Contains(err.Error(), "assign") || !strings.Contains(err.Error(), "postgres") { + t.Fatalf("the refusal does not say what to do: %v", err) + } +} + +func TestTwoNodesProvidingItIsRefusedRatherThanPicked(t *testing.T) { + // Same rule as everywhere else. Picking one would be a guess about which database a person + // meant, and the wrong guess is somebody's data in the wrong place. + _, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: onNetwork("anchor", "archive")}) + if err == nil { + t.Fatal("one of two databases was picked silently") + } + for _, want := range []string{"anchor", "archive", "pin"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("the refusal does not name %s: %v", want, err) + } + } +} + +func TestSayingWhichOneSettlesIt(t *testing.T) { + got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{ + Offered: onNetwork("anchor", "archive"), + Pinned: map[string]mcat.Chosen{"postgres-database": {Node: "archive", Module: "postgres"}}, + }) + if err != nil { + t.Fatal(err) + } + if len(got.Needs) != 1 || got.Needs[0].From != "archive" { + t.Fatalf("the choice was not taken: %v", got.Needs) + } +} + +func TestBeingPointedAtAMachineThatDoesNotProvideItIsRefused(t *testing.T) { + // Rather than falling back to one that does. A fallback would quietly move somebody's data to + // a machine they did not choose, which is the whole reason the question is asked. + _, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{ + Offered: onNetwork("anchor", "archive"), + Pinned: map[string]mcat.Chosen{"postgres-database": {Node: "somewhere-else", Module: "postgres"}}, + }) + if err == nil { + t.Fatal("a machine was silently given a different database from the one chosen") + } + if !strings.Contains(err.Error(), "somewhere-else") { + t.Fatalf("the refusal does not say what was chosen: %v", err) + } +} + +func TestOneProviderDoesNotOverruleAChoice(t *testing.T) { + // A single answer is normally taken silently. Not when somebody said they wanted a different + // one -- that is the mesh overruling a person, which it does nowhere else. + _, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{ + Offered: onNetwork("anchor"), + Pinned: map[string]mcat.Chosen{"postgres-database": {Node: "archive", Module: "postgres"}}, + }) + if err == nil { + t.Fatal("the only database was used although another was chosen") + } + if !strings.Contains(err.Error(), "only anchor/postgres provides it") { + t.Fatalf("the refusal does not say what is available: %v", err) + } +} + +func TestACatalogueThatDisagreesAboutScopeIsRefused(t *testing.T) { + // If one module says a database is local and another says it is anywhere, the same + // requirement means two things depending on which one happens to answer it. + _, err := Resolve(shelf(mcat.Manifest{Module: "postgres", Version: "1", Provides: mcat.FromAnywhere("postgres-database")}, mcat.Manifest{Module: "sqlite", Version: "1", Provides: mcat.Offers("postgres-database")}, mcat.Manifest{Module: "meshboard", Version: "1", Requires: []string{"postgres-database"}}), []string{"meshboard"}, workstation(), mcat.World{}) + if err == nil { + t.Fatal("a catalogue that disagrees about where a database lives was accepted") + } + if !strings.Contains(err.Error(), "two things") { + t.Fatalf("unhelpful refusal: %v", err) + } +} + +func TestALocalRequirementIsStillAnsweredLocally(t *testing.T) { + // The change must not have made everything brokered. A shell is still installed here. + got, err := Resolve(shelf(mcat.Manifest{Module: "zsh", Version: "1", Provides: mcat.Offers("shell")}, mcat.Manifest{Module: "tools", Version: "1", Requires: []string{"shell"}}), []string{"tools"}, workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + if have := strings.Join(names(got), " "); !strings.Contains(have, "zsh") { + t.Fatalf("a shell was not installed on the machine that needs one: %s", have) + } +} + +func boundShelf() map[string]mcat.Manifest { + return shelf(mcat.Manifest{Module: "postgres", Version: "1", Provides: mcat.FromAnywhere("postgres-database"), + Serves: map[string]map[string]any{"postgres-database": {"port": 5432, "driver": "postgres"}}}, mcat.Manifest{Module: "meshboard", Version: "1", Requires: []string{"postgres-database"}, + Binds: map[string]string{"postgres-database": "/etc/meshboard/database.json"}}, + ) +} + +func binding(t *testing.T, out []map[string]any) map[string]any { + t.Helper() + for _, r := range out { + if r["path"] != "/etc/meshboard/database.json" { + continue + } + var parsed map[string]any + if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil { + t.Fatalf("what the app is told is not readable: %v", err) + } + return parsed + } + t.Fatalf("the app was told nothing: %v", out) + return nil +} + +func TestAnAppIsToldWhereItsDatabaseIs(t *testing.T) { + // Knowing it needs the anchor's database is useless to the program that needs it unless the + // program is told. This is the whole point of the field. + got, err := Resolve(boundShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: map[string][]mcat.Provider{"postgres-database": {{Node: "anchor", At: "anchor.internal", + Serves: map[string]any{"port": 5432, "driver": "postgres"}}}}}) + if err != nil { + t.Fatal(err) + } + told := binding(t, mustDeclare(t, got)) + if told["from"] != "anchor" || told["at"] != "anchor.internal" { + t.Fatalf("it was not told where: %v", told) + } + serves, _ := told["serves"].(map[string]any) + if serves["port"] != float64(5432) || serves["driver"] != "postgres" { + t.Fatalf("it was not told how: %v", serves) + } +} + +func TestItSaysWhereTheCredentialIsInstead(t *testing.T) { + // A missing field looks like a bug; a stated absence looks like a boundary. Somebody wiring + // this up must not spend an afternoon looking for the password field. + // + // It used to say only that the mesh had no way to issue one, which stopped being true when + // 021 was fixed. A stated absence is only useful while it is accurate — once it is not, it + // sends the reader somewhere there is nothing to find. + got, _ := Resolve(boundShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: map[string][]mcat.Provider{"postgres-database": {{Node: "anchor", At: "anchor.internal"}}}}) + told := binding(t, mustDeclare(t, got)) + note, _ := told["generated"].(string) + if !strings.Contains(note, "not here") || !strings.Contains(note, "secrets") { + t.Fatalf("the file does not say where the credential is instead: %v", note) + } + for key := range told { + if strings.Contains(key, "password") || strings.Contains(key, "secret") { + t.Fatalf("something that looks like a credential appeared: %q", key) + } + } +} + +func TestTwoMachinesThatCannotReachEachOtherAreRefused(t *testing.T) { + // An app on one machine and a database on another that share no private network is a mesh + // that reports itself configured and does not work. Said here rather than discovered as a + // connection timing out. + _, err := Resolve(boundShelf(), []string{"meshboard"}, workstation(), mcat. // not on the network + World{Offered: map[string][]mcat.Provider{"postgres-database": {{Node: "anchor", At: "anchor.internal"}}}}) + if err == nil { + t.Fatal("an app was pointed at a database it has no path to") + } + if !strings.Contains(err.Error(), "private network") { + t.Fatalf("the refusal does not say what is wrong: %v", err) + } + if !strings.Contains(err.Error(), meshNetwork) { + t.Fatalf("the refusal does not say what to assign: %v", err) + } +} + +func TestTheProviderBeingOffTheNetworkIsAlsoRefused(t *testing.T) { + // Both directions, because the failure is identical from either end and the remedy differs. + _, err := Resolve(boundShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: map[string][]mcat.Provider{"postgres-database": {{Node: "anchor"}}}}) + if err == nil { + t.Fatal("an app was pointed at a database that is not on the private network") + } + if !strings.Contains(err.Error(), "anchor") { + t.Fatalf("the refusal does not name the unreachable end: %v", err) + } +} + +func TestBindingSomethingAnsweredHereWritesNothing(t *testing.T) { + // A file saying "it is on this node" is a fact nobody needs and one more thing to keep true. + got, err := Resolve(shelf(mcat.Manifest{Module: "zsh", Version: "1", Provides: mcat.Offers("shell")}, mcat.Manifest{Module: "tools", Version: "1", Requires: []string{"shell"}, + Binds: map[string]string{"shell": "/etc/tools/shell.json"}}, + ), []string{"tools"}, reachable(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + for _, r := range mustDeclare(t, got) { + if r["path"] == "/etc/tools/shell.json" { + t.Fatalf("a binding was written for something on this machine: %v", r) + } + } +} diff --git a/internal/compose/resolution/bucket_shares.go b/internal/compose/resolution/bucket_shares.go new file mode 100644 index 0000000..0ada52a --- /dev/null +++ b/internal/compose/resolution/bucket_shares.go @@ -0,0 +1,48 @@ +package resolution + +import ( + "fmt" + "sort" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// sortedShares is a requirement's shares as they are handed to the provider: sorted by `to` (a file whose lines move +// looks changed), each entry's methods sorted, and a copy so the manifest is not reordered. +func sortedShares(shares []mcat.BucketShare) []mcat.BucketShare { + out := make([]mcat.BucketShare, 0, len(shares)) + for _, s := range shares { + methods := append([]string(nil), s.Methods...) + sort.Strings(methods) + out = append(out, mcat.BucketShare{To: s.To, Methods: methods}) + } + sort.Slice(out, func(i, j int) bool { return out[i].To < out[j].To }) + return out +} + +// withShares adds this module's shares of the requirement to the values it gives the provider. A module whose own +// `contributes` already names `shares` is refused: the field is the only way to say it (ADR 0468 §8). +func withShares(m mcat.Manifest, requirement string, values map[string]any) (map[string]any, error) { + shares := m.RequireShares[requirement] + if _, named := values[mcat.SharesValue]; named && requirement == mcat.BucketRequirement { + return nil, fmt.Errorf("%s contributing to %s names `shares`; write it on its requirement instead (novox/hq ADR 0468 §8)", + m.Module, requirement) + } + if len(shares) == 0 { + return values, nil + } + out := make(map[string]any, len(values)+1) + for k, v := range values { + out[k] = v + } + listed := []any{} + for _, s := range sortedShares(shares) { + methods := []any{} + for _, method := range s.Methods { + methods = append(methods, method) + } + listed = append(listed, map[string]any{"to": s.To, "methods": methods}) + } + out[mcat.SharesValue] = listed + return out, nil +} diff --git a/internal/compose/resolution/bucket_shares_test.go b/internal/compose/resolution/bucket_shares_test.go new file mode 100644 index 0000000..4c5a2cc --- /dev/null +++ b/internal/compose/resolution/bucket_shares_test.go @@ -0,0 +1,105 @@ +package resolution + +import ( + "encoding/json" + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +const filesWithShares = `{"module":"files","version":"1","requires":["shell", + {"name":"s3-bucket","shares":[{"to":"seat:node-files","methods":["put"]},{"to":"print-and-scan","methods":["get","put"]}]}]}` + +func minioLike() mcat.Manifest { + return mcat.Manifest{Module: "minio", Version: "1", Provides: mcat.Offers("s3-bucket"), + Receives: map[string]string{"s3-bucket": "/grants/mesh.json"}} +} + +func givenToMinio(t *testing.T, out []map[string]any) []mcat.Contribution { + t.Helper() + for _, r := range out { + if r["path"] != "/grants/mesh.json" { + continue + } + var parsed struct { + Given []mcat.Contribution `json:"given"` + } + if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil { + t.Fatal(err) + } + return parsed.Given + } + t.Fatalf("minio was given no file: %v", out) + return nil +} + +func sharesOf(t *testing.T, values map[string]any) string { + t.Helper() + raw, err := json.Marshal(values["shares"]) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +const sortedSharesJSON = `[{"methods":["get","put"],"to":"print-and-scan"},{"methods":["put"],"to":"seat:node-files"}]` + +// 1591/AC3: the values a consumer gives carry the shares sorted by `to`, remote (grant) and co-located alike. +func TestTheGrantCarriesTheSharesSorted(t *testing.T) { + m := mustParse(t, filesWithShares) + remote := mcat.Resolution{Node: "laptop", Modules: []mcat.Manifest{m}} + values, asks, err := ContributionsFrom(remote, "s3-bucket", "files", mcat.SettingsBy{}) + if err != nil || !asks { + t.Fatalf("asks %v, err %v", asks, err) + } + if got := sharesOf(t, values); got != sortedSharesJSON { + t.Fatalf("the grant values carry %s", got) + } + // As the provider's file shows it, through a grant. + provider, err := Resolve(shelf(minioLike()), []string{"minio"}, workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + out, err := Declaration(provider, mcat.Rendering{Grants: []mcat.Grant{{Provision: "s3-bucket", Consumer: "laptop", From: "files", Values: values}}}) + if err != nil { + t.Fatal(err) + } + given := givenToMinio(t, out) + if len(given) != 1 || sharesOf(t, given[0].Values) != sortedSharesJSON { + t.Fatalf("the remote consumer's shares did not reach the file: %+v", given) + } + + // Co-located: the consumer contributes to s3-bucket too, and no grant is passed. + co := mustParse(t, `{"module":"files","version":"1","contributes":{"s3-bucket":{"name":"files"}}, + "requires":[{"name":"s3-bucket","shares":[{"to":"seat:node-files","methods":["put"]},{"to":"print-and-scan","methods":["put","get"]}]}]}`) + both, err := Resolve(shelf(minioLike(), co), []string{"files"}, workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + out, err = Declaration(both, mcat.Rendering{}) + if err != nil { + t.Fatal(err) + } + given = givenToMinio(t, out) + if len(given) != 1 || sharesOf(t, given[0].Values) != sortedSharesJSON || given[0].Values["name"] != "files" { + t.Fatalf("the co-located consumer's shares did not reach the file: %+v", given) + } +} + +// 1591/AC5: a consumer whose contributes to s3-bucket names `shares` itself is refused. +func TestAConsumerCannotContributeSharesItself(t *testing.T) { + _, err := decodeForTest([]byte(`{"module":"files","version":"1", + "contributes":{"s3-bucket":{"shares":"x"}}, + "requires":[{"name":"s3-bucket","shares":[{"to":"a","methods":["get"]}]}]}`)) + if err == nil || !strings.Contains(err.Error(), "ADR 0468 §8") { + t.Errorf("both written, accepted: %v", err) + } + // Without the field the grant is still refused, so the provider is never told something the manifest did not say. + m := mcat.Manifest{Module: "files", Requires: []string{"s3-bucket"}, + Contributes: map[string]map[string]any{"s3-bucket": {"shares": "x"}}} + r := mcat.Resolution{Node: "laptop", Modules: []mcat.Manifest{m}} + if _, _, err := ContributionsFrom(r, "s3-bucket", "files", mcat.SettingsBy{}); err == nil { + t.Error("a contributed `shares` reached the grant") + } +} diff --git a/internal/compose/resolution/build.go b/internal/compose/resolution/build.go new file mode 100644 index 0000000..08bfe76 --- /dev/null +++ b/internal/compose/resolution/build.go @@ -0,0 +1,20 @@ +package resolution + +import ( + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// runByAResource is the artifacts one of a module's own resources names — a process that runs it, +// a step, an archive that unpacks it — by name. +func runByAResource(m mcat.Manifest) map[string]bool { + named := map[string]bool{} + for _, r := range m.Resources { + if a, ok := r["artifact"].(string); ok && a != "" { + named[a] = true + } + } + return named +} + +// RunByAResource is the bundles of m one of its own resources runs (its service, not its tools). +func RunByAResource(m mcat.Manifest) map[string]bool { return runByAResource(m) } diff --git a/internal/compose/resolution/busy.go b/internal/compose/resolution/busy.go new file mode 100644 index 0000000..d27ba08 --- /dev/null +++ b/internal/compose/resolution/busy.go @@ -0,0 +1,20 @@ +package resolution + +import ( + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// busyInto composes a container's `busy` into the node-engine's words, or takes it away: for an engine that +// reads it, the command and its timeout written out; for one that does not, the field is not sent, and the +// container is replaced at once, as before. +func busyInto(resource map[string]any, with mcat.Rendering) { + if _, has := resource[mcat.BusyField]; !has { + return + } + if !with.ReadsBusy { + delete(resource, mcat.BusyField) + return + } + b, _, _ := mcat.ReadBusy(resource) + resource[mcat.BusyField] = map[string]any{"command": b.Command, "timeout": b.Timeout.String()} +} diff --git a/internal/compose/resolution/busy_test.go b/internal/compose/resolution/busy_test.go new file mode 100644 index 0000000..8940b78 --- /dev/null +++ b/internal/compose/resolution/busy_test.go @@ -0,0 +1,54 @@ +package resolution + +import ( + "encoding/json" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +func busyManifest(t *testing.T, on string, busy any) []byte { + t.Helper() + extra := map[string]any{"id": "agent", "type": "container", "name": "app-agent", + "image": "registry.example/app" + healthDigest} + var m map[string]any + if err := json.Unmarshal(healthManifest(t, "", nil, extra), &m); err != nil { + t.Fatal(err) + } + for _, r := range m["resources"].([]any) { + if r := r.(map[string]any); r["id"] == on { + r[mcat.BusyField] = busy + } + } + out, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + return out +} + +func TestABusyIsSentOnlyToAnEngineThatReadsIt(t *testing.T) { + m, err := decodeForTest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"})) + if err != nil { + t.Fatal(err) + } + compose := func(with mcat.Rendering) []map[string]any { + out, err := Declaration(mcat.Resolution{Node: "laptop", Modules: []mcat.Manifest{m}}, with) + if err != nil { + t.Fatal(err) + } + return out + } + older := compose(mcat.Rendering{}) + if b, sent := older[indexOfID(older, "app.agent")][mcat.BusyField]; sent { + t.Fatalf("busy was sent to an engine that does not read it: %v", b) + } + newer := compose(mcat.Rendering{ReadsBusy: true}) + got, _ := newer[indexOfID(newer, "app.agent")][mcat.BusyField].(map[string]any) + if got["command"] != "mesh-builder busy" || got["timeout"] != "10s" { + t.Fatalf("busy composed as %v", got) + } + if _, has := m.Resources[len(m.Resources)-1][mcat.BusyField].(map[string]any)["timeout"]; has { + t.Error("composing wrote the timeout into the catalogue's own manifest") + } +} diff --git a/internal/compose/resolution/catalogue_check_test.go b/internal/compose/resolution/catalogue_check_test.go new file mode 100644 index 0000000..3e75f08 --- /dev/null +++ b/internal/compose/resolution/catalogue_check_test.go @@ -0,0 +1,46 @@ +package resolution + +import ( + "path/filepath" + "testing" + + "github.com/novox/mesh-host/internal/beside" +) + +// TestEveryCatalogueManifestParses runs the real catalogue through the real gate. +// +// Not a fixture: the point is whether the manifests as written are accepted by the control plane that +// will read them, and a copy of one manifest proves nothing about the other seventy-one. +// catalogueRoot is the catalogue these checks run over: in a merge check the clone the build seat put +// beside this one, elsewhere the copy captured in testdata/beside (internal/beside). A check that only +// ran when somebody remembered a variable was a check nobody ran (novox/hq issue 134), and one that read +// whatever checkout sat beside judged the machine, not the change (novox/hq issue 432): it never skips. +func catalogueRoot(t *testing.T) string { + t.Helper() + return beside.Dir(t, "mesh-catalog") +} + +func TestEveryCatalogueManifestParses(t *testing.T) { + _, found := catalogueManifests(t) + named, routed := 0, 0 + for _, r := range found { + m, err := r.manifest, r.err + if err != nil { + t.Errorf("%s: %v", filepath.Base(filepath.Dir(r.path)), err) + continue + } + for _, l := range m.Listens { + if l.Name != "" { + named++ + } + } + for port := range RoutedPorts(m) { + _ = port + routed++ + } + } + t.Logf("%d manifests, %d named endpoints, %d routed endpoints resolved", len(found), named, routed) + if named == 0 { + t.Fatal("no endpoint in the catalogue is named, so this proved nothing") + } +} diff --git a/internal/compose/resolution/catalogue_read_test.go b/internal/compose/resolution/catalogue_read_test.go new file mode 100644 index 0000000..08bd7c2 --- /dev/null +++ b/internal/compose/resolution/catalogue_read_test.go @@ -0,0 +1,46 @@ +package resolution + +import ( + "path/filepath" + "sync" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// readManifest is one manifest of the catalogue as ReadTreeManifest read it. +type readManifest struct { + path string // the module.json + manifest mcat.Manifest + err error +} + +var readOnce sync.Once + +var readAll []readManifest + +var readRoot string + +// catalogueManifests is every manifest of the catalogue (catalogueRoot), read through ReadTreeManifest once +// per test binary and not once per test: the checks over the real catalogue each read and parsed every +// manifest again, and together took 1.5 s of mesh-sdk's unit bound of 2 s (issue 1756). What it returns is +// shared and read-only: a test that changes a manifest copies it first. +func catalogueManifests(t *testing.T) (string, []readManifest) { + t.Helper() + root := catalogueRoot(t) + readOnce.Do(func() { + readRoot = root + found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json")) + if err != nil { + return + } + for _, p := range found { + m, err := mcat.ReadTreeManifest(root, filepath.Dir(p)) + readAll = append(readAll, readManifest{path: p, manifest: m, err: err}) + } + }) + if len(readAll) == 0 { + t.Fatalf("no manifests under %s", root) + } + return readRoot, readAll +} diff --git a/internal/compose/resolution/catrust_manifest_test.go b/internal/compose/resolution/catrust_manifest_test.go new file mode 100644 index 0000000..67a3a8c --- /dev/null +++ b/internal/compose/resolution/catrust_manifest_test.go @@ -0,0 +1,70 @@ +package resolution + +import ( + "path/filepath" + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" + "github.com/novox/mesh-host/internal/beside" +) + +// **A machine trusts the mesh's authority because a module put its root there** (novox/hq ADR +// 0147, issue 129). The module carries a shell script and a unit, and both are worthless unless +// the mesh fills in where the authority is — which is the one thing about it the manifest cannot +// state, because the authority's address is a fact about the mesh and not about the module. +// +// So what is checked here is the rendering, not the parsing: the script the machine will run +// names the authority it was bound to, and the unit runs that script both ways. The verification +// itself — a plain client trusting an internal name on a machine holding this, and failing on one +// that does not — is the lab's, and cannot be had here. +func TestCaTrustRendersTheAuthorityItWasBoundTo(t *testing.T) { + m, err := mcat.ReadTreeManifest(beside.Dir(t, "mesh-catalog"), filepath.Join(beside.Catalogue(t), "ca-trust")) + if err != nil { + t.Fatalf("the trust module does not parse:\n%v", err) + } + + r := mcat.Resolution{ + Node: "workstation", + Modules: []mcat.Manifest{m}, + Needs: []mcat.Needed{{ + Name: "internal-acme-ca", From: "anchor", At: "anchor.internal", For: "ca-trust", + Serves: map[string]any{ + "port": float64(9000), "path": "/acme/acme/directory", "roots": "/roots.pem", + }, + }}, + } + out, err := Declaration(r, mcat.Rendering{}) + if err != nil { + t.Fatalf("the trust module could not be composed for a machine: %v", err) + } + + script := fileNamed(out, "ca-trust.anchor") + if script == nil { + t.Fatalf("nothing writes the script the unit runs: %v", out) + } + body, _ := script["content"].(string) + if !strings.Contains(body, "https://anchor.internal:9000/roots.pem") { + t.Errorf("the script does not fetch from the authority it was bound to:\n%s", body) + } + if script["mode"] != "0755" { + t.Errorf("the script is written %v, which systemd cannot execute", script["mode"]) + } + + unit := fileNamed(out, "ca-trust.unit") + if unit == nil { + t.Fatalf("no unit: %v", out) + } + text, _ := unit["content"].(string) + // Both halves. A unit that only installs the anchor leaves a machine trusting an authority + // nobody assigned it to any more, which is the half issue 129 asked for by name. + for _, want := range []string{ + "ExecStart=" + script["path"].(string) + " install", + "ExecStop=" + script["path"].(string) + " remove", + "RemainAfterExit=yes", + } { + if !strings.Contains(text, want) { + t.Errorf("the unit does not say %q:\n%s", want, text) + } + } +} diff --git a/internal/compose/resolution/checker_seat.go b/internal/compose/resolution/checker_seat.go new file mode 100644 index 0000000..337ffa9 --- /dev/null +++ b/internal/compose/resolution/checker_seat.go @@ -0,0 +1,33 @@ +package resolution + +import ( + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// checkerVerbs are the checker seat's verbs: ask a try, read one, end one by hand. Optional until the checker serves them +// everywhere. +func checkerVerbs() []mcat.Verb { + return []mcat.Verb{ + {Name: "try", Optional: true, Description: "Try a pushed head before its pull request is opened: the checker " + + "judges it as it judges a pull request's head, merged onto its trunk's head, its unit and integration " + + "levels through the build modules, and owes the forge nothing. Answers the try's id at once; try-read " + + "reads where it stands. A try of the same head that waits or runs is the answer to a second ask; at most " + + "4 wait or run at once, mesh-wide.", + Input: schema(map[string]string{"repository": "/", + "head": "the full commit to try, 40 or 64 hex digits", + "trunk": "the trunk commit to merge it onto, or the trunk's branch for its head now; the trunk's head when absent", + "tests": "a go test -run pattern: only those tests run, and nothing is kept", + "level": "unit or integration: only this level; both when absent"}, + []string{"repository", "head"}), + Replaces: []string{"sh merge-check.sh", "docker run postgres"}}, + {Name: "try-read", Optional: true, Description: "Where a try stands, as stored: queued, running, or its end " + + "(pass, fail, or error when it could not run), with what was said of it, the head and the trunk commit " + + "it was merged onto, the time it took and what failed first. Read-only.", + Input: schema(map[string]string{"id": "the try's id, try-"}, []string{"id"})}, + {Name: "try-end", Optional: true, Description: "End a try that waits or runs, as error, by hand: say why (kept in " + + "what it says). Its place among the tries is free at once and the next try of its head asks anew with a new " + + "id. A try that ended already is refused.", + Input: schema(map[string]string{"id": "the try's id, try-", "why": "why it is ended, in words"}, + []string{"id", "why"})}, + } +} diff --git a/internal/compose/resolution/chosen.go b/internal/compose/resolution/chosen.go new file mode 100644 index 0000000..fffdd19 --- /dev/null +++ b/internal/compose/resolution/chosen.go @@ -0,0 +1,86 @@ +package resolution + +import ( + "sort" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// matches is whether this provider is the one chosen. +func chosenMatches(c mcat.Chosen, p mcat.Provider) bool { + return p.Node == c.Node && (c.Module == "" || p.Module == c.Module) +} + +// among is every offered provider the choice names — one, when the choice is whole. +func chosenAmong(c mcat.Chosen, where []mcat.Provider) []mcat.Provider { + var out []mcat.Provider + for _, p := range where { + if chosenMatches(c, p) { + out = append(out, p) + } + } + return out +} + +// nameOf is how a refusal names a provider: the node and the module on it. +func nameOf(p mcat.Provider) string { + return mcat.Chosen{Node: p.Node, Module: p.Module}.String() +} + +// providerNames is every provider named, sorted, for a refusal to list. +func providerNames(where []mcat.Provider) []string { + out := make([]string, 0, len(where)) + for _, p := range where { + out = append(out, nameOf(p)) + } + sort.Strings(out) + return out +} + +// providersHere is which modules in this node's own set offer a provision, sorted. +func providersHere(catalogue map[string]mcat.Manifest, here func(string) bool, want string) []string { + var out []string + for name, m := range catalogue { + if !here(name) { + continue + } + for _, o := range m.Offers() { + if o == want { + out = append(out, name) + break + } + } + } + sort.Strings(out) + return out +} + +// servedByOne is what one provider beside the consumer says a consumer needs to know, or nothing. +// +// Serving is *whether* a need is created at all when the provider is on this same machine (novox/hq +// 04-ISSUES/038's sibling): a need never created is a binding the consumer never gets. The manifest +// alone answers that; the values are settled later, with the node's settings. +func servedByOne(m mcat.Manifest, want string) map[string]any { + if _, ok := m.Serves[want]; ok { + return ServedOn(m, want, nil) + } + return nil +} + +// sharedByOne is the own secret that provider names as its credential (ADR 0158), or "" when it +// gives each consumer its own. +func sharedByOne(m mcat.Manifest, want string) string { + if own, shared := m.SharedCredentialOf(want); shared { + return own + } + return "" +} + +func oneOf(list []string, s string) bool { + for _, x := range list { + if x == s { + return true + } + } + return false +} diff --git a/internal/compose/resolution/co_located_test.go b/internal/compose/resolution/co_located_test.go new file mode 100644 index 0000000..0b39a17 --- /dev/null +++ b/internal/compose/resolution/co_located_test.go @@ -0,0 +1,306 @@ +package resolution + +import ( + "encoding/json" + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// A root certificate, as a certificate authority serves one: a real, self-signed one made for these tests (its key +// thrown away), because the one setting that may hold lines must parse as a certificate (novox/hq issue 339). +const servedRoot = `-----BEGIN CERTIFICATE----- +MIIBPjCB8aADAgECAhRZG3p93hUUB5bz00uxhYo/nJnTQjAFBgMrZXAwFDESMBAG +A1UEAwwJdGVzdCByb290MCAXDTI2MTAwODIyMjE0NloYDzIxMjYwOTE0MjIyMTQ2 +WjAUMRIwEAYDVQQDDAl0ZXN0IHJvb3QwKjAFBgMrZXADIQA0pt/ld+W0MXwBhPfO +cuAt56kIW6Qcn+4vqWpuvHTiqaNTMFEwHQYDVR0OBBYEFIjgaLk4OVGIOeZQiqnN +p9vhciFjMB8GA1UdIwQYMBaAFIjgaLk4OVGIOeZQiqnNp9vhciFjMA8GA1UdEwEB +/wQFMAMBAf8wBQYDK2VwA0EAl9uWeSM2XAV8u0reyV3BLRxNVik+4FCRO1QKPs2k +IlB1rK9oAOYManH+VFuBMI/JJ31ajSti81q4E0CSw8r5DQ== +-----END CERTIFICATE----- +` + +// stepCA is an internal ACME authority: it answers `acme-ca` from anywhere in the mesh, and what a +// consumer must know is the directory URL and the root to trust. **The root is not knowable when +// the module is written** — it exists only once the CA has been initialised — so the manifest +// declares the key and the operator supplies the value as a setting, which is exactly the shape the +// cross-node path settles and the same-node path did not. +func stepCA() mcat.Manifest { + return mcat.Manifest{ + Module: "step-ca", Version: "1", + Provides: mcat.FromAnywhere("acme-ca"), + Listens: []mcat.Listening{{Port: 9000, Protocol: "tcp", From: mcat.FromMesh}}, + Serves: map[string]map[string]any{"acme-ca": { + "directory": "https://anchor.internal/acme/acme/directory", + // Declared empty: a manifest is the same on every mesh, and this mesh's root is not. + "root": "", + }}, + Resources: []map[string]any{{ + "id": "server", "type": "container", "name": "step-ca", "ports": []any{"9000"}, + }}, + } +} + +// stepCAWithItsRoot is the authority whose root the operator supplies: the served key asks for it as +// `${setting:root}` (ADR 0155), the only way a setting reaches what a provider serves since a layer +// stopped replacing a served key (novox/hq issue 696; before it, the empty `root` was replaced whole). +func stepCAWithItsRoot() mcat.Manifest { + m := stepCA() + m.Serves["acme-ca"]["root"] = "${setting:root}" + return m +} + +// routeProxy issues from that authority, so it must be told the root to verify it with. +func routeProxy() mcat.Manifest { + return mcat.Manifest{ + Module: "route-proxy", Version: "1", + Requires: []string{"acme-ca"}, + Provides: mcat.FromAnywhere("route"), + Binds: map[string]string{"acme-ca": "/var/lib/route-proxy/acme-ca.json"}, + Receives: map[string]string{"route": "/var/lib/route-proxy/routes.json"}, + Resources: []map[string]any{{ + "id": "bundle", "type": "file", "path": "/var/lib/route-proxy/ca.pem", "mode": "0644", + "content": "${bound:acme-ca:root}", + }}, + } +} + +// A co-located provider's served VALUES reach its consumer, not just its served keys. +// +// The mesh walks a provider on ANOTHER machine and settles what it serves with that node's settings +// layers before offering it (cmd/mesh-controller plan.go, theRestOfTheMesh). A provider on the +// consumer's own machine was never settled at all: resolve.go's servedHere and declaration.go's +// here() both read the manifest and stop there. So a served value the operator supplied — the one +// kind of value a manifest cannot carry, because it is different on every mesh — arrived as the +// manifest's empty default. +// +// The consequence is silent and total: route-proxy wrote an empty CA bundle, fell back to the +// system trust store, could not verify the internal authority, and no certificate was ever issued. +func TestACoLocatedProvidersServedValuesReachItsConsumer(t *testing.T) { + r, err := Resolve(shelf(stepCAWithItsRoot(), routeProxy()), + []string{"step-ca", "route-proxy"}, reachable(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + + // What the operator set on the provider, on this node — the CA's root, which only exists once + // the CA has been initialised. + out, err := Declaration(r, mcat.Rendering{Settings: mcat.SettingsBy{ + "step-ca": {{From: "the operator", Values: map[string]any{"root": servedRoot}}}, + }}) + if err != nil { + t.Fatal(err) + } + + bundle := fileNamed(out, "route-proxy.bundle") + if bundle == nil { + t.Fatalf("the consumer was given no bundle at all: %v", out) + } + if got := bundle["content"]; got != servedRoot { + t.Errorf("the co-located consumer was not told the root it must trust:\n got %q\nwant %q", + got, servedRoot) + } +} + +// And the binding file says the same thing, for a consumer that reads the binding rather than a +// substituted placeholder. The two are one fact and must not be able to disagree. +func TestACoLocatedConsumersBindingCarriesTheSettledValues(t *testing.T) { + r, err := Resolve(shelf(stepCAWithItsRoot(), routeProxy()), + []string{"step-ca", "route-proxy"}, reachable(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + out, err := Declaration(r, mcat.Rendering{ + Settings: mcat.SettingsBy{ + "step-ca": {{From: "the operator", Values: map[string]any{"root": servedRoot}}}, + }, + // And the machine moved the provider's port while it was at it, so both halves of the + // cross-node derivation are exercised at once. + Ports: map[string]map[int]int{"step-ca": {9000: 19000}}, + }) + if err != nil { + t.Fatal(err) + } + + binding := fileNamed(out, "route-proxy.bound-acme-ca") + if binding == nil { + t.Fatalf("the consumer was given no binding at all: %v", out) + } + var said struct { + Serves map[string]any `json:"serves"` + } + if err := json.Unmarshal([]byte(binding["content"].(string)), &said); err != nil { + t.Fatal(err) + } + if said.Serves["root"] != servedRoot { + t.Errorf("the binding does not carry the settled root: %q", said.Serves["root"]) + } + if port, _ := asPort(said.Serves["port"]); port != 19000 { + t.Errorf("the binding does not carry the port the machine publishes: %v", said.Serves["port"]) + } +} + +// A provider PULLED IN rather than assigned is settled the same way. +// +// The resolver's same-node need is built during the walk, from whichever modules had been chosen by +// the time the requirement came up — so which path a co-located binding took depended on the order +// a person happened to assign things in. Settling after the closure is known removes that: both +// orders now produce the same file. +func TestACoLocatedProviderIsSettledWhicheverWayItWasPulledIn(t *testing.T) { + for _, assigned := range [][]string{ + {"step-ca", "route-proxy"}, + {"route-proxy", "step-ca"}, + } { + r, err := Resolve(shelf(stepCAWithItsRoot(), routeProxy()), assigned, reachable(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + out, err := Declaration(r, mcat.Rendering{Settings: mcat.SettingsBy{ + "step-ca": {{From: "the operator", Values: map[string]any{"root": servedRoot}}}, + }}) + if err != nil { + t.Fatal(err) + } + bundle := fileNamed(out, "route-proxy.bundle") + if bundle == nil || bundle["content"] != servedRoot { + t.Errorf("assigned %v: the consumer was not told the root", assigned) + } + } +} + +// A same-node contribution names the port the MACHINE publishes, not the one the module declared. +// +// 04-ISSUES/038 fixed this for what a consumer is TOLD about a provider. This is the other +// direction: what a workload TELLS the provider about itself. gitea declares a bare container port +// 3000, the mesh publishes it as 20000:3000 — and gitea's route contribution still said 3000, so +// the proxy beside it dialled a port nothing listens on and answered 502 for every request. +// +// The redirect uses the CONTRIBUTING module's assignment: the port belongs to the workload, and +// using the provider's map would move it to wherever the proxy happens to be published. +func TestASameNodeContributionNamesThePortTheMachinePublishes(t *testing.T) { + gitea := mcat.Manifest{ + Module: "gitea", Version: "1", + Listens: []mcat.Listening{{Port: 3000, Protocol: "tcp", From: mcat.FromMesh}}, + Contributes: map[string]map[string]any{"route": {"label": "git", "port": 3000}}, + Resources: []map[string]any{{ + "id": "server", "type": "container", "name": "gitea", "ports": []any{"3000"}, + }}, + } + r, err := Resolve(shelf(gitea, routeProxy(), stepCA()), + []string{"gitea", "route-proxy", "step-ca"}, reachable(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + out, err := Declaration(r, mcat.Rendering{ + // The machine put gitea's 3000 on 20000, and published it that way. + Ports: map[string]map[int]int{"gitea": {3000: 20000}}, + }) + if err != nil { + t.Fatal(err) + } + + // Where the workload is actually published. + server := fileNamed(out, "gitea.server") + if published := strings.Join(asStrings(server["ports"]), ","); published != "20000:3000" { + t.Fatalf("the workload was not published on the assigned port: %v", server["ports"]) + } + + // What the proxy beside it was told to dial: the SAME port. + routes := fileNamed(out, "route-proxy.received-route") + if routes == nil { + t.Fatalf("the proxy was given no routes file at all: %v", out) + } + var given struct { + Given []mcat.Contribution `json:"given"` + } + if err := json.Unmarshal([]byte(routes["content"].(string)), &given); err != nil { + t.Fatal(err) + } + if len(given.Given) != 1 { + t.Fatalf("expected one route, got %v", given.Given) + } + port, ok := asPort(given.Given[0].Values["port"]) + if !ok || port != 20000 { + t.Errorf("the proxy was told to dial a port nothing listens on: %v", + given.Given[0].Values["port"]) + } +} + +// A contribution from ANOTHER machine is left exactly as it was. +// +// Its port belongs to that machine's assignment, which this node's map knows nothing about — +// applying this node's map to it would move a remote workload's port to wherever a local module of +// the same name happens to be published, which is worse than the fault being fixed. +func TestAContributionFromAnotherMachineKeepsItsOwnPort(t *testing.T) { + r, err := Resolve(shelf(routeProxy(), stepCA()), + []string{"route-proxy", "step-ca"}, reachable(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + out, err := Declaration(r, mcat.Rendering{ + // A same-named module on this machine, moved. The grant below is a laptop's, and must not + // be dragged along with it. + Ports: map[string]map[int]int{"gitea": {3000: 20000}}, + Grants: []mcat.Grant{{ + Provision: "route", Consumer: "laptop", From: "gitea", At: "laptop.internal", + Values: map[string]any{"name": "git.example", "port": 3000}, + }}, + }) + if err != nil { + t.Fatal(err) + } + routes := fileNamed(out, "route-proxy.received-route") + var given struct { + Given []mcat.Contribution `json:"given"` + } + if err := json.Unmarshal([]byte(routes["content"].(string)), &given); err != nil { + t.Fatal(err) + } + if len(given.Given) != 1 { + t.Fatalf("expected one route, got %v", given.Given) + } + if port, _ := asPort(given.Given[0].Values["port"]); port != 3000 { + t.Errorf("another machine's contribution was rewritten with this machine's ports: %v", + given.Given[0].Values["port"]) + } +} + +// The cross-node half of the same fault: a grant assembled on the providing machine carries the +// consumer's declared port until the CONSUMER's machine's assignments are applied to it. The +// declaration layer cannot do it — those assignments are not this machine's, which is the line the +// test above holds — so the grant layer is handed them and does it there. +func TestAContributionIsRedirectedToWhereItsOwnMachinePublishedIt(t *testing.T) { + published := map[int]int{3000: 20000} + + got := AtPublishedPort(map[string]any{"name": "git.example.tld", "port": 3000}, "forge", published) + if port, _ := asPort(got["port"]); port != 20000 { + t.Errorf("the proxy would dial a port that machine never published: %v", got["port"]) + } + if got["name"] != "git.example.tld" { + t.Errorf("redirecting the port disturbed the rest of the contribution: %v", got) + } + + // Idempotent, and silent about a port nobody moved: a module that pinned its own mapping has no + // assignment, and must come back exactly as it was written. + again := AtPublishedPort(got, "forge", published) + if port, _ := asPort(again["port"]); port != 20000 { + t.Errorf("applying it twice moved the port again: %v", again["port"]) + } + pinned := AtPublishedPort(map[string]any{"port": 9070}, "office", published) + if port, _ := asPort(pinned["port"]); port != 9070 { + t.Errorf("a port the mesh never assigned was rewritten: %v", pinned["port"]) + } +} + +func asStrings(v any) []string { + listed, ok := v.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(listed)) + for _, one := range listed { + out = append(out, strings.TrimSpace(plainly(one))) + } + return out +} diff --git a/internal/compose/resolution/collections.go b/internal/compose/resolution/collections.go new file mode 100644 index 0000000..472fc46 --- /dev/null +++ b/internal/compose/resolution/collections.go @@ -0,0 +1,312 @@ +package resolution + +import ( + "fmt" + "regexp" + "slices" + "sort" + "strconv" + "strings" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +func itemKey(it mcat.Item) string { return mcat.ItemKey(it.Collection, it.Name) } + +// order is the item's place among the items of one placeholder: its `order` attribute, or OrderDefault. +func itemOrder(it mcat.Item) int { + if n, err := strconv.Atoi(it.Attributes[mcat.OrderAttribute]); err == nil && n >= 0 && n <= 99 { + return n + } + return mcat.OrderDefault +} + +// matches is whether the item's attributes carry every pair of the selector. +func itemMatches(it mcat.Item, where map[string]string) bool { + for k, v := range where { + if got, has := it.Attributes[k]; !has || got != v { + return false + } + } + return true +} + +// itemsInOrder is the module's items by order, then name; the order the old form placed a module's pieces in. +func itemsInOrder(items []mcat.Item) []mcat.Item { + out := append([]mcat.Item(nil), items...) + sort.SliceStable(out, func(a, b int) bool { + if oa, ob := itemOrder(out[a]), itemOrder(out[b]); oa != ob { + return oa < ob + } + return out[a].Name < out[b].Name + }) + return out +} + +// oldCollectionNamespaces are the namespaces read as collections of their own name. +var oldCollectionNamespaces = []string{"shell", "environment", "contribution"} + +// ofCollected is where a collector places a collection. Loose inside the braces, so a misspelt name is refused. +var ofCollected = regexp.MustCompile(`\$\{collected:([^}]*)\}`) + +var attributeKey = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) + +var attributeValue = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`) + +// splitSelector is a placeholder's name without its selector, and the selector: `widgets:bar=top,place=left` is +// `widgets` and `bar=top,place=left`. A collection's name holds no `=`, so the selector begins at the last colon +// before the first one. +func splitSelector(name string) (collection, selector string) { + eq := strings.Index(name, "=") + if eq < 0 { + return name, "" + } + colon := strings.LastIndex(name[:eq], ":") + if colon < 0 { + return name, "" + } + return name[:colon], name[colon+1:] +} + +// selectorOf is a selector's pairs, or why it is none. +func selectorOf(selector string) (map[string]string, error) { + out := map[string]string{} + if selector == "" { + return out, nil + } + for _, pair := range strings.Split(selector, ",") { + k, v, ok := strings.Cut(pair, "=") + switch { + case !ok || !attributeKey.MatchString(k) || !attributeValue.MatchString(v): + return nil, fmt.Errorf("%q in %q is not =", pair, selector) + case out[k] != "": + return nil, fmt.Errorf("%q names %s twice", selector, k) + } + out[k] = v + } + return out, nil +} + +// collectionOfPlaceholder is the collection a placeholder's namespace and key name, and whether they name one. +func collectionOfPlaceholder(namespace, key string) (string, bool) { + key, _ = splitSelector(key) + switch { + case namespace == mcat.CollectedNamespace && strings.HasPrefix(key, mcat.EnvironmentCollection+":"): + // The environment is one collection, and the rest of the name picks the rendering (issue 795). + return mcat.EnvironmentCollection, true + case namespace == mcat.CollectedNamespace: + return key, true + case namespace == "environment": + return mcat.EnvironmentCollection, true + case slices.Contains(oldCollectionNamespaces, namespace): + return namespace + ":" + key, true + } + return "", false +} + +// oldCollection is an old namespace's collection by its name: its comment, or why no item may be contributed to it. +// ok is false for a name that is not an old namespace's. +// +// forItem is whether an item is to be contributed to it: an item may be contributed to a kind received as data, +// which a collector may place as a collection only where its shape says its pieces are text (design 57 L3). +func oldCollection(name string, forItem bool) (comment string, old bool, wrong error) { + namespace, key, _ := strings.Cut(name, ":") + if !slices.Contains(oldCollectionNamespaces, namespace) { + return "", false, nil + } + switch namespace { + case "shell": + target, slot, two := strings.Cut(key, ":") + if !two || !oneOf(contributionTargets(), target) || !oneOf(knownSlots, slot) { + return "", true, fmt.Errorf("%s is no slot: shell code is shell::, the shell one of %s, "+ + "the session's file one of %s or the power moment one of %s, and the slot one of %s", name, + strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", "), strings.Join(powerMoments, ", "), + strings.Join(knownSlots, ", ")) + } + // shellCode's line before each module's code. + return "#", true, nil + case "environment": + if key == "" { + // The collection itself, which an item is contributed to; a placeholder names a rendering. + return "#", true, nil + } + if key != mcat.EnvironmentPOSIX && key != mcat.EnvironmentSystemd { + return "", true, fmt.Errorf("%s is no format: the environment is environment:%s or environment:%s", + name, mcat.EnvironmentPOSIX, mcat.EnvironmentSystemd) + } + return "#", true, nil + } + seat, kind, _ := strings.Cut(key, ":") + s, r, ok := receivable(seat, kind) + switch { + case !ok && s.Name == "": + return "", true, fmt.Errorf("%s names the seat %q, which the mesh does not define", name, seat) + case !ok: + return "", true, fmt.Errorf("%s: %s; %s", name, s.Name, kindsOf(s)) + case r.Shape != nil && (forItem || collectableAsText(r)): + return r.Comment, true, nil + case r.Shape != nil: + return "", true, fmt.Errorf("%s is received by %s as data in its own shape, which no item of text is "+ + "(novox/hq ADR 0255)", name, s.Name) + case r.Dirs: + return "", true, fmt.Errorf("%s names its contributors' directories, which the holder reads as paths "+ + "the controller fills; it is not yet a collection (design 57 L1)", name) + case s.Name != canonicalSeat(seat): + return "", true, fmt.Errorf("%s names the seat by another name; a collection names it %s", name, s.Name) + } + return r.Comment, true, nil +} + +// isOld is whether a collection's name is an old namespace's. +func isOld(name string) bool { + _, old, _ := oldCollection(name, true) + return old +} + +// collectedPlaceholderProblems is what is wrong with one resource's ${collected:…}: placed only in a file's content, +// and naming a collection the module declares it collects. +func collectedPlaceholderProblems(m mcat.Manifest, r map[string]any) []string { + var problems []string + for _, field := range sortedKeys(r) { + v, ok := r[field].(string) + if !ok { + continue + } + for _, f := range ofCollected.FindAllStringSubmatch(v, -1) { + base, selector := splitSelector(f[1]) + if _, err := selectorOf(selector); err != nil { + problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], err)) + continue + } + switch { + case field != "content": + problems = append(problems, fmt.Sprintf("%s's resource %v names %s in its %s; a collection is placed "+ + "only in a file's content", m.Module, r["id"], f[0], field)) + case isOld(base): + if _, _, wrong := oldCollection(base, false); wrong != nil { + problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], wrong)) + } + case !slices.ContainsFunc(m.Collections, func(c mcat.Collection) bool { return c.Name == base }): + problems = append(problems, fmt.Sprintf("%s's resource %v names %s, and %s does not collect %q: "+ + "declare it under collections (novox/hq ADR 0349 §13)", m.Module, r["id"], f[0], m.Module, base)) + } + } + } + return problems +} + +// collectionComment is the comment of a collection a module's file names: declared, or the old namespace's. +func collectionComment(m mcat.Manifest, name string) (string, error) { + if comment, old, wrong := oldCollection(name, false); old { + return comment, wrong + } + for _, c := range m.Collections { + if c.Name == name { + return c.Comment, nil + } + } + return "", fmt.Errorf("%s names ${collected:%s} and does not collect it", m.Module, name) +} + +// itemsLeftOutFor is itemsLeftOut for a placeholder that selects by attributes (where), on a machine with these +// capabilities: an item the selector does not take, or whose capability the machine lacks, is not left out but not +// wanted here. +func itemsLeftOutFor(modules []mcat.Manifest, holder mcat.Manifest, collection string, where map[string]string, + facts map[string]string, caps map[string]bool) []string { + var out []string + for _, m := range inModuleOrder(modules) { + for _, it := range itemsInOrder(m.Items) { + if it.Collection != collection || !itemMatches(it, where) || (it.IfCapability != "" && !caps[it.IfCapability]) { + continue + } + if _, rendered := heldText(it, facts); rendered && renderedOldWay(collection) { + continue + } + out = append(out, fmt.Sprintf("%s's item of %s is not placed: %s is filled the old way on this "+ + "machine, from contributions, and an item only by the provisioner's client (design 57 L1)", + m.Module, itemKey(it), holder.Module)) + } + } + return out +} + +// oldFormsLeftOut says each piece of an old form a handed collector's collections would have held: the collector is +// filled from items alone, and the piece's module contributes none (ADR 0349 clause 17). +func oldFormsLeftOut(modules []mcat.Manifest, holder mcat.Manifest, collections []mcat.PlanCollection) []string { + var out []string + for _, c := range collections { + namespace, key, _ := strings.Cut(c.Name, ":") + for _, m := range inModuleOrder(modules) { + old := false + switch namespace { + case "shell": + target, slot, _ := strings.Cut(key, ":") + old = slices.ContainsFunc(m.Shell, func(s mcat.ShellCode) bool { return s.For == target && s.Slot == slot }) + case "environment": + old = m.Environment != nil && (len(m.Environment.Variables) > 0 || len(m.Environment.Path) > 0) + case "contribution": + seat, kind, _ := strings.Cut(key, ":") + old = slices.ContainsFunc(manifestAllContributions(m), func(s mcat.SeatContribution) bool { + return canonicalSeat(s.Seat) == canonicalSeat(seat) && s.Kind == kind + }) + } + if old { + out = append(out, fmt.Sprintf("%s's %s is not placed: %s is filled by the provisioner's client on "+ + "this machine, from items, and %s contributes it the old way (design 57 L1)", m.Module, c.Name, + holder.Module, m.Module)) + } + } + } + return out +} + +// renderedOldWay is whether the controller renders a collection's items into a collector not handed. +func renderedOldWay(collection string) bool { + namespace, _, _ := strings.Cut(collection, ":") + return namespace == "shell" || namespace == "contribution" || namespace == mcat.EnvironmentCollection +} + +// heldItem is one item of a module rendered the old way. +type heldItem struct { + item mcat.Item + text string +} + +// heldItems is a module's items of the collection that the selector takes and the machine's capabilities allow, by +// order then name, each rendered as heldText does; the ones it cannot render are left out (itemsLeftOutFor says so). +func heldItems(m mcat.Manifest, collection string, where map[string]string, facts map[string]string, + caps map[string]bool) []heldItem { + if !renderedOldWay(collection) { + return nil + } + var out []heldItem + for _, it := range itemsInOrder(m.Items) { + if it.Collection != collection || !itemMatches(it, where) || (it.IfCapability != "" && !caps[it.IfCapability]) { + continue + } + if text, ok := heldText(it, facts); ok { + out = append(out, heldItem{item: it, text: text}) + } + } + return out +} + +// heldText is an item's text with the machine's facts filled, ending its line; false for an item whose text the mesh +// does not hold or which names anything but a machine fact this machine has. +func heldText(it mcat.Item, facts map[string]string) (string, bool) { + if it.Content == "" { + return "", false + } + text := it.Content + for _, f := range templatePlaceholder.FindAllStringSubmatch(text, -1) { + value, has := facts[f[2]] + if f[1] != "machine" || !has { + return "", false + } + text = strings.ReplaceAll(text, f[0], value) + } + if !strings.HasSuffix(text, "\n") { + text += "\n" + } + return text, true +} diff --git a/internal/compose/resolution/collections_bar_test.go b/internal/compose/resolution/collections_bar_test.go new file mode 100644 index 0000000..c00debd --- /dev/null +++ b/internal/compose/resolution/collections_bar_test.go @@ -0,0 +1,110 @@ +package resolution + +import ( + "os" + "path/filepath" + "reflect" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +const collectedBarsText = "top\n${collected:contribution:node-bar:block:bar=top,place=status}" + + "bottom\n${collected:contribution:node-bar:block:bar=bottom,place=status}" + +const collectedBars = `{"module":"bars","version":"1","moved":true, + "claims":[{"name":"node-bar","scope":"node","renders":{"block":"{{.shows}}"}}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[{"id":"conf","type":"file","path":"/etc/bars.conf","template":"files/bars"}]}` + +const itemsOfPower = `{"module":"power","version":"1","moved":true, + "items":[{"collection":"contribution:node-bar:block","name":"battery","if-capability":"battery","attributes":{"bar":"bottom","place":"status","order":"40"},"template":"files/battery"}, + {"collection":"contribution:node-bar:block","name":"draw","attributes":{"bar":"bottom","place":"status","order":"45"},"template":"files/draw"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}` + +func collectedBarsOn(t *testing.T, caps map[string]bool) mcat.Resolution { + t.Helper() + bars := held(t, collectedBars, builtWith("bars", map[string]string{"bars": collectedBarsText})) + power := held(t, itemsOfPower, builtWith("power", map[string]string{"battery": "B\n", "draw": "D ${machine:name}\n"})) + r, err := Resolve(shelf(bars, power), []string{"bars", "power"}, workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + r.Account, r.Capabilities = "op", caps + return r +} + +func TestACollectorMayNameTheBarsBlocksAsACollectionAndNoOtherDataKind(t *testing.T) { + bars := held(t, collectedBars, builtWith("bars", map[string]string{"bars": collectedBarsText})) + if _, err := Compose(collectedBarsOn(t, nil), handingTo("store:5000")); err != nil { + t.Fatalf("the handed collector is refused: %v", err) + } + if got := collectedPlaceholderProblems(bars, map[string]any{"id": "x", + "content": "${collected:contribution:node-bar:block:bar=top,place=status}"}); len(got) != 0 { + t.Fatalf("the check refuses it: %v", got) + } + // A data kind whose shape does not say its items are text stays refused. + _, plain, _ := receivable(mcat.BarSeat, mcat.BarKindBlock) + plain.Shape = &mcat.Shape{} + if collectableAsText(plain) { + t.Fatal("a shape not marked as text is collectable") + } + if _, _, err := oldCollection("contribution:node-bar:block", false); err != nil { + t.Fatal(err) + } +} + +// The bars of the catalogue's i3status-rust, with the blocks of power's items, compose to the files the old shaped +// contribution made (issue 781). testdata/bars holds the catalogue's bars and block texts as of the move and the bars as +// composed before it, for a machine with neither capability, the battery, the power meter and both. Where the +// collector is not handed the controller fills the files; where it is, it names the items for the client, which +// fills the same files (mesh-catalog's provisioner client holds that half against the same golden files). +func TestTheBarsOfTheCatalogueAreTheSameHandedOrNot(t *testing.T) { + read := func(name string) string { + b, err := os.ReadFile(filepath.Join("testdata", "bars", name)) + if err != nil { + t.Fatal(err) + } + return string(b) + } + collector := `{"module":"bars","version":"1","moved":true,"claims":[{"name":"node-bar","scope":"node"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[{"id":"top","type":"file","path":"/t","template":"files/top"}, + {"id":"bottom","type":"file","path":"/b","template":"files/bottom"}]}` + power := `{"module":"power","version":"1","moved":true, + "items":[{"collection":"contribution:node-bar:block","name":"battery","if-capability":"battery","attributes":{"bar":"bottom","place":"status","order":"40"},"template":"files/battery"}, + {"collection":"contribution:node-bar:block","name":"power-draw","if-capability":"power-meter","attributes":{"bar":"bottom","place":"status","order":"45"},"template":"files/draw"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}` + for name, caps := range map[string]map[string]bool{"none": nil, "battery": {"battery": true}, + "power-meter": {"power-meter": true}, "both": {"battery": true, "power-meter": true}} { + bars := held(t, collector, builtWith("bars", map[string]string{"top": read("top-bar.toml"), "bottom": read("bottom-bar.toml")})) + blocks := held(t, power, builtWith("power", map[string]string{"battery": read("bar-battery"), "draw": read("bar-power-draw")})) + r, err := Resolve(shelf(bars, blocks), []string{"bars", "power"}, workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + r.Account, r.Capabilities = "op", caps + // Not handed: the controller fills them. + for id, which := range map[string]string{"bars.top": "top", "bars.bottom": "bottom"} { + if got := composedFile(t, r, id); got != read(name+"."+which+"-bar.toml") { + t.Errorf("%s, %s bar, not handed:\n%s\nwant\n%s", name, which, got, read(name+"."+which+"-bar.toml")) + } + } + // Handed: the plan names the items the machine's capabilities allow, for the client to fill. + c, _ := composeWith(t, r, handingTo("store:5000")) + var names []string + for _, it := range c.Plans["power"].Items { + names = append(names, it.Name) + } + want := []string{} + if caps["battery"] { + want = append(want, "battery") + } + if caps["power-meter"] { + want = append(want, "power-draw") + } + if !reflect.DeepEqual(append([]string{}, names...), want) { + t.Errorf("%s: the plan names %v, want %v", name, names, want) + } + } +} diff --git a/internal/compose/resolution/collections_l2_test.go b/internal/compose/resolution/collections_l2_test.go new file mode 100644 index 0000000..cda4d55 --- /dev/null +++ b/internal/compose/resolution/collections_l2_test.go @@ -0,0 +1,57 @@ +package resolution + +import ( + "os" + "path/filepath" + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +func l2Moved(t *testing.T, parts ...string) string { + t.Helper() + raw, err := os.ReadFile(filepath.Join(append([]string{"testdata", "l2-moved"}, parts...)...)) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +func TestTheCollectorsL2MovedComposeAsBeforeWhereNothingIsHanded(t *testing.T) { + // Each moved contributor, its items read back from its templates archive as the mesh holds them. + items := map[string][]string{ + "asus-zephyrus-g14": {"shell:after-wake:normal", "contribution:node-hotkeys:trigger"}, + "claude-code": {"shell:zsh:normal"}, + "mesh-cli": {"shell:zsh:normal"}, + "powerlevel10k": {"shell:zsh:normal"}, + "zsh-autosuggestions": {"shell:zsh:normal"}, + "zsh-syntax-highlighting": {"shell:zsh:last"}, + } + var modules []mcat.Manifest + for _, module := range sortedKeys(items) { + m := mcat.Manifest{Module: module, Moved: true} + for _, collection := range items[module] { + m.Items = append(m.Items, mcat.Item{Collection: collection, + Content: l2Moved(t, "items", module+"."+strings.ReplaceAll(collection, ":", "-"))}) + } + modules = append(modules, m) + } + for _, c := range []struct{ module, seat string }{{"zsh", mcat.LoginShellSeat}, {"triggerhappy", mcat.HotkeysSeat}, + {"power", mcat.PowerSeat}} { + holder := mcat.Manifest{Module: c.module, Moved: true, Claims: []mcat.Claim{{Name: c.seat}}} + resource := map[string]any{"id": "file", "type": "file", "path": "/etc/" + c.module, + "content": l2Moved(t, "templates", c.module)} + var unplaced []string + if err := contributionsInto(resource, holder, append([]mcat.Manifest{holder}, modules...), + map[string]string{"name": "g14"}, mcat.Rendering{}, map[string]bool{}, &unplaced); err != nil { + t.Fatalf("%s: %v", c.module, err) + } + if got, want := resource["content"], l2Moved(t, "want", c.module); got != want { + t.Errorf("%s's file is not what the old forms made:\n%s\nwant:\n%s", c.module, got, want) + } + if len(unplaced) != 0 { + t.Errorf("%s: unplaced %v", c.module, unplaced) + } + } +} diff --git a/internal/compose/resolution/collections_l3_test.go b/internal/compose/resolution/collections_l3_test.go new file mode 100644 index 0000000..8b66048 --- /dev/null +++ b/internal/compose/resolution/collections_l3_test.go @@ -0,0 +1,36 @@ +package resolution + +import ( + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +const barCollector = `{"module":"bars","version":"1","moved":true, + "collections":[{"name":"blocks","comment":"#"}], + "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, + "resources":[{"id":"conf","type":"file","path":"/etc/bars.conf","template":"files/bars"}]}` + +func TestASelectorIsRefusedWhenMalformedOrNamingACollectionNotCollected(t *testing.T) { + for placeholder, want := range map[string]string{ + "${collected:blocks:bar}": "", // no equals sign: a collection named blocks:bar + "${collected:blocks:bar=top,bar=bottom}": "names bar twice", + "${collected:blocks:bar=}": "is not =", + "${collected:other:bar=top}": `does not collect "other"`, + } { + raw := strings.Replace(barCollector, `"template":"files/bars"`, `"content":"`+placeholder+`"`, 1) + raw = strings.Replace(raw, `"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},`, "", 1) + raw = strings.Replace(raw, `"moved":true,`, "", 1) + m, err := decodeForTest([]byte(raw)) + if err == nil { + _, err = Declaration((mcat.Resolution{Node: "n", Modules: []mcat.Manifest{m}}), mcat.Rendering{}) + } + if want == "" { + continue + } + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: %v, want %q", placeholder, err, want) + } + } +} diff --git a/internal/compose/resolution/collections_test.go b/internal/compose/resolution/collections_test.go new file mode 100644 index 0000000..006624d --- /dev/null +++ b/internal/compose/resolution/collections_test.go @@ -0,0 +1,13 @@ +package resolution + +import ( + "strings" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +func builtWith(module string, texts map[string]string) []mcat.Built { + return []mcat.Built{{Name: "files", Kind: mcat.ArtifactArchive, + Reference: mcat.ArtifactStoreScheme + module + "/files/blobs/sha256:" + strings.Repeat("e", 64), + Digest: "sha256:" + strings.Repeat("e", 64), Texts: texts}} +} diff --git a/internal/compose/resolution/computed_test.go b/internal/compose/resolution/computed_test.go new file mode 100644 index 0000000..ed7e4f7 --- /dev/null +++ b/internal/compose/resolution/computed_test.go @@ -0,0 +1,184 @@ +package resolution + +import ( + "fmt" + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +type fake struct { + on map[string]bool + asked []string + err error +} + +func (f *fake) Resources(node string) ([]map[string]any, bool, error) { + f.asked = append(f.asked, node) + if f.err != nil { + return nil, false, f.err + } + if !f.on[node] { + return nil, false, nil + } + return []map[string]any{{"id": "peers", "type": "file", "path": "/etc/x.conf", + "merge": mcat.MergeJSON, "content": `{"peer":"` + node + `"}`}}, true, nil +} + +func computedShelf() map[string]mcat.Manifest { + return shelf(mcat.Manifest{Module: "mesh-network", Computed: "mesh-network", + Provides: mcat.Offers("private-network")}) +} + +func TestAComputedModuleIsAskedAboutTheNodeItIsFor(t *testing.T) { + // The generator gets a node name, not a plan. Everything it needs is the whole mesh, which + // it was built with — this is the one thing a node could never work out for itself. + got, err := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + gen := &fake{on: map[string]bool{"workstation": true}} + out, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": gen}}) + if err != nil { + t.Fatal(err) + } + if len(gen.asked) != 1 || gen.asked[0] != "workstation" { + t.Fatalf("asked about %v, wanted workstation once", gen.asked) + } + if len(out) != 1 || !strings.Contains(out[0]["content"].(string), `"peer": "workstation"`) { + t.Fatalf("got %v", out) + } +} + +func TestAMachineNobodyGaveItGetsNothing(t *testing.T) { + // The whole point of making the network a module. Before this, every machine with an address + // was on the private network and there was no way to say one should stay off. + got, err := Resolve(computedShelf(), nil, workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + gen := &fake{on: map[string]bool{"workstation": true}} + out, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": gen}}) + if err != nil { + t.Fatal(err) + } + if len(out) != 0 { + t.Fatalf("a machine that was assigned nothing got %d resource(s)", len(out)) + } + if len(gen.asked) != 0 { + t.Fatalf("the generator was asked about %v, and nobody had asked for it", gen.asked) + } +} + +func TestAssignedAndNotYetPartOfItIsNotAFailure(t *testing.T) { + // A machine given the network module before it has a place on it. Brief and ordinary — the + // answer is "nothing yet", and treating it as an error would make an ordering a fault. + got, _ := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), mcat.World{}) + gen := &fake{on: map[string]bool{}} + out, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": gen}}) + if err != nil { + t.Fatalf("refused a node that is not on the network yet: %v", err) + } + if len(out) != 0 { + t.Fatalf("got %v", out) + } +} + +func TestAGeneratorThisControlPlaneDoesNotHaveIsRefused(t *testing.T) { + // Sending a machine a module with no files would look like it worked. Named in the message, + // because the only fix is a control plane that has it. + got, _ := Resolve(shelf(mcat.Manifest{Module: "weather", Computed: "the-weather"}), []string{"weather"}, workstation(), mcat.World{}) + + _, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": &fake{}}}) + if err == nil { + t.Fatal("a module computed by nothing was accepted") + } + if !strings.Contains(err.Error(), "the-weather") { + t.Fatalf("the refusal does not name what is missing: %v", err) + } +} + +func TestSettingsApplyToAComputedModuleToo(t *testing.T) { + // Being computed is about where the files come from, not about whether they are configurable. + // A line drawn there would be arbitrary and nobody could predict it. + got, _ := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), mcat.World{}) + gen := &fake{on: map[string]bool{"workstation": true}} + out, err := Declaration(got, mcat.Rendering{ + Generators: map[string]mcat.Generator{"mesh-network": gen}, + Settings: mcat.SettingsBy{"mesh-network": {{From: "the mesh", + Values: map[string]any{"keepalive": 25}}}}, + }) + if err != nil { + t.Fatal(err) + } + content := out[0]["content"].(string) + if !strings.Contains(content, `"keepalive": 25`) { + t.Fatalf("the setting did not reach a computed file: %s", content) + } + if !strings.Contains(content, `"peer": "workstation"`) { + t.Fatalf("the setting replaced what the generator computed: %s", content) + } +} + +func TestTwoWaysToBeOnAPrivateNetworkRefuseAndNameBoth(t *testing.T) { + // WireGuard is one way. The refusing rule is what makes a second one safe to add: assigning + // both is caught rather than producing a machine on two networks that each half-work. + _, err := Resolve(shelf(mcat.Manifest{Module: "mesh-network", Computed: "mesh-network", + Provides: mcat.Offers("private-network")}, mcat.Manifest{Module: "tailscale", Provides: mcat.Offers("private-network")}, mcat.Manifest{Module: "backups", Requires: []string{"private-network"}}, + ), []string{"backups"}, workstation(), mcat.World{}) + + if err == nil { + t.Fatal("two answers to one requirement were taken silently") + } + for _, want := range []string{"mesh-network", "tailscale", "private-network"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("the refusal does not name %s: %v", want, err) + } + } +} + +// reloading answers the runtime's trust as the networking module once did (novox/hq ADR 0102; it no +// longer does, ADR 0222 — and beside the runtime's module it is refused, generated_collision_test): a file +// written into, and the runtime reloaded on it. +type reloading struct{} + +func (reloading) Resources(node string) ([]map[string]any, bool, error) { + return []map[string]any{ + {"id": "registry-trust", "type": "file", "path": "/etc/docker/daemon.json", + "merge": mcat.MergeJSON, "into": "json", "content": `{"insecure-registries":["r:5000"]}`}, + {"id": "registry-trust-reload", "type": "service", "unit": "docker.service", + "state": "running", "reload-on": []string{"registry-trust"}}, + }, true, nil +} + +func TestWhatAServiceIsReloadedOnIsNamedAsTheHostWillSeeIt(t *testing.T) { + // Ids are prefixed with their module on the way out. An unprefixed reload-on would name a + // resource the host never sees, and the runtime would never be reloaded for its trust. + got, err := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + out, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": reloading{}}}) + if err != nil { + t.Fatal(err) + } + var file, service map[string]any + for _, r := range out { + switch r["type"] { + case "file": + file = r + case "service": + service = r + } + } + if file == nil || service == nil { + t.Fatalf("got %v", out) + } + if file["into"] != "json" { + t.Errorf("into did not reach the host: %v", file) + } + if want := "[" + file["id"].(string) + "]"; fmt.Sprint(service["reload-on"]) != want { + t.Errorf("reload-on names %v, the file is %v", service["reload-on"], file["id"]) + } +} diff --git a/internal/compose/resolution/config_test.go b/internal/compose/resolution/config_test.go new file mode 100644 index 0000000..d731c9d --- /dev/null +++ b/internal/compose/resolution/config_test.go @@ -0,0 +1,181 @@ +package resolution + +import ( + "bytes" + "encoding/json" + "errors" + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// inConfigForm is a module in the config form that registers: every value typed, described, defaulted or +// required, placed in a file or read at runtime, and its mergeable file naming its keys. +func inConfigForm() map[string]any { + return map[string]any{ + "module": "notifier", + "version": "1", + "config": map[string]any{ + "font-size": map[string]any{"type": "number", "description": "the notification's font size, in points", + "minimum": 6, "maximum": 40, "default": 10}, + "width": map[string]any{"type": "number", "description": "the notification's width, in pixels", + "default": 250}, + "position": map[string]any{"type": "string", "description": "the corner it shows in, a word", + "enum": []any{"top-right", "top-left"}, "default": "top-right"}, + "theme": map[string]any{"type": "string", "description": "the colour theme, a lower-case name", + "pattern": "[a-z]+", "default": "dark"}, + "history": map[string]any{"type": "number", "description": "how many notifications it keeps, a count", + "default": 20, "runtime": true}, + "sender": map[string]any{"type": "string", "description": "the address it mails a digest from", + "required": true}, + }, + "resources": []any{ + map[string]any{"id": "configuration", "type": "file", "path": "/x/notifierrc", + "content": "font = Inter ${config:font-size}\nwidth = ${config:width}\nfrom = ${config:sender}\n"}, + map[string]any{"id": "rules", "type": "file", "path": "/x/rules.json", "merge": "json", + "takes-config": []any{"position", "theme"}, "content": "{\"position\": \"top-right\"}\n"}, + map[string]any{"id": "notifier", "type": "service", "unit": "notifier.service", + "reload-on": []any{"configuration"}, "restart-on": []any{"rules"}}, + }, + } +} + +func parseRaw(t *testing.T, m map[string]any) (mcat.Manifest, error) { + t.Helper() + raw, err := json.Marshal(m) + if err != nil { + t.Fatal(err) + } + return decodeForTest(raw) +} + +// settingsDunst is the dunst module as it is written today; configDunst the same module rewritten from settings to +// config. +const settingsDunst = `{"module": "dunst", "version": "1", + "settings": { + "font-size": {"kind": "preference", "default": 10, "why": "10 points of Inter reads well on a screen of about 100 DPI"}, + "width": {"kind": "preference", "default": 250, "why": "250 pixels fits a title of about forty characters at 10 points"} + }, + "resources": [ + {"id": "package", "type": "package", "package": "dunst"}, + {"id": "configuration", "type": "file", "path": "/home/me/.config/dunst/dunstrc", "mode": "0644", + "content": "[global]\n font = Inter ${setting:font-size}\n width = ${setting:width}\n"}, + {"id": "rules", "type": "file", "path": "/home/me/.config/dunst/rules.json", "merge": "json", + "takes-settings": [], "content": "{\"urgency\": \"low\"}\n"}, + {"id": "notifier", "type": "service", "unit": "dunst.service", "reload-on": ["configuration"]} + ]}` + +const configDunst = `{"module": "dunst", "version": "1", + "config": { + "font-size": {"type": "number", "description": "the font size of a notification, in points", "minimum": 6, + "maximum": 40, "default": 10}, + "width": {"type": "number", "description": "the width of a notification, in pixels", "minimum": 100, + "default": 250} + }, + "resources": [ + {"id": "package", "type": "package", "package": "dunst"}, + {"id": "configuration", "type": "file", "path": "/home/me/.config/dunst/dunstrc", "mode": "0644", + "content": "[global]\n font = Inter ${config:font-size}\n width = ${config:width}\n"}, + {"id": "rules", "type": "file", "path": "/home/me/.config/dunst/rules.json", "merge": "json", + "takes-config": [], "content": "{\"urgency\": \"low\"}\n"}, + {"id": "notifier", "type": "service", "unit": "dunst.service", "reload-on": ["configuration"]} + ]}` + +// ${config:} fills exactly as ${setting:} does: the module rewritten from settings to config composes +// to the same bytes, with no layer, with the node's, and with the mesh's under the node's. +func TestAModuleRewrittenFromSettingsToConfigComposesByteIdentically(t *testing.T) { + composed := func(manifest string, layers []mcat.Layer) []byte { + t.Helper() + m, err := decodeForTest([]byte(manifest)) + if err != nil { + t.Fatal(err) + } + r := anAdoptedAnchor() + r.Modules = append(r.Modules, m) + with := anchorRendering(false) + if layers != nil { + with.Settings["dunst"] = layers + } + c, err := Compose(r, with) + if err != nil { + t.Fatal(err) + } + if why, left := c.LeftOut["dunst"]; left { + t.Fatalf("dunst was left out: %s", why) + } + raw, err := json.Marshal(c) + if err != nil { + t.Fatal(err) + } + return raw + } + for _, layers := range [][]mcat.Layer{ + nil, + {{From: "anchor", Values: map[string]any{"font-size": float64(13)}}}, + {{From: mcat.MeshWideLayer, Values: map[string]any{"width": float64(300)}}, + {From: "anchor", Values: map[string]any{"font-size": float64(12), "width": float64(320)}}}, + } { + was, is := composed(settingsDunst, layers), composed(configDunst, layers) + if !bytes.Equal(was, is) { + t.Fatalf("composed differently with %v:\nsettings: %s\nconfig: %s", layers, was, is) + } + if !strings.Contains(string(is), "font = Inter 1") || strings.Contains(string(is), "${config:") { + t.Fatalf("not filled: %s", is) + } + } +} + +// A required value nothing sets is refused at composition in the config form's words: ${config:} and the +// config verb, never `settings set` (review of mesh-controller#320). The settings form's words are unchanged. +func TestAnUnsetRequiredValueIsRefusedInTheConfigFormsWords(t *testing.T) { + m, err := parseRaw(t, inConfigForm()) + if err != nil { + t.Fatal(err) + } + r := anAdoptedAnchor() + r.Modules = append(r.Modules, m) + composed, err := Compose(r, anchorRendering(false)) + if err != nil { + t.Fatal(err) + } + why, left := composed.LeftOut["notifier"] + if !left { + t.Fatal("a module whose required value nothing sets was not left out") + } + for _, said := range []string{"${config:sender}", `required config value "sender"`, "config verb"} { + if !strings.Contains(why, said) { + t.Fatalf("left out without %q: %s", said, why) + } + } + for _, unsaid := range []string{"${setting:", "settings set"} { + if strings.Contains(why, unsaid) { + t.Fatalf("left out in the settings form's words (%q): %s", unsaid, why) + } + } + var unset *UnsetSettingError + if err := unsetInConfigWords(settingInto(map[string]any{"type": "file", "content": "${setting:sender}"}, nil, + "notifier"), m); !errors.As(err, &unset) || unset.Setting != "sender" { + t.Fatalf("not typed as a value nobody gave: %v", err) + } + + // A contribution's value too (issue 1126, seed-8: de-spiegel's route names were refused with ${setting:…}). + withRoute := inConfigForm() + withRoute["contributes"] = map[string]any{"route": map[string]any{"site": map[string]any{"name": "${config:sender}", "port": 80}}} + routed, err := parseRaw(t, withRoute) + if err != nil { + t.Fatal(err) + } + _, err = resolutionComposed(mcat.Resolution{}, routed, "route", routed.ContributesMany["route"]["site"], nil, "notifier contributing site to route") + if !errors.As(err, &unset) || unset.Module != "notifier" || !strings.Contains(err.Error(), + `notifier has a contribution site to route, in "name", that says ${config:sender}`) || strings.Contains(err.Error(), "${setting:") { + t.Fatalf("a contribution's unset value is not said in the config form's words: %v", err) + } + + settingsForm := notifier() + settingsForm.Resources[0]["content"] = "from = ${setting:domain}\n" + err = unsetInConfigWords(settingInto(settingsForm.Resources[0], nil, "notifier"), settingsForm) + if err == nil || !strings.Contains(err.Error(), "`settings set notifier `") { + t.Fatalf("the settings form's refusal changed: %v", err) + } +} diff --git a/internal/compose/resolution/consumer_into_serves.go b/internal/compose/resolution/consumer_into_serves.go new file mode 100644 index 0000000..abaaf55 --- /dev/null +++ b/internal/compose/resolution/consumer_into_serves.go @@ -0,0 +1,267 @@ +package resolution + +import ( + "fmt" + "regexp" + "sort" + "strings" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// consumerFact is `${consumer:}` or `${consumer::}`. +var consumerFact = regexp.MustCompile(`\$\{consumer:([a-z][a-z0-9-]*)(?::([a-z][a-z0-9-]*))?\}`) + +// consumerFacts are what a served value may name about the consumer it is being derived for. +// One entry, deliberately: the identity is the one thing about a consumer the mesh itself chose, +// so it is the one thing the mesh can hand to a provider without either end guessing. +var consumerFacts = []string{"as"} + +// consumerAlphabets are the ways the mesh will write that identity. `dns` is the mesh's own +// identifier with its separator written `-` instead of `_` — the whole of the difference between +// the alphabet the mesh mints in and the one buckets, vhosts and hostnames accept. +var consumerAlphabets = []string{"dns"} + +// ServedTo fills a provider's served values for one consumer. +// +// `as` is the identity the mesh minted for that consumer — the same string it is told to present +// as a login. Values with no placeholder are returned exactly as they were, and a block with no +// placeholder at all is returned unchanged, so this costs nothing for the providers that derive +// nothing. +// +// Only strings carry placeholders. A number, a boolean or a nested object is a value the provider +// stated outright, and is left alone. +func ServedTo(serves map[string]any, as string) (map[string]any, error) { + if len(serves) == 0 { + return serves, nil + } + var out map[string]any + for _, key := range sortedAnyKeys(serves) { + text, ok := serves[key].(string) + if !ok || !strings.Contains(text, "${consumer:") { + continue + } + filled, err := consumerInto(text, as) + if err != nil { + return nil, fmt.Errorf("the value served as %q: %w", key, err) + } + if out == nil { + // Copied only once something actually changes: the caller's map is the manifest's, + // and a provider that derives nothing must not have it rewritten underneath it. + out = make(map[string]any, len(serves)) + for k, v := range serves { + out[k] = v + } + } + out[key] = filled + } + if out == nil { + return serves, nil + } + return out, nil +} + +// consumerInto replaces every `${consumer:…}` in one value. +// +// **A fact or an alphabet the mesh does not have is refused, not left standing.** Written through, +// the literal `${consumer:as}` would reach a configuration file and be read as a bucket name, +// failing somewhere that names neither the module nor the mesh — the same reasoning `${bound:…}` +// is refused by (boundInto). +func consumerInto(value, as string) (string, error) { + var failed error + out := consumerFact.ReplaceAllStringFunc(value, func(match string) string { + parts := consumerFact.FindStringSubmatch(match) + fact, alphabet := parts[1], parts[2] + if fact != "as" { + if failed == nil { + failed = fmt.Errorf( + "says %s, and the mesh states %s about a consumer", match, orNothing(consumerFacts)) + } + return match + } + switch alphabet { + case "": + return as + case "dns": + return asDNSLabel(as) + default: + if failed == nil { + failed = fmt.Errorf( + "says %s, and the mesh writes an identity as %s", match, orNothing(consumerAlphabets)) + } + return match + } + }) + if failed != nil { + return "", failed + } + return out, nil +} + +// asDNSLabel writes a minted identity as a DNS label. +// +// The mesh's identities are already lower-case letters, digits and `_` (ConsumerIdentity), and +// already inside the bound of the provision serving them: a provider that serves one as a DNS label +// bounds it at 63 or less (CheckServes, novox/hq ADR 0225), and one that serves it at all without +// saying is held to twenty (IdentityBoundOf). So +// this is the separator and nothing else — no lower-casing of what is already lower case, no +// truncation to a limit the identity is already inside, no padding of a name that is already long +// enough. Each of those would be the mesh guessing at a rule it has not been given. +func asDNSLabel(as string) string { + return strings.ReplaceAll(as, "_", "-") +} + +func sortedAnyKeys(values map[string]any) []string { + out := make([]string, 0, len(values)) + for k := range values { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// derivedFor is what the provider on this machine derives for one consumer of one provision +// (novox/hq ADR 0202). +// +// Settled first, then derived: an operator may set a prefix on what the provider serves and the +// mesh still fills the consumer's half of it ([ADR 0174]). Only the keys that actually name the +// consumer are returned — the rest of a `serves` block is the same for every consumer and is +// already in the provider's own definition, so repeating it here would be a second copy to go +// stale. +// +// The first module in the resolved order that says it serves the provision answers, which is the +// choice servedOnThisMachine makes for the consumer's half. Nothing serving it on this machine is +// not an error: a contribution can reach a machine whose provider is a record or an adapter, and +// then there is nothing derived to tell. +func resolutionDerivedFor(r mcat.Resolution, provision, as, consumer, local string, settings mcat.SettingsBy) (map[string]any, error) { + for _, m := range r.Modules { + serves, said := m.Serves[provision] + if !said { + continue + } + var names map[string]any + for key, value := range serves { + if text, ok := value.(string); ok && strings.Contains(text, "${consumer:") { + if names == nil { + names = map[string]any{} + } + names[key] = value + } + } + if names == nil { + return nil, nil + } + // **A consumer that keeps several holders of this provision is refused** — this is issue + // 124's own failure one case to the side, and it would be just as quiet. + // + // Each holder gets its own login, `…_` (ADR 0094), and a provider derives from the + // login, so it would make one resource per holder. The consumer's side has no such + // dimension: one binding file per provision, one `${bound::}`, both + // derived from the un-suffixed identity. So the provider would create the holder's + // resource and the consumer would be configured against a name nothing made — it would + // authenticate successfully and be refused on every object, which reads like a credential + // fault and is not one. + // + // Lifting this means giving the consumer's side a local dimension. That is a decision, + // not an omission, and until it is taken the mesh says so rather than guessing. + if local != "" { + return nil, fmt.Errorf( + "%s keeps several holders of %s (this one is %q), and %s derives %s for each "+ + "consumer from the login the mesh minted. Each holder has its own login, and a "+ + "consumer is told one value per requirement — so the two ends would name "+ + "different things and nothing would compare them (novox/hq ADR 0202)", + consumer, local, provision, m.Module, orNothing(sortedAnyKeys(names))) + } + settled, err := Settle(names, WithDefaults(m, settings[m.Module])) + if err != nil { + return nil, fmt.Errorf("%s serving %s: %w", m.Module, provision, err) + } + derived, err := ServedTo(settled, as) + if err != nil { + return nil, fmt.Errorf("%s serving %s to %s: %w", m.Module, provision, as, err) + } + return derived, nil + } + return nil, nil +} + +// notTranscribed refuses a consumer's file that writes out the value its provider derives for it, +// instead of asking for it (novox/hq ADR 0202, issue 124). +// +// **What would have caught the one wrong instance.** The object store's three consumers each wrote +// their bucket into their own configuration by hand. One of them named a predecessor's bucket, and +// nothing compared it to what the provider would actually create: the module would have +// authenticated successfully and been refused on every object, which reads like a credential fault +// and is not one. It looked authoritative for months. +// +// The test is exact and costs one string search: a definition whose file already contains the +// value the mesh is about to derive for it has written down somebody else's rule. It cannot be a +// coincidence — a derived value carries the identity the mesh minted for this very consumer on +// this very machine, which nothing else would spell out — and it cannot be checked afterwards, +// because after substitution every consumer's file contains it legitimately. +// +// Only values that actually name the consumer are judged. A provider that serves a constant under +// the same key serves the same constant to everyone, and a consumer repeating it is redundant +// rather than wrong. +func notTranscribed(resource map[string]any, known map[string]map[string]string, module string) error { + if fmt.Sprint(resource["type"]) != "file" { + return nil + } + content, ok := resource["content"].(string) + if !ok || content == "" { + return nil + } + for _, provision := range sortedKnown(known) { + values := known[provision] + identity := values["as"] + if identity == "" { + continue + } + for _, key := range sortedStringKeys(values) { + if key == "as" { + // The login is not derived from itself, and a consumer that must present it in a + // connection string legitimately has it from `${bound:…}` — which is what it will + // be after substitution, so this would judge the substitution, not the module. + continue + } + value := values[key] + if value == "" || !namesTheConsumer(value, identity) { + continue + } + if !strings.Contains(content, value) { + continue + } + return fmt.Errorf( + "%s writes %q into %v, and that is exactly what %s derives for it — a definition "+ + "keeping its own copy of somebody else's naming rule is one that can disagree "+ + "with it, silently. Say ${bound:%s:%s} and be told", + module, value, resource["id"], provision, provision, key) + } + } + return nil +} + +// namesTheConsumer is whether a derived value was built from this consumer's identity — in the +// alphabet it was minted in, or as a DNS label. A value that does not contain it was not derived +// from it, whatever else it may be. +func namesTheConsumer(value, identity string) bool { + return strings.Contains(value, identity) || strings.Contains(value, asDNSLabel(identity)) +} + +func sortedKnown(known map[string]map[string]string) []string { + out := make([]string, 0, len(known)) + for k := range known { + out = append(out, k) + } + sort.Strings(out) + return out +} + +func sortedStringKeys(values map[string]string) []string { + out := make([]string, 0, len(values)) + for k := range values { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/internal/compose/resolution/consumers.go b/internal/compose/resolution/consumers.go new file mode 100644 index 0000000..b74e74f --- /dev/null +++ b/internal/compose/resolution/consumers.go @@ -0,0 +1,100 @@ +package resolution + +import ( + "fmt" + "regexp" + "slices" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// ofConsumers is where a provider places its consumers' credentials. Loose inside the braces, so a misspelt name is +// refused rather than passed over. +var ofConsumers = regexp.MustCompile(`\$\{consumers:([^}]*)\}`) + +// consumersPlaceholderProblems is what is wrong with one resource's ${consumers:…}: placed only in a file's content, +// for a provision the module provides, in a file only the provider reads and the client writes back. +func consumersPlaceholderProblems(m mcat.Manifest, r map[string]any) []string { + var problems []string + for _, field := range sortedKeys(r) { + v, ok := r[field].(string) + if !ok { + continue + } + for _, f := range ofConsumers.FindAllStringSubmatch(v, -1) { + switch { + case field != "content" || fmt.Sprint(r["type"]) != "file": + problems = append(problems, fmt.Sprintf("%s's resource %v names %s in its %s; a provider's consumers "+ + "are placed only in a file's content (novox/hq ADR 0373)", m.Module, r["id"], f[0], field)) + continue + case !slices.ContainsFunc(m.Provides, func(o mcat.Offer) bool { return o.Name == f[1] }): + problems = append(problems, fmt.Sprintf("%s's resource %v names %s, and %s does not provide %q: only a "+ + "provider's template holds its consumers' credentials (novox/hq ADR 0373)", m.Module, r["id"], f[0], + m.Module, f[1])) + } + if mode := fmt.Sprint(r["mode"]); mode != "0600" && mode != "0400" { + problems = append(problems, fmt.Sprintf("%s's resource %v holds its consumers' credentials (%s) with "+ + "mode %q: the provider alone reads it, 0600 or 0400 (novox/hq ADR 0373)", m.Module, r["id"], f[0], + r["mode"])) + } + if owner := ownershipOf(r); owner != mcat.RuntimeOwned { + problems = append(problems, fmt.Sprintf("%s's resource %v holds its consumers' credentials (%s) and is "+ + "%s at runtime: it is owned, written back whenever it differs (novox/hq ADR 0373)", m.Module, + r["id"], f[0], owner)) + } + } + } + return problems +} + +// consumersHandedOnly says what a file naming ${consumers:…} is where it would be written the old way: the provisioner's +// client alone fills it, so on a machine that does not hand the module it is **left out** of the declaration, never +// written as that text or as an empty list the provider would read as no consumers, and never the reason the module is +// refused: a provider not handed there keeps working as it did, from its grant files (novox/hq ADR 0373, issue 780). +// A moved module judged from its repository's tree (WithTemplatesFrom: `module check`), on a machine +// that would hand it, has no templates archive to hand and is judged as it composes; there the placeholder is judged +// and taken out of the text, since what is composed so is never sent (judged answers whether the file is so judged). +func consumersHandedOnly(m mcat.Manifest, r map[string]any, with mcat.Rendering) (judged, leftOut bool) { + content, _ := r["content"].(string) + switch { + case ofConsumers.FindString(content) == "": + return false, false + case m.Moved && m.Handed == nil && with.Hands && with.Provisioner != "": + return true, false + } + return false, true +} + +// consumersLeftOutLine is the plan's one line for a file left out because the module is not handed. +func consumersLeftOutLine(m mcat.Manifest, r map[string]any) string { + return fmt.Sprintf("%s's %v names ${consumers:…}, which the provisioner's client alone fills: left out, handed only "+ + "(novox/hq ADR 0373)", m.Module, r["id"]) +} + +// consumersNotHanded is consumersHandedOnly as a refusal, for a caller that has no way to leave the file out. +func consumersNotHanded(m mcat.Manifest, r map[string]any, with mcat.Rendering) (judged bool, err error) { + judged, left := consumersHandedOnly(m, r, with) + if left { + return false, fmt.Errorf("%s's resource %v names %s, which the provisioner's client alone fills, and %s is not "+ + "handed to it on this machine: it would be written as that text, or empty, and the provider would read no "+ + "consumers (novox/hq ADR 0373)", m.Module, r["id"], ofConsumers.FindString(fmt.Sprint(r["content"])), m.Module) + } + return judged, nil +} + +// consumersOfTemplate is what is wrong with a handed file's template naming ${consumers:…}, judged on the file's +// resource as if its text were inline. +func consumersOfTemplate(m mcat.Manifest, id, text string) []string { + for _, r := range m.Resources { + if fmt.Sprint(r["id"]) != id { + continue + } + withText := map[string]any{} + for k, v := range r { + withText[k] = v + } + withText["content"] = text + return consumersPlaceholderProblems(m, withText) + } + return nil +} diff --git a/internal/compose/resolution/contributes_test.go b/internal/compose/resolution/contributes_test.go new file mode 100644 index 0000000..44f9b9c --- /dev/null +++ b/internal/compose/resolution/contributes_test.go @@ -0,0 +1,311 @@ +package resolution + +import ( + "encoding/json" + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +func published(module, host string, port int) mcat.Manifest { + return mcat.Manifest{Module: module, Version: "1", + Contributes: map[string]map[string]any{ + "reverse-proxy": {"host": host, "port": port}, + }} +} + +func proxy() mcat.Manifest { + return mcat.Manifest{Module: "traefik", Version: "1", + Provides: mcat.Offers("reverse-proxy"), + Receives: map[string]string{"reverse-proxy": "/etc/traefik/mesh.json"}, + Resources: []map[string]any{ + {"id": "up", "type": "service", "unit": "traefik", "state": "running", + "restart-on": []any{mcat.ReceivedID("reverse-proxy")}}, + }} +} + +// received digs the delivered contributions back out of a declaration. +func received(t *testing.T, out []map[string]any) []mcat.Contribution { + t.Helper() + for _, r := range out { + if r["path"] != "/etc/traefik/mesh.json" { + continue + } + body := r["content"].(string) + var parsed struct { + Requirement string `json:"requirement"` + Given []mcat.Contribution `json:"given"` + } + if err := json.Unmarshal([]byte(body), &parsed); err != nil { + t.Fatalf("the file the proxy is given is not readable: %v\n%s", err, body) + } + if parsed.Requirement != "reverse-proxy" { + t.Fatalf("the file does not say what it is about: %q", parsed.Requirement) + } + return parsed.Given + } + t.Fatalf("the provider was given no file at all: %v", out) + return nil +} + +func TestTheFileTheProviderGetsIsMachineReadable(t *testing.T) { + // It is written for a program, and the first version put a `//` header above the JSON — a + // file that says "do not edit" to a person and fails to parse for the thing meant to read it. + got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{}) + + for _, r := range mustDeclare(t, got) { + if r["path"] != "/etc/traefik/mesh.json" { + continue + } + var any map[string]any + if err := json.Unmarshal([]byte(r["content"].(string)), &any); err != nil { + t.Fatalf("the file is not parseable: %v\n%s", err, r["content"]) + } + if note, _ := any["generated"].(string); !strings.Contains(note, "do not edit") { + // Inside the document rather than above it, so it reaches a person without + // breaking the parse. + t.Fatalf("the file does not say it is generated: %v", any["generated"]) + } + return + } + t.Fatal("no received file") +} + +func TestAModuleTellsItsProviderWhatItNeeds(t *testing.T) { + got, err := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{}) + + if err != nil { + t.Fatal(err) + } + out, err := Declaration(got, mcat.Rendering{}) + if err != nil { + t.Fatal(err) + } + given := received(t, out) + if len(given) != 1 || given[0].From != "board" { + t.Fatalf("the proxy was not told about board: %v", given) + } + if given[0].Values["host"] != "board" || given[0].Values["port"] != float64(8080) { + t.Fatalf("the contribution did not survive: %v", given[0].Values) + } +} + +func TestContributingToSomethingIsRequiringIt(t *testing.T) { + // Asking to be published means a publisher must exist. A module that had to say both would + // eventually say one, and the failure would be a machine where nothing serves the route. + got, err := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{}) + + if err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(names(got), " "), "traefik") { + t.Fatalf("contributing to reverse-proxy did not bring one in: %v", names(got)) + } +} + +func TestNothingToContributeToIsRefused(t *testing.T) { + _, err := Resolve(shelf(published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{}) + + if err == nil { + t.Fatal("a module was published through a proxy that does not exist") + } + if !strings.Contains(err.Error(), "reverse-proxy") { + t.Fatalf("the refusal does not name what is missing: %v", err) + } +} + +func TestEveryPublisherOnTheMachineIsInOneFile(t *testing.T) { + got, err := Resolve(shelf(proxy(), + published("board", "board", 8080), + published("archive", "archive", 9000), + ), []string{"board", "archive"}, workstation(), mcat.World{}) + + if err != nil { + t.Fatal(err) + } + given := received(t, mustDeclare(t, got)) + if len(given) != 2 { + t.Fatalf("the proxy was told about %d of 2: %v", len(given), given) + } + // Ordered by module, because this becomes a file and a file whose lines move about looks + // changed when nothing changed — which would restart the proxy for ever. + if given[0].From != "archive" || given[1].From != "board" { + t.Fatalf("the order is not stable: %v", given) + } +} + +func TestAProviderWithNoConsumersStillGetsTheFile(t *testing.T) { + // Empty rather than absent. A provider that finds no file cannot tell "nothing asked for me" + // from "the mesh never wrote it", and those want completely different responses — the same + // rule the host follows about a service that does not exist. + got, err := Resolve(shelf(proxy()), []string{"traefik"}, workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + if given := received(t, mustDeclare(t, got)); len(given) != 0 { + t.Fatalf("got %v", given) + } +} + +func TestTheProviderCanReloadWhenTheRoutesChange(t *testing.T) { + // A proxy that got a new route and did not reload is a route that silently does not work. + // The same fault the private network had when a peer list changed under a running interface, + // which is why the received file has a name a module can point at. + got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{}) + + out := mustDeclare(t, got) + for _, r := range out { + if r["type"] != "service" { + continue + } + reflects, ok := r["restart-on"].([]any) + if !ok || len(reflects) != 1 { + t.Fatalf("the proxy does not reflect anything: %v", r) + } + if reflects[0] != "traefik."+mcat.ReceivedID("reverse-proxy") { + t.Fatalf("it reflects %v, which is not the file it was given", reflects[0]) + } + return + } + t.Fatal("no service in the declaration") +} + +func TestARouteCanBeSetPerMesh(t *testing.T) { + // The hostname is exactly the kind of thing that differs between one mesh and the next. A + // module whose route could not be set would have to be edited to be reused anywhere. + got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{}) + + out, err := Declaration(got, mcat.Rendering{Settings: mcat.SettingsBy{ + "board": {{From: "the mesh", Values: map[string]any{"host": "dashboard"}}}, + }}) + if err != nil { + t.Fatal(err) + } + given := received(t, out) + if given[0].Values["host"] != "dashboard" { + t.Fatalf("the setting did not reach the route: %v", given[0].Values) + } + if given[0].Values["port"] != float64(8080) { + t.Fatalf("setting the host dropped the port: %v", given[0].Values) + } +} + +func TestASettingReachesAContributionOnlyWhereItDeclaresTheKey(t *testing.T) { + // novox/hq 04-ISSUES/173: the mail module's site name, set so its environment file could read + // it, arrived in every route it contributed. A setting overrides a key the contribution + // declares and adds none — the provider reads the contribution as a contract. + got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{}) + + out, err := Declaration(got, mcat.Rendering{Settings: mcat.SettingsBy{ + "board": {{From: "the mesh", Values: map[string]any{"host": "dashboard", "sitename": "Board"}}}, + }}) + if err != nil { + t.Fatal(err) + } + given := received(t, out) + if given[0].Values["host"] != "dashboard" { + t.Fatalf("the setting did not override the route's host: %v", given[0].Values) + } + if _, leaked := given[0].Values["sitename"]; leaked { + t.Fatalf("a setting the route never declared reached the proxy: %v", given[0].Values) + } +} + +func provider() mcat.Manifest { + return mcat.Manifest{Module: "postgres", Version: "1", + Provides: mcat.FromAnywhere("postgres-database"), + Receives: map[string]string{"postgres-database": "/var/lib/postgres/grants/mesh.json"}, + Grants: map[string]string{"postgres-database": "/var/lib/postgres/grants"}, + } +} + +func grantedTo(t *testing.T, out []map[string]any) []mcat.Contribution { + t.Helper() + for _, r := range out { + if r["path"] != "/var/lib/postgres/grants/mesh.json" { + continue + } + var parsed struct { + Given []mcat.Contribution `json:"given"` + } + if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil { + t.Fatal(err) + } + return parsed.Given + } + t.Fatalf("the provider was given no manifest: %v", out) + return nil +} + +func TestAConsumersOwnContributionsAreStillThere(t *testing.T) { + // Cross-node grants are merged in with this machine's own, because from the provider's side + // they are the same thing — somebody wanting something — and a provider that had to read two + // lists would read one of them. + got, err := Resolve(shelf(proxy(), published("board", "board", 8080)), + []string{"board"}, reachable(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + given := received(t, mustDeclare(t, got)) + if len(given) != 1 || given[0].Node != "" { + t.Fatalf("a local contribution grew a node: %v", given) + } +} + +func TestARequireOnlyConsumerOfAParameterlessProvisionStillAsks(t *testing.T) { + // A consumer that requires a parameterless provision (one whose serves names no consumer key — + // redis-cache, amqp) contributes no payload, but it still ASKS for the provision and must be + // granted a credential. Keying "asks" on contributions alone gave its grant From="" — read as + // withdrawn — so the provider never created the account and the consumer authenticated nowhere. + r := mcat.Resolution{ + Node: "laptop", + Modules: []mcat.Manifest{{ + Module: "amqp-ping", + Requires: []string{"amqp"}, + }}, + } + values, asks, err := ContributionsFrom(r, "amqp", "amqp-ping", mcat.SettingsBy{}) + if err != nil { + t.Fatal(err) + } + if !asks { + t.Fatal("a require-only consumer was treated as not asking; its grant would be withdrawn and it would never be provisioned") + } + if values == nil { + t.Fatalf("asks is true but values is nil; expected an empty contribution, got %v", values) + } +} + +func TestAConsumerThatStoppedAskingIsWithdrawn(t *testing.T) { + // Withdrawal is how a credential is taken away. The provisioner removes what nobody asks for, + // and it can only do that if the mesh stops asking — a consumer that was unassigned would + // otherwise keep a working login for ever, and nothing would say so. + // + // A grant with no asking module is exactly that state: the mesh still holds the secret, + // because it is sealed and unusable to the mesh anyway, and the machine no longer wants it. + got, err := Resolve(shelf(provider()), []string{"postgres"}, reachable(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + out, err := Declaration(got, mcat.Rendering{Grants: []mcat.Grant{ + {Provision: "postgres-database", Consumer: "still-here", From: "meshboard", + Values: map[string]any{"name": "meshboard"}, Sealed: "c2VhbGVk"}, + {Provision: "postgres-database", Consumer: "gone-away", Sealed: "c3RhbGU="}, + }}) + if err != nil { + t.Fatal(err) + } + + given := grantedTo(t, out) + if len(given) != 1 || given[0].Node != "still-here" { + t.Fatalf("the provider is still told about a machine that stopped asking: %v", given) + } + // And no credential is written for it either, or the provisioner would find a file for a + // consumer its manifest does not mention and have to guess what that means. + for _, r := range out { + if path, _ := r["path"].(string); strings.Contains(path, "gone-away") { + t.Fatalf("a withdrawn consumer's credential is still delivered: %v", r) + } + } +} diff --git a/internal/compose/resolution/contribution_dependencies_test.go b/internal/compose/resolution/contribution_dependencies_test.go new file mode 100644 index 0000000..10fd91d --- /dev/null +++ b/internal/compose/resolution/contribution_dependencies_test.go @@ -0,0 +1,58 @@ +package resolution + +import ( + "reflect" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +func TestAContributionDependsOnTheSeatThatPlacesIt(t *testing.T) { + env := mod("theme", nil, nil, nil) + env.Environment = &mcat.Environment{Variables: map[string]string{"GTK_THEME": "Adwaita:dark"}} + path := mod("toolchain", nil, nil, nil) + path.Environment = &mcat.Environment{Path: []mcat.PathEntry{{Entry: "/opt/x/bin"}}} + shell := mod("prompt", nil, nil, nil) + shell.Shell = []mcat.ShellCode{{For: "zsh", Slot: "first", Code: "true"}} + session := mod("wallpaper", nil, nil, nil) + session.Shell = []mcat.ShellCode{{For: "xinitrc", Slot: "normal", Code: "true"}} + resources := mod("bar", nil, nil, nil) + resources.Shell = []mcat.ShellCode{{For: "xresources", Slot: "normal", Code: "x: y"}} + empty := mod("nothing", nil, nil, nil) + empty.Environment = &mcat.Environment{} + + cases := map[string]struct { + m mcat.Manifest + want []string + }{ + "a variable": {env, []string{mcat.EnvironmentSeat}}, + "a path entry": {path, []string{mcat.EnvironmentSeat}}, + "shell code": {shell, []string{mcat.LoginShellSeat}}, + "the session's start": {session, []string{mcat.DisplayServerSeat}}, + "the session's X resources": {resources, []string{mcat.DisplayServerSeat}}, + "an empty environment": {empty, nil}, + } + for name, c := range cases { + got := DependsOn(nil, c.m) + if len(got) == 0 { + got = nil + } + if !reflect.DeepEqual(got, c.want) { + t.Errorf("%s depends on %v, want %v", name, got, c.want) + } + } +} + +func TestAHolderMeetsItsOwnContribution(t *testing.T) { + // The display server's module contributes nothing to its own seat today, but the zsh module's + // environment contributions do go to another seat: a claim meets only the seat it names. + zsh := mod("zsh", nil, nil, nil, mcat.Claim{Name: mcat.LoginShellSeat}) + zsh.Shell = []mcat.ShellCode{{For: "zsh", Slot: "normal", Code: "true"}} + zsh.Environment = &mcat.Environment{Variables: map[string]string{"EDITOR": "vim"}} + catalogue := map[string]mcat.Manifest{"zsh": zsh, + "node-env": mod("node-env", nil, nil, nil, mcat.Claim{Name: mcat.EnvironmentSeat})} + unheld := UnheldDependencies(catalogue, "laptop", []mcat.Manifest{zsh}, nil) + if len(unheld) != 1 || unheld[0].Seat != mcat.EnvironmentSeat { + t.Errorf("zsh alone: unheld %v, want only %s (its shell code is its own seat's)", unheld, mcat.EnvironmentSeat) + } +} diff --git a/internal/compose/resolution/control_records_seats.go b/internal/compose/resolution/control_records_seats.go new file mode 100644 index 0000000..d3b53f1 --- /dev/null +++ b/internal/compose/resolution/control_records_seats.go @@ -0,0 +1,52 @@ +package resolution + +import ( + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// controlRecordsDecision is the record that made the two seats. +const controlRecordsDecision = "novox/hq ADR 0565" + +// wantsVerbs are the assigner's verbs on mesh-wants (ADR 0565 clauses 7, 8, 9, 15). +func wantsVerbs() []mcat.Verb { + return []mcat.Verb{ + {Name: "upgrade", Description: "Whether each module's new builds wait for a person: its policy (roll, together or record) " + + "and where it comes from (a person, the module, the bus, its irreplaceable data, the default); the bus is never " + + "rolled. With module, that one; with module, policy and why, a person's choice for it, recorded as a hand act; " + + "with policy default, the choice taken back. With compare, each node's own composition and the assigner's findings.", + Input: schema(map[string]string{ + "module": "one module", + "policy": "with module: roll, together, record, or default to take a person's choice back", + "why": "with policy: why, required and kept", + "compare": "\"true\": each node's own composition, by its digest, and the assigner's findings", + }, nil, "compare")}, + {Name: "bus", Description: "What a bus upgrade would do: the fingerprint of the bus module each node runs against the " + + "indexer's newest, and the last bus steps from the hand-act log and the assigner's puts of the bus. Read-only: " + + "the step itself is the put on the operator's warrant.", + Input: schema(map[string]string{}, nil)}, + } +} + +// handActsVerbs are mesh-hand-acts' verbs (ADR 0565 clause 10), as the controller's took them. +func handActsVerbs() []mcat.Verb { + return []mcat.Verb{ + {Name: "hand-act", Description: "Record one act done by hand: what, why, its cause in a word or a condition's kind " + + "(the word a second act for the same reason uses) and the condition it addresses. Who did it is the caller the " + + "runtime proves, never said by the caller. Said as act-recorded.", + Input: schema(map[string]string{ + "what": "what was done, in a line", + "why": "why it had to be done by hand", + "cause": "the cause in a word, or a condition's kind", + "condition": "the key of the condition it addressed, if any (optional)", + "verb": "the act's verb in the log: a repair when absent, `hand-act drill` for a drill, or a recorder's own", + }, []string{"what", "why"})}, + {Name: "hand-acts", Description: "What was done by hand lately, over this log and the controller's: who, why and the " + + "cause of each, and the causes recorded twice within a fortnight (a healer is wanted).", + Input: schema(map[string]string{"days": "how many days back (default 14)", "cause": "only this cause", + "who": "only acts whose who contains this", "warrants": "\"true\": every warrant recorded"}, nil, "warrants")}, + {Name: "warranted", Description: "Record what a module did on the operator's warrant: who chose, through which channel, " + + "with which proofs and which answer read from the router's own record of that module's ask, never from the " + + "caller; once per ask. Refused for an ask still open, ended without a choice, or not that module's.", + Input: schema(map[string]string{"asker": "the module that asked", "ask": "its ask's id"}, []string{"asker", "ask"})}, + } +} diff --git a/internal/compose/resolution/core_credentials.go b/internal/compose/resolution/core_credentials.go new file mode 100644 index 0000000..6b667f3 --- /dev/null +++ b/internal/compose/resolution/core_credentials.go @@ -0,0 +1,19 @@ +package resolution + +import ( + "encoding/json" + "slices" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// CarriesVaultSealed says whether module's own secrets are the core's credentials. +func CarriesVaultSealed(module string) bool { + return slices.Contains(mcat.CoreCredentialModules, module) +} + +// vaultSealedOf is the vault's box for one of module's own secrets, if the rendering was given one. +func renderingVaultSealedOf(with mcat.Rendering, module, name string) (json.RawMessage, bool) { + box, ok := with.VaultSealed[module][name] + return box, ok && len(box) > 0 +} diff --git a/internal/compose/resolution/core_credentials_test.go b/internal/compose/resolution/core_credentials_test.go new file mode 100644 index 0000000..6c7ffd2 --- /dev/null +++ b/internal/compose/resolution/core_credentials_test.go @@ -0,0 +1,24 @@ +package resolution + +import ( + "reflect" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +func TestTheCoresOwnCredentialsAreTheBusTheProvisionerAndItsClient(t *testing.T) { + if want := []string{"mesh-provisioner", "mesh-provisioner-client", "nats"}; !reflect.DeepEqual(mcat.CoreCredentialModules, want) { + t.Fatalf("the core's modules are %v, want %v (mesh-vault's CoreModules is the same list)", mcat.CoreCredentialModules, want) + } + for _, m := range []string{"nats", "mesh-provisioner", "mesh-provisioner-client"} { + if !CarriesVaultSealed(m) { + t.Errorf("%s is not on the list", m) + } + } + for _, m := range []string{"postgres", "mesh-vault", "mesh-controller", ""} { + if CarriesVaultSealed(m) { + t.Errorf("%s is on the list", m) + } + } +} diff --git a/internal/compose/resolution/data.go b/internal/compose/resolution/data.go new file mode 100644 index 0000000..1d81163 --- /dev/null +++ b/internal/compose/resolution/data.go @@ -0,0 +1,77 @@ +package resolution + +import ( + "fmt" + "strings" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// derivedContributions is what a module's data section gives the backup holder: its backup lines and +// its items. Every item is listed, so a valuable one on a machine is measured whether or not it is +// copied; a cache is listed and not measured. +func manifestDerivedContributions(m mcat.Manifest) []mcat.SeatContribution { + items := m.DataItems() + if len(items) == 0 { + return nil + } + var lines []string + kept := map[string]bool{} + keep := func(path string) { + if !kept[path] { + kept[path] = true + lines = append(lines, "path "+path) + } + } + for _, it := range items { + if !it.BackedUp() { + continue + } + if it.Backup.IsDump() { + lines = append(lines, "run "+strings.TrimSpace(it.Backup.Dump)) + if into, ok := m.DataItem(it.Backup.Into); ok { + keep(into.Path) + } + continue + } + keep(it.Path) + } + var described []string + for _, it := range items { + covered := "-" + switch { + case it.Backup.IsDump(): + if into, ok := m.DataItem(it.Backup.Into); ok { + covered = into.Path + } + case it.BackedUp(): + covered = it.Path + } + described = append(described, fmt.Sprintf("item %s %s %s %s %s %s", it.ID, it.Class, it.Path, covered, + it.Protection(), it.MeasuredBy())) + } + var out []mcat.SeatContribution + if len(lines) > 0 { + out = append(out, mcat.SeatContribution{Seat: mcat.BackupSeat, Kind: mcat.BackupKindBackup, Content: strings.Join(lines, "\n") + "\n"}) + } + return append(out, mcat.SeatContribution{Seat: mcat.BackupSeat, Kind: mcat.BackupKindData, Content: strings.Join(described, "\n") + "\n"}) +} + +// allContributions is every contribution a module makes to a seat's holder: what it wrote, and what +// its data section derives. **One list**: a module that declares its data has its backup lines +// composed from it, and a backup line it still writes by hand is not placed — the catalogue check +// refuses it — so the holder never copies two lists that disagree. +func manifestAllContributions(m mcat.Manifest) []mcat.SeatContribution { + if m.Data == nil { + return m.Contributions + } + derived := manifestDerivedContributions(m) + out := make([]mcat.SeatContribution, 0, len(m.Contributions)+len(derived)) + for _, c := range m.Contributions { + if s, known := mcat.SeatNamed(c.Seat); known && s.Name == mcat.BackupSeat { + continue + } + out = append(out, c) + } + return append(out, derived...) +} diff --git a/internal/compose/resolution/data_placed.go b/internal/compose/resolution/data_placed.go new file mode 100644 index 0000000..d1a17ee --- /dev/null +++ b/internal/compose/resolution/data_placed.go @@ -0,0 +1,31 @@ +package resolution + +import ( + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// DataItems is every data item the resolution's modules declare, placed on its node with the rendering given: the text +// the node-backup holder is handed, read. A module left out of the node's declaration keeps its items, as its data is +// still on the node (ADR 0262); an item that cannot be placed is said in unplaced and left out. +func DataItems(r mcat.Resolution, with mcat.Rendering) (items map[string][]mcat.PlacedDataItem, unplaced []string, err error) { + left := LeftOut(r, with.Settings, with.Adopted) + for module, why := range with.NotPlaced { + if _, already := left[module]; !already { + left[module] = why + } + } + modules := make([]mcat.Manifest, 0, len(r.Modules)) + for _, m := range r.Modules { + if _, isLeft := left[m.Module]; isLeft { + m = backupView(m) + } + modules = append(modules, m) + } + text, unplaced, err := seatContributions(modules, mcat.Manifest{}, mcat.BackupSeat+":"+mcat.BackupKindData, with, + machineFacts(r, with.Names, with.MeshRange), r.Capabilities) + if err != nil { + return nil, unplaced, err + } + items, err = mcat.ParseDataItems(text) + return items, unplaced, err +} diff --git a/internal/compose/resolution/data_test.go b/internal/compose/resolution/data_test.go new file mode 100644 index 0000000..29731f7 --- /dev/null +++ b/internal/compose/resolution/data_test.go @@ -0,0 +1,16 @@ +package resolution + +import ( + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +func mustParse(t *testing.T, raw string) mcat.Manifest { + t.Helper() + m, err := decodeForTest([]byte(raw)) + if err != nil { + t.Fatalf("refused: %v", err) + } + return m +} diff --git a/internal/compose/resolution/declaration.go b/internal/compose/resolution/declaration.go new file mode 100644 index 0000000..3a774e3 --- /dev/null +++ b/internal/compose/resolution/declaration.go @@ -0,0 +1,2522 @@ +package resolution + +import ( + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "strings" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" +) + +// rootInto composes a user's `root` for the node-engine: taken away when it asserts nothing (empty — a +// machine where agents run as the operator) or when the engine is older than the field and parses +// strictly; kept as "never" otherwise. +func rootInto(resource map[string]any, with mcat.Rendering) { + if resource["type"] != "user" { + return + } + value, has := resource[mcat.RootField] + if !has { + return + } + if s, _ := value.(string); s == "" || !with.JudgesRoot { + delete(resource, mcat.RootField) + } +} + +// machinePort is where a module's port lives on this machine, or the port itself when the mesh has +// not been asked. Unassigned is not an error here: a module with no `listens` never needed one, +// and a caller composing a declaration without a store still gets something coherent. +func renderingMachinePort(r mcat.Rendering, module string, wanted int) int { + if at, given := r.Given[module][wanted]; given { + return at + } + if at, known := r.Ports[module][wanted]; known { + return at + } + return wanted +} + +// Declaration is everything the resolved modules put on the node, with settings applied. +// +// Resource identities are prefixed with the module they came from. Two modules may reasonably +// both call something "config", and without this the second would silently replace the first — +// the node applying one of them and reporting success. +func Declaration(r mcat.Resolution, with mcat.Rendering) ([]map[string]any, error) { + composed, err := Compose(r, with) + if err != nil { + return nil, err + } + return composed.Resources, nil +} + +// NotMadeError is a module's own secret the composition was given no value for (novox/hq issue 275): +// typed, so that a caller can tell "a send would make this, and none has yet" from a composition that +// fails for any other reason without reading the words. +type NotMadeError struct { + Module, Name string +} + +func (e *NotMadeError) Error() string { + return fmt.Sprintf("%s needs a secret called %q and none was made for it", e.Module, e.Name) +} + +// LeftOut is which of this machine's modules a declaration composed with these settings leaves +// out, and why (novox/hq ADR 0163, rule 6): each whose stored settings its definition can no longer +// compose. Empty when every module composes. The same judgement SetSettings makes before storing. +func LeftOut(r mcat.Resolution, settings mcat.SettingsBy, adopted bool) map[string]string { + out := map[string]string{} + for module, why := range LeftOutBecause(r, settings, adopted) { + out[module] = why.Error() + } + return out +} + +// LeftOutBecause is LeftOut with each reason as the error it was, so a reader can tell a setting nobody +// gave (an *UnsetSettingError) from any other cause and say it as the operator's to give (novox/hq issue +// 380). An UnreadManifestError for a stored manifest this controller cannot read whole. +func LeftOutBecause(r mcat.Resolution, settings mcat.SettingsBy, adopted bool) map[string]error { + out := map[string]error{} + for _, m := range r.Modules { + if why := UnknownFieldReason(m); why != "" { + out[m.Module] = &UnreadManifestError{Module: m.Module, said: why} + continue + } + if err := JudgeSettings(m, settings[m.Module], adopted); err != nil { + out[m.Module] = err + } + } + return out +} + +// UnreadManifestError is a module left out because its stored manifest has a key this controller does not know +// (novox/hq ADR 0262): said once for the whole mesh, by the catalogue's own condition, not per machine. +type UnreadManifestError struct { + Module string + said string +} + +func (e *UnreadManifestError) Error() string { return e.said } + +// Compose is Declaration with the owner of every resource said. +func Compose(r mcat.Resolution, with mcat.Rendering) (mcat.Composed, error) { + owner := map[string]string{} + received := map[string]map[string][]mcat.Contribution{} + leftOut := map[string]string{} + var foreseen, unplaced, handedOnly []string + handed := &handedFiles{plans: map[string]mcat.Plan{}} + resources, err := resolutionCompose(r, with, owner, received, leftOut, &foreseen, &unplaced, &handedOnly, handed) + if err != nil { + return mcat.Composed{}, err + } + sort.Strings(foreseen) + if with.BusMembership != "" { + // The machine's own, not any module's: how it reaches the mesh from now on. Sealed like a + // secret and placed where the host looks for exactly this (design 28, task 5.2). + resources = append(resources, map[string]any{ + "id": BusMembershipID(), "type": "file", "path": mcat.BusMembershipPath, + "sealed": with.BusMembership, "mode": "0600", + }) + } + sort.Strings(unplaced) + sort.Strings(handedOnly) + out := mcat.Composed{Resources: resources, Owner: owner, Received: received, LeftOut: leftOut, + Foreseen: foreseen, Unplaced: unplaced, HandedOnly: handedOnly, Filled: handed.paths} + if plans := handed.finished(r.Needs); len(plans) > 0 { + out.Plans = plans + } + return out, nil +} + +// BusMembershipID names the resource carrying a machine's membership for the new bus, and +// BusMembershipPath is where the host reads it — the same constant on both sides. +func BusMembershipID() string { return "bus-membership" } + +func resolutionCompose(r mcat.Resolution, with mcat.Rendering, owner map[string]string, + received map[string]map[string][]mcat.Contribution, leftOut map[string]string, + foreseen, unplaced, handedOnly *[]string, handed *handedFiles) ([]map[string]any, error) { + // **A setting is judged where it is stored, and an impossible one costs a module, not a + // machine** (novox/hq ADR 0163, rule 6). A definition that moved under a stored setting makes + // this module uncomposable; it is left out of the declaration — its held things kept, its + // containers untouched, the machine told so by name — and the machine is told everything else. + // Before placing, because a placement is a setting too. + left := LeftOut(r, with.Settings, with.Adopted) + kept := make([]mcat.Manifest, 0, len(r.Modules)) + var stillBackedUp []mcat.Manifest + for module, why := range with.NotPlaced { + if _, already := left[module]; !already { + left[module] = why + } + } + for _, m := range r.Modules { + if why, isLeft := left[m.Module]; isLeft { + if leftOut != nil { + leftOut[m.Module] = why + } + // **Its data is still copied** (novox/hq ADR 0262): a module left out runs nothing new, and + // the data it already holds on the machine is the reason to keep copying it. Only its data, + // as the backup holder's lines are derived from it, and the directories they name. + stillBackedUp = append(stillBackedUp, backupView(m)) + continue + } + kept = append(kept, m) + } + r.Modules = kept + + // Every manifest is placed first (novox/hq ADR 0112): the maps naming where its bindings, + // credentials and contributions land are resolved against this node's directories, so every + // reader below — the binding files, the sealed secrets, the grant paths a contribution + // names — sees a concrete place and none learns the vocabulary. + // Into a fresh slice, never the caller's: one resolution may compose for many nodes, and a + // slice element written in place would carry the first node's places into the second's. + placed := make([]mcat.Manifest, len(r.Modules)) + for i, m := range r.Modules { + var err error + if placed[i], err = placedManifest(m, with); err != nil { + return nil, err + } + } + r.Modules = placed + + // Where each provision's credentials land, so a contribution can name the file rather than + // carry a value the mesh does not have. + directories := map[string]string{} + for _, m := range r.Modules { + for provision, where := range m.Grants { + directories[provision] = where + } + } + given, err := resolutionContributions(r, with.Settings, with.Grants, directories) + if err != nil { + return nil, err + } + + // A workload on THIS machine contributes the port it declared, and the machine may have + // published it somewhere else (novox/hq ADR 0066). The mirror of the redirect below: 038 fixed + // what a consumer is TOLD about a provider, and this is what a workload TELLS the provider about + // itself — gitea declaring 3000, published as 20000:3000, and the co-located proxy dialling 3000, + // where nothing listens, for every request. + // + // **The contributing module's assignment, never the provider's.** The port belongs to the + // workload; using the map of whatever answers the requirement would move a route to wherever the + // proxy happens to be published. A contribution carried here from another machine is left exactly + // as it is — its port is that machine's to assign, and this map knows nothing about it. + for provision := range given { + for i := range given[provision] { + said := &given[provision][i] + if said.Node != "" && said.Node != r.Node { + continue + } + said.Values = atMachinePort(said.Values, said.From, with.Ports) + } + } + + // A provider answered on this same machine never passed through the walk that works out what a + // provider on ANOTHER machine serves (novox/hq 04-ISSUES/038, ADR 0066). That walk does two + // things — it derives the served facts with the provider node's port assignments, and it settles + // them with that node's settings layers — and the same-node paths (resolve.go's servedHere, and + // here() below) did neither, because both run while resolving, before either is known. + // + // The port was the first half to be noticed (038). The second half is worse and quieter: a served + // value the OPERATOR supplied — a certificate authority's root, which is the one kind of value a + // manifest cannot carry, because it differs on every mesh — arrived as the manifest's empty + // default. A proxy then wrote an empty CA bundle, fell back to the system trust store, could not + // verify the internal authority, and stopped issuing with nothing saying why. + // + // So it is re-derived here, in full, exactly as the cross-node path derives it. **After the + // closure is known**, which also removes an order dependence: the resolver built these needs + // mid-walk from whichever modules had been chosen by then, so what a co-located binding carried + // depended on the order somebody happened to assign things in. + for i := range r.Needs { + if r.Needs[i].ByRecord || r.Needs[i].From != r.Node { + continue + } + serves, answered, err := resolutionServedOnThisMachine(r, r.Needs[i].Name, with) + if err != nil { + return nil, err + } + if answered { + r.Needs[i].Serves = serves + } + } + + // Once, from every module's listens -- not per module. A module receiving only its own ports + // would write a rule set that closed every other module on the machine. Each module's per-node + // exposure settings override its listens' source first (novox/hq ADR 0046). + rules, err := Rules(r, with) + if err != nil { + return nil, err + } + // **A machine that has not said which links face outside is sent no filter** (novox/hq ADR + // 0140). The whole chain is written around those links: with none, the rule that lets this + // machine's own guests keep working would name an empty set, which nftables refuses, and a rule + // set that does not load is a machine filtering nothing while its unit reports success. Refused + // here, where a person reads it, rather than on the machine — and the machine keeps the filter + // it already has. + if filters := resolutionFiltersHere(r); filters != "" && len(with.OutwardLinks) == 0 { + return nil, fmt.Errorf( + "%s cannot be sent a filter: it has not reported which of its links face outside, and "+ + "every rule in the chain is written around them. It reports that on each apply; "+ + "`node show %s` says whether it has. Until then %s is not sent, and the machine "+ + "keeps the filter it has", r.Node, r.Node, filters) + } + filtering := AsNftablesWithLocal(rules, with.Mesh, r.PublicDomain != "", with.Foundation, + with.OutwardLinks, with.TunnelInterface, with.LocalNetworks) + + // **A variable two modules set is refused whether or not anything places it** (novox/hq ADR + // 0203 §5): the account has one environment, and a machine whose holder arrives later should not + // be the moment two modules are found to disagree about it. + if err := variablesSetOnce(r.Modules); err != nil { + return nil, err + } + + generated, err := resolutionGeneratedHere(r, with) + if err != nil { + return nil, err + } + + var out []map[string]any + for _, m := range r.Modules { + if with.Adopted && m.Filtering != nil { + // Nothing of a module that loads a filter, on an adopted node: its table would drop + // by default and hold accepts, and the found firewall stays in force. Every resource, + // not only the rule set — its service must not run, and a node returned to adopted + // stops it by the ordinary removal of what is no longer declared. + continue + } + resources := m.Resources + + // What the mesh computes for this module goes FIRST, before the module's own resources. + // + // **Order is stated, not derived — the host does not sort** (novox/hq ADR 0005), so + // whatever the mesh writes down is the order a machine applies. A module's service or + // container routinely depends on one of these files; nothing here ever depends on a + // module's resources, because none of it is computed from them. + // + // Appended, this was wrong in a way that only showed on the first apply and then healed: + // the service started before its certificate or its rule set existed, failed, and the next + // reconcile fixed it. A fault that repairs itself on the second attempt is worse than one + // that does not, because what gets remembered is that it works. + var first []map[string]any + if f := m.Filtering; f != nil { + first = append(first, map[string]any{ + "id": mcat.FilteringID(), "type": "file", "path": f.Into, + "content": filtering, "mode": "0600", + }) + } + // The node's fail2ban jails, composed from every module it runs (novox/hq to-be 31), written + // where the intrusion-prevention holder owns them. Like the rule set above: gathered from all + // modules, written by the one that holds the role. + if j := m.Jailing; j != nil { + first = append(first, jailsInto(r.Modules, j)...) + } + if c := m.Certificate; c != nil { + if with.Certificate == "" { + // Asked for and not issued. Refused rather than skipped: a module that serves TLS + // with no certificate does not start, and the reason is somewhere else entirely. + return nil, fmt.Errorf( + "%s wants a certificate for this machine and none was issued", m.Module) + } + first = append(first, map[string]any{ + "id": mcat.CertificateID(), "type": "file", "path": c.Into, + // Public. It travels in the open like any other file, because it is a statement + // about a key rather than the key. + "content": with.Certificate, "mode": "0644", + }) + if c.Authority != "" { + first = append(first, map[string]any{ + "id": mcat.AuthorityID(), "type": "file", "path": c.Authority, + "content": with.Authority, "mode": "0644", + }) + } + } + if m.BusUsers != "" { + // **The claim authorises it, not the field.** This file holds every user's password + // hash, so a module that could ask for it could read every credential on the bus. + // Checked from this manifest alone, which is the cheapest check there is: whether some + // other module also claims the seat is resolution's business elsewhere, and one holder + // mesh-wide is already guaranteed. + if !m.ClaimsSeat("mesh-broker") { + return nil, fmt.Errorf( + "%s asks for the mesh's user list and does not claim mesh-broker. That file "+ + "holds every user's password hash, so the seat is what authorises it", + m.Module) + } + if with.BusUsers == "" { + // Asked for and not composed. Refused rather than skipped, for the reason a + // certificate is: a bus with no user list refuses every connection in the mesh, and + // an empty file would look like a configuration problem on the machine. + return nil, fmt.Errorf( + "%s holds mesh-broker and the mesh composed no user list, so the bus would "+ + "refuse every connection", m.Module) + } + first = append(first, map[string]any{ + "id": mcat.BusUsersID(), "type": "file", "path": m.BusUsers, + "content": with.BusUsers, + // Readable by the server and nothing else. Hashes rather than passwords, so this is + // not a set of working credentials — but a list of every user in the mesh is worth + // keeping to the one process that needs it. + "mode": "0600", + }) + } + // **A secret family's members, as given** (novox/hq ADR 0283): one file each at the family's path, and + // nothing for a member not given — the mesh never makes one, and the module says it waits for it. + for _, name := range sortedKeys(with.Needed[m.Module]) { + own, family, ok := m.OwnSecrets.Lookup(name) + if !ok || family == "" || with.Needed[m.Module][name] == "" { + continue + } + // A per-secret owner (novox/hq ADR 0436 §1) stands in for the module's secrets-owner, when + // the manifest named one for this secret (or the family it belongs to). + owner := m.SecretsOwner + if own.Owner != "" { + owner = own.Owner + } + first = append(first, ownedBy(owner, map[string]any{ + "id": mcat.NeedID(name), "type": "file", "path": own.Path, "sealed": with.Needed[m.Module][name], + })) + } + // **A handed module's own secrets are the vault's** (design 57 V4, issue 647): each file holding one is + // declared placed and without a value, filled by the machine's provisioner client from the vault; the + // controller makes and seals none of them here. + handsSecrets := Handing(with, m) + for _, name := range sortedKeys(m.OwnSecrets.Plain()) { + if handsSecrets { + owner := resolutionOwnSecretOwner(r, m, name) + first = append(first, ownedBy(owner, map[string]any{ + "id": mcat.NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, mcat.HandedSecretField: name, + })) + continue + } + // **A core credential is the vault's box** (ADR 0349 §16, issue 755): carried as the vault sealed it to this + // machine, which the controller cannot open; the controller's own sealed value is not sent with it. + if box, ok := renderingVaultSealedOf(with, m.Module, name); ok { + if string(box) == string(mcat.ForeseenVaultBox) { + *foreseen = append(*foreseen, m.Module+"/"+name) + } + owner := resolutionOwnSecretOwner(r, m, name) + first = append(first, ownedBy(owner, map[string]any{ + "id": mcat.NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, mcat.VaultSealedField: box, + })) + continue + } + sealed := with.Needed[m.Module][name] + if sealed == "" && with.Foreseen[m.Module][name] { + // Not made, and the next send makes it: composed with a stand-in so that whatever + // else this composition would refuse is still found (novox/hq issue 275). + sealed = mcat.ForeseenSealed + *foreseen = append(*foreseen, m.Module+"/"+name) + } + if sealed == "" && m.OwnSecrets[name].IssuedBy == mcat.IssuedOutside { + // **Not given, and the mesh never makes it** (novox/hq issue 401, ADR 0228): no file, as for a + // family's member (ADR 0283), so the module reads its absence as not given. + continue + } + if sealed == "" { + // Declared and not made. Refused rather than skipped: a module whose own + // credential is silently absent starts, fails to authenticate, and the reason is + // three layers away from the machine reporting it. + return nil, &NotMadeError{Module: m.Module, Name: name} + } + // The runtime's credential belongs to the account the runtime runs as (novox/hq ADR 0175, + // to-be 38 WP3): its process is composed `user: ` where the node has one, and a + // root-owned 0600 file is one that process cannot read. Composed here rather than said in + // the manifest, because a manifest cannot say ${machine:account} safely — a node with no + // account has nothing to resolve it to, and then the runtime runs as root and the file + // stays root's. + first = append(first, ownedBy(resolutionOwnSecretOwner(r, m, name), map[string]any{ + "id": mcat.NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, "sealed": sealed, + })) + } + // This module's tools bundles, where the machine runs the node's tool runtime (novox/hq + // ADR 0175, to-be 38 WP2). Mesh-computed like everything above it, and before the module's + // own resources for the same reason: the runtime's process names the files inside these + // and is restarted when one changes, so they are on the machine before it is. + if resolutionRuntimeHere(r) { + first = append(first, bundleArchives(m)...) + } + // Operator-owned paths this module is granted use of (novox/hq ADR 0051). Written before + // the module's own resources, and so before the container that mounts them: the host must + // find each present — refusing clearly if the operator has not provided it — before it + // starts anything that depends on it. The mesh creates, chowns and reconciles none of it; + // an `access` resource says only *this path must exist, and this module reaches it*. + // Where each is on THIS machine is the assignment's (novox/hq issue 153): placed by id + // where the operator said, the definition's default otherwise, refused where neither. + accesses, accessPaths, err := accessesFor(m, with.Settings[m.Module]) + if err != nil { + return nil, err + } + for _, a := range accesses { + first = append(first, map[string]any{ + "id": mcat.AccessID(a.Path), "type": "access", "path": a.Path, "mode": a.Mode, + }) + } + for _, to := range m.SecretRequirements() { + for _, file := range m.SecretFiles(to) { + var found *mcat.Needed + for i, n := range r.Needs { + // **This module's need, not the provision's** (novox/hq 04-ISSUES/022). Matching + // on the name alone, every consumer of a provision took whichever credential + // happened to be last in the list — so on a node with two of them, one module + // would be given the other's password and fail to authenticate with a valid + // credential belonging to somebody else. And this file's local name, where the + // module keeps several (ADR 0094). + if n.Name == to && n.For == m.Module && n.Local == file.Local { + found = &r.Needs[i] + } + } + // **A credential the vault keeps for this holder alone** (issue 757): where the module is handed, its + // file is declared placed and without a value, and the machine's client fills it from the vault. + if found != nil && found.IssuedToHolder && Handing(with, m) { + first = append(first, ownedBy(m.SecretsOwner, map[string]any{ + "id": mcat.SecretID(mcat.SecretLocal(to, file.Local)), "type": "file", "path": file.Path, mcat.HandedProvisionField: mcat.PlanProvision{Name: mcat.SecretLocal(to, file.Local), Provision: to, + Local: file.Local, Provider: found.From, ProviderModule: found.Module}, + })) + continue + } + if found != nil && found.ByRecord && found.Sealed == "" && !found.Manager { + // Answered by a record whose key has not been supplied since this consumer was + // put on it. **Refused, not skipped.** The mesh discarded the plaintext when the + // key was accepted and cannot seal another, so a machine that resolved cleanly + // would receive no file at all and fail at whatever tried to read it — which is + // the outcome ADR 0024 exists to avoid, arrived at politely. + // + // The manager holder is the one exception (novox/hq ADR 0050): an empty refresh token + // is a licence whose manager has not adopted one yet, a real waiting state rather than + // a lost key. It falls through to the skip below — its bound facts (carrying the + // manager's public key) are still delivered, which is what adoption needs to seal the + // first refresh token. + return nil, fmt.Errorf( + "%s on this machine uses the licence %q and no key has been sealed to it. "+ + "The mesh cannot make one; supply it again with `licence key %s`", + m.Module, found.From, found.From) + } + if found == nil || found.Sealed == "" { + // Answered on this machine, or answered by a node the mesh could not seal to. + // Nothing to write either way, and writing an empty credential file would be + // worse than none: something would read it and fail authenticating. + continue + } + first = append(first, ownedBy(m.SecretsOwner, map[string]any{ + "id": mcat.SecretID(mcat.SecretLocal(to, file.Local)), "type": "file", "path": file.Path, + "sealed": found.Sealed, + })) + } + } + for _, to := range sortedKeys(m.Grants) { + for _, g := range with.Grants { + if g.Provision != to { + continue + } + if g.From == "" { + // Nothing on that machine asks for this any more. Skipped here rather than + // where grants are gathered, so the rule holds whoever gathers them. + // + // **This is how a credential is withdrawn.** The provisioner removes what + // nobody asks for, and it can only do that if the mesh stops asking — a + // consumer that was unassigned would otherwise keep a working login for ever, + // and nothing would say so. + continue + } + if g.Sealed == "" { + // Its offer presents no credential (form none, issue 1126): nothing to write, and an empty credential + // file would be read as one that failed. Who asked still reaches the provider in its contributions. + continue + } + first = append(first, ownedBy(resolutionProvisionsAs(r, m), map[string]any{ + // One file per holder — the consumer's module with its local name after it + // where it keeps several (ADR 0094); the lab found two files with one id. + "id": mcat.GrantID(to, g.Consumer+"."+holderAs(g.From, g.Local)), + "type": "file", + "path": grantPath(m.Grants[to], g.Consumer, holderAs(g.From, g.Local)), + "sealed": g.Sealed, + })) + } + } + for _, to := range sortedKeys(m.Binds) { + var found *mcat.Needed + for i, n := range r.Needs { + if n.Name == to && n.For == m.Module { + found = &r.Needs[i] + } + } + if found == nil { + // Answered on this same machine rather than from elsewhere in the mesh. + // + // **Still written.** It used to be skipped, reasoning that "it is on this node" + // is a fact nobody needs — and that is true of the *location* and false of + // everything beside it. A binding also carries what the provider said a consumer + // must know, which is the port; a consumer cannot invent that, and got an absent + // file with no explanation. A build machine sharing a node with the registry it + // pushes to sat in a loop saying it could not read its own binding. + here, err := here(r, to, with) + if err != nil { + return nil, err + } + if here == nil { + // Nothing in this node's set offers it either, so there is genuinely nothing + // to say. Resolution has already refused anything unanswerable, so this is a + // requirement met by the module itself. + continue + } + found = here + } + own, err := resolutionOwnNames(r, m, to, with.Settings[m.Module]) + if err != nil { + return nil, err + } + as := mcat.ConsumerIdentity(r.Node, mcat.IdentitySource(m.Slug, m.Module)) + // What the provider derives for THIS consumer, filled here where the consumer is + // known (novox/hq ADR 0202). The same fill knownFor does below, so the binding file + // and the module's `${bound:…}` substitutions cannot say different things. + told := *found + told.Serves, err = ServedTo(told.Serves, as) + if err != nil { + return nil, fmt.Errorf("%s is told about %s: %w", m.Module, to, err) + } + file, err := boundFile(told, m.Binds[to], as, own) + if err != nil { + return nil, err + } + first = append(first, file) + } + for _, to := range sortedKeys(m.Receives) { + file, err := receivedFile(to, m.Receives[to], given[to]) + if err != nil { + return nil, err + } + first = append(first, file) + if received[m.Module] == nil { + received[m.Module] = map[string][]mcat.Contribution{} + } + // Empty rather than absent when nobody contributed, for the reason the file is + // written empty: "nothing asked" and "never told" want different responses. + received[m.Module][to] = append([]mcat.Contribution{}, given[to]...) + } + if m.Keeps != "" && with.Kept != nil { + file, err := keptFile(m.Keeps, with.Kept) + if err != nil { + return nil, err + } + // Read by the module as its secrets' owner when it names one: the vault, as its own account (issue 723). + first = append(first, ownedBy(m.SecretsOwner, file)) + } else if runs, ok := with.KeptRuns[m.Module]; m.Keeps != "" && ok { + file := make(map[string]any, len(runs)) + for k, v := range runs { + file[k] = v + } + file["id"] = KeptID() + delete(file, "module") + first = append(first, file) + } + if m.Computed != "" { + mine, part := generated[m.Module] + if !part { + // Assigned, and not yet part of what this generates. Nothing to put on the + // machine, which is different from an error: a node given the network module + // before it has an address is in exactly that state, briefly. + continue + } + resources = mine + } + + // Now, and not before: a module whose resources are computed replaces them wholesale, and + // merging earlier would throw away the files it still needs. + // + // **Except the module's own accounts, which go before even those** (novox/hq issue 213). What + // the mesh computes may belong to one: a module whose code runs as an account it declares has + // its secrets written owned by that account, and a file given to a user the machine does not + // have yet fails — so on the first apply the secrets were refused, the process started without + // them, and the second apply healed it, which is the fault the paragraph above describes. + // An account depends on nothing the mesh computes. + accounts, rest := accountsFirst(resources) + resources = append(append(accounts, first...), rest...) + // **A file delivered only while its optional requirement is bound is left out while it is + // absent** (novox/hq ADR 0330 §5), and the field is dropped from the rest: the node-engine + // parses strictly and knows no such field. + absent := AbsentFor(r, m.Module) + resources = withoutAbsent(resources, absent) + + // No container is given the mesh's names (novox/hq ADR 0148). It used to be: every + // container got the whole roster as `--add-host` entries at creation, and a name that + // moved afterwards was wrong inside it for as long as it ran (issues 109, 135) — and once + // the roster was made part of a container's identity so that could be caught, one name + // moving anywhere replaced every container in the mesh (issue 151). A container resolves a + // mesh name through its machine's resolver at the moment it asks, which the runtime is + // told once per machine, as a file, by the resolver's own module. The names a module + // declares for itself are its own and stay exactly as written: they are part of what the + // module is, and the mesh does not know what they mean. + // What this module may name from inside one of its own files. Gathered once per module + // rather than per file, because it is a fact about the module. + sealed, err := sealedFor(m, r.Needs, with) + if err != nil { + return nil, err + } + // And what its bindings say, for the half of a connection that is not secret. + known, err := knownFor(m, r.Needs, r.Node) + if err != nil { + return nil, err + } + // A requirement answered on this same machine is not in r.Needs — its binding file is + // written from `here` (above) — and so `${bound:…}` could not name it, though the file + // beside it said the same facts. Filled from the same answer, so the two cannot disagree. + for _, want := range m.Wants() { + if _, has := known[want]; has { + continue + } + answered, err := here(r, want, with) + if err != nil { + return nil, err + } + if answered == nil { + continue + } + local := *answered + local.For = m.Module + here, err := knownFor(m, []mcat.Needed{local}, r.Node) + if err != nil { + return nil, err + } + for provision, values := range here { + known[provision] = values + } + } + // And the providing machine's private address, beside its name (novox/hq ADR 0194). A name is + // what nearly every consumer wants; the one that cannot use it is a machine's resolver + // configuration, which must reach the resolver before it can resolve anything — the resolver's + // own name included. Absent when the machine has no address yet, so a file naming it is refused + // rather than written with a blank where an address belongs. + for _, values := range known { + if address := with.Machines[values["at"]]; address != "" { + values["address"] = address + } + } + // And what the module is called through each requirement it contributes to (novox/hq + // 04-ISSUES/122) — the same composition its binding file carries. + for provision, values := range known { + own, err := resolutionOwnNames(r, m, provision, with.Settings[m.Module]) + if err != nil { + return nil, err + } + withOwnNames(values, own) + } + // And where this node places the directories the module declared without a path + // (novox/hq ADR 0112) — resolved once per module, named by ${dir:…} from any resource. + // — and, on an adopted machine, where the assignment says they already are, with the + // owner the data already has (novox/hq issue 153). Malformed placements are refused here. + placed, err := mcat.Places(m, with.Settings[m.Module]) + if err != nil { + return nil, err + } + dirs := dirsFor(m, with) + // And the machine underneath, which no binding of its own can tell it. + thisMachine := machineFacts(r, with.Names, with.MeshRange) + + // **A definition that already holds the answer transcribed it** (novox/hq ADR 0202). + // Judged over what the module itself declares, and before anything is substituted: the + // mesh's own generated files — the binding, the contributions — legitimately carry the + // derived value, and after substitution so does every consumer's file, so this is the one + // moment the two can be told apart. + for _, own := range m.Resources { + if err := notTranscribed(own, known, m.Module); err != nil { + return nil, err + } + } + // **And every placeholder no pass fills where it stands is refused** (novox/hq issue 231): + // judged here, over the definition as written and before any pass, by the function the + // catalogue check runs — so a manifest registered by an older binary is judged too, and + // what a setting or a binding later puts into a file is its software's text, never swept. + for _, own := range m.Resources { + if problems := unconsumedPlaceholders(m.Module, own); len(problems) > 0 { + return nil, fmt.Errorf("%s", problems[0]) + } + } + + // Which of this module's files carry a secret, for the rule that a container may not read + // one of them as its environment without saying so (ADR 0086, issue 041). + secretFiles := secretFilesOf(resources) + + // Which of this module's resources its preparation runs before, if it prepares anything. + prepareBefore := preparationTarget(m) + + // Which found networks this machine's setting keeps for each of its containers (novox/hq + // ADR 0163, rule 4); judged above, so an invalid one is not here. + keptNetworks, _ := KeptNetworks(m, with.Settings[m.Module], with.Adopted) + + // **A moved module's templated files are the provisioner's** (novox/hq ADR 0349 §4, §6, design 57 P6): on a + // machine that can take them, each is declared placed and without its text, and no pass below fills it. + handing, err := handed.begin(r.Node, m, dirs, accessPaths, thisMachine, with, r.Capabilities, r.Needs, + backupPlacer{append(append([]mcat.Manifest(nil), r.Modules...), stillBackedUp...), with, thisMachine, r.Capabilities, + unplaced}) + if err != nil { + return nil, err + } + // **A plan for what the seat keys of it all the same** (catalogue.Keyed, issue 774): a module not handed whose + // provider's helper answers a provision of it, its route the commonest, has that provision in a plan of its own, + // so the seat keys it and the helper hears it. + keyedOnly := handing == nil && handed.keyed(r.Node, m, r.Needs) + if _, ok := handed.plans[m.Module]; ok && (handing != nil || keyedOnly) { + for _, to := range requirementsContributedTo(m) { + own, err := resolutionOwnNames(r, m, to, with.Settings[m.Module]) + if err != nil { + return nil, err + } + handed.name(m.Module, to, own) + contributed, err := resolutionContributedOf(r, m, to, with) + if err != nil { + return nil, err + } + handed.contribute(m.Module, to, contributed) + } + } + // A handed collector is filled from items alone: each old form its collections would have held is said + // (design 57 L1). + if plan, ok := handed.plans[m.Module]; ok && handing != nil && len(plan.Collections) > 0 { + *unplaced = append(*unplaced, oldFormsLeftOut(append(append([]mcat.Manifest(nil), r.Modules...), + stillBackedUp...), m, plan.Collections)...) + } + for _, unsettled := range resources { + if pr, ok := unsettled[mcat.HandedProvisionField].(mcat.PlanProvision); ok { + if handing == nil && m.Stubbed { + continue // held: its credential file is what runs of it, left out (issue 1126) + } + if handing == nil { + return nil, fmt.Errorf("%s: its credential of %s is handed to the provisioner and the module is "+ + "not: nothing would fill it", m.Module, pr.Provision) + } + marked := map[string]any{} + for k, v := range unsettled { + marked[k] = v + } + delete(marked, mcat.HandedProvisionField) + marked[mcat.HandedSecretField] = pr.Name + file, sec, err := handedSecretFile(marked, m, dirs, accessPaths, thisMachine, placed) + if err != nil { + return nil, err + } + file["id"] = m.Module + "." + fmt.Sprint(unsettled["id"]) + owner[fmt.Sprint(file["id"])] = m.Module + pr.Path = sec.Path + if err := handed.addProvisionFile(m.Module, pr); err != nil { + return nil, err + } + out = append(out, file) + continue + } + if unsettled[mcat.HandedSecretField] != nil { + if handing == nil && m.Stubbed { + continue // held: its own secret's file is what runs of it, left out (issue 1126) + } + if handing == nil { + return nil, fmt.Errorf("%s: its own secret %v is handed to the provisioner and the module is "+ + "not: nothing would fill it", m.Module, unsettled[mcat.HandedSecretField]) + } + file, sec, err := handedSecretFile(unsettled, m, dirs, accessPaths, thisMachine, placed) + if err != nil { + return nil, err + } + file["id"] = m.Module + "." + fmt.Sprint(unsettled["id"]) + owner[fmt.Sprint(file["id"])] = m.Module + if err := handed.addSecretFile(m.Module, sec); err != nil { + return nil, err + } + out = append(out, file) + continue + } + if handing != nil && fmt.Sprint(unsettled["type"]) == "file" { + if text, _ := unsettled["content"].(string); len(secretsUsed(text)) > 0 && + !handing[fmt.Sprint(unsettled["id"])] { + return nil, fmt.Errorf("%s: its file %v names a secret in its text, and a handed module's "+ + "secrets reach only the files its provisioner client fills: keep it in a template "+ + "(design 57 V4)", m.Module, unsettled["id"]) + } + } + if handing[fmt.Sprint(unsettled["id"])] { + file, line, err := handedFile(unsettled, m, dirs, accessPaths, thisMachine, placed, with) + if err != nil { + return nil, err + } + file["id"] = m.Module + "." + fmt.Sprint(unsettled["id"]) + owner[fmt.Sprint(file["id"])] = m.Module + if err := handed.add(m.Module, line); err != nil { + return nil, err + } + out = append(out, file) + continue + } + if fmt.Sprint(unsettled["type"]) == "file" { + if _, left := consumersHandedOnly(m, unsettled, with); left { + *handedOnly = append(*handedOnly, consumersLeftOutLine(m, unsettled)) + continue + } + } + resource, err := ApplySettings(unsettled, with.Settings[m.Module]) + if err != nil { + return nil, err + } + copied := map[string]any{} + for k, v := range resource { + copied[k] = v + } + if networks, keeps := keptNetworks[fmt.Sprint(copied["id"])]; keeps { + // The container also joins the found network the setting names, so a neighbour + // that resolves it there keeps resolving it. Passed to the host as its own field, + // which it joins after the container is made. + joins := make([]any, 0, len(networks)) + for _, n := range networks { + joins = append(joins, n) + } + copied["networks"] = joins + } + if err := refuseSecretsInEnvironment(copied, secretFiles, m.Module); err != nil { + return nil, err + } + // Said in the catalogue, not on the machine: the host parses strictly and knows no + // such field, and the reason is for a reader of the manifest. + delete(copied, mcat.SecretsInEnvironment) + delete(copied, mcat.NamesOnPurpose) + delete(copied, mcat.TrustedField) + // A process's bus stays controller-side (novox/hq ADR 0436 §1): never composed onto the + // node-engine's process resource, whose wire declaration is mesh-host's own, vendored, with a + // strict decoder that refuses an unknown key. + delete(copied, mcat.ProcessBusField) + // **An operator's value, from the assignment** (novox/hq ADR 0112, ADR 0155): what a + // definition may not carry because it is true of one installation only. Filled from + // the same layers a mergeable file takes, and refused when no layer set it. + if err := unsetInConfigWords(settingInto(copied, WithDefaults(m, with.Settings[m.Module]), m.Module), m); err != nil { + return nil, err + } + // **Placed before anything reads a path.** A pathless directory receives the path + // this node resolves for it, and every ${dir:…} — in paths, mounts, content and + // environment — becomes that path, so what follows sees only concrete places + // (novox/hq ADR 0112). The host receives paths exactly as it always has. + if err := dirInto(copied, dirs, m.Module); err != nil { + return nil, err + } + // The operator's data the same way: ${access:…} becomes where this node keeps it, + // and a placed directory takes the owner the assignment said (issue 153). + if err := accessInto(copied, accessPaths, m.Module); err != nil { + return nil, err + } + ownerInto(copied, placed) + // **After settings, and that is the whole reason it is here.** A module's file + // content is where a setting lands, so a placeholder may only exist once the setting + // has been put in — filling secrets first would look at content that is not yet what + // the machine receives. + if err := intoFile(copied, sealed, outsideNotGiven(m, with.Needed[m.Module]), m.Module); err != nil { + return nil, err + } + // **Substituted here, not on the machine.** A bound value is not secret — the mesh + // holds it in the clear — so there is nothing for the host to be the only witness of, + // and sending it already filled in means the host learns no new field. + if err := boundInto(copied, known, m.Module, absent); err != nil { + return nil, err + } + // And what the module could not have written: the machine it turned out to be + // assigned to. Beside the bound values because it is the same kind of fact — the + // mesh's own, held in the clear — and because a module that must name itself to + // something else has no binding to learn it from (novox/hq ADR 0066). + // Which port this machine gave it, for a module that binds directly rather than + // through a runtime that can remap (ADR 0038). Applied before the machine's facts so + // a refusal names the port rather than whatever came after it. + if err := portInto(copied, m.Module, m.Listens, with); err != nil { + return nil, err + } + // And where this machine put the foundation's servers, for the one module that + // reaches them by seat rather than by binding (novox/hq 04-ISSUES/102). + if err := seatInto(copied, m.Module, with); err != nil { + return nil, err + } + if err := machineInto(copied, thisMachine, m.Module); err != nil { + return nil, err + } + if err := pinned(copied, m.Module); err != nil { + return nil, err + } + // And the other half of the same question: an image nobody has published yet is named + // by an artifact rather than by a placeholder digest, and is just as unrunnable. + if err := built(copied, m.Module); err != nil { + return nil, err + } + // What the mesh built is kept by digest and path; the store's address is this + // network's now, composed here and never recorded (novox/hq 04-ISSUES/102). + if err := artifactsInto(copied, m.Module, with); err != nil { + return nil, err + } + publishedOn(copied, m.Module, with) + // How it is ready, in the node-engine's words: its endpoint as the port this machine + // published it on — or not sent at all to an engine older than the field (ADR 0240). + healthInto(copied, m, with) + // And whether it is busy (novox/hq ADR 0335): sent only to an engine that asks it. + busyInto(copied, with) + // And a user's `root` (novox/hq ADR 0266): sent only when it asserts something, to an engine + // that judges it. + rootInto(copied, with) + // The account's environment and every module's shell code, where this module holds the + // seat that places them (novox/hq ADR 0203, ADR 0204). Gathered from every module on + // the node, as the jails are, and **last of every placeholder pass**: shell code is a + // shell's own syntax, full of `${…}` no pass above should ever be shown. + contributing := append(append([]mcat.Manifest(nil), r.Modules...), stillBackedUp...) + if err := contributionsInto(copied, m, contributing, thisMachine, with, r.Capabilities, unplaced); err != nil { + return nil, err + } + copied["id"] = m.Module + "." + fmt.Sprint(resource["id"]) + // A service saying what it reflects names resources within its own module, so those + // are prefixed too or they would point at nothing. + // + // **Unless it already names one.** A module may reflect a file another module put on + // the machine — the case this exists for is a resolver restarting when the mesh + // rewrites the names, which are computed by the mesh and belong to its module, not to + // the daemon's. Written `.`, and a dot is what marks it as already + // answered: prefixing it again would point at nothing, silently, and the daemon would + // serve the old names for ever while everything reported success. + if renamed := reflectsRenamed(m.Module, resource["restart-on"]); renamed != nil { + copied["restart-on"] = renamed + } + // And what it is reloaded on, by the same rule (novox/hq ADR 0102): an id left + // unprefixed matches nothing, and the service is never reloaded. + if renamed := reflectsRenamed(m.Module, resource["reload-on"]); renamed != nil { + copied["reload-on"] = renamed + } + // And which of its module's containers a scheduled step holds still (novox/hq ADR 0189). + // **The loudest of the three when it is missed.** An unprefixed `restart-on` matches + // nothing and a service quietly never restarts; an unprefixed `while-stopped` names a + // container the declaration does not contain, and the host refuses the whole + // declaration — so the machine takes nothing at all, for every push, until this is + // right. That is what it did on the control node (2026-10-04). + if renamed := reflectsRenamed(m.Module, resource[mcat.WhileStopped]); renamed != nil { + copied[mcat.WhileStopped] = renamed + } + // And what a process replaces (novox/hq issue 213): a resource of this module's that it + // no longer declares, named as the host recorded it, or the host hands nothing over and + // removes it first. + if renamed := reflectsRenamed(m.Module, resource["replaces"]); renamed != nil { + copied["replaces"] = renamed + } + // **What reads one of this module's own secrets is restarted when it changes** (novox/hq + // issue 203, issue 206). A credential is re-issued by the mesh, and a container that + // mounted the old file keeps the old one open: the build machine ran for an hour on a + // credential the mesh had replaced, because its manifest restarted it on its + // environment file and nobody had thought to name the credential too. Composed here so + // no manifest has to say it, for a container or a daemon that names the secret's path. + if reads := secretsReadBy(copied, m, with.Needed[m.Module]); len(reads) > 0 { + copied["restart-on"] = withRestartOn(copied["restart-on"], reads) + } + // **A version prepares its state before it runs** (novox/hq ADR 0135). Derived from the + // module's own resource rather than declared beside it: what prepares the state is the + // module's own code, so what it is given has to be what that code is given — and a + // second resource written by hand is a second copy to drift from the first. Placed + // immediately before it, because a run-once step stops everything the declaration + // places after it (ADR 0052), which is how a version whose preparation failed does not + // serve. + if prepareBefore != "" && fmt.Sprint(resource["id"]) == prepareBefore { + step := prepared(copied) + owner[fmt.Sprint(step["id"])] = m.Module + out = append(out, step) + } + owner[fmt.Sprint(copied["id"])] = m.Module + out = append(out, copied) + } + + // **What only the mesh knows, where this module asked for it.** The graph is the control + // plane's; making a name resolve is the module's software. Emitted as ordinary files under + // this module's name, so they are applied, reported and removed exactly as anything else + // it declares. + // The resolver file is the node-resolver holder's where one is held here, and the uplink's + // holder steps back from it (novox/hq ADR 0247). + given, err := FactsFrom(stepsBack(m, r.Modules), r, with) + if err != nil { + return nil, err + } + for _, fact := range given { + fact["id"] = m.Module + "." + fmt.Sprint(fact["id"]) + owner[fmt.Sprint(fact["id"])] = m.Module + out = append(out, fact) + } + } + // The node's tool runtime, last (novox/hq ADR 0175, to-be 38 WP2.3): one process loading every + // bundle delivered above and holding the credential sealed above, so both exist before it starts + // — the order written here is the order the machine applies. + if resolutionRuntimeHere(r) { + process, err := resolutionRuntimeProcess(r, with) + if err != nil { + return nil, err + } + if process != nil { // none for a held runtime's stand-in: what runs of it is carried + owner[fmt.Sprint(process["id"])] = mcat.RuntimeModule + out = append(out, process) + } + // And a runtime of its own for each module of its own account, after it (novox/hq ADR 0259 §8). + owns, err := resolutionOwnRuntimes(r, with) + if err != nil { + return nil, err + } + for _, p := range owns { + id := fmt.Sprint(p["id"]) + owner[id] = strings.TrimSuffix(id, "."+mcat.OwnRuntimeID()) + out = append(out, p) + } + // What each module's bundles are given is read as the account the runtime runs as — not what a + // module of its own account is given, which its own account reads. + words := map[string]map[string]string{} + for _, m := range r.Modules { + if m.RunsAs != "" { + continue + } + w, err := bundleWords(m, with) + if err != nil { + return nil, err + } + words[m.Module] = w + } + // And a file a module's words name is one the runtime is restarted for when it changes. + if named := givenTo(out, owner, words, r.Account); len(named) > 0 && process != nil { + restarts, _ := process["restart-on"].([]any) + seen := map[string]bool{} + for _, id := range restarts { + seen[fmt.Sprint(id)] = true + } + for _, id := range named { + if !seen[id] { + restarts = append(restarts, id) + seen[id] = true + } + } + process["restart-on"] = restarts + } + // **And what a module of its own account is given, its own account reads** (issue 723): the files and + // directories its words name that say no owner, given to the account it runs as, never the operator's. + for _, m := range r.Modules { + if m.RunsAs == "" { + continue + } + w, err := bundleWords(m, with) + if err != nil { + return nil, err + } + givenTo(out, owner, map[string]map[string]string{m.Module: w}, m.RunsAs) + } + } + if with.Adopted { + // First, before anything a module declares: what the mesh needs reachable, then its guard. + // The order a machine applies is the order written here. + ours := Openings(rules, with.Foundation, mcat.Published(out)) + ours = append(ours, GuardResources(resolutionGuarded(r, out, owner, rules, with))...) + out = append(ours, out...) + } + return out, nil +} + +// guarded is what the mesh's guard refuses on an adopted node (novox/hq ADR 0103): derived, and +// for taken modules only. +// +// For each taken module, every machine port its containers publish that the filter would admit +// from the private network only — a published port is forwarded, not received, so a found +// firewall filtering only what it receives never sees it — together with the ports the module's +// manifest guards explicitly (the store's port, the broker's management port), wherever the +// machine put them. A port of a module assigned but not taken is not guarded: it may still be the +// predecessor's, serving the predecessor's other machines. The foundation's ports are admitted +// from everywhere and are never guarded. +func resolutionGuarded(r mcat.Resolution, out []map[string]any, owner map[string]string, rules []mcat.Rule, + with mcat.Rendering) []int { + meshOnly := map[int]bool{} + fromEverywhere := map[int]bool{} + for _, rule := range rules { + if rule.Protocol != "tcp" { + continue + } + switch rule.From { + case mcat.FromMesh: + meshOnly[rule.Port] = true + case mcat.FromEverywhere: + fromEverywhere[rule.Port] = true + } + } + for _, port := range with.Foundation { + delete(meshOnly, port) + fromEverywhere[port] = true + } + seen := map[int]bool{} + var ports []int + guard := func(at int) { + if !seen[at] { + seen[at] = true + ports = append(ports, at) + } + } + for _, m := range r.Modules { + if !with.Taken[m.Module] { + continue + } + var mine []map[string]any + for _, resource := range out { + if owner[fmt.Sprint(resource["id"])] == m.Module { + mine = append(mine, resource) + } + } + for outer := range mcat.Published(mine)["tcp"] { + if meshOnly[outer] { + guard(outer) + } + } + for _, want := range m.Guards { + at := renderingMachinePort(with, m.Module, want) + for _, resource := range mine { + if fmt.Sprint(resource["type"]) != "container" { + continue + } + listed, _ := resource["ports"].([]any) + for _, entry := range listed { + outer, inner, _, ok := mapping(fmt.Sprint(entry)) + if ok && inner == want { + at = outer + } + } + } + // Not what this node is told to open to everyone: a per-node exposure setting that + // widens a guarded port is the operator saying so, and declaring an opening for it + // and a guard dropping it would be one statement refusing the other. + if !fromEverywhere[at] { + guard(at) + } + } + } + sort.Ints(ports) + return ports +} + +// mapping reads a container's port mapping — `[address:]outer:inner[/protocol]`, the address +// possibly an IPv6 one in brackets — indexing from the end, so an address's own colons never +// shift the ports. Not ok for a short form or anything that is not a mapping. +func mapping(written string) (outer, inner int, address string, ok bool) { + written = strings.TrimSpace(written) + if cut := strings.LastIndex(written, "/"); cut >= 0 { + written = written[:cut] + } + parts := strings.Split(written, ":") + if len(parts) < 2 { + return 0, 0, "", false + } + inner, err := strconv.Atoi(parts[len(parts)-1]) + if err != nil { + return 0, 0, "", false + } + outer, err = strconv.Atoi(parts[len(parts)-2]) + if err != nil { + return 0, 0, "", false + } + return outer, inner, strings.Join(parts[:len(parts)-2], ":"), true +} + +// filtersHere is the module on this node that loads the machine's packet filter, or empty when none +// does. Named rather than counted: a refusal that says which module is one step from acted on. +func resolutionFiltersHere(r mcat.Resolution) string { + for _, m := range r.Modules { + if m.Filtering != nil { + return m.Module + } + } + return "" +} + +// Rules is the rule set this node's filter is derived from: every module's listens, what was +// computed for this machine, and each module's per-node exposure. The same answer whether the node +// is adopted or converged — the one loads it as a filter, the other declares it as openings. +func Rules(r mcat.Resolution, with mcat.Rendering) ([]mcat.Rule, error) { + // A module whose settings cannot compose is left out of the declaration (novox/hq ADR 0163, + // rule 6), and out of the filter with it: nothing of it is declared, so nothing of it is let + // through. + left := LeftOut(r, with.Settings, with.Adopted) + exposure := map[string]map[int]string{} + for _, m := range r.Modules { + if _, isLeft := left[m.Module]; isLeft { + continue + } + e, err := Exposure(m, with.Settings[m.Module]) + if err != nil { + return nil, err + } + // And how far each endpoint reaches, which says the same thing to the filter and more + // besides (novox/hq ADR 0138). Folded in here rather than beside: the filter has one + // question — from where — and a reach answers it, so giving it two inputs would let them + // disagree. Reaches refuses a port that both name, so this cannot silently prefer one. + reaches, err := Reaches(m, with.Settings[m.Module]) + if err != nil { + return nil, err + } + // **Only for an endpoint the proxy does not serve.** A routed endpoint's port is how the + // proxy reaches it and nothing else (ADR 0045), so `public` there asks for a public name and + // says nothing about the port — opening it to the world as well would undo the arrangement + // the proxy exists for, and would silently reopen a port an operator had narrowed. + // + // Found by trying to express a real module: one whose routed name must be public and whose + // machine-side port must not be. Under one value for both, there was no way to say it. + routed := RoutedPorts(m) + for port, reach := range reaches { + if routed[port] { + continue + } + source, ok := FilterSource(reach) + if !ok { + return nil, fmt.Errorf("%s: %q is not a reach the filter can read", m.Module, reach) + } + if e == nil { + e = map[int]string{} + } + e[port] = source + } + if e != nil { + exposure[m.Module] = e + } + } + return Filtering(r, with.Generators, with.Ports, exposure) +} + +// provisionsAs is the account that reads what the mesh writes for this provider: the one secret +// per consumer it must open to set that consumer's password (novox/hq issue 225). +// +// **A root-owned 0600 file is one that process cannot read**, which is the same sentence already +// written above for a module's own secrets — and the grant secret is the other kind of secret +// the mesh writes for a module, so it is the same rule. +// +// Which account depends on where the module's code runs. A module whose code is a bundle is run +// by the node's tool runtime, as the node's account ([ADR 0198](0198)); one still in a container +// is whatever it declares as its secrets owner. Nothing names these paths, so the rule that +// claims a bundle's other files by the words that name them (givenTo) cannot reach them: the +// harness composes a grant secret's path from the contributions file, not from a word. +// +// Empty is root, which is what it was and what a module with no bundle and no declared owner +// still wants. +func resolutionProvisionsAs(r mcat.Resolution, m mcat.Manifest) string { + // A module of its own account reads them as that account (ADR 0259 §8, issue 723). + if m.RunsAs != "" { + return m.RunsAs + } + if len(m.Bundles) > 0 && r.Account != "" { + return r.Account + } + return m.SecretsOwner +} + +// ownSecretOwner is who one of a module's own secret files belongs to: the account the manifest names for it, else the +// runtime's account for the runtime's own credential and for a tools bundle's bus account (ToolsBusSecret; novox/hq ADR +// 0564 Limits 4), which the runtime runs that bundle as, else the module's secrets-owner (root when none). +func resolutionOwnSecretOwner(r mcat.Resolution, m mcat.Manifest, name string) string { + if o := m.OwnSecrets[name].Owner; o != "" { + return o + } + if r.Account != "" && (m.Module == mcat.RuntimeModule || ToolsBusSecret(m, name)) { + return r.Account + } + return m.SecretsOwner +} + +// grantPath is where one consumer's sealed credential lands on the providing machine. +// +// Suffixed, so the directory can also hold whatever the module writing it keeps there and so a +// node named like something else in that directory cannot collide with it. +// One file per consumer, and a consumer is a module on a machine (novox/hq 04-ISSUES/022). +// +// Named after both. Named after the machine alone, two modules on one node wrote to one path: the +// second overwrote the first, and the provisioner — reading a directory — saw one consumer where +// there were two. +// reflectsRenamed is a resource's restart-on list under the module's prefix, or nil when it has +// none. It reads both the shape JSON parsing produces ([]any) and the shape code composing +// resources natively produces ([]string): a reference that was skipped because its list arrived +// in the other shape would point at nothing — silently, with the service never restarting and +// every check passing, which is how a runtime kept serving without the registry trust its +// daemon file already carried. +func reflectsRenamed(module string, reflects any) []any { + var names []string + switch v := reflects.(type) { + case []any: + for _, id := range v { + names = append(names, fmt.Sprint(id)) + } + case []string: + names = v + } + if names == nil { + return nil + } + var renamed []any + for _, named := range names { + if strings.Contains(named, ".") { + renamed = append(renamed, named) + continue + } + renamed = append(renamed, module+"."+named) + } + return renamed +} + +func grantPath(directory, consumer, module string) string { + return strings.TrimRight(directory, "/") + "/" + consumer + "." + module + ".secret" +} + +// holderAs is a consumer's name at the provider with a local name after it, where it keeps several +// credentials for one provision (ADR 0094); the name alone otherwise. +func holderAs(as, local string) string { + if local == "" { + return as + } + return as + "_" + local +} + +// contributions collects what every module in this set contributes, by requirement. +// +// Ordered by contributing module, because the result becomes a file on a machine and a file whose +// lines move about is a file that looks changed when nothing changed. +func resolutionContributions(r mcat.Resolution, settings mcat.SettingsBy, grants []mcat.Grant, + directories map[string]string) (map[string][]mcat.Contribution, error) { + out := map[string][]mcat.Contribution{} + modules := append([]mcat.Manifest{}, r.Modules...) + sort.Slice(modules, func(i, j int) bool { return modules[i].Module < modules[j].Module }) + + // What consumers on other machines asked for. Merged in with this machine's own, because from + // the provider's side they are the same thing — somebody wanting something — and a provider + // that had to read two lists would be a provider that reads one of them. + sorted := append([]mcat.Grant{}, grants...) + sort.Slice(sorted, func(i, j int) bool { + if sorted[i].Provision != sorted[j].Provision { + return sorted[i].Provision < sorted[j].Provision + } + if sorted[i].Consumer != sorted[j].Consumer { + return sorted[i].Consumer < sorted[j].Consumer + } + if sorted[i].From != sorted[j].From { + return sorted[i].From < sorted[j].From + } + return sorted[i].Local < sorted[j].Local + }) + // A consumer already carried by the grants loop, keyed (provision, module). When provider and + // consumer are co-located, `grantsFor` enumerates the same-node consumer too, so without this the + // module would be emitted a second time by the m.Contributes loop below — once full (with the + // grant's secret/as) and once partial — which is the duplicate seen in a co-located mesh.json. + granted := map[string]map[string]bool{} + for _, g := range sorted { + if g.From == "" { + // As above: nothing on that machine asks for this any more, so the provider is not + // told about it and withdraws the login on its next pass. + continue + } + as := holderAs(mcat.ConsumerIdentity(g.Consumer, mcat.IdentitySource(g.Slug, g.From)), g.Local) + derived, err := resolutionDerivedFor(r, g.Provision, as, g.From, g.Local, settings) + if err != nil { + return nil, err + } + out[g.Provision] = append(out[g.Provision], mcat.Contribution{ + From: g.From, Node: g.Consumer, At: g.At, Values: g.Values, Derived: derived, + // One holder per local name: the identity the consumer is known by, and the local name + // after it where the module keeps several (ADR 0094). Not a login any backend checks — + // a secret is not a login — so the identity limit does not apply to the suffix. + As: as, + Secret: grantPath(directories[g.Provision], g.Consumer, holderAs(g.From, g.Local)), + }) + if granted[g.Provision] == nil { + granted[g.Provision] = map[string]bool{} + } + granted[g.Provision][g.From] = true + } + for _, m := range modules { + for _, to := range sortedKeys(m.Contributes) { + // The grants loop already emitted this module's contribution to this provision, with its + // minted secret — emitting the partial copy again would duplicate it. A contribution with + // no grant (a reverse-proxy route names a host, not a credential) is not in `granted`, so + // it still reaches the provider from here. + if granted[to][m.Module] { + continue + } + // Settings reach a contribution the same way they reach a file. A route's hostname is + // exactly the kind of thing that differs between one mesh and the next, and a module + // that could not have it set would have to be edited to be reused. + values, err := resolutionComposed(r, m, to, m.Contributes[to], settings[m.Module], + m.Module+" contributing to "+to) + if err != nil { + return nil, err + } + // What the module shares of its bucket reaches the provider with the rest (novox/hq ADR 0468 §8). + if values, err = withShares(m, to, values); err != nil { + return nil, err + } + out[to] = append(out[to], mcat.Contribution{From: m.Module, Values: values}) + } + // Several contributions to one requirement (ADR 0094's sibling for `contributes`): an + // object store's data API and its console are two different public names from one module, + // not one. Never in `granted` — a route names a host, not a credential — so every local + // name always reaches the provider from here. + for _, to := range sortedKeys(m.ContributesMany) { + for _, local := range sortedKeys(m.ContributesMany[to]) { + values, err := resolutionComposed(r, m, to, m.ContributesMany[to][local], settings[m.Module], + m.Module+" contributing "+local+" to "+to) + if err != nil { + return nil, err + } + out[to] = append(out[to], mcat.Contribution{From: m.Module, Values: values}) + } + } + } + return out, nil +} + +// contributedOf is each contribution m makes to one requirement, composed as its provider receives it in a received +// file (contributions: the one of `contributes` with what the module shares of its bucket, then each of several by +// local name), with where m's machine is, encoded for PlanProvision.Contributed (issue 774): one composition, so a +// plan and a file cannot say different things. Nil where m contributes nothing to it. +// +// **Its port is the one the machine published** (atAssignedPort): a plan is read on whatever machine its provider runs, +// which cannot redirect the number as a received file on this machine is redirected (atMachinePort), so the plan +// carries the machine port already. route-proxy dialled gitea's 3000 for git (published at 20000) and grafana's 3000 +// on another machine (published at 20012) until it did (2026-10-31). +func resolutionContributedOf(r mcat.Resolution, m mcat.Manifest, to string, with mcat.Rendering) (json.RawMessage, error) { + layers := with.Settings[m.Module] + form := mcat.ContributedForm{At: r.At} + add := func(local string, values map[string]any) error { + values, err := resolutionAtAssignedPort(r, m, to, values, with) + if err != nil { + return err + } + raw, err := json.Marshal(values) + if err != nil { + return fmt.Errorf("%s contributing to %s: %w", m.Module, to, err) + } + form.Each = append(form.Each, mcat.ContributedOne{Local: local, Values: raw}) + return nil + } + if raw, ok := m.Contributes[to]; ok { + values, err := resolutionComposed(r, m, to, raw, layers, m.Module+" contributing to "+to) + if err != nil { + return nil, err + } + if values, err = withShares(m, to, values); err != nil { + return nil, err + } + if err := add("", values); err != nil { + return nil, err + } + } + for _, local := range sortedKeys(m.ContributesMany[to]) { + values, err := resolutionComposed(r, m, to, m.ContributesMany[to][local], layers, m.Module+" contributing "+local+" to "+to) + if err != nil { + return nil, err + } + if err := add(local, values); err != nil { + return nil, err + } + } + if len(form.Each) == 0 { + return nil, nil + } + return EncodeContributed(form) +} + +// atAssignedPort is a contribution of m with its port where this machine publishes it for m: the number +// `${port:}` gives m itself and its declaration publishes (machinePort, port_into.go), the placement's +// assignment where it holds one and the software's own number where it holds none (a bare port not yet moved, a fixed +// mapping, the host's network). What its provider dials, on this machine over loopback and from another at m's address. +// +// **Refused where a plan carries a port m does not listen on** (with.Planned): no machine port stands for it, so the +// provider would dial a number nothing on the machine is known to publish. Elsewhere (a declaration, a composition +// with no ports) such a port is left as it is, as machinePort leaves it. +func resolutionAtAssignedPort(r mcat.Resolution, m mcat.Manifest, to string, values map[string]any, with mcat.Rendering) (map[string]any, error) { + number, ok := asPort(values["port"]) + if !ok { + return values, nil + } + _, given := with.Given[m.Module][number] + _, placed := with.Ports[m.Module][number] + if !given && !placed { + if with.Planned { + return nil, fmt.Errorf("%s contributing to %s: its port %d on %s is none of the ports it listens on, so no "+ + "machine port stands for it", m.Module, to, number, r.Node) + } + return values, nil + } + return atMachinePort(values, m.Module, map[string]map[int]int{m.Module: {number: renderingMachinePort(with, m.Module, number)}}), nil +} + +// composed is one contribution as its provider receives it: settled with this node's settings, its +// endpoint's port filled in, and its names composed from the label. +// +// **One function, because two readers must agree.** The provider is told the names in its received +// file; the contributing module is told the same names in its own binding (novox/hq 04-ISSUES/122). +// Composing them twice, in two places, is how the proxy would come to serve one name while the +// module wrote another into its configuration. +func resolutionComposed(r mcat.Resolution, m mcat.Manifest, to string, raw map[string]any, layers []mcat.Layer, what string) ( + map[string]any, error) { + // Overridden, not merged: a setting changes a key the contribution declares and adds none. + // The provider reads the contribution as a contract, and a setting made for one of this + // module's files is no part of it (novox/hq 04-ISSUES/173). + values, err := overridden(raw, WithDefaults(m, layers), what) + if err != nil { + // A required value nothing sets is said in the words of the module's form, as a file's, a unit's and an owner's + // are (issue 1126, seed-8: de-spiegel in the config form was refused with ${setting:…} and `settings set`). + var unset *UnsetSettingError + if errors.As(err, &unset) && unset.Module == "" { + unset.Module = m.Module + unset.where = fmt.Sprintf("a contribution%s, in %q,", strings.TrimPrefix(what, m.Module+" contributing"), unset.field) + } + return nil, unsetInConfigWords(fmt.Errorf("%s: %w", what, err), m) + } + reaches, err := Reaches(m, layers) + if err != nil { + return nil, fmt.Errorf("%s: %w", what, err) + } + blocks, err := mcat.Endpoints(m, layers) + if err != nil { + return nil, fmt.Errorf("%s: %w", what, err) + } + portOfEndpoint(values, endpointPorts(m)) + composeName(values, r.PublicDomain, servingAt(r, to), reaches, endpointPorts(m), blocks) + return values, nil +} + +// ownNames is what a module is known by through what it contributes to one requirement — the names +// the mesh composed for it, and nothing else of the contribution. +// +// **The half a module could not learn** (novox/hq 04-ISSUES/122). A module contributes a label, the +// mesh joins it with this node's domains, and the provider serves the result — and the module itself +// was never told. Software that must know its own address (a login redirect, a canonical URL, an +// issuer) had it written into the manifest as a literal, which is a domain in a definition and wrong +// on every other machine. `${bound::name}` is the answer, from the same composition the +// provider receives. +// +// Several contributions to one requirement are keyed by their local name under `names`. +func resolutionOwnNames(r mcat.Resolution, m mcat.Manifest, to string, layers []mcat.Layer) (map[string]any, error) { + pick := func(values map[string]any) map[string]any { + names := map[string]any{} + for _, key := range []string{"name", "internal-name"} { + if v, ok := values[key].(string); ok && v != "" { + names[key] = v + } + } + return names + } + out := map[string]any{} + if raw, ok := m.Contributes[to]; ok { + values, err := resolutionComposed(r, m, to, raw, layers, m.Module+" contributing to "+to) + if err != nil { + return nil, err + } + for k, v := range pick(values) { + out[k] = v + } + } + if locals := m.ContributesMany[to]; len(locals) > 0 { + many := map[string]any{} + for _, local := range sortedKeys(locals) { + values, err := resolutionComposed(r, m, to, locals[local], layers, + m.Module+" contributing "+local+" to "+to) + if err != nil { + return nil, err + } + if names := pick(values); len(names) > 0 { + many[local] = names + } + } + if len(many) > 0 { + out["names"] = many + } + } + return out, nil +} + +// composeName joins a contribution's label with a node's public domain, and separately with its +// private one, in place (novox/hq ADR 0056). +// +// **The whole of what the mesh does with a route's name: join two given strings — twice.** A +// contribution carries a `label` — the subdomain its operator chose — and the node carries its +// public domain and its own private-network address; the granted names are `