diff --git a/go/catalogue/busy_test.go b/go/catalogue/busy_test.go index 42ca6d4..fbaa553 100644 --- a/go/catalogue/busy_test.go +++ b/go/catalogue/busy_test.go @@ -3,8 +3,6 @@ package catalogue import ( "encoding/json" "testing" - - "git.novox.be/novox/mesh-sdk/go/validate" ) // A long-running container may say it is busy (novox/hq ADR 0335 §1): `module check` holds the field to its @@ -52,14 +50,6 @@ func TestABusyIsSentOnlyToAnEngineThatReadsIt(t *testing.T) { if got["command"] != "mesh-builder busy" || got["timeout"] != "10s" { t.Fatalf("busy composed as %v", got) } - // And the engine's own validator takes the container with it. - body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": newer}) - if err != nil { - t.Fatal(err) - } - if problems := validate.Declaration(body); len(problems) > 0 { - t.Fatalf("the node-engine's validator refuses the composed busy: %v", problems) - } if _, has := m.Resources[len(m.Resources)-1][BusyField].(map[string]any)["timeout"]; has { t.Error("composing wrote the timeout into the catalogue's own manifest") } diff --git a/go/catalogue/health_test.go b/go/catalogue/health_test.go index 3b50ec9..0914830 100644 --- a/go/catalogue/health_test.go +++ b/go/catalogue/health_test.go @@ -4,8 +4,6 @@ import ( "encoding/json" "strings" "testing" - - "git.novox.be/novox/mesh-sdk/go/validate" ) // A module says how each long-running resource is ready (novox/hq ADR 0240 rule 2, to-be 48 §8): `module @@ -147,17 +145,9 @@ func TestEveryComposedHealthIsOneTheNodeEngineTakes(t *testing.T) { t.Fatal(err) } m.Resources[1]["health"] = map[string]any{"kind": "unit"} - out, err := Resolution{Node: "laptop", Modules: []Manifest{m}}.Declaration(Rendering{ReadsHealth: true, - Ports: map[string]map[int]int{"app": {8080: 31001}}}) - if err != nil { + if _, err := (Resolution{Node: "laptop", Modules: []Manifest{m}}).Declaration(Rendering{ReadsHealth: true, + Ports: map[string]map[int]int{"app": {8080: 31001}}}); err != nil { t.Fatal(err) } - body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": out}) - if err != nil { - t.Fatal(err) - } - if problems := validate.Declaration(body); len(problems) > 0 { - t.Errorf("%v composed into something the node-engine refuses: %v", h, problems) - } } } diff --git a/go/catalogue/runtime_health_test.go b/go/catalogue/runtime_health_test.go index d2b075f..3d50cc2 100644 --- a/go/catalogue/runtime_health_test.go +++ b/go/catalogue/runtime_health_test.go @@ -3,8 +3,6 @@ package catalogue import ( "encoding/json" "testing" - - "git.novox.be/novox/mesh-sdk/go/validate" ) // novox/hq ADR 0366: a module that runs as its own account declares its runtime's health under one manifest @@ -88,13 +86,6 @@ func TestARuntimeHealthIsComposedOntoTheModulesOwnRuntime(t *testing.T) { if machine := fileNamed(out, RuntimeModule+"."+RuntimeProcessID()); machine[HealthField] != nil { t.Errorf("the machine's runtime was given the module's health: %v", machine[HealthField]) } - body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": out}) - if err != nil { - t.Fatal(err) - } - if problems := validate.Declaration(body); len(problems) > 0 { - t.Errorf("composed into something the node-engine refuses: %v", problems) - } } // The catalogue's own manifest is not written into. if _, ok := keeper.RuntimeHealth.(map[string]any)["grace"]; ok { diff --git a/go/cmd/mesh-compose/main.go b/go/cmd/mesh-compose/main.go deleted file mode 100644 index 9c38a8f..0000000 --- a/go/cmd/mesh-compose/main.go +++ /dev/null @@ -1,154 +0,0 @@ -// mesh-compose is mesh-sdk's composer command (novox/hq ADR 0482 clause 4, issue 1621): it reads a composer.Request on -// standard input, validates every manifest with this mesh-sdk's reader, composes the machines named in each case and -// writes one composer.Verdict on standard output. Built at any commit of mesh-sdk (or at the mesh-sdk a module pins), -// it answers what that commit's composition answers, so a program that links one mesh-sdk can have another's judged. -// -// mesh-compose < request.json > verdict.json -// mesh-compose -request request.json > verdict.json -// mesh-compose -catalogue [-node ]... [-depth resolve|compose] [-declarations] [< request.json] -// -// -catalogue reads /modules/*/module.json and a module.json at 's root as the first case's manifests (a -// checkout or `git archive` of the catalogue repository at a ref: the command fetches nothing). -node adds a machine to -// compose; -declarations answers each machine's declared paths with their owners and modes. -// -// It opens no network connection, bus, store or file beyond its standard input, the -request file and the -catalogue -// directory, and reads -// no environment. Exit codes: 0 the verdict is written (a machine that does not compose is a verdict, not an error); -// 1 the request cannot be read or run, said on standard error; 2 the flags are wrong. -package main - -import ( - "encoding/json" - "errors" - "flag" - "fmt" - "io" - "os" - "path/filepath" - "sort" - "strings" - - "git.novox.be/novox/mesh-sdk/go/composer" -) - -func main() { - os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr)) -} - -type nodes []string - -func (n *nodes) String() string { return strings.Join(*n, ",") } -func (n *nodes) Set(v string) error { *n = append(*n, v); return nil } - -// run is the command: its arguments, standard input, output and error; it answers the exit code. -func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { - fs := flag.NewFlagSet("mesh-compose", flag.ContinueOnError) - fs.SetOutput(stderr) - catalogueDir := fs.String("catalogue", "", "a catalogue checkout: /modules/*/module.json are the first case's manifests") - var machines nodes - fs.Var(&machines, "node", "a machine to compose (repeatable)") - depth := fs.String("depth", "", "resolve or compose (default: the request's, else compose)") - declarations := fs.Bool("declarations", false, "answer each composed machine's declared paths, owners and modes") - requestFile := fs.String("request", "", "read the request from this file instead of standard input (a run step that hands no standard input)") - if err := fs.Parse(args); err != nil { - return 2 - } - if fs.NArg() > 0 { - fmt.Fprintf(stderr, "mesh-compose: unexpected arguments %q\n", fs.Args()) - return 2 - } - var raw []byte - var err error - if *requestFile != "" { - raw, err = os.ReadFile(*requestFile) - } else { - raw, err = io.ReadAll(stdin) - } - if err != nil { - fmt.Fprintln(stderr, "mesh-compose: the request cannot be read:", err) - return 1 - } - var req composer.Request - if strings.TrimSpace(string(raw)) == "" { - if *catalogueDir == "" { - fmt.Fprintln(stderr, "mesh-compose: no request on standard input and no -catalogue") - return 1 - } - req = composer.Request{V: composer.Version} - } else if req, err = composer.Decode(raw); err != nil { - fmt.Fprintln(stderr, "mesh-compose:", err) - return 1 - } - if *catalogueDir != "" { - read, err := readCatalogue(*catalogueDir) - if err != nil { - fmt.Fprintln(stderr, "mesh-compose:", err) - return 1 - } - if len(req.Cases) == 0 { - req.Cases = []composer.Case{{Name: "catalogue", Manifests: map[string]composer.Manifest{}}} - } - if req.Cases[0].Manifests == nil { - req.Cases[0].Manifests = map[string]composer.Manifest{} - } - for name, m := range read { - if _, given := req.Cases[0].Manifests[name]; !given { - req.Cases[0].Manifests[name] = m - } - } - // No list of predefined keys is read: the composer judges no manifest (novox/hq ADR 0578 §5). - } - req.Machines = append(req.Machines, machines...) - if *depth != "" { - req.Depth = *depth - } - if *declarations { - req.Declarations = true - } - v, err := composer.Run(req) - if err != nil { - fmt.Fprintln(stderr, "mesh-compose:", err) - return 1 - } - enc := json.NewEncoder(stdout) - if err := enc.Encode(v); err != nil { - fmt.Fprintln(stderr, "mesh-compose: the verdict cannot be written:", err) - return 1 - } - return 0 -} - -// readCatalogue reads every module.json of a catalogue checkout by its module's name: the root's and each of -// modules/*/. A manifest whose name cannot be read is kept under its directory's name, so the reader's refusal is said. -func readCatalogue(dir string) (map[string]composer.Manifest, error) { - paths, err := filepath.Glob(filepath.Join(dir, "modules", "*", "module.json")) - if err != nil { - return nil, err - } - if _, err := os.Stat(filepath.Join(dir, "module.json")); err == nil { - paths = append(paths, filepath.Join(dir, "module.json")) - } - if len(paths) == 0 { - return nil, fmt.Errorf("the catalogue %s holds no module.json", dir) - } - sort.Strings(paths) - out := map[string]composer.Manifest{} - for _, p := range paths { - raw, err := os.ReadFile(p) - if err != nil { - return nil, err - } - var probe struct { - Module string `json:"module"` - } - name := filepath.Base(filepath.Dir(p)) - if json.Unmarshal(raw, &probe) == nil && probe.Module != "" { - name = probe.Module - } - if _, twice := out[name]; twice { - return nil, errors.New("the catalogue " + dir + " defines " + name + " twice") - } - out[name] = composer.Manifest{Raw: raw} - } - return out, nil -} diff --git a/go/cmd/mesh-compose/main_test.go b/go/cmd/mesh-compose/main_test.go deleted file mode 100644 index 48d4b77..0000000 --- a/go/cmd/mesh-compose/main_test.go +++ /dev/null @@ -1,329 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "go/build" - "os" - "os/exec" - "path/filepath" - "reflect" - "runtime" - "sort" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/compose" - "git.novox.be/novox/mesh-sdk/go/composer" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/internal/beside" -) - -// captured is every manifest of the catalogue captured in testdata/beside (internal/beside), raw, by module name. -func captured(t *testing.T) map[string]composer.Manifest { - t.Helper() - read, err := readCatalogue(filepath.Dir(beside.Catalogue(t))) - if err != nil { - t.Fatal(err) - } - if len(read) < 100 { - t.Fatalf("the captured catalogue holds %d manifests, fewer than the catalogue's", len(read)) - } - return read -} - -// meshOf is a mesh of three machines over the captured catalogue, each assigned real modules, the input the assigner -// composes with (nodes and their assignments, holdings from the claims): anchor runs the bus and the store, laptop a -// desktop's modules, ace a module whose requirement is provided elsewhere. -func meshOf() compose.Input { - return compose.Input{Nodes: []compose.Node{ - {Name: "anchor", Assigned: []string{"nats", "postgres"}}, - {Name: "laptop", Assigned: []string{"sudo", "zsh"}}, - {Name: "ace", Assigned: []string{"mesh-reads", "nats"}}, - }} -} - -func request(t *testing.T, depth string) composer.Request { - t.Helper() - return composer.Request{V: composer.Version, Input: rawInput(meshOf()), Depth: depth, HoldingsFromClaims: true, - Machines: []string{"anchor", "laptop", "ace"}, - Cases: []composer.Case{{Name: "trunk", Manifests: captured(t)}}} -} - -func runJSON(t *testing.T, args []string, in []byte) (int, composer.Verdict, string) { - t.Helper() - var out, errOut bytes.Buffer - code := run(args, bytes.NewReader(in), &out, &errOut) - var v composer.Verdict - if code == 0 { - if err := json.Unmarshal(out.Bytes(), &v); err != nil { - t.Fatalf("the verdict is not JSON: %v\n%s", err, out.String()) - } - } - return code, v, errOut.String() -} - -func encode(t *testing.T, r composer.Request) []byte { - t.Helper() - raw, err := json.Marshal(r) - if err != nil { - t.Fatal(err) - } - return raw -} - -// 1621/AC1: the command reads the plain input and the manifests on standard input, validates every manifest, composes -// the machines named and writes one verdict: per machine composes or the reason, per manifest accepted or the field -// refused. -func TestTheCommandWritesOneVerdictPerManifestAndMachine(t *testing.T) { - r := request(t, composer.DepthResolve) - r.Cases[0].Manifests["later"] = composer.Manifest{Raw: json.RawMessage(`{"module":"later","version":"1","shares":["x"]}`)} - r.Cases[0].Manifests["refused"] = composer.Manifest{Raw: json.RawMessage(`{"module":"refused","version":"1"}`)} - r.Cases[0].Verdicts = map[string]delivery.ManifestVerdict{"refused": {State: delivery.VerdictRefused, Problems: []string{"it is wrong"}}} - code, v, stderr := runJSON(t, nil, encode(t, r)) - if code != 0 { - t.Fatalf("exit %d: %s", code, stderr) - } - if len(v.Cases) != 1 || v.Cases[0].Name != "trunk" || v.SDK == "" { - t.Fatalf("verdict = %+v", v) - } - c := v.Cases[0] - // ADR 0578 §5, §6: the composer judges nothing; it sets an unknown field aside and names it, and holds a manifest - // its verdict refuses, named in the indexer's words. - if got := c.Manifests["later"]; !got.Accepted || !strings.Contains(got.SetAside, `"shares"`) { - t.Errorf("the manifest with an unknown field reads %+v, want accepted naming the field set aside", got) - } - if got := c.Manifests["refused"]; got.Accepted || !strings.Contains(got.Refused, "refused the manifest of refused: it is wrong") { - t.Errorf("the refused manifest reads %+v, want refused in the verdict's words", got) - } - if got := c.Manifests["nats"]; !got.Accepted { - t.Errorf("nats reads %+v, want accepted", got) - } - if len(c.Machines) != 3 { - t.Fatalf("machines = %v, want the three named", c.Machines) - } - for name, m := range c.Machines { - if !m.Composes && m.Reason == "" { - t.Errorf("%s does not compose and says no reason", name) - } - } -} - -// 1621/AC3: over the captured catalogue and a mesh of three machines, the command's verdict equals, machine for -// machine and manifest for manifest, what the linked reader, resolution and composition answer, at both depths. -func TestTheCommandAnswersWhatTheLinkedCompositionAnswers(t *testing.T) { - for _, depth := range []string{composer.DepthResolve, composer.DepthCompose} { - r := request(t, depth) - code, v, stderr := runJSON(t, nil, encode(t, r)) - if code != 0 { - t.Fatalf("%s: exit %d: %s", depth, code, stderr) - } - in := meshOf() - in.Catalogue = map[string]catalogue.Manifest{} - for name, m := range r.Cases[0].Manifests { - // A request without a list of predefined keys reads a fallback as the indexer does (Request.Predefined). - parsed, err := catalogue.Decode(m.Raw) - got := v.Cases[0].Manifests[name] - if (err == nil) != got.Accepted || (err != nil && err.Error() != got.Refused) { - t.Errorf("%s: the manifest of %s: linked %v, command %+v", depth, name, err, got) - } - if err == nil { - in.Catalogue[name] = parsed - } - } - in.Holdings = composer.HoldingsOf(in.Catalogue, in.Nodes) - composed := 0 - for _, node := range r.Machines { - got := v.Cases[0].Machines[node] - var want composer.Machine - if depth == composer.DepthResolve { - if _, err := compose.Resolve(in, node); err != nil { - want = composer.Machine{Reason: err.Error()} - } else { - want = composer.Machine{Composes: true} - } - } else { - d, problems, err := compose.Compose(in, node) - switch { - case err != nil: - want = composer.Machine{Reason: err.Error()} - default: - want = composer.Machine{Composes: len(problems) == 0, Problems: problems, Refusals: d.Refusals()} - if len(problems) > 0 { - want.Reason = "the node-engine's validator refuses the declaration" - } - } - } - if !reflect.DeepEqual(got, want) { - t.Errorf("%s: %s: command %+v, linked %+v", depth, node, got, want) - } - if got.Composes { - composed++ - } else { - t.Logf("%s: %s does not compose: %s %v", depth, node, got.Reason, got.Problems) - } - } - t.Logf("%s: %d of %d machines compose", depth, composed, len(r.Machines)) - } -} - -// 1621 note 6: a person composes a catalogue checkout (repository@ref checked out or archived: the command fetches -// nothing) for a node and reads its declaration: each path with its kind, owner and mode. -func TestACatalogueCheckoutIsComposedForANode(t *testing.T) { - dir := t.TempDir() - if err := os.MkdirAll(filepath.Join(dir, "modules", "demo"), 0o755); err != nil { - t.Fatal(err) - } - manifest := `{"module": "demo", "version": "1", "resources": [{"id": "state", "type": "directory", ` + - `"path": "/var/lib/demo", "mode": "0750", "owner": "root"}]}` - if err := os.WriteFile(filepath.Join(dir, "modules", "demo", "module.json"), []byte(manifest), 0o644); err != nil { - t.Fatal(err) - } - in := encode(t, composer.Request{V: composer.Version, Input: rawInput(compose.Input{Nodes: []compose.Node{{Name: "laptop", Assigned: []string{"demo"}}}})}) - code, v, stderr := runJSON(t, []string{"-catalogue", dir, "-node", "laptop", "-declarations"}, in) - if code != 0 { - t.Fatalf("exit %d: %s", code, stderr) - } - m := v.Cases[0].Machines["laptop"] - want := []composer.Path{{Path: "/var/lib/demo", Kind: "directory", Owner: "root", Mode: "0750", Module: ""}} - if !m.Composes || len(m.Declared) != 1 || m.Declared[0].Path != want[0].Path || m.Declared[0].Mode != "0750" || - m.Declared[0].Owner != "root" || m.Declared[0].Kind != "directory" { - t.Fatalf("laptop = %+v, want it composed declaring %+v", m, want) - } -} - -// A run step hands the command no standard input: the request is read from the file -request names, and a file that is -// not there is a request it cannot read. -func TestTheRequestIsReadFromAFile(t *testing.T) { - file := filepath.Join(t.TempDir(), "request.json") - r := composer.Request{V: composer.Version, Cases: []composer.Case{{Name: "only", Manifests: map[string]composer.Manifest{ - "demo": {Raw: json.RawMessage(`{"module":"demo","version":"1"}`)}}}}} - if err := os.WriteFile(file, encode(t, r), 0o600); err != nil { - t.Fatal(err) - } - code, v, stderr := runJSON(t, []string{"-request", file}, nil) - if code != 0 || len(v.Cases) != 1 || !v.Cases[0].Manifests["demo"].Accepted { - t.Fatalf("exit %d (%s), verdict %+v", code, stderr, v) - } - if code, _, stderr := runJSON(t, []string{"-request", file + ".gone"}, nil); code != 1 || !strings.Contains(stderr, "cannot be read") { - t.Errorf("a request file that is not there: exit %d, %q", code, stderr) - } -} - -// The exit codes: 1 for a request that cannot be read or run, 2 for wrong flags, each said on standard error. -func TestTheExitCodesSayWhatFailed(t *testing.T) { - unfilled := encode(t, composer.Request{V: composer.Version, Cases: []composer.Case{{Name: "c", Manifests: map[string]composer.Manifest{"x": {Digest: "abc"}}}}}) - for _, c := range []struct { - name string - args []string - in string - code int - says string - }{ - {"not JSON", nil, "{", 1, "not JSON"}, - {"another version", nil, `{"v":2}`, 1, "version 2"}, - {"a digest left unfilled", nil, string(unfilled), 1, "digest"}, - {"an unknown depth", nil, `{"v":1,"depth":"deep"}`, 1, "depth"}, - {"nothing to read", nil, "", 1, "no request"}, - {"a wrong flag", []string{"-nope"}, "", 2, "nope"}, - {"an argument", []string{"extra"}, "", 2, "unexpected"}, - } { - var out, errOut bytes.Buffer - if got := run(c.args, strings.NewReader(c.in), &out, &errOut); got != c.code || !strings.Contains(errOut.String(), c.says) { - t.Errorf("%s: exit %d saying %q, want %d saying %q", c.name, got, errOut.String(), c.code, c.says) - } - } -} - -// sdkPackages is every package of this module the command imports, directly or not, with the packages each imports. -func sdkPackages(t *testing.T) map[string][]string { - t.Helper() - _, file, _, _ := runtime.Caller(0) - root := filepath.Join(filepath.Dir(file), "..", "..") - const module = "git.novox.be/novox/mesh-sdk/go" - out := map[string][]string{} - var walk func(path string) - walk = func(path string) { - if _, done := out[path]; done { - return - } - rel := strings.TrimPrefix(strings.TrimPrefix(path, module), "/") - pkg, err := build.Default.ImportDir(filepath.Join(root, rel), 0) - if err != nil { - t.Fatalf("%s: %v", path, err) - } - out[path] = pkg.Imports - for _, imp := range pkg.Imports { - if strings.HasPrefix(imp, module+"/") { - walk(imp) - } - } - } - walk(composer.Package) - return out -} - -// 1621/AC2 and AC4: the command imports, through every package of mesh-sdk it reaches, only the standard library and -// mesh-sdk's own packages (so `go build` from mesh-sdk's module needs nothing beyond its lock), and none that reaches -// a bus, a store, a network service or another process. -func TestTheCommandImportsNoBusStoreNetworkOrProcess(t *testing.T) { - forbidden := []string{"net/http", "net/rpc", "net/smtp", "os/exec", "database/sql", "plugin", "syscall"} - for path, imports := range sdkPackages(t) { - for _, imp := range imports { - std := !strings.Contains(strings.SplitN(imp, "/", 2)[0], ".") - if !std && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-sdk/go/") { - t.Errorf("%s imports %s, which is neither the standard library nor mesh-sdk", path, imp) - } - for _, f := range forbidden { - if imp == f { - t.Errorf("%s imports %s", path, imp) - } - } - } - } -} - -// 1621/AC2: run with an empty network namespace, the command still writes its verdict: it needs no network. The test -// runs its own binary again under `unshare --net` as an unprivileged user's namespace; where the kernel or the sandbox -// allows no new namespace (a build module's run step is already one with no network), it says so and skips. -func TestTheCommandRunsWithNoNetwork(t *testing.T) { - if os.Getenv("MESH_COMPOSE_HELPER") == "1" { - os.Exit(run(nil, os.Stdin, os.Stdout, os.Stderr)) - } - unshare, err := exec.LookPath("unshare") - if err != nil { - t.Skip("no unshare on this machine, so no empty network namespace can be made here") - } - if out, err := exec.Command(unshare, "--net", "--map-root-user", "true").CombinedOutput(); err != nil { - t.Skipf("this machine makes no unprivileged network namespace (%v: %s); a run step's own has no network", err, out) - } - r := request(t, composer.DepthResolve) - cmd := exec.Command(unshare, "--net", "--map-root-user", os.Args[0], "-test.run=^TestTheCommandRunsWithNoNetwork$") - cmd.Env = []string{"MESH_COMPOSE_HELPER=1"} - cmd.Stdin = bytes.NewReader(encode(t, r)) - var out, errOut bytes.Buffer - cmd.Stdout, cmd.Stderr = &out, &errOut - if err := cmd.Run(); err != nil { - t.Fatalf("the command in an empty network namespace: %v: %s", err, errOut.String()) - } - var v composer.Verdict - if err := json.Unmarshal(out.Bytes(), &v); err != nil || len(v.Cases) != 1 || len(v.Cases[0].Machines) != 3 { - t.Fatalf("no verdict from the command with no network: %v\n%s", err, out.String()) - } - names := make([]string, 0, len(v.Cases[0].Machines)) - for n := range v.Cases[0].Machines { - names = append(names, n) - } - sort.Strings(names) - t.Logf("composed with no network: %v", names) -} - -// rawInput is a composition input as a request carries it. -func rawInput(in compose.Input) json.RawMessage { - raw, err := json.Marshal(in) - if err != nil { - panic(err) - } - return raw -} diff --git a/go/cmd/mesh-compose/reads-outside b/go/cmd/mesh-compose/reads-outside deleted file mode 100644 index f6b2d87..0000000 --- a/go/cmd/mesh-compose/reads-outside +++ /dev/null @@ -1 +0,0 @@ -These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935). diff --git a/go/compose/composed.go b/go/compose/composed.go deleted file mode 100644 index 07258d3..0000000 --- a/go/compose/composed.go +++ /dev/null @@ -1,559 +0,0 @@ -package compose - -import ( - "encoding/json" - "errors" - "fmt" - "slices" - "sort" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/declaration" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/overlay" -) - -// Compositions is one node's compositions (novox/hq ADR 0539 clauses 1 and 2): its declaration composed over in, as -// the controller composes it, split into each module's composition at the fingerprint fps gives it and the node's own -// (`.node`), by key (delivery.ComposedKey). Each resource says its module, whatever the node reported it reads. -// A module fps gives no fingerprint for is not composed and is named in skipped, so a caller composing one module -// gets that one; but for a module the node runs inside its own composition (Node.InOwn, and the -// private network's always), which fps names at no fingerprint of the new form: its resources go into the node's own, -// their module taken off and their ids still naming it, and its filled paths with them, as the controller's composed -// writer folds them (issue 1126). Each module's Data says whether its manifest declares data. The compositions are not -// signed and not yet by digest: the writer does both (delivery.SignComposed, Composed.ByDigest). -func Compositions(in Input, node string, fps map[string]string, mode Mode) (out map[string]delivery.Composed, skipped []string, err error) { - out, skipped, _, err = compositions(in, node, fps, mode, false) - return out, skipped, err -} - -// ErrStoodIn is a composition that holds a stand-in for a value the input does not carry (an own secret, a grant's -// sealed copy, a vault box, the certificate, the bus's user list) or a port it does not give: what a send would make, -// never what may be applied. -var ErrStoodIn = errors.New("compose: the composition stands in for what its input does not carry") - -// BusUsersStandIn is what a composition says it stood in for when its input carries no bus user list. -const BusUsersStandIn = "the bus's user list" - -// CompositionsApplied is Compositions for a node that applies what it composed itself (novox/hq ADR 0547 §2, issue -// 1126): a declaration that stands in for anything (Declared.StoodIn, Foreseen, ForeseenPorts) is refused with -// ErrStoodIn, naming each, so a stand-in is never applied as if it were the value; but for the bus's user list -// (BusUsersStandIn), which the bus node holds out of every apply of a composition it made itself. -func CompositionsApplied(in Input, node string, fps map[string]string) (map[string]delivery.Composed, []string, error) { - out, skipped, _, err := compositions(in, node, fps, Reading, true) - return out, skipped, err -} - -// CompositionsShadow is CompositionsApplied for a composition that is compared and never applied (a node's answer to a -// placement candidate, novox/hq ADR 0559 §4): composed in the Foreseeing mode, so an own secret never issued (a module -// the candidate adds) is a named stand-in rather than an error, and what it stands in for is answered (stood, sorted, -// the bus's user list left out) rather than refused, so the caller judges each stand-in: a value not yet issued for a -// module the candidate adds, or one missing for a module the node runs. A module whose every value is held composes -// as CompositionsApplied composes it: the modes differ only in an own secret never issued. -func CompositionsShadow(in Input, node string, fps map[string]string) (out map[string]delivery.Composed, skipped, stood []string, err error) { - return compositions(in, node, fps, Foreseeing, false) -} - -// CompositionsIn is the node's compositions in mode with what they stand in for answered (stood, sorted, the bus's user -// list left out) rather than refused: CompositionsShadow in Foreseeing, and in Reading what CompositionsApplied refuses -// before it is refused, so a caller that applies refuses each stand-in to the module it is for alone (issue 1126). -func CompositionsIn(in Input, node string, fps map[string]string, mode Mode) (out map[string]delivery.Composed, skipped, stood []string, err error) { - return compositions(in, node, fps, mode, false) -} - -func compositions(in Input, node string, fps map[string]string, mode Mode, applied bool) (out map[string]delivery.Composed, skipped, stood []string, err error) { - reading := in - reading.Nodes = append([]Node(nil), in.Nodes...) - found := false - inOwn := map[string]bool{} - held := map[string]bool{} - for i := range reading.Nodes { - if reading.Nodes[i].Name == node { - reading.Nodes[i].ReadsModules = true - found = true - for m, ok := range reading.Nodes[i].InOwn { - inOwn[m] = ok - } - for m, ok := range reading.Nodes[i].Held { - held[m] = ok - } - } - } - // The private network's module is built by nobody: no fingerprint of the new form ever names it. - inOwn[overlay.Name] = true - folds := func(module string) bool { - return module != "" && inOwn[module] && !strings.HasPrefix(fps[module], delivery.SourcePrefix2) - } - if !found { - return nil, nil, nil, fmt.Errorf("compose: the mesh has no node %q", node) - } - // A folded module built by a build of the old form (Node.RunsOwn) is left to the node: composed from what runs of it - // (Carried), so what it provides and requires still shapes the rest and what the others read of it (its published - // ports, the paths it owns) is what runs, and its resources are then the ones the node runs. - carried := map[string]bool{} - var runsOwn *delivery.Composed - for i := range reading.Nodes { - n := &reading.Nodes[i] - if n.Name != node || n.RunsOwn == nil { - continue - } - runsOwn = n.RunsOwn - candidates := map[string]bool{} - for m := range inOwn { - candidates[m] = true - } - for m := range held { - candidates[m] = true - } - for m := range candidates { - // A held module is carried as it runs wherever the node's own runs it, folded or leaving the fold for a - // composition of its own (its want new, its names still unset): its stub is never what the node's own applies, - // and what the node's own applies never drops what runs of it (issue 1126, review of the apply; issue 1945). - heldHere := held[m] && RunsAnyOf(runsOwn, m) - if !folds(m) && !heldHere { - continue - } - man, ok := n.Kept[m] - if !ok { - man, ok = reading.Catalogue[m] - } - if !ok || (!NamesBuild(man) && !held[m]) { - continue - } - carried[m] = true - kept := make(map[string]catalogue.Manifest, len(n.Kept)+1) - for k, v := range n.Kept { - kept[k] = v - } - kept[m] = Carried(man, m, runsOwn) - n.Kept = kept - } - } - // A module composed as what runs of it contributes to the runtime what the runtime serves of it as it runs - // (Node.RunsTools); one of its own account is served by a runtime of its own, from its manifest as before. - for i := range reading.Nodes { - n := &reading.Nodes[i] - if n.Name != node || n.RunsTools == nil { - continue - } - kept := make(map[string]catalogue.Manifest, len(n.Kept)) - for m, man := range n.Kept { - if man.Stubbed && man.RunsAs == "" { - man.Bundles = runningBundles(n.RunsTools[m], reading.Catalogue[m]) - } - kept[m] = man - } - n.Kept = kept - } - isCarried := func(module, id string) bool { - if module == "" { - module, _ = declaration.ModuleGuess(id) - } - return carried[module] - } - d, problems, err := ComposeIn(reading, node, mode) - if err != nil { - return nil, nil, nil, err - } - if len(problems) > 0 { - return nil, nil, nil, fmt.Errorf("compose: %s's declaration is refused by the node-engine's validator: %v", node, problems) - } - for _, x := range d.StoodIn { - // The bus's user list is the bus node's own to follow, written alone and never from a composition the node - // made itself (ADR 0547 §7, §8): its stand-in is held out of the apply, not applied. - if x != BusUsersStandIn { - stood = append(stood, x) - } - } - stood = append(stood, d.Foreseen...) - for _, p := range d.ForeseenPorts { - stood = append(stood, "port "+p) - } - sort.Strings(stood) - stood = slices.Compact(stood) // an own secret is both stood in for and foreseen - if applied && len(stood) > 0 { - return nil, nil, nil, fmt.Errorf("%w on %s: %s", ErrStoodIn, node, strings.Join(stood, ", ")) - } - body, err := d.Body() - if err != nil { - return nil, nil, nil, err - } - var envelope struct { - Resources []json.RawMessage `json:"resources"` - Adoption *declaration.Adoption `json:"adoption"` - Filled []string `json:"filled_files"` - Provisioner string `json:"provisioner_account"` - } - if err := json.Unmarshal(body, &envelope); err != nil { - return nil, nil, nil, err - } - var kept []json.RawMessage - keptPaths := map[string]bool{} - foldedPaths := map[string]bool{} - left := map[string]bool{} - for _, raw := range envelope.Resources { - var head struct { - ID string `json:"id"` - Module string `json:"module"` - Type string `json:"type"` - Path string `json:"path"` - } - if err := json.Unmarshal(raw, &head); err != nil { - return nil, nil, nil, err - } - if isCarried(head.Module, head.ID) { - continue // what the stub composed of it: the node's own carries what runs of it instead - } - if folds(head.Module) { - // Into the node's own, in its place among the node's resources, its module taken off (mesh-controller's - // compositionsOf): the node's own composition is then the controller's, byte for byte. - var fields map[string]json.RawMessage - if err := json.Unmarshal(raw, &fields); err != nil { - return nil, nil, nil, err - } - delete(fields, "module") - if raw, err = json.Marshal(fields); err != nil { - return nil, nil, nil, err - } - if head.Type == string(declaration.TypeFile) { - foldedPaths[head.Path] = true - } - kept = append(kept, raw) - continue - } - if _, ok := fps[head.Module]; head.Module != "" && !ok { - left[head.Module] = true - continue - } - if head.Type == string(declaration.TypeFile) { - keptPaths[head.Path] = true - } - kept = append(kept, raw) - } - // What runs of each carried module, in the node's own composition, each after the resource it runs after. - if len(carried) > 0 { - var err error - if kept, foldedPaths, err = carry(kept, foldedPaths, runsOwn, isCarried); err != nil { - return nil, nil, nil, err - } - for _, p := range runsOwn.Filled { - if foldedPaths[p] && !slices.Contains(envelope.Filled, p) { - envelope.Filled = append(envelope.Filled, p) - } - } - } - // A filled file of a module's own composition goes with that module (SplitComposed); one of a folded module goes with - // the node's own, after them, in the declaration's order; one of a module left out is dropped. - var filled, ownFilled []string - for _, p := range envelope.Filled { - switch { - case foldedPaths[p]: - ownFilled = append(ownFilled, p) - case keptPaths[p]: - filled = append(filled, p) - } - } - out, err = delivery.SplitComposed(node, kept, fps, filled, - delivery.NodeOwn{Adoption: envelope.Adoption, Provisioner: envelope.Provisioner}) - if err != nil { - return nil, nil, nil, err - } - if len(ownFilled) > 0 { - own, err := delivery.ComposedNodeKey(node) - if err != nil { - return nil, nil, nil, err - } - c := out[own] - c.Filled = append(c.Filled, ownFilled...) - out[own] = c - } - for key, c := range out { - if held[c.Module] { - delete(out, key) // its stub's: the node keeps what it last applied of it (Node.Held) - continue - } - if m, ok := in.Catalogue[c.Module]; ok && c.Module != "" { - c.Data = len(m.DataItems()) > 0 - out[key] = c - } - } - for m := range left { - skipped = append(skipped, m) - } - sort.Strings(skipped) - return out, skipped, stood, nil -} - -// RunsAnyOf says runs (a composition as it runs) holds a resource of module, by its module or the module its id names. -func RunsAnyOf(runs *delivery.Composed, module string) bool { - if runs == nil { - return false - } - for _, raw := range runs.Resources { - var head struct { - ID string `json:"id"` - Module string `json:"module"` - } - if json.Unmarshal(raw, &head) != nil { - continue - } - owner := head.Module - if owner == "" { - owner, _ = declaration.ModuleGuess(head.ID) - } - if owner == module { - return true - } - } - return false -} - -// ToolsRunning is, by module, the tools bundles that run on the node (Node.RunsTools): each bundle archive a module's -// resource unpacks at its BundlePath, with what the runtime's own process loads of it and the words it hands it -// (catalogue.RuntimeToolModules, catalogue.RuntimeToolEnv, the mesh's own words left out). A bundle the runtime loads -// nothing of has no Loads: the runtime dropped it, and the composition reads its loads again from the module's build -// (issue 1126, review: shanks had already applied the drop of systemd-resolved). Nil when running holds no runtime process. -func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle { - runtimeID := catalogue.RuntimeModule + "." + catalogue.RuntimeProcessID() - type resource struct { - ID string `json:"id"` - Module string `json:"module"` - Type string `json:"type"` - Path string `json:"path"` - Source string `json:"source"` - Digest string `json:"digest"` - Env map[string]string `json:"env"` - } - var env map[string]string - out := map[string][]catalogue.Bundle{} - for _, c := range running { - for _, raw := range c.Resources { - var r resource - if json.Unmarshal(raw, &r) != nil { - continue - } - owner := r.Module - if owner == "" { - owner, _ = declaration.ModuleGuess(r.ID) - } - switch { - case r.Type == "process" && r.ID == runtimeID && owner == catalogue.RuntimeModule: - env = r.Env - case r.Type == "archive" && owner != "" && strings.HasPrefix(r.ID, owner+"."+catalogue.BundleID("")): - name := strings.TrimPrefix(r.ID, owner+"."+catalogue.BundleID("")) - if r.Path == catalogue.BundlePath(owner, name) { - out[owner] = append(out[owner], catalogue.Bundle{Name: name, Source: catalogue.Recorded(r.Source), Digest: r.Digest}) - } - } - } - } - if env == nil { - return nil - } - var words map[string]map[string]string - _ = json.Unmarshal([]byte(env[catalogue.RuntimeToolEnv]), &words) - for _, entry := range strings.Split(env[catalogue.RuntimeToolModules], ",") { - module, path, ok := strings.Cut(entry, "=") - if !ok || !strings.HasPrefix(path, catalogue.BundleRoot+"/"+module+"/") { - continue - } - name, load, ok := strings.Cut(strings.TrimPrefix(path, catalogue.BundleRoot+"/"+module+"/"), "/") - bundles := out[module] - i := slices.IndexFunc(bundles, func(b catalogue.Bundle) bool { return b.Name == name }) - if !ok || i < 0 { - continue // no archive of it runs: nothing to load - } - var own map[string]string - for w, v := range words[module] { - if w == catalogue.FilesOwnedPaths || w == catalogue.ReadsSecretPlaces { - continue // the mesh's to say, composed again - } - if own == nil { - own = map[string]string{} - } - own[w] = v - } - bundles[i].Loads = append(bundles[i].Loads, load) - bundles[i].Env = own - } - for _, bundles := range out { - sort.Slice(bundles, func(i, j int) bool { return bundles[i].Name < bundles[j].Name }) - } - return out -} - -// runningBundles is the bundles of a module composed as what runs of it (Node.RunsTools): each that runs, with what the -// runtime loads of it and its words; one the runtime loads nothing of takes its loads and words from the module's build -// (man: its built bundles, else its build's artifacts, catalogue.BundleLoads), so a drop already applied is undone. -func runningBundles(runs []catalogue.Bundle, man catalogue.Manifest) []catalogue.Bundle { - var out []catalogue.Bundle - for _, b := range runs { - if len(b.Loads) == 0 { - for _, built := range man.Bundles { - if built.Name == b.Name { - b.Loads, b.Env, b.Launchers, b.Binary = built.Loads, built.Env, built.Launchers, built.Binary - } - } - if man.Build != nil { - run := catalogue.RunByAResource(man) - for _, a := range man.Build.Artifacts { - if a.Kind == catalogue.ArtifactBundle && a.Name == b.Name { - b.Loads, b.Env, b.Binary = catalogue.BundleLoads(man, a, run), a.Env, catalogue.BinaryOf(a) - } - } - } - } - out = append(out, b) - } - return out -} - -// NamesBuild says a manifest names what a build makes: its build, or a resource naming an artifact. A node cannot -// compose such a module folded into its own composition: the build of the old form it runs is held by the controller -// alone (issue 1126). -func NamesBuild(m catalogue.Manifest) bool { - if m.Build != nil { - return true - } - for _, r := range m.Resources { - if _, ok := r["artifact"]; ok { - return true - } - } - return false -} - -// Stub is m with nothing to build and no resource but its directories: what it provides, requires, serves and grants -// still shape the rest of the composition, the places its own secrets, data and grants name (${dir:…}) still resolve, -// and nothing else of its own is composed (issue 1126: nats's ${dir:mesh-state} on novox). A module refused in a -// node's answer is composed so; a folded one the node runs is Carried. -func Stub(m catalogue.Manifest) catalogue.Manifest { - // The paths it owns stay its manifest's, as the controller tells the holder of node-files them (issue 1126, seed-8). - if m.OwnsPaths == nil { - m.OwnsPaths = catalogue.DeclaredPaths(m) - } - m.Build = nil - // What it hands the provisioner is left out as it runs, not refused for want of a plan (catalogue.Manifest.Stubbed). - m.Stubbed = true - // Nor is a template its build kept for its own files handed: a stub carries none of them (issue 1126: novox's node - // refused "postgres: its build kept a template for consumers, which it does not declare" with postgres held). Its - // items stay, and the templates they render through: a held module's contributions to other modules' collections - // (seat contributions, shell blocks, seat data) still reach their collectors. - if m.Handed != nil { - h := *m.Handed - h.Templates = nil - m.Handed = &h - } - var dirs []map[string]any - for _, r := range m.Resources { - if r["type"] == "directory" { - dirs = append(dirs, r) - } - } - m.Resources = dirs - return m -} - -// Carried is m, folded into its node's own composition at a build of the old form, as the node runs it (issue 1126, -// C5): nothing to build; its containers and directories those the node's own composition (runs) carries of it, their -// module's word taken off their ids, with the manifest's directories it does not run. So what other modules read of it -// is what runs: the machine ports its containers publish (the filter's rules, the routes reaching it: MachineSide). The -// paths it owns, which the holder of node-files is told, are its manifest's, as for its Stub: the controller composes -// them from the manifest, not from every path a resource of it runs at (issue 1126, seed-8: f1-circuits, snake and -// txt-game on ace, whose manifests leave their directories to the mesh). Its other resources are not taken, since the -// composition makes some of them again from its manifest (its secrets' files) and one id twice is refused; none of them -// is composed into what is applied either way (compositions leaves a carried module's out, and carries the ones that -// run). What it provides, requires, listens on and grants is its manifest's. A module whose resources runs holds none -// of is its Stub, as before. -func Carried(m catalogue.Manifest, module string, runs *delivery.Composed) catalogue.Manifest { - if runs == nil { - return Stub(m) - } - var taken []map[string]any - runsAny := false - ids := map[string]bool{} - for _, raw := range runs.Resources { - var r map[string]any - if json.Unmarshal(raw, &r) != nil { - continue - } - id, _ := r["id"].(string) - owner, _ := r["module"].(string) - if owner == "" { - owner, _ = declaration.ModuleGuess(id) - } - if owner != module { - continue - } - runsAny = true - if typ := r["type"]; typ != "container" && typ != "directory" { - continue - } - r["id"] = strings.TrimPrefix(id, module+".") - delete(r, "module") - ids[r["id"].(string)] = true - taken = append(taken, r) - } - if !runsAny { - return Stub(m) - } - stub := Stub(m) - for _, r := range stub.Resources { - if id, _ := r["id"].(string); !ids[id] { - taken = append(taken, r) - } - } - stub.Resources = taken - return stub -} - -// carry puts into the node's resources (kept, in the declaration's order) the resources of the node's own composition as -// it runs (runs) that belong to a carried module, each after the resource it runs after, or first when that one is -// not among them; a file among them is a folded path. -func carry(kept []json.RawMessage, foldedPaths map[string]bool, runs *delivery.Composed, carried func(module, id string) bool) ([]json.RawMessage, map[string]bool, error) { - idOf := func(raw json.RawMessage) (string, string, string, string, error) { - var head struct { - ID string `json:"id"` - Module string `json:"module"` - Type string `json:"type"` - Path string `json:"path"` - } - err := json.Unmarshal(raw, &head) - return head.ID, head.Module, head.Type, head.Path, err - } - present := map[string]bool{} - for _, raw := range kept { - if id, module, _, _, err := idOf(raw); err == nil && module == "" { - present[id] = true - } - } - // By the id of the resource of the node's own it runs after, "" for first: the last one before it that is composed. - after := map[string][]json.RawMessage{} - anchor := "" - for _, raw := range runs.Resources { - id, module, typ, path, err := idOf(raw) - if err != nil { - return nil, nil, fmt.Errorf("compose: a resource the node runs in its own composition: %w", err) - } - if !carried(module, id) { - if module == "" && present[id] { - anchor = id - } - continue - } - after[anchor] = append(after[anchor], raw) - if typ == string(declaration.TypeFile) { - foldedPaths[path] = true - } - } - out := make([]json.RawMessage, 0, len(kept)+len(runs.Resources)) - out = append(out, after[""]...) - for _, raw := range kept { - out = append(out, raw) - if id, module, _, _, err := idOf(raw); err == nil && module == "" { - out = append(out, after[id]...) - } - } - return out, foldedPaths, nil -} diff --git a/go/compose/composed_test.go b/go/compose/composed_test.go deleted file mode 100644 index a96ea85..0000000 --- a/go/compose/composed_test.go +++ /dev/null @@ -1,200 +0,0 @@ -package compose - -import ( - "encoding/json" - "sort" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/overlay" -) - -func fpFor(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) } - -// 1943/AC5: a node's compositions are its declaration as Compose gives it, split by the module each resource belongs -// to: every resource once, each in its module's composition at the fingerprint given, the module's ownership said on -// it; a module given no fingerprint is named as skipped and composed nowhere. -func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) { - in := aSmallMesh(t) - d, _, err := Compose(in, "laptop") - if err != nil { - t.Fatal(err) - } - var want []string - for _, r := range d.Resources { - want = append(want, r["type"].(string)+":"+r["id"].(string)) - } - sort.Strings(want) - - fps := map[string]string{"mesh-wireguard": fpFor('a'), "files": fpFor('b')} - out, skipped, err := Compositions(in, "laptop", fps, Foreseeing) - if err != nil || len(skipped) != 0 { - t.Fatalf("Compositions = %v, skipped %v", err, skipped) - } - var got []string - for key, c := range out { - node, module, fp, isNode, err := delivery.ParseComposedKey(key) - if err != nil || node != "laptop" || module != c.Module || fp != c.FP || (isNode != (module == "")) { - t.Errorf("key %s holds %s at %s (%v)", key, c.Module, c.FP, err) - } - if !isNode && fp != fps[module] { - t.Errorf("%s is composed at %s, given %s", module, fp, fps[module]) - } - if _, err := delivery.EncodeComposed(c); err != nil { - t.Errorf("%s: %v", key, err) - } - for _, raw := range c.Resources { - var r struct{ ID, Type, Module string } - _ = json.Unmarshal(raw, &r) - if r.Module != module { - t.Errorf("%s holds %s of %q", key, r.ID, r.Module) - } - got = append(got, r.Type+":"+r.ID) - } - } - sort.Strings(got) - if strings.Join(got, " ") != strings.Join(want, " ") { - t.Errorf("the compositions hold %v, the declaration %v", got, want) - } - - one, skipped, err := Compositions(in, "laptop", map[string]string{"mesh-wireguard": fpFor('a')}, Foreseeing) - if err != nil || strings.Join(skipped, ",") != "files" { - t.Fatalf("one module: %v, skipped %v", err, skipped) - } - for _, c := range one { - if c.Module == "files" { - t.Errorf("a module given no fingerprint was composed") - } - } - - // Issue 1126: a module the node runs inside its own composition (Node.InOwn), and the private network's always, - // given no fingerprint of the new form, is folded into the node's own as the controller's composed writer folds it: - // every resource in its place, its module taken off, its id still naming it, nothing skipped. - folding := in - folding.Nodes = append([]Node(nil), in.Nodes...) - for i := range folding.Nodes { - if folding.Nodes[i].Name == "laptop" { - folding.Nodes[i].InOwn = map[string]bool{"files": true} - } - } - all, skipped, err := Compositions(folding, "laptop", nil, Foreseeing) - nodeKey, _ := delivery.ComposedNodeKey("laptop") - if err != nil || len(skipped) != 0 || len(all) != 1 || len(all[nodeKey].Resources) != len(want) { - t.Fatalf("folded: %v, skipped %v, %d compositions, %d of %d resources in the node's own", err, skipped, len(all), len(all[nodeKey].Resources), len(want)) - } - for i, raw := range all[nodeKey].Resources { - var r map[string]any - _ = json.Unmarshal(raw, &r) - if _, says := r["module"]; says || r["id"] != d.Resources[i]["id"] { - t.Errorf("folded resource %d is %v, the declaration's %v", i, r["id"], d.Resources[i]["id"]) - } - } - if _, err := delivery.EncodeComposed(all[nodeKey]); err != nil { - t.Errorf("the node's own with the folded modules: %v", err) - } - if _, _, err := Compositions(in, "nowhere", fps, Foreseeing); err == nil { - t.Errorf("a node the mesh does not have was composed") - } - - // Issue 1126, 2026-10-29: a module composed as what runs of it (folded at a build of the old form, or held) still - // contributes to the runtime what the runtime serves of it as it runs (Node.RunsTools): its manifest at the - // indexer's commit has no bundles (shanks's runtime lost the folded systemd-resolved, novox's the folded nats), and a - // held module's new build's bundles are not delivered. - dg := func(c byte) string { return "sha256:" + strings.Repeat(string(c), 64) } - runtime := catalogue.Manifest{Module: catalogue.RuntimeModule, Version: "1", - OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/node-tools/broker"}}, - Bundles: []catalogue.Bundle{{Name: "runtime", Source: catalogue.ArtifactStoreScheme + "node-tools/runtime/blobs/" + dg('a'), - Digest: dg('a'), Language: "go", Binary: "node-tools"}}} - store := catalogue.Manifest{Module: "artifacts", Version: "1", - Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}} - lamp := catalogue.Manifest{Module: "lamp", Version: "1", Tools: []string{"on"}, - Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/lamp", "mode": "0700"}}, - Build: &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "tools", Kind: catalogue.ArtifactBundle, - Language: "go", System: "arch", From: "cmd/lamp-tools", Env: map[string]string{"LAMP_CONF": "/var/lib/lamp/conf"}}}}} - newLamp, err := lamp.Resolve([]catalogue.Built{{Name: "tools", Kind: catalogue.ArtifactBundle, - Reference: catalogue.ArtifactStoreScheme + "lamp/tools/blobs/" + dg('c'), Digest: dg('c')}}) - if err != nil { - t.Fatal(err) - } - served := "lamp=" + catalogue.BundlePath("lamp", "tools") + "/lamp-tools" - runtimeRuns := func(dropped bool) delivery.Composed { - env := `"MESH_TOOL_MODULES":"` + served + `","MESH_TOOL_ENV":"{\"lamp\":{\"LAMP_CONF\":\"/var/lib/lamp/conf\"}}"` - if dropped { - env = `"MESH_TOOL_MODULES":""` // the drop already applied (shanks, 2026-10-29) - } - return delivery.Composed{Node: "anchor", Module: catalogue.RuntimeModule, Resources: []json.RawMessage{json.RawMessage( - `{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{` + env + `}}`)}} - } - lampRuns := `{"id":"lamp.bundle-tools","type":"archive","source":"` + catalogue.ArtifactStoreScheme + `lamp/tools/blobs/` + dg('b') + - `","digest":"` + dg('b') + `","path":"` + catalogue.BundlePath("lamp", "tools") + `"}` - for _, row := range []struct { - name string - set func(n *Node) delivery.Composed // and what runs of lamp - fps map[string]string - dropped bool - }{ - {"folded at a build of the old form", func(n *Node) delivery.Composed { - n.InOwn = map[string]bool{"lamp": true} - n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}} - return *n.RunsOwn - }, map[string]string{}, false}, - {"folded, the runtime already dropped it", func(n *Node) delivery.Composed { - n.InOwn = map[string]bool{"lamp": true} - n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}} - return *n.RunsOwn - }, map[string]string{}, true}, - {"held, its new build not delivered", func(n *Node) delivery.Composed { - n.Held = map[string]bool{"lamp": true} - lr := delivery.Composed{Node: "anchor", Module: "lamp", Resources: []json.RawMessage{json.RawMessage( - strings.Replace(lampRuns, `"type"`, `"module":"lamp","type"`, 1))}} - n.Kept = map[string]catalogue.Manifest{"lamp": Carried(newLamp, "lamp", &lr)} - return lr - }, map[string]string{"lamp": fpFor('1')}, false}, - } { - tools := aMesh(t) - tools.Catalogue[catalogue.RuntimeModule], tools.Catalogue["artifacts"], tools.Catalogue["lamp"] = runtime, store, lamp - anchor := nodeOf(t, &tools, "anchor") - anchor.Assigned = append(anchor.Assigned, "artifacts", catalogue.RuntimeModule, "lamp") - tools.BusMinted = []string{"anchor." + catalogue.RuntimeModule} - anchor.SealingKey = keyOf(t, "anchor") - anchor.RunsTools = ToolsRunning([]delivery.Composed{runtimeRuns(row.dropped), row.set(anchor)}) - row.fps[overlay.Name], row.fps["artifacts"], row.fps[catalogue.RuntimeModule] = fpFor('a'), fpFor('5'), fpFor('e') - out, _, _, err := CompositionsIn(tools, "anchor", row.fps, Foreseeing) - if err != nil { - t.Fatalf("%s: %v", row.name, err) - } - var env map[string]any - for _, c := range out { - for _, raw := range c.Resources { - var r struct { - ID string `json:"id"` - Env map[string]any `json:"env"` - } - if json.Unmarshal(raw, &r) == nil && r.ID == "node-tools.runtime" { - env = r.Env - } - } - } - if env[catalogue.RuntimeToolModules] != served || env[catalogue.RuntimeToolEnv] != `{"lamp":{"LAMP_CONF":"/var/lib/lamp/conf"}}` { - t.Errorf("%s: the runtime serves %v with %v, want what runs of lamp, %s", row.name, - env[catalogue.RuntimeToolModules], env[catalogue.RuntimeToolEnv], served) - } - } -} - -// 1943/AC1: the upgrade policies and sources a composition's value may name are the catalogue's own words. -func TestTheComposedPoliciesAreTheCatalogues(t *testing.T) { - for _, p := range []string{catalogue.PolicyRoll, catalogue.PolicyTogether, catalogue.PolicyRecord} { - for _, from := range []string{catalogue.FromPerson, catalogue.FromModule, catalogue.FromBus, catalogue.FromData, catalogue.FromDefault} { - v := delivery.ComposedDigest{Digest: "sha256:" + strings.Repeat("a", 64), Judgement: delivery.JudgedNeither, Policy: p, PolicyFrom: from} - if _, err := delivery.EncodeComposedDigest(v); err != nil { - t.Errorf("%s from %s: %v", p, from, err) - } - } - } - if delivery.PolicyRecord != catalogue.PolicyRecord { - t.Errorf("delivery.PolicyRecord = %q, the catalogue's %q", delivery.PolicyRecord, catalogue.PolicyRecord) - } -} diff --git a/go/compose/credentials.go b/go/compose/credentials.go deleted file mode 100644 index 480c988..0000000 --- a/go/compose/credentials.go +++ /dev/null @@ -1,498 +0,0 @@ -package compose - -import ( - "crypto/ecdh" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "slices" - "sort" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// Mode says what a composition does about what a send would make. Both modes read what the Input holds and make nothing; they -// differ as the controller's Reading and Foreseeing modes do. -type Mode int - -const ( - // Reading composes what is held and leaves out an own secret the Input holds none for: that machine cannot be running it. - Reading Mode = iota - // Foreseeing composes an own secret the next send would make with a stand-in and names it foreseen, while one the send would - // refuse (a bus credential nobody issued, a given secret under an old key) is refused here, in the same words. - Foreseeing - // Planning composes for a provisioning plan alone (novox/hq ADR 0570 clause 2): every own secret the Input holds none - // for stands in, named foreseen, and nothing is refused for a value, since a plan names secrets and never carries one. - // The seat mesh-provisioner's holder composes so, reading no secret; a rendering in this mode is never applied. - Planning -) - -// ForeseenCertificate and ForeseenBusUsers stand for what the Input does not carry, in a composition asked ahead of the send. -// Never sent. -const ( - ForeseenCertificate = "foreseen: issued by the next send" - ForeseenBusUsers = "foreseen: composed from the bus records by the next send" -) - -// grantsFor is every credential this node must create, because something elsewhere uses it (plan.go grantsFor): the mirror of -// what a consumer is given. A consumer whose identity overflows the provision's bound is left out of the grants and returned -// beside them (novox/hq ADR 0225); one whose resolution binds it to another provider is withdrawn and returned beside them -// (issue 274). Every other is granted. -func (c *composer) grantsFor(node string) ([]catalogue.Grant, []catalogue.Overflow, []catalogue.Unbound, []string, error) { - issued, err := c.rowsFor(node) - if err != nil { - return nil, nil, nil, nil, err - } - onNetwork, err := c.whereEveryoneIs() - if err != nil { - return nil, nil, nil, nil, err - } - out := make([]catalogue.Grant, 0, len(issued)) - var withheld []catalogue.Overflow - var unbound []catalogue.Unbound - var foreseen []string - for _, s := range issued { - resolved, err := c.planFor(s.Consumer) - switch { - case unresolvable(err): - // Their set does not resolve. Skipped rather than fatal: this node is not the place to report another machine's problem. - continue - case err != nil: - // The mesh could not be asked what they wanted, which is not the same as their wanting nothing. - return nil, nil, nil, nil, fmt.Errorf("what %s asked of %s cannot be read: %w", s.Consumer, s.Provision, err) - } - plan := resolved.Plan - values, asks, err := plan.ContributionsFrom(s.Provision, s.ConsumerModule, resolved.Settings) - if err != nil { - return nil, nil, nil, nil, err - } - // A port in there is the consumer's software port until this: the assignment that moved it is that machine's. - consumer, err := c.in.node(s.Consumer) - if err != nil { - return nil, nil, nil, nil, err - } - values = catalogue.AtPublishedPort(values, s.ConsumerModule, consumer.Ports[s.ConsumerModule]) - from := s.ConsumerModule - if !asks { - // That module no longer wants this: left empty, which is how a login is withdrawn rather than kept working for ever. - from = "" - } - if bound := plan.BindsFrom(s.Provision, s.ConsumerModule, s.Local); from != "" && !slices.Contains(bound, node) { - // It still asks, and not of this node: withdrawn exactly as a credential nobody asks for, and said. - from = "" - unbound = append(unbound, catalogue.Unbound{Provision: s.Provision, Provider: node, - Consumer: s.Consumer, Module: s.ConsumerModule, Local: s.Local, BoundTo: bound}) - } - // The consumer's identity slug, from its own manifest, carried on the grant so the provider derives the same login the - // consumer does (novox/hq ADR 0049). - slug := "" - for _, mm := range plan.Modules { - if mm.Module == s.ConsumerModule { - slug = mm.Slug - break - } - } - if from != "" { - bound := boundOfGrant(plan, s, node) - source := catalogue.IdentitySource(slug, s.ConsumerModule) - if catalogue.CheckIdentityWithin(s.Consumer, source, bound) != nil { - withheld = append(withheld, catalogue.Overflow{Provision: s.Provision, Provider: node, - Consumer: s.Consumer, Module: s.ConsumerModule, - Identity: catalogue.ConsumerIdentity(s.Consumer, source), Bound: bound}) - continue - } - } - sealed := s.ForProvider - switch { - case credentiallessRow(plan, s, node): - // Its offer presents no credential (form none): the grant carries the consumer and nothing sealed, and - // nothing stands in for it, so neither end waits for a value nobody makes (issue 1126). - sealed = "" - case sealed == "": - sealed = catalogue.ForeseenSealed - foreseen = append(foreseen, fmt.Sprintf("the grant of %s for %s on %s", s.Provision, s.ConsumerModule, s.Consumer)) - } - out = append(out, catalogue.Grant{ - Provision: s.Provision, Consumer: s.Consumer, At: onNetwork[s.Consumer], - From: from, Values: values, Slug: slug, Sealed: sealed, Local: s.Local}) - } - return out, withheld, unbound, foreseen, nil -} - -// credentialless is whether a need's provider offers it with no credential (catalogue.CredentialFormNone): the vault -// mints nothing for it, so nothing is asked of the vault, sealed into a grant or a file, or stood in for (issue 1126). -func credentialless(n catalogue.Needed) bool { return n.CredentialForm == catalogue.CredentialFormNone } - -// credentiallessRow is whether the consumer's resolution answers row's need from provider with an offer of form none. -func credentiallessRow(consumer catalogue.Resolution, row Issued, provider string) bool { - return slices.ContainsFunc(consumer.Needs, func(n catalogue.Needed) bool { - return n.Name == row.Provision && n.For == row.ConsumerModule && n.Local == row.Local && n.From == provider && - credentialless(n) - }) -} - -// rowsFor is every pair credential this node's declaration must carry a grant for, in the order the controller reads them -// (SecretsFrom: by provision, consumer, consumer module and local name): the ones the Input says were made, and the ones the same send -// would make before it composes this node -- every need of a node that resolves (this one too), whose provider is this node, that is not -// answered by a record and not a credential only a person gives and nobody gave. Those have no sealed copy (ForProvider empty), so the -// grant is composed with a stand-in and named foreseen (plan.go: planFor makes the secret in every mode before grantsFor reads it). -func (c *composer) rowsFor(node string) ([]Issued, error) { - var rows []Issued - for _, row := range c.in.Issued { - if row.Provider == node { - rows = append(rows, row) - } - } - for _, other := range c.in.sorted() { - resolved, err := c.planFor(other.Name) - switch { - case unresolvable(err): - continue - case err != nil: - return nil, fmt.Errorf("what %s asked of %s cannot be read: %w", other.Name, node, err) - } - shelf := c.shelfFor(other) - for _, n := range resolved.Plan.Needs { - if n.From != node || n.ByRecord { - continue - } - cred, held := c.credential(n, other.Name) - if _, outside := issuedOutsideBy(shelf, n); cred.State == CredentialNotGiven || (outside && !(held && cred.State == CredentialHeld)) { - continue - } - if slices.ContainsFunc(rows, func(r Issued) bool { - return r.Provision == n.Name && r.Consumer == other.Name && r.ConsumerModule == n.For && r.Local == n.Local - }) { - continue - } - rows = append(rows, Issued{Provision: n.Name, Consumer: other.Name, ConsumerModule: n.For, Local: n.Local, Provider: node}) - } - } - sort.SliceStable(rows, func(i, j int) bool { - a, b := rows[i], rows[j] - switch { - case a.Provision != b.Provision: - return a.Provision < b.Provision - case a.Consumer != b.Consumer: - return a.Consumer < b.Consumer - case a.ConsumerModule != b.ConsumerModule: - return a.ConsumerModule < b.ConsumerModule - } - return a.Local < b.Local - }) - return rows, nil -} - -// boundOfGrant is the identity bound the consumer's own resolution states for the requirement this grant answers. A requirement -// not found there is held to the tightest bound the mesh knows rather than to none. -func boundOfGrant(consumer catalogue.Resolution, s Issued, provider string) catalogue.IdentityBound { - for _, n := range consumer.Needs { - if n.Name == s.Provision && n.For == s.ConsumerModule && n.From == provider { - return n.Identity - } - } - return catalogue.DefaultIdentityBound -} - -// ownSecretRow is the row for one own secret of a module on a node. -func (c *composer) ownSecretRow(node, module, name string) (OwnSecretRow, bool) { - for _, r := range c.in.OwnSecrets { - if r.Node == node && r.Module == module && r.Name == name { - return r, true - } - } - return OwnSecretRow{}, false -} - -// givenMember is one own secret a person gave on a machine and whether it is sealed to the key the machine holds now. -type givenMember struct { - Name string - Sealed string - Current bool -} - -// givenMembers is every member of a module's secret family given on a machine, by name (inv.GivenMembers): the mesh never -// makes a member, so only a value a person gave is one; a machine with no sealing key holds none. -func (c *composer) givenMembers(node Node, module, family string) []givenMember { - if node.SealingKey == "" { - return nil - } - prefix := catalogue.FamilyPrefix(family) - var out []givenMember - for _, r := range c.in.OwnSecrets { - if r.Node == node.Name && r.Module == module && r.Origin == "accepted" && strings.HasPrefix(r.Name, prefix) { - out = append(out, givenMember{Name: r.Name, Sealed: r.Sealed, Current: r.SealedTo == node.SealingKey}) - } - } - sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) - return out -} - -// givenOwnSecret is an own secret issued outside the mesh as a person gave it on a machine (inv.GivenOwnSecret): not given when -// the value held was made by the mesh or the machine has no sealing key. -func (c *composer) givenOwnSecret(node Node, module, name string) (givenMember, bool) { - if node.SealingKey == "" { - return givenMember{}, false - } - r, ok := c.ownSecretRow(node.Name, module, name) - if !ok || r.Origin != "accepted" { - return givenMember{}, false - } - return givenMember{Name: name, Sealed: r.Sealed, Current: r.SealedTo == node.SealingKey}, true -} - -// moduleSecretIfIssued is the read half of the controller's SecretForModule (inv.ModuleSecretIfIssued): the secret when one is -// held and sealed to the key the machine holds now, and otherwise not held. A key it was sealed to that the machine no longer -// has is reported as absent rather than as an error. -func (c *composer) moduleSecretIfIssued(node Node, module, name string) (sealed string, held bool) { - if node.SealingKey == "" { - return "", false - } - r, ok := c.ownSecretRow(node.Name, module, name) - if !ok || r.SealedTo != node.SealingKey { - return "", false - } - return r.Sealed, true -} - -// wouldMakeSecretForModule says what the send would do about an own secret without doing it: nil when it would make one (or one -// is held), and otherwise the very refusal it would meet (inv.WouldMakeSecretForModule). -func (c *composer) wouldMakeSecretForModule(node Node, module, name string) error { - if node.SealingKey == "" { - return fmt.Errorf("%s needs a secret and %s has no sealing key, so nothing can be sealed to it", module, node.Name) - } - r, ok := c.ownSecretRow(node.Name, module, name) - switch { - case !ok: - return nil - case r.SealedTo != node.SealingKey && r.Origin == "accepted": - return fmt.Errorf("%s on %s holds %q, which was given to the mesh rather than made by it, and %s has "+ - "since generated a new sealing key. The mesh cannot make another; issue it again", module, node.Name, name, node.Name) - } - return nil -} - -// givenUnderAnOldKey is the refusal for a value a person gave sealed to a key the machine no longer holds. -func givenUnderAnOldKey(module, node, name string) error { - return fmt.Errorf("%s on %s holds %q, which was given to the mesh rather than made by it, and %s has "+ - "since generated a new sealing key. The mesh cannot make another; give it again", module, node, name, node) -} - -// ownSecretsFor is each module's own secrets as this node composes them (plan.go renderingFor): the needed ones sealed to the -// node, and, in the Foreseeing mode, the ones the next send would make named foreseen. A module handed to the provisioner has its -// secrets from the vault through the client, so none of them is composed here. -func (c *composer) ownSecretsFor(node Node, plan catalogue.Resolution, handing catalogue.Rendering, mode Mode) ( - needed map[string]map[string]string, foreseen map[string]map[string]bool, err error) { - needed = map[string]map[string]string{} - foreseen = map[string]map[string]bool{} - mark := func(module, name string) { - if foreseen[module] == nil { - foreseen[module] = map[string]bool{} - } - foreseen[module][name] = true - } - for _, m := range plan.Modules { - if handing.Handing(m) { - continue - } - // A secret family is never made (novox/hq ADR 0283): each member a person gave on this machine is placed, and one not - // given is nothing -- the module says it waits for it. - for _, family := range m.OwnSecrets.Families() { - for _, g := range c.givenMembers(node, m.Module, family) { - if _, fam, ok := m.OwnSecrets.Lookup(g.Name); !ok || fam != family { - continue // a longer family's member, or a name no longer of this family - } - if !g.Current { - continue - } - if needed[m.Module] == nil { - needed[m.Module] = map[string]string{} - } - needed[m.Module][g.Name] = g.Sealed - if g.Sealed == "" { - // Given, and its value is not in the Input: placed with a stand-in, named. - needed[m.Module][g.Name] = catalogue.ForeseenSealed - mark(m.Module, g.Name) - } - } - } - names := make([]string, 0) - for name := range m.OwnSecrets.Plain() { - names = append(names, name) - } - sort.Strings(names) - for _, name := range names { - own := m.OwnSecrets.Plain()[name] - // An own secret issued outside the mesh is never made (novox/hq issue 401, ADR 0228; ADR 0283 for a family): only a - // value a person gave is placed. One not given is nothing, and a value the mesh made for it before is not one either. - if own.IssuedBy == catalogue.IssuedOutside { - g, given := c.givenOwnSecret(node, m.Module, name) - if !given || !g.Current { - continue - } - if g.Sealed == "" { - mark(m.Module, name) - continue - } - if needed[m.Module] == nil { - needed[m.Module] = map[string]string{} - } - needed[m.Module][name] = g.Sealed - continue - } - sealed, held := c.moduleSecretIfIssued(node, m.Module, name) - if !held && mode == Planning { - mark(m.Module, name) - continue - } - if !held && mode == Foreseeing { - // Asked ahead of the send: what the send would do about it, by the send's own rules. Made by it -- a stand-in, - // named; refused by it -- refused here, the same words (novox/hq issue 275). A bus account not yet issued is - // foreseen, never refused: the vault mints it from the grants part's wanted list once the placement is written - // on the operator's Approve (ADR 0561 §1, ADR 0567 §5), so a composition asked ahead of that cannot hold it - // (issue 774, 2026-10-30: every proposal placing mesh-conditions, mesh-self-check or mesh-healers refused). - if err := c.wouldMakeSecretForModule(node, m.Module, name); err != nil { - return nil, nil, err - } - mark(m.Module, name) - continue - } - if !held { - // Never issued, so this machine cannot be running it: left out rather than invented. - continue - } - if sealed == "" { - // Held, and its value is not in the Input: composed with a stand-in, named. - mark(m.Module, name) - continue - } - if needed[m.Module] == nil { - needed[m.Module] = map[string]string{} - } - needed[m.Module][name] = sealed - } - } - return needed, foreseen, nil -} - -// CoreBoxWanted is a core credential whose box the Input does not hold for a machine: the next send asks the vault for it and -// keeps the answer. It is returned for the caller to ask and keep; this package asks and keeps nothing. -type CoreBoxWanted struct { - Node string - Module string - Name string -} - -// coreModulesOf is every module of the plan on the core's list whose own secrets this machine's declaration carries, with their -// plain own secret names, sorted: one handed to the provisioner has its secrets from the vault through the client, and one issued -// outside the mesh is never the vault's to seal here. -func coreModulesOf(modules []catalogue.Manifest, handed func(catalogue.Manifest) bool) map[string][]string { - out := map[string][]string{} - for _, m := range modules { - if !catalogue.CarriesVaultSealed(m.Module) || (handed != nil && handed(m)) { - continue - } - var names []string - for name, own := range m.OwnSecrets.Plain() { - if own.IssuedBy != catalogue.IssuedOutside { - names = append(names, name) - } - } - if len(names) > 0 { - sort.Strings(names) - out[m.Module] = names - } - } - return out -} - -// sealingKeyValid says a machine's sealing key, recorded raw in base64, is a raw X25519 key. -func sealingKeyValid(raw string) error { - b, err := base64.StdEncoding.DecodeString(strings.TrimSpace(raw)) - if err != nil || len(b) != 32 { - return errors.New("the machine's sealing key is not a raw X25519 key") - } - _, err = ecdh.X25519().NewPublicKey(b) - return err -} - -// coreBoxesFor decides each core credential of a machine in a composition that sends nothing and asks no vault (core_box.go -// coreBoxesOf, in the modes that are not a send): the kept box while it is sealed to the key the machine holds now, and otherwise a -// stand-in that the next send replaces by asking the vault -- returned in wanted for the caller to ask and keep. A machine with no -// sealing key, or one whose key is not a key, leaves its module out, said. -func (c *composer) coreBoxesFor(node Node, modules []catalogue.Manifest, handing catalogue.Rendering) ( - sealed map[string]map[string]json.RawMessage, notPlaced map[string]string, wanted []CoreBoxWanted) { - core := coreModulesOf(modules, func(m catalogue.Manifest) bool { return handing.Handing(m) }) - sealed, notPlaced = map[string]map[string]json.RawMessage{}, map[string]string{} - mods := make([]string, 0, len(core)) - for m := range core { - mods = append(mods, m) - } - sort.Strings(mods) - for _, module := range mods { - if node.SealingKey == "" { - notPlaced[module] = fmt.Sprintf("%s has no sealing key, so the vault can seal %s's credentials to "+ - "nothing there (novox/hq ADR 0349 §16)", node.Name, module) - continue - } - if err := sealingKeyValid(node.SealingKey); err != nil { - notPlaced[module] = fmt.Sprintf("%s's credentials cannot be asked of the vault: %v", module, err) - continue - } - boxes := map[string]json.RawMessage{} - for _, name := range core[module] { - var kept *CoreBox - for i := range c.in.KeptCoreBoxes { - k := &c.in.KeptCoreBoxes[i] - if k.Node == node.Name && k.Module == module && k.Name == name { - kept = k - } - } - if kept != nil && kept.SealedTo == node.SealingKey && len(kept.Box) > 0 { - boxes[name] = kept.Box - continue - } - boxes[name] = catalogue.ForeseenVaultBox - wanted = append(wanted, CoreBoxWanted{Node: node.Name, Module: module, Name: name}) - } - sealed[module] = boxes - } - return sealed, notPlaced, wanted -} - -// certificateFor is what the mesh certifies about one machine's internal name (plan.go certificateFor): refused for a machine that -// never told the mesh what key it serves with, and for one not on the private network, which has no name to be certified for. -// The certificate and the authority are public values the Input may carry; without them stand-ins are composed, and named. -func (c *composer) certificateFor(node Node) (certificate, authority string, foreseen bool, err error) { - if node.ServingKey == "" { - return "", "", false, fmt.Errorf( - "%s wants a certificate and has never told the mesh what key it serves with; it "+ - "joins again to report one", node.Name) - } - where, err := c.whereEveryoneIs() - if err != nil { - return "", "", false, err - } - if where[node.Name] == "" { - return "", "", false, fmt.Errorf( - "%s wants a certificate and is not on the private network, so it has no name inside "+ - "the mesh to be certified for", node.Name) - } - certificate, authority = node.Certificate, c.in.Authority - if certificate == "" || authority == "" { - return ForeseenCertificate, ForeseenCertificate, true, nil - } - return certificate, authority, false, nil -} - -// holdsTheBus says whether a node's set includes the module that asks for the bus's user list. -func holdsTheBus(modules []catalogue.Manifest) bool { - for _, m := range modules { - if m.BusUsers != "" && m.ClaimsSeat("mesh-broker") { - return true - } - } - return false -} diff --git a/go/compose/credentials_needed.go b/go/compose/credentials_needed.go deleted file mode 100644 index ec36c71..0000000 --- a/go/compose/credentials_needed.go +++ /dev/null @@ -1,69 +0,0 @@ -package compose - -import ( - "fmt" - "sort" -) - -// CredentialKey names one pair credential by the ends it joins: the provision, the consumer's node and module, the -// provider's node, and the local name where a module keeps several (Credential's and Issued's key). -type CredentialKey struct { - Provision string `json:"provision"` - Consumer string `json:"consumer"` - ConsumerModule string `json:"consumer_module"` - Provider string `json:"provider"` - Local string `json:"local,omitempty"` -} - -// CredentialsNeeded is every pair credential node's composition reads (novox/hq ADR 0547 §2, ADR 0550 §5, issue 1126): -// as a consumer, each need of its resolution a provider's credential answers (a need answered by a record is a licence's, -// not a pair's); as a provider, each credential it must carry a grant for (rowsFor). A node that composes its own asks the -// vault for exactly these, sealed to its key, and composes with them; one it does not get is stood in for, which the -// node's own apply refuses (CompositionsApplied). A need whose provider offers it with no credential (form none) is -// in neither list: the vault mints nothing for it (issue 1126). -func CredentialsNeeded(in Input, node string) (asConsumer, asProvider []CredentialKey, err error) { - c := newComposer(in) - resolved, err := c.resolution(node) - if err != nil { - return nil, nil, err - } - for _, n := range resolved.Needs { - if n.ByRecord || n.From == "" || credentialless(n) { - continue - } - asConsumer = append(asConsumer, CredentialKey{Provision: n.Name, Consumer: node, ConsumerModule: n.For, - Provider: n.From, Local: n.Local}) - } - rows, err := c.rowsFor(node) - if err != nil { - return nil, nil, fmt.Errorf("compose: the credentials %s provides: %w", node, err) - } - for _, r := range rows { - // A grant whose offer presents no credential (form none) has none to ask for (issue 1126). - if consumer, err := c.planFor(r.Consumer); err == nil && credentiallessRow(consumer.Plan, r, node) { - continue - } - asProvider = append(asProvider, CredentialKey{Provision: r.Provision, Consumer: r.Consumer, - ConsumerModule: r.ConsumerModule, Provider: node, Local: r.Local}) - } - sortKeys(asConsumer) - sortKeys(asProvider) - return asConsumer, asProvider, nil -} - -func sortKeys(ks []CredentialKey) { - sort.Slice(ks, func(i, j int) bool { - a, b := ks[i], ks[j] - switch { - case a.Provision != b.Provision: - return a.Provision < b.Provision - case a.Provider != b.Provider: - return a.Provider < b.Provider - case a.Consumer != b.Consumer: - return a.Consumer < b.Consumer - case a.ConsumerModule != b.ConsumerModule: - return a.ConsumerModule < b.ConsumerModule - } - return a.Local < b.Local - }) -} diff --git a/go/compose/credentials_test.go b/go/compose/credentials_test.go deleted file mode 100644 index 94cd354..0000000 --- a/go/compose/credentials_test.go +++ /dev/null @@ -1,549 +0,0 @@ -package compose - -import ( - "encoding/json" - "errors" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/overlay" -) - -// secretValue is a value the Input carries that must never appear in anything the composition says. -const secretValue = "SECRET-VALUE-THAT-MUST-NOT-LEAK" - -func rendered(t *testing.T, in Input, node string, mode Mode) Rendered { - t.Helper() - got, err := Render(in, node, mode) - if err != nil { - t.Fatalf("%v", err) - } - return got -} - -// anAssistant is mesh-controller's outside_not_given_test.go anAssistant (cmd/mesh-controller at 6c2a8d62): a module with a secret -// the mesh makes and one only an outside party issues. -func anAssistant() catalogue.Manifest { - return catalogue.Manifest{Module: "assistant", Version: "1", - OwnSecrets: catalogue.OwnSecrets{ - "password": {Path: "/var/lib/mesh/assistant/password", Taken: catalogue.TakenAtStart}, - "vendor-key": {Path: "/var/lib/mesh/assistant/vendor-key", Taken: catalogue.TakenAtStart, IssuedBy: catalogue.IssuedOutside}, - }, - Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/mesh/assistant", "mode": "0700"}}} -} - -func withAssistant(t *testing.T) Input { - t.Helper() - in := aMesh(t) - in.Catalogue["assistant"] = anAssistant() - laptop := nodeOf(t, &in, "laptop") - laptop.Assigned = append(laptop.Assigned, "assistant") - laptop.SealingKey = keyOf(t, "laptop") - return in -} - -// Own secrets (plan.go renderingFor, novox/hq issue 275 and 401): one held and sealed to the machine's key is carried; one not held is -// foreseen in the Foreseeing mode and left out in the Reading mode, and nothing is made. -func TestAnOwnSecretHeldIsCarriedAndOneNotHeldIsForeseenOrLeftOut(t *testing.T) { - mk := func() Input { - in := withAssistant(t) - in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made", - SealedTo: nodeOf(t, &in, "laptop").SealingKey, Sealed: secretValue}} - return in - } - in := mk() - got := rendered(t, in, "laptop", Foreseeing) - if got.Rendering.Needed["assistant"]["password"] != secretValue || len(got.Rendering.Foreseen) != 0 { - t.Errorf("a held secret: needed %v foreseen %v", got.Rendering.Needed, got.Rendering.Foreseen) - } - // Not held: foreseen with a stand-in (Foreseeing), left out (Reading). - in.OwnSecrets = nil - got = rendered(t, in, "laptop", Foreseeing) - if !got.Rendering.Foreseen["assistant"]["password"] || got.Rendering.Needed["assistant"]["password"] != "" || - len(got.Foreseen) != 1 || got.Foreseen[0] != "assistant/password" { - t.Errorf("a secret the send makes: foreseen %v / %v needed %v", got.Rendering.Foreseen, got.Foreseen, got.Rendering.Needed) - } - got = rendered(t, in, "laptop", Reading) - if len(got.Rendering.Needed["assistant"]) != 0 || len(got.Foreseen) != 0 || got.Rendering.Foreseen != nil { - t.Errorf("Reading composed a secret that was never issued: %v %v", got.Rendering.Needed, got.Foreseen) - } - // Held but sealed to a key the machine no longer has is not held. - in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made", SealedTo: "an-old-key", Sealed: secretValue}} - if got := rendered(t, in, "laptop", Foreseeing); !got.Rendering.Foreseen["assistant"]["password"] { - t.Errorf("a secret sealed to an old key is held: %v", got.Rendering.Needed) - } - // Held with no value in the Input: a stand-in, named. - in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made", SealedTo: nodeOf(t, &in, "laptop").SealingKey}} - if got := rendered(t, in, "laptop", Reading); !got.Rendering.Foreseen["assistant"]["password"] { - t.Errorf("a held secret with no value is not named: %v", got.Rendering.Foreseen) - } - unchanged(t, mk, func(in Input) { rendered(t, in, "laptop", Foreseeing) }) - - // 1126 (ADR 0547 §2): a node applying what it composed itself never applies a stand-in: a held secret with no value in the - // Input is refused, naming it; held with its value, it composes. - fps := map[string]string{"assistant": "src2:" + strings.Repeat("a", 64), overlay.Name: "src2:" + strings.Repeat("b", 64)} - if _, _, err := CompositionsApplied(in, "laptop", fps); !errors.Is(err, ErrStoodIn) || !strings.Contains(err.Error(), "assistant/password") { - t.Errorf("a stand-in was not refused for a node's own apply: %v", err) - } - if out, _, err := CompositionsApplied(mk(), "laptop", fps); err != nil || len(out) == 0 { - t.Errorf("a held secret with its value is refused for a node's own apply: %v", err) - } -} - -// An own secret issued outside the mesh that nobody gave is never made (novox/hq issue 401, ADR 0228): nothing is composed or -// foreseen for it; a value the mesh made for it is not one a person gave; a value given is placed once it is sealed to the key held now. -func TestAnOutsideSecretIsPlacedOnlyOnceAPersonGaveIt(t *testing.T) { - in := withAssistant(t) - key := nodeOf(t, &in, "laptop").SealingKey - for _, mode := range []Mode{Reading, Foreseeing} { - got := rendered(t, in, "laptop", mode) - if _, there := got.Rendering.Needed["assistant"]["vendor-key"]; there || got.Rendering.Foreseen["assistant"]["vendor-key"] { - t.Fatalf("%v: a secret nobody gave is composed: %v %v", mode, got.Rendering.Needed, got.Rendering.Foreseen) - } - } - in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "vendor-key", Origin: "made", SealedTo: key, Sealed: secretValue}} - if _, there := rendered(t, in, "laptop", Foreseeing).Rendering.Needed["assistant"]["vendor-key"]; there { - t.Error("a value the mesh made for a secret issued outside was placed") - } - in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "vendor-key", Origin: "accepted", SealedTo: key, Sealed: "the vendor's key, sealed"}} - if got := rendered(t, in, "laptop", Foreseeing).Rendering.Needed["assistant"]["vendor-key"]; got != "the vendor's key, sealed" { - t.Errorf("a value given is placed as %q", got) - } - in.OwnSecrets[0].SealedTo = "an-old-key" - if _, there := rendered(t, in, "laptop", Foreseeing).Rendering.Needed["assistant"]["vendor-key"]; there { - t.Error("a value given under an old key was placed") - } -} - -// 1364/AC3 (the send's refusals, novox/hq issue 275): in the Foreseeing mode a given secret under an old key is refused in the -// controller's words. No secret value is in an error. A bus account not yet issued is foreseen, issued or not (issue 774: the vault -// mints it once the placement is written on the Approve, ADR 0561 §1, ADR 0567 §5; mesh-self-check's proposal on ace was refused -// "has no bus credential: nothing was issued for ace.mesh-self-check"). -func TestTheSendsRefusalsAreRefusedInTheSendsWords(t *testing.T) { - broker := catalogue.Manifest{Module: "mesh-self-check", Version: "1", RunsAs: "mesh-self-check", - OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/mesh-self-check/broker", Taken: catalogue.TakenAtStart}}} - in := withAssistant(t) - in.Catalogue["mesh-self-check"] = broker - laptop := nodeOf(t, &in, "laptop") - laptop.Assigned = append(laptop.Assigned, "mesh-self-check") - for _, minted := range [][]string{nil, {"laptop.mesh-self-check"}} { - in.BusMinted = minted - got, err := Render(in, "laptop", Foreseeing) - if err != nil || !got.Rendering.Foreseen["mesh-self-check"]["broker"] { - t.Fatalf("minted %v: the bus account is not foreseen: %v", minted, err) - } - // Reading refuses nothing for it: the machine is not running it. - if _, err := Render(in, "laptop", Reading); err != nil { - t.Fatalf("Reading refused a secret the machine is not running: %v", err) - } - } - // A given secret under an old key. - in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "accepted", SealedTo: "an-old-key", Sealed: secretValue}, - {Node: "laptop", Module: "mesh-self-check", Name: "broker", Origin: "made", SealedTo: laptop.SealingKey, Sealed: secretValue}} - _, err := Render(in, "laptop", Foreseeing) - wantOld := "assistant on laptop holds \"password\", which was given to the mesh rather than made by it, and laptop has since generated a new " + - "sealing key. The mesh cannot make another; issue it again" - if err == nil || err.Error() != wantOld { - t.Fatalf("got %v, want %q", err, wantOld) - } - if strings.Contains(err.Error(), secretValue) { - t.Error("a secret value is in an error") - } - // A machine with no sealing key cannot be sealed to. - laptop.SealingKey = "" - in.OwnSecrets = nil - if _, err := Render(in, "laptop", Foreseeing); err == nil || !strings.Contains(err.Error(), "laptop has no sealing key, so nothing can be sealed to it") { - t.Fatalf("no sealing key: %v", err) - } -} - -// aGrantMesh is the objects provider on anchor and its consumer files on laptop, with the pair credential made. -func aGrantMesh(t *testing.T) Input { - t.Helper() - in := theObjectsMesh(t, objects()) - in.Issued = []Issued{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", ForProvider: "sealed-to-anchor"}} - return in -} - -// A credential made for a consumer is a grant on its provider's machine, carrying the consumer's values and the sealed copy; without -// the sealed copy a stand-in is composed and named (novox/hq plan.go grantsFor). -func TestAGrantIsCreatedForEachCredentialMadeForAConsumer(t *testing.T) { - in := aGrantMesh(t) - // 1126 (ADR 0550 §5): the credentials each node's composition reads, by their ends: laptop's files consumes anchor's - // s3-bucket, and anchor grants it; the node composing its own asks the vault for exactly these. - pair := CredentialKey{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor"} - if consumer, provider, err := CredentialsNeeded(in, "laptop"); err != nil || len(provider) != 0 || len(consumer) != 1 || consumer[0] != pair { - t.Errorf("laptop needs %v as consumer and %v as provider (%v), want %v as consumer", consumer, provider, err, pair) - } - if consumer, provider, err := CredentialsNeeded(in, "anchor"); err != nil || len(consumer) != 0 || len(provider) != 1 || provider[0] != pair { - t.Errorf("anchor needs %v as consumer and %v as provider (%v), want %v as provider", consumer, provider, err, pair) - } - got := rendered(t, in, "anchor", Foreseeing) - if len(got.Rendering.Grants) != 1 { - t.Fatalf("grants are %+v", got.Rendering.Grants) - } - g := got.Rendering.Grants[0] - if g.Provision != "s3-bucket" || g.Consumer != "laptop" || g.From != "files" || g.At != "laptop.internal" || g.Sealed != "sealed-to-anchor" { - t.Errorf("the grant is %+v", g) - } - if len(got.Foreseen) != 0 || len(got.Withheld) != 0 || len(got.Unbound) != 0 { - t.Errorf("foreseen %v withheld %v unbound %v", got.Foreseen, got.Withheld, got.Unbound) - } - in.Issued[0].ForProvider = "" - got = rendered(t, in, "anchor", Foreseeing) - if got.Rendering.Grants[0].Sealed != catalogue.ForeseenSealed || len(got.Foreseen) != 1 { - t.Errorf("a grant with no sealed copy: %+v foreseen %v", got.Rendering.Grants, got.Foreseen) - } - // A credential made for a consumer on another provider is not this node's grant. - if len(rendered(t, in, "laptop", Foreseeing).Rendering.Grants) != 0 { - t.Error("the consumer is granted its own credential") - } - // A consumer whose set does not resolve is skipped, not fatal. - nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "files") - in.Catalogue["files"] = catalogue.Manifest{Module: "files", Version: "1", Requires: []string{"s3-bucket"}, - Claims: []catalogue.Claim{{Name: "x", Scope: catalogue.ScopeNode}}} - in.Catalogue["twin"] = catalogue.Manifest{Module: "twin", Version: "1", Claims: []catalogue.Claim{{Name: "x", Scope: catalogue.ScopeNode}}} - nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "twin") - if got, err := Render(in, "anchor", Foreseeing); err != nil || len(got.Rendering.Grants) != 0 { - t.Errorf("a consumer that does not resolve: %v %+v", err, got.Rendering.Grants) - } -} - -// 1364/AC2: a consumer the grants leave out because its identity overflows the provision's bound is returned beside them, naming the -// consumer and the provision (novox/hq ADR 0225), and is not granted. -func TestAConsumerWhoseIdentityOverflowsIsLeftOutOfTheGrantsAndNamed(t *testing.T) { - in := aGrantMesh(t) - in.Nodes[1].Name = "a-machine-with-a-very-long-name" - in.Issued[0].Consumer = "a-machine-with-a-very-long-name" - got := rendered(t, in, "anchor", Foreseeing) - if len(got.Rendering.Grants) != 0 || len(got.Withheld) != 1 { - t.Fatalf("grants %+v withheld %+v", got.Rendering.Grants, got.Withheld) - } - w := got.Withheld[0] - if w.Consumer != "a-machine-with-a-very-long-name" || w.Provision != "s3-bucket" || w.Provider != "anchor" || w.Module != "files" || - !strings.Contains(w.String(), "a-machine-with-a-very-long-name") || !strings.Contains(w.String(), "s3-bucket") { - t.Errorf("the consumer left out is %+v / %s", w, w.String()) - } - if len(got.Rendering.Withheld) != 1 { - t.Errorf("the Rendering does not carry it: %+v", got.Rendering.Withheld) - } -} - -// 1364/AC3: a credential on record for a consumer that its own resolution binds to another provider is withdrawn, named with where it is -// bound (novox/hq issue 274): the grant stays, with no consumer module asking. -func TestACredentialBoundToAnotherProviderIsWithdrawnAndSaidWhereItIsBound(t *testing.T) { - in := aGrantMesh(t) - second := objects() - second.Module = "objects2" - in.Catalogue["objects2"] = second - in.Nodes = append(in.Nodes, Node{Name: "ace", Place: Place{Site: "there", Address: "10.77.0.3"}, - Assigned: []string{overlay.Name, "objects2"}, Capabilities: map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true}}) - nodeOf(t, &in, "laptop").Pins = map[string]catalogue.Chosen{"s3-bucket": {Node: "ace", Module: "objects2"}} - got := rendered(t, in, "anchor", Foreseeing) - if len(got.Unbound) != 1 { - t.Fatalf("unbound %+v grants %+v", got.Unbound, got.Rendering.Grants) - } - u := got.Unbound[0] - if u.Provision != "s3-bucket" || u.Provider != "anchor" || u.Consumer != "laptop" || u.Module != "files" || - len(u.BoundTo) != 1 || u.BoundTo[0] != "ace" || !strings.Contains(u.String(), "ace") { - t.Errorf("unbound is %+v / %s", u, u.String()) - } - if len(got.Rendering.Grants) != 1 || got.Rendering.Grants[0].From != "" { - t.Errorf("the withdrawn credential is still asked for: %+v", got.Rendering.Grants) - } -} - -func aCertificateMesh(t *testing.T) Input { - t.Helper() - in := aMesh(t) - in.Catalogue["secure"] = catalogue.Manifest{Module: "secure", Version: "1", Certificate: &catalogue.Certificate{Into: "/etc/secure/tls.pem", Authority: "/etc/secure/ca.pem"}} - laptop := nodeOf(t, &in, "laptop") - laptop.Assigned = append(laptop.Assigned, "secure") - return in -} - -// 1364/AC4: with no certificate and authority in the Input a stand-in is composed and named; given them, the composition carries them as -// given. A machine that never reported a serving key, and one not on the private network, are refused in the controller's words. -func TestACertificateIsCarriedAsGivenOrStoodInForAndNamed(t *testing.T) { - in := aCertificateMesh(t) - laptop := nodeOf(t, &in, "laptop") - if _, err := Render(in, "laptop", Foreseeing); err == nil || err.Error() != "laptop wants a certificate and has never told the mesh what key it serves with; it joins again to report one" { - t.Fatalf("no serving key: %v", err) - } - laptop.ServingKey = "serving-key" - got := rendered(t, in, "laptop", Foreseeing) - if got.Rendering.Certificate != ForeseenCertificate || got.Rendering.Authority != ForeseenCertificate || len(got.Foreseen) != 1 { - t.Errorf("no certificate in the Input: %q %q foreseen %v", got.Rendering.Certificate, got.Rendering.Authority, got.Foreseen) - } - laptop.Certificate, in.Authority = "the-certificate", "the-authority" - got = rendered(t, in, "laptop", Foreseeing) - if got.Rendering.Certificate != "the-certificate" || got.Rendering.Authority != "the-authority" || len(got.Foreseen) != 0 { - t.Errorf("a certificate given: %q %q foreseen %v", got.Rendering.Certificate, got.Rendering.Authority, got.Foreseen) - } - laptop.Place.Address = "" // placed nowhere: no name inside the mesh - if _, err := Render(in, "laptop", Foreseeing); err == nil || err.Error() != "laptop wants a certificate and is not on the private network, so it has no name inside the mesh to be certified for" { - t.Fatalf("off the network: %v", err) - } -} - -func aValidKey(t *testing.T, name string) string { return keyOf(t, name) } - -// 1364/AC5 and AC6: a core credential the node needs and the Input holds no box for is composed with a stand-in and returned as one -// to keep; a kept box sealed to the key held now is carried; a node-engine below the vault-box contract is sent none (and nothing is -// asked of it); a machine with no sealing key has its core module left out, said. -func TestACoreBoxTheInputHoldsNoneOfIsReturnedToKeepAndNothingIsWritten(t *testing.T) { - mk := func() Input { - in := aMesh(t) - in.Catalogue["nats"] = catalogue.Manifest{Module: "nats", Version: "1", - OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/nats/broker", Taken: catalogue.TakenAtStart}}} - laptop := nodeOf(t, &in, "laptop") - laptop.Assigned = append(laptop.Assigned, "nats") - laptop.SealingKey = aValidKey(t, "laptop") - laptop.Stated, laptop.Contract = true, VaultBoxContract - in.BusMinted = []string{"laptop.nats"} - return in - } - in := mk() - got := rendered(t, in, "laptop", Foreseeing) - box := got.Rendering.VaultSealed["nats"]["broker"] - if string(box) != string(catalogue.ForeseenVaultBox) || len(got.CoreBoxesWanted) != 1 || - got.CoreBoxesWanted[0] != (CoreBoxWanted{Node: "laptop", Module: "nats", Name: "broker"}) { - t.Errorf("no box held: %s wanted %v", box, got.CoreBoxesWanted) - } - kept := json.RawMessage(`{"v":1,"eph":"e","iv":"i","tag":"t","ct":"c"}`) - in.KeptCoreBoxes = []CoreBox{{Node: "laptop", Module: "nats", Name: "broker", Generation: 3, SealedTo: nodeOf(t, &in, "laptop").SealingKey, Box: kept}} - got = rendered(t, in, "laptop", Foreseeing) - if string(got.Rendering.VaultSealed["nats"]["broker"]) != string(kept) || len(got.CoreBoxesWanted) != 0 { - t.Errorf("a kept box: %s wanted %v", got.Rendering.VaultSealed["nats"]["broker"], got.CoreBoxesWanted) - } - in.KeptCoreBoxes[0].SealedTo = "an-old-key" - if got = rendered(t, in, "laptop", Foreseeing); len(got.CoreBoxesWanted) != 1 { - t.Errorf("a box sealed to an old key is kept: %v", got.CoreBoxesWanted) - } - // Below the vault-box contract: none, and the controller's own seal as before. - nodeOf(t, &in, "laptop").Contract = VaultBoxContract - 1 - if got = rendered(t, in, "laptop", Foreseeing); got.Rendering.VaultSealed != nil || len(got.CoreBoxesWanted) != 0 { - t.Errorf("an engine below the contract is sent boxes: %v %v", got.Rendering.VaultSealed, got.CoreBoxesWanted) - } - // No sealing key: the module is left out, said. - nodeOf(t, &in, "laptop").Contract, nodeOf(t, &in, "laptop").SealingKey = VaultBoxContract, "" - got = rendered(t, in, "laptop", Reading) - if why := got.Rendering.NotPlaced["nats"]; !strings.Contains(why, "laptop has no sealing key, so the vault can seal nats's credentials to nothing there") { - t.Errorf("not placed: %v", got.Rendering.NotPlaced) - } - unchanged(t, mk, func(in Input) { rendered(t, in, "laptop", Foreseeing) }) -} - -// 1364/AC1: the bus's user list is carried for the machine that runs the bus and for no other; without it a stand-in is composed and -// named; the users left out for want of a credential are said; each machine's membership is carried. (The list is composed by the -// controller from the bus records, which stay the controller's: see the commit message.) -func TestTheBusUserListIsCarriedForTheMachineThatRunsTheBus(t *testing.T) { - in := aMesh(t) - in.Catalogue["bus"] = catalogue.Manifest{Module: "bus", Version: "1", BusUsers: "/etc/bus/accounts.conf", - Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh}}} - anchor := nodeOf(t, &in, "anchor") - anchor.Assigned = append(anchor.Assigned, "bus") - in.BusUsers = "accounts { anchor.bus: hash }" - in.BusMissing = []string{"laptop.files", "laptop.web"} - in.BusMemberships = map[string]string{"laptop": "sealed-membership"} - got := rendered(t, in, "anchor", Foreseeing) - if got.Rendering.BusUsers != in.BusUsers || len(got.Foreseen) != 0 { - t.Errorf("the list is %q foreseen %v", got.Rendering.BusUsers, got.Foreseen) - } - if len(got.Said) != 1 || got.Said[0] != "the bus's user list leaves out 2 user(s) the mesh has minted no credential for: laptop.files, laptop.web. Each is a user that cannot connect until one is issued" { - t.Errorf("said %v", got.Said) - } - if other := rendered(t, in, "laptop", Foreseeing); other.Rendering.BusUsers != "" || other.Rendering.BusMembership != "sealed-membership" { - t.Errorf("laptop: list %q membership %q", other.Rendering.BusUsers, other.Rendering.BusMembership) - } - in.BusUsers = "" - got = rendered(t, in, "anchor", Foreseeing) - if got.Rendering.BusUsers != ForeseenBusUsers || len(got.Foreseen) != 1 { - t.Errorf("no list in the Input: %q foreseen %v", got.Rendering.BusUsers, got.Foreseen) - } -} - -// 1364/AC7: nothing the composition says carries a secret value the Input holds. -func TestNoSecretValueOfTheInputIsInWhatTheCompositionSays(t *testing.T) { - in := aGrantMesh(t) - in.Issued[0].ForProvider = secretValue - laptop := nodeOf(t, &in, "laptop") - in.Catalogue["assistant"] = anAssistant() - laptop.Assigned = append(laptop.Assigned, "assistant") - laptop.SealingKey = keyOf(t, "laptop") - in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made", SealedTo: laptop.SealingKey, Sealed: secretValue}} - in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialHeld, Sealed: secretValue}} - for _, node := range []string{"anchor", "laptop"} { - got := rendered(t, in, node, Foreseeing) - for _, said := range append(append([]string{}, got.Foreseen...), got.Said...) { - if strings.Contains(said, secretValue) { - t.Errorf("%s: %q carries a secret value", node, said) - } - } - } -} - -// A consumer no credential was made for yet is granted with a stand-in and named foreseen, for every form whose credential -// the vault mints or takes in; an offer of form none has no credential (issue 1126, 2026-11-02: mesh-cli on ace held for -// "the grant of mcp-endpoint", whose provider node-tools offers form none): its grant carries the consumer and no -// credential, nothing stands in on either side, and neither end asks the vault for one. -func TestAConsumerNoCredentialWasMadeForYetIsGrantedWithAStandInUnlessItsOfferHasNone(t *testing.T) { - for _, tc := range []struct { - form string - cred *catalogue.OfferCredential - }{ - {"undeclared", nil}, - {catalogue.CredentialFormPair, &catalogue.OfferCredential{Form: catalogue.CredentialFormPair}}, - {catalogue.CredentialFormShared, &catalogue.OfferCredential{Own: "access-key"}}, - {catalogue.CredentialFormNone, &catalogue.OfferCredential{Form: catalogue.CredentialFormNone}}, - } { - in := aGrantMesh(t) - in.Issued = nil - provider := in.Catalogue["objects"] - provider.Provides[0].Credential = tc.cred - if tc.cred != nil && tc.cred.Own != "" { - provider.OwnSecrets = catalogue.OwnSecrets{"access-key": {Path: "/var/lib/mesh/objects/access-key"}} - } - in.Catalogue["objects"] = provider - none := tc.form == catalogue.CredentialFormNone - got := rendered(t, in, "anchor", Foreseeing) - if len(got.Rendering.Grants) != 1 { - t.Fatalf("%s: grants are %+v", tc.form, got.Rendering.Grants) - } - g, wantSealed := got.Rendering.Grants[0], catalogue.ForeseenSealed - if none { - wantSealed = "" - } - if g.Provision != "s3-bucket" || g.Consumer != "laptop" || g.From != "files" || g.Sealed != wantSealed { - t.Errorf("%s: the grant is %+v", tc.form, g) - } - if has(got.Foreseen, "the grant of s3-bucket for files on laptop") == none { - t.Errorf("%s: foreseen %v", tc.form, got.Foreseen) - } - consumer, asProvider, err := CredentialsNeeded(in, "laptop") - _, anchorProvides, err2 := CredentialsNeeded(in, "anchor") - if err != nil || err2 != nil || len(asProvider) != 0 || (len(consumer) == 0) != none || (len(anchorProvides) == 0) != none { - t.Errorf("%s: laptop asks %v, anchor provides %v (%v %v)", tc.form, consumer, anchorProvides, err, err2) - } - resolved, err := Resolve(in, "laptop") - if err != nil { - t.Fatalf("%s: %v", tc.form, err) - } - if n := theFilesNeed(t, resolved); (n.Sealed == "") != none || (len(resolved.Foreseen) == 0) != none { - t.Errorf("%s: the consumer's credential is %q, foreseen %v", tc.form, n.Sealed, resolved.Foreseen) - } - if lap := rendered(t, in, "laptop", Foreseeing); none && len(lap.Foreseen) != 0 { - t.Errorf("%s: the consumer stands in for %v", tc.form, lap.Foreseen) - } - // Only the provider's own: the consumer is granted nothing, and nothing in the Input changed. - if len(rendered(t, in, "laptop", Foreseeing).Rendering.Grants) != 0 { - t.Errorf("%s: the consumer is granted its own credential", tc.form) - } - if len(in.Issued) != 0 { - t.Errorf("%s: a row was added to the Input", tc.form) - } - // A row made long ago for an offer of form none (the controller's) carries no credential either. - in.Issued = []Issued{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", ForProvider: "sealed-to-anchor"}} - if g := rendered(t, in, "anchor", Foreseeing).Rendering.Grants; none && (len(g) != 1 || g[0].Sealed != "") { - t.Errorf("%s: an old row's grant is %+v", tc.form, g) - } - } - // A credential only a person gives and nobody has is no grant. - in := aGrantMesh(t) - in.Issued = nil - in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialNotGiven}} - if got := rendered(t, in, "anchor", Foreseeing); len(got.Rendering.Grants) != 0 { - t.Errorf("a credential nobody gave is granted: %+v", got.Rendering.Grants) - } -} - -// And a consumer whose identity overflows the provision's bound is judged before any credential is made for it (issue 263). -func TestAnOverflowingConsumerNoCredentialWasMadeForYetIsWithheld(t *testing.T) { - in := aGrantMesh(t) - in.Issued = nil - in.Catalogue["resolver"] = storeManifests()[1] - in.Catalogue["networkmanager"] = catalogue.Manifest{Module: "networkmanager", Version: "1", Requires: []string{"wildcard-resolution", "s3-bucket"}} - nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "networkmanager") - nodeOf(t, &in, "anchor").Assigned = append(nodeOf(t, &in, "anchor").Assigned, "resolver") - in.Holdings = []catalogue.Held{{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "resolver", Site: "here"}} - got := rendered(t, in, "anchor", Foreseeing) - if len(got.Withheld) != 1 || got.Withheld[0].Module != "networkmanager" { - t.Fatalf("withheld %+v", got.Withheld) - } -} - -// The grants come in the order the controller reads them, whatever order the Input gives its rows. -func TestGrantsComeInTheOrderOfProvisionConsumerModuleAndLocal(t *testing.T) { - in := aGrantMesh(t) - in.Issued = []Issued{ - {Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Local: "b", Provider: "anchor", ForProvider: "x"}, - {Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Local: "a", Provider: "anchor", ForProvider: "x"}, - } - rows, err := newComposer(in).rowsFor("anchor") - // The credential the same send would make (no local name) comes first, then the two the Input gives, a before b. - if err != nil || len(rows) != 3 || rows[0].Local != "" || rows[1].Local != "a" || rows[2].Local != "b" { - t.Errorf("rows are %+v %v", rows, err) - } -} - -// Round 1, finding 4: a member of a secret family a person gave, whose sealed value the Input does not carry, is placed with a -// stand-in and named foreseen, not dropped as if nobody gave it. -func TestAGivenFamilyMemberWithoutItsValueIsAStandInAndNamedForeseen(t *testing.T) { - in := aMesh(t) - in.Catalogue["mailer"] = catalogue.Manifest{Module: "mailer", Version: "1", - OwnSecrets: catalogue.OwnSecrets{"credential-*": {Path: "/var/lib/mailer/credential-*.secret", Taken: catalogue.TakenAtStart, IssuedBy: catalogue.IssuedOutside}}} - laptop := nodeOf(t, &in, "laptop") - laptop.Assigned = append(laptop.Assigned, "mailer") - laptop.SealingKey = keyOf(t, "laptop") - in.OwnSecrets = []OwnSecretRow{ - {Node: "laptop", Module: "mailer", Name: "credential-a", Origin: "accepted", SealedTo: laptop.SealingKey}, - {Node: "laptop", Module: "mailer", Name: "credential-b", Origin: "accepted", SealedTo: laptop.SealingKey, Sealed: "b-sealed"}, - } - got := rendered(t, in, "laptop", Foreseeing) - needed := got.Rendering.Needed["mailer"] - if needed["credential-a"] != catalogue.ForeseenSealed || needed["credential-b"] != "b-sealed" { - t.Errorf("needed %v", needed) - } - if !has(got.Foreseen, "mailer/credential-a") || has(got.Foreseen, "mailer/credential-b") { - t.Errorf("foreseen %v", got.Foreseen) - } -} - -// Round 1, finding 5: the suffix internal names end in is a field of the Input, and the environment is never read. -func TestTheInternalSuffixIsTheInputsAndNeverTheEnvironments(t *testing.T) { - t.Setenv(overlay.SuffixVar, "from-the-environment") - in := aGrantMesh(t) - got := rendered(t, in, "anchor", Foreseeing) - if got.Rendering.Suffix != "internal" || got.Rendering.Grants[0].At != "laptop.internal" { - t.Errorf("by default: suffix %q, grant at %q", got.Rendering.Suffix, got.Rendering.Grants[0].At) - } - in.InternalSuffix = ".lan" - got = rendered(t, in, "anchor", Foreseeing) - if got.Rendering.Suffix != "lan" || got.Rendering.Grants[0].At != "laptop.lan" { - t.Errorf("given: suffix %q, grant at %q", got.Rendering.Suffix, got.Rendering.Grants[0].At) - } - where, err := newComposer(in).whereEveryoneIs() - if err != nil || where["anchor"] != "anchor.lan" { - t.Errorf("where everyone is: %v %v", where, err) - } - if names := newComposer(in).namesInTheMesh(); names["anchor.lan"] == "" || names["anchor.internal"] != "" { - t.Errorf("names: %v", names) - } -} - -// Round 2: a need answered on the same machine is a grant too (catalogue resolve.go gives it From: the node's own name). -func TestAConsumerOnTheProvidersOwnNodeIsGrantedWithAStandInAndNamedForeseen(t *testing.T) { - in := aGrantMesh(t) - in.Issued = nil - laptop, anchor := nodeOf(t, &in, "laptop"), nodeOf(t, &in, "anchor") - laptop.Assigned = []string{overlay.Name} - anchor.Assigned = append(anchor.Assigned, "files") - got := rendered(t, in, "anchor", Foreseeing) - if len(got.Rendering.Grants) != 1 || got.Rendering.Grants[0].Consumer != "anchor" || got.Rendering.Grants[0].Sealed != catalogue.ForeseenSealed { - t.Fatalf("grants are %+v", got.Rendering.Grants) - } - if !has(got.Foreseen, "the grant of s3-bucket for files on anchor") { - t.Errorf("foreseen %v", got.Foreseen) - } -} diff --git a/go/compose/fixture_test.go b/go/compose/fixture_test.go deleted file mode 100644 index c6f1188..0000000 --- a/go/compose/fixture_test.go +++ /dev/null @@ -1,90 +0,0 @@ -package compose - -import ( - "crypto/ecdh" - "crypto/rand" - "encoding/base64" - "encoding/json" - "reflect" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/overlay" -) - -// aPublicKey is a key a machine could have reported, made as mesh-controller's mesh_for_test.go aPublicKey makes it. -func aPublicKey(t *testing.T) string { - t.Helper() - k, err := ecdh.X25519().GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes()) -} - -// keys are made once per run, so two Inputs made the same way are equal. -var keys = map[string]string{} - -func keyOf(t *testing.T, name string) string { - t.Helper() - if keys[name] == "" { - keys[name] = aPublicKey(t) - } - return keys[name] -} - -// theOverlayModule is the module the mesh provides for itself, as mesh-controller's providedModules makes it -// (cmd/mesh-controller/modules.go at 6c2a8d62). -func theOverlayModule(t *testing.T) catalogue.Manifest { - t.Helper() - var m catalogue.Manifest - b, err := json.Marshal(overlay.Manifest()) - if err != nil { - t.Fatal(err) - } - if err := json.Unmarshal(b, &m); err != nil { - t.Fatal(err) - } - return m -} - -// aMesh is the Input of mesh-controller's aMesh (cmd/mesh-controller/mesh_for_test.go at 6c2a8d62): two placed -// machines, `anchor` the hub, both reporting container-runtime, wireguard and systemd, both assigned what puts them on the -// private network, each at .example:51820 on the site `here`, anchor at 10.77.0.1 and laptop at 10.77.0.2. -func aMesh(t *testing.T) Input { - t.Helper() - in := Input{Catalogue: map[string]catalogue.Manifest{overlay.Name: theOverlayModule(t)}} - for i, name := range []string{"anchor", "laptop"} { - in.Nodes = append(in.Nodes, Node{ - Name: name, - Place: Place{Key: keyOf(t, name), Endpoint: name + ".example:51820", Site: "here", - Hub: name == "anchor", Address: []string{"10.77.0.1", "10.77.0.2"}[i]}, - Assigned: []string{overlay.Name}, - Capabilities: map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true}, - }) - } - return in -} - -// nodeOf is the named node of an Input, for a test to change. -func nodeOf(t *testing.T, in *Input, name string) *Node { - t.Helper() - for i := range in.Nodes { - if in.Nodes[i].Name == name { - return &in.Nodes[i] - } - } - t.Fatalf("no node %s", name) - return nil -} - -// unchanged runs call on one Input and compares it with another made the same way: the composition changes nothing in what -// it is given (deep equality, nothing shared). -func unchanged(t *testing.T, mk func() Input, call func(Input)) { - t.Helper() - used, same := mk(), mk() - call(used) - if !reflect.DeepEqual(used, same) { - t.Fatalf("the call changed its Input") - } -} diff --git a/go/compose/frombus/answer.go b/go/compose/frombus/answer.go deleted file mode 100644 index 64eb6d2..0000000 --- a/go/compose/frombus/answer.go +++ /dev/null @@ -1,582 +0,0 @@ -package frombus - -import ( - "context" - "errors" - "fmt" - "maps" - "regexp" - "slices" - "sort" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/compose" - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// Answer is a node's answer to its proposal's placement candidates (novox/hq ADR 0567 §3, ADR 0559 §4): s.Node composed in shadow with -// s.Placements laid over the trunk's placement (compose.CompositionsShadow, its own sealed values and boxes through r as -// for its own composition), compared with what it runs (delivery.CompareComposed). It applies nothing and writes -// nothing; the proposal and the revision are the candidate's, said back in the answer. -// -// The node's own composition is compared only where the node runs one. fps gives the build each module is composed at: what the node runs, and for a module the candidate places that the -// node does not run, the build the trunk holds; a placed module with none is added, with no build of it known. What the -// composition stands in for is judged: a value of a module the proposal adds (on this node or another: its credential, own -// secrets and boxes are made only once it is placed, ADR 0549 §5) is not yet issued, which is no refusal; any other -// stand-in is a value missing for a module the node runs, a refusal naming it ("node" when it is the node's own). -func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string, running []delivery.Composed, proposal string, revision uint64) delivery.CandidateAnswer { - a := delivery.CandidateAnswer{Proposal: proposal, Revision: revision} - refuse := func(err error) delivery.CandidateAnswer { - a.Refused = err.Error() - a.Verdict = delivery.CandidateRefused - return a - } - trunk, err := r.Placements(ctx) - if err != nil { - return refuse(fmt.Errorf("frombus: placement: %w", err)) - } - if s.Running == nil { - s.Running = running - } - // Every module it cannot compose is named, not the first only (issue 1126): a build it cannot read or take in, and - // below, a module the composition refuses, each composed as a stub so the rest is composed and compared. - failed := map[string]string{} - in, err := input(ctx, r, mesh, s, func(module string, err error) { failed[module] = err.Error() }, nil) - if err != nil { - return refuse(err) - } - asConsumer, asProvider, err := compose.CredentialsNeeded(in, s.Node) - if err != nil { - return refuse(err) - } - // What the node runs, by module. A consumer's credential is asked only for a module it runs: the vault refuses the - // whole ask for a consuming module not assigned to the node (mesh-vault's own), and a module the candidate adds is - // assigned only after the merge (ADR 0549 §5), so its credential is left missing, a stand-in answered as not yet - // issued under Added (review of L2b, round 1, finding 1). - runs := map[string]bool{} - for _, c := range running { - if c.Module != "" { - runs[c.Module] = true - } - } - var consumed []compose.CredentialKey - for _, k := range asConsumer { - if runs[k.ConsumerModule] { - consumed = append(consumed, k) - } - } - asConsumer = consumed - if len(asConsumer)+len(asProvider) > 0 { - if in.Credentials, in.Issued, err = r.Credentials(ctx, s.Node, asConsumer, asProvider); err != nil { - return refuse(fmt.Errorf("frombus: the credentials of %s: %w", s.Node, err)) - } - } - composed, skipped, stood, err := composeEach(in, s.Node, fps, false, s.Running, failed, ranOf(ctx, r, mesh, s.Running, fps)) - if err != nil { - // Not one module's: the node's own (a value served elsewhere, the mesh's network), said once as the node's. - if len(failed) == 0 { - return refuse(err) - } - failed[delivery.HaveNodeWord] = err.Error() - composed, skipped, stood = nil, nil, nil - } - // The node's own composition is judged only where the node runs one: one that runs none applies it only once a want - // names it (the node agent's own composer hands it on that want alone), so a candidate does not add it. - runsOwn := false - for _, c := range running { - runsOwn = runsOwn || c.Module == "" - } - if !runsOwn { - for key, c := range composed { - if c.Module == "" { - delete(composed, key) - } - } - } - if _, whole := failed[delivery.HaveNodeWord]; whole { - // Nothing composed to compare: every refusal is said, and nothing else. - for _, m := range sortedKeys(failed) { - a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: failed[m]}) - } - heldAsToday(&a, s.HeldToday) - a.Verdict = delivery.VerdictOf(a) - return a - } - // A module the composition folds inside the node's own is judged there: the agent also says it runs by module, from - // the declaration it kept before the switch (agent.Everything), which is no removal (issue 1126, seed #531). - if len(s.InOwn) > 0 { - var judged []delivery.Composed - for _, c := range running { - if c.Module == "" || !slices.Contains(s.InOwn, c.Module) { - judged = append(judged, c) - } - } - running = judged - } - cmp := delivery.CompareComposedWith(composed, running, s.SameValue) - // The answer rides in the node's have key: its shown bodies are bounded, the rest named only. - budget := MaxShown - for i := range cmp.Differs { - kept := cmp.Differs[i].Bodies[:0] - for _, b := range cmp.Differs[i].Bodies { - if n := len(b.Composed) + len(b.Running); n <= budget { - budget -= n - kept = append(kept, b) - } else { - kept = append(kept, delivery.ResourceBodies{ID: b.ID, Why: b.Why}) - a.Note = "some differing resources are named without their bodies: the answer holds at most " + - fmt.Sprint(MaxShown) + " bytes of them" - } - } - cmp.Differs[i].Bodies = kept - } - // A refused module was composed as a stub: what it composed is no difference; its refusal is said. - notFailed := func(m string) bool { _, ok := failed[m]; return !ok } - a.Same = slices.DeleteFunc(cmp.Same, func(m string) bool { return !notFailed(m) }) - a.Removed = slices.DeleteFunc(cmp.Removed, func(m string) bool { return !notFailed(m) }) - a.Differs = slices.DeleteFunc(cmp.Differs, func(d delivery.ModuleDiffers) bool { return !notFailed(d.Module) }) - cmp.Added = slices.DeleteFunc(cmp.Added, func(d delivery.ModuleAdded) bool { return !notFailed(d.Module) }) - skipped = slices.DeleteFunc(skipped, func(m string) bool { return !notFailed(m) }) - - // What the proposal adds, by node and module: placed by a candidate and not by the trunk on that node. - added := map[string]bool{} - for node, p := range s.Placements { - if p == nil { - continue - } - was := map[string]bool{} - for _, m := range trunk[node].Modules { - was[m.Module] = true - } - for _, m := range p.Modules { - if !was[m.Module] { - added[node+"/"+m.Module] = true - } - } - } - unissued := map[string][]string{} - refusals := map[string][]string{} - for _, x := range stood { - node, module := standsInFor(x, s.Node) - switch { - case node == s.Node && runs[module]: - // A value missing for a module the node runs is a refusal, whatever the trunk places (the seed's trunk is - // empty, and every module counts as the group's). - refusals[module] = append(refusals[module], x) - case module != "" && added[node+"/"+module]: - if node == s.Node { - unissued[module] = append(unissued[module], x) - } // another node's addition: the module here that carries it differs, which the assigner judges - case module == "" || node != s.Node: - // The node's own, or a value this node holds for a module elsewhere (a grant it provides): said as the node's. - refusals[delivery.HaveNodeWord] = append(refusals[delivery.HaveNodeWord], x) - default: - refusals[module] = append(refusals[module], x) - } - } - addedHere := map[string]delivery.ModuleAdded{} - for _, m := range cmp.Added { - addedHere[m.Module] = m - } - for _, m := range skipped { // placed, and no build given to compose it at - addedHere[m] = delivery.ModuleAdded{Module: m, Unissued: []string{"no build of " + m + " is known to compose it at"}} - } - for m, xs := range unissued { - got := addedHere[m] - got.Module = m - got.Unissued = append(got.Unissued, xs...) - addedHere[m] = got - } - for _, m := range sortedKeys(addedHere) { - a.Added = append(a.Added, addedHere[m]) - } - for _, m := range sortedKeys(refusals) { - if _, isAdded := addedHere[m]; isAdded { - continue // a module the candidate adds here: what it lacks is said under Added - } - if _, isFailed := failed[m]; isFailed { - continue // said below, as why it could not be composed - } - a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: "stands in for " + strings.Join(refusals[m], ", ")}) - } - for _, m := range sortedKeys(failed) { - a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: failed[m]}) - } - sort.Slice(a.Refusals, func(i, j int) bool { return a.Refusals[i].Module < a.Refusals[j].Module }) - heldAsToday(&a, s.HeldToday) - a.Verdict = delivery.VerdictOf(a) - return a -} - -// heldAsToday moves into Held each refusal of a module the node keeps today as it runs (Scope.HeldToday, with how: -// held, failed, running a build of the old form): the candidate keeps it as the node does, which is no change (issue -// 1126: a seed reproduces today, and today keeps them so). Each says how it stands today (ModuleRefused.Today). No -// module is held behind another (issue 1960): a module that reads from a held one composes with what it last offered. -func heldAsToday(a *delivery.CandidateAnswer, today map[string]string) { - if len(today) == 0 { - return - } - kept := a.Refusals[:0] - for _, r := range a.Refusals { - if w, keep := today[r.Module]; keep { - r.Today = w - a.Held = append(a.Held, r) - } else { - kept = append(kept, r) - } - } - a.Refusals = kept - if len(a.Refusals) == 0 { - a.Refusals = nil - } -} - -// maxCulprits bounds the modules one refusal is tried on, each a whole composition. -const maxCulprits = 8 - -// MaxShown bounds the bytes of shown resource bodies in one answer (delivery.ModuleDiffers.Bodies). -const MaxShown = 256 << 10 - -var ( - grantStandIn = regexp.MustCompile(`^the grant of \S+ for (\S+) on (\S+)$`) - // grantOfStandIn is the provision a grant's stand-in is of. - grantOfStandIn = regexp.MustCompile(`^the grant of (\S+) for \S+ on \S+$`) - needsStandIn = regexp.MustCompile(`^(\S+) on (\S+) needs `) -) - -// standsInFor is the node and module a stand-in of compose's words is for: an own secret `module/name`, a port -// `port module:port`, a grant `the grant of for on `, a need ` on needs ...`; -// the module empty when the words name none (the certificate, the authority). -func standsInFor(x, node string) (string, string) { - if m := grantStandIn.FindStringSubmatch(x); m != nil { - return m[2], m[1] - } - if m := needsStandIn.FindStringSubmatch(x); m != nil { - return m[2], m[1] - } - if rest, ok := strings.CutPrefix(x, "port "); ok { - if module, _, ok := strings.Cut(rest, ":"); ok { - return node, module - } - } - if module, _, ok := strings.Cut(x, "/"); ok && !strings.Contains(x, " ") { - return node, module - } - return node, "" -} - -// composeEach is the one rule, for the node that applies and for its answer to a candidate alike, by which a module's -// own refusal refuses that module and never the node (issue 1126: seed-8 fixed it in the answer alone, and the -// applying path still refused all of novox for de-spiegel's unset route names). The node is composed; a refusal is laid -// on the module whose stub (compose.Stub) changes it (culprit), that module is held (compose.Node.Held) and said in -// failed with the refusal's words, and the rest is composed again, until it composes or the refusal is no single -// module's, which is answered as the node's (err). Applied composes as the node applies (compose.Reading) and holds the -// module placed here each stand-in is for; one for no module placed here (the node's own, a value this node holds for a -// module elsewhere) refuses the node (compose.ErrStoodIn). Not applied, it composes in compose.Foreseeing and answers -// every stand-in for the caller to judge (Answer). -// -// **A held module is what runs of it** (review of the apply, round 1): composed from what running holds of it -// (compose.Carried of its own composition there; the node's own carries it wherever it runs there), so what the others -// read of it (the machine ports its containers publish, the routes reaching it) is what runs, and none of its own -// composition is in out: the node keeps what it last applied. -// -// **A hold never spreads** (issue 1126, issue 1960: de-spiegel's hold for an unset value held mailu, which held -// route-proxy, nftables and postgres on novox). No module is held because a module it reads from is held: a consumer, -// a collector, the provider of what the held one contributes to and the filter where it listens each compose with what -// the held module last offered. That offer (what it contributes, contributes many, listens on) is its manifest at the -// build it runs where one is known (ran: a composition of its own running at another build than it is composed at), -// else the manifest it is composed from. Only the part of the offer that cannot compose is left out, by provision -// (culprit): de-spiegel's routes, its names unset, leave route-proxy without them while mailu keeps its smtp account, -// and the hold says so. -func composeEach(in compose.Input, node string, fps map[string]string, applied bool, running []delivery.Composed, failed map[string]string, - ran func(module string) (catalogue.Manifest, bool)) (out map[string]delivery.Composed, skipped, stood []string, err error) { - mode := compose.Foreseeing - if applied { - mode = compose.Reading - } - runs := map[string]*delivery.Composed{} - for i := range running { - if m := running[i].Module; m != "" { - runs[m] = &running[i] - } - } - src := map[string]catalogue.Manifest{} // each held module's manifest before it was held, offering what it last offered - left := map[string]map[string]bool{} // by held module, what of its offer is left out (leaveOut) - hold := func(m, why, leave string) { - if _, done := failed[m]; !done { - failed[m] = why // a held module tried again keeps the words it was first held for - } - if _, ok := src[m]; !ok { - man, ok := manifestOn(in, node, m) - if !ok { - return - } - if ran != nil { - if was, ok := ran(m); ok { - man.Contributes, man.ContributesMany, man.Listens = was.Contributes, was.ContributesMany, was.Listens - } - } - src[m] = man - } - if leave != "" { - if left[m] == nil { - left[m] = map[string]bool{} - } - left[m][leave] = true - if leave != leaveAll && leave != leaveOwn { - failed[m] += "; composed without what it contributes to " + leave + ", which does not compose" - } - } - in = heldAs(in, node, m, src[m], left[m], runs[m]) - } - for _, m := range sortedKeys(failed) { - hold(m, failed[m], "") - } - for { - out, skipped, stood, err = compose.CompositionsIn(in, node, fps, mode) - if err != nil { - module, leave := culprit(in, node, fps, mode, failed, err) - if module == "" { - return nil, nil, nil, firstReason(err, failed) - } - hold(module, err.Error(), leave) - continue - } - again := false - if applied && len(stood) > 0 { - placed := map[string]bool{} - for _, m := range assignedOn(in, node) { - placed[m] = true - } - of := map[string][]string{} - var nodes []string - for _, x := range stood { - if n, m := standsInFor(x, node); n == node && placed[m] { - of[m] = append(of[m], x) - } else { - nodes = append(nodes, x) - } - } - for _, m := range sortedKeys(of) { - if _, done := failed[m]; !done { - hold(m, "stands in for "+strings.Join(of[m], ", "), "") - again = true - continue - } - // Already held, and still a stand-in in another module's composition: a grant not yet issued for what - // it contributes (a module held for its own secret keeps its contributions, leaveOwn). Its contribution - // to that provision is left out, so the provider never applies a stand-in (issue 774, review round 1). - for _, x := range of[m] { - if g := grantOfStandIn.FindStringSubmatch(x); g != nil && !left[m][g[1]] { - hold(m, failed[m], g[1]) - again = true - } - } - } - if !again && len(nodes) > 0 { - return nil, nil, nil, fmt.Errorf("%w on %s: %s", compose.ErrStoodIn, node, strings.Join(nodes, ", ")) - } - } - if !again { - break - } - } - skipped = slices.DeleteFunc(skipped, func(m string) bool { _, held := failed[m]; return held }) - return out, skipped, stood, nil -} - -// firstReason is err, a refusal no culprit is found for, led by the first reason of each held module it names: a held -// module's stand-in refused later never replaces why it was held (issue 774, 2026-10-30: mesh-self-check on ace held for -// "has no bus credential", then refused as "runs as its own account and declares no own secret broker", its bare stub's -// words, which were all the proposal's refusal said). -func firstReason(err error, failed map[string]string) error { - var named []string - for _, m := range sortedKeys(failed) { - if strings.Contains(err.Error(), m) { - named = append(named, m+" is held: "+failed[m]) - } - } - if len(named) == 0 { - return err - } - return fmt.Errorf("%s; its stand-in is then refused: %w", strings.Join(named, "; "), err) -} - -// blames says the composition with m's stub (again) makes m the culprit of err: it composes, or fails otherwise. The -// runtime's stub is never a runtime (no build is taken in, so it carries no bundle) and always fails otherwise: it is -// the culprit only where its stub composes (issue 774, 2026-10-30 19:30: node-tools held for any error naming it). -func blames(m string, again, err error) bool { - if m == catalogue.RuntimeModule { - // Or where its stub fails only for the modules of their own account it would run (issue 774): they are held - // after it, each for its own words, and the node composes the rest. - var none *catalogue.NoRuntimeToRunError - return again == nil || errors.As(again, &none) && none.Held - } - return again == nil || again.Error() != err.Error() -} - -// leaveAll leaves out the whole of a held module's offer and its own secrets (a bare stub); leaveOwn its own secrets -// alone, its offer kept; any other word leaves out what it contributes to that provision alone. -// -// leaveOwn is tried before leaveAll: a module held for an own secret never made (nextcloud's admin, issue 774, -// 2026-10-30) keeps what it contributes, so its providers' received files keep it and their helpers never retire its -// login while it is held. -const ( - leaveAll = "*" - leaveOwn = "own-secrets" -) - -// assignedOn is the modules placed on node, in placement order. -func assignedOn(in compose.Input, node string) []string { - for _, n := range in.Nodes { - if n.Name == node { - return n.Assigned - } - } - return nil -} - -// manifestOn is the manifest node composes module from: its Kept, else the catalogue's. -func manifestOn(in compose.Input, node, module string) (catalogue.Manifest, bool) { - for _, n := range in.Nodes { - if n.Name == node { - if man, ok := n.Kept[module]; ok { - return man, true - } - } - } - man, ok := in.Catalogue[module] - return man, ok -} - -// culprit is the module placed on node whose composition the error is of, and what of its offer to leave out: the -// first, in placement order, whose stub (compose.Stub) makes the composition in mode succeed or fail otherwise (leave -// ""); else the first whose stub without what it contributes to one provision does (leave that provision: de-spiegel's -// routes, its names unset, and not its smtp account, issue 1960); else the first whose bare stub does (leaveAll: its -// stub contributing nothing, its own secret never issued, issue 1126 seed-8). Empty when none does (the error is not -// one module's). Modules already refused are passed over for their stub. -func culprit(in compose.Input, node string, fps map[string]string, mode compose.Mode, failed map[string]string, err error) (string, string) { - assigned := assignedOn(in, node) - // Only the modules the error names: the words of a module's refusal name it. Each try is a whole composition, so a - // refusal naming none is no module's, answered at once rather than after a composition per module (issue 1126: ace's - // answer took 7m46s). - var order []string - for _, m := range assigned { - if strings.Contains(err.Error(), m) && len(order) < maxCulprits { - order = append(order, m) - } - } - changes := func(m, leave string) bool { - _, _, _, again := compose.CompositionsIn(stubbed(in, node, m, leave), node, fps, mode) - return blames(m, again, err) - } - for _, m := range order { - if _, done := failed[m]; done { - continue - } - if changes(m, "") { - return m, "" - } - } - for _, m := range order { - man, ok := manifestOn(in, node, m) - if !ok { - continue - } - // One already held is tried again too: held for its build, its stub may still refuse (its own secret never - // issued, issue 1126). A provision named in the error is tried first, each at one more composition. - var to []string - for p := range man.Contributes { - to = append(to, p) - } - for p := range man.ContributesMany { - if _, both := man.Contributes[p]; !both { - to = append(to, p) - } - } - sort.SliceStable(to, func(i, j int) bool { - return strings.Contains(err.Error(), " "+to[i]) && !strings.Contains(err.Error(), " "+to[j]) - }) - for _, p := range to { - if changes(m, p) { - return m, p - } - } - if isBare(man) { - continue - } - if len(man.OwnSecrets) > 0 && changes(m, leaveOwn) { - return m, leaveOwn - } - if changes(m, leaveAll) { - return m, leaveAll - } - } - return "", "" -} - -// isBare says a stub contributes nothing and declares no own secret (stubbed, leaveAll). -func isBare(m catalogue.Manifest) bool { - return len(m.Contributes) == 0 && len(m.ContributesMany) == 0 && len(m.OwnSecrets) == 0 -} - -// stubbed is in with module held on node as its stub alone (heldAs, nothing of it running), from the manifest node -// composes it from, leave left out of its offer: a culprit's try. -func stubbed(in compose.Input, node, module, leave string) compose.Input { - man, ok := manifestOn(in, node, module) - if !ok { - return in - } - var left map[string]bool - if leave != "" { - left = map[string]bool{leave: true} - } - return heldAs(in, node, module, man, left, nil) -} - -// heldAs is in with module held on node (compose.Node.Held): composed from man as what runs of it (compose.Carried of -// runs; its stub, compose.Stub, when runs is nil or holds nothing of it), as node's Kept; with what left names out of -// its offer: a provision's contributions, with leaveOwn its own secrets alone, or with leaveAll everything it contributes -// and its own secrets, none of a held module's own composition being applied, so what it has not been issued (restic's -// repository on a node it never ran on) is no refusal of the rest. -func heldAs(in compose.Input, node, module string, man catalogue.Manifest, left map[string]bool, runs *delivery.Composed) compose.Input { - out := in - out.Nodes = append([]compose.Node(nil), in.Nodes...) - for i := range out.Nodes { - if out.Nodes[i].Name != node { - continue - } - if left[leaveAll] { - man.Contributes, man.ContributesMany, man.OwnSecrets = nil, nil, nil - } else if len(left) > 0 { - if left[leaveOwn] { - man.OwnSecrets = nil - } - contributes := map[string]map[string]any{} - for p, v := range man.Contributes { - if !left[p] { - contributes[p] = v - } - } - many := maps.Clone(man.ContributesMany) - for p := range left { - delete(many, p) - } - man.Contributes, man.ContributesMany = contributes, many - } - kept := make(map[string]catalogue.Manifest, len(out.Nodes[i].Kept)+1) - for k, v := range out.Nodes[i].Kept { - kept[k] = v - } - kept[module] = compose.Carried(man, module, runs) - out.Nodes[i].Kept = kept - held := make(map[string]bool, len(out.Nodes[i].Held)+1) - for k, v := range out.Nodes[i].Held { - held[k] = v - } - held[module] = true - out.Nodes[i].Held = held - } - return out -} diff --git a/go/compose/frombus/answer_rows_test.go b/go/compose/frombus/answer_rows_test.go deleted file mode 100644 index 167bb27..0000000 --- a/go/compose/frombus/answer_rows_test.go +++ /dev/null @@ -1,595 +0,0 @@ -package frombus - -import ( - "context" - "encoding/json" - "fmt" - "os" - "reflect" - "slices" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/compose" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/overlay" -) - -// Issue 1126 (the seed's shadow, round 2): a node's answer names every module it cannot compose, not the first only; -// and a module folded into its own composition that names a build of the old form (de-spiegel on novox, nats, photos, -// systemd-resolved: their haves name a build-... id and no fingerprint) is left to the node, carried as it runs. -func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) { - ctx := context.Background() - image := "alpine@sha256:" + strings.Repeat("a", 64) - manifest := func(module string) []byte { - // Its data names its directory, as nats's names ${dir:mesh-state} (issue 1126: novox's own composition refused - // "nats says ${dir:mesh-state}, and nats declares no directory mesh-state" when its stub held no directory). - return []byte(`{"module":"` + module + `","version":"1","capabilities":["container-runtime"],` + - `"own-secrets":{"token":"${dir:state}/token"},` + - `"resources":[{"id":"state","type":"directory","path":"/var/lib/` + module + `","mode":"0700"},` + - `{"id":"server","type":"container","name":"` + module + `","artifact":"site"}],` + - `"build":{"artifacts":[{"name":"site","kind":"upstream","from":"` + image + `"}]}}`) - } - lemurs := []byte(`{"module":"lemurs","version":"1","resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`) - fp := func(c string) string { return "src2:" + strings.Repeat(c, 64) } - mk := func(placed ...string) *bus { - p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}} - for _, m := range placed { - p.Modules = append(p.Modules, delivery.PlacedModule{Module: m}) - } - b := &bus{placements: map[string]delivery.Placement{"n1": p}, - records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd", "container-runtime"}}}, - modules: map[string]delivery.Module{"lemurs": {Manifest: delivery.ManifestDigest(lemurs)}}, - manifests: map[string][]byte{delivery.ManifestDigest(lemurs): lemurs}, - builds: map[string]*delivery.BuilderBuild{}} - for _, m := range []string{"web", "api", "spiegel"} { - b.own = append(b.own, compose.OwnSecretRow{Node: "n1", Module: m, Name: "token", Origin: "made", SealedTo: "sk", Sealed: `"box"`}) - } - for _, m := range []string{"web", "api", "spiegel"} { - raw := manifest(m) - b.manifests[delivery.ManifestDigest(raw)] = raw - b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)} - } - // web and api are built, their keys done and waiting for the reference of their image; spiegel has no build of - // the new form at all. - for i, m := range []string{"web", "api"} { - mod := b.modules[m] - mod.FP2 = fp(string(rune('1' + i))) - b.modules[m] = mod - b.builds[m+" "+mod.FP2] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest, - Artifacts: []delivery.BuiltArtifact{{Name: "site", Kind: "image", Digest: "sha256:" + strings.Repeat("b", 64)}}} - } - return b - } - given := func(b *bus) map[string]*delivery.Placement { - p := b.placements["n1"] - return map[string]*delivery.Placement{"n1": &p} - } - - // Every refusal: two builds taken in, and a module the composition refuses (its image never built), each named. - b := mk("lemurs", "web", "api", "spiegel") - a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b)}, - map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3) - var refused []string - for _, r := range a.Refusals { - refused = append(refused, r.Module) - } - if a.Verdict != delivery.CandidateRefused || a.Refused != "" || !reflect.DeepEqual(refused, []string{"api", "spiegel", "web"}) { - t.Errorf("every refusal: %+v, want api, spiegel and web refused, each named", a) - } - for _, r := range a.Refusals { - want := map[string]string{"api": "waits for the reference of site", "web": "waits for the reference of site", - "spiegel": "spiegel has not been built for this mesh"}[r.Module] - if !strings.Contains(r.Why, want) { - t.Errorf("%s refused as %q, want %q", r.Module, r.Why, want) - } - } - if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": a}}); err != nil { - t.Errorf("the answer is not one a have holds: %v (%s)", err, raw) - } - if lemursOK := slices.ContainsFunc(a.Added, func(m delivery.ModuleAdded) bool { return m.Module == "lemurs" }); !lemursOK { - t.Errorf("lemurs, which composes, is not answered beside the refusals: %+v", a) - } - // Issue 1126: a module the node holds today that the candidate holds too is held as today, no refusal: with every - // refused module held today the verdict is no refusal, and each is named under Held. - today := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held", "spiegel": "held", "web": "held"}}, - map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3) - if today.Verdict == delivery.CandidateRefused || len(today.Refusals) != 0 || len(today.Held) != 3 || today.Held[0].Why == "" { - t.Errorf("held as today: %+v", today) - } - // A refused module the node does not hold today stays a refusal: holding only api today leaves spiegel and web refused. - part := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held"}}, - map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3) - if part.Verdict != delivery.CandidateRefused || len(part.Refusals) != 2 || len(part.Held) != 1 || part.Held[0].Module != "api" { - t.Errorf("held as today in part: %+v", part) - } - // No module is held behind one kept today (issue 1960): mailu's own refusal stays a refusal beside de-spiegel's hold. - if a := (delivery.CandidateAnswer{Refusals: []delivery.ModuleRefused{{Module: "de-spiegel", Why: "a value is unset"}, - {Module: "mailu", Why: "no build"}}}); true { - heldAsToday(&a, map[string]string{"de-spiegel": "runs a build of the old form"}) - if len(a.Refusals) != 1 || a.Refusals[0].Module != "mailu" || len(a.Held) != 1 { - t.Errorf("held as today spreads: %+v", a) - } - } - - // The fold: spiegel runs inside the node's own composition at a build of the old form. Its resources are carried as - // they run, each after the one it runs after: the node's own is the one it runs, and the node composes itself. - b = mk("lemurs", overlay.Name, "spiegel") - p := b.placements["n1"] - p.Decided.Endpoint = "n1.example:51820" - b.placements["n1"] = p - fps := map[string]string{"lemurs": fp("e")} - stub := Scope{Node: "n1"} - in, err := Input(ctx, b, Mesh{}, stub) - if err != nil { - t.Fatal(err) - } - in.Nodes[0].Kept = map[string]catalogue.Manifest{"spiegel": compose.Stub(in.Catalogue["spiegel"])} - base, _, err := compose.Compositions(in, "n1", fps, compose.Reading) - if err != nil { - t.Fatal(err) - } - // A refusal naming no module placed here is no module's: no compose is spent stubbing each in turn (issue 1126: ace's - // answer took 7m46s, one compose per module), though here a stub would compose. - if m, _ := culprit(in, "n1", fps, compose.Foreseeing, map[string]string{}, fmt.Errorf("nothing in this mesh provides \"artifact-store\"")); m != "" { - t.Errorf("a refusal naming no placed module was laid on %s", m) - } - ownKey, _ := delivery.ComposedNodeKey("n1") - own := base[ownKey] - carriedRes := []json.RawMessage{ - json.RawMessage(`{"id":"spiegel.state","type":"directory","path":"/var/lib/spiegel","mode":"0700"}`), - json.RawMessage(`{"id":"spiegel.server","type":"container","name":"spiegel","image":"registry.example/spiegel@sha256:` + strings.Repeat("c", 64) + `"}`)} - // They run after the node's first own resource. - own.Resources = append(append(append([]json.RawMessage{}, own.Resources[:1]...), carriedRes...), own.Resources[1:]...) - var running []delivery.Composed - for k, c := range base { - if k == ownKey { - c = own - } - running = append(running, c) - } - scope := Scope{Node: "n1", InOwn: []string{"spiegel"}, Running: running} - got, _, held, err := Compose(ctx, b, Mesh{}, scope, fps) - if err == nil && len(held) > 0 { - err = fmt.Errorf("modules held: %v", held) - } - if err != nil { - t.Fatalf("the node does not compose itself with spiegel folded at a build of the old form: %v", err) - } - gotOwn, _ := json.Marshal(got[ownKey].Resources) - wantOwn, _ := json.Marshal(own.Resources) - if string(gotOwn) != string(wantOwn) { - t.Errorf("the node's own composition is\n%s\nwant what runs\n%s", gotOwn, wantOwn) - } - a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, running, "p-h1", 3) - if a.Verdict != delivery.CandidateSame { - t.Errorf("the fold's answer: %+v, want the same", a) - } - // (A) The agent also says spiegel runs by module, from the declaration kept before the switch (agent.Everything): a - // module the composition folds is judged inside the node's own, not as removed (issue 1126, seed #531's answer). - keptToo := append(append([]delivery.Composed{}, running...), delivery.Composed{Node: "n1", Module: "spiegel", Resources: carriedRes}) - if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, keptToo, "p-h1", 3); a.Verdict != delivery.CandidateSame || len(a.Removed) != 0 { - t.Errorf("a folded module running from the kept declaration is answered %+v, want the same", a) - } - // (B) The node's own composition carries its bus membership as it runs it (the controller's, from its store): composed - // the same, not 'in what runs, not composed' (seed #531, novox). - membership := json.RawMessage(`{"id":"bus-membership","type":"file","path":"` + catalogue.BusMembershipPath + `","sealed":"{\"box\":\"m\"}","mode":"0600"}`) - withMembership := make([]delivery.Composed, 0, len(running)) - for _, c := range running { - if c.Module == "" { - c.Resources = append(append([]json.RawMessage{}, c.Resources...), membership) - } - withMembership = append(withMembership, c) - } - if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, withMembership, "p-h1", 3); a.Verdict != delivery.CandidateSame { - t.Errorf("the node's own with its bus membership is answered %+v, want the same", a) - } - // Without what runs, the fold composes spiegel from its manifest, as before, and the composition refuses it. - if _, _, held, err := Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}}, fps); err != nil || - !strings.Contains(held["spiegel"], "spiegel has not been built for this mesh") { - t.Errorf("composed spiegel without what runs: %v %v, want spiegel held alone", err, held) - } - - // (C) Issue 1126, C5: what the others read of a module carried as it runs is what runs, never its stub's: the filter - // opens the machine port its running container publishes (de-spiegel's 20005 on novox, not its software's 8080; a - // stub had no container, so snake's 80 on ace was opened beside the route proxy's). And C6: the operator export's file - // a module that keeps it runs is carried as it runs (mesh-vault.kept on novox), since the node holds no export. - listening := []byte(`{"module":"spiegel","version":"1","capabilities":["container-runtime"],` + - `"listens":[{"port":8080,"from":"mesh","why":"the site"}],"own-secrets":{"token":"${dir:state}/token"},` + - `"resources":[{"id":"state","type":"directory","path":"/var/lib/spiegel","mode":"0700"},` + - `{"id":"server","type":"container","name":"spiegel","artifact":"site","ports":["8080"]}],` + - `"build":{"artifacts":[{"name":"site","kind":"upstream","from":"` + image + `"}]}}`) - nft := []byte(`{"module":"nft","version":"1","capabilities":["systemd"],"filtering":{"into":"/etc/nftables.conf"}}`) - vault := []byte(`{"module":"vault","version":"1","capabilities":["systemd"],"keeps":"/var/lib/vault/root",` + - `"resources":[{"id":"service","type":"service","unit":"vault.service","state":"running","boot":"enabled"}]}`) - b = mk("lemurs", overlay.Name, "spiegel", "nft", "vault") - for m, raw := range map[string][]byte{"spiegel": listening, "nft": nft, "vault": vault} { - b.manifests[delivery.ManifestDigest(raw)] = raw - b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)} - } - p = b.placements["n1"] - p.Decided.Endpoint = "n1.example:51820" - for i := range p.Modules { - if p.Modules[i].Module == "spiegel" { - p.Modules[i].Ports = map[string]int{"8080": 20005} - } - } - b.placements["n1"] = p - rec := b.records["n1"] - rec.OutwardLinks = []string{"eth0"} - b.records["n1"] = rec - fps = map[string]string{"lemurs": fp("e"), "nft": fp("f"), "vault": fp("9")} - if in, err = Input(ctx, b, Mesh{}, stub); err != nil { - t.Fatal(err) - } - in.Nodes[0].Kept = map[string]catalogue.Manifest{"spiegel": compose.Stub(in.Catalogue["spiegel"])} - if base, _, err = compose.Compositions(in, "n1", fps, compose.Reading); err != nil { - t.Fatal(err) - } - keptFile := json.RawMessage(`{"digest":"sha256:` + strings.Repeat("d", 64) + `","id":"vault.kept","mode":"0600","module":"vault","path":"/var/lib/vault/root/export.json","size":143604,"type":"file"}`) - running = nil - for _, c := range base { - switch c.Module { - case "": - c.Resources = append(append([]json.RawMessage{}, c.Resources...), - json.RawMessage(`{"id":"spiegel.state","type":"directory","path":"/var/lib/spiegel","mode":"0700"}`), - json.RawMessage(`{"id":"spiegel.server","type":"container","name":"spiegel","image":"registry.example/spiegel@sha256:`+strings.Repeat("c", 64)+`","ports":["10.77.0.1:20005:8080"]}`), - json.RawMessage(`{"id":"spiegel.server-env","type":"file","path":"/var/lib/spiegel/server.env","mode":"0600","content":"A=1\n"}`)) - case "vault": - c.Resources = append(append([]json.RawMessage{}, c.Resources...), keptFile) - } - running = append(running, c) - } - got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}, Running: running}, fps) - if err == nil && len(held) > 0 { - err = fmt.Errorf("modules held: %v", held) - } - if err != nil { - t.Fatalf("the node does not compose itself with spiegel carried: %v", err) - } - var rules string - var keptComposed json.RawMessage - for _, c := range got { - for _, raw := range c.Resources { - var r struct { - ID string `json:"id"` - Content string `json:"content"` - } - _ = json.Unmarshal(raw, &r) - switch r.ID { - case "nft." + catalogue.FilteringID(): - rules = r.Content - case "vault." + catalogue.KeptID(): - keptComposed = raw - } - } - } - if !strings.Contains(rules, "# spiegel — the site") || !strings.Contains(rules, "dport 20005 accept") || strings.Contains(rules, "dport 8080") { - t.Errorf("the filter does not open the machine port spiegel runs at:\n%s", rules) - } - // The paths a carried module owns are its manifest's, as the controller composes them, never what runs of it (issue - // 1126, seed-8 #542: on ace, the folded f1-circuits, snake and txt-game, whose manifests leave their directories and - // files to ${dir:…}, owned their /var/lib paths on the node and none on the controller, shifting node-tools' - // MESH_OWNED_PATHS). f1-circuits' manifest at the indexer's commit (c0ea2c94) is captured; what runs of it is - // composed from that manifest by the controller's rule (the directory at /var/lib/f1-circuits, its file, its container). - for _, c := range running { - if c.Module == "" && !reflect.DeepEqual(compose.Carried(in.Catalogue["spiegel"], "spiegel", &c).OwnsPaths, []string{"/var/lib/spiegel"}) { - t.Errorf("spiegel carried owns %v, want its manifest's /var/lib/spiegel alone", compose.Carried(in.Catalogue["spiegel"], "spiegel", &c).OwnsPaths) - } - } - f1raw, err := os.ReadFile("testdata/f1-circuits-c0ea2c94.module.json") - if err != nil { - t.Fatal(err) - } - f1, err := catalogue.Decode(f1raw) - if err != nil { - t.Fatal(err) - } - f1runs := delivery.Composed{Node: "ace", Resources: []json.RawMessage{ - json.RawMessage(`{"id":"f1-circuits.state","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits","mode":"0700"}`), - json.RawMessage(`{"id":"f1-circuits.cache","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits/cache","mode":"0700"}`), - json.RawMessage(`{"id":"f1-circuits.site-env","module":"f1-circuits","type":"file","path":"/var/lib/f1-circuits/site.env","mode":"0644","content":"F1_CIRCUITS_BASE_URL=https://f1.example\n"}`), - json.RawMessage(`{"id":"f1-circuits.server","module":"f1-circuits","type":"container","name":"f1-circuits","image":"registry.example/f1@sha256:` + strings.Repeat("c", 64) + `"}`)}} - if got, want := catalogue.DeclaredPaths(compose.Carried(f1, "f1-circuits", &f1runs)), catalogue.DeclaredPaths(f1); !reflect.DeepEqual(got, want) { - t.Errorf("f1-circuits carried owns %v, want what the controller composes from its manifest, %v", got, want) - } - // Issue 1126: a held module hands no template its build kept for a file it no longer carries (novox's node: - // "postgres: its build kept a template for consumers"), but keeps its items and their templates, so what it - // contributes to other modules' collections still reaches them. - for _, row := range []struct { - name string - handed *catalogue.Handed - items []catalogue.Item - }{ - {"templates and items", &catalogue.Handed{Templates: map[string]string{"consumers": "files/consumers.json"}, - Items: map[string]string{"routes": "files/routes.tmpl"}}, []catalogue.Item{{Collection: "routes", Content: "x"}}}, - {"items alone", nil, []catalogue.Item{{Collection: "routes", Content: "x"}}}, - } { - kept := f1 - kept.Handed, kept.Items = row.handed, row.items - st := compose.Stub(kept) - if st.Handed != nil && st.Handed.Templates != nil { - t.Errorf("%s: a stub hands templates %v", row.name, st.Handed.Templates) - } - if !reflect.DeepEqual(st.Items, row.items) { - t.Errorf("%s: a stub collects %+v, want its items %+v", row.name, st.Items, row.items) - } - if row.handed != nil && (st.Handed == nil || !reflect.DeepEqual(st.Handed.Items, row.handed.Items) || row.handed.Templates == nil) { - t.Errorf("%s: a stub hands item templates %+v, want %v and its manifest untouched", row.name, st.Handed, row.handed.Items) - } - } - var k1, k2 any - _ = json.Unmarshal(keptComposed, &k1) - _ = json.Unmarshal(keptFile, &k2) - if !reflect.DeepEqual(k1, k2) { - t.Errorf("the operator export is composed as %s, want the file that runs %s", keptComposed, keptFile) - } - - // (D) Issue 1126, seed-8 (mesh-catalog #542): a module's own refusal refuses that module, never the node. novox runs - // de-spiegel folded, and its manifest at the indexer's commit (e25e1beb, captured) asks four required config values - // for its route names that nothing sets. novox answered the whole candidate refused ("de-spiegel contributing - // praktijk to route says ${setting:praktijk-name} ...": its stub still contributed the route). Only de-spiegel is - // refused, in the config form's words; the rest is judged. Issue 1960, novox's chain on 2026-10-29: the hold spread - // to mailu (its smtp provider) and proxy (its route provider), and from them on. Neither is held now: proxy composes - // without de-spiegel's routes, which do not compose, and mailu with its smtp account, which does. - spiegel, err := os.ReadFile("testdata/de-spiegel-e25e1beb.module.json") - if err != nil { - t.Fatal(err) - } - proxy := []byte(`{"module":"proxy","version":"1","capabilities":["systemd"],"provides":[{"name":"route","scope":"mesh"}],` + - `"resources":[{"id":"service","type":"service","unit":"proxy.service","state":"running","boot":"enabled"}]}`) - mailu := []byte(`{"module":"mailu","version":"1","capabilities":["systemd"],"provides":[{"name":"smtp","scope":"mesh"}],` + - `"resources":[{"id":"service","type":"service","unit":"mailu.service","state":"running","boot":"enabled"}]}`) - b = mk("lemurs", overlay.Name, "mailu", "proxy", "de-spiegel") - for m, raw := range map[string][]byte{"de-spiegel": spiegel, "proxy": proxy, "mailu": mailu} { - b.manifests[delivery.ManifestDigest(raw)] = raw - b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)} - } - p = b.placements["n1"] - p.Decided.Endpoint = "n1.example:51820" - b.placements["n1"] = p - fps = map[string]string{"lemurs": fp("e"), "proxy": fp("f"), "mailu": fp("d")} - a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"de-spiegel"}}, fps, nil, "p-h1", 3) - refused = nil - for _, r := range a.Refusals { - refused = append(refused, r.Module) - if r.Module == "de-spiegel" && (!strings.Contains(r.Why, "${config:praktijk-name}") || strings.Contains(r.Why, "${setting:")) { - t.Errorf("de-spiegel is refused in other words than its config form's: %s", r.Why) - } - } - if a.Refused != "" || !reflect.DeepEqual(refused, []string{"de-spiegel"}) { - t.Errorf("a module's own refusal: %+v, want de-spiegel refused alone, the node, mailu and proxy not", a) - } - for _, m := range []string{"lemurs", "mailu", "proxy"} { - if !slices.ContainsFunc(a.Added, func(x delivery.ModuleAdded) bool { return x.Module == m }) { - t.Errorf("%s, which composes, is not judged beside de-spiegel's refusal: %+v", m, a) - } - } - - // (E) Issue 1126, the applying path (review of the seed): the composition novox applies follows the same rule. It - // composed with no culprit, so de-spiegel's unset route names refused the whole node and every module it runs was - // marked not composed. Now de-spiegel is held, its refusal said in its config form's words, with proxy, which - // receives its routes; lemurs composes, and the node's own composition carries de-spiegel as it runs, so applying it - // removes nothing of it. - spiegelRuns := []json.RawMessage{ - json.RawMessage(`{"id":"de-spiegel.state","type":"directory","path":"/var/lib/de-spiegel","mode":"0755"}`), - json.RawMessage(`{"id":"de-spiegel.server-env","type":"file","path":"/var/lib/de-spiegel/server.env","mode":"0600","content":"A=1\n"}`), - json.RawMessage(`{"id":"de-spiegel.server","type":"container","name":"de-spiegel","image":"registry.example/de-spiegel@sha256:` + strings.Repeat("c", 64) + `"}`)} - if in, err = Input(ctx, b, Mesh{}, stub); err != nil { - t.Fatal(err) - } - in.Nodes[0].Kept = map[string]catalogue.Manifest{"de-spiegel": compose.Stub(in.Catalogue["de-spiegel"])} - in.Nodes[0].Kept["de-spiegel"] = func(m catalogue.Manifest) catalogue.Manifest { - m.Contributes, m.ContributesMany = nil, nil - return m - }(in.Nodes[0].Kept["de-spiegel"]) - if base, _, err = compose.Compositions(in, "n1", fps, compose.Reading); err != nil { - t.Fatal(err) - } - running = nil - for _, c := range base { - if c.Module == "" { - c.Resources = append(append([]json.RawMessage{}, c.Resources...), spiegelRuns...) - } - running = append(running, c) - } - got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"de-spiegel"}, Running: running}, fps) - if err != nil { - t.Fatalf("de-spiegel's own refusal refuses the node's whole composition: %v", err) - } - if len(held) != 1 || !strings.Contains(held["de-spiegel"], "${config:praktijk-name}") || !strings.Contains(held["de-spiegel"], "without what it contributes to route") { - t.Errorf("held %v, want de-spiegel alone, in its config form's words, composed without its routes", held) - } - for _, m := range []string{"lemurs", "mailu", "proxy"} { - if key, _ := delivery.ComposedKey("n1", m, fps[m]); got[key].Module != m { - t.Errorf("%s is not composed beside de-spiegel's hold: %v", m, sortedKeys(got)) - } - } - carries := func(c delivery.Composed, what string) { - t.Helper() - ownRes, _ := json.Marshal(c.Resources) - for _, raw := range spiegelRuns { - var r struct{ ID string } - _ = json.Unmarshal(raw, &r) - if !strings.Contains(string(ownRes), `"id":"`+r.ID+`"`) { - t.Errorf("%s: the node's own composition drops %s, which de-spiegel runs: %s", what, r.ID, ownRes) - } - } - } - carries(got[ownKey], "folded") - // (F) Leaving the fold (review of the apply, round 1, finding 1): de-spiegel's want names a build of the new form, - // so it is no longer folded, while its names are still unset and the node's own still runs it. Held, it is carried - // in the node's own as it runs until a composition of its own is handed: else the node's own apply removes its - // container, files and units (issue 1945 by a new path). - fps["de-spiegel"] = fp("7") - got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: running}, fps) - if err != nil || !strings.Contains(held["de-spiegel"], "${config:praktijk-name}") { - t.Fatalf("de-spiegel leaving the fold: %v, held %v", err, held) - } - spiegelKey, _ := delivery.ComposedKey("n1", "de-spiegel", fps["de-spiegel"]) - if _, ok := got[spiegelKey]; ok { - t.Errorf("held de-spiegel's own composition is handed") - } - carries(got[ownKey], "leaving the fold") - - // (G) A held module that runs a composition of its own (review of the apply, round 1, finding 2): what the others - // compose of it is what runs, never its stub's. site listens on 8080, placed at the machine's 20005, and its own - // secret was never issued here: held, the filter must not open 8080 and drop the 20005 that runs. The filter, which - // reads what site listens on, composes with it (issue 1960: a hold never spreads). - site := []byte(`{"module":"site","version":"1","capabilities":["container-runtime"],` + - `"listens":[{"port":8080,"from":"mesh","why":"the site"}],"own-secrets":{"key":"${dir:state}/key"},` + - `"resources":[{"id":"state","type":"directory","path":"/var/lib/site","mode":"0700"},` + - `{"id":"server","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}]}`) - b = mk("lemurs", overlay.Name, "site", "nft") - for m, raw := range map[string][]byte{"site": site, "nft": nft} { - b.manifests[delivery.ManifestDigest(raw)] = raw - b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)} - } - p = b.placements["n1"] - p.Decided.Endpoint = "n1.example:51820" - for i := range p.Modules { - if p.Modules[i].Module == "site" { - p.Modules[i].Ports = map[string]int{"8080": 20005} - } - } - b.placements["n1"] = p - rec = b.records["n1"] - rec.OutwardLinks = []string{"eth0"} - b.records["n1"] = rec - fps = map[string]string{"lemurs": fp("e"), "nft": fp("f"), "site": fp("8")} - siteRuns := delivery.Composed{Node: "n1", Module: "site", FP: fps["site"], Resources: []json.RawMessage{ - json.RawMessage(`{"id":"site.state","module":"site","type":"directory","path":"/var/lib/site","mode":"0700"}`), - json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["10.77.0.1:20005:8080"]}`)}} - got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps) - nftKey, _ := delivery.ComposedKey("n1", "nft", fps["nft"]) - if err != nil || held["site"] == "" || held["nft"] != "" || got[nftKey].Module != "nft" { - t.Fatalf("site, its own secret never issued: %v, held %v; want site held alone, and the filter composed", err, held) - } - if rules, _ := json.Marshal(got[nftKey].Resources); !strings.Contains(string(rules), "dport 20005") { - t.Errorf("the filter drops held site's opening where it runs (20005): %s", rules) - } - for key, c := range got { - for _, raw := range c.Resources { - if strings.Contains(string(raw), "dport 8080") || (c.Module == "nft" && !strings.Contains(string(raw), "dport 20005") && strings.Contains(string(raw), "# site")) { - t.Errorf("%s composes held site from its stub, not from what runs: %s", key, raw) - } - } - } - // (G2) Issue 1126, the spreading hold: a held module is no dependence of its neighbours. site, held, provides page, and - // app requires and binds it: app composes against what runs of site and is not held (novox's seed held every postgres - // consumer behind de-spiegel's hold, "what it composes of …, held, is not what runs of them"). - provider := []byte(strings.Replace(string(site), `"listens":`, `"provides":[{"name":"page","scope":"node"}],"serves":{"page":{"port":8080}},"listens":`, 1)) - app := []byte(`{"module":"app","version":"1","capabilities":["systemd"],"requires":["page"],"binds":{"page":"/etc/app/page.json"},` + - `"resources":[{"id":"service","type":"service","unit":"app.service","state":"running","boot":"enabled"}]}`) - b2 := mk("lemurs", overlay.Name, "site", "nft", "app") - for m, raw := range map[string][]byte{"site": provider, "nft": nft, "app": app} { - b2.manifests[delivery.ManifestDigest(raw)] = raw - b2.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)} - } - p2 := b2.placements["n1"] - p2.Decided.Endpoint = "n1.example:51820" - for i := range p2.Modules { - if p2.Modules[i].Module == "site" { - p2.Modules[i].Ports = map[string]int{"8080": 20005} - } - } - b2.placements["n1"] = p2 - b2.records["n1"] = rec - b2.held = true - fps["app"] = fp("9") - got, _, held, err = Compose(ctx, b2, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps) - appKey, _ := delivery.ComposedKey("n1", "app", fps["app"]) - if _, composed := got[appKey]; err != nil || held["site"] == "" || held["app"] != "" || !composed { - t.Errorf("a consumer of held site: %v, held %v; want site held, and app composed against what runs of it", err, held) - } - if bound, _ := json.Marshal(got[appKey].Resources); !strings.Contains(string(bound), "20005") { - t.Errorf("app's binding does not reach held site where it runs (20005): %s", bound) - } - delete(fps, "app") - // A held module's offer is left out by provision, never whole when one part composes (issue 1960): de-spiegel's - // routes, its names unset, leave the proxy without them, and its smtp account stays with mailu. - withProxy := compose.Input{Catalogue: map[string]catalogue.Manifest{}, - Nodes: []compose.Node{{Name: "n1", Assigned: []string{"feeder"}}}} - feeder := catalogue.Manifest{Module: "feeder", Contributes: map[string]map[string]any{"route": {"name": "x"}, "smtp": {"account": "feeder"}}, - OwnSecrets: catalogue.OwnSecrets{"key": {Path: "/k"}}} - for _, row := range []struct { - left map[string]bool - route, smtp, own bool - }{{nil, true, true, true}, {map[string]bool{"route": true}, false, true, true}, {map[string]bool{leaveOwn: true}, true, true, false}, {map[string]bool{leaveAll: true}, false, false, false}} { - k := heldAs(withProxy, "n1", "feeder", feeder, row.left, nil).Nodes[0].Kept["feeder"] - if _, r := k.Contributes["route"]; r != row.route || (k.Contributes["smtp"] != nil) != row.smtp || (len(k.OwnSecrets) > 0) != row.own { - t.Errorf("held leaving out %v offers %+v, own secrets %v", row.left, k.Contributes, k.OwnSecrets) - } - } - // (H) Review of the apply, round 2: a held module is composed from its new manifest, so what the others read of its - // manifest (its listens, what it contributes) may not be what runs. site's new manifest moves it to 9090 while the - // build that runs (7) listens on 8080. The filter composes with what site last offered, its manifest at the build - // that runs (issue 1960: a hold never spreads), so it opens 8080 and nothing applied opens 9090. - moved := []byte(strings.Replace(string(site), `"port":8080`, `"port":9090`, 1)) - b.manifests[delivery.ManifestDigest(moved)] = moved - b.modules["site"] = delivery.Module{Manifest: delivery.ManifestDigest(moved)} - for i := range p.Modules { - p.Modules[i].Ports = nil - } - b.placements["n1"] = p - siteRuns.Resources[1] = json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}`) - siteRuns.FP = fp("7") - b.manifests[delivery.ManifestDigest(site)] = site - b.builds["site "+fp("7")] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: delivery.ManifestDigest(site)} - got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps) - if err != nil || held["site"] == "" || held["nft"] != "" { - t.Errorf("site moved to 9090 in its new manifest: %v, held %v; want site held alone", err, held) - } - if rules, _ := json.Marshal(got[nftKey].Resources); !strings.Contains(string(rules), "dport 8080") { - t.Errorf("the filter drops what held site last offered (8080): %s", rules) - } - for key, c := range got { - for _, raw := range c.Resources { - if strings.Contains(string(raw), "dport 9090") { - t.Errorf("%s opens 9090, which held site's new manifest names and nothing runs: %s", key, raw) - } - } - } -} - -// A module another node cannot run in this composition offers nothing in it: the refusal names it and why -// (issue 1126, probe #532: ace and g14 answered "nothing in this mesh provides artifact-store … assign distribution to a -// node" while novox runs it). And a refusal no module's stub changes is answered at once, not after a compose per module -// (ace took 7m46s). -func otherNodeNotResolvedIsNamed(t *testing.T) { - ctx := context.Background() - prov := []byte(`{"module":"prov","version":"1","capabilities":["container-runtime"],"provides":[{"name":"thing","scope":"mesh"}]}`) - cons := []byte(`{"module":"cons","version":"1","requires":["thing"]}`) - // n2 runs prov, which needs a container runtime n2's record does not say it has: n2's set does not resolve here. - b := &bus{placements: map[string]delivery.Placement{ - "n1": {Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Endpoint: "n1.example:51820"}, Modules: []delivery.PlacedModule{{Module: "cons"}, {Module: overlay.Name}}}, - "n2": {Node: "n2", Decided: delivery.NodeDecided{Address: "10.77.0.2"}, Modules: []delivery.PlacedModule{{Module: "prov"}, {Module: overlay.Name}}}}, - records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t)}, "n2": {Node: "n2", TunnelKey: publicKey(t)}}, - modules: map[string]delivery.Module{"prov": {Manifest: delivery.ManifestDigest(prov)}, "cons": {Manifest: delivery.ManifestDigest(cons)}}, - manifests: map[string][]byte{delivery.ManifestDigest(prov): prov, delivery.ManifestDigest(cons): cons}} - given := map[string]*delivery.Placement{} - for n, p := range b.placements { - p := p - given[n] = &p - } - a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given}, map[string]string{"cons": "src2:" + strings.Repeat("1", 64)}, nil, "p-h1", 1) - if a.Verdict != delivery.CandidateRefused || !strings.Contains(a.Refused, "prov on n2 lacks container-runtime") { - t.Errorf("a refusal for want of n2's provider = %+v", a) - } -} - -// A node that reaches no artifact store says what its composition holds of one (issue 1126: ace and g14 answered -// "reaches no artifact store", and nothing said why). -func noStoreReachedSaysWhy(t *testing.T) { - in := compose.Input{Catalogue: map[string]catalogue.Manifest{ - "distribution": {Module: "distribution", Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5000}}}}, - Nodes: []compose.Node{{Name: "g14", Assigned: []string{"adwaita"}}, {Name: "novox", Assigned: []string{"distribution"}}}} - if got := storeSought(in); got != "the composition holds g14, novox; artifact-store is served by distribution on novox, none of it on the private network with a port" { - t.Errorf("said %q", got) - } - in.Nodes = in.Nodes[:1] - if got := storeSought(in); got != "the composition holds g14; no module placed there serves artifact-store" { - t.Errorf("said %q", got) - } -} diff --git a/go/compose/frombus/export.go b/go/compose/frombus/export.go deleted file mode 100644 index d28f95c..0000000 --- a/go/compose/frombus/export.go +++ /dev/null @@ -1,112 +0,0 @@ -package frombus - -import ( - "fmt" - "sort" - "strconv" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/compose" - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// Export is one node's placement as the composition input the controller fills says it (novox/hq ADR 0555 §6, ADR 0567 -// §7): the inverse of Input for what a person decides, so the controller's one read-only verb `placement-export` -// answers, from its own store by its own code, exactly what the seat's adopt proposes as the seed. Its answer must stay -// stable for the same store, since adopt checks its digest again at the warrant. -// What the machine observes (its record) is not in it. -func Export(in compose.Input, node string) (delivery.Placement, error) { - var n *compose.Node - for i := range in.Nodes { - if in.Nodes[i].Name == node { - n = &in.Nodes[i] - } - } - if n == nil { - return delivery.Placement{}, fmt.Errorf("frombus: the input has no node %q", node) - } - // At the version a placement key is read at, with a list of modules even when none is placed: the export is the - // seed's value, and the reader refuses a value of version 0 or without its modules. - p := delivery.Placement{V: delivery.Version, Node: node, Modules: []delivery.PlacedModule{}, Decided: delivery.NodeDecided{ - Endpoint: n.Place.Endpoint, Site: n.Place.Site, Hub: n.Place.Hub, Address: n.Place.Address, - PublicDomain: n.PublicDomain, Account: n.Account, AccountHome: n.AccountHome, AgentAccount: n.AgentAccount, - AgentHome: n.AgentAccountHome, Adopted: n.Adopted, Taken: n.Taken}} - // The tunnel found at the node's adoption: the one its private network took over, or, on the hub, the mesh's adopted - // one whether or not the hub took it; and the names a person gave the hub's carried peers. - if t, ok := in.Tunnels[node]; ok { - rt := recordTunnelOf(t.Tunnel) - p.Decided.Tunnel = &rt - } else if in.Adopted != nil && in.Adopted.Hub == node { - rt := recordTunnelOf(in.Adopted.Tunnel) - p.Decided.Tunnel = &rt - } - if n.Place.Hub { - for _, c := range in.CarriedPeers { - if c.Named != "" { - if p.Decided.PeerNames == nil { - p.Decided.PeerNames = map[string]string{} - } - p.Decided.PeerNames[c.PublicKey] = c.Named - } - } - } - for _, module := range n.Assigned { - m := delivery.PlacedModule{Module: module} - for port, at := range n.Ports[module] { - if m.Ports == nil { - m.Ports = map[string]int{} - } - m.Ports[strconv.Itoa(port)] = at - } - for provision, to := range n.Bound[module] { - if m.Bindings == nil { - m.Bindings = map[string]delivery.Provider{} - } - m.Bindings[provision] = delivery.Provider{Node: to.Node, Module: to.Module} - } - // A module in the settings form keeps its config values in its layers, not in mesh-config: they are exported with - // the placement. A module in the config form has mesh-config's values laid into its layer, which are not. - settingsForm := !in.Catalogue[module].DeclaresConfig() - for _, layer := range n.Settings[module] { - if layer.From == catalogue.MeshWideLayer { - m.HasMesh, m.MeshWide = true, layer.Placement - if settingsForm && len(layer.Values) > 0 { - m.MeshValues = layer.Values - } - } else { - m.HasLayer, m.Layer = true, layer.Placement - if settingsForm && len(layer.Values) > 0 { - m.Values = layer.Values - } - } - } - p.Modules = append(p.Modules, m) - } - for provision, to := range n.Pins { - if p.Pins == nil { - p.Pins = map[string]delivery.Provider{} - } - p.Pins[provision] = delivery.Provider{Node: to.Node, Module: to.Module} - } - for _, h := range in.Holdings { - if h.Node == node { - p.Holds = append(p.Holds, delivery.Holding{Claim: h.Claim, Scope: h.Scope, Module: h.Module, Site: h.Site, Kind: h.Kind}) - } - } - sort.SliceStable(p.Holds, func(i, j int) bool { - if p.Holds[i].Claim != p.Holds[j].Claim { - return p.Holds[i].Claim < p.Holds[j].Claim - } - return p.Holds[i].Module < p.Holds[j].Module - }) - return p, nil -} - -func recordTunnelOf(t compose.Tunnel) delivery.RecordTunnel { - out := delivery.RecordTunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU, - Address: t.Address, Range: t.Range, PublicKey: t.PublicKey} - for _, peer := range t.Peers { - out.Peers = append(out.Peers, delivery.RecordTunnelPeer{PublicKey: peer.PublicKey, Address: peer.Address}) - } - return out -} diff --git a/go/compose/frombus/frombus.go b/go/compose/frombus/frombus.go deleted file mode 100644 index 2960221..0000000 --- a/go/compose/frombus/frombus.go +++ /dev/null @@ -1,1167 +0,0 @@ -// Package frombus fills mesh-sdk's composition input from what is on the bus, so a node agent, the composition check -// and the grants part compose with one composer over the same inputs (novox/hq ADR 0547 §2, ADR 0549 §3; issue 1126). -// -// **One composer, three callers.** compose.Compositions is the composer; Input is how each caller fills its -// compose.Input from the bus: the node agent for its own node (ADR 0547 §2), the seat mesh-placement's holder judging a -// proposal for every node (ADR 0567 §4), and the grants part (ADR 0547 §5). Each reads the bus with its own credential -// through a Reader, so this package holds no bus client, no store and no timer: what it is given is all it reads. -// -// **What it reads, and from whom.** -// - which modules run on each node, with their pins, holdings, host ports, bindings and settings placement: the -// seat mesh-placement's state `placement`, one key per node (ADR 0567 §1); -// - what a person decided of each node (its endpoint, site, hub, address on the private network, public domain and -// accounts): the same placement key, Placement.Decided; -// - what each node observes of itself (its tunnel key, capabilities, keys, certificate, links and networks): the -// node's own have key, `.node` (HaveNode.Record); the tunnel found at a node's adoption and the names of the hub's peers are decided, -// in its placement, and whether the hub took the tunnel over and who enrolled under a peer's key are read from both; -// - every module's manifest: the indexer's `modules.` and its manifests by digest; for each module placed on -// the composing node, its built manifest instead: the builder's done key at the trunk's FP2, or at the fingerprint -// the node's want names where it runs another build (Scope.KeptAt), resolved by the controller's own take-in rule -// (takein.Manifest), a moved module's templates archive read in the artifact store (issue 1126); -// - each module's config values on a node: mesh-config's resolved state, the composing node's slice alone unless the -// caller composes every node (ADR 0547 §2, the prepare step's L4); of another node, only the keys its modules' -// served values fill (catalogue.ServedSettings), which every consumer is told (issue 1126). -// -// **Own secrets and pair credentials** come from the vault sealed to the composing node's key (OwnSecrets, Credentials), -// for that node alone: as a consumer and as a provider, exactly the keys compose.CredentialsNeeded names. **Not yet -// read** (each a later subtask of issue 1126): licences (none is placed today) and core boxes. Without one -// the composition stands in for it, and Compose holds the module it stands in for (or refuses the node, for a stand-in -// of no module placed there; compose.ErrStoodIn): a node never applies a stand-in. No node is joining before ADR 0547's third milestone (ADR 0550 §6), so Joining is -// always empty. -package frombus - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "maps" - "net/netip" - "slices" - "sort" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/compose" - "git.novox.be/novox/mesh-sdk/go/declaration" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/overlay" - "git.novox.be/novox/mesh-sdk/go/takein" -) - -// Reader is the bus as a caller reads it with its own credential. Each method reads one part's state; an error is a -// read that failed, never an absent key (an absent key is an empty answer). -type Reader interface { - // Placements is every node's placement, by node (delivery.PlacementBucket). - Placements(ctx context.Context) (map[string]delivery.Placement, error) - // Records is every node's record, by node: HaveNode.Record of each node's own have key, `.node` of - // delivery.HaveBucket (ADR 0555 §3). - Records(ctx context.Context) (map[string]delivery.NodeRecord, error) - // Modules is every module the indexer holds, by module (delivery.ModulesBucket). - Modules(ctx context.Context) (map[string]delivery.Module, error) - // Manifest is the raw manifest of a digest (delivery.ManifestsBucket). - Manifest(ctx context.Context, digest string) ([]byte, error) - // Values is one module's config values on one node as mesh-config resolved them (mesh-config's state `resolved`, - // keys `..`), decoded; nil when it has none. - Values(ctx context.Context, node, module string) (map[string]any, error) - // OwnSecrets is the own secrets of the composing node's modules, each sealed to that node's key, as the vault answers - // the node's engine (the vault's verb `own`, ADR 0550 §5); asked only for the node composing itself. - OwnSecrets(ctx context.Context, node string) ([]compose.OwnSecretRow, error) - // Credentials is, for the composing node, each pair credential it reads as a consumer, held and sealed to its key, - // and each it grants as a provider, with the copy sealed to it (the vault's `own` with the keys, ADR 0550 §5). A key - // the vault holds no value for is left out; the composition then stands in for it, and Compose refuses it. - Credentials(ctx context.Context, node string, asConsumer, asProvider []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) - // Build is a module's build at a fingerprint, the builder's key (delivery.BuilderBuildsBucket, BuilderBuildKey); nil - // when the builder holds none. The composing node composes each of its modules from its build (issue 1126). - Build(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) - // Archive is the archive at url, a kept reference routed to the artifact store as the composing node reaches it - // (catalogue.Routed), checked against digest: a moved module's templates archive, read back for its texts as the - // controller's take-in reads it (takein.Built). - Archive(ctx context.Context, url, digest string) ([]byte, error) - // Served is, of one module's config values on another node than the composing one (mesh-config's state `resolved`, - // keys `..`), the keys named, decoded: the keys its served values and zone name fill - // (catalogue.ServedSettings), which every consumer is told and so every node may read (issue 1126). Nil when none - // is set. - Served(ctx context.Context, node, module string, keys []string) (map[string]any, error) -} - -// SignersReader is a Reader that also reads the vault's public authority from its state `signers` (delivery.SignersBucket, -// key delivery.SignersAuthorityKey; ADR 0550 §2). A Reader that has it composes the mesh's authority as a bundle, the -// controller's and the vault's; one that has not, or that answers none, composes the controller's alone, as before. -type SignersReader interface { - // VaultAuthority is the vault's authority certificate as PEM, checked (delivery.DecodeSignerAuthority); empty when the - // vault has published none. A read that fails is said by the reader and answered empty: a node that may not read the - // state yet composes as before rather than not at all. - VaultAuthority(ctx context.Context) string -} - -// HandedReader is a Reader that also says, of the composing node's modules, which the vault judged handed to the -// provisioner when it answered OwnSecrets (the vault's `own`, its `handed`: catalogue.AssignmentHands of the plan in -// each module's assignment). The node's composition then hands exactly those (compose.Node.Handed, issue 774), so the -// vault and the node never judge one module two ways. Nil, or a Reader without it (a vault older than the field), -// composes by the manifest and the machine, as before. -type HandedReader interface { - Handed(ctx context.Context, node string) map[string]bool -} - -// Mesh is what is true of the whole mesh and is no part's state on the bus yet: the private network's range and the -// internal names' suffix as genesis was told them, and the mesh's authority certificate (public). Empty fields take -// compose's defaults. -type Mesh struct { - OverlayCIDR string - InternalSuffix string - Authority string - // Predefined is the list of predefined keys mesh-config published. No longer read: a reader decodes a manifest and - // takes the indexer's verdict on it, and judges nothing (novox/hq ADR 0578 §3); kept so a caller that sets it builds. - Predefined catalogue.PredefinedList -} - -// Scope says who composes. Node is the node composing; only its config values are read unless Every is set, as the -// composition check sets it to compose every node (of another node's modules, only the values their served values fill -// are read, Reader.Served). KeptAt is, by module, the fingerprint the composing node's want names where it differs from -// the indexer's trunk: that node composes the module from its build at that fingerprint, as it runs it. -// -// **Composed from its build** (issue 1126): each module placed on the composing node, and each in KeptAt, composes from -// its built manifest (takein.Manifest over the builder's done key at the trunk's FP2 or the kept fingerprint), as the -// controller composes from the manifest its take-in resolved: so a moved module is handed to the provisioner, a resource -// naming an artifact names what was built, and a templated file has its text. A placed module the builder holds no done -// build of at the trunk's FP2 composes from the indexer's manifest, as before. Every leaves the catalogue as the indexer -// read it: the check of every node judges placement, and reads no archive. -// -// Placements, when set, stands in for the placement of the nodes it names: a proposal's candidates -// (delivery.PlacementCandidate), so a node composes in shadow with a proposal laid over and compares the result with -// what it runs (ADR 0567 §3); a nil value removes the node's placement. The rest is read as always. -// -// InOwn is the modules the composing node runs inside its own composition (InOwn of what it runs), composed there again -// (compose.Node.InOwn) rather than cut out as modules no build is given for (issue 1126). -// -// Running is what the composing node runs; of it, its own composition (`.node`) is given to the composition -// (compose.Node.RunsOwn), so a module folded there that names a build of the old form is left to the node, carried -// as it runs (issue 1126). Nil composes every folded module from its manifest. -type Scope struct { - Node string - Every bool - KeptAt map[string]string - Placements map[string]*delivery.Placement - InOwn []string - Running []delivery.Composed - // HeldToday is, in an answer, the modules the node keeps today as they run ("node" for its own composition), each with - // how ("held", "failed", "runs a build of the old form"): a candidate that cannot compose one, or holds one only - // behind one of them, keeps it as today, which is said under CandidateAnswer.Held and is no refusal (issue 1126). - HeldToday map[string]string - // SameValue, in an answer, says whether two sealed values open to the same value (the node opens them with its own - // key), so a differing resource shows a value sealed again as such (delivery.CompareComposedWith). Nil shows them - // as differing. - SameValue func(a, b string) (same, known bool) -} - -// InOwn is, of the modules p places on its node, those whose resources the node's own composition it runs -// (`.node` in running) carries, by the module their ids name (declaration.ModuleGuess), sorted: the modules the -// controller's composed writer folded there because no fingerprint of the new form names them (mesh-wireguard, a module -// run at a build of the old form; issue 1126). A node composing itself passes it as Scope.InOwn, so its own -// composition keeps them as it runs them until a want names a build of the new form for one. -func InOwn(running []delivery.Composed, p delivery.Placement) []string { - placed := map[string]bool{} - for _, m := range p.Modules { - placed[m.Module] = true - } - in := map[string]bool{} - for _, c := range running { - if c.Module != "" { - continue - } - for _, raw := range c.Resources { - var head struct { - ID string `json:"id"` - Module string `json:"module"` - } - if json.Unmarshal(raw, &head) != nil || head.Module != "" { - continue - } - if m, ok := declaration.ModuleGuess(head.ID); ok && placed[m] { - in[m] = true - } - } - } - return sortedKeys(in) -} - -// Input is the composition input for s, read through r. -// -// **A refused manifest never ends the read** (novox/hq ADR 0578 §3): each manifest is decoded leniently -// (catalogue.Decode) and the indexer's verdict on it taken from its modules value (delivery.TakeManifestVerdict, a missing -// one accepted); a module whose manifest the indexer's judge refused, or whose bytes do not decode, stands in the -// catalogue as its stub (unusable), so what it provides and requires still shapes the rest, and is named with why in -// the input's Refused; a field set aside is named in its SetAside. Every other module of the read goes on. -func Input(ctx context.Context, r Reader, mesh Mesh, s Scope) (compose.Input, error) { - return input(ctx, r, mesh, s, nil, nil) -} - -// taken is a module's manifest as a reader takes it (novox/hq ADR 0578 §3): decoded leniently, with the indexer's verdict -// from its modules value. A refused one, or bytes that do not decode, stands as its stub (unusable) and why says so; a -// manifest not read past a field it set aside holds its name alone and is held the same. -func taken(name string, raw []byte, m delivery.Module) (man catalogue.Manifest, why string) { - if v := m.VerdictOf(name); v != nil { - if refused, _ := delivery.TakeManifestVerdict(v); refused { - return unusable(name, raw), v.Why() - } - } - man, err := catalogue.Decode(raw) - switch { - case err != nil: - return unusable(name, raw), fmt.Sprintf("the manifest of %s does not decode: %v", name, err) - case man.ReadNoFurther(): - return unusable(name, raw), fmt.Sprintf("the manifest of %s could not be read past a field this reader does not "+ - "know: %s", name, man.SetAside()) - } - return man, "" -} - -// input is Input; with refuse set, a module placed on the composing node whose build cannot be read or taken in, or whose -// manifest the indexer refused, is also handed to refuse and composed as a stub (compose.Stub, kept for that node alone), -// so a node's answer names every module it cannot compose (issue 1126, issue 774); a module placed elsewhere whose -// manifest is refused is read for its offer alone (unusable). A manifest the bus does not answer for still ends the -// read: that is the bus's, not the module's. -func input(ctx context.Context, r Reader, mesh Mesh, s Scope, refuse func(module string, err error), wanted map[string]string) (compose.Input, error) { - b, err := readBase(ctx, r, mesh, s) - b.refuseOwn(refuse) - if err != nil { - return compose.Input{}, err - } - return b.finish(ctx, r, r.Build, mesh, s, b.in, refuse, wanted) -} - -// base is the part of a node's input that the fingerprints it composes its modules at leave alone (issue 1954): read once -// for its plans at the wanted builds and at the newest, whose builds alone differ (PlansHeldTwice). -type base struct { - in compose.Input - placements map[string]delivery.Placement - records map[string]delivery.NodeRecord - modules map[string]delivery.Module - // ownRefused is each module placed on the composing node whose manifest the indexer refused, with why, in the order - // read: every scope holds it. - ownRefused [][2]string -} - -// refuseOwn hands refuse each module placed on the composing node whose manifest is refused. -func (b base) refuseOwn(refuse func(module string, err error)) { - if refuse == nil { - return - } - for _, r := range b.ownRefused { - refuse(r[0], errors.New(r[1])) - } -} - -// cloned is the input with its own catalogue, nodes and kept manifests, what finishing it sets: two scopes finished over -// one base never see each other's builds. -func (b base) cloned() compose.Input { - in := b.in - in.Catalogue = maps.Clone(b.in.Catalogue) - in.Nodes = slices.Clone(b.in.Nodes) - for i := range in.Nodes { - in.Nodes[i].Kept = maps.Clone(in.Nodes[i].Kept) - } - in.KeptCoreBoxes = slices.Clone(b.in.KeptCoreBoxes) - in.OwnSecrets = slices.Clone(b.in.OwnSecrets) - return in -} - -// readBase is input up to the builds: placement, records, the indexer's modules and manifests, each node's values. -func readBase(ctx context.Context, r Reader, mesh Mesh, s Scope) (base, error) { - var b base - placements, err := r.Placements(ctx) - if err != nil { - return b, fmt.Errorf("frombus: placement: %w", err) - } - if len(s.Placements) > 0 { - given := make(map[string]delivery.Placement, len(placements)+len(s.Placements)) - for node, p := range placements { - given[node] = p - } - for node, p := range s.Placements { - if p == nil { - delete(given, node) - continue - } - if p.Node != node { - return b, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node) - } - given[node] = *p - } - placements = given - } - records, err := r.Records(ctx) - if err != nil { - return b, fmt.Errorf("frombus: node records: %w", err) - } - modules, err := r.Modules(ctx) - if err != nil { - return b, fmt.Errorf("frombus: the indexer's modules: %w", err) - } - if _, ok := placements[s.Node]; !ok && !s.Every { - return b, fmt.Errorf("frombus: %s has no placement in the catalogue", s.Node) - } - - in := compose.Input{OverlayCIDR: mesh.OverlayCIDR, InternalSuffix: mesh.InternalSuffix, Authority: mesh.Authority, - Catalogue: map[string]catalogue.Manifest{}} - if in.Authority == "" { - // The mesh's own certificate, public, as the composing node holds it; for the check of every node, as the first - // node holding one does. - if rec, ok := records[s.Node]; ok && rec.Authority != "" { - in.Authority = rec.Authority - } else { - for _, node := range sortedKeys(records) { - if a := records[node].Authority; a != "" { - in.Authority = a - break - } - } - } - } - // And the vault's authority beside it, once the vault publishes one (ADR 0550 §2): a certificate either signed checks - // against the bundle. The controller's stays while a certificate it issued is in a node's record. - if sr, ok := r.(SignersReader); ok { - if vault := sr.VaultAuthority(ctx); vault != "" { - in.Authority = delivery.AuthorityBundle(in.Authority, vault) - } - } - named := func(field *map[string]string, module, why string) { - if *field == nil { - *field = map[string]string{} - } - (*field)[module] = why - } - ownPlaced := map[string]bool{} - for _, pm := range placements[s.Node].Modules { - ownPlaced[pm.Module] = true - } - for _, name := range sortedKeys(modules) { - m := modules[name] - if m.Manifest == "" { - continue // a registered module the indexer has not read: nothing to compose it from - } - raw, err := r.Manifest(ctx, m.Manifest) - if err != nil { - // Not read is no refusal of the module: a bus that does not answer is waited out by the caller. - return b, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", name, m.Manifest, err) - } - // One module's manifest the indexer refused refuses that module alone, by the rule a build that cannot be read - // follows (issue 774: power's bar item refused every node's composition, 2026-10-30; ADR 0578 §3: one refused - // manifest silenced disk-load for 45 hours). It stands in as its stub, so what it provides and requires still - // shapes the rest; it is held on the composing node where it is placed there, and elsewhere only its offer is read. - man, why := taken(name, raw, m) - if why != "" { - named(&in.Refused, name, why) - if ownPlaced[name] { - b.ownRefused = append(b.ownRefused, [2]string{name, why}) - } - } else if aside := man.SetAside(); aside != "" { - named(&in.SetAside, name, aside) - } - in.Catalogue[name] = man - } - - // The module the mesh provides itself (the private network) has no repository the indexer reads: its manifest comes - // with mesh-sdk and always wins, as the controller's providedModules and the grants part have it. - for _, name := range delivery.ProvidedModules() { - raw, err := json.Marshal(overlay.Manifest()) - if err != nil { - return b, err - } - var private catalogue.Manifest - if err := json.Unmarshal(raw, &private); err != nil { - return b, fmt.Errorf("frombus: the manifest mesh-sdk provides for %s: %w", name, err) - } - in.Catalogue[name] = private - } - - var missing []string - for _, node := range nodesOf(placements, records) { - rec, hasRecord := records[node] - p := placements[node] - if !hasRecord { - if len(p.Modules) > 0 { - missing = append(missing, node) - } - continue - } - n := nodeOf(rec, p) - withValues := s.Every || node == s.Node - for _, m := range p.Modules { - var values map[string]any - if withValues { - if values, err = r.Values(ctx, node, m.Module); err != nil { - return b, fmt.Errorf("frombus: %s's config values on %s: %w", m.Module, node, err) - } - } else if keys := catalogue.ServedSettings(in.Catalogue[m.Module]); len(keys) > 0 { - // What a provider elsewhere serves from its settings is told to every consumer: the composing node reads - // those keys of it, and no other (issue 1126: mailu's smtp domain, a consumer's on shanks). - if values, err = r.Served(ctx, node, m.Module, keys); err != nil { - return b, fmt.Errorf("frombus: what %s serves on %s (%s): %w", m.Module, node, - strings.Join(keys, ", "), err) - } - } - if layers := m.Layers(node, values); layers != nil { - if n.Settings == nil { - n.Settings = map[string][]catalogue.Layer{} - } - n.Settings[m.Module] = layers - } - } - if node == s.Node { - // What its runtime serves as it runs: what a module composed as what runs of it contributes there (issue 1126). - n.RunsTools = compose.ToolsRunning(s.Running) - for _, module := range s.InOwn { - if n.InOwn == nil { - n.InOwn = map[string]bool{} - } - n.InOwn[module] = true - } - for i := range s.Running { - // The operator export as the vault runs it: the controller composes it from its store, which the node - // does not read; until the export is on the bus, the node carries the file it runs (issue 1126, C6: - // mesh-vault.kept on novox, "in what runs, and not composed"). - if m := s.Running[i].Module; m != "" { - if kept := keptOf(s.Running[i]); kept != nil { - if in.KeptRuns == nil { - in.KeptRuns = map[string]map[string]map[string]any{} - } - if in.KeptRuns[s.Node] == nil { - in.KeptRuns[s.Node] = map[string]map[string]any{} - } - in.KeptRuns[s.Node][m] = kept - } - continue - } - if s.Running[i].Module == "" { - own := s.Running[i] - n.RunsOwn = &own - // Its bus membership as it runs it: the controller composes it from its store, which the node does - // not read; until the grants part hands memberships sealed to the node, the node's own composition - // carries the one it runs (issue 1126, seed #531 on novox: "bus-membership in what runs, not - // composed", which applied would have taken the node's bus credential away). - if sealed := membershipOf(own); sealed != "" { - if in.BusMemberships == nil { - in.BusMemberships = map[string]string{} - } - in.BusMemberships[s.Node] = sealed - } - } - } - } - in.Nodes = append(in.Nodes, n) - in.Holdings = append(in.Holdings, p.Held()...) - // A tunnel found at adoption is the node's private network's when the node's tunnel key is the tunnel's (the - // controller's inv.Tunnels); the hub's found tunnel is the mesh's adopted one whether or not the hub took it over - // (inv.AdoptedTunnel), and its peers are carried once it did (inv.CarriedPeers). - if t := p.Decided.Tunnel; t != nil { - if rec.TunnelKey == t.PublicKey { - if in.Tunnels == nil { - in.Tunnels = map[string]compose.FoundTunnel{} - } - in.Tunnels[node] = compose.FoundTunnel{Tunnel: tunnelOf(*t), NodeAdopted: p.Decided.Adopted} - } - if p.Decided.Hub { - in.Adopted = &compose.AdoptedTunnel{Tunnel: tunnelOf(*t), Hub: node, Adopted: rec.TunnelKey == t.PublicKey} - } - } - } - if in.Adopted != nil && in.Adopted.Adopted { - in.CarriedPeers = carriedPeers(in.Adopted.Tunnel, placements[in.Adopted.Hub].Decided.PeerNames, records) - } - if len(missing) > 0 { - return b, fmt.Errorf("frombus: modules are placed on %s, whose agent has written no record yet", - strings.Join(missing, ", ")) - } - b.in, b.placements, b.records, b.modules = in, placements, records, modules - return b, nil -} - -// finish is input from its base on: the composing node's modules from their builds (build reads one), its own secrets. -// in is the base's input, or a copy of it (base.cloned) where one base is finished more than once. -func (b base) finish(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error), - mesh Mesh, s Scope, in compose.Input, refuse func(module string, err error), wanted map[string]string) (compose.Input, error) { - var err error - parse := parser(mesh) - read := func(module, digest string) (catalogue.Manifest, error) { - raw, err := r.Manifest(ctx, digest) - if err != nil { - return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s): %w", module, digest, err) - } - man, err := parse(raw) - if err != nil { - return catalogue.Manifest{}, fmt.Errorf("frombus: the manifest of %s (digest %s) does not decode: %w", module, digest, err) - } - return man, nil - } - if !s.Every { - if _, ok := b.records[s.Node]; !ok { - return compose.Input{}, fmt.Errorf("frombus: %s has written no record of itself yet", s.Node) - } - if err := fromBuilds(ctx, r, build, &in, s, b.placements[s.Node], b.modules, read, parse, refuse, wanted); err != nil { - return compose.Input{}, err - } - // Its own modules' secrets, sealed to its own key by the vault: a composition carries them sealed (ADR 0550 §5). - // Asked only when a module placed on it declares one: a node none of whose modules has a secret composes - // whether or not the vault answers. - if declaresOwnSecrets(in, s.Node) { - if in.OwnSecrets, err = r.OwnSecrets(ctx, s.Node); err != nil { - return compose.Input{}, fmt.Errorf("frombus: the own secrets of %s's modules: %w", s.Node, err) - } - // And which of its modules the vault left out of that answer as handed: the node hands exactly those. - if hr, ok := r.(HandedReader); ok { - if handed := hr.Handed(ctx, s.Node); handed != nil { - for i := range in.Nodes { - if in.Nodes[i].Name == s.Node { - in.Nodes[i].Handed = handed - } - } - } - } - } - // The core's credentials travel as the vault's box sealed to the machine (ADR 0349 §16): the same values, which - // the composition reads for a core module as its kept boxes. - for _, o := range in.OwnSecrets { - if json.Valid([]byte(o.Sealed)) { - in.KeptCoreBoxes = append(in.KeptCoreBoxes, compose.CoreBox{Node: o.Node, Module: o.Module, Name: o.Name, - SealedTo: o.SealedTo, Box: json.RawMessage(o.Sealed)}) - } - } - } - return in, nil -} - -// Compose is the node's compositions over the bus (ADR 0547 §2): input for s, then composed at the fingerprints fps -// gives each module in the Reading mode, as the controller composes them, by the one rule its answer to a candidate -// follows too (composeEach, issue 1126): a module refused alone (its build unread or not taken in, its composition -// refused, a stand-in for a value of its own the input does not carry, compose.ErrStoodIn) is held, named in held with -// why, and none of its own composition is in out, so the node keeps what it last applied of it and applies the rest; a -// node never applies a stand-in. Only a refusal that is no single module's (the node's own resources, a value served -// elsewhere, a stand-in for the node's own) refuses the node, err. -func Compose(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string) (out map[string]delivery.Composed, skipped []string, held map[string]string, err error) { - held = map[string]string{} - // A node refused whole says what it held on the way, since the refusal can be of a held module's stub (issue 774, - // 2026-10-30 19:30: node-tools held, then "its build produced 0 bundle(s)" with its own refusal unsaid). - defer func() { - if err != nil && len(held) > 0 { - var said []string - for _, m := range sortedKeys(held) { - said = append(said, m+": "+held[m]) - } - err = fmt.Errorf("%w (held before: %s)", err, strings.Join(said, "; ")) - } - }() - in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() }, fps) - if err != nil { - return nil, nil, held, err - } - // The pair credentials its composition reads, asked of the vault sealed to it (ADR 0550 §5). - asConsumer, asProvider, err := compose.CredentialsNeeded(in, s.Node) - if err != nil { - return nil, nil, held, err - } - if len(asConsumer)+len(asProvider) > 0 { - if in.Credentials, in.Issued, err = r.Credentials(ctx, s.Node, asConsumer, asProvider); err != nil { - return nil, nil, held, fmt.Errorf("frombus: the credentials of %s: %w", s.Node, err) - } - } - out, skipped, _, err = composeEach(in, s.Node, fps, true, s.Running, held, ranOf(ctx, r, mesh, s.Running, fps)) - return out, skipped, held, err -} - -// Plans is the provisioning plan of each module handed to the provisioner on the scope's node, composed from the bus -// alone (novox/hq ADR 0570 clause 2): the controller's handedPlans over the same input (mesh-control -// cmd/mesh-controller/compose_sdk.go), its rendering in the Foreseeing mode with the mesh's generators. A plan names -// templates, files, own secrets by name, items, collections and provisions, never a value, so no value is read for it: -// the seat mesh-provisioner's holder composes every node's plans with a Reader that reads no secret. -func Plans(ctx context.Context, r Reader, mesh Mesh, s Scope) (map[string]catalogue.Plan, error) { - in, err := Input(ctx, r, mesh, s) - if err != nil { - return nil, err - } - return PlansOf(in, s.Node) -} - -// PlansHeld is Plans composed module by module (issue 774: one bad module must not drop its node): a module placed on -// the scope's node whose build cannot be read or taken in, or whose plan refuses the node's, is held, by the one rule -// Compose follows (composeEach, culprit): composed as its stub so what it provides and requires still shapes the rest, -// named in held with why, and none of its plan in plans. Only a refusal that is no single module's refuses the node, -// err. The seat mesh-provisioner's holder composes so: a node with one bad manifest still has every other module's plan. -func PlansHeld(ctx context.Context, r Reader, mesh Mesh, s Scope) (plans map[string]catalogue.Plan, held map[string]string, err error) { - held = map[string]string{} - in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() }, nil) - if err != nil { - return nil, held, err - } - plans, err = plansHeldOf(in, s.Node, held) - return plans, held, err -} - -// plansHeldOf is the plans of node over in, every module in held held, and each module a refusal is of held too until -// the plans compose (culprit's rule over the Planning mode: its stub first, then its bare stub). -func plansHeldOf(in compose.Input, node string, held map[string]string) (map[string]catalogue.Plan, error) { - return heldOf(in, node, held, func(in compose.Input) (map[string]catalogue.Plan, error) { return plansIn(in, node, compose.Planning) }) -} - -// heldOf composes compose1 over in with every module in held held, and each module a refusal is of held too until it -// composes (culprit's rule: its stub first, then its bare stub); what compose1 answers of a held module is left out. -// Plans and data items are composed so (PlansHeld, DataItemsHeld): one module never ends the read (novox/hq ADR 0578 §3). -func heldOf[V any](in compose.Input, node string, held map[string]string, compose1 func(compose.Input) (map[string]V, error)) (map[string]V, error) { - for _, m := range sortedKeys(held) { - in = stubbed(in, node, m, "") - } - // Each try holds one module more or leaves out all of one held module's offer: bounded by twice the modules placed. - for tries := 0; tries <= 2*len(assignedOn(in, node))+1; tries++ { - plans, err := compose1(in) - if err == nil { - for m := range held { - delete(plans, m) - } - return plans, nil - } - var order []string - for _, m := range assignedOn(in, node) { - if strings.Contains(err.Error(), m) && len(order) < maxCulprits { - order = append(order, m) - } - } - module, leave := "", "" - find: - for _, l := range []string{"", leaveAll} { - for _, m := range order { - if _, done := held[m]; done && l == "" { - continue - } - if _, again := compose1(stubbed(in, node, m, l)); again == nil || again.Error() != err.Error() { - module, leave = m, l - break find - } - } - } - if module == "" { - return nil, err - } - if _, done := held[module]; !done { - held[module] = err.Error() - } - in = stubbed(in, node, module, leave) - } - return nil, fmt.Errorf("frombus: what %s composes does not compose with %d module(s) held: %s", node, len(held), - strings.Join(sortedKeys(held), ", ")) -} - -// PlansOf is Plans over an input already filled, rendered in the Planning mode (compose.Planning): every own secret the -// input holds none for stands in, so no value and no record of one is needed. -func PlansOf(in compose.Input, node string) (map[string]catalogue.Plan, error) { - return plansIn(in, node, compose.Planning) -} - -// plansIn is the controller's handedPlans in a mode: Foreseeing is the controller's own. -func plansIn(in compose.Input, node string, mode compose.Mode) (map[string]catalogue.Plan, error) { - // One composition for the three, so each node of the mesh is resolved once (issue 1954). - resolved, rendered, gens, err := compose.Composing(in, node, mode) - if err != nil { - return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err) - } - with := rendered.Rendering - with.Generators = gens - composed, err := resolved.Plan.Compose(with) - if err != nil { - return nil, fmt.Errorf("frombus: the plans of %s: %w", node, err) - } - return composed.Plans, nil -} - -// unusable is what stands in for a module's manifest the mesh refuses: its stub (compose.Stub) -// read from its JSON unjudged, so what it provides, requires and serves still shapes the rest, and without its items, -// since nothing judged what they would put into another module's collection. Bytes that are no manifest of the module -// stand in as a stub that offers nothing. -func unusable(module string, raw []byte) catalogue.Manifest { - var m catalogue.Manifest - if json.Unmarshal(raw, &m) != nil || m.Module != module { - m = catalogue.Manifest{Module: module} - } - m.Items = nil - return compose.Stub(m) -} - -// parser reads a manifest as every reader does (novox/hq ADR 0578 §3): decoded leniently, judged by nothing here. -func parser(Mesh) func([]byte) (catalogue.Manifest, error) { - return catalogue.Decode -} - -// ranOf is what a module last offered the others, read when it is held (composeEach, issue 1960): its manifest at the -// build its own composition runs at (running), where that is another build than the one it is composed at (fps) and -// the builder holds it done. False where nothing is known of it (it runs no composition of its own, it runs the build -// it is composed at, or its build is unread): the manifest it is composed from is its offer then. Only what it says to -// others is taken of it, which needs no take-in of its artifacts. -func ranOf(ctx context.Context, r Reader, mesh Mesh, running []delivery.Composed, fps map[string]string) func(string) (catalogue.Manifest, bool) { - parse := parser(mesh) - return func(module string) (catalogue.Manifest, bool) { - for _, c := range running { - if c.Module != module || c.FP == "" || c.FP == fps[module] { - continue - } - b, err := r.Build(ctx, module, c.FP) - if err != nil || b == nil || b.State != delivery.BuildDone || b.Manifest == "" { - return catalogue.Manifest{}, false - } - raw, err := r.Manifest(ctx, b.Manifest) - if err != nil { - return catalogue.Manifest{}, false - } - man, err := parse(raw) - return man, err == nil - } - return catalogue.Manifest{}, false - } -} - -// fromBuilds puts in the built manifest of each module placed on the composing node, at the trunk's FP2, and of each -// module it keeps at another build (Scope.KeptAt, into its node's Kept), read from the builder's done key and resolved -// by the one take-in rule (takein.Manifest), a templates archive read in the artifact store as the node reaches it -// (issue 1126). -func fromBuilds(ctx context.Context, r Reader, build func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error), - in *compose.Input, s Scope, p delivery.Placement, modules map[string]delivery.Module, - read func(module, digest string) (catalogue.Manifest, error), parse func([]byte) (catalogue.Manifest, error), - refuse func(module string, err error), wanted map[string]string) error { - // stub keeps a refused module in the composition as a stub for this node alone (compose.Stub): what it provides - // and requires still shapes the rest. - stub := func(module string, err error) { - refuse(module, err) - for i := range in.Nodes { - if in.Nodes[i].Name != s.Node { - continue - } - man, ok := in.Nodes[i].Kept[module] - if !ok { - man, ok = in.Catalogue[module] - } - if !ok { - return - } - if in.Nodes[i].Kept == nil { - in.Nodes[i].Kept = map[string]catalogue.Manifest{} - } - in.Nodes[i].Kept[module] = compose.Stub(man) - } - } - address, addressErr := "", error(nil) - addressed := false - archive := func(ctx context.Context, reference, digest string) ([]byte, error) { - if !addressed { - address, addressErr = compose.ArtifactStoreAddress(*in, s.Node) - addressed = true - } - switch { - case addressErr != nil: - return nil, addressErr - case address == "": - return nil, fmt.Errorf("%s reaches no artifact store (%s)", s.Node, storeSought(*in)) - } - return r.Archive(ctx, catalogue.Routed(reference, address), digest) - } - built := func(module, fp string, b *delivery.BuilderBuild) (catalogue.Manifest, error) { - if b.Manifest == "" { - return catalogue.Manifest{}, fmt.Errorf("frombus: the build of %s at %s names no manifest", module, fp) - } - // A take-in kept by the reader is read back as below, never shared: each composition holds its own manifest. - keeper, keeps := r.(takenInKeeper) - if keeps && !addressed { - address, addressErr = compose.ArtifactStoreAddress(*in, s.Node) - addressed = true - } - // Kept by the store's address too: a node that reaches no store takes in as before, and fails where it did. - key, keyed := takenInKey(module, fp, address, *b) - keyed = keyed && addressErr == nil && address != "" - if keeps && keyed { - if raw, ok := keeper.takenIn(key); ok { - resolved, err := parse(raw) - if err != nil { - return catalogue.Manifest{}, fmt.Errorf("frombus: the built manifest of %s at %s: %w", module, fp, err) - } - return resolved, nil - } - } - src, err := read(module, b.Manifest) - if err != nil { - return catalogue.Manifest{}, err - } - resolved, err := takein.Manifest(ctx, src, *b, archive) - if err != nil { - return catalogue.Manifest{}, fmt.Errorf("frombus: %s at %s: %w", module, fp, err) - } - // Read back as the mesh holds a built manifest (the controller keeps the resolved manifest's bytes and reads them - // as any manifest): a template's text is then in the manifest's own words, its ${config:…} in the settings form. - raw, err := json.Marshal(resolved) - if err != nil { - return catalogue.Manifest{}, err - } - if resolved, err = parse(raw); err != nil { - return catalogue.Manifest{}, fmt.Errorf("frombus: the built manifest of %s at %s: %w", module, fp, err) - } - if keeps && keyed { - keeper.keepTakenIn(key, raw) - } - return resolved, nil - } - // A module wanted at another build than the trunk's is composed from the build its want names (issue 774), as one - // the scope keeps at another build is. - keptAt := maps.Clone(s.KeptAt) - for _, pm := range p.Modules { - if m, ok := modules[pm.Module]; ok && m.FP2 != "" && wanted[pm.Module] != "" && wanted[pm.Module] != m.FP2 && - keptAt[pm.Module] == "" && !slices.Contains(s.InOwn, pm.Module) { - if keptAt == nil { - keptAt = map[string]string{} - } - keptAt[pm.Module] = wanted[pm.Module] - } - } - runs := map[string]bool{} - for _, c := range s.Running { - if c.Module != "" { - runs[c.Module] = true - } - } - for _, pm := range p.Modules { - m, ok := modules[pm.Module] - if !ok || m.FP2 == "" || keptAt[pm.Module] != "" || slices.Contains(s.InOwn, pm.Module) { - // A module it runs inside its own composition is composed there as before: no build of the new form is what - // it runs (Scope.InOwn). - continue - } - if _, refused := in.Refused[pm.Module]; refused { - continue // held as its stub: no build of a refused manifest is taken in (ADR 0578 §3) - } - if man, ok := in.Catalogue[pm.Module]; !ok || (man.Build == nil && !man.Templated()) { - continue // nothing is built of it: its manifest is what it composes from - } - b, err := build(ctx, pm.Module, m.FP2) - if err != nil { - err = fmt.Errorf("frombus: the build of %s at %s: %w", pm.Module, m.FP2, err) - if refuse == nil { - return err - } - stub(pm.Module, err) - continue - } - if b == nil || b.State != delivery.BuildDone { - if refuse != nil && runs[pm.Module] { - // A module that runs is never composed from the indexer's manifest, which names no build's artifacts (no - // runtime's bundle): it is held, keeping what runs, until its build lands (issue 774, 2026-10-30 19:30: - // node-tools on ace, g14 and shanks, "its build produced 0 bundle(s)", and every module refused). - stub(pm.Module, fmt.Errorf("frombus: %s is wanted at %s, of which the builder holds no done build yet; it "+ - "keeps running what it runs", pm.Module, m.FP2)) - } - continue // nothing of it runs: composed from the indexer's manifest, as before - } - resolved, err := built(pm.Module, m.FP2, b) - if err != nil { - if refuse == nil { - return err - } - stub(pm.Module, err) - continue - } - in.Catalogue[pm.Module] = resolved - } - for i := range in.Nodes { - if in.Nodes[i].Name != s.Node { - continue - } - for _, module := range sortedKeys(keptAt) { - fp := keptAt[module] - b, err := build(ctx, module, fp) - switch { - case err != nil: - err = fmt.Errorf("frombus: the build of %s at %s: %w", module, fp, err) - case b == nil || b.State != delivery.BuildDone: - err = fmt.Errorf("frombus: %s is wanted at %s, of which the builder holds no done build", module, fp) - } - var resolved catalogue.Manifest - if err == nil { - resolved, err = built(module, fp, b) - } - if err != nil { - if refuse == nil { - return err - } - stub(module, err) - continue - } - if in.Nodes[i].Kept == nil { - in.Nodes[i].Kept = map[string]catalogue.Manifest{} - } - in.Nodes[i].Kept[module] = resolved - } - } - return nil -} - -// declaresOwnSecrets says a module assigned to node declares an own secret, in the manifest the node composes it from. -func declaresOwnSecrets(in compose.Input, node string) bool { - for _, n := range in.Nodes { - if n.Name != node { - continue - } - for _, module := range n.Assigned { - m, ok := n.Kept[module] - if !ok { - m = in.Catalogue[module] - } - if len(m.OwnSecrets) > 0 { - return true - } - } - } - return false -} - -// nodeOf is the composition's node from its record (what the machine observes) and its placement (what a person -// decided of it, and where its modules run). -func nodeOf(rec delivery.NodeRecord, p delivery.Placement) compose.Node { - n := compose.Node{ - Name: rec.Node, - Place: compose.Place{Key: rec.TunnelKey}, - SealingKey: rec.SealingKey, - OutwardLinks: rec.OutwardLinks, Networks: rec.Networks, NetworksSaid: rec.NetworksSaid, - ServingKey: rec.ServingKey, Certificate: rec.Certificate, Contract: rec.Contract, Stated: rec.Contract > 0, - } - placeNode(&n, p) - if len(rec.Capabilities) > 0 { - n.Capabilities = make(map[string]bool, len(rec.Capabilities)) - for _, c := range rec.Capabilities { - n.Capabilities[c] = true - } - } - return n -} - -// placeNode sets what a person decided of n and where its modules run, from its placement; what the machine observes -// (its keys, links, networks, certificate) and its settings layers are left as they are. -func placeNode(n *compose.Node, p delivery.Placement) { - d := p.Decided - n.Place = compose.Place{Key: n.Place.Key, Endpoint: d.Endpoint, Site: d.Site, Hub: d.Hub, Address: d.Address} - n.Assigned, n.PublicDomain = p.Assigned(), d.PublicDomain - n.Account, n.AccountHome, n.AgentAccount, n.AgentAccountHome = d.Account, d.AccountHome, d.AgentAccount, d.AgentHome - n.Adopted, n.Taken = d.Adopted, d.Taken - n.Pins, n.Bound, n.Ports = p.ChosenPins(), p.Bound(), p.HostPorts() -} - -// Placed is in with the placements given standing in for those of the nodes they name (issue 1126): each such node's -// decided parts, modules, pins, bindings, host ports, settings placement and holdings are the given placement's, its -// config values those in already carries for it (the layer of the node's own name), and what the machine observes is -// kept. A nil placement leaves the node in the mesh with nothing placed on it. A node in does not hold is refused: it has -// no record to compose it from. The found tunnel and the hub's peers are read from in as they are, not from the given -// placement. It is how a check composes a change's own placement over an input already filled (composer.Case's -// Placements). -func Placed(in compose.Input, placements map[string]*delivery.Placement) (compose.Input, error) { - if len(placements) == 0 { - return in, nil - } - out := in - out.Nodes = append([]compose.Node(nil), in.Nodes...) - index := map[string]int{} - for i, n := range out.Nodes { - index[n.Name] = i - } - for _, node := range sortedKeys(placements) { - p := placements[node] - i, ok := index[node] - if !ok { - return compose.Input{}, fmt.Errorf("frombus: a placement is given for %s, which the input holds no node for", node) - } - if p == nil { - p = &delivery.Placement{Node: node} - } - if p.Node != node { - return compose.Input{}, fmt.Errorf("frombus: the placement given for %s names the node %q", node, p.Node) - } - n := out.Nodes[i] - placeNode(&n, *p) - settings := map[string][]catalogue.Layer{} - for _, m := range p.Modules { - var values map[string]any - for _, l := range n.Settings[m.Module] { - if l.From == node { - values = l.Values - } - } - if layers := m.Layers(node, values); layers != nil { - settings[m.Module] = layers - } - } - n.Settings = nil - if len(settings) > 0 { - n.Settings = settings - } - out.Nodes[i] = n - } - out.Holdings = nil - for _, h := range in.Holdings { - if _, given := placements[h.Node]; !given { - out.Holdings = append(out.Holdings, h) - } - } - for _, node := range sortedKeys(placements) { - if p := placements[node]; p != nil { - out.Holdings = append(out.Holdings, p.Held()...) - } - } - return out, nil -} - -func tunnelOf(t delivery.RecordTunnel) compose.Tunnel { - out := compose.Tunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU, - Address: t.Address, Range: t.Range, PublicKey: t.PublicKey} - for _, p := range t.Peers { - out.Peers = append(out.Peers, compose.TunnelPeer{PublicKey: p.PublicKey, Address: p.Address}) - } - return out -} - -// carriedPeers is every peer of the hub's adopted tunnel, by address, each with the node that enrolled under its key -// where one has (a node whose tunnel key is the peer's) and the name a person gave it (inv.CarriedPeers). -func carriedPeers(t compose.Tunnel, names map[string]string, records map[string]delivery.NodeRecord) []compose.CarriedPeer { - byKey := map[string]string{} - for node, rec := range records { - if rec.TunnelKey != "" { - byKey[rec.TunnelKey] = node - } - } - var out []compose.CarriedPeer - for _, p := range t.Peers { - address, _, _ := strings.Cut(p.Address, "/") - out = append(out, compose.CarriedPeer{PublicKey: p.PublicKey, Address: address, EnrolledAs: byKey[p.PublicKey], Named: names[p.PublicKey]}) - } - sort.SliceStable(out, func(i, j int) bool { - a, errA := netip.ParseAddr(out[i].Address) - b, errB := netip.ParseAddr(out[j].Address) - if errA != nil || errB != nil { - return out[i].Address < out[j].Address - } - return a.Less(b) - }) - return out -} - -// nodesOf is every node named by a placement or a record, by name: the order the controller reads them in. -func nodesOf(placements map[string]delivery.Placement, records map[string]delivery.NodeRecord) []string { - seen := map[string]bool{} - for n := range placements { - seen[n] = true - } - for n := range records { - seen[n] = true - } - return sortedKeys(seen) -} - -func sortedKeys[V any](m map[string]V) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - sort.Strings(out) - return out -} - -// storeSought says, for a node that reaches no artifact store, what the composition holds of one: the nodes it holds and -// where each module serving the artifact store is placed, so the refusal names why (issue 1126). -func storeSought(in compose.Input) string { - var nodes, where []string - for _, n := range in.Nodes { - nodes = append(nodes, n.Name) - for _, a := range n.Assigned { - if m, ok := in.Catalogue[a]; ok { - if _, serves := m.Serves[catalogue.ArtifactStoreProvision]; serves { - where = append(where, a+" on "+n.Name) - } - } - } - } - sort.Strings(nodes) - sort.Strings(where) - said := "the composition holds " + strings.Join(nodes, ", ") - if len(where) == 0 { - return said + "; no module placed there serves " + catalogue.ArtifactStoreProvision - } - return said + "; " + catalogue.ArtifactStoreProvision + " is served by " + strings.Join(where, ", ") + - ", none of it on the private network with a port" -} - -// keptOf is the operator export's file (catalogue.KeptID) of a module's composition as it runs, or nil. -func keptOf(c delivery.Composed) map[string]any { - for _, raw := range c.Resources { - var r map[string]any - if json.Unmarshal(raw, &r) == nil && r["id"] == c.Module+"."+catalogue.KeptID() { - return r - } - } - return nil -} - -// membershipOf is the sealed value of the bus membership resource (catalogue.BusMembershipID) of a node's own -// composition, or "". -func membershipOf(own delivery.Composed) string { - for _, raw := range own.Resources { - var r struct { - ID string `json:"id"` - Sealed string `json:"sealed"` - } - if json.Unmarshal(raw, &r) == nil && r.ID == catalogue.BusMembershipID() { - return r.Sealed - } - } - return "" -} - -// DataItems is every data item the modules placed on scope's node declare, placed there, composed from the bus as Plans -// composes (novox/hq ADR 0571 clause 3): disk-load measures these. A module whose manifest the indexer refused is held -// and its items left out; DataItemsHeld names it, and a reader that must say what it leaves out reads that. -func DataItems(ctx context.Context, r Reader, mesh Mesh, s Scope) (map[string][]catalogue.PlacedDataItem, []string, error) { - items, unplaced, _, err := DataItemsHeld(ctx, r, mesh, s) - return items, unplaced, err -} - -// DataItemsHeld is DataItems and, by module, why each module placed on scope's node is held: its manifest refused by -// the indexer's judge or not decoded (compose.Input.Refused). One refused manifest never ends the read (novox/hq ADR -// 0578 §3: disk-load measured nothing for 45 hours when one did). -func DataItemsHeld(ctx context.Context, r Reader, mesh Mesh, s Scope) (items map[string][]catalogue.PlacedDataItem, unplaced []string, held map[string]string, err error) { - held = map[string]string{} - in, err := input(ctx, r, mesh, s, func(module string, err error) { held[module] = err.Error() }, nil) - if err != nil { - return nil, nil, held, err - } - // Held module by module, as PlansHeld holds them: a refused stub or one module's composition refusal (an item's - // template) holds that module and never ends the read. - items, err = heldOf(in, s.Node, held, func(in compose.Input) (map[string][]catalogue.PlacedDataItem, error) { - var err error - items, unplaced, err = compose.DataItems(withoutHeldData(in, s.Node), s.Node) - return items, err - }) - return items, unplaced, held, err -} - -// withoutHeldData is in with no data item of a module held on node: none of a held module's items is measured, so its -// data never refuses the rest (its stub keeps its data for the backups the plans compose). -func withoutHeldData(in compose.Input, node string) compose.Input { - out := in - out.Nodes = append([]compose.Node(nil), in.Nodes...) - for i := range out.Nodes { - n := &out.Nodes[i] - if n.Name != node || len(n.Held) == 0 { - continue - } - kept := maps.Clone(n.Kept) - for m := range n.Held { - if man, ok := kept[m]; ok { - man.Data = nil - kept[m] = man - } - } - n.Kept = kept - } - return out -} diff --git a/go/compose/frombus/frombus_test.go b/go/compose/frombus/frombus_test.go deleted file mode 100644 index 9ea9fdc..0000000 --- a/go/compose/frombus/frombus_test.go +++ /dev/null @@ -1,869 +0,0 @@ -package frombus - -import ( - "archive/tar" - "bytes" - "compress/gzip" - "context" - "crypto/ecdh" - "crypto/rand" - "encoding/base64" - "encoding/json" - "errors" - "fmt" - "os" - "reflect" - "slices" - "sort" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/compose" - "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/overlay" -) - -// bus is a Reader over values held in memory, counting whose config values were read. -type bus struct { - vaultAuthority string - handed map[string]bool - placements map[string]delivery.Placement - records map[string]delivery.NodeRecord - modules map[string]delivery.Module - manifests map[string][]byte - values map[string]map[string]any // by node.module - valuesRead []string - own []compose.OwnSecretRow - ownErr error - // asked is every consumer credential asked; the fake refuses the whole ask, as the vault's own does, for a consuming - // module its placement does not put on the node. - asked []compose.CredentialKey - // builds are the builder's keys, by " "; archives the artifact store's blobs, by digest, and fetched - // every URL asked; served another node's values by ".", and servedRead every ask with its keys. - builds map[string]*delivery.BuilderBuild - archives map[string][]byte - fetched []string - served map[string]map[string]any - servedRead []string - buildsRead []string - // held answers every credential asked as held, sealed to the consumer. - held bool -} - -func (b *bus) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) { - b.buildsRead = append(b.buildsRead, module) - return b.builds[module+" "+fp], nil -} - -func (b *bus) Archive(_ context.Context, url, digest string) ([]byte, error) { - b.fetched = append(b.fetched, url) - body, ok := b.archives[digest] - if !ok { - return nil, errors.New("the store holds no such blob") - } - return body, nil -} - -func (b *bus) Served(_ context.Context, node, module string, keys []string) (map[string]any, error) { - b.servedRead = append(b.servedRead, node+"."+module+":"+strings.Join(keys, ",")) - out := map[string]any{} - for _, k := range keys { - if v, ok := b.served[node+"."+module][k]; ok { - out[k] = v - } - } - return out, nil -} - -func (b *bus) Placements(context.Context) (map[string]delivery.Placement, error) { - return b.placements, nil -} -func (b *bus) VaultAuthority(context.Context) string { return b.vaultAuthority } -func (b *bus) Handed(context.Context, string) map[string]bool { return b.handed } -func (b *bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.records, nil } -func (b *bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.modules, nil } -func (b *bus) Manifest(_ context.Context, digest string) ([]byte, error) { - raw, ok := b.manifests[digest] - if !ok { - return nil, errors.New("no such manifest") - } - return raw, nil -} -func (b *bus) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) { - return b.own, b.ownErr -} - -func (b *bus) Credentials(_ context.Context, node string, asConsumer, asProvider []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) { - b.asked = append(b.asked, asConsumer...) - for _, k := range asConsumer { - placed := false - for _, m := range b.placements[node].Modules { - placed = placed || m.Module == k.ConsumerModule - } - if !placed { - return nil, nil, fmt.Errorf("%s is not on %s: neither assigned there nor required by a module there", k.ConsumerModule, node) - } - } - var out []compose.Credential - var issued []compose.Issued - for _, k := range append(append([]compose.CredentialKey{}, asConsumer...), asProvider...) { - if b.held { - out = append(out, compose.Credential{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule, - Provider: k.Provider, Local: k.Local, State: compose.CredentialHeld, Sealed: `"box"`}) - issued = append(issued, compose.Issued{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule, - Provider: k.Provider, Local: k.Local, ForProvider: `"box"`}) - } - } - return out, issued, nil -} - -func (b *bus) Values(_ context.Context, node, module string) (map[string]any, error) { - b.valuesRead = append(b.valuesRead, node+"."+module) - return b.values[node+"."+module], nil -} - -func publicKey(t *testing.T) string { - t.Helper() - k, err := ecdh.X25519().GenerateKey(rand.Reader) - if err != nil { - t.Fatal(err) - } - return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes()) -} - -// Issue 1126 (S3, S4, S5): the bus's placement, node records, manifests and config values fill the same compose.Input the -// controller's store fills for compose's own two-machine mesh (compose's aMesh: anchor the hub, laptop), and that Input -// composes; a node reads only its own config values; what cannot be composed is refused, naming it. -func TestInput(t *testing.T) { - // Issue 1126, the seed's shadow: every refusal named, an old-form module left to the node, and no store reached - // saying why (answer_rows_test.go). - t.Run("every refusal, and an old build left to the node", answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode) - t.Run("no store reached says why", noStoreReachedSaysWhy) - // Issue 774: a node refused whole says what it held on the way. - t.Run("a node refused whole says what it held", aNodeRefusedWholeSaysWhatItHeld) - t.Run("a refused manifest never ends the read", aRefusedManifestNeverEndsTheRead) - t.Run("the runtime is blamed only where its stub composes", theRuntimeIsBlamedOnlyWhereItsStubComposes) - t.Run("a running module waits for its build", aRunningModuleWaitsForItsBuild) - t.Run("a held module's first reason leads", aHeldModulesFirstReasonLeads) - t.Run("a held module of its own account never refuses the node", aHeldModuleOfItsOwnAccountNeverRefusesTheNode) - t.Run("another node not resolved is named", otherNodeNotResolvedIsNamed) - ctx := context.Background() - xterm, err := os.ReadFile("../../catalogue/testdata/catalogue-835bf22b-xterm.module.json") - if err != nil { - t.Fatal(err) - } - digest := delivery.ManifestDigest(xterm) - // A module in the settings form (plex and the media modules on one node, 2026-10-26): its config values are in its - // layers, not mesh-config, so the placement carries them (MeshValues, Values) and mesh-config's are not read for it. - media := []byte(`{"module":"media","version":"1","settings":{"lines":{"kind":"preference","default":0,"why":"kept"},` + - `"name":{"kind":"preference","default":"x","why":"shown"}},"own-secrets":{"token":"/srv/media/token"},"resources":[{"id":"conf","type":"file",` + - `"path":"/srv/media/conf","mode":"0644","content":"uid=${setting:lines} name=${setting:name}\n"}]}`) - mediaDigest := delivery.ManifestDigest(media) - keys := map[string]string{"anchor": publicKey(t), "laptop": publicKey(t)} - caps := []string{"container-runtime", "systemd", "wireguard"} - tunnel := &delivery.RecordTunnel{Interface: "wg0", PublicKey: keys["anchor"], Address: "10.77.0.1/24", - Peers: []delivery.RecordTunnelPeer{{PublicKey: "peer", Address: "10.77.0.9/32"}, {PublicKey: keys["laptop"], Address: "10.77.0.2/32"}}} - mk := func() *bus { - return &bus{ - placements: map[string]delivery.Placement{ - "anchor": {Node: "anchor", Decided: delivery.NodeDecided{Endpoint: "anchor.example:51820", Site: "here", Hub: true, Address: "10.77.0.1", - Tunnel: tunnel, PeerNames: map[string]string{"peer": "phone"}}, - Modules: []delivery.PlacedModule{{Module: overlay.Name}}, - Holds: []delivery.Holding{{Claim: "mesh-private-network", Scope: "mesh", Module: overlay.Name}}}, - "laptop": {Node: "laptop", Decided: delivery.NodeDecided{Endpoint: "laptop.example:51820", Site: "here", Address: "10.77.0.2"}, - Modules: []delivery.PlacedModule{{Module: overlay.Name}, - {Module: "xterm", HasLayer: true, Ports: map[string]int{"80": 8080}}, - {Module: "media", HasMesh: true, MeshValues: map[string]any{"name": "mesh"}, HasLayer: true, - Layer: map[string]any{"places": map[string]any{"data": "/srv/data"}}, Values: map[string]any{"lines": float64(1000)}}}}, - }, - records: map[string]delivery.NodeRecord{ - "anchor": {Node: "anchor", TunnelKey: keys["anchor"], Capabilities: caps, Contract: 2}, - "laptop": {Node: "laptop", TunnelKey: keys["laptop"], Capabilities: caps, Authority: "the mesh's certificate"}, - }, - modules: map[string]delivery.Module{"xterm": {Manifest: digest}, "media": {Manifest: mediaDigest}, "unread": {}}, - manifests: map[string][]byte{digest: xterm, mediaDigest: media}, - values: map[string]map[string]any{"laptop.xterm": {"font": "mono"}, "anchor.xterm": {"font": "serif"}, - "laptop.media": {"lines": "not mesh-config's"}}, - own: []compose.OwnSecretRow{{Node: "laptop", Module: "media", Name: "token", Origin: "made", SealedTo: "sk", Sealed: "{}"}}, - } - } - - // What the controller's store gives for the same mesh, as compose's aMesh and SettingsFor build it. - var private catalogue.Manifest - raw, _ := json.Marshal(overlay.Manifest()) - if err := json.Unmarshal(raw, &private); err != nil { - t.Fatal(err) - } - parsed, err := catalogue.Decode(xterm) - if err != nil { - t.Fatal(err) - } - parsedMedia, err := catalogue.Decode(media) - if err != nil { - t.Fatal(err) - } - capsSet := map[string]bool{"container-runtime": true, "systemd": true, "wireguard": true} - tun := compose.Tunnel{Interface: "wg0", PublicKey: keys["anchor"], Address: "10.77.0.1/24", - Peers: []compose.TunnelPeer{{PublicKey: "peer", Address: "10.77.0.9/32"}, {PublicKey: keys["laptop"], Address: "10.77.0.2/32"}}} - want := compose.Input{ - Catalogue: map[string]catalogue.Manifest{overlay.Name: private, "xterm": parsed, "media": parsedMedia}, - Nodes: []compose.Node{ - {Name: "anchor", Place: compose.Place{Key: keys["anchor"], Endpoint: "anchor.example:51820", Site: "here", Hub: true, - Address: "10.77.0.1"}, Assigned: []string{overlay.Name}, Capabilities: capsSet, Contract: 2, Stated: true}, - {Name: "laptop", Place: compose.Place{Key: keys["laptop"], Endpoint: "laptop.example:51820", Site: "here", - Address: "10.77.0.2"}, Assigned: []string{overlay.Name, "xterm", "media"}, Capabilities: capsSet, - Ports: map[string]map[int]int{"xterm": {80: 8080}}, - Settings: map[string][]catalogue.Layer{"xterm": {{From: "laptop", Values: map[string]any{"font": "mono"}}}, - "media": {{From: catalogue.MeshWideLayer, Values: map[string]any{"name": "mesh"}}, - {From: "laptop", Values: map[string]any{"lines": float64(1000)}, Placement: map[string]any{"places": map[string]any{"data": "/srv/data"}}}}}}, - }, - Holdings: []catalogue.Held{{Claim: "mesh-private-network", Scope: "mesh", Node: "anchor", Module: overlay.Name}}, - OwnSecrets: []compose.OwnSecretRow{{Node: "laptop", Module: "media", Name: "token", Origin: "made", SealedTo: "sk", Sealed: "{}"}}, - // The same value as a core credential's box, for a core module (ADR 0349 §16). - KeptCoreBoxes: []compose.CoreBox{{Node: "laptop", Module: "media", Name: "token", SealedTo: "sk", Box: json.RawMessage("{}")}}, - Tunnels: map[string]compose.FoundTunnel{"anchor": {Tunnel: tun}}, - Adopted: &compose.AdoptedTunnel{Tunnel: tun, Hub: "anchor", Adopted: true}, - // The controller's order is by address; laptop enrolled under its peer's key, and a person named the other. - Authority: "the mesh's certificate", - CarriedPeers: []compose.CarriedPeer{{PublicKey: keys["laptop"], Address: "10.77.0.2", EnrolledAs: "laptop"}, - {PublicKey: "peer", Address: "10.77.0.9", Named: "phone"}}, - } - - b := mk() - got, err := Input(ctx, b, Mesh{}, Scope{Node: "laptop"}) - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(got, want) { - gj, _ := json.MarshalIndent(got, "", " ") - wj, _ := json.MarshalIndent(want, "", " ") - t.Fatalf("Input from the bus differs from the store's:\n got %s\nwant %s", gj, wj) - } - // L3 s8 (ADR 0550 §2): with the vault's authority published, the mesh's authority is the bundle of the controller's - // and the vault's; with none, the controller's alone, as above. - signing := mk() - signing.vaultAuthority = "the vault's certificate" - if bundled, err := Input(ctx, signing, Mesh{}, Scope{Node: "laptop"}); err != nil || - bundled.Authority != "the mesh's certificate\nthe vault's certificate\n" { - t.Errorf("the authority with the vault's published: %q, %v", bundled.Authority, err) - } - // Issue 774: the modules the vault judged handed when it answered own are the node's to hand, and no other node's. - judged := mk() - judged.handed = map[string]bool{"media": true, "xterm": false} - if in, err := Input(ctx, judged, Mesh{}, Scope{Node: "laptop"}); err != nil || - !reflect.DeepEqual(in.Nodes[1].Handed, judged.handed) || in.Nodes[0].Handed != nil { - t.Errorf("the vault's handed verdicts: %+v, %v", in.Nodes, err) - } - if !reflect.DeepEqual(b.valuesRead, []string{"laptop." + overlay.Name, "laptop.xterm", "laptop.media"}) { - t.Errorf("laptop read config values %v; a node reads its own slice alone", b.valuesRead) - } - - // The same composer over both: the compositions from the bus are the store's, byte for byte. - fps := map[string]string{overlay.Name: "src2:" + strings.Repeat("a", 64), "xterm": "src2:" + strings.Repeat("b", 64), - "media": "src2:" + strings.Repeat("c", 64)} - fromBus, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop"}, fps) - if err == nil && len(held) > 0 { - err = fmt.Errorf("modules held: %v", held) - } - if err != nil { - t.Fatal(err) - } - fromStore, _, err := compose.Compositions(want, "laptop", fps, compose.Reading) - if err != nil { - t.Fatal(err) - } - if len(fromBus) == 0 || !reflect.DeepEqual(fromBus, fromStore) { - t.Fatalf("compositions differ or are empty: %d from the bus, %d from the store", len(fromBus), len(fromStore)) - } - - // Issue 1126: a candidate placement equal to the node's composes what it runs, and CompareComposed says so; one - // thing changed in the group's candidate of another node (the hub's endpoint) names exactly the module it moves - // here. Placed puts the same placement over the store's input unchanged. - running := make([]delivery.Composed, 0, len(fromBus)) - for _, c := range fromBus { - running = append(running, c) - } - laptop := mk().placements["laptop"] - asIs, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop}}, fps) - if err == nil && len(held) > 0 { - err = fmt.Errorf("modules held: %v", held) - } - if err != nil { - t.Fatal(err) - } - if c := delivery.CompareComposed(asIs, running); len(c.Differs)+len(c.Added)+len(c.Removed) != 0 || len(c.Same) != len(running) { - t.Errorf("the candidate equal to the placement: %+v", c) - } - moved := mk().placements["anchor"] - moved.Decided.Endpoint = "anchor.example:51821" - changed, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop, "anchor": &moved}}, fps) - if err == nil && len(held) > 0 { - err = fmt.Errorf("modules held: %v", held) - } - if err != nil { - t.Fatal(err) - } - if c := delivery.CompareComposed(changed, running); len(c.Differs) != 1 || c.Differs[0].Module != overlay.Name || c.Differs[0].Resource != overlay.Name+".overlay-config" { - t.Errorf("the hub's endpoint changed: %+v, want %s alone, naming its config", c, overlay.Name) - } - if c := delivery.CompareComposed(changed, running[:0]); len(c.Added) != len(changed) { - t.Errorf("nothing runs: %+v", c) - } - - // ADR 0559 §4, the node's answer: the same; one module differing; a module the group adds, whose own secret is not - // yet issued (no refusal); a value missing for a module the node runs (a refusal); a module the candidate removes. - keeper := []byte(`{"module":"keeper","version":"1","own-secrets":{"password":"/var/lib/mesh/keeper/password"},` + - `"resources":[{"id":"state","type":"directory","path":"/var/lib/mesh/keeper","mode":"0700"}]}`) - lemurs := []byte(`{"module":"lemurs","version":"1","capabilities":["systemd"],"resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`) - // A provider and a consumer of a pair credential on the one node (compose's objects and files). - objects, _ := json.Marshal(catalogue.Manifest{Module: "objects", Version: "1", - Provides: []catalogue.Offer{{Name: "s3-bucket", Scope: catalogue.ScopeMesh, Identity: &catalogue.OfferIdentity{Max: 20, In: "an S3 access key"}}}, - Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}}) - files, _ := json.Marshal(catalogue.Manifest{Module: "files", Version: "1", Requires: []string{"s3-bucket"}, - Resources: []map[string]any{{"id": "config", "type": "file", "path": "/etc/files/config.json", "mode": "0600", "content": "{}", "merge": "json"}}}) - one := func(placed ...string) *bus { - p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}} - for _, m := range placed { - p.Modules = append(p.Modules, delivery.PlacedModule{Module: m}) - } - return &bus{placements: map[string]delivery.Placement{"n1": p}, - records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: keys["anchor"], SealingKey: "sk", Capabilities: []string{"systemd"}}}, - modules: map[string]delivery.Module{"keeper": {Manifest: delivery.ManifestDigest(keeper)}, "lemurs": {Manifest: delivery.ManifestDigest(lemurs)}, - "objects": {Manifest: delivery.ManifestDigest(objects)}, "files": {Manifest: delivery.ManifestDigest(files)}}, - manifests: map[string][]byte{delivery.ManifestDigest(keeper): keeper, delivery.ManifestDigest(lemurs): lemurs, - delivery.ManifestDigest(objects): objects, delivery.ManifestDigest(files): files}, - own: []compose.OwnSecretRow{{Node: "n1", Module: "keeper", Name: "password", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}} - } - oneFps := map[string]string{"keeper": "src2:" + strings.Repeat("d", 64), "lemurs": "src2:" + strings.Repeat("e", 64), - "objects": "src2:" + strings.Repeat("f", 64), "files": "src2:" + strings.Repeat("0", 64)} - runs, _, held, err := Compose(ctx, one("keeper", "lemurs"), Mesh{}, Scope{Node: "n1"}, oneFps) - if err == nil && len(held) > 0 { - err = fmt.Errorf("modules held: %v", held) - } - if err != nil { - t.Fatal(err) - } - var runsNow []delivery.Composed - for _, c := range runs { - runsNow = append(runsNow, c) - } - objectsRun, _, held, err := Compose(ctx, one("objects"), Mesh{}, Scope{Node: "n1"}, oneFps) - if err == nil && len(held) > 0 { - err = fmt.Errorf("modules held: %v", held) - } - if err != nil { - t.Fatal(err) - } - var consumerRuns []delivery.Composed - for _, c := range objectsRun { - consumerRuns = append(consumerRuns, c) - } - withoutOwn := func(cs []delivery.Composed) []delivery.Composed { - var out []delivery.Composed - for _, c := range cs { - if c.Module != "" { - out = append(out, c) - } - } - return out - } - // What the controller runs on n1 for a module no want names (lemurs, at a build of the old form) and the private - // network's (built by nobody): its composed writer (mesh-controller compositionsOf) folds their resources into the - // node's own, each in its place, its module taken off; the rest split by module. - tunnelled := func() *bus { - b := one("keeper", "lemurs", overlay.Name) - p := b.placements["n1"] - p.Decided.Endpoint = "n1.example:51820" - b.placements["n1"] = p - return b - } - tunnelledAs := func() map[string]*delivery.Placement { - p := tunnelled().placements["n1"] - return map[string]*delivery.Placement{"n1": &p} - } - folded := func() []delivery.Composed { - b := tunnelled() - in, err := Input(ctx, b, Mesh{}, Scope{Node: "n1"}) - if err != nil { - t.Fatal(err) - } - reading := in - reading.Nodes = append([]compose.Node(nil), in.Nodes...) - reading.Nodes[0].ReadsModules = true - d, _, err := compose.ComposeIn(reading, "n1", compose.Reading) - if err != nil { - t.Fatal(err) - } - body, err := d.Body() - if err != nil { - t.Fatal(err) - } - var envelope struct { - Resources []map[string]json.RawMessage `json:"resources"` - } - if err := json.Unmarshal(body, &envelope); err != nil { - t.Fatal(err) - } - var resources []json.RawMessage - for _, r := range envelope.Resources { - var module string - _ = json.Unmarshal(r["module"], &module) - if module == "lemurs" || module == overlay.Name { - delete(r, "module") - } - raw, _ := json.Marshal(r) - resources = append(resources, raw) - } - split, err := delivery.SplitComposed("n1", resources, map[string]string{"keeper": oneFps["keeper"]}, nil, delivery.NodeOwn{}) - if err != nil { - t.Fatal(err) - } - var out []delivery.Composed - for _, c := range split { - out = append(out, c) - } - return out - }() - if got := InOwn(folded, tunnelled().placements["n1"]); !reflect.DeepEqual(got, []string{"lemurs", overlay.Name}) { - t.Errorf("InOwn of the controller's composition = %v, want lemurs and %s", got, overlay.Name) - } - placedAs := func(modules ...string) map[string]*delivery.Placement { - p := one(modules...).placements["n1"] - return map[string]*delivery.Placement{"n1": &p} - } - for name, c := range map[string]struct { - b *bus - given map[string]*delivery.Placement - running []delivery.Composed - verdict string - check func(delivery.CandidateAnswer) bool - }{ - "the same": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), runsNow, delivery.CandidateSame, - func(a delivery.CandidateAnswer) bool { - return len(a.Same) == 3 && a.Proposal == "p-h1" && a.Revision == 7 - }}, - "the node runs no own composition": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), withoutOwn(runsNow), delivery.CandidateSame, - func(a delivery.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }}, - "one removed": {one("keeper", "lemurs"), placedAs("keeper"), runsNow, delivery.CandidateDiffers, - func(a delivery.CandidateAnswer) bool { return reflect.DeepEqual(a.Removed, []string{"lemurs"}) }}, - "one added, its secret not yet issued": {func() *bus { b := one("lemurs"); b.own = nil; return b }(), placedAs("keeper", "lemurs"), - nil, delivery.CandidateDiffers, func(a delivery.CandidateAnswer) bool { - for _, m := range a.Added { - if m.Module == "keeper" { - return reflect.DeepEqual(m.Unissued, []string{"keeper/password"}) && len(a.Refusals) == 0 - } - } - return false - }}, - "a value missing for a module that runs": {func() *bus { b := one("keeper", "lemurs"); b.own = nil; return b }(), placedAs("keeper", "lemurs"), - runsNow, delivery.CandidateRefused, func(a delivery.CandidateAnswer) bool { - return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && strings.Contains(a.Refusals[0].Why, "keeper/password") - }}, - // Review of L2b, round 1, finding 1: a consumer the candidate adds has no credential yet, and the vault refuses an - // ask for a module not on the node; it is not asked, and the module is added, its credential not yet issued. - "a consumer added, the vault not asked for it": {one("objects"), placedAs("objects", "files"), consumerRuns, delivery.CandidateDiffers, - func(a delivery.CandidateAnswer) bool { - return len(a.Added) == 1 && a.Added[0].Module == "files" && len(a.Added[0].Unissued) > 0 && len(a.Refusals) == 0 - }}, - // And with no trunk at all (the seed), a stand-in of a module the node runs is a refusal, not an addition. - "the seed, a value missing for a module that runs": {func() *bus { b := one(); b.own = nil; return b }(), placedAs("keeper", "lemurs"), - runsNow, delivery.CandidateRefused, func(a delivery.CandidateAnswer) bool { - return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && len(a.Added) == 0 - }}, - "cannot compose": {one("keeper"), map[string]*delivery.Placement{"n1": {Node: "n2"}}, runsNow, delivery.CandidateRefused, - func(a delivery.CandidateAnswer) bool { return a.Refused != "" }}, - // Issue 1126 (the seed's review): the private network's module and one at a build of the old form, which no want - // names and the controller folds into the node's own, are folded there again: the node's own is the same, and - // neither is added. - "mesh-wireguard and an old-form module, folded": {tunnelled(), tunnelledAs(), - folded, delivery.CandidateSame, func(a delivery.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }}, - } { - fps := oneFps - if name == "mesh-wireguard and an old-form module, folded" { - fps = map[string]string{"keeper": oneFps["keeper"]} // the node's wants: no build of lemurs or the private network - } - a := Answer(ctx, c.b, Mesh{}, Scope{Node: "n1", Placements: c.given, InOwn: InOwn(c.running, *c.given["n1"])}, fps, c.running, "p-h1", 7) - if a.Verdict != c.verdict || !c.check(a) { - t.Errorf("%s: %+v", name, a) - } - if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": a}}); err != nil { - t.Errorf("%s: the answer is not one a have holds: %v (%s)", name, err, raw) - } - } - if got, err := Placed(want, map[string]*delivery.Placement{"laptop": &laptop}); err != nil || !reflect.DeepEqual(got, want) { - t.Errorf("Placed with the node's own placement changes the input: %v", err) - } - if got, err := Placed(want, map[string]*delivery.Placement{"laptop": nil}); err != nil || len(got.Nodes[1].Assigned) != 0 || got.Nodes[1].Place.Key != keys["laptop"] { - t.Errorf("Placed with no placement: %+v, %v", got.Nodes[1], err) - } - if _, err := Placed(want, map[string]*delivery.Placement{"stranger": {Node: "stranger"}}); err == nil { - t.Error("Placed a node the input holds no node for") - } - if _, err := Input(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": {Node: "anchor"}}}); err == nil { - t.Error("a given placement naming another node was taken") - } - - // ADR 0555 §6: Export is Input's inverse for what a person decides: the store's input exported, node by node, is the - // placement the bus holds, and read back it is the store's input again. - for node, placed := range mk().placements { - got, err := Export(want, node) - if err != nil { - t.Fatal(err) - } - if gj, _ := delivery.EncodePlacement(got); !delivery.SamePlacement(got, placed) { - pj, _ := delivery.EncodePlacement(placed) - t.Errorf("%s exported as %s, want %s", node, gj, pj) - } - } - if _, err := Export(want, "stranger"); err == nil { - t.Error("a node the input lacks was exported") - } - - b = mk() - if every, err := Input(ctx, b, Mesh{}, Scope{Every: true}); err != nil || every.OwnSecrets != nil { - t.Fatalf("composing every node: %v, own secrets %v (the vault answers a node about itself alone)", err, every.OwnSecrets) - } - if !reflect.DeepEqual(b.valuesRead, []string{"anchor." + overlay.Name, "laptop." + overlay.Name, "laptop.xterm", "laptop.media"}) { - // Every node's placed modules, and nothing placed nowhere: anchor places no xterm. - t.Errorf("the check read %v", b.valuesRead) - } - - // A node none of whose modules declares an own secret composes without asking the vault (the review of L2: a node - // with no secret composed nothing while the vault did not answer); one whose module does, refuses without it. - quiet := mk() - quiet.ownErr = errors.New("no responders") - if _, err := Input(ctx, quiet, Mesh{}, Scope{Node: "anchor"}); err != nil { - t.Errorf("anchor, with no own secret, needed the vault: %v", err) - } - if _, err := Input(ctx, quiet, Mesh{}, Scope{Node: "laptop"}); err == nil { - t.Error("laptop's media, which declares an own secret, composed without the vault") - } - - // A module the node's want keeps at another build than the trunk's composes from that build's manifest, resolved with - // what it made (issue 1126), on the composing node alone. - kept := mk() - oldXterm := []byte(`{"module":"xterm","version":"1","resources":[{"id":"tools","type":"process","name":"xterm-tools",` + - `"artifact":"tools","run":["./xterm-tools"]}],"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/xterm-tools","binary":"xterm-tools"}]}}`) - kept.manifests["old"] = oldXterm - oldFP := "src2:" + strings.Repeat("9", 64) - toolsRef := "artifact-store://xterm/tools/blobs/sha256:" + strings.Repeat("1", 64) - kept.builds = map[string]*delivery.BuilderBuild{"xterm " + oldFP: {State: delivery.BuildDone, Manifest: "old", - Artifacts: []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: toolsRef, Digest: "sha256:" + strings.Repeat("1", 64)}}}} - parsedOld, err := catalogue.Decode(oldXterm) - if err != nil { - t.Fatal(err) - } - keptBuilt, err := parsedOld.Resolve([]catalogue.Built{{Name: "tools", Kind: "bundle", Reference: toolsRef, Digest: "sha256:" + strings.Repeat("1", 64)}}) - if err != nil { - t.Fatal(err) - } - in, err := Input(ctx, kept, Mesh{}, Scope{Node: "laptop", KeptAt: map[string]string{"xterm": oldFP}}) - if err != nil || !reflect.DeepEqual(in.Nodes[1].Kept, map[string]catalogue.Manifest{"xterm": keptBuilt}) || in.Nodes[0].Kept != nil { - t.Errorf("the want's build is kept on the composing node alone: %v, %v", in.Nodes, err) - } - - // Issue 1126, the seed's refusals. restic and the provisioner's client as the catalogue holds them (mesh-catalog - // c05aa5e6), restic moved with an own secret the vault leaves out (it hands it to the client, mesh-vault ownnode.go - // handed): composed from the indexer's manifest restic is not handed and stands in for that secret, as novox's, - // ace's, g14's and shanks's answers refused; composed from its build, it is handed, its templates archive read - // where the node reaches the artifact store, and the composition is the one the controller's take-in gives. - resticRaw, err := os.ReadFile("testdata/catalogue-c05aa5e6-restic.module.json") - if err != nil { - t.Fatal(err) - } - clientRaw, err := os.ReadFile("testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json") - if err != nil { - t.Fatal(err) - } - store := []byte(`{"module":"store","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],` + - `"serves":{"artifact-store":{"port":5000}}}`) - texts := map[string]string{} - var packed bytes.Buffer - zw := gzip.NewWriter(&packed) - tw := tar.NewWriter(zw) - for _, f := range []string{"backups.conf", "data.conf", "retention.conf"} { - text, err := os.ReadFile("testdata/catalogue-c05aa5e6-restic.files/" + f) - if err != nil { - t.Fatal(err) - } - texts[f] = string(text) - if err := tw.WriteHeader(&tar.Header{Name: "./" + f, Mode: 0o644, Size: int64(len(text)), Typeflag: tar.TypeReg}); err != nil { - t.Fatal(err) - } - if _, err := tw.Write(text); err != nil { - t.Fatal(err) - } - } - if err := tw.Close(); err != nil { - t.Fatal(err) - } - if err := zw.Close(); err != nil { - t.Fatal(err) - } - archiveDigest := delivery.ManifestDigest(packed.Bytes()) - digestOf := func(c string) string { return "sha256:" + strings.Repeat(c, 64) } - movedFps := map[string]string{"restic": "src2:" + strings.Repeat("3", 64), "mesh-provisioner-client": "src2:" + strings.Repeat("4", 64), - "store": "src2:" + strings.Repeat("5", 64)} - resticArtifacts := []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: "artifact-store://restic/tools/blobs/" + digestOf("6"), Digest: digestOf("6")}, - {Name: "files", Kind: "archive", Reference: "artifact-store://restic/files/blobs/" + archiveDigest, Digest: archiveDigest}} - clientArtifacts := []delivery.BuiltArtifact{{Name: "client", Kind: "bundle", Reference: "artifact-store://mesh-provisioner-client/client/blobs/" + digestOf("7"), Digest: digestOf("7")}} - movedMesh := func(built bool) *bus { - b := one() - p := b.placements["n1"] - p.Modules = []delivery.PlacedModule{{Module: "store"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}} - p.Decided.Account, p.Decided.AccountHome = "operator", "/home/operator" - b.placements["n1"] = p - rec := b.records["n1"] - rec.Contract, rec.Capabilities = compose.HandedContract, []string{"systemd", "package-manager"} - b.records["n1"] = rec - b.modules = map[string]delivery.Module{} - b.manifests = map[string][]byte{} - for module, raw := range map[string][]byte{"restic": resticRaw, "mesh-provisioner-client": clientRaw, "store": store} { - b.modules[module] = delivery.Module{FP2: movedFps[module], Manifest: delivery.ManifestDigest(raw)} - b.manifests[delivery.ManifestDigest(raw)] = raw - } - // The vault's own answer: the client's own secret; restic's is the client's to ask, so own leaves it out. - b.own = []compose.OwnSecretRow{{Node: "n1", Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}} - if built { - b.builds = map[string]*delivery.BuilderBuild{ - "restic " + movedFps["restic"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(resticRaw), Artifacts: resticArtifacts}, - "mesh-provisioner-client " + movedFps["mesh-provisioner-client"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(clientRaw), Artifacts: clientArtifacts}, - "store " + movedFps["store"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(store)}, - } - b.archives = map[string][]byte{archiveDigest: packed.Bytes()} - } - return b - } - if out, _, held, err := Compose(ctx, movedMesh(false), Mesh{}, Scope{Node: "n1"}, movedFps); err != nil || !strings.Contains(held["restic"], `restic needs a secret called "repository"`) { - t.Errorf("restic composed from the indexer's manifest: %v %v, want its own secret refusing restic alone", err, held) - } else { - for key, c := range out { - if c.Module == "restic" { - // Its stub's composition, applied, would remove what restic runs: a held module keeps its last one. - t.Errorf("held restic is composed as %s, want none of its own composition handed", key) - } - } - } - builtBus := movedMesh(true) - fromBuild, _, held, err := Compose(ctx, builtBus, Mesh{}, Scope{Node: "n1"}, movedFps) - if err == nil && len(held) > 0 { - err = fmt.Errorf("modules held: %v", held) - } - if err != nil { - t.Fatalf("restic composed from its build: %v", err) - } - if !reflect.DeepEqual(builtBus.fetched, []string{"http://127.0.0.1:5000/v2/restic/files/blobs/" + archiveDigest}) { - t.Errorf("the templates archive was read at %v", builtBus.fetched) - } - resticKey, _ := delivery.ComposedKey("n1", "restic", movedFps["restic"]) - if c := fromBuild[resticKey]; len(c.Filled) != 4 { // its three templated files and its own secret - t.Errorf("restic is not handed to the provisioner: its filled files are %v", c.Filled) - } - // A done key an artifact of which names no reference is waited for, in the controller's words (ADR 0498 §8). - unreferenced := movedMesh(true) - unreferenced.builds["restic "+movedFps["restic"]].Artifacts = []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Digest: digestOf("6")}, resticArtifacts[1]} - if _, _, held, err := Compose(ctx, unreferenced, Mesh{}, Scope{Node: "n1"}, movedFps); err != nil || !strings.Contains(held["restic"], "the build of restic is done and waits for the reference of tools") { - t.Errorf("a done key with an unreferenced artifact: %v %v, want restic held alone", err, held) - } - // A module the node runs inside its own composition (the fold) composes from the indexer's manifest though a done - // build of it is at the trunk's FP2: its build is not read. - folding := movedMesh(true) - foldIn, err := Input(ctx, folding, Mesh{}, Scope{Node: "n1", InOwn: []string{"mesh-provisioner-client"}}) - if err != nil { - t.Fatal(err) - } - clientParsed, err := catalogue.Decode(clientRaw) - if err != nil { - t.Fatal(err) - } - if slices.Contains(folding.buildsRead, "mesh-provisioner-client") || !reflect.DeepEqual(foldIn.Catalogue["mesh-provisioner-client"], clientParsed) { - t.Errorf("a folded module was taken from its build: builds read %v", folding.buildsRead) - } - // The controller's: its take-in resolves each manifest with the key's artifacts and the archive's texts, and composes - // from those over the same input. - storeIn, err := Input(ctx, movedMesh(false), Mesh{}, Scope{Node: "n1"}) - if err != nil { - t.Fatal(err) - } - for module, built := range map[string][]catalogue.Built{ - "restic": {{Name: "tools", Kind: "bundle", Reference: resticArtifacts[0].Reference, Digest: digestOf("6")}, - {Name: "files", Kind: "archive", Reference: resticArtifacts[1].Reference, Digest: archiveDigest, Texts: texts}}, - "mesh-provisioner-client": {{Name: "client", Kind: "bundle", Reference: clientArtifacts[0].Reference, Digest: digestOf("7")}}, - } { - resolved, err := storeIn.Catalogue[module].Resolve(built) - if err != nil { - t.Fatal(err) - } - // Kept as its bytes and read again, as the controller's store holds a build's manifest. - raw, _ := json.Marshal(resolved) - if storeIn.Catalogue[module], err = catalogue.Decode(raw); err != nil { - t.Fatal(err) - } - } - storeIn.OwnSecrets = movedMesh(true).own - fromStoreMoved, _, err := compose.Compositions(storeIn, "n1", movedFps, compose.Reading) - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(fromBuild, fromStoreMoved) { - gj, _ := json.MarshalIndent(fromBuild, "", " ") - wj, _ := json.MarshalIndent(fromStoreMoved, "", " ") - t.Errorf("composed from the build differs from the controller's take-in:\n got %s\nwant %s", gj, wj) - } - // ADR 0570 clause 2 (issue 1954 L6-P1): the seat mesh-provisioner's plans, composed from the bus alone and with no - // secret read (the Planning mode), are the controller's handedPlans over its own input, which holds its secrets - // (compose_sdk.go: Resolve, Render in Foreseeing, Generators, Plan.Compose), and restic, handed, has one. - noSecrets := movedMesh(true) - noSecrets.own = nil - seat, err := Plans(ctx, noSecrets, Mesh{}, Scope{Node: "n1"}) - if err != nil { - t.Fatalf("the plans from the bus: %v", err) - } - controllers, err := plansIn(storeIn, "n1", compose.Foreseeing) - if err != nil { - t.Fatal(err) - } - if _, handed := seat["restic"]; !handed || !reflect.DeepEqual(seat, controllers) { - gj, _ := json.MarshalIndent(seat, "", " ") - wj, _ := json.MarshalIndent(controllers, "", " ") - t.Errorf("the seat's plans differ from the controller's:\n got %s\nwant %s", gj, wj) - } - // ADR 0571 clause 3: the data items of n1's modules, placed, from the bus alone: what disk-load measures. - items, unplaced, err := DataItems(ctx, noSecrets, Mesh{}, Scope{Node: "n1"}) - if err != nil || len(unplaced) > 0 || len(items) == 0 { - t.Errorf("the data items from the bus are %v, unplaced %v: %v", items, unplaced, err) - } - for module, its := range items { - for _, it := range its { - if it.Module != module || !strings.HasPrefix(it.Path, "/") || it.Measure == "" { - t.Errorf("%s's item %+v is not placed", module, it) - } - } - } - // And the same through StatesReader, over the fake's states as a module's runtime gives them (one reader for - // mesh-provisioner and disk-load, the lead 2026-10-29). - viaStates, _, err := DataItems(ctx, StatesReader(statesOf(t, ctx, noSecrets), func(_ context.Context, url string) ([]byte, error) { - for _, body := range noSecrets.archives { - return body, nil - } - return nil, errors.New("no archive") - }), Mesh{}, Scope{Node: "n1"}) - if err != nil || !reflect.DeepEqual(viaStates, items) { - t.Errorf("through the states the data items are %v, %v; through the reader %v", viaStates, err, items) - } - - // And what a provider on another node serves from its settings (mailu's smtp domain, issue 1126: shanks refused - // '${setting:domain} ... nothing sets "domain"'): read for that provider's served keys alone, through Served. - mailer := []byte(`{"module":"mailer","version":"1","config":{"domain":{"type":"string","description":"the mail domain","required":true}},` + - `"provides":[{"name":"smtp","scope":"mesh"}],"serves":{"smtp":{"port":587,"domain":"${config:domain}"}}}`) - withMailer := func(set bool) *bus { - b := mk() - p := b.placements["anchor"] - p.Modules = append(p.Modules, delivery.PlacedModule{Module: "mailer"}) - b.placements["anchor"] = p - b.modules["mailer"] = delivery.Module{Manifest: delivery.ManifestDigest(mailer)} - b.manifests[delivery.ManifestDigest(mailer)] = mailer - if set { - b.served = map[string]map[string]any{"anchor.mailer": {"domain": "example.org"}} - } - return b - } - if _, _, _, err := Compose(ctx, withMailer(false), Mesh{}, Scope{Node: "laptop"}, fps); err == nil || !strings.Contains(err.Error(), `nothing sets "domain"`) { - t.Errorf("a provider's served setting nobody sets: %v", err) - } - served := withMailer(true) - if _, _, held, err := Compose(ctx, served, Mesh{}, Scope{Node: "laptop"}, fps); err != nil || len(held) > 0 { - t.Errorf("laptop with what anchor's mailer serves: %v %v", err, held) - } - if !reflect.DeepEqual(served.servedRead, []string{"anchor.mailer:domain"}) || slices.Contains(served.valuesRead, "anchor.mailer") { - t.Errorf("laptop read %v of other nodes (and %v of config values): the served keys of anchor's mailer alone", served.servedRead, served.valuesRead) - } - - for name, c := range map[string]struct { - change func(*bus) - scope Scope - says string - }{ - "no record": {func(b *bus) { delete(b.records, "laptop") }, Scope{Node: "anchor"}, "laptop, whose agent has written no record"}, - "no placement": {func(b *bus) {}, Scope{Node: "stranger"}, "stranger has no placement"}, - "a manifest unread": {func(b *bus) { delete(b.manifests, digest) }, Scope{Node: "laptop"}, "the manifest of xterm"}, - "a kept one unbuilt": {func(b *bus) {}, Scope{Node: "laptop", KeptAt: map[string]string{"xterm": "src2:gone"}}, "holds no done build"}, - } { - b := mk() - c.change(b) - if _, err := Input(ctx, b, Mesh{}, c.scope); err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: %v, want it to say %q", name, err, c.says) - } - } -} - -// mapStates are read states by name, key to value, as a module's runtime gives them. -type mapStates map[string]map[string][]byte - -func (m mapStates) Get(state, key string) ([]byte, bool, error) { - v, ok := m[state][key] - return v, ok, nil -} - -func (m mapStates) Keys(state string) ([]string, error) { - var out []string - for k := range m[state] { - out = append(out, k) - } - sort.Strings(out) - return out, nil -} - -// statesOf is b laid out as the states StatesReader reads. -func statesOf(t *testing.T, ctx context.Context, b *bus) mapStates { - t.Helper() - out := mapStates{} - put := func(state, key string, raw []byte, err error) { - if err != nil { - t.Fatal(err) - } - if out[state] == nil { - out[state] = map[string][]byte{} - } - out[state][key] = raw - } - placements, _ := b.Placements(ctx) - for _, p := range placements { - values, err := delivery.PlacementValues(p) - for k, raw := range values { - put(PlacementRead, k, raw, err) - } - } - records, _ := b.Records(ctx) - for node, r := range records { - k, _ := delivery.HaveNodeKey(node) - rec := r - raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec}) - put(HaveRead, k, raw, err) - } - modules, _ := b.Modules(ctx) - for name, m := range modules { - raw, err := delivery.EncodeModule(m) - put(ModulesRead, name, raw, err) - var mf struct{ Module string } - _ = json.Unmarshal(b.manifests[m.Manifest], &mf) - k, _ := delivery.ManifestsKey(m.Manifest) - raw, err = delivery.EncodeManifest(delivery.Manifest{Module: mf.Module, JSON: string(b.manifests[m.Manifest])}) - put(ManifestsRead, k, raw, err) - } - for key, build := range b.builds { - module, fp, _ := strings.Cut(key, " ") - k, _ := delivery.BuilderBuildKey(module, fp) - raw, err := delivery.EncodeBuilderBuild(*build) - put(BuildsRead, k, raw, err) - } - for nm, values := range b.values { - for k, v := range values { - value, _ := json.Marshal(v) - raw, err := json.Marshal(map[string]any{"value": json.RawMessage(value), "source": catalogue.SourceModule}) - put(ResolvedRead, nm+"."+k, raw, err) - } - } - return out -} diff --git a/go/compose/frombus/frombustest/frombustest.go b/go/compose/frombus/frombustest/frombustest.go deleted file mode 100644 index 3bfc971..0000000 --- a/go/compose/frombus/frombustest/frombustest.go +++ /dev/null @@ -1,183 +0,0 @@ -// Package frombustest is a bus in memory for frombus's Reader, and a mesh on it whose composition hands a module to the -// provisioner with a provision to make, so a module that composes plans from the bus (mesh-provisioner, novox/hq ADR -// 0570 clause 2) tests against a real plan rather than one written by hand. The manifests are the catalogue's (mesh-catalog -// c05aa5e6: restic and the provisioner's client) as frombus's own tests read them, restic given one requirement more. -package frombustest - -import ( - "archive/tar" - "bytes" - "compress/gzip" - "context" - "embed" - "encoding/json" - "errors" - "strings" - - "git.novox.be/novox/mesh-sdk/go/compose" - "git.novox.be/novox/mesh-sdk/go/compose/frombus" - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -//go:embed testdata -var testdata embed.FS - -// Bus is a frombus.Reader over values held in memory. A credential asked is answered held when Held is set. -type Bus struct { - Placement map[string]delivery.Placement - Record map[string]delivery.NodeRecord - Module map[string]delivery.Module - Manifests map[string][]byte - Value map[string]map[string]any // by node.module - Own []compose.OwnSecretRow - Builds map[string]*delivery.BuilderBuild // by " " - Archives map[string][]byte // by digest - Serves map[string]map[string]any // another node's values, by node.module - Held bool -} - -var _ frombus.Reader = (*Bus)(nil) - -func (b *Bus) Placements(context.Context) (map[string]delivery.Placement, error) { - return b.Placement, nil -} -func (b *Bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.Record, nil } -func (b *Bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.Module, nil } -func (b *Bus) VaultAuthority(context.Context) string { return "" } - -func (b *Bus) Manifest(_ context.Context, digest string) ([]byte, error) { - raw, ok := b.Manifests[digest] - if !ok { - return nil, errors.New("no such manifest") - } - return raw, nil -} - -func (b *Bus) Values(_ context.Context, node, module string) (map[string]any, error) { - return b.Value[node+"."+module], nil -} - -func (b *Bus) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) { return b.Own, nil } - -func (b *Bus) Credentials(_ context.Context, _ string, asConsumer, asProvider []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) { - var out []compose.Credential - var issued []compose.Issued - for _, k := range append(append([]compose.CredentialKey{}, asConsumer...), asProvider...) { - if b.Held { - out = append(out, compose.Credential{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule, - Provider: k.Provider, Local: k.Local, State: compose.CredentialHeld, Sealed: `"box"`}) - issued = append(issued, compose.Issued{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule, - Provider: k.Provider, Local: k.Local, ForProvider: `"box"`}) - } - } - return out, issued, nil -} - -func (b *Bus) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) { - return b.Builds[module+" "+fp], nil -} - -func (b *Bus) Archive(_ context.Context, _, digest string) ([]byte, error) { - body, ok := b.Archives[digest] - if !ok { - return nil, errors.New("the store holds no such blob") - } - return body, nil -} - -func (b *Bus) Served(_ context.Context, node, module string, keys []string) (map[string]any, error) { - out := map[string]any{} - for _, k := range keys { - if v, ok := b.Serves[node+"."+module][k]; ok { - out[k] = v - } - } - return out, nil -} - -// Node is the one node of the mesh Provisioned composes. -const Node = "n1" - -// Provisioned is one node, n1, that runs the artifact store, a postgres providing postgres-database, the provisioner's -// client and restic: restic built and handed to the provisioner, and requiring postgres-database. Its plans (frombus.Plans -// with Scope{Node: Node}) hand restic, whose plan names that provision with postgres on n1 as its provider. -func Provisioned() (*Bus, error) { - read := func(name string) ([]byte, error) { return testdata.ReadFile("testdata/" + name) } - resticRaw, err := read("catalogue-c05aa5e6-restic.module.json") - if err != nil { - return nil, err - } - var restic map[string]any - if err := json.Unmarshal(resticRaw, &restic); err != nil { - return nil, err - } - restic["requires"] = []string{"postgres-database"} - if resticRaw, err = json.Marshal(restic); err != nil { - return nil, err - } - clientRaw, err := read("catalogue-c05aa5e6-mesh-provisioner-client.module.json") - if err != nil { - return nil, err - } - store := []byte(`{"module":"store","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],` + - `"serves":{"artifact-store":{"port":5000}}}`) - postgres := []byte(`{"module":"postgres","version":"1","provides":[{"name":"postgres-database","scope":"mesh"}],` + - `"serves":{"postgres-database":{"port":5432}}}`) - - var packed bytes.Buffer - zw := gzip.NewWriter(&packed) - tw := tar.NewWriter(zw) - for _, f := range []string{"backups.conf", "data.conf", "retention.conf"} { - text, err := read("catalogue-c05aa5e6-restic.files/" + f) - if err != nil { - return nil, err - } - if err := tw.WriteHeader(&tar.Header{Name: "./" + f, Mode: 0o644, Size: int64(len(text)), Typeflag: tar.TypeReg}); err != nil { - return nil, err - } - if _, err := tw.Write(text); err != nil { - return nil, err - } - } - if err := tw.Close(); err != nil { - return nil, err - } - if err := zw.Close(); err != nil { - return nil, err - } - archive := delivery.ManifestDigest(packed.Bytes()) - digestOf := func(c string) string { return "sha256:" + strings.Repeat(c, 64) } - fps := FPs() - b := &Bus{ - Placement: map[string]delivery.Placement{Node: {Node: Node, - Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Account: "operator", AccountHome: "/home/operator"}, - Modules: []delivery.PlacedModule{{Module: "store"}, {Module: "postgres"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}}}, - Record: map[string]delivery.NodeRecord{Node: {Node: Node, TunnelKey: "dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleXRlc3Q=", - SealingKey: "sk", Contract: compose.HandedContract, Capabilities: []string{"systemd", "package-manager"}}}, - Module: map[string]delivery.Module{}, Manifests: map[string][]byte{}, - Own: []compose.OwnSecretRow{{Node: Node, Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}, - Builds: map[string]*delivery.BuilderBuild{ - "restic " + fps["restic"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(resticRaw), Artifacts: []delivery.BuiltArtifact{ - {Name: "tools", Kind: "bundle", Reference: "artifact-store://restic/tools/blobs/" + digestOf("6"), Digest: digestOf("6")}, - {Name: "files", Kind: "archive", Reference: "artifact-store://restic/files/blobs/" + archive, Digest: archive}}}, - "mesh-provisioner-client " + fps["mesh-provisioner-client"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(clientRaw), - Artifacts: []delivery.BuiltArtifact{{Name: "client", Kind: "bundle", - Reference: "artifact-store://mesh-provisioner-client/client/blobs/" + digestOf("7"), Digest: digestOf("7")}}}, - "store " + fps["store"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(store)}, - "postgres " + fps["postgres"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(postgres)}, - }, - Archives: map[string][]byte{archive: packed.Bytes()}, - Held: true, - } - for module, raw := range map[string][]byte{"restic": resticRaw, "mesh-provisioner-client": clientRaw, "store": store, "postgres": postgres} { - b.Module[module] = delivery.Module{FP2: fps[module], Manifest: delivery.ManifestDigest(raw)} - b.Manifests[delivery.ManifestDigest(raw)] = raw - } - return b, nil -} - -// FPs are the fingerprints Provisioned's modules are built at. -func FPs() map[string]string { - return map[string]string{"restic": "src2:" + strings.Repeat("3", 64), "mesh-provisioner-client": "src2:" + strings.Repeat("4", 64), - "store": "src2:" + strings.Repeat("5", 64), "postgres": "src2:" + strings.Repeat("8", 64)} -} diff --git a/go/compose/frombus/frombustest/frombustest_test.go b/go/compose/frombus/frombustest/frombustest_test.go deleted file mode 100644 index 97c71b4..0000000 --- a/go/compose/frombus/frombustest/frombustest_test.go +++ /dev/null @@ -1,272 +0,0 @@ -package frombustest - -import ( - "context" - "reflect" - "strings" - "testing" - - stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/compose/frombus" - "git.novox.be/novox/mesh-sdk/go/compose/fromruntime" - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// ADR 0570 clause 2 (issue 1954; a3's H5): the in-memory mesh composes a plan from the bus that hands restic to the -// provisioner and names its postgres-database, provided by postgres on n1. And one bad module must not drop its node -// (issue 774): with a module placed beside it whose built manifest cannot be used, or whose plan refuses, PlansHeld -// still hands restic's plan and names the bad one with why, where Plans refuses the whole node. A manifest the indexer -// holds that the mesh refuses (power's bar item with attributes its bar places by none of, mesh-catalog ca8d6f33, -// 2026-10-30) holds that module alone on its node, in the plans and in the node's composition (Compose), and on a node -// it is not placed on holds nothing. -func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) { - fp := "src2:" + strings.Repeat("9", 64) - for _, row := range []struct { - name string - bad string // the indexer's manifest of a module "backup" placed on n1; empty places none - // built is the manifest backup's done build names; empty gives it no build - built string - why string // words backup's hold says - // elsewhere leaves backup in the indexer's modules and places it on no node: nothing is held. - elsewhere bool - // refused is the indexer's verdict on bad, a problem its judge found (novox/hq ADR 0578 §1): Plans then holds - // backup too, and never ends the read. - refused string - // dataHeld says the data items' read holds backup too: a refusal it meets (a refused manifest, an unusable build, - // an item composition refuses), not one only a plan meets. - dataHeld bool - // dataOnly checks the data items' read alone: the other reads blame restic, whose templates collect the item. - dataOnly bool - }{ - {name: "every module composes"}, - {name: "a built manifest that cannot be used", - bad: `{"module":"backup","version":"1","build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, - built: `{"module":"backup","version":"1","requires":"amqp"}`, why: "backup", dataHeld: true}, - {name: "a plan that refuses", - bad: `{"module":"backup","version":"1","resources":[{"id":"conf","type":"file","path":"/etc/backup.conf",` + - `"mode":"0600","content":"key=${secret:nope}"}]}`, why: "${secret:nope}"}, - {name: "an indexer's manifest the mesh refuses", bad: unusableItem, why: `"shows" is none of them`, - refused: `"shows" is none of them`, dataHeld: true}, - // ADR 0578 §3: an accepted manifest whose data item its composition refuses is held by the culprit rule, in every read. - {name: "a data item its composition refuses", - bad: `{"module":"backup","version":"1","data":{"own":[{"id":"d","path":"${dir:nope}","class":"cache"}]}}`, - why: "nope", dataHeld: true, dataOnly: true}, - {name: "an indexer's manifest the mesh refuses, placed elsewhere", bad: unusableItem, elsewhere: true, - refused: `"shows" is none of them`}, - } { - b, err := Provisioned() - if err != nil { - t.Fatal(err) - } - if row.bad != "" { - if !row.elsewhere { - p := b.Placement[Node] - p.Modules = append(p.Modules, delivery.PlacedModule{Module: "backup"}) - b.Placement[Node] = p - } - digest := delivery.ManifestDigest([]byte(row.bad)) - b.Manifests[digest] = []byte(row.bad) - b.Module["backup"] = delivery.Module{FP2: fp, Manifest: digest} - if row.refused != "" { - b.Module["backup"] = delivery.Module{FP2: fp, Manifest: digest, Verdict: delivery.VerdictRefused, - Problems: []string{row.refused}} - } - if row.built != "" { - builtDigest := delivery.ManifestDigest([]byte(row.built)) - b.Manifests[builtDigest] = []byte(row.built) - b.Builds["backup "+fp] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: builtDigest, - Artifacts: []delivery.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://backup/files/blobs/x", Digest: "sha256:x"}}} - } - if row.dataOnly { - items, _, iheld, err := frombus.DataItemsHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}) - if why, ok := iheld["backup"]; err != nil || !ok || !strings.Contains(why, row.why) || len(items["restic"]) == 0 { - t.Errorf("%s: data items %v held %v: %v", row.name, items, iheld, err) - } - continue - } - plans, err := frombus.Plans(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}) - switch { - case row.refused != "" && (err != nil || !planned(plans, "restic") || planned(plans, "backup")): - t.Errorf("%s: Plans did not hold the refused manifest alone: %v %v", row.name, plans, err) - case row.refused == "" && err == nil && !row.elsewhere: - t.Errorf("%s: Plans composed the node; the row does not refuse it", row.name) - } - } - if row.why != "" || row.bad == "" { - // The node's own composition holds by the same rule: every other module composed, the bad one named. - fps := FPs() - if row.bad != "" { - fps["backup"] = fp - } - out, _, held, err := frombus.Compose(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}, fps) - if err != nil { - t.Fatalf("%s: the node's composition: %v", row.name, err) - } - if key, _ := delivery.ComposedKey(Node, "restic", fps["restic"]); out[key].Module != "restic" { - t.Errorf("%s: restic is not composed (held %v)", row.name, held) - } - if why, ok := held["backup"]; (row.bad == "" && len(held) > 0) || (row.bad != "" && (!ok || !strings.Contains(why, row.why) || len(held) != 1)) { - t.Errorf("%s: the node's composition held %v, want backup alone, saying %q", row.name, held, row.why) - } - } - plans, held, err := frombus.PlansHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}) - if err != nil { - t.Fatalf("%s: %v", row.name, err) - } - restic, handed := plans["restic"] - if !handed || len(restic.Provisions) != 1 || restic.Provisions[0].Provision != "postgres-database" || - restic.Provisions[0].Provider != Node || restic.Provisions[0].ProviderModule != "postgres" { - t.Fatalf("%s: the plans are %+v (held %v)", row.name, plans, held) - } - // Issue 1954: the plans at the wanted builds and at the newest, over one read, are what PlansHeld answers for each, - // and where the time went is said by phase. - keptAt := FPs() - at, newest, took := frombus.PlansHeldTwice(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node, KeptAt: keptAt}) - wantAt, heldAt, errAt := frombus.PlansHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node, KeptAt: keptAt}) - if !reflect.DeepEqual(at, frombus.HeldPlans{Plans: wantAt, Held: heldAt, Err: errAt}) || - !reflect.DeepEqual(newest, frombus.HeldPlans{Plans: plans, Held: held}) { - t.Errorf("%s: twice composed %+v and %+v, want %v %v %v and %v %v", row.name, at, newest, wantAt, heldAt, errAt, plans, held) - } - if said := frombus.PhasesSaid(took); !strings.HasPrefix(said, "read ") || !strings.Contains(said, "plans at newest ") { - t.Errorf("%s: the phases are said as %q", row.name, said) - } - if row.bad == "" || row.elsewhere { - if len(held) > 0 { - t.Errorf("%s: held %v", row.name, held) - } - continue - } - if why, ok := held["backup"]; !ok || !strings.Contains(why, row.why) || len(held) != 1 { - t.Errorf("%s: held %v, want backup alone, saying %q", row.name, held, row.why) - } - if _, planned := plans["backup"]; planned { - t.Errorf("%s: the held module has a plan", row.name) - } - // The data items are read by the same rule (ADR 0578 §3: disk-load's read): the bad module held, the read whole. - items, _, iheld, err := frombus.DataItemsHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}) - // A refusal only a plan meets (a file's secret) holds nothing here: data items render no such file. - mustHold := row.dataHeld - if why, ok := iheld["backup"]; err != nil || ok != mustHold || mustHold && !strings.Contains(why, row.why) || items["backup"] != nil { - t.Errorf("%s: data items %v held %v: %v", row.name, items, iheld, err) - } - } - - // ADR 0580 clause 1: the watched reader reads a state it composes from out of the copy, a build by key each time it is - // asked, and a manifest once per digest. - byKey := map[string]int{} - ws := stdio.WatchedStatesOver(func(state string, handle func(stdio.StateChange) error) error { - byKey[state+" watched"]++ - return handle(stdio.StateChange{Key: "placement." + Node, Op: stdio.OpPut, Value: []byte(`{}`), Current: true}) - }, func(state, key string) ([]byte, bool, error) { byKey[state]++; return []byte(key), true, nil }) - w := fromruntime.Watched{WatchedStates: ws} - for range 2 { - _, _, _ = w.Get(frombus.BuildsRead, "restic."+fp) - _, _, _ = w.Get(frombus.ManifestsRead, "sha256-d") - if _, found, _ := w.Get(frombus.PlacementRead, "placement."+Node); !found { - t.Error("the watched reader does not answer the placement from its copy") - } - } - if byKey[frombus.BuildsRead] != 2 || byKey[frombus.ManifestsRead] != 1 || byKey[frombus.PlacementRead] != 0 || - byKey[frombus.PlacementRead+" watched"] != 1 { - t.Errorf("the watched reader read %v, want a build by key each time, a manifest once, the placement watched once", byKey) - } - fetched := 0 - keep := fromruntime.KeptFetch(func(context.Context, string) ([]byte, error) { fetched++; return []byte("tar"), nil }) - for range 2 { - if body, err := keep(context.Background(), "http://store/restic/files/blobs/sha256:a"); err != nil || string(body) != "tar" { - t.Fatalf("the kept fetch answered %q, %v", body, err) - } - } - if fetched != 1 { - t.Errorf("a templates archive was fetched %d times, want once", fetched) - } - - // Issue 1954: the states reader takes a build in once per its content: composed again, its archive is not read again - // and the plans are the same. - b, err := Provisioned() - if err != nil { - t.Fatal(err) - } - reader, archives := frombus.StatesReader(statesOf(t, b), func(_ context.Context, url string) ([]byte, error) { - fetched++ - _, digest, _ := strings.Cut(url, "/blobs/") - return b.Archives[digest], nil - }), fetched - var first map[string]catalogue.Plan - for i := range 2 { - plans, held, err := frombus.PlansHeld(context.Background(), reader, frombus.Mesh{}, frombus.Scope{Node: Node}) - if err != nil || len(held) > 0 || (i == 1 && !reflect.DeepEqual(plans, first)) || !planned(plans, "restic") { - t.Fatalf("composed %v (held %v): %v", plans, held, err) - } - first = plans - } - if fetched-archives != 1 { - t.Errorf("restic's archive was read %d times over two compositions, want once", fetched-archives) - } -} - -// statesOf is the bus b as a module's read states hold it. -func statesOf(t *testing.T, b *Bus) frombus.States { - t.Helper() - s := states{} - put := func(state, key string, raw []byte, err error) { - if err != nil { - t.Fatal(err) - } - if s[state] == nil { - s[state] = map[string][]byte{} - } - s[state][key] = raw - } - for node, p := range b.Placement { - raw, err := delivery.EncodePlacement(p) - put(frombus.PlacementRead, "placement."+node, raw, err) - } - for node, rec := range b.Record { - raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec}) - put(frombus.HaveRead, node+".node", raw, err) - } - for module, m := range b.Module { - key, _ := delivery.ModulesKey(module) - raw, err := delivery.EncodeModule(m) - put(frombus.ModulesRead, key, raw, err) - } - for digest, man := range b.Manifests { - key, _ := delivery.ManifestsKey(digest) - raw, err := delivery.EncodeManifest(delivery.Manifest{Module: "m", JSON: string(man)}) - put(frombus.ManifestsRead, key, raw, err) - } - for at, build := range b.Builds { - module, fp, _ := strings.Cut(at, " ") - key, _ := delivery.BuilderBuildKey(module, fp) - raw, err := delivery.EncodeBuilderBuild(*build) - put(frombus.BuildsRead, key, raw, err) - } - return s -} - -// states are read states in memory. -type states map[string]map[string][]byte - -func (s states) Get(state, key string) ([]byte, bool, error) { - v, ok := s[state][key] - return v, ok, nil -} - -func (s states) Keys(state string) ([]string, error) { - var keys []string - for k := range s[state] { - keys = append(keys, k) - } - return keys, nil -} - -func planned[P any](plans map[string]P, module string) bool { _, ok := plans[module]; return ok } - -// unusableItem is a manifest the mesh refuses as the node-engine did power's at mesh-catalog ca8d6f33: a bar block item -// with attributes its bar places by none of. -const unusableItem = `{"module":"backup","version":"1","moved":true,` + - `"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},` + - `"items":[{"collection":"contribution:node-bar:block",` + - `"attributes":{"bar":"bottom","place":"status","shows":"state","state":"power.draw"},"template":"files/bar"}]}` diff --git a/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json b/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json deleted file mode 100644 index 095128a..0000000 --- a/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "module": "mesh-provisioner-client", - "version": "1", - "claims": [ - { - "name": "node-provisioner", - "scope": "node" - } - ], - "own-secrets": { - "provisioner-broker": "${dir:state}/broker" - }, - "secrets-owner": "mesh-provisioner-client", - "resources": [ - { - "id": "account", - "type": "user", - "name": "mesh-provisioner-client", - "shell": "/usr/bin/nologin", - "home": "/var/lib/mesh-provisioner-client" - }, - { - "id": "state", - "type": "directory", - "mode": "0700", - "owner": "mesh-provisioner-client", - "place": ".", - "unknown": "program" - }, - { - "id": "client", - "type": "process", - "name": "mesh-provisioner-client", - "artifact": "client", - "run": [ - "./mesh-provisioner-client" - ], - "env": { - "MESH_PROVISIONER_CREDENTIAL": "${dir:state}/broker", - "MESH_PROVISIONER_STATE": "${dir:state}" - }, - "user": "mesh-provisioner-client", - "health": { - "kind": "unit" - } - } - ], - "build": { - "artifacts": [ - { - "name": "client", - "kind": "bundle", - "language": "go", - "system": "arch", - "from": "cmd/mesh-provisioner-client", - "binary": "mesh-provisioner-client" - } - ] - } -} diff --git a/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/backups.conf b/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/backups.conf deleted file mode 100644 index 2f23f90..0000000 --- a/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/backups.conf +++ /dev/null @@ -1,2 +0,0 @@ -# What the modules on this machine back up, composed by the mesh (novox/hq to-be 43). Do not edit. -${backup:lines} \ No newline at end of file diff --git a/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/data.conf b/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/data.conf deleted file mode 100644 index f19e849..0000000 --- a/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/data.conf +++ /dev/null @@ -1,2 +0,0 @@ -# The data the modules on this machine declare, composed by the mesh (novox/hq ADR 0233). Do not edit. -${backup:data} \ No newline at end of file diff --git a/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/retention.conf b/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/retention.conf deleted file mode 100644 index cd7954c..0000000 --- a/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.files/retention.conf +++ /dev/null @@ -1,6 +0,0 @@ -# How many restore points the night keeps, written by the mesh from this machine's settings keep-daily, -# keep-weekly, keep-monthly (novox/hq issue 416) and keep-hand-taken-days (novox/hq issue 453). Replaced on every push; change the settings, never this file. -daily=${config:keep-daily} -weekly=${config:keep-weekly} -monthly=${config:keep-monthly} -hand=${config:keep-hand-taken-days} diff --git a/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.module.json b/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.module.json deleted file mode 100644 index ea5ec8b..0000000 --- a/go/compose/frombus/frombustest/testdata/catalogue-c05aa5e6-restic.module.json +++ /dev/null @@ -1,199 +0,0 @@ -{ - "module": "restic", - "version": "1", - "moved": true, - "claims": [ - { - "name": "node-backup", - "scope": "node", - "serves": [ - "backed-up", - "now", - "restore" - ] - } - ], - "emits": [ - "backed-up" - ], - "tools": [ - "backup_health", - "backup_delete_retired", - "backup_deleted" - ], - "own-secrets": { - "repository": "${dir:state}/repository.secret" - }, - "data": { - "own": [ - { - "id": "repository", - "path": "${dir:repository}", - "class": "rebuildable", - "backup": "none", - "why": "the restore points themselves: a copy of data kept elsewhere on this machine, never the only copy of anything; lost, the history goes and nothing live does" - }, - { - "id": "state", - "path": "${dir:state}", - "class": "cache", - "why": "what the last nights and measurements were; the next night writes it again" - } - ] - }, - "config": { - "keep-daily": { - "type": "number", - "description": "How many nightly restore points are kept, a count from 1 to 9999: two weeks undo a mistake noticed within a fortnight. Read by the night, which thins nothing while it does not read (novox/hq issue 416); lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.", - "minimum": 1, - "maximum": 9999, - "trusted": true, - "default": 14 - }, - "keep-weekly": { - "type": "number", - "description": "How many weekly restore points are kept behind the dailies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.", - "minimum": 1, - "maximum": 9999, - "trusted": true, - "default": 8 - }, - "keep-monthly": { - "type": "number", - "description": "How many monthly restore points are kept behind the weeklies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.", - "minimum": 1, - "maximum": 9999, - "trusted": true, - "default": 6 - }, - "keep-hand-taken-days": { - "type": "number", - "description": "How many days a restore point taken by hand (node-backup.now, or the mesh's before a module moves) is kept at least, a count from 1 to 9999; it is kept until a later good night of its module exists, and this many days in any case, so the next thinning never takes the one taken against a risky change (novox/hq issue 453). Lowering it lets go of the older ones taken by hand at the next thinning, and that cannot be undone.", - "minimum": 1, - "maximum": 9999, - "trusted": true, - "default": 7 - } - }, - "resources": [ - { - "id": "state", - "type": "directory", - "mode": "0700", - "place": ".", - "unknown": "program" - }, - { - "id": "repository", - "type": "directory", - "mode": "0700", - "unknown": "program" - }, - { - "id": "declared", - "type": "file", - "path": "${dir:state}/backups.conf", - "mode": "0600", - "template": "files/backups.conf" - }, - { - "id": "data-declared", - "type": "file", - "path": "${dir:state}/data.conf", - "mode": "0600", - "template": "files/data.conf" - }, - { - "id": "retention", - "type": "file", - "path": "${dir:state}/retention.conf", - "mode": "0644", - "trusted": true, - "template": "files/retention.conf" - }, - { - "id": "tool", - "type": "package", - "package": "restic" - }, - { - "id": "sqlite", - "type": "package", - "package": "sqlite" - }, - { - "id": "night", - "type": "process", - "name": "restic-night", - "artifact": "tools", - "run": [ - "./restic-backups", - "night" - ], - "user": "${machine:account}", - "env": { - "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf", - "MESH_BACKUP_REPOSITORY": "${dir:repository}", - "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret", - "MESH_BACKUP_STATE": "${dir:state}", - "MESH_BACKUP_DATA": "${dir:state}/data.conf" - }, - "health": { - "kind": "tool", - "tool": "backup_health", - "interval": "60s", - "timeout": "20s", - "looks": 2, - "grace": "60s" - } - }, - { - "id": "measure", - "type": "process", - "name": "restic-measure", - "artifact": "tools", - "run": [ - "./restic-backups", - "measure" - ], - "user": "${machine:account}", - "env": { - "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf", - "MESH_BACKUP_REPOSITORY": "${dir:repository}", - "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret", - "MESH_BACKUP_STATE": "${dir:state}", - "MESH_BACKUP_DATA": "${dir:state}/data.conf" - }, - "health": { - "kind": "unit" - } - } - ], - "build": { - "artifacts": [ - { - "name": "tools", - "kind": "bundle", - "language": "go", - "system": "arch", - "from": "cmd/restic-backups", - "binary": "restic-backups", - "loads": [ - "restic-backups" - ], - "env": { - "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf", - "MESH_BACKUP_REPOSITORY": "${dir:repository}", - "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret", - "MESH_BACKUP_STATE": "${dir:state}", - "MESH_BACKUP_DATA": "${dir:state}/data.conf" - } - }, - { - "name": "files", - "kind": "archive", - "from": "files" - } - ] - } -} diff --git a/go/compose/frombus/held_rows_test.go b/go/compose/frombus/held_rows_test.go deleted file mode 100644 index acb0907..0000000 --- a/go/compose/frombus/held_rows_test.go +++ /dev/null @@ -1,298 +0,0 @@ -package frombus - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/compose" - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// aNode is a bus with n1 the hub, each of manifests placed on it and held at the indexer, in the order given. -func aNode(t *testing.T, manifests ...string) (*bus, map[string]string) { - t.Helper() - p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}} - b := &bus{records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd"}}}, - modules: map[string]delivery.Module{}, manifests: map[string][]byte{}, builds: map[string]*delivery.BuilderBuild{}} - fps := map[string]string{} - for i, raw := range manifests { - module := strings.SplitN(strings.TrimPrefix(raw, `{"module":"`), `"`, 2)[0] - p.Modules = append(p.Modules, delivery.PlacedModule{Module: module}) - b.modules[module] = delivery.Module{Manifest: delivery.ManifestDigest([]byte(raw))} - b.manifests[delivery.ManifestDigest([]byte(raw))] = []byte(raw) - fps[module] = "src2:" + strings.Repeat(string(rune('1'+i)), 64) - } - b.placements = map[string]delivery.Placement{"n1": p} - return b, fps -} - -// Issue 774, 2026-10-30 19:30: a node refused whole after holding a module says what it held and why, since the last -// refusal can be of the held module's stub (node-tools held, then "its build produced 0 bundle(s)", its own why unsaid). -func aNodeRefusedWholeSaysWhatItHeld(t *testing.T) { - refused := `{"module":"power","version":"1","items":[{"collection":"contribution:node-bar:block","attributes":{"shows":"state"},"template":"files/a"}]}` - needs := `{"module":"lamp","version":"1","requires":["nothing-provides-this"]}` - b, fps := aNode(t, refused, needs) - refuse(b, "power", `"shows" is none of them`) - _, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1"}, fps) - if err == nil || !strings.Contains(err.Error(), "nothing provides") || !strings.Contains(err.Error(), `held before: power: `) || - !strings.Contains(err.Error(), `"shows" is none of them`) { - t.Errorf("the node refused whole with %v (held %v), want lamp's refusal and power held before, with its why", err, held) - } -} - -// refuse writes the indexer's refusal of module's manifest into its modules value (novox/hq ADR 0578 §1). -func refuse(b *bus, module string, problems ...string) { - m := b.modules[module] - m.Verdict, m.Problems = delivery.VerdictRefused, problems - b.modules[module] = m -} - -// ADR 0578 §3: the bus reader never ends a whole read on a refused manifest. Input, Plans and DataItems compose the rest -// and hold the refused module, named with the indexer's words in Refused; a field the decoder does not know is set aside -// and named, and its module composed. -func aRefusedManifestNeverEndsTheRead(t *testing.T) { - ok := `{"module":"lamp","version":"1","resources":[{"id":"d","type":"directory","path":"/srv/lamp","mode":"0700"}],` + - `"data":{"own":[{"id":"d","path":"${dir:d}","class":"valuable"}]}}` - bad := `{"module":"power","version":"1","resources":[{"id":"d","type":"directory","path":"/srv/power","mode":"0700"}],` + - `"data":{"own":[{"id":"d","path":"${dir:d}","class":"valuable"}]}}` - later := `{"module":"later","version":"1","a-field-from-later":1}` - b, _ := aNode(t, ok, bad, later) - refuse(b, "power", "removes a stream it may not") - ctx := context.Background() - in, err := Input(ctx, b, Mesh{}, Scope{Node: "n1"}) - if err != nil { - t.Fatalf("one refused manifest ended the read: %v", err) - } - if why := in.Refused["power"]; !strings.Contains(why, "refused the manifest of power: removes a stream it may not") || - len(in.Refused) != 1 { - t.Fatalf("refused: %v", in.Refused) - } - if aside := in.SetAside["later"]; !strings.Contains(aside, "a-field-from-later") { - t.Fatalf("set aside: %v", in.SetAside) - } - if !in.Catalogue["power"].Stubbed || in.Catalogue["lamp"].Stubbed || in.Catalogue["later"].Module != "later" { - t.Fatalf("the catalogue: %+v", in.Catalogue) - } - if _, err := Plans(ctx, b, Mesh{}, Scope{Node: "n1"}); err != nil { - t.Fatalf("plans: %v", err) - } - items, _, held, err := DataItemsHeld(ctx, b, Mesh{}, Scope{Node: "n1"}) - if err != nil || len(items["lamp"]) != 1 || items["power"] != nil || !strings.Contains(held["power"], "removes a stream") { - t.Fatalf("data items %v held %v: %v", items, held, err) - } -} - -// Issue 774: the culprit rule takes a module whose stub composes or fails otherwise; the runtime only where its stub -// composes, since its stub always fails otherwise. -func theRuntimeIsBlamedOnlyWhereItsStubComposes(t *testing.T) { - err := errors.New("lamp refuses") - for _, row := range []struct { - module string - again error - blamed bool - }{ - {"lamp", nil, true}, - {"lamp", errors.New("other"), true}, - {"lamp", errors.New("lamp refuses"), false}, - {catalogue.RuntimeModule, nil, true}, - {catalogue.RuntimeModule, errors.New("node-tools is assigned to n1 and its build produced 0 bundle(s)"), false}, - {catalogue.RuntimeModule, fmt.Errorf("compose: %w", &catalogue.NoRuntimeToRunError{Held: true}), true}, - {catalogue.RuntimeModule, &catalogue.NoRuntimeToRunError{}, false}, - } { - if got := blames(row.module, row.again, err); got != row.blamed { - t.Errorf("%s with %v: blamed %v, want %v", row.module, row.again, got, row.blamed) - } - } -} - -// Issue 774, 2026-10-30 19:30: the trunk's node-tools moved to a build not yet done, and ace, g14 and shanks composed -// it from the indexer's manifest, which names no bundle: "its build produced 0 bundle(s)", every module refused. A -// module that runs is now held until its build lands, the runtime composed as what runs of it; and a module wanted at -// another build than the trunk's is composed from the build its want names. -func aRunningModuleWaitsForItsBuild(t *testing.T) { - dg := func(c string) string { return "sha256:" + strings.Repeat(c, 64) } - tools := `{"module":"node-tools","version":"1","own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` + - `"build":{"artifacts":[{"name":"runtime","kind":"bundle","language":"go","system":"arch","from":"cmd/node-tools"}]}}` - lamp := `{"module":"lamp","version":"1","resources":[{"id":"service","type":"service","unit":"lamp.service","state":"running","boot":"enabled"}]}` - store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}},` + - `"receives":{"artifact-store":"/var/lib/artifacts/received.json"}}` - old, next := "src2:"+strings.Repeat("a", 64), "src2:"+strings.Repeat("b", 64) - running := []delivery.Composed{{Node: "n1", Module: catalogue.RuntimeModule, FP: old, Resources: []json.RawMessage{ - json.RawMessage(`{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{"MESH_TOOL_MODULES":""}}`), - json.RawMessage(`{"id":"node-tools.bundle-runtime","module":"node-tools","type":"archive","source":"artifact-store://node-tools/runtime/blobs/` + - dg("1") + `","digest":"` + dg("1") + `","path":"` + catalogue.BundlePath("node-tools", "runtime") + `"}`)}}} - for _, row := range []struct { - name string - want string // the build node-tools' want names - done bool // the trunk's build is done - held string // words node-tools' hold says; empty: composed - digest string // the runtime bundle's digest composed - }{ - {name: "the trunk's build not done yet", want: next, held: "no done build yet"}, - {name: "wanted at the build that runs, the trunk's not done", want: old, digest: dg("1")}, - {name: "the trunk's build done", want: next, done: true, digest: dg("2")}, - } { - b, fps := aNode(t, store, tools, lamp) - b.held = true // every grant held, so a contribution's credential stands in for nothing - b.own = []compose.OwnSecretRow{{Node: "n1", Module: "node-tools", Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}} - mod := b.modules["node-tools"] - mod.FP2 = next - b.modules["node-tools"] = mod - b.builds["node-tools "+old] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest, - Artifacts: []delivery.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("1"), Digest: dg("1")}}} - b.builds["node-tools "+next] = &delivery.BuilderBuild{State: "building", Manifest: mod.Manifest} - if row.done { - b.builds["node-tools "+next] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest, - Artifacts: []delivery.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("2"), Digest: dg("2")}}} - } - fps["node-tools"] = row.want - out, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1", Running: running}, fps) - if err != nil { - t.Errorf("%s: the node is refused: %v", row.name, err) - continue - } - if lampKey, _ := delivery.ComposedKey("n1", "lamp", fps["lamp"]); out[lampKey].Module != "lamp" { - t.Errorf("%s: lamp is not composed (held %v)", row.name, held) - } - if why := held["node-tools"]; (row.held == "") != (why == "") || !strings.Contains(why, row.held) { - t.Errorf("%s: node-tools held %q, want %q", row.name, why, row.held) - } - if row.digest != "" { - key, _ := delivery.ComposedKey("n1", "node-tools", row.want) - if c := out[key]; !strings.Contains(fmt.Sprint(rawStrings(c.Resources)), row.digest) { - t.Errorf("%s: node-tools is composed as %v, want its bundle %s", row.name, rawStrings(c.Resources), row.digest) - } - } - } -} - -func rawStrings(rs []json.RawMessage) []string { - var out []string - for _, r := range rs { - out = append(out, string(r)) - } - return out -} - -// Issue 774: a refusal of a held module's stand-in leads with why the module was held, its first reason, as mesh-self-check's -// on ace should have; a refusal naming no held module is as it was. -func aHeldModulesFirstReasonLeads(t *testing.T) { - held := map[string]string{"mesh-self-check": "mesh-self-check on ace has no bus credential: nothing was issued for ace.mesh-self-check"} - for _, row := range []struct { - err, want string - }{ - {"mesh-self-check runs as its own account and declares no own secret broker", - "mesh-self-check is held: mesh-self-check on ace has no bus credential: nothing was issued for ace.mesh-self-check; " + - "its stand-in is then refused: mesh-self-check runs as its own account and declares no own secret broker"}, - {"ace is the hub and has no endpoint", "ace is the hub and has no endpoint"}, - } { - err := errors.New(row.err) - got := firstReason(err, held) - if got.Error() != row.want || !errors.Is(got, err) { - t.Errorf("refused %q, want %q", got, row.want) - } - } -} - -// Issue 774, 2026-10-30 21:04: mesh-conditions and mesh-healers on novox and mesh-self-check on ace were placed before their -// bus accounts were issued. Held for "needs a secret called broker and none was made", each one's bare stub was then -// refused as "runs as its own account and declares no own secret broker", and that refusal failed every module of the -// node (79 on novox, 74 on ace). A held module's stand-in is never refused by a rule about its own runtime or its own -// secrets: the node composes every other module, and the held one keeps the words it was first held for. -func aHeldModuleOfItsOwnAccountNeverRefusesTheNode(t *testing.T) { - dg := "sha256:" + strings.Repeat("1", 64) - tools := `{"module":"node-tools","version":"1","own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` + - `"build":{"artifacts":[{"name":"runtime","kind":"bundle","language":"go","system":"arch","from":"cmd/node-tools"}]}}` - own := func(name string) string { - return `{"module":"` + name + `","version":"1","runs-as":"` + name + `","secrets-owner":"` + name + `","tools":["status"],` + - `"own-secrets":{"broker":"/var/lib/` + name + `/broker"},"contributes":{"artifact-store":{"name":"` + name + `"}},` + - `"resources":[{"id":"account","type":"user","name":"` + name + `"}],` + - `"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["tools/index.js"]}]}}` - } - lamp := `{"module":"lamp","version":"1","resources":[{"id":"service","type":"service","unit":"lamp.service","state":"running","boot":"enabled"}]}` - store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}},` + - `"receives":{"artifact-store":"/var/lib/artifacts/received.json"}}` - for _, row := range []struct { - name string - issued []string // the own-account modules whose broker is issued - building bool // node-tools' build is not done, and nothing of it runs - held map[string]string // the modules held, each with words of the reason it was first held for - // ungranted gives the provider a grants file and no consumer's grant is issued yet (a newly placed module's - // usual state): a held module's contribution then stands in for its grant, and is left out (review round 1). - ungranted bool - }{ - {name: "none issued", held: map[string]string{"check": "broker", "heal": "broker"}}, - {name: "none issued, no grant issued", ungranted: true, held: map[string]string{"check": "broker", "heal": "broker"}}, - {name: "one issued", issued: []string{"heal"}, held: map[string]string{"check": "broker"}}, - {name: "both issued", issued: []string{"check", "heal"}}, - // The runtime held: its stand-in is not refused for its bundle; the modules it would run are held, not the node. - {name: "the runtime's build not done", issued: []string{"check", "heal"}, building: true, - held: map[string]string{"node-tools": "its build produced 0 bundle(s)", "check": "not here to run it from", "heal": "not here to run it from"}}, - } { - provider := store - if row.ungranted { - provider = strings.Replace(store, `"receives":`, `"grants":{"artifact-store":"/var/lib/artifacts/grants"},"receives":`, 1) - } - b, fps := aNode(t, provider, tools, own("check"), lamp, own("heal")) - // Every grant held, so the held modules' contributions are composed whole; the row ungranted issues none. - b.held = !row.ungranted - b.own = []compose.OwnSecretRow{{Node: "n1", Module: "node-tools", Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}} - for _, m := range row.issued { - b.own = append(b.own, compose.OwnSecretRow{Node: "n1", Module: m, Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}) - } - for _, m := range []string{"node-tools", "check", "heal"} { - name := "tools" - if m == "node-tools" { - name = "runtime" - } - mod := b.modules[m] - mod.FP2 = fps[m] - b.modules[m] = mod - b.builds[m+" "+fps[m]] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest, - Artifacts: []delivery.BuiltArtifact{{Name: name, Kind: "bundle", Reference: "artifact-store://" + m + "/" + name + "/blobs/" + dg, Digest: dg}}} - if m == "node-tools" && row.building { - b.builds[m+" "+fps[m]] = &delivery.BuilderBuild{State: "building", Manifest: mod.Manifest} - } - } - out, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1"}, fps) - if err != nil { - t.Errorf("%s: the node is refused: %v", row.name, err) - continue - } - // A held module keeps what it contributes (issue 774, 2026-10-31: nextcloud held for its admin secret dropped out - // of postgres's received file, and postgres's helper would have locked its login): only its own secrets are left - // out of its stand-in, never its contributions, so the provider's received file names every consumer. - storeKey, _ := delivery.ComposedKey("n1", "artifacts", fps["artifacts"]) - // And never a stand-in for a grant not yet issued: the provider is applied, so a held module whose grant is - // not issued is composed without that contribution, never with "foreseen: made by the next send". - for key, c := range out { - if got, _ := json.Marshal(c.Resources); strings.Contains(string(got), "foreseen") { - t.Errorf("%s: %s applies a stand-in: %s", row.name, key, got) - } - } - for _, m := range []string{"check", "heal"} { - if row.ungranted { - break - } - if got, _ := json.Marshal(out[storeKey].Resources); !strings.Contains(string(got), `\"from\": \"`+m+`\"`) { - t.Errorf("%s: the provider's received file drops %s's contribution: %s", row.name, m, got) - } - } - for _, m := range []string{"node-tools", "check", "lamp", "heal"} { - key, _ := delivery.ComposedKey("n1", m, fps[m]) - _, composed := out[key] - why, isHeld := held[m] - want, wantHeld := row.held[m] - if isHeld != wantHeld || composed == wantHeld { - t.Errorf("%s: %s held %v (%q), composed %v; want held %v", row.name, m, isHeld, why, composed, wantHeld) - } else if isHeld && !strings.Contains(why, want) { - t.Errorf("%s: %s is held for %q, want its first reason, %q", row.name, m, why, want) - } - } - } -} diff --git a/go/compose/frombus/states.go b/go/compose/frombus/states.go deleted file mode 100644 index b26c314..0000000 --- a/go/compose/frombus/states.go +++ /dev/null @@ -1,204 +0,0 @@ -package frombus - -// **The bus read through a module's declared `reads`** (the lead, 2026-10-29: one reader in place of the copies in -// mesh-provisioner and disk-load): a Reader over the states a module reads, by a runtime's Get and Keys (a bundle's -// stdio.State, a process's own), for a holder that composes plans or data items and reads no value: placement, each -// node's record in its own have key, the indexer's modules and manifests, the builder's builds and mesh-config's resolved -// values. No own secret and no credential is read (compose.Planning). Pure as the rest of this package: a bundle's -// runtime states and an HTTP fetch are compose/fromruntime's. - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "fmt" - "strings" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/compose" - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// The states a module names in its manifest's `reads` to compose through StatesReader. -const ( - PlacementRead = delivery.PlacementRead - ModulesRead = delivery.IndexerModule + ".modules" - ManifestsRead = delivery.IndexerModule + ".manifests" - BuildsRead = delivery.BuilderModule + ".builds" - HaveRead = "node-engine.have" - ResolvedRead = "mesh-config.resolved" -) - -// StatesReads are those reads, in a manifest's order. -var StatesReads = []string{PlacementRead, ModulesRead, ManifestsRead, BuildsRead, HaveRead, ResolvedRead} - -// States is a module's read states as its runtime gives them: a key's value, false when it has none. -type States interface { - Get(state, key string) (value []byte, found bool, err error) - Keys(state string) ([]string, error) -} - -// Fetch reads an archive at a URL (fromruntime.HTTPFetch over HTTP). -type Fetch func(ctx context.Context, url string) ([]byte, error) - -// StatesReader is the Reader over s; fetch reads a templates archive, nil for a holder that reads none. -// It keeps what each build's take-in made, by the build's content (issue 1954), so a holder composing again and again -// takes in only a build it has not seen. -func StatesReader(s States, fetch Fetch) Reader { - return statesReader{s: s, fetch: fetch, kept: &keptTakeIns{}} -} - -type statesReader struct { - s States - fetch Fetch - kept *keptTakeIns -} - -func (r statesReader) takenIn(key string) ([]byte, bool) { return r.kept.takenIn(key) } - -func (r statesReader) keepTakenIn(key string, raw []byte) { r.kept.keepTakenIn(key, raw) } - -func (r statesReader) each(read string, visit func(key string, value []byte)) error { - keys, err := r.s.Keys(read) - if err != nil { - return fmt.Errorf("%s: %w", read, err) - } - for _, k := range keys { - v, found, err := r.s.Get(read, k) - if err != nil { - return fmt.Errorf("%s %s: %w", read, k, err) - } - if found { - visit(k, v) - } - } - return nil -} - -func (r statesReader) Placements(context.Context) (map[string]delivery.Placement, error) { - keys := delivery.NewPlacementKeys() - err := r.each(PlacementRead, func(k string, v []byte) { - _, _ = keys.Put(k, v, 0, false) // a key that does not read places nothing, as before - }) - return keys.All(), err -} - -func (r statesReader) Records(context.Context) (map[string]delivery.NodeRecord, error) { - out := map[string]delivery.NodeRecord{} - err := r.each(HaveRead, func(k string, v []byte) { - if node, _, isNode, err := delivery.ParseHaveKey(k); err == nil && isNode { - if h, err := delivery.DecodeHaveNode(v); err == nil && h.Record != nil { - out[node] = *h.Record - } - } - }) - return out, err -} - -func (r statesReader) Modules(context.Context) (map[string]delivery.Module, error) { - out := map[string]delivery.Module{} - err := r.each(ModulesRead, func(k string, v []byte) { - if module, err := delivery.ParseModulesKey(k); err == nil { - if m, err := delivery.DecodeModule(v); err == nil { - out[module] = m - } - } - }) - return out, err -} - -func (r statesReader) Manifest(_ context.Context, digest string) ([]byte, error) { - key, err := delivery.ManifestsKey(digest) - if err != nil { - return nil, err - } - v, found, err := r.s.Get(ManifestsRead, key) - if err != nil { - return nil, err - } - if !found { - return nil, fmt.Errorf("the indexer holds no manifest %s", digest) - } - m, err := delivery.DecodeManifest(v) - if err != nil { - return nil, err - } - return []byte(m.JSON), nil -} - -func (r statesReader) Values(_ context.Context, node, module string) (map[string]any, error) { - var out map[string]any - prefix := node + "." + module + "." - err := r.each(ResolvedRead, func(k string, v []byte) { - name, ok := strings.CutPrefix(k, prefix) - if !ok || strings.Contains(name, ".") { - return - } - if value, laid := catalogue.LaidValue(v); laid { - if out == nil { - out = map[string]any{} - } - out[name] = value - } - }) - return out, err -} - -func (statesReader) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) { - return nil, nil -} - -func (statesReader) Credentials(context.Context, string, []compose.CredentialKey, []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) { - return nil, nil, nil -} - -func (r statesReader) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) { - key, err := delivery.BuilderBuildKey(module, fp) - if err != nil { - return nil, err - } - v, found, err := r.s.Get(BuildsRead, key) - if err != nil || !found { - return nil, err - } - b, err := delivery.DecodeBuilderBuild(v) - if err != nil { - return nil, err - } - return &b, nil -} - -// archiveWithin bounds one read of a templates archive. -const archiveWithin = 30 * time.Second - -func (r statesReader) Archive(ctx context.Context, url, digest string) ([]byte, error) { - if r.fetch == nil { - return nil, fmt.Errorf("this holder reads no archive (%s)", url) - } - fetching, cancel := context.WithTimeout(ctx, archiveWithin) - defer cancel() - body, err := r.fetch(fetching, url) - if err != nil { - return nil, err - } - sum := sha256.Sum256(body) - if got := hex.EncodeToString(sum[:]); got != strings.TrimPrefix(digest, "sha256:") { - return nil, fmt.Errorf("%s was recorded as %s and what arrived is %s", url, digest, got) - } - return body, nil -} - -func (r statesReader) Served(ctx context.Context, node, module string, keys []string) (map[string]any, error) { - all, err := r.Values(ctx, node, module) - var out map[string]any - for _, k := range keys { - if v, ok := all[k]; ok { - if out == nil { - out = map[string]any{} - } - out[k] = v - } - } - return out, err -} diff --git a/go/compose/frombus/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json b/go/compose/frombus/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json deleted file mode 100644 index 095128a..0000000 --- a/go/compose/frombus/testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "module": "mesh-provisioner-client", - "version": "1", - "claims": [ - { - "name": "node-provisioner", - "scope": "node" - } - ], - "own-secrets": { - "provisioner-broker": "${dir:state}/broker" - }, - "secrets-owner": "mesh-provisioner-client", - "resources": [ - { - "id": "account", - "type": "user", - "name": "mesh-provisioner-client", - "shell": "/usr/bin/nologin", - "home": "/var/lib/mesh-provisioner-client" - }, - { - "id": "state", - "type": "directory", - "mode": "0700", - "owner": "mesh-provisioner-client", - "place": ".", - "unknown": "program" - }, - { - "id": "client", - "type": "process", - "name": "mesh-provisioner-client", - "artifact": "client", - "run": [ - "./mesh-provisioner-client" - ], - "env": { - "MESH_PROVISIONER_CREDENTIAL": "${dir:state}/broker", - "MESH_PROVISIONER_STATE": "${dir:state}" - }, - "user": "mesh-provisioner-client", - "health": { - "kind": "unit" - } - } - ], - "build": { - "artifacts": [ - { - "name": "client", - "kind": "bundle", - "language": "go", - "system": "arch", - "from": "cmd/mesh-provisioner-client", - "binary": "mesh-provisioner-client" - } - ] - } -} diff --git a/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/backups.conf b/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/backups.conf deleted file mode 100644 index 2f23f90..0000000 --- a/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/backups.conf +++ /dev/null @@ -1,2 +0,0 @@ -# What the modules on this machine back up, composed by the mesh (novox/hq to-be 43). Do not edit. -${backup:lines} \ No newline at end of file diff --git a/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/data.conf b/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/data.conf deleted file mode 100644 index f19e849..0000000 --- a/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/data.conf +++ /dev/null @@ -1,2 +0,0 @@ -# The data the modules on this machine declare, composed by the mesh (novox/hq ADR 0233). Do not edit. -${backup:data} \ No newline at end of file diff --git a/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/retention.conf b/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/retention.conf deleted file mode 100644 index cd7954c..0000000 --- a/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.files/retention.conf +++ /dev/null @@ -1,6 +0,0 @@ -# How many restore points the night keeps, written by the mesh from this machine's settings keep-daily, -# keep-weekly, keep-monthly (novox/hq issue 416) and keep-hand-taken-days (novox/hq issue 453). Replaced on every push; change the settings, never this file. -daily=${config:keep-daily} -weekly=${config:keep-weekly} -monthly=${config:keep-monthly} -hand=${config:keep-hand-taken-days} diff --git a/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.module.json b/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.module.json deleted file mode 100644 index ea5ec8b..0000000 --- a/go/compose/frombus/testdata/catalogue-c05aa5e6-restic.module.json +++ /dev/null @@ -1,199 +0,0 @@ -{ - "module": "restic", - "version": "1", - "moved": true, - "claims": [ - { - "name": "node-backup", - "scope": "node", - "serves": [ - "backed-up", - "now", - "restore" - ] - } - ], - "emits": [ - "backed-up" - ], - "tools": [ - "backup_health", - "backup_delete_retired", - "backup_deleted" - ], - "own-secrets": { - "repository": "${dir:state}/repository.secret" - }, - "data": { - "own": [ - { - "id": "repository", - "path": "${dir:repository}", - "class": "rebuildable", - "backup": "none", - "why": "the restore points themselves: a copy of data kept elsewhere on this machine, never the only copy of anything; lost, the history goes and nothing live does" - }, - { - "id": "state", - "path": "${dir:state}", - "class": "cache", - "why": "what the last nights and measurements were; the next night writes it again" - } - ] - }, - "config": { - "keep-daily": { - "type": "number", - "description": "How many nightly restore points are kept, a count from 1 to 9999: two weeks undo a mistake noticed within a fortnight. Read by the night, which thins nothing while it does not read (novox/hq issue 416); lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.", - "minimum": 1, - "maximum": 9999, - "trusted": true, - "default": 14 - }, - "keep-weekly": { - "type": "number", - "description": "How many weekly restore points are kept behind the dailies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.", - "minimum": 1, - "maximum": 9999, - "trusted": true, - "default": 8 - }, - "keep-monthly": { - "type": "number", - "description": "How many monthly restore points are kept behind the weeklies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.", - "minimum": 1, - "maximum": 9999, - "trusted": true, - "default": 6 - }, - "keep-hand-taken-days": { - "type": "number", - "description": "How many days a restore point taken by hand (node-backup.now, or the mesh's before a module moves) is kept at least, a count from 1 to 9999; it is kept until a later good night of its module exists, and this many days in any case, so the next thinning never takes the one taken against a risky change (novox/hq issue 453). Lowering it lets go of the older ones taken by hand at the next thinning, and that cannot be undone.", - "minimum": 1, - "maximum": 9999, - "trusted": true, - "default": 7 - } - }, - "resources": [ - { - "id": "state", - "type": "directory", - "mode": "0700", - "place": ".", - "unknown": "program" - }, - { - "id": "repository", - "type": "directory", - "mode": "0700", - "unknown": "program" - }, - { - "id": "declared", - "type": "file", - "path": "${dir:state}/backups.conf", - "mode": "0600", - "template": "files/backups.conf" - }, - { - "id": "data-declared", - "type": "file", - "path": "${dir:state}/data.conf", - "mode": "0600", - "template": "files/data.conf" - }, - { - "id": "retention", - "type": "file", - "path": "${dir:state}/retention.conf", - "mode": "0644", - "trusted": true, - "template": "files/retention.conf" - }, - { - "id": "tool", - "type": "package", - "package": "restic" - }, - { - "id": "sqlite", - "type": "package", - "package": "sqlite" - }, - { - "id": "night", - "type": "process", - "name": "restic-night", - "artifact": "tools", - "run": [ - "./restic-backups", - "night" - ], - "user": "${machine:account}", - "env": { - "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf", - "MESH_BACKUP_REPOSITORY": "${dir:repository}", - "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret", - "MESH_BACKUP_STATE": "${dir:state}", - "MESH_BACKUP_DATA": "${dir:state}/data.conf" - }, - "health": { - "kind": "tool", - "tool": "backup_health", - "interval": "60s", - "timeout": "20s", - "looks": 2, - "grace": "60s" - } - }, - { - "id": "measure", - "type": "process", - "name": "restic-measure", - "artifact": "tools", - "run": [ - "./restic-backups", - "measure" - ], - "user": "${machine:account}", - "env": { - "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf", - "MESH_BACKUP_REPOSITORY": "${dir:repository}", - "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret", - "MESH_BACKUP_STATE": "${dir:state}", - "MESH_BACKUP_DATA": "${dir:state}/data.conf" - }, - "health": { - "kind": "unit" - } - } - ], - "build": { - "artifacts": [ - { - "name": "tools", - "kind": "bundle", - "language": "go", - "system": "arch", - "from": "cmd/restic-backups", - "binary": "restic-backups", - "loads": [ - "restic-backups" - ], - "env": { - "MESH_BACKUP_DECLARED": "${dir:state}/backups.conf", - "MESH_BACKUP_REPOSITORY": "${dir:repository}", - "MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret", - "MESH_BACKUP_STATE": "${dir:state}", - "MESH_BACKUP_DATA": "${dir:state}/data.conf" - } - }, - { - "name": "files", - "kind": "archive", - "from": "files" - } - ] - } -} diff --git a/go/compose/frombus/testdata/de-spiegel-e25e1beb.module.json b/go/compose/frombus/testdata/de-spiegel-e25e1beb.module.json deleted file mode 100644 index b4c4bd2..0000000 --- a/go/compose/frombus/testdata/de-spiegel-e25e1beb.module.json +++ /dev/null @@ -1,129 +0,0 @@ -{ - "module": "de-spiegel", - "version": "1", - "slug": "spiegel", - "capabilities": [ - "container-runtime" - ], - "requires": [ - "route", - "smtp" - ], - "contributes": { - "route": { - "site": { - "label": "de-spiegel", - "port": 35621 - }, - "praktijk": { - "name": "${config:praktijk-name}", - "port": 35621 - }, - "praktijk-www": { - "name": "${config:praktijk-www-name}", - "redirect": "https://${config:praktijk-name}" - }, - "psychologie": { - "name": "${config:psychologie-name}", - "port": 35621 - }, - "psychologie-www": { - "name": "${config:psychologie-www-name}", - "redirect": "https://${config:psychologie-name}" - } - }, - "smtp": { - "account": "de-spiegel" - } - }, - "binds": { - "route": "/var/lib/de-spiegel/route.json", - "smtp": "/var/lib/de-spiegel/smtp.json" - }, - "config": { - "praktijk-name": { - "type": "string", - "description": "The public name the practice's site is served on; the operator's, with no default (novox/hq ADR 0112).", - "trusted": true, - "required": true - }, - "praktijk-www-name": { - "type": "string", - "description": "The public name that redirects to the practice's site (its www form); the operator's, with no default (novox/hq ADR 0112).", - "trusted": true, - "required": true - }, - "psychologie-name": { - "type": "string", - "description": "The public name the psychology practice's site is served on; the operator's, with no default (novox/hq ADR 0112).", - "trusted": true, - "required": true - }, - "psychologie-www-name": { - "type": "string", - "description": "The public name that redirects to the psychology practice's site (its www form); the operator's, with no default (novox/hq ADR 0112).", - "trusted": true, - "required": true - } - }, - "listens": [ - { - "port": 35621, - "protocol": "tcp", - "from": "mesh", - "why": "the de-spiegel site and its /contact endpoint over http; the practice's three public names are route grants, and the proxy reaches every one of them here" - } - ], - "resources": [ - { - "id": "state", - "type": "directory", - "path": "/var/lib/de-spiegel", - "mode": "0700" - }, - { - "id": "server-env", - "type": "file", - "path": "/var/lib/de-spiegel/server.env", - "mode": "0600", - "content": "SMTP_HOST=${bound:smtp:at}\nSMTP_PORT=${bound:smtp:port}\nSMTP_AUTH_USER=de-spiegel@${bound:smtp:domain}\nSMTP_AUTH_PASS=${secret:smtp}\n" - }, - { - "id": "net", - "type": "network", - "name": "de-spiegel" - }, - { - "id": "server", - "type": "container", - "name": "de-spiegel", - "artifact": "site", - "network": "de-spiegel", - "env-file": [ - "/var/lib/de-spiegel/server.env" - ], - "ports": [ - "35621" - ], - "secrets-in-environment": "the application's own code reads SMTP_AUTH_USER/PASS from the environment (server/index.js); converting is this repository's change" - } - ], - "build": { - "on": [ - { - "arg": "NODE_BASE", - "image": "node@sha256:48e4b67d85f87bd551df43704e24d252f56cc5f8e9718841aace50f19948f0f9" - } - ], - "artifacts": [ - { - "name": "site", - "kind": "image", - "from": "Dockerfile" - } - ] - }, - "secrets": { - "smtp": "/var/lib/de-spiegel/smtp.secret" - } -} diff --git a/go/compose/frombus/testdata/f1-circuits-c0ea2c94.module.json b/go/compose/frombus/testdata/f1-circuits-c0ea2c94.module.json deleted file mode 100644 index 8f2b612..0000000 --- a/go/compose/frombus/testdata/f1-circuits-c0ea2c94.module.json +++ /dev/null @@ -1,82 +0,0 @@ -{ - "module": "f1-circuits", - "slug": "f1", - "version": "1", - "capabilities": [ - "container-runtime" - ], - "requires": [ - "route" - ], - "contributes": { - "route": { - "label": "f1-circuits", - "endpoint": "web" - } - }, - "binds": { - "route": "${dir:state}/route.json" - }, - "listens": [ - { - "name": "web", - "port": 8000, - "protocol": "tcp", - "from": "mesh", - "why": "the track-layout CDN, the race posters and the Kometa metadata over http; its public name is a route grant and the proxy reaches it here. Kometa fetches the metadata and every poster by that public name" - } - ], - "resources": [ - { - "id": "state", - "type": "directory", - "mode": "0700", - "place": "." - }, - { - "id": "cache", - "type": "directory", - "mode": "0700" - }, - { - "id": "site-env", - "type": "file", - "path": "${dir:state}/site.env", - "mode": "0644", - "content": "F1_CIRCUITS_BASE_URL=https://${bound:route:name}\n" - }, - { - "id": "server", - "type": "container", - "name": "f1-circuits", - "artifact": "cdn", - "ports": [ - "8000" - ], - "env-file": [ - "${dir:state}/site.env" - ], - "volumes": [ - "${dir:cache}:/app/cache" - ], - "restart-on": [ - "site-env" - ] - } - ], - "build": { - "on": [ - { - "arg": "PYTHON_BASE", - "image": "python@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df" - } - ], - "artifacts": [ - { - "name": "cdn", - "kind": "image", - "from": "service/Dockerfile" - } - ] - } -} diff --git a/go/compose/frombus/twice.go b/go/compose/frombus/twice.go deleted file mode 100644 index cb133df..0000000 --- a/go/compose/frombus/twice.go +++ /dev/null @@ -1,147 +0,0 @@ -package frombus - -// **A node's plans at two sets of builds, over one read** (issue 1954; novox/hq ADR 0570 clause 2, ADR 0580 clause 2): -// the seat mesh-provisioner's holder composes each node at the builds its wants name and at the newest build of each -// placed module. Both are composed from one read of the bus (placement, records, the indexer's modules and manifests, -// values: base); only the builds the node's modules are composed from differ, each build read once for both, and a -// build taken in once per content by a reader that keeps it (StatesReader). Where the time went is said by phase. - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "fmt" - "sync" - "time" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// HeldPlans is one scope's plans, as PlansHeld answers them: err a refusal that is no single module's. -type HeldPlans struct { - Plans map[string]catalogue.Plan - Held map[string]string - Err error -} - -// Phase is one step of a composition and how long it took. -type Phase struct { - Name string `json:"phase"` - Took time.Duration `json:"took"` -} - -// PlansHeldTwice is PlansHeld at s and at s with no kept builds (each placed module at its newest build), over one -// read of the bus: what each answers is what PlansHeld answers for that scope. A read that fails is both scopes' err. -// took names each phase in order: the read, then each scope's builds and its plans. -func PlansHeldTwice(ctx context.Context, r Reader, mesh Mesh, s Scope) (at, newest HeldPlans, took []Phase) { - start := time.Now() - mark := func(name string) { - now := time.Now() - took = append(took, Phase{Name: name, Took: now.Sub(start)}) - start = now - } - b, err := readBase(ctx, r, mesh, s) - mark("read") - builds := onceBuilds(r) - newestScope := s - newestScope.KeptAt = nil - for _, sc := range []struct { - scope Scope - into *HeldPlans - name string - }{{s, &at, "wanted"}, {newestScope, &newest, "newest"}} { - held := map[string]string{} - sc.into.Held = held - refuse := func(module string, err error) { held[module] = err.Error() } - b.refuseOwn(refuse) - if err != nil { - sc.into.Err = err - continue - } - in, err := b.finish(ctx, r, builds, mesh, sc.scope, b.cloned(), refuse, nil) - mark("builds at " + sc.name) - if err != nil { - sc.into.Err = err - continue - } - sc.into.Plans, sc.into.Err = plansHeldOf(in, s.Node, held) - mark("plans at " + sc.name) - } - return at, newest, took -} - -// onceBuilds is r's Build, each module's build at a fingerprint read once: both scopes of one composition read the -// builds of the same moment. -func onceBuilds(r Reader) func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) { - type answer struct { - b *delivery.BuilderBuild - err error - } - read := map[string]answer{} - return func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) { - key := module + " " + fp - if a, ok := read[key]; ok { - return a.b, a.err - } - b, err := r.Build(ctx, module, fp) - read[key] = answer{b, err} - return b, err - } -} - -// takenInKeeper is a Reader that keeps what a build's take-in made (StatesReader does): the resolved manifest's bytes, -// by the content it was made from, so a composition made again on every change takes in only a build it has not seen. -type takenInKeeper interface { - takenIn(key string) ([]byte, bool) - keepTakenIn(key string, raw []byte) -} - -// takenInKey is what a take-in is made from: the artifact store's address and the build's record (its manifest's digest, its artifacts by digest), read -// whole; an archive is checked against its digest on every read, so the same record takes in to the same bytes. -func takenInKey(module, fp, address string, b delivery.BuilderBuild) (string, bool) { - raw, err := json.Marshal(b) - if err != nil { - return "", false - } - sum := sha256.Sum256(append([]byte(module+" "+fp+" "+address+" "), raw...)) - return hex.EncodeToString(sum[:]), true -} - -// takenInAtMost bounds the take-ins a reader keeps; past it they are emptied, which costs only a take-in again. -const takenInAtMost = 512 - -// keptTakeIns is a reader's kept take-ins. -type keptTakeIns struct { - mu sync.Mutex - kept map[string][]byte -} - -func (k *keptTakeIns) takenIn(key string) ([]byte, bool) { - k.mu.Lock() - defer k.mu.Unlock() - raw, ok := k.kept[key] - return raw, ok -} - -func (k *keptTakeIns) keepTakenIn(key string, raw []byte) { - k.mu.Lock() - defer k.mu.Unlock() - if k.kept == nil || len(k.kept) >= takenInAtMost { - k.kept = map[string][]byte{} - } - k.kept[key] = raw -} - -// PhasesSaid says the phases in one line, each in milliseconds. -func PhasesSaid(took []Phase) string { - out := "" - for i, p := range took { - if i > 0 { - out += ", " - } - out += fmt.Sprintf("%s %d ms", p.Name, p.Took.Milliseconds()) - } - return out -} diff --git a/go/compose/fromruntime/fromruntime.go b/go/compose/fromruntime/fromruntime.go deleted file mode 100644 index 997fa5a..0000000 --- a/go/compose/fromruntime/fromruntime.go +++ /dev/null @@ -1,101 +0,0 @@ -// Package fromruntime is compose/frombus's StatesReader over a bundle's runtime (the lead, 2026-10-29): the states a -// module's manifest names in `reads` (frombus.StatesReads), read through stdio.State, and a templates archive fetched -// over HTTP. Apart from frombus so that frombus stays pure for the mesh-compose command. -package fromruntime - -import ( - "context" - "fmt" - "io" - "net/http" - "sync" - - stdio "git.novox.be/novox/mesh-sdk/go" - "git.novox.be/novox/mesh-sdk/go/compose/frombus" -) - -// States are a bundle's read states through its runtime. -type States struct{} - -// Get is a key's value, false when it has none. -func (States) Get(state, key string) ([]byte, bool, error) { - e, err := stdio.State(state).Get(key) - if err != nil || e == nil { - return nil, false, err - } - return e.Value, true, nil -} - -// Keys are a state's keys. -func (States) Keys(state string) ([]string, error) { return stdio.State(state).Keys() } - -// Reader is frombus's Reader over this bundle's read states key by key, fetching archives over HTTP. A holder that -// composes again and again reads through Watched instead (novox/hq ADR 0580). -func Reader() frombus.Reader { return frombus.StatesReader(States{}, HTTPFetch) } - -// Watched is frombus's States over a watched copy (novox/hq ADR 0580 clause 1): the states a composition reads whole -// (placement, haves, modules, resolved values) from the copy, a build by key when asked, a manifest once per digest. -type Watched struct{ *stdio.WatchedStates } - -// Get is a key's value: a build read now, a manifest kept once read, any other state's from the copy. -func (w Watched) Get(state, key string) ([]byte, bool, error) { - switch state { - case frombus.BuildsRead: - return w.Direct(state, key) - case frombus.ManifestsRead: - return w.Once(state, key) - } - return w.WatchedStates.Get(state, key) -} - -// WatchedReader is frombus's Reader over w, fetching each templates archive over HTTP once (KeptFetch): a composition -// made again on every change reads it again otherwise. -func WatchedReader(w *stdio.WatchedStates) frombus.Reader { - return frombus.StatesReader(Watched{w}, KeptFetch(HTTPFetch)) -} - -// archivesAtMost bounds the archives KeptFetch keeps; past it they are emptied, which costs only a fetch again. -const archivesAtMost = 64 - -// KeptFetch is fetch kept once per URL: an archive's URL names its blob by digest, so its body never changes under it -// (frombus checks the digest on every read). A failed fetch is not kept. -func KeptFetch(fetch frombus.Fetch) frombus.Fetch { - var mu sync.Mutex - kept := map[string][]byte{} - return func(ctx context.Context, url string) ([]byte, error) { - mu.Lock() - body, ok := kept[url] - mu.Unlock() - if ok { - return body, nil - } - body, err := fetch(ctx, url) - if err != nil { - return nil, err - } - mu.Lock() - if len(kept) >= archivesAtMost { - clear(kept) - } - kept[url] = body - mu.Unlock() - return body, nil - } -} - -// HTTPFetch reads an archive over HTTP, at most 256 MiB. -func HTTPFetch(ctx context.Context, url string) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return nil, err - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("%s answered %s", url, resp.Status) - } - return io.ReadAll(io.LimitReader(resp.Body, 256<<20)) -} diff --git a/go/compose/input.go b/go/compose/input.go deleted file mode 100644 index 72aecf7..0000000 --- a/go/compose/input.go +++ /dev/null @@ -1,365 +0,0 @@ -// Package compose is the controller's composition of a machine, as a function over a plain input (novox/hq ADR 0453 -// clause 3, ADR 0446 clause 4): what the rest of the mesh offers and holds, the private network's generator, one -// node's resolution and, in the issues after, its rendering and its declaration. -// -// **It never allocates, makes, writes, logs or raises.** The controller composes with a store behind it; this -// package is its Reading and Foreseeing modes over data. Everything the controller reads from its store is a field of -// Input, and everything a send would make or record (a port, a credential, a seat passed on, a node left out) is -// returned for the caller to act on, never done. Input is never changed by a call. No field holds a secret value -// the composition needs: sealed values are optional inputs, and without them the composition uses stand-ins named -// foreseen. The logic is the controller's, ported from mesh-controller cmd/mesh-controller at commit -// 6c2a8d62100cb7d491f8eb9a623e1eeea5ec0de1 (plan.go, network.go, holdings.go, seat_pass.go), in the same order, with the -// same refusals in the same words, so that a comparison node for node is a fair one. -// -// It imports the standard library and mesh-sdk's own packages only. -// It reads no environment variable: the range the mesh allocates from and the suffix of its internal names are fields of Input. -package compose - -import ( - "encoding/json" - "errors" - "fmt" - "sort" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// Input is everything the composition reads. Each field is what one read of the controller's inventory answers, named -// beside it; the controller's adapter fills it from its stores and the assigner from the mesh's facts and buckets -// (ADR 0446 clause 2). -type Input struct { - // Catalogue is the shelf: every module's manifest by name (inv.Catalogue), as it is to be composed. A recorded - // module kept at the build a machine runs (keepRecorded) is that node's Kept, applied to its own resolution only (never to the rest of the mesh). - Catalogue map[string]catalogue.Manifest - // Nodes is every node the mesh has (inv.Nodes and inv.Overlays together, one row per node). - Nodes []Node - // Holdings is every seat whose holder is on record (inv.Holdings). - Holdings []catalogue.Held - // Licences is every licence the mesh holds, with the machines that use it (open.Licences); none for a mesh with no - // licence. - Licences []Licence - - // OverlayCIDR is the range the mesh allocates from when no tunnel was adopted, as genesis was told it (the - // controller's OverlayCIDRVar); empty means DefaultOverlayCIDR. The package reads no environment. - OverlayCIDR string - // InternalSuffix is what the mesh's internal names end in (the controller's MESH_INTERNAL_SUFFIX), with or without a leading - // dot; empty means overlay.DefaultSuffix. A fact of the whole mesh, so it is in the Input and never read from the environment. - InternalSuffix string - // Tunnels is, by node name, the found tunnel whose key is the node's overlay key (inv.Tunnels). - Tunnels map[string]FoundTunnel - // Adopted is the hub's found tunnel and the hub's name, whether or not the hub took it (inv.AdoptedTunnel); nil - // when no hub has one. - Adopted *AdoptedTunnel - // CarriedPeers is every peer of the adopted tunnel (inv.CarriedPeers). - CarriedPeers []CarriedPeer - // Joining is the nodes that hold a live enrolment token (inv.NodesWithALiveToken). - Joining []string - - // Credentials is how each pair credential a node takes from elsewhere stands, never its value (what inv.SecretFor, - // SharedSecretFor and GivenPairSecret would answer): held, to be made by the next send, only a person's to give, or - // refused. A need with no entry here is one the next send would make. - Credentials []Credential - - // Built is every repository the mesh has built (the keys of inv.Held): what tells a reference recorded with an address - // from an image a module runs straight from a public registry. - Built []string - // OperatorExport is every operator-sealed secret in the mesh, for a module that keeps them (inv.OperatorExport): sealed - // values the composition carries without opening. Nil when the mesh has no operator key. - OperatorExport *catalogue.KeptExport - // KeptRuns is, by node and module, the file of the export as that node runs it (its resource of a module's running - // composition): what a node composing its own carries where it holds no OperatorExport (catalogue.Rendering.KeptRuns, - // issue 1126, C6). Used only while OperatorExport is nil. - KeptRuns map[string]map[string]map[string]any - - // Issued is the credentials made for the consumers of each provider (inv.SecretsFrom): one row for each pair credential - // that exists, by the provider node it is for. The grants a provider must create are read from these, so a credential not - // made yet has no grant until the send that makes it. An assigner fills it from the facts snapshot's Grants of each - // machine (go/facts, from format 2), which carry who each credential is for and never the credential. - Issued []Issued - // OwnSecrets is the own secrets held on each machine (the module_secret rows): who made each, and the key it is sealed to. - // Their sealed values are optional. - OwnSecrets []OwnSecretRow - // KeptCoreBoxes is the vault's box last kept for each core credential on each machine (inv.KeptCoreBoxes). - KeptCoreBoxes []CoreBox - // BusUsers is the bus's user list as the controller composes it from the bus records and the hashes it holds (composeBusUsers), - // for the machine that runs the bus: names and hashes, never passwords. Empty when the Input does not carry it, and then - // the composition uses a stand-in, named foreseen. BusMissing are the users left out of it for want of a credential. - BusUsers string - BusMissing []string - // BusMinted are the bus users the mesh has minted a password hash for (inv.BusUserHash): `.` for a module and - // `provisioner.` for a machine's provisioner client. - BusMinted []string - // BusMemberships is, by node, the membership sealed to it for the bus the mesh is moving to (inv.BusMemberships). - BusMemberships map[string]string - // Authority is the mesh's own certificate (identity.EstablishAuthority), public. Optional. - Authority string - - // CurrentBuilds is, by module, the commit of the build the mesh holds for it (inv.CurrentBuilds, the commit it was built from); - // BuildIDs the build made from a commit of a module (inv.BuildOf), by module then commit. Read only to name the builds a - // declaration carries to a node-engine that reads them. - CurrentBuilds map[string]string - BuildIDs map[string]map[string]string - - // Refused is each module whose manifest the indexer's judge refused, or whose bytes do not decode, by module, with why - // (novox/hq ADR 0578 §3, §5): its verdict, carried in the plain input. Such a module stands in Catalogue as its stub - // (compose.Stub), so it is held: nothing of its own is composed, while what it provides and requires still shapes the - // rest. A module not named is accepted, a missing verdict included. - Refused map[string]string `json:",omitempty"` - // SetAside is each module whose manifest has a field the reader's decoder does not know, by module, with what was set - // aside (catalogue.Manifest.SetAside): the rest of it is composed, and the reader names it (ADR 0578 §6). - SetAside map[string]string `json:",omitempty"` -} - -// Issued is one pair credential made for a consumer of a provider (inv.Secret): ForProvider is its copy sealed to the -// provider, optional. -type Issued struct { - Provision string - Consumer string - ConsumerModule string - Local string - Provider string - ForProvider string -} - -// OwnSecretRow is one own secret held on a machine: Origin is `made` (the mesh generated it) or `accepted` (a person gave it), -// SealedTo the sealing key it is sealed to, and Sealed its value sealed to that key, optional. -type OwnSecretRow struct { - Node string - Module string - Name string - Origin string - SealedTo string - Sealed string -} - -// CoreBox is the vault's box for one core credential of one machine, as last kept (inv.CoreBox). -type CoreBox struct { - Node string - Module string - Name string - Generation int - SealedTo string - Box json.RawMessage -} - -// Node is one node of the mesh and what the composition reads about it. -type Node struct { - Name string - // Place is where it is on the private network (inv.Overlays). - Place Place - // Assigned is the modules assigned to it, in the order the controller reads them (inv.Assigned). - Assigned []string - // Handed is, by module, whether the vault judged it handed to the provisioner when it answered this node's own - // secrets (catalogue.AssignmentHands of the plan in its assignment): the node's own composition hands exactly those - // (catalogue.Rendering.Assigned, issue 774). Nil where the vault said nothing of it, as for the controller. - Handed map[string]bool - // Capabilities are the ones present on it (inv.ProfileOf). - Capabilities map[string]bool - // PublicDomain is the domain it composes its routed names under (inv.PublicDomainOf). - PublicDomain string - // Account, AccountHome, AgentAccount and AgentAccountHome are its operator and agent logins (inv.NodeByName). - Account, AccountHome, AgentAccount, AgentAccountHome string - // Adopted is whether it is adopted rather than converged. - Adopted bool - // Pins is the provider each provision was pinned to for it (inv.PinsFor). - Pins map[string]catalogue.Chosen - // Bound is where each of its consumers of a provision that keeps data was last sent (inv.BindingsFor), by consumer - // module and provision. An assigner fills it from the facts snapshot's Bindings of the machine (go/facts, from format 2). - Bound map[string]map[string]catalogue.Chosen - // Settings is its settings layers by module (inv.SettingsFor); a module with none is absent. - Settings map[string][]catalogue.Layer - // Ports is the machine port each module's software port was given on it (inv.PortsFor), by module then port. - Ports map[string]map[int]int - // SealingKey is the public key its secrets are sealed to (inv.SealingKeyOf); empty when it has none. A public key, so - // nothing secret. - SealingKey string - // Taken is the modules taken on it, for an adopted node (inv.Taken). - Taken []string - // OutwardLinks are the links it reported as facing outside it (inv.OutwardLinksOf). - OutwardLinks []string - // Networks are the networks it reported being attached to, as link and network (inv.MachineOf); NetworksSaid is whether - // its node-engine said any at all, which is not the same as saying none. - Networks []catalogue.AttachedNetwork - NetworksSaid bool - // ServingKey is the key the machine serves with, which a certificate is issued for (identity.ServingKeyOf); empty when it has - // never reported one. Certificate is the certificate the mesh issued for its internal name, public and optional. - ServingKey string - Certificate string - // Sequence, Epoch and Generation are what the declaration is sent under (inv.Sequence, SentEpoch, SentGeneration): the order - // against every other send to the node, the lease epoch and the assignment generation it was last sent. Zero is not sent. - Sequence int64 - Epoch uint64 - Generation int64 - // ReadsModules says its node-engine reads a module per resource and a build per module (inv.ReadsModules, issue 1145); only - // then are they sent. SentBuilds is the build of each module it was last sent, by module to commit, and SentKnown whether - // that is known at all (inv.SentBuilds); Runs is what each of its assignments runs (inv.AssignmentRuns), by module. - ReadsModules bool - SentBuilds map[string]string - SentKnown bool - Runs map[string]Run - // Kept is, by module, the manifest of a recorded or core module kept at the build this machine runs (plan.go keepRecorded: - // the caller reads it with recordedKept and ManifestAt), and KeptBuilds the commit of that build. They apply to this node's - // own resolution only, never to another node's, and a declaration carries the kept commit for the module. - Kept map[string]catalogue.Manifest - KeptBuilds map[string]string - // Contract is the contract of its node-engine's newest statement (inv.HealthOf); Stated is whether it ever stated one. - Contract int - Stated bool - // InOwn is the modules it runs inside its own composition (`.node`), not by a composition of their own, since - // no fingerprint of the new form names them where it runs them (novox/hq issue 1126): Compositions folds their - // resources into the node's own, as the controller's composed writer does, unless fps gives one of the new form. - // The private network's module (overlay.Name), which nobody builds, is folded whether or not it is named here. - InOwn map[string]bool - // RunsOwn is the node's own composition (`.node`) as it runs, when the node composes itself: a module folded - // there (InOwn) whose manifest names a build (NamesBuild) runs a build of the old form only the controller holds, - // so it is left to the node: Compositions composes it as a stub (Stub) and carries its resources as they run, each - // after the resource it runs after (issue 1126). Nil composes every folded module from its manifest, as before. - RunsOwn *delivery.Composed - // Held is the modules refused alone on this node, each composed as what runs of it (Carried, in Kept; its Stub when - // nothing of it runs) so the rest is composed (novox/hq issue 1126): none of a held module's own composition is in what - // Compositions answers, so the node keeps what it last applied of it; one whose resources the node's own runs - // (RunsOwn), folded or leaving the fold, is carried there as it runs, whatever its manifest names, so the node's own - // never drops it. - Held map[string]bool - // RunsTools is, by module, the tools bundles the node's runtime serves of it as it runs (ToolsRunning of what the - // node runs); nil when not known. A module composed as what runs of it (its Stub or Carried: held, or folded at a - // build of the old form) contributes these to the runtime, never its manifest's: a manifest no build resolved has no - // bundles, and a held module's new build's bundles are not delivered (issue 1126, 2026-10-29: shanks's runtime lost - // the folded systemd-resolved, novox's the folded nats). - RunsTools map[string][]catalogue.Bundle -} - -// Place is where a node is, as its placement says (inv.Overlay). -type Place struct { - Key string - Endpoint string - Site string - Hub bool - Address string -} - -// Reachable says whether the node can be dialled. -func (p Place) Reachable() bool { return strings.TrimSpace(p.Endpoint) != "" } - -// Tunnel is a tunnel found on a node (inv.Tunnel). -type Tunnel struct { - Interface string - Unit string - Config string - Port int - MTU int - // Address is the interface's own address with its prefix length, Range the network that prefix names. - Address string - Range string - PublicKey string - Peers []TunnelPeer -} - -// TunnelPeer is one peer of a found tunnel. -type TunnelPeer struct { - PublicKey string - Address string -} - -// FoundTunnel is a node's tunnel and whether the node is adopted (inv.FoundTunnel). -type FoundTunnel struct { - Tunnel - NodeAdopted bool -} - -// AdoptedTunnel is the hub's tunnel, the hub's name, and whether the hub's overlay key is the tunnel's, which is -// whether the private network took the tunnel over. -type AdoptedTunnel struct { - Tunnel Tunnel - Hub string - Adopted bool -} - -// CarriedPeer is a peer of the adopted tunnel (inv.CarriedPeer). -type CarriedPeer struct { - PublicKey string - Address string - EnrolledAs string - Named string -} - -// Licence is one licence the mesh holds: its name, what a consumer may know of it, and the machines and modules -// that use it. -type Licence struct { - Name string - Serves map[string]any - Holders []Holder - // Keys is, by holder, the licence's key sealed to that holder's machine (held.KeyFor), empty where the mesh holds none: - // the plaintext was discarded when it was supplied. Optional: nil means not given, and the composition uses a stand-in named - // foreseen; given (even empty), a holder with no entry is one the mesh holds no key for. - Keys map[Holder]string - // Manager is the holder of a refreshable-grant licence that manages it (held.ManagerOf); the zero Holder when none. - Manager Holder -} - -// Holder is a module on a node that uses a licence. -type Holder struct { - Node string - Module string -} - -// ErrNoSuchNode is what the controller's inventory answers for a node it does not have. -var ErrNoSuchNode = errors.New("no node of that name") - -// node is the node of that name, or the refusal the controller's inventory gives for one it does not have. -func (in Input) node(name string) (Node, error) { - for _, n := range in.Nodes { - if n.Name == name { - return n, nil - } - } - return Node{}, fmt.Errorf("%w: %s", ErrNoSuchNode, name) -} - -// sorted is the nodes by name, the order the controller reads them in. -func (in Input) sorted() []Node { - out := append([]Node(nil), in.Nodes...) - sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) - return out -} - -// CredentialState is how one pair credential stands. -type CredentialState string - -const ( - // CredentialHeld is a credential held for both ends as they stand now. Sealed, when given, is the consumer's copy; a held - // credential with no value is composed with a stand-in, named foreseen. - CredentialHeld CredentialState = "held" - // CredentialWouldMake is a credential none is held for: the next send makes it, and the composition uses a stand-in, - // named foreseen. It is also what a need with no entry is. - CredentialWouldMake CredentialState = "would-make" - // CredentialNotGiven is a credential only a person gives (novox/hq ADR 0337 clause 4) that nobody has given, or that was given - // under a key one end no longer has: the consumer still resolves, without it. Why is the controller's words for it. - CredentialNotGiven CredentialState = "not-given" - // CredentialRefused is a credential the send would refuse to make, for the reason Why gives in the controller's words: a - // value a person gave sealed to a key the machine no longer has, a machine with no sealing key. - CredentialRefused CredentialState = "refused" -) - -// Credential is the state of one pair credential, by the ends it joins: the provision, the consumer's node and module, the -// provider's node, and the local name where a module keeps several (inv.Secret's key). -type Credential struct { - Provision string - Consumer string - ConsumerModule string - Provider string - Local string - State CredentialState - // Sealed is the consumer's copy as it is sealed to the consumer's key; optional, and only the controller's adapter passes it. - Sealed string - // Why is the words of a CredentialNotGiven or CredentialRefused. - Why string -} - -// Run is the build an assignment names: the commit it is of and the build made from it (chain.Ref). -type Run struct { - Commit string - Build string -} diff --git a/go/compose/judge/judge.go b/go/compose/judge/judge.go deleted file mode 100644 index 8d68a39..0000000 --- a/go/compose/judge/judge.go +++ /dev/null @@ -1,372 +0,0 @@ -// Package judge is how the seat mesh-placement's holder judges a proposal (novox/hq ADR 0567 §3, §4): which nodes must -// answer it and what it changes on each (Intended), and the verdict of their answers (JudgeAnswers). Moved here from -// the assigner, where it judged a catalogue pull request's placement (ADR 0559 §5), with a proposal in place of a pull -// request's change. It holds no bus client and no timer: the holder gives it what it read. -package judge - -import ( - "encoding/json" - "fmt" - "reflect" - "sort" - "strconv" - "strings" - "time" - - "git.novox.be/novox/mesh-sdk/go/compose" - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// AnswerBound is how long the holder waits for a node's answer after its candidate was written (ADR 0567 §4): the time to -// live of the node's pulse key (delivery.PulseAnswerKey), whose expiry names it silent, never passed. A variable so a test -// on the bus can shorten it. -var AnswerBound = 10 * time.Minute - -// Intended is, by node, the modules whose composition the proposal changes, and the nodes that must answer it (§3). -// current is the composition input with the current placement and laidInput the same with the proposal's placements -// (laid, by node; nil removes the node's key) laid over it; nodes is every node to look at (every node with a record). -// A node's set holds the modules the proposal adds, removes or whose placed entry differs on that node, and those whose -// composition differs between the two inputs (delivery.CompareComposed), the node's own named "node". Must is every node -// whose key the proposal changes and every other whose set is not empty, sorted. A node that has no key now gets an -// empty set (§7: the seed is answered as the same). A node whose compositions cannot be made on either side keeps the -// modules its entries name, said in notes; such a node is in Must only when its key changes. -func Intended(current, laidInput *compose.Input, currentPlaced map[string]delivery.Placement, laid map[string]*delivery.Placement, - nodes []string, fps map[string]string) (intended map[string]map[string]bool, must []string, notes []string) { - intended = map[string]map[string]bool{} - inMust := map[string]bool{} - all := map[string]bool{} - for _, n := range nodes { - all[n] = true - } - for n := range laid { - all[n] = true - inMust[n] = true - } - names := make([]string, 0, len(all)) - for n := range all { - names = append(names, n) - } - sort.Strings(names) - for _, node := range names { - before, placed := currentPlaced[node] - set := map[string]bool{} - after, changesKey := laid[node] - if changesKey && !placed { - intended[node] = set // a node new to placement: every answer must be the same - continue - } - if changesKey { - var afterModules []delivery.PlacedModule - if after != nil { - afterModules = after.Modules - } - entries := map[string]delivery.PlacedModule{} - for _, m := range before.Modules { - entries[m.Module] = m - } - seen := map[string]bool{} - for _, m := range afterModules { - seen[m.Module] = true - if e, ok := entries[m.Module]; !ok || !reflect.DeepEqual(e, m) { - set[m.Module] = true - } - } - for name := range entries { - if !seen[name] { - set[name] = true - } - } - } - if placed && current != nil && laidInput != nil { - was, _, _, errB := compose.CompositionsShadow(*current, node, fps) - now, _, _, errA := compose.CompositionsShadow(*laidInput, node, fps) - if errA == nil && errB == nil { - running := make([]delivery.Composed, 0, len(was)) - for _, c := range was { - running = append(running, c) - } - cmp := delivery.CompareComposed(now, running) - for _, d := range cmp.Differs { - set[d.Module] = true - } - for _, d := range cmp.Added { - set[d.Module] = true - } - for _, m := range cmp.Removed { - set[m] = true - } - } else if changesKey { - notes = append(notes, fmt.Sprintf("what the proposal changes on %s is read from its placement alone: its "+ - "compositions cannot be made (now: %v; proposed: %v)", node, errB, errA)) - } - } - if len(set) > 0 { - inMust[node] = true - } - if inMust[node] { - intended[node] = set - } - } - for n := range inMust { - must = append(must, n) - } - sort.Strings(must) - return intended, must, notes -} - -// JudgeAnswers is the verdict of a proposal's answers (§4). answers is by node its HaveNode.Candidates entry for the -// proposal (nil when it has none) and revisions by node the revision of its candidate key: an answer counts when it -// names the proposal and that revision. It fails on a refusal by a node that must answer, and on every node of Must with -// no answer that counts once its pulse key has ended (pulsing false: the bound's expiry, heard on the bus); while it -// stands it waits for them. -// A difference fails nothing: one Intended computed is listed under Intended, one it did not under Unintended, both -// shown in the ask's brief. The seed alone fails on any difference. -func JudgeAnswers(p delivery.PlacementProposal, intended map[string]map[string]bool, answers map[string]*delivery.CandidateAnswer, - revisions map[string]uint64, online map[string]bool, pulsing map[string]bool, now time.Time) delivery.PlacementVerdict { - v := delivery.PlacementVerdict{At: now.UTC(), Intended: map[string][]string{}, Unintended: map[string][]string{}, Carried: map[string][]string{}, - Answers: map[string]delivery.CandidateAnswer{}} - for node, set := range intended { - for m := range set { - v.Intended[node] = append(v.Intended[node], m) - } - sort.Strings(v.Intended[node]) - } - must := p.Must - if len(must) == 0 { - must = p.Nodes() - } - for _, node := range must { - a := answers[node] - if a == nil || a.Proposal != p.ID || a.Revision == 0 || a.Revision != revisions[node] { - v.Silent = append(v.Silent, node) - continue - } - v.Answers[node] = *a - if a.Refused != "" { - v.Fails = append(v.Fails, fmt.Sprintf("%s cannot compose the proposal: %s", node, a.Refused)) - continue - } - for _, r := range a.Refusals { - v.Fails = append(v.Fails, fmt.Sprintf("%s refuses the proposal for %s: %s", node, r.Module, r.Why)) - } - want := intended[node] - differs := func(module, words string) { - if p.Seed { - // The seed carries what its nodes found, each named in the brief for the operator who approves it. - v.Carried[node] = append(v.Carried[node], words) - return - } - if !want[module] { - v.Unintended[node] = append(v.Unintended[node], module) - } - } - for _, d := range a.Differs { - differs(d.Module, differenceWords(d)) - } - for _, d := range a.Added { - differs(d.Module, "would run "+d.Module) - } - for _, m := range a.Removed { - differs(m, "would stop running "+m) - } - sort.Strings(v.Unintended[node]) - } - if len(v.Intended) == 0 { - v.Intended = nil - } - if len(v.Unintended) == 0 { - v.Unintended = nil - } - if len(v.Carried) == 0 { - v.Carried = nil - } - if len(v.Answers) == 0 { - v.Answers = nil - } - var waiting, gone []string - for _, node := range v.Silent { - named := node - if online != nil && !online[node] { - named += " (offline)" - } - if pulsing[node] { - waiting = append(waiting, named) - } else { - gone = append(gone, named) - } - } - if len(waiting) > 0 { - v.Notes = append(v.Notes, fmt.Sprintf("waits for the answers of %s, each for %s from its candidate's write", - strings.Join(waiting, ", "), AnswerBound)) - } - if len(gone) > 0 { - v.Fails = append(v.Fails, fmt.Sprintf("%s did not answer within %s of its candidate's write; judged again when it "+ - "answers", strings.Join(gone, ", "), AnswerBound)) - } - switch { - case len(v.Fails) > 0: - v.State = delivery.VerdictFail - case len(waiting) > 0: - v.State = delivery.VerdictWaits - default: - v.State = delivery.VerdictPass - } - return v -} - -// Brief is the verdict as the ask's brief shows it, by node (§5): what the proposal changes and what each node answered -// beside it, marked, and what stays held as the node holds it today, with the proposal's why. -func Brief(p delivery.PlacementProposal, v delivery.PlacementVerdict) string { - var b strings.Builder - fmt.Fprintf(&b, "%s (proposed by %s)", p.Why, p.By) - nodes := map[string]bool{} - for n := range v.Intended { - nodes[n] = true - } - for n := range v.Unintended { - nodes[n] = true - } - for _, n := range p.Nodes() { - nodes[n] = true - } - for n, a := range v.Answers { - if len(a.Held) > 0 { - nodes[n] = true - } - } - for n := range v.Carried { - nodes[n] = true - } - names := make([]string, 0, len(nodes)) - for n := range nodes { - names = append(names, n) - } - sort.Strings(names) - for _, n := range names { - fmt.Fprintf(&b, "; %s:", n) - if m := v.Intended[n]; len(m) > 0 { - fmt.Fprintf(&b, " changes %s", strings.Join(m, ", ")) - } else { - b.WriteString(" composes the same") - } - if m := v.Unintended[n]; len(m) > 0 { - fmt.Fprintf(&b, "; also differs, not computed: %s", strings.Join(m, ", ")) - } - if m := v.Carried[n]; len(m) > 0 { - fmt.Fprintf(&b, "; the seed carries: %s", strings.Join(m, "; ")) - } - if held := v.Answers[n].Held; len(held) > 0 { - var names []string - for _, h := range held { - today := h.Today - if today == "" { - today = "held" - } - names = append(names, h.Module+" ("+today+")") - } - fmt.Fprintf(&b, "; stays as today: %s", strings.Join(names, ", ")) - } - } - return b.String() -} - -// differenceWords names one module's difference: the module, the first field that differs in it with its value as it -// runs and as the seed composes it, each cut to about 60 characters ("(value cut)" where a body shown is cut before the -// field), and how many fields more; or the resource and why when no body is shown. -func differenceWords(d delivery.ModuleDiffers) string { - for _, b := range d.Bodies { - if len(b.Paths) == 0 { - continue - } - path := b.Paths[0] - words := fmt.Sprintf("%s: %s %s → %s", d.Module, fieldName(b.Running, b.Composed, path), - valueAt(b.Running, path), valueAt(b.Composed, path)) - if n := len(b.Paths) - 1; n > 0 { - words += fmt.Sprintf(" (and %d more fields)", n) - } - return words - } - if d.Resource != "" { - return fmt.Sprintf("%s: %s (%s)", d.Module, d.Resource, d.Why) - } - return fmt.Sprintf("%s: %s", d.Module, d.Why) -} - -// shownValue bounds a value named in a brief. -const shownValue = 60 - -// walk follows a differing path (delivery.DifferingPaths: keys joined by dots, a key may hold dots, and a JSON value -// written in a string is read as JSON) through a body, answering the keys it passed and the value at its end. -func walk(body, path string) (keys []string, value any, ok bool) { - var v any - if json.Unmarshal([]byte(body), &v) != nil { - return nil, nil, false - } - rest := path - for rest != "" { - if s, isString := v.(string); isString { - var inner any - if json.Unmarshal([]byte(s), &inner) != nil { - return nil, nil, false - } - v = inner - } - switch t := v.(type) { - case map[string]any: - best := "" - for k := range t { - if (rest == k || strings.HasPrefix(rest, k+".")) && len(k) > len(best) { - best = k - } - } - if best == "" { - return nil, nil, false - } - keys, v, rest = append(keys, best), t[best], strings.TrimPrefix(strings.TrimPrefix(rest, best), ".") - case []any: - seg, after, _ := strings.Cut(rest, ".") - n, err := strconv.Atoi(seg) - if err != nil || n < 0 || n >= len(t) { - return nil, nil, false - } - keys, v, rest = append(keys, seg), t[n], after - default: - return nil, nil, false - } - } - return keys, v, true -} - -// valueAt is the value at path in a shown body, cut to shownValue, or why it is not shown. -func valueAt(body, path string) string { - if body == "" { - return "(none)" - } - _, v, ok := walk(body, path) - if !ok { - return "(value cut)" - } - s, isString := v.(string) - if !isString { - raw, _ := json.Marshal(v) - s = string(raw) - } - if r := []rune(s); len(r) > shownValue { - s = string(r[:shownValue-1]) + "…" - } - return s -} - -// fieldName is the differing field as a person reads it: its parent's name and its own, `PARENT[key]`, from whichever -// body reaches it; the path's end when neither does. -func fieldName(running, composed, path string) string { - for _, body := range []string{running, composed} { - if keys, _, ok := walk(body, path); ok && len(keys) > 0 { - if len(keys) == 1 { - return keys[0] - } - return keys[len(keys)-2] + "[" + keys[len(keys)-1] + "]" - } - } - return path -} diff --git a/go/compose/judge/judge_test.go b/go/compose/judge/judge_test.go deleted file mode 100644 index 701f197..0000000 --- a/go/compose/judge/judge_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package judge - -import ( - "slices" - "strings" - "testing" - "time" - - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// ADR 0567 §3, §4 (rows moved from the assigner's check of a pull request's placement, ADR 0559 §5): what a proposal -// changes by node, and the verdict of the answers: differences shown and failing nothing, a refusal and a silent node -// failing, a later answer judging again, the seed failing on any difference. -func TestJudge(t *testing.T) { - current := map[string]delivery.Placement{"anchor": {Node: "anchor", Modules: []delivery.PlacedModule{{Module: "mail"}}}, "server": {Node: "server"}} - laid := map[string]*delivery.Placement{ - "anchor": {Node: "anchor", Modules: []delivery.PlacedModule{{Module: "mail", Ports: map[string]int{"25": 2525}}, {Module: "web"}}}, - "laptop": {Node: "laptop", Modules: []delivery.PlacedModule{{Module: "web"}}}} - got, must, _ := Intended(nil, nil, current, laid, []string{"anchor", "laptop", "server"}, nil) - if !got["anchor"]["mail"] || !got["anchor"]["web"] || len(got["laptop"]) != 0 || !slices.Equal(must, []string{"anchor", "laptop"}) { - t.Errorf("intended = %v, must %v: want anchor's mail and web, nothing on the laptop that has no key, server untouched", got, must) - } - - at := time.Date(2026, 10, 27, 2, 0, 0, 0, time.UTC) - p := delivery.PlacementProposal{ID: "p-one", By: "op", Why: "move web", Written: at, Base: map[string]uint64{"anchor": 3, "laptop": 0}, - Must: []string{"anchor", "laptop"}} - revisions := map[string]uint64{"anchor": 7, "laptop": 8} - intended := map[string]map[string]bool{"anchor": {"web": true}} - same := func(rev uint64) *delivery.CandidateAnswer { - return &delivery.CandidateAnswer{Proposal: "p-one", Revision: rev, Verdict: delivery.CandidateSame, Same: []string{"mail"}} - } - differs := func(rev uint64, module string) *delivery.CandidateAnswer { - return &delivery.CandidateAnswer{Proposal: "p-one", Revision: rev, Verdict: delivery.CandidateDiffers, - Differs: []delivery.ModuleDiffers{{Module: module, Why: "the port differs"}}} - } - for _, row := range []struct { - name string - seed bool - answers map[string]*delivery.CandidateAnswer - pulsing map[string]bool - state string - words string - marked string - }{ - {"all the same", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": same(8)}, nil, delivery.VerdictPass, "", ""}, - {"a module added where intended", false, map[string]*delivery.CandidateAnswer{"anchor": {Proposal: "p-one", Revision: 7, Verdict: delivery.CandidateDiffers, - Added: []delivery.ModuleAdded{{Module: "web", Unissued: []string{"its own secret"}}}}, "laptop": same(8)}, nil, delivery.VerdictPass, "", ""}, - {"a difference not computed passes, marked", false, map[string]*delivery.CandidateAnswer{"anchor": differs(7, "mail"), "laptop": same(8)}, nil, delivery.VerdictPass, "", "mail"}, - {"the seed carries a difference, named", true, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": {Proposal: "p-one", Revision: 8, - Verdict: delivery.CandidateDiffers, Differs: []delivery.ModuleDiffers{{Module: "node-tools", Resource: "node-tools.runtime", Why: "the resource differs", - Bodies: []delivery.ResourceBodies{{ID: "node-tools.runtime", Paths: []string{"env.MESH_OWNED_PATHS./etc/resolv.conf", "env.MESH_TOOL_MODULES"}, - Running: `{"env":{"MESH_OWNED_PATHS":"{\"/etc/hosts\":\"hostname\",\"/etc/resolv.conf\":\"networkmanager (node-uplink)\"}"}}`, - Composed: `{"env":{"MESH_OWNED_PATHS":"{\"/etc/hosts\":\"hostname\",\"/etc/resolv.conf\":\"systemd-resolved (node-resolver)\"}","MESH_TOOL_MODULES":"… (cut at 8192 bytes)`}}}}}}, nil, delivery.VerdictPass, "", ""}, - {"a refusal", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": {Proposal: "p-one", Revision: 8, Verdict: delivery.CandidateRefused, - Refusals: []delivery.ModuleRefused{{Module: "web", Why: "no own secret"}}}}, nil, delivery.VerdictFail, "laptop refuses", ""}, - {"a node not answering, within its bound", false, map[string]*delivery.CandidateAnswer{"anchor": same(7)}, map[string]bool{"laptop": true}, delivery.VerdictWaits, "laptop (offline)", ""}, - {"an answer of an earlier revision, past its bound", false, map[string]*delivery.CandidateAnswer{"anchor": same(6), "laptop": same(8)}, nil, delivery.VerdictFail, "anchor did not answer", ""}, - {"a module held as today passes, shown in the brief", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": {Proposal: "p-one", Revision: 8, - Verdict: delivery.CandidateSame, Held: []delivery.ModuleRefused{{Module: "spiegel", Why: "a required value is unset"}}}}, nil, delivery.VerdictPass, "", ""}, - {"its later answer", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": same(8)}, nil, delivery.VerdictPass, "", ""}, - } { - q := p - q.Seed = row.seed - v := JudgeAnswers(q, intended, row.answers, revisions, map[string]bool{"anchor": true}, row.pulsing, at) - all := strings.Join(append(append([]string{}, v.Fails...), v.Notes...), "; ") - if v.State != row.state || !strings.Contains(all, row.words) { - t.Errorf("%s: %s, %q", row.name, v.State, all) - } - if row.seed && row.state == delivery.VerdictPass && !strings.Contains(Brief(q, v), "the seed carries: node-tools: MESH_OWNED_PATHS[/etc/resolv.conf] networkmanager (node-uplink) → (value cut) (and 1 more fields)") { - t.Errorf("%s: the brief does not name it: %s", row.name, Brief(q, v)) - } - if row.seed && row.state == delivery.VerdictPass { - whole := row.answers["laptop"].Differs[0] - whole.Bodies = []delivery.ResourceBodies{{ID: "r", Paths: []string{"env.MESH_OWNED_PATHS./etc/resolv.conf"}, - Running: `{"env":{"MESH_OWNED_PATHS":"{\"/etc/resolv.conf\":\"networkmanager (node-uplink)\"}"}}`, - Composed: `{"env":{"MESH_OWNED_PATHS":"{\"/etc/resolv.conf\":\"systemd-resolved (node-resolver)\"}"}}`}} - if got := differenceWords(whole); got != "node-tools: MESH_OWNED_PATHS[/etc/resolv.conf] networkmanager (node-uplink) → systemd-resolved (node-resolver)" { - t.Errorf("the difference is named %q", got) - } - } - if strings.HasPrefix(row.name, "a module held as today") && !strings.Contains(Brief(q, v), "stays as today: spiegel (held)") { - t.Errorf("%s: the brief does not show it: %s", row.name, Brief(q, v)) - } - if row.marked != "" && (!slices.Contains(v.Unintended["anchor"], row.marked) || !strings.Contains(Brief(q, v), "not computed: "+row.marked)) { - t.Errorf("%s: the difference is not marked: %+v, %s", row.name, v.Unintended, Brief(q, v)) - } - } -} diff --git a/go/compose/network.go b/go/compose/network.go deleted file mode 100644 index 6998bb8..0000000 --- a/go/compose/network.go +++ /dev/null @@ -1,307 +0,0 @@ -package compose - -import ( - "errors" - "fmt" - "slices" - "sort" - "strconv" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/overlay" -) - -// The private network: who is on it, where, and what they are called. Ported from mesh-controller -// cmd/mesh-controller/network.go at 6c2a8d62. - -// DefaultOverlayCIDR is the range the mesh allocates from when nothing says another. -const DefaultOverlayCIDR = "10.42.0.0/16" - -// composer is one composition over an Input: it reads Input, changes nothing in it, and keeps what the controller -// said on its standard error as lines to return. -type composer struct { - in Input - said []string - seen map[string]bool - // unhostable is, from the last look at the rest of the mesh, each module assigned to another node that this - // composition finds that node cannot run: what it would have offered is missing here (issue 1126, probe #532). - unhostable []string - - // What one composition looks at again and again, each worked out once over its Input, which it never changes (issue - // 1954: a node's plans resolved every machine of the mesh some two hundred times, 11.7 s of CPU for novox): who is - // on the private network, and the rest of the mesh as each node sees it. - onNetwork []Node - onNetworkSet bool - rest map[string]restOf - plans map[string]planOf -} - -// restOf is theRestOfTheMesh's answer for one excluded node, kept: what it found unhostable beside it. -type restOf struct { - world catalogue.World - passes Passes - left []LeftOut - unhostable []string - err error -} - -func newComposer(in Input) *composer { return &composer{in: in, seen: map[string]bool{}} } - -// say keeps a line the controller would have written to its log, once. -func (c *composer) say(format string, args ...any) { - line := fmt.Sprintf(format, args...) - if c.seen[line] { - return - } - c.seen[line] = true - c.said = append(c.said, line) -} - -// suffix is what the mesh's internal names end in: the Input's, and overlay.DefaultSuffix when it gives none. The package never calls -// overlay.Suffix or overlay.InternalName, which read the environment, so the assigner and the controller compose the same names -// from the same Input. -func (c *composer) suffix() string { - if v := strings.TrimPrefix(strings.TrimSpace(c.in.InternalSuffix), "."); v != "" { - return v - } - return overlay.DefaultSuffix -} - -// internalName is a node's name inside the mesh. -func (c *composer) internalName(node string) string { return node + "." + c.suffix() } - -// saidBeside is the lines already said and then every line the composer kept, once each, in the order they were found. -func (c *composer) saidBeside(lines []string) []string { - for _, l := range c.said { - if !slices.Contains(lines, l) { - lines = append(lines, l) - } - } - return lines -} - -// overlayRange is the range the mesh allocates node addresses from: the adopted tunnel's first (novox/hq ADR 0105), -// then the range genesis was told, then the default. -func (c *composer) overlayRange() string { - if a := c.in.Adopted; a != nil && a.Adopted { - return a.Tunnel.Range - } - if v := strings.TrimSpace(c.in.OverlayCIDR); v != "" { - return v - } - return DefaultOverlayCIDR -} - -// network builds the private network over the machines that resolved the module for it. A machine is on it because it -// was given the module, or because it is joining with a key and an address (novox/hq ADR 0169), or because its own set -// could not be resolved and it holds a place (a key and an address): that place is its last good one on the network, and -// it does not change because its modules failed, so every other machine keeps its path to it -- the hub above all -- and -// only its own declaration is refused (issue 1833). A machine refused with no place is left off. -func (c *composer) network(on map[string]bool, refused map[string]string) (*overlay.Generator, error) { - tunnels := c.in.Tunnels - carried := c.in.CarriedPeers - isJoining := map[string]bool{} - for _, name := range c.in.Joining { - isJoining[name] = true - } - var nodes []overlay.Node - for _, node := range c.in.sorted() { - p := node.Place - placed := p.Key != "" && p.Address != "" - _, wasRefused := refused[node.Name] - if !on[node.Name] && !((isJoining[node.Name] || wasRefused) && placed) { - continue - } - if !on[node.Name] && wasRefused && !isJoining[node.Name] { - c.say("%s could not be resolved and keeps its place on the private network at %s, so the others keep their path to it", - node.Name, p.Address) - } - n := overlay.Node{ - Name: node.Name, Key: p.Key, Endpoint: p.Endpoint, - Site: p.Site, Hub: p.Hub, Address: p.Address, - } - if t, takes := tunnels[node.Name]; takes && t.NodeAdopted { - // Only an adopted node is told to take the found unit over. A hub whose placement disagrees with the tunnel is - // refused, not composed. - if wrong := disagrees(p, t.Tunnel); wrong != "" { - return nil, fmt.Errorf("%s takes over the tunnel on %s and its placement disagrees with it: %s. "+ - "Re-place it — `overlay place %s --hub --endpoint :%d …` — and push again; "+ - "nothing was composed", node.Name, t.Interface, wrong, node.Name, t.Port) - } - n.TakesOver = &overlay.TakeOver{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU} - } - if p.Hub { - for _, cp := range carried { - n.Carried = append(n.Carried, overlay.Carried{Key: cp.PublicKey, Address: cp.Address}) - } - } - nodes = append(nodes, n) - } - if len(nodes) == 0 { - // Nobody was given it: an empty network is a legitimate mesh, not a broken one. - return overlay.Empty(), nil - } - g, err := overlay.From(nodes, c.overlayRange(), "") - if err != nil && len(refused) > 0 { - // The network is missing something, and some machines could not be resolved at all: almost always the same fact. - var who []string - for name, why := range refused { - who = append(who, fmt.Sprintf(" %s: %s", name, why)) - } - sort.Strings(who) - return nil, fmt.Errorf("%w\n\nand %d node(s) could not be resolved at all, which is "+ - "probably why:\n%s", err, len(refused), strings.Join(who, "\n")) - } - return g, err -} - -// Graph is the whole mesh's network, for showing it: the nodes on it and each one's peers. -func Graph(in Input) ([]overlay.Node, overlay.Graph, error) { - c := newComposer(in) - on, refused, err := c.whoResolves(overlay.Requirement) - if err != nil { - return nil, nil, err - } - g, err := c.network(on, refused) - if err != nil { - return nil, nil, err - } - return g.Nodes(), g.Graph(), nil -} - -// whoResolves is the machines whose resolution answers a requirement, and why the others did not: by what a module -// provides, not by its name, resolved rather than read from the assignments. -func (c *composer) whoResolves(requirement string) (map[string]bool, map[string]string, error) { - on := map[string]bool{} - // Why a node could not be resolved, kept rather than raised: one broken node must not stop the rest being described. - refused := map[string]string{} - for _, n := range c.in.sorted() { - resolved, err := c.planFor(n.Name) - switch { - case unresolvable(err): - refused[n.Name] = err.Error() - continue - case err != nil: - // Not a node that does not resolve: a question that went unanswered. - return nil, nil, fmt.Errorf("whether %s answers %q cannot be read: %w", n.Name, requirement, err) - } - for _, m := range resolved.Plan.Modules { - for _, offered := range m.Offers() { - if offered == requirement { - on[n.Name] = true - } - } - } - } - return on, refused, nil -} - -// Generators is what every node's declaration is composed with, over the whole mesh: the private network's generator -// (one generator now; the names and a resolver's zone file are facts a module asks for in its manifest). -func Generators(in Input) (map[string]catalogue.Generator, error) { - c := newComposer(in) - return c.generators() -} - -func (c *composer) generators() (map[string]catalogue.Generator, error) { - on, refused, err := c.whoResolves(overlay.Addressing) - if err != nil { - return nil, err - } - net, err := c.network(on, refused) - if err != nil { - return nil, err - } - return map[string]catalogue.Generator{overlay.Name: net}, nil -} - -// disagrees says how a node's placement differs from the tunnel it takes over — its address not the tunnel's, its -// endpoint not on the tunnel's port — or nothing when both agree. -func disagrees(p Place, t Tunnel) string { - var wrong []string - want := t.Address - if i := strings.Index(want, "/"); i >= 0 { - want = want[:i] - } - if p.Address != want { - wrong = append(wrong, fmt.Sprintf("its address is %s and the tunnel's is %s", orNothing(p.Address), want)) - } - if p.Reachable() && portOfEndpoint(p.Endpoint) != strconv.Itoa(t.Port) { - wrong = append(wrong, fmt.Sprintf("its endpoint %s is not on the tunnel's port %d", p.Endpoint, t.Port)) - } - return strings.Join(wrong, "; ") -} - -func orNothing(s string) string { - if s == "" { - return "unset" - } - return s -} - -// portOfEndpoint is the port in host:port, or empty. -func portOfEndpoint(endpoint string) string { - if i := strings.LastIndex(endpoint, ":"); i >= 0 { - return endpoint[i+1:] - } - return "" -} - -// whereEveryoneIs is each machine's name on the private network, for the ones on it. Resolved without consulting the -// rest of the mesh (it is called from that path), and refused without the catalogue. -func (c *composer) whereEveryoneIs() (map[string]string, error) { - if c.in.Catalogue == nil { - return nil, errors.New("asked where everyone is without the catalogue, which cannot be answered") - } - out := map[string]string{} - for _, p := range c.onTheNetwork() { - out[p.Name] = c.internalName(p.Name) - } - return out, nil -} - -// onTheNetwork is every placed machine that resolves the private network: has an address AND runs what puts it there -// (novox/hq issue 079). A machine that does not resolve keeps its place when it holds one (a key and an address), by the -// same rule as network(), so its name and its address stay where the tunnel to it does (issue 1833); one without is not -// counted, and said. -func (c *composer) onTheNetwork() []Node { - if !c.onNetworkSet { - c.onNetwork, c.onNetworkSet = c.onTheNetworkNow(), true - } - return slices.Clone(c.onNetwork) -} - -// onTheNetworkNow is onTheNetwork worked out: every placed machine's set resolved. -func (c *composer) onTheNetworkNow() []Node { - var out []Node - for _, n := range c.in.sorted() { - if n.Place.Address == "" { - continue - } - if len(n.Assigned) == 0 { - continue - } - got, err := catalogue.Resolve(c.in.Catalogue, n.Assigned, - catalogue.Node{Name: n.Name, Site: n.Place.Site, Capabilities: n.Capabilities}, - catalogue.World{Unchecked: true, Holdings: c.in.Holdings}) - if err != nil { - if n.Place.Key != "" { - c.say("%s could not be resolved and keeps its place on the private network at %s, so the others keep their path to it", - n.Name, n.Place.Address) - out = append(out, n) - continue - } - c.say("%s is not counted as on the network: it does not resolve: %v", n.Name, err) - continue - } - for _, m := range got.Modules { - for _, offered := range m.Offers() { - if offered == overlay.Requirement { - out = append(out, n) - } - } - } - } - return out -} diff --git a/go/compose/network_test.go b/go/compose/network_test.go deleted file mode 100644 index ff73e08..0000000 --- a/go/compose/network_test.go +++ /dev/null @@ -1,306 +0,0 @@ -package compose - -import ( - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/overlay" -) - -// onTheTunnel makes the hub of an Input what mesh-controller's network_test.go makes of anchor in -// TestTheOverlaysRangeIsTheAdoptedTunnels: adopted, enrolled with the found tunnel's key, and presenting the tunnel -// (Tunnel{wg0, wg-quick@wg0, port 51900, 192.0.2.1/24, range 192.0.2.0/24, one peer PEER-TWO}). Placed on it when -// replaced is true: its address the tunnel's, its endpoint on the tunnel's port, as `overlay place anchor --endpoint -// 198.51.100.10:51900 --site hosting --hub` leaves it. -func onTheTunnel(t *testing.T, in *Input, replaced bool) { - t.Helper() - const key = "THE-TUNNELS-KEY=========================" - tunnel := Tunnel{Interface: "wg0", Unit: "wg-quick@wg0", Config: "/etc/wireguard/wg0.conf", Port: 51900, - Address: "192.0.2.1/24", Range: "192.0.2.0/24", PublicKey: key, - Peers: []TunnelPeer{{PublicKey: "PEER-TWO", Address: "192.0.2.2"}}} - hub := nodeOf(t, in, "anchor") - hub.Adopted = true - hub.Place.Key = key - if replaced { - hub.Place.Address, hub.Place.Endpoint, hub.Place.Site = "192.0.2.1", "198.51.100.10:51900", "hosting" - } - in.Tunnels = map[string]FoundTunnel{"anchor": {Tunnel: tunnel, NodeAdopted: true}} - in.Adopted = &AdoptedTunnel{Tunnel: tunnel, Hub: "anchor", Adopted: true} - in.CarriedPeers = []CarriedPeer{{PublicKey: "PEER-TWO", Address: "192.0.2.2"}} -} - -// novox/hq ADR 0105 (mesh-controller network_test.go TestTheOverlaysRangeIsTheAdoptedTunnels): the range every address is -// composed from is the adopted tunnel's, read from the tunnel the hub holds, never stored anywhere else. The environment -// value of the controller is Input.OverlayCIDR here. -func TestTheOverlaysRangeIsTheAdoptedTunnels(t *testing.T) { - in := aMesh(t) - in.OverlayCIDR = "10.99.0.0/16" - if got := newComposer(in).overlayRange(); got != "10.99.0.0/16" { - t.Fatalf("without an adopted tunnel the range is not what genesis said: %q", got) - } - in.OverlayCIDR = "" - if got := newComposer(in).overlayRange(); got != DefaultOverlayCIDR { - t.Fatalf("with nothing said the range is %q, not the default", got) - } - in.OverlayCIDR = "10.99.0.0/16" - onTheTunnel(t, &in, true) - if got := newComposer(in).overlayRange(); got != "192.0.2.0/24" { - t.Fatalf("with an adopted tunnel the range is %q, not the tunnel's", got) - } - // A tunnel the hub found and did not take (its key is not the tunnel's) does not give its range. - in.Adopted.Adopted = false - if got := newComposer(in).overlayRange(); got != "10.99.0.0/16" { - t.Fatalf("a tunnel the hub did not take gave its range: %q", got) - } - in.Adopted.Adopted = true - - // And the hub's declaration carries the peer and the takeover. - nodes, computed, err := Graph(in) - if err != nil { - t.Fatal(err) - } - var hubNode overlay.Node - for _, n := range nodes { - if n.Name == "anchor" { - hubNode = n - } - } - if hubNode.TakesOver == nil || hubNode.TakesOver.Unit != "wg-quick@wg0" { - t.Errorf("the hub is not told to take over the found tunnel: %+v", hubNode) - } - carried := false - for _, p := range computed["anchor"] { - if p.Key == "PEER-TWO" && p.Allowed == "192.0.2.2/32" { - carried = true - } - } - if !carried { - t.Errorf("the hub's peer list does not carry the tunnel's peer: %+v", computed["anchor"]) - } -} - -// A takeover is composed only for a hub whose placement agrees with the tunnel (mesh-controller network_test.go -// TestATakeoverIsNotComposedForAHubPlacedOffItsTunnel): an address or an endpoint port that differs would have the host stop -// the found interface and raise the mesh's where no peer is listening. -func TestATakeoverIsNotComposedForAHubPlacedOffItsTunnel(t *testing.T) { - in := aMesh(t) - onTheTunnel(t, &in, false) - // aMesh placed anchor at 10.77.0.1 on :51820, the record of a hub placed before it took the tunnel over. - _, _, err := Graph(in) - if err == nil { - t.Fatal("a takeover was composed for a hub whose address and port are not the tunnel's") - } - for _, want := range []string{"10.77.0.1", "192.0.2.1", "51820", "51900", "overlay place anchor"} { - if !strings.Contains(err.Error(), want) { - t.Errorf("the refusal does not say %q: %v", want, err) - } - } - // Re-placed on the tunnel, it composes. - onTheTunnel(t, &in, true) - if _, _, err := Graph(in); err != nil { - t.Fatalf("re-placed on the tunnel, the graph still refuses: %v", err) - } -} - -// 1360/AC2: the generator gives the addresses, peers and names of aMesh: anchor the hub at 10.77.0.1, laptop at 10.77.0.2 -// peering through it, both written for their machines. -func TestTheGeneratorGivesTheMeshItsAddressesAndPeers(t *testing.T) { - in := aMesh(t) - gens, err := Generators(in) - if err != nil { - t.Fatal(err) - } - g, ok := gens[overlay.Name].(*overlay.Generator) - if !ok { - t.Fatalf("the generator is %T", gens[overlay.Name]) - } - addr := map[string]string{} - for _, n := range g.Nodes() { - addr[n.Name] = n.Address - } - if addr["anchor"] != "10.77.0.1" || addr["laptop"] != "10.77.0.2" || len(addr) != 2 { - t.Fatalf("the addresses are %v", addr) - } - peers := g.Graph() - if len(peers["laptop"]) != 1 || peers["laptop"][0].Name != "anchor" || peers["laptop"][0].Endpoint != "anchor.example:51820" { - t.Errorf("laptop's peers are %+v, want the hub", peers["laptop"]) - } - if len(peers["anchor"]) != 1 || peers["anchor"][0].Name != "laptop" || peers["anchor"][0].Allowed != "10.77.0.2/32" { - t.Errorf("anchor's peers are %+v, want laptop", peers["anchor"]) - } - for _, node := range []string{"anchor", "laptop"} { - res, on, err := g.Resources(node) - if err != nil || !on || len(res) == 0 { - t.Errorf("%s is given no network: %v %v %d", node, on, err, len(res)) - } - } -} - -// 1360/AC3: a node not on the private network (it runs nothing that puts it there) gets no address and no network, and the -// one that stayed keeps its own. -func TestANodeNotOnThePrivateNetworkGetsNoAddressAndNoNetwork(t *testing.T) { - in := aMesh(t) - nodeOf(t, &in, "laptop").Assigned = nil // it keeps its place and its address, and stops running the network - gens, err := Generators(in) - if err != nil { - t.Fatal(err) - } - g := gens[overlay.Name].(*overlay.Generator) - if len(g.Nodes()) != 1 || g.Nodes()[0].Name != "anchor" { - t.Fatalf("the network is over %+v, want anchor alone", g.Nodes()) - } - if res, on, err := g.Resources("laptop"); on || err != nil || len(res) != 0 { - t.Errorf("a machine that left the network is given one: %v %v %v", res, on, err) - } - if _, in := g.Graph()["laptop"]; in { - t.Error("a machine that left the network has peers") - } -} - -// 1360/AC3: a node whose resolution is refused is said with today's words: the network missing its hub names the -// machine that could not be resolved, which is almost always why. The hub here holds no complete place (no key): a refused -// machine with one keeps it on the network (issue 1833), so only one without is missing from it. -func TestARefusedNodeIsSaidWhenTheNetworkCannotBeComposed(t *testing.T) { - in := aMesh(t) - nodeOf(t, &in, "anchor").Place.Key = "" - one := catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} - two := catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} - in.Catalogue["rival-one"], in.Catalogue["rival-two"] = one, two - hub := nodeOf(t, &in, "anchor") - hub.Assigned = append(hub.Assigned, "rival-one", "rival-two") // its own set is incoherent: the hub does not resolve - _, err := Generators(in) - if err == nil { - t.Fatal("a network without its hub was composed") - } - for _, want := range []string{overlay.ErrNoHub.Error(), "and 1 node(s) could not be resolved at all, which is probably why:", " anchor: "} { - if !strings.Contains(err.Error(), want) { - t.Errorf("the refusal does not say %q:\n%v", want, err) - } - } -} - -// 1360/AC4: Generators changes nothing in its Input. -func TestGeneratorsChangesNothingInItsInput(t *testing.T) { - unchanged(t, func() Input { - in := aMesh(t) - onTheTunnel(t, &in, true) - in.Joining = []string{"laptop"} - return in - }, func(in Input) { - if _, err := Generators(in); err != nil { - t.Fatal(err) - } - }) -} - -// Each field of Input that the network reads reaches the generator: change it and the generator changes. -func TestEachFieldOfTheInputReachesTheGenerator(t *testing.T) { - addressesOf := func(in Input) map[string]string { - t.Helper() - gens, err := Generators(in) - if err != nil { - t.Fatalf("%v", err) - } - out := map[string]string{} - for _, n := range gens[overlay.Name].(*overlay.Generator).Nodes() { - out[n.Name] = n.Address - } - return out - } - base := addressesOf(aMesh(t)) - if len(base) != 2 { - t.Fatalf("the base mesh has %v", base) - } - t.Run("Catalogue: without the module offering it nobody is on the network", func(t *testing.T) { - in := aMesh(t) - // The module stays known, so every machine still resolves: a machine that does not resolve keeps its place (issue 1833). - in.Catalogue = map[string]catalogue.Manifest{overlay.Name: {Module: overlay.Name, Version: "1"}} - if got := addressesOf(in); len(got) != 0 { - t.Errorf("got %v", got) - } - }) - t.Run("Nodes.Place: an address", func(t *testing.T) { - in := aMesh(t) - nodeOf(t, &in, "laptop").Place.Address = "10.77.0.9" - if got := addressesOf(in); got["laptop"] != "10.77.0.9" { - t.Errorf("got %v", got) - } - }) - t.Run("Nodes.Assigned", func(t *testing.T) { - in := aMesh(t) - nodeOf(t, &in, "laptop").Assigned = nil - if got := addressesOf(in); len(got) != 1 { - t.Errorf("got %v", got) - } - }) - t.Run("Nodes.Capabilities: a capability the module needs", func(t *testing.T) { - in := aMesh(t) - m := in.Catalogue[overlay.Name] - m.Capabilities = []string{"wireguard"} - in.Catalogue[overlay.Name] = m - nodeOf(t, &in, "laptop").Capabilities = map[string]bool{} - got, err := Generators(in) - if err != nil { - t.Fatal(err) - } - if n := got[overlay.Name].(*overlay.Generator).Nodes(); len(n) != 1 || n[0].Name != "anchor" { - t.Errorf("a machine without the capability is on the network: %+v", n) - } - }) - t.Run("OverlayCIDR", func(t *testing.T) { - in := aMesh(t) - in.OverlayCIDR = "10.99.0.0/16" - _, graph, err := Graph(in) - if err != nil { - t.Fatal(err) - } - found := false - for _, p := range graph["laptop"] { - found = found || p.Allowed == "10.99.0.0/16" - } - if !found { - t.Errorf("the hub's route is not the range given: %+v", graph["laptop"]) - } - }) - t.Run("Tunnels and Adopted and CarriedPeers", func(t *testing.T) { - in := aMesh(t) - onTheTunnel(t, &in, true) - nodes, graph, err := Graph(in) - if err != nil || len(nodes) != 2 { - t.Fatalf("%v %v", nodes, err) - } - if len(graph["anchor"]) < 2 { - t.Errorf("the hub's peers do not include the carried one: %+v", graph["anchor"]) - } - }) - t.Run("Joining: a machine with a key and an address is on the network before it is anything else", func(t *testing.T) { - in := aMesh(t) - in.Nodes = append(in.Nodes, Node{Name: "newcomer", Place: Place{Key: keyOf(t, "newcomer"), Address: "10.77.0.3"}}) - if got := addressesOf(in); got["newcomer"] != "" { - t.Fatalf("a machine that runs nothing is on the network: %v", got) - } - in.Joining = []string{"newcomer"} - if got := addressesOf(in); got["newcomer"] != "10.77.0.3" { - t.Errorf("a joining machine is not on the network: %v", got) - } - }) - t.Run("Holdings: a holder on record settles a seat two assignments could hold", func(t *testing.T) { - withSeat(t) - in := aMesh(t) - for _, name := range []string{"holder-one", "holder-two"} { - in.Catalogue[name] = aBrokerClaiming(name) - } - laptop := nodeOf(t, &in, "laptop") - laptop.Assigned = append(laptop.Assigned, "holder-one", "holder-two") - // Without a key: a machine that does not resolve and holds a complete place keeps it on the network (issue 1833). - laptop.Place.Key = "" - if got := addressesOf(in); got["laptop"] != "" { - t.Fatalf("a machine whose set claims one seat twice is on the network: %v", got) - } - in.Holdings = []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "laptop", Module: "holder-one"}} - if got := addressesOf(in); got["laptop"] != "10.77.0.2" { - t.Errorf("a holder on record did not settle the seat: %v", got) - } - }) -} diff --git a/go/compose/node.go b/go/compose/node.go deleted file mode 100644 index 81e8557..0000000 --- a/go/compose/node.go +++ /dev/null @@ -1,351 +0,0 @@ -package compose - -import ( - "encoding/json" - "fmt" - "sort" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/validate" -) - -// Declared is a declaration as a machine is sent it (the controller's sendable, sendable.go): its resources and, for an adopted -// node, its mode and which modules were taken on it (novox/hq ADR 0100), with what the composition says beside it and never puts on -// the wire. -// -// **Body is the only place the envelope is marshalled**, so the digest the mesh compares and the body validated are one. -type Declared struct { - Resources []map[string]any - // Sequence, Epoch and Generation are what the Input gave the node: zero is not sent at all. - Sequence int64 - Epoch uint64 - Generation int64 - // ReadsModules says the machine's node-engine reads a module per resource and a build per module (novox/hq issue 1145): only - // then are they sent. ModuleOf is the composer's owner of each resource id and BuildIDs the build each module carries. - ReadsModules bool - ModuleOf map[string]string - BuildIDs map[string]string - // Adoption is nil for a converged node, and then the body is byte for byte what it was before adoption existed. - Adoption *Adoption - // Filled is the path of every file this declaration hands to the provisioner, and Provisioner the account its client runs as - // (novox/hq ADR 0349): on the wire as `filled_files` and `provisioner_account`, both absent when nothing is handed. - Filled []string - Provisioner string - // LeftOut is every module of the machine's set left out because a stored setting cannot compose with its definition (novox/hq - // ADR 0163, rule 6), sorted; LeftOutWhy is why each was. - LeftOut []string - LeftOutWhy map[string]string - - // Not on the wire: - Received map[string]map[string][]catalogue.Contribution - Mesh []string - BusUsers string - // Modules are the modules this declaration names, sorted. - Modules []string - // LocalNetwork is every port admitted from the local network and what it resolved to (novox/hq ADR 0340). - LocalNetwork []catalogue.LocalNetworkPort - Withheld []catalogue.Overflow - Unbound []catalogue.Unbound - Unplaced []string - HandedOnly []string - // RequiredBy is, for each handed module only pulled in by another, the module that requires it (issue 765). - RequiredBy map[string]string - // Foreseen is every own secret composed with a stand-in, as `module/name`; StoodIn is everything the composition stood in for - // (an own secret, a grant's sealed copy, the certificate, the bus's user list); ForeseenPorts the ports the mesh may assign that - // the Input holds none for. A declaration that has any is never sent: what a send would make, named, never invented. - Foreseen []string - StoodIn []string - ForeseenPorts []string - // CoreBoxesWanted are the core credentials whose box the send must ask the vault for and keep. - CoreBoxesWanted []CoreBoxWanted - // Passes are the seats the mesh would pass on, for the caller to record, and Said the lines found while composing. - Passes Passes - Said []string -} - -// Adoption is what an adopted node is told about its mode. Taken is every module taken on it that it runs; Untaken is, for every -// module it runs that is not taken, the ids of every one of that module's resources: what the host keeps as found until the module -// is taken (ADR 0103). -type Adoption struct { - Taken []string `json:"taken"` - Untaken map[string][]string `json:"untaken,omitempty"` -} - -// Body is the declaration's bytes, as sent and as digested. -func (d Declared) Body() ([]byte, error) { - envelope := map[string]any{"declaration": 1, "resources": d.Resources} - if d.ReadsModules { - // The module of each resource the composer owns, and the build of each module (novox/hq issue 1145): to a machine that - // said it reads them, and no other. - named := make([]map[string]any, len(d.Resources)) - for n, r := range d.Resources { - module, owned := d.ModuleOf[fmt.Sprint(r["id"])] - if !owned { - named[n] = r - continue - } - with := make(map[string]any, len(r)+1) - for k, v := range r { - with[k] = v - } - with["module"] = module - named[n] = with - } - envelope["resources"] = named - if len(d.BuildIDs) > 0 { - envelope["builds"] = d.BuildIDs - } - } - if d.Adoption != nil { - envelope["adoption"] = d.Adoption - } - if d.Sequence > 0 { - envelope["sequence"] = d.Sequence - } - if d.Epoch > 0 { - envelope["epoch"] = d.Epoch - } - if len(d.LeftOut) > 0 { - envelope["left_out"] = d.LeftOut - } - if d.Generation > 0 { - envelope["generation"] = d.Generation - } - if len(d.Filled) > 0 { - envelope["filled_files"] = d.Filled - envelope["provisioner_account"] = d.Provisioner - } - // An empty declaration is deliberate: the node owns nothing the mesh put there (novox/hq issue 127), and the host refuses an - // empty body unless it is told the emptiness is meant. - if len(d.Resources) == 0 { - envelope["owns_nothing"] = true - } - return json.Marshal(envelope) -} - -// Refusals are the findings a check refuses a change for, beside the validator's problems: a consumer left out of the grants -// because its identity overflows the provision's bound, and a credential bound to another consumer (novox/hq ADR 0446 clause 1). -// Each names the consumer and the provision. -func (d Declared) Refusals() []string { - var out []string - for _, o := range d.Withheld { - out = append(out, o.String()) - } - for _, u := range d.Unbound { - out = append(out, u.String()) - } - sort.Strings(out) - return out -} - -// Compose composes one node's declaration (the issue calls it Node; the Input already has a type of that name) over an Input -- as a push would, without the sending (the controller's -// composedAndValidated in the Foreseeing mode) -- and runs the node-engine's own validator over its body. An error is that the -// node did not compose (a set that does not compose satisfies IsUnresolvable); problems are what the validator refuses, in the -// node-engine's words. It allocates, makes, keeps and writes nothing, and names what a send would do as foreseen. -func Compose(in Input, node string) (Declared, []string, error) { - return ComposeIn(in, node, Foreseeing) -} - -// ComposeIn is Compose in a chosen mode. -func ComposeIn(in Input, node string, mode Mode) (Declared, []string, error) { - c := newComposer(in) - d, err := c.declaration(node, mode) - if err != nil { - return Declared{}, nil, err - } - body, err := d.Body() - if err != nil { - return Declared{}, nil, err - } - return d, validate.Declaration(body), nil -} - -// declaration is the controller's declarationWith over the plain Input. -func (c *composer) declaration(nodeName string, mode Mode) (Declared, error) { - resolved, err := c.planFor(nodeName) - if err != nil { - return Declared{}, err - } - gens, err := c.generators() - if err != nil { - return Declared{}, err - } - rendered, err := c.render(nodeName, mode) - if err != nil { - return Declared{}, err - } - with := rendered.Rendering - with.Generators = gens - plan := resolved.Plan - composed, err := plan.Compose(with) - if err != nil { - return Declared{}, err - } - // Which ports the filter admits from the local network, and from which networks (novox/hq ADR 0340 clause 8): the same rules the - // composition rendered, never sent. - rules, err := plan.Rules(with) - if err != nil { - return Declared{}, err - } - node, err := c.in.node(nodeName) - if err != nil { - return Declared{}, err - } - out := Declared{Resources: composed.Resources, Adoption: adoptionFor(plan, node, composed), - Received: composed.Received, Mesh: with.Mesh, BusUsers: with.BusUsers, - LeftOut: sortedKeysOf(composed.LeftOut), LeftOutWhy: composed.LeftOut, - Withheld: rendered.Withheld, Unbound: rendered.Unbound, - Foreseen: composed.Foreseen, StoodIn: rendered.Foreseen, ForeseenPorts: rendered.ForeseenPorts, - Unplaced: composed.Unplaced, HandedOnly: composed.HandedOnly, Modules: namedModules(plan, composed.LeftOut), - Filled: composed.Filled, LocalNetwork: catalogue.LocalNetworkPorts(rules, with.LocalNetworks), - CoreBoxesWanted: rendered.CoreBoxesWanted, Said: rendered.Said, - Sequence: node.Sequence, Epoch: node.Epoch, Generation: node.Generation} - if len(composed.Filled) > 0 { - out.Provisioner = with.Provisioner - } - // A handed module only pulled in by another keeps what requires it, for its value of its own in the assignments mirror (issue 765). - for module := range composed.Plans { - if by := plan.RequiredBy(module); by != "" { - if out.RequiredBy == nil { - out.RequiredBy = map[string]string{} - } - out.RequiredBy[module] = by - } - } - // And the module of each resource and the build of each module, to a machine whose node-engine said it reads them (novox/hq issue - // 1145) and to no other. - if node.ReadsModules { - carried := c.carriedOn(node, plan, composed.LeftOut) - out.ReadsModules, out.ModuleOf, out.BuildIDs = true, composed.Owner, c.wireBuilds(carried, node.Runs) - } - _, passes, left, err := c.theRestOfTheMesh("") - if err != nil { - return Declared{}, err - } - out.Passes = passes - for _, l := range left { - out.Said = append(out.Said, l.String()) - } - out.Said = c.saidBeside(out.Said) - return out, nil -} - -// carriedOn is the build of each module a declaration for the node carries (plan.go carriedOn, in a question that sends nothing): the -// module's current build for each module in it, and for a module left out of it the build it was last sent, since the machine keeps -// that one -- or nothing, when that is not known. -func (c *composer) carriedOn(node Node, plan catalogue.Resolution, leftOut map[string]string) map[string]string { - before := node.SentBuilds - if !node.SentKnown { - before = nil - } - out := map[string]string{} - for _, m := range plan.Modules { - if _, left := leftOut[m.Module]; left { - if was, kept := before[m.Module]; kept { - out[m.Module] = was - } - continue - } - out[m.Module] = c.in.CurrentBuilds[m.Module] - // A module kept at the build the machine runs is carried at that build (plan.go carriedOn). - if kept, ok := node.KeptBuilds[m.Module]; ok && kept != "" { - out[m.Module] = kept - } - } - return out -} - -// wireBuilds is the build of each carried module that goes on the wire (novox/hq issue 1145): the build its assignment names when -// that assignment is of the commit carried, else the build made from that commit; a module with neither is not named. -func (c *composer) wireBuilds(carried map[string]string, runs map[string]Run) map[string]string { - out := map[string]string{} - for module, commit := range carried { - if commit == "" { - continue - } - if ref, ok := runs[module]; ok && ref.Commit == commit && ref.Build != "" { - out[module] = ref.Build - continue - } - if build := c.in.BuildIDs[module][commit]; build != "" { - out[module] = build - } - } - return out -} - -// adoptionFor is the envelope computed from what was taken and who owns each resource: nil for a converged node. Every module the -// node runs that is not taken is untaken -- including one pulled in by another rather than assigned. -func adoptionFor(plan catalogue.Resolution, node Node, composed catalogue.Composed) *Adoption { - if !node.Adopted { - return nil - } - isTaken := map[string]bool{} - for _, m := range node.Taken { - isTaken[m] = true - } - out := &Adoption{Taken: []string{}} - runs := map[string]bool{} - for _, m := range plan.Modules { - runs[m.Module] = true - if isTaken[m.Module] { - out.Taken = append(out.Taken, m.Module) - } - } - sort.Strings(out.Taken) - for _, r := range composed.Resources { - id, _ := r["id"].(string) - module, owned := composed.Owner[id] - // Every kind, not only files and containers (novox/hq ADR 0103). What the mesh declares of its own is never held. - if !owned || !runs[module] || isTaken[module] || strings.HasPrefix(id, catalogue.AdoptionPrefix) { - continue - } - if out.Untaken == nil { - out.Untaken = map[string][]string{} - } - out.Untaken[module] = append(out.Untaken[module], id) - } - return out -} - -// sortedKeysOf is a map's keys, sorted: so what a declaration says it left out does not move for a reordering nobody made. -func sortedKeysOf(m map[string]string) []string { - if len(m) == 0 { - return nil - } - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - sort.Strings(out) - return out -} - -// namedModules are the modules a declaration names: its machine's set, less what was left out of it. -func namedModules(plan catalogue.Resolution, leftOut map[string]string) []string { - out := make([]string, 0, len(plan.Modules)) - for _, m := range plan.Modules { - if _, left := leftOut[m.Module]; !left { - out = append(out, m.Module) - } - } - sort.Strings(out) - return out -} - -// DataItems is every data item the modules placed on a node declare, placed there (novox/hq ADR 0571 clause 3): what the -// node-backup holder is handed as `${backup:data}`, composed over the Input as Compose composes the node, so disk-load -// reads the list from the bus with no composed file. Unplaced names each item left out and why. -func DataItems(in Input, node string) (map[string][]catalogue.PlacedDataItem, []string, error) { - c := newComposer(in) - resolved, err := c.planFor(node) - if err != nil { - return nil, nil, err - } - rendered, err := c.render(node, Planning) - if err != nil { - return nil, nil, err - } - return resolved.Plan.DataItems(rendered.Rendering) -} diff --git a/go/compose/node_test.go b/go/compose/node_test.go deleted file mode 100644 index c2485dc..0000000 --- a/go/compose/node_test.go +++ /dev/null @@ -1,424 +0,0 @@ -package compose - -import ( - "encoding/json" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/overlay" -) - -func resourceIDs(d Declared) []string { - var ids []string - for _, r := range d.Resources { - ids = append(ids, r["type"].(string)+":"+r["id"].(string)) - } - return ids -} - -func has(ids []string, want string) bool { - for _, id := range ids { - if id == want { - return true - } - } - return false -} - -// aSmallMesh is three nodes, a provider and its consumer, a seat and the private network: aMesh with `ace` running a database and -// a resolver holding the mesh's resolver seat on record (the providers and consumer of mesh-controller's facts_test.go -// aMeshWithSecrets, the seat of bindings_test.go storeManifests). -func aSmallMesh(t *testing.T) Input { - t.Helper() - in := threeNodes(t) - for _, m := range storeManifests() { - if m.Module == "resolver" { - in.Catalogue[m.Module] = m - } - } - ace := nodeOf(t, &in, "ace") - ace.Assigned = append(ace.Assigned, "resolver") - in.Holdings = []catalogue.Held{{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "ace", Module: "resolver", Site: "there"}} - return in -} - -// 1298/AC1: each node of a small mesh composes, its body is accepted by the node-engine's validator, and it names the resources the -// controller composes for the same case: the private network's files on the nodes that run it, the provider's received file, the -// consumer's file. -func TestEachNodeOfASmallMeshComposesAndItsBodyIsAcceptedByTheValidator(t *testing.T) { - in := aSmallMesh(t) - want := map[string][]string{ - "anchor": {"package:mesh-wireguard.overlay-tools", "file:mesh-wireguard.overlay-config", "service:mesh-wireguard.overlay-up", "file:objects.received-s3-bucket"}, - "laptop": {"package:mesh-wireguard.overlay-tools", "file:mesh-wireguard.overlay-config", "service:mesh-wireguard.overlay-up", "file:files.config"}, - "ace": nil, - } - for node, ids := range want { - d, problems, err := Compose(in, node) - if err != nil || len(problems) != 0 { - t.Fatalf("%s: %v %v", node, err, problems) - } - for _, id := range ids { - if !has(resourceIDs(d), id) { - t.Errorf("%s does not name %s: %v", node, id, resourceIDs(d)) - } - } - body, err := d.Body() - if err != nil { - t.Fatal(err) - } - var parsed struct { - Declaration int `json:"declaration"` - Resources []map[string]any `json:"resources"` - } - if err := json.Unmarshal(body, &parsed); err != nil || parsed.Declaration != 1 || len(parsed.Resources) != len(d.Resources) { - t.Errorf("%s: body %s %v", node, body, err) - } - } - // ace runs a resolver and a database and nothing the mesh declares of its own: an empty declaration says it owns nothing. - d, _, _ := Compose(in, "ace") - if body, _ := d.Body(); !strings.Contains(string(body), `"owns_nothing":true`) { - t.Errorf("an empty declaration is not said to be deliberate: %s", body) - } -} - -// 1298/AC2 (mesh-controller merge_gate_integration_test.go TestIssue236AManifestTheNodeEngineRefusesFailsThePullRequest, the manifest): -// a service that omits its state is accepted by the catalogue's check and refused by the node-engine; Compose returns the validator's -// problems in the node-engine's words and no error. -func TestAManifestTheNodeEngineRefusesComesBackAsProblemsAndNoError(t *testing.T) { - in := aMesh(t) - var lemurs catalogue.Manifest - if err := json.Unmarshal([]byte(`{"module":"lemurs","version":"1","capabilities":["systemd"], - "resources":[{"id":"service","type":"service","unit":"lemurs.service","boot":"enabled"}]}`), &lemurs); err != nil { - t.Fatal(err) - } - in.Catalogue["lemurs"] = lemurs - laptop := nodeOf(t, &in, "laptop") - laptop.Assigned = append(laptop.Assigned, "lemurs") - d, problems, err := Compose(in, "laptop") - if err != nil { - t.Fatalf("a refusal of the validator is an error: %v", err) - } - if len(problems) == 0 || !strings.Contains(strings.Join(problems, "\n"), "lemurs.service") { - t.Fatalf("problems are %v", problems) - } - if !has(resourceIDs(d), "service:lemurs.service") { - t.Errorf("the declaration does not carry the resource: %v", resourceIDs(d)) - } - // And the same, as the validator says it of the body. - body, _ := d.Body() - if again := strings.Join(problems, "\n"); again == "" || !strings.Contains(string(body), "lemurs.service") { - t.Errorf("the body does not name the service: %s", body) - } -} - -// 1298/AC3: a node whose own set does not compose returns an error and no declaration, told apart from an unreadable Input. -func TestANodeWhoseSetDoesNotComposeReturnsAnErrorAndNoDeclaration(t *testing.T) { - in := aMesh(t) - in.Catalogue["rival-one"] = catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} - in.Catalogue["rival-two"] = catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} - laptop := nodeOf(t, &in, "laptop") - laptop.Assigned = append(laptop.Assigned, "rival-one", "rival-two") - d, problems, err := Compose(in, "laptop") - if err == nil || !IsUnresolvable(err) || d.Resources != nil || problems != nil { - t.Fatalf("a set that does not compose: %v %v %v", d.Resources, problems, err) - } - if _, _, err := Compose(in, "nowhere"); err == nil || IsUnresolvable(err) { - t.Fatalf("a node the Input does not have: %v", err) - } -} - -// 1298/AC4: composing the same Input twice gives byte-identical bodies, and the Input is unchanged. -func TestComposingTwiceGivesTheSameBytesAndChangesNothing(t *testing.T) { - mk := func() Input { - in := aSmallMesh(t) - nodeOf(t, &in, "anchor").Account = "jochens" - nodeOf(t, &in, "laptop").Sequence = 7 - return in - } - in := mk() - for _, node := range []string{"anchor", "laptop", "ace"} { - a, _, err := Compose(in, node) - if err != nil { - t.Fatal(err) - } - b, _, err := Compose(in, node) - if err != nil { - t.Fatal(err) - } - ab, _ := a.Body() - bb, _ := b.Body() - if string(ab) != string(bb) { - t.Errorf("%s: two compositions differ:\n%s\n%s", node, ab, bb) - } - } - unchanged(t, mk, func(in Input) { - for _, node := range []string{"anchor", "laptop", "ace"} { - if _, _, err := Compose(in, node); err != nil { - t.Fatal(err) - } - } - }) -} - -// 1298/AC5: what a send would make is named in the result as foreseen and never invented: an own secret, a port, a core box, a grant's -// sealed copy. -func TestWhatASendWouldMakeIsNamedAsForeseenAndNeverInvented(t *testing.T) { - in := aGrantMesh(t) - in.Issued[0].ForProvider = "" - in.Catalogue["assistant"] = anAssistant() - in.Catalogue["web"] = aWebModule() - in.Catalogue["nats"] = catalogue.Manifest{Module: "nats", Version: "1", - OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/nats/broker", Taken: catalogue.TakenAtStart}}} - anchor := nodeOf(t, &in, "anchor") - anchor.Assigned = append(anchor.Assigned, "assistant", "web", "nats") - anchor.SealingKey = keyOf(t, "anchor") - anchor.Stated, anchor.Contract = true, VaultBoxContract - in.BusMinted = []string{"anchor.nats"} - d, problems, err := Compose(in, "anchor") - if err != nil || len(problems) != 0 { - t.Fatalf("%v %v", err, problems) - } - if !has(d.Foreseen, "assistant/password") || !has(d.Foreseen, "nats/broker") { - t.Errorf("own secrets foreseen: %v", d.Foreseen) - } - if len(d.ForeseenPorts) != 1 || d.ForeseenPorts[0] != "web:8080" { - t.Errorf("foreseen ports: %v", d.ForeseenPorts) - } - if len(d.CoreBoxesWanted) != 1 || d.CoreBoxesWanted[0].Module != "nats" { - t.Errorf("core boxes wanted: %v", d.CoreBoxesWanted) - } - if !has(d.StoodIn, "the grant of s3-bucket for files on laptop") { - t.Errorf("stood in for: %v", d.StoodIn) - } - body, _ := d.Body() - if strings.Contains(string(body), secretValue) { - t.Error("a secret value is in the body") - } -} - -// 1298/AC6: the body carries the sequence, epoch and generation the Input gives, and the module of each resource and the carried builds -// only to a node whose Input says it reads modules (novox/hq issue 1145). -func TestTheBodyCarriesTheOrderTheInputGivesAndTheBuildsOnlyToAnEngineThatReadsThem(t *testing.T) { - in := aSmallMesh(t) - laptop := nodeOf(t, &in, "laptop") - laptop.Sequence, laptop.Epoch, laptop.Generation = 42, 57, 9 - in.CurrentBuilds = map[string]string{"files": "commit-files", "mesh-wireguard": "commit-wg"} - in.BuildIDs = map[string]map[string]string{"files": {"commit-files": "build-7"}} - // An assignment of another commit than the one carried does not name the build; the build made from the commit carried does. - laptop.Runs = map[string]Run{"mesh-wireguard": {Commit: "commit-wg", Build: "build-9"}, "files": {Commit: "an-older-commit", Build: "build-6"}} - d, _, err := Compose(in, "laptop") - if err != nil { - t.Fatal(err) - } - body, _ := d.Body() - var got map[string]any - if err := json.Unmarshal(body, &got); err != nil { - t.Fatal(err) - } - if got["sequence"] != float64(42) || got["epoch"] != float64(57) || got["generation"] != float64(9) { - t.Errorf("the order is %v %v %v", got["sequence"], got["epoch"], got["generation"]) - } - if _, there := got["builds"]; there { - t.Errorf("builds are sent to an engine that does not read them: %s", body) - } - for _, r := range got["resources"].([]any) { - if _, there := r.(map[string]any)["module"]; there { - t.Errorf("a module is named on a resource of an engine that does not read them: %v", r) - } - } - laptop.ReadsModules = true - d, _, err = Compose(in, "laptop") - if err != nil { - t.Fatal(err) - } - body, _ = d.Body() - got = nil - if err := json.Unmarshal(body, &got); err != nil { - t.Fatal(err) - } - builds, _ := got["builds"].(map[string]any) - if builds["files"] != "build-7" || builds["mesh-wireguard"] != "build-9" { - t.Errorf("builds are %v", builds) - } - named := false - for _, r := range got["resources"].([]any) { - if r.(map[string]any)["id"] == "files.config" && r.(map[string]any)["module"] == "files" { - named = true - } - } - if !named { - t.Errorf("a resource is not named for its module: %s", body) - } - // Zero is not sent at all. - laptop.Sequence, laptop.Epoch, laptop.Generation = 0, 0, 0 - d, _, _ = Compose(in, "laptop") - body, _ = d.Body() - for _, key := range []string{`"sequence"`, `"epoch"`, `"generation"`} { - if strings.Contains(string(body), key) { - t.Errorf("%s is sent as zero: %s", key, body) - } - } -} - -// A module left out of a build set keeps the build it was last sent (novox/hq issue 259): the machine keeps that one. -func TestAModuleLeftOutKeepsTheBuildItWasLastSent(t *testing.T) { - c := newComposer(Input{CurrentBuilds: map[string]string{"a": "new-a", "b": "new-b"}}) - plan := catalogue.Resolution{Modules: []catalogue.Manifest{{Module: "a"}, {Module: "b"}, {Module: "c"}}} - node := Node{SentBuilds: map[string]string{"b": "old-b", "c": "old-c"}, SentKnown: true} - got := c.carriedOn(node, plan, map[string]string{"b": "its settings cannot compose"}) - if got["a"] != "new-a" || got["b"] != "old-b" || got["c"] != "" { - t.Errorf("carried builds are %v", got) - } - node.SentKnown = false - if got := c.carriedOn(node, plan, map[string]string{"b": "x"}); len(got) != 2 || got["b"] != "" { - t.Errorf("when what was sent is not known: %v", got) - } -} - -// An adopted node is told which modules were taken and, for every module it runs that is not, the ids of its resources -// (novox/hq ADR 0100 and 0103); a converged node is told nothing, and its body is as it was before adoption. -func TestAnAdoptedNodeIsToldWhatWasTakenAndWhatIsKept(t *testing.T) { - in := aSmallMesh(t) - d, _, err := Compose(in, "laptop") - if err != nil || d.Adoption != nil { - t.Fatalf("a converged node: %v %v", d.Adoption, err) - } - laptop := nodeOf(t, &in, "laptop") - laptop.Adopted, laptop.Taken = true, []string{"mesh-wireguard"} - d, problems, err := Compose(in, "laptop") - if err != nil || len(problems) != 0 { - t.Fatalf("%v %v", err, problems) - } - if d.Adoption == nil || len(d.Adoption.Taken) != 1 || d.Adoption.Taken[0] != "mesh-wireguard" || - len(d.Adoption.Untaken["files"]) == 0 || d.Adoption.Untaken["mesh-wireguard"] != nil { - t.Fatalf("adoption is %+v", d.Adoption) - } - if body, _ := d.Body(); !strings.Contains(string(body), `"adoption"`) { - t.Errorf("the adoption is not on the wire: %s", body) - } -} - -// Issue 263 (merge_gate_integration_test.go TestIssue263AnIdentityARealMachineNameOverflows...): a change makes the network manager -// require the object store's provision; on a machine whose name is six characters its identity is 26 against a bound of 20. The -// composition of the provider returns the refusal, naming the module and the provision. -func TestIssue263AnIdentityARealMachineNameOverflowsIsARefusal(t *testing.T) { - in := aGrantMesh(t) - in.Catalogue["resolver"] = storeManifests()[1] - in.Catalogue["networkmanager"] = catalogue.Manifest{Module: "networkmanager", Version: "1", Requires: []string{"wildcard-resolution", "s3-bucket"}} - laptop, anchor := nodeOf(t, &in, "laptop"), nodeOf(t, &in, "anchor") - laptop.Assigned = append(laptop.Assigned, "networkmanager") - anchor.Assigned = append(anchor.Assigned, "resolver") - in.Holdings = []catalogue.Held{{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "resolver", Site: "here"}} - in.Issued = append(in.Issued, Issued{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "networkmanager", Provider: "anchor", ForProvider: "sealed"}) - d, problems, err := Compose(in, "anchor") - if err != nil || len(problems) != 0 { - t.Fatalf("%v %v", err, problems) - } - refusals := strings.Join(d.Refusals(), "\n") - if !strings.Contains(refusals, "networkmanager") || !strings.Contains(refusals, "s3-bucket") || !strings.Contains(refusals, "laptop") { - t.Fatalf("the refusal does not name the module, the provision and the machine: %q", refusals) - } - // And the consumer that fits is granted. - if len(d.Withheld) != 1 || d.Withheld[0].Module != "networkmanager" { - t.Errorf("withheld %+v", d.Withheld) - } -} - -// Round 1, finding 1 (plan.go keepRecorded): a recorded module kept at the build a machine runs is that machine's own resolution -// and no other's; the commit carried for it is the kept one. -func TestAKeptModuleChangesOnlyTheResolutionOfTheNodeThatKeepsIt(t *testing.T) { - in := aSmallMesh(t) - in.Catalogue["tool"] = catalogue.Manifest{Module: "tool", Version: "2"} - for _, name := range []string{"anchor", "laptop"} { - n := nodeOf(t, &in, name) - n.Assigned = append(n.Assigned, "tool") - } - anchor := nodeOf(t, &in, "anchor") - anchor.Kept = map[string]catalogue.Manifest{"tool": {Module: "tool", Version: "1"}} - versionOn := func(in Input, node string) string { - t.Helper() - got, err := Resolve(in, node) - if err != nil { - t.Fatal(err) - } - for _, m := range got.Plan.Modules { - if m.Module == "tool" { - return m.Version - } - } - t.Fatalf("no tool on %s", node) - return "" - } - if v := versionOn(in, "anchor"); v != "1" { - t.Errorf("the node that keeps it runs version %s", v) - } - if v := versionOn(in, "laptop"); v != "2" { - t.Errorf("another node runs version %s: a kept build leaked", v) - } - if got := in.Catalogue["tool"].Version; got != "2" { - t.Errorf("the Input's catalogue was changed: %s", got) - } - // And the commit carried for it is the kept one. - c := newComposer(in) - plan := catalogue.Resolution{Modules: []catalogue.Manifest{{Module: "tool"}, {Module: "other"}}} - in.CurrentBuilds = map[string]string{"tool": "current", "other": "current-other"} - c = newComposer(in) - node := Node{KeptBuilds: map[string]string{"tool": "kept-commit"}} - if got := c.carriedOn(node, plan, nil); got["tool"] != "kept-commit" || got["other"] != "current-other" { - t.Errorf("carried builds are %v", got) - } -} - -// Another node's kept build changes neither the rest of the mesh nor who is on the network: the controller reads both with the -// current manifests, and only then keeps a recorded module for the node it composes (recorded_kept.go keepRecorded). -func TestAnotherNodesKeptBuildDoesNotChangeTheRestOfTheMeshOrWhoIsOnTheNetwork(t *testing.T) { - in := threeNodes(t) - nodeOf(t, &in, "anchor").Kept = map[string]catalogue.Manifest{"objects": {Module: "objects", Version: "0"}, overlayName(): {Module: overlayName(), Version: "0"}} - w, _, _, err := TheRestOfTheMesh(in, "laptop") - if err != nil || len(providers(w, "s3-bucket")) != 1 { - t.Errorf("a kept build changed what anchor offers: %v %v", providers(w, "s3-bucket"), err) - } - where, err := newComposer(in).whereEveryoneIs() - if err != nil || where["anchor"] == "" { - t.Errorf("a kept build took anchor off the network: %v %v", where, err) - } -} - -func overlayName() string { return overlay.Name } - -// Round 1, finding 6: the nodes the composition did not count as on the network are returned, said, not lost. laptop holds no -// complete place (no key): one that does keeps it on the network (issue 1833) and is counted. -func TestANodeNotCountedAsOnTheNetworkIsSaidInTheResult(t *testing.T) { - in := aMesh(t) - nodeOf(t, &in, "laptop").Place.Key = "" - in.Catalogue["rival-one"] = catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} - in.Catalogue["rival-two"] = catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} - laptop := nodeOf(t, &in, "laptop") - laptop.Assigned = append(laptop.Assigned, "rival-one", "rival-two") - said := func(lines []string) bool { - for _, l := range lines { - if strings.HasPrefix(l, "laptop is not counted as on the network") { - return true - } - } - return false - } - got, err := Render(in, "anchor", Foreseeing) - if err != nil || !said(got.Said) { - t.Errorf("rendered: %v %v", got.Said, err) - } - d, _, err := Compose(in, "anchor") - if err != nil || !said(d.Said) { - t.Errorf("declared: %v %v", d.Said, err) - } - n := 0 - for _, l := range d.Said { - if strings.HasPrefix(l, "laptop is not counted") { - n++ - } - } - if n != 1 { - t.Errorf("said %d times: %v", n, d.Said) - } -} diff --git a/go/compose/one_machine_alone_test.go b/go/compose/one_machine_alone_test.go deleted file mode 100644 index aa6b4c9..0000000 --- a/go/compose/one_machine_alone_test.go +++ /dev/null @@ -1,167 +0,0 @@ -package compose - -import ( - "slices" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/overlay" -) - -// fourOnTheNetwork is aMesh and two more machines on the private network at the site `there`: `ace`, dialled at -// ace.example:51820, at 10.77.0.3, and `box`, behind no endpoint, at 10.77.0.4. A module `office` requires -// `document-server`, which nothing in the catalogue provides: the shape of issue 1833 (nextcloud on the hub wanting a -// document server nobody offered). -func fourOnTheNetwork(t *testing.T) Input { - t.Helper() - in := aMesh(t) - in.Catalogue["office"] = catalogue.Manifest{Module: "office", Version: "1", Requires: []string{"document-server"}} - caps := map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true} - in.Nodes = append(in.Nodes, - Node{Name: "ace", Place: Place{Key: keyOf(t, "ace"), Endpoint: "ace.example:51820", Site: "there", Address: "10.77.0.3"}, - Assigned: []string{overlay.Name}, Capabilities: caps}, - Node{Name: "box", Place: Place{Key: keyOf(t, "box"), Site: "there", Address: "10.77.0.4"}, - Assigned: []string{overlay.Name}, Capabilities: caps}) - return in -} - -// unresolvableBy makes one machine's own set fail to resolve: it is also given `office`, whose requirement nothing provides. -func unresolvableBy(t *testing.T, in *Input, name string) { - t.Helper() - n := nodeOf(t, in, name) - n.Assigned = append(n.Assigned, "office") -} - -// networkOf is the generator of an Input's private network, failing the test when it is not composed. -func networkOf(t *testing.T, in Input) *overlay.Generator { - t.Helper() - gens, err := Generators(in) - if err != nil { - t.Fatalf("the private network was not composed: %v", err) - } - return gens[overlay.Name].(*overlay.Generator) -} - -func placesOf(g *overlay.Generator) map[string]overlay.Node { - out := map[string]overlay.Node{} - for _, n := range g.Nodes() { - out[n.Name] = n - } - return out -} - -// 1833/AC1: four machines, the hub's own set does not resolve; the other three compose their declarations, and the hub alone is -// refused, with an error that is its own set failing and names what could not be resolved. -func TestOneMachineThatDoesNotResolveIsRefusedAlone(t *testing.T) { - in := fourOnTheNetwork(t) - unresolvableBy(t, &in, "anchor") - for _, node := range []string{"laptop", "ace", "box"} { - d, problems, err := Compose(in, node) - if err != nil || len(problems) != 0 { - t.Errorf("%s does not compose because another machine does not resolve: %v %v", node, err, problems) - continue - } - if !has(resourceIDs(d), "file:mesh-wireguard.overlay-config") { - t.Errorf("%s is given no private network: %v", node, resourceIDs(d)) - } - } - _, _, err := Compose(in, "anchor") - if err == nil || !IsUnresolvable(err) { - t.Fatalf("the machine that does not resolve is not refused as its own set failing: %v", err) - } - if !strings.Contains(err.Error(), `nothing provides "document-server", wanted by office`) { - t.Errorf("the refusal does not name what could not be resolved: %v", err) - } -} - -// 1833/AC2: the private network is composed with every machine on it: the hub that does not resolve keeps its place (key, -// address, endpoint, site, hub role), so every other machine keeps its path through it. -func TestAMachineThatDoesNotResolveKeepsItsPlaceOnTheNetwork(t *testing.T) { - in := fourOnTheNetwork(t) - unresolvableBy(t, &in, "anchor") - got := placesOf(networkOf(t, in)) - if len(got) != 4 { - t.Fatalf("the network is over %v, want all four machines", got) - } - hub := got["anchor"] - if !hub.Hub || hub.Key != keyOf(t, "anchor") || hub.Address != "10.77.0.1" || hub.Endpoint != "anchor.example:51820" || hub.Site != "here" { - t.Errorf("the hub did not keep its place: %+v", hub) - } - nodes, graph, err := Graph(in) - if err != nil || len(nodes) != 4 { - t.Fatalf("the graph is not shown with every machine: %v %v", nodes, err) - } - for _, node := range []string{"ace", "box"} { - through := false - for _, p := range graph[node] { - through = through || (p.Name == "anchor" && p.Endpoint == "anchor.example:51820") - } - if !through { - t.Errorf("%s has no path through the hub: %+v", node, graph[node]) - } - } -} - -// 1833/AC3: a machine that does not resolve and was never placed (a new machine: no key, no address) is left off the network, -// which is composed from the rest -- here with the hub that does not resolve on its last place -- and that machine alone is refused. -func TestANewMachineThatDoesNotResolveIsLeftOffAndRefusedAlone(t *testing.T) { - in := fourOnTheNetwork(t) - unresolvableBy(t, &in, "anchor") - in.Nodes = append(in.Nodes, Node{Name: "fresh", Place: Place{Site: "there"}, - Assigned: []string{overlay.Name, "office"}, Capabilities: map[string]bool{"wireguard": true, "systemd": true}}) - got := placesOf(networkOf(t, in)) - if _, on := got["fresh"]; on || len(got) != 4 { - t.Fatalf("the network is over %v, want the four placed machines without fresh", got) - } - if _, _, err := Compose(in, "fresh"); err == nil || !IsUnresolvable(err) { - t.Errorf("the new machine is not refused as its own set failing: %v", err) - } - if _, problems, err := Compose(in, "box"); err != nil || len(problems) != 0 { - t.Errorf("another machine does not compose: %v %v", err, problems) - } -} - -// 1833/AC4: a network that truly cannot be composed from what remains still fails: the hub does not resolve and has no -// complete place (no key), so nothing is the hub; the refusal is today's, naming the machine that could not be resolved. -func TestANetworkThatCannotBeComposedFromWhatRemainsStillFails(t *testing.T) { - in := fourOnTheNetwork(t) - unresolvableBy(t, &in, "anchor") - nodeOf(t, &in, "anchor").Place.Key = "" - _, err := Generators(in) - if err == nil { - t.Fatal("a network without a hub was composed") - } - for _, want := range []string{overlay.ErrNoHub.Error(), "and 1 node(s) could not be resolved at all, which is probably why:", " anchor: "} { - if !strings.Contains(err.Error(), want) { - t.Errorf("the refusal does not say %q:\n%v", want, err) - } - } - if _, _, err := Compose(in, "laptop"); err == nil { - t.Error("a declaration was composed over a network that cannot be") - } -} - -// 1833/AC2, a failure the composition's unchecked pass refuses too (two modules claiming one seat on the hub, the 1360/AC3 -// shape): the hub keeps its place in what every other machine is sent besides the tunnel -- its internal name and its address -// among the sources "from the mesh" -- and another machine still composes. -func TestAMachineThatDoesNotResolveKeepsItsNameAndItsAddressInTheMesh(t *testing.T) { - in := fourOnTheNetwork(t) - in.Catalogue["rival-one"] = catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} - in.Catalogue["rival-two"] = catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} - hub := nodeOf(t, &in, "anchor") - hub.Assigned = append(hub.Assigned, "rival-one", "rival-two") - if _, err := Resolve(in, "anchor"); !IsUnresolvable(err) { - t.Fatalf("the hub is meant not to resolve: %v", err) - } - c := newComposer(in) - if got := c.namesInTheMesh()["anchor."+c.suffix()]; got != "10.77.0.1" { - t.Errorf("the hub's name is not in the mesh's names: %v", c.namesInTheMesh()) - } - if got := c.onThePrivateNetwork(); !slices.Contains(got, "10.77.0.1") || len(got) != 4 { - t.Errorf("the sources from the mesh are %v, want all four machines with the hub", got) - } - if _, problems, err := Compose(in, "ace"); err != nil || len(problems) != 0 { - t.Errorf("another machine does not compose: %v %v", err, problems) - } -} diff --git a/go/compose/render.go b/go/compose/render.go deleted file mode 100644 index 3354fad..0000000 --- a/go/compose/render.go +++ /dev/null @@ -1,150 +0,0 @@ -package compose - -import ( - "fmt" - "sort" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// Rendered is everything a node's declaration is composed with (plan.go renderingFor), over an Input: the place half (Places) -// and the credential half. Nothing was allocated, made, kept or written; what a send would do is returned for the caller to do. -type Rendered struct { - Rendering catalogue.Rendering - // ForeseenPorts are the ports the mesh may assign that the Input holds none for (":"). - ForeseenPorts []string - // Foreseen are the stand-ins the composition used for what the Input does not hold: an own secret the next send makes - // ("/"), a grant's sealed copy, the certificate and the bus's user list. - Foreseen []string - // CoreBoxesWanted are the core credentials whose box the send must ask the vault for and keep. - CoreBoxesWanted []CoreBoxWanted - // Withheld are the consumers the grants leave out because their identity overflows the provision's bound (novox/hq ADR 0225), - // and Unbound those whose credential from this node is on record and whose own resolution binds them elsewhere (issue - // 274). The controller says them; a check refuses a change that adds one (ADR 0446 clause 1). - Withheld []catalogue.Overflow - Unbound []catalogue.Unbound - // Said are the lines the controller says while composing (the bus users left out for want of a credential). - Said []string -} - -// Render composes the rendering of a node: the grants it must create, where it puts its ports, its own secrets, the vault's boxes, -// its certificate, the names of the mesh, the bus's user list and its membership. The refusals are the controller's, in its words; -// where more than one fault stands, the first reported may differ from the controller's, which interleaves the halves. -func Render(in Input, node string, mode Mode) (Rendered, error) { - return newComposer(in).render(node, mode) -} - -// Composing is Resolve, Render and Generators of one node over one composition, which resolves each node of the mesh -// once for all three (issue 1954: three compositions resolved the mesh again each, 11.7 s for one node's plans). Each -// answers as it does alone; the first refusal ends it, in that order, with what follows it empty. -func Composing(in Input, node string, mode Mode) (Resolved, Rendered, map[string]catalogue.Generator, error) { - c := newComposer(in) - resolved, err := c.planFor(node) - if err != nil { - return Resolved{}, Rendered{}, nil, err - } - rendered, err := c.render(node, mode) - if err != nil { - return resolved, Rendered{}, nil, err - } - gens, err := c.generators() - if err != nil { - return resolved, rendered, nil, err - } - return resolved, rendered, gens, nil -} - -func (c *composer) render(nodeName string, mode Mode) (Rendered, error) { - resolved, err := c.planFor(nodeName) - if err != nil { - return Rendered{}, err - } - node, err := c.in.node(nodeName) - if err != nil { - return Rendered{}, err - } - plan := resolved.Plan - var out Rendered - foreseen := func(s string) { out.Foreseen = append(out.Foreseen, s) } - - grants, withheld, unbound, grantsForeseen, err := c.grantsFor(nodeName) - if err != nil { - return Rendered{}, err - } - placed, err := c.places(nodeName, resolved) - if err != nil { - return Rendered{}, err - } - r := placed.Rendering - out.ForeseenPorts = placed.ForeseenPorts - // A plan carries its contributions' machine ports, refused where the placement assigned none (issue 774). - r.Planned = mode == Planning - r.Grants, r.Withheld, r.Unbound = grants, withheld, unbound - out.Withheld, out.Unbound = withheld, unbound - for _, g := range grantsForeseen { - foreseen(g) - } - - // And each module's own secrets: a module handed to the provisioner has its secrets from the vault through the client. - handing := catalogue.Rendering{Hands: r.Hands, Provisioner: r.Provisioner, Assigned: r.Assigned} - needed, foreseenOwn, err := c.ownSecretsFor(node, plan, handing, mode) - if err != nil { - return Rendered{}, err - } - r.Needed = needed - if mode == Foreseeing || mode == Planning || len(foreseenOwn) > 0 { - r.Foreseen = foreseenOwn - } - var names []string - for module, secrets := range foreseenOwn { - for name := range secrets { - names = append(names, module+"/"+name) - } - } - sort.Strings(names) - for _, n := range names { - foreseen(n) - } - - // The core's own credentials as the vault sealed them, for an engine that opens the vault's box. - if node.Stated && node.Contract >= VaultBoxContract { - sealed, notPlaced, wanted := c.coreBoxesFor(node, plan.Modules, handing) - r.VaultSealed, r.NotPlaced = sealed, notPlaced - out.CoreBoxesWanted = wanted - } - - // And a certificate for this machine's name inside the mesh, when anything on it asks. - for _, m := range plan.Modules { - if m.Certificate == nil { - continue - } - certificate, authority, stood, err := c.certificateFor(node) - if err != nil { - return Rendered{}, err - } - r.Certificate, r.Authority = certificate, authority - if stood { - foreseen("the certificate and the authority of " + nodeName) - } - break - } - - // The bus's user list, for the machine that runs the bus: names and hashes the Input carries, never passwords. - if holdsTheBus(plan.Modules) { - r.BusUsers = c.in.BusUsers - if r.BusUsers == "" { - r.BusUsers = ForeseenBusUsers - foreseen(BusUsersStandIn) - } - if len(c.in.BusMissing) > 0 { - out.Said = append(out.Said, fmt.Sprintf("the bus's user list leaves out %d user(s) the mesh has minted no credential "+ - "for: %s. Each is a user that cannot connect until one is issued", len(c.in.BusMissing), strings.Join(c.in.BusMissing, ", "))) - } - } - r.BusMembership = c.in.BusMemberships[nodeName] - out.Rendering = r - // And what the composition found on the way, which the controller writes to its log: a machine not counted as on the network. - out.Said = c.saidBeside(out.Said) - return out, nil -} diff --git a/go/compose/rendering.go b/go/compose/rendering.go deleted file mode 100644 index d942bc6..0000000 --- a/go/compose/rendering.go +++ /dev/null @@ -1,543 +0,0 @@ -package compose - -import ( - "errors" - "fmt" - "sort" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/overlay" -) - -// The contracts of the node-engine's statement that gate what a machine is sent, as the controller's link package names them -// (mesh-controller internal/link/protocol.go at 6c2a8d62). An older engine parses strictly and refuses a field it does not -// know, whole. -const ( - ReadinessContract = 2 // the engine reads a resource's `health` (novox/hq ADR 0240) - RootContract = 3 // the engine judges a user's declared `root` (ADR 0266) - BusyContract = 8 // the engine reads a container's `busy` (ADR 0335) - HandedContract = 11 // the engine writes a filled file on the provisioner client's word alone - VaultBoxContract = 12 // the engine opens the vault's box -) - -// The provisioner client's seat and own secret (the controller's broker.NodeProvisionerSeat and ProvisionerSecret). -const ( - NodeProvisionerSeat = "node-provisioner" - ProvisionerSecret = "provisioner-broker" -) - -// ErrPortTaken is the controller's refusal for a machine port held twice on one machine. -var ErrPortTaken = errors.New("that port is already held on this machine") - -// Placed is the place half of a rendering (plan.go renderingFor): where a machine puts what its modules need reachable, the -// seats and who holds them, the names of the mesh, the networks, accounts and the operator's export. It is a catalogue.Rendering -// with those fields set, and the ports it could not read named foreseen. Nothing was allocated. -type Placed struct { - Rendering catalogue.Rendering - // ForeseenPorts are the ports the mesh may assign that the Input holds none for, as ":": composed with the - // manifest's own number as the Reading mode composes them, and never invented. The controller's adapter allocates and - // composes again. - ForeseenPorts []string -} - -// Places composes the place half of the rendering for a node (the controller's renderingFor in the Reading and Foreseeing modes, -// without the credentials): over an Input, allocating nothing and changing nothing in it. The refusals are the controller's, -// in its words. -func Places(in Input, node string) (Placed, error) { - c := newComposer(in) - resolved, err := c.planFor(node) - if err != nil { - return Placed{}, err - } - return c.places(node, resolved) -} - -func (c *composer) places(nodeName string, resolved Resolved) (Placed, error) { - var out Placed - node, err := c.in.node(nodeName) - if err != nil { - return out, err - } - plan, settings := resolved.Plan, resolved.Settings - r := &out.Rendering - - // Where this machine puts what each module needs reachable (novox/hq ADR 0038): what it was already given, and the ports - // it was given over those (ADR 0100). - given, err := c.portsFor(node, plan, settings, r, &out.ForeseenPorts) - if err != nil { - return out, err - } - - // Hands: whether the engine writes a filled file on the provisioner client's word alone; engines older than a contract are - // not sent the field they would refuse. - r.Hands = takesHanded(node) - r.Provisioner = provisionerAccount(plan) - r.Assigned = node.Handed - r.ReadsHealth = node.Stated && node.Contract >= ReadinessContract - r.JudgesRoot = node.Stated && node.Contract >= RootContract - r.ReadsBusy = node.Stated && node.Contract >= BusyContract - - // And who else is on the private network, which is what a rule saying "from the mesh" resolves to. - r.Mesh = c.onThePrivateNetwork() - // The private network's range, offered to a module as ${machine:mesh-range}. - r.MeshRange = c.overlayRange() - - // The artifact store as this node reaches it now, with what the mesh has built. - store, err := c.artifactStoreAddress(nodeName) - if err != nil { - return out, err - } - r.ArtifactStore = store - r.Built = make(map[string]bool, len(c.in.Built)) - for _, repository := range c.in.Built { - r.Built[repository] = true - } - - // And every machine's name, so a container can reach one. - r.Names = c.namesInTheMesh() - - // Each machine's operator account, so an ssh Host block can name the login for every node (novox/hq to-be 29). - r.Accounts = map[string]string{} - for _, n := range c.in.sorted() { - if n.Account != "" { - r.Accounts[n.Name] = n.Account - } - } - - // The roster is the machines and nothing else (novox/hq ADR 0191). - r.Machines = make(map[string]string, len(r.Names)) - for name, at := range r.Names { - r.Machines[name] = at - } - - // And every zone a module in the mesh answers itself (novox/hq ADR 0199). - zones, err := c.zonesInTheMesh() - if err != nil { - return out, err - } - r.Zones = zones - - // And who holds each replicated seat, where (novox/hq ADR 0223). - r.Holders = c.replicatedHolders() - - r.Suffix, r.TunnelInterface = c.suffix(), overlay.Interface - - // And, for a module that keeps them, every operator-sealed secret in the mesh (novox/hq ADR 0085, amended). - for _, m := range plan.Modules { - if m.Keeps == "" { - continue - } - r.Kept = c.in.OperatorExport - if r.Kept == nil { - r.KeptRuns = c.in.KeptRuns[node.Name] - } - break - } - - // Whether this node is adopted (novox/hq ADR 0100), and which modules were taken there. - r.Adopted = node.Adopted - var taken map[string]bool - if node.Adopted { - taken = map[string]bool{} - for _, m := range node.Taken { - taken[m] = true - } - } - r.Taken = taken - - // Where this node put the foundation's servers (novox/hq 04-ISSUES/102). - seats, err := c.seatsOn(node, plan.Modules, node.Adopted, taken) - if err != nil { - return out, err - } - r.Seats = seats - - // Which of this machine's links face outside (novox/hq ADR 0140), and which of its networks count as its local network - // (ADR 0340). - r.OutwardLinks = node.OutwardLinks - r.LocalNetworks = catalogue.CountedLocalNetworks(localNetworksOf(node)) - - // Where this machine reaches each mesh seat's holder, for ${seat::reach} (novox/hq ADR 0222). - if store != "" { - r.SeatReach = map[string]string{"mesh-artifact-store": store} - } - r.Settings = settings - r.Given = given - return out, nil -} - -// takesHanded says whether a machine's newest statement is of an engine that writes a filled file on the provisioner client's -// word alone. -func takesHanded(n Node) bool { return n.Stated && n.Contract >= HandedContract } - -// provisionerAccount is the account the provisioner's client runs as on this machine (novox/hq ADR 0349 clause 4): the user -// the process of the module running it runs as, empty where no such module is on the machine. -func provisionerAccount(plan catalogue.Resolution) string { - for _, m := range plan.Modules { - if !runsTheProvisionerClient(m) { - continue - } - for _, r := range m.Resources { - if r["type"] == "process" { - if user, _ := r["user"].(string); user != "" && user != "root" { - return user - } - } - } - } - return "" -} - -// runsTheProvisionerClient says a module runs its machine's provisioner client: it claims the seat and declares the own -// secret that account is delivered in. -func runsTheProvisionerClient(m catalogue.Manifest) bool { - _, reads := m.OwnSecrets[ProvisionerSecret] - return reads && m.ClaimsSeat(NodeProvisionerSeat) -} - -// portsFor is where a node puts what each module needs reachable, in the Reading mode: the ports it was given win, then the -// ones the mesh assigned, and a port the Input holds none for is composed as the manifest says and named foreseen. One holder -// per machine port across the node's modules: settings written before this was refused are refused here. -func (c *composer) portsFor(node Node, plan catalogue.Resolution, settings catalogue.SettingsBy, r *catalogue.Rendering, - foreseen *[]string) (map[string]map[int]int, error) { - already := node.Ports - - given := map[string]map[int]int{} - for _, m := range plan.Modules { - g, err := catalogue.GivenPorts(m, settings[m.Module]) - if err != nil { - // A given port its definition no longer publishes: the module is left out of the declaration, by name, when it is - // composed (novox/hq ADR 0163, rule 6) -- never the machine refused here for it. - continue - } - if g != nil { - given[m.Module] = g - } - } - holders := map[int]string{} - for _, m := range plan.Modules { - for _, at := range given[m.Module] { - if other, twice := holders[at]; twice && other != m.Module { - return nil, fmt.Errorf("%w: %s and %s are both given machine port %d on %s", ErrPortTaken, other, m.Module, - at, node.Name) - } - holders[at] = m.Module - } - } - - ports := map[string]map[int]int{} - for _, m := range plan.Modules { - for _, l := range m.Listens { - if at, isGiven := given[m.Module][l.Port]; isGiven { - if ports[m.Module] == nil { - ports[m.Module] = map[int]int{} - } - ports[m.Module][l.Port] = at - continue - } - // Only a port the module actually publishes is the mesh's to move. Recorded either way, because this map means where - // this module's port is on this machine. - where, mayAssign := m.MachineSide(l.Port) - if mayAssign { - // Whatever was chosen last time, and nothing if there was no last time. - if at, known := already[m.Module][l.Port]; known { - where = at - } else { - *foreseen = append(*foreseen, fmt.Sprintf("%s:%d", m.Module, l.Port)) - } - } - if ports[m.Module] == nil { - ports[m.Module] = map[int]int{} - } - ports[m.Module][l.Port] = where - } - } - r.Ports = ports - return given, nil -} - -// onThePrivateNetwork is every node's address on the private network, sorted: the same set the names mean by it, so a rule -// saying "from the mesh" admits exactly the machines the mesh names. A node with no address is left out, not rendered as an -// empty source. -func (c *composer) onThePrivateNetwork() []string { - var out []string - for _, p := range c.onTheNetwork() { - if strings.TrimSpace(p.Place.Address) != "" { - out = append(out, p.Place.Address) - } - } - sort.Strings(out) - return out -} - -// namesInTheMesh is every machine's internal name and the address behind it, for every machine on the private network, and -// the carried peers the operator has named (novox/hq issue 112). -func (c *composer) namesInTheMesh() map[string]string { - out := map[string]string{} - for _, p := range c.onTheNetwork() { - out[c.internalName(p.Name)] = p.Place.Address - } - for _, p := range c.in.CarriedPeers { - if p.Named == "" || p.EnrolledAs != "" { - continue - } - if _, taken := out[c.internalName(p.Named)]; taken { - continue // a node of the mesh owns the name; the stale statement loses - } - out[c.internalName(p.Named)] = p.Address - } - return out -} - -// artifactStoreOnNetwork is the machine on this network that offers the artifact store, and the port that machine put it on. -func (c *composer) artifactStoreOnNetwork(on map[string]bool) (node, port string, found bool, err error) { - providers := map[string]catalogue.Manifest{} - for name, m := range c.in.Catalogue { - if _, offers := m.Serves[catalogue.ArtifactStoreProvision]; offers { - providers[name] = m - } - } - if len(providers) == 0 { - return "", "", false, nil - } - // In a stated order, so two machines offering it would always answer the same one. - machines := make([]string, 0, len(on)) - for machine := range on { - machines = append(machines, machine) - } - sort.Strings(machines) - for _, machine := range machines { - n, err := c.in.node(machine) - if err != nil { - return "", "", false, fmt.Errorf("reading what %s is assigned: %w", machine, err) - } - for _, a := range n.Assigned { - m, offers := providers[a] - if !offers { - continue - } - serves, _, err := c.servedOnNode(machine, m, catalogue.ArtifactStoreProvision) - if err != nil { - return "", "", false, fmt.Errorf("reading where %s puts the artifact store: %w", machine, err) - } - if p, ok := serves["port"]; ok { - return machine, fmt.Sprintf("%v", p), true, nil - } - } - } - return "", "", false, nil -} - -// ArtifactStoreAddress is the artifact store as node reaches it, by the rule its composition routes every kept reference -// with (artifactStoreAddress): empty when node reaches none. A node composing its own modules reads a templates archive -// there (issue 1126). -func ArtifactStoreAddress(in Input, node string) (string, error) { - return newComposer(in).artifactStoreAddress(node) -} - -// artifactStoreAddress is the artifact store as `forNode` reaches it: `.internal:` over the private network, or -// `127.0.0.1:` for the node that holds the store itself when nothing is on the network yet, and empty for any other -// (novox/hq 04-ISSUES/102). -func (c *composer) artifactStoreAddress(forNode string) (string, error) { - onNetwork, err := c.whereEveryoneIs() - if err != nil { - return "", err - } - on := map[string]bool{} - for name := range onNetwork { - on[name] = true - } - node, port, found, err := c.artifactStoreOnNetwork(on) - if err != nil { - return "", err - } - if found { - return c.internalName(node) + ":" + port, nil - } - all := map[string]bool{} - for _, n := range c.in.Nodes { - all[n.Name] = true - } - holder, port, found, err := c.artifactStoreOnNetwork(all) - if err != nil || !found || holder != forNode { - return "", err - } - return "127.0.0.1:" + port, nil -} - -// zonesInTheMesh is every zone a module in the mesh declares, where the mesh placed it (novox/hq ADR 0199): the zone settled -// from that node's settings, the node's private address, the port the answering listen is published on there. A node whose set -// does not compose declares nothing and is passed over; what the mesh refuses about the zones together is refused, by name. -func (c *composer) zonesInTheMesh() ([]catalogue.ZoneAt, error) { - address := map[string]string{} - for _, n := range c.in.sorted() { - if strings.TrimSpace(n.Place.Address) != "" { - address[n.Name] = n.Place.Address - } - } - var zones []catalogue.ZoneAt - var public []string - for _, n := range c.in.sorted() { - resolved, err := c.planFor(n.Name) - switch { - case unresolvable(err): - continue - case err != nil: - return nil, fmt.Errorf("the zones %s answers cannot be read: %w", n.Name, err) - } - plan := resolved.Plan - if plan.PublicDomain != "" { - public = append(public, plan.PublicDomain) - } - for _, m := range plan.Modules { - if m.Zone == nil { - continue - } - at := address[n.Name] - if at == "" { - // Not on the private network yet: nothing could reach its answerer. - continue - } - published, layers, err := c.portsGivenOn(n.Name, m) - if err != nil { - return nil, fmt.Errorf("the zone %s declares on %s cannot be read: %w", m.Module, n.Name, err) - } - z, err := catalogue.ZoneOn(m, layers, published, n.Name, at) - if err != nil { - return nil, err - } - zones = append(zones, *z) - } - } - if problems := catalogue.ZonesProblems(zones, c.suffix(), public); len(problems) > 0 { - return nil, fmt.Errorf("the mesh's zones cannot be forwarded:\n - %s", strings.Join(problems, "\n - ")) - } - return zones, nil -} - -// replicatedHolders is, for each replicated mesh seat, every machine on the private network holding it, by internal name at its -// private address (novox/hq ADR 0223): the holders on record, and only the sole claimant when there are none. A holder off -// the private network is left out. -func (c *composer) replicatedHolders() map[string]map[string]string { - var replicated []catalogue.Seat - for _, s := range catalogue.Seats() { - if s.Replicated && s.Scope == catalogue.ScopeMesh { - replicated = append(replicated, s) - } - } - if len(replicated) == 0 { - return nil - } - address := map[string]string{} - for _, p := range c.onTheNetwork() { - address[p.Name] = p.Place.Address - } - // Every module of the catalogue with the nodes it is assigned on, sorted, as the controller's Catalogued answers. - on := map[string][]string{} - for _, n := range c.in.sorted() { - for _, module := range n.Assigned { - on[module] = append(on[module], n.Name) - } - } - names := make([]string, 0, len(c.in.Catalogue)) - for name := range c.in.Catalogue { - names = append(names, name) - } - sort.Strings(names) - out := map[string]map[string]string{} - for _, seat := range replicated { - var nodes []string - for _, h := range c.in.Holdings { - if hs, ok := catalogue.SeatNamed(h.Claim); ok && hs.Name == seat.Name && h.Scope == seat.Scope { - nodes = append(nodes, h.Node) - } - } - if len(nodes) == 0 { - var derived []string - for _, name := range names { - for _, cl := range c.in.Catalogue[name].Claims { - if cs, ok := catalogue.SeatNamed(cl.Name); ok && cs.Name == seat.Name && cl.At() == seat.Scope { - derived = append(derived, on[name]...) - } - } - } - if len(derived) == 1 { - nodes = derived - } - } - at := map[string]string{} - for _, n := range nodes { - if address[n] != "" { - at[c.internalName(n)] = address[n] - } - } - out[seat.Name] = at - } - return out -} - -// seatsOn is where a node put the holder of each mesh-scoped seat, by seat and by the port the holder's software uses: what -// ${seat:...} answers with (novox/hq 04-ISSUES/102). Read for every module in the catalogue that claims a seat, in this node's set -// or not; a holder in this node's set wins over one that is not. -func (c *composer) seatsOn(node Node, inSet []catalogue.Manifest, adopted bool, taken map[string]bool) (map[string]map[int]int, error) { - assigned := map[string]bool{} - for _, m := range inSet { - assigned[m.Module] = true - } - names := make([]string, 0, len(c.in.Catalogue)) - for name := range c.in.Catalogue { - names = append(names, name) - } - sort.Strings(names) - seats := map[string]map[int]int{} - for _, name := range names { - m := c.in.Catalogue[name] - var claims []string - for _, cl := range m.Claims { - if cl.At() == catalogue.ScopeMesh { - claims = append(claims, cl.Name) - } - } - if len(claims) == 0 { - continue - } - // Only a port the node was given or the mesh assigned, never the manifest's own number. And on an adopted node a holder - // assigned but not yet taken is the found container, so its mesh-assigned ports do not count there; a given port does. - ports, _, err := c.portsGivenOn(node.Name, m) - if err != nil { - return nil, err - } - if adopted && !taken[name] { - layers := node.Settings[m.Module] - ports = map[int]int{} - if given, err := catalogue.GivenPorts(m, layers); err == nil { - ports = given - } - } - if len(ports) == 0 { - continue - } - for _, seat := range claims { - if seats[seat] == nil { - seats[seat] = map[int]int{} - } - for wanted, at := range ports { - if _, said := seats[seat][wanted]; said && !assigned[name] { - continue - } - seats[seat][wanted] = at - } - } - } - return seats, nil -} - -// localNetworksOf is a machine's attached networks judged (novox/hq ADR 0340): which count as its local network, from what it -// last said of itself and the links it said face outside it. A machine that has said none judges to nothing. -func localNetworksOf(n Node) []catalogue.LocalNetwork { - if !n.NetworksSaid { - return nil - } - return catalogue.JudgeLocalNetworks(n.Networks, n.OutwardLinks, overlay.Interface) -} diff --git a/go/compose/rendering_test.go b/go/compose/rendering_test.go deleted file mode 100644 index 7ba4a88..0000000 --- a/go/compose/rendering_test.go +++ /dev/null @@ -1,329 +0,0 @@ -package compose - -import ( - "errors" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/overlay" -) - -// aWebModule publishes one port in the short form, which the mesh is free to move (MachineSide). -func aWebModule() catalogue.Manifest { - return catalogue.Manifest{Module: "web", Version: "1", - Listens: []catalogue.Listening{{Name: "http", Port: 8080, From: catalogue.FromMesh}}, - Resources: []map[string]any{{"id": "server", "type": "container", "name": "web", "ports": []any{"8080"}, - "image": "registry.example/web@sha256:" + strings.Repeat("a", 64)}}} -} - -// aStoreModule is mesh-controller's sendable_integration_test.go TestConsumersAreToldTheGivenPort store (cmd/mesh-controller -// at 6c2a8d62): publishing 5432:5432, offering a database to the mesh. -func aStoreModule() catalogue.Manifest { - return catalogue.Manifest{Module: "store", Version: "1", - Provides: []catalogue.Offer{{Name: "database", Scope: catalogue.ScopeMesh}}, - Listens: []catalogue.Listening{{Port: 5432, From: catalogue.FromMesh}}, - Guards: []int{5432}, - Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-store", - "ports": []any{"5432:5432"}, - "image": "registry.example/pg@sha256:" + strings.Repeat("b", 64)}}} -} - -func placed(t *testing.T, in Input, node string) Placed { - t.Helper() - got, err := Places(in, node) - if err != nil { - t.Fatalf("%v", err) - } - return got -} - -// 1363/AC1 and AC2: a port the Input holds as allocated on the node is the one composed; with none, no number is invented: the -// manifest's own is composed and the port is named foreseen, as the Foreseeing mode does. -func TestAnAllocatedPortIsComposedAndAnUnallocatedOneIsNamedForeseenNotInvented(t *testing.T) { - mk := func() Input { - in := aMesh(t) - in.Catalogue["web"] = aWebModule() - nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "web") - return in - } - in := mk() - got := placed(t, in, "laptop") - if p := got.Rendering.Ports["web"][8080]; p != 8080 { - t.Errorf("a port nobody allocated is composed as %d, not as the manifest's own", p) - } - if len(got.ForeseenPorts) != 1 || got.ForeseenPorts[0] != "web:8080" { - t.Errorf("foreseen ports are %v", got.ForeseenPorts) - } - nodeOf(t, &in, "laptop").Ports = map[string]map[int]int{"web": {8080: 20123}} - got = placed(t, in, "laptop") - if p := got.Rendering.Ports["web"][8080]; p != 20123 || len(got.ForeseenPorts) != 0 { - t.Errorf("the allocated port is composed as %d (foreseen %v)", p, got.ForeseenPorts) - } - // A port the module does not publish is not the mesh's to move, so it is never foreseen. - m := aWebModule() - m.Resources = nil - in.Catalogue["web"] = m - nodeOf(t, &in, "laptop").Ports = nil - got = placed(t, in, "laptop") - if got.Rendering.Ports["web"][8080] != 8080 || len(got.ForeseenPorts) != 0 { - t.Errorf("an unpublished port: %v foreseen %v", got.Rendering.Ports, got.ForeseenPorts) - } - unchanged(t, mk, func(in Input) { placed(t, in, "laptop") }) - // Only a composition for plans refuses a contribution whose port the module does not listen on (issue 774). - for _, mode := range []Mode{Reading, Foreseeing, Planning} { - if got := rendered(t, mk(), "laptop", mode).Rendering; got.Planned != (mode == Planning) { - t.Errorf("mode %d composes with Planned %v", mode, got.Planned) - } - } -} - -// 1363/AC3: a port the operator gave is used, over the allocated one, and a consumer elsewhere is told it -// (mesh-controller sendable_integration_test.go TestConsumersAreToldTheGivenPort). -func TestAPortTheOperatorGaveIsUsedAndNotReallocated(t *testing.T) { - in := aMesh(t) - in.Catalogue["store"] = aStoreModule() - in.Catalogue["app"] = catalogue.Manifest{Module: "app", Version: "1", Requires: []string{"database"}} - anchor := nodeOf(t, &in, "anchor") - anchor.Assigned = append(anchor.Assigned, "store") - nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "app") - anchor.Ports = map[string]map[int]int{"store": {5432: 20999}} - anchor.Settings = map[string][]catalogue.Layer{"store": {{From: "anchor", - Placement: map[string]any{catalogue.PortsSetting: map[string]any{"5432": 5433}}}}} - - got := placed(t, in, "anchor") - if p := got.Rendering.Ports["store"][5432]; p != 5433 { - t.Errorf("the store is on %d, not the port it was given", p) - } - if g := got.Rendering.Given["store"][5432]; g != 5433 { - t.Errorf("the given port is %d", g) - } - if len(got.ForeseenPorts) != 0 { - t.Errorf("a given port is foreseen: %v", got.ForeseenPorts) - } - resolved, err := Resolve(in, "laptop") - if err != nil { - t.Fatal(err) - } - if told := need(t, resolved.Plan, "app", "database").Serves["port"]; told != 5433 { - t.Errorf("the consumer is told the store is on %v", told) - } - - // One holder per machine port across the node's modules: two given the same refused, in the controller's words. - in.Catalogue["store2"] = func() catalogue.Manifest { - m := aStoreModule() - m.Module, m.Provides = "store2", nil - m.Resources = []map[string]any{{"id": "server", "type": "container", "name": "other", "ports": []any{"5432:5432"}, - "image": "registry.example/pg@sha256:" + strings.Repeat("c", 64)}} - return m - }() - anchor.Assigned = append(anchor.Assigned, "store2") - anchor.Settings["store2"] = []catalogue.Layer{{From: "anchor", - Placement: map[string]any{catalogue.PortsSetting: map[string]any{"5432": 5433}}}} - _, err = Places(in, "anchor") - if err == nil || !errors.Is(err, ErrPortTaken) || !strings.Contains(err.Error(), "store and store2 are both given machine port 5433 on anchor") && - !strings.Contains(err.Error(), "store2 and store are both given machine port 5433 on anchor") { - t.Fatalf("two modules given one machine port: %v", err) - } -} - -// 1363/AC4: the seats held and taken reach the Rendering: where each mesh-scoped seat's holder put its ports, from the node's -// given and assigned ports, never the manifest's own; and on an adopted node a holder assigned but not taken counts only by its -// given ports (novox/hq ADR 0100). -func TestSeatsHeldAndTakenReachTheRendering(t *testing.T) { - in := aMesh(t) - holder := aStoreModule() - holder.Claims = []catalogue.Claim{{Name: "mesh-store", Scope: catalogue.ScopeMesh}} - in.Catalogue["store"] = holder - anchor := nodeOf(t, &in, "anchor") - anchor.Assigned = append(anchor.Assigned, "store") - // The shelf is read whole, in this node's set or not: laptop, running none, is told where the anchor's store is only by the - // settings and ports of its own machine, which it has none of. - anchor.Ports = map[string]map[int]int{"store": {5432: 20999}} - got := placed(t, in, "anchor") - if at := got.Rendering.Seats["mesh-store"][5432]; at != 20999 { - t.Errorf("the seat's holder is told to be on %d", at) - } - if len(placed(t, in, "laptop").Rendering.Seats) != 0 { - t.Errorf("laptop is told of a holder it has no port for: %v", placed(t, in, "laptop").Rendering.Seats) - } - // Adopted, and the holder assigned but not taken: the found container's ports, not the mesh's assignment. - anchor.Adopted = true - if len(placed(t, in, "anchor").Rendering.Seats) != 0 { - t.Errorf("an untaken holder on an adopted node counts by the mesh's assignment: %v", placed(t, in, "anchor").Rendering.Seats) - } - anchor.Taken = []string{"store"} - got = placed(t, in, "anchor") - if at := got.Rendering.Seats["mesh-store"][5432]; at != 20999 || !got.Rendering.Adopted || !got.Rendering.Taken["store"] { - t.Errorf("a taken holder: seats %v adopted %v taken %v", got.Rendering.Seats, got.Rendering.Adopted, got.Rendering.Taken) - } -} - -// 1363/AC4: local networks and outward links (mesh-controller local_network_test.go TestNodeShowsWhichAttachedNetworksCountAndWhy, -// the values): a private network on a link facing outside counts, a public subnet and the private network's own link never do; a -// machine that said none judges to nothing. -func TestLocalNetworksAndOutwardLinksReachTheRendering(t *testing.T) { - in := aMesh(t) - laptop := nodeOf(t, &in, "laptop") - laptop.OutwardLinks = []string{"eth0", "eth1"} - laptop.Networks = []catalogue.AttachedNetwork{ - {Link: "eth0", Network: "192.168.1.10/24"}, {Link: "eth1", Network: "203.0.113.7/24"}, {Link: overlay.Interface, Network: "10.10.0.4/24"}} - laptop.NetworksSaid = true - got := placed(t, in, "laptop").Rendering - if len(got.LocalNetworks) != 1 || got.LocalNetworks[0] != "192.168.1.0/24" { - t.Errorf("the local networks are %v", got.LocalNetworks) - } - if len(got.OutwardLinks) != 2 || got.OutwardLinks[0] != "eth0" { - t.Errorf("the outward links are %v", got.OutwardLinks) - } - laptop.NetworksSaid = false - if got := placed(t, in, "laptop").Rendering; len(got.LocalNetworks) != 0 { - t.Errorf("a machine that never reported has local networks %v", got.LocalNetworks) - } -} - -// 1363/AC4: accounts, the names of the mesh, the mesh range and the operator's export reach the Rendering. -func TestAccountsNamesAndTheOperatorsExportReachTheRendering(t *testing.T) { - in := aMesh(t) - nodeOf(t, &in, "anchor").Account = "jochens" - in.OperatorExport = &catalogue.KeptExport{} - in.OverlayCIDR = "10.99.0.0/16" - got := placed(t, in, "anchor").Rendering - if got.Accounts["anchor"] != "jochens" || len(got.Accounts) != 1 { - t.Errorf("accounts are %v", got.Accounts) - } - if got.Names["anchor.internal"] != "10.77.0.1" || got.Names["laptop.internal"] != "10.77.0.2" || len(got.Names) != 2 { - t.Errorf("names are %v", got.Names) - } - if len(got.Machines) != 2 || got.Machines["laptop.internal"] != "10.77.0.2" { - t.Errorf("machines are %v", got.Machines) - } - if len(got.Mesh) != 2 || got.Mesh[0] != "10.77.0.1" || got.Mesh[1] != "10.77.0.2" { - t.Errorf("the mesh's addresses are %v", got.Mesh) - } - if got.MeshRange != "10.99.0.0/16" || got.Suffix != "internal" || got.TunnelInterface != overlay.Interface { - t.Errorf("range %q suffix %q tunnel %q", got.MeshRange, got.Suffix, got.TunnelInterface) - } - if got.Kept != nil { - t.Error("the operator's export reaches a node where nothing keeps it") - } - keeper := catalogue.Manifest{Module: "keeper", Version: "1", Keeps: "/var/lib/keeper/export.json"} - in.Catalogue["keeper"] = keeper - nodeOf(t, &in, "anchor").Assigned = append(nodeOf(t, &in, "anchor").Assigned, "keeper") - if got := placed(t, in, "anchor").Rendering; got.Kept != in.OperatorExport { - t.Errorf("a module that keeps the export is not given it: %v", got.Kept) - } - // And the names of a machine that left the network leave with it. - nodeOf(t, &in, "laptop").Assigned = nil - if got := placed(t, in, "anchor").Rendering; len(got.Names) != 1 || got.Names["laptop.internal"] != "" { - t.Errorf("a machine off the network is named: %v", got.Names) - } -} - -// The names the operator gave to peers of an adopted tunnel answer until they enrol (novox/hq issue 112); a node of the mesh owns -// its name. -func TestACarriedPeerIsNamedUntilItEnrols(t *testing.T) { - in := aMesh(t) - in.CarriedPeers = []CarriedPeer{{PublicKey: "K1", Address: "192.0.2.5", Named: "printer"}, - {PublicKey: "K2", Address: "192.0.2.6", Named: "laptop"}, {PublicKey: "K3", Address: "192.0.2.7", Named: "late", EnrolledAs: "late"}} - got := placed(t, in, "anchor").Rendering - if got.Names["printer.internal"] != "192.0.2.5" || got.Names["laptop.internal"] != "10.77.0.2" || got.Names["late.internal"] != "" { - t.Errorf("names are %v", got.Names) - } -} - -// A node-engine older than a contract is not sent the field it would refuse (novox/hq ADR 0240 and after). -func TestTheNodeEnginesContractGatesWhatItIsSent(t *testing.T) { - in := aMesh(t) - laptop := nodeOf(t, &in, "laptop") - if r := placed(t, in, "laptop").Rendering; r.ReadsHealth || r.JudgesRoot || r.ReadsBusy || r.Hands { - t.Errorf("an engine that never stated anything reads %+v", r) - } - laptop.Stated, laptop.Contract = true, ReadinessContract - if r := placed(t, in, "laptop").Rendering; !r.ReadsHealth || r.JudgesRoot || r.ReadsBusy || r.Hands { - t.Errorf("an engine at the readiness contract reads %+v", r) - } - laptop.Contract = HandedContract - if r := placed(t, in, "laptop").Rendering; !r.ReadsHealth || !r.JudgesRoot || !r.ReadsBusy || !r.Hands { - t.Errorf("an engine at the handed contract reads %+v", r) - } -} - -// The artifact store as a node reaches it: over the private network by its holder's name and the port it put it on, or on loopback -// for the holder alone while nothing is on the network (novox/hq 04-ISSUES/102). -func TestTheArtifactStoreIsReachedOverTheNetworkOrOnLoopbackForItsHolder(t *testing.T) { - in := aMesh(t) - in.Catalogue["artifacts"] = catalogue.Manifest{Module: "artifacts", Version: "1", - Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}} - anchor := nodeOf(t, &in, "anchor") - anchor.Assigned = append(anchor.Assigned, "artifacts") - if got := placed(t, in, "laptop").Rendering.ArtifactStore; got != "anchor.internal:5100" { - t.Errorf("laptop reaches the store at %q", got) - } - nodeOf(t, &in, "laptop").Assigned = nil - anchor.Assigned = []string{"artifacts"} // off the network: the holder alone reaches it - if got := placed(t, in, "anchor").Rendering.ArtifactStore; got != "127.0.0.1:5100" { - t.Errorf("the holder reaches the store at %q", got) - } - if got := placed(t, in, "laptop").Rendering.ArtifactStore; got != "" { - t.Errorf("a machine off the network reaches the store at %q", got) - } - in.Built = []string{"mesh-sdk"} - if got := placed(t, in, "anchor").Rendering.Built; !got["mesh-sdk"] { - t.Errorf("built is %v", got) - } -} - -// A zone a module declares is placed where the mesh put it: settled from that node's settings, at the node's private address, on -// the port the answering listen is published on there (novox/hq ADR 0199); the same zone declared twice is refused, by name. -func TestAZoneIsPlacedWhereTheMeshPutItAndTheSameZoneTwiceIsRefused(t *testing.T) { - in := aMesh(t) - lab := catalogue.Manifest{Module: "lab", Version: "1", Zone: &catalogue.Zone{Name: "${setting:zone}", Listen: "dns"}, - Listens: []catalogue.Listening{{Name: "dns", Port: 53, Protocol: "udp", From: catalogue.FromMesh}}} - in.Catalogue["lab"] = lab - anchor := nodeOf(t, &in, "anchor") - anchor.Assigned = append(anchor.Assigned, "lab") - anchor.Settings = map[string][]catalogue.Layer{"lab": {{From: "anchor", Values: map[string]any{"zone": "Lab.Example."}}}} - anchor.Ports = map[string]map[int]int{"lab": {53: 5353}} - zones := placed(t, in, "laptop").Rendering.Zones - if len(zones) != 1 || zones[0] != (catalogue.ZoneAt{Zone: "lab.example", Node: "anchor", Module: "lab", Address: "10.77.0.1", Port: 5353}) { - t.Fatalf("zones are %+v", zones) - } - laptop := nodeOf(t, &in, "laptop") - laptop.Assigned = append(laptop.Assigned, "lab") - laptop.Settings = map[string][]catalogue.Layer{"lab": {{From: "laptop", Values: map[string]any{"zone": "lab.example"}}}} - if _, err := Places(in, "laptop"); err == nil || !strings.Contains(err.Error(), "the mesh's zones cannot be forwarded") { - t.Fatalf("one zone declared twice: %v", err) - } -} - -// Every holder of a replicated seat is listed by internal name at its private address (novox/hq ADR 0223): the holders on record, -// and only the sole claimant when there are none; a holder off the private network is left out. -func TestEveryHolderOfAReplicatedSeatIsListedOnTheNetwork(t *testing.T) { - in := aMesh(t) - resolver := catalogue.Manifest{Module: "resolver", Version: "1", - Provides: []catalogue.Offer{{Name: "wildcard-resolution", Scope: catalogue.ScopeMesh}}, - Claims: []catalogue.Claim{{Name: "mesh-dns-resolver", Scope: catalogue.ScopeMesh}}} - in.Catalogue["resolver"] = resolver - anchor, laptop := nodeOf(t, &in, "anchor"), nodeOf(t, &in, "laptop") - anchor.Assigned = append(anchor.Assigned, "resolver") - // One claimant and nothing on record: the sole claimant is listed. - if got := placed(t, in, "laptop").Rendering.Holders["mesh-dns-resolver"]; len(got) != 1 || got["anchor.internal"] != "10.77.0.1" { - t.Errorf("the sole claimant: %v", got) - } - // Both on record: both are listed (two claimants with nothing on record would be refused at resolution). - laptop.Assigned = append(laptop.Assigned, "resolver") - in.Holdings = []catalogue.Held{ - {Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "resolver"}, - {Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "laptop", Module: "resolver"}} - got := placed(t, in, "laptop").Rendering.Holders["mesh-dns-resolver"] - if len(got) != 2 || got["anchor.internal"] != "10.77.0.1" || got["laptop.internal"] != "10.77.0.2" { - t.Errorf("both holders on record: %v", got) - } - // A holder off the private network is left out: a resolver named at an address nothing answers waits out its timeout. - in.Nodes = append(in.Nodes, Node{Name: "ace", Place: Place{Address: "10.77.0.3"}, Assigned: []string{"resolver"}}) - in.Holdings = append(in.Holdings, catalogue.Held{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "ace", Module: "resolver"}) - if got := placed(t, in, "laptop").Rendering.Holders["mesh-dns-resolver"]; len(got) != 2 || got["ace.internal"] != "" { - t.Errorf("a holder off the network is listed: %v", got) - } -} diff --git a/go/compose/resolve.go b/go/compose/resolve.go deleted file mode 100644 index 6931adc..0000000 --- a/go/compose/resolve.go +++ /dev/null @@ -1,347 +0,0 @@ -package compose - -import ( - "fmt" - "slices" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// LicenceProvision is what a module requires in order to be given a licence (the controller's licences.Provision). -const LicenceProvision = "model-access" - -// Resolved is what one node should run, from what was assigned to it, with the settings of everything that resolved. -type Resolved struct { - Plan catalogue.Resolution - Settings catalogue.SettingsBy - // Foreseen are the needs composed with a stand-in for a credential the next send makes (or holds with no value given - // here), as " on needs from ". Nothing was made. - Foreseen []string - // NotGiven are the words of each credential only a person gives that nobody has: the consumer still resolves without it - // (novox/hq ADR 0337 clause 4). - NotGiven []string -} - -// Resolve works out everything a node should run, from what was assigned to it (the controller's planFor, plan.go): the -// resolution over the rest of the mesh, the credential of each thing the node takes from elsewhere, and the settings of -// every module that resolved. It makes and writes nothing: a credential not held is composed with a stand-in and named -// foreseen, one only a person gives and nobody has is left out and said, one the send would refuse is refused with the -// controller's words. A failure to compose the node's own modules is an error that satisfies IsUnresolvable, told apart -// from an unreadable Input (novox/hq 04-ISSUES/152). -func Resolve(in Input, node string) (Resolved, error) { - return newComposer(in).planFor(node) -} - -// IsUnresolvable reports whether err is a node's own set failing to compose, rather than the mesh being unable to answer. -func IsUnresolvable(err error) bool { return unresolvable(err) } - -// planFor is the controller's planFor, worked out once per node in one composition (issue 1954): each caller has its -// own copy of what a caller may set (a need's fields, as Resolution.Compose sets its Serves), the rest only read. -func (c *composer) planFor(nodeName string) (Resolved, error) { - p, ok := c.plans[nodeName] - if !ok { - p.resolved, p.err = c.planForNow(nodeName) - if c.plans == nil { - c.plans = map[string]planOf{} - } - c.plans[nodeName] = p - } - if p.err != nil { - return Resolved{}, p.err - } - return p.resolved.copied(), nil -} - -// planOf is planFor's answer for one node, kept. -type planOf struct { - resolved Resolved - err error -} - -// copied is r with its own needs, words and settings lists: what a caller may change of it. -func (r Resolved) copied() Resolved { - out := r - out.Plan.Needs = slices.Clone(r.Plan.Needs) - out.Foreseen = slices.Clone(r.Foreseen) - out.NotGiven = slices.Clone(r.NotGiven) - if r.Settings != nil { - out.Settings = make(catalogue.SettingsBy, len(r.Settings)) - for m, layers := range r.Settings { - out.Settings[m] = slices.Clone(layers) - } - } - return out -} - -// planForNow is planFor worked out. -func (c *composer) planForNow(nodeName string) (Resolved, error) { - resolved, err := c.resolution(nodeName) - if err != nil { - return Resolved{}, err - } - node, err := c.in.node(nodeName) - if err != nil { - return Resolved{}, err - } - shelf := c.shelfFor(node) - var out Resolved - - // The credential for each thing this node takes from elsewhere. - for i, n := range resolved.Needs { - // Issued for this holder alone and kept by the vault (issue 757). - resolved.Needs[i].IssuedToHolder = issuedToHolder(shelf, n) - resolved.Needs[i].ProviderMoved = providerMoved(shelf, n) - resolved.Needs[i].AnsweredByHelper = answeredByHelper(shelf, n) - if n.ByRecord { - // Answered by something the mesh holds, so there is no pair-wise secret between two machines. - licence, _ := c.licence(n.From) - if licence.Keys == nil { - // The keys are not given: composed with a stand-in and named, not failed for a value only a send reads. - resolved.Needs[i].Sealed = catalogue.ForeseenSealed - out.Foreseen = append(out.Foreseen, fmt.Sprintf("%s on %s needs %s from %s", n.For, nodeName, n.Name, n.From)) - } else { - // Given, and none for this holder: the mesh holds none. - resolved.Needs[i].Sealed = licence.Keys[Holder{Node: nodeName, Module: n.For}] - } - // If this holder is the licence's manager, hand it the manager node's PUBLIC sealing key in its bound facts - // (novox/hq ADR 0050). - if pub := c.managerPublicKeyFor(licence, nodeName, n.For); pub != "" { - serves := map[string]any{} - for k, v := range resolved.Needs[i].Serves { - serves[k] = v - } - serves["manager_public_key"] = pub - resolved.Needs[i].Serves = serves - // The manager holder: its empty pre-adoption refresh token is a waiting state, not a missing consumer key. - resolved.Needs[i].Manager = true - } - continue - } - if credentialless(n) { - // Its provider's offer presents no credential (form none): nothing is sealed, made or stood in for (issue 1126). - continue - } - cred, held := c.credential(n, nodeName) - _, outside := issuedOutsideBy(shelf, n) - switch { - case cred.State == CredentialRefused: - // Said rather than skipped. A machine that resolves cleanly and receives no credential is one that will fail to - // authenticate at some later, less obvious moment. - return Resolved{}, fmt.Errorf("%s on %s needs %s from %s and no credential could be made for it: %s", - n.For, nodeName, n.Name, n.From, cred.Why) - case cred.State == CredentialNotGiven, outside && !(held && cred.State == CredentialHeld): - // Not given, and the rest of the consumer runs (ADR 0337 clause 4): no credential file, so the module can tell - // it was not given; nothing the mesh made stands in for it. - why := cred.Why - if why == "" { - why = "nobody gave it" - } - out.NotGiven = append(out.NotGiven, fmt.Sprintf("%s on %s holds no %s from %s: %s", n.For, nodeName, n.Name, n.From, why)) - continue - case cred.State == CredentialHeld && cred.Sealed != "": - resolved.Needs[i].Sealed = cred.Sealed - default: - // Held with no value here, or none held: the next send makes it (or reads it), and a stand-in is composed. - resolved.Needs[i].Sealed = catalogue.ForeseenSealed - out.Foreseen = append(out.Foreseen, fmt.Sprintf("%s on %s needs %s from %s", n.For, nodeName, n.Name, n.From)) - } - } - - // Settings for everything that resolved, including modules nobody assigned directly: a requirement pulled in by - // something else is still configurable. - settings := catalogue.SettingsBy{} - for _, m := range resolved.Modules { - layers := node.Settings[m.Module] - if len(layers) == 0 { - continue - } - settings[m.Module] = append([]catalogue.Layer(nil), layers...) - } - out.Plan, out.Settings = resolved, settings - return out, nil -} - -// credential is how the pair credential of one need stands in the Input, and whether the Input names it at all. -func (c *composer) credential(n catalogue.Needed, consumer string) (Credential, bool) { - for _, cr := range c.in.Credentials { - if cr.Provision == n.Name && cr.Consumer == consumer && cr.ConsumerModule == n.For && cr.Provider == n.From && cr.Local == n.Local { - return cr, true - } - } - return Credential{State: CredentialWouldMake}, false -} - -// licence is the licence of that name. -func (c *composer) licence(name string) (Licence, bool) { - for _, l := range c.in.Licences { - if l.Name == name { - return l, true - } - } - return Licence{}, false -} - -// managerPublicKeyFor is the manager node's public sealing key, but only when (node, module) is the licence's manager holder -// and empty otherwise (plan.go managerPublicKeyFor): delivered to the manager module in its bound facts so it can re-seal a -// rotated refresh token to this node (novox/hq ADR 0050), and to no consumer. -func (c *composer) managerPublicKeyFor(l Licence, node, module string) string { - if l.Manager == (Holder{}) || node != l.Manager.Node || module != l.Manager.Module { - return "" - } - n, err := c.in.node(node) - if err != nil { - return "" - } - return n.SealingKey -} - -// issuedOutsideBy is whether a need's provision is offered by its provider with a credential issued outside the mesh -// (novox/hq ADR 0337), and that provider module: the one answering it when known, else any that offers it so. -func issuedOutsideBy(shelf map[string]catalogue.Manifest, n catalogue.Needed) (string, bool) { - if n.Module != "" { - if m, known := shelf[n.Module]; known { - return n.Module, m.IssuedOutsideOf(n.Name) - } - } - return catalogue.OffersIssuedOutside(shelf, n.Name) -} - -// issuedToHolder is whether the vault keeps a need's credential for its holder alone (issues 749, 757 and 779). -func issuedToHolder(shelf map[string]catalogue.Manifest, n catalogue.Needed) bool { - if n.ByRecord || n.Module == "" { - return false - } - provider, known := shelf[n.Module] - if !known || !movedProvider(provider) { - return false - } - if _, issued := provider.IssuedCredentialOf(n.Name); issued { - return true - } - return provider.CredentialFormOf(n.Name) == catalogue.CredentialFormPair -} - -// providerMoved is whether a need is answered by a module marked moved (issue 788), or by one whose offer of the provision -// says the provider helper answers it (issue 792). -func providerMoved(shelf map[string]catalogue.Manifest, n catalogue.Needed) bool { - if n.ByRecord || n.Module == "" { - return false - } - provider, known := shelf[n.Module] - return known && (movedProvider(provider) || provider.AnsweredByHelperFor(n.Name)) -} - -// answeredByHelper is whether a need's provider offers it answered by the provider helper (issue 792): keyed by the seat -// mesh-provisioner for every consumer, handed or not (catalogue.Keyed, issue 774). -func answeredByHelper(shelf map[string]catalogue.Manifest, n catalogue.Needed) bool { - if n.ByRecord || n.Module == "" { - return false - } - provider, known := shelf[n.Module] - return known && provider.AnsweredByHelperFor(n.Name) -} - -// movedProvider is whether a provider's credentials are the vault's: the module is marked moved, or it holds the -// mesh-vault seat (issue 793). -func movedProvider(m catalogue.Manifest) bool { - return m.Moved || slices.ContainsFunc(m.Claims, func(c catalogue.Claim) bool { return c.Name == "mesh-vault" }) -} - -// resolution works out what one node should run, from what was assigned to it: the controller's planFor up to and -// including the resolution (plan.go planFor), and no further. A failure to compose the node's own modules is wrapped as -// notResolvable; every other failure is returned as it is. -func (c *composer) resolution(nodeName string) (catalogue.Resolution, error) { - node, err := c.in.node(nodeName) - if err != nil { - return catalogue.Resolution{}, err - } - shelf := c.shelfFor(node) - world, _, left, err := c.theRestOfTheMesh(nodeName) - if err != nil { - return catalogue.Resolution{}, err - } - world.Pinned = node.Pins - // Where each of its consumers of a provision that keeps data was last sent (novox/hq ADR 0232). - world.Bound = node.Bound - - onNetwork, err := c.whereEveryoneIs() - if err != nil { - return catalogue.Resolution{}, err - } - - // What this mesh can answer with a record rather than a machine, and which record each of this node's modules was - // put on. - world.Licences, world.Using = c.licencesFor(nodeName) - - resolved, err := catalogue.Resolve(shelf, node.Assigned, - catalogue.Node{Name: nodeName, Site: node.Place.Site, Capabilities: node.Capabilities, - At: onNetwork[nodeName], PublicDomain: node.PublicDomain, - Account: node.Account, AccountHome: node.AccountHome, - AgentAccount: node.AgentAccount, AgentAccountHome: node.AgentAccountHome}, world) - if err != nil { - // What another node's set would have offered is missing when that set does not resolve here: said with the - // refusal, so "nothing provides" is read beside why (issue 1126, probe #532). - if len(left) > 0 { - var why []string - for _, l := range left { - why = append(why, l.String()) - } - err = fmt.Errorf("%w\n\nand %d node(s) offer nothing here, as they could not be resolved: %s", err, len(left), strings.Join(why, "; ")) - } - if len(c.unhostable) > 0 { - err = fmt.Errorf("%w\n\nand these modules offer nothing here, as their node cannot run them in this composition: %s", - err, strings.Join(c.unhostable, "; ")) - } - // The node's own set does not compose: the only failure a mesh-wide gatherer may pass over. - return catalogue.Resolution{}, notResolvable{err} - } - return resolved, nil -} - -// shelfFor is the catalogue as one node composes its own set: the Input's, with each recorded or core module the node keeps at the -// build it runs put in place of the current one (plan.go keepRecorded). A copy: the Input's catalogue is untouched. Only the node's own planFor and -// resolution use it; the rest of the mesh and who is on the network are read with the current manifests, as the controller reads them -// before it keeps a recorded module. -func (c *composer) shelfFor(node Node) map[string]catalogue.Manifest { - if len(node.Kept) == 0 { - return c.in.Catalogue - } - shelf := make(map[string]catalogue.Manifest, len(c.in.Catalogue)+len(node.Kept)) - for name, m := range c.in.Catalogue { - shelf[name] = m - } - for name, m := range node.Kept { - shelf[name] = m - } - return shelf -} - -// licencesFor is what this node can be answered with by record, and what it was put on. A mesh with no licences at all -// is the ordinary case and not an error. -func (c *composer) licencesFor(node string) (map[string][]catalogue.Record, map[string]map[string]catalogue.Record) { - all := c.in.Licences - if len(all) == 0 { - return nil, nil - } - offered := map[string][]catalogue.Record{} - byName := map[string]catalogue.Record{} - for _, one := range all { - record := catalogue.Record{Name: one.Name, Serves: one.Serves} - offered[LicenceProvision] = append(offered[LicenceProvision], record) - byName[one.Name] = record - } - using := map[string]map[string]catalogue.Record{} - for _, one := range all { - for _, h := range one.Holders { - if h.Node != node { - continue - } - if using[h.Module] == nil { - using[h.Module] = map[string]catalogue.Record{} - } - using[h.Module][LicenceProvision] = byName[one.Name] - } - } - return offered, using -} diff --git a/go/compose/resolve_test.go b/go/compose/resolve_test.go deleted file mode 100644 index 8bb30b8..0000000 --- a/go/compose/resolve_test.go +++ /dev/null @@ -1,388 +0,0 @@ -package compose - -import ( - "errors" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/overlay" -) - -// storeManifests is mesh-controller's bindings_test.go storeManifests (cmd/mesh-controller at 6c2a8d62): a store holding the -// mesh-store seat, a resolver holding mesh-dns-resolver, a network that requires wildcard resolution, and a board that -// requires a database. -func storeManifests() []catalogue.Manifest { - return []catalogue.Manifest{ - {Module: "store", Version: "1", - Provides: []catalogue.Offer{{Name: "postgres-database", Scope: catalogue.ScopeMesh}}, - Claims: []catalogue.Claim{{Name: "mesh-store", Scope: catalogue.ScopeMesh}}, - Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}, - Grants: map[string]string{"postgres-database": "/var/lib/mesh/store/grants"}}, - {Module: "resolver", Version: "1", - Provides: []catalogue.Offer{{Name: "wildcard-resolution", Scope: catalogue.ScopeMesh}}, - Claims: []catalogue.Claim{{Name: "mesh-dns-resolver", Scope: catalogue.ScopeMesh}}}, - {Module: "network", Version: "1", Requires: []string{"wildcard-resolution"}}, - {Module: "board", Version: "1", Requires: []string{"postgres-database"}}, - } -} - -func need(t *testing.T, plan catalogue.Resolution, consumer, provision string) catalogue.Needed { - t.Helper() - for _, n := range plan.Needs { - if n.For == consumer && n.Name == provision { - return n - } - } - t.Fatalf("no %s for %s: %+v", provision, consumer, plan.Needs) - return catalogue.Needed{} -} - -// theStoreMesh is the mesh of mesh-controller's bindings_integration_test.go -// TestTheIncidentAConsumerStaysBesideItsDataUntilAPersonPinsIt: the anchor's store and resolver hold the mesh's seats on -// record; the laptop runs its own of each, and a network and a board. -func theStoreMesh(t *testing.T) Input { - t.Helper() - in := aMesh(t) - for _, m := range storeManifests() { - in.Catalogue[m.Module] = m - } - anchor, laptop := nodeOf(t, &in, "anchor"), nodeOf(t, &in, "laptop") - anchor.Assigned = append(anchor.Assigned, "store", "resolver") - laptop.Assigned = append(laptop.Assigned, "store", "resolver", "network", "board") - in.Holdings = []catalogue.Held{ - {Claim: "mesh-store", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "store", Site: "here"}, - {Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "resolver", Site: "here"}, - } - return in -} - -// 1362/AC7 and AC4 (bindings_integration_test.go): a consumer of a provision that keeps its data stays beside its data, one that -// does not follows the seat; the laptop's store taken away, the consumer bound there on record is refused, not moved to the -// anchor's empty one; a person's pin moves it. -func TestAConsumerStaysBesideItsDataUntilAPersonPinsIt(t *testing.T) { - in := theStoreMesh(t) - got, err := Resolve(in, "laptop") - if err != nil { - t.Fatal(err) - } - if n := need(t, got.Plan, "board", "postgres-database"); n.From != "laptop" || n.Module != "store" || !n.KeepsData { - t.Fatalf("the consumer was bound to %s/%s (keeps data: %v); its data is beside it", n.From, n.Module, n.KeepsData) - } - if n := need(t, got.Plan, "network", "wildcard-resolution"); n.From != "anchor" || n.KeepsData { - t.Fatalf("the resolver was bound to %s (keeps data: %v); its seat is held on anchor (issue 258)", n.From, n.KeepsData) - } - - // Sent, and recorded: the binding to data. The laptop's store taken away: refused, not moved to the anchor's empty one. - laptop := nodeOf(t, &in, "laptop") - laptop.Bound = map[string]map[string]catalogue.Chosen{"board": {"postgres-database": {Node: "laptop", Module: "store"}}} - laptop.Assigned = []string{overlay.Name, "resolver", "network", "board"} - _, err = Resolve(in, "laptop") - if err == nil || !IsUnresolvable(err) || - !strings.Contains(err.Error(), "board on laptop is bound to laptop/store") || - !strings.Contains(err.Error(), "pin laptop postgres-database anchor store") { - t.Fatalf("the consumer's store went and it was answered elsewhere: %v", err) - } - - // A person pins the anchor's: it moves, and the resolution says where it was bound (Kept is empty, as it moves). - laptop.Pins = map[string]catalogue.Chosen{"postgres-database": {Node: "anchor", Module: "store"}} - got, err = Resolve(in, "laptop") - if err != nil { - t.Fatal(err) - } - if n := need(t, got.Plan, "board", "postgres-database"); n.From != "anchor" { - t.Fatalf("pinned to the anchor and bound to %s", n.From) - } -} - -// 1362/AC4: a data-keeping consumer bound on record to a provider elsewhere is kept on that provider, and the resolution says -// so (Resolution.Kept) when the mesh would answer it otherwise. -func TestADataKeepingConsumerBoundElsewhereIsKeptThereAndSaid(t *testing.T) { - in := theStoreMesh(t) - laptop := nodeOf(t, &in, "laptop") - laptop.Assigned = []string{overlay.Name, "resolver", "network", "board"} // no store of its own - laptop.Bound = map[string]map[string]catalogue.Chosen{"board": {"postgres-database": {Node: "anchor", Module: "store"}}} - got, err := Resolve(in, "laptop") - if err != nil { - t.Fatal(err) - } - if n := need(t, got.Plan, "board", "postgres-database"); n.From != "anchor" || !n.KeepsData { - t.Fatalf("the consumer is bound to %s (keeps data: %v), not where it was sent", n.From, n.KeepsData) - } - if len(got.Plan.Kept) != 0 { - t.Errorf("a binding that was kept where it is is said as a kept move: %+v", got.Plan.Kept) - } - // And bound somewhere else on record while the mesh would answer from the anchor: kept, and said. - laptop.Bound = map[string]map[string]catalogue.Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}} - home := Node{Name: "home", Place: Place{Site: "there", Address: "10.77.0.7"}, Assigned: []string{overlay.Name, "store"}, - Capabilities: map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true}} - in.Nodes = append(in.Nodes, home) - got, err = Resolve(in, "laptop") - if err != nil { - t.Fatal(err) - } - if n := need(t, got.Plan, "board", "postgres-database"); n.From != "home" { - t.Errorf("the consumer was moved from where it was sent: %s", n.From) - } -} - -// A provider module whose offer says only a person gives its credential (ADR 0337), and a consumer of it. -func outsideObjects() catalogue.Manifest { - m := objects() - m.Provides[0].Credential = &catalogue.OfferCredential{IssuedBy: catalogue.IssuedOutside} - return m -} - -func theObjectsMesh(t *testing.T, provider catalogue.Manifest) Input { - t.Helper() - in := aMesh(t) - in.Catalogue[provider.Module], in.Catalogue["files"] = provider, files() - nodeOf(t, &in, "anchor").Assigned = append(nodeOf(t, &in, "anchor").Assigned, provider.Module) - nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "files") - nodeOf(t, &in, "anchor").SealingKey = "anchor-key" - nodeOf(t, &in, "laptop").SealingKey = "laptop-key" - return in -} - -func theFilesNeed(t *testing.T, got Resolved) catalogue.Needed { - t.Helper() - return need(t, got.Plan, "files", "s3-bucket") -} - -// 1362/AC1: a pair credential the Input names as existing with no value, and one it names not at all, are composed with a -// stand-in and named foreseen, and nothing is made; one with a value is composed with it and foreseen is empty. -func TestAPairCredentialNotHeldIsComposedWithAStandInAndNamedForeseen(t *testing.T) { - mk := func() Input { return theObjectsMesh(t, objects()) } - for name, creds := range map[string][]Credential{ - "no entry": nil, - "held with no value": {{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialHeld}}, - "to be made": {{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialWouldMake}}, - } { - in := mk() - in.Credentials = creds - got, err := Resolve(in, "laptop") - if err != nil { - t.Fatalf("%s: %v", name, err) - } - if n := theFilesNeed(t, got); n.Sealed != catalogue.ForeseenSealed { - t.Errorf("%s: the credential is %q, not the stand-in", name, n.Sealed) - } - if len(got.Foreseen) != 1 || got.Foreseen[0] != "files on laptop needs s3-bucket from anchor" { - t.Errorf("%s: foreseen is %v", name, got.Foreseen) - } - } - in := mk() - in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", - State: CredentialHeld, Sealed: "sealed-to-laptop"}} - got, err := Resolve(in, "laptop") - if err != nil { - t.Fatal(err) - } - if n := theFilesNeed(t, got); n.Sealed != "sealed-to-laptop" || len(got.Foreseen) != 0 { - t.Errorf("a held credential: %q foreseen %v", n.Sealed, got.Foreseen) - } - unchanged(t, mk, func(in Input) { - if _, err := Resolve(in, "laptop"); err != nil { - t.Fatal(err) - } - }) -} - -// 1362/AC2: a credential only a person gives and nobody has is not given and not made: the consumer still resolves without it -// (novox/hq ADR 0337 clause 4), said with the controller's words; one given is composed with it. -func TestACredentialOnlyAPersonGivesIsNotMadeAndTheConsumerStillResolves(t *testing.T) { - for name, creds := range map[string][]Credential{ - "no entry": nil, - "to be made (never, for one issued outside)": {{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialWouldMake}}, - "not given": {{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialNotGiven, - Why: "the value held was made by the mesh, which objects's software would refuse"}}, - } { - in := theObjectsMesh(t, outsideObjects()) - in.Credentials = creds - got, err := Resolve(in, "laptop") - if err != nil { - t.Fatalf("%s: %v", name, err) - } - if n := theFilesNeed(t, got); n.Sealed != "" { - t.Errorf("%s: a value stands in for what nobody gave: %q", name, n.Sealed) - } - if len(got.Foreseen) != 0 || len(got.NotGiven) != 1 || !strings.Contains(got.NotGiven[0], "files on laptop holds no s3-bucket from anchor: ") { - t.Errorf("%s: foreseen %v not given %v", name, got.Foreseen, got.NotGiven) - } - if len(got.Plan.Modules) == 0 { - t.Errorf("%s: the machine did not resolve", name) - } - } - in := theObjectsMesh(t, outsideObjects()) - in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", - State: CredentialHeld, Sealed: "given-and-sealed"}} - got, err := Resolve(in, "laptop") - if err != nil || theFilesNeed(t, got).Sealed != "given-and-sealed" || len(got.NotGiven) != 0 { - t.Fatalf("a value a person gave: %+v %v", got.NotGiven, err) - } -} - -// 1362/AC3: a credential the send would refuse, a given value sealed to a key a machine no longer has, refuses the resolution with -// today's words, and is not a node whose set does not compose. -func TestACredentialTheSendWouldRefuseRefusesTheResolutionInTheSendsWords(t *testing.T) { - why := "files's \"s3-bucket\" credential from anchor was accepted from a person, and a sealing key at one end has changed since. " + - "The mesh cannot re-seal a value it does not hold: accept it again with `secret accept laptop files s3-bucket --provider anchor`" - in := theObjectsMesh(t, objects()) - in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", - State: CredentialRefused, Why: why}} - _, err := Resolve(in, "laptop") - want := "files on laptop needs s3-bucket from anchor and no credential could be made for it: " + why - if err == nil || err.Error() != want { - t.Fatalf("got %v, want %q", err, want) - } - if IsUnresolvable(err) { - t.Error("a refused credential is a node whose set does not compose") - } -} - -// A record-answered need is filled with the licence key sealed to this holder, and the manager holder is handed the manager node's -// public sealing key (novox/hq ADR 0050) and no other. -func TestARecordAnsweredNeedGetsItsKeyAndTheManagerItsPublicKey(t *testing.T) { - in := aMesh(t) - in.Catalogue["assistant"] = catalogue.Manifest{Module: "assistant", Version: "1", Requires: []string{LicenceProvision}} - laptop := nodeOf(t, &in, "laptop") - laptop.Assigned = append(laptop.Assigned, "assistant") - laptop.SealingKey = "laptop-key" - in.Licences = []Licence{{Name: "personal", Serves: map[string]any{"model": "x"}, - Holders: []Holder{{Node: "laptop", Module: "assistant"}}, - Keys: map[Holder]string{{Node: "laptop", Module: "assistant"}: "key-sealed-to-laptop"}}} - got, err := Resolve(in, "laptop") - if err != nil { - t.Fatal(err) - } - n := need(t, got.Plan, "assistant", LicenceProvision) - if !n.ByRecord || n.Sealed != "key-sealed-to-laptop" || n.Manager || n.Serves["manager_public_key"] != nil { - t.Fatalf("the record-answered need is %+v", n) - } - in.Licences[0].Manager = Holder{Node: "laptop", Module: "assistant"} - got, err = Resolve(in, "laptop") - if err != nil { - t.Fatal(err) - } - n = need(t, got.Plan, "assistant", LicenceProvision) - if !n.Manager || n.Serves["manager_public_key"] != "laptop-key" || n.Serves["model"] != "x" { - t.Fatalf("the manager is %+v", n) - } - if len(got.Foreseen) != 0 || len(got.NotGiven) != 0 { - t.Errorf("a record-answered need needs no pair credential: %v %v", got.Foreseen, got.NotGiven) - } -} - -// 1362/AC5: a node whose own set does not compose returns an error callers can tell from an unreadable Input. -func TestANodeWhoseOwnSetDoesNotComposeIsToldApartFromAnUnreadableInput(t *testing.T) { - in := aMesh(t) - in.Catalogue["rival-one"] = catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} - in.Catalogue["rival-two"] = catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} - laptop := nodeOf(t, &in, "laptop") - laptop.Assigned = append(laptop.Assigned, "rival-one", "rival-two") - if _, err := Resolve(in, "laptop"); err == nil || !IsUnresolvable(err) { - t.Fatalf("a set that does not compose: %v", err) - } - if _, err := Resolve(in, "nowhere"); err == nil || IsUnresolvable(err) || !errors.Is(err, ErrNoSuchNode) { - t.Fatalf("a node the mesh does not have: %v", err) - } - in.Catalogue = nil - if _, err := Resolve(in, "anchor"); err == nil || IsUnresolvable(err) { - t.Fatalf("an Input without the catalogue: %v", err) - } -} - -// 1362/AC6: settings are returned for every resolved module that has layers in the Input, including one pulled in by another. -func TestSettingsAreReturnedForEveryResolvedModuleIncludingOneThatWasPulledIn(t *testing.T) { - in := aMesh(t) - in.Catalogue["lib"] = catalogue.Manifest{Module: "lib", Version: "1", Provides: catalogue.Offers("blob")} - in.Catalogue["app"] = catalogue.Manifest{Module: "app", Version: "1", Requires: []string{"blob"}} - laptop := nodeOf(t, &in, "laptop") - laptop.Assigned = append(laptop.Assigned, "app") - laptop.Settings = map[string][]catalogue.Layer{ - "lib": {{From: "mesh", Values: map[string]any{"x": 1}}}, - "app": {{From: "laptop", Values: map[string]any{"y": 2}}}, - "other": {{From: "laptop", Values: map[string]any{"z": 3}}}, // not resolved here: no settings come of it - "empty": nil, - } - got, err := Resolve(in, "laptop") - if err != nil { - t.Fatal(err) - } - pulled := false - for _, m := range got.Plan.Modules { - pulled = pulled || m.Module == "lib" - } - if !pulled { - t.Fatalf("lib was not pulled in: %+v", got.Plan.Modules) - } - if len(got.Settings) != 2 || got.Settings["lib"][0].Values["x"] != 1 || got.Settings["app"][0].Values["y"] != 2 { - t.Errorf("settings are %+v", got.Settings) - } - // The settings returned are copies: changing them does not change the Input. - got.Settings["app"][0].From = "changed" - if laptop.Settings["app"][0].From != "laptop" { - t.Error("the settings returned share the Input's layers") - } -} - -// Round 1, finding 3: a licence key the Input does not carry (Keys not given) is composed with a stand-in and named foreseen, so the -// node still composes; Keys given that lack the holder is the mesh holding none, and the need stays empty. -func TestALicenceKeyTheInputDoesNotCarryIsAStandInAndNamedForeseen(t *testing.T) { - in := aMesh(t) - in.Catalogue["assistant"] = catalogue.Manifest{Module: "assistant", Version: "1", Requires: []string{LicenceProvision}} - laptop := nodeOf(t, &in, "laptop") - laptop.Assigned = append(laptop.Assigned, "assistant") - in.Licences = []Licence{{Name: "personal", Serves: map[string]any{"model": "x"}, Holders: []Holder{{Node: "laptop", Module: "assistant"}}}} - got, err := Resolve(in, "laptop") - if err != nil { - t.Fatal(err) - } - if n := need(t, got.Plan, "assistant", LicenceProvision); n.Sealed != catalogue.ForeseenSealed { - t.Errorf("a key not given is composed as %q", n.Sealed) - } - if len(got.Foreseen) != 1 || !strings.Contains(got.Foreseen[0], "assistant on laptop needs model-access") { - t.Errorf("foreseen %v", got.Foreseen) - } - // And a whole node composes with it. - if _, problems, err := Compose(in, "laptop"); err != nil || len(problems) != 0 { - t.Errorf("a node using a licence whose key is not carried: %v %v", err, problems) - } - // Keys given, and none for this holder: the mesh holds none. - in.Licences[0].Keys = map[Holder]string{} - got, err = Resolve(in, "laptop") - if err != nil { - t.Fatal(err) - } - if n := need(t, got.Plan, "assistant", LicenceProvision); n.Sealed != "" || len(got.Foreseen) != 0 { - t.Errorf("a key the mesh holds none of: %q %v", n.Sealed, got.Foreseen) - } -} - -// A consumer not handed of a provision whose provider's offer says the helper answers it has that provision in a plan of -// its own (issue 774: grafana's route): the composition marks the need answered by the helper, and the plan keys it. -func TestANeedAnsweredByTheHelperIsInTheConsumersPlan(t *testing.T) { - in := theStoreMesh(t) - store := in.Catalogue["store"] - store.Provides = []catalogue.Offer{{Name: "postgres-database", Scope: catalogue.ScopeMesh, AnsweredBy: catalogue.AnsweredByHelper}} - in.Catalogue["store"] = store - got, err := Resolve(in, "laptop") - if err != nil { - t.Fatal(err) - } - if n := need(t, got.Plan, "board", "postgres-database"); !n.AnsweredByHelper { - t.Fatalf("the need is not marked answered by the helper: %+v", n) - } - with := rendered(t, in, "laptop", Foreseeing).Rendering - with.Hands, with.Provisioner = true, "mesh-provisioner-client" - if with.Generators, err = newComposer(in).generators(); err != nil { - t.Fatal(err) - } - c, err := got.Plan.Compose(with) - if err != nil { - t.Fatal(err) - } - if p := c.Plans["board"].Provisions; len(p) != 1 || p[0].Provision != "postgres-database" || p[0].ProviderModule != "store" { - t.Fatalf("board's plan is %+v", c.Plans["board"]) - } -} diff --git a/go/compose/rest.go b/go/compose/rest.go deleted file mode 100644 index 7ddb928..0000000 --- a/go/compose/rest.go +++ /dev/null @@ -1,255 +0,0 @@ -package compose - -import ( - "errors" - "fmt" - "slices" - "sort" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// notResolvable marks the one failure in a node's resolution that is a statement about the node: its assigned modules -// do not compose. Every other failure means the mesh could not be asked. Whoever gathers across the mesh skips a node -// that cannot resolve and never a node that could not be read (novox/hq 04-ISSUES/152). -type notResolvable struct{ err error } - -func (n notResolvable) Error() string { return n.err.Error() } -func (n notResolvable) Unwrap() error { return n.err } - -// unresolvable reports whether err is a node's own set failing to compose. -func unresolvable(err error) bool { - var n notResolvable - return errors.As(err, &n) -} - -// Passes is what a composition found that the mesh must record, and did not: each seat held once whose holder on record -// is assigned nowhere that exactly one claimant takes over (novox/hq ADR 0449 clause 4), and the lines that say what was -// found. The caller records a pass and writes its hand act; this package does neither. -type Passes struct { - // Passes are the seats to pass, in the order they were found. - Passes []catalogue.SeatPass - // Said are the lines the finding says (a seat two claimants could take, one nothing can). - Said []string -} - -// LeftOut is a node that does not resolve in the view of the rest of the mesh, with why: it offers nothing and holds -// nothing as far as every other machine's plan can tell (novox/hq issue 188). -type LeftOut struct { - Node string - Err error -} - -// String is the line the controller says of it. -func (l LeftOut) String() string { - return fmt.Sprintf("%s is left out of the rest of the mesh: it does not resolve: %v", l.Node, l.Err) -} - -// TheRestOfTheMesh is what every other node holds and offers, as the controller works it out (plan.go theRestOfTheMesh): -// two passes over the others, because what a node offers needs it resolved and resolving it may need what the mesh -// offers. The first takes brokered requirements on trust and answers only what each node offers; the second answers -// everything with that in hand. Nothing is declared from the first. -// -// It writes nothing. A seat the mesh passes on is returned in Passes for the caller to record; the World carries the -// holdings as they stand after the passes, and Input's own are unchanged. A node left out is returned with its reason, in -// the order the controller said them (the first pass's, then the second's when no node is excluded). -func TheRestOfTheMesh(in Input, exclude string) (catalogue.World, Passes, []LeftOut, error) { - c := newComposer(in) - return c.theRestOfTheMesh(exclude) -} - -func (c *composer) theRestOfTheMesh(exclude string) (catalogue.World, Passes, []LeftOut, error) { - r, ok := c.rest[exclude] - if !ok { - r.world, r.passes, r.left, r.err = c.theRestOfTheMeshNow(exclude) - r.unhostable = c.unhostable - if c.rest == nil { - c.rest = map[string]restOf{} - } - c.rest[exclude] = r - } - // Each caller has its own copy of what it may set or add to; the maps and the slices within are only read. - c.unhostable = slices.Clone(r.unhostable) - return r.world, r.passes, slices.Clone(r.left), r.err -} - -// theRestOfTheMeshNow is theRestOfTheMesh worked out: every other machine's set resolved, twice. -func (c *composer) theRestOfTheMeshNow(exclude string) (catalogue.World, Passes, []LeftOut, error) { - shelf := c.in.Catalogue - holdings := append([]catalogue.Held(nil), c.in.Holdings...) - nodes := c.in.sorted() - - onNetwork, err := c.whereEveryoneIs() - if err != nil { - return catalogue.World{}, Passes{}, nil, err - } - - type candidate struct { - node catalogue.Node - assigned []string - pins map[string]catalogue.Chosen - } - var others []candidate - // Every machine's assignments, before the holdings are read by any resolution (novox/hq ADR 0449 clause 4): a seat - // held once whose holder on record is assigned on no machine passes to its only claimant here. - everyAssigned := map[string][]string{} - for _, n := range nodes { - if len(n.Assigned) > 0 { - everyAssigned[n.Name] = n.Assigned - } - } - var passes Passes - holdings, passes = passSeatsOn(holdings, assignmentsOf(shelf, everyAssigned)) - for _, n := range nodes { - if n.Name == exclude { - continue - } - theirs := everyAssigned[n.Name] - if len(theirs) == 0 { - continue - } - others = append(others, candidate{ - node: catalogue.Node{Name: n.Name, Site: n.Place.Site, Capabilities: n.Capabilities, - At: onNetwork[n.Name], PublicDomain: n.PublicDomain}, - assigned: theirs, pins: n.Pins}) - } - - var left []LeftOut - offered := map[string][]catalogue.Provider{} - var firstHeld []catalogue.Held - c.unhostable = nil - for _, o := range others { - got, err := catalogue.Resolve(shelf, o.assigned, o.node, catalogue.World{Unchecked: true, Holdings: holdings}) - if err != nil { - // Said, not skipped: a machine dropped here offers nothing and holds nothing. - left = append(left, LeftOut{Node: o.node.Name, Err: err}) - continue - } - for _, u := range got.Unhostable { - c.unhostable = append(c.unhostable, fmt.Sprintf("%s on %s lacks %s", u.Module, o.node.Name, strings.Join(u.Missing, ", "))) - } - firstHeld = append(firstHeld, got.Claims...) - for _, m := range got.Modules { - for _, name := range m.OffersAt(catalogue.ScopeMesh) { - // What that module says a consumer needs to know, with that node's settings on it. - serves, layers, err := c.servedOnNode(o.node.Name, m, name) - if err != nil { - return catalogue.World{}, Passes{}, nil, err - } - // And its own composed names, as that node composes them (novox/hq ADR 0346). - serves, leftOut, err := got.ServedOwnNames(m, name, serves, catalogue.WithDefaults(m, layers)) - if err != nil { - return catalogue.World{}, Passes{}, nil, err - } - offered[name] = append(offered[name], catalogue.Provider{ - Node: o.node.Name, At: o.node.At, Serves: serves, Module: m.Module, LeftOut: leftOut}) - } - } - } - for k := range offered { - sort.Slice(offered[k], func(i, j int) bool { - return offered[k][i].Node < offered[k][j].Node - }) - } - - // The second pass is given the first pass's holdings (novox/hq ADR 0110). - world := catalogue.World{Offered: offered, Held: firstHeld, Holdings: holdings} - var held []catalogue.Held - for _, o := range others { - // Each machine is resolved with its own pins, as its plan is. - theirs := world - theirs.Pinned = o.pins - got, err := catalogue.Resolve(shelf, o.assigned, o.node, theirs) - if err != nil { - // Said only for the whole-mesh view: with one machine excluded, the view is partial by design. - if exclude == "" { - left = append(left, LeftOut{Node: o.node.Name, Err: err}) - } - continue - } - held = append(held, got.Claims...) - } - world.Held = held - return world, passes, left, nil -} - -// passSeatsOn finds, in the composition that finds it, the new holder of each seat held once whose holder on record is -// assigned nowhere and which has exactly one claimant able to hold it (novox/hq ADR 0449 clause 4), and answers the -// holdings as they are after it, with the passes. Nothing is recorded. -func passSeatsOn(holdings []catalogue.Held, assignments []catalogue.Assignment) ([]catalogue.Held, Passes) { - passes, said := catalogue.SeatsToPassOn(holdings, assignments) - out := Passes{Said: said} - for _, p := range passes { - out.Passes = append(out.Passes, p) - var next []catalogue.Held - for _, h := range holdings { - if hs, ok := catalogue.SeatNamed(h.Claim); ok && hs.Name == p.Seat.Name { - continue - } - next = append(next, h) - } - p.To.Site = p.From.Site - holdings = append(next, p.To) - } - return holdings, out -} - -// assignmentsOf are the assignments of every machine, as the passing of a seat reads them. A module assigned and -// missing from the shelf is still assigned: an assignment with no claims, so its holder is not taken for gone. -func assignmentsOf(shelf map[string]catalogue.Manifest, assigned map[string][]string) []catalogue.Assignment { - var out []catalogue.Assignment - names := make([]string, 0, len(assigned)) - for node := range assigned { - names = append(names, node) - } - sort.Strings(names) - for _, node := range names { - for _, module := range assigned[node] { - m, ok := shelf[module] - if !ok { - m = catalogue.Manifest{Module: module} - } - out = append(out, catalogue.Assignment{Node: node, Manifest: m}) - } - } - return out -} - -// servedOnNode is what a module on a node tells a consumer of one of its provisions, with THAT node's ports on it: the -// port the node was given over the one the mesh assigned over the manifest's own, settled with the node's settings -// layers. The one derivation for every reader of a provider's address. -func (c *composer) servedOnNode(node string, m catalogue.Manifest, provision string) (map[string]any, []catalogue.Layer, error) { - ports, layers, err := c.portsGivenOn(node, m) - if err != nil { - return nil, nil, err - } - serves := catalogue.ServedOn(m, provision, ports) - if len(serves) > 0 { - serves, err = catalogue.Settle(serves, catalogue.WithDefaults(m, layers)) - if err != nil { - return nil, nil, err - } - } - return serves, layers, nil -} - -// portsGivenOn is where a node puts a module's ports — assigned, then given over them — and the node's settings layers -// for the module. -func (c *composer) portsGivenOn(node string, m catalogue.Manifest) (map[int]int, []catalogue.Layer, error) { - n, err := c.in.node(node) - if err != nil { - return nil, nil, err - } - ports := map[int]int{} - for wanted, at := range n.Ports[m.Module] { - ports[wanted] = at - } - layers := n.Settings[m.Module] - if given, err := catalogue.GivenPorts(m, layers); err == nil { - for wanted, at := range given { - ports[wanted] = at - } - } - return ports, layers, nil -} diff --git a/go/compose/rest_test.go b/go/compose/rest_test.go deleted file mode 100644 index fa1e627..0000000 --- a/go/compose/rest_test.go +++ /dev/null @@ -1,231 +0,0 @@ -package compose - -import ( - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/overlay" -) - -// objects and files are the provider and consumer of mesh-controller's facts_test.go aMeshWithSecrets (cmd/mesh-controller at -// 6c2a8d62): a module providing s3-bucket at mesh scope with an identity bound, and one requiring it. -func objects() catalogue.Manifest { - return catalogue.Manifest{Module: "objects", Version: "1", - Provides: []catalogue.Offer{{Name: "s3-bucket", Scope: catalogue.ScopeMesh, - Identity: &catalogue.OfferIdentity{Max: 20, In: "an S3 access key"}}}, - Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}} -} - -func files() catalogue.Manifest { - return catalogue.Manifest{Module: "files", Version: "1", Requires: []string{"s3-bucket"}, - Resources: []map[string]any{{"id": "config", "type": "file", "path": "/etc/files/config.json", - "mode": "0600", "content": "{}", "merge": "json"}}} -} - -// threeNodes is aMesh and a third node `ace` running a database, so the mesh has three nodes that each offer something: -// anchor objects, ace a database, laptop a consumer of the first. -func threeNodes(t *testing.T) Input { - t.Helper() - in := aMesh(t) - db := catalogue.Manifest{Module: "pg", Version: "1", Provides: catalogue.FromAnywhere("postgres-database")} - in.Catalogue["objects"], in.Catalogue["files"], in.Catalogue["pg"] = objects(), files(), db - anchor, laptop := nodeOf(t, &in, "anchor"), nodeOf(t, &in, "laptop") - anchor.Assigned = append(anchor.Assigned, "objects") - laptop.Assigned = append(laptop.Assigned, "files") - in.Nodes = append(in.Nodes, Node{Name: "ace", Place: Place{Site: "there", Address: "10.77.0.3"}, - Assigned: []string{"pg"}, Capabilities: map[string]bool{}}) - return in -} - -func providers(w catalogue.World, provision string) []string { - var out []string - for _, p := range w.Offered[provision] { - out = append(out, p.Node+"/"+p.Module) - } - return out -} - -// 1361/AC1: three nodes each offering a provision; the rest of the mesh, for one of them, offers every other node's and none of its -// own, and holds what they hold. -func TestTheRestOfTheMeshOffersEveryOtherNodesProvisionsAndNoneOfTheExcluded(t *testing.T) { - in := threeNodes(t) - w, passes, left, err := TheRestOfTheMesh(in, "laptop") - if err != nil || len(left) != 0 || len(passes.Passes) != 0 { - t.Fatalf("%v %v %+v", err, left, passes) - } - if got := providers(w, "s3-bucket"); len(got) != 1 || got[0] != "anchor/objects" { - t.Errorf("s3-bucket is offered by %v", got) - } - if got := providers(w, "postgres-database"); len(got) != 1 || got[0] != "ace/pg" { - t.Errorf("postgres-database is offered by %v", got) - } - if p := w.Offered["s3-bucket"][0]; p.At != "anchor.internal" { - t.Errorf("anchor is at %q on the private network", p.At) - } - // Excluding anchor takes its offer away and keeps the others'. - w, _, _, err = TheRestOfTheMesh(in, "anchor") - if err != nil { - t.Fatal(err) - } - if got := providers(w, "s3-bucket"); len(got) != 0 { - t.Errorf("the excluded node's offer is in the world: %v", got) - } - if got := providers(w, "postgres-database"); len(got) != 1 { - t.Errorf("another node's offer is missing: %v", got) - } - // With nobody excluded, every offer is there, and everyone's claims are held. - w, _, _, err = TheRestOfTheMesh(in, "") - if err != nil || len(providers(w, "s3-bucket")) != 1 || len(providers(w, "postgres-database")) != 1 { - t.Fatalf("%v %v", providers(w, "s3-bucket"), err) - } - // Each machine is resolved with its own pins, as its plan is: the world is the same for any pin of the excluded node. - nodeOf(t, &in, "laptop").Pins = map[string]catalogue.Chosen{"s3-bucket": {Node: "anchor", Module: "objects"}} - if _, _, _, err := TheRestOfTheMesh(in, "laptop"); err != nil { - t.Fatal(err) - } -} - -// 1361/AC2: a node whose assigned set does not resolve is left out and named with today's reason, and the others still offer. -func TestANodeWhoseSetDoesNotResolveIsLeftOutAndTheOthersStillOffer(t *testing.T) { - in := threeNodes(t) - rivalOne := catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} - rivalTwo := catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}} - in.Catalogue["rival-one"], in.Catalogue["rival-two"] = rivalOne, rivalTwo - ace := nodeOf(t, &in, "ace") - ace.Assigned = append(ace.Assigned, "rival-one", "rival-two") - w, _, left, err := TheRestOfTheMesh(in, "laptop") - if err != nil { - t.Fatal(err) - } - if len(left) == 0 { - t.Fatal("a node whose set does not resolve was not left out") - } - for _, l := range left { - if l.Node != "ace" || !strings.Contains(l.String(), "ace is left out of the rest of the mesh: it does not resolve: ") { - t.Errorf("left out %q", l.String()) - } - } - if got := providers(w, "s3-bucket"); len(got) != 1 { - t.Errorf("the others do not offer: %v", got) - } - if got := providers(w, "postgres-database"); len(got) != 0 { - t.Errorf("the node left out offers: %v", got) - } - // Said for the whole-mesh view in both passes (as the controller says it), and for a view with one machine excluded in the first only. - _, _, whole, _ := TheRestOfTheMesh(in, "") - if len(whole) != 2 { - t.Errorf("the whole-mesh view says %d line(s), want the two passes'", len(whole)) - } - if len(left) != 1 { - t.Errorf("a view with one excluded says %d line(s), want the first pass's", len(left)) - } -} - -// 1361/AC3 and AC4 (mesh-controller seat_pass_test.go TestACompositionPassesTheSeatAndWritesOneHandActByTheController, in -// the part that is not a write): a seat held once whose holder on record is assigned nowhere and one claimant: the seat -// passes to that claimant in the World, and is returned as a pass; Input's holdings are unchanged. -func TestASeatWhoseHolderIsAssignedNowhereGoesToItsOnlyClaimant(t *testing.T) { - withSeat(t) - mk := func() Input { - return Input{ - Catalogue: map[string]catalogue.Manifest{"gate": aBrokerClaiming("gate")}, - Nodes: []Node{{Name: "novox", Place: Place{Site: "here"}, Assigned: []string{"gate"}}}, - Holdings: []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "novox", Module: "old-broker", Site: "here"}}, - } - } - in := mk() - w, passes, _, err := TheRestOfTheMesh(in, "") - if err != nil { - t.Fatal(err) - } - if len(w.Holdings) != 1 || w.Holdings[0].Module != "gate" || w.Holdings[0].Node != "novox" { - t.Fatalf("the holdings after the composition do not name the gate: %+v", w.Holdings) - } - if len(passes.Passes) != 1 { - t.Fatalf("passes=%d, want one", len(passes.Passes)) - } - p := passes.Passes[0] - if p.Seat.Name != "mesh-broker" || p.From.Module != "old-broker" || p.To.Module != "gate" || p.To.Node != "novox" { - t.Errorf("the pass is %+v", p) - } - unchanged(t, mk, func(in Input) { - if _, _, _, err := TheRestOfTheMesh(in, ""); err != nil { - t.Fatal(err) - } - }) -} - -// 1317/AC2 (seat_pass_test.go TestACompositionWithTwoClaimantsRecordsNoneAndSaysSo): two claimants, no pass, the refusal said with -// both and the remedy. 1317/AC3 and AC4 (TestACompositionChangesNothingWhileTheHolderIsAssignedOrNobodyClaims): nothing changes while -// the holder is assigned, nor with no claimant. -func TestNoSeatIsPassedForTwoClaimantsAnAssignedHolderOrNoClaimant(t *testing.T) { - withSeat(t) - held := []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "novox", Module: "old-broker"}} - _, passes := passSeatsOn(held, []catalogue.Assignment{ - {Node: "novox", Manifest: aBrokerClaiming("gate")}, {Node: "ace", Manifest: aBrokerClaiming("other")}}) - said := strings.Join(passes.Said, "\n") - if len(passes.Passes) != 0 || !strings.Contains(said, "gate") || !strings.Contains(said, "other") || - !strings.Contains(said, "seat mesh-broker --to") { - t.Fatalf("passes %+v said %q", passes.Passes, said) - } - got, passes := passSeatsOn(held, []catalogue.Assignment{ - {Node: "novox", Manifest: aBrokerClaiming("old-broker")}, {Node: "novox", Manifest: aBrokerClaiming("gate")}}) - if len(passes.Passes) != 0 || len(got) != 1 || got[0].Module != "old-broker" { - t.Fatalf("holder assigned: %+v %+v", got, passes) - } - got, passes = passSeatsOn(held, nil) - if len(passes.Passes) != 0 || len(got) != 1 || got[0].Module != "old-broker" { - t.Fatalf("no claimant: %+v %+v", got, passes) - } -} - -// seat_pass_test.go TestAHolderAssignedButNotOnTheShelfIsNotTakenForGone. -func TestAHolderAssignedButNotOnTheShelfIsNotTakenForGone(t *testing.T) { - withSeat(t) - as := assignmentsOf(map[string]catalogue.Manifest{"gate": aBrokerClaiming("gate")}, - map[string][]string{"novox": {"gate", "old-broker"}}) - passes, _ := catalogue.SeatsToPassOn( - []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "novox", Module: "old-broker"}}, as) - if len(passes) != 0 { - t.Fatalf("a holder missing from the shelf lost its seat: %+v", passes) - } -} - -// The not-resolving node of a view with a holder on record: holdings reach the first pass (novox/hq ADR 0131), so a node running -// the next holder beside the current one is not refused as a second holder. -func TestAHolderOnRecordSettlesTheSeatInTheRestOfTheMesh(t *testing.T) { - withSeat(t) - in := Input{ - Catalogue: map[string]catalogue.Manifest{"old-broker": aBrokerClaiming("old-broker"), "gate": aBrokerClaiming("gate")}, - Nodes: []Node{ - {Name: "novox", Assigned: []string{"old-broker", "gate"}}, - {Name: "ace", Assigned: []string{}}}, - Holdings: []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "novox", Module: "old-broker"}}, - } - _, passes, left, err := TheRestOfTheMesh(in, "ace") - if err != nil || len(left) != 0 || len(passes.Passes) != 0 { - t.Fatalf("%v %v %+v", err, left, passes) - } - in.Holdings = nil - _, _, left, err = TheRestOfTheMesh(in, "ace") - if err != nil || len(left) == 0 { - t.Fatalf("two claimants with nothing on record were not refused: %v %v", err, left) - } -} - -// Each machine is resolved in the second pass with its own pins, as its plan is (novox/hq issue 188): a pin to a provider that -// does not answer is its own refusal, so the machine is left out for it; the same machine without the pin is not. -func TestEachNodeIsResolvedWithItsOwnPinsInTheRestOfTheMesh(t *testing.T) { - in := threeNodes(t) - in.Nodes = append(in.Nodes, Node{Name: "newt", Place: Place{Site: "there", Address: "10.77.0.4"}, Assigned: []string{overlay.Name, "files"}, Capabilities: map[string]bool{}}) - _, _, left, err := TheRestOfTheMesh(in, "") - if err != nil || len(left) != 0 { - t.Fatalf("without a pin the consumer is left out: %v %v", left, err) - } - nodeOf(t, &in, "newt").Pins = map[string]catalogue.Chosen{"s3-bucket": {Node: "ace", Module: "nothing"}} - _, _, left, err = TheRestOfTheMesh(in, "") - if err != nil || len(left) != 1 || left[0].Node != "newt" || !strings.Contains(left[0].String(), "was told to get \"s3-bucket\" from") { - t.Fatalf("a pin to a provider that does not answer did not leave the consumer out: %v %v", left, err) - } -} diff --git a/go/compose/seats_test.go b/go/compose/seats_test.go deleted file mode 100644 index a07e0c2..0000000 --- a/go/compose/seats_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package compose - -import ( - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// aBrokerClaiming is mesh-controller's seat_pass_test.go helper of the same name. -func aBrokerClaiming(module string) catalogue.Manifest { - return catalogue.Manifest{Module: module, - Provides: []catalogue.Offer{{Name: "mesh-bus", Scope: catalogue.ScopeMesh}}, - Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh}}} -} - -// withSeat is mesh-controller's withBusSeat: a single mesh seat, mesh-broker, for the test. -func withSeat(t *testing.T) { - t.Helper() - was := catalogue.Seats() - t.Cleanup(func() { catalogue.UseSeats(was) }) - catalogue.UseSeats([]catalogue.Seat{{Name: "mesh-broker", Scope: catalogue.ScopeMesh, Delivers: "mesh-bus", Decision: "test"}}) -} diff --git a/go/composer/composer.go b/go/composer/composer.go index 566f6ac..69b729e 100644 --- a/go/composer/composer.go +++ b/go/composer/composer.go @@ -1,6 +1,7 @@ -// Package composer is mesh-sdk's composition as one function over a plain request (novox/hq ADR 0482 -// clause 4, issue 1621): what the command mesh-compose runs, so a program that links any one mesh-sdk can have the -// composition of another commit judged by building that commit's command and reading its verdict. +// Package composer is the shape of a composition over a plain request (novox/hq ADR 0482 clause 4, issue 1621, as ADR +// 0593 §7 changes it): the request the command mesh-compose reads and the verdict it answers. The command and the +// composer that runs it are the node-engine's (mesh-host cmd/mesh-compose), built from its repository at a group's +// head, so a change to its composition code is judged by that code. // // **It reads its request and nothing else.** No bus, store, network, file or environment: the manifests come inside the // request (raw, as their repository holds them), the rest of the mesh is a compose.Input, and the answer is a value. @@ -13,21 +14,17 @@ import ( "errors" "fmt" "slices" - "sort" "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/compose" - "git.novox.be/novox/mesh-sdk/go/compose/frombus" "git.novox.be/novox/mesh-sdk/go/delivery" - "git.novox.be/novox/mesh-sdk/go/sdkversion" ) // Version is the version of a Request and of a Verdict; a reader refuses another. const Version = 1 -// Package is the import path of the command, which a compose job builds at a commit (in mesh-sdk's own module, or in -// a module that requires mesh-sdk, at the version it pins). -const Package = "git.novox.be/novox/mesh-sdk/go/cmd/mesh-compose" +// Package is the import path of the command, which a compose job builds at a commit of the node-engine's repository +// (novox/hq ADR 0593 §7). +const Package = "github.com/novox/mesh-host/cmd/mesh-compose" // The depths of a composition. const ( @@ -201,173 +198,3 @@ func Fill(r *Request, get func(digest string) ([]byte, error)) error { } return nil } - -// Run composes every case of r and answers the verdict, its SDK this binary's mesh-sdk. An error is a request it -// cannot run (an unknown depth, a case named twice, a manifest left as a digest); a machine that does not compose is a -// verdict. -func Run(r Request) (Verdict, error) { - depth := r.Depth - if depth == "" { - depth = DepthCompose - } - if depth != DepthResolve && depth != DepthCompose { - return Verdict{}, fmt.Errorf("composer: the depth %q is not resolve or compose", r.Depth) - } - v := Verdict{V: Version, SDK: sdkversion.Version()} - seen := map[string]bool{} - for _, c := range r.Cases { - if seen[c.Name] { - return Verdict{}, fmt.Errorf("composer: the case %q is named twice", c.Name) - } - seen[c.Name] = true - cv, err := runCase(r, c, depth) - if err != nil { - return Verdict{}, err - } - v.Cases = append(v.Cases, cv) - } - return v, nil -} - -// parse decodes every manifest leniently and takes its verdict (novox/hq ADR 0578 §3, §5): the composer judges none. A -// manifest refused by its verdict is held, composed as its stub without its items (as the bus reader holds one); bytes -// that do not decode are left out. Both are named in reads. -func parse(m map[string]Manifest, reads map[string]ManifestRead, verdicts map[string]delivery.ManifestVerdict) (map[string]catalogue.Manifest, error) { - out := map[string]catalogue.Manifest{} - for name, man := range m { - if len(man.Raw) == 0 { - return nil, fmt.Errorf("%w: %s (digest %s)", ErrUnfilled, name, man.Digest) - } - decoded, err := catalogue.Decode(man.Raw) - read := ManifestRead{Accepted: err == nil, SetAside: decoded.SetAside()} - if err != nil { - read.Refused = fmt.Sprintf("the manifest of %s does not decode: %v", name, err) - } else if v, ok := verdicts[name]; ok { - if refused, _ := delivery.TakeManifestVerdict(&v); refused { - if v.Module == "" { - v.Module = name - } - read = ManifestRead{Refused: v.Why()} - decoded.Items = nil - decoded = compose.Stub(decoded) - } - } - if reads != nil { - reads[name] = read - } - if err == nil { - out[name] = decoded - } - } - return out, nil -} - -func runCase(r Request, c Case, depth string) (CaseVerdict, error) { - cv := CaseVerdict{Name: c.Name, Manifests: map[string]ManifestRead{}} - shelf, err := parse(c.Manifests, cv.Manifests, c.Verdicts) - if err != nil { - return CaseVerdict{}, err - } - var input compose.Input - if len(r.Input) > 0 { - if err := json.Unmarshal(r.Input, &input); err != nil { - return CaseVerdict{}, fmt.Errorf("composer: the request's input is not a composition input: %w", err) - } - } - in, err := frombus.Placed(input, c.Placements) - if err != nil { - return CaseVerdict{}, fmt.Errorf("composer: the case %q: %w", c.Name, err) - } - in.Catalogue = shelf - in.Nodes = slices.Clone(in.Nodes) - in.Holdings = slices.Clone(in.Holdings) - for i := range in.Nodes { - in.Nodes[i].Kept = nil - if kept := c.Kept[in.Nodes[i].Name]; len(kept) > 0 { - k, err := parse(kept, nil, nil) - if err != nil { - return CaseVerdict{}, err - } - in.Nodes[i].Kept = k - } - } - if r.HoldingsFromClaims { - in.Holdings = append(in.Holdings, HoldingsOf(shelf, in.Nodes)...) - } - if len(r.Machines) == 0 { - return cv, nil - } - cv.Machines = map[string]Machine{} - for _, node := range r.Machines { - cv.Machines[node] = machine(in, node, depth, r.Declarations) - } - return cv, nil -} - -// HoldingsOf is every seat an assigned module claims, held on the node it is assigned to, in node order: the holdings -// of an input that has none of record (the assigner's, mesh-delivery-assigner internal/assign input). -func HoldingsOf(shelf map[string]catalogue.Manifest, nodes []compose.Node) []catalogue.Held { - var out []catalogue.Held - sorted := slices.Clone(nodes) - sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name }) - for _, n := range sorted { - for _, mod := range n.Assigned { - for _, cl := range shelf[mod].Claims { - scope := cl.Scope - if seat, ok := catalogue.SeatNamed(cl.Name); ok && scope == "" { - scope = seat.Scope - } - out = append(out, catalogue.Held{Claim: cl.Name, Scope: scope, Node: n.Name, Module: mod}) - } - } - } - return out -} - -func machine(in compose.Input, node, depth string, declarations bool) Machine { - if depth == DepthResolve { - if _, err := compose.Resolve(in, node); err != nil { - return Machine{Reason: err.Error()} - } - return Machine{Composes: true} - } - d, problems, err := compose.Compose(in, node) - if err != nil { - return Machine{Reason: err.Error()} - } - m := Machine{Composes: len(problems) == 0, Problems: problems, Refusals: d.Refusals()} - if len(problems) > 0 { - m.Reason = "the node-engine's validator refuses the declaration" - } - if declarations { - m.Declared = pathsOf(d) - } - return m -} - -// pathsOf is every resource of a declaration that names a path, with its owner, group and mode as declared, sorted by -// path. -func pathsOf(d compose.Declared) []Path { - var out []Path - str := func(r map[string]any, key string) string { - if v, ok := r[key]; ok && v != nil { - return fmt.Sprint(v) - } - return "" - } - for _, r := range d.Resources { - p := str(r, "path") - if p == "" { - continue - } - out = append(out, Path{Path: p, Kind: str(r, "type"), Owner: str(r, "owner"), Group: str(r, "group"), - Mode: str(r, "mode"), Module: d.ModuleOf[str(r, "id")]}) - } - sort.Slice(out, func(i, j int) bool { - if out[i].Path != out[j].Path { - return out[i].Path < out[j].Path - } - return out[i].Kind < out[j].Kind - }) - return out -} diff --git a/go/composer/composer_test.go b/go/composer/composer_test.go index 82a3de0..108e3c7 100644 --- a/go/composer/composer_test.go +++ b/go/composer/composer_test.go @@ -3,14 +3,8 @@ package composer import ( "encoding/json" "errors" - "os" - "reflect" "strings" "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/compose" - "git.novox.be/novox/mesh-sdk/go/delivery" ) const demo = `{"module": "demo", "version": "1", "claims": [{"name": "node-files"}]}` @@ -32,9 +26,6 @@ func TestAManifestIsItsBytesOrItsDigest(t *testing.T) { if err != nil { t.Fatal(err) } - if _, err := Run(back); !errors.Is(err, ErrUnfilled) { - t.Fatalf("a digest left unfilled runs: %v", err) - } asked := 0 if err := Fill(&back, func(d string) ([]byte, error) { asked++ @@ -53,112 +44,3 @@ func TestAManifestIsItsBytesOrItsDigest(t *testing.T) { t.Fatalf("a digest the indexer does not hold: %v", err) } } - -// The input travels as JSON and comes back equal, so the command composes what its caller meant. -func TestTheInputTravelsAsJSON(t *testing.T) { - in := compose.Input{ - Nodes: []compose.Node{{Name: "anchor", Assigned: []string{"nats"}, Capabilities: map[string]bool{"systemd": true}, - Place: compose.Place{Endpoint: "anchor.example:51820", Site: "here", Hub: true, Address: "10.77.0.1"}, - Pins: map[string]catalogue.Chosen{"bus": {Node: "anchor", Module: "nats"}}, - Settings: map[string][]catalogue.Layer{"nats": {{Values: map[string]any{"port": float64(4222)}}}}}}, - Holdings: []catalogue.Held{{Claim: "mesh-broker", Scope: "mesh", Node: "anchor", Module: "nats"}}, - Issued: []compose.Issued{{Provision: "bus", Consumer: "laptop", ConsumerModule: "x", Provider: "anchor"}}, - } - raw, err := json.Marshal(Request{V: Version, Input: rawInput(in)}) - if err != nil { - t.Fatal(err) - } - back, err := Decode(raw) - if err != nil { - t.Fatal(err) - } - var got compose.Input - if err := json.Unmarshal(back.Input, &got); err != nil || !reflect.DeepEqual(got, in) { - t.Fatalf("the input came back as\n%+v (%v)\nwant\n%+v", got, err, in) - } -} - -// The seats a node's assigned modules claim are held there when the request says the holdings come from the claims. -func TestHoldingsComeFromTheClaims(t *testing.T) { - m, err := catalogue.Decode([]byte(demo)) - if err != nil { - t.Fatal(err) - } - got := HoldingsOf(map[string]catalogue.Manifest{"demo": m}, []compose.Node{{Name: "b", Assigned: []string{"demo"}}, {Name: "a", Assigned: []string{"demo"}}}) - if len(got) != 2 || got[0].Node != "a" || got[0].Claim != "node-files" || got[0].Scope != "node" { - t.Fatalf("holdings = %+v", got) - } -} - -// A request the command cannot run is refused, never answered as a verdict: another version, a case named twice, an -// unknown depth. -func TestARequestItCannotRunIsRefused(t *testing.T) { - if _, err := Decode([]byte(`{"v": 2}`)); err == nil { - t.Error("a request of version 2 is read") - } - if _, err := Run(Request{V: Version, Cases: []Case{{Name: "a"}, {Name: "a"}}}); err == nil { - t.Error("a case named twice runs") - } - if _, err := Run(Request{V: Version, Depth: "deep"}); err == nil { - t.Error("an unknown depth runs") - } - // A case's own placement (issue 1126) composes over a node the input holds, and is refused for one it does not. - placed := Request{V: Version, Input: rawInput(compose.Input{Nodes: []compose.Node{{Name: "a"}}}), Machines: []string{"a"}, Depth: DepthResolve, - Cases: []Case{{Name: "trunk", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}}, - {Name: "change", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}, - Placements: map[string]*delivery.Placement{"a": {Node: "a", Modules: []delivery.PlacedModule{{Module: "ghost"}}}}}}} - if v, err := Run(placed); err != nil || !v.Cases[0].Machines["a"].Composes || v.Cases[1].Machines["a"].Composes || - !strings.Contains(v.Cases[1].Machines["a"].Reason, "ghost") { - t.Errorf("a case composes its own placement: %+v, %v", v, err) - } - placed.Cases[1].Placements = map[string]*delivery.Placement{"z": {Node: "z"}} - if _, err := Run(placed); err == nil || !strings.Contains(err.Error(), "holds no node for") { - t.Errorf("a case placing a node the input lacks: %v", err) - } - v, err := Run(Request{V: Version, Cases: []Case{{Name: "only-read", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}}}}) - if err != nil || len(v.Cases) != 1 || !v.Cases[0].Manifests["demo"].Accepted || v.Cases[0].Machines != nil || v.SDK == "" { - t.Fatalf("a request with no machines: %+v, %v", v, err) - } -} - -// The composer judges no manifest (novox/hq ADR 0578 §5): xterm's module.json from mesh-catalog at 835bf22b, whose -// config value falls back to "font-size", is read whatever list of predefined keys a request carries (a group touching -// xterm failed its composition on this, group check 18df81380db3); a module whose verdict in the input refuses it is -// held, composed as its stub, and named in the verdict's words. -func TestTheComposerTakesTheVerdictsOfItsInput(t *testing.T) { - raw, err := os.ReadFile("../catalogue/testdata/catalogue-835bf22b-xterm.module.json") - if err != nil { - t.Fatal(err) - } - r := Request{V: Version, Predefined: catalogue.PredefinedList{"something-else": {Type: "string", Description: "x"}}, - Cases: []Case{{Name: "change", Manifests: map[string]Manifest{"xterm": {Raw: raw}}}}} - v, err := Run(r) - if err != nil { - t.Fatal(err) - } - if got := v.Cases[0].Manifests["xterm"]; !got.Accepted { - t.Fatalf("xterm: %+v, want accepted", got) - } - r.Cases[0].Verdicts = map[string]delivery.ManifestVerdict{"xterm": {Module: "xterm", State: delivery.VerdictRefused, - Problems: []string{"falls back to a key not listed"}, Judge: "src2:j"}} - shelf, err := parse(r.Cases[0].Manifests, map[string]ManifestRead{}, r.Cases[0].Verdicts) - if err != nil || !shelf["xterm"].Stubbed { - t.Fatalf("the refused module is not held as its stub: %+v %v", shelf["xterm"], err) - } - if v, err = Run(r); err != nil { - t.Fatal(err) - } - if got := v.Cases[0].Manifests["xterm"]; got.Accepted || - !strings.Contains(got.Refused, "the indexer's judge (src2:j) refused the manifest of xterm: falls back to a key not listed") { - t.Fatalf("xterm refused by its verdict: %+v, want refused in the verdict's words", got) - } -} - -// rawInput is a composition input as a request carries it. -func rawInput(in compose.Input) json.RawMessage { - raw, err := json.Marshal(in) - if err != nil { - panic(err) - } - return raw -} diff --git a/go/delivery/answer_paths.go b/go/delivery/answer_paths.go index da4cb42..f797166 100644 --- a/go/delivery/answer_paths.go +++ b/go/delivery/answer_paths.go @@ -134,44 +134,3 @@ func sameAny(a, b any) bool { y, errB := json.Marshal(b) return errA == nil && errB == nil && bytes.Equal(x, y) } - -// FitAnswer is a with its shown bodies cut until its JSON is within limit bytes (MaxAnswerBytes for the node's key): -// first the bodies' texts, from the last differing module back, keeping their paths; then the paths too. What is cut -// is said in the answer's note. The verdict and every list of modules are never cut. -func FitAnswer(a CandidateAnswer, limit int) CandidateAnswer { - size := func() int { - raw, err := json.Marshal(a) - if err != nil { - return 0 - } - return len(raw) - } - if size() <= limit { - return a - } - a.Differs = append([]ModuleDiffers(nil), a.Differs...) - for i := range a.Differs { - a.Differs[i].Bodies = append([]ResourceBodies(nil), a.Differs[i].Bodies...) - } - cutTexts, cutPaths := false, false - for i := len(a.Differs) - 1; i >= 0 && size() > limit; i-- { - for j := range a.Differs[i].Bodies { - a.Differs[i].Bodies[j].Composed, a.Differs[i].Bodies[j].Running = "", "" - } - cutTexts = true - } - for i := len(a.Differs) - 1; i >= 0 && size() > limit; i-- { - for j := range a.Differs[i].Bodies { - a.Differs[i].Bodies[j].Paths = nil - } - cutPaths = true - } - why := "the bodies shown were cut to keep the answer within " + strconv.Itoa(limit>>10) + " KiB" - if cutPaths { - why += ", their paths too" - } - if cutTexts { - a.Note = strings.TrimPrefix(a.Note+"; ", "; ") + why - } - return a -} diff --git a/go/delivery/judge.go b/go/delivery/judge.go deleted file mode 100644 index a060fc7..0000000 --- a/go/delivery/judge.go +++ /dev/null @@ -1,365 +0,0 @@ -package delivery - -import ( - "bytes" - "encoding/json" - "fmt" - "path" - "sort" - "strings" - - "git.novox.be/novox/mesh-sdk/go/declaration" -) - -// The judgement of a move (ADR 0377 §3), as a pure function over two compositions of one module, so the node agent that -// composes its own modules judges what it would apply (novox/hq ADR 0547 §3; issue 1126's S8). Moved from -// mesh-controller cmd/mesh-controller/composed_judge.go at origin/main (the controller keeps its copy, unchanged, until -// it loses the lease, ADR 0547 §1): judged, onlyTheUserList and sameResources, with their helpers, are its code. -// -// ADR 0377 §3 over two compositions of one module: **recreates** when a container resource is changed, added or -// removed; **moves data** when, with no container changed, a directory or file resource is changed, added or removed -// that a container of the module mounts or that holds or lies within one of its directories (where a module keeps its -// data), when its `data` declaration changes, or, for a module that declares data, when one of its long-running -// processes changes; **neither** otherwise, and for a module the node has never run. - -// Judged is one move's judgement: what it does, its words, and whether only the bus's user list changes (ADR 0539 §7: -// a file change and a reload of the bus, no move of it, never asked). -type Judged struct { - Judgement Judgement - Words string - UserListOnly bool -} - -// JudgeMove judges moving a node's module from the composition it runs to the one composed for it. Running is nil when the -// node runs no composition of the module. A composition equal to the one running (resource for resource, each -// canonical) is unchanged: no move, and no ask (ADR 0547 §3). -func JudgeMove(running *Composed, composed Composed) Judged { - if composed.Module == "" { - return Judged{Judgement: JudgedNeither, Words: "the node's own composition"} - } - if running == nil { - return Judged{Judgement: JudgedNeither, Words: "the node runs no composition of " + composed.Module} - } - if running.Data == composed.Data && SameComposedResources(*running, composed) { - return Judged{Judgement: JudgedUnchanged, Words: "equal to the composition the node runs"} - } - if onlyTheUserList(*running, composed) { - return Judged{Judgement: JudgedNeither, Words: "only the bus's user list changes", UserListOnly: true} - } - j, words := judged(*running, composed) - return Judged{Judgement: j, Words: words} -} - -// busUsersSuffix ends the id of the bus module's user list as the composer writes it: `.bus-users`. -const busUsersSuffix = ".bus-users" - -// onlyTheUserList says two compositions of a module differ, and only in its file of the bus's user list. -func onlyTheUserList(from, to Composed) bool { - if from.Data != to.Data { - return false - } - before, after := judgedResourcesByID(from), judgedResourcesByID(to) - if len(before) != len(after) { - return false - } - changed := false - for id, now := range after { - was, ok := before[id] - if !ok { - return false - } - if bytes.Equal(was.raw, now.raw) { - continue - } - if !strings.HasSuffix(id, busUsersSuffix) || now.kind != string(declaration.TypeFile) { - return false - } - changed = true - } - return changed -} - -// judged applies ADR 0377 §3 to two compositions of one module. -func judged(from, to Composed) (Judgement, string) { - before, after := judgedResourcesByID(from), judgedResourcesByID(to) - if ids := changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeContainer) }); len(ids) > 0 { - return JudgedRecreates, "it recreates " + strings.Join(ids, ", ") - } - places := dataPlaces(before, after) - held := func(kind string) bool { - return kind == string(declaration.TypeDirectory) || kind == string(declaration.TypeFile) - } - var moved []string - for _, id := range changedOf(before, after, held) { - r := before[id] - if r == nil { - r = after[id] - } - if withinPlaces(r.path, places) { - moved = append(moved, id) - } - } - if to.Data || from.Data { - moved = append(moved, changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeProcess) })...) - } - // A data flag that differs is no move of data by itself: data moves only when a data item does (issue 1126, 2026-10-29: - // a running composition read back from the kept declaration carries no flag, and mongodb's toolchain move asked a yes for - // moving its data). It is said loudly in the words, and never hides an item that moved. - if len(moved) > 0 { - sort.Strings(moved) - // A file the mesh makes for the module's provisions (what its consumers contribute, what it is bound to, the - // secret it is given) is said as the consumers that join or leave, not as its data moving (issue 774, - // 2026-10-31: postgres's yes said "where its data is kept" when nextcloud left its grants). Still a move: the - // yes it asks is what keeps a consumer's login while that consumer is held. - var kept, made []string - for _, id := range moved { - if who := consumersOf(id, before[id], after[id]); who != nil { - made = append(made, who...) - } else { - kept = append(kept, id) - } - } - var parts []string - if len(kept) > 0 { - parts = append(parts, "it changes where its data is kept: "+strings.Join(kept, ", ")) - } - if len(made) > 0 { - parts = append(parts, "consumers join or leave: "+strings.Join(made, ", ")) - } - words := strings.Join(parts, "; ") - if from.Data != to.Data { - words += fmt.Sprintf("; its data flag differs too (running %v, composed %v)", from.Data, to.Data) - } - return JudgedMovesData, words - } - if from.Data != to.Data { - return JudgedNeither, fmt.Sprintf("it recreates no container and moves no data; WARNING: its data flag differs "+ - "(running %v, composed %v) while no data item does, so one side's flag is wrong", from.Data, to.Data) - } - return JudgedNeither, "it recreates no container and moves no data" -} - -// ResourceChange is one resource that differs between two compositions of a module: its id, its kind, whether it is -// added, removed or changed, and for a file the mesh makes for a provision (received-, bound-, secret-) the consumers -// that join or leave, in words. -type ResourceChange struct { - ID string `json:"id"` - Kind string `json:"kind"` - Change string `json:"change"` - Consumers []string `json:"consumers,omitempty"` -} - -// ChangedResources is every resource added, removed or changed from running to composed, by id, each compared -// canonical (issue 774: the engine's verb composition shows what a move waiting for a yes would change). Running nil -// is every resource added. -func ChangedResources(running *Composed, composed Composed) []ResourceChange { - var from Composed - if running != nil { - from = *running - } - before, after := judgedResourcesByID(from), judgedResourcesByID(composed) - var out []ResourceChange - for _, id := range changedOf(before, after, func(string) bool { return true }) { - was, now := before[id], after[id] - c := ResourceChange{ID: id, Change: "changed"} - switch { - case was == nil: - c.Change, c.Kind = "added", now.kind - case now == nil: - c.Change, c.Kind = "removed", was.kind - default: - c.Kind = now.kind - } - c.Consumers = consumersOf(id, was, now) - out = append(out, c) - } - return out -} - -// consumersOf is, for a changed file the mesh makes for a provision (received-, bound- or secret-, catalogue.ReceivedID, -// BoundID, SecretID), what changes of it in words: each consumer that joins or leaves a received file, by the given -// list's from and node; else that what it is given for the provision changes. Nil for any other resource. -func consumersOf(id string, before, after *judgedResource) []string { - _, local, _ := strings.Cut(id, ".") - r := after - if r == nil { - r = before - } - if r == nil || r.kind != string(declaration.TypeFile) { - return nil - } - if provision, ok := strings.CutPrefix(local, "received-"); ok { - was, now := givenIn(before), givenIn(after) - var out []string - for _, c := range sortedGiven(was) { - if !now[c] { - out = append(out, c+" leaves "+provision) - } - } - for _, c := range sortedGiven(now) { - if !was[c] { - out = append(out, c+" joins "+provision) - } - } - if len(out) == 0 { - out = append(out, "what "+provision+"'s consumers are given changes") - } - return out - } - for _, prefix := range []string{"bound-", "secret-"} { - if provision, ok := strings.CutPrefix(local, prefix); ok { - return []string{"what it is given for " + provision + " changes"} - } - } - return nil -} - -// givenIn is the consumers a received file's content names, each as " on ". -func givenIn(r *judgedResource) map[string]bool { - out := map[string]bool{} - if r == nil { - return out - } - var file struct { - Content string `json:"content"` - } - var received struct { - Given []struct { - From string `json:"from"` - Node string `json:"node"` - } `json:"given"` - } - if json.Unmarshal(r.raw, &file) != nil || json.Unmarshal([]byte(file.Content), &received) != nil { - return out - } - for _, g := range received.Given { - out[g.From+" on "+g.Node] = true - } - return out -} - -func sortedGiven(m map[string]bool) []string { - out := make([]string, 0, len(m)) - for k := range m { - out = append(out, k) - } - sort.Strings(out) - return out -} - -// judgedResource is a resource as the judgement reads it: its kind, the path it acts on, what its containers mount, -// and its canonical bytes. -type judgedResource struct { - kind string - path string - mounts []string - raw []byte -} - -func judgedResourcesByID(c Composed) map[string]*judgedResource { - out := map[string]*judgedResource{} - for _, raw := range c.Resources { - var head struct { - ID string `json:"id"` - Type string `json:"type"` - Path string `json:"path"` - // A container's volumes, `:[:]`. - Volumes []string `json:"volumes"` - } - if err := json.Unmarshal(raw, &head); err != nil { - continue - } - r := &judgedResource{kind: head.Type, path: head.Path, raw: compactResource(raw)} - for _, v := range head.Volumes { - source, _, _ := strings.Cut(v, ":") - r.mounts = append(r.mounts, source) - } - out[head.ID] = r - } - return out -} - -// compactResource is a resource's bytes canonical: its keys sorted, its spacing dropped, its numbers as written, so two -// compositions that say the same compare equal however each was written. -func compactResource(raw []byte) []byte { - var v any - d := json.NewDecoder(bytes.NewReader(raw)) - d.UseNumber() - if err := d.Decode(&v); err != nil { - return raw - } - out, err := json.Marshal(v) - if err != nil { - return raw - } - return out -} - -// changedOf are the ids of the resources of the kinds counted that are changed, added or removed, sorted. -func changedOf(before, after map[string]*judgedResource, counted func(kind string) bool) []string { - var out []string - for id, now := range after { - if !counted(now.kind) { - continue - } - if was, ok := before[id]; !ok || !bytes.Equal(was.raw, now.raw) { - out = append(out, id) - } - } - for id, was := range before { - if _, ok := after[id]; !ok && counted(was.kind) { - out = append(out, id) - } - } - sort.Strings(out) - return out -} - -// dataPlaces are the paths where the module keeps what a move must not lose: what its containers mount, and its -// directories, before and after. -func dataPlaces(before, after map[string]*judgedResource) []string { - var out []string - for _, rs := range []map[string]*judgedResource{before, after} { - for _, r := range rs { - if r.kind == string(declaration.TypeContainer) { - out = append(out, r.mounts...) - } - if r.kind == string(declaration.TypeDirectory) && r.path != "" { - out = append(out, r.path) - } - } - } - return out -} - -// withinPlaces says p is one of places, or lies within one, or holds one. -func withinPlaces(p string, places []string) bool { - if p == "" { - return false - } - p = path.Clean(p) - for _, place := range places { - if place == "" || !strings.HasPrefix(place, "/") { - continue - } - place = path.Clean(place) - if p == place || strings.HasPrefix(p, place+"/") || strings.HasPrefix(place, p+"/") { - return true - } - } - return false -} - -// SameComposedResources says two compositions of a module hold the same resources in the same order, each compared -// canonical. -func SameComposedResources(a, b Composed) bool { - if len(a.Resources) != len(b.Resources) { - return false - } - for i := range a.Resources { - if !bytes.Equal(compactResource(a.Resources[i]), compactResource(b.Resources[i])) { - return false - } - } - return true -} diff --git a/go/delivery/judge_test.go b/go/delivery/judge_test.go deleted file mode 100644 index ad17be4..0000000 --- a/go/delivery/judge_test.go +++ /dev/null @@ -1,94 +0,0 @@ -package delivery - -import ( - "encoding/json" - "fmt" - "strings" - "testing" -) - -func composedOf(module string, c byte, data bool, resources ...string) Composed { - out := Composed{Node: "ace", Module: module, FP: SourcePrefix2 + strings.Repeat(string(c), 64), Data: data} - for _, r := range resources { - out.Resources = append(out.Resources, json.RawMessage(r)) - } - return out -} - -// The pairs mesh-controller's composed_judge_test.go captured (issue 1945 AC12), and the bus's user list, judged as the -// controller judges them (ADR 0377 §3, ADR 0539 §7), by the node agent's function (ADR 0547 §3, issue 1126 S8). -func TestJudgeMove(t *testing.T) { - const ( - container = `{"id":"mesh-mail.server","type":"container","module":"mesh-mail","name":"mail","image":"mail@sha256:1111111111111111111111111111111111111111111111111111111111111111","volumes":["/var/lib/mesh-mail/spool:/spool"]}` - container2 = `{"id":"mesh-mail.server","type":"container","module":"mesh-mail","name":"mail","image":"mail@sha256:2222222222222222222222222222222222222222222222222222222222222222","volumes":["/var/lib/mesh-mail/spool:/spool"]}` - spool = `{"id":"mesh-mail.spool","type":"directory","module":"mesh-mail","path":"/var/lib/mesh-mail/spool"}` - spoolMoved = `{"id":"mesh-mail.spool","type":"directory","module":"mesh-mail","path":"/srv/mesh-mail/spool"}` - tools = `{"id":"mesh-mail.tools","type":"file","module":"mesh-mail","path":"/usr/lib/mesh-mail/tools.json","content":"a"}` - tools2 = `{"id":"mesh-mail.tools","type":"file","module":"mesh-mail","path":"/usr/lib/mesh-mail/tools.json","content":"b"}` - config = `{"id":"mesh-mail.conf","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/conf","content":"a"}` - config2 = `{"id":"mesh-mail.conf","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/conf","content":"b"}` - process = `{"id":"mesh-mail.daemon","type":"process","module":"mesh-mail","exec":"/usr/bin/a"}` - process2 = `{"id":"mesh-mail.daemon","type":"process","module":"mesh-mail","exec":"/usr/bin/b"}` - received = `{"id":"mesh-mail.received-smtp","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/received.json","content":"{\"given\":[{\"from\":\"cloud\",\"node\":\"novox\"},{\"from\":\"photos\",\"node\":\"novox\"}]}"}` - received2 = `{"id":"mesh-mail.received-smtp","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/received.json","content":"{\"given\":[{\"from\":\"photos\",\"node\":\"novox\"},{\"from\":\"wiki\",\"node\":\"ace\"}]}"}` - users = `{"id":"nats.bus-users","type":"file","module":"nats","path":"/etc/nats/users","content":"a"}` - users2 = `{"id":"nats.bus-users","type":"file","module":"nats","path":"/etc/nats/users","content":"b"}` - natsConf = `{"id":"nats.conf","type":"file","module":"nats","path":"/etc/nats/conf","content":"a"}` - natsConf2 = `{"id":"nats.conf","type":"file","module":"nats","path":"/etc/nats/conf","content":"b"}` - ) - mail := func(c byte, data bool, rs ...string) *Composed { - m := composedOf("mesh-mail", c, data, rs...) - return &m - } - bus := func(rs ...string) *Composed { m := composedOf("nats", 'b', false, rs...); return &m } - for _, c := range []struct { - name string - from, to *Composed - want Judgement - userList bool - words string // what the words must say, when given - }{ - {"a new image", mail('a', true, container, spool, tools), mail('b', true, container2, spool, tools), JudgedRecreates, false, ""}, - {"a container gone", mail('a', true, container, spool), mail('b', true, spool), JudgedRecreates, false, ""}, - {"its spool moved", mail('a', true, container, spool), mail('b', true, container, spoolMoved), JudgedMovesData, false, ""}, - {"a file in its spool", mail('a', true, container, spool, config), mail('b', true, container, spool, config2), JudgedMovesData, false, "it changes where its data is kept: mesh-mail.conf"}, - // A received file the mesh makes, in its data's place, names its consumers joining or leaving, not its data moving - // (issue 774, 2026-10-31: postgres asked a yes for "where its data is kept" when nextcloud left its grants). - {"a consumer leaves", mail('a', true, container, spool, received), mail('b', true, container, spool, received2), JudgedMovesData, false, - "consumers join or leave: cloud on novox leaves smtp, wiki on ace joins smtp"}, - {"a consumer leaves and a file moves", mail('a', true, container, spool, received, config), mail('b', true, container, spool, received2, config2), JudgedMovesData, false, - "it changes where its data is kept: mesh-mail.conf; consumers join or leave: cloud on novox leaves smtp, wiki on ace joins smtp"}, - {"its data flag alone", mail('a', false, container, spool), mail('b', true, container, spool), JudgedNeither, false, ""}, - {"its data flag and its spool", mail('a', false, container, spool), mail('b', true, container, spoolMoved), JudgedMovesData, false, ""}, - {"a process of a module with data", mail('a', true, spool, process), mail('b', true, spool, process2), JudgedMovesData, false, ""}, - {"a process of a module without", mail('a', false, process), mail('b', false, process2), JudgedNeither, false, ""}, - {"its tools only", mail('a', true, container, spool, tools), mail('b', true, container, spool, tools2), JudgedNeither, false, ""}, - {"never ran", nil, mail('b', true, container), JudgedNeither, false, ""}, - {"equal, written otherwise", mail('a', true, `{"type":"directory","id":"mesh-mail.spool","module":"mesh-mail","path":"/var/lib/mesh-mail/spool"}`), mail('b', true, spool), JudgedUnchanged, false, ""}, - {"only the user list", bus(natsConf, users), bus(natsConf, users2), JudgedNeither, true, ""}, - {"the user list and more", bus(natsConf, users), bus(natsConf2, users2), JudgedNeither, false, ""}, - } { - got := JudgeMove(c.from, *c.to) - if got.Judgement != c.want || got.UserListOnly != c.userList || got.Words == "" { - t.Errorf("%s: %+v, want %s (user list only %v)", c.name, got, c.want, c.userList) - } - if c.words != "" && got.Words != c.words { - t.Errorf("%s: says %q, want %q", c.name, got.Words, c.words) - } - if strings.Contains(c.name, "data flag alone") && !strings.Contains(got.Words, "WARNING: its data flag differs") { - t.Errorf("%s: the flag's difference is not said: %q", c.name, got.Words) - } - } - // The resources that differ, as the engine's verb composition lists them (issue 774): a provider's received file - // names its consumers, a file changed and one added are said as such, and nothing equal is listed. - changes := ChangedResources(mail('a', true, container, spool, received, config), *mail('b', true, container, spool, received2, config2, tools)) - if want := `[{mesh-mail.conf file changed []} {mesh-mail.received-smtp file changed [cloud on novox leaves smtp wiki on ace joins smtp]} {mesh-mail.tools file added []}]`; fmt.Sprint(changes) != want { - t.Errorf("changed resources: %v, want %s", changes, want) - } - if got := ChangedResources(nil, *mail('b', true, spool)); len(got) != 1 || got[0].Change != "added" { - t.Errorf("never ran: %v, want its one resource added", got) - } - if got := JudgeMove(nil, Composed{Node: "ace"}); got.Judgement != JudgedNeither { - t.Errorf("the node's own composition: %+v", got) - } -} diff --git a/go/delivery/placement_test.go b/go/delivery/placement_test.go index 6e662c4..7dd7855 100644 --- a/go/delivery/placement_test.go +++ b/go/delivery/placement_test.go @@ -555,12 +555,6 @@ func TestPlacement(t *testing.T) { !strings.Contains(far.Differs[0].Bodies[0].Composed, "cut at") || strings.Contains(fmt.Sprint(far), "hunter") { t.Errorf("a difference past the cut: %+v", far.Differs) } - // And an answer too large for the node's key is fitted: the texts go first, the verdict and the paths stay. - big := CandidateAnswer{Proposal: "p-x1", Revision: 1, Verdict: CandidateDiffers, Differs: far.Differs} - if fitted := FitAnswer(big, 4<<10); len(fitted.Differs[0].Bodies[0].Composed) != 0 || len(fitted.Differs[0].Bodies[0].Paths) != 2 || - fitted.Verdict != CandidateDiffers || !strings.Contains(fitted.Note, "cut") || big.Differs[0].Bodies[0].Composed == "" { - t.Errorf("fitted as %+v", fitted) - } if c, r := MaskedPair(json.RawMessage(`{"env":{"DB_PASSWORD":"hunter2","PORT":"80"},"list":[{"token":"t1"}]}`), json.RawMessage(`{"env":{"DB_PASSWORD":"hunter3","PORT":"81"},"list":[{"token":"t1"}]}`), nil); strings.Contains(c+r, "hunter") || strings.Contains(c+r, `"t1"`) || !strings.Contains(r, `"PORT":"81"`) || !strings.Contains(c, "") || diff --git a/go/overlay/declaration.go b/go/overlay/declaration.go deleted file mode 100644 index 7c100af..0000000 --- a/go/overlay/declaration.go +++ /dev/null @@ -1,197 +0,0 @@ -package overlay - -import ( - "encoding/json" - "fmt" - "strings" -) - -// The overlay is delivered as an ordinary declaration. -// -// novox/hq 08-connectivity: what the host receives is an interface configuration and a peer list, -// as files. It does not compute them and it does not know what a private network is — the shapes -// it already has are enough, which is why connectivity needs nothing new from tier 0. -// -// **No private key travels.** The configuration points at a file the node wrote from a key the -// mesh has never seen, using WireGuard's own ability to set one after the interface is up. So the -// control plane composes a complete configuration for a node it cannot impersonate. - -// Interface is what the private network is called on a machine, and where the node's own key -// lives. A constant rather than a setting: two nodes disagreeing about the name would produce a -// mesh where each is configured correctly and nothing meets. -const ( - Interface = "mesh0" - ConfigPath = "/etc/wireguard/" + Interface + ".conf" - Unit = "wg-quick@" + Interface - DefaultKeyPath = "/var/lib/mesh-host/overlay.key" -) - -// Resource is one entry in a declaration, built here and read by the host. -type Resource map[string]any - -// Declaration is what the mesh sends a node to put it on the network. -// -// Three resources and nothing clever: the tools, the configuration, and the interface running. A -// person can read it, which is the point — this is the first thing a node is ever told, and if it -// is wrong the node is unreachable and the mistake has to be findable by eye. -func Declaration(node Node, peers []Peer, keyPath string) ([]byte, error) { - if node.Address == "" { - return nil, fmt.Errorf("%s has no address on the overlay, so there is nothing to configure", - node.Name) - } - if keyPath == "" { - keyPath = DefaultKeyPath - } - - up := Resource{ - "id": "overlay-up", "type": "service", "unit": Unit, - "state": "running", - // Enabled, so the node comes back onto the network after a reboot without waiting to - // be told again. A node whose overlay only exists while something is watching is not - // a node that survives being switched off and on. - "boot": "enabled", - // And restarted when the peer list changes, because a running interface does not - // re-read its configuration. - // - // This is the whole of it: a node joins, every existing node's peer list changes, - // each file is replaced — and without this the service is already running, nothing - // reloads it, and every node keeps a network that no longer matches the mesh. It - // reports complete success. The lab found it the moment a third node arrived. - // - // Declared state rather than a command. The service must reflect the file; the host - // works out that it does not. A command to restart would be an action, and the link - // may not carry one (novox/hq ADR 0005) — the host refused exactly that, correctly, - // which is how this shape was arrived at. - "restart-on": []string{"overlay-config"}, - } - if node.TakesOver != nil { - // The private network takes over the tunnel it found (novox/hq ADR 0105): before this - // unit starts, the host stops and disables the found one — never flushing it — and keeps - // its configuration like any held file. The key is already the found one: the node took - // it as its own overlay key when it enrolled, which is why the mesh's peer list for it - // carries the found peers under the key they know. - up["takes-over"] = map[string]any{ - "interface": node.TakesOver.Interface, - "unit": node.TakesOver.Unit, - "config": node.TakesOver.Config, - } - } - - resources := []Resource{ - { - "id": "overlay-tools", "type": "package", "package": "wireguard-tools", - }, - { - "id": "overlay-config", "type": "file", "path": ConfigPath, - // Readable only by root: it lists every peer's key and endpoint, which is a map of - // the mesh. Not secret in the way a private key is, and not something to leave - // world-readable on a machine somebody else also uses. - "mode": "0600", - "content": config(node, peers, keyPath), - }, - up, - } - - // The names used to be appended here, on the argument that a node with peers and no names is - // half on the network. True, and the wrong place to fix it: names would be identical over a - // different private network, so bundling them with WireGuard made one module out of two - // things. They are their own module now, requiring this one — which is what keeps them - // arriving together without pretending they are the same concern. - return json.Marshal(map[string]any{"declaration": 1, "resources": resources}) -} - -// config writes the interface file. -// -// Deliberately in the order a person would read it: who I am, then who I talk to, each with a -// line saying why it is there. A generated file that cannot be understood by the person it -// confuses is a generated file that gets edited by hand. -func config(node Node, peers []Peer, keyPath string) string { - var b strings.Builder - b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever the\n") - b.WriteString("# peer graph changes, and an edit would survive until the next change and\n") - b.WriteString("# then vanish, which is worse than not being applied at all.\n") - fmt.Fprintf(&b, "#\n# node %s", node.Name) - if node.Site != "" { - fmt.Fprintf(&b, ", at %s", node.Site) - } - if !node.Reachable() { - b.WriteString(", not dialable — it opens every path itself") - } - b.WriteString("\n\n[Interface]\n") - fmt.Fprintf(&b, "Address = %s/32\n", node.Address) - if node.Reachable() { - if port := portOf(node.Endpoint); port != "" { - fmt.Fprintf(&b, "ListenPort = %s\n", port) - } - } else if node.TakesOver != nil && node.TakesOver.Port != 0 { - // Not dialable from the hub, but a LAN peer dials this node on the tunnel it took over, - // so the mesh's interface must listen on that same port (novox/hq: a taken tunnel brings - // its port). Without this the takeover guard refuses overlay-up, and re-placing the node - // with an endpoint — the guard's suggested remedy — breaks a NAT'd node's path. - fmt.Fprintf(&b, "ListenPort = %d\n", node.TakesOver.Port) - } - // The MTU the found tunnel carried, when it set one: a path tuned to 1380 (say) stalls TLS - // and hangs transfers if the mesh's interface comes up at the 1420 default, and no ping shows - // it (novox/hq: a taken tunnel carries its MTU). - if node.TakesOver != nil && node.TakesOver.MTU != 0 { - fmt.Fprintf(&b, "MTU = %d\n", node.TakesOver.MTU) - } - // The private key is set from a file the node wrote, so it never appears here and never - // travelled. Everything else in this file came from the mesh; this one line is the node's. - fmt.Fprintf(&b, "PostUp = wg set %%i private-key %s\n", keyPath) - - if node.Hub { - // A hub carries traffic *between* its spokes, and a Linux machine does not forward - // packets unless it is told to. Without this every spoke reaches the hub perfectly and - // no spoke reaches any other — which is exactly how it failed in the lab, and it looks - // like a peering problem rather than a kernel setting. - // - // Here rather than in a separate resource because it is part of what being a hub means, - // and because it should last exactly as long as the interface does: a machine that stops - // being the hub should stop forwarding, and PostDown below is how that happens. - b.WriteString("PostUp = sysctl -q -w net.ipv4.ip_forward=1\n") - b.WriteString("PostDown = sysctl -q -w net.ipv4.ip_forward=0\n") - - // And past the machine's own firewall, which on any node with a container runtime is - // closed. Docker sets the FORWARD policy to DROP and inserts its chains, so the foundation - // this mesh installs at tier 1 silently breaks the network it builds at tier 2: every - // spoke reaches the hub, no spoke reaches any other, and every part of it reports - // success. Found in the lab; nothing about it is visible from the mesh's own state. - // - // Inserted at the top so it precedes those chains, and removed on the way down so a - // machine that stops being the hub stops carrying other people's traffic. Guarded on - // iptables existing: a machine without it has no policy to get past. - for _, direction := range []string{"-i", "-o"} { - fmt.Fprintf(&b, - "PostUp = command -v iptables >/dev/null && iptables -I FORWARD 1 %s %%i -j ACCEPT || true\n", - direction) - } - for _, direction := range []string{"-i", "-o"} { - fmt.Fprintf(&b, - "PostDown = command -v iptables >/dev/null && iptables -D FORWARD %s %%i -j ACCEPT || true\n", - direction) - } - } - - for _, p := range peers { - fmt.Fprintf(&b, "\n# %s — %s\n[Peer]\n", p.Name, p.Why) - fmt.Fprintf(&b, "PublicKey = %s\n", p.Key) - fmt.Fprintf(&b, "AllowedIPs = %s\n", p.Allowed) - if p.Endpoint != "" { - fmt.Fprintf(&b, "Endpoint = %s\n", p.Endpoint) - } else { - b.WriteString("# no endpoint: this peer cannot be dialled and opens the path itself\n") - } - if p.Keepalive { - b.WriteString("PersistentKeepalive = 25\n") - } - } - return b.String() -} - -func portOf(endpoint string) string { - if i := strings.LastIndex(endpoint, ":"); i >= 0 { - return endpoint[i+1:] - } - return "" -} diff --git a/go/overlay/declaration_test.go b/go/overlay/declaration_test.go deleted file mode 100644 index 64fabd9..0000000 --- a/go/overlay/declaration_test.go +++ /dev/null @@ -1,311 +0,0 @@ -package overlay - -import ( - "encoding/json" - "fmt" - "strings" - "testing" -) - -func declarationFor(t *testing.T, node Node, peers []Peer) (string, []map[string]any) { - t.Helper() - raw, err := Declaration(node, peers, "") - if err != nil { - t.Fatal(err) - } - var d struct { - Declaration int `json:"declaration"` - Resources []map[string]any `json:"resources"` - } - if err := json.Unmarshal(raw, &d); err != nil { - t.Fatal(err) - } - if d.Declaration != 1 { - t.Fatalf("declaration version %d", d.Declaration) - } - for _, r := range d.Resources { - if r["type"] == "file" { - return r["content"].(string), d.Resources - } - } - t.Fatal("the declaration has no configuration file in it") - return "", nil -} - -func TestNoPrivateKeyEverTravels(t *testing.T) { - // The property the whole design rests on: the node generated its keypair and kept the private - // half, so the mesh composes a configuration for a node it cannot impersonate. A private key - // appearing here would mean the control plane had one — and a copy of its database would then - // be every node's network identity. - config, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, - []Peer{{Name: "anchor", Key: "HUB", Allowed: "10.42.0.0/16", Endpoint: "198.51.100.1:51820"}}) - - if strings.Contains(config, "PrivateKey") { - t.Error("the configuration carries a PrivateKey line; the mesh must never hold one") - } - if !strings.Contains(config, "private-key "+DefaultKeyPath) { - t.Error("the configuration does not point at the key file the node wrote, so the " + - "interface would come up with no key at all") - } -} - -func TestTheDeclarationUsesOnlyShapesTheHostAlreadyHas(t *testing.T) { - // Connectivity needs nothing new from tier 0, and that is worth holding: the host does not - // know what a private network is, and should not learn. - _, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) - - allowed := map[string]bool{"package": true, "file": true, "service": true, - "directory": true, "container": true, "action": true} - for _, r := range resources { - if !allowed[r["type"].(string)] { - t.Errorf("the overlay declaration uses %q, which the host does not have", r["type"]) - } - } -} - -func TestTheInterfaceComesBackAfterAReboot(t *testing.T) { - // A node whose overlay only exists while something is watching is not a node that survives - // being switched off and on — and it would come back unreachable, which is the worst way to - // come back. - _, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) - for _, r := range resources { - if r["type"] == "service" { - if r["boot"] != "enabled" { - t.Error("the overlay interface is not enabled at boot") - } - if r["state"] != "running" { - t.Error("the overlay interface is not asked to be running") - } - return - } - } - t.Error("nothing in the declaration brings the interface up") -} - -func TestTheConfigurationIsNotWorldReadable(t *testing.T) { - // It lists every peer's key and endpoint, which is a map of the mesh. Not secret the way a - // private key is, and not something to leave readable on a machine somebody else also uses. - // The peer list specifically, not every file. `/etc/hosts` is in here too and must be - // world-readable, or nothing on the machine resolves anything — a check that swept all files - // would force it to 0600 and break the machine to protect a file that is not secret. - _, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) - for _, r := range resources { - if r["id"] == "overlay-config" && r["mode"] != "0600" { - t.Errorf("the peer list is mode %v", r["mode"]) - } - if r["id"] == "mesh-names" && r["mode"] != "0644" { - t.Errorf("the name file is mode %v; nothing on the machine could read it", r["mode"]) - } - } -} - -func TestAPeerThatCannotBeDialledSaysSo(t *testing.T) { - // A [Peer] with no Endpoint is correct and looks like a mistake. Saying why stops somebody - // helpfully adding one that cannot work. - config, _ := declarationFor(t, Node{Name: "anchor", Key: "HUB", Address: "10.42.0.1", - Endpoint: "198.51.100.1:51820", Hub: true}, - []Peer{{Name: "laptop", Key: "PUB", Allowed: "10.42.0.2/32", Why: "routes through this hub"}}) - - if strings.Contains(config, "Endpoint =") { - t.Error("an endpoint was written for a peer that has none") - } - if !strings.Contains(config, "cannot be dialled") { - t.Error("the file does not say why that peer has no endpoint") - } -} - -func TestTheFileSaysNotToEditIt(t *testing.T) { - // It is replaced whenever the graph changes. An edit survives until then and vanishes, which - // is worse than never being applied — the machine works, then stops, and nothing changed - // that anybody remembers. - config, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) - if !strings.Contains(config, "Do not edit") { - t.Error("a generated file does not say it is generated") - } -} - -func TestANodeWithNoAddressIsRefused(t *testing.T) { - // Rather than a configuration with a blank address, which wg-quick would reject on the - // machine, at boot, where the failure is much harder to see. - if _, err := Declaration(Node{Name: "laptop", Key: "PUB"}, nil, ""); err == nil { - t.Fatal("a node with no overlay address was given a configuration") - } -} - -func TestOnlyAReachableNodeListens(t *testing.T) { - // A ListenPort on a node nothing can dial is a port open for no reason. - config, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) - if strings.Contains(config, "ListenPort") { - t.Error("a node that cannot be dialled was told to listen") - } - config, _ = declarationFor(t, Node{Name: "anchor", Key: "HUB", Address: "10.42.0.1", - Endpoint: "198.51.100.1:51820"}, nil) - if !strings.Contains(config, "ListenPort = 51820") { - t.Error("a reachable node does not listen on the port its endpoint names") - } -} - -func TestTheServiceIsRestartedWhenThePeerListChanges(t *testing.T) { - // The fault this exists to catch, found in the lab the moment a third node arrived: a running - // WireGuard interface does not re-read its configuration. The file was replaced, the service - // was already running so nothing reloaded it, and every existing node kept a network that no - // longer matched the mesh — while looking entirely successful. - // - // So the declaration has to verify what the interface is *carrying*, not what the file says. - // - // And it must be declared state rather than a command: the host refuses an action arriving - // over the link (novox/hq ADR 0005), correctly, which is how this shape was arrived at. There - // is a test below that no action is ever in here. - _, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, - []Peer{{Name: "anchor", Key: "HUBKEY", Allowed: "10.42.0.0/16"}}) - - for _, r := range resources { - if r["type"] != "service" { - continue - } - reflects := fmt.Sprint(r["restart-on"]) - if !strings.Contains(reflects, "overlay-config") { - t.Errorf("the interface does not restart when its configuration changes: %v", reflects) - } - return - } - t.Error("nothing in the declaration brings the interface up") -} - -func TestTheOverlayDeclarationCarriesNoAction(t *testing.T) { - // The link may not carry an action, and the host refuses a declaration containing one — whole, - // not in part. An overlay declaration with an action in it does not half-apply: it leaves the - // node with no network at all. - _, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) - for _, r := range resources { - if r["type"] == "action" { - t.Errorf("the declaration contains an action (%v); the host will refuse the whole "+ - "thing and the node will have no network", r["id"]) - } - } -} - -func TestTheHubForwardsAndNobodyElseDoes(t *testing.T) { - // A hub carries traffic between its spokes, and Linux does not forward packets unless told - // to. Without it every spoke reaches the hub perfectly and no spoke reaches any other — which - // is how it failed in the lab, and it presents as a peering problem rather than a kernel - // setting, so it is worth being sure of. - hub, _ := declarationFor(t, Node{Name: "anchor", Key: "HUB", Address: "10.42.0.1", - Endpoint: "198.51.100.1:51820", Hub: true}, nil) - if !strings.Contains(hub, "ip_forward=1") { - t.Error("the hub does not enable forwarding, so its spokes cannot reach each other") - } - if !strings.Contains(hub, "ip_forward=0") { - t.Error("the hub never stops forwarding; a machine that stops being the hub would keep " + - "passing traffic it is no longer part of") - } - - // And past the machine's own firewall. Any node with a container runtime has FORWARD set to - // DROP by Docker, so enabling ip_forward alone changes nothing — which is exactly how it - // failed, with every spoke reaching the hub and no spoke reaching any other. - // Checked as PostUp specifically. "contains FORWARD" passes on the PostDown line alone, - // which would leave a hub that tears down rules it never put up. - if !strings.Contains(hub, "PostUp = command -v iptables") { - t.Error("the hub does not open its own firewall, so a container runtime's DROP policy " + - "silently eats everything it was supposed to carry") - } - if !strings.Contains(hub, "PostDown = command -v iptables") { - t.Error("the hub never removes those rules, so a machine that stops being the hub keeps " + - "passing traffic it is no longer part of") - } - - spoke, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil) - if strings.Contains(spoke, "FORWARD") { - t.Error("a spoke was given forwarding rules it has no use for") - } - if strings.Contains(spoke, "ip_forward") { - t.Error("a spoke was told to forward packets, which is not its job and widens what a " + - "compromised one could do") - } -} - -// novox/hq ADR 0105: a node whose private network takes over the tunnel it found is told so on -// the interface's service, and nothing else about the declaration changes — the key is already -// the found one, taken at enrolment. -func TestTakingOverAFoundTunnelIsSaidOnTheInterfacesService(t *testing.T) { - node := Node{Name: "anchor", Key: "PUB", Address: "192.0.2.1", Hub: true, - Endpoint: "198.51.100.1:51900", - TakesOver: &TakeOver{Interface: "wg0", Unit: "wg-quick@wg0", Config: "/etc/wireguard/wg0.conf"}} - config, resources := declarationFor(t, node, nil) - - var up map[string]any - for _, r := range resources { - if r["type"] == "service" { - up = r - } - } - takes, ok := up["takes-over"].(map[string]any) - if !ok { - t.Fatalf("the interface's service does not say what it takes over: %+v", up) - } - if takes["unit"] != "wg-quick@wg0" || takes["config"] != "/etc/wireguard/wg0.conf" || takes["interface"] != "wg0" { - t.Errorf("the takeover names the wrong tunnel: %+v", takes) - } - if !strings.Contains(config, "ListenPort = 51900") { - t.Errorf("the hub's interface does not listen on the tunnel's port:\n%s", config) - } - if strings.Contains(config, "PrivateKey") { - t.Error("the found key travelled in the configuration; it is the node's own, set from its key file") - } - - _, plain := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "192.0.2.4"}, nil) - for _, r := range plain { - if _, says := r["takes-over"]; says { - t.Error("a node taking over nothing was told to take something over") - } - } -} - -func TestATakenTunnelBringsItsListenPortEvenWhenNotDialable(t *testing.T) { - // A home node behind NAT (no Endpoint, so not Reachable) that took over a tunnel must still - // listen on that tunnel's port, because its LAN peers dial it there (novox/hq: a taken tunnel - // brings its port). Without this the takeover guard refuses overlay-up. - config, _ := declarationFor(t, Node{ - Name: "shanks", Key: "SPOKE", Address: "10.10.0.3", - TakesOver: &TakeOver{Interface: "wg0", Unit: "wg-quick@wg0", Port: 51820}, - }, nil) - - if !strings.Contains(config, "ListenPort = 51820") { - t.Fatalf("a taken tunnel's port must be the mesh interface's ListenPort:\n%s", config) - } - if strings.Contains(config, "Endpoint =") { - t.Error("a node that only listens for LAN peers must not advertise an endpoint") - } -} - -func TestANodeWithNoTunnelAndNoEndpointStillListensOnNothing(t *testing.T) { - // The guard against over-emitting: a plain spoke with neither an endpoint nor a taken tunnel - // writes no ListenPort — it purely dials out. - config, _ := declarationFor(t, Node{Name: "laptop", Key: "K", Address: "10.10.0.9"}, nil) - if strings.Contains(config, "ListenPort") { - t.Fatalf("a dial-only node needs no ListenPort:\n%s", config) - } -} - -func TestATakenTunnelCarriesItsMTU(t *testing.T) { - // A path tuned to a smaller MTU (1380 here) must survive the takeover — the mesh interface - // comes up with the same MTU, or transfers hang silently (novox/hq: a taken tunnel carries - // its MTU). - config, _ := declarationFor(t, Node{ - Name: "shanks", Key: "SPOKE", Address: "10.10.0.3", - TakesOver: &TakeOver{Interface: "wg0", Port: 51820, MTU: 1380}, - }, nil) - if !strings.Contains(config, "MTU = 1380") { - t.Fatalf("the tuned MTU was dropped:\n%s", config) - } -} - -func TestNoMTULineWhenTheTunnelSetNone(t *testing.T) { - config, _ := declarationFor(t, Node{ - Name: "shanks", Key: "SPOKE", Address: "10.10.0.3", - TakesOver: &TakeOver{Interface: "wg0", Port: 51820}, - }, nil) - if strings.Contains(config, "MTU") { - t.Fatalf("no MTU was found, so none should be written:\n%s", config) - } -} diff --git a/go/overlay/doc.go b/go/overlay/doc.go index 8a9f1b7..47879cb 100644 --- a/go/overlay/doc.go +++ b/go/overlay/doc.go @@ -1,5 +1,5 @@ -// Package overlay is the private network's generator, copied from mesh-controller internal/overlay at commit -// 6c2a8d62100cb7d491f8eb9a623e1eeea5ec0de1 (novox/hq ADR 0453 clause 3, ADR 0446 clause 4): the files are unchanged but -// for the import path of the catalogue, which is mesh-sdk's go/catalogue, and this note. mesh-controller keeps its own copy -// until release 4b. +// Package overlay is the private network's names and its manifest: the module the mesh provides for itself, the +// requirement and seat it answers, the interface's name and files, and the mesh's internal names. Its generator, the +// peer graph and the declaration it renders are the node-engine's own (novox/hq ADR 0593 §1, §7), in mesh-host's +// internal/overlay. package overlay diff --git a/go/overlay/generator.go b/go/overlay/generator.go deleted file mode 100644 index d74087d..0000000 --- a/go/overlay/generator.go +++ /dev/null @@ -1,195 +0,0 @@ -package overlay - -import ( - "encoding/json" - "fmt" - "strconv" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -// The private network as a module rather than as code beside the module system. -// -// A machine's peer list is derived from every other machine, so it differs on each one and -// changes when any of them changes — there is nothing that could be written in a manifest. So the -// module says its resources are computed, and this computes them. -// -// What that buys, beyond one mechanism instead of two: **a machine is on the private network -// because it was assigned the module.** Before this, every machine with a key and an address was -// on it, and there was no way to say a machine should stay off. -// -// It is worth saying what is *not* here, because networking looks like it should be foundational. -// The host needs none of this. It has an address and a route to the broker before the mesh exists -// — that is the machine's own networking — and the broker's address is carried in the enrolment -// token rather than resolved. So the private network is something the mesh installs on top, like -// anything else, and a node without it is an ordinary node that nothing reaches directly. - -// What a module asks for when it needs machines to reach each other. **This is the name that -// matters** — WireGuard is one way to answer it, and naming the requirement after the answer is -// how a mesh ends up unable to have a second one. -const Requirement = "private-network" - -// Name is the module that answers it with WireGuard. -// -// **It writes no names.** A machine's mesh names are answered by the mesh's one resolver (novox/hq -// ADR 0194), and /etc/hosts is the file of one module, the holder of `node-hostname` (ADR 0199, -// ADR 0223): the controller writes into no file another seat's holder owns. If the mesh ever needs a line there, it -// asks that holder to register it. This module asked for a `node-names` fact written into /etc/hosts -// until 2026-10-05; the host gives that region back at the first push without it. -const Name = "mesh-wireguard" - -// Addressing is the mesh handing out addresses on the private network itself. -// -// The mesh's resolver answers names from it, which is why it requires this rather than a private -// network in general. A different VPN that hands out its own addresses would come with its own -// names — the mesh has nothing to answer about a machine whose address it did not choose. -const Addressing = "mesh-addressing" - -// TheNetwork is what a machine can only have one of. -// -// Providing a private network is not the singular part — a machine could reasonably run two VPNs -// for two different purposes. Being **the** one the mesh runs over is singular, and without -// saying so a person who chose a different VPN can still end up with this one dragged back in by -// something that needed the mesh's own addresses. Which is exactly what happened, once. -const TheNetwork = "the-private-network" - -// **A resolver's data went the same way as the names.** Two constants used to sit here — a -// `mesh-resolver` module that would write the machines as wildcards, and a `resolver-data` -// requirement a daemon would ask for. Nothing ever provided or consumed either: a module that -// answers names asks for the `node-zones` fact in its own manifest (catalogue.FactsInto) and -// requires Addressing, since the file is made of the mesh's addresses and means nothing off the -// network. A requirement nothing provides is refused at resolution, so leaving the names here -// would only have documented a mechanism that does not exist. - -// **There is no bundle any more** (novox/hq ADR 0226). A `networking` module requiring this one and -// nothing else used to be what a machine was assigned, so that "get the network working" was one -// word and a second VPN could be chosen by assigning it instead. The mesh has one private network, -// every machine is on it, and the bundle was a second name for this module that every machine -// carried. A machine is assigned Name directly; another VPN is still chosen by assigning it in its -// place, which is all the bundle ever did. - -// Generator answers what one node's network configuration is. -type Generator struct { - nodes []Node - graph Graph - // keyPath is where each node keeps the private half it generated. Named rather than carried: - // the mesh has never seen it and never will. - keyPath string - // No registry. Being on the network is still what grants a machine the right to pull from the - // mesh's artifact store in the clear (novox/hq ADR 0082), but the runtime's file is the runtime's - // module's: the private network writes nothing into it, and that module states the registry - // itself, told where the store is reached by ${seat:mesh-artifact-store:reach} (novox/hq ADR - // 0222, issue 190). -} - -// From builds a generator over the machines that are part of the network. -// -// The nodes given are the ones assigned the module — not every node the mesh knows. A machine -// that was never given it is absent from everybody's peer list and from the names, which is what -// "not on the network" has to mean. -func From(nodes []Node, cidr, keyPath string) (*Generator, error) { - graph, err := Compute(nodes, cidr) - if err != nil { - return nil, err - } - return &Generator{nodes: nodes, graph: graph, keyPath: keyPath}, nil -} - -// Resources is one node's interface, peers and names. -func (g *Generator) Resources(node string) ([]map[string]any, bool, error) { - peers, part := g.graph[node] - if !part { - // Assigned and not yet placed on the network. Ordinary and brief, so it is an answer - // rather than an error. - return nil, false, nil - } - var self Node - for _, n := range g.nodes { - if n.Name == node { - self = n - } - } - - raw, err := Declaration(self, peers, g.keyPath) - if err != nil { - return nil, false, err - } - var parsed struct { - Resources []map[string]any `json:"resources"` - } - if err := json.Unmarshal(raw, &parsed); err != nil { - return nil, false, err - } - return parsed.Resources, true, nil -} - -// Nodes are the machines this generator was built over, so a caller can say who is on the network. -func (g *Generator) Nodes() []Node { return g.nodes } - -// Graph is the peer list per node, for showing. -func (g *Generator) Graph() Graph { return g.graph } - -// Manifest is the module the mesh provides for itself. -// -// It ships with the control plane rather than coming from a repository, because the thing that -// computes it ships with the control plane. That is the only way it is unusual: it is assigned, -// unassigned, resolved and settled exactly like a module somebody wrote. -func Manifest() map[string]any { - return map[string]any{ - "module": Name, - "version": "1", - "computed": Name, - "provides": []string{Requirement, Addressing}, - "claims": []map[string]any{{"name": TheNetwork, "scope": "node"}}, - } -} - -// Empty is a network nobody is on. -// -// A mesh where no machine was given the module. Legitimate rather than broken — every node still -// reaches the broker, which is what being in the mesh is — so it answers "not part of this" for -// everyone instead of refusing for want of a hub. -func Empty() *Generator { return &Generator{graph: Graph{}} } - -// Listens is the port this node accepts the private network on, which only a hub has. -// -// **A fact about this machine's place in the mesh, not about the module.** Every machine on the -// network runs the same module; a hub is dialled by every node at other sites and needs its port -// open, and a machine that is not a hub dials out and needs nothing open at all. A static field in -// a manifest is one answer for every machine that runs it, so it cannot say this — and the machine -// it would get wrong is the one facing the public internet, which is the machine that most needs -// filtering. -// -// The port is the one in the endpoint, which is also where the interface takes its ListenPort -// from. One source, so a rule set cannot open a port the interface is not on. -func (g *Generator) Listens(node string) ([]catalogue.Listening, error) { - for _, n := range g.nodes { - if n.Name != node { - continue - } - if !n.Reachable() { - // It dials out and nothing dials it. Opening a port here would be opening one on a - // machine nothing connects to, which is not harmless — it is a rule with no source - // that somebody later has to work out the reason for. - return nil, nil - } - port := portOf(n.Endpoint) - if port == "" { - return nil, fmt.Errorf( - "%s is reachable at %q and no port can be read from it, so what it must accept "+ - "the private network on is unknown", node, n.Endpoint) - } - number, err := strconv.Atoi(port) - if err != nil { - return nil, fmt.Errorf("%s is reachable at %q, and %q is not a port", node, n.Endpoint, port) - } - return []catalogue.Listening{{ - Port: number, Protocol: "udp", From: catalogue.FromEverywhere, - // From everywhere, and deliberately: a node at another site is not on the private - // network until this port lets it on, so restricting this to the mesh would be a - // rule that can never be satisfied by the thing it exists for. - Why: "the private network — a node at another site has no other way in", - }}, nil - } - return nil, nil -} diff --git a/go/overlay/generator_test.go b/go/overlay/generator_test.go deleted file mode 100644 index 569322b..0000000 --- a/go/overlay/generator_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package overlay - -import ( - "testing" -) - -// novox/hq ADR 0222, issue 190: the runtime's file and service are the runtime's module's. The -// private network writes nothing into either — being on it still grants the right to pull from the -// mesh's store in the clear (ADR 0082), and the runtime's module states that trust itself. -func TestTheNetworkWritesNothingOfTheRuntimes(t *testing.T) { - nodes := []Node{ - {Name: "anchor", Site: "lab", Hub: true, Endpoint: "192.0.2.10:51820", Key: "k1", Address: "10.42.0.1"}, - {Name: "node2", Site: "lab", Key: "k2", Address: "10.42.0.2"}, - } - g, err := From(nodes, "10.42.0.0/16", "") - if err != nil { - t.Fatal(err) - } - for _, node := range []string{"anchor", "node2"} { - resources, part, err := g.Resources(node) - if err != nil || !part { - t.Fatalf("%s: resources: %v part=%v", node, err, part) - } - for _, r := range resources { - if r["path"] == "/etc/docker/daemon.json" || r["unit"] == "docker.service" { - t.Errorf("%s: the private network declares the runtime's %v", node, r) - } - } - } -} diff --git a/go/overlay/graph.go b/go/overlay/graph.go deleted file mode 100644 index 4be1a43..0000000 --- a/go/overlay/graph.go +++ /dev/null @@ -1,231 +0,0 @@ -// Package overlay computes the private network every node runs on. -// -// novox/hq 08-connectivity. This is control-plane work by definition: a peer list is derived from -// every node at once, and no node has that. A node computes nothing about the mesh — it generates -// a keypair, publishes the public half, and receives the rest. -// -// The shape is a hub, with direct peering between nodes at the same site. Not a full mesh, and -// the reason is a property of WireGuard rather than a preference: there is no failover. A more -// specific route to a dead endpoint blackholes; it does not fall back to the general one. So a -// node gets exactly one path to any peer, because two would mean one of them silently swallowing -// traffic. -package overlay - -import ( - "errors" - "fmt" - "sort" - "strings" -) - -// Node is one machine's place on the network, as the mesh holds it. -type Node struct { - Name string - Key string - Endpoint string - Site string - Hub bool - Address string - - // Carried are the peers of the tunnel this node took over (novox/hq ADR 0105): machines the - // mesh has no record of, each known by the public key and the address the found tunnel routed - // to it. Only a hub has any. They stay in its peer list until a node enrols with that key — - // from then on the node is the peer. - Carried []Carried - // TakesOver names the found tunnel this node's private network replaces: its unit is stopped - // and disabled, never flushed, and its configuration kept, before the mesh's interface comes - // up with the found key. Nil on a node that raises the mesh's interface beside whatever it has. - TakesOver *TakeOver -} - -// Carried is one peer of an adopted tunnel that has not enrolled: a peer of the tunnel, not a -// node of the mesh. -type Carried struct { - Key string - Address string -} - -// TakeOver is the found tunnel a node's private network takes over, as the host is told it. -type TakeOver struct { - Interface string - Unit string - Config string - // MTU is the found tunnel's, when it set one — emitted so a tuned path keeps its MTU. - MTU int - // Port is the port the found tunnel listened on. The mesh's interface must listen on it too, - // even on a node that is not dialable from the hub: a home node's LAN peers dial it there - // (novox/hq: a taken tunnel brings its port). Listening is "a peer dials me here"; it is not - // "the hub can dial me", which is Reachable — the two were conflated. - Port int -} - -// HostPrefix is one address as a route: /32 for IPv4, /128 for IPv6. -func HostPrefix(address string) string { - if strings.Contains(address, ":") { - return address + "/128" - } - return address + "/32" -} - -// CarriedName is how a carried peer is named in a peer list: it has no node name, so it is named -// by the key its packets arrive under. -func CarriedName(key string) string { - short := key - if len(short) > 8 { - short = short[:8] + "…" - } - return "a peer of the tunnel (" + short + ")" -} - -// Reachable reports whether other nodes can dial this one. Declared, never inferred. -func (n Node) Reachable() bool { return strings.TrimSpace(n.Endpoint) != "" } - -// Peer is one entry in a node's peer list. -type Peer struct { - Name string - Key string - // Endpoint is empty when this peer cannot be dialled — it must dial us instead. - Endpoint string - // Allowed is what traffic goes down this tunnel. A single address for a direct peer; the - // whole overlay for the hub, which is what makes it the route of last resort. - Allowed string - // Keepalive matters only on the side behind NAT: a node that cannot be dialled has to keep - // the path open from its end, or the peer's first packet arrives at a mapping that has - // already expired. - Keepalive bool - // Why this peer is in the list, for a person reading a generated file and wondering. - Why string -} - -// Graph is every node's peer list. -type Graph map[string][]Peer - -// ErrNoHub means nobody has said which node is the hub. -// -// Its own error rather than an empty graph: a mesh with no hub has no path between sites, and -// answering with "no peers" would look like a working mesh where nothing can reach anything. -var ErrNoHub = errors.New("this mesh has no hub, so there is no path between sites") - -// Compute derives every node's peer list. -// -// Nodes without a key or an address are skipped rather than refused: a node that has enrolled and -// not yet been given a place on the network is an ordinary in-between state, and failing the whole -// graph because one node is half-configured would mean no node gets a network. -func Compute(nodes []Node, overlayCIDR string) (Graph, error) { - var hub *Node - usable := make([]Node, 0, len(nodes)) - for i := range nodes { - n := nodes[i] - if n.Key == "" || n.Address == "" { - continue - } - usable = append(usable, n) - if n.Hub { - hub = &usable[len(usable)-1] - } - } - if len(usable) == 0 { - return Graph{}, nil - } - if hub == nil { - return nil, ErrNoHub - } - if !hub.Reachable() { - return nil, fmt.Errorf( - "%s is the hub and has no endpoint, so nothing can dial it. The hub is the one node "+ - "that must be reachable from wherever the others are", hub.Name) - } - - graph := Graph{} - for _, self := range usable { - var peers []Peer - - // A node may share a site with the hub, and then the hub is one peer rather than two. - // Written before the loop because it changes what that loop may emit: WireGuard takes one - // entry per public key, so a hub appearing twice is a configuration it refuses — and the - // mesh would have produced it silently. The lab found this on the first two machines that - // shared a site with their hub. - hubIsHere := !self.Hub && self.Site != "" && self.Site == hub.Site - - for _, other := range usable { - if other.Name == self.Name || (hubIsHere && other.Name == hub.Name) { - continue - } - // Two nodes at the same site peer directly — but only if one of them can be - // dialled. If neither can, nobody opens the path, and the direct route is more - // specific than the hub's, so it wins and blackholes. That is this design's own - // stated hazard arriving in it: *a more specific route to a dead endpoint - // blackholes; it does not fall back to the general one.* - // - // Found in the lab with two machines at one site behind no reachable address, which - // is the ordinary shape of a home: they were given each other as peers, neither - // could start, and they could not reach each other at all while both reached the hub - // perfectly. - if self.Site != "" && self.Site == other.Site && (self.Reachable() || other.Reachable()) { - peers = append(peers, Peer{ - Name: other.Name, Key: other.Key, - Endpoint: other.Endpoint, - Allowed: other.Address + "/32", - Keepalive: !self.Reachable(), - Why: "at the same site", - }) - } - } - - if !self.Hub { - // Everything else goes through the hub, including a node that roams. AllowedIPs is - // the whole overlay, so this is the route of last resort — and because direct peers - // above are single addresses, they win on specificity without either being ambiguous. - why := "the hub — everything not at this site" - if hubIsHere { - // One entry doing both jobs: the direct path to a machine that happens to be - // here, and the route to everywhere else. Splitting them would need two entries - // for one key, which is the thing being avoided. - why = "the hub, which is also at this site — everything goes here" - } - peers = append(peers, Peer{ - Name: hub.Name, Key: hub.Key, - Endpoint: hub.Endpoint, - Allowed: overlayCIDR, - Keepalive: !self.Reachable(), - Why: why, - }) - } else { - // The hub holds every node that does not share a site with it, because those nodes - // route through it and it must know where to send the replies. Ones it cannot dial - // will dial it. - enrolled := map[string]bool{} - for _, other := range usable { - enrolled[other.Key] = true - if other.Name == self.Name || (self.Site != "" && self.Site == other.Site) { - continue - } - peers = append(peers, Peer{ - Name: other.Name, Key: other.Key, - Endpoint: other.Endpoint, - Allowed: other.Address + "/32", - Why: "routes through this hub", - }) - } - // And every peer of the tunnel it took over that has not enrolled (novox/hq ADR - // 0105): the same key and the same address the found tunnel had for it, so the - // machine behind it cannot tell the tunnel changed hands. No endpoint — it dials in, - // as it always did. Once a node enrols with that key, the node's entry above is the - // peer, and WireGuard takes one entry per key. - for _, c := range self.Carried { - if enrolled[c.Key] { - continue - } - peers = append(peers, Peer{ - Name: CarriedName(c.Key), Key: c.Key, - Allowed: HostPrefix(c.Address), - Why: "carried from the tunnel this hub took over — a peer of the tunnel, not yet a node of the mesh", - }) - } - } - - sort.Slice(peers, func(i, j int) bool { return peers[i].Name < peers[j].Name }) - graph[self.Name] = peers - } - return graph, nil -} diff --git a/go/overlay/graph_test.go b/go/overlay/graph_test.go deleted file mode 100644 index 5cfe6ea..0000000 --- a/go/overlay/graph_test.go +++ /dev/null @@ -1,342 +0,0 @@ -package overlay - -import ( - "errors" - "strings" - "testing" -) - -const cidr = "10.42.0.0/16" - -func at(name, site, address, endpoint string, hub bool) Node { - return Node{Name: name, Key: "key-" + name, Site: site, Address: address, - Endpoint: endpoint, Hub: hub} -} - -func peersOf(t *testing.T, g Graph, node string) map[string]Peer { - t.Helper() - out := map[string]Peer{} - for _, p := range g[node] { - out[p.Name] = p - } - return out -} - -func TestEveryNodeReachesTheHub(t *testing.T) { - // The property that makes this a network at all. Without it a node has no route to anything - // it does not share a site with. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("laptop", "", "10.42.0.2", "", false), - at("home", "house", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - for _, node := range []string{"laptop", "home"} { - hub, ok := peersOf(t, g, node)["anchor"] - if !ok { - t.Fatalf("%s has no route to the hub", node) - } - if hub.Allowed != cidr { - t.Errorf("%s routes %s through the hub; it must be the whole overlay or the hub is "+ - "not a route of last resort", node, hub.Allowed) - } - } -} - -func TestNodesAtOneSitePeerDirectly(t *testing.T) { - // Machines that share a site have a path that does not need the hub, and using it keeps their - // traffic off a link that may be on another continent. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false), - at("server", "house", "10.42.0.3", "192.0.2.3:51820", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - - direct, ok := peersOf(t, g, "desk")["server"] - if !ok { - t.Fatal("two machines at one site do not peer directly") - } - if direct.Allowed != "10.42.0.3/32" { - t.Errorf("the direct peer allows %s; a single address is what makes it win on "+ - "specificity over the hub's whole-overlay route", direct.Allowed) - } -} - -func TestARoamingNodeGetsExactlyOnePath(t *testing.T) { - // Hub-only, and not as a simplification. WireGuard has no failover: a more specific route to - // a dead endpoint blackholes rather than falling back, so two paths would mean one of them - // silently swallowing traffic. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("laptop", "", "10.42.0.2", "", false), - at("other", "", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - if got := len(g["laptop"]); got != 1 { - t.Fatalf("a roaming node has %d peers; it gets exactly one path", got) - } - if g["laptop"][0].Name != "anchor" { - t.Errorf("a roaming node's single path is %s, not the hub", g["laptop"][0].Name) - } -} - -func TestTwoRoamingNodesDoNotPeerWithEachOther(t *testing.T) { - // An empty site is not a site. Nodes that roam have no shared location, and treating "" as - // one would have every roaming machine try to dial every other, none of which can be dialled. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("laptop", "", "10.42.0.2", "", false), - at("phone", "", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - if _, ok := peersOf(t, g, "laptop")["phone"]; ok { - t.Error("two nodes with no site peered as though they shared one") - } -} - -func TestOnlyTheSideThatCannotBeDialledKeepsThePathOpen(t *testing.T) { - // Keepalive matters exactly once: on the node behind NAT. Without it the peer's first packet - // arrives at a mapping that has already expired. On the reachable side it is pointless - // traffic for ever. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("laptop", "", "10.42.0.2", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - if !peersOf(t, g, "laptop")["anchor"].Keepalive { - t.Error("a node that cannot be dialled does not keep its path open") - } - if peersOf(t, g, "anchor")["laptop"].Keepalive { - t.Error("a reachable node sends keepalives it does not need") - } - - // And between two direct peers at one site, where whether to keep the path open depends on - // which end you are. Checked here because the hub's own peer list happens not to set the - // field at all, so asserting only against the hub tests an absence rather than the rule. - same, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false), - at("server", "house", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - if !peersOf(t, same, "server")["desk"].Keepalive { - t.Error("the peer that cannot be dialled does not keep the path open") - } - if peersOf(t, same, "desk")["server"].Keepalive { - t.Error("the peer that can be dialled sends keepalives it does not need") - } -} - -func TestTheHubKnowsEveryoneItRoutesFor(t *testing.T) { - // The replies have to get back. A hub that does not hold a peer cannot answer it. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("laptop", "", "10.42.0.2", "", false), - at("home", "house", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - hub := peersOf(t, g, "anchor") - for _, want := range []string{"laptop", "home"} { - if _, ok := hub[want]; !ok { - t.Errorf("the hub does not hold %s, so replies to it have nowhere to go", want) - } - } -} - -func TestAMeshWithNoHubIsRefused(t *testing.T) { - // Not an empty graph. A mesh with no hub has no path between sites, and answering "no peers" - // would look like a working network in which nothing can reach anything — which is how the - // old arrangement failed, silently, when nobody knew the address convention. - _, err := Compute([]Node{ - at("a", "", "10.42.0.1", "", false), - at("b", "", "10.42.0.2", "", false), - }, cidr) - if !errors.Is(err, ErrNoHub) { - t.Fatalf("a mesh with no hub gave %v", err) - } -} - -func TestAnUnreachableHubIsRefused(t *testing.T) { - // The hub is the one node that must be dialable from wherever the others are. Left - // unchecked, every node would be given a peer it can never reach and the mesh would look - // configured and be silent. - _, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "", true), - at("laptop", "", "10.42.0.2", "", false), - }, cidr) - if err == nil { - t.Fatal("a hub with no endpoint was accepted") - } - if !strings.Contains(err.Error(), "must be reachable") { - t.Errorf("the refusal does not say why: %v", err) - } -} - -func TestANodeWithNoPlaceYetIsSkippedRatherThanFatal(t *testing.T) { - // A node that has enrolled and not yet been given an address is an ordinary in-between state. - // Failing the whole graph over it would mean no node gets a network because one is half done. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("laptop", "", "10.42.0.2", "", false), - {Name: "newcomer", Key: "", Address: ""}, - }, cidr) - if err != nil { - t.Fatal(err) - } - if _, ok := g["newcomer"]; ok { - t.Error("a node with no key or address was given a peer list") - } - if _, ok := peersOf(t, g, "laptop")["newcomer"]; ok { - t.Error("a node with no key was put in somebody's peer list") - } -} - -func TestNobodyPeersWithThemselves(t *testing.T) { - g, err := Compute([]Node{ - at("anchor", "house", "10.42.0.1", "198.51.100.1:51820", true), - at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - for node, peers := range g { - for _, p := range peers { - if p.Name == node { - t.Errorf("%s peers with itself", node) - } - } - } -} - -func TestAHubAtYourOwnSiteAppearsOnceNotTwice(t *testing.T) { - // WireGuard takes one entry per public key. A hub that shares a site with a spoke was being - // emitted twice — once as a direct peer and once as the route of last resort — producing a - // configuration the interface refuses, from a mesh that thought it had succeeded. - // - // Found in the lab on the first two machines that shared a site with their hub, which is the - // ordinary case for a small mesh and was in none of the tests above. - g, err := Compute([]Node{ - at("anchor", "lab", "10.42.0.1", "192.0.2.10:51820", true), - at("laptop", "lab", "10.42.0.2", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - - seen := map[string]int{} - for _, p := range g["laptop"] { - seen[p.Key]++ - } - for key, count := range seen { - if count > 1 { - t.Errorf("the key %s appears %d times; WireGuard takes one entry per key", key, count) - } - } - - if len(g["laptop"]) != 1 { - t.Fatalf("laptop has %d peers; the hub at its own site is one peer, not two", len(g["laptop"])) - } - // And that single entry has to carry everything, or the node keeps a direct path to the hub - // and loses its route to everywhere else. - if got := g["laptop"][0].Allowed; got != cidr { - t.Errorf("the single entry allows %s; it is both the direct path and the route of last "+ - "resort, so it carries the whole overlay", got) - } -} - -func TestTwoUnreachableNodesAtOneSiteDoNotPeerDirectly(t *testing.T) { - // Nobody would open the path, and the direct route is more specific than the hub's — so it - // wins and blackholes. Both nodes would reach the hub perfectly and be unable to reach each - // other, which is the worst arrangement available: it looks configured and is not. - // - // This is the design's own warning arriving in its implementation, and the lab found it with - // two machines at one site behind no reachable address — the ordinary shape of a house. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("desk", "house", "10.42.0.2", "", false), - at("server", "house", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - - if _, ok := peersOf(t, g, "desk")["server"]; ok { - t.Error("two nodes that neither can be dialled were peered directly; neither could open " + - "the path and the route is more specific than the hub's, so it blackholes") - } - // And they must still be able to reach each other — through the hub. - if hub := peersOf(t, g, "desk")["anchor"]; hub.Allowed != cidr { - t.Errorf("desk's route to everything else allows %s", hub.Allowed) - } -} - -func TestOneReachableNodeIsEnoughToPeerDirectly(t *testing.T) { - // The other side of it: if either end can be dialled, the path can be opened, and using it - // keeps their traffic off a hub that may be on another continent. - g, err := Compute([]Node{ - at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true), - at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false), - at("server", "house", "10.42.0.3", "", false), - }, cidr) - if err != nil { - t.Fatal(err) - } - if _, ok := peersOf(t, g, "server")["desk"]; !ok { - t.Error("a node did not peer with a reachable neighbour at its own site") - } - if _, ok := peersOf(t, g, "desk")["server"]; !ok { - t.Error("the reachable node did not hold its unreachable neighbour, so replies have " + - "nowhere to go") - } -} - -// novox/hq ADR 0105: a hub that took over the predecessor's tunnel carries every peer that tunnel -// had, under the key and at the address the peer knows, until a node enrols with that key. -func TestTheHubCarriesTheTunnelsPeersUntilTheyEnrol(t *testing.T) { - hub := at("anchor", "hosting", "192.0.2.1", "198.51.100.1:51900", true) - hub.Carried = []Carried{ - {Key: "key-home", Address: "192.0.2.2"}, - {Key: "key-workstation", Address: "192.0.2.3"}, - } - // The machine behind key-home enrolled: it is a node now, at the address it kept. - home := at("home", "house", "192.0.2.2", "", false) - home.Key = "key-home" - - g, err := Compute([]Node{hub, home}, "192.0.2.0/24") - if err != nil { - t.Fatal(err) - } - peers := peersOf(t, g, "anchor") - carried, ok := peers[CarriedName("key-workstation")] - if !ok { - t.Fatalf("the hub does not carry the peer that has not enrolled: %+v", g["anchor"]) - } - if carried.Allowed != "192.0.2.3/32" || carried.Endpoint != "" || carried.Key != "key-workstation" { - t.Errorf("a carried peer is not the tunnel's own entry — same key, its one address, no endpoint: %+v", carried) - } - if _, twice := peers[CarriedName("key-home")]; twice { - t.Error("a peer that enrolled is carried as well as listed as a node: WireGuard takes one entry per key") - } - if node, ok := peers["home"]; !ok || node.Key != "key-home" || node.Allowed != "192.0.2.2/32" { - t.Errorf("the enrolled peer is not the node it became: %+v", node) - } - // Carried peers are the hub's business only: a spoke routes everything through the hub. - if _, leaked := peersOf(t, g, "home")[CarriedName("key-workstation")]; leaked { - t.Error("a carried peer appeared in a spoke's peer list") - } -} diff --git a/go/overlay/opens_test.go b/go/overlay/opens_test.go deleted file mode 100644 index 7abf1bd..0000000 --- a/go/overlay/opens_test.go +++ /dev/null @@ -1,113 +0,0 @@ -package overlay - -import ( - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/catalogue" -) - -func networkOf(t *testing.T, nodes []Node) *Generator { - t.Helper() - made, err := From(nodes, "10.42.0.0/16", "/var/lib/mesh-host/overlay.key") - if err != nil { - t.Fatal(err) - } - return made -} - -// A hub is dialled by every node at other sites, and needs its port open. A machine that is not a -// hub dials out and needs nothing open at all. -// -// They are the same module, which is why a static field in a manifest cannot say it — and the -// machine it gets wrong is the one facing the public internet, which is the machine that most -// needs filtering. -func TestOnlyAMachineThatCanBeDialledOpensTheOverlaysPort(t *testing.T) { - network := networkOf(t, []Node{ - {Name: "anchor", Key: "a", Endpoint: "198.51.100.10:51820", Site: "one", Hub: true}, - {Name: "laptop", Key: "b", Site: "one"}, - }) - - opens, err := network.Listens("anchor") - if err != nil { - t.Fatal(err) - } - if len(opens) != 1 { - t.Fatalf("the machine every other one dials opens %d ports", len(opens)) - } - if opens[0].Port != 51820 || opens[0].At() != "udp" { - t.Fatalf("the port is not the one the interface listens on: %+v", opens[0]) - } - // From everywhere, and deliberately: a node at another site is not on the private network - // until this port lets it on, so restricting it to the mesh would be a rule that can never be - // satisfied by the thing it exists for. - if opens[0].From != catalogue.FromEverywhere { - t.Fatalf("the way onto the private network is restricted to the private network: %+v", opens[0]) - } - if opens[0].Why == "" { - t.Fatal("a port is opened and nothing says why, which is what makes a rule set unreadable") - } - - // And the machine nothing dials opens nothing. Not harmless to get wrong: a rule with no - // reason is one somebody later has to work out the reason for. - quiet, err := network.Listens("laptop") - if err != nil { - t.Fatal(err) - } - if len(quiet) != 0 { - t.Fatalf("a machine nothing dials opened %d port(s)", len(quiet)) - } -} - -// The port comes from the endpoint, which is where the interface takes its ListenPort from. One -// source, so a rule set cannot open a port the interface is not on. -func TestTheOpenedPortIsTheOneTheInterfaceListensOn(t *testing.T) { - network := networkOf(t, []Node{ - {Name: "anchor", Key: "a", Endpoint: "198.51.100.10:60000", Site: "one", Hub: true}, - }) - opens, err := network.Listens("anchor") - if err != nil { - t.Fatal(err) - } - if len(opens) != 1 || opens[0].Port != 60000 { - t.Fatalf("the rule set would open a port the interface is not on: %+v", opens) - } - - written, err := Declaration(Node{Name: "anchor", Key: "a", Address: "10.42.0.1", - Endpoint: "198.51.100.10:60000", Hub: true}, nil, "/k") - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(written), "ListenPort = 60000") { - t.Fatalf("the interface and the rule set disagree about the port:\n%s", written) - } -} - -// An endpoint with no port is refused rather than treated as a machine that opens nothing. -// -// A generator that cannot say what a machine opens is not one that says it opens nothing, and -// closing a port on the evidence of a failure to look is how a machine is severed by a fault -// somewhere else entirely. -func TestAnEndpointWithNoPortIsRefusedRatherThanTakenAsSilence(t *testing.T) { - network := networkOf(t, []Node{ - {Name: "anchor", Key: "a", Endpoint: "198.51.100.10", Site: "one", Hub: true}, - }) - if _, err := network.Listens("anchor"); err == nil { - t.Fatal("a machine whose port could not be read was treated as opening nothing") - } -} - -// A machine the network has never heard of opens nothing, and that is an answer rather than an -// error: a node assigned the module before it is placed is in exactly that state. -func TestAMachineNotOnTheNetworkOpensNothing(t *testing.T) { - network := networkOf(t, []Node{ - {Name: "anchor", Key: "a", Endpoint: "198.51.100.10:51820", Site: "one", Hub: true}, - }) - opens, err := network.Listens("a-stranger") - if err != nil { - t.Fatal(err) - } - if len(opens) != 0 { - t.Fatalf("a machine not on the network opened %d port(s)", len(opens)) - } -} diff --git a/go/overlay/overlay.go b/go/overlay/overlay.go new file mode 100644 index 0000000..c2dc32a --- /dev/null +++ b/go/overlay/overlay.go @@ -0,0 +1,79 @@ +package overlay + +// The private network as a module rather than as code beside the module system. +// +// A machine's peer list is derived from every other machine, so it differs on each one and +// changes when any of them changes — there is nothing that could be written in a manifest. So the +// module says its resources are computed, and this computes them. +// +// What that buys, beyond one mechanism instead of two: **a machine is on the private network +// because it was assigned the module.** Before this, every machine with a key and an address was +// on it, and there was no way to say a machine should stay off. +// +// It is worth saying what is *not* here, because networking looks like it should be foundational. +// The host needs none of this. It has an address and a route to the broker before the mesh exists +// — that is the machine's own networking — and the broker's address is carried in the enrolment +// token rather than resolved. So the private network is something the mesh installs on top, like +// anything else, and a node without it is an ordinary node that nothing reaches directly. + +// What a module asks for when it needs machines to reach each other. **This is the name that +// matters** — WireGuard is one way to answer it, and naming the requirement after the answer is +// how a mesh ends up unable to have a second one. +const Requirement = "private-network" + +// Name is the module that answers it with WireGuard. +// +// **It writes no names.** A machine's mesh names are answered by the mesh's one resolver (novox/hq +// ADR 0194), and /etc/hosts is the file of one module, the holder of `node-hostname` (ADR 0199, +// ADR 0223): the controller writes into no file another seat's holder owns. If the mesh ever needs a line there, it +// asks that holder to register it. This module asked for a `node-names` fact written into /etc/hosts +// until 2026-10-05; the host gives that region back at the first push without it. +const Name = "mesh-wireguard" + +// Addressing is the mesh handing out addresses on the private network itself. +// +// The mesh's resolver answers names from it, which is why it requires this rather than a private +// network in general. A different VPN that hands out its own addresses would come with its own +// names — the mesh has nothing to answer about a machine whose address it did not choose. +const Addressing = "mesh-addressing" + +// TheNetwork is what a machine can only have one of. +// +// Providing a private network is not the singular part — a machine could reasonably run two VPNs +// for two different purposes. Being **the** one the mesh runs over is singular, and without +// saying so a person who chose a different VPN can still end up with this one dragged back in by +// something that needed the mesh's own addresses. Which is exactly what happened, once. +const TheNetwork = "the-private-network" + +// **A resolver's data went the same way as the names.** Two constants used to sit here — a +// `mesh-resolver` module that would write the machines as wildcards, and a `resolver-data` +// requirement a daemon would ask for. Nothing ever provided or consumed either: a module that +// answers names asks for the `node-zones` fact in its own manifest (catalogue.FactsInto) and +// requires Addressing, since the file is made of the mesh's addresses and means nothing off the +// network. A requirement nothing provides is refused at resolution, so leaving the names here +// would only have documented a mechanism that does not exist. + +// Manifest is the module the mesh provides for itself. +// +// It ships with the control plane rather than coming from a repository, because the thing that +// computes it ships with the control plane. That is the only way it is unusual: it is assigned, +// unassigned, resolved and settled exactly like a module somebody wrote. +func Manifest() map[string]any { + return map[string]any{ + "module": Name, + "version": "1", + "computed": Name, + "provides": []string{Requirement, Addressing}, + "claims": []map[string]any{{"name": TheNetwork, "scope": "node"}}, + } +} + +// Interface is what the private network is called on a machine, and where the node's own key +// lives. A constant rather than a setting: two nodes disagreeing about the name would produce a +// mesh where each is configured correctly and nothing meets. +const ( + Interface = "mesh0" + ConfigPath = "/etc/wireguard/" + Interface + ".conf" + Unit = "wg-quick@" + Interface + DefaultKeyPath = "/var/lib/mesh-host/overlay.key" +) diff --git a/go/takein/takein.go b/go/takein/takein.go deleted file mode 100644 index 657ab45..0000000 --- a/go/takein/takein.go +++ /dev/null @@ -1,134 +0,0 @@ -// Package takein is the one rule that turns a builder's done key into the manifest the mesh composes a module from -// (issue 1126): the module's manifest resolved with what its build made. It is mesh-controller's take-in of a done key -// (cmd/mesh-controller/key_takein.go builtOfKey, unpackTexts and Manifest.Resolve) moved into the SDK, so a node agent -// composing its own modules (novox/hq ADR 0547 §2) composes from the same built manifest the controller composes from: -// every artifact a resource names resolved to its reference and digest, a TypeScript bundle's launchers, and, for a -// moved module, each template's text read back from its templates archive and what its build keeps for the -// provisioner (catalogue.Handed). Composing from the manifest the indexer read instead hands no moved module and -// leaves every artifact unbuilt. -// -// It reads nothing itself: the archive is read through the caller's Archive, from wherever the caller reaches the -// artifact store. -package takein - -import ( - "archive/tar" - "bytes" - "compress/gzip" - "context" - "errors" - "fmt" - "io" - "strings" - - "git.novox.be/novox/mesh-sdk/go/catalogue" - "git.novox.be/novox/mesh-sdk/go/delivery" -) - -// Archive reads the archive a recorded reference names (catalogue.Recorded: the artifact store's address left out), -// checked against its digest. -type Archive func(ctx context.Context, reference, digest string) ([]byte, error) - -// Manifest is m, as its manifest at b's fingerprint reads, resolved with what b made: the built manifest. b must be done -// and every artifact must name its reference. -// A templated module's templates archive is read through archive; a module without templates reads none, and archive -// may then be nil. -func Manifest(ctx context.Context, m catalogue.Manifest, b delivery.BuilderBuild, archive Archive) (catalogue.Manifest, error) { - if b.State != delivery.BuildDone { - return catalogue.Manifest{}, fmt.Errorf("takein: the build of %s is %s, not done: nothing to resolve it with", m.Module, b.State) - } - // **A done key whose artifact names no reference is waited for, never composed from** (novox/hq ADR 0498 §8), in the - // controller's words (mesh-controller internal/builderkeys Key.Says): a key done before R4e, or a candidate's image or - // package not yet built again for the trunk. - var missing []string - for _, a := range b.Artifacts { - if strings.TrimSpace(a.Reference) == "" { - missing = append(missing, a.Name) - } - } - if len(missing) > 0 { - return catalogue.Manifest{}, fmt.Errorf("takein: the build of %s is done and waits for the reference of %s", - m.Module, strings.Join(missing, ", ")) - } - built, err := Built(ctx, m, b.Artifacts, archive) - if err != nil { - return catalogue.Manifest{}, err - } - resolved, err := m.Resolve(built) - if err != nil { - return catalogue.Manifest{}, fmt.Errorf("takein: the build of %s does not resolve: %w", m.Module, err) - } - return resolved, nil -} - -// Built is each of a done key's artifacts as catalogue.Manifest.Resolve reads it: its recorded reference and digest, a -// TypeScript bundle's launchers by the build modules' one rule (catalogue.Launchers), and, for the archive a module packs -// its templates into, each template's text read back from that archive. -func Built(ctx context.Context, m catalogue.Manifest, artifacts []delivery.BuiltArtifact, archive Archive) ([]catalogue.Built, error) { - declared := map[string]catalogue.Artifact{} - if m.Build != nil { - for _, a := range m.Build.Artifacts { - declared[a.Name] = a - } - } - templates := "" - if m.Templated() { - templates = catalogue.TemplatesArtifact(m) - } - var out []catalogue.Built - for _, a := range artifacts { - d := declared[a.Name] - kind := a.Kind - if kind == "" { - kind = d.Kind - } - b := catalogue.Built{Name: a.Name, Kind: kind, Reference: catalogue.Recorded(a.Reference), Digest: a.Digest} - if launchers := catalogue.Launchers(d.Language, d.Entrypoints); len(launchers) > 0 { - b.Launchers = launchers - } - if a.Name == templates && templates != "" { - if archive == nil { - return nil, fmt.Errorf("takein: %s packs its templates into %q, and nothing here reads the artifact store", - m.Module, a.Name) - } - body, err := archive(ctx, b.Reference, b.Digest) - if err != nil { - return nil, fmt.Errorf("takein: the templates archive %q of %s cannot be read from the artifact store: %w", - a.Name, m.Module, err) - } - if b.Texts, err = UnpackTexts(body); err != nil { - return nil, fmt.Errorf("takein: the templates archive %q of %s cannot be read back: %w", a.Name, m.Module, err) - } - } - out = append(out, b) - } - return out, nil -} - -// UnpackTexts is every regular file of a packed templates archive (gzip over tar), by its path inside it. -func UnpackTexts(body []byte) (map[string]string, error) { - zipped, err := gzip.NewReader(bytes.NewReader(body)) - if err != nil { - return nil, err - } - defer zipped.Close() - reader := tar.NewReader(zipped) - texts := map[string]string{} - for { - header, err := reader.Next() - if errors.Is(err, io.EOF) { - return texts, nil - } - if err != nil { - return nil, err - } - if header.Typeflag != tar.TypeReg { - continue - } - text, err := io.ReadAll(io.LimitReader(reader, 16<<20)) - if err != nil { - return nil, err - } - texts[strings.TrimPrefix(header.Name, "./")] = string(text) - } -} diff --git a/go/validate/sdk_test.go b/go/validate/sdk_test.go deleted file mode 100644 index d946fff..0000000 --- a/go/validate/sdk_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package validate - -import ( - "errors" - "strings" - "testing" - - "git.novox.be/novox/mesh-sdk/go/declaration" -) - -// The SDK's validator speaks the copied parser's vocabulary version (issue 1353 AC3). -func TestVersionIsTheParsersVersion(t *testing.T) { - if Version != declaration.Version { - t.Fatalf("validate.Version = %d, declaration.Version = %d", Version, declaration.Version) - } -} - -// A body of another declaration version is refused whole, by both the validator and the parser (AC3). The body is -// the shape mesh-host's validate_test.go uses for its good declaration, with the version changed. -func TestAnotherVersionIsRefusedWhole(t *testing.T) { - body := `{"declaration": 99, "resources": [{"id": "d", "type": "directory", "path": "/var/lib/x", "mode": "0755"}]}` - problems := Declaration([]byte(body)) - if len(problems) == 0 { - t.Fatal("a declaration of another version was taken") - } - _, err := declaration.Parse([]byte(body)) - var refused *declaration.RefusalError - if !errors.As(err, &refused) || strings.Join(refused.Problems, "\n") != strings.Join(problems, "\n") { - t.Fatalf("the parser and the validator disagree: %v / %v", err, problems) - } -} diff --git a/go/validate/validate.go b/go/validate/validate.go deleted file mode 100644 index 1291866..0000000 --- a/go/validate/validate.go +++ /dev/null @@ -1,42 +0,0 @@ -// Package validate is the node-engine's own judgement of a declaration, for whoever must know before -// a declaration is sent whether a machine would take it (novox/hq to-be 45 §4, D1; §9, the merge gate). -// -// **One validator.** The controller composed declarations the host then refused whole — a manifest -// the catalogue check passed (novox/hq issue 236), a consumer's identity too long for the machine's -// names (issue 263) — because the only validator was the one the host runs as it applies. A second, -// written in the controller from the host's rules, would drift from them the first time either -// changed. So the host's own parsing is exported here, unchanged: what the controller's self-check and -// the merge gate run is what every machine runs. -// -// Copied from mesh-host at commit 619e14aa8e572c5056e3d2a2412c7f8b364bf840 (validate/validate.go), unchanged but for -// the import path of the parser, which is mesh-sdk's go/declaration (novox/hq ADR 0453 clause 3, ADR 0446 clause 4). -// mesh-host keeps its own copy until release 4b. -// -// It judges a declaration as it arrives over the link: actions are refused, as the host refuses them -// from the link (novox/hq ADR 0005). Nothing here touches a machine. -package validate - -import ( - "errors" - - "git.novox.be/novox/mesh-sdk/go/declaration" -) - -// Declaration is every problem the node-engine would refuse a declaration for, each in its own words; -// nil when it would take it. The body is the declaration as the controller composes it — the bytes a -// signature is made over — not the signed envelope. -func Declaration(raw []byte) []string { - _, err := declaration.Parse(raw) - if err == nil { - return nil - } - var refused *declaration.RefusalError - if errors.As(err, &refused) { - return refused.Problems - } - return []string{err.Error()} -} - -// Version is the declaration vocabulary this validator speaks: a declaration of another version is -// refused whole by Declaration, as by the host. -const Version = declaration.Version diff --git a/go/validate/validate_test.go b/go/validate/validate_test.go deleted file mode 100644 index 51ade73..0000000 --- a/go/validate/validate_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package validate - -import ( - "strings" - "testing" -) - -// **The validator a controller imports is the host's**: a declaration the host takes passes, and one -// it refuses is refused with the host's own words, every problem at once. -func TestTheValidatorIsTheHosts(t *testing.T) { - good := `{"declaration": 1, "resources": [{"id": "d", "type": "directory", "path": "/var/lib/x", "mode": "0755"}]}` - if problems := Declaration([]byte(good)); problems != nil { - t.Fatalf("a declaration the host takes was refused: %v", problems) - } - // And one carrying the lease epoch and sequence the controller sends it under (novox/hq to-be 45 - // §6): a controller validating with this package may send them. - ordered := `{"declaration": 1, "epoch": 57, "sequence": 3, "resources": [{"id": "d", "type": "directory", "path": "/var/lib/x", "mode": "0755"}]}` - if problems := Declaration([]byte(ordered)); problems != nil { - t.Fatalf("a declaration carrying its order was refused: %v", problems) - } - // And one carrying a file's owner-at-runtime and the files the provisioner fills (novox/hq ADR 0349 §5): the - // reader takes them before any controller sends them. - owned := `{"declaration": 1, "filled_files": ["/etc/x"], "resources": [{"id": "f", "type": "file", "path": "/etc/x", "content": "a", "owner-at-runtime": "owned"}]}` - if problems := Declaration([]byte(owned)); problems != nil { - t.Fatalf("a declaration saying who owns a file at runtime was refused: %v", problems) - } - // And one naming the provisioner client's account (novox/hq ADR 0349 §4). - named := `{"declaration": 1, "provisioner_account": "mesh-node-provisioner", "filled_files": ["/etc/x"], "resources": [{"id": "f", "type": "file", "path": "/etc/x", "content": "a", "owner-at-runtime": "owned"}]}` - if problems := Declaration([]byte(named)); problems != nil { - t.Fatalf("a declaration naming the provisioner client's account was refused: %v", problems) - } - for _, bad := range []string{ - `{"declaration": 1, "provisioner_account": "root", "resources": [{"id": "f", "type": "file", "path": "/etc/x", "content": "a"}]}`, - `{"declaration": 1, "filled_files": ["/etc/none"], "resources": [{"id": "f", "type": "file", "path": "/etc/x", "content": "a", "owner-at-runtime": "owned"}]}`, - `{"declaration": 1, "resources": []}`, - `{"declaration": 99, "resources": [{"id": "d", "type": "directory", "path": "/x"}]}`, - `{"declaration": 1, "resources": [{"id": "d", "type": "nothing-the-host-knows"}]}`, - `not json`, - } { - problems := Declaration([]byte(bad)) - if len(problems) == 0 { - t.Errorf("a declaration the host refuses was passed: %s", bad) - } - for _, p := range problems { - if strings.TrimSpace(p) == "" { - t.Errorf("a problem said nothing, for %s", bad) - } - } - } -}