Merge pull request 'sdk1: the node-engine alone composes and answers compose-candidate (ADR 0593)' (#198) from feat/sdk1 into main

This commit was merged in pull request #198.
This commit is contained in:
2026-11-02 20:42:47 +00:00
765 changed files with 70733 additions and 255 deletions
+154
View File
@@ -0,0 +1,154 @@
// mesh-compose is the node-engine's composer command (novox/hq ADR 0482 clause 4, ADR 0593 §7, issue 1621): it reads a
// composer.Request on standard input, reads every manifest, composes the machines named in each case and writes one
// composer.Verdict on standard output. Built at a commit of the node-engine's repository, it answers what that
// commit's composition answers, so a group that changes the node-engine's composition is judged by its own next code.
//
// mesh-compose < request.json > verdict.json
// mesh-compose -request request.json > verdict.json
// mesh-compose -catalogue <dir> [-node <name>]... [-depth resolve|compose] [-declarations] [< request.json]
//
// -catalogue reads <dir>/modules/*/module.json and a module.json at <dir>'s root as the first case's manifests (a
// checkout or `git archive` of the catalogue repository at a ref: the command fetches nothing). -node adds a machine to
// compose; -declarations answers each machine's declared paths with their owners and modes.
//
// It opens no network connection, bus, store or file beyond its standard input, the -request file and the -catalogue
// directory, and reads
// no environment. Exit codes: 0 the verdict is written (a machine that does not compose is a verdict, not an error);
// 1 the request cannot be read or run, said on standard error; 2 the flags are wrong.
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"github.com/novox/mesh-host/internal/composer"
)
func main() {
os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
}
type nodes []string
func (n *nodes) String() string { return strings.Join(*n, ",") }
func (n *nodes) Set(v string) error { *n = append(*n, v); return nil }
// run is the command: its arguments, standard input, output and error; it answers the exit code.
func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("mesh-compose", flag.ContinueOnError)
fs.SetOutput(stderr)
catalogueDir := fs.String("catalogue", "", "a catalogue checkout: <dir>/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
}
+339
View File
@@ -0,0 +1,339 @@
package main
import (
"bytes"
"encoding/json"
"go/build"
"os"
"os/exec"
"path/filepath"
"reflect"
"runtime"
"sort"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/beside"
"github.com/novox/mesh-host/internal/compose"
"github.com/novox/mesh-host/internal/composer"
)
// captured is every manifest of the catalogue captured in testdata/beside (internal/beside), raw, by module name.
func captured(t *testing.T) map[string]composer.Manifest {
t.Helper()
read, err := readCatalogue(filepath.Dir(beside.Catalogue(t)))
if err != nil {
t.Fatal(err)
}
if len(read) < 100 {
t.Fatalf("the captured catalogue holds %d manifests, fewer than the catalogue's", len(read))
}
return read
}
// meshOf is a mesh of three machines over the captured catalogue, each assigned real modules, the input the assigner
// composes with (nodes and their assignments, holdings from the claims): anchor runs the bus and the store, laptop a
// desktop's modules, ace a module whose requirement is provided elsewhere.
func meshOf() compose.Input {
return compose.Input{Nodes: []compose.Node{
{Name: "anchor", Assigned: []string{"nats", "postgres"}},
{Name: "laptop", Assigned: []string{"sudo", "zsh"}},
{Name: "ace", Assigned: []string{"mesh-reads", "nats"}},
}}
}
func request(t *testing.T, depth string) composer.Request {
t.Helper()
r := composer.Request{V: composer.Version, Depth: depth, HoldingsFromClaims: true,
Machines: []string{"anchor", "laptop", "ace"},
Cases: []composer.Case{{Name: "trunk", Manifests: captured(t)}}}
return withInput(t, r, meshOf())
}
// withInput is r with in as its input, as a caller puts it on the wire: the request's shape carries the input as JSON.
func withInput(t *testing.T, r composer.Request, in compose.Input) composer.Request {
t.Helper()
raw, err := json.Marshal(in)
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(raw, &r.Input); err != nil {
t.Fatal(err)
}
return r
}
func runJSON(t *testing.T, args []string, in []byte) (int, composer.Verdict, string) {
t.Helper()
var out, errOut bytes.Buffer
code := run(args, bytes.NewReader(in), &out, &errOut)
var v composer.Verdict
if code == 0 {
if err := json.Unmarshal(out.Bytes(), &v); err != nil {
t.Fatalf("the verdict is not JSON: %v\n%s", err, out.String())
}
}
return code, v, errOut.String()
}
func encode(t *testing.T, r composer.Request) []byte {
t.Helper()
raw, err := json.Marshal(r)
if err != nil {
t.Fatal(err)
}
return raw
}
// 1621/AC1: the command reads the plain input and the manifests on standard input, validates every manifest, composes
// the machines named and writes one verdict: per machine composes or the reason, per manifest accepted or the field
// refused.
func TestTheCommandWritesOneVerdictPerManifestAndMachine(t *testing.T) {
r := request(t, composer.DepthResolve)
r.Cases[0].Manifests["later"] = composer.Manifest{Raw: json.RawMessage(`{"module":"later","version":"1","shares":["x"]}`)}
r.Cases[0].Manifests["refused"] = composer.Manifest{Raw: json.RawMessage(`{"module":"refused","version":"1"}`)}
r.Cases[0].Verdicts = map[string]delivery.ManifestVerdict{"refused": {State: delivery.VerdictRefused, Problems: []string{"it is wrong"}}}
code, v, stderr := runJSON(t, nil, encode(t, r))
if code != 0 {
t.Fatalf("exit %d: %s", code, stderr)
}
if len(v.Cases) != 1 || v.Cases[0].Name != "trunk" || v.SDK == "" {
t.Fatalf("verdict = %+v", v)
}
c := v.Cases[0]
// ADR 0578 §5, §6: the composer judges nothing; it sets an unknown field aside and names it, and holds a manifest
// its verdict refuses, named in the indexer's words.
if got := c.Manifests["later"]; !got.Accepted || !strings.Contains(got.SetAside, `"shares"`) {
t.Errorf("the manifest with an unknown field reads %+v, want accepted naming the field set aside", got)
}
if got := c.Manifests["refused"]; got.Accepted || !strings.Contains(got.Refused, "refused the manifest of refused: it is wrong") {
t.Errorf("the refused manifest reads %+v, want refused in the verdict's words", got)
}
if got := c.Manifests["nats"]; !got.Accepted {
t.Errorf("nats reads %+v, want accepted", got)
}
if len(c.Machines) != 3 {
t.Fatalf("machines = %v, want the three named", c.Machines)
}
for name, m := range c.Machines {
if !m.Composes && m.Reason == "" {
t.Errorf("%s does not compose and says no reason", name)
}
}
}
// 1621/AC3: over the captured catalogue and a mesh of three machines, the command's verdict equals, machine for
// machine and manifest for manifest, what the linked reader, resolution and composition answer, at both depths.
func TestTheCommandAnswersWhatTheLinkedCompositionAnswers(t *testing.T) {
for _, depth := range []string{composer.DepthResolve, composer.DepthCompose} {
r := request(t, depth)
code, v, stderr := runJSON(t, nil, encode(t, r))
if code != 0 {
t.Fatalf("%s: exit %d: %s", depth, code, stderr)
}
in := meshOf()
in.Catalogue = map[string]catalogue.Manifest{}
for name, m := range r.Cases[0].Manifests {
// A request without a list of predefined keys reads a fallback as the indexer does (Request.Predefined).
parsed, err := catalogue.Decode(m.Raw)
got := v.Cases[0].Manifests[name]
if (err == nil) != got.Accepted || (err != nil && err.Error() != got.Refused) {
t.Errorf("%s: the manifest of %s: linked %v, command %+v", depth, name, err, got)
}
if err == nil {
in.Catalogue[name] = parsed
}
}
in.Holdings = composer.HoldingsOf(in.Catalogue, in.Nodes)
composed := 0
for _, node := range r.Machines {
got := v.Cases[0].Machines[node]
var want composer.Machine
if depth == composer.DepthResolve {
if _, err := compose.Resolve(in, node); err != nil {
want = composer.Machine{Reason: err.Error()}
} else {
want = composer.Machine{Composes: true}
}
} else {
d, problems, err := compose.Compose(in, node)
switch {
case err != nil:
want = composer.Machine{Reason: err.Error()}
default:
want = composer.Machine{Composes: len(problems) == 0, Problems: problems, Refusals: d.Refusals()}
if len(problems) > 0 {
want.Reason = "the node-engine's validator refuses the declaration"
}
}
}
if !reflect.DeepEqual(got, want) {
t.Errorf("%s: %s: command %+v, linked %+v", depth, node, got, want)
}
if got.Composes {
composed++
} else {
t.Logf("%s: %s does not compose: %s %v", depth, node, got.Reason, got.Problems)
}
}
t.Logf("%s: %d of %d machines compose", depth, composed, len(r.Machines))
}
}
// 1621 note 6: a person composes a catalogue checkout (repository@ref checked out or archived: the command fetches
// nothing) for a node and reads its declaration: each path with its kind, owner and mode.
func TestACatalogueCheckoutIsComposedForANode(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "modules", "demo"), 0o755); err != nil {
t.Fatal(err)
}
manifest := `{"module": "demo", "version": "1", "resources": [{"id": "state", "type": "directory", ` +
`"path": "/var/lib/demo", "mode": "0750", "owner": "root"}]}`
if err := os.WriteFile(filepath.Join(dir, "modules", "demo", "module.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
in := encode(t, withInput(t, composer.Request{V: composer.Version}, compose.Input{Nodes: []compose.Node{{Name: "laptop", Assigned: []string{"demo"}}}}))
code, v, stderr := runJSON(t, []string{"-catalogue", dir, "-node", "laptop", "-declarations"}, in)
if code != 0 {
t.Fatalf("exit %d: %s", code, stderr)
}
m := v.Cases[0].Machines["laptop"]
want := []composer.Path{{Path: "/var/lib/demo", Kind: "directory", Owner: "root", Mode: "0750", Module: ""}}
if !m.Composes || len(m.Declared) != 1 || m.Declared[0].Path != want[0].Path || m.Declared[0].Mode != "0750" ||
m.Declared[0].Owner != "root" || m.Declared[0].Kind != "directory" {
t.Fatalf("laptop = %+v, want it composed declaring %+v", m, want)
}
}
// A run step hands the command no standard input: the request is read from the file -request names, and a file that is
// not there is a request it cannot read.
func TestTheRequestIsReadFromAFile(t *testing.T) {
file := filepath.Join(t.TempDir(), "request.json")
r := composer.Request{V: composer.Version, Cases: []composer.Case{{Name: "only", Manifests: map[string]composer.Manifest{
"demo": {Raw: json.RawMessage(`{"module":"demo","version":"1"}`)}}}}}
if err := os.WriteFile(file, encode(t, r), 0o600); err != nil {
t.Fatal(err)
}
code, v, stderr := runJSON(t, []string{"-request", file}, nil)
if code != 0 || len(v.Cases) != 1 || !v.Cases[0].Manifests["demo"].Accepted {
t.Fatalf("exit %d (%s), verdict %+v", code, stderr, v)
}
if code, _, stderr := runJSON(t, []string{"-request", file + ".gone"}, nil); code != 1 || !strings.Contains(stderr, "cannot be read") {
t.Errorf("a request file that is not there: exit %d, %q", code, stderr)
}
}
// The exit codes: 1 for a request that cannot be read or run, 2 for wrong flags, each said on standard error.
func TestTheExitCodesSayWhatFailed(t *testing.T) {
unfilled := encode(t, composer.Request{V: composer.Version, Cases: []composer.Case{{Name: "c", Manifests: map[string]composer.Manifest{"x": {Digest: "abc"}}}}})
for _, c := range []struct {
name string
args []string
in string
code int
says string
}{
{"not JSON", nil, "{", 1, "not JSON"},
{"another version", nil, `{"v":2}`, 1, "version 2"},
{"a digest left unfilled", nil, string(unfilled), 1, "digest"},
{"an unknown depth", nil, `{"v":1,"depth":"deep"}`, 1, "depth"},
{"nothing to read", nil, "", 1, "no request"},
{"a wrong flag", []string{"-nope"}, "", 2, "nope"},
{"an argument", []string{"extra"}, "", 2, "unexpected"},
} {
var out, errOut bytes.Buffer
if got := run(c.args, strings.NewReader(c.in), &out, &errOut); got != c.code || !strings.Contains(errOut.String(), c.says) {
t.Errorf("%s: exit %d saying %q, want %d saying %q", c.name, got, errOut.String(), c.code, c.says)
}
}
}
// ownPackages is every package of this repository the command imports, directly or not, with the packages each
// imports: what this repository adds to the composition; mesh-sdk's packages are judged in mesh-sdk.
func ownPackages(t *testing.T) map[string][]string {
t.Helper()
_, file, _, _ := runtime.Caller(0)
root := filepath.Join(filepath.Dir(file), "..", "..")
const module = "github.com/novox/mesh-host"
out := map[string][]string{}
var walk func(path string)
walk = func(path string) {
if _, done := out[path]; done {
return
}
rel := strings.TrimPrefix(strings.TrimPrefix(path, module), "/")
pkg, err := build.Default.ImportDir(filepath.Join(root, rel), 0)
if err != nil {
t.Fatalf("%s: %v", path, err)
}
out[path] = pkg.Imports
for _, imp := range pkg.Imports {
if strings.HasPrefix(imp, module+"/") {
walk(imp)
}
}
}
walk(composer.Package)
return out
}
// 1621/AC2 and AC4: the command imports, through every package of this repository it reaches, only the standard
// library, mesh-sdk and this repository's own packages, and none that reaches a bus, a store, a network service or
// another process.
func TestTheCommandImportsNoBusStoreNetworkOrProcess(t *testing.T) {
forbidden := []string{"net/http", "net/rpc", "net/smtp", "os/exec", "database/sql", "plugin", "syscall"}
own := ownPackages(t)
if len(own) < 4 {
t.Fatalf("the command reaches %d packages of this repository; the walk found too few to judge", len(own))
}
for path, imports := range own {
for _, imp := range imports {
std := !strings.Contains(strings.SplitN(imp, "/", 2)[0], ".")
if !std && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-sdk/go") && !strings.HasPrefix(imp, "github.com/novox/mesh-host/") {
t.Errorf("%s imports %s, which is neither the standard library, mesh-sdk nor this repository", path, imp)
}
for _, f := range forbidden {
if imp == f {
t.Errorf("%s imports %s", path, imp)
}
}
}
}
}
// 1621/AC2: run with an empty network namespace, the command still writes its verdict: it needs no network. The test
// runs its own binary again under `unshare --net` as an unprivileged user's namespace; where the kernel or the sandbox
// allows no new namespace (a build module's run step is already one with no network), it says so and skips.
func TestTheCommandRunsWithNoNetwork(t *testing.T) {
if os.Getenv("MESH_COMPOSE_HELPER") == "1" {
os.Exit(run(nil, os.Stdin, os.Stdout, os.Stderr))
}
unshare, err := exec.LookPath("unshare")
if err != nil {
t.Skip("no unshare on this machine, so no empty network namespace can be made here")
}
if out, err := exec.Command(unshare, "--net", "--map-root-user", "true").CombinedOutput(); err != nil {
t.Skipf("this machine makes no unprivileged network namespace (%v: %s); a run step's own has no network", err, out)
}
r := request(t, composer.DepthResolve)
cmd := exec.Command(unshare, "--net", "--map-root-user", os.Args[0], "-test.run=^TestTheCommandRunsWithNoNetwork$")
cmd.Env = []string{"MESH_COMPOSE_HELPER=1"}
cmd.Stdin = bytes.NewReader(encode(t, r))
var out, errOut bytes.Buffer
cmd.Stdout, cmd.Stderr = &out, &errOut
if err := cmd.Run(); err != nil {
t.Fatalf("the command in an empty network namespace: %v: %s", err, errOut.String())
}
var v composer.Verdict
if err := json.Unmarshal(out.Bytes(), &v); err != nil || len(v.Cases) != 1 || len(v.Cases[0].Machines) != 3 {
t.Fatalf("no verdict from the command with no network: %v\n%s", err, out.String())
}
names := make([]string, 0, len(v.Cases[0].Machines))
for n := range v.Cases[0].Machines {
names = append(names, n)
}
sort.Strings(names)
t.Logf("composed with no network: %v", names)
}
+1
View File
@@ -0,0 +1 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
+30
View File
@@ -3,6 +3,7 @@ package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"os"
@@ -15,6 +16,7 @@ import (
stdio "git.novox.be/novox/mesh-sdk/go"
meshasker "git.novox.be/novox/mesh-sdk/go/asker"
"git.novox.be/novox/mesh-sdk/go/asks"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/accounts"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/link"
@@ -359,3 +361,31 @@ func packagesFrom(node string, session *link.Session, composer *atomic.Pointer[s
return v, nil
}
}
// composeCandidateFrom answers the verb compose-candidate (novox/hq ADR 0593 §7): the check's candidate composed on this
// node in shadow by the run's shadow, applying and writing nothing, within delivery.ComposeAnswerBound. The answer is
// the seat's shape (delivery.ComposeCandidateAnswer), checked as it is encoded; a node whose shadow does not run yet
// answers unready, which the checker takes as no answer.
func composeCandidateFrom(node string, shadow *atomic.Pointer[selfcompose.Shadow]) func(candidate string, input bool) (any, error) {
return func(candidate string, input bool) (any, error) {
ask := delivery.ComposeCandidateAsk{Candidate: candidate, Input: input}
if _, err := delivery.EncodeComposeCandidateAsk(ask); err != nil {
return nil, err
}
var a delivery.ComposeCandidateAnswer
if s := shadow.Load(); s == nil {
// Unready, never an error the checker reads words of: its agent is starting (ADR 0593 §9).
a = delivery.ComposeCandidateAnswer{Node: node, Candidate: candidate, Verdict: delivery.ComposeCandidateUnready,
Refused: "this node's shadow does not run now (its node agent is starting), so it composes no candidate"}
} else {
ctx, cancel := context.WithTimeout(context.Background(), delivery.ComposeAnswerBound)
defer cancel()
a = s.ComposeCandidate(ctx, ask)
}
raw, err := delivery.EncodeComposeCandidateAnswer(a)
if err != nil {
return nil, fmt.Errorf("this node's answer to %s does not fit the seat's shape: %w", candidate, err)
}
return json.RawMessage(raw), nil
}
}
+1
View File
@@ -1001,6 +1001,7 @@ func runLink(ctx context.Context, opts options) error {
queue.Acts.Composition = compositionFrom(current)
queue.Acts.Compose = composeFrom(current, &composerNow)
queue.Acts.Packages = packagesFrom(mine.Node, queue, &composerNow)
queue.Acts.ComposeCandidate = composeCandidateFrom(mine.Node, &shadowNow)
setRunsHere(runsFromAgent(current))
factsOfNow := func(ctx context.Context, ag *agent.Agent) {
sayFacts(ctx, opts, mine.Membership.Signer, queue, say, ag)
+10 -1
View File
@@ -14,12 +14,12 @@ import (
"sync/atomic"
"time"
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/overlay"
"github.com/novox/mesh-host/internal/agent"
"github.com/novox/mesh-host/internal/apply"
"github.com/novox/mesh-host/internal/compose/frombus"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/liveness"
"github.com/novox/mesh-host/internal/selfcompose"
@@ -623,6 +623,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
session.NoteCandidates(ctx, delivery.WithValuesDiffer(answers))
})
go shadow.Keep(run, link.Backoff) // this run's alone: a switch begins a run, and its shadow, again
shadowNow.Store(shadow) // what the verb compose-candidate composes a check's candidate with
// Its own composer, always (issue 1126, A7): a composition made elsewhere (the controller's, in the node's
// composition bucket) is never applied. While the seat's placement holds no key for this node it composes
// nothing and keeps running what it runs; a placement it cannot watch stops the agent, said, and it begins again.
@@ -640,6 +641,7 @@ func nodeAgent(ctx context.Context, opts options, signer ed25519.PublicKey, trus
cancel()
current.set(nil)
composerNow.Store(nil)
shadowNow.Store(nil)
if ctx.Err() != nil {
return
}
@@ -766,6 +768,10 @@ var machinesNow atomic.Pointer[selfcompose.Composer]
// compositions or no run is going: what the verb compose asks a pass of (issue 774).
var composerNow atomic.Pointer[selfcompose.Composer]
// shadowNow is the shadow of the run now, which answers placement candidates and the verb compose-candidate; nil
// between runs.
var shadowNow atomic.Pointer[selfcompose.Shadow]
// keptRetired says the kept declaration is read no more: every module the seat's placement puts on this node has a have of
// its own (placedAllHave), so what runs is the agent's alone (ADR 0550 §2, design 47's L3 s9).
var keptRetired atomic.Bool
@@ -968,6 +974,9 @@ func selfComposer(session *link.Session, node string, hooks composerHooks, say f
// And the packages its compositions declare in its own key of the state packages, which pacman reads (novox/hq ADR
// 0565 §6).
env.Packages = link.EnginePackages{Session: session}
// And every composition it makes in its composition state composed-<node>, a record the provisioner, the assigner
// and the view read and nothing applies (novox/hq ADR 0593 §6).
env.ComposedState = link.EngineComposed{Session: session, Node: node}
return selfcompose.New(env)
}
+99 -52
View File
@@ -19,6 +19,7 @@ import (
"github.com/nats-io/nats.go"
"github.com/novox/mesh-host/internal/agent"
"github.com/novox/mesh-host/internal/link"
"github.com/novox/mesh-host/internal/nodegrant"
"github.com/novox/mesh-host/internal/selfcompose"
)
@@ -32,7 +33,7 @@ type itBed struct {
t *testing.T
conn *nats.Conn
js nats.JetStreamContext
// nodeConn and nodeJS are the node's own, under its real grant (buscompose.PermissionsFor, KindNode): the agent runs
// nodeConn and nodeJS are the node's own, under its real grant (the grants part's, nodegrant.For): the agent runs
// on them, so a watch or a read the grant does not allow fails here as it fails on the bus (issue 1126, 2026-10-27
// 09:21: a watch of every have key stopped every node agent). conn is the test's, unrestricted, for setting up.
nodeConn *nats.Conn
@@ -51,13 +52,9 @@ type itBed struct {
}
// aServer is an embedded bus with JetStream and two users: the test's, unrestricted, and the node's, granted exactly what
// buscompose.PermissionsFor gives a node (KindNode); it answers a connection of each.
func aServer(t *testing.T, principal buscompose.Principal, refused func(error)) (admin, node *nats.Conn) {
// the grants part gives a node (nodegrant.For); it answers a connection of each.
func aServer(t *testing.T, perms nodegrant.Permissions, refused func(error)) (admin, node *nats.Conn) {
t.Helper()
perms, err := buscompose.PermissionsFor(principal)
if err != nil {
t.Fatal(err)
}
granted := &server.Permissions{Publish: &server.SubjectPermission{Allow: perms.Publish, Deny: perms.PublishDeny},
Subscribe: &server.SubjectPermission{Allow: perms.Subscribe}}
if perms.AllowResponses {
@@ -88,11 +85,11 @@ func aServer(t *testing.T, principal buscompose.Principal, refused func(error))
func newItBed(t *testing.T) *itBed {
t.Helper()
return newItBedAs(t, buscompose.Principal{Kind: buscompose.KindNode, Node: itNode})
return newItBedAs(t, nodegrant.For(itNode))
}
// newItBedAs is newItBed with the node's connection under the grant of principal, a node's (KindNode).
func newItBedAs(t *testing.T, principal buscompose.Principal) *itBed {
// newItBedAs is newItBed with the node's connection under the grant perms, a node's.
func newItBedAs(t *testing.T, perms nodegrant.Permissions) *itBed {
t.Helper()
// Not skipped off Arch: a package manager that answers is put on PATH, so the apply of files runs on any build node.
bin := t.TempDir()
@@ -105,7 +102,7 @@ func newItBedAs(t *testing.T, principal buscompose.Principal) *itBed {
t.Cleanup(func() { builtFor = was })
b := &itBed{t: t, dir: t.TempDir()}
b.conn, b.nodeConn = aServer(t, principal, func(err error) {
b.conn, b.nodeConn = aServer(t, perms, func(err error) {
b.mu.Lock()
b.refusals = append(b.refusals, err.Error())
b.mu.Unlock()
@@ -166,28 +163,6 @@ func fpIt(c string) string { return delivery.SourcePrefix2 + strings.Repeat(c, 6
func fpDigest(c string) string { return "sha256:" + strings.Repeat(c, 64) }
// compose writes a module's signed composition of one file into the node's bucket and returns its digest.
func (b *itBed) compose(module, fp, content string, data bool, signer ed25519.PrivateKey) string {
b.t.Helper()
path := filepath.Join(b.dir, module+".conf")
res, _ := json.Marshal(map[string]any{"id": module + ".conf", "type": "file", "path": path, "content": content, "module": module})
c := delivery.Composed{Node: itNode, Module: module, FP: fp, Data: data, Resources: []json.RawMessage{res}}
signed, err := delivery.SignComposed(c, signer)
if err != nil {
b.t.Fatal(err)
}
raw, err := delivery.EncodeComposed(signed)
if err != nil {
b.t.Fatal(err)
}
dig, _ := delivery.ComposedDigestOf(signed)
key, _ := delivery.ComposedKey(itNode, module, fp)
if _, err := b.composed.Put(key, raw); err != nil {
b.t.Fatal(err)
}
return dig
}
func (b *itBed) want(module, fp, digest, backup string) {
b.t.Helper()
raw, err := delivery.EncodeWant(delivery.Want{FP: fp, Composed: digest, Backup: backup})
@@ -233,24 +208,10 @@ func within(t *testing.T, d time.Duration, what string, ok func() bool) {
// in the node's own key, and nothing is applied for it (ADR 0559 §4, subtask 10 of the milestone).
//
// And (issue 1126, ADR 0560) it reads, through its own session under the grant the grants part composes for it
// (buscompose.Users, so it carries ServedReads), the build of a placed module that declares one and the one config value
// (nodegrant.For with the served key, as the grants part grants it), the build of a placed module that declares one and the one config value
// a provider on another node serves from (a mail domain); under a grant without that served key the bus refuses the
// read and the node composes nothing.
func TestNatsANodeThePlacementNamesComposesAndAppliesItsOwn(t *testing.T) {
users, err := buscompose.Users(buscompose.Records{Nodes: []string{itNode, "it2"},
Assigned: map[string][]buscompose.Declared{"it2": {{Module: "mailer", Served: []string{"domain"}}}}})
if err != nil {
t.Fatal(err)
}
var principal buscompose.Principal
for _, u := range users {
if u.Kind == buscompose.KindNode && u.Node == itNode {
principal = u
}
}
if len(principal.ServedReads) != 1 {
t.Fatalf("the grants part's principal of %s reads %v of the served keys", itNode, principal.ServedReads)
}
t.Run("without the served key the bus refuses the read", func(t *testing.T) {
b := newItBed(t)
stop, _ := h8(t, b, false)
@@ -268,7 +229,7 @@ func TestNatsANodeThePlacementNamesComposesAndAppliesItsOwn(t *testing.T) {
t.Errorf("composed and applied under a grant without the served key: %q", b.file("web"))
}
})
b := newItBedAs(t, principal)
b := newItBedAs(t, nodegrant.For(itNode, "it2.mailer.domain"))
stop, st := h8(t, b, true)
defer stop()
placement, resolved, candidates := st.placement, st.resolved, st.candidates
@@ -524,10 +485,22 @@ func h8rest(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, hkey str
h := b.have("web")
return h.FP == fp && h.Composed != "" && h.Phase == delivery.PhaseRunning
})
if keys, _ := b.composed.Keys(); len(keys) != 0 {
t.Errorf("the controller's compositions hold %v; the node composed its own", keys)
}
// ADR 0593 §6: the node publishes what it composed in its composition state, under its own grant: web at the build
// its want names, with the digest its have names (the plan left out of it), and the node's own.
first := b.have("web").Composed
webKey, _ := delivery.ComposedKey(itNode, "web", fp)
within(t, 5*time.Second, "the node did not publish web's composition in its composition state", func() bool {
e, err := b.composed.Get(webKey)
if err != nil {
return false
}
c, err := delivery.DecodeComposed(e.Value())
if err != nil {
return false
}
d, err := delivery.ComposedDigestOf(c)
return err == nil && d == first
})
if _, err := resolved.Put(itNode+".web.greeting", []byte(`{"value":"salut","source":"module"}`)); err != nil {
t.Fatal(err)
}
@@ -568,4 +541,78 @@ func h8rest(t *testing.T, b *itBed, resolved, candidates nats.KeyValue, hkey str
if h := b.have("web"); h.Composed != second || b.file("web") != "greeting=salut\n" {
t.Errorf("a candidate moved what runs: %+v, %q", h, b.file("web"))
}
h8candidate(t, b, fp, second)
}
// h8candidate is ADR 0593 §7: a check's candidate is composed on the node through its seat's verb compose-candidate,
// under its own grant: the indexer's candidate changing web's manifest is answered composed, web changed at the
// candidate's build, within the answer bound; nothing is applied and the composition state does not move.
func h8candidate(t *testing.T, b *itBed, fp, running string) {
t.Helper()
kvOf := func(bucket string) nats.KeyValue {
kv, err := b.js.CreateKeyValue(&nats.KeyValueConfig{Bucket: bucket, History: 5})
if err != nil {
t.Fatal(err)
}
return kv
}
cands, _ := kvOf(delivery.CandidatesBucket), kvOf(delivery.VerdictsBucket)
manifests, err := b.js.KeyValue(delivery.ManifestsBucket)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(b.dir, "web.conf")
man := `{"module":"web","version":"2","config":{"greeting":{"type":"string","description":"what the file says","default":"hello"}},` +
`"resources":[{"id":"conf","type":"file","path":"` + path + `","mode":"0640","content":"greeting=${config:greeting}\n"},` +
`{"id":"tools","type":"package","package":"web-tools"}]}`
digest := delivery.ManifestDigest([]byte(man))
mraw, _ := delivery.EncodeManifest(delivery.Manifest{Module: "web", JSON: man})
mkey, _ := delivery.ManifestsKey(digest)
if _, err := manifests.Put(mkey, mraw); err != nil {
t.Fatal(err)
}
head := delivery.Head{Repository: "novox/web", Number: 3, Head: strings.Repeat("3", 40)}
heads, _ := delivery.HeadsHash([]delivery.Head{head})
pulls := []delivery.CandidatePull{{Head: head, Modules: []string{"web"},
Fps: map[string]delivery.CandidateModule{"web": {FP2: fpIt("c"), Manifest: digest}}}}
craw, err := delivery.EncodeCandidate(delivery.Candidate{Group: "novox/web#3", HeadsHash: heads, Pulls: pulls,
FPSet: delivery.FingerprintSet(pulls)})
if err != nil {
t.Fatal(err)
}
key, _ := delivery.CompositionKey("novox/web#3", heads)
if _, err := cands.Put(key, craw); err != nil {
t.Fatal(err)
}
before, _ := b.composed.Status()
got, err := composeCandidateFrom(itNode, &shadowNow)(key, false)
if err != nil {
t.Fatal(err)
}
raw, _ := got.(json.RawMessage)
a, err := delivery.DecodeComposeCandidateAnswer(raw)
if err != nil {
t.Fatalf("the answer %s: %v", raw, err)
}
if a.Verdict != delivery.ComposeCandidateComposed || len(a.Modules) != 1 || a.Modules[0].Module != "web" ||
a.Modules[0].FP != fpIt("c") || !a.Modules[0].Changed || a.Took > delivery.ComposeAnswerBound.Milliseconds() {
t.Errorf("the node's answer to a candidate changing web: %+v", a)
}
after, _ := b.composed.Status()
if h := b.have("web"); h.Composed != running || b.file("web") != "greeting=salut\n" || before == nil || after == nil ||
after.Values() != before.Values() {
t.Errorf("composing a candidate moved what runs or the composition state: %+v, %q", h, b.file("web"))
}
if _, err := b.composed.Get(mustComposedKey(t, "web", fpIt("c"))); err == nil {
t.Error("the candidate's composition was written into the composition state")
}
}
func mustComposedKey(t *testing.T, module, fp string) string {
t.Helper()
key, err := delivery.ComposedKey(itNode, module, fp)
if err != nil {
t.Fatal(err)
}
return key
}
+5
View File
@@ -22,6 +22,11 @@ func TestNoPackageHearsOrOrdersAWholeDeclaration(t *testing.T) {
if err != nil {
return err
}
// The composition moved in from mesh-sdk (novox/hq ADR 0593 §7) has a node's input field of the same name,
// ReadsModules, which says the node reads a module per resource; it is no reconciling of a declaration.
if d.IsDir() && path == filepath.Join(root, "internal", "compose") {
return filepath.SkipDir
}
if d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "nodeclare_test.go") {
return nil
}
+1 -1
View File
@@ -3,7 +3,7 @@ module github.com/novox/mesh-host
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
golang.org/x/crypto v0.57.0
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791 h1:7SYIDPMxbPBr8dMB6k9fYsgVt4ZyWVL41q4LMFsfAvs=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102112556-abf5eace7791/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128 h1:aI1DhZAuqVBXbybSqhLNOGX5rUOrn9T0cHNCDUQF7IQ=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261102191058-f02f142af128/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+8 -24
View File
@@ -15,7 +15,6 @@ package agent
import (
"bytes"
"context"
"crypto/ed25519"
"encoding/json"
"fmt"
"sort"
@@ -26,6 +25,7 @@ import (
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/judge"
)
// Update is one change of a key in a watched bucket. Caught marks the end of the keys that were there when the watch
@@ -119,10 +119,6 @@ type Env struct {
// whose have names a composition the bucket keeps runs from that composition, never from the kept declaration
// (issue 924): the kept declaration is older than every composition applied since the switch.
KeptFilled func() map[string][]string
// Trusted is the public keys a composition may be signed by: the controller's and the operator's (ADR 0539 §3). A
// composition unsigned, signed by another key, or changed after it was signed is refused and nothing of it is
// applied. Nil trusts no key, so nothing is applied.
Trusted func() []ed25519.PublicKey
// Recorded says the engine holds a record of every resource of the composition, so it was applied and not removed
// since; nil says yes. A composition the kept declaration or a have says runs is applied again when it says no.
Recorded func(ctx context.Context, c delivery.Composed) bool
@@ -685,26 +681,14 @@ func (a *Agent) takeComposition(u Update) string {
}
now := compNow{Raw: u.Value, Rev: u.Revision}
c, err := delivery.DecodeComposed(u.Value)
if err == nil && a.env.SelfComposed {
// The node composed it itself: nothing crossed the bus, so nothing is signed (ADR 0547 §2).
if err == nil {
// The node composed it itself: it is applied as composed, never verified against a signer, since no composition
// read from the bus is applied (novox/hq ADR 0547 §2, ADR 0593 §6).
now.C = c
now.Dig, err = delivery.ComposedDigestOf(c)
if err != nil {
now.C = delivery.Composed{}
}
} else if err == nil {
var trusted []ed25519.PublicKey
if a.env.Trusted != nil {
trusted = a.env.Trusted()
}
// The signature before anything of the composition is used: its digest is what the want names.
now.C = c
now.Dig, err = delivery.VerifyComposed(c, trusted)
if err != nil {
now.C = delivery.Composed{}
a.sayOnce("verify:"+u.Key, fmt.Sprintf("the composition %s is refused and nothing of it is applied: %v", u.Key, err))
err = fmt.Errorf("refused: %w", err)
}
} else {
a.sayOnce("decode:"+u.Key, fmt.Sprintf("the composition %s cannot be read, and nothing of it is applied: %v", u.Key, err))
}
@@ -1646,14 +1630,14 @@ func (a *Agent) waitsForYes(ctx context.Context, module string, w wantNow, comp
prior = true
}
}
var j delivery.Judged
var j judge.Judged
switch {
case known:
j = delivery.JudgeMove(&from, comp.C)
j = judge.JudgeMove(&from, comp.C)
case prior:
j = delivery.Judged{Judgement: delivery.JudgedCannot, Words: "what " + module + " runs on this node cannot be read"}
j = judge.Judged{Judgement: delivery.JudgedCannot, Words: "what " + module + " runs on this node cannot be read"}
default:
j = delivery.JudgeMove(nil, comp.C)
j = judge.JudgeMove(nil, comp.C)
}
switch j.Judgement {
case delivery.JudgedRecreates, delivery.JudgedMovesData, delivery.JudgedCannot:
+25 -102
View File
@@ -2,7 +2,6 @@ package agent
import (
"context"
"crypto/ed25519"
"encoding/json"
"fmt"
"maps"
@@ -18,19 +17,6 @@ import (
const node = "n1"
// The keys of the tests: the controller's and the operator's are trusted, a stranger's is not.
var controllerPub, controllerKey = newKey()
var operatorPub, operatorKey = newKey()
var _, strangerKey = newKey()
func newKey() (ed25519.PublicKey, ed25519.PrivateKey) {
pub, key, err := ed25519.GenerateKey(nil)
if err != nil {
panic(err)
}
return pub, key
}
func fpOf(c string) string { return delivery.SourcePrefix2 + strings.Repeat(c, 64) }
// A watcher the test writes to; it says Caught once the keys written before the watch began are out.
@@ -183,8 +169,7 @@ func newBed(t *testing.T) *bed {
b.settled = append(b.settled, m)
b.settledRan = append(b.settledRan, ran)
},
Kept: func() map[string][]json.RawMessage { return b.kept },
Trusted: func() []ed25519.PublicKey { return []ed25519.PublicKey{controllerPub, operatorPub} },
Kept: func() map[string][]json.RawMessage { return b.kept },
Say: func(s string) {
t.Log(s)
b.mu.Lock()
@@ -223,21 +208,17 @@ func composition(t *testing.T, module, fp string, data bool, rs ...json.RawMessa
if err != nil {
t.Fatal(err)
}
return signedBy(t, c, key, controllerKey)
return encodedAs(t, c, key)
}
// signedBy is c signed with signer, as the writer's code signs it: its key, its bytes and its digest.
func signedBy(t *testing.T, c delivery.Composed, key string, signer ed25519.PrivateKey) (string, []byte, string) {
// encodedAs is c as the node's composer hands it: unsigned (ADR 0593 §6), its key, its bytes and its digest.
func encodedAs(t *testing.T, c delivery.Composed, key string) (string, []byte, string) {
t.Helper()
signed, err := delivery.SignComposed(c, signer)
raw, err := delivery.EncodeComposed(c)
if err != nil {
t.Fatal(err)
}
raw, err := delivery.EncodeComposed(signed)
if err != nil {
t.Fatal(err)
}
digest, err := delivery.ComposedDigestOf(signed)
digest, err := delivery.ComposedDigestOf(c)
if err != nil {
t.Fatal(err)
}
@@ -735,76 +716,6 @@ func TestTheNodesOwnCompositionComesBeforeAnyModules(t *testing.T) {
}
}
// R6-A AC9: a composition signed by either trusted key is applied; one signed by another key, one changed after it was
// signed and one unsigned are refused and nothing of them is applied (ADR 0539 §3).
func TestOnlyACompositionSignedByATrustedKeyIsApplied(t *testing.T) {
t.Run("with no trusted key", testWithNoTrustedKeyNothingIsApplied)
b := newBed(t)
stop := b.run()
defer stop()
plain := func(module, fp, content string) delivery.Composed {
return delivery.Composed{Node: node, Module: module, FP: fp, Resources: []json.RawMessage{file(module, "x", content)}}
}
put := func(c delivery.Composed, signer ed25519.PrivateKey, tamper func([]byte) []byte) {
key, _ := delivery.ComposedKey(node, c.Module, c.FP)
var raw []byte
var dig string
if signer == nil {
raw, _ = delivery.EncodeComposed(c)
dig, _ = delivery.ComposedDigestOf(c)
} else {
_, raw, dig = signedBy(t, c, key, signer)
}
if tamper != nil {
raw = tamper(raw)
}
b.comps.put(key, raw)
wk, wraw := wantFor(t, c.Module, c.FP, dig, "")
b.wants.put(wk, wraw)
}
// Refused: signed by a stranger, changed after signing, unsigned.
put(plain("stranger", fpOf("1"), "x"), strangerKey, nil)
put(plain("changed", fpOf("2"), "before"), controllerKey, func(raw []byte) []byte {
return []byte(strings.Replace(string(raw), "before", "after!", 1))
})
put(plain("unsigned", fpOf("3"), "x"), nil, nil)
time.Sleep(200 * time.Millisecond)
if got := b.appliedList(); len(got) != 0 {
t.Fatalf("a composition that is not signed by a trusted key was applied: %v", got)
}
// Applied: signed by the controller, signed by the operator.
put(plain("bycontroller", fpOf("4"), "x"), controllerKey, nil)
put(plain("byoperator", fpOf("5"), "x"), operatorKey, nil)
eventually(t, "the trusted signers' compositions were not applied", func() bool { return len(b.appliedList()) == 2 })
got := b.appliedList()
if !(got[0] == "bycontroller" && got[1] == "byoperator") && !(got[0] == "byoperator" && got[1] == "bycontroller") {
t.Fatalf("applied %v", got)
}
if h := b.haves.last("byoperator"); h.Phase != string(delivery.PhaseRunning) || h.FP != fpOf("5") {
t.Fatalf("the operator's module has no running have: %+v", h)
}
}
// With no key trusted, nothing is applied: a node that does not know its signers fails closed.
func testWithNoTrustedKeyNothingIsApplied(t *testing.T) {
b := newBed(t)
b.env.Trusted = nil
stop := b.run()
defer stop()
fp := fpOf("a")
ck, craw, dig := composition(t, "web", fp, false, file("web", "x", "1"))
b.comps.put(ck, craw)
wk, wraw := wantFor(t, "web", fp, dig, "")
b.wants.put(wk, wraw)
time.Sleep(200 * time.Millisecond)
if got := b.appliedList(); len(got) != 0 {
t.Fatalf("applied with no trusted key: %v", got)
}
}
// What a module's apply must not take is every other module that runs, those the agent holds and those still running from
// the kept declaration (ADR 0429 §4): the resources of the kept declaration reach Apply as the others.
func TestApplyingOneModuleHoldsOutTheModulesStillRunningFromTheKeptDeclaration(t *testing.T) {
@@ -860,7 +771,7 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) {
// and is what runs from before the first composition is watched; an apply keeps, a removal forgets.
t.Run("what this engine applied is what runs before the node composes its own", func(t *testing.T) {
b := newBed(t)
b.env.SelfComposed, b.env.Trusted, b.kept = true, nil, nil
b.env.SelfComposed, b.kept = true, nil
store := &memApplied{kept: map[string][]byte{}}
b.env.Applied = store
// A folded module's resource in the node's own composition: its id names the module, as the controller folds it.
@@ -930,7 +841,7 @@ func TestEverythingIsWhatTheAgentRunsAndWhatIsKeptForTheRest(t *testing.T) {
h.Write(context.Background(), Have{Module: NodeModule, Composed: "sha256:" + strings.Repeat("c", 64)})
firstPass := func(kept map[string][]json.RawMessage) []delivery.Composed {
b := newBed(t)
b.env.SelfComposed, b.env.Trusted, b.kept, b.env.Applied, b.env.Haves = true, nil, kept, store, h
b.env.SelfComposed, b.kept, b.env.Applied, b.env.Haves = true, kept, store, h
firstCh := make(chan []delivery.Composed, 1)
comps := b.comps
var ag *Agent
@@ -1155,8 +1066,7 @@ func testARemovedModulesCompositionHoldsNothingBack(t *testing.T) {
}
}
// The node's own composition is not applied while a module's composition in the node's bucket cannot be read or is not
// signed by a trusted key: what that one carries may be what left the node's own, and the node's own apply would remove
// The node's own composition is not applied while a module's composition in the node's bucket cannot be read: what that one carries may be what left the node's own, and the node's own apply would remove
// it (issue 1945, the review's gap 2). It is said, naming the composition, and applied once that one reads.
func TestTheNodesOwnWaitsWhileAModulesCompositionCannotBeRead(t *testing.T) {
b := newBed(t)
@@ -1284,11 +1194,11 @@ func TestWhatRunsBeforeTheAgentTakesAModuleNamesItsFilledPaths(t *testing.T) {
ck, _ := delivery.ComposedKey(node, "power", fp)
applied := delivery.Composed{Node: node, Module: "power", FP: fp, Filled: []string{old, added}, Data: true,
Resources: []json.RawMessage{filledFile("settings"), filledFile("bound")}}
_, appliedRaw, appliedDig := signedBy(t, applied, ck, controllerKey)
_, appliedRaw, appliedDig := encodedAs(t, applied, ck)
newer := applied
newer.Resources = append([]json.RawMessage{}, applied.Resources...)
newer.Resources = append(newer.Resources, file("power", "later", "1"))
_, newerRaw, _ := signedBy(t, newer, ck, controllerKey)
_, newerRaw, _ := encodedAs(t, newer, ck)
for _, tc := range []struct {
name string
// have is the module's have; bucket the value its composition key holds now, history the revisions kept.
@@ -1371,7 +1281,7 @@ func TestWhatRunsBeforeTheAgentTakesAModuleNamesItsFilledPaths(t *testing.T) {
// the yes for that composition, and is then applied.
func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(t *testing.T) {
b := newBed(t)
b.env.SelfComposed, b.env.Trusted = true, nil
b.env.SelfComposed = true
b.env.Policy = func(_ context.Context, m string) string {
if m == "db" || m == "rec" {
return catalogue.PolicyRecord
@@ -1560,6 +1470,19 @@ func TestTheNodesOwnCompositionIsAppliedWhenItDiffersAndARecordMoveWaitsForAYes(
if _, err := b.ag.Composition(context.Background(), "db", "sha256:"+strings.Repeat("0", 64), false); err == nil {
t.Error("a digest no revision has was answered")
}
// novox/hq ADR 0593 §6: the engine publishes each composition with its plan in its composition state; the verb reads
// that revision by the digest its have and want name, its resources' (the plan left out of the digest).
planned, _ := delivery.DecodeComposed(draw)
planned.Plan = &catalogue.Plan{}
praw, err := delivery.EncodeComposed(planned)
if err != nil {
t.Fatal(err)
}
keep(dk, praw)
if view, err := b.ag.Composition(context.Background(), "db", ddig, false); err != nil || view.Composition.Digest != ddig ||
len(view.Revisions) != 3 || view.Revisions[2].Digest != ddig {
t.Errorf("db's published revision with its plan, asked by its digest, reads %+v (%v)", view, err)
}
b.comps.put(dk, draw)
eventually(t, "db's have stays waiting-yes after its composition went back to what runs", func() bool {
h := b.haves.last("db")
+7 -16
View File
@@ -2,12 +2,12 @@ package agent
import (
"context"
"crypto/ed25519"
"errors"
"fmt"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/judge"
)
// The node-engine seat's read verb composition (issue 774): one module's composition beside what runs of it, so why a
@@ -31,7 +31,7 @@ type CompositionView struct {
// Judged is the move from what runs to the composition read, as the agent judges it before an apply.
Judged JudgedView `json:"judged"`
// Diff is every resource that differs from what runs, when asked.
Diff []delivery.ResourceChange `json:"diff,omitempty"`
Diff []judge.ResourceChange `json:"diff,omitempty"`
}
// WantView is a want as the verb shows it.
@@ -171,7 +171,7 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo
}
view.Runs = rv
}
j := delivery.JudgeMove(running, read.C)
j := judge.JudgeMove(running, read.C)
view.Judged = JudgedView{Judgement: string(j.Judgement), Words: j.Words}
if a.env.SelfComposed && a.env.Policy != nil && module != NodeModule && (!w.Present || w.Want.Yes != read.Dig) {
switch j.Judgement {
@@ -180,7 +180,7 @@ func (a *Agent) Composition(ctx context.Context, module, digest string, diff boo
}
}
if diff {
view.Diff = delivery.ChangedResources(running, read.C)
view.Diff = judge.ChangedResources(running, read.C)
}
return view, nil
}
@@ -193,8 +193,8 @@ func (a *Agent) compositionKey(module, fp string) (string, error) {
return delivery.ComposedKey(a.env.Node, module, fp)
}
// revisionsOf is every revision of a composition key the bucket keeps, oldest first, each with its digest as this node
// trusts it (its own unsigned with SelfComposed, else signed by a trusted key), or why it is refused. Nil Revisions
// revisionsOf is every revision of a composition key the state keeps, oldest first, each with its digest (unsigned, ADR
// 0593 §6), or why it is refused. Nil Revisions
// reads none.
func (a *Agent) revisionsOf(ctx context.Context, key string) ([]compNow, error) {
if a.env.Revisions == nil {
@@ -204,10 +204,6 @@ func (a *Agent) revisionsOf(ctx context.Context, key string) ([]compNow, error)
if err != nil {
return nil, err
}
var trusted []ed25519.PublicKey
if a.env.Trusted != nil {
trusted = a.env.Trusted()
}
var out []compNow
for _, u := range revs {
if u.Deleted || u.Key != key {
@@ -218,12 +214,7 @@ func (a *Agent) revisionsOf(ctx context.Context, key string) ([]compNow, error)
out = append(out, compNow{Raw: u.Value, Rev: u.Revision, Err: err})
continue
}
var dig string
if a.env.SelfComposed {
dig, err = delivery.ComposedDigestOf(c) // the node's own: unsigned (ADR 0547 §2)
} else {
dig, err = delivery.VerifyComposed(c, trusted)
}
dig, err := delivery.ComposedDigestOf(c) // unsigned: a record, never verified (ADR 0593 §6)
out = append(out, compNow{Raw: u.Value, C: c, Dig: dig, Rev: u.Revision, Err: err})
}
return out, nil
+134
View File
@@ -0,0 +1,134 @@
// Package beside is where a test finds another repository of the mesh it reads: the catalogue's manifests,
// the node-engine's genesis template (novox/hq issue 432), the SDK's conformance fixtures (issue 449).
//
// Copied from mesh-controller internal/beside at commit 6c2a8d62 (R4b, issue 1379); the one change is that Dir
// lays out the captured copy of a repository once per process, not once per test (see laidOut). In mesh-sdk the
// catalogue's tests read mesh-catalog and mesh-host only; the captured copy holds just what they read.
//
// A test used to read the checkout beside this one, `../../../mesh-catalog`, so its verdict depended on
// whatever sat on the machine running it: a stale or dirty checkout failed it on a desktop, and where none
// was beside it skipped and said nothing. Now there are two inputs, both named, and no third:
//
// - In a merge check, the build seat clones each core repository beside the one checked (the catalogue
// at its main, the node-engine at the commit the mesh runs) and says where in MESH_CHECK_BESIDE. A
// test judges against those clones, so agreement with the other repository is checked where
// `mesh/repo-check` runs; a repository missing there fails the test, never skips it. Which clones a
// check gets is chosen from the inventory: mesh-catalog by the source of the `nats` module, mesh-host
// by the source of `mesh-host`, and mesh-sdk as the controller's sibling at the commit the controller's
// go.mod pins. Each repository's tests read these siblings, and the checker places exactly these
// (mesh-delivery's checker, besideRules, follows this list): mesh-controller's read mesh-catalog,
// mesh-host and mesh-sdk; mesh-host's read mesh-catalog, mesh-sdk and mesh-controller (its step-nine
// test reads the controller's own manifest, novox/hq issue 1295); mesh-sdk's read mesh-catalog and
// mesh-host; mesh-catalog's read mesh-sdk and mesh-controller; mesh-delivery's read mesh-sdk and
// mesh-catalog. In a mesh where either module has no source repository nothing is
// cloned, and these tests fail loudly with "not beside this check": a cause in the setup, not in the
// change. And in a delivery group that holds a mesh-catalog pull request, these tests read the
// catalogue's main, not the group's head.
// - Anywhere else, the test judges against the copy captured in this repository's testdata/beside, at the
// commit testdata/beside/CAPTURED names. Never against a developer's own checkout: to judge one, set
// MESH_CHECK_BESIDE to the directory holding it, as the check does.
//
// The captured manifests are named module.json.captured, and put back as module.json in a directory of
// the test's own: a module.json anywhere in this repository is a module of it to the forge's announcer and
// the planner, and a merge would build and register a hundred copies of the catalogue's.
package beside
import (
"io/fs"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
)
// Env is where a merge check says the repositories cloned beside the one checked are (internal/builder's
// EnvBeside, repeated here so a test does not import the builder).
const Env = "MESH_CHECK_BESIDE"
// CapturedSuffix is what a captured file's name carries that the repository's own does not.
const CapturedSuffix = ".captured"
// Dir is the named repository a test reads — the clone beside a merge check, or the captured copy — and
// says which in the test's log.
func Dir(t testing.TB, repository string) string {
t.Helper()
if root := os.Getenv(Env); root != "" {
dir := filepath.Join(root, repository)
if _, err := os.Stat(dir); err != nil {
t.Fatalf("%s is not beside this check in %s=%s, so its agreement with this repository cannot be "+
"judged here: %v", repository, Env, root, err)
}
t.Logf("judged against %s as cloned beside this check", dir)
return dir
}
from := filepath.Join(Captured(), repository)
if _, err := os.Stat(from); err != nil {
t.Fatalf("no captured copy of %s at %s: %v", repository, from, err)
}
dir, err := layOut(repository, from)
if err != nil {
t.Fatalf("the captured copy of %s could not be laid out: %v", repository, err)
}
t.Logf("judged against the copy of %s captured in testdata/beside (see CAPTURED); a merge check "+
"judges it against the repository's own clone", repository)
return dir
}
// laidOut is the captured copies already put back in place in this process, by repository. A copy is
// read-only to every test, so it is laid out once per process and not once per test: with a copy per test
// the catalogue's tests alone took 2.14 s, over mesh-sdk's unit bound of 2 s, and with one 0.81 s (measured
// 2026-10-20). The directories are in the temp directory, which the system clears; a process cannot remove
// them while another test may still read them.
var (
laidOutMu sync.Mutex
laidOut = map[string]string{}
)
func layOut(repository, from string) (string, error) {
laidOutMu.Lock()
defer laidOutMu.Unlock()
if dir, ok := laidOut[repository]; ok {
return dir, nil
}
root, err := os.MkdirTemp("", "beside-")
if err != nil {
return "", err
}
dir := filepath.Join(root, repository)
err = filepath.WalkDir(from, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, _ := filepath.Rel(from, path)
into := filepath.Join(dir, strings.TrimSuffix(rel, CapturedSuffix))
if d.IsDir() {
return os.MkdirAll(into, 0o755)
}
raw, err := os.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(into, raw, 0o644)
})
if err != nil {
os.RemoveAll(root)
return "", err
}
laidOut[repository] = dir
return dir, nil
}
// Catalogue is the catalogue's modules directory, as Dir finds the catalogue.
func Catalogue(t testing.TB) string {
t.Helper()
return filepath.Join(Dir(t, "mesh-catalog"), "modules")
}
// Captured is this repository's testdata/beside, found from this file rather than from the working
// directory, so a test in any package reaches it.
func Captured() string {
_, file, _, _ := runtime.Caller(0)
return filepath.Join(filepath.Dir(file), "..", "..", "testdata", "beside")
}
+1
View File
@@ -0,0 +1 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
-32
View File
@@ -237,35 +237,3 @@ func TestAFileNothingOfTheControllerWritesIsStillRefused(t *testing.T) {
t.Fatalf("a file nothing writes was accepted, or refused without its name: %v", err)
}
}
// The controller's own manifest, as the mesh runs it, passes step 9 (issue 597): in a merge check the
// node-engine's check has the controller cloned beside it (MESH_CHECK_BESIDE), where it must be; else a
// checkout beside this one; else it skips aloud.
//
// The checker places the controller beside this repository (mesh-delivery's besideRules, since R4g, issue 1295), so a
// MESH_CHECK_BESIDE without a mesh-controller directory, or without its module.json, fails. Only a run with no
// MESH_CHECK_BESIDE and no checkout beside this one (a developer's clone alone) skips aloud.
func TestTheControllersOwnManifestPassesStepNine(t *testing.T) {
var manifest []byte
if root := os.Getenv("MESH_CHECK_BESIDE"); root != "" {
if info, err := os.Stat(filepath.Join(root, "mesh-controller")); err != nil || !info.IsDir() {
t.Fatalf("the controller is not beside this check in MESH_CHECK_BESIDE=%s: the checker places mesh-controller beside the node-engine's check (mesh-delivery besideRules, issue 1295), and this test judges the controller's own manifest", root)
}
raw, err := os.ReadFile(filepath.Join(root, "mesh-controller", "module.json"))
if err != nil {
t.Fatalf("the controller is not beside this check in MESH_CHECK_BESIDE=%s: %v", root, err)
}
manifest = raw
} else if raw, err := os.ReadFile(filepath.Join("..", "..", "..", "mesh-controller", "module.json")); err == nil {
manifest = raw
} else {
t.Skip("NOT JUDGED: no controller beside this checkout and no MESH_CHECK_BESIDE; the merge check judges it")
}
wanted, err := theStepNineSecretsOf(t, manifest)
if err != nil {
t.Fatalf("genesis's step 9 refuses the controller the mesh runs: %v", err)
}
if wanted["MESH_STORE_INVENTORY"] != "inventory" {
t.Errorf("step 9 delivers %v", wanted)
}
}
+560
View File
@@ -0,0 +1,560 @@
package compose
import (
"encoding/json"
"errors"
"fmt"
"slices"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/declaration"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/compose/resolution"
"github.com/novox/mesh-host/internal/overlay"
)
// Compositions is one node's compositions (novox/hq ADR 0539 clauses 1 and 2): its declaration composed over in, as
// the controller composes it, split into each module's composition at the fingerprint fps gives it and the node's own
// (`<node>.node`), by key (delivery.ComposedKey). Each resource says its module, whatever the node reported it reads.
// A module fps gives no fingerprint for is not composed and is named in skipped, so a caller composing one module
// gets that one; but for a module the node runs inside its own composition (Node.InOwn, and the
// private network's always), which fps names at no fingerprint of the new form: its resources go into the node's own,
// their module taken off and their ids still naming it, and its filled paths with them, as the controller's composed
// writer folds them (issue 1126). Each module's Data says whether its manifest declares data. The compositions are unsigned
// (ADR 0593 §6) and not yet by digest (Composed.ByDigest).
func Compositions(in Input, node string, fps map[string]string, mode Mode) (out map[string]delivery.Composed, skipped []string, err error) {
out, skipped, _, err = compositions(in, node, fps, mode, false)
return out, skipped, err
}
// ErrStoodIn is a composition that holds a stand-in for a value the input does not carry (an own secret, a grant's
// sealed copy, a vault box, the certificate, the bus's user list) or a port it does not give: what a send would make,
// never what may be applied.
var ErrStoodIn = errors.New("compose: the composition stands in for what its input does not carry")
// BusUsersStandIn is what a composition says it stood in for when its input carries no bus user list.
const BusUsersStandIn = "the bus's user list"
// CompositionsApplied is Compositions for a node that applies what it composed itself (novox/hq ADR 0547 §2, issue
// 1126): a declaration that stands in for anything (Declared.StoodIn, Foreseen, ForeseenPorts) is refused with
// ErrStoodIn, naming each, so a stand-in is never applied as if it were the value; but for the bus's user list
// (BusUsersStandIn), which the bus node holds out of every apply of a composition it made itself.
func CompositionsApplied(in Input, node string, fps map[string]string) (map[string]delivery.Composed, []string, error) {
out, skipped, _, err := compositions(in, node, fps, Reading, true)
return out, skipped, err
}
// CompositionsShadow is CompositionsApplied for a composition that is compared and never applied (a node's answer to a
// placement candidate, novox/hq ADR 0559 §4): composed in the Foreseeing mode, so an own secret never issued (a module
// the candidate adds) is a named stand-in rather than an error, and what it stands in for is answered (stood, sorted,
// the bus's user list left out) rather than refused, so the caller judges each stand-in: a value not yet issued for a
// module the candidate adds, or one missing for a module the node runs. A module whose every value is held composes
// as CompositionsApplied composes it: the modes differ only in an own secret never issued.
func CompositionsShadow(in Input, node string, fps map[string]string) (out map[string]delivery.Composed, skipped, stood []string, err error) {
return compositions(in, node, fps, Foreseeing, false)
}
// CompositionsIn is the node's compositions in mode with what they stand in for answered (stood, sorted, the bus's user
// list left out) rather than refused: CompositionsShadow in Foreseeing, and in Reading what CompositionsApplied refuses
// before it is refused, so a caller that applies refuses each stand-in to the module it is for alone (issue 1126).
func CompositionsIn(in Input, node string, fps map[string]string, mode Mode) (out map[string]delivery.Composed, skipped, stood []string, err error) {
return compositions(in, node, fps, mode, false)
}
func compositions(in Input, node string, fps map[string]string, mode Mode, applied bool) (out map[string]delivery.Composed, skipped, stood []string, err error) {
reading := in
reading.Nodes = append([]Node(nil), in.Nodes...)
found := false
inOwn := map[string]bool{}
held := map[string]bool{}
for i := range reading.Nodes {
if reading.Nodes[i].Name == node {
reading.Nodes[i].ReadsModules = true
found = true
for m, ok := range reading.Nodes[i].InOwn {
inOwn[m] = ok
}
for m, ok := range reading.Nodes[i].Held {
held[m] = ok
}
}
}
// The private network's module is built by nobody: no fingerprint of the new form ever names it.
inOwn[overlay.Name] = true
folds := func(module string) bool {
return module != "" && inOwn[module] && !strings.HasPrefix(fps[module], delivery.SourcePrefix2)
}
if !found {
return nil, nil, nil, fmt.Errorf("compose: the mesh has no node %q", node)
}
// A folded module built by a build of the old form (Node.RunsOwn) is left to the node: composed from what runs of it
// (Carried), so what it provides and requires still shapes the rest and what the others read of it (its published
// ports, the paths it owns) is what runs, and its resources are then the ones the node runs.
carried := map[string]bool{}
var runsOwn *delivery.Composed
for i := range reading.Nodes {
n := &reading.Nodes[i]
if n.Name != node || n.RunsOwn == nil {
continue
}
runsOwn = n.RunsOwn
candidates := map[string]bool{}
for m := range inOwn {
candidates[m] = true
}
for m := range held {
candidates[m] = true
}
for m := range candidates {
// A held module is carried as it runs wherever the node's own runs it, folded or leaving the fold for a
// composition of its own (its want new, its names still unset): its stub is never what the node's own applies,
// and what the node's own applies never drops what runs of it (issue 1126, review of the apply; issue 1945).
heldHere := held[m] && RunsAnyOf(runsOwn, m)
if !folds(m) && !heldHere {
continue
}
man, ok := n.Kept[m]
if !ok {
man, ok = reading.Catalogue[m]
}
if !ok || (!NamesBuild(man) && !held[m]) {
continue
}
carried[m] = true
kept := make(map[string]catalogue.Manifest, len(n.Kept)+1)
for k, v := range n.Kept {
kept[k] = v
}
kept[m] = Carried(man, m, runsOwn)
n.Kept = kept
}
}
// A module composed as what runs of it contributes to the runtime what the runtime serves of it as it runs
// (Node.RunsTools); one of its own account is served by a runtime of its own, from its manifest as before.
for i := range reading.Nodes {
n := &reading.Nodes[i]
if n.Name != node || n.RunsTools == nil {
continue
}
kept := make(map[string]catalogue.Manifest, len(n.Kept))
for m, man := range n.Kept {
if man.Stubbed && man.RunsAs == "" {
man.Bundles = runningBundles(n.RunsTools[m], reading.Catalogue[m])
}
kept[m] = man
}
n.Kept = kept
}
isCarried := func(module, id string) bool {
if module == "" {
module, _ = declaration.ModuleGuess(id)
}
return carried[module]
}
d, problems, err := ComposeIn(reading, node, mode)
if err != nil {
return nil, nil, nil, err
}
if len(problems) > 0 {
return nil, nil, nil, fmt.Errorf("compose: %s's declaration is refused by the node-engine's validator: %v", node, problems)
}
for _, x := range d.StoodIn {
// The bus's user list is the bus node's own to follow, written alone and never from a composition the node
// made itself (ADR 0547 §7, §8): its stand-in is held out of the apply, not applied.
if x != BusUsersStandIn {
stood = append(stood, x)
}
}
stood = append(stood, d.Foreseen...)
for _, p := range d.ForeseenPorts {
stood = append(stood, "port "+p)
}
sort.Strings(stood)
stood = slices.Compact(stood) // an own secret is both stood in for and foreseen
if applied && len(stood) > 0 {
return nil, nil, nil, fmt.Errorf("%w on %s: %s", ErrStoodIn, node, strings.Join(stood, ", "))
}
body, err := d.Body()
if err != nil {
return nil, nil, nil, err
}
var envelope struct {
Resources []json.RawMessage `json:"resources"`
Adoption *declaration.Adoption `json:"adoption"`
Filled []string `json:"filled_files"`
Provisioner string `json:"provisioner_account"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
return nil, nil, nil, err
}
var kept []json.RawMessage
keptPaths := map[string]bool{}
foldedPaths := map[string]bool{}
left := map[string]bool{}
for _, raw := range envelope.Resources {
var head struct {
ID string `json:"id"`
Module string `json:"module"`
Type string `json:"type"`
Path string `json:"path"`
}
if err := json.Unmarshal(raw, &head); err != nil {
return nil, nil, nil, err
}
if isCarried(head.Module, head.ID) {
continue // what the stub composed of it: the node's own carries what runs of it instead
}
if folds(head.Module) {
// Into the node's own, in its place among the node's resources, its module taken off (mesh-controller's
// compositionsOf): the node's own composition is then the controller's, byte for byte.
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil {
return nil, nil, nil, err
}
delete(fields, "module")
if raw, err = json.Marshal(fields); err != nil {
return nil, nil, nil, err
}
if head.Type == string(declaration.TypeFile) {
foldedPaths[head.Path] = true
}
kept = append(kept, raw)
continue
}
if _, ok := fps[head.Module]; head.Module != "" && !ok {
left[head.Module] = true
continue
}
if head.Type == string(declaration.TypeFile) {
keptPaths[head.Path] = true
}
kept = append(kept, raw)
}
// What runs of each carried module, in the node's own composition, each after the resource it runs after.
if len(carried) > 0 {
var err error
if kept, foldedPaths, err = carry(kept, foldedPaths, runsOwn, isCarried); err != nil {
return nil, nil, nil, err
}
for _, p := range runsOwn.Filled {
if foldedPaths[p] && !slices.Contains(envelope.Filled, p) {
envelope.Filled = append(envelope.Filled, p)
}
}
}
// A filled file of a module's own composition goes with that module (SplitComposed); one of a folded module goes with
// the node's own, after them, in the declaration's order; one of a module left out is dropped.
var filled, ownFilled []string
for _, p := range envelope.Filled {
switch {
case foldedPaths[p]:
ownFilled = append(ownFilled, p)
case keptPaths[p]:
filled = append(filled, p)
}
}
out, err = delivery.SplitComposed(node, kept, fps, filled,
delivery.NodeOwn{Adoption: envelope.Adoption, Provisioner: envelope.Provisioner})
if err != nil {
return nil, nil, nil, err
}
if len(ownFilled) > 0 {
own, err := delivery.ComposedNodeKey(node)
if err != nil {
return nil, nil, nil, err
}
c := out[own]
c.Filled = append(c.Filled, ownFilled...)
out[own] = c
}
for key, c := range out {
if held[c.Module] {
delete(out, key) // its stub's: the node keeps what it last applied of it (Node.Held)
continue
}
if m, ok := in.Catalogue[c.Module]; ok && c.Module != "" {
c.Data = len(m.DataItems()) > 0
out[key] = c
}
}
for m := range left {
skipped = append(skipped, m)
}
sort.Strings(skipped)
return out, skipped, stood, nil
}
// RunsAnyOf says runs (a composition as it runs) holds a resource of module, by its module or the module its id names.
func RunsAnyOf(runs *delivery.Composed, module string) bool {
if runs == nil {
return false
}
for _, raw := range runs.Resources {
var head struct {
ID string `json:"id"`
Module string `json:"module"`
}
if json.Unmarshal(raw, &head) != nil {
continue
}
owner := head.Module
if owner == "" {
owner, _ = declaration.ModuleGuess(head.ID)
}
if owner == module {
return true
}
}
return false
}
// ToolsRunning is, by module, the tools bundles that run on the node (Node.RunsTools): each bundle archive a module's
// resource unpacks at its BundlePath, with what the runtime's own process loads of it and the words it hands it
// (catalogue.RuntimeToolModules, catalogue.RuntimeToolEnv, the mesh's own words left out). A bundle the runtime loads
// nothing of has no Loads: the runtime dropped it, and the composition reads its loads again from the module's build
// (issue 1126, review: shanks had already applied the drop of systemd-resolved). Nil when running holds no runtime process.
func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle {
runtimeID := catalogue.RuntimeModule + "." + resolution.RuntimeProcessID()
type resource struct {
ID string `json:"id"`
Module string `json:"module"`
Type string `json:"type"`
Path string `json:"path"`
Source string `json:"source"`
Digest string `json:"digest"`
Env map[string]string `json:"env"`
}
var env map[string]string
out := map[string][]catalogue.Bundle{}
for _, c := range running {
for _, raw := range c.Resources {
var r resource
if json.Unmarshal(raw, &r) != nil {
continue
}
owner := r.Module
if owner == "" {
owner, _ = declaration.ModuleGuess(r.ID)
}
switch {
case r.Type == "process" && r.ID == runtimeID && owner == catalogue.RuntimeModule:
env = r.Env
case r.Type == "archive" && owner != "" && strings.HasPrefix(r.ID, owner+"."+resolution.BundleID("")):
name := strings.TrimPrefix(r.ID, owner+"."+resolution.BundleID(""))
if r.Path == resolution.BundlePath(owner, name) {
out[owner] = append(out[owner], catalogue.Bundle{Name: name, Source: catalogue.Recorded(r.Source), Digest: r.Digest})
}
}
}
}
if env == nil {
return nil
}
var words map[string]map[string]string
_ = json.Unmarshal([]byte(env[catalogue.RuntimeToolEnv]), &words)
for _, entry := range strings.Split(env[catalogue.RuntimeToolModules], ",") {
module, path, ok := strings.Cut(entry, "=")
if !ok || !strings.HasPrefix(path, catalogue.BundleRoot+"/"+module+"/") {
continue
}
name, load, ok := strings.Cut(strings.TrimPrefix(path, catalogue.BundleRoot+"/"+module+"/"), "/")
bundles := out[module]
i := slices.IndexFunc(bundles, func(b catalogue.Bundle) bool { return b.Name == name })
if !ok || i < 0 {
continue // no archive of it runs: nothing to load
}
var own map[string]string
for w, v := range words[module] {
if w == catalogue.FilesOwnedPaths || w == catalogue.ReadsSecretPlaces {
continue // the mesh's to say, composed again
}
if own == nil {
own = map[string]string{}
}
own[w] = v
}
bundles[i].Loads = append(bundles[i].Loads, load)
bundles[i].Env = own
}
for _, bundles := range out {
sort.Slice(bundles, func(i, j int) bool { return bundles[i].Name < bundles[j].Name })
}
return out
}
// runningBundles is the bundles of a module composed as what runs of it (Node.RunsTools): each that runs, with what the
// runtime loads of it and its words; one the runtime loads nothing of takes its loads and words from the module's build
// (man: its built bundles, else its build's artifacts, catalogue.BundleLoads), so a drop already applied is undone.
func runningBundles(runs []catalogue.Bundle, man catalogue.Manifest) []catalogue.Bundle {
var out []catalogue.Bundle
for _, b := range runs {
if len(b.Loads) == 0 {
for _, built := range man.Bundles {
if built.Name == b.Name {
b.Loads, b.Env, b.Launchers, b.Binary = built.Loads, built.Env, built.Launchers, built.Binary
}
}
if man.Build != nil {
run := resolution.RunByAResource(man)
for _, a := range man.Build.Artifacts {
if a.Kind == catalogue.ArtifactBundle && a.Name == b.Name {
b.Loads, b.Env, b.Binary = catalogue.BundleLoads(man, a, run), a.Env, catalogue.BinaryOf(a)
}
}
}
}
out = append(out, b)
}
return out
}
// NamesBuild says a manifest names what a build makes: its build, or a resource naming an artifact. A node cannot
// compose such a module folded into its own composition: the build of the old form it runs is held by the controller
// alone (issue 1126).
func NamesBuild(m catalogue.Manifest) bool {
if m.Build != nil {
return true
}
for _, r := range m.Resources {
if _, ok := r["artifact"]; ok {
return true
}
}
return false
}
// Stub is m with nothing to build and no resource but its directories: what it provides, requires, serves and grants
// still shape the rest of the composition, the places its own secrets, data and grants name (${dir:…}) still resolve,
// and nothing else of its own is composed (issue 1126: nats's ${dir:mesh-state} on novox). A module refused in a
// node's answer is composed so; a folded one the node runs is Carried.
func Stub(m catalogue.Manifest) catalogue.Manifest {
// The paths it owns stay its manifest's, as the controller tells the holder of node-files them (issue 1126, seed-8).
if m.OwnsPaths == nil {
m.OwnsPaths = resolution.DeclaredPaths(m)
}
m.Build = nil
// What it hands the provisioner is left out as it runs, not refused for want of a plan (catalogue.Manifest.Stubbed).
m.Stubbed = true
// Nor is a template its build kept for its own files handed: a stub carries none of them (issue 1126: novox's node
// refused "postgres: its build kept a template for consumers, which it does not declare" with postgres held). Its
// items stay, and the templates they render through: a held module's contributions to other modules' collections
// (seat contributions, shell blocks, seat data) still reach their collectors.
if m.Handed != nil {
h := *m.Handed
h.Templates = nil
m.Handed = &h
}
var dirs []map[string]any
for _, r := range m.Resources {
if r["type"] == "directory" {
dirs = append(dirs, r)
}
}
m.Resources = dirs
return m
}
// Carried is m, folded into its node's own composition at a build of the old form, as the node runs it (issue 1126,
// C5): nothing to build; its containers and directories those the node's own composition (runs) carries of it, their
// module's word taken off their ids, with the manifest's directories it does not run. So what other modules read of it
// is what runs: the machine ports its containers publish (the filter's rules, the routes reaching it: MachineSide). The
// paths it owns, which the holder of node-files is told, are its manifest's, as for its Stub: the controller composes
// them from the manifest, not from every path a resource of it runs at (issue 1126, seed-8: f1-circuits, snake and
// txt-game on ace, whose manifests leave their directories to the mesh). Its other resources are not taken, since the
// composition makes some of them again from its manifest (its secrets' files) and one id twice is refused; none of them
// is composed into what is applied either way (compositions leaves a carried module's out, and carries the ones that
// run). What it provides, requires, listens on and grants is its manifest's. A module whose resources runs holds none
// of is its Stub, as before.
func Carried(m catalogue.Manifest, module string, runs *delivery.Composed) catalogue.Manifest {
if runs == nil {
return Stub(m)
}
var taken []map[string]any
runsAny := false
ids := map[string]bool{}
for _, raw := range runs.Resources {
var r map[string]any
if json.Unmarshal(raw, &r) != nil {
continue
}
id, _ := r["id"].(string)
owner, _ := r["module"].(string)
if owner == "" {
owner, _ = declaration.ModuleGuess(id)
}
if owner != module {
continue
}
runsAny = true
if typ := r["type"]; typ != "container" && typ != "directory" {
continue
}
r["id"] = strings.TrimPrefix(id, module+".")
delete(r, "module")
ids[r["id"].(string)] = true
taken = append(taken, r)
}
if !runsAny {
return Stub(m)
}
stub := Stub(m)
for _, r := range stub.Resources {
if id, _ := r["id"].(string); !ids[id] {
taken = append(taken, r)
}
}
stub.Resources = taken
return stub
}
// carry puts into the node's resources (kept, in the declaration's order) the resources of the node's own composition as
// it runs (runs) that belong to a carried module, each after the resource it runs after, or first when that one is
// not among them; a file among them is a folded path.
func carry(kept []json.RawMessage, foldedPaths map[string]bool, runs *delivery.Composed, carried func(module, id string) bool) ([]json.RawMessage, map[string]bool, error) {
idOf := func(raw json.RawMessage) (string, string, string, string, error) {
var head struct {
ID string `json:"id"`
Module string `json:"module"`
Type string `json:"type"`
Path string `json:"path"`
}
err := json.Unmarshal(raw, &head)
return head.ID, head.Module, head.Type, head.Path, err
}
present := map[string]bool{}
for _, raw := range kept {
if id, module, _, _, err := idOf(raw); err == nil && module == "" {
present[id] = true
}
}
// By the id of the resource of the node's own it runs after, "" for first: the last one before it that is composed.
after := map[string][]json.RawMessage{}
anchor := ""
for _, raw := range runs.Resources {
id, module, typ, path, err := idOf(raw)
if err != nil {
return nil, nil, fmt.Errorf("compose: a resource the node runs in its own composition: %w", err)
}
if !carried(module, id) {
if module == "" && present[id] {
anchor = id
}
continue
}
after[anchor] = append(after[anchor], raw)
if typ == string(declaration.TypeFile) {
foldedPaths[path] = true
}
}
out := make([]json.RawMessage, 0, len(kept)+len(runs.Resources))
out = append(out, after[""]...)
for _, raw := range kept {
out = append(out, raw)
if id, module, _, _, err := idOf(raw); err == nil && module == "" {
out = append(out, after[id]...)
}
}
return out, foldedPaths, nil
}
+201
View File
@@ -0,0 +1,201 @@
package compose
import (
"encoding/json"
"sort"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/compose/resolution"
"github.com/novox/mesh-host/internal/overlay"
)
func fpFor(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) }
// 1943/AC5: a node's compositions are its declaration as Compose gives it, split by the module each resource belongs
// to: every resource once, each in its module's composition at the fingerprint given, the module's ownership said on
// it; a module given no fingerprint is named as skipped and composed nowhere.
func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
in := aSmallMesh(t)
d, _, err := Compose(in, "laptop")
if err != nil {
t.Fatal(err)
}
var want []string
for _, r := range d.Resources {
want = append(want, r["type"].(string)+":"+r["id"].(string))
}
sort.Strings(want)
fps := map[string]string{"mesh-wireguard": fpFor('a'), "files": fpFor('b')}
out, skipped, err := Compositions(in, "laptop", fps, Foreseeing)
if err != nil || len(skipped) != 0 {
t.Fatalf("Compositions = %v, skipped %v", err, skipped)
}
var got []string
for key, c := range out {
node, module, fp, isNode, err := delivery.ParseComposedKey(key)
if err != nil || node != "laptop" || module != c.Module || fp != c.FP || (isNode != (module == "")) {
t.Errorf("key %s holds %s at %s (%v)", key, c.Module, c.FP, err)
}
if !isNode && fp != fps[module] {
t.Errorf("%s is composed at %s, given %s", module, fp, fps[module])
}
if _, err := delivery.EncodeComposed(c); err != nil {
t.Errorf("%s: %v", key, err)
}
for _, raw := range c.Resources {
var r struct{ ID, Type, Module string }
_ = json.Unmarshal(raw, &r)
if r.Module != module {
t.Errorf("%s holds %s of %q", key, r.ID, r.Module)
}
got = append(got, r.Type+":"+r.ID)
}
}
sort.Strings(got)
if strings.Join(got, " ") != strings.Join(want, " ") {
t.Errorf("the compositions hold %v, the declaration %v", got, want)
}
one, skipped, err := Compositions(in, "laptop", map[string]string{"mesh-wireguard": fpFor('a')}, Foreseeing)
if err != nil || strings.Join(skipped, ",") != "files" {
t.Fatalf("one module: %v, skipped %v", err, skipped)
}
for _, c := range one {
if c.Module == "files" {
t.Errorf("a module given no fingerprint was composed")
}
}
// Issue 1126: a module the node runs inside its own composition (Node.InOwn), and the private network's always,
// given no fingerprint of the new form, is folded into the node's own as the controller's composed writer folds it:
// every resource in its place, its module taken off, its id still naming it, nothing skipped.
folding := in
folding.Nodes = append([]Node(nil), in.Nodes...)
for i := range folding.Nodes {
if folding.Nodes[i].Name == "laptop" {
folding.Nodes[i].InOwn = map[string]bool{"files": true}
}
}
all, skipped, err := Compositions(folding, "laptop", nil, Foreseeing)
nodeKey, _ := delivery.ComposedNodeKey("laptop")
if err != nil || len(skipped) != 0 || len(all) != 1 || len(all[nodeKey].Resources) != len(want) {
t.Fatalf("folded: %v, skipped %v, %d compositions, %d of %d resources in the node's own", err, skipped, len(all), len(all[nodeKey].Resources), len(want))
}
for i, raw := range all[nodeKey].Resources {
var r map[string]any
_ = json.Unmarshal(raw, &r)
if _, says := r["module"]; says || r["id"] != d.Resources[i]["id"] {
t.Errorf("folded resource %d is %v, the declaration's %v", i, r["id"], d.Resources[i]["id"])
}
}
if _, err := delivery.EncodeComposed(all[nodeKey]); err != nil {
t.Errorf("the node's own with the folded modules: %v", err)
}
if _, _, err := Compositions(in, "nowhere", fps, Foreseeing); err == nil {
t.Errorf("a node the mesh does not have was composed")
}
// Issue 1126, 2026-10-29: a module composed as what runs of it (folded at a build of the old form, or held) still
// contributes to the runtime what the runtime serves of it as it runs (Node.RunsTools): its manifest at the
// indexer's commit has no bundles (shanks's runtime lost the folded systemd-resolved, novox's the folded nats), and a
// held module's new build's bundles are not delivered.
dg := func(c byte) string { return "sha256:" + strings.Repeat(string(c), 64) }
runtime := catalogue.Manifest{Module: catalogue.RuntimeModule, Version: "1",
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/node-tools/broker"}},
Bundles: []catalogue.Bundle{{Name: "runtime", Source: catalogue.ArtifactStoreScheme + "node-tools/runtime/blobs/" + dg('a'),
Digest: dg('a'), Language: "go", Binary: "node-tools"}}}
store := catalogue.Manifest{Module: "artifacts", Version: "1",
Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}}
lamp := catalogue.Manifest{Module: "lamp", Version: "1", Tools: []string{"on"},
Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/lamp", "mode": "0700"}},
Build: &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "tools", Kind: catalogue.ArtifactBundle,
Language: "go", System: "arch", From: "cmd/lamp-tools", Env: map[string]string{"LAMP_CONF": "/var/lib/lamp/conf"}}}}}
newLamp, err := lamp.Resolve([]catalogue.Built{{Name: "tools", Kind: catalogue.ArtifactBundle,
Reference: catalogue.ArtifactStoreScheme + "lamp/tools/blobs/" + dg('c'), Digest: dg('c')}})
if err != nil {
t.Fatal(err)
}
served := "lamp=" + resolution.BundlePath("lamp", "tools") + "/lamp-tools"
runtimeRuns := func(dropped bool) delivery.Composed {
env := `"MESH_TOOL_MODULES":"` + served + `","MESH_TOOL_ENV":"{\"lamp\":{\"LAMP_CONF\":\"/var/lib/lamp/conf\"}}"`
if dropped {
env = `"MESH_TOOL_MODULES":""` // the drop already applied (shanks, 2026-10-29)
}
return delivery.Composed{Node: "anchor", Module: catalogue.RuntimeModule, Resources: []json.RawMessage{json.RawMessage(
`{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{` + env + `}}`)}}
}
lampRuns := `{"id":"lamp.bundle-tools","type":"archive","source":"` + catalogue.ArtifactStoreScheme + `lamp/tools/blobs/` + dg('b') +
`","digest":"` + dg('b') + `","path":"` + resolution.BundlePath("lamp", "tools") + `"}`
for _, row := range []struct {
name string
set func(n *Node) delivery.Composed // and what runs of lamp
fps map[string]string
dropped bool
}{
{"folded at a build of the old form", func(n *Node) delivery.Composed {
n.InOwn = map[string]bool{"lamp": true}
n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}}
return *n.RunsOwn
}, map[string]string{}, false},
{"folded, the runtime already dropped it", func(n *Node) delivery.Composed {
n.InOwn = map[string]bool{"lamp": true}
n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}}
return *n.RunsOwn
}, map[string]string{}, true},
{"held, its new build not delivered", func(n *Node) delivery.Composed {
n.Held = map[string]bool{"lamp": true}
lr := delivery.Composed{Node: "anchor", Module: "lamp", Resources: []json.RawMessage{json.RawMessage(
strings.Replace(lampRuns, `"type"`, `"module":"lamp","type"`, 1))}}
n.Kept = map[string]catalogue.Manifest{"lamp": Carried(newLamp, "lamp", &lr)}
return lr
}, map[string]string{"lamp": fpFor('1')}, false},
} {
tools := aMesh(t)
tools.Catalogue[catalogue.RuntimeModule], tools.Catalogue["artifacts"], tools.Catalogue["lamp"] = runtime, store, lamp
anchor := nodeOf(t, &tools, "anchor")
anchor.Assigned = append(anchor.Assigned, "artifacts", catalogue.RuntimeModule, "lamp")
tools.BusMinted = []string{"anchor." + catalogue.RuntimeModule}
anchor.SealingKey = keyOf(t, "anchor")
anchor.RunsTools = ToolsRunning([]delivery.Composed{runtimeRuns(row.dropped), row.set(anchor)})
row.fps[overlay.Name], row.fps["artifacts"], row.fps[catalogue.RuntimeModule] = fpFor('a'), fpFor('5'), fpFor('e')
out, _, _, err := CompositionsIn(tools, "anchor", row.fps, Foreseeing)
if err != nil {
t.Fatalf("%s: %v", row.name, err)
}
var env map[string]any
for _, c := range out {
for _, raw := range c.Resources {
var r struct {
ID string `json:"id"`
Env map[string]any `json:"env"`
}
if json.Unmarshal(raw, &r) == nil && r.ID == "node-tools.runtime" {
env = r.Env
}
}
}
if env[catalogue.RuntimeToolModules] != served || env[catalogue.RuntimeToolEnv] != `{"lamp":{"LAMP_CONF":"/var/lib/lamp/conf"}}` {
t.Errorf("%s: the runtime serves %v with %v, want what runs of lamp, %s", row.name,
env[catalogue.RuntimeToolModules], env[catalogue.RuntimeToolEnv], served)
}
}
}
// 1943/AC1: the upgrade policies and sources a composition's value may name are the catalogue's own words.
func TestTheComposedPoliciesAreTheCatalogues(t *testing.T) {
for _, p := range []string{catalogue.PolicyRoll, catalogue.PolicyTogether, catalogue.PolicyRecord} {
for _, from := range []string{catalogue.FromPerson, catalogue.FromModule, catalogue.FromBus, catalogue.FromData, catalogue.FromDefault} {
v := delivery.ComposedDigest{Digest: "sha256:" + strings.Repeat("a", 64), Judgement: delivery.JudgedNeither, Policy: p, PolicyFrom: from}
if _, err := delivery.EncodeComposedDigest(v); err != nil {
t.Errorf("%s from %s: %v", p, from, err)
}
}
}
if delivery.PolicyRecord != catalogue.PolicyRecord {
t.Errorf("delivery.PolicyRecord = %q, the catalogue's %q", delivery.PolicyRecord, catalogue.PolicyRecord)
}
}
+499
View File
@@ -0,0 +1,499 @@
package compose
import (
"crypto/ecdh"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"slices"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"github.com/novox/mesh-host/internal/compose/resolution"
)
// Mode says what a composition does about what a send would make. Both modes read what the Input holds and make nothing; they
// differ as the controller's Reading and Foreseeing modes do.
type Mode int
const (
// Reading composes what is held and leaves out an own secret the Input holds none for: that machine cannot be running it.
Reading Mode = iota
// Foreseeing composes an own secret the next send would make with a stand-in and names it foreseen, while one the send would
// refuse (a bus credential nobody issued, a given secret under an old key) is refused here, in the same words.
Foreseeing
// Planning composes for a provisioning plan alone (novox/hq ADR 0570 clause 2): every own secret the Input holds none
// for stands in, named foreseen, and nothing is refused for a value, since a plan names secrets and never carries one.
// The seat mesh-provisioner's holder composes so, reading no secret; a rendering in this mode is never applied.
Planning
)
// ForeseenCertificate and ForeseenBusUsers stand for what the Input does not carry, in a composition asked ahead of the send.
// Never sent.
const (
ForeseenCertificate = "foreseen: issued by the next send"
ForeseenBusUsers = "foreseen: composed from the bus records by the next send"
)
// grantsFor is every credential this node must create, because something elsewhere uses it (plan.go grantsFor): the mirror of
// what a consumer is given. A consumer whose identity overflows the provision's bound is left out of the grants and returned
// beside them (novox/hq ADR 0225); one whose resolution binds it to another provider is withdrawn and returned beside them
// (issue 274). Every other is granted.
func (c *composer) grantsFor(node string) ([]catalogue.Grant, []catalogue.Overflow, []catalogue.Unbound, []string, error) {
issued, err := c.rowsFor(node)
if err != nil {
return nil, nil, nil, nil, err
}
onNetwork, err := c.whereEveryoneIs()
if err != nil {
return nil, nil, nil, nil, err
}
out := make([]catalogue.Grant, 0, len(issued))
var withheld []catalogue.Overflow
var unbound []catalogue.Unbound
var foreseen []string
for _, s := range issued {
resolved, err := c.planFor(s.Consumer)
switch {
case unresolvable(err):
// Their set does not resolve. Skipped rather than fatal: this node is not the place to report another machine's problem.
continue
case err != nil:
// The mesh could not be asked what they wanted, which is not the same as their wanting nothing.
return nil, nil, nil, nil, fmt.Errorf("what %s asked of %s cannot be read: %w", s.Consumer, s.Provision, err)
}
plan := resolved.Plan
values, asks, err := resolution.ContributionsFrom(plan, s.Provision, s.ConsumerModule, resolved.Settings)
if err != nil {
return nil, nil, nil, nil, err
}
// A port in there is the consumer's software port until this: the assignment that moved it is that machine's.
consumer, err := c.in.node(s.Consumer)
if err != nil {
return nil, nil, nil, nil, err
}
values = resolution.AtPublishedPort(values, s.ConsumerModule, consumer.Ports[s.ConsumerModule])
from := s.ConsumerModule
if !asks {
// That module no longer wants this: left empty, which is how a login is withdrawn rather than kept working for ever.
from = ""
}
if bound := resolution.BindsFrom(plan, s.Provision, s.ConsumerModule, s.Local); from != "" && !slices.Contains(bound, node) {
// It still asks, and not of this node: withdrawn exactly as a credential nobody asks for, and said.
from = ""
unbound = append(unbound, catalogue.Unbound{Provision: s.Provision, Provider: node,
Consumer: s.Consumer, Module: s.ConsumerModule, Local: s.Local, BoundTo: bound})
}
// The consumer's identity slug, from its own manifest, carried on the grant so the provider derives the same login the
// consumer does (novox/hq ADR 0049).
slug := ""
for _, mm := range plan.Modules {
if mm.Module == s.ConsumerModule {
slug = mm.Slug
break
}
}
if from != "" {
bound := boundOfGrant(plan, s, node)
source := catalogue.IdentitySource(slug, s.ConsumerModule)
if catalogue.CheckIdentityWithin(s.Consumer, source, bound) != nil {
withheld = append(withheld, catalogue.Overflow{Provision: s.Provision, Provider: node,
Consumer: s.Consumer, Module: s.ConsumerModule,
Identity: catalogue.ConsumerIdentity(s.Consumer, source), Bound: bound})
continue
}
}
sealed := s.ForProvider
switch {
case credentiallessRow(plan, s, node):
// Its offer presents no credential (form none): the grant carries the consumer and nothing sealed, and
// nothing stands in for it, so neither end waits for a value nobody makes (issue 1126).
sealed = ""
case sealed == "":
sealed = catalogue.ForeseenSealed
foreseen = append(foreseen, fmt.Sprintf("the grant of %s for %s on %s", s.Provision, s.ConsumerModule, s.Consumer))
}
out = append(out, catalogue.Grant{
Provision: s.Provision, Consumer: s.Consumer, At: onNetwork[s.Consumer],
From: from, Values: values, Slug: slug, Sealed: sealed, Local: s.Local})
}
return out, withheld, unbound, foreseen, nil
}
// credentialless is whether a need's provider offers it with no credential (catalogue.CredentialFormNone): the vault
// mints nothing for it, so nothing is asked of the vault, sealed into a grant or a file, or stood in for (issue 1126).
func credentialless(n catalogue.Needed) bool { return n.CredentialForm == catalogue.CredentialFormNone }
// credentiallessRow is whether the consumer's resolution answers row's need from provider with an offer of form none.
func credentiallessRow(consumer catalogue.Resolution, row Issued, provider string) bool {
return slices.ContainsFunc(consumer.Needs, func(n catalogue.Needed) bool {
return n.Name == row.Provision && n.For == row.ConsumerModule && n.Local == row.Local && n.From == provider &&
credentialless(n)
})
}
// rowsFor is every pair credential this node's declaration must carry a grant for, in the order the controller reads them
// (SecretsFrom: by provision, consumer, consumer module and local name): the ones the Input says were made, and the ones the same send
// would make before it composes this node -- every need of a node that resolves (this one too), whose provider is this node, that is not
// answered by a record and not a credential only a person gives and nobody gave. Those have no sealed copy (ForProvider empty), so the
// grant is composed with a stand-in and named foreseen (plan.go: planFor makes the secret in every mode before grantsFor reads it).
func (c *composer) rowsFor(node string) ([]Issued, error) {
var rows []Issued
for _, row := range c.in.Issued {
if row.Provider == node {
rows = append(rows, row)
}
}
for _, other := range c.in.sorted() {
resolved, err := c.planFor(other.Name)
switch {
case unresolvable(err):
continue
case err != nil:
return nil, fmt.Errorf("what %s asked of %s cannot be read: %w", other.Name, node, err)
}
shelf := c.shelfFor(other)
for _, n := range resolved.Plan.Needs {
if n.From != node || n.ByRecord {
continue
}
cred, held := c.credential(n, other.Name)
if _, outside := issuedOutsideBy(shelf, n); cred.State == CredentialNotGiven || (outside && !(held && cred.State == CredentialHeld)) {
continue
}
if slices.ContainsFunc(rows, func(r Issued) bool {
return r.Provision == n.Name && r.Consumer == other.Name && r.ConsumerModule == n.For && r.Local == n.Local
}) {
continue
}
rows = append(rows, Issued{Provision: n.Name, Consumer: other.Name, ConsumerModule: n.For, Local: n.Local, Provider: node})
}
}
sort.SliceStable(rows, func(i, j int) bool {
a, b := rows[i], rows[j]
switch {
case a.Provision != b.Provision:
return a.Provision < b.Provision
case a.Consumer != b.Consumer:
return a.Consumer < b.Consumer
case a.ConsumerModule != b.ConsumerModule:
return a.ConsumerModule < b.ConsumerModule
}
return a.Local < b.Local
})
return rows, nil
}
// boundOfGrant is the identity bound the consumer's own resolution states for the requirement this grant answers. A requirement
// not found there is held to the tightest bound the mesh knows rather than to none.
func boundOfGrant(consumer catalogue.Resolution, s Issued, provider string) catalogue.IdentityBound {
for _, n := range consumer.Needs {
if n.Name == s.Provision && n.For == s.ConsumerModule && n.From == provider {
return n.Identity
}
}
return catalogue.DefaultIdentityBound
}
// ownSecretRow is the row for one own secret of a module on a node.
func (c *composer) ownSecretRow(node, module, name string) (OwnSecretRow, bool) {
for _, r := range c.in.OwnSecrets {
if r.Node == node && r.Module == module && r.Name == name {
return r, true
}
}
return OwnSecretRow{}, false
}
// givenMember is one own secret a person gave on a machine and whether it is sealed to the key the machine holds now.
type givenMember struct {
Name string
Sealed string
Current bool
}
// givenMembers is every member of a module's secret family given on a machine, by name (inv.GivenMembers): the mesh never
// makes a member, so only a value a person gave is one; a machine with no sealing key holds none.
func (c *composer) givenMembers(node Node, module, family string) []givenMember {
if node.SealingKey == "" {
return nil
}
prefix := catalogue.FamilyPrefix(family)
var out []givenMember
for _, r := range c.in.OwnSecrets {
if r.Node == node.Name && r.Module == module && r.Origin == "accepted" && strings.HasPrefix(r.Name, prefix) {
out = append(out, givenMember{Name: r.Name, Sealed: r.Sealed, Current: r.SealedTo == node.SealingKey})
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// givenOwnSecret is an own secret issued outside the mesh as a person gave it on a machine (inv.GivenOwnSecret): not given when
// the value held was made by the mesh or the machine has no sealing key.
func (c *composer) givenOwnSecret(node Node, module, name string) (givenMember, bool) {
if node.SealingKey == "" {
return givenMember{}, false
}
r, ok := c.ownSecretRow(node.Name, module, name)
if !ok || r.Origin != "accepted" {
return givenMember{}, false
}
return givenMember{Name: name, Sealed: r.Sealed, Current: r.SealedTo == node.SealingKey}, true
}
// moduleSecretIfIssued is the read half of the controller's SecretForModule (inv.ModuleSecretIfIssued): the secret when one is
// held and sealed to the key the machine holds now, and otherwise not held. A key it was sealed to that the machine no longer
// has is reported as absent rather than as an error.
func (c *composer) moduleSecretIfIssued(node Node, module, name string) (sealed string, held bool) {
if node.SealingKey == "" {
return "", false
}
r, ok := c.ownSecretRow(node.Name, module, name)
if !ok || r.SealedTo != node.SealingKey {
return "", false
}
return r.Sealed, true
}
// wouldMakeSecretForModule says what the send would do about an own secret without doing it: nil when it would make one (or one
// is held), and otherwise the very refusal it would meet (inv.WouldMakeSecretForModule).
func (c *composer) wouldMakeSecretForModule(node Node, module, name string) error {
if node.SealingKey == "" {
return fmt.Errorf("%s needs a secret and %s has no sealing key, so nothing can be sealed to it", module, node.Name)
}
r, ok := c.ownSecretRow(node.Name, module, name)
switch {
case !ok:
return nil
case r.SealedTo != node.SealingKey && r.Origin == "accepted":
return fmt.Errorf("%s on %s holds %q, which was given to the mesh rather than made by it, and %s has "+
"since generated a new sealing key. The mesh cannot make another; issue it again", module, node.Name, name, node.Name)
}
return nil
}
// givenUnderAnOldKey is the refusal for a value a person gave sealed to a key the machine no longer holds.
func givenUnderAnOldKey(module, node, name string) error {
return fmt.Errorf("%s on %s holds %q, which was given to the mesh rather than made by it, and %s has "+
"since generated a new sealing key. The mesh cannot make another; give it again", module, node, name, node)
}
// ownSecretsFor is each module's own secrets as this node composes them (plan.go renderingFor): the needed ones sealed to the
// node, and, in the Foreseeing mode, the ones the next send would make named foreseen. A module handed to the provisioner has its
// secrets from the vault through the client, so none of them is composed here.
func (c *composer) ownSecretsFor(node Node, plan catalogue.Resolution, handing catalogue.Rendering, mode Mode) (
needed map[string]map[string]string, foreseen map[string]map[string]bool, err error) {
needed = map[string]map[string]string{}
foreseen = map[string]map[string]bool{}
mark := func(module, name string) {
if foreseen[module] == nil {
foreseen[module] = map[string]bool{}
}
foreseen[module][name] = true
}
for _, m := range plan.Modules {
if resolution.Handing(handing, m) {
continue
}
// A secret family is never made (novox/hq ADR 0283): each member a person gave on this machine is placed, and one not
// given is nothing -- the module says it waits for it.
for _, family := range m.OwnSecrets.Families() {
for _, g := range c.givenMembers(node, m.Module, family) {
if _, fam, ok := m.OwnSecrets.Lookup(g.Name); !ok || fam != family {
continue // a longer family's member, or a name no longer of this family
}
if !g.Current {
continue
}
if needed[m.Module] == nil {
needed[m.Module] = map[string]string{}
}
needed[m.Module][g.Name] = g.Sealed
if g.Sealed == "" {
// Given, and its value is not in the Input: placed with a stand-in, named.
needed[m.Module][g.Name] = catalogue.ForeseenSealed
mark(m.Module, g.Name)
}
}
}
names := make([]string, 0)
for name := range m.OwnSecrets.Plain() {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
own := m.OwnSecrets.Plain()[name]
// An own secret issued outside the mesh is never made (novox/hq issue 401, ADR 0228; ADR 0283 for a family): only a
// value a person gave is placed. One not given is nothing, and a value the mesh made for it before is not one either.
if own.IssuedBy == catalogue.IssuedOutside {
g, given := c.givenOwnSecret(node, m.Module, name)
if !given || !g.Current {
continue
}
if g.Sealed == "" {
mark(m.Module, name)
continue
}
if needed[m.Module] == nil {
needed[m.Module] = map[string]string{}
}
needed[m.Module][name] = g.Sealed
continue
}
sealed, held := c.moduleSecretIfIssued(node, m.Module, name)
if !held && mode == Planning {
mark(m.Module, name)
continue
}
if !held && mode == Foreseeing {
// Asked ahead of the send: what the send would do about it, by the send's own rules. Made by it -- a stand-in,
// named; refused by it -- refused here, the same words (novox/hq issue 275). A bus account not yet issued is
// foreseen, never refused: the vault mints it from the grants part's wanted list once the placement is written
// on the operator's Approve (ADR 0561 §1, ADR 0567 §5), so a composition asked ahead of that cannot hold it
// (issue 774, 2026-10-30: every proposal placing mesh-conditions, mesh-self-check or mesh-healers refused).
if err := c.wouldMakeSecretForModule(node, m.Module, name); err != nil {
return nil, nil, err
}
mark(m.Module, name)
continue
}
if !held {
// Never issued, so this machine cannot be running it: left out rather than invented.
continue
}
if sealed == "" {
// Held, and its value is not in the Input: composed with a stand-in, named.
mark(m.Module, name)
continue
}
if needed[m.Module] == nil {
needed[m.Module] = map[string]string{}
}
needed[m.Module][name] = sealed
}
}
return needed, foreseen, nil
}
// CoreBoxWanted is a core credential whose box the Input does not hold for a machine: the next send asks the vault for it and
// keeps the answer. It is returned for the caller to ask and keep; this package asks and keeps nothing.
type CoreBoxWanted struct {
Node string
Module string
Name string
}
// coreModulesOf is every module of the plan on the core's list whose own secrets this machine's declaration carries, with their
// plain own secret names, sorted: one handed to the provisioner has its secrets from the vault through the client, and one issued
// outside the mesh is never the vault's to seal here.
func coreModulesOf(modules []catalogue.Manifest, handed func(catalogue.Manifest) bool) map[string][]string {
out := map[string][]string{}
for _, m := range modules {
if !resolution.CarriesVaultSealed(m.Module) || (handed != nil && handed(m)) {
continue
}
var names []string
for name, own := range m.OwnSecrets.Plain() {
if own.IssuedBy != catalogue.IssuedOutside {
names = append(names, name)
}
}
if len(names) > 0 {
sort.Strings(names)
out[m.Module] = names
}
}
return out
}
// sealingKeyValid says a machine's sealing key, recorded raw in base64, is a raw X25519 key.
func sealingKeyValid(raw string) error {
b, err := base64.StdEncoding.DecodeString(strings.TrimSpace(raw))
if err != nil || len(b) != 32 {
return errors.New("the machine's sealing key is not a raw X25519 key")
}
_, err = ecdh.X25519().NewPublicKey(b)
return err
}
// coreBoxesFor decides each core credential of a machine in a composition that sends nothing and asks no vault (core_box.go
// coreBoxesOf, in the modes that are not a send): the kept box while it is sealed to the key the machine holds now, and otherwise a
// stand-in that the next send replaces by asking the vault -- returned in wanted for the caller to ask and keep. A machine with no
// sealing key, or one whose key is not a key, leaves its module out, said.
func (c *composer) coreBoxesFor(node Node, modules []catalogue.Manifest, handing catalogue.Rendering) (
sealed map[string]map[string]json.RawMessage, notPlaced map[string]string, wanted []CoreBoxWanted) {
core := coreModulesOf(modules, func(m catalogue.Manifest) bool { return resolution.Handing(handing, m) })
sealed, notPlaced = map[string]map[string]json.RawMessage{}, map[string]string{}
mods := make([]string, 0, len(core))
for m := range core {
mods = append(mods, m)
}
sort.Strings(mods)
for _, module := range mods {
if node.SealingKey == "" {
notPlaced[module] = fmt.Sprintf("%s has no sealing key, so the vault can seal %s's credentials to "+
"nothing there (novox/hq ADR 0349 §16)", node.Name, module)
continue
}
if err := sealingKeyValid(node.SealingKey); err != nil {
notPlaced[module] = fmt.Sprintf("%s's credentials cannot be asked of the vault: %v", module, err)
continue
}
boxes := map[string]json.RawMessage{}
for _, name := range core[module] {
var kept *CoreBox
for i := range c.in.KeptCoreBoxes {
k := &c.in.KeptCoreBoxes[i]
if k.Node == node.Name && k.Module == module && k.Name == name {
kept = k
}
}
if kept != nil && kept.SealedTo == node.SealingKey && len(kept.Box) > 0 {
boxes[name] = kept.Box
continue
}
boxes[name] = catalogue.ForeseenVaultBox
wanted = append(wanted, CoreBoxWanted{Node: node.Name, Module: module, Name: name})
}
sealed[module] = boxes
}
return sealed, notPlaced, wanted
}
// certificateFor is what the mesh certifies about one machine's internal name (plan.go certificateFor): refused for a machine that
// never told the mesh what key it serves with, and for one not on the private network, which has no name to be certified for.
// The certificate and the authority are public values the Input may carry; without them stand-ins are composed, and named.
func (c *composer) certificateFor(node Node) (certificate, authority string, foreseen bool, err error) {
if node.ServingKey == "" {
return "", "", false, fmt.Errorf(
"%s wants a certificate and has never told the mesh what key it serves with; it "+
"joins again to report one", node.Name)
}
where, err := c.whereEveryoneIs()
if err != nil {
return "", "", false, err
}
if where[node.Name] == "" {
return "", "", false, fmt.Errorf(
"%s wants a certificate and is not on the private network, so it has no name inside "+
"the mesh to be certified for", node.Name)
}
certificate, authority = node.Certificate, c.in.Authority
if certificate == "" || authority == "" {
return ForeseenCertificate, ForeseenCertificate, true, nil
}
return certificate, authority, false, nil
}
// holdsTheBus says whether a node's set includes the module that asks for the bus's user list.
func holdsTheBus(modules []catalogue.Manifest) bool {
for _, m := range modules {
if m.BusUsers != "" && m.ClaimsSeat("mesh-broker") {
return true
}
}
return false
}
+69
View File
@@ -0,0 +1,69 @@
package compose
import (
"fmt"
"sort"
)
// CredentialKey names one pair credential by the ends it joins: the provision, the consumer's node and module, the
// provider's node, and the local name where a module keeps several (Credential's and Issued's key).
type CredentialKey struct {
Provision string `json:"provision"`
Consumer string `json:"consumer"`
ConsumerModule string `json:"consumer_module"`
Provider string `json:"provider"`
Local string `json:"local,omitempty"`
}
// CredentialsNeeded is every pair credential node's composition reads (novox/hq ADR 0547 §2, ADR 0550 §5, issue 1126):
// as a consumer, each need of its resolution a provider's credential answers (a need answered by a record is a licence's,
// not a pair's); as a provider, each credential it must carry a grant for (rowsFor). A node that composes its own asks the
// vault for exactly these, sealed to its key, and composes with them; one it does not get is stood in for, which the
// node's own apply refuses (CompositionsApplied). A need whose provider offers it with no credential (form none) is
// in neither list: the vault mints nothing for it (issue 1126).
func CredentialsNeeded(in Input, node string) (asConsumer, asProvider []CredentialKey, err error) {
c := newComposer(in)
resolved, err := c.resolution(node)
if err != nil {
return nil, nil, err
}
for _, n := range resolved.Needs {
if n.ByRecord || n.From == "" || credentialless(n) {
continue
}
asConsumer = append(asConsumer, CredentialKey{Provision: n.Name, Consumer: node, ConsumerModule: n.For,
Provider: n.From, Local: n.Local})
}
rows, err := c.rowsFor(node)
if err != nil {
return nil, nil, fmt.Errorf("compose: the credentials %s provides: %w", node, err)
}
for _, r := range rows {
// A grant whose offer presents no credential (form none) has none to ask for (issue 1126).
if consumer, err := c.planFor(r.Consumer); err == nil && credentiallessRow(consumer.Plan, r, node) {
continue
}
asProvider = append(asProvider, CredentialKey{Provision: r.Provision, Consumer: r.Consumer,
ConsumerModule: r.ConsumerModule, Provider: node, Local: r.Local})
}
sortKeys(asConsumer)
sortKeys(asProvider)
return asConsumer, asProvider, nil
}
func sortKeys(ks []CredentialKey) {
sort.Slice(ks, func(i, j int) bool {
a, b := ks[i], ks[j]
switch {
case a.Provision != b.Provision:
return a.Provision < b.Provision
case a.Provider != b.Provider:
return a.Provider < b.Provider
case a.Consumer != b.Consumer:
return a.Consumer < b.Consumer
case a.ConsumerModule != b.ConsumerModule:
return a.ConsumerModule < b.ConsumerModule
}
return a.Local < b.Local
})
}
+549
View File
@@ -0,0 +1,549 @@
package compose
import (
"encoding/json"
"errors"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"github.com/novox/mesh-host/internal/overlay"
)
// secretValue is a value the Input carries that must never appear in anything the composition says.
const secretValue = "SECRET-VALUE-THAT-MUST-NOT-LEAK"
func rendered(t *testing.T, in Input, node string, mode Mode) Rendered {
t.Helper()
got, err := Render(in, node, mode)
if err != nil {
t.Fatalf("%v", err)
}
return got
}
// anAssistant is mesh-controller's outside_not_given_test.go anAssistant (cmd/mesh-controller at 6c2a8d62): a module with a secret
// the mesh makes and one only an outside party issues.
func anAssistant() catalogue.Manifest {
return catalogue.Manifest{Module: "assistant", Version: "1",
OwnSecrets: catalogue.OwnSecrets{
"password": {Path: "/var/lib/mesh/assistant/password", Taken: catalogue.TakenAtStart},
"vendor-key": {Path: "/var/lib/mesh/assistant/vendor-key", Taken: catalogue.TakenAtStart, IssuedBy: catalogue.IssuedOutside},
},
Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/mesh/assistant", "mode": "0700"}}}
}
func withAssistant(t *testing.T) Input {
t.Helper()
in := aMesh(t)
in.Catalogue["assistant"] = anAssistant()
laptop := nodeOf(t, &in, "laptop")
laptop.Assigned = append(laptop.Assigned, "assistant")
laptop.SealingKey = keyOf(t, "laptop")
return in
}
// Own secrets (plan.go renderingFor, novox/hq issue 275 and 401): one held and sealed to the machine's key is carried; one not held is
// foreseen in the Foreseeing mode and left out in the Reading mode, and nothing is made.
func TestAnOwnSecretHeldIsCarriedAndOneNotHeldIsForeseenOrLeftOut(t *testing.T) {
mk := func() Input {
in := withAssistant(t)
in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made",
SealedTo: nodeOf(t, &in, "laptop").SealingKey, Sealed: secretValue}}
return in
}
in := mk()
got := rendered(t, in, "laptop", Foreseeing)
if got.Rendering.Needed["assistant"]["password"] != secretValue || len(got.Rendering.Foreseen) != 0 {
t.Errorf("a held secret: needed %v foreseen %v", got.Rendering.Needed, got.Rendering.Foreseen)
}
// Not held: foreseen with a stand-in (Foreseeing), left out (Reading).
in.OwnSecrets = nil
got = rendered(t, in, "laptop", Foreseeing)
if !got.Rendering.Foreseen["assistant"]["password"] || got.Rendering.Needed["assistant"]["password"] != "" ||
len(got.Foreseen) != 1 || got.Foreseen[0] != "assistant/password" {
t.Errorf("a secret the send makes: foreseen %v / %v needed %v", got.Rendering.Foreseen, got.Foreseen, got.Rendering.Needed)
}
got = rendered(t, in, "laptop", Reading)
if len(got.Rendering.Needed["assistant"]) != 0 || len(got.Foreseen) != 0 || got.Rendering.Foreseen != nil {
t.Errorf("Reading composed a secret that was never issued: %v %v", got.Rendering.Needed, got.Foreseen)
}
// Held but sealed to a key the machine no longer has is not held.
in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made", SealedTo: "an-old-key", Sealed: secretValue}}
if got := rendered(t, in, "laptop", Foreseeing); !got.Rendering.Foreseen["assistant"]["password"] {
t.Errorf("a secret sealed to an old key is held: %v", got.Rendering.Needed)
}
// Held with no value in the Input: a stand-in, named.
in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made", SealedTo: nodeOf(t, &in, "laptop").SealingKey}}
if got := rendered(t, in, "laptop", Reading); !got.Rendering.Foreseen["assistant"]["password"] {
t.Errorf("a held secret with no value is not named: %v", got.Rendering.Foreseen)
}
unchanged(t, mk, func(in Input) { rendered(t, in, "laptop", Foreseeing) })
// 1126 (ADR 0547 §2): a node applying what it composed itself never applies a stand-in: a held secret with no value in the
// Input is refused, naming it; held with its value, it composes.
fps := map[string]string{"assistant": "src2:" + strings.Repeat("a", 64), overlay.Name: "src2:" + strings.Repeat("b", 64)}
if _, _, err := CompositionsApplied(in, "laptop", fps); !errors.Is(err, ErrStoodIn) || !strings.Contains(err.Error(), "assistant/password") {
t.Errorf("a stand-in was not refused for a node's own apply: %v", err)
}
if out, _, err := CompositionsApplied(mk(), "laptop", fps); err != nil || len(out) == 0 {
t.Errorf("a held secret with its value is refused for a node's own apply: %v", err)
}
}
// An own secret issued outside the mesh that nobody gave is never made (novox/hq issue 401, ADR 0228): nothing is composed or
// foreseen for it; a value the mesh made for it is not one a person gave; a value given is placed once it is sealed to the key held now.
func TestAnOutsideSecretIsPlacedOnlyOnceAPersonGaveIt(t *testing.T) {
in := withAssistant(t)
key := nodeOf(t, &in, "laptop").SealingKey
for _, mode := range []Mode{Reading, Foreseeing} {
got := rendered(t, in, "laptop", mode)
if _, there := got.Rendering.Needed["assistant"]["vendor-key"]; there || got.Rendering.Foreseen["assistant"]["vendor-key"] {
t.Fatalf("%v: a secret nobody gave is composed: %v %v", mode, got.Rendering.Needed, got.Rendering.Foreseen)
}
}
in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "vendor-key", Origin: "made", SealedTo: key, Sealed: secretValue}}
if _, there := rendered(t, in, "laptop", Foreseeing).Rendering.Needed["assistant"]["vendor-key"]; there {
t.Error("a value the mesh made for a secret issued outside was placed")
}
in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "vendor-key", Origin: "accepted", SealedTo: key, Sealed: "the vendor's key, sealed"}}
if got := rendered(t, in, "laptop", Foreseeing).Rendering.Needed["assistant"]["vendor-key"]; got != "the vendor's key, sealed" {
t.Errorf("a value given is placed as %q", got)
}
in.OwnSecrets[0].SealedTo = "an-old-key"
if _, there := rendered(t, in, "laptop", Foreseeing).Rendering.Needed["assistant"]["vendor-key"]; there {
t.Error("a value given under an old key was placed")
}
}
// 1364/AC3 (the send's refusals, novox/hq issue 275): in the Foreseeing mode a given secret under an old key is refused in the
// controller's words. No secret value is in an error. A bus account not yet issued is foreseen, issued or not (issue 774: the vault
// mints it once the placement is written on the Approve, ADR 0561 §1, ADR 0567 §5; mesh-self-check's proposal on ace was refused
// "has no bus credential: nothing was issued for ace.mesh-self-check").
func TestTheSendsRefusalsAreRefusedInTheSendsWords(t *testing.T) {
broker := catalogue.Manifest{Module: "mesh-self-check", Version: "1", RunsAs: "mesh-self-check",
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/mesh-self-check/broker", Taken: catalogue.TakenAtStart}}}
in := withAssistant(t)
in.Catalogue["mesh-self-check"] = broker
laptop := nodeOf(t, &in, "laptop")
laptop.Assigned = append(laptop.Assigned, "mesh-self-check")
for _, minted := range [][]string{nil, {"laptop.mesh-self-check"}} {
in.BusMinted = minted
got, err := Render(in, "laptop", Foreseeing)
if err != nil || !got.Rendering.Foreseen["mesh-self-check"]["broker"] {
t.Fatalf("minted %v: the bus account is not foreseen: %v", minted, err)
}
// Reading refuses nothing for it: the machine is not running it.
if _, err := Render(in, "laptop", Reading); err != nil {
t.Fatalf("Reading refused a secret the machine is not running: %v", err)
}
}
// A given secret under an old key.
in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "accepted", SealedTo: "an-old-key", Sealed: secretValue},
{Node: "laptop", Module: "mesh-self-check", Name: "broker", Origin: "made", SealedTo: laptop.SealingKey, Sealed: secretValue}}
_, err := Render(in, "laptop", Foreseeing)
wantOld := "assistant on laptop holds \"password\", which was given to the mesh rather than made by it, and laptop has since generated a new " +
"sealing key. The mesh cannot make another; issue it again"
if err == nil || err.Error() != wantOld {
t.Fatalf("got %v, want %q", err, wantOld)
}
if strings.Contains(err.Error(), secretValue) {
t.Error("a secret value is in an error")
}
// A machine with no sealing key cannot be sealed to.
laptop.SealingKey = ""
in.OwnSecrets = nil
if _, err := Render(in, "laptop", Foreseeing); err == nil || !strings.Contains(err.Error(), "laptop has no sealing key, so nothing can be sealed to it") {
t.Fatalf("no sealing key: %v", err)
}
}
// aGrantMesh is the objects provider on anchor and its consumer files on laptop, with the pair credential made.
func aGrantMesh(t *testing.T) Input {
t.Helper()
in := theObjectsMesh(t, objects())
in.Issued = []Issued{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", ForProvider: "sealed-to-anchor"}}
return in
}
// A credential made for a consumer is a grant on its provider's machine, carrying the consumer's values and the sealed copy; without
// the sealed copy a stand-in is composed and named (novox/hq plan.go grantsFor).
func TestAGrantIsCreatedForEachCredentialMadeForAConsumer(t *testing.T) {
in := aGrantMesh(t)
// 1126 (ADR 0550 §5): the credentials each node's composition reads, by their ends: laptop's files consumes anchor's
// s3-bucket, and anchor grants it; the node composing its own asks the vault for exactly these.
pair := CredentialKey{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor"}
if consumer, provider, err := CredentialsNeeded(in, "laptop"); err != nil || len(provider) != 0 || len(consumer) != 1 || consumer[0] != pair {
t.Errorf("laptop needs %v as consumer and %v as provider (%v), want %v as consumer", consumer, provider, err, pair)
}
if consumer, provider, err := CredentialsNeeded(in, "anchor"); err != nil || len(consumer) != 0 || len(provider) != 1 || provider[0] != pair {
t.Errorf("anchor needs %v as consumer and %v as provider (%v), want %v as provider", consumer, provider, err, pair)
}
got := rendered(t, in, "anchor", Foreseeing)
if len(got.Rendering.Grants) != 1 {
t.Fatalf("grants are %+v", got.Rendering.Grants)
}
g := got.Rendering.Grants[0]
if g.Provision != "s3-bucket" || g.Consumer != "laptop" || g.From != "files" || g.At != "laptop.internal" || g.Sealed != "sealed-to-anchor" {
t.Errorf("the grant is %+v", g)
}
if len(got.Foreseen) != 0 || len(got.Withheld) != 0 || len(got.Unbound) != 0 {
t.Errorf("foreseen %v withheld %v unbound %v", got.Foreseen, got.Withheld, got.Unbound)
}
in.Issued[0].ForProvider = ""
got = rendered(t, in, "anchor", Foreseeing)
if got.Rendering.Grants[0].Sealed != catalogue.ForeseenSealed || len(got.Foreseen) != 1 {
t.Errorf("a grant with no sealed copy: %+v foreseen %v", got.Rendering.Grants, got.Foreseen)
}
// A credential made for a consumer on another provider is not this node's grant.
if len(rendered(t, in, "laptop", Foreseeing).Rendering.Grants) != 0 {
t.Error("the consumer is granted its own credential")
}
// A consumer whose set does not resolve is skipped, not fatal.
nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "files")
in.Catalogue["files"] = catalogue.Manifest{Module: "files", Version: "1", Requires: []string{"s3-bucket"},
Claims: []catalogue.Claim{{Name: "x", Scope: catalogue.ScopeNode}}}
in.Catalogue["twin"] = catalogue.Manifest{Module: "twin", Version: "1", Claims: []catalogue.Claim{{Name: "x", Scope: catalogue.ScopeNode}}}
nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "twin")
if got, err := Render(in, "anchor", Foreseeing); err != nil || len(got.Rendering.Grants) != 0 {
t.Errorf("a consumer that does not resolve: %v %+v", err, got.Rendering.Grants)
}
}
// 1364/AC2: a consumer the grants leave out because its identity overflows the provision's bound is returned beside them, naming the
// consumer and the provision (novox/hq ADR 0225), and is not granted.
func TestAConsumerWhoseIdentityOverflowsIsLeftOutOfTheGrantsAndNamed(t *testing.T) {
in := aGrantMesh(t)
in.Nodes[1].Name = "a-machine-with-a-very-long-name"
in.Issued[0].Consumer = "a-machine-with-a-very-long-name"
got := rendered(t, in, "anchor", Foreseeing)
if len(got.Rendering.Grants) != 0 || len(got.Withheld) != 1 {
t.Fatalf("grants %+v withheld %+v", got.Rendering.Grants, got.Withheld)
}
w := got.Withheld[0]
if w.Consumer != "a-machine-with-a-very-long-name" || w.Provision != "s3-bucket" || w.Provider != "anchor" || w.Module != "files" ||
!strings.Contains(w.String(), "a-machine-with-a-very-long-name") || !strings.Contains(w.String(), "s3-bucket") {
t.Errorf("the consumer left out is %+v / %s", w, w.String())
}
if len(got.Rendering.Withheld) != 1 {
t.Errorf("the Rendering does not carry it: %+v", got.Rendering.Withheld)
}
}
// 1364/AC3: a credential on record for a consumer that its own resolution binds to another provider is withdrawn, named with where it is
// bound (novox/hq issue 274): the grant stays, with no consumer module asking.
func TestACredentialBoundToAnotherProviderIsWithdrawnAndSaidWhereItIsBound(t *testing.T) {
in := aGrantMesh(t)
second := objects()
second.Module = "objects2"
in.Catalogue["objects2"] = second
in.Nodes = append(in.Nodes, Node{Name: "ace", Place: Place{Site: "there", Address: "10.77.0.3"},
Assigned: []string{overlay.Name, "objects2"}, Capabilities: map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true}})
nodeOf(t, &in, "laptop").Pins = map[string]catalogue.Chosen{"s3-bucket": {Node: "ace", Module: "objects2"}}
got := rendered(t, in, "anchor", Foreseeing)
if len(got.Unbound) != 1 {
t.Fatalf("unbound %+v grants %+v", got.Unbound, got.Rendering.Grants)
}
u := got.Unbound[0]
if u.Provision != "s3-bucket" || u.Provider != "anchor" || u.Consumer != "laptop" || u.Module != "files" ||
len(u.BoundTo) != 1 || u.BoundTo[0] != "ace" || !strings.Contains(u.String(), "ace") {
t.Errorf("unbound is %+v / %s", u, u.String())
}
if len(got.Rendering.Grants) != 1 || got.Rendering.Grants[0].From != "" {
t.Errorf("the withdrawn credential is still asked for: %+v", got.Rendering.Grants)
}
}
func aCertificateMesh(t *testing.T) Input {
t.Helper()
in := aMesh(t)
in.Catalogue["secure"] = catalogue.Manifest{Module: "secure", Version: "1", Certificate: &catalogue.Certificate{Into: "/etc/secure/tls.pem", Authority: "/etc/secure/ca.pem"}}
laptop := nodeOf(t, &in, "laptop")
laptop.Assigned = append(laptop.Assigned, "secure")
return in
}
// 1364/AC4: with no certificate and authority in the Input a stand-in is composed and named; given them, the composition carries them as
// given. A machine that never reported a serving key, and one not on the private network, are refused in the controller's words.
func TestACertificateIsCarriedAsGivenOrStoodInForAndNamed(t *testing.T) {
in := aCertificateMesh(t)
laptop := nodeOf(t, &in, "laptop")
if _, err := Render(in, "laptop", Foreseeing); err == nil || err.Error() != "laptop wants a certificate and has never told the mesh what key it serves with; it joins again to report one" {
t.Fatalf("no serving key: %v", err)
}
laptop.ServingKey = "serving-key"
got := rendered(t, in, "laptop", Foreseeing)
if got.Rendering.Certificate != ForeseenCertificate || got.Rendering.Authority != ForeseenCertificate || len(got.Foreseen) != 1 {
t.Errorf("no certificate in the Input: %q %q foreseen %v", got.Rendering.Certificate, got.Rendering.Authority, got.Foreseen)
}
laptop.Certificate, in.Authority = "the-certificate", "the-authority"
got = rendered(t, in, "laptop", Foreseeing)
if got.Rendering.Certificate != "the-certificate" || got.Rendering.Authority != "the-authority" || len(got.Foreseen) != 0 {
t.Errorf("a certificate given: %q %q foreseen %v", got.Rendering.Certificate, got.Rendering.Authority, got.Foreseen)
}
laptop.Place.Address = "" // placed nowhere: no name inside the mesh
if _, err := Render(in, "laptop", Foreseeing); err == nil || err.Error() != "laptop wants a certificate and is not on the private network, so it has no name inside the mesh to be certified for" {
t.Fatalf("off the network: %v", err)
}
}
func aValidKey(t *testing.T, name string) string { return keyOf(t, name) }
// 1364/AC5 and AC6: a core credential the node needs and the Input holds no box for is composed with a stand-in and returned as one
// to keep; a kept box sealed to the key held now is carried; a node-engine below the vault-box contract is sent none (and nothing is
// asked of it); a machine with no sealing key has its core module left out, said.
func TestACoreBoxTheInputHoldsNoneOfIsReturnedToKeepAndNothingIsWritten(t *testing.T) {
mk := func() Input {
in := aMesh(t)
in.Catalogue["nats"] = catalogue.Manifest{Module: "nats", Version: "1",
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/nats/broker", Taken: catalogue.TakenAtStart}}}
laptop := nodeOf(t, &in, "laptop")
laptop.Assigned = append(laptop.Assigned, "nats")
laptop.SealingKey = aValidKey(t, "laptop")
laptop.Stated, laptop.Contract = true, VaultBoxContract
in.BusMinted = []string{"laptop.nats"}
return in
}
in := mk()
got := rendered(t, in, "laptop", Foreseeing)
box := got.Rendering.VaultSealed["nats"]["broker"]
if string(box) != string(catalogue.ForeseenVaultBox) || len(got.CoreBoxesWanted) != 1 ||
got.CoreBoxesWanted[0] != (CoreBoxWanted{Node: "laptop", Module: "nats", Name: "broker"}) {
t.Errorf("no box held: %s wanted %v", box, got.CoreBoxesWanted)
}
kept := json.RawMessage(`{"v":1,"eph":"e","iv":"i","tag":"t","ct":"c"}`)
in.KeptCoreBoxes = []CoreBox{{Node: "laptop", Module: "nats", Name: "broker", Generation: 3, SealedTo: nodeOf(t, &in, "laptop").SealingKey, Box: kept}}
got = rendered(t, in, "laptop", Foreseeing)
if string(got.Rendering.VaultSealed["nats"]["broker"]) != string(kept) || len(got.CoreBoxesWanted) != 0 {
t.Errorf("a kept box: %s wanted %v", got.Rendering.VaultSealed["nats"]["broker"], got.CoreBoxesWanted)
}
in.KeptCoreBoxes[0].SealedTo = "an-old-key"
if got = rendered(t, in, "laptop", Foreseeing); len(got.CoreBoxesWanted) != 1 {
t.Errorf("a box sealed to an old key is kept: %v", got.CoreBoxesWanted)
}
// Below the vault-box contract: none, and the controller's own seal as before.
nodeOf(t, &in, "laptop").Contract = VaultBoxContract - 1
if got = rendered(t, in, "laptop", Foreseeing); got.Rendering.VaultSealed != nil || len(got.CoreBoxesWanted) != 0 {
t.Errorf("an engine below the contract is sent boxes: %v %v", got.Rendering.VaultSealed, got.CoreBoxesWanted)
}
// No sealing key: the module is left out, said.
nodeOf(t, &in, "laptop").Contract, nodeOf(t, &in, "laptop").SealingKey = VaultBoxContract, ""
got = rendered(t, in, "laptop", Reading)
if why := got.Rendering.NotPlaced["nats"]; !strings.Contains(why, "laptop has no sealing key, so the vault can seal nats's credentials to nothing there") {
t.Errorf("not placed: %v", got.Rendering.NotPlaced)
}
unchanged(t, mk, func(in Input) { rendered(t, in, "laptop", Foreseeing) })
}
// 1364/AC1: the bus's user list is carried for the machine that runs the bus and for no other; without it a stand-in is composed and
// named; the users left out for want of a credential are said; each machine's membership is carried. (The list is composed by the
// controller from the bus records, which stay the controller's: see the commit message.)
func TestTheBusUserListIsCarriedForTheMachineThatRunsTheBus(t *testing.T) {
in := aMesh(t)
in.Catalogue["bus"] = catalogue.Manifest{Module: "bus", Version: "1", BusUsers: "/etc/bus/accounts.conf",
Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh}}}
anchor := nodeOf(t, &in, "anchor")
anchor.Assigned = append(anchor.Assigned, "bus")
in.BusUsers = "accounts { anchor.bus: hash }"
in.BusMissing = []string{"laptop.files", "laptop.web"}
in.BusMemberships = map[string]string{"laptop": "sealed-membership"}
got := rendered(t, in, "anchor", Foreseeing)
if got.Rendering.BusUsers != in.BusUsers || len(got.Foreseen) != 0 {
t.Errorf("the list is %q foreseen %v", got.Rendering.BusUsers, got.Foreseen)
}
if len(got.Said) != 1 || got.Said[0] != "the bus's user list leaves out 2 user(s) the mesh has minted no credential for: laptop.files, laptop.web. Each is a user that cannot connect until one is issued" {
t.Errorf("said %v", got.Said)
}
if other := rendered(t, in, "laptop", Foreseeing); other.Rendering.BusUsers != "" || other.Rendering.BusMembership != "sealed-membership" {
t.Errorf("laptop: list %q membership %q", other.Rendering.BusUsers, other.Rendering.BusMembership)
}
in.BusUsers = ""
got = rendered(t, in, "anchor", Foreseeing)
if got.Rendering.BusUsers != ForeseenBusUsers || len(got.Foreseen) != 1 {
t.Errorf("no list in the Input: %q foreseen %v", got.Rendering.BusUsers, got.Foreseen)
}
}
// 1364/AC7: nothing the composition says carries a secret value the Input holds.
func TestNoSecretValueOfTheInputIsInWhatTheCompositionSays(t *testing.T) {
in := aGrantMesh(t)
in.Issued[0].ForProvider = secretValue
laptop := nodeOf(t, &in, "laptop")
in.Catalogue["assistant"] = anAssistant()
laptop.Assigned = append(laptop.Assigned, "assistant")
laptop.SealingKey = keyOf(t, "laptop")
in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made", SealedTo: laptop.SealingKey, Sealed: secretValue}}
in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialHeld, Sealed: secretValue}}
for _, node := range []string{"anchor", "laptop"} {
got := rendered(t, in, node, Foreseeing)
for _, said := range append(append([]string{}, got.Foreseen...), got.Said...) {
if strings.Contains(said, secretValue) {
t.Errorf("%s: %q carries a secret value", node, said)
}
}
}
}
// A consumer no credential was made for yet is granted with a stand-in and named foreseen, for every form whose credential
// the vault mints or takes in; an offer of form none has no credential (issue 1126, 2026-11-02: mesh-cli on ace held for
// "the grant of mcp-endpoint", whose provider node-tools offers form none): its grant carries the consumer and no
// credential, nothing stands in on either side, and neither end asks the vault for one.
func TestAConsumerNoCredentialWasMadeForYetIsGrantedWithAStandInUnlessItsOfferHasNone(t *testing.T) {
for _, tc := range []struct {
form string
cred *catalogue.OfferCredential
}{
{"undeclared", nil},
{catalogue.CredentialFormPair, &catalogue.OfferCredential{Form: catalogue.CredentialFormPair}},
{catalogue.CredentialFormShared, &catalogue.OfferCredential{Own: "access-key"}},
{catalogue.CredentialFormNone, &catalogue.OfferCredential{Form: catalogue.CredentialFormNone}},
} {
in := aGrantMesh(t)
in.Issued = nil
provider := in.Catalogue["objects"]
provider.Provides[0].Credential = tc.cred
if tc.cred != nil && tc.cred.Own != "" {
provider.OwnSecrets = catalogue.OwnSecrets{"access-key": {Path: "/var/lib/mesh/objects/access-key"}}
}
in.Catalogue["objects"] = provider
none := tc.form == catalogue.CredentialFormNone
got := rendered(t, in, "anchor", Foreseeing)
if len(got.Rendering.Grants) != 1 {
t.Fatalf("%s: grants are %+v", tc.form, got.Rendering.Grants)
}
g, wantSealed := got.Rendering.Grants[0], catalogue.ForeseenSealed
if none {
wantSealed = ""
}
if g.Provision != "s3-bucket" || g.Consumer != "laptop" || g.From != "files" || g.Sealed != wantSealed {
t.Errorf("%s: the grant is %+v", tc.form, g)
}
if has(got.Foreseen, "the grant of s3-bucket for files on laptop") == none {
t.Errorf("%s: foreseen %v", tc.form, got.Foreseen)
}
consumer, asProvider, err := CredentialsNeeded(in, "laptop")
_, anchorProvides, err2 := CredentialsNeeded(in, "anchor")
if err != nil || err2 != nil || len(asProvider) != 0 || (len(consumer) == 0) != none || (len(anchorProvides) == 0) != none {
t.Errorf("%s: laptop asks %v, anchor provides %v (%v %v)", tc.form, consumer, anchorProvides, err, err2)
}
resolved, err := Resolve(in, "laptop")
if err != nil {
t.Fatalf("%s: %v", tc.form, err)
}
if n := theFilesNeed(t, resolved); (n.Sealed == "") != none || (len(resolved.Foreseen) == 0) != none {
t.Errorf("%s: the consumer's credential is %q, foreseen %v", tc.form, n.Sealed, resolved.Foreseen)
}
if lap := rendered(t, in, "laptop", Foreseeing); none && len(lap.Foreseen) != 0 {
t.Errorf("%s: the consumer stands in for %v", tc.form, lap.Foreseen)
}
// Only the provider's own: the consumer is granted nothing, and nothing in the Input changed.
if len(rendered(t, in, "laptop", Foreseeing).Rendering.Grants) != 0 {
t.Errorf("%s: the consumer is granted its own credential", tc.form)
}
if len(in.Issued) != 0 {
t.Errorf("%s: a row was added to the Input", tc.form)
}
// A row made long ago for an offer of form none (the controller's) carries no credential either.
in.Issued = []Issued{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", ForProvider: "sealed-to-anchor"}}
if g := rendered(t, in, "anchor", Foreseeing).Rendering.Grants; none && (len(g) != 1 || g[0].Sealed != "") {
t.Errorf("%s: an old row's grant is %+v", tc.form, g)
}
}
// A credential only a person gives and nobody has is no grant.
in := aGrantMesh(t)
in.Issued = nil
in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialNotGiven}}
if got := rendered(t, in, "anchor", Foreseeing); len(got.Rendering.Grants) != 0 {
t.Errorf("a credential nobody gave is granted: %+v", got.Rendering.Grants)
}
}
// And a consumer whose identity overflows the provision's bound is judged before any credential is made for it (issue 263).
func TestAnOverflowingConsumerNoCredentialWasMadeForYetIsWithheld(t *testing.T) {
in := aGrantMesh(t)
in.Issued = nil
in.Catalogue["resolver"] = storeManifests()[1]
in.Catalogue["networkmanager"] = catalogue.Manifest{Module: "networkmanager", Version: "1", Requires: []string{"wildcard-resolution", "s3-bucket"}}
nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "networkmanager")
nodeOf(t, &in, "anchor").Assigned = append(nodeOf(t, &in, "anchor").Assigned, "resolver")
in.Holdings = []catalogue.Held{{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "resolver", Site: "here"}}
got := rendered(t, in, "anchor", Foreseeing)
if len(got.Withheld) != 1 || got.Withheld[0].Module != "networkmanager" {
t.Fatalf("withheld %+v", got.Withheld)
}
}
// The grants come in the order the controller reads them, whatever order the Input gives its rows.
func TestGrantsComeInTheOrderOfProvisionConsumerModuleAndLocal(t *testing.T) {
in := aGrantMesh(t)
in.Issued = []Issued{
{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Local: "b", Provider: "anchor", ForProvider: "x"},
{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Local: "a", Provider: "anchor", ForProvider: "x"},
}
rows, err := newComposer(in).rowsFor("anchor")
// The credential the same send would make (no local name) comes first, then the two the Input gives, a before b.
if err != nil || len(rows) != 3 || rows[0].Local != "" || rows[1].Local != "a" || rows[2].Local != "b" {
t.Errorf("rows are %+v %v", rows, err)
}
}
// Round 1, finding 4: a member of a secret family a person gave, whose sealed value the Input does not carry, is placed with a
// stand-in and named foreseen, not dropped as if nobody gave it.
func TestAGivenFamilyMemberWithoutItsValueIsAStandInAndNamedForeseen(t *testing.T) {
in := aMesh(t)
in.Catalogue["mailer"] = catalogue.Manifest{Module: "mailer", Version: "1",
OwnSecrets: catalogue.OwnSecrets{"credential-*": {Path: "/var/lib/mailer/credential-*.secret", Taken: catalogue.TakenAtStart, IssuedBy: catalogue.IssuedOutside}}}
laptop := nodeOf(t, &in, "laptop")
laptop.Assigned = append(laptop.Assigned, "mailer")
laptop.SealingKey = keyOf(t, "laptop")
in.OwnSecrets = []OwnSecretRow{
{Node: "laptop", Module: "mailer", Name: "credential-a", Origin: "accepted", SealedTo: laptop.SealingKey},
{Node: "laptop", Module: "mailer", Name: "credential-b", Origin: "accepted", SealedTo: laptop.SealingKey, Sealed: "b-sealed"},
}
got := rendered(t, in, "laptop", Foreseeing)
needed := got.Rendering.Needed["mailer"]
if needed["credential-a"] != catalogue.ForeseenSealed || needed["credential-b"] != "b-sealed" {
t.Errorf("needed %v", needed)
}
if !has(got.Foreseen, "mailer/credential-a") || has(got.Foreseen, "mailer/credential-b") {
t.Errorf("foreseen %v", got.Foreseen)
}
}
// Round 1, finding 5: the suffix internal names end in is a field of the Input, and the environment is never read.
func TestTheInternalSuffixIsTheInputsAndNeverTheEnvironments(t *testing.T) {
t.Setenv(overlay.SuffixVar, "from-the-environment")
in := aGrantMesh(t)
got := rendered(t, in, "anchor", Foreseeing)
if got.Rendering.Suffix != "internal" || got.Rendering.Grants[0].At != "laptop.internal" {
t.Errorf("by default: suffix %q, grant at %q", got.Rendering.Suffix, got.Rendering.Grants[0].At)
}
in.InternalSuffix = ".lan"
got = rendered(t, in, "anchor", Foreseeing)
if got.Rendering.Suffix != "lan" || got.Rendering.Grants[0].At != "laptop.lan" {
t.Errorf("given: suffix %q, grant at %q", got.Rendering.Suffix, got.Rendering.Grants[0].At)
}
where, err := newComposer(in).whereEveryoneIs()
if err != nil || where["anchor"] != "anchor.lan" {
t.Errorf("where everyone is: %v %v", where, err)
}
if names := newComposer(in).namesInTheMesh(); names["anchor.lan"] == "" || names["anchor.internal"] != "" {
t.Errorf("names: %v", names)
}
}
// Round 2: a need answered on the same machine is a grant too (catalogue resolve.go gives it From: the node's own name).
func TestAConsumerOnTheProvidersOwnNodeIsGrantedWithAStandInAndNamedForeseen(t *testing.T) {
in := aGrantMesh(t)
in.Issued = nil
laptop, anchor := nodeOf(t, &in, "laptop"), nodeOf(t, &in, "anchor")
laptop.Assigned = []string{overlay.Name}
anchor.Assigned = append(anchor.Assigned, "files")
got := rendered(t, in, "anchor", Foreseeing)
if len(got.Rendering.Grants) != 1 || got.Rendering.Grants[0].Consumer != "anchor" || got.Rendering.Grants[0].Sealed != catalogue.ForeseenSealed {
t.Fatalf("grants are %+v", got.Rendering.Grants)
}
if !has(got.Foreseen, "the grant of s3-bucket for files on anchor") {
t.Errorf("foreseen %v", got.Foreseen)
}
}
+90
View File
@@ -0,0 +1,90 @@
package compose
import (
"crypto/ecdh"
"crypto/rand"
"encoding/base64"
"encoding/json"
"reflect"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"github.com/novox/mesh-host/internal/overlay"
)
// aPublicKey is a key a machine could have reported, made as mesh-controller's mesh_for_test.go aPublicKey makes it.
func aPublicKey(t *testing.T) string {
t.Helper()
k, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes())
}
// keys are made once per run, so two Inputs made the same way are equal.
var keys = map[string]string{}
func keyOf(t *testing.T, name string) string {
t.Helper()
if keys[name] == "" {
keys[name] = aPublicKey(t)
}
return keys[name]
}
// theOverlayModule is the module the mesh provides for itself, as mesh-controller's providedModules makes it
// (cmd/mesh-controller/modules.go at 6c2a8d62).
func theOverlayModule(t *testing.T) catalogue.Manifest {
t.Helper()
var m catalogue.Manifest
b, err := json.Marshal(overlay.Manifest())
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(b, &m); err != nil {
t.Fatal(err)
}
return m
}
// aMesh is the Input of mesh-controller's aMesh (cmd/mesh-controller/mesh_for_test.go at 6c2a8d62): two placed
// machines, `anchor` the hub, both reporting container-runtime, wireguard and systemd, both assigned what puts them on the
// private network, each at <name>.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")
}
}
+583
View File
@@ -0,0 +1,583 @@
package frombus
import (
"context"
"errors"
"fmt"
"maps"
"regexp"
"slices"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/compose"
"github.com/novox/mesh-host/internal/compose/resolution"
)
// Answer is a node's answer to its proposal's placement candidates (novox/hq ADR 0567 §3, ADR 0559 §4): s.Node composed in shadow with
// s.Placements laid over the trunk's placement (compose.CompositionsShadow, its own sealed values and boxes through r as
// for its own composition), compared with what it runs (delivery.CompareComposed). It applies nothing and writes
// nothing; the proposal and the revision are the candidate's, said back in the answer.
//
// The node's own composition is compared only where the node runs one. fps gives the build each module is composed at: what the node runs, and for a module the candidate places that the
// node does not run, the build the trunk holds; a placed module with none is added, with no build of it known. What the
// composition stands in for is judged: a value of a module the proposal adds (on this node or another: its credential, own
// secrets and boxes are made only once it is placed, ADR 0549 §5) is not yet issued, which is no refusal; any other
// stand-in is a value missing for a module the node runs, a refusal naming it ("node" when it is the node's own).
func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string, running []delivery.Composed, proposal string, revision uint64) delivery.CandidateAnswer {
a := delivery.CandidateAnswer{Proposal: proposal, Revision: revision}
refuse := func(err error) delivery.CandidateAnswer {
a.Refused = err.Error()
a.Verdict = delivery.CandidateRefused
return a
}
trunk, err := r.Placements(ctx)
if err != nil {
return refuse(fmt.Errorf("frombus: placement: %w", err))
}
if s.Running == nil {
s.Running = running
}
// Every module it cannot compose is named, not the first only (issue 1126): a build it cannot read or take in, and
// below, a module the composition refuses, each composed as a stub so the rest is composed and compared.
failed := map[string]string{}
in, err := input(ctx, r, mesh, s, func(module string, err error) { failed[module] = err.Error() }, nil)
if err != nil {
return refuse(err)
}
asConsumer, asProvider, err := compose.CredentialsNeeded(in, s.Node)
if err != nil {
return refuse(err)
}
// What the node runs, by module. A consumer's credential is asked only for a module it runs: the vault refuses the
// whole ask for a consuming module not assigned to the node (mesh-vault's own), and a module the candidate adds is
// assigned only after the merge (ADR 0549 §5), so its credential is left missing, a stand-in answered as not yet
// issued under Added (review of L2b, round 1, finding 1).
runs := map[string]bool{}
for _, c := range running {
if c.Module != "" {
runs[c.Module] = true
}
}
var consumed []compose.CredentialKey
for _, k := range asConsumer {
if runs[k.ConsumerModule] {
consumed = append(consumed, k)
}
}
asConsumer = consumed
if len(asConsumer)+len(asProvider) > 0 {
if in.Credentials, in.Issued, err = r.Credentials(ctx, s.Node, asConsumer, asProvider); err != nil {
return refuse(fmt.Errorf("frombus: the credentials of %s: %w", s.Node, err))
}
}
composed, skipped, stood, err := composeEach(in, s.Node, fps, false, s.Running, failed, ranOf(ctx, r, mesh, s.Running, fps))
if err != nil {
// Not one module's: the node's own (a value served elsewhere, the mesh's network), said once as the node's.
if len(failed) == 0 {
return refuse(err)
}
failed[delivery.HaveNodeWord] = err.Error()
composed, skipped, stood = nil, nil, nil
}
// The node's own composition is judged only where the node runs one: one that runs none applies it only once a want
// names it (the node agent's own composer hands it on that want alone), so a candidate does not add it.
runsOwn := false
for _, c := range running {
runsOwn = runsOwn || c.Module == ""
}
if !runsOwn {
for key, c := range composed {
if c.Module == "" {
delete(composed, key)
}
}
}
if _, whole := failed[delivery.HaveNodeWord]; whole {
// Nothing composed to compare: every refusal is said, and nothing else.
for _, m := range sortedKeys(failed) {
a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: failed[m]})
}
heldAsToday(&a, s.HeldToday)
a.Verdict = delivery.VerdictOf(a)
return a
}
// A module the composition folds inside the node's own is judged there: the agent also says it runs by module, from
// the declaration it kept before the switch (agent.Everything), which is no removal (issue 1126, seed #531).
if len(s.InOwn) > 0 {
var judged []delivery.Composed
for _, c := range running {
if c.Module == "" || !slices.Contains(s.InOwn, c.Module) {
judged = append(judged, c)
}
}
running = judged
}
cmp := delivery.CompareComposedWith(composed, running, s.SameValue)
// The answer rides in the node's have key: its shown bodies are bounded, the rest named only.
budget := MaxShown
for i := range cmp.Differs {
kept := cmp.Differs[i].Bodies[:0]
for _, b := range cmp.Differs[i].Bodies {
if n := len(b.Composed) + len(b.Running); n <= budget {
budget -= n
kept = append(kept, b)
} else {
kept = append(kept, delivery.ResourceBodies{ID: b.ID, Why: b.Why})
a.Note = "some differing resources are named without their bodies: the answer holds at most " +
fmt.Sprint(MaxShown) + " bytes of them"
}
}
cmp.Differs[i].Bodies = kept
}
// A refused module was composed as a stub: what it composed is no difference; its refusal is said.
notFailed := func(m string) bool { _, ok := failed[m]; return !ok }
a.Same = slices.DeleteFunc(cmp.Same, func(m string) bool { return !notFailed(m) })
a.Removed = slices.DeleteFunc(cmp.Removed, func(m string) bool { return !notFailed(m) })
a.Differs = slices.DeleteFunc(cmp.Differs, func(d delivery.ModuleDiffers) bool { return !notFailed(d.Module) })
cmp.Added = slices.DeleteFunc(cmp.Added, func(d delivery.ModuleAdded) bool { return !notFailed(d.Module) })
skipped = slices.DeleteFunc(skipped, func(m string) bool { return !notFailed(m) })
// What the proposal adds, by node and module: placed by a candidate and not by the trunk on that node.
added := map[string]bool{}
for node, p := range s.Placements {
if p == nil {
continue
}
was := map[string]bool{}
for _, m := range trunk[node].Modules {
was[m.Module] = true
}
for _, m := range p.Modules {
if !was[m.Module] {
added[node+"/"+m.Module] = true
}
}
}
unissued := map[string][]string{}
refusals := map[string][]string{}
for _, x := range stood {
node, module := standsInFor(x, s.Node)
switch {
case node == s.Node && runs[module]:
// A value missing for a module the node runs is a refusal, whatever the trunk places (the seed's trunk is
// empty, and every module counts as the group's).
refusals[module] = append(refusals[module], x)
case module != "" && added[node+"/"+module]:
if node == s.Node {
unissued[module] = append(unissued[module], x)
} // another node's addition: the module here that carries it differs, which the assigner judges
case module == "" || node != s.Node:
// The node's own, or a value this node holds for a module elsewhere (a grant it provides): said as the node's.
refusals[delivery.HaveNodeWord] = append(refusals[delivery.HaveNodeWord], x)
default:
refusals[module] = append(refusals[module], x)
}
}
addedHere := map[string]delivery.ModuleAdded{}
for _, m := range cmp.Added {
addedHere[m.Module] = m
}
for _, m := range skipped { // placed, and no build given to compose it at
addedHere[m] = delivery.ModuleAdded{Module: m, Unissued: []string{"no build of " + m + " is known to compose it at"}}
}
for m, xs := range unissued {
got := addedHere[m]
got.Module = m
got.Unissued = append(got.Unissued, xs...)
addedHere[m] = got
}
for _, m := range sortedKeys(addedHere) {
a.Added = append(a.Added, addedHere[m])
}
for _, m := range sortedKeys(refusals) {
if _, isAdded := addedHere[m]; isAdded {
continue // a module the candidate adds here: what it lacks is said under Added
}
if _, isFailed := failed[m]; isFailed {
continue // said below, as why it could not be composed
}
a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: "stands in for " + strings.Join(refusals[m], ", ")})
}
for _, m := range sortedKeys(failed) {
a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: failed[m]})
}
sort.Slice(a.Refusals, func(i, j int) bool { return a.Refusals[i].Module < a.Refusals[j].Module })
heldAsToday(&a, s.HeldToday)
a.Verdict = delivery.VerdictOf(a)
return a
}
// heldAsToday moves into Held each refusal of a module the node keeps today as it runs (Scope.HeldToday, with how:
// held, failed, running a build of the old form): the candidate keeps it as the node does, which is no change (issue
// 1126: a seed reproduces today, and today keeps them so). Each says how it stands today (ModuleRefused.Today). No
// module is held behind another (issue 1960): a module that reads from a held one composes with what it last offered.
func heldAsToday(a *delivery.CandidateAnswer, today map[string]string) {
if len(today) == 0 {
return
}
kept := a.Refusals[:0]
for _, r := range a.Refusals {
if w, keep := today[r.Module]; keep {
r.Today = w
a.Held = append(a.Held, r)
} else {
kept = append(kept, r)
}
}
a.Refusals = kept
if len(a.Refusals) == 0 {
a.Refusals = nil
}
}
// maxCulprits bounds the modules one refusal is tried on, each a whole composition.
const maxCulprits = 8
// MaxShown bounds the bytes of shown resource bodies in one answer (delivery.ModuleDiffers.Bodies).
const MaxShown = 256 << 10
var (
grantStandIn = regexp.MustCompile(`^the grant of \S+ for (\S+) on (\S+)$`)
// grantOfStandIn is the provision a grant's stand-in is of.
grantOfStandIn = regexp.MustCompile(`^the grant of (\S+) for \S+ on \S+$`)
needsStandIn = regexp.MustCompile(`^(\S+) on (\S+) needs `)
)
// standsInFor is the node and module a stand-in of compose's words is for: an own secret `module/name`, a port
// `port module:port`, a grant `the grant of <provision> for <module> on <node>`, a need `<module> on <node> needs ...`;
// the module empty when the words name none (the certificate, the authority).
func standsInFor(x, node string) (string, string) {
if m := grantStandIn.FindStringSubmatch(x); m != nil {
return m[2], m[1]
}
if m := needsStandIn.FindStringSubmatch(x); m != nil {
return m[2], m[1]
}
if rest, ok := strings.CutPrefix(x, "port "); ok {
if module, _, ok := strings.Cut(rest, ":"); ok {
return node, module
}
}
if module, _, ok := strings.Cut(x, "/"); ok && !strings.Contains(x, " ") {
return node, module
}
return node, ""
}
// composeEach is the one rule, for the node that applies and for its answer to a candidate alike, by which a module's
// own refusal refuses that module and never the node (issue 1126: seed-8 fixed it in the answer alone, and the
// applying path still refused all of novox for de-spiegel's unset route names). The node is composed; a refusal is laid
// on the module whose stub (compose.Stub) changes it (culprit), that module is held (compose.Node.Held) and said in
// failed with the refusal's words, and the rest is composed again, until it composes or the refusal is no single
// module's, which is answered as the node's (err). Applied composes as the node applies (compose.Reading) and holds the
// module placed here each stand-in is for; one for no module placed here (the node's own, a value this node holds for a
// module elsewhere) refuses the node (compose.ErrStoodIn). Not applied, it composes in compose.Foreseeing and answers
// every stand-in for the caller to judge (Answer).
//
// **A held module is what runs of it** (review of the apply, round 1): composed from what running holds of it
// (compose.Carried of its own composition there; the node's own carries it wherever it runs there), so what the others
// read of it (the machine ports its containers publish, the routes reaching it) is what runs, and none of its own
// composition is in out: the node keeps what it last applied.
//
// **A hold never spreads** (issue 1126, issue 1960: de-spiegel's hold for an unset value held mailu, which held
// route-proxy, nftables and postgres on novox). No module is held because a module it reads from is held: a consumer,
// a collector, the provider of what the held one contributes to and the filter where it listens each compose with what
// the held module last offered. That offer (what it contributes, contributes many, listens on) is its manifest at the
// build it runs where one is known (ran: a composition of its own running at another build than it is composed at),
// else the manifest it is composed from. Only the part of the offer that cannot compose is left out, by provision
// (culprit): de-spiegel's routes, its names unset, leave route-proxy without them while mailu keeps its smtp account,
// and the hold says so.
func composeEach(in compose.Input, node string, fps map[string]string, applied bool, running []delivery.Composed, failed map[string]string,
ran func(module string) (catalogue.Manifest, bool)) (out map[string]delivery.Composed, skipped, stood []string, err error) {
mode := compose.Foreseeing
if applied {
mode = compose.Reading
}
runs := map[string]*delivery.Composed{}
for i := range running {
if m := running[i].Module; m != "" {
runs[m] = &running[i]
}
}
src := map[string]catalogue.Manifest{} // each held module's manifest before it was held, offering what it last offered
left := map[string]map[string]bool{} // by held module, what of its offer is left out (leaveOut)
hold := func(m, why, leave string) {
if _, done := failed[m]; !done {
failed[m] = why // a held module tried again keeps the words it was first held for
}
if _, ok := src[m]; !ok {
man, ok := manifestOn(in, node, m)
if !ok {
return
}
if ran != nil {
if was, ok := ran(m); ok {
man.Contributes, man.ContributesMany, man.Listens = was.Contributes, was.ContributesMany, was.Listens
}
}
src[m] = man
}
if leave != "" {
if left[m] == nil {
left[m] = map[string]bool{}
}
left[m][leave] = true
if leave != leaveAll && leave != leaveOwn {
failed[m] += "; composed without what it contributes to " + leave + ", which does not compose"
}
}
in = heldAs(in, node, m, src[m], left[m], runs[m])
}
for _, m := range sortedKeys(failed) {
hold(m, failed[m], "")
}
for {
out, skipped, stood, err = compose.CompositionsIn(in, node, fps, mode)
if err != nil {
module, leave := culprit(in, node, fps, mode, failed, err)
if module == "" {
return nil, nil, nil, firstReason(err, failed)
}
hold(module, err.Error(), leave)
continue
}
again := false
if applied && len(stood) > 0 {
placed := map[string]bool{}
for _, m := range assignedOn(in, node) {
placed[m] = true
}
of := map[string][]string{}
var nodes []string
for _, x := range stood {
if n, m := standsInFor(x, node); n == node && placed[m] {
of[m] = append(of[m], x)
} else {
nodes = append(nodes, x)
}
}
for _, m := range sortedKeys(of) {
if _, done := failed[m]; !done {
hold(m, "stands in for "+strings.Join(of[m], ", "), "")
again = true
continue
}
// Already held, and still a stand-in in another module's composition: a grant not yet issued for what
// it contributes (a module held for its own secret keeps its contributions, leaveOwn). Its contribution
// to that provision is left out, so the provider never applies a stand-in (issue 774, review round 1).
for _, x := range of[m] {
if g := grantOfStandIn.FindStringSubmatch(x); g != nil && !left[m][g[1]] {
hold(m, failed[m], g[1])
again = true
}
}
}
if !again && len(nodes) > 0 {
return nil, nil, nil, fmt.Errorf("%w on %s: %s", compose.ErrStoodIn, node, strings.Join(nodes, ", "))
}
}
if !again {
break
}
}
skipped = slices.DeleteFunc(skipped, func(m string) bool { _, held := failed[m]; return held })
return out, skipped, stood, nil
}
// firstReason is err, a refusal no culprit is found for, led by the first reason of each held module it names: a held
// module's stand-in refused later never replaces why it was held (issue 774, 2026-10-30: mesh-self-check on ace held for
// "has no bus credential", then refused as "runs as its own account and declares no own secret broker", its bare stub's
// words, which were all the proposal's refusal said).
func firstReason(err error, failed map[string]string) error {
var named []string
for _, m := range sortedKeys(failed) {
if strings.Contains(err.Error(), m) {
named = append(named, m+" is held: "+failed[m])
}
}
if len(named) == 0 {
return err
}
return fmt.Errorf("%s; its stand-in is then refused: %w", strings.Join(named, "; "), err)
}
// blames says the composition with m's stub (again) makes m the culprit of err: it composes, or fails otherwise. The
// runtime's stub is never a runtime (no build is taken in, so it carries no bundle) and always fails otherwise: it is
// the culprit only where its stub composes (issue 774, 2026-10-30 19:30: node-tools held for any error naming it).
func blames(m string, again, err error) bool {
if m == catalogue.RuntimeModule {
// Or where its stub fails only for the modules of their own account it would run (issue 774): they are held
// after it, each for its own words, and the node composes the rest.
var none *resolution.NoRuntimeToRunError
return again == nil || errors.As(again, &none) && none.Held
}
return again == nil || again.Error() != err.Error()
}
// leaveAll leaves out the whole of a held module's offer and its own secrets (a bare stub); leaveOwn its own secrets
// alone, its offer kept; any other word leaves out what it contributes to that provision alone.
//
// leaveOwn is tried before leaveAll: a module held for an own secret never made (nextcloud's admin, issue 774,
// 2026-10-30) keeps what it contributes, so its providers' received files keep it and their helpers never retire its
// login while it is held.
const (
leaveAll = "*"
leaveOwn = "own-secrets"
)
// assignedOn is the modules placed on node, in placement order.
func assignedOn(in compose.Input, node string) []string {
for _, n := range in.Nodes {
if n.Name == node {
return n.Assigned
}
}
return nil
}
// manifestOn is the manifest node composes module from: its Kept, else the catalogue's.
func manifestOn(in compose.Input, node, module string) (catalogue.Manifest, bool) {
for _, n := range in.Nodes {
if n.Name == node {
if man, ok := n.Kept[module]; ok {
return man, true
}
}
}
man, ok := in.Catalogue[module]
return man, ok
}
// culprit is the module placed on node whose composition the error is of, and what of its offer to leave out: the
// first, in placement order, whose stub (compose.Stub) makes the composition in mode succeed or fail otherwise (leave
// ""); else the first whose stub without what it contributes to one provision does (leave that provision: de-spiegel's
// routes, its names unset, and not its smtp account, issue 1960); else the first whose bare stub does (leaveAll: its
// stub contributing nothing, its own secret never issued, issue 1126 seed-8). Empty when none does (the error is not
// one module's). Modules already refused are passed over for their stub.
func culprit(in compose.Input, node string, fps map[string]string, mode compose.Mode, failed map[string]string, err error) (string, string) {
assigned := assignedOn(in, node)
// Only the modules the error names: the words of a module's refusal name it. Each try is a whole composition, so a
// refusal naming none is no module's, answered at once rather than after a composition per module (issue 1126: ace's
// answer took 7m46s).
var order []string
for _, m := range assigned {
if strings.Contains(err.Error(), m) && len(order) < maxCulprits {
order = append(order, m)
}
}
changes := func(m, leave string) bool {
_, _, _, again := compose.CompositionsIn(stubbed(in, node, m, leave), node, fps, mode)
return blames(m, again, err)
}
for _, m := range order {
if _, done := failed[m]; done {
continue
}
if changes(m, "") {
return m, ""
}
}
for _, m := range order {
man, ok := manifestOn(in, node, m)
if !ok {
continue
}
// One already held is tried again too: held for its build, its stub may still refuse (its own secret never
// issued, issue 1126). A provision named in the error is tried first, each at one more composition.
var to []string
for p := range man.Contributes {
to = append(to, p)
}
for p := range man.ContributesMany {
if _, both := man.Contributes[p]; !both {
to = append(to, p)
}
}
sort.SliceStable(to, func(i, j int) bool {
return strings.Contains(err.Error(), " "+to[i]) && !strings.Contains(err.Error(), " "+to[j])
})
for _, p := range to {
if changes(m, p) {
return m, p
}
}
if isBare(man) {
continue
}
if len(man.OwnSecrets) > 0 && changes(m, leaveOwn) {
return m, leaveOwn
}
if changes(m, leaveAll) {
return m, leaveAll
}
}
return "", ""
}
// isBare says a stub contributes nothing and declares no own secret (stubbed, leaveAll).
func isBare(m catalogue.Manifest) bool {
return len(m.Contributes) == 0 && len(m.ContributesMany) == 0 && len(m.OwnSecrets) == 0
}
// stubbed is in with module held on node as its stub alone (heldAs, nothing of it running), from the manifest node
// composes it from, leave left out of its offer: a culprit's try.
func stubbed(in compose.Input, node, module, leave string) compose.Input {
man, ok := manifestOn(in, node, module)
if !ok {
return in
}
var left map[string]bool
if leave != "" {
left = map[string]bool{leave: true}
}
return heldAs(in, node, module, man, left, nil)
}
// heldAs is in with module held on node (compose.Node.Held): composed from man as what runs of it (compose.Carried of
// runs; its stub, compose.Stub, when runs is nil or holds nothing of it), as node's Kept; with what left names out of
// its offer: a provision's contributions, with leaveOwn its own secrets alone, or with leaveAll everything it contributes
// and its own secrets, none of a held module's own composition being applied, so what it has not been issued (restic's
// repository on a node it never ran on) is no refusal of the rest.
func heldAs(in compose.Input, node, module string, man catalogue.Manifest, left map[string]bool, runs *delivery.Composed) compose.Input {
out := in
out.Nodes = append([]compose.Node(nil), in.Nodes...)
for i := range out.Nodes {
if out.Nodes[i].Name != node {
continue
}
if left[leaveAll] {
man.Contributes, man.ContributesMany, man.OwnSecrets = nil, nil, nil
} else if len(left) > 0 {
if left[leaveOwn] {
man.OwnSecrets = nil
}
contributes := map[string]map[string]any{}
for p, v := range man.Contributes {
if !left[p] {
contributes[p] = v
}
}
many := maps.Clone(man.ContributesMany)
for p := range left {
delete(many, p)
}
man.Contributes, man.ContributesMany = contributes, many
}
kept := make(map[string]catalogue.Manifest, len(out.Nodes[i].Kept)+1)
for k, v := range out.Nodes[i].Kept {
kept[k] = v
}
kept[module] = compose.Carried(man, module, runs)
out.Nodes[i].Kept = kept
held := make(map[string]bool, len(out.Nodes[i].Held)+1)
for k, v := range out.Nodes[i].Held {
held[k] = v
}
held[module] = true
out.Nodes[i].Held = held
}
return out
}
@@ -0,0 +1,596 @@
package frombus
import (
"context"
"encoding/json"
"fmt"
"os"
"reflect"
"slices"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/compose"
"github.com/novox/mesh-host/internal/compose/resolution"
"github.com/novox/mesh-host/internal/overlay"
)
// Issue 1126 (the seed's shadow, round 2): a node's answer names every module it cannot compose, not the first only;
// and a module folded into its own composition that names a build of the old form (de-spiegel on novox, nats, photos,
// systemd-resolved: their haves name a build-... id and no fingerprint) is left to the node, carried as it runs.
func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
ctx := context.Background()
image := "alpine@sha256:" + strings.Repeat("a", 64)
manifest := func(module string) []byte {
// Its data names its directory, as nats's names ${dir:mesh-state} (issue 1126: novox's own composition refused
// "nats says ${dir:mesh-state}, and nats declares no directory mesh-state" when its stub held no directory).
return []byte(`{"module":"` + module + `","version":"1","capabilities":["container-runtime"],` +
`"own-secrets":{"token":"${dir:state}/token"},` +
`"resources":[{"id":"state","type":"directory","path":"/var/lib/` + module + `","mode":"0700"},` +
`{"id":"server","type":"container","name":"` + module + `","artifact":"site"}],` +
`"build":{"artifacts":[{"name":"site","kind":"upstream","from":"` + image + `"}]}}`)
}
lemurs := []byte(`{"module":"lemurs","version":"1","resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`)
fp := func(c string) string { return "src2:" + strings.Repeat(c, 64) }
mk := func(placed ...string) *bus {
p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}}
for _, m := range placed {
p.Modules = append(p.Modules, delivery.PlacedModule{Module: m})
}
b := &bus{placements: map[string]delivery.Placement{"n1": p},
records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd", "container-runtime"}}},
modules: map[string]delivery.Module{"lemurs": {Manifest: delivery.ManifestDigest(lemurs)}},
manifests: map[string][]byte{delivery.ManifestDigest(lemurs): lemurs},
builds: map[string]*delivery.BuilderBuild{}}
for _, m := range []string{"web", "api", "spiegel"} {
b.own = append(b.own, compose.OwnSecretRow{Node: "n1", Module: m, Name: "token", Origin: "made", SealedTo: "sk", Sealed: `"box"`})
}
for _, m := range []string{"web", "api", "spiegel"} {
raw := manifest(m)
b.manifests[delivery.ManifestDigest(raw)] = raw
b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
}
// web and api are built, their keys done and waiting for the reference of their image; spiegel has no build of
// the new form at all.
for i, m := range []string{"web", "api"} {
mod := b.modules[m]
mod.FP2 = fp(string(rune('1' + i)))
b.modules[m] = mod
b.builds[m+" "+mod.FP2] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
Artifacts: []delivery.BuiltArtifact{{Name: "site", Kind: "image", Digest: "sha256:" + strings.Repeat("b", 64)}}}
}
return b
}
given := func(b *bus) map[string]*delivery.Placement {
p := b.placements["n1"]
return map[string]*delivery.Placement{"n1": &p}
}
// Every refusal: two builds taken in, and a module the composition refuses (its image never built), each named.
b := mk("lemurs", "web", "api", "spiegel")
a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b)},
map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3)
var refused []string
for _, r := range a.Refusals {
refused = append(refused, r.Module)
}
if a.Verdict != delivery.CandidateRefused || a.Refused != "" || !reflect.DeepEqual(refused, []string{"api", "spiegel", "web"}) {
t.Errorf("every refusal: %+v, want api, spiegel and web refused, each named", a)
}
for _, r := range a.Refusals {
want := map[string]string{"api": "waits for the reference of site", "web": "waits for the reference of site",
"spiegel": "spiegel has not been built for this mesh"}[r.Module]
if !strings.Contains(r.Why, want) {
t.Errorf("%s refused as %q, want %q", r.Module, r.Why, want)
}
}
if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": a}}); err != nil {
t.Errorf("the answer is not one a have holds: %v (%s)", err, raw)
}
if lemursOK := slices.ContainsFunc(a.Added, func(m delivery.ModuleAdded) bool { return m.Module == "lemurs" }); !lemursOK {
t.Errorf("lemurs, which composes, is not answered beside the refusals: %+v", a)
}
// Issue 1126: a module the node holds today that the candidate holds too is held as today, no refusal: with every
// refused module held today the verdict is no refusal, and each is named under Held.
today := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held", "spiegel": "held", "web": "held"}},
map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3)
if today.Verdict == delivery.CandidateRefused || len(today.Refusals) != 0 || len(today.Held) != 3 || today.Held[0].Why == "" {
t.Errorf("held as today: %+v", today)
}
// A refused module the node does not hold today stays a refusal: holding only api today leaves spiegel and web refused.
part := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held"}},
map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3)
if part.Verdict != delivery.CandidateRefused || len(part.Refusals) != 2 || len(part.Held) != 1 || part.Held[0].Module != "api" {
t.Errorf("held as today in part: %+v", part)
}
// No module is held behind one kept today (issue 1960): mailu's own refusal stays a refusal beside de-spiegel's hold.
if a := (delivery.CandidateAnswer{Refusals: []delivery.ModuleRefused{{Module: "de-spiegel", Why: "a value is unset"},
{Module: "mailu", Why: "no build"}}}); true {
heldAsToday(&a, map[string]string{"de-spiegel": "runs a build of the old form"})
if len(a.Refusals) != 1 || a.Refusals[0].Module != "mailu" || len(a.Held) != 1 {
t.Errorf("held as today spreads: %+v", a)
}
}
// The fold: spiegel runs inside the node's own composition at a build of the old form. Its resources are carried as
// they run, each after the one it runs after: the node's own is the one it runs, and the node composes itself.
b = mk("lemurs", overlay.Name, "spiegel")
p := b.placements["n1"]
p.Decided.Endpoint = "n1.example:51820"
b.placements["n1"] = p
fps := map[string]string{"lemurs": fp("e")}
stub := Scope{Node: "n1"}
in, err := Input(ctx, b, Mesh{}, stub)
if err != nil {
t.Fatal(err)
}
in.Nodes[0].Kept = map[string]catalogue.Manifest{"spiegel": compose.Stub(in.Catalogue["spiegel"])}
base, _, err := compose.Compositions(in, "n1", fps, compose.Reading)
if err != nil {
t.Fatal(err)
}
// A refusal naming no module placed here is no module's: no compose is spent stubbing each in turn (issue 1126: ace's
// answer took 7m46s, one compose per module), though here a stub would compose.
if m, _ := culprit(in, "n1", fps, compose.Foreseeing, map[string]string{}, fmt.Errorf("nothing in this mesh provides \"artifact-store\"")); m != "" {
t.Errorf("a refusal naming no placed module was laid on %s", m)
}
ownKey, _ := delivery.ComposedNodeKey("n1")
own := base[ownKey]
carriedRes := []json.RawMessage{
json.RawMessage(`{"id":"spiegel.state","type":"directory","path":"/var/lib/spiegel","mode":"0700"}`),
json.RawMessage(`{"id":"spiegel.server","type":"container","name":"spiegel","image":"registry.example/spiegel@sha256:` + strings.Repeat("c", 64) + `"}`)}
// They run after the node's first own resource.
own.Resources = append(append(append([]json.RawMessage{}, own.Resources[:1]...), carriedRes...), own.Resources[1:]...)
var running []delivery.Composed
for k, c := range base {
if k == ownKey {
c = own
}
running = append(running, c)
}
scope := Scope{Node: "n1", InOwn: []string{"spiegel"}, Running: running}
got, _, held, err := Compose(ctx, b, Mesh{}, scope, fps)
if err == nil && len(held) > 0 {
err = fmt.Errorf("modules held: %v", held)
}
if err != nil {
t.Fatalf("the node does not compose itself with spiegel folded at a build of the old form: %v", err)
}
gotOwn, _ := json.Marshal(got[ownKey].Resources)
wantOwn, _ := json.Marshal(own.Resources)
if string(gotOwn) != string(wantOwn) {
t.Errorf("the node's own composition is\n%s\nwant what runs\n%s", gotOwn, wantOwn)
}
a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, running, "p-h1", 3)
if a.Verdict != delivery.CandidateSame {
t.Errorf("the fold's answer: %+v, want the same", a)
}
// (A) The agent also says spiegel runs by module, from the declaration kept before the switch (agent.Everything): a
// module the composition folds is judged inside the node's own, not as removed (issue 1126, seed #531's answer).
keptToo := append(append([]delivery.Composed{}, running...), delivery.Composed{Node: "n1", Module: "spiegel", Resources: carriedRes})
if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, keptToo, "p-h1", 3); a.Verdict != delivery.CandidateSame || len(a.Removed) != 0 {
t.Errorf("a folded module running from the kept declaration is answered %+v, want the same", a)
}
// (B) The node's own composition carries its bus membership as it runs it (the controller's, from its store): composed
// the same, not 'in what runs, not composed' (seed #531, novox).
membership := json.RawMessage(`{"id":"bus-membership","type":"file","path":"` + catalogue.BusMembershipPath + `","sealed":"{\"box\":\"m\"}","mode":"0600"}`)
withMembership := make([]delivery.Composed, 0, len(running))
for _, c := range running {
if c.Module == "" {
c.Resources = append(append([]json.RawMessage{}, c.Resources...), membership)
}
withMembership = append(withMembership, c)
}
if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, withMembership, "p-h1", 3); a.Verdict != delivery.CandidateSame {
t.Errorf("the node's own with its bus membership is answered %+v, want the same", a)
}
// Without what runs, the fold composes spiegel from its manifest, as before, and the composition refuses it.
if _, _, held, err := Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}}, fps); err != nil ||
!strings.Contains(held["spiegel"], "spiegel has not been built for this mesh") {
t.Errorf("composed spiegel without what runs: %v %v, want spiegel held alone", err, held)
}
// (C) Issue 1126, C5: what the others read of a module carried as it runs is what runs, never its stub's: the filter
// opens the machine port its running container publishes (de-spiegel's 20005 on novox, not its software's 8080; a
// stub had no container, so snake's 80 on ace was opened beside the route proxy's). And C6: the operator export's file
// a module that keeps it runs is carried as it runs (mesh-vault.kept on novox), since the node holds no export.
listening := []byte(`{"module":"spiegel","version":"1","capabilities":["container-runtime"],` +
`"listens":[{"port":8080,"from":"mesh","why":"the site"}],"own-secrets":{"token":"${dir:state}/token"},` +
`"resources":[{"id":"state","type":"directory","path":"/var/lib/spiegel","mode":"0700"},` +
`{"id":"server","type":"container","name":"spiegel","artifact":"site","ports":["8080"]}],` +
`"build":{"artifacts":[{"name":"site","kind":"upstream","from":"` + image + `"}]}}`)
nft := []byte(`{"module":"nft","version":"1","capabilities":["systemd"],"filtering":{"into":"/etc/nftables.conf"}}`)
vault := []byte(`{"module":"vault","version":"1","capabilities":["systemd"],"keeps":"/var/lib/vault/root",` +
`"resources":[{"id":"service","type":"service","unit":"vault.service","state":"running","boot":"enabled"}]}`)
b = mk("lemurs", overlay.Name, "spiegel", "nft", "vault")
for m, raw := range map[string][]byte{"spiegel": listening, "nft": nft, "vault": vault} {
b.manifests[delivery.ManifestDigest(raw)] = raw
b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
}
p = b.placements["n1"]
p.Decided.Endpoint = "n1.example:51820"
for i := range p.Modules {
if p.Modules[i].Module == "spiegel" {
p.Modules[i].Ports = map[string]int{"8080": 20005}
}
}
b.placements["n1"] = p
rec := b.records["n1"]
rec.OutwardLinks = []string{"eth0"}
b.records["n1"] = rec
fps = map[string]string{"lemurs": fp("e"), "nft": fp("f"), "vault": fp("9")}
if in, err = Input(ctx, b, Mesh{}, stub); err != nil {
t.Fatal(err)
}
in.Nodes[0].Kept = map[string]catalogue.Manifest{"spiegel": compose.Stub(in.Catalogue["spiegel"])}
if base, _, err = compose.Compositions(in, "n1", fps, compose.Reading); err != nil {
t.Fatal(err)
}
keptFile := json.RawMessage(`{"digest":"sha256:` + strings.Repeat("d", 64) + `","id":"vault.kept","mode":"0600","module":"vault","path":"/var/lib/vault/root/export.json","size":143604,"type":"file"}`)
running = nil
for _, c := range base {
switch c.Module {
case "":
c.Resources = append(append([]json.RawMessage{}, c.Resources...),
json.RawMessage(`{"id":"spiegel.state","type":"directory","path":"/var/lib/spiegel","mode":"0700"}`),
json.RawMessage(`{"id":"spiegel.server","type":"container","name":"spiegel","image":"registry.example/spiegel@sha256:`+strings.Repeat("c", 64)+`","ports":["10.77.0.1:20005:8080"]}`),
json.RawMessage(`{"id":"spiegel.server-env","type":"file","path":"/var/lib/spiegel/server.env","mode":"0600","content":"A=1\n"}`))
case "vault":
c.Resources = append(append([]json.RawMessage{}, c.Resources...), keptFile)
}
running = append(running, c)
}
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}, Running: running}, fps)
if err == nil && len(held) > 0 {
err = fmt.Errorf("modules held: %v", held)
}
if err != nil {
t.Fatalf("the node does not compose itself with spiegel carried: %v", err)
}
var rules string
var keptComposed json.RawMessage
for _, c := range got {
for _, raw := range c.Resources {
var r struct {
ID string `json:"id"`
Content string `json:"content"`
}
_ = json.Unmarshal(raw, &r)
switch r.ID {
case "nft." + catalogue.FilteringID():
rules = r.Content
case "vault." + resolution.KeptID():
keptComposed = raw
}
}
}
if !strings.Contains(rules, "# spiegel — the site") || !strings.Contains(rules, "dport 20005 accept") || strings.Contains(rules, "dport 8080") {
t.Errorf("the filter does not open the machine port spiegel runs at:\n%s", rules)
}
// The paths a carried module owns are its manifest's, as the controller composes them, never what runs of it (issue
// 1126, seed-8 #542: on ace, the folded f1-circuits, snake and txt-game, whose manifests leave their directories and
// files to ${dir:…}, owned their /var/lib paths on the node and none on the controller, shifting node-tools'
// MESH_OWNED_PATHS). f1-circuits' manifest at the indexer's commit (c0ea2c94) is captured; what runs of it is
// composed from that manifest by the controller's rule (the directory at /var/lib/f1-circuits, its file, its container).
for _, c := range running {
if c.Module == "" && !reflect.DeepEqual(compose.Carried(in.Catalogue["spiegel"], "spiegel", &c).OwnsPaths, []string{"/var/lib/spiegel"}) {
t.Errorf("spiegel carried owns %v, want its manifest's /var/lib/spiegel alone", compose.Carried(in.Catalogue["spiegel"], "spiegel", &c).OwnsPaths)
}
}
f1raw, err := os.ReadFile("testdata/f1-circuits-c0ea2c94.module.json")
if err != nil {
t.Fatal(err)
}
f1, err := catalogue.Decode(f1raw)
if err != nil {
t.Fatal(err)
}
f1runs := delivery.Composed{Node: "ace", Resources: []json.RawMessage{
json.RawMessage(`{"id":"f1-circuits.state","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits","mode":"0700"}`),
json.RawMessage(`{"id":"f1-circuits.cache","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits/cache","mode":"0700"}`),
json.RawMessage(`{"id":"f1-circuits.site-env","module":"f1-circuits","type":"file","path":"/var/lib/f1-circuits/site.env","mode":"0644","content":"F1_CIRCUITS_BASE_URL=https://f1.example\n"}`),
json.RawMessage(`{"id":"f1-circuits.server","module":"f1-circuits","type":"container","name":"f1-circuits","image":"registry.example/f1@sha256:` + strings.Repeat("c", 64) + `"}`)}}
if got, want := resolution.DeclaredPaths(compose.Carried(f1, "f1-circuits", &f1runs)), resolution.DeclaredPaths(f1); !reflect.DeepEqual(got, want) {
t.Errorf("f1-circuits carried owns %v, want what the controller composes from its manifest, %v", got, want)
}
// Issue 1126: a held module hands no template its build kept for a file it no longer carries (novox's node:
// "postgres: its build kept a template for consumers"), but keeps its items and their templates, so what it
// contributes to other modules' collections still reaches them.
for _, row := range []struct {
name string
handed *catalogue.Handed
items []catalogue.Item
}{
{"templates and items", &catalogue.Handed{Templates: map[string]string{"consumers": "files/consumers.json"},
Items: map[string]string{"routes": "files/routes.tmpl"}}, []catalogue.Item{{Collection: "routes", Content: "x"}}},
{"items alone", nil, []catalogue.Item{{Collection: "routes", Content: "x"}}},
} {
kept := f1
kept.Handed, kept.Items = row.handed, row.items
st := compose.Stub(kept)
if st.Handed != nil && st.Handed.Templates != nil {
t.Errorf("%s: a stub hands templates %v", row.name, st.Handed.Templates)
}
if !reflect.DeepEqual(st.Items, row.items) {
t.Errorf("%s: a stub collects %+v, want its items %+v", row.name, st.Items, row.items)
}
if row.handed != nil && (st.Handed == nil || !reflect.DeepEqual(st.Handed.Items, row.handed.Items) || row.handed.Templates == nil) {
t.Errorf("%s: a stub hands item templates %+v, want %v and its manifest untouched", row.name, st.Handed, row.handed.Items)
}
}
var k1, k2 any
_ = json.Unmarshal(keptComposed, &k1)
_ = json.Unmarshal(keptFile, &k2)
if !reflect.DeepEqual(k1, k2) {
t.Errorf("the operator export is composed as %s, want the file that runs %s", keptComposed, keptFile)
}
// (D) Issue 1126, seed-8 (mesh-catalog #542): a module's own refusal refuses that module, never the node. novox runs
// de-spiegel folded, and its manifest at the indexer's commit (e25e1beb, captured) asks four required config values
// for its route names that nothing sets. novox answered the whole candidate refused ("de-spiegel contributing
// praktijk to route says ${setting:praktijk-name} ...": its stub still contributed the route). Only de-spiegel is
// refused, in the config form's words; the rest is judged. Issue 1960, novox's chain on 2026-10-29: the hold spread
// to mailu (its smtp provider) and proxy (its route provider), and from them on. Neither is held now: proxy composes
// without de-spiegel's routes, which do not compose, and mailu with its smtp account, which does.
spiegel, err := os.ReadFile("testdata/de-spiegel-e25e1beb.module.json")
if err != nil {
t.Fatal(err)
}
proxy := []byte(`{"module":"proxy","version":"1","capabilities":["systemd"],"provides":[{"name":"route","scope":"mesh"}],` +
`"resources":[{"id":"service","type":"service","unit":"proxy.service","state":"running","boot":"enabled"}]}`)
mailu := []byte(`{"module":"mailu","version":"1","capabilities":["systemd"],"provides":[{"name":"smtp","scope":"mesh"}],` +
`"resources":[{"id":"service","type":"service","unit":"mailu.service","state":"running","boot":"enabled"}]}`)
b = mk("lemurs", overlay.Name, "mailu", "proxy", "de-spiegel")
for m, raw := range map[string][]byte{"de-spiegel": spiegel, "proxy": proxy, "mailu": mailu} {
b.manifests[delivery.ManifestDigest(raw)] = raw
b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
}
p = b.placements["n1"]
p.Decided.Endpoint = "n1.example:51820"
b.placements["n1"] = p
fps = map[string]string{"lemurs": fp("e"), "proxy": fp("f"), "mailu": fp("d")}
a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"de-spiegel"}}, fps, nil, "p-h1", 3)
refused = nil
for _, r := range a.Refusals {
refused = append(refused, r.Module)
if r.Module == "de-spiegel" && (!strings.Contains(r.Why, "${config:praktijk-name}") || strings.Contains(r.Why, "${setting:")) {
t.Errorf("de-spiegel is refused in other words than its config form's: %s", r.Why)
}
}
if a.Refused != "" || !reflect.DeepEqual(refused, []string{"de-spiegel"}) {
t.Errorf("a module's own refusal: %+v, want de-spiegel refused alone, the node, mailu and proxy not", a)
}
for _, m := range []string{"lemurs", "mailu", "proxy"} {
if !slices.ContainsFunc(a.Added, func(x delivery.ModuleAdded) bool { return x.Module == m }) {
t.Errorf("%s, which composes, is not judged beside de-spiegel's refusal: %+v", m, a)
}
}
// (E) Issue 1126, the applying path (review of the seed): the composition novox applies follows the same rule. It
// composed with no culprit, so de-spiegel's unset route names refused the whole node and every module it runs was
// marked not composed. Now de-spiegel is held, its refusal said in its config form's words, with proxy, which
// receives its routes; lemurs composes, and the node's own composition carries de-spiegel as it runs, so applying it
// removes nothing of it.
spiegelRuns := []json.RawMessage{
json.RawMessage(`{"id":"de-spiegel.state","type":"directory","path":"/var/lib/de-spiegel","mode":"0755"}`),
json.RawMessage(`{"id":"de-spiegel.server-env","type":"file","path":"/var/lib/de-spiegel/server.env","mode":"0600","content":"A=1\n"}`),
json.RawMessage(`{"id":"de-spiegel.server","type":"container","name":"de-spiegel","image":"registry.example/de-spiegel@sha256:` + strings.Repeat("c", 64) + `"}`)}
if in, err = Input(ctx, b, Mesh{}, stub); err != nil {
t.Fatal(err)
}
in.Nodes[0].Kept = map[string]catalogue.Manifest{"de-spiegel": compose.Stub(in.Catalogue["de-spiegel"])}
in.Nodes[0].Kept["de-spiegel"] = func(m catalogue.Manifest) catalogue.Manifest {
m.Contributes, m.ContributesMany = nil, nil
return m
}(in.Nodes[0].Kept["de-spiegel"])
if base, _, err = compose.Compositions(in, "n1", fps, compose.Reading); err != nil {
t.Fatal(err)
}
running = nil
for _, c := range base {
if c.Module == "" {
c.Resources = append(append([]json.RawMessage{}, c.Resources...), spiegelRuns...)
}
running = append(running, c)
}
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"de-spiegel"}, Running: running}, fps)
if err != nil {
t.Fatalf("de-spiegel's own refusal refuses the node's whole composition: %v", err)
}
if len(held) != 1 || !strings.Contains(held["de-spiegel"], "${config:praktijk-name}") || !strings.Contains(held["de-spiegel"], "without what it contributes to route") {
t.Errorf("held %v, want de-spiegel alone, in its config form's words, composed without its routes", held)
}
for _, m := range []string{"lemurs", "mailu", "proxy"} {
if key, _ := delivery.ComposedKey("n1", m, fps[m]); got[key].Module != m {
t.Errorf("%s is not composed beside de-spiegel's hold: %v", m, sortedKeys(got))
}
}
carries := func(c delivery.Composed, what string) {
t.Helper()
ownRes, _ := json.Marshal(c.Resources)
for _, raw := range spiegelRuns {
var r struct{ ID string }
_ = json.Unmarshal(raw, &r)
if !strings.Contains(string(ownRes), `"id":"`+r.ID+`"`) {
t.Errorf("%s: the node's own composition drops %s, which de-spiegel runs: %s", what, r.ID, ownRes)
}
}
}
carries(got[ownKey], "folded")
// (F) Leaving the fold (review of the apply, round 1, finding 1): de-spiegel's want names a build of the new form,
// so it is no longer folded, while its names are still unset and the node's own still runs it. Held, it is carried
// in the node's own as it runs until a composition of its own is handed: else the node's own apply removes its
// container, files and units (issue 1945 by a new path).
fps["de-spiegel"] = fp("7")
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: running}, fps)
if err != nil || !strings.Contains(held["de-spiegel"], "${config:praktijk-name}") {
t.Fatalf("de-spiegel leaving the fold: %v, held %v", err, held)
}
spiegelKey, _ := delivery.ComposedKey("n1", "de-spiegel", fps["de-spiegel"])
if _, ok := got[spiegelKey]; ok {
t.Errorf("held de-spiegel's own composition is handed")
}
carries(got[ownKey], "leaving the fold")
// (G) A held module that runs a composition of its own (review of the apply, round 1, finding 2): what the others
// compose of it is what runs, never its stub's. site listens on 8080, placed at the machine's 20005, and its own
// secret was never issued here: held, the filter must not open 8080 and drop the 20005 that runs. The filter, which
// reads what site listens on, composes with it (issue 1960: a hold never spreads).
site := []byte(`{"module":"site","version":"1","capabilities":["container-runtime"],` +
`"listens":[{"port":8080,"from":"mesh","why":"the site"}],"own-secrets":{"key":"${dir:state}/key"},` +
`"resources":[{"id":"state","type":"directory","path":"/var/lib/site","mode":"0700"},` +
`{"id":"server","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}]}`)
b = mk("lemurs", overlay.Name, "site", "nft")
for m, raw := range map[string][]byte{"site": site, "nft": nft} {
b.manifests[delivery.ManifestDigest(raw)] = raw
b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
}
p = b.placements["n1"]
p.Decided.Endpoint = "n1.example:51820"
for i := range p.Modules {
if p.Modules[i].Module == "site" {
p.Modules[i].Ports = map[string]int{"8080": 20005}
}
}
b.placements["n1"] = p
rec = b.records["n1"]
rec.OutwardLinks = []string{"eth0"}
b.records["n1"] = rec
fps = map[string]string{"lemurs": fp("e"), "nft": fp("f"), "site": fp("8")}
siteRuns := delivery.Composed{Node: "n1", Module: "site", FP: fps["site"], Resources: []json.RawMessage{
json.RawMessage(`{"id":"site.state","module":"site","type":"directory","path":"/var/lib/site","mode":"0700"}`),
json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["10.77.0.1:20005:8080"]}`)}}
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps)
nftKey, _ := delivery.ComposedKey("n1", "nft", fps["nft"])
if err != nil || held["site"] == "" || held["nft"] != "" || got[nftKey].Module != "nft" {
t.Fatalf("site, its own secret never issued: %v, held %v; want site held alone, and the filter composed", err, held)
}
if rules, _ := json.Marshal(got[nftKey].Resources); !strings.Contains(string(rules), "dport 20005") {
t.Errorf("the filter drops held site's opening where it runs (20005): %s", rules)
}
for key, c := range got {
for _, raw := range c.Resources {
if strings.Contains(string(raw), "dport 8080") || (c.Module == "nft" && !strings.Contains(string(raw), "dport 20005") && strings.Contains(string(raw), "# site")) {
t.Errorf("%s composes held site from its stub, not from what runs: %s", key, raw)
}
}
}
// (G2) Issue 1126, the spreading hold: a held module is no dependence of its neighbours. site, held, provides page, and
// app requires and binds it: app composes against what runs of site and is not held (novox's seed held every postgres
// consumer behind de-spiegel's hold, "what it composes of …, held, is not what runs of them").
provider := []byte(strings.Replace(string(site), `"listens":`, `"provides":[{"name":"page","scope":"node"}],"serves":{"page":{"port":8080}},"listens":`, 1))
app := []byte(`{"module":"app","version":"1","capabilities":["systemd"],"requires":["page"],"binds":{"page":"/etc/app/page.json"},` +
`"resources":[{"id":"service","type":"service","unit":"app.service","state":"running","boot":"enabled"}]}`)
b2 := mk("lemurs", overlay.Name, "site", "nft", "app")
for m, raw := range map[string][]byte{"site": provider, "nft": nft, "app": app} {
b2.manifests[delivery.ManifestDigest(raw)] = raw
b2.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
}
p2 := b2.placements["n1"]
p2.Decided.Endpoint = "n1.example:51820"
for i := range p2.Modules {
if p2.Modules[i].Module == "site" {
p2.Modules[i].Ports = map[string]int{"8080": 20005}
}
}
b2.placements["n1"] = p2
b2.records["n1"] = rec
b2.held = true
fps["app"] = fp("9")
got, _, held, err = Compose(ctx, b2, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps)
appKey, _ := delivery.ComposedKey("n1", "app", fps["app"])
if _, composed := got[appKey]; err != nil || held["site"] == "" || held["app"] != "" || !composed {
t.Errorf("a consumer of held site: %v, held %v; want site held, and app composed against what runs of it", err, held)
}
if bound, _ := json.Marshal(got[appKey].Resources); !strings.Contains(string(bound), "20005") {
t.Errorf("app's binding does not reach held site where it runs (20005): %s", bound)
}
delete(fps, "app")
// A held module's offer is left out by provision, never whole when one part composes (issue 1960): de-spiegel's
// routes, its names unset, leave the proxy without them, and its smtp account stays with mailu.
withProxy := compose.Input{Catalogue: map[string]catalogue.Manifest{},
Nodes: []compose.Node{{Name: "n1", Assigned: []string{"feeder"}}}}
feeder := catalogue.Manifest{Module: "feeder", Contributes: map[string]map[string]any{"route": {"name": "x"}, "smtp": {"account": "feeder"}},
OwnSecrets: catalogue.OwnSecrets{"key": {Path: "/k"}}}
for _, row := range []struct {
left map[string]bool
route, smtp, own bool
}{{nil, true, true, true}, {map[string]bool{"route": true}, false, true, true}, {map[string]bool{leaveOwn: true}, true, true, false}, {map[string]bool{leaveAll: true}, false, false, false}} {
k := heldAs(withProxy, "n1", "feeder", feeder, row.left, nil).Nodes[0].Kept["feeder"]
if _, r := k.Contributes["route"]; r != row.route || (k.Contributes["smtp"] != nil) != row.smtp || (len(k.OwnSecrets) > 0) != row.own {
t.Errorf("held leaving out %v offers %+v, own secrets %v", row.left, k.Contributes, k.OwnSecrets)
}
}
// (H) Review of the apply, round 2: a held module is composed from its new manifest, so what the others read of its
// manifest (its listens, what it contributes) may not be what runs. site's new manifest moves it to 9090 while the
// build that runs (7) listens on 8080. The filter composes with what site last offered, its manifest at the build
// that runs (issue 1960: a hold never spreads), so it opens 8080 and nothing applied opens 9090.
moved := []byte(strings.Replace(string(site), `"port":8080`, `"port":9090`, 1))
b.manifests[delivery.ManifestDigest(moved)] = moved
b.modules["site"] = delivery.Module{Manifest: delivery.ManifestDigest(moved)}
for i := range p.Modules {
p.Modules[i].Ports = nil
}
b.placements["n1"] = p
siteRuns.Resources[1] = json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}`)
siteRuns.FP = fp("7")
b.manifests[delivery.ManifestDigest(site)] = site
b.builds["site "+fp("7")] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: delivery.ManifestDigest(site)}
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps)
if err != nil || held["site"] == "" || held["nft"] != "" {
t.Errorf("site moved to 9090 in its new manifest: %v, held %v; want site held alone", err, held)
}
if rules, _ := json.Marshal(got[nftKey].Resources); !strings.Contains(string(rules), "dport 8080") {
t.Errorf("the filter drops what held site last offered (8080): %s", rules)
}
for key, c := range got {
for _, raw := range c.Resources {
if strings.Contains(string(raw), "dport 9090") {
t.Errorf("%s opens 9090, which held site's new manifest names and nothing runs: %s", key, raw)
}
}
}
}
// A module another node cannot run in this composition offers nothing in it: the refusal names it and why
// (issue 1126, probe #532: ace and g14 answered "nothing in this mesh provides artifact-store … assign distribution to a
// node" while novox runs it). And a refusal no module's stub changes is answered at once, not after a compose per module
// (ace took 7m46s).
func otherNodeNotResolvedIsNamed(t *testing.T) {
ctx := context.Background()
prov := []byte(`{"module":"prov","version":"1","capabilities":["container-runtime"],"provides":[{"name":"thing","scope":"mesh"}]}`)
cons := []byte(`{"module":"cons","version":"1","requires":["thing"]}`)
// n2 runs prov, which needs a container runtime n2's record does not say it has: n2's set does not resolve here.
b := &bus{placements: map[string]delivery.Placement{
"n1": {Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Endpoint: "n1.example:51820"}, Modules: []delivery.PlacedModule{{Module: "cons"}, {Module: overlay.Name}}},
"n2": {Node: "n2", Decided: delivery.NodeDecided{Address: "10.77.0.2"}, Modules: []delivery.PlacedModule{{Module: "prov"}, {Module: overlay.Name}}}},
records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t)}, "n2": {Node: "n2", TunnelKey: publicKey(t)}},
modules: map[string]delivery.Module{"prov": {Manifest: delivery.ManifestDigest(prov)}, "cons": {Manifest: delivery.ManifestDigest(cons)}},
manifests: map[string][]byte{delivery.ManifestDigest(prov): prov, delivery.ManifestDigest(cons): cons}}
given := map[string]*delivery.Placement{}
for n, p := range b.placements {
p := p
given[n] = &p
}
a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given}, map[string]string{"cons": "src2:" + strings.Repeat("1", 64)}, nil, "p-h1", 1)
if a.Verdict != delivery.CandidateRefused || !strings.Contains(a.Refused, "prov on n2 lacks container-runtime") {
t.Errorf("a refusal for want of n2's provider = %+v", a)
}
}
// A node that reaches no artifact store says what its composition holds of one (issue 1126: ace and g14 answered
// "reaches no artifact store", and nothing said why).
func noStoreReachedSaysWhy(t *testing.T) {
in := compose.Input{Catalogue: map[string]catalogue.Manifest{
"distribution": {Module: "distribution", Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5000}}}},
Nodes: []compose.Node{{Name: "g14", Assigned: []string{"adwaita"}}, {Name: "novox", Assigned: []string{"distribution"}}}}
if got := storeSought(in); got != "the composition holds g14, novox; artifact-store is served by distribution on novox, none of it on the private network with a port" {
t.Errorf("said %q", got)
}
in.Nodes = in.Nodes[:1]
if got := storeSought(in); got != "the composition holds g14; no module placed there serves artifact-store" {
t.Errorf("said %q", got)
}
}
+112
View File
@@ -0,0 +1,112 @@
package frombus
import (
"fmt"
"sort"
"strconv"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/compose"
)
// Export is one node's placement as the composition input the controller fills says it (novox/hq ADR 0555 §6, ADR 0567
// §7): the inverse of Input for what a person decides, so the controller's one read-only verb `placement-export`
// answers, from its own store by its own code, exactly what the seat's adopt proposes as the seed. Its answer must stay
// stable for the same store, since adopt checks its digest again at the warrant.
// What the machine observes (its record) is not in it.
func Export(in compose.Input, node string) (delivery.Placement, error) {
var n *compose.Node
for i := range in.Nodes {
if in.Nodes[i].Name == node {
n = &in.Nodes[i]
}
}
if n == nil {
return delivery.Placement{}, fmt.Errorf("frombus: the input has no node %q", node)
}
// At the version a placement key is read at, with a list of modules even when none is placed: the export is the
// seed's value, and the reader refuses a value of version 0 or without its modules.
p := delivery.Placement{V: delivery.Version, Node: node, Modules: []delivery.PlacedModule{}, Decided: delivery.NodeDecided{
Endpoint: n.Place.Endpoint, Site: n.Place.Site, Hub: n.Place.Hub, Address: n.Place.Address,
PublicDomain: n.PublicDomain, Account: n.Account, AccountHome: n.AccountHome, AgentAccount: n.AgentAccount,
AgentHome: n.AgentAccountHome, Adopted: n.Adopted, Taken: n.Taken}}
// The tunnel found at the node's adoption: the one its private network took over, or, on the hub, the mesh's adopted
// one whether or not the hub took it; and the names a person gave the hub's carried peers.
if t, ok := in.Tunnels[node]; ok {
rt := recordTunnelOf(t.Tunnel)
p.Decided.Tunnel = &rt
} else if in.Adopted != nil && in.Adopted.Hub == node {
rt := recordTunnelOf(in.Adopted.Tunnel)
p.Decided.Tunnel = &rt
}
if n.Place.Hub {
for _, c := range in.CarriedPeers {
if c.Named != "" {
if p.Decided.PeerNames == nil {
p.Decided.PeerNames = map[string]string{}
}
p.Decided.PeerNames[c.PublicKey] = c.Named
}
}
}
for _, module := range n.Assigned {
m := delivery.PlacedModule{Module: module}
for port, at := range n.Ports[module] {
if m.Ports == nil {
m.Ports = map[string]int{}
}
m.Ports[strconv.Itoa(port)] = at
}
for provision, to := range n.Bound[module] {
if m.Bindings == nil {
m.Bindings = map[string]delivery.Provider{}
}
m.Bindings[provision] = delivery.Provider{Node: to.Node, Module: to.Module}
}
// A module in the settings form keeps its config values in its layers, not in mesh-config: they are exported with
// the placement. A module in the config form has mesh-config's values laid into its layer, which are not.
settingsForm := !in.Catalogue[module].DeclaresConfig()
for _, layer := range n.Settings[module] {
if layer.From == catalogue.MeshWideLayer {
m.HasMesh, m.MeshWide = true, layer.Placement
if settingsForm && len(layer.Values) > 0 {
m.MeshValues = layer.Values
}
} else {
m.HasLayer, m.Layer = true, layer.Placement
if settingsForm && len(layer.Values) > 0 {
m.Values = layer.Values
}
}
}
p.Modules = append(p.Modules, m)
}
for provision, to := range n.Pins {
if p.Pins == nil {
p.Pins = map[string]delivery.Provider{}
}
p.Pins[provision] = delivery.Provider{Node: to.Node, Module: to.Module}
}
for _, h := range in.Holdings {
if h.Node == node {
p.Holds = append(p.Holds, delivery.Holding{Claim: h.Claim, Scope: h.Scope, Module: h.Module, Site: h.Site, Kind: h.Kind})
}
}
sort.SliceStable(p.Holds, func(i, j int) bool {
if p.Holds[i].Claim != p.Holds[j].Claim {
return p.Holds[i].Claim < p.Holds[j].Claim
}
return p.Holds[i].Module < p.Holds[j].Module
})
return p, nil
}
func recordTunnelOf(t compose.Tunnel) delivery.RecordTunnel {
out := delivery.RecordTunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU,
Address: t.Address, Range: t.Range, PublicKey: t.PublicKey}
for _, peer := range t.Peers {
out.Peers = append(out.Peers, delivery.RecordTunnelPeer{PublicKey: peer.PublicKey, Address: peer.Address})
}
return out
}
File diff suppressed because it is too large Load Diff
+869
View File
@@ -0,0 +1,869 @@
package frombus
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/ecdh"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"reflect"
"slices"
"sort"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/compose"
"github.com/novox/mesh-host/internal/overlay"
)
// bus is a Reader over values held in memory, counting whose config values were read.
type bus struct {
vaultAuthority string
handed map[string]bool
placements map[string]delivery.Placement
records map[string]delivery.NodeRecord
modules map[string]delivery.Module
manifests map[string][]byte
values map[string]map[string]any // by node.module
valuesRead []string
own []compose.OwnSecretRow
ownErr error
// asked is every consumer credential asked; the fake refuses the whole ask, as the vault's own does, for a consuming
// module its placement does not put on the node.
asked []compose.CredentialKey
// builds are the builder's keys, by "<module> <fp>"; archives the artifact store's blobs, by digest, and fetched
// every URL asked; served another node's values by "<node>.<module>", and servedRead every ask with its keys.
builds map[string]*delivery.BuilderBuild
archives map[string][]byte
fetched []string
served map[string]map[string]any
servedRead []string
buildsRead []string
// held answers every credential asked as held, sealed to the consumer.
held bool
}
func (b *bus) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) {
b.buildsRead = append(b.buildsRead, module)
return b.builds[module+" "+fp], nil
}
func (b *bus) Archive(_ context.Context, url, digest string) ([]byte, error) {
b.fetched = append(b.fetched, url)
body, ok := b.archives[digest]
if !ok {
return nil, errors.New("the store holds no such blob")
}
return body, nil
}
func (b *bus) Served(_ context.Context, node, module string, keys []string) (map[string]any, error) {
b.servedRead = append(b.servedRead, node+"."+module+":"+strings.Join(keys, ","))
out := map[string]any{}
for _, k := range keys {
if v, ok := b.served[node+"."+module][k]; ok {
out[k] = v
}
}
return out, nil
}
func (b *bus) Placements(context.Context) (map[string]delivery.Placement, error) {
return b.placements, nil
}
func (b *bus) VaultAuthority(context.Context) string { return b.vaultAuthority }
func (b *bus) Handed(context.Context, string) map[string]bool { return b.handed }
func (b *bus) Records(context.Context) (map[string]delivery.NodeRecord, error) { return b.records, nil }
func (b *bus) Modules(context.Context) (map[string]delivery.Module, error) { return b.modules, nil }
func (b *bus) Manifest(_ context.Context, digest string) ([]byte, error) {
raw, ok := b.manifests[digest]
if !ok {
return nil, errors.New("no such manifest")
}
return raw, nil
}
func (b *bus) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) {
return b.own, b.ownErr
}
func (b *bus) Credentials(_ context.Context, node string, asConsumer, asProvider []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) {
b.asked = append(b.asked, asConsumer...)
for _, k := range asConsumer {
placed := false
for _, m := range b.placements[node].Modules {
placed = placed || m.Module == k.ConsumerModule
}
if !placed {
return nil, nil, fmt.Errorf("%s is not on %s: neither assigned there nor required by a module there", k.ConsumerModule, node)
}
}
var out []compose.Credential
var issued []compose.Issued
for _, k := range append(append([]compose.CredentialKey{}, asConsumer...), asProvider...) {
if b.held {
out = append(out, compose.Credential{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule,
Provider: k.Provider, Local: k.Local, State: compose.CredentialHeld, Sealed: `"box"`})
issued = append(issued, compose.Issued{Provision: k.Provision, Consumer: k.Consumer, ConsumerModule: k.ConsumerModule,
Provider: k.Provider, Local: k.Local, ForProvider: `"box"`})
}
}
return out, issued, nil
}
func (b *bus) Values(_ context.Context, node, module string) (map[string]any, error) {
b.valuesRead = append(b.valuesRead, node+"."+module)
return b.values[node+"."+module], nil
}
func publicKey(t *testing.T) string {
t.Helper()
k, err := ecdh.X25519().GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes())
}
// Issue 1126 (S3, S4, S5): the bus's placement, node records, manifests and config values fill the same compose.Input the
// controller's store fills for compose's own two-machine mesh (compose's aMesh: anchor the hub, laptop), and that Input
// composes; a node reads only its own config values; what cannot be composed is refused, naming it.
func TestInput(t *testing.T) {
// Issue 1126, the seed's shadow: every refusal named, an old-form module left to the node, and no store reached
// saying why (answer_rows_test.go).
t.Run("every refusal, and an old build left to the node", answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode)
t.Run("no store reached says why", noStoreReachedSaysWhy)
// Issue 774: a node refused whole says what it held on the way.
t.Run("a node refused whole says what it held", aNodeRefusedWholeSaysWhatItHeld)
t.Run("a refused manifest never ends the read", aRefusedManifestNeverEndsTheRead)
t.Run("the runtime is blamed only where its stub composes", theRuntimeIsBlamedOnlyWhereItsStubComposes)
t.Run("a running module waits for its build", aRunningModuleWaitsForItsBuild)
t.Run("a held module's first reason leads", aHeldModulesFirstReasonLeads)
t.Run("a held module of its own account never refuses the node", aHeldModuleOfItsOwnAccountNeverRefusesTheNode)
t.Run("another node not resolved is named", otherNodeNotResolvedIsNamed)
ctx := context.Background()
xterm, err := os.ReadFile("testdata/catalogue-835bf22b-xterm.module.json")
if err != nil {
t.Fatal(err)
}
digest := delivery.ManifestDigest(xterm)
// A module in the settings form (plex and the media modules on one node, 2026-10-26): its config values are in its
// layers, not mesh-config, so the placement carries them (MeshValues, Values) and mesh-config's are not read for it.
media := []byte(`{"module":"media","version":"1","settings":{"lines":{"kind":"preference","default":0,"why":"kept"},` +
`"name":{"kind":"preference","default":"x","why":"shown"}},"own-secrets":{"token":"/srv/media/token"},"resources":[{"id":"conf","type":"file",` +
`"path":"/srv/media/conf","mode":"0644","content":"uid=${setting:lines} name=${setting:name}\n"}]}`)
mediaDigest := delivery.ManifestDigest(media)
keys := map[string]string{"anchor": publicKey(t), "laptop": publicKey(t)}
caps := []string{"container-runtime", "systemd", "wireguard"}
tunnel := &delivery.RecordTunnel{Interface: "wg0", PublicKey: keys["anchor"], Address: "10.77.0.1/24",
Peers: []delivery.RecordTunnelPeer{{PublicKey: "peer", Address: "10.77.0.9/32"}, {PublicKey: keys["laptop"], Address: "10.77.0.2/32"}}}
mk := func() *bus {
return &bus{
placements: map[string]delivery.Placement{
"anchor": {Node: "anchor", Decided: delivery.NodeDecided{Endpoint: "anchor.example:51820", Site: "here", Hub: true, Address: "10.77.0.1",
Tunnel: tunnel, PeerNames: map[string]string{"peer": "phone"}},
Modules: []delivery.PlacedModule{{Module: overlay.Name}},
Holds: []delivery.Holding{{Claim: "mesh-private-network", Scope: "mesh", Module: overlay.Name}}},
"laptop": {Node: "laptop", Decided: delivery.NodeDecided{Endpoint: "laptop.example:51820", Site: "here", Address: "10.77.0.2"},
Modules: []delivery.PlacedModule{{Module: overlay.Name},
{Module: "xterm", HasLayer: true, Ports: map[string]int{"80": 8080}},
{Module: "media", HasMesh: true, MeshValues: map[string]any{"name": "mesh"}, HasLayer: true,
Layer: map[string]any{"places": map[string]any{"data": "/srv/data"}}, Values: map[string]any{"lines": float64(1000)}}}},
},
records: map[string]delivery.NodeRecord{
"anchor": {Node: "anchor", TunnelKey: keys["anchor"], Capabilities: caps, Contract: 2},
"laptop": {Node: "laptop", TunnelKey: keys["laptop"], Capabilities: caps, Authority: "the mesh's certificate"},
},
modules: map[string]delivery.Module{"xterm": {Manifest: digest}, "media": {Manifest: mediaDigest}, "unread": {}},
manifests: map[string][]byte{digest: xterm, mediaDigest: media},
values: map[string]map[string]any{"laptop.xterm": {"font": "mono"}, "anchor.xterm": {"font": "serif"},
"laptop.media": {"lines": "not mesh-config's"}},
own: []compose.OwnSecretRow{{Node: "laptop", Module: "media", Name: "token", Origin: "made", SealedTo: "sk", Sealed: "{}"}},
}
}
// What the controller's store gives for the same mesh, as compose's aMesh and SettingsFor build it.
var private catalogue.Manifest
raw, _ := json.Marshal(overlay.Manifest())
if err := json.Unmarshal(raw, &private); err != nil {
t.Fatal(err)
}
parsed, err := catalogue.Decode(xterm)
if err != nil {
t.Fatal(err)
}
parsedMedia, err := catalogue.Decode(media)
if err != nil {
t.Fatal(err)
}
capsSet := map[string]bool{"container-runtime": true, "systemd": true, "wireguard": true}
tun := compose.Tunnel{Interface: "wg0", PublicKey: keys["anchor"], Address: "10.77.0.1/24",
Peers: []compose.TunnelPeer{{PublicKey: "peer", Address: "10.77.0.9/32"}, {PublicKey: keys["laptop"], Address: "10.77.0.2/32"}}}
want := compose.Input{
Catalogue: map[string]catalogue.Manifest{overlay.Name: private, "xterm": parsed, "media": parsedMedia},
Nodes: []compose.Node{
{Name: "anchor", Place: compose.Place{Key: keys["anchor"], Endpoint: "anchor.example:51820", Site: "here", Hub: true,
Address: "10.77.0.1"}, Assigned: []string{overlay.Name}, Capabilities: capsSet, Contract: 2, Stated: true},
{Name: "laptop", Place: compose.Place{Key: keys["laptop"], Endpoint: "laptop.example:51820", Site: "here",
Address: "10.77.0.2"}, Assigned: []string{overlay.Name, "xterm", "media"}, Capabilities: capsSet,
Ports: map[string]map[int]int{"xterm": {80: 8080}},
Settings: map[string][]catalogue.Layer{"xterm": {{From: "laptop", Values: map[string]any{"font": "mono"}}},
"media": {{From: catalogue.MeshWideLayer, Values: map[string]any{"name": "mesh"}},
{From: "laptop", Values: map[string]any{"lines": float64(1000)}, Placement: map[string]any{"places": map[string]any{"data": "/srv/data"}}}}}},
},
Holdings: []catalogue.Held{{Claim: "mesh-private-network", Scope: "mesh", Node: "anchor", Module: overlay.Name}},
OwnSecrets: []compose.OwnSecretRow{{Node: "laptop", Module: "media", Name: "token", Origin: "made", SealedTo: "sk", Sealed: "{}"}},
// The same value as a core credential's box, for a core module (ADR 0349 §16).
KeptCoreBoxes: []compose.CoreBox{{Node: "laptop", Module: "media", Name: "token", SealedTo: "sk", Box: json.RawMessage("{}")}},
Tunnels: map[string]compose.FoundTunnel{"anchor": {Tunnel: tun}},
Adopted: &compose.AdoptedTunnel{Tunnel: tun, Hub: "anchor", Adopted: true},
// The controller's order is by address; laptop enrolled under its peer's key, and a person named the other.
Authority: "the mesh's certificate",
CarriedPeers: []compose.CarriedPeer{{PublicKey: keys["laptop"], Address: "10.77.0.2", EnrolledAs: "laptop"},
{PublicKey: "peer", Address: "10.77.0.9", Named: "phone"}},
}
b := mk()
got, err := Input(ctx, b, Mesh{}, Scope{Node: "laptop"})
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
gj, _ := json.MarshalIndent(got, "", " ")
wj, _ := json.MarshalIndent(want, "", " ")
t.Fatalf("Input from the bus differs from the store's:\n got %s\nwant %s", gj, wj)
}
// L3 s8 (ADR 0550 §2): with the vault's authority published, the mesh's authority is the bundle of the controller's
// and the vault's; with none, the controller's alone, as above.
signing := mk()
signing.vaultAuthority = "the vault's certificate"
if bundled, err := Input(ctx, signing, Mesh{}, Scope{Node: "laptop"}); err != nil ||
bundled.Authority != "the mesh's certificate\nthe vault's certificate\n" {
t.Errorf("the authority with the vault's published: %q, %v", bundled.Authority, err)
}
// Issue 774: the modules the vault judged handed when it answered own are the node's to hand, and no other node's.
judged := mk()
judged.handed = map[string]bool{"media": true, "xterm": false}
if in, err := Input(ctx, judged, Mesh{}, Scope{Node: "laptop"}); err != nil ||
!reflect.DeepEqual(in.Nodes[1].Handed, judged.handed) || in.Nodes[0].Handed != nil {
t.Errorf("the vault's handed verdicts: %+v, %v", in.Nodes, err)
}
if !reflect.DeepEqual(b.valuesRead, []string{"laptop." + overlay.Name, "laptop.xterm", "laptop.media"}) {
t.Errorf("laptop read config values %v; a node reads its own slice alone", b.valuesRead)
}
// The same composer over both: the compositions from the bus are the store's, byte for byte.
fps := map[string]string{overlay.Name: "src2:" + strings.Repeat("a", 64), "xterm": "src2:" + strings.Repeat("b", 64),
"media": "src2:" + strings.Repeat("c", 64)}
fromBus, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop"}, fps)
if err == nil && len(held) > 0 {
err = fmt.Errorf("modules held: %v", held)
}
if err != nil {
t.Fatal(err)
}
fromStore, _, err := compose.Compositions(want, "laptop", fps, compose.Reading)
if err != nil {
t.Fatal(err)
}
if len(fromBus) == 0 || !reflect.DeepEqual(fromBus, fromStore) {
t.Fatalf("compositions differ or are empty: %d from the bus, %d from the store", len(fromBus), len(fromStore))
}
// Issue 1126: a candidate placement equal to the node's composes what it runs, and CompareComposed says so; one
// thing changed in the group's candidate of another node (the hub's endpoint) names exactly the module it moves
// here. Placed puts the same placement over the store's input unchanged.
running := make([]delivery.Composed, 0, len(fromBus))
for _, c := range fromBus {
running = append(running, c)
}
laptop := mk().placements["laptop"]
asIs, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop}}, fps)
if err == nil && len(held) > 0 {
err = fmt.Errorf("modules held: %v", held)
}
if err != nil {
t.Fatal(err)
}
if c := delivery.CompareComposed(asIs, running); len(c.Differs)+len(c.Added)+len(c.Removed) != 0 || len(c.Same) != len(running) {
t.Errorf("the candidate equal to the placement: %+v", c)
}
moved := mk().placements["anchor"]
moved.Decided.Endpoint = "anchor.example:51821"
changed, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop, "anchor": &moved}}, fps)
if err == nil && len(held) > 0 {
err = fmt.Errorf("modules held: %v", held)
}
if err != nil {
t.Fatal(err)
}
if c := delivery.CompareComposed(changed, running); len(c.Differs) != 1 || c.Differs[0].Module != overlay.Name || c.Differs[0].Resource != overlay.Name+".overlay-config" {
t.Errorf("the hub's endpoint changed: %+v, want %s alone, naming its config", c, overlay.Name)
}
if c := delivery.CompareComposed(changed, running[:0]); len(c.Added) != len(changed) {
t.Errorf("nothing runs: %+v", c)
}
// ADR 0559 §4, the node's answer: the same; one module differing; a module the group adds, whose own secret is not
// yet issued (no refusal); a value missing for a module the node runs (a refusal); a module the candidate removes.
keeper := []byte(`{"module":"keeper","version":"1","own-secrets":{"password":"/var/lib/mesh/keeper/password"},` +
`"resources":[{"id":"state","type":"directory","path":"/var/lib/mesh/keeper","mode":"0700"}]}`)
lemurs := []byte(`{"module":"lemurs","version":"1","capabilities":["systemd"],"resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`)
// A provider and a consumer of a pair credential on the one node (compose's objects and files).
objects, _ := json.Marshal(catalogue.Manifest{Module: "objects", Version: "1",
Provides: []catalogue.Offer{{Name: "s3-bucket", Scope: catalogue.ScopeMesh, Identity: &catalogue.OfferIdentity{Max: 20, In: "an S3 access key"}}},
Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}})
files, _ := json.Marshal(catalogue.Manifest{Module: "files", Version: "1", Requires: []string{"s3-bucket"},
Resources: []map[string]any{{"id": "config", "type": "file", "path": "/etc/files/config.json", "mode": "0600", "content": "{}", "merge": "json"}}})
one := func(placed ...string) *bus {
p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}}
for _, m := range placed {
p.Modules = append(p.Modules, delivery.PlacedModule{Module: m})
}
return &bus{placements: map[string]delivery.Placement{"n1": p},
records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: keys["anchor"], SealingKey: "sk", Capabilities: []string{"systemd"}}},
modules: map[string]delivery.Module{"keeper": {Manifest: delivery.ManifestDigest(keeper)}, "lemurs": {Manifest: delivery.ManifestDigest(lemurs)},
"objects": {Manifest: delivery.ManifestDigest(objects)}, "files": {Manifest: delivery.ManifestDigest(files)}},
manifests: map[string][]byte{delivery.ManifestDigest(keeper): keeper, delivery.ManifestDigest(lemurs): lemurs,
delivery.ManifestDigest(objects): objects, delivery.ManifestDigest(files): files},
own: []compose.OwnSecretRow{{Node: "n1", Module: "keeper", Name: "password", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}}
}
oneFps := map[string]string{"keeper": "src2:" + strings.Repeat("d", 64), "lemurs": "src2:" + strings.Repeat("e", 64),
"objects": "src2:" + strings.Repeat("f", 64), "files": "src2:" + strings.Repeat("0", 64)}
runs, _, held, err := Compose(ctx, one("keeper", "lemurs"), Mesh{}, Scope{Node: "n1"}, oneFps)
if err == nil && len(held) > 0 {
err = fmt.Errorf("modules held: %v", held)
}
if err != nil {
t.Fatal(err)
}
var runsNow []delivery.Composed
for _, c := range runs {
runsNow = append(runsNow, c)
}
objectsRun, _, held, err := Compose(ctx, one("objects"), Mesh{}, Scope{Node: "n1"}, oneFps)
if err == nil && len(held) > 0 {
err = fmt.Errorf("modules held: %v", held)
}
if err != nil {
t.Fatal(err)
}
var consumerRuns []delivery.Composed
for _, c := range objectsRun {
consumerRuns = append(consumerRuns, c)
}
withoutOwn := func(cs []delivery.Composed) []delivery.Composed {
var out []delivery.Composed
for _, c := range cs {
if c.Module != "" {
out = append(out, c)
}
}
return out
}
// What the controller runs on n1 for a module no want names (lemurs, at a build of the old form) and the private
// network's (built by nobody): its composed writer (mesh-controller compositionsOf) folds their resources into the
// node's own, each in its place, its module taken off; the rest split by module.
tunnelled := func() *bus {
b := one("keeper", "lemurs", overlay.Name)
p := b.placements["n1"]
p.Decided.Endpoint = "n1.example:51820"
b.placements["n1"] = p
return b
}
tunnelledAs := func() map[string]*delivery.Placement {
p := tunnelled().placements["n1"]
return map[string]*delivery.Placement{"n1": &p}
}
folded := func() []delivery.Composed {
b := tunnelled()
in, err := Input(ctx, b, Mesh{}, Scope{Node: "n1"})
if err != nil {
t.Fatal(err)
}
reading := in
reading.Nodes = append([]compose.Node(nil), in.Nodes...)
reading.Nodes[0].ReadsModules = true
d, _, err := compose.ComposeIn(reading, "n1", compose.Reading)
if err != nil {
t.Fatal(err)
}
body, err := d.Body()
if err != nil {
t.Fatal(err)
}
var envelope struct {
Resources []map[string]json.RawMessage `json:"resources"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
t.Fatal(err)
}
var resources []json.RawMessage
for _, r := range envelope.Resources {
var module string
_ = json.Unmarshal(r["module"], &module)
if module == "lemurs" || module == overlay.Name {
delete(r, "module")
}
raw, _ := json.Marshal(r)
resources = append(resources, raw)
}
split, err := delivery.SplitComposed("n1", resources, map[string]string{"keeper": oneFps["keeper"]}, nil, delivery.NodeOwn{})
if err != nil {
t.Fatal(err)
}
var out []delivery.Composed
for _, c := range split {
out = append(out, c)
}
return out
}()
if got := InOwn(folded, tunnelled().placements["n1"]); !reflect.DeepEqual(got, []string{"lemurs", overlay.Name}) {
t.Errorf("InOwn of the controller's composition = %v, want lemurs and %s", got, overlay.Name)
}
placedAs := func(modules ...string) map[string]*delivery.Placement {
p := one(modules...).placements["n1"]
return map[string]*delivery.Placement{"n1": &p}
}
for name, c := range map[string]struct {
b *bus
given map[string]*delivery.Placement
running []delivery.Composed
verdict string
check func(delivery.CandidateAnswer) bool
}{
"the same": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), runsNow, delivery.CandidateSame,
func(a delivery.CandidateAnswer) bool {
return len(a.Same) == 3 && a.Proposal == "p-h1" && a.Revision == 7
}},
"the node runs no own composition": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), withoutOwn(runsNow), delivery.CandidateSame,
func(a delivery.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }},
"one removed": {one("keeper", "lemurs"), placedAs("keeper"), runsNow, delivery.CandidateDiffers,
func(a delivery.CandidateAnswer) bool { return reflect.DeepEqual(a.Removed, []string{"lemurs"}) }},
"one added, its secret not yet issued": {func() *bus { b := one("lemurs"); b.own = nil; return b }(), placedAs("keeper", "lemurs"),
nil, delivery.CandidateDiffers, func(a delivery.CandidateAnswer) bool {
for _, m := range a.Added {
if m.Module == "keeper" {
return reflect.DeepEqual(m.Unissued, []string{"keeper/password"}) && len(a.Refusals) == 0
}
}
return false
}},
"a value missing for a module that runs": {func() *bus { b := one("keeper", "lemurs"); b.own = nil; return b }(), placedAs("keeper", "lemurs"),
runsNow, delivery.CandidateRefused, func(a delivery.CandidateAnswer) bool {
return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && strings.Contains(a.Refusals[0].Why, "keeper/password")
}},
// Review of L2b, round 1, finding 1: a consumer the candidate adds has no credential yet, and the vault refuses an
// ask for a module not on the node; it is not asked, and the module is added, its credential not yet issued.
"a consumer added, the vault not asked for it": {one("objects"), placedAs("objects", "files"), consumerRuns, delivery.CandidateDiffers,
func(a delivery.CandidateAnswer) bool {
return len(a.Added) == 1 && a.Added[0].Module == "files" && len(a.Added[0].Unissued) > 0 && len(a.Refusals) == 0
}},
// And with no trunk at all (the seed), a stand-in of a module the node runs is a refusal, not an addition.
"the seed, a value missing for a module that runs": {func() *bus { b := one(); b.own = nil; return b }(), placedAs("keeper", "lemurs"),
runsNow, delivery.CandidateRefused, func(a delivery.CandidateAnswer) bool {
return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && len(a.Added) == 0
}},
"cannot compose": {one("keeper"), map[string]*delivery.Placement{"n1": {Node: "n2"}}, runsNow, delivery.CandidateRefused,
func(a delivery.CandidateAnswer) bool { return a.Refused != "" }},
// Issue 1126 (the seed's review): the private network's module and one at a build of the old form, which no want
// names and the controller folds into the node's own, are folded there again: the node's own is the same, and
// neither is added.
"mesh-wireguard and an old-form module, folded": {tunnelled(), tunnelledAs(),
folded, delivery.CandidateSame, func(a delivery.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }},
} {
fps := oneFps
if name == "mesh-wireguard and an old-form module, folded" {
fps = map[string]string{"keeper": oneFps["keeper"]} // the node's wants: no build of lemurs or the private network
}
a := Answer(ctx, c.b, Mesh{}, Scope{Node: "n1", Placements: c.given, InOwn: InOwn(c.running, *c.given["n1"])}, fps, c.running, "p-h1", 7)
if a.Verdict != c.verdict || !c.check(a) {
t.Errorf("%s: %+v", name, a)
}
if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": a}}); err != nil {
t.Errorf("%s: the answer is not one a have holds: %v (%s)", name, err, raw)
}
}
if got, err := Placed(want, map[string]*delivery.Placement{"laptop": &laptop}); err != nil || !reflect.DeepEqual(got, want) {
t.Errorf("Placed with the node's own placement changes the input: %v", err)
}
if got, err := Placed(want, map[string]*delivery.Placement{"laptop": nil}); err != nil || len(got.Nodes[1].Assigned) != 0 || got.Nodes[1].Place.Key != keys["laptop"] {
t.Errorf("Placed with no placement: %+v, %v", got.Nodes[1], err)
}
if _, err := Placed(want, map[string]*delivery.Placement{"stranger": {Node: "stranger"}}); err == nil {
t.Error("Placed a node the input holds no node for")
}
if _, err := Input(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": {Node: "anchor"}}}); err == nil {
t.Error("a given placement naming another node was taken")
}
// ADR 0555 §6: Export is Input's inverse for what a person decides: the store's input exported, node by node, is the
// placement the bus holds, and read back it is the store's input again.
for node, placed := range mk().placements {
got, err := Export(want, node)
if err != nil {
t.Fatal(err)
}
if gj, _ := delivery.EncodePlacement(got); !delivery.SamePlacement(got, placed) {
pj, _ := delivery.EncodePlacement(placed)
t.Errorf("%s exported as %s, want %s", node, gj, pj)
}
}
if _, err := Export(want, "stranger"); err == nil {
t.Error("a node the input lacks was exported")
}
b = mk()
if every, err := Input(ctx, b, Mesh{}, Scope{Every: true}); err != nil || every.OwnSecrets != nil {
t.Fatalf("composing every node: %v, own secrets %v (the vault answers a node about itself alone)", err, every.OwnSecrets)
}
if !reflect.DeepEqual(b.valuesRead, []string{"anchor." + overlay.Name, "laptop." + overlay.Name, "laptop.xterm", "laptop.media"}) {
// Every node's placed modules, and nothing placed nowhere: anchor places no xterm.
t.Errorf("the check read %v", b.valuesRead)
}
// A node none of whose modules declares an own secret composes without asking the vault (the review of L2: a node
// with no secret composed nothing while the vault did not answer); one whose module does, refuses without it.
quiet := mk()
quiet.ownErr = errors.New("no responders")
if _, err := Input(ctx, quiet, Mesh{}, Scope{Node: "anchor"}); err != nil {
t.Errorf("anchor, with no own secret, needed the vault: %v", err)
}
if _, err := Input(ctx, quiet, Mesh{}, Scope{Node: "laptop"}); err == nil {
t.Error("laptop's media, which declares an own secret, composed without the vault")
}
// A module the node's want keeps at another build than the trunk's composes from that build's manifest, resolved with
// what it made (issue 1126), on the composing node alone.
kept := mk()
oldXterm := []byte(`{"module":"xterm","version":"1","resources":[{"id":"tools","type":"process","name":"xterm-tools",` +
`"artifact":"tools","run":["./xterm-tools"]}],"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/xterm-tools","binary":"xterm-tools"}]}}`)
kept.manifests["old"] = oldXterm
oldFP := "src2:" + strings.Repeat("9", 64)
toolsRef := "artifact-store://xterm/tools/blobs/sha256:" + strings.Repeat("1", 64)
kept.builds = map[string]*delivery.BuilderBuild{"xterm " + oldFP: {State: delivery.BuildDone, Manifest: "old",
Artifacts: []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: toolsRef, Digest: "sha256:" + strings.Repeat("1", 64)}}}}
parsedOld, err := catalogue.Decode(oldXterm)
if err != nil {
t.Fatal(err)
}
keptBuilt, err := parsedOld.Resolve([]catalogue.Built{{Name: "tools", Kind: "bundle", Reference: toolsRef, Digest: "sha256:" + strings.Repeat("1", 64)}})
if err != nil {
t.Fatal(err)
}
in, err := Input(ctx, kept, Mesh{}, Scope{Node: "laptop", KeptAt: map[string]string{"xterm": oldFP}})
if err != nil || !reflect.DeepEqual(in.Nodes[1].Kept, map[string]catalogue.Manifest{"xterm": keptBuilt}) || in.Nodes[0].Kept != nil {
t.Errorf("the want's build is kept on the composing node alone: %v, %v", in.Nodes, err)
}
// Issue 1126, the seed's refusals. restic and the provisioner's client as the catalogue holds them (mesh-catalog
// c05aa5e6), restic moved with an own secret the vault leaves out (it hands it to the client, mesh-vault ownnode.go
// handed): composed from the indexer's manifest restic is not handed and stands in for that secret, as novox's,
// ace's, g14's and shanks's answers refused; composed from its build, it is handed, its templates archive read
// where the node reaches the artifact store, and the composition is the one the controller's take-in gives.
resticRaw, err := os.ReadFile("testdata/catalogue-c05aa5e6-restic.module.json")
if err != nil {
t.Fatal(err)
}
clientRaw, err := os.ReadFile("testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json")
if err != nil {
t.Fatal(err)
}
store := []byte(`{"module":"store","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],` +
`"serves":{"artifact-store":{"port":5000}}}`)
texts := map[string]string{}
var packed bytes.Buffer
zw := gzip.NewWriter(&packed)
tw := tar.NewWriter(zw)
for _, f := range []string{"backups.conf", "data.conf", "retention.conf"} {
text, err := os.ReadFile("testdata/catalogue-c05aa5e6-restic.files/" + f)
if err != nil {
t.Fatal(err)
}
texts[f] = string(text)
if err := tw.WriteHeader(&tar.Header{Name: "./" + f, Mode: 0o644, Size: int64(len(text)), Typeflag: tar.TypeReg}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(text); err != nil {
t.Fatal(err)
}
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
archiveDigest := delivery.ManifestDigest(packed.Bytes())
digestOf := func(c string) string { return "sha256:" + strings.Repeat(c, 64) }
movedFps := map[string]string{"restic": "src2:" + strings.Repeat("3", 64), "mesh-provisioner-client": "src2:" + strings.Repeat("4", 64),
"store": "src2:" + strings.Repeat("5", 64)}
resticArtifacts := []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: "artifact-store://restic/tools/blobs/" + digestOf("6"), Digest: digestOf("6")},
{Name: "files", Kind: "archive", Reference: "artifact-store://restic/files/blobs/" + archiveDigest, Digest: archiveDigest}}
clientArtifacts := []delivery.BuiltArtifact{{Name: "client", Kind: "bundle", Reference: "artifact-store://mesh-provisioner-client/client/blobs/" + digestOf("7"), Digest: digestOf("7")}}
movedMesh := func(built bool) *bus {
b := one()
p := b.placements["n1"]
p.Modules = []delivery.PlacedModule{{Module: "store"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}
p.Decided.Account, p.Decided.AccountHome = "operator", "/home/operator"
b.placements["n1"] = p
rec := b.records["n1"]
rec.Contract, rec.Capabilities = compose.HandedContract, []string{"systemd", "package-manager"}
b.records["n1"] = rec
b.modules = map[string]delivery.Module{}
b.manifests = map[string][]byte{}
for module, raw := range map[string][]byte{"restic": resticRaw, "mesh-provisioner-client": clientRaw, "store": store} {
b.modules[module] = delivery.Module{FP2: movedFps[module], Manifest: delivery.ManifestDigest(raw)}
b.manifests[delivery.ManifestDigest(raw)] = raw
}
// The vault's own answer: the client's own secret; restic's is the client's to ask, so own leaves it out.
b.own = []compose.OwnSecretRow{{Node: "n1", Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}
if built {
b.builds = map[string]*delivery.BuilderBuild{
"restic " + movedFps["restic"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(resticRaw), Artifacts: resticArtifacts},
"mesh-provisioner-client " + movedFps["mesh-provisioner-client"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(clientRaw), Artifacts: clientArtifacts},
"store " + movedFps["store"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(store)},
}
b.archives = map[string][]byte{archiveDigest: packed.Bytes()}
}
return b
}
if out, _, held, err := Compose(ctx, movedMesh(false), Mesh{}, Scope{Node: "n1"}, movedFps); err != nil || !strings.Contains(held["restic"], `restic needs a secret called "repository"`) {
t.Errorf("restic composed from the indexer's manifest: %v %v, want its own secret refusing restic alone", err, held)
} else {
for key, c := range out {
if c.Module == "restic" {
// Its stub's composition, applied, would remove what restic runs: a held module keeps its last one.
t.Errorf("held restic is composed as %s, want none of its own composition handed", key)
}
}
}
builtBus := movedMesh(true)
fromBuild, _, held, err := Compose(ctx, builtBus, Mesh{}, Scope{Node: "n1"}, movedFps)
if err == nil && len(held) > 0 {
err = fmt.Errorf("modules held: %v", held)
}
if err != nil {
t.Fatalf("restic composed from its build: %v", err)
}
if !reflect.DeepEqual(builtBus.fetched, []string{"http://127.0.0.1:5000/v2/restic/files/blobs/" + archiveDigest}) {
t.Errorf("the templates archive was read at %v", builtBus.fetched)
}
resticKey, _ := delivery.ComposedKey("n1", "restic", movedFps["restic"])
if c := fromBuild[resticKey]; len(c.Filled) != 4 { // its three templated files and its own secret
t.Errorf("restic is not handed to the provisioner: its filled files are %v", c.Filled)
}
// A done key an artifact of which names no reference is waited for, in the controller's words (ADR 0498 §8).
unreferenced := movedMesh(true)
unreferenced.builds["restic "+movedFps["restic"]].Artifacts = []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Digest: digestOf("6")}, resticArtifacts[1]}
if _, _, held, err := Compose(ctx, unreferenced, Mesh{}, Scope{Node: "n1"}, movedFps); err != nil || !strings.Contains(held["restic"], "the build of restic is done and waits for the reference of tools") {
t.Errorf("a done key with an unreferenced artifact: %v %v, want restic held alone", err, held)
}
// A module the node runs inside its own composition (the fold) composes from the indexer's manifest though a done
// build of it is at the trunk's FP2: its build is not read.
folding := movedMesh(true)
foldIn, err := Input(ctx, folding, Mesh{}, Scope{Node: "n1", InOwn: []string{"mesh-provisioner-client"}})
if err != nil {
t.Fatal(err)
}
clientParsed, err := catalogue.Decode(clientRaw)
if err != nil {
t.Fatal(err)
}
if slices.Contains(folding.buildsRead, "mesh-provisioner-client") || !reflect.DeepEqual(foldIn.Catalogue["mesh-provisioner-client"], clientParsed) {
t.Errorf("a folded module was taken from its build: builds read %v", folding.buildsRead)
}
// The controller's: its take-in resolves each manifest with the key's artifacts and the archive's texts, and composes
// from those over the same input.
storeIn, err := Input(ctx, movedMesh(false), Mesh{}, Scope{Node: "n1"})
if err != nil {
t.Fatal(err)
}
for module, built := range map[string][]catalogue.Built{
"restic": {{Name: "tools", Kind: "bundle", Reference: resticArtifacts[0].Reference, Digest: digestOf("6")},
{Name: "files", Kind: "archive", Reference: resticArtifacts[1].Reference, Digest: archiveDigest, Texts: texts}},
"mesh-provisioner-client": {{Name: "client", Kind: "bundle", Reference: clientArtifacts[0].Reference, Digest: digestOf("7")}},
} {
resolved, err := storeIn.Catalogue[module].Resolve(built)
if err != nil {
t.Fatal(err)
}
// Kept as its bytes and read again, as the controller's store holds a build's manifest.
raw, _ := json.Marshal(resolved)
if storeIn.Catalogue[module], err = catalogue.Decode(raw); err != nil {
t.Fatal(err)
}
}
storeIn.OwnSecrets = movedMesh(true).own
fromStoreMoved, _, err := compose.Compositions(storeIn, "n1", movedFps, compose.Reading)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(fromBuild, fromStoreMoved) {
gj, _ := json.MarshalIndent(fromBuild, "", " ")
wj, _ := json.MarshalIndent(fromStoreMoved, "", " ")
t.Errorf("composed from the build differs from the controller's take-in:\n got %s\nwant %s", gj, wj)
}
// ADR 0570 clause 2 (issue 1954 L6-P1): the seat mesh-provisioner's plans, composed from the bus alone and with no
// secret read (the Planning mode), are the controller's handedPlans over its own input, which holds its secrets
// (compose_sdk.go: Resolve, Render in Foreseeing, Generators, Plan.Compose), and restic, handed, has one.
noSecrets := movedMesh(true)
noSecrets.own = nil
seat, err := Plans(ctx, noSecrets, Mesh{}, Scope{Node: "n1"})
if err != nil {
t.Fatalf("the plans from the bus: %v", err)
}
controllers, err := plansIn(storeIn, "n1", compose.Foreseeing)
if err != nil {
t.Fatal(err)
}
if _, handed := seat["restic"]; !handed || !reflect.DeepEqual(seat, controllers) {
gj, _ := json.MarshalIndent(seat, "", " ")
wj, _ := json.MarshalIndent(controllers, "", " ")
t.Errorf("the seat's plans differ from the controller's:\n got %s\nwant %s", gj, wj)
}
// ADR 0571 clause 3: the data items of n1's modules, placed, from the bus alone: what disk-load measures.
items, unplaced, err := DataItems(ctx, noSecrets, Mesh{}, Scope{Node: "n1"})
if err != nil || len(unplaced) > 0 || len(items) == 0 {
t.Errorf("the data items from the bus are %v, unplaced %v: %v", items, unplaced, err)
}
for module, its := range items {
for _, it := range its {
if it.Module != module || !strings.HasPrefix(it.Path, "/") || it.Measure == "" {
t.Errorf("%s's item %+v is not placed", module, it)
}
}
}
// And the same through StatesReader, over the fake's states as a module's runtime gives them (one reader for
// mesh-provisioner and disk-load, the lead 2026-10-29).
viaStates, _, err := DataItems(ctx, StatesReader(statesOf(t, ctx, noSecrets), func(_ context.Context, url string) ([]byte, error) {
for _, body := range noSecrets.archives {
return body, nil
}
return nil, errors.New("no archive")
}), Mesh{}, Scope{Node: "n1"})
if err != nil || !reflect.DeepEqual(viaStates, items) {
t.Errorf("through the states the data items are %v, %v; through the reader %v", viaStates, err, items)
}
// And what a provider on another node serves from its settings (mailu's smtp domain, issue 1126: shanks refused
// '${setting:domain} ... nothing sets "domain"'): read for that provider's served keys alone, through Served.
mailer := []byte(`{"module":"mailer","version":"1","config":{"domain":{"type":"string","description":"the mail domain","required":true}},` +
`"provides":[{"name":"smtp","scope":"mesh"}],"serves":{"smtp":{"port":587,"domain":"${config:domain}"}}}`)
withMailer := func(set bool) *bus {
b := mk()
p := b.placements["anchor"]
p.Modules = append(p.Modules, delivery.PlacedModule{Module: "mailer"})
b.placements["anchor"] = p
b.modules["mailer"] = delivery.Module{Manifest: delivery.ManifestDigest(mailer)}
b.manifests[delivery.ManifestDigest(mailer)] = mailer
if set {
b.served = map[string]map[string]any{"anchor.mailer": {"domain": "example.org"}}
}
return b
}
if _, _, _, err := Compose(ctx, withMailer(false), Mesh{}, Scope{Node: "laptop"}, fps); err == nil || !strings.Contains(err.Error(), `nothing sets "domain"`) {
t.Errorf("a provider's served setting nobody sets: %v", err)
}
served := withMailer(true)
if _, _, held, err := Compose(ctx, served, Mesh{}, Scope{Node: "laptop"}, fps); err != nil || len(held) > 0 {
t.Errorf("laptop with what anchor's mailer serves: %v %v", err, held)
}
if !reflect.DeepEqual(served.servedRead, []string{"anchor.mailer:domain"}) || slices.Contains(served.valuesRead, "anchor.mailer") {
t.Errorf("laptop read %v of other nodes (and %v of config values): the served keys of anchor's mailer alone", served.servedRead, served.valuesRead)
}
for name, c := range map[string]struct {
change func(*bus)
scope Scope
says string
}{
"no record": {func(b *bus) { delete(b.records, "laptop") }, Scope{Node: "anchor"}, "laptop, whose agent has written no record"},
"no placement": {func(b *bus) {}, Scope{Node: "stranger"}, "stranger has no placement"},
"a manifest unread": {func(b *bus) { delete(b.manifests, digest) }, Scope{Node: "laptop"}, "the manifest of xterm"},
"a kept one unbuilt": {func(b *bus) {}, Scope{Node: "laptop", KeptAt: map[string]string{"xterm": "src2:gone"}}, "holds no done build"},
} {
b := mk()
c.change(b)
if _, err := Input(ctx, b, Mesh{}, c.scope); err == nil || !strings.Contains(err.Error(), c.says) {
t.Errorf("%s: %v, want it to say %q", name, err, c.says)
}
}
}
// mapStates are read states by name, key to value, as a module's runtime gives them.
type mapStates map[string]map[string][]byte
func (m mapStates) Get(state, key string) ([]byte, bool, error) {
v, ok := m[state][key]
return v, ok, nil
}
func (m mapStates) Keys(state string) ([]string, error) {
var out []string
for k := range m[state] {
out = append(out, k)
}
sort.Strings(out)
return out, nil
}
// statesOf is b laid out as the states StatesReader reads.
func statesOf(t *testing.T, ctx context.Context, b *bus) mapStates {
t.Helper()
out := mapStates{}
put := func(state, key string, raw []byte, err error) {
if err != nil {
t.Fatal(err)
}
if out[state] == nil {
out[state] = map[string][]byte{}
}
out[state][key] = raw
}
placements, _ := b.Placements(ctx)
for _, p := range placements {
values, err := delivery.PlacementValues(p)
for k, raw := range values {
put(PlacementRead, k, raw, err)
}
}
records, _ := b.Records(ctx)
for node, r := range records {
k, _ := delivery.HaveNodeKey(node)
rec := r
raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec})
put(HaveRead, k, raw, err)
}
modules, _ := b.Modules(ctx)
for name, m := range modules {
raw, err := delivery.EncodeModule(m)
put(ModulesRead, name, raw, err)
var mf struct{ Module string }
_ = json.Unmarshal(b.manifests[m.Manifest], &mf)
k, _ := delivery.ManifestsKey(m.Manifest)
raw, err = delivery.EncodeManifest(delivery.Manifest{Module: mf.Module, JSON: string(b.manifests[m.Manifest])})
put(ManifestsRead, k, raw, err)
}
for key, build := range b.builds {
module, fp, _ := strings.Cut(key, " ")
k, _ := delivery.BuilderBuildKey(module, fp)
raw, err := delivery.EncodeBuilderBuild(*build)
put(BuildsRead, k, raw, err)
}
for nm, values := range b.values {
for k, v := range values {
value, _ := json.Marshal(v)
raw, err := json.Marshal(map[string]any{"value": json.RawMessage(value), "source": catalogue.SourceModule})
put(ResolvedRead, nm+"."+k, raw, err)
}
}
return out
}
@@ -0,0 +1,183 @@
// Package frombustest is a bus in memory for frombus's Reader, and a mesh on it whose composition hands a module to the
// provisioner with a provision to make, so a module that composes plans from the bus (mesh-provisioner, novox/hq ADR
// 0570 clause 2) tests against a real plan rather than one written by hand. The manifests are the catalogue's (mesh-catalog
// c05aa5e6: restic and the provisioner's client) as frombus's own tests read them, restic given one requirement more.
package frombustest
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"embed"
"encoding/json"
"errors"
"strings"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/compose"
"github.com/novox/mesh-host/internal/compose/frombus"
)
//go:embed testdata
var testdata embed.FS
// Bus is a frombus.Reader over values held in memory. A credential asked is answered held when Held is set.
type Bus struct {
Placement map[string]delivery.Placement
Record map[string]delivery.NodeRecord
Module map[string]delivery.Module
Manifests map[string][]byte
Value map[string]map[string]any // by node.module
Own []compose.OwnSecretRow
Builds map[string]*delivery.BuilderBuild // by "<module> <fp>"
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)}
}
@@ -0,0 +1,272 @@
package frombustest
import (
"context"
"reflect"
"strings"
"testing"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/compose/frombus"
"github.com/novox/mesh-host/internal/compose/fromruntime"
)
// ADR 0570 clause 2 (issue 1954; a3's H5): the in-memory mesh composes a plan from the bus that hands restic to the
// provisioner and names its postgres-database, provided by postgres on n1. And one bad module must not drop its node
// (issue 774): with a module placed beside it whose built manifest cannot be used, or whose plan refuses, PlansHeld
// still hands restic's plan and names the bad one with why, where Plans refuses the whole node. A manifest the indexer
// holds that the mesh refuses (power's bar item with attributes its bar places by none of, mesh-catalog ca8d6f33,
// 2026-10-30) holds that module alone on its node, in the plans and in the node's composition (Compose), and on a node
// it is not placed on holds nothing.
func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
fp := "src2:" + strings.Repeat("9", 64)
for _, row := range []struct {
name string
bad string // the indexer's manifest of a module "backup" placed on n1; empty places none
// built is the manifest backup's done build names; empty gives it no build
built string
why string // words backup's hold says
// elsewhere leaves backup in the indexer's modules and places it on no node: nothing is held.
elsewhere bool
// refused is the indexer's verdict on bad, a problem its judge found (novox/hq ADR 0578 §1): Plans then holds
// backup too, and never ends the read.
refused string
// dataHeld says the data items' read holds backup too: a refusal it meets (a refused manifest, an unusable build,
// an item composition refuses), not one only a plan meets.
dataHeld bool
// dataOnly checks the data items' read alone: the other reads blame restic, whose templates collect the item.
dataOnly bool
}{
{name: "every module composes"},
{name: "a built manifest that cannot be used",
bad: `{"module":"backup","version":"1","build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`,
built: `{"module":"backup","version":"1","requires":"amqp"}`, why: "backup", dataHeld: true},
{name: "a plan that refuses",
bad: `{"module":"backup","version":"1","resources":[{"id":"conf","type":"file","path":"/etc/backup.conf",` +
`"mode":"0600","content":"key=${secret:nope}"}]}`, why: "${secret:nope}"},
{name: "an indexer's manifest the mesh refuses", bad: unusableItem, why: `"shows" is none of them`,
refused: `"shows" is none of them`, dataHeld: true},
// ADR 0578 §3: an accepted manifest whose data item its composition refuses is held by the culprit rule, in every read.
{name: "a data item its composition refuses",
bad: `{"module":"backup","version":"1","data":{"own":[{"id":"d","path":"${dir:nope}","class":"cache"}]}}`,
why: "nope", dataHeld: true, dataOnly: true},
{name: "an indexer's manifest the mesh refuses, placed elsewhere", bad: unusableItem, elsewhere: true,
refused: `"shows" is none of them`},
} {
b, err := Provisioned()
if err != nil {
t.Fatal(err)
}
if row.bad != "" {
if !row.elsewhere {
p := b.Placement[Node]
p.Modules = append(p.Modules, delivery.PlacedModule{Module: "backup"})
b.Placement[Node] = p
}
digest := delivery.ManifestDigest([]byte(row.bad))
b.Manifests[digest] = []byte(row.bad)
b.Module["backup"] = delivery.Module{FP2: fp, Manifest: digest}
if row.refused != "" {
b.Module["backup"] = delivery.Module{FP2: fp, Manifest: digest, Verdict: delivery.VerdictRefused,
Problems: []string{row.refused}}
}
if row.built != "" {
builtDigest := delivery.ManifestDigest([]byte(row.built))
b.Manifests[builtDigest] = []byte(row.built)
b.Builds["backup "+fp] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: builtDigest,
Artifacts: []delivery.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://backup/files/blobs/x", Digest: "sha256:x"}}}
}
if row.dataOnly {
items, _, iheld, err := frombus.DataItemsHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
if why, ok := iheld["backup"]; err != nil || !ok || !strings.Contains(why, row.why) || len(items["restic"]) == 0 {
t.Errorf("%s: data items %v held %v: %v", row.name, items, iheld, err)
}
continue
}
plans, err := frombus.Plans(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
switch {
case row.refused != "" && (err != nil || !planned(plans, "restic") || planned(plans, "backup")):
t.Errorf("%s: Plans did not hold the refused manifest alone: %v %v", row.name, plans, err)
case row.refused == "" && err == nil && !row.elsewhere:
t.Errorf("%s: Plans composed the node; the row does not refuse it", row.name)
}
}
if row.why != "" || row.bad == "" {
// The node's own composition holds by the same rule: every other module composed, the bad one named.
fps := FPs()
if row.bad != "" {
fps["backup"] = fp
}
out, _, held, err := frombus.Compose(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}, fps)
if err != nil {
t.Fatalf("%s: the node's composition: %v", row.name, err)
}
if key, _ := delivery.ComposedKey(Node, "restic", fps["restic"]); out[key].Module != "restic" {
t.Errorf("%s: restic is not composed (held %v)", row.name, held)
}
if why, ok := held["backup"]; (row.bad == "" && len(held) > 0) || (row.bad != "" && (!ok || !strings.Contains(why, row.why) || len(held) != 1)) {
t.Errorf("%s: the node's composition held %v, want backup alone, saying %q", row.name, held, row.why)
}
}
plans, held, err := frombus.PlansHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
if err != nil {
t.Fatalf("%s: %v", row.name, err)
}
restic, handed := plans["restic"]
if !handed || len(restic.Provisions) != 1 || restic.Provisions[0].Provision != "postgres-database" ||
restic.Provisions[0].Provider != Node || restic.Provisions[0].ProviderModule != "postgres" {
t.Fatalf("%s: the plans are %+v (held %v)", row.name, plans, held)
}
// Issue 1954: the plans at the wanted builds and at the newest, over one read, are what PlansHeld answers for each,
// and where the time went is said by phase.
keptAt := FPs()
at, newest, took := frombus.PlansHeldTwice(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node, KeptAt: keptAt})
wantAt, heldAt, errAt := frombus.PlansHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node, KeptAt: keptAt})
if !reflect.DeepEqual(at, frombus.HeldPlans{Plans: wantAt, Held: heldAt, Err: errAt}) ||
!reflect.DeepEqual(newest, frombus.HeldPlans{Plans: plans, Held: held}) {
t.Errorf("%s: twice composed %+v and %+v, want %v %v %v and %v %v", row.name, at, newest, wantAt, heldAt, errAt, plans, held)
}
if said := frombus.PhasesSaid(took); !strings.HasPrefix(said, "read ") || !strings.Contains(said, "plans at newest ") {
t.Errorf("%s: the phases are said as %q", row.name, said)
}
if row.bad == "" || row.elsewhere {
if len(held) > 0 {
t.Errorf("%s: held %v", row.name, held)
}
continue
}
if why, ok := held["backup"]; !ok || !strings.Contains(why, row.why) || len(held) != 1 {
t.Errorf("%s: held %v, want backup alone, saying %q", row.name, held, row.why)
}
if _, planned := plans["backup"]; planned {
t.Errorf("%s: the held module has a plan", row.name)
}
// The data items are read by the same rule (ADR 0578 §3: disk-load's read): the bad module held, the read whole.
items, _, iheld, err := frombus.DataItemsHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
// A refusal only a plan meets (a file's secret) holds nothing here: data items render no such file.
mustHold := row.dataHeld
if why, ok := iheld["backup"]; err != nil || ok != mustHold || mustHold && !strings.Contains(why, row.why) || items["backup"] != nil {
t.Errorf("%s: data items %v held %v: %v", row.name, items, iheld, err)
}
}
// ADR 0580 clause 1: the watched reader reads a state it composes from out of the copy, a build by key each time it is
// asked, and a manifest once per digest.
byKey := map[string]int{}
ws := stdio.WatchedStatesOver(func(state string, handle func(stdio.StateChange) error) error {
byKey[state+" watched"]++
return handle(stdio.StateChange{Key: "placement." + Node, Op: stdio.OpPut, Value: []byte(`{}`), Current: true})
}, func(state, key string) ([]byte, bool, error) { byKey[state]++; return []byte(key), true, nil })
w := fromruntime.Watched{WatchedStates: ws}
for range 2 {
_, _, _ = w.Get(frombus.BuildsRead, "restic."+fp)
_, _, _ = w.Get(frombus.ManifestsRead, "sha256-d")
if _, found, _ := w.Get(frombus.PlacementRead, "placement."+Node); !found {
t.Error("the watched reader does not answer the placement from its copy")
}
}
if byKey[frombus.BuildsRead] != 2 || byKey[frombus.ManifestsRead] != 1 || byKey[frombus.PlacementRead] != 0 ||
byKey[frombus.PlacementRead+" watched"] != 1 {
t.Errorf("the watched reader read %v, want a build by key each time, a manifest once, the placement watched once", byKey)
}
fetched := 0
keep := fromruntime.KeptFetch(func(context.Context, string) ([]byte, error) { fetched++; return []byte("tar"), nil })
for range 2 {
if body, err := keep(context.Background(), "http://store/restic/files/blobs/sha256:a"); err != nil || string(body) != "tar" {
t.Fatalf("the kept fetch answered %q, %v", body, err)
}
}
if fetched != 1 {
t.Errorf("a templates archive was fetched %d times, want once", fetched)
}
// Issue 1954: the states reader takes a build in once per its content: composed again, its archive is not read again
// and the plans are the same.
b, err := Provisioned()
if err != nil {
t.Fatal(err)
}
reader, archives := frombus.StatesReader(statesOf(t, b), func(_ context.Context, url string) ([]byte, error) {
fetched++
_, digest, _ := strings.Cut(url, "/blobs/")
return b.Archives[digest], nil
}), fetched
var first map[string]catalogue.Plan
for i := range 2 {
plans, held, err := frombus.PlansHeld(context.Background(), reader, frombus.Mesh{}, frombus.Scope{Node: Node})
if err != nil || len(held) > 0 || (i == 1 && !reflect.DeepEqual(plans, first)) || !planned(plans, "restic") {
t.Fatalf("composed %v (held %v): %v", plans, held, err)
}
first = plans
}
if fetched-archives != 1 {
t.Errorf("restic's archive was read %d times over two compositions, want once", fetched-archives)
}
}
// statesOf is the bus b as a module's read states hold it.
func statesOf(t *testing.T, b *Bus) frombus.States {
t.Helper()
s := states{}
put := func(state, key string, raw []byte, err error) {
if err != nil {
t.Fatal(err)
}
if s[state] == nil {
s[state] = map[string][]byte{}
}
s[state][key] = raw
}
for node, p := range b.Placement {
raw, err := delivery.EncodePlacement(p)
put(frombus.PlacementRead, "placement."+node, raw, err)
}
for node, rec := range b.Record {
raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec})
put(frombus.HaveRead, node+".node", raw, err)
}
for module, m := range b.Module {
key, _ := delivery.ModulesKey(module)
raw, err := delivery.EncodeModule(m)
put(frombus.ModulesRead, key, raw, err)
}
for digest, man := range b.Manifests {
key, _ := delivery.ManifestsKey(digest)
raw, err := delivery.EncodeManifest(delivery.Manifest{Module: "m", JSON: string(man)})
put(frombus.ManifestsRead, key, raw, err)
}
for at, build := range b.Builds {
module, fp, _ := strings.Cut(at, " ")
key, _ := delivery.BuilderBuildKey(module, fp)
raw, err := delivery.EncodeBuilderBuild(*build)
put(frombus.BuildsRead, key, raw, err)
}
return s
}
// states are read states in memory.
type states map[string]map[string][]byte
func (s states) Get(state, key string) ([]byte, bool, error) {
v, ok := s[state][key]
return v, ok, nil
}
func (s states) Keys(state string) ([]string, error) {
var keys []string
for k := range s[state] {
keys = append(keys, k)
}
return keys, nil
}
func planned[P any](plans map[string]P, module string) bool { _, ok := plans[module]; return ok }
// unusableItem is a manifest the mesh refuses as the node-engine did power's at mesh-catalog ca8d6f33: a bar block item
// with attributes its bar places by none of.
const unusableItem = `{"module":"backup","version":"1","moved":true,` +
`"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},` +
`"items":[{"collection":"contribution:node-bar:block",` +
`"attributes":{"bar":"bottom","place":"status","shows":"state","state":"power.draw"},"template":"files/bar"}]}`
@@ -0,0 +1,60 @@
{
"module": "mesh-provisioner-client",
"version": "1",
"claims": [
{
"name": "node-provisioner",
"scope": "node"
}
],
"own-secrets": {
"provisioner-broker": "${dir:state}/broker"
},
"secrets-owner": "mesh-provisioner-client",
"resources": [
{
"id": "account",
"type": "user",
"name": "mesh-provisioner-client",
"shell": "/usr/bin/nologin",
"home": "/var/lib/mesh-provisioner-client"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"owner": "mesh-provisioner-client",
"place": ".",
"unknown": "program"
},
{
"id": "client",
"type": "process",
"name": "mesh-provisioner-client",
"artifact": "client",
"run": [
"./mesh-provisioner-client"
],
"env": {
"MESH_PROVISIONER_CREDENTIAL": "${dir:state}/broker",
"MESH_PROVISIONER_STATE": "${dir:state}"
},
"user": "mesh-provisioner-client",
"health": {
"kind": "unit"
}
}
],
"build": {
"artifacts": [
{
"name": "client",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/mesh-provisioner-client",
"binary": "mesh-provisioner-client"
}
]
}
}
@@ -0,0 +1,2 @@
# What the modules on this machine back up, composed by the mesh (novox/hq to-be 43). Do not edit.
${backup:lines}
@@ -0,0 +1,2 @@
# The data the modules on this machine declare, composed by the mesh (novox/hq ADR 0233). Do not edit.
${backup:data}
@@ -0,0 +1,6 @@
# How many restore points the night keeps, written by the mesh from this machine's settings keep-daily,
# keep-weekly, keep-monthly (novox/hq issue 416) and keep-hand-taken-days (novox/hq issue 453). Replaced on every push; change the settings, never this file.
daily=${config:keep-daily}
weekly=${config:keep-weekly}
monthly=${config:keep-monthly}
hand=${config:keep-hand-taken-days}
@@ -0,0 +1,199 @@
{
"module": "restic",
"version": "1",
"moved": true,
"claims": [
{
"name": "node-backup",
"scope": "node",
"serves": [
"backed-up",
"now",
"restore"
]
}
],
"emits": [
"backed-up"
],
"tools": [
"backup_health",
"backup_delete_retired",
"backup_deleted"
],
"own-secrets": {
"repository": "${dir:state}/repository.secret"
},
"data": {
"own": [
{
"id": "repository",
"path": "${dir:repository}",
"class": "rebuildable",
"backup": "none",
"why": "the restore points themselves: a copy of data kept elsewhere on this machine, never the only copy of anything; lost, the history goes and nothing live does"
},
{
"id": "state",
"path": "${dir:state}",
"class": "cache",
"why": "what the last nights and measurements were; the next night writes it again"
}
]
},
"config": {
"keep-daily": {
"type": "number",
"description": "How many nightly restore points are kept, a count from 1 to 9999: two weeks undo a mistake noticed within a fortnight. Read by the night, which thins nothing while it does not read (novox/hq issue 416); lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
"minimum": 1,
"maximum": 9999,
"trusted": true,
"default": 14
},
"keep-weekly": {
"type": "number",
"description": "How many weekly restore points are kept behind the dailies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
"minimum": 1,
"maximum": 9999,
"trusted": true,
"default": 8
},
"keep-monthly": {
"type": "number",
"description": "How many monthly restore points are kept behind the weeklies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
"minimum": 1,
"maximum": 9999,
"trusted": true,
"default": 6
},
"keep-hand-taken-days": {
"type": "number",
"description": "How many days a restore point taken by hand (node-backup.now, or the mesh's before a module moves) is kept at least, a count from 1 to 9999; it is kept until a later good night of its module exists, and this many days in any case, so the next thinning never takes the one taken against a risky change (novox/hq issue 453). Lowering it lets go of the older ones taken by hand at the next thinning, and that cannot be undone.",
"minimum": 1,
"maximum": 9999,
"trusted": true,
"default": 7
}
},
"resources": [
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": ".",
"unknown": "program"
},
{
"id": "repository",
"type": "directory",
"mode": "0700",
"unknown": "program"
},
{
"id": "declared",
"type": "file",
"path": "${dir:state}/backups.conf",
"mode": "0600",
"template": "files/backups.conf"
},
{
"id": "data-declared",
"type": "file",
"path": "${dir:state}/data.conf",
"mode": "0600",
"template": "files/data.conf"
},
{
"id": "retention",
"type": "file",
"path": "${dir:state}/retention.conf",
"mode": "0644",
"trusted": true,
"template": "files/retention.conf"
},
{
"id": "tool",
"type": "package",
"package": "restic"
},
{
"id": "sqlite",
"type": "package",
"package": "sqlite"
},
{
"id": "night",
"type": "process",
"name": "restic-night",
"artifact": "tools",
"run": [
"./restic-backups",
"night"
],
"user": "${machine:account}",
"env": {
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
"MESH_BACKUP_STATE": "${dir:state}",
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
},
"health": {
"kind": "tool",
"tool": "backup_health",
"interval": "60s",
"timeout": "20s",
"looks": 2,
"grace": "60s"
}
},
{
"id": "measure",
"type": "process",
"name": "restic-measure",
"artifact": "tools",
"run": [
"./restic-backups",
"measure"
],
"user": "${machine:account}",
"env": {
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
"MESH_BACKUP_STATE": "${dir:state}",
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
},
"health": {
"kind": "unit"
}
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/restic-backups",
"binary": "restic-backups",
"loads": [
"restic-backups"
],
"env": {
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
"MESH_BACKUP_STATE": "${dir:state}",
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
}
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
}
+299
View File
@@ -0,0 +1,299 @@
package frombus
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/compose"
"github.com/novox/mesh-host/internal/compose/resolution"
)
// aNode is a bus with n1 the hub, each of manifests placed on it and held at the indexer, in the order given.
func aNode(t *testing.T, manifests ...string) (*bus, map[string]string) {
t.Helper()
p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}}
b := &bus{records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd"}}},
modules: map[string]delivery.Module{}, manifests: map[string][]byte{}, builds: map[string]*delivery.BuilderBuild{}}
fps := map[string]string{}
for i, raw := range manifests {
module := strings.SplitN(strings.TrimPrefix(raw, `{"module":"`), `"`, 2)[0]
p.Modules = append(p.Modules, delivery.PlacedModule{Module: module})
b.modules[module] = delivery.Module{Manifest: delivery.ManifestDigest([]byte(raw))}
b.manifests[delivery.ManifestDigest([]byte(raw))] = []byte(raw)
fps[module] = "src2:" + strings.Repeat(string(rune('1'+i)), 64)
}
b.placements = map[string]delivery.Placement{"n1": p}
return b, fps
}
// Issue 774, 2026-10-30 19:30: a node refused whole after holding a module says what it held and why, since the last
// refusal can be of the held module's stub (node-tools held, then "its build produced 0 bundle(s)", its own why unsaid).
func aNodeRefusedWholeSaysWhatItHeld(t *testing.T) {
refused := `{"module":"power","version":"1","items":[{"collection":"contribution:node-bar:block","attributes":{"shows":"state"},"template":"files/a"}]}`
needs := `{"module":"lamp","version":"1","requires":["nothing-provides-this"]}`
b, fps := aNode(t, refused, needs)
refuse(b, "power", `"shows" is none of them`)
_, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1"}, fps)
if err == nil || !strings.Contains(err.Error(), "nothing provides") || !strings.Contains(err.Error(), `held before: power: `) ||
!strings.Contains(err.Error(), `"shows" is none of them`) {
t.Errorf("the node refused whole with %v (held %v), want lamp's refusal and power held before, with its why", err, held)
}
}
// refuse writes the indexer's refusal of module's manifest into its modules value (novox/hq ADR 0578 §1).
func refuse(b *bus, module string, problems ...string) {
m := b.modules[module]
m.Verdict, m.Problems = delivery.VerdictRefused, problems
b.modules[module] = m
}
// ADR 0578 §3: the bus reader never ends a whole read on a refused manifest. Input, Plans and DataItems compose the rest
// and hold the refused module, named with the indexer's words in Refused; a field the decoder does not know is set aside
// and named, and its module composed.
func aRefusedManifestNeverEndsTheRead(t *testing.T) {
ok := `{"module":"lamp","version":"1","resources":[{"id":"d","type":"directory","path":"/srv/lamp","mode":"0700"}],` +
`"data":{"own":[{"id":"d","path":"${dir:d}","class":"valuable"}]}}`
bad := `{"module":"power","version":"1","resources":[{"id":"d","type":"directory","path":"/srv/power","mode":"0700"}],` +
`"data":{"own":[{"id":"d","path":"${dir:d}","class":"valuable"}]}}`
later := `{"module":"later","version":"1","a-field-from-later":1}`
b, _ := aNode(t, ok, bad, later)
refuse(b, "power", "removes a stream it may not")
ctx := context.Background()
in, err := Input(ctx, b, Mesh{}, Scope{Node: "n1"})
if err != nil {
t.Fatalf("one refused manifest ended the read: %v", err)
}
if why := in.Refused["power"]; !strings.Contains(why, "refused the manifest of power: removes a stream it may not") ||
len(in.Refused) != 1 {
t.Fatalf("refused: %v", in.Refused)
}
if aside := in.SetAside["later"]; !strings.Contains(aside, "a-field-from-later") {
t.Fatalf("set aside: %v", in.SetAside)
}
if !in.Catalogue["power"].Stubbed || in.Catalogue["lamp"].Stubbed || in.Catalogue["later"].Module != "later" {
t.Fatalf("the catalogue: %+v", in.Catalogue)
}
if _, err := Plans(ctx, b, Mesh{}, Scope{Node: "n1"}); err != nil {
t.Fatalf("plans: %v", err)
}
items, _, held, err := DataItemsHeld(ctx, b, Mesh{}, Scope{Node: "n1"})
if err != nil || len(items["lamp"]) != 1 || items["power"] != nil || !strings.Contains(held["power"], "removes a stream") {
t.Fatalf("data items %v held %v: %v", items, held, err)
}
}
// Issue 774: the culprit rule takes a module whose stub composes or fails otherwise; the runtime only where its stub
// composes, since its stub always fails otherwise.
func theRuntimeIsBlamedOnlyWhereItsStubComposes(t *testing.T) {
err := errors.New("lamp refuses")
for _, row := range []struct {
module string
again error
blamed bool
}{
{"lamp", nil, true},
{"lamp", errors.New("other"), true},
{"lamp", errors.New("lamp refuses"), false},
{catalogue.RuntimeModule, nil, true},
{catalogue.RuntimeModule, errors.New("node-tools is assigned to n1 and its build produced 0 bundle(s)"), false},
{catalogue.RuntimeModule, fmt.Errorf("compose: %w", &resolution.NoRuntimeToRunError{Held: true}), true},
{catalogue.RuntimeModule, &resolution.NoRuntimeToRunError{}, false},
} {
if got := blames(row.module, row.again, err); got != row.blamed {
t.Errorf("%s with %v: blamed %v, want %v", row.module, row.again, got, row.blamed)
}
}
}
// Issue 774, 2026-10-30 19:30: the trunk's node-tools moved to a build not yet done, and ace, g14 and shanks composed
// it from the indexer's manifest, which names no bundle: "its build produced 0 bundle(s)", every module refused. A
// module that runs is now held until its build lands, the runtime composed as what runs of it; and a module wanted at
// another build than the trunk's is composed from the build its want names.
func aRunningModuleWaitsForItsBuild(t *testing.T) {
dg := func(c string) string { return "sha256:" + strings.Repeat(c, 64) }
tools := `{"module":"node-tools","version":"1","own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` +
`"build":{"artifacts":[{"name":"runtime","kind":"bundle","language":"go","system":"arch","from":"cmd/node-tools"}]}}`
lamp := `{"module":"lamp","version":"1","resources":[{"id":"service","type":"service","unit":"lamp.service","state":"running","boot":"enabled"}]}`
store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}},` +
`"receives":{"artifact-store":"/var/lib/artifacts/received.json"}}`
old, next := "src2:"+strings.Repeat("a", 64), "src2:"+strings.Repeat("b", 64)
running := []delivery.Composed{{Node: "n1", Module: catalogue.RuntimeModule, FP: old, Resources: []json.RawMessage{
json.RawMessage(`{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{"MESH_TOOL_MODULES":""}}`),
json.RawMessage(`{"id":"node-tools.bundle-runtime","module":"node-tools","type":"archive","source":"artifact-store://node-tools/runtime/blobs/` +
dg("1") + `","digest":"` + dg("1") + `","path":"` + resolution.BundlePath("node-tools", "runtime") + `"}`)}}}
for _, row := range []struct {
name string
want string // the build node-tools' want names
done bool // the trunk's build is done
held string // words node-tools' hold says; empty: composed
digest string // the runtime bundle's digest composed
}{
{name: "the trunk's build not done yet", want: next, held: "no done build yet"},
{name: "wanted at the build that runs, the trunk's not done", want: old, digest: dg("1")},
{name: "the trunk's build done", want: next, done: true, digest: dg("2")},
} {
b, fps := aNode(t, store, tools, lamp)
b.held = true // every grant held, so a contribution's credential stands in for nothing
b.own = []compose.OwnSecretRow{{Node: "n1", Module: "node-tools", Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}
mod := b.modules["node-tools"]
mod.FP2 = next
b.modules["node-tools"] = mod
b.builds["node-tools "+old] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
Artifacts: []delivery.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("1"), Digest: dg("1")}}}
b.builds["node-tools "+next] = &delivery.BuilderBuild{State: "building", Manifest: mod.Manifest}
if row.done {
b.builds["node-tools "+next] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
Artifacts: []delivery.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("2"), Digest: dg("2")}}}
}
fps["node-tools"] = row.want
out, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1", Running: running}, fps)
if err != nil {
t.Errorf("%s: the node is refused: %v", row.name, err)
continue
}
if lampKey, _ := delivery.ComposedKey("n1", "lamp", fps["lamp"]); out[lampKey].Module != "lamp" {
t.Errorf("%s: lamp is not composed (held %v)", row.name, held)
}
if why := held["node-tools"]; (row.held == "") != (why == "") || !strings.Contains(why, row.held) {
t.Errorf("%s: node-tools held %q, want %q", row.name, why, row.held)
}
if row.digest != "" {
key, _ := delivery.ComposedKey("n1", "node-tools", row.want)
if c := out[key]; !strings.Contains(fmt.Sprint(rawStrings(c.Resources)), row.digest) {
t.Errorf("%s: node-tools is composed as %v, want its bundle %s", row.name, rawStrings(c.Resources), row.digest)
}
}
}
}
func rawStrings(rs []json.RawMessage) []string {
var out []string
for _, r := range rs {
out = append(out, string(r))
}
return out
}
// Issue 774: a refusal of a held module's stand-in leads with why the module was held, its first reason, as mesh-self-check's
// on ace should have; a refusal naming no held module is as it was.
func aHeldModulesFirstReasonLeads(t *testing.T) {
held := map[string]string{"mesh-self-check": "mesh-self-check on ace has no bus credential: nothing was issued for ace.mesh-self-check"}
for _, row := range []struct {
err, want string
}{
{"mesh-self-check runs as its own account and declares no own secret broker",
"mesh-self-check is held: mesh-self-check on ace has no bus credential: nothing was issued for ace.mesh-self-check; " +
"its stand-in is then refused: mesh-self-check runs as its own account and declares no own secret broker"},
{"ace is the hub and has no endpoint", "ace is the hub and has no endpoint"},
} {
err := errors.New(row.err)
got := firstReason(err, held)
if got.Error() != row.want || !errors.Is(got, err) {
t.Errorf("refused %q, want %q", got, row.want)
}
}
}
// Issue 774, 2026-10-30 21:04: mesh-conditions and mesh-healers on novox and mesh-self-check on ace were placed before their
// bus accounts were issued. Held for "needs a secret called broker and none was made", each one's bare stub was then
// refused as "runs as its own account and declares no own secret broker", and that refusal failed every module of the
// node (79 on novox, 74 on ace). A held module's stand-in is never refused by a rule about its own runtime or its own
// secrets: the node composes every other module, and the held one keeps the words it was first held for.
func aHeldModuleOfItsOwnAccountNeverRefusesTheNode(t *testing.T) {
dg := "sha256:" + strings.Repeat("1", 64)
tools := `{"module":"node-tools","version":"1","own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` +
`"build":{"artifacts":[{"name":"runtime","kind":"bundle","language":"go","system":"arch","from":"cmd/node-tools"}]}}`
own := func(name string) string {
return `{"module":"` + name + `","version":"1","runs-as":"` + name + `","secrets-owner":"` + name + `","tools":["status"],` +
`"own-secrets":{"broker":"/var/lib/` + name + `/broker"},"contributes":{"artifact-store":{"name":"` + name + `"}},` +
`"resources":[{"id":"account","type":"user","name":"` + name + `"}],` +
`"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["tools/index.js"]}]}}`
}
lamp := `{"module":"lamp","version":"1","resources":[{"id":"service","type":"service","unit":"lamp.service","state":"running","boot":"enabled"}]}`
store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}},` +
`"receives":{"artifact-store":"/var/lib/artifacts/received.json"}}`
for _, row := range []struct {
name string
issued []string // the own-account modules whose broker is issued
building bool // node-tools' build is not done, and nothing of it runs
held map[string]string // the modules held, each with words of the reason it was first held for
// ungranted gives the provider a grants file and no consumer's grant is issued yet (a newly placed module's
// usual state): a held module's contribution then stands in for its grant, and is left out (review round 1).
ungranted bool
}{
{name: "none issued", held: map[string]string{"check": "broker", "heal": "broker"}},
{name: "none issued, no grant issued", ungranted: true, held: map[string]string{"check": "broker", "heal": "broker"}},
{name: "one issued", issued: []string{"heal"}, held: map[string]string{"check": "broker"}},
{name: "both issued", issued: []string{"check", "heal"}},
// The runtime held: its stand-in is not refused for its bundle; the modules it would run are held, not the node.
{name: "the runtime's build not done", issued: []string{"check", "heal"}, building: true,
held: map[string]string{"node-tools": "its build produced 0 bundle(s)", "check": "not here to run it from", "heal": "not here to run it from"}},
} {
provider := store
if row.ungranted {
provider = strings.Replace(store, `"receives":`, `"grants":{"artifact-store":"/var/lib/artifacts/grants"},"receives":`, 1)
}
b, fps := aNode(t, provider, tools, own("check"), lamp, own("heal"))
// Every grant held, so the held modules' contributions are composed whole; the row ungranted issues none.
b.held = !row.ungranted
b.own = []compose.OwnSecretRow{{Node: "n1", Module: "node-tools", Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}
for _, m := range row.issued {
b.own = append(b.own, compose.OwnSecretRow{Node: "n1", Module: m, Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`})
}
for _, m := range []string{"node-tools", "check", "heal"} {
name := "tools"
if m == "node-tools" {
name = "runtime"
}
mod := b.modules[m]
mod.FP2 = fps[m]
b.modules[m] = mod
b.builds[m+" "+fps[m]] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
Artifacts: []delivery.BuiltArtifact{{Name: name, Kind: "bundle", Reference: "artifact-store://" + m + "/" + name + "/blobs/" + dg, Digest: dg}}}
if m == "node-tools" && row.building {
b.builds[m+" "+fps[m]] = &delivery.BuilderBuild{State: "building", Manifest: mod.Manifest}
}
}
out, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1"}, fps)
if err != nil {
t.Errorf("%s: the node is refused: %v", row.name, err)
continue
}
// A held module keeps what it contributes (issue 774, 2026-10-31: nextcloud held for its admin secret dropped out
// of postgres's received file, and postgres's helper would have locked its login): only its own secrets are left
// out of its stand-in, never its contributions, so the provider's received file names every consumer.
storeKey, _ := delivery.ComposedKey("n1", "artifacts", fps["artifacts"])
// And never a stand-in for a grant not yet issued: the provider is applied, so a held module whose grant is
// not issued is composed without that contribution, never with "foreseen: made by the next send".
for key, c := range out {
if got, _ := json.Marshal(c.Resources); strings.Contains(string(got), "foreseen") {
t.Errorf("%s: %s applies a stand-in: %s", row.name, key, got)
}
}
for _, m := range []string{"check", "heal"} {
if row.ungranted {
break
}
if got, _ := json.Marshal(out[storeKey].Resources); !strings.Contains(string(got), `\"from\": \"`+m+`\"`) {
t.Errorf("%s: the provider's received file drops %s's contribution: %s", row.name, m, got)
}
}
for _, m := range []string{"node-tools", "check", "lamp", "heal"} {
key, _ := delivery.ComposedKey("n1", m, fps[m])
_, composed := out[key]
why, isHeld := held[m]
want, wantHeld := row.held[m]
if isHeld != wantHeld || composed == wantHeld {
t.Errorf("%s: %s held %v (%q), composed %v; want held %v", row.name, m, isHeld, why, composed, wantHeld)
} else if isHeld && !strings.Contains(why, want) {
t.Errorf("%s: %s is held for %q, want its first reason, %q", row.name, m, why, want)
}
}
}
}
+204
View File
@@ -0,0 +1,204 @@
package frombus
// **The bus read through a module's declared `reads`** (the lead, 2026-10-29: one reader in place of the copies in
// mesh-provisioner and disk-load): a Reader over the states a module reads, by a runtime's Get and Keys (a bundle's
// stdio.State, a process's own), for a holder that composes plans or data items and reads no value: placement, each
// node's record in its own have key, the indexer's modules and manifests, the builder's builds and mesh-config's resolved
// values. No own secret and no credential is read (compose.Planning). Pure as the rest of this package: a bundle's
// runtime states and an HTTP fetch are compose/fromruntime's.
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/novox/mesh-host/internal/compose"
"github.com/novox/mesh-host/internal/compose/resolution"
)
// The states a module names in its manifest's `reads` to compose through StatesReader.
const (
PlacementRead = delivery.PlacementRead
ModulesRead = delivery.IndexerModule + ".modules"
ManifestsRead = delivery.IndexerModule + ".manifests"
BuildsRead = delivery.BuilderModule + ".builds"
HaveRead = "node-engine.have"
ResolvedRead = "mesh-config.resolved"
)
// StatesReads are those reads, in a manifest's order.
var StatesReads = []string{PlacementRead, ModulesRead, ManifestsRead, BuildsRead, HaveRead, ResolvedRead}
// States is a module's read states as its runtime gives them: a key's value, false when it has none.
type States interface {
Get(state, key string) (value []byte, found bool, err error)
Keys(state string) ([]string, error)
}
// Fetch reads an archive at a URL (fromruntime.HTTPFetch over HTTP).
type Fetch func(ctx context.Context, url string) ([]byte, error)
// StatesReader is the Reader over s; fetch reads a templates archive, nil for a holder that reads none.
// It keeps what each build's take-in made, by the build's content (issue 1954), so a holder composing again and again
// takes in only a build it has not seen.
func StatesReader(s States, fetch Fetch) Reader {
return statesReader{s: s, fetch: fetch, kept: &keptTakeIns{}}
}
type statesReader struct {
s States
fetch Fetch
kept *keptTakeIns
}
func (r statesReader) takenIn(key string) ([]byte, bool) { return r.kept.takenIn(key) }
func (r statesReader) keepTakenIn(key string, raw []byte) { r.kept.keepTakenIn(key, raw) }
func (r statesReader) each(read string, visit func(key string, value []byte)) error {
keys, err := r.s.Keys(read)
if err != nil {
return fmt.Errorf("%s: %w", read, err)
}
for _, k := range keys {
v, found, err := r.s.Get(read, k)
if err != nil {
return fmt.Errorf("%s %s: %w", read, k, err)
}
if found {
visit(k, v)
}
}
return nil
}
func (r statesReader) Placements(context.Context) (map[string]delivery.Placement, error) {
keys := delivery.NewPlacementKeys()
err := r.each(PlacementRead, func(k string, v []byte) {
_, _ = keys.Put(k, v, 0, false) // a key that does not read places nothing, as before
})
return keys.All(), err
}
func (r statesReader) Records(context.Context) (map[string]delivery.NodeRecord, error) {
out := map[string]delivery.NodeRecord{}
err := r.each(HaveRead, func(k string, v []byte) {
if node, _, isNode, err := delivery.ParseHaveKey(k); err == nil && isNode {
if h, err := delivery.DecodeHaveNode(v); err == nil && h.Record != nil {
out[node] = *h.Record
}
}
})
return out, err
}
func (r statesReader) Modules(context.Context) (map[string]delivery.Module, error) {
out := map[string]delivery.Module{}
err := r.each(ModulesRead, func(k string, v []byte) {
if module, err := delivery.ParseModulesKey(k); err == nil {
if m, err := delivery.DecodeModule(v); err == nil {
out[module] = m
}
}
})
return out, err
}
func (r statesReader) Manifest(_ context.Context, digest string) ([]byte, error) {
key, err := delivery.ManifestsKey(digest)
if err != nil {
return nil, err
}
v, found, err := r.s.Get(ManifestsRead, key)
if err != nil {
return nil, err
}
if !found {
return nil, fmt.Errorf("the indexer holds no manifest %s", digest)
}
m, err := delivery.DecodeManifest(v)
if err != nil {
return nil, err
}
return []byte(m.JSON), nil
}
func (r statesReader) Values(_ context.Context, node, module string) (map[string]any, error) {
var out map[string]any
prefix := node + "." + module + "."
err := r.each(ResolvedRead, func(k string, v []byte) {
name, ok := strings.CutPrefix(k, prefix)
if !ok || strings.Contains(name, ".") {
return
}
if value, laid := resolution.LaidValue(v); laid {
if out == nil {
out = map[string]any{}
}
out[name] = value
}
})
return out, err
}
func (statesReader) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) {
return nil, nil
}
func (statesReader) Credentials(context.Context, string, []compose.CredentialKey, []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) {
return nil, nil, nil
}
func (r statesReader) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) {
key, err := delivery.BuilderBuildKey(module, fp)
if err != nil {
return nil, err
}
v, found, err := r.s.Get(BuildsRead, key)
if err != nil || !found {
return nil, err
}
b, err := delivery.DecodeBuilderBuild(v)
if err != nil {
return nil, err
}
return &b, nil
}
// archiveWithin bounds one read of a templates archive.
const archiveWithin = 30 * time.Second
func (r statesReader) Archive(ctx context.Context, url, digest string) ([]byte, error) {
if r.fetch == nil {
return nil, fmt.Errorf("this holder reads no archive (%s)", url)
}
fetching, cancel := context.WithTimeout(ctx, archiveWithin)
defer cancel()
body, err := r.fetch(fetching, url)
if err != nil {
return nil, err
}
sum := sha256.Sum256(body)
if got := hex.EncodeToString(sum[:]); got != strings.TrimPrefix(digest, "sha256:") {
return nil, fmt.Errorf("%s was recorded as %s and what arrived is %s", url, digest, got)
}
return body, nil
}
func (r statesReader) Served(ctx context.Context, node, module string, keys []string) (map[string]any, error) {
all, err := r.Values(ctx, node, module)
var out map[string]any
for _, k := range keys {
if v, ok := all[k]; ok {
if out == nil {
out = map[string]any{}
}
out[k] = v
}
}
return out, err
}
@@ -0,0 +1,236 @@
{
"module": "xterm",
"version": "1",
"moved": true,
"capabilities": [
"package-manager"
],
"requires": [
"x11-display"
],
"claims": [
{
"name": "node-terminal-emulator",
"scope": "node",
"serves": [
"open"
]
}
],
"tools": [
"xterm_font",
"xterm_colours"
],
"config": {
"face": {
"type": "string",
"description": "The font family xterm draws with, as fc-list names it: JetBrainsMono Nerd Font, because research 026/04 chose it for its glyphs the prompt uses. A family the machine does not have falls back to the nearest installed face (xterm_font says which).",
"pattern": "[A-Za-z0-9 ._-]{1,64}",
"default": "JetBrainsMono Nerd Font"
},
"size": {
"type": "number",
"description": "The size of xterm's text, in points: 12 reads well on a screen of about 100 DPI, and a denser screen needs a larger size.",
"minimum": 6,
"maximum": 72,
"predefined": "font-size",
"default": 12
},
"save-lines": {
"type": "number",
"description": "How many lines of scrollback xterm keeps per terminal: 10000 holds a long build log, and each line costs memory in every open terminal.",
"minimum": 0,
"maximum": 1000000,
"default": 10000
},
"term-name": {
"type": "string",
"description": "The TERM value xterm sets for the programs it runs: xterm-256color, because the palette has 256 colours and every program knows the name.",
"pattern": "[A-Za-z0-9._-]{1,64}",
"default": "xterm-256color"
},
"render-font": {
"type": "boolean",
"description": "Whether xterm draws with fontconfig faces (true) or the old X core fonts (false): true, because the face is a fontconfig family and core fonts cannot show it.",
"default": true
},
"bold-mode": {
"type": "boolean",
"description": "Whether xterm draws bold text by overstriking the normal face (true): false, because the face has a real bold style and overstriking blurs it.",
"default": false
},
"always-bold-mode": {
"type": "boolean",
"description": "Whether xterm overstrikes bold even where the face has a bold style (true): false, for the same reason as bold-mode.",
"default": false
},
"allow-bold-fonts": {
"type": "boolean",
"description": "Whether xterm may use the face's bold style for bold text (true): true, so bold text is drawn with the real bold weight.",
"default": true
},
"background": {
"type": "string",
"description": "The terminal's background colour, as #rrggbb: black, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#000000"
},
"foreground": {
"type": "string",
"description": "The terminal's text colour, as #rrggbb: a light grey, easier on the eyes than white on black.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#a8a8a8"
},
"color0": {
"type": "string",
"description": "Palette entry 0, the normal black, as #rrggbb: #000000, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#000000"
},
"color1": {
"type": "string",
"description": "Palette entry 1, the normal red, as #rrggbb: #9e1828, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#9e1828"
},
"color2": {
"type": "string",
"description": "Palette entry 2, the normal green, as #rrggbb: #008800, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#008800"
},
"color3": {
"type": "string",
"description": "Palette entry 3, the normal yellow, as #rrggbb: #d2bb4b, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#d2bb4b"
},
"color4": {
"type": "string",
"description": "Palette entry 4, the normal blue, as #rrggbb: #414171, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#414171"
},
"color5": {
"type": "string",
"description": "Palette entry 5, the normal magenta, as #rrggbb: #963c59, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#963c59"
},
"color6": {
"type": "string",
"description": "Palette entry 6, the normal cyan, as #rrggbb: #418179, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#418179"
},
"color7": {
"type": "string",
"description": "Palette entry 7, the normal white, as #rrggbb: #bebebe, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#bebebe"
},
"color8": {
"type": "string",
"description": "Palette entry 8, the bright black, as #rrggbb: #666666, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#666666"
},
"color9": {
"type": "string",
"description": "Palette entry 9, the bright red, as #rrggbb: #bc5766, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#bc5766"
},
"color10": {
"type": "string",
"description": "Palette entry 10, the bright green, as #rrggbb: #61a171, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#61a171"
},
"color11": {
"type": "string",
"description": "Palette entry 11, the bright yellow, as #rrggbb: #e7db52, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#e7db52"
},
"color12": {
"type": "string",
"description": "Palette entry 12, the bright blue, as #rrggbb: #5085af, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#5085af"
},
"color13": {
"type": "string",
"description": "Palette entry 13, the bright magenta, as #rrggbb: #a97a99, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#a97a99"
},
"color14": {
"type": "string",
"description": "Palette entry 14, the bright cyan, as #rrggbb: #6ba4a4, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#6ba4a4"
},
"color15": {
"type": "string",
"description": "Palette entry 15, the bright white, as #rrggbb: #ffffff, as both workstations ran it.",
"pattern": "#[0-9a-fA-F]{6}",
"default": "#ffffff"
},
"copy-binding": {
"type": "string",
"description": "The key combination that copies the selection to the clipboard, in X translation syntax: Ctrl Shift ~Alt <Key> C, so a plain Ctrl+C still interrupts the program. It holds no colon, backslash or line break, which would end the resource.",
"pattern": "[^:\\\\\\n]+",
"default": "Ctrl Shift ~Alt <Key> C"
},
"paste-binding": {
"type": "string",
"description": "The key combination that pastes the clipboard, in X translation syntax: Ctrl Shift ~Alt <Key> V, the pair of copy-binding. It holds no colon, backslash or line break, which would end the resource.",
"pattern": "[^:\\\\\\n]+",
"default": "Ctrl Shift ~Alt <Key> V"
}
},
"items": [
{
"collection": "environment",
"name": "terminal",
"attributes": {
"kind": "variable",
"name": "TERMINAL"
},
"template": "files/env-terminal"
},
{
"collection": "shell:xresources:normal",
"name": "xresources-normal",
"template": "files/xresources-normal"
}
],
"resources": [
{
"id": "package",
"type": "package",
"package": "xterm"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/xterm-tools",
"binary": "xterm-tools",
"loads": [
"xterm-tools"
]
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
}
@@ -0,0 +1,60 @@
{
"module": "mesh-provisioner-client",
"version": "1",
"claims": [
{
"name": "node-provisioner",
"scope": "node"
}
],
"own-secrets": {
"provisioner-broker": "${dir:state}/broker"
},
"secrets-owner": "mesh-provisioner-client",
"resources": [
{
"id": "account",
"type": "user",
"name": "mesh-provisioner-client",
"shell": "/usr/bin/nologin",
"home": "/var/lib/mesh-provisioner-client"
},
{
"id": "state",
"type": "directory",
"mode": "0700",
"owner": "mesh-provisioner-client",
"place": ".",
"unknown": "program"
},
{
"id": "client",
"type": "process",
"name": "mesh-provisioner-client",
"artifact": "client",
"run": [
"./mesh-provisioner-client"
],
"env": {
"MESH_PROVISIONER_CREDENTIAL": "${dir:state}/broker",
"MESH_PROVISIONER_STATE": "${dir:state}"
},
"user": "mesh-provisioner-client",
"health": {
"kind": "unit"
}
}
],
"build": {
"artifacts": [
{
"name": "client",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/mesh-provisioner-client",
"binary": "mesh-provisioner-client"
}
]
}
}
@@ -0,0 +1,2 @@
# What the modules on this machine back up, composed by the mesh (novox/hq to-be 43). Do not edit.
${backup:lines}
@@ -0,0 +1,2 @@
# The data the modules on this machine declare, composed by the mesh (novox/hq ADR 0233). Do not edit.
${backup:data}
@@ -0,0 +1,6 @@
# How many restore points the night keeps, written by the mesh from this machine's settings keep-daily,
# keep-weekly, keep-monthly (novox/hq issue 416) and keep-hand-taken-days (novox/hq issue 453). Replaced on every push; change the settings, never this file.
daily=${config:keep-daily}
weekly=${config:keep-weekly}
monthly=${config:keep-monthly}
hand=${config:keep-hand-taken-days}
@@ -0,0 +1,199 @@
{
"module": "restic",
"version": "1",
"moved": true,
"claims": [
{
"name": "node-backup",
"scope": "node",
"serves": [
"backed-up",
"now",
"restore"
]
}
],
"emits": [
"backed-up"
],
"tools": [
"backup_health",
"backup_delete_retired",
"backup_deleted"
],
"own-secrets": {
"repository": "${dir:state}/repository.secret"
},
"data": {
"own": [
{
"id": "repository",
"path": "${dir:repository}",
"class": "rebuildable",
"backup": "none",
"why": "the restore points themselves: a copy of data kept elsewhere on this machine, never the only copy of anything; lost, the history goes and nothing live does"
},
{
"id": "state",
"path": "${dir:state}",
"class": "cache",
"why": "what the last nights and measurements were; the next night writes it again"
}
]
},
"config": {
"keep-daily": {
"type": "number",
"description": "How many nightly restore points are kept, a count from 1 to 9999: two weeks undo a mistake noticed within a fortnight. Read by the night, which thins nothing while it does not read (novox/hq issue 416); lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
"minimum": 1,
"maximum": 9999,
"trusted": true,
"default": 14
},
"keep-weekly": {
"type": "number",
"description": "How many weekly restore points are kept behind the dailies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
"minimum": 1,
"maximum": 9999,
"trusted": true,
"default": 8
},
"keep-monthly": {
"type": "number",
"description": "How many monthly restore points are kept behind the weeklies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
"minimum": 1,
"maximum": 9999,
"trusted": true,
"default": 6
},
"keep-hand-taken-days": {
"type": "number",
"description": "How many days a restore point taken by hand (node-backup.now, or the mesh's before a module moves) is kept at least, a count from 1 to 9999; it is kept until a later good night of its module exists, and this many days in any case, so the next thinning never takes the one taken against a risky change (novox/hq issue 453). Lowering it lets go of the older ones taken by hand at the next thinning, and that cannot be undone.",
"minimum": 1,
"maximum": 9999,
"trusted": true,
"default": 7
}
},
"resources": [
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": ".",
"unknown": "program"
},
{
"id": "repository",
"type": "directory",
"mode": "0700",
"unknown": "program"
},
{
"id": "declared",
"type": "file",
"path": "${dir:state}/backups.conf",
"mode": "0600",
"template": "files/backups.conf"
},
{
"id": "data-declared",
"type": "file",
"path": "${dir:state}/data.conf",
"mode": "0600",
"template": "files/data.conf"
},
{
"id": "retention",
"type": "file",
"path": "${dir:state}/retention.conf",
"mode": "0644",
"trusted": true,
"template": "files/retention.conf"
},
{
"id": "tool",
"type": "package",
"package": "restic"
},
{
"id": "sqlite",
"type": "package",
"package": "sqlite"
},
{
"id": "night",
"type": "process",
"name": "restic-night",
"artifact": "tools",
"run": [
"./restic-backups",
"night"
],
"user": "${machine:account}",
"env": {
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
"MESH_BACKUP_STATE": "${dir:state}",
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
},
"health": {
"kind": "tool",
"tool": "backup_health",
"interval": "60s",
"timeout": "20s",
"looks": 2,
"grace": "60s"
}
},
{
"id": "measure",
"type": "process",
"name": "restic-measure",
"artifact": "tools",
"run": [
"./restic-backups",
"measure"
],
"user": "${machine:account}",
"env": {
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
"MESH_BACKUP_STATE": "${dir:state}",
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
},
"health": {
"kind": "unit"
}
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/restic-backups",
"binary": "restic-backups",
"loads": [
"restic-backups"
],
"env": {
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
"MESH_BACKUP_STATE": "${dir:state}",
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
}
},
{
"name": "files",
"kind": "archive",
"from": "files"
}
]
}
}
@@ -0,0 +1,129 @@
{
"module": "de-spiegel",
"version": "1",
"slug": "spiegel",
"capabilities": [
"container-runtime"
],
"requires": [
"route",
"smtp"
],
"contributes": {
"route": {
"site": {
"label": "de-spiegel",
"port": 35621
},
"praktijk": {
"name": "${config:praktijk-name}",
"port": 35621
},
"praktijk-www": {
"name": "${config:praktijk-www-name}",
"redirect": "https://${config:praktijk-name}"
},
"psychologie": {
"name": "${config:psychologie-name}",
"port": 35621
},
"psychologie-www": {
"name": "${config:psychologie-www-name}",
"redirect": "https://${config:psychologie-name}"
}
},
"smtp": {
"account": "de-spiegel"
}
},
"binds": {
"route": "/var/lib/de-spiegel/route.json",
"smtp": "/var/lib/de-spiegel/smtp.json"
},
"config": {
"praktijk-name": {
"type": "string",
"description": "The public name the practice's site is served on; the operator's, with no default (novox/hq ADR 0112).",
"trusted": true,
"required": true
},
"praktijk-www-name": {
"type": "string",
"description": "The public name that redirects to the practice's site (its www form); the operator's, with no default (novox/hq ADR 0112).",
"trusted": true,
"required": true
},
"psychologie-name": {
"type": "string",
"description": "The public name the psychology practice's site is served on; the operator's, with no default (novox/hq ADR 0112).",
"trusted": true,
"required": true
},
"psychologie-www-name": {
"type": "string",
"description": "The public name that redirects to the psychology practice's site (its www form); the operator's, with no default (novox/hq ADR 0112).",
"trusted": true,
"required": true
}
},
"listens": [
{
"port": 35621,
"protocol": "tcp",
"from": "mesh",
"why": "the de-spiegel site and its /contact endpoint over http; the practice's three public names are route grants, and the proxy reaches every one of them here"
}
],
"resources": [
{
"id": "state",
"type": "directory",
"path": "/var/lib/de-spiegel",
"mode": "0700"
},
{
"id": "server-env",
"type": "file",
"path": "/var/lib/de-spiegel/server.env",
"mode": "0600",
"content": "SMTP_HOST=${bound:smtp:at}\nSMTP_PORT=${bound:smtp:port}\nSMTP_AUTH_USER=de-spiegel@${bound:smtp:domain}\nSMTP_AUTH_PASS=${secret:smtp}\n"
},
{
"id": "net",
"type": "network",
"name": "de-spiegel"
},
{
"id": "server",
"type": "container",
"name": "de-spiegel",
"artifact": "site",
"network": "de-spiegel",
"env-file": [
"/var/lib/de-spiegel/server.env"
],
"ports": [
"35621"
],
"secrets-in-environment": "the application's own code reads SMTP_AUTH_USER/PASS from the environment (server/index.js); converting is this repository's change"
}
],
"build": {
"on": [
{
"arg": "NODE_BASE",
"image": "node@sha256:48e4b67d85f87bd551df43704e24d252f56cc5f8e9718841aace50f19948f0f9"
}
],
"artifacts": [
{
"name": "site",
"kind": "image",
"from": "Dockerfile"
}
]
},
"secrets": {
"smtp": "/var/lib/de-spiegel/smtp.secret"
}
}
@@ -0,0 +1,82 @@
{
"module": "f1-circuits",
"slug": "f1",
"version": "1",
"capabilities": [
"container-runtime"
],
"requires": [
"route"
],
"contributes": {
"route": {
"label": "f1-circuits",
"endpoint": "web"
}
},
"binds": {
"route": "${dir:state}/route.json"
},
"listens": [
{
"name": "web",
"port": 8000,
"protocol": "tcp",
"from": "mesh",
"why": "the track-layout CDN, the race posters and the Kometa metadata over http; its public name is a route grant and the proxy reaches it here. Kometa fetches the metadata and every poster by that public name"
}
],
"resources": [
{
"id": "state",
"type": "directory",
"mode": "0700",
"place": "."
},
{
"id": "cache",
"type": "directory",
"mode": "0700"
},
{
"id": "site-env",
"type": "file",
"path": "${dir:state}/site.env",
"mode": "0644",
"content": "F1_CIRCUITS_BASE_URL=https://${bound:route:name}\n"
},
{
"id": "server",
"type": "container",
"name": "f1-circuits",
"artifact": "cdn",
"ports": [
"8000"
],
"env-file": [
"${dir:state}/site.env"
],
"volumes": [
"${dir:cache}:/app/cache"
],
"restart-on": [
"site-env"
]
}
],
"build": {
"on": [
{
"arg": "PYTHON_BASE",
"image": "python@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df"
}
],
"artifacts": [
{
"name": "cdn",
"kind": "image",
"from": "service/Dockerfile"
}
]
}
}
+147
View File
@@ -0,0 +1,147 @@
package frombus
// **A node's plans at two sets of builds, over one read** (issue 1954; novox/hq ADR 0570 clause 2, ADR 0580 clause 2):
// the seat mesh-provisioner's holder composes each node at the builds its wants name and at the newest build of each
// placed module. Both are composed from one read of the bus (placement, records, the indexer's modules and manifests,
// values: base); only the builds the node's modules are composed from differ, each build read once for both, and a
// build taken in once per content by a reader that keeps it (StatesReader). Where the time went is said by phase.
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// HeldPlans is one scope's plans, as PlansHeld answers them: err a refusal that is no single module's.
type HeldPlans struct {
Plans map[string]catalogue.Plan
Held map[string]string
Err error
}
// Phase is one step of a composition and how long it took.
type Phase struct {
Name string `json:"phase"`
Took time.Duration `json:"took"`
}
// PlansHeldTwice is PlansHeld at s and at s with no kept builds (each placed module at its newest build), over one
// read of the bus: what each answers is what PlansHeld answers for that scope. A read that fails is both scopes' err.
// took names each phase in order: the read, then each scope's builds and its plans.
func PlansHeldTwice(ctx context.Context, r Reader, mesh Mesh, s Scope) (at, newest HeldPlans, took []Phase) {
start := time.Now()
mark := func(name string) {
now := time.Now()
took = append(took, Phase{Name: name, Took: now.Sub(start)})
start = now
}
b, err := readBase(ctx, r, mesh, s)
mark("read")
builds := onceBuilds(r)
newestScope := s
newestScope.KeptAt = nil
for _, sc := range []struct {
scope Scope
into *HeldPlans
name string
}{{s, &at, "wanted"}, {newestScope, &newest, "newest"}} {
held := map[string]string{}
sc.into.Held = held
refuse := func(module string, err error) { held[module] = err.Error() }
b.refuseOwn(refuse)
if err != nil {
sc.into.Err = err
continue
}
in, err := b.finish(ctx, r, builds, mesh, sc.scope, b.cloned(), refuse, nil)
mark("builds at " + sc.name)
if err != nil {
sc.into.Err = err
continue
}
sc.into.Plans, sc.into.Err = plansHeldOf(in, s.Node, held)
mark("plans at " + sc.name)
}
return at, newest, took
}
// onceBuilds is r's Build, each module's build at a fingerprint read once: both scopes of one composition read the
// builds of the same moment.
func onceBuilds(r Reader) func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) {
type answer struct {
b *delivery.BuilderBuild
err error
}
read := map[string]answer{}
return func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) {
key := module + " " + fp
if a, ok := read[key]; ok {
return a.b, a.err
}
b, err := r.Build(ctx, module, fp)
read[key] = answer{b, err}
return b, err
}
}
// takenInKeeper is a Reader that keeps what a build's take-in made (StatesReader does): the resolved manifest's bytes,
// by the content it was made from, so a composition made again on every change takes in only a build it has not seen.
type takenInKeeper interface {
takenIn(key string) ([]byte, bool)
keepTakenIn(key string, raw []byte)
}
// takenInKey is what a take-in is made from: the artifact store's address and the build's record (its manifest's digest, its artifacts by digest), read
// whole; an archive is checked against its digest on every read, so the same record takes in to the same bytes.
func takenInKey(module, fp, address string, b delivery.BuilderBuild) (string, bool) {
raw, err := json.Marshal(b)
if err != nil {
return "", false
}
sum := sha256.Sum256(append([]byte(module+" "+fp+" "+address+" "), raw...))
return hex.EncodeToString(sum[:]), true
}
// takenInAtMost bounds the take-ins a reader keeps; past it they are emptied, which costs only a take-in again.
const takenInAtMost = 512
// keptTakeIns is a reader's kept take-ins.
type keptTakeIns struct {
mu sync.Mutex
kept map[string][]byte
}
func (k *keptTakeIns) takenIn(key string) ([]byte, bool) {
k.mu.Lock()
defer k.mu.Unlock()
raw, ok := k.kept[key]
return raw, ok
}
func (k *keptTakeIns) keepTakenIn(key string, raw []byte) {
k.mu.Lock()
defer k.mu.Unlock()
if k.kept == nil || len(k.kept) >= takenInAtMost {
k.kept = map[string][]byte{}
}
k.kept[key] = raw
}
// PhasesSaid says the phases in one line, each in milliseconds.
func PhasesSaid(took []Phase) string {
out := ""
for i, p := range took {
if i > 0 {
out += ", "
}
out += fmt.Sprintf("%s %d ms", p.Name, p.Took.Milliseconds())
}
return out
}
+101
View File
@@ -0,0 +1,101 @@
// Package fromruntime is compose/frombus's StatesReader over a bundle's runtime (the lead, 2026-10-29): the states a
// module's manifest names in `reads` (frombus.StatesReads), read through stdio.State, and a templates archive fetched
// over HTTP. Apart from frombus so that frombus stays pure for the mesh-compose command.
package fromruntime
import (
"context"
"fmt"
"io"
"net/http"
"sync"
stdio "git.novox.be/novox/mesh-sdk/go"
"github.com/novox/mesh-host/internal/compose/frombus"
)
// States are a bundle's read states through its runtime.
type States struct{}
// Get is a key's value, false when it has none.
func (States) Get(state, key string) ([]byte, bool, error) {
e, err := stdio.State(state).Get(key)
if err != nil || e == nil {
return nil, false, err
}
return e.Value, true, nil
}
// Keys are a state's keys.
func (States) Keys(state string) ([]string, error) { return stdio.State(state).Keys() }
// Reader is frombus's Reader over this bundle's read states key by key, fetching archives over HTTP. A holder that
// composes again and again reads through Watched instead (novox/hq ADR 0580).
func Reader() frombus.Reader { return frombus.StatesReader(States{}, HTTPFetch) }
// Watched is frombus's States over a watched copy (novox/hq ADR 0580 clause 1): the states a composition reads whole
// (placement, haves, modules, resolved values) from the copy, a build by key when asked, a manifest once per digest.
type Watched struct{ *stdio.WatchedStates }
// Get is a key's value: a build read now, a manifest kept once read, any other state's from the copy.
func (w Watched) Get(state, key string) ([]byte, bool, error) {
switch state {
case frombus.BuildsRead:
return w.Direct(state, key)
case frombus.ManifestsRead:
return w.Once(state, key)
}
return w.WatchedStates.Get(state, key)
}
// WatchedReader is frombus's Reader over w, fetching each templates archive over HTTP once (KeptFetch): a composition
// made again on every change reads it again otherwise.
func WatchedReader(w *stdio.WatchedStates) frombus.Reader {
return frombus.StatesReader(Watched{w}, KeptFetch(HTTPFetch))
}
// archivesAtMost bounds the archives KeptFetch keeps; past it they are emptied, which costs only a fetch again.
const archivesAtMost = 64
// KeptFetch is fetch kept once per URL: an archive's URL names its blob by digest, so its body never changes under it
// (frombus checks the digest on every read). A failed fetch is not kept.
func KeptFetch(fetch frombus.Fetch) frombus.Fetch {
var mu sync.Mutex
kept := map[string][]byte{}
return func(ctx context.Context, url string) ([]byte, error) {
mu.Lock()
body, ok := kept[url]
mu.Unlock()
if ok {
return body, nil
}
body, err := fetch(ctx, url)
if err != nil {
return nil, err
}
mu.Lock()
if len(kept) >= archivesAtMost {
clear(kept)
}
kept[url] = body
mu.Unlock()
return body, nil
}
}
// HTTPFetch reads an archive over HTTP, at most 256 MiB.
func HTTPFetch(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s answered %s", url, resp.Status)
}
return io.ReadAll(io.LimitReader(resp.Body, 256<<20))
}
+365
View File
@@ -0,0 +1,365 @@
// Package compose is the controller's composition of a machine, as a function over a plain input (novox/hq ADR 0453
// clause 3, ADR 0446 clause 4): what the rest of the mesh offers and holds, the private network's generator, one
// node's resolution and, in the issues after, its rendering and its declaration.
//
// **It never allocates, makes, writes, logs or raises.** The controller composes with a store behind it; this
// package is its Reading and Foreseeing modes over data. Everything the controller reads from its store is a field of
// Input, and everything a send would make or record (a port, a credential, a seat passed on, a node left out) is
// returned for the caller to act on, never done. Input is never changed by a call. No field holds a secret value
// the composition needs: sealed values are optional inputs, and without them the composition uses stand-ins named
// foreseen. The logic is the controller's, ported from mesh-controller cmd/mesh-controller at commit
// 6c2a8d62100cb7d491f8eb9a623e1eeea5ec0de1 (plan.go, network.go, holdings.go, seat_pass.go), in the same order, with the
// same refusals in the same words, so that a comparison node for node is a fair one.
//
// It imports the standard library and mesh-sdk's own packages only.
// It reads no environment variable: the range the mesh allocates from and the suffix of its internal names are fields of Input.
package compose
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// Input is everything the composition reads. Each field is what one read of the controller's inventory answers, named
// beside it; the controller's adapter fills it from its stores and the assigner from the mesh's facts and buckets
// (ADR 0446 clause 2).
type Input struct {
// Catalogue is the shelf: every module's manifest by name (inv.Catalogue), as it is to be composed. A recorded
// module kept at the build a machine runs (keepRecorded) is that node's Kept, applied to its own resolution only (never to the rest of the mesh).
Catalogue map[string]catalogue.Manifest
// Nodes is every node the mesh has (inv.Nodes and inv.Overlays together, one row per node).
Nodes []Node
// Holdings is every seat whose holder is on record (inv.Holdings).
Holdings []catalogue.Held
// Licences is every licence the mesh holds, with the machines that use it (open.Licences); none for a mesh with no
// licence.
Licences []Licence
// OverlayCIDR is the range the mesh allocates from when no tunnel was adopted, as genesis was told it (the
// controller's OverlayCIDRVar); empty means DefaultOverlayCIDR. The package reads no environment.
OverlayCIDR string
// InternalSuffix is what the mesh's internal names end in (the controller's MESH_INTERNAL_SUFFIX), with or without a leading
// dot; empty means overlay.DefaultSuffix. A fact of the whole mesh, so it is in the Input and never read from the environment.
InternalSuffix string
// Tunnels is, by node name, the found tunnel whose key is the node's overlay key (inv.Tunnels).
Tunnels map[string]FoundTunnel
// Adopted is the hub's found tunnel and the hub's name, whether or not the hub took it (inv.AdoptedTunnel); nil
// when no hub has one.
Adopted *AdoptedTunnel
// CarriedPeers is every peer of the adopted tunnel (inv.CarriedPeers).
CarriedPeers []CarriedPeer
// Joining is the nodes that hold a live enrolment token (inv.NodesWithALiveToken).
Joining []string
// Credentials is how each pair credential a node takes from elsewhere stands, never its value (what inv.SecretFor,
// SharedSecretFor and GivenPairSecret would answer): held, to be made by the next send, only a person's to give, or
// refused. A need with no entry here is one the next send would make.
Credentials []Credential
// Built is every repository the mesh has built (the keys of inv.Held): what tells a reference recorded with an address
// from an image a module runs straight from a public registry.
Built []string
// OperatorExport is every operator-sealed secret in the mesh, for a module that keeps them (inv.OperatorExport): sealed
// values the composition carries without opening. Nil when the mesh has no operator key.
OperatorExport *catalogue.KeptExport
// KeptRuns is, by node and module, the file of the export as that node runs it (its resource of a module's running
// composition): what a node composing its own carries where it holds no OperatorExport (catalogue.Rendering.KeptRuns,
// issue 1126, C6). Used only while OperatorExport is nil.
KeptRuns map[string]map[string]map[string]any
// Issued is the credentials made for the consumers of each provider (inv.SecretsFrom): one row for each pair credential
// that exists, by the provider node it is for. The grants a provider must create are read from these, so a credential not
// made yet has no grant until the send that makes it. An assigner fills it from the facts snapshot's Grants of each
// machine (go/facts, from format 2), which carry who each credential is for and never the credential.
Issued []Issued
// OwnSecrets is the own secrets held on each machine (the module_secret rows): who made each, and the key it is sealed to.
// Their sealed values are optional.
OwnSecrets []OwnSecretRow
// KeptCoreBoxes is the vault's box last kept for each core credential on each machine (inv.KeptCoreBoxes).
KeptCoreBoxes []CoreBox
// BusUsers is the bus's user list as the controller composes it from the bus records and the hashes it holds (composeBusUsers),
// for the machine that runs the bus: names and hashes, never passwords. Empty when the Input does not carry it, and then
// the composition uses a stand-in, named foreseen. BusMissing are the users left out of it for want of a credential.
BusUsers string
BusMissing []string
// BusMinted are the bus users the mesh has minted a password hash for (inv.BusUserHash): `<node>.<module>` for a module and
// `provisioner.<node>` 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>.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>.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
}
+308
View File
@@ -0,0 +1,308 @@
package compose
import (
"errors"
"fmt"
"slices"
"sort"
"strconv"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"github.com/novox/mesh-host/internal/compose/resolution"
"github.com/novox/mesh-host/internal/overlay"
)
// The private network: who is on it, where, and what they are called. Ported from mesh-controller
// cmd/mesh-controller/network.go at 6c2a8d62.
// DefaultOverlayCIDR is the range the mesh allocates from when nothing says another.
const DefaultOverlayCIDR = "10.42.0.0/16"
// composer is one composition over an Input: it reads Input, changes nothing in it, and keeps what the controller
// said on its standard error as lines to return.
type composer struct {
in Input
said []string
seen map[string]bool
// unhostable is, from the last look at the rest of the mesh, each module assigned to another node that this
// composition finds that node cannot run: what it would have offered is missing here (issue 1126, probe #532).
unhostable []string
// What one composition looks at again and again, each worked out once over its Input, which it never changes (issue
// 1954: a node's plans resolved every machine of the mesh some two hundred times, 11.7 s of CPU for novox): who is
// on the private network, and the rest of the mesh as each node sees it.
onNetwork []Node
onNetworkSet bool
rest map[string]restOf
plans map[string]planOf
}
// restOf is theRestOfTheMesh's answer for one excluded node, kept: what it found unhostable beside it.
type restOf struct {
world catalogue.World
passes Passes
left []LeftOut
unhostable []string
err error
}
func newComposer(in Input) *composer { return &composer{in: in, seen: map[string]bool{}} }
// say keeps a line the controller would have written to its log, once.
func (c *composer) say(format string, args ...any) {
line := fmt.Sprintf(format, args...)
if c.seen[line] {
return
}
c.seen[line] = true
c.said = append(c.said, line)
}
// suffix is what the mesh's internal names end in: the Input's, and overlay.DefaultSuffix when it gives none. The package never calls
// overlay.Suffix or overlay.InternalName, which read the environment, so the assigner and the controller compose the same names
// from the same Input.
func (c *composer) suffix() string {
if v := strings.TrimPrefix(strings.TrimSpace(c.in.InternalSuffix), "."); v != "" {
return v
}
return overlay.DefaultSuffix
}
// internalName is a node's name inside the mesh.
func (c *composer) internalName(node string) string { return node + "." + c.suffix() }
// saidBeside is the lines already said and then every line the composer kept, once each, in the order they were found.
func (c *composer) saidBeside(lines []string) []string {
for _, l := range c.said {
if !slices.Contains(lines, l) {
lines = append(lines, l)
}
}
return lines
}
// overlayRange is the range the mesh allocates node addresses from: the adopted tunnel's first (novox/hq ADR 0105),
// then the range genesis was told, then the default.
func (c *composer) overlayRange() string {
if a := c.in.Adopted; a != nil && a.Adopted {
return a.Tunnel.Range
}
if v := strings.TrimSpace(c.in.OverlayCIDR); v != "" {
return v
}
return DefaultOverlayCIDR
}
// network builds the private network over the machines that resolved the module for it. A machine is on it because it
// was given the module, or because it is joining with a key and an address (novox/hq ADR 0169), or because its own set
// could not be resolved and it holds a place (a key and an address): that place is its last good one on the network, and
// it does not change because its modules failed, so every other machine keeps its path to it -- the hub above all -- and
// only its own declaration is refused (issue 1833). A machine refused with no place is left off.
func (c *composer) network(on map[string]bool, refused map[string]string) (*overlay.Generator, error) {
tunnels := c.in.Tunnels
carried := c.in.CarriedPeers
isJoining := map[string]bool{}
for _, name := range c.in.Joining {
isJoining[name] = true
}
var nodes []overlay.Node
for _, node := range c.in.sorted() {
p := node.Place
placed := p.Key != "" && p.Address != ""
_, wasRefused := refused[node.Name]
if !on[node.Name] && !((isJoining[node.Name] || wasRefused) && placed) {
continue
}
if !on[node.Name] && wasRefused && !isJoining[node.Name] {
c.say("%s could not be resolved and keeps its place on the private network at %s, so the others keep their path to it",
node.Name, p.Address)
}
n := overlay.Node{
Name: node.Name, Key: p.Key, Endpoint: p.Endpoint,
Site: p.Site, Hub: p.Hub, Address: p.Address,
}
if t, takes := tunnels[node.Name]; takes && t.NodeAdopted {
// Only an adopted node is told to take the found unit over. A hub whose placement disagrees with the tunnel is
// refused, not composed.
if wrong := disagrees(p, t.Tunnel); wrong != "" {
return nil, fmt.Errorf("%s takes over the tunnel on %s and its placement disagrees with it: %s. "+
"Re-place it — `overlay place %s --hub --endpoint <host>:%d …` — and push again; "+
"nothing was composed", node.Name, t.Interface, wrong, node.Name, t.Port)
}
n.TakesOver = &overlay.TakeOver{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU}
}
if p.Hub {
for _, cp := range carried {
n.Carried = append(n.Carried, overlay.Carried{Key: cp.PublicKey, Address: cp.Address})
}
}
nodes = append(nodes, n)
}
if len(nodes) == 0 {
// Nobody was given it: an empty network is a legitimate mesh, not a broken one.
return overlay.Empty(), nil
}
g, err := overlay.From(nodes, c.overlayRange(), "")
if err != nil && len(refused) > 0 {
// The network is missing something, and some machines could not be resolved at all: almost always the same fact.
var who []string
for name, why := range refused {
who = append(who, fmt.Sprintf(" %s: %s", name, why))
}
sort.Strings(who)
return nil, fmt.Errorf("%w\n\nand %d node(s) could not be resolved at all, which is "+
"probably why:\n%s", err, len(refused), strings.Join(who, "\n"))
}
return g, err
}
// Graph is the whole mesh's network, for showing it: the nodes on it and each one's peers.
func Graph(in Input) ([]overlay.Node, overlay.Graph, error) {
c := newComposer(in)
on, refused, err := c.whoResolves(overlay.Requirement)
if err != nil {
return nil, nil, err
}
g, err := c.network(on, refused)
if err != nil {
return nil, nil, err
}
return g.Nodes(), g.Graph(), nil
}
// whoResolves is the machines whose resolution answers a requirement, and why the others did not: by what a module
// provides, not by its name, resolved rather than read from the assignments.
func (c *composer) whoResolves(requirement string) (map[string]bool, map[string]string, error) {
on := map[string]bool{}
// Why a node could not be resolved, kept rather than raised: one broken node must not stop the rest being described.
refused := map[string]string{}
for _, n := range c.in.sorted() {
resolved, err := c.planFor(n.Name)
switch {
case unresolvable(err):
refused[n.Name] = err.Error()
continue
case err != nil:
// Not a node that does not resolve: a question that went unanswered.
return nil, nil, fmt.Errorf("whether %s answers %q cannot be read: %w", n.Name, requirement, err)
}
for _, m := range resolved.Plan.Modules {
for _, offered := range m.Offers() {
if offered == requirement {
on[n.Name] = true
}
}
}
}
return on, refused, nil
}
// Generators is what every node's declaration is composed with, over the whole mesh: the private network's generator
// (one generator now; the names and a resolver's zone file are facts a module asks for in its manifest).
func Generators(in Input) (map[string]catalogue.Generator, error) {
c := newComposer(in)
return c.generators()
}
func (c *composer) generators() (map[string]catalogue.Generator, error) {
on, refused, err := c.whoResolves(overlay.Addressing)
if err != nil {
return nil, err
}
net, err := c.network(on, refused)
if err != nil {
return nil, err
}
return map[string]catalogue.Generator{overlay.Name: net}, nil
}
// disagrees says how a node's placement differs from the tunnel it takes over — its address not the tunnel's, its
// endpoint not on the tunnel's port — or nothing when both agree.
func disagrees(p Place, t Tunnel) string {
var wrong []string
want := t.Address
if i := strings.Index(want, "/"); i >= 0 {
want = want[:i]
}
if p.Address != want {
wrong = append(wrong, fmt.Sprintf("its address is %s and the tunnel's is %s", orNothing(p.Address), want))
}
if p.Reachable() && portOfEndpoint(p.Endpoint) != strconv.Itoa(t.Port) {
wrong = append(wrong, fmt.Sprintf("its endpoint %s is not on the tunnel's port %d", p.Endpoint, t.Port))
}
return strings.Join(wrong, "; ")
}
func orNothing(s string) string {
if s == "" {
return "unset"
}
return s
}
// portOfEndpoint is the port in host:port, or empty.
func portOfEndpoint(endpoint string) string {
if i := strings.LastIndex(endpoint, ":"); i >= 0 {
return endpoint[i+1:]
}
return ""
}
// whereEveryoneIs is each machine's name on the private network, for the ones on it. Resolved without consulting the
// rest of the mesh (it is called from that path), and refused without the catalogue.
func (c *composer) whereEveryoneIs() (map[string]string, error) {
if c.in.Catalogue == nil {
return nil, errors.New("asked where everyone is without the catalogue, which cannot be answered")
}
out := map[string]string{}
for _, p := range c.onTheNetwork() {
out[p.Name] = c.internalName(p.Name)
}
return out, nil
}
// onTheNetwork is every placed machine that resolves the private network: has an address AND runs what puts it there
// (novox/hq issue 079). A machine that does not resolve keeps its place when it holds one (a key and an address), by the
// same rule as network(), so its name and its address stay where the tunnel to it does (issue 1833); one without is not
// counted, and said.
func (c *composer) onTheNetwork() []Node {
if !c.onNetworkSet {
c.onNetwork, c.onNetworkSet = c.onTheNetworkNow(), true
}
return slices.Clone(c.onNetwork)
}
// onTheNetworkNow is onTheNetwork worked out: every placed machine's set resolved.
func (c *composer) onTheNetworkNow() []Node {
var out []Node
for _, n := range c.in.sorted() {
if n.Place.Address == "" {
continue
}
if len(n.Assigned) == 0 {
continue
}
got, err := resolution.Resolve(c.in.Catalogue, n.Assigned,
catalogue.Node{Name: n.Name, Site: n.Place.Site, Capabilities: n.Capabilities},
catalogue.World{Unchecked: true, Holdings: c.in.Holdings})
if err != nil {
if n.Place.Key != "" {
c.say("%s could not be resolved and keeps its place on the private network at %s, so the others keep their path to it",
n.Name, n.Place.Address)
out = append(out, n)
continue
}
c.say("%s is not counted as on the network: it does not resolve: %v", n.Name, err)
continue
}
for _, m := range got.Modules {
for _, offered := range m.Offers() {
if offered == overlay.Requirement {
out = append(out, n)
}
}
}
}
return out
}
+306
View File
@@ -0,0 +1,306 @@
package compose
import (
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"github.com/novox/mesh-host/internal/overlay"
)
// onTheTunnel makes the hub of an Input what mesh-controller's network_test.go makes of anchor in
// TestTheOverlaysRangeIsTheAdoptedTunnels: adopted, enrolled with the found tunnel's key, and presenting the tunnel
// (Tunnel{wg0, wg-quick@wg0, port 51900, 192.0.2.1/24, range 192.0.2.0/24, one peer PEER-TWO}). Placed on it when
// replaced is true: its address the tunnel's, its endpoint on the tunnel's port, as `overlay place anchor --endpoint
// 198.51.100.10:51900 --site hosting --hub` leaves it.
func onTheTunnel(t *testing.T, in *Input, replaced bool) {
t.Helper()
const key = "THE-TUNNELS-KEY========================="
tunnel := Tunnel{Interface: "wg0", Unit: "wg-quick@wg0", Config: "/etc/wireguard/wg0.conf", Port: 51900,
Address: "192.0.2.1/24", Range: "192.0.2.0/24", PublicKey: key,
Peers: []TunnelPeer{{PublicKey: "PEER-TWO", Address: "192.0.2.2"}}}
hub := nodeOf(t, in, "anchor")
hub.Adopted = true
hub.Place.Key = key
if replaced {
hub.Place.Address, hub.Place.Endpoint, hub.Place.Site = "192.0.2.1", "198.51.100.10:51900", "hosting"
}
in.Tunnels = map[string]FoundTunnel{"anchor": {Tunnel: tunnel, NodeAdopted: true}}
in.Adopted = &AdoptedTunnel{Tunnel: tunnel, Hub: "anchor", Adopted: true}
in.CarriedPeers = []CarriedPeer{{PublicKey: "PEER-TWO", Address: "192.0.2.2"}}
}
// novox/hq ADR 0105 (mesh-controller network_test.go TestTheOverlaysRangeIsTheAdoptedTunnels): the range every address is
// composed from is the adopted tunnel's, read from the tunnel the hub holds, never stored anywhere else. The environment
// value of the controller is Input.OverlayCIDR here.
func TestTheOverlaysRangeIsTheAdoptedTunnels(t *testing.T) {
in := aMesh(t)
in.OverlayCIDR = "10.99.0.0/16"
if got := newComposer(in).overlayRange(); got != "10.99.0.0/16" {
t.Fatalf("without an adopted tunnel the range is not what genesis said: %q", got)
}
in.OverlayCIDR = ""
if got := newComposer(in).overlayRange(); got != DefaultOverlayCIDR {
t.Fatalf("with nothing said the range is %q, not the default", got)
}
in.OverlayCIDR = "10.99.0.0/16"
onTheTunnel(t, &in, true)
if got := newComposer(in).overlayRange(); got != "192.0.2.0/24" {
t.Fatalf("with an adopted tunnel the range is %q, not the tunnel's", got)
}
// A tunnel the hub found and did not take (its key is not the tunnel's) does not give its range.
in.Adopted.Adopted = false
if got := newComposer(in).overlayRange(); got != "10.99.0.0/16" {
t.Fatalf("a tunnel the hub did not take gave its range: %q", got)
}
in.Adopted.Adopted = true
// And the hub's declaration carries the peer and the takeover.
nodes, computed, err := Graph(in)
if err != nil {
t.Fatal(err)
}
var hubNode overlay.Node
for _, n := range nodes {
if n.Name == "anchor" {
hubNode = n
}
}
if hubNode.TakesOver == nil || hubNode.TakesOver.Unit != "wg-quick@wg0" {
t.Errorf("the hub is not told to take over the found tunnel: %+v", hubNode)
}
carried := false
for _, p := range computed["anchor"] {
if p.Key == "PEER-TWO" && p.Allowed == "192.0.2.2/32" {
carried = true
}
}
if !carried {
t.Errorf("the hub's peer list does not carry the tunnel's peer: %+v", computed["anchor"])
}
}
// A takeover is composed only for a hub whose placement agrees with the tunnel (mesh-controller network_test.go
// TestATakeoverIsNotComposedForAHubPlacedOffItsTunnel): an address or an endpoint port that differs would have the host stop
// the found interface and raise the mesh's where no peer is listening.
func TestATakeoverIsNotComposedForAHubPlacedOffItsTunnel(t *testing.T) {
in := aMesh(t)
onTheTunnel(t, &in, false)
// aMesh placed anchor at 10.77.0.1 on :51820, the record of a hub placed before it took the tunnel over.
_, _, err := Graph(in)
if err == nil {
t.Fatal("a takeover was composed for a hub whose address and port are not the tunnel's")
}
for _, want := range []string{"10.77.0.1", "192.0.2.1", "51820", "51900", "overlay place anchor"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the refusal does not say %q: %v", want, err)
}
}
// Re-placed on the tunnel, it composes.
onTheTunnel(t, &in, true)
if _, _, err := Graph(in); err != nil {
t.Fatalf("re-placed on the tunnel, the graph still refuses: %v", err)
}
}
// 1360/AC2: the generator gives the addresses, peers and names of aMesh: anchor the hub at 10.77.0.1, laptop at 10.77.0.2
// peering through it, both written for their machines.
func TestTheGeneratorGivesTheMeshItsAddressesAndPeers(t *testing.T) {
in := aMesh(t)
gens, err := Generators(in)
if err != nil {
t.Fatal(err)
}
g, ok := gens[overlay.Name].(*overlay.Generator)
if !ok {
t.Fatalf("the generator is %T", gens[overlay.Name])
}
addr := map[string]string{}
for _, n := range g.Nodes() {
addr[n.Name] = n.Address
}
if addr["anchor"] != "10.77.0.1" || addr["laptop"] != "10.77.0.2" || len(addr) != 2 {
t.Fatalf("the addresses are %v", addr)
}
peers := g.Graph()
if len(peers["laptop"]) != 1 || peers["laptop"][0].Name != "anchor" || peers["laptop"][0].Endpoint != "anchor.example:51820" {
t.Errorf("laptop's peers are %+v, want the hub", peers["laptop"])
}
if len(peers["anchor"]) != 1 || peers["anchor"][0].Name != "laptop" || peers["anchor"][0].Allowed != "10.77.0.2/32" {
t.Errorf("anchor's peers are %+v, want laptop", peers["anchor"])
}
for _, node := range []string{"anchor", "laptop"} {
res, on, err := g.Resources(node)
if err != nil || !on || len(res) == 0 {
t.Errorf("%s is given no network: %v %v %d", node, on, err, len(res))
}
}
}
// 1360/AC3: a node not on the private network (it runs nothing that puts it there) gets no address and no network, and the
// one that stayed keeps its own.
func TestANodeNotOnThePrivateNetworkGetsNoAddressAndNoNetwork(t *testing.T) {
in := aMesh(t)
nodeOf(t, &in, "laptop").Assigned = nil // it keeps its place and its address, and stops running the network
gens, err := Generators(in)
if err != nil {
t.Fatal(err)
}
g := gens[overlay.Name].(*overlay.Generator)
if len(g.Nodes()) != 1 || g.Nodes()[0].Name != "anchor" {
t.Fatalf("the network is over %+v, want anchor alone", g.Nodes())
}
if res, on, err := g.Resources("laptop"); on || err != nil || len(res) != 0 {
t.Errorf("a machine that left the network is given one: %v %v %v", res, on, err)
}
if _, in := g.Graph()["laptop"]; in {
t.Error("a machine that left the network has peers")
}
}
// 1360/AC3: a node whose resolution is refused is said with today's words: the network missing its hub names the
// machine that could not be resolved, which is almost always why. The hub here holds no complete place (no key): a refused
// machine with one keeps it on the network (issue 1833), so only one without is missing from it.
func TestARefusedNodeIsSaidWhenTheNetworkCannotBeComposed(t *testing.T) {
in := aMesh(t)
nodeOf(t, &in, "anchor").Place.Key = ""
one := catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
two := catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
in.Catalogue["rival-one"], in.Catalogue["rival-two"] = one, two
hub := nodeOf(t, &in, "anchor")
hub.Assigned = append(hub.Assigned, "rival-one", "rival-two") // its own set is incoherent: the hub does not resolve
_, err := Generators(in)
if err == nil {
t.Fatal("a network without its hub was composed")
}
for _, want := range []string{overlay.ErrNoHub.Error(), "and 1 node(s) could not be resolved at all, which is probably why:", " anchor: "} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the refusal does not say %q:\n%v", want, err)
}
}
}
// 1360/AC4: Generators changes nothing in its Input.
func TestGeneratorsChangesNothingInItsInput(t *testing.T) {
unchanged(t, func() Input {
in := aMesh(t)
onTheTunnel(t, &in, true)
in.Joining = []string{"laptop"}
return in
}, func(in Input) {
if _, err := Generators(in); err != nil {
t.Fatal(err)
}
})
}
// Each field of Input that the network reads reaches the generator: change it and the generator changes.
func TestEachFieldOfTheInputReachesTheGenerator(t *testing.T) {
addressesOf := func(in Input) map[string]string {
t.Helper()
gens, err := Generators(in)
if err != nil {
t.Fatalf("%v", err)
}
out := map[string]string{}
for _, n := range gens[overlay.Name].(*overlay.Generator).Nodes() {
out[n.Name] = n.Address
}
return out
}
base := addressesOf(aMesh(t))
if len(base) != 2 {
t.Fatalf("the base mesh has %v", base)
}
t.Run("Catalogue: without the module offering it nobody is on the network", func(t *testing.T) {
in := aMesh(t)
// The module stays known, so every machine still resolves: a machine that does not resolve keeps its place (issue 1833).
in.Catalogue = map[string]catalogue.Manifest{overlay.Name: {Module: overlay.Name, Version: "1"}}
if got := addressesOf(in); len(got) != 0 {
t.Errorf("got %v", got)
}
})
t.Run("Nodes.Place: an address", func(t *testing.T) {
in := aMesh(t)
nodeOf(t, &in, "laptop").Place.Address = "10.77.0.9"
if got := addressesOf(in); got["laptop"] != "10.77.0.9" {
t.Errorf("got %v", got)
}
})
t.Run("Nodes.Assigned", func(t *testing.T) {
in := aMesh(t)
nodeOf(t, &in, "laptop").Assigned = nil
if got := addressesOf(in); len(got) != 1 {
t.Errorf("got %v", got)
}
})
t.Run("Nodes.Capabilities: a capability the module needs", func(t *testing.T) {
in := aMesh(t)
m := in.Catalogue[overlay.Name]
m.Capabilities = []string{"wireguard"}
in.Catalogue[overlay.Name] = m
nodeOf(t, &in, "laptop").Capabilities = map[string]bool{}
got, err := Generators(in)
if err != nil {
t.Fatal(err)
}
if n := got[overlay.Name].(*overlay.Generator).Nodes(); len(n) != 1 || n[0].Name != "anchor" {
t.Errorf("a machine without the capability is on the network: %+v", n)
}
})
t.Run("OverlayCIDR", func(t *testing.T) {
in := aMesh(t)
in.OverlayCIDR = "10.99.0.0/16"
_, graph, err := Graph(in)
if err != nil {
t.Fatal(err)
}
found := false
for _, p := range graph["laptop"] {
found = found || p.Allowed == "10.99.0.0/16"
}
if !found {
t.Errorf("the hub's route is not the range given: %+v", graph["laptop"])
}
})
t.Run("Tunnels and Adopted and CarriedPeers", func(t *testing.T) {
in := aMesh(t)
onTheTunnel(t, &in, true)
nodes, graph, err := Graph(in)
if err != nil || len(nodes) != 2 {
t.Fatalf("%v %v", nodes, err)
}
if len(graph["anchor"]) < 2 {
t.Errorf("the hub's peers do not include the carried one: %+v", graph["anchor"])
}
})
t.Run("Joining: a machine with a key and an address is on the network before it is anything else", func(t *testing.T) {
in := aMesh(t)
in.Nodes = append(in.Nodes, Node{Name: "newcomer", Place: Place{Key: keyOf(t, "newcomer"), Address: "10.77.0.3"}})
if got := addressesOf(in); got["newcomer"] != "" {
t.Fatalf("a machine that runs nothing is on the network: %v", got)
}
in.Joining = []string{"newcomer"}
if got := addressesOf(in); got["newcomer"] != "10.77.0.3" {
t.Errorf("a joining machine is not on the network: %v", got)
}
})
t.Run("Holdings: a holder on record settles a seat two assignments could hold", func(t *testing.T) {
withSeat(t)
in := aMesh(t)
for _, name := range []string{"holder-one", "holder-two"} {
in.Catalogue[name] = aBrokerClaiming(name)
}
laptop := nodeOf(t, &in, "laptop")
laptop.Assigned = append(laptop.Assigned, "holder-one", "holder-two")
// Without a key: a machine that does not resolve and holds a complete place keeps it on the network (issue 1833).
laptop.Place.Key = ""
if got := addressesOf(in); got["laptop"] != "" {
t.Fatalf("a machine whose set claims one seat twice is on the network: %v", got)
}
in.Holdings = []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "laptop", Module: "holder-one"}}
if got := addressesOf(in); got["laptop"] != "10.77.0.2" {
t.Errorf("a holder on record did not settle the seat: %v", got)
}
})
}
+352
View File
@@ -0,0 +1,352 @@
package compose
import (
"encoding/json"
"fmt"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"github.com/novox/mesh-host/internal/compose/resolution"
"github.com/novox/mesh-host/internal/validate"
)
// Declared is a declaration as a machine is sent it (the controller's sendable, sendable.go): its resources and, for an adopted
// node, its mode and which modules were taken on it (novox/hq ADR 0100), with what the composition says beside it and never puts on
// the wire.
//
// **Body is the only place the envelope is marshalled**, so the digest the mesh compares and the body validated are one.
type Declared struct {
Resources []map[string]any
// Sequence, Epoch and Generation are what the Input gave the node: zero is not sent at all.
Sequence int64
Epoch uint64
Generation int64
// ReadsModules says the machine's node-engine reads a module per resource and a build per module (novox/hq issue 1145): only
// then are they sent. ModuleOf is the composer's owner of each resource id and BuildIDs the build each module carries.
ReadsModules bool
ModuleOf map[string]string
BuildIDs map[string]string
// Adoption is nil for a converged node, and then the body is byte for byte what it was before adoption existed.
Adoption *Adoption
// Filled is the path of every file this declaration hands to the provisioner, and Provisioner the account its client runs as
// (novox/hq ADR 0349): on the wire as `filled_files` and `provisioner_account`, both absent when nothing is handed.
Filled []string
Provisioner string
// LeftOut is every module of the machine's set left out because a stored setting cannot compose with its definition (novox/hq
// ADR 0163, rule 6), sorted; LeftOutWhy is why each was.
LeftOut []string
LeftOutWhy map[string]string
// Not on the wire:
Received map[string]map[string][]catalogue.Contribution
Mesh []string
BusUsers string
// Modules are the modules this declaration names, sorted.
Modules []string
// LocalNetwork is every port admitted from the local network and what it resolved to (novox/hq ADR 0340).
LocalNetwork []catalogue.LocalNetworkPort
Withheld []catalogue.Overflow
Unbound []catalogue.Unbound
Unplaced []string
HandedOnly []string
// RequiredBy is, for each handed module only pulled in by another, the module that requires it (issue 765).
RequiredBy map[string]string
// Foreseen is every own secret composed with a stand-in, as `module/name`; StoodIn is everything the composition stood in for
// (an own secret, a grant's sealed copy, the certificate, the bus's user list); ForeseenPorts the ports the mesh may assign that
// the Input holds none for. A declaration that has any is never sent: what a send would make, named, never invented.
Foreseen []string
StoodIn []string
ForeseenPorts []string
// CoreBoxesWanted are the core credentials whose box the send must ask the vault for and keep.
CoreBoxesWanted []CoreBoxWanted
// Passes are the seats the mesh would pass on, for the caller to record, and Said the lines found while composing.
Passes Passes
Said []string
}
// Adoption is what an adopted node is told about its mode. Taken is every module taken on it that it runs; Untaken is, for every
// module it runs that is not taken, the ids of every one of that module's resources: what the host keeps as found until the module
// is taken (ADR 0103).
type Adoption struct {
Taken []string `json:"taken"`
Untaken map[string][]string `json:"untaken,omitempty"`
}
// Body is the declaration's bytes, as sent and as digested.
func (d Declared) Body() ([]byte, error) {
envelope := map[string]any{"declaration": 1, "resources": d.Resources}
if d.ReadsModules {
// The module of each resource the composer owns, and the build of each module (novox/hq issue 1145): to a machine that
// said it reads them, and no other.
named := make([]map[string]any, len(d.Resources))
for n, r := range d.Resources {
module, owned := d.ModuleOf[fmt.Sprint(r["id"])]
if !owned {
named[n] = r
continue
}
with := make(map[string]any, len(r)+1)
for k, v := range r {
with[k] = v
}
with["module"] = module
named[n] = with
}
envelope["resources"] = named
if len(d.BuildIDs) > 0 {
envelope["builds"] = d.BuildIDs
}
}
if d.Adoption != nil {
envelope["adoption"] = d.Adoption
}
if d.Sequence > 0 {
envelope["sequence"] = d.Sequence
}
if d.Epoch > 0 {
envelope["epoch"] = d.Epoch
}
if len(d.LeftOut) > 0 {
envelope["left_out"] = d.LeftOut
}
if d.Generation > 0 {
envelope["generation"] = d.Generation
}
if len(d.Filled) > 0 {
envelope["filled_files"] = d.Filled
envelope["provisioner_account"] = d.Provisioner
}
// An empty declaration is deliberate: the node owns nothing the mesh put there (novox/hq issue 127), and the host refuses an
// empty body unless it is told the emptiness is meant.
if len(d.Resources) == 0 {
envelope["owns_nothing"] = true
}
return json.Marshal(envelope)
}
// Refusals are the findings a check refuses a change for, beside the validator's problems: a consumer left out of the grants
// because its identity overflows the provision's bound, and a credential bound to another consumer (novox/hq ADR 0446 clause 1).
// Each names the consumer and the provision.
func (d Declared) Refusals() []string {
var out []string
for _, o := range d.Withheld {
out = append(out, o.String())
}
for _, u := range d.Unbound {
out = append(out, u.String())
}
sort.Strings(out)
return out
}
// Compose composes one node's declaration (the issue calls it Node; the Input already has a type of that name) over an Input -- as a push would, without the sending (the controller's
// composedAndValidated in the Foreseeing mode) -- and runs the node-engine's own validator over its body. An error is that the
// node did not compose (a set that does not compose satisfies IsUnresolvable); problems are what the validator refuses, in the
// node-engine's words. It allocates, makes, keeps and writes nothing, and names what a send would do as foreseen.
func Compose(in Input, node string) (Declared, []string, error) {
return ComposeIn(in, node, Foreseeing)
}
// ComposeIn is Compose in a chosen mode.
func ComposeIn(in Input, node string, mode Mode) (Declared, []string, error) {
c := newComposer(in)
d, err := c.declaration(node, mode)
if err != nil {
return Declared{}, nil, err
}
body, err := d.Body()
if err != nil {
return Declared{}, nil, err
}
return d, validate.Declaration(body), nil
}
// declaration is the controller's declarationWith over the plain Input.
func (c *composer) declaration(nodeName string, mode Mode) (Declared, error) {
resolved, err := c.planFor(nodeName)
if err != nil {
return Declared{}, err
}
gens, err := c.generators()
if err != nil {
return Declared{}, err
}
rendered, err := c.render(nodeName, mode)
if err != nil {
return Declared{}, err
}
with := rendered.Rendering
with.Generators = gens
plan := resolved.Plan
composed, err := resolution.Compose(plan, with)
if err != nil {
return Declared{}, err
}
// Which ports the filter admits from the local network, and from which networks (novox/hq ADR 0340 clause 8): the same rules the
// composition rendered, never sent.
rules, err := resolution.Rules(plan, with)
if err != nil {
return Declared{}, err
}
node, err := c.in.node(nodeName)
if err != nil {
return Declared{}, err
}
out := Declared{Resources: composed.Resources, Adoption: adoptionFor(plan, node, composed),
Received: composed.Received, Mesh: with.Mesh, BusUsers: with.BusUsers,
LeftOut: sortedKeysOf(composed.LeftOut), LeftOutWhy: composed.LeftOut,
Withheld: rendered.Withheld, Unbound: rendered.Unbound,
Foreseen: composed.Foreseen, StoodIn: rendered.Foreseen, ForeseenPorts: rendered.ForeseenPorts,
Unplaced: composed.Unplaced, HandedOnly: composed.HandedOnly, Modules: namedModules(plan, composed.LeftOut),
Filled: composed.Filled, LocalNetwork: resolution.LocalNetworkPorts(rules, with.LocalNetworks),
CoreBoxesWanted: rendered.CoreBoxesWanted, Said: rendered.Said,
Sequence: node.Sequence, Epoch: node.Epoch, Generation: node.Generation}
if len(composed.Filled) > 0 {
out.Provisioner = with.Provisioner
}
// A handed module only pulled in by another keeps what requires it, for its value of its own in the assignments mirror (issue 765).
for module := range composed.Plans {
if by := resolution.RequiredBy(plan, module); by != "" {
if out.RequiredBy == nil {
out.RequiredBy = map[string]string{}
}
out.RequiredBy[module] = by
}
}
// And the module of each resource and the build of each module, to a machine whose node-engine said it reads them (novox/hq issue
// 1145) and to no other.
if node.ReadsModules {
carried := c.carriedOn(node, plan, composed.LeftOut)
out.ReadsModules, out.ModuleOf, out.BuildIDs = true, composed.Owner, c.wireBuilds(carried, node.Runs)
}
_, passes, left, err := c.theRestOfTheMesh("")
if err != nil {
return Declared{}, err
}
out.Passes = passes
for _, l := range left {
out.Said = append(out.Said, l.String())
}
out.Said = c.saidBeside(out.Said)
return out, nil
}
// carriedOn is the build of each module a declaration for the node carries (plan.go carriedOn, in a question that sends nothing): the
// module's current build for each module in it, and for a module left out of it the build it was last sent, since the machine keeps
// that one -- or nothing, when that is not known.
func (c *composer) carriedOn(node Node, plan catalogue.Resolution, leftOut map[string]string) map[string]string {
before := node.SentBuilds
if !node.SentKnown {
before = nil
}
out := map[string]string{}
for _, m := range plan.Modules {
if _, left := leftOut[m.Module]; left {
if was, kept := before[m.Module]; kept {
out[m.Module] = was
}
continue
}
out[m.Module] = c.in.CurrentBuilds[m.Module]
// A module kept at the build the machine runs is carried at that build (plan.go carriedOn).
if kept, ok := node.KeptBuilds[m.Module]; ok && kept != "" {
out[m.Module] = kept
}
}
return out
}
// wireBuilds is the build of each carried module that goes on the wire (novox/hq issue 1145): the build its assignment names when
// that assignment is of the commit carried, else the build made from that commit; a module with neither is not named.
func (c *composer) wireBuilds(carried map[string]string, runs map[string]Run) map[string]string {
out := map[string]string{}
for module, commit := range carried {
if commit == "" {
continue
}
if ref, ok := runs[module]; ok && ref.Commit == commit && ref.Build != "" {
out[module] = ref.Build
continue
}
if build := c.in.BuildIDs[module][commit]; build != "" {
out[module] = build
}
}
return out
}
// adoptionFor is the envelope computed from what was taken and who owns each resource: nil for a converged node. Every module the
// node runs that is not taken is untaken -- including one pulled in by another rather than assigned.
func adoptionFor(plan catalogue.Resolution, node Node, composed catalogue.Composed) *Adoption {
if !node.Adopted {
return nil
}
isTaken := map[string]bool{}
for _, m := range node.Taken {
isTaken[m] = true
}
out := &Adoption{Taken: []string{}}
runs := map[string]bool{}
for _, m := range plan.Modules {
runs[m.Module] = true
if isTaken[m.Module] {
out.Taken = append(out.Taken, m.Module)
}
}
sort.Strings(out.Taken)
for _, r := range composed.Resources {
id, _ := r["id"].(string)
module, owned := composed.Owner[id]
// Every kind, not only files and containers (novox/hq ADR 0103). What the mesh declares of its own is never held.
if !owned || !runs[module] || isTaken[module] || strings.HasPrefix(id, catalogue.AdoptionPrefix) {
continue
}
if out.Untaken == nil {
out.Untaken = map[string][]string{}
}
out.Untaken[module] = append(out.Untaken[module], id)
}
return out
}
// sortedKeysOf is a map's keys, sorted: so what a declaration says it left out does not move for a reordering nobody made.
func sortedKeysOf(m map[string]string) []string {
if len(m) == 0 {
return nil
}
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// namedModules are the modules a declaration names: its machine's set, less what was left out of it.
func namedModules(plan catalogue.Resolution, leftOut map[string]string) []string {
out := make([]string, 0, len(plan.Modules))
for _, m := range plan.Modules {
if _, left := leftOut[m.Module]; !left {
out = append(out, m.Module)
}
}
sort.Strings(out)
return out
}
// DataItems is every data item the modules placed on a node declare, placed there (novox/hq ADR 0571 clause 3): what the
// node-backup holder is handed as `${backup:data}`, composed over the Input as Compose composes the node, so disk-load
// reads the list from the bus with no composed file. Unplaced names each item left out and why.
func DataItems(in Input, node string) (map[string][]catalogue.PlacedDataItem, []string, error) {
c := newComposer(in)
resolved, err := c.planFor(node)
if err != nil {
return nil, nil, err
}
rendered, err := c.render(node, Planning)
if err != nil {
return nil, nil, err
}
return resolution.DataItems(resolved.Plan, rendered.Rendering)
}
+424
View File
@@ -0,0 +1,424 @@
package compose
import (
"encoding/json"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"github.com/novox/mesh-host/internal/overlay"
)
func resourceIDs(d Declared) []string {
var ids []string
for _, r := range d.Resources {
ids = append(ids, r["type"].(string)+":"+r["id"].(string))
}
return ids
}
func has(ids []string, want string) bool {
for _, id := range ids {
if id == want {
return true
}
}
return false
}
// aSmallMesh is three nodes, a provider and its consumer, a seat and the private network: aMesh with `ace` running a database and
// a resolver holding the mesh's resolver seat on record (the providers and consumer of mesh-controller's facts_test.go
// aMeshWithSecrets, the seat of bindings_test.go storeManifests).
func aSmallMesh(t *testing.T) Input {
t.Helper()
in := threeNodes(t)
for _, m := range storeManifests() {
if m.Module == "resolver" {
in.Catalogue[m.Module] = m
}
}
ace := nodeOf(t, &in, "ace")
ace.Assigned = append(ace.Assigned, "resolver")
in.Holdings = []catalogue.Held{{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "ace", Module: "resolver", Site: "there"}}
return in
}
// 1298/AC1: each node of a small mesh composes, its body is accepted by the node-engine's validator, and it names the resources the
// controller composes for the same case: the private network's files on the nodes that run it, the provider's received file, the
// consumer's file.
func TestEachNodeOfASmallMeshComposesAndItsBodyIsAcceptedByTheValidator(t *testing.T) {
in := aSmallMesh(t)
want := map[string][]string{
"anchor": {"package:mesh-wireguard.overlay-tools", "file:mesh-wireguard.overlay-config", "service:mesh-wireguard.overlay-up", "file:objects.received-s3-bucket"},
"laptop": {"package:mesh-wireguard.overlay-tools", "file:mesh-wireguard.overlay-config", "service:mesh-wireguard.overlay-up", "file:files.config"},
"ace": nil,
}
for node, ids := range want {
d, problems, err := Compose(in, node)
if err != nil || len(problems) != 0 {
t.Fatalf("%s: %v %v", node, err, problems)
}
for _, id := range ids {
if !has(resourceIDs(d), id) {
t.Errorf("%s does not name %s: %v", node, id, resourceIDs(d))
}
}
body, err := d.Body()
if err != nil {
t.Fatal(err)
}
var parsed struct {
Declaration int `json:"declaration"`
Resources []map[string]any `json:"resources"`
}
if err := json.Unmarshal(body, &parsed); err != nil || parsed.Declaration != 1 || len(parsed.Resources) != len(d.Resources) {
t.Errorf("%s: body %s %v", node, body, err)
}
}
// ace runs a resolver and a database and nothing the mesh declares of its own: an empty declaration says it owns nothing.
d, _, _ := Compose(in, "ace")
if body, _ := d.Body(); !strings.Contains(string(body), `"owns_nothing":true`) {
t.Errorf("an empty declaration is not said to be deliberate: %s", body)
}
}
// 1298/AC2 (mesh-controller merge_gate_integration_test.go TestIssue236AManifestTheNodeEngineRefusesFailsThePullRequest, the manifest):
// a service that omits its state is accepted by the catalogue's check and refused by the node-engine; Compose returns the validator's
// problems in the node-engine's words and no error.
func TestAManifestTheNodeEngineRefusesComesBackAsProblemsAndNoError(t *testing.T) {
in := aMesh(t)
var lemurs catalogue.Manifest
if err := json.Unmarshal([]byte(`{"module":"lemurs","version":"1","capabilities":["systemd"],
"resources":[{"id":"service","type":"service","unit":"lemurs.service","boot":"enabled"}]}`), &lemurs); err != nil {
t.Fatal(err)
}
in.Catalogue["lemurs"] = lemurs
laptop := nodeOf(t, &in, "laptop")
laptop.Assigned = append(laptop.Assigned, "lemurs")
d, problems, err := Compose(in, "laptop")
if err != nil {
t.Fatalf("a refusal of the validator is an error: %v", err)
}
if len(problems) == 0 || !strings.Contains(strings.Join(problems, "\n"), "lemurs.service") {
t.Fatalf("problems are %v", problems)
}
if !has(resourceIDs(d), "service:lemurs.service") {
t.Errorf("the declaration does not carry the resource: %v", resourceIDs(d))
}
// And the same, as the validator says it of the body.
body, _ := d.Body()
if again := strings.Join(problems, "\n"); again == "" || !strings.Contains(string(body), "lemurs.service") {
t.Errorf("the body does not name the service: %s", body)
}
}
// 1298/AC3: a node whose own set does not compose returns an error and no declaration, told apart from an unreadable Input.
func TestANodeWhoseSetDoesNotComposeReturnsAnErrorAndNoDeclaration(t *testing.T) {
in := aMesh(t)
in.Catalogue["rival-one"] = catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
in.Catalogue["rival-two"] = catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
laptop := nodeOf(t, &in, "laptop")
laptop.Assigned = append(laptop.Assigned, "rival-one", "rival-two")
d, problems, err := Compose(in, "laptop")
if err == nil || !IsUnresolvable(err) || d.Resources != nil || problems != nil {
t.Fatalf("a set that does not compose: %v %v %v", d.Resources, problems, err)
}
if _, _, err := Compose(in, "nowhere"); err == nil || IsUnresolvable(err) {
t.Fatalf("a node the Input does not have: %v", err)
}
}
// 1298/AC4: composing the same Input twice gives byte-identical bodies, and the Input is unchanged.
func TestComposingTwiceGivesTheSameBytesAndChangesNothing(t *testing.T) {
mk := func() Input {
in := aSmallMesh(t)
nodeOf(t, &in, "anchor").Account = "jochens"
nodeOf(t, &in, "laptop").Sequence = 7
return in
}
in := mk()
for _, node := range []string{"anchor", "laptop", "ace"} {
a, _, err := Compose(in, node)
if err != nil {
t.Fatal(err)
}
b, _, err := Compose(in, node)
if err != nil {
t.Fatal(err)
}
ab, _ := a.Body()
bb, _ := b.Body()
if string(ab) != string(bb) {
t.Errorf("%s: two compositions differ:\n%s\n%s", node, ab, bb)
}
}
unchanged(t, mk, func(in Input) {
for _, node := range []string{"anchor", "laptop", "ace"} {
if _, _, err := Compose(in, node); err != nil {
t.Fatal(err)
}
}
})
}
// 1298/AC5: what a send would make is named in the result as foreseen and never invented: an own secret, a port, a core box, a grant's
// sealed copy.
func TestWhatASendWouldMakeIsNamedAsForeseenAndNeverInvented(t *testing.T) {
in := aGrantMesh(t)
in.Issued[0].ForProvider = ""
in.Catalogue["assistant"] = anAssistant()
in.Catalogue["web"] = aWebModule()
in.Catalogue["nats"] = catalogue.Manifest{Module: "nats", Version: "1",
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/nats/broker", Taken: catalogue.TakenAtStart}}}
anchor := nodeOf(t, &in, "anchor")
anchor.Assigned = append(anchor.Assigned, "assistant", "web", "nats")
anchor.SealingKey = keyOf(t, "anchor")
anchor.Stated, anchor.Contract = true, VaultBoxContract
in.BusMinted = []string{"anchor.nats"}
d, problems, err := Compose(in, "anchor")
if err != nil || len(problems) != 0 {
t.Fatalf("%v %v", err, problems)
}
if !has(d.Foreseen, "assistant/password") || !has(d.Foreseen, "nats/broker") {
t.Errorf("own secrets foreseen: %v", d.Foreseen)
}
if len(d.ForeseenPorts) != 1 || d.ForeseenPorts[0] != "web:8080" {
t.Errorf("foreseen ports: %v", d.ForeseenPorts)
}
if len(d.CoreBoxesWanted) != 1 || d.CoreBoxesWanted[0].Module != "nats" {
t.Errorf("core boxes wanted: %v", d.CoreBoxesWanted)
}
if !has(d.StoodIn, "the grant of s3-bucket for files on laptop") {
t.Errorf("stood in for: %v", d.StoodIn)
}
body, _ := d.Body()
if strings.Contains(string(body), secretValue) {
t.Error("a secret value is in the body")
}
}
// 1298/AC6: the body carries the sequence, epoch and generation the Input gives, and the module of each resource and the carried builds
// only to a node whose Input says it reads modules (novox/hq issue 1145).
func TestTheBodyCarriesTheOrderTheInputGivesAndTheBuildsOnlyToAnEngineThatReadsThem(t *testing.T) {
in := aSmallMesh(t)
laptop := nodeOf(t, &in, "laptop")
laptop.Sequence, laptop.Epoch, laptop.Generation = 42, 57, 9
in.CurrentBuilds = map[string]string{"files": "commit-files", "mesh-wireguard": "commit-wg"}
in.BuildIDs = map[string]map[string]string{"files": {"commit-files": "build-7"}}
// An assignment of another commit than the one carried does not name the build; the build made from the commit carried does.
laptop.Runs = map[string]Run{"mesh-wireguard": {Commit: "commit-wg", Build: "build-9"}, "files": {Commit: "an-older-commit", Build: "build-6"}}
d, _, err := Compose(in, "laptop")
if err != nil {
t.Fatal(err)
}
body, _ := d.Body()
var got map[string]any
if err := json.Unmarshal(body, &got); err != nil {
t.Fatal(err)
}
if got["sequence"] != float64(42) || got["epoch"] != float64(57) || got["generation"] != float64(9) {
t.Errorf("the order is %v %v %v", got["sequence"], got["epoch"], got["generation"])
}
if _, there := got["builds"]; there {
t.Errorf("builds are sent to an engine that does not read them: %s", body)
}
for _, r := range got["resources"].([]any) {
if _, there := r.(map[string]any)["module"]; there {
t.Errorf("a module is named on a resource of an engine that does not read them: %v", r)
}
}
laptop.ReadsModules = true
d, _, err = Compose(in, "laptop")
if err != nil {
t.Fatal(err)
}
body, _ = d.Body()
got = nil
if err := json.Unmarshal(body, &got); err != nil {
t.Fatal(err)
}
builds, _ := got["builds"].(map[string]any)
if builds["files"] != "build-7" || builds["mesh-wireguard"] != "build-9" {
t.Errorf("builds are %v", builds)
}
named := false
for _, r := range got["resources"].([]any) {
if r.(map[string]any)["id"] == "files.config" && r.(map[string]any)["module"] == "files" {
named = true
}
}
if !named {
t.Errorf("a resource is not named for its module: %s", body)
}
// Zero is not sent at all.
laptop.Sequence, laptop.Epoch, laptop.Generation = 0, 0, 0
d, _, _ = Compose(in, "laptop")
body, _ = d.Body()
for _, key := range []string{`"sequence"`, `"epoch"`, `"generation"`} {
if strings.Contains(string(body), key) {
t.Errorf("%s is sent as zero: %s", key, body)
}
}
}
// A module left out of a build set keeps the build it was last sent (novox/hq issue 259): the machine keeps that one.
func TestAModuleLeftOutKeepsTheBuildItWasLastSent(t *testing.T) {
c := newComposer(Input{CurrentBuilds: map[string]string{"a": "new-a", "b": "new-b"}})
plan := catalogue.Resolution{Modules: []catalogue.Manifest{{Module: "a"}, {Module: "b"}, {Module: "c"}}}
node := Node{SentBuilds: map[string]string{"b": "old-b", "c": "old-c"}, SentKnown: true}
got := c.carriedOn(node, plan, map[string]string{"b": "its settings cannot compose"})
if got["a"] != "new-a" || got["b"] != "old-b" || got["c"] != "" {
t.Errorf("carried builds are %v", got)
}
node.SentKnown = false
if got := c.carriedOn(node, plan, map[string]string{"b": "x"}); len(got) != 2 || got["b"] != "" {
t.Errorf("when what was sent is not known: %v", got)
}
}
// An adopted node is told which modules were taken and, for every module it runs that is not, the ids of its resources
// (novox/hq ADR 0100 and 0103); a converged node is told nothing, and its body is as it was before adoption.
func TestAnAdoptedNodeIsToldWhatWasTakenAndWhatIsKept(t *testing.T) {
in := aSmallMesh(t)
d, _, err := Compose(in, "laptop")
if err != nil || d.Adoption != nil {
t.Fatalf("a converged node: %v %v", d.Adoption, err)
}
laptop := nodeOf(t, &in, "laptop")
laptop.Adopted, laptop.Taken = true, []string{"mesh-wireguard"}
d, problems, err := Compose(in, "laptop")
if err != nil || len(problems) != 0 {
t.Fatalf("%v %v", err, problems)
}
if d.Adoption == nil || len(d.Adoption.Taken) != 1 || d.Adoption.Taken[0] != "mesh-wireguard" ||
len(d.Adoption.Untaken["files"]) == 0 || d.Adoption.Untaken["mesh-wireguard"] != nil {
t.Fatalf("adoption is %+v", d.Adoption)
}
if body, _ := d.Body(); !strings.Contains(string(body), `"adoption"`) {
t.Errorf("the adoption is not on the wire: %s", body)
}
}
// Issue 263 (merge_gate_integration_test.go TestIssue263AnIdentityARealMachineNameOverflows...): a change makes the network manager
// require the object store's provision; on a machine whose name is six characters its identity is 26 against a bound of 20. The
// composition of the provider returns the refusal, naming the module and the provision.
func TestIssue263AnIdentityARealMachineNameOverflowsIsARefusal(t *testing.T) {
in := aGrantMesh(t)
in.Catalogue["resolver"] = storeManifests()[1]
in.Catalogue["networkmanager"] = catalogue.Manifest{Module: "networkmanager", Version: "1", Requires: []string{"wildcard-resolution", "s3-bucket"}}
laptop, anchor := nodeOf(t, &in, "laptop"), nodeOf(t, &in, "anchor")
laptop.Assigned = append(laptop.Assigned, "networkmanager")
anchor.Assigned = append(anchor.Assigned, "resolver")
in.Holdings = []catalogue.Held{{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "resolver", Site: "here"}}
in.Issued = append(in.Issued, Issued{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "networkmanager", Provider: "anchor", ForProvider: "sealed"})
d, problems, err := Compose(in, "anchor")
if err != nil || len(problems) != 0 {
t.Fatalf("%v %v", err, problems)
}
refusals := strings.Join(d.Refusals(), "\n")
if !strings.Contains(refusals, "networkmanager") || !strings.Contains(refusals, "s3-bucket") || !strings.Contains(refusals, "laptop") {
t.Fatalf("the refusal does not name the module, the provision and the machine: %q", refusals)
}
// And the consumer that fits is granted.
if len(d.Withheld) != 1 || d.Withheld[0].Module != "networkmanager" {
t.Errorf("withheld %+v", d.Withheld)
}
}
// Round 1, finding 1 (plan.go keepRecorded): a recorded module kept at the build a machine runs is that machine's own resolution
// and no other's; the commit carried for it is the kept one.
func TestAKeptModuleChangesOnlyTheResolutionOfTheNodeThatKeepsIt(t *testing.T) {
in := aSmallMesh(t)
in.Catalogue["tool"] = catalogue.Manifest{Module: "tool", Version: "2"}
for _, name := range []string{"anchor", "laptop"} {
n := nodeOf(t, &in, name)
n.Assigned = append(n.Assigned, "tool")
}
anchor := nodeOf(t, &in, "anchor")
anchor.Kept = map[string]catalogue.Manifest{"tool": {Module: "tool", Version: "1"}}
versionOn := func(in Input, node string) string {
t.Helper()
got, err := Resolve(in, node)
if err != nil {
t.Fatal(err)
}
for _, m := range got.Plan.Modules {
if m.Module == "tool" {
return m.Version
}
}
t.Fatalf("no tool on %s", node)
return ""
}
if v := versionOn(in, "anchor"); v != "1" {
t.Errorf("the node that keeps it runs version %s", v)
}
if v := versionOn(in, "laptop"); v != "2" {
t.Errorf("another node runs version %s: a kept build leaked", v)
}
if got := in.Catalogue["tool"].Version; got != "2" {
t.Errorf("the Input's catalogue was changed: %s", got)
}
// And the commit carried for it is the kept one.
c := newComposer(in)
plan := catalogue.Resolution{Modules: []catalogue.Manifest{{Module: "tool"}, {Module: "other"}}}
in.CurrentBuilds = map[string]string{"tool": "current", "other": "current-other"}
c = newComposer(in)
node := Node{KeptBuilds: map[string]string{"tool": "kept-commit"}}
if got := c.carriedOn(node, plan, nil); got["tool"] != "kept-commit" || got["other"] != "current-other" {
t.Errorf("carried builds are %v", got)
}
}
// Another node's kept build changes neither the rest of the mesh nor who is on the network: the controller reads both with the
// current manifests, and only then keeps a recorded module for the node it composes (recorded_kept.go keepRecorded).
func TestAnotherNodesKeptBuildDoesNotChangeTheRestOfTheMeshOrWhoIsOnTheNetwork(t *testing.T) {
in := threeNodes(t)
nodeOf(t, &in, "anchor").Kept = map[string]catalogue.Manifest{"objects": {Module: "objects", Version: "0"}, overlayName(): {Module: overlayName(), Version: "0"}}
w, _, _, err := TheRestOfTheMesh(in, "laptop")
if err != nil || len(providers(w, "s3-bucket")) != 1 {
t.Errorf("a kept build changed what anchor offers: %v %v", providers(w, "s3-bucket"), err)
}
where, err := newComposer(in).whereEveryoneIs()
if err != nil || where["anchor"] == "" {
t.Errorf("a kept build took anchor off the network: %v %v", where, err)
}
}
func overlayName() string { return overlay.Name }
// Round 1, finding 6: the nodes the composition did not count as on the network are returned, said, not lost. laptop holds no
// complete place (no key): one that does keeps it on the network (issue 1833) and is counted.
func TestANodeNotCountedAsOnTheNetworkIsSaidInTheResult(t *testing.T) {
in := aMesh(t)
nodeOf(t, &in, "laptop").Place.Key = ""
in.Catalogue["rival-one"] = catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
in.Catalogue["rival-two"] = catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
laptop := nodeOf(t, &in, "laptop")
laptop.Assigned = append(laptop.Assigned, "rival-one", "rival-two")
said := func(lines []string) bool {
for _, l := range lines {
if strings.HasPrefix(l, "laptop is not counted as on the network") {
return true
}
}
return false
}
got, err := Render(in, "anchor", Foreseeing)
if err != nil || !said(got.Said) {
t.Errorf("rendered: %v %v", got.Said, err)
}
d, _, err := Compose(in, "anchor")
if err != nil || !said(d.Said) {
t.Errorf("declared: %v %v", d.Said, err)
}
n := 0
for _, l := range d.Said {
if strings.HasPrefix(l, "laptop is not counted") {
n++
}
}
if n != 1 {
t.Errorf("said %d times: %v", n, d.Said)
}
}
+167
View File
@@ -0,0 +1,167 @@
package compose
import (
"slices"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"github.com/novox/mesh-host/internal/overlay"
)
// fourOnTheNetwork is aMesh and two more machines on the private network at the site `there`: `ace`, dialled at
// ace.example:51820, at 10.77.0.3, and `box`, behind no endpoint, at 10.77.0.4. A module `office` requires
// `document-server`, which nothing in the catalogue provides: the shape of issue 1833 (nextcloud on the hub wanting a
// document server nobody offered).
func fourOnTheNetwork(t *testing.T) Input {
t.Helper()
in := aMesh(t)
in.Catalogue["office"] = catalogue.Manifest{Module: "office", Version: "1", Requires: []string{"document-server"}}
caps := map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true}
in.Nodes = append(in.Nodes,
Node{Name: "ace", Place: Place{Key: keyOf(t, "ace"), Endpoint: "ace.example:51820", Site: "there", Address: "10.77.0.3"},
Assigned: []string{overlay.Name}, Capabilities: caps},
Node{Name: "box", Place: Place{Key: keyOf(t, "box"), Site: "there", Address: "10.77.0.4"},
Assigned: []string{overlay.Name}, Capabilities: caps})
return in
}
// unresolvableBy makes one machine's own set fail to resolve: it is also given `office`, whose requirement nothing provides.
func unresolvableBy(t *testing.T, in *Input, name string) {
t.Helper()
n := nodeOf(t, in, name)
n.Assigned = append(n.Assigned, "office")
}
// networkOf is the generator of an Input's private network, failing the test when it is not composed.
func networkOf(t *testing.T, in Input) *overlay.Generator {
t.Helper()
gens, err := Generators(in)
if err != nil {
t.Fatalf("the private network was not composed: %v", err)
}
return gens[overlay.Name].(*overlay.Generator)
}
func placesOf(g *overlay.Generator) map[string]overlay.Node {
out := map[string]overlay.Node{}
for _, n := range g.Nodes() {
out[n.Name] = n
}
return out
}
// 1833/AC1: four machines, the hub's own set does not resolve; the other three compose their declarations, and the hub alone is
// refused, with an error that is its own set failing and names what could not be resolved.
func TestOneMachineThatDoesNotResolveIsRefusedAlone(t *testing.T) {
in := fourOnTheNetwork(t)
unresolvableBy(t, &in, "anchor")
for _, node := range []string{"laptop", "ace", "box"} {
d, problems, err := Compose(in, node)
if err != nil || len(problems) != 0 {
t.Errorf("%s does not compose because another machine does not resolve: %v %v", node, err, problems)
continue
}
if !has(resourceIDs(d), "file:mesh-wireguard.overlay-config") {
t.Errorf("%s is given no private network: %v", node, resourceIDs(d))
}
}
_, _, err := Compose(in, "anchor")
if err == nil || !IsUnresolvable(err) {
t.Fatalf("the machine that does not resolve is not refused as its own set failing: %v", err)
}
if !strings.Contains(err.Error(), `nothing provides "document-server", wanted by office`) {
t.Errorf("the refusal does not name what could not be resolved: %v", err)
}
}
// 1833/AC2: the private network is composed with every machine on it: the hub that does not resolve keeps its place (key,
// address, endpoint, site, hub role), so every other machine keeps its path through it.
func TestAMachineThatDoesNotResolveKeepsItsPlaceOnTheNetwork(t *testing.T) {
in := fourOnTheNetwork(t)
unresolvableBy(t, &in, "anchor")
got := placesOf(networkOf(t, in))
if len(got) != 4 {
t.Fatalf("the network is over %v, want all four machines", got)
}
hub := got["anchor"]
if !hub.Hub || hub.Key != keyOf(t, "anchor") || hub.Address != "10.77.0.1" || hub.Endpoint != "anchor.example:51820" || hub.Site != "here" {
t.Errorf("the hub did not keep its place: %+v", hub)
}
nodes, graph, err := Graph(in)
if err != nil || len(nodes) != 4 {
t.Fatalf("the graph is not shown with every machine: %v %v", nodes, err)
}
for _, node := range []string{"ace", "box"} {
through := false
for _, p := range graph[node] {
through = through || (p.Name == "anchor" && p.Endpoint == "anchor.example:51820")
}
if !through {
t.Errorf("%s has no path through the hub: %+v", node, graph[node])
}
}
}
// 1833/AC3: a machine that does not resolve and was never placed (a new machine: no key, no address) is left off the network,
// which is composed from the rest -- here with the hub that does not resolve on its last place -- and that machine alone is refused.
func TestANewMachineThatDoesNotResolveIsLeftOffAndRefusedAlone(t *testing.T) {
in := fourOnTheNetwork(t)
unresolvableBy(t, &in, "anchor")
in.Nodes = append(in.Nodes, Node{Name: "fresh", Place: Place{Site: "there"},
Assigned: []string{overlay.Name, "office"}, Capabilities: map[string]bool{"wireguard": true, "systemd": true}})
got := placesOf(networkOf(t, in))
if _, on := got["fresh"]; on || len(got) != 4 {
t.Fatalf("the network is over %v, want the four placed machines without fresh", got)
}
if _, _, err := Compose(in, "fresh"); err == nil || !IsUnresolvable(err) {
t.Errorf("the new machine is not refused as its own set failing: %v", err)
}
if _, problems, err := Compose(in, "box"); err != nil || len(problems) != 0 {
t.Errorf("another machine does not compose: %v %v", err, problems)
}
}
// 1833/AC4: a network that truly cannot be composed from what remains still fails: the hub does not resolve and has no
// complete place (no key), so nothing is the hub; the refusal is today's, naming the machine that could not be resolved.
func TestANetworkThatCannotBeComposedFromWhatRemainsStillFails(t *testing.T) {
in := fourOnTheNetwork(t)
unresolvableBy(t, &in, "anchor")
nodeOf(t, &in, "anchor").Place.Key = ""
_, err := Generators(in)
if err == nil {
t.Fatal("a network without a hub was composed")
}
for _, want := range []string{overlay.ErrNoHub.Error(), "and 1 node(s) could not be resolved at all, which is probably why:", " anchor: "} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the refusal does not say %q:\n%v", want, err)
}
}
if _, _, err := Compose(in, "laptop"); err == nil {
t.Error("a declaration was composed over a network that cannot be")
}
}
// 1833/AC2, a failure the composition's unchecked pass refuses too (two modules claiming one seat on the hub, the 1360/AC3
// shape): the hub keeps its place in what every other machine is sent besides the tunnel -- its internal name and its address
// among the sources "from the mesh" -- and another machine still composes.
func TestAMachineThatDoesNotResolveKeepsItsNameAndItsAddressInTheMesh(t *testing.T) {
in := fourOnTheNetwork(t)
in.Catalogue["rival-one"] = catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
in.Catalogue["rival-two"] = catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
hub := nodeOf(t, &in, "anchor")
hub.Assigned = append(hub.Assigned, "rival-one", "rival-two")
if _, err := Resolve(in, "anchor"); !IsUnresolvable(err) {
t.Fatalf("the hub is meant not to resolve: %v", err)
}
c := newComposer(in)
if got := c.namesInTheMesh()["anchor."+c.suffix()]; got != "10.77.0.1" {
t.Errorf("the hub's name is not in the mesh's names: %v", c.namesInTheMesh())
}
if got := c.onThePrivateNetwork(); !slices.Contains(got, "10.77.0.1") || len(got) != 4 {
t.Errorf("the sources from the mesh are %v, want all four machines with the hub", got)
}
if _, problems, err := Compose(in, "ace"); err != nil || len(problems) != 0 {
t.Errorf("another machine does not compose: %v %v", err, problems)
}
}
+150
View File
@@ -0,0 +1,150 @@
package compose
import (
"fmt"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// Rendered is everything a node's declaration is composed with (plan.go renderingFor), over an Input: the place half (Places)
// and the credential half. Nothing was allocated, made, kept or written; what a send would do is returned for the caller to do.
type Rendered struct {
Rendering catalogue.Rendering
// ForeseenPorts are the ports the mesh may assign that the Input holds none for ("<module>:<port>").
ForeseenPorts []string
// Foreseen are the stand-ins the composition used for what the Input does not hold: an own secret the next send makes
// ("<module>/<name>"), 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
}
+544
View File
@@ -0,0 +1,544 @@
package compose
import (
"errors"
"fmt"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"github.com/novox/mesh-host/internal/compose/resolution"
"github.com/novox/mesh-host/internal/overlay"
)
// The contracts of the node-engine's statement that gate what a machine is sent, as the controller's link package names them
// (mesh-controller internal/link/protocol.go at 6c2a8d62). An older engine parses strictly and refuses a field it does not
// know, whole.
const (
ReadinessContract = 2 // the engine reads a resource's `health` (novox/hq ADR 0240)
RootContract = 3 // the engine judges a user's declared `root` (ADR 0266)
BusyContract = 8 // the engine reads a container's `busy` (ADR 0335)
HandedContract = 11 // the engine writes a filled file on the provisioner client's word alone
VaultBoxContract = 12 // the engine opens the vault's box
)
// The provisioner client's seat and own secret (the controller's broker.NodeProvisionerSeat and ProvisionerSecret).
const (
NodeProvisionerSeat = "node-provisioner"
ProvisionerSecret = "provisioner-broker"
)
// ErrPortTaken is the controller's refusal for a machine port held twice on one machine.
var ErrPortTaken = errors.New("that port is already held on this machine")
// Placed is the place half of a rendering (plan.go renderingFor): where a machine puts what its modules need reachable, the
// seats and who holds them, the names of the mesh, the networks, accounts and the operator's export. It is a catalogue.Rendering
// with those fields set, and the ports it could not read named foreseen. Nothing was allocated.
type Placed struct {
Rendering catalogue.Rendering
// ForeseenPorts are the ports the mesh may assign that the Input holds none for, as "<module>:<port>": composed with the
// manifest's own number as the Reading mode composes them, and never invented. The controller's adapter allocates and
// composes again.
ForeseenPorts []string
}
// Places composes the place half of the rendering for a node (the controller's renderingFor in the Reading and Foreseeing modes,
// without the credentials): over an Input, allocating nothing and changing nothing in it. The refusals are the controller's,
// in its words.
func Places(in Input, node string) (Placed, error) {
c := newComposer(in)
resolved, err := c.planFor(node)
if err != nil {
return Placed{}, err
}
return c.places(node, resolved)
}
func (c *composer) places(nodeName string, resolved Resolved) (Placed, error) {
var out Placed
node, err := c.in.node(nodeName)
if err != nil {
return out, err
}
plan, settings := resolved.Plan, resolved.Settings
r := &out.Rendering
// Where this machine puts what each module needs reachable (novox/hq ADR 0038): what it was already given, and the ports
// it was given over those (ADR 0100).
given, err := c.portsFor(node, plan, settings, r, &out.ForeseenPorts)
if err != nil {
return out, err
}
// Hands: whether the engine writes a filled file on the provisioner client's word alone; engines older than a contract are
// not sent the field they would refuse.
r.Hands = takesHanded(node)
r.Provisioner = provisionerAccount(plan)
r.Assigned = node.Handed
r.ReadsHealth = node.Stated && node.Contract >= ReadinessContract
r.JudgesRoot = node.Stated && node.Contract >= RootContract
r.ReadsBusy = node.Stated && node.Contract >= BusyContract
// And who else is on the private network, which is what a rule saying "from the mesh" resolves to.
r.Mesh = c.onThePrivateNetwork()
// The private network's range, offered to a module as ${machine:mesh-range}.
r.MeshRange = c.overlayRange()
// The artifact store as this node reaches it now, with what the mesh has built.
store, err := c.artifactStoreAddress(nodeName)
if err != nil {
return out, err
}
r.ArtifactStore = store
r.Built = make(map[string]bool, len(c.in.Built))
for _, repository := range c.in.Built {
r.Built[repository] = true
}
// And every machine's name, so a container can reach one.
r.Names = c.namesInTheMesh()
// Each machine's operator account, so an ssh Host block can name the login for every node (novox/hq to-be 29).
r.Accounts = map[string]string{}
for _, n := range c.in.sorted() {
if n.Account != "" {
r.Accounts[n.Name] = n.Account
}
}
// The roster is the machines and nothing else (novox/hq ADR 0191).
r.Machines = make(map[string]string, len(r.Names))
for name, at := range r.Names {
r.Machines[name] = at
}
// And every zone a module in the mesh answers itself (novox/hq ADR 0199).
zones, err := c.zonesInTheMesh()
if err != nil {
return out, err
}
r.Zones = zones
// And who holds each replicated seat, where (novox/hq ADR 0223).
r.Holders = c.replicatedHolders()
r.Suffix, r.TunnelInterface = c.suffix(), overlay.Interface
// And, for a module that keeps them, every operator-sealed secret in the mesh (novox/hq ADR 0085, amended).
for _, m := range plan.Modules {
if m.Keeps == "" {
continue
}
r.Kept = c.in.OperatorExport
if r.Kept == nil {
r.KeptRuns = c.in.KeptRuns[node.Name]
}
break
}
// Whether this node is adopted (novox/hq ADR 0100), and which modules were taken there.
r.Adopted = node.Adopted
var taken map[string]bool
if node.Adopted {
taken = map[string]bool{}
for _, m := range node.Taken {
taken[m] = true
}
}
r.Taken = taken
// Where this node put the foundation's servers (novox/hq 04-ISSUES/102).
seats, err := c.seatsOn(node, plan.Modules, node.Adopted, taken)
if err != nil {
return out, err
}
r.Seats = seats
// Which of this machine's links face outside (novox/hq ADR 0140), and which of its networks count as its local network
// (ADR 0340).
r.OutwardLinks = node.OutwardLinks
r.LocalNetworks = resolution.CountedLocalNetworks(localNetworksOf(node))
// Where this machine reaches each mesh seat's holder, for ${seat:<seat>:reach} (novox/hq ADR 0222).
if store != "" {
r.SeatReach = map[string]string{"mesh-artifact-store": store}
}
r.Settings = settings
r.Given = given
return out, nil
}
// takesHanded says whether a machine's newest statement is of an engine that writes a filled file on the provisioner client's
// word alone.
func takesHanded(n Node) bool { return n.Stated && n.Contract >= HandedContract }
// provisionerAccount is the account the provisioner's client runs as on this machine (novox/hq ADR 0349 clause 4): the user
// the process of the module running it runs as, empty where no such module is on the machine.
func provisionerAccount(plan catalogue.Resolution) string {
for _, m := range plan.Modules {
if !runsTheProvisionerClient(m) {
continue
}
for _, r := range m.Resources {
if r["type"] == "process" {
if user, _ := r["user"].(string); user != "" && user != "root" {
return user
}
}
}
}
return ""
}
// runsTheProvisionerClient says a module runs its machine's provisioner client: it claims the seat and declares the own
// secret that account is delivered in.
func runsTheProvisionerClient(m catalogue.Manifest) bool {
_, reads := m.OwnSecrets[ProvisionerSecret]
return reads && m.ClaimsSeat(NodeProvisionerSeat)
}
// portsFor is where a node puts what each module needs reachable, in the Reading mode: the ports it was given win, then the
// ones the mesh assigned, and a port the Input holds none for is composed as the manifest says and named foreseen. One holder
// per machine port across the node's modules: settings written before this was refused are refused here.
func (c *composer) portsFor(node Node, plan catalogue.Resolution, settings catalogue.SettingsBy, r *catalogue.Rendering,
foreseen *[]string) (map[string]map[int]int, error) {
already := node.Ports
given := map[string]map[int]int{}
for _, m := range plan.Modules {
g, err := resolution.GivenPorts(m, settings[m.Module])
if err != nil {
// A given port its definition no longer publishes: the module is left out of the declaration, by name, when it is
// composed (novox/hq ADR 0163, rule 6) -- never the machine refused here for it.
continue
}
if g != nil {
given[m.Module] = g
}
}
holders := map[int]string{}
for _, m := range plan.Modules {
for _, at := range given[m.Module] {
if other, twice := holders[at]; twice && other != m.Module {
return nil, fmt.Errorf("%w: %s and %s are both given machine port %d on %s", ErrPortTaken, other, m.Module,
at, node.Name)
}
holders[at] = m.Module
}
}
ports := map[string]map[int]int{}
for _, m := range plan.Modules {
for _, l := range m.Listens {
if at, isGiven := given[m.Module][l.Port]; isGiven {
if ports[m.Module] == nil {
ports[m.Module] = map[int]int{}
}
ports[m.Module][l.Port] = at
continue
}
// Only a port the module actually publishes is the mesh's to move. Recorded either way, because this map means where
// this module's port is on this machine.
where, mayAssign := m.MachineSide(l.Port)
if mayAssign {
// Whatever was chosen last time, and nothing if there was no last time.
if at, known := already[m.Module][l.Port]; known {
where = at
} else {
*foreseen = append(*foreseen, fmt.Sprintf("%s:%d", m.Module, l.Port))
}
}
if ports[m.Module] == nil {
ports[m.Module] = map[int]int{}
}
ports[m.Module][l.Port] = where
}
}
r.Ports = ports
return given, nil
}
// onThePrivateNetwork is every node's address on the private network, sorted: the same set the names mean by it, so a rule
// saying "from the mesh" admits exactly the machines the mesh names. A node with no address is left out, not rendered as an
// empty source.
func (c *composer) onThePrivateNetwork() []string {
var out []string
for _, p := range c.onTheNetwork() {
if strings.TrimSpace(p.Place.Address) != "" {
out = append(out, p.Place.Address)
}
}
sort.Strings(out)
return out
}
// namesInTheMesh is every machine's internal name and the address behind it, for every machine on the private network, and
// the carried peers the operator has named (novox/hq issue 112).
func (c *composer) namesInTheMesh() map[string]string {
out := map[string]string{}
for _, p := range c.onTheNetwork() {
out[c.internalName(p.Name)] = p.Place.Address
}
for _, p := range c.in.CarriedPeers {
if p.Named == "" || p.EnrolledAs != "" {
continue
}
if _, taken := out[c.internalName(p.Named)]; taken {
continue // a node of the mesh owns the name; the stale statement loses
}
out[c.internalName(p.Named)] = p.Address
}
return out
}
// artifactStoreOnNetwork is the machine on this network that offers the artifact store, and the port that machine put it on.
func (c *composer) artifactStoreOnNetwork(on map[string]bool) (node, port string, found bool, err error) {
providers := map[string]catalogue.Manifest{}
for name, m := range c.in.Catalogue {
if _, offers := m.Serves[catalogue.ArtifactStoreProvision]; offers {
providers[name] = m
}
}
if len(providers) == 0 {
return "", "", false, nil
}
// In a stated order, so two machines offering it would always answer the same one.
machines := make([]string, 0, len(on))
for machine := range on {
machines = append(machines, machine)
}
sort.Strings(machines)
for _, machine := range machines {
n, err := c.in.node(machine)
if err != nil {
return "", "", false, fmt.Errorf("reading what %s is assigned: %w", machine, err)
}
for _, a := range n.Assigned {
m, offers := providers[a]
if !offers {
continue
}
serves, _, err := c.servedOnNode(machine, m, catalogue.ArtifactStoreProvision)
if err != nil {
return "", "", false, fmt.Errorf("reading where %s puts the artifact store: %w", machine, err)
}
if p, ok := serves["port"]; ok {
return machine, fmt.Sprintf("%v", p), true, nil
}
}
}
return "", "", false, nil
}
// ArtifactStoreAddress is the artifact store as node reaches it, by the rule its composition routes every kept reference
// with (artifactStoreAddress): empty when node reaches none. A node composing its own modules reads a templates archive
// there (issue 1126).
func ArtifactStoreAddress(in Input, node string) (string, error) {
return newComposer(in).artifactStoreAddress(node)
}
// artifactStoreAddress is the artifact store as `forNode` reaches it: `<node>.internal:<port>` over the private network, or
// `127.0.0.1:<port>` for the node that holds the store itself when nothing is on the network yet, and empty for any other
// (novox/hq 04-ISSUES/102).
func (c *composer) artifactStoreAddress(forNode string) (string, error) {
onNetwork, err := c.whereEveryoneIs()
if err != nil {
return "", err
}
on := map[string]bool{}
for name := range onNetwork {
on[name] = true
}
node, port, found, err := c.artifactStoreOnNetwork(on)
if err != nil {
return "", err
}
if found {
return c.internalName(node) + ":" + port, nil
}
all := map[string]bool{}
for _, n := range c.in.Nodes {
all[n.Name] = true
}
holder, port, found, err := c.artifactStoreOnNetwork(all)
if err != nil || !found || holder != forNode {
return "", err
}
return "127.0.0.1:" + port, nil
}
// zonesInTheMesh is every zone a module in the mesh declares, where the mesh placed it (novox/hq ADR 0199): the zone settled
// from that node's settings, the node's private address, the port the answering listen is published on there. A node whose set
// does not compose declares nothing and is passed over; what the mesh refuses about the zones together is refused, by name.
func (c *composer) zonesInTheMesh() ([]catalogue.ZoneAt, error) {
address := map[string]string{}
for _, n := range c.in.sorted() {
if strings.TrimSpace(n.Place.Address) != "" {
address[n.Name] = n.Place.Address
}
}
var zones []catalogue.ZoneAt
var public []string
for _, n := range c.in.sorted() {
resolved, err := c.planFor(n.Name)
switch {
case unresolvable(err):
continue
case err != nil:
return nil, fmt.Errorf("the zones %s answers cannot be read: %w", n.Name, err)
}
plan := resolved.Plan
if plan.PublicDomain != "" {
public = append(public, plan.PublicDomain)
}
for _, m := range plan.Modules {
if m.Zone == nil {
continue
}
at := address[n.Name]
if at == "" {
// Not on the private network yet: nothing could reach its answerer.
continue
}
published, layers, err := c.portsGivenOn(n.Name, m)
if err != nil {
return nil, fmt.Errorf("the zone %s declares on %s cannot be read: %w", m.Module, n.Name, err)
}
z, err := resolution.ZoneOn(m, layers, published, n.Name, at)
if err != nil {
return nil, err
}
zones = append(zones, *z)
}
}
if problems := resolution.ZonesProblems(zones, c.suffix(), public); len(problems) > 0 {
return nil, fmt.Errorf("the mesh's zones cannot be forwarded:\n - %s", strings.Join(problems, "\n - "))
}
return zones, nil
}
// replicatedHolders is, for each replicated mesh seat, every machine on the private network holding it, by internal name at its
// private address (novox/hq ADR 0223): the holders on record, and only the sole claimant when there are none. A holder off
// the private network is left out.
func (c *composer) replicatedHolders() map[string]map[string]string {
var replicated []catalogue.Seat
for _, s := range catalogue.Seats() {
if s.Replicated && s.Scope == catalogue.ScopeMesh {
replicated = append(replicated, s)
}
}
if len(replicated) == 0 {
return nil
}
address := map[string]string{}
for _, p := range c.onTheNetwork() {
address[p.Name] = p.Place.Address
}
// Every module of the catalogue with the nodes it is assigned on, sorted, as the controller's Catalogued answers.
on := map[string][]string{}
for _, n := range c.in.sorted() {
for _, module := range n.Assigned {
on[module] = append(on[module], n.Name)
}
}
names := make([]string, 0, len(c.in.Catalogue))
for name := range c.in.Catalogue {
names = append(names, name)
}
sort.Strings(names)
out := map[string]map[string]string{}
for _, seat := range replicated {
var nodes []string
for _, h := range c.in.Holdings {
if hs, ok := catalogue.SeatNamed(h.Claim); ok && hs.Name == seat.Name && h.Scope == seat.Scope {
nodes = append(nodes, h.Node)
}
}
if len(nodes) == 0 {
var derived []string
for _, name := range names {
for _, cl := range c.in.Catalogue[name].Claims {
if cs, ok := catalogue.SeatNamed(cl.Name); ok && cs.Name == seat.Name && cl.At() == seat.Scope {
derived = append(derived, on[name]...)
}
}
}
if len(derived) == 1 {
nodes = derived
}
}
at := map[string]string{}
for _, n := range nodes {
if address[n] != "" {
at[c.internalName(n)] = address[n]
}
}
out[seat.Name] = at
}
return out
}
// seatsOn is where a node put the holder of each mesh-scoped seat, by seat and by the port the holder's software uses: what
// ${seat:...} answers with (novox/hq 04-ISSUES/102). Read for every module in the catalogue that claims a seat, in this node's set
// or not; a holder in this node's set wins over one that is not.
func (c *composer) seatsOn(node Node, inSet []catalogue.Manifest, adopted bool, taken map[string]bool) (map[string]map[int]int, error) {
assigned := map[string]bool{}
for _, m := range inSet {
assigned[m.Module] = true
}
names := make([]string, 0, len(c.in.Catalogue))
for name := range c.in.Catalogue {
names = append(names, name)
}
sort.Strings(names)
seats := map[string]map[int]int{}
for _, name := range names {
m := c.in.Catalogue[name]
var claims []string
for _, cl := range m.Claims {
if cl.At() == catalogue.ScopeMesh {
claims = append(claims, cl.Name)
}
}
if len(claims) == 0 {
continue
}
// Only a port the node was given or the mesh assigned, never the manifest's own number. And on an adopted node a holder
// assigned but not yet taken is the found container, so its mesh-assigned ports do not count there; a given port does.
ports, _, err := c.portsGivenOn(node.Name, m)
if err != nil {
return nil, err
}
if adopted && !taken[name] {
layers := node.Settings[m.Module]
ports = map[int]int{}
if given, err := resolution.GivenPorts(m, layers); err == nil {
ports = given
}
}
if len(ports) == 0 {
continue
}
for _, seat := range claims {
if seats[seat] == nil {
seats[seat] = map[int]int{}
}
for wanted, at := range ports {
if _, said := seats[seat][wanted]; said && !assigned[name] {
continue
}
seats[seat][wanted] = at
}
}
}
return seats, nil
}
// localNetworksOf is a machine's attached networks judged (novox/hq ADR 0340): which count as its local network, from what it
// last said of itself and the links it said face outside it. A machine that has said none judges to nothing.
func localNetworksOf(n Node) []catalogue.LocalNetwork {
if !n.NetworksSaid {
return nil
}
return resolution.JudgeLocalNetworks(n.Networks, n.OutwardLinks, overlay.Interface)
}
+329
View File
@@ -0,0 +1,329 @@
package compose
import (
"errors"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"github.com/novox/mesh-host/internal/overlay"
)
// aWebModule publishes one port in the short form, which the mesh is free to move (MachineSide).
func aWebModule() catalogue.Manifest {
return catalogue.Manifest{Module: "web", Version: "1",
Listens: []catalogue.Listening{{Name: "http", Port: 8080, From: catalogue.FromMesh}},
Resources: []map[string]any{{"id": "server", "type": "container", "name": "web", "ports": []any{"8080"},
"image": "registry.example/web@sha256:" + strings.Repeat("a", 64)}}}
}
// aStoreModule is mesh-controller's sendable_integration_test.go TestConsumersAreToldTheGivenPort store (cmd/mesh-controller
// at 6c2a8d62): publishing 5432:5432, offering a database to the mesh.
func aStoreModule() catalogue.Manifest {
return catalogue.Manifest{Module: "store", Version: "1",
Provides: []catalogue.Offer{{Name: "database", Scope: catalogue.ScopeMesh}},
Listens: []catalogue.Listening{{Port: 5432, From: catalogue.FromMesh}},
Guards: []int{5432},
Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-store",
"ports": []any{"5432:5432"},
"image": "registry.example/pg@sha256:" + strings.Repeat("b", 64)}}}
}
func placed(t *testing.T, in Input, node string) Placed {
t.Helper()
got, err := Places(in, node)
if err != nil {
t.Fatalf("%v", err)
}
return got
}
// 1363/AC1 and AC2: a port the Input holds as allocated on the node is the one composed; with none, no number is invented: the
// manifest's own is composed and the port is named foreseen, as the Foreseeing mode does.
func TestAnAllocatedPortIsComposedAndAnUnallocatedOneIsNamedForeseenNotInvented(t *testing.T) {
mk := func() Input {
in := aMesh(t)
in.Catalogue["web"] = aWebModule()
nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "web")
return in
}
in := mk()
got := placed(t, in, "laptop")
if p := got.Rendering.Ports["web"][8080]; p != 8080 {
t.Errorf("a port nobody allocated is composed as %d, not as the manifest's own", p)
}
if len(got.ForeseenPorts) != 1 || got.ForeseenPorts[0] != "web:8080" {
t.Errorf("foreseen ports are %v", got.ForeseenPorts)
}
nodeOf(t, &in, "laptop").Ports = map[string]map[int]int{"web": {8080: 20123}}
got = placed(t, in, "laptop")
if p := got.Rendering.Ports["web"][8080]; p != 20123 || len(got.ForeseenPorts) != 0 {
t.Errorf("the allocated port is composed as %d (foreseen %v)", p, got.ForeseenPorts)
}
// A port the module does not publish is not the mesh's to move, so it is never foreseen.
m := aWebModule()
m.Resources = nil
in.Catalogue["web"] = m
nodeOf(t, &in, "laptop").Ports = nil
got = placed(t, in, "laptop")
if got.Rendering.Ports["web"][8080] != 8080 || len(got.ForeseenPorts) != 0 {
t.Errorf("an unpublished port: %v foreseen %v", got.Rendering.Ports, got.ForeseenPorts)
}
unchanged(t, mk, func(in Input) { placed(t, in, "laptop") })
// Only a composition for plans refuses a contribution whose port the module does not listen on (issue 774).
for _, mode := range []Mode{Reading, Foreseeing, Planning} {
if got := rendered(t, mk(), "laptop", mode).Rendering; got.Planned != (mode == Planning) {
t.Errorf("mode %d composes with Planned %v", mode, got.Planned)
}
}
}
// 1363/AC3: a port the operator gave is used, over the allocated one, and a consumer elsewhere is told it
// (mesh-controller sendable_integration_test.go TestConsumersAreToldTheGivenPort).
func TestAPortTheOperatorGaveIsUsedAndNotReallocated(t *testing.T) {
in := aMesh(t)
in.Catalogue["store"] = aStoreModule()
in.Catalogue["app"] = catalogue.Manifest{Module: "app", Version: "1", Requires: []string{"database"}}
anchor := nodeOf(t, &in, "anchor")
anchor.Assigned = append(anchor.Assigned, "store")
nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "app")
anchor.Ports = map[string]map[int]int{"store": {5432: 20999}}
anchor.Settings = map[string][]catalogue.Layer{"store": {{From: "anchor",
Placement: map[string]any{catalogue.PortsSetting: map[string]any{"5432": 5433}}}}}
got := placed(t, in, "anchor")
if p := got.Rendering.Ports["store"][5432]; p != 5433 {
t.Errorf("the store is on %d, not the port it was given", p)
}
if g := got.Rendering.Given["store"][5432]; g != 5433 {
t.Errorf("the given port is %d", g)
}
if len(got.ForeseenPorts) != 0 {
t.Errorf("a given port is foreseen: %v", got.ForeseenPorts)
}
resolved, err := Resolve(in, "laptop")
if err != nil {
t.Fatal(err)
}
if told := need(t, resolved.Plan, "app", "database").Serves["port"]; told != 5433 {
t.Errorf("the consumer is told the store is on %v", told)
}
// One holder per machine port across the node's modules: two given the same refused, in the controller's words.
in.Catalogue["store2"] = func() catalogue.Manifest {
m := aStoreModule()
m.Module, m.Provides = "store2", nil
m.Resources = []map[string]any{{"id": "server", "type": "container", "name": "other", "ports": []any{"5432:5432"},
"image": "registry.example/pg@sha256:" + strings.Repeat("c", 64)}}
return m
}()
anchor.Assigned = append(anchor.Assigned, "store2")
anchor.Settings["store2"] = []catalogue.Layer{{From: "anchor",
Placement: map[string]any{catalogue.PortsSetting: map[string]any{"5432": 5433}}}}
_, err = Places(in, "anchor")
if err == nil || !errors.Is(err, ErrPortTaken) || !strings.Contains(err.Error(), "store and store2 are both given machine port 5433 on anchor") &&
!strings.Contains(err.Error(), "store2 and store are both given machine port 5433 on anchor") {
t.Fatalf("two modules given one machine port: %v", err)
}
}
// 1363/AC4: the seats held and taken reach the Rendering: where each mesh-scoped seat's holder put its ports, from the node's
// given and assigned ports, never the manifest's own; and on an adopted node a holder assigned but not taken counts only by its
// given ports (novox/hq ADR 0100).
func TestSeatsHeldAndTakenReachTheRendering(t *testing.T) {
in := aMesh(t)
holder := aStoreModule()
holder.Claims = []catalogue.Claim{{Name: "mesh-store", Scope: catalogue.ScopeMesh}}
in.Catalogue["store"] = holder
anchor := nodeOf(t, &in, "anchor")
anchor.Assigned = append(anchor.Assigned, "store")
// The shelf is read whole, in this node's set or not: laptop, running none, is told where the anchor's store is only by the
// settings and ports of its own machine, which it has none of.
anchor.Ports = map[string]map[int]int{"store": {5432: 20999}}
got := placed(t, in, "anchor")
if at := got.Rendering.Seats["mesh-store"][5432]; at != 20999 {
t.Errorf("the seat's holder is told to be on %d", at)
}
if len(placed(t, in, "laptop").Rendering.Seats) != 0 {
t.Errorf("laptop is told of a holder it has no port for: %v", placed(t, in, "laptop").Rendering.Seats)
}
// Adopted, and the holder assigned but not taken: the found container's ports, not the mesh's assignment.
anchor.Adopted = true
if len(placed(t, in, "anchor").Rendering.Seats) != 0 {
t.Errorf("an untaken holder on an adopted node counts by the mesh's assignment: %v", placed(t, in, "anchor").Rendering.Seats)
}
anchor.Taken = []string{"store"}
got = placed(t, in, "anchor")
if at := got.Rendering.Seats["mesh-store"][5432]; at != 20999 || !got.Rendering.Adopted || !got.Rendering.Taken["store"] {
t.Errorf("a taken holder: seats %v adopted %v taken %v", got.Rendering.Seats, got.Rendering.Adopted, got.Rendering.Taken)
}
}
// 1363/AC4: local networks and outward links (mesh-controller local_network_test.go TestNodeShowsWhichAttachedNetworksCountAndWhy,
// the values): a private network on a link facing outside counts, a public subnet and the private network's own link never do; a
// machine that said none judges to nothing.
func TestLocalNetworksAndOutwardLinksReachTheRendering(t *testing.T) {
in := aMesh(t)
laptop := nodeOf(t, &in, "laptop")
laptop.OutwardLinks = []string{"eth0", "eth1"}
laptop.Networks = []catalogue.AttachedNetwork{
{Link: "eth0", Network: "192.168.1.10/24"}, {Link: "eth1", Network: "203.0.113.7/24"}, {Link: overlay.Interface, Network: "10.10.0.4/24"}}
laptop.NetworksSaid = true
got := placed(t, in, "laptop").Rendering
if len(got.LocalNetworks) != 1 || got.LocalNetworks[0] != "192.168.1.0/24" {
t.Errorf("the local networks are %v", got.LocalNetworks)
}
if len(got.OutwardLinks) != 2 || got.OutwardLinks[0] != "eth0" {
t.Errorf("the outward links are %v", got.OutwardLinks)
}
laptop.NetworksSaid = false
if got := placed(t, in, "laptop").Rendering; len(got.LocalNetworks) != 0 {
t.Errorf("a machine that never reported has local networks %v", got.LocalNetworks)
}
}
// 1363/AC4: accounts, the names of the mesh, the mesh range and the operator's export reach the Rendering.
func TestAccountsNamesAndTheOperatorsExportReachTheRendering(t *testing.T) {
in := aMesh(t)
nodeOf(t, &in, "anchor").Account = "jochens"
in.OperatorExport = &catalogue.KeptExport{}
in.OverlayCIDR = "10.99.0.0/16"
got := placed(t, in, "anchor").Rendering
if got.Accounts["anchor"] != "jochens" || len(got.Accounts) != 1 {
t.Errorf("accounts are %v", got.Accounts)
}
if got.Names["anchor.internal"] != "10.77.0.1" || got.Names["laptop.internal"] != "10.77.0.2" || len(got.Names) != 2 {
t.Errorf("names are %v", got.Names)
}
if len(got.Machines) != 2 || got.Machines["laptop.internal"] != "10.77.0.2" {
t.Errorf("machines are %v", got.Machines)
}
if len(got.Mesh) != 2 || got.Mesh[0] != "10.77.0.1" || got.Mesh[1] != "10.77.0.2" {
t.Errorf("the mesh's addresses are %v", got.Mesh)
}
if got.MeshRange != "10.99.0.0/16" || got.Suffix != "internal" || got.TunnelInterface != overlay.Interface {
t.Errorf("range %q suffix %q tunnel %q", got.MeshRange, got.Suffix, got.TunnelInterface)
}
if got.Kept != nil {
t.Error("the operator's export reaches a node where nothing keeps it")
}
keeper := catalogue.Manifest{Module: "keeper", Version: "1", Keeps: "/var/lib/keeper/export.json"}
in.Catalogue["keeper"] = keeper
nodeOf(t, &in, "anchor").Assigned = append(nodeOf(t, &in, "anchor").Assigned, "keeper")
if got := placed(t, in, "anchor").Rendering; got.Kept != in.OperatorExport {
t.Errorf("a module that keeps the export is not given it: %v", got.Kept)
}
// And the names of a machine that left the network leave with it.
nodeOf(t, &in, "laptop").Assigned = nil
if got := placed(t, in, "anchor").Rendering; len(got.Names) != 1 || got.Names["laptop.internal"] != "" {
t.Errorf("a machine off the network is named: %v", got.Names)
}
}
// The names the operator gave to peers of an adopted tunnel answer until they enrol (novox/hq issue 112); a node of the mesh owns
// its name.
func TestACarriedPeerIsNamedUntilItEnrols(t *testing.T) {
in := aMesh(t)
in.CarriedPeers = []CarriedPeer{{PublicKey: "K1", Address: "192.0.2.5", Named: "printer"},
{PublicKey: "K2", Address: "192.0.2.6", Named: "laptop"}, {PublicKey: "K3", Address: "192.0.2.7", Named: "late", EnrolledAs: "late"}}
got := placed(t, in, "anchor").Rendering
if got.Names["printer.internal"] != "192.0.2.5" || got.Names["laptop.internal"] != "10.77.0.2" || got.Names["late.internal"] != "" {
t.Errorf("names are %v", got.Names)
}
}
// A node-engine older than a contract is not sent the field it would refuse (novox/hq ADR 0240 and after).
func TestTheNodeEnginesContractGatesWhatItIsSent(t *testing.T) {
in := aMesh(t)
laptop := nodeOf(t, &in, "laptop")
if r := placed(t, in, "laptop").Rendering; r.ReadsHealth || r.JudgesRoot || r.ReadsBusy || r.Hands {
t.Errorf("an engine that never stated anything reads %+v", r)
}
laptop.Stated, laptop.Contract = true, ReadinessContract
if r := placed(t, in, "laptop").Rendering; !r.ReadsHealth || r.JudgesRoot || r.ReadsBusy || r.Hands {
t.Errorf("an engine at the readiness contract reads %+v", r)
}
laptop.Contract = HandedContract
if r := placed(t, in, "laptop").Rendering; !r.ReadsHealth || !r.JudgesRoot || !r.ReadsBusy || !r.Hands {
t.Errorf("an engine at the handed contract reads %+v", r)
}
}
// The artifact store as a node reaches it: over the private network by its holder's name and the port it put it on, or on loopback
// for the holder alone while nothing is on the network (novox/hq 04-ISSUES/102).
func TestTheArtifactStoreIsReachedOverTheNetworkOrOnLoopbackForItsHolder(t *testing.T) {
in := aMesh(t)
in.Catalogue["artifacts"] = catalogue.Manifest{Module: "artifacts", Version: "1",
Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}}
anchor := nodeOf(t, &in, "anchor")
anchor.Assigned = append(anchor.Assigned, "artifacts")
if got := placed(t, in, "laptop").Rendering.ArtifactStore; got != "anchor.internal:5100" {
t.Errorf("laptop reaches the store at %q", got)
}
nodeOf(t, &in, "laptop").Assigned = nil
anchor.Assigned = []string{"artifacts"} // off the network: the holder alone reaches it
if got := placed(t, in, "anchor").Rendering.ArtifactStore; got != "127.0.0.1:5100" {
t.Errorf("the holder reaches the store at %q", got)
}
if got := placed(t, in, "laptop").Rendering.ArtifactStore; got != "" {
t.Errorf("a machine off the network reaches the store at %q", got)
}
in.Built = []string{"mesh-sdk"}
if got := placed(t, in, "anchor").Rendering.Built; !got["mesh-sdk"] {
t.Errorf("built is %v", got)
}
}
// A zone a module declares is placed where the mesh put it: settled from that node's settings, at the node's private address, on
// the port the answering listen is published on there (novox/hq ADR 0199); the same zone declared twice is refused, by name.
func TestAZoneIsPlacedWhereTheMeshPutItAndTheSameZoneTwiceIsRefused(t *testing.T) {
in := aMesh(t)
lab := catalogue.Manifest{Module: "lab", Version: "1", Zone: &catalogue.Zone{Name: "${setting:zone}", Listen: "dns"},
Listens: []catalogue.Listening{{Name: "dns", Port: 53, Protocol: "udp", From: catalogue.FromMesh}}}
in.Catalogue["lab"] = lab
anchor := nodeOf(t, &in, "anchor")
anchor.Assigned = append(anchor.Assigned, "lab")
anchor.Settings = map[string][]catalogue.Layer{"lab": {{From: "anchor", Values: map[string]any{"zone": "Lab.Example."}}}}
anchor.Ports = map[string]map[int]int{"lab": {53: 5353}}
zones := placed(t, in, "laptop").Rendering.Zones
if len(zones) != 1 || zones[0] != (catalogue.ZoneAt{Zone: "lab.example", Node: "anchor", Module: "lab", Address: "10.77.0.1", Port: 5353}) {
t.Fatalf("zones are %+v", zones)
}
laptop := nodeOf(t, &in, "laptop")
laptop.Assigned = append(laptop.Assigned, "lab")
laptop.Settings = map[string][]catalogue.Layer{"lab": {{From: "laptop", Values: map[string]any{"zone": "lab.example"}}}}
if _, err := Places(in, "laptop"); err == nil || !strings.Contains(err.Error(), "the mesh's zones cannot be forwarded") {
t.Fatalf("one zone declared twice: %v", err)
}
}
// Every holder of a replicated seat is listed by internal name at its private address (novox/hq ADR 0223): the holders on record,
// and only the sole claimant when there are none; a holder off the private network is left out.
func TestEveryHolderOfAReplicatedSeatIsListedOnTheNetwork(t *testing.T) {
in := aMesh(t)
resolver := catalogue.Manifest{Module: "resolver", Version: "1",
Provides: []catalogue.Offer{{Name: "wildcard-resolution", Scope: catalogue.ScopeMesh}},
Claims: []catalogue.Claim{{Name: "mesh-dns-resolver", Scope: catalogue.ScopeMesh}}}
in.Catalogue["resolver"] = resolver
anchor, laptop := nodeOf(t, &in, "anchor"), nodeOf(t, &in, "laptop")
anchor.Assigned = append(anchor.Assigned, "resolver")
// One claimant and nothing on record: the sole claimant is listed.
if got := placed(t, in, "laptop").Rendering.Holders["mesh-dns-resolver"]; len(got) != 1 || got["anchor.internal"] != "10.77.0.1" {
t.Errorf("the sole claimant: %v", got)
}
// Both on record: both are listed (two claimants with nothing on record would be refused at resolution).
laptop.Assigned = append(laptop.Assigned, "resolver")
in.Holdings = []catalogue.Held{
{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "resolver"},
{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "laptop", Module: "resolver"}}
got := placed(t, in, "laptop").Rendering.Holders["mesh-dns-resolver"]
if len(got) != 2 || got["anchor.internal"] != "10.77.0.1" || got["laptop.internal"] != "10.77.0.2" {
t.Errorf("both holders on record: %v", got)
}
// A holder off the private network is left out: a resolver named at an address nothing answers waits out its timeout.
in.Nodes = append(in.Nodes, Node{Name: "ace", Place: Place{Address: "10.77.0.3"}, Assigned: []string{"resolver"}})
in.Holdings = append(in.Holdings, catalogue.Held{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "ace", Module: "resolver"})
if got := placed(t, in, "laptop").Rendering.Holders["mesh-dns-resolver"]; len(got) != 2 || got["ace.internal"] != "" {
t.Errorf("a holder off the network is listed: %v", got)
}
}
@@ -0,0 +1,10 @@
package resolution
func indexOf(out []map[string]any, id string) int {
for i, r := range out {
if r["id"] == id {
return i
}
}
return -1
}
@@ -0,0 +1,80 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// The fault 04-ISSUES/036 records: a media stack is several modules — a library server, the
// acquisition managers, a download client — that must share directories on one machine. Written
// as owned `directory` resources, two of them on one node are refused as rivalrous owners, which
// is right in general and wrong here: sharing those directories is the whole point of the stack.
//
// Declared as accesses to an operator-owned path (novox/hq ADR 0051), they co-resolve. An access
// is not a resource and never enters the duplicate-owner map, so this is the exact case the
// resolver refuses above when the same path is owned — and does not, when it is merely accessed.
func TestTwoModulesAccessingOnePathCoResolve(t *testing.T) {
a := mod("a", nil, nil, nil)
a.Accesses = []mcat.Access{{Path: "/services/media/downloads", Mode: mcat.AccessReadWrite}}
b := mod("b", nil, nil, nil)
b.Accesses = []mcat.Access{{Path: "/services/media/downloads", Mode: mcat.AccessRead}}
got, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), mcat.World{})
if err != nil {
t.Fatalf("two modules accessing one operator-owned path were refused: %v", err)
}
// And each accessing module's grant reaches the machine as its own `access` resource, so the
// host can find the path present or refuse — both naming the one operator-owned path.
var accesses int
for _, r := range mustDeclare(t, got) {
if r["type"] == "access" && r["path"] == "/services/media/downloads" {
accesses++
}
}
if accesses != 2 {
t.Errorf("expected both modules' accesses in the declaration, got %d", accesses)
}
}
// The mirror of the case above, so the sharing vocabulary does not weaken the rule it sits beside:
// two modules OWNING one path is still refused, exactly as before accesses existed.
func TestTwoModulesOwningOnePathAreStillRefused(t *testing.T) {
a := mod("a", nil, nil, nil)
a.Resources = []map[string]any{
{"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}}
b := mod("b", nil, nil, nil)
b.Resources = []map[string]any{
{"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}}
_, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), mcat.World{})
if err == nil {
t.Fatal("two modules owning the same directory were both assigned")
}
if !strings.Contains(err.Error(), "/services/media/movies") {
t.Errorf("the refusal does not name the path: %v", err)
}
}
// Shared data is the operator's, owned by no module (novox/hq ADR 0051). A module owning the path
// another module was told to expect the operator to provide would create and chown it — and the
// two intentions cannot both hold, so it is refused by name. This is what keeps the distinction
// machine-checkable rather than a convention nobody enforces.
func TestAModuleMayNotOwnWhatAnotherAccesses(t *testing.T) {
owns := mod("owns", nil, nil, nil)
owns.Resources = []map[string]any{
{"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}}
uses := mod("uses", nil, nil, nil)
uses.Accesses = []mcat.Access{{Path: "/services/media/movies", Mode: mcat.AccessRead}}
_, err := Resolve(shelf(owns, uses), []string{"owns", "uses"}, workstation(), mcat.World{})
if err == nil {
t.Fatal("a module owning a path another accesses was allowed")
}
if !strings.Contains(err.Error(), "/services/media/movies") ||
!strings.Contains(err.Error(), "the operator's") {
t.Errorf("the refusal does not explain the ownership conflict: %v", err)
}
}
+177
View File
@@ -0,0 +1,177 @@
package resolution
import (
"fmt"
"sort"
"strconv"
"strings"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// GuardID, GuardUnitID and GuardRunningID are the guard's resource identities. The installer
// raises the same three on an adopted genesis, so the first push finds them already there.
func GuardID() string { return mcat.AdoptionPrefix + "guard" }
func GuardUnitID() string { return mcat.AdoptionPrefix + "guard-unit" }
func GuardRunningID() string { return mcat.AdoptionPrefix + "guard-running" }
// GuardPackageID is the tool that loads the guard, declared first: a node joining adopted has
// no filter module and may have no nft at all, and a table nothing can load guards nothing.
func GuardPackageID() string { return mcat.AdoptionPrefix + "guard-package" }
// OpeningID is an opening's resource identity: its protocol, port and path say what it is.
func OpeningID(protocol string, port int, path string) string {
return fmt.Sprintf("%sopening-%s-%d-%s", mcat.AdoptionPrefix, protocol, port, path)
}
// Openings are what the mesh needs reachable on an adopted node, from the same inputs as the
// filter it would load were the node converged, each from where that filter would admit it.
//
// `rules` is Filtering's answer — every module's listens, the hub's port, the per-node exposure —
// and `foundation` is the ports the mesh itself needs, from everywhere. A rule for this machine
// only opens nothing. `published` maps a machine port a container publishes to the container's
// port: a published port is forwarded, not received, so its opening names the forwarded path and
// the port the packet is forwarded to.
func Openings(rules []mcat.Rule, foundation []int, published map[string]map[int]int) []map[string]any {
type key struct {
protocol string
port int
}
from := map[key]string{}
var order []key
widen := func(k key, f string) {
was, seen := from[k]
if !seen {
order = append(order, k)
}
if !seen || was != mcat.OpeningFromEverywhere {
from[k] = f
}
}
for _, rule := range rules {
switch rule.From {
case mcat.FromEverywhere:
widen(key{rule.Protocol, rule.Port}, mcat.OpeningFromEverywhere)
case mcat.FromMesh:
widen(key{rule.Protocol, rule.Port}, mcat.OpeningFromMesh)
case mcat.FromLocalNetwork:
// The private network's part only (novox/hq ADR 0340). An adopted node's found firewall
// stays, and it is what admits the machine's local network; an opening has no word for a
// local network, and widening it to everywhere would open the port to the internet.
widen(key{rule.Protocol, rule.Port}, mcat.OpeningFromMesh)
}
}
for _, port := range foundation {
widen(key{"tcp", port}, mcat.OpeningFromEverywhere)
}
sort.Slice(order, func(a, b int) bool {
if order[a].port != order[b].port {
return order[a].port < order[b].port
}
return order[a].protocol < order[b].protocol
})
out := make([]map[string]any, 0, len(order))
for _, k := range order {
opening := map[string]any{"type": "opening", "port": k.port, "protocol": k.protocol,
"from": from[k]}
if to, forwarded := published[k.protocol][k.port]; forwarded {
opening["id"] = OpeningID(k.protocol, k.port, mcat.PathForwarded)
opening["path"] = mcat.PathForwarded
opening["to"] = to
} else {
opening["id"] = OpeningID(k.protocol, k.port, mcat.PathIncoming)
opening["path"] = mcat.PathIncoming
}
out = append(out, opening)
}
return out
}
// AsGuard renders the mesh's refusal-only table for the given machine ports.
//
// It passes everything by default and holds nothing but a refusal, so it cannot close anything
// the machine serves; and it is the mesh's own table, so the found firewall reloading does not
// touch it. It refuses only packets addressed to this machine, and the ports except from the
// machine itself — its loopback and the container runtime's own networks — and from the private
// network, known by the interface a packet arrives on and never by its source address. At
// prerouting, ahead of the runtime's destination translation, so it matches the port the packet
// was sent to; in the inet family, so both address families.
//
// **The machine's own interfaces are named, where the derived filter names address ranges.** The
// filter accepts the container runtime's networks by CIDR; this excludes its bridges by name — lo,
// docker0, the br-* a compose network gets, and the mesh's own mesh0. A runtime whose bridge is
// named anything else (a podman or libvirt bridge, or a docker network created with a fixed name)
// would have its containers' traffic to a guarded port refused, which reads as the port being
// down. Names rather than addresses is deliberate: a source address can be claimed by whoever
// sends the packet, and this table exists to refuse what the found firewall never sees. Widening
// it means adding names here and in the installer's copy together, which the golden test holds to
// one text.
//
// The same text the installer raises on an adopted genesis; a test holds both to it.
func AsGuard(ports []int) string {
sorted := append([]int{}, ports...)
sort.Ints(sorted)
listed := make([]string, len(sorted))
for i, p := range sorted {
listed[i] = strconv.Itoa(p)
}
var b strings.Builder
b.WriteString("table inet mesh_guard {}\n")
b.WriteString("delete table inet mesh_guard\n")
b.WriteString("table inet mesh_guard {\n")
b.WriteString("\tchain prerouting {\n")
b.WriteString("\t\ttype filter hook prerouting priority raw; policy accept;\n")
// Only packets addressed to this machine: traffic it routes for others — a predecessor's hub,
// say — is never the guard's business (novox/hq ADR 0103).
fmt.Fprintf(&b, "\t\tfib daddr type local iifname != \"lo\" iifname != \"docker0\" "+
"iifname != \"br-*\" iifname != \"mesh0\" tcp dport { %s } drop\n",
strings.Join(listed, ", "))
b.WriteString("\t}\n")
b.WriteString("}\n")
return b.String()
}
// GuardUnitText is the unit that loads the guard. Stopping it deletes only its own table: never
// a flush, which would take the container runtime's rules and the found firewall with it.
func GuardUnitText() string {
return "[Unit]\n" +
"Description=The mesh's guard: refuses its own ports from outside (novox/hq ADR 0100)\n" +
// Early, before the network is up, and without the default dependencies that would
// order it after the network; stopped at shutdown like any unit.
"DefaultDependencies=no\n" +
"Wants=network-pre.target\n" +
"Before=network-pre.target shutdown.target\n" +
"Conflicts=shutdown.target\n" +
"\n" +
"[Service]\n" +
"Type=oneshot\n" +
"RemainAfterExit=yes\n" +
"ExecStart=nft -f " + mcat.GuardPath + "\n" +
"ExecReload=nft -f " + mcat.GuardPath + "\n" +
"ExecStop=nft delete table inet mesh_guard\n" +
"\n" +
"[Install]\n" +
"WantedBy=multi-user.target\n"
}
// GuardResources are the guard as four resources of the existing kinds: the tool that loads it,
// the table, the unit, and the unit running — reloaded when the table changes, so the new table
// replaces the old in one `nft -f` through the unit's ExecReload with no moment unguarded, and
// restarted only when the unit itself changes. Nothing when there is nothing to guard: an empty
// set is not a table nft loads.
func GuardResources(ports []int) []map[string]any {
if len(ports) == 0 {
return nil
}
return []map[string]any{
{"id": GuardPackageID(), "type": "package", "package": mcat.GuardPackage},
{"id": GuardID(), "type": "file", "path": mcat.GuardPath, "content": AsGuard(ports),
"mode": "0644"},
{"id": GuardUnitID(), "type": "file", "path": mcat.GuardUnitPath, "content": GuardUnitText(),
"mode": "0644"},
{"id": GuardRunningID(), "type": "service", "unit": mcat.GuardUnit, "state": "running",
"boot": "enabled", "restart-on": []any{GuardUnitID()}, "reload-on": []any{GuardID()}},
}
}
@@ -0,0 +1,629 @@
package resolution
import (
"encoding/json"
"reflect"
"slices"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// hub is the private network's generator on the hub: it opens the hub's port from anywhere.
type hub struct{}
func (hub) Resources(string) ([]map[string]any, bool, error) {
return []map[string]any{{"id": "config", "type": "file", "path": "/etc/wireguard/mesh0.conf",
"content": "[Interface]\n"}}, true, nil
}
func (hub) Listens(string) ([]mcat.Listening, error) {
return []mcat.Listening{{Port: 51820, Protocol: "udp", From: mcat.FromEverywhere}}, nil
}
// anAdoptedAnchor is the control-node's set: the store, the bus, the registry, the private network,
// a served module and the filter module.
func anAdoptedAnchor() mcat.Resolution {
return mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{
{Module: "network", Computed: "overlay"},
{Module: "postgres", Guards: []int{5432},
Listens: []mcat.Listening{{Port: 5432, From: mcat.FromMesh}},
Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-store",
"ports": []any{"5432:5432"}}}},
{Module: "lavinmq", Guards: []int{15672},
Listens: []mcat.Listening{{Port: 5671, From: mcat.FromMesh}, {Port: 5672, From: mcat.FromMesh}},
Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-broker",
"ports": []any{"5671:5671", "5672:5672", "127.0.0.1:15672:15672"}}}},
{Module: "distribution",
Listens: []mcat.Listening{{Port: 5000, From: mcat.FromMesh}},
Resources: []map[string]any{{"id": "store", "type": "container", "name": "registry",
"ports": []any{"5000"}}}},
{Module: "hello-web",
Listens: []mcat.Listening{{Port: 8080, From: mcat.FromEverywhere}},
Resources: []map[string]any{{"id": "server", "type": "container", "name": "hello-web",
"ports": []any{"8080"}}}},
{Module: "helper", Listens: []mcat.Listening{{Port: 9000, From: mcat.FromMachine}}},
{Module: "nftables", Filtering: &mcat.Filtering{Into: "/etc/nftables.conf"},
Resources: []map[string]any{{"id": "load", "type": "service", "unit": "mesh-filter.service",
"state": "running", "restart-on": []any{"filtering"}}}},
}}
}
func anchorRendering(adopted bool) mcat.Rendering {
return mcat.Rendering{
Generators: map[string]mcat.Generator{"overlay": hub{}},
Ports: map[string]map[int]int{"distribution": {5000: 5000}, "hello-web": {8080: 20001}},
Settings: mcat.SettingsBy{"distribution": {{From: "node anchor",
Placement: map[string]any{mcat.ExposeSetting: map[string]any{"5000": mcat.FromEverywhere}}}}},
Mesh: []string{"10.42.0.1"},
Foundation: []int{5671},
// What the machine reported faces outside, which every rule in the filter is written
// around (novox/hq ADR 0140).
OutwardLinks: []string{"eth0"},
TunnelInterface: "mesh0",
Adopted: adopted,
// Genesis takes the foundation's modules.
Taken: map[string]bool{"postgres": true, "lavinmq": true},
}
}
func byID(resources []map[string]any) map[string]map[string]any {
out := map[string]map[string]any{}
for _, r := range resources {
out[r["id"].(string)] = r
}
return out
}
func TestAnAdoptedNodeIsDeclaredOpeningsFromTheSameInputsAsTheFilter(t *testing.T) {
composed, err := Compose(anAdoptedAnchor(), anchorRendering(true))
if err != nil {
t.Fatal(err)
}
got := byID(composed.Resources)
want := map[string]map[string]any{
// The hub's port, from anywhere, received.
"adoption.opening-udp-51820-incoming": {"port": 51820, "protocol": "udp",
"from": "everywhere", "path": "incoming"},
// The store's port from the private network only, and forwarded: a container publishes it.
"adoption.opening-tcp-5432-forwarded": {"port": 5432, "protocol": "tcp", "from": "mesh",
"path": "forwarded", "to": 5432},
// The bus from anywhere: a node enrols over it before it has a private address.
"adoption.opening-tcp-5671-forwarded": {"port": 5671, "protocol": "tcp",
"from": "everywhere", "path": "forwarded", "to": 5671},
"adoption.opening-tcp-5672-forwarded": {"port": 5672, "protocol": "tcp", "from": "mesh",
"path": "forwarded", "to": 5672},
// The registry from anywhere, by its node's exposure setting.
"adoption.opening-tcp-5000-forwarded": {"port": 5000, "protocol": "tcp",
"from": "everywhere", "path": "forwarded", "to": 5000},
// A published port names the machine port and the container port it is forwarded to.
"adoption.opening-tcp-20001-forwarded": {"port": 20001, "protocol": "tcp",
"from": "everywhere", "path": "forwarded", "to": 8080},
}
for id, fields := range want {
opening, ok := got[id]
if !ok {
t.Errorf("no %s among %v", id, keys(got))
continue
}
if opening["type"] != "opening" {
t.Errorf("%s is a %v", id, opening["type"])
}
for k, v := range fields {
if opening[k] != v {
t.Errorf("%s: %s is %v, want %v", id, k, opening[k], v)
}
}
}
for id := range got {
if strings.HasPrefix(id, "adoption.opening-") && want[id] == nil {
t.Errorf("an opening nothing asked for: %s", id)
}
}
// A port for this machine only opens nothing, and the management port is not opened at all.
for id := range got {
if strings.Contains(id, "-9000-") || strings.Contains(id, "-15672-") {
t.Errorf("%s is opened", id)
}
}
// And openings come first, in the order the machine applies them.
if !strings.HasPrefix(composed.Resources[0]["id"].(string), "adoption.opening-") {
t.Errorf("openings are not first: %v", composed.Resources[0]["id"])
}
}
func TestAnAdoptedNodeLoadsNoFilterOfTheMeshs(t *testing.T) {
composed, err := Compose(anAdoptedAnchor(), anchorRendering(true))
if err != nil {
t.Fatal(err)
}
for _, r := range composed.Resources {
if r["path"] == "/etc/nftables.conf" || strings.HasPrefix(r["id"].(string), "nftables.") {
t.Fatalf("an adopted node is declared the filter module's %v", r["id"])
}
if content, _ := r["content"].(string); strings.Contains(content, "policy drop") {
t.Fatalf("an adopted node is declared a table that drops by default: %v", r["id"])
}
// Nothing but refusals: the only accept in the guard is its policy.
if content, _ := r["content"].(string); r["id"] == GuardID() &&
strings.Count(content, "accept") != 1 {
t.Fatalf("the guard holds an accept:\n%s", content)
}
}
got := byID(composed.Resources)
guard := got[GuardID()]
if guard == nil || got[GuardUnitID()] == nil || got[GuardRunningID()] == nil {
t.Fatalf("no guard: %v", keys(got))
}
if guard["content"] != AsGuard([]int{5432, 5672, 15672}) {
t.Fatalf("the guard does not guard the store, the broker and its management port:\n%s",
guard["content"])
}
// The tool that loads it comes first, and the table after it: a node joining adopted has no
// filter module and may have no nft.
pkg, table := -1, -1
for i, r := range composed.Resources {
switch r["id"] {
case GuardPackageID():
pkg = i
if r["type"] != "package" || r["package"] != "nftables" {
t.Fatalf("the guard's package is %v", r)
}
case GuardID():
table = i
}
}
if pkg < 0 || pkg > table {
t.Fatalf("nftables is not declared before the guard's table (%d, %d)", pkg, table)
}
// A changed table is reloaded — one `nft -f`, atomic — never restarted, which would delete the
// table and leave the ports unguarded until it is loaded again. Only a changed unit restarts.
if !reflect.DeepEqual(got[GuardRunningID()]["reload-on"], []any{GuardID()}) ||
!reflect.DeepEqual(got[GuardRunningID()]["restart-on"], []any{GuardUnitID()}) {
t.Fatalf("the guard is not reloaded on its table and restarted on its unit: %v",
got[GuardRunningID()])
}
// Nothing of the mesh's own is anybody's to hold.
for id, module := range composed.Owner {
if strings.HasPrefix(id, mcat.AdoptionPrefix) {
t.Fatalf("%s is owned by %s", id, module)
}
}
}
func TestAConvergedNodeIsDeclaredItsFilterAndNoOpenings(t *testing.T) {
composed, err := Compose(anAdoptedAnchor(), anchorRendering(false))
if err != nil {
t.Fatal(err)
}
got := byID(composed.Resources)
if got["nftables.filtering"] == nil || got["nftables.load"] == nil {
t.Fatalf("a converged node lost its filter: %v", keys(got))
}
for id := range got {
if strings.HasPrefix(id, mcat.AdoptionPrefix) {
t.Fatalf("a converged node is declared %s", id)
}
}
plain, err := Declaration(anAdoptedAnchor(), anchorRendering(false))
if err != nil {
t.Fatal(err)
}
a, _ := json.Marshal(plain)
b, _ := json.Marshal(composed.Resources)
if string(a) != string(b) {
t.Fatal("Compose and Declaration disagree on a converged node")
}
}
// The table the installer raises and the controller declares, character for character.
func TestTheGuardIsExactlyThisTable(t *testing.T) {
const golden = `table inet mesh_guard {}
delete table inet mesh_guard
table inet mesh_guard {
chain prerouting {
type filter hook prerouting priority raw; policy accept;
fib daddr type local iifname != "lo" iifname != "docker0" iifname != "br-*" iifname != "mesh0" tcp dport { 5432, 15672 } drop
}
}
`
if got := AsGuard([]int{15672, 5432}); got != golden {
t.Fatalf("the guard changed:\n%s", got)
}
const unit = `[Unit]
Description=The mesh's guard: refuses its own ports from outside (novox/hq ADR 0100)
DefaultDependencies=no
Wants=network-pre.target
Before=network-pre.target shutdown.target
Conflicts=shutdown.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=nft -f /etc/mesh/guard.nft
ExecReload=nft -f /etc/mesh/guard.nft
ExecStop=nft delete table inet mesh_guard
[Install]
WantedBy=multi-user.target
`
if got := GuardUnitText(); got != unit {
t.Fatalf("the guard's unit changed:\n%s", got)
}
if GuardResources(nil) != nil {
t.Fatal("a guard with nothing to guard is an empty set nft refuses to load")
}
}
func keys[V any](m map[string]V) []string {
return sortedKeys(m)
}
// novox/hq ADR 0100: the foundation's ports are the node's. Given 5433 for the store, every place
// that uses the port reads it from there: the container, the filter, the openings, the guard.
func TestAGivenPortIsUsedEverywhereThePortIs(t *testing.T) {
given := map[string]map[int]int{"postgres": {5432: 5433}, "lavinmq": {15672: 15673}}
for _, adopted := range []bool{true, false} {
with := anchorRendering(adopted)
with.Given = given
with.Ports["postgres"] = map[int]int{5432: 5433}
composed, err := Compose(anAdoptedAnchor(), with)
if err != nil {
t.Fatal(err)
}
got := byID(composed.Resources)
if ports := got["postgres.server"]["ports"]; !reflect.DeepEqual(ports, []any{"5433:5432"}) {
t.Fatalf("the store's container publishes %v", ports)
}
if ports := got["lavinmq.server"]["ports"]; !reflect.DeepEqual(ports,
[]any{"5671:5671", "5672:5672", "127.0.0.1:15673:15672"}) {
t.Fatalf("the broker's container publishes %v", ports)
}
if !adopted {
filter, _ := got["nftables.filtering"]["content"].(string)
if !strings.Contains(filter, "tcp dport 5433 accept") || strings.Contains(filter, "5432") {
t.Fatalf("the filter does not use the given port:\n%s", filter)
}
continue
}
if o := got["adoption.opening-tcp-5433-forwarded"]; o == nil || o["to"] != 5432 {
t.Fatalf("no opening for the given port: %v", keys(got))
}
if guard := got[GuardID()]["content"]; guard != AsGuard([]int{5433, 5672, 15673}) {
t.Fatalf("the guard does not guard the given ports:\n%s", guard)
}
}
}
// novox/hq ADR 0103: the guard is derived, and from taken modules only — every machine port a taken
// module publishes that the filter admits from the private network only, and the ports its
// manifest guards. A module assigned but not taken is not guarded: its port may still be the
// predecessor's.
func TestTheGuardIsDerivedFromTakenModulesOnly(t *testing.T) {
guardOf := func(with mcat.Rendering) string {
t.Helper()
composed, err := Compose(anAdoptedAnchor(), with)
if err != nil {
t.Fatal(err)
}
content, _ := byID(composed.Resources)[GuardID()]["content"].(string)
return content
}
// The broker taken, the store not: the broker's plain port follows from its listens (from
// the mesh, published), its management port from its manifest; the store is not guarded, and
// neither is the bus, which the mesh needs from everywhere.
with := anchorRendering(true)
with.Taken = map[string]bool{"lavinmq": true}
if got := guardOf(with); got != AsGuard([]int{5672, 15672}) {
t.Fatalf("the guard is not the taken broker's ports:\n%s", got)
}
// A taken module publishing a port admitted from everywhere is not guarded; one admitted from
// the mesh is. The registry is exposed everywhere on this node, and hello-web listens from
// everywhere.
with.Taken = map[string]bool{"distribution": true, "hello-web": true}
if got := guardOf(with); got != "" {
t.Fatalf("a port admitted from everywhere is guarded:\n%s", got)
}
with.Settings = nil
if got := guardOf(with); got != AsGuard([]int{5000}) {
t.Fatalf("the registry, from the mesh only, is not guarded:\n%s", got)
}
// Nothing taken, nothing guarded — and no guard at all rather than an empty set.
with = anchorRendering(true)
with.Taken = nil
if got := guardOf(with); got != "" {
t.Fatalf("an untaken store is guarded:\n%s", got)
}
// A given port is followed: where the machine put it is what is refused.
with = anchorRendering(true)
with.Given = map[string]map[int]int{"lavinmq": {5672: 5682, 15672: 15673}}
with.Ports["lavinmq"] = map[int]int{5671: 5671, 5672: 5682}
if got := guardOf(with); got != AsGuard([]int{5432, 5682, 15673}) {
t.Fatalf("the guard does not follow the given ports:\n%s", got)
}
}
// novox/hq ADR 0038: only a published port is the mesh's to move. A module that binds the machine
// itself listens where its software was told to, so giving it a machine port is refused.
func TestAGivenPortIsRefusedForAPortNoContainerPublishes(t *testing.T) {
onTheMachine := mcat.Manifest{Module: "daemon",
Listens: []mcat.Listening{{Port: 9000, From: mcat.FromMesh}}, Guards: []int{9000}}
layers := []mcat.Layer{{From: "node anchor",
Placement: map[string]any{mcat.PortsSetting: map[string]any{"9000": float64(9100)}}}}
_, err := GivenPorts(onTheMachine, layers)
if err == nil || !strings.Contains(err.Error(), "does not publish") {
t.Fatalf("a port no container publishes was given: %v", err)
}
}
// novox/hq ADR 0103: a guarded port this node is told to open to everyone is opened, not guarded.
// An opening from everywhere beside a guard dropping it is one statement refusing the other.
func TestAGuardedPortOpenedToEveryoneIsNotGuarded(t *testing.T) {
with := anchorRendering(true)
with.Settings["postgres"] = []mcat.Layer{{From: "node anchor",
Placement: map[string]any{mcat.ExposeSetting: map[string]any{"5432": mcat.FromEverywhere}}}}
composed, err := Compose(anAdoptedAnchor(), with)
if err != nil {
t.Fatal(err)
}
got := byID(composed.Resources)
if o := got["adoption.opening-tcp-5432-forwarded"]; o == nil || o["from"] != mcat.OpeningFromEverywhere {
t.Fatalf("the store's port is not opened to everyone: %v", o)
}
if guard, _ := got[GuardID()]["content"].(string); guard != AsGuard([]int{5672, 15672}) {
t.Fatalf("a port opened to everyone is still guarded:\n%s", guard)
}
}
// A module that publishes its ssh port the long way — `2222:22`, because the machine's own daemon
// holds 22 — and says it listens on the machine side of that mapping, which is what anything
// reaching it dials.
func aForge() mcat.Manifest {
return mcat.Manifest{Module: "forge",
Provides: []mcat.Offer{{Name: "git-over-ssh", Scope: mcat.ScopeMesh}},
Serves: map[string]map[string]any{"git-over-ssh": {"port": 2222}},
Listens: []mcat.Listening{{Port: 3000, From: mcat.FromMesh}, {Port: 2222, From: mcat.FromMesh}},
Resources: []map[string]any{{"id": "server", "type": "container", "name": "forge",
"ports": []any{"3000", "2222:22"}}}}
}
// portsAsThePlanWould is where this machine puts each of a module's ports, derived the way
// cmd/mesh-controller/plan.go derives it: a given port first, looked up by the port the module
// says it listens on, and otherwise wherever the manifest's own mapping already put it. Written
// here because everything below — the filter, the openings, the guard, what a consumer is told —
// reads that map, and a given port that the lookup does not find moves the container's mapping
// and nothing else.
//
// It stands in for the plan only where the plan does not allocate: a port the manifest already
// placed, or one a node was given. For a short form with no given port the real plan asks the
// inventory for a machine port and may come back with one from the pool, which needs a store and
// is what cmd/mesh-controller's own tests exercise. So an assertion here about such a port asserts
// this helper, not the mesh; keep the assertions to the ports under test.
func portsAsThePlanWould(m mcat.Manifest, given map[int]int) map[int]int {
out := map[int]int{}
for _, l := range m.Listens {
if at, is := given[l.Port]; is {
out[l.Port] = at
continue
}
at, _ := m.MachineSide(l.Port)
out[l.Port] = at
}
return out
}
// novox/hq ADR 0100 and 0038: the machine side of a long-form mapping is a port the module
// publishes, so a node may move it — and a module may name a mapping by either end.
func TestAGivenPortNamesEitherEndOfWhatTheModulePublishes(t *testing.T) {
forge := aForge()
node := func(v any) []mcat.Layer {
return []mcat.Layer{{From: "node anchor", Placement: map[string]any{mcat.PortsSetting: v}}}
}
// The machine side — the number this module says it listens on, and the one the predecessor
// had somewhere else. Answered under 2222, the end the module itself names, which is what
// every reader of this map asks for. One entry, not two: a second key for the same answer is
// an entry another mapping's reader could find instead.
given, err := GivenPorts(forge, node(map[string]any{"2222": float64(222)}))
if err != nil {
t.Fatalf("the machine side of a mapping cannot be given a port: %v", err)
}
if want := map[int]int{2222: 222}; !reflect.DeepEqual(given, want) {
t.Fatalf("the forge was given %v; the mapping it names is filed under %v", given, want)
}
// The container's own port names the same mapping and means the same thing — and is filed
// under the same name, because the module's name for it has not changed.
if inside, err := GivenPorts(forge, node(map[string]any{"22": float64(222)})); err != nil ||
!reflect.DeepEqual(inside, map[int]int{2222: 222}) {
t.Fatalf("the container's end of the mapping was given %v: %v", inside, err)
}
// A short form is published on the number it names, and is unchanged by any of this.
if short, err := GivenPorts(forge, node(map[string]any{"3000": float64(2999)})); err != nil ||
short[3000] != 2999 {
t.Fatalf("the short form was given %v: %v", short, err)
}
// A port no container publishes is still refused, in the same words.
if _, err := GivenPorts(forge, node(map[string]any{"9000": float64(9100)})); err == nil ||
!strings.Contains(err.Error(), "does not publish") {
t.Fatalf("a port the forge does not publish was given: %v", err)
}
// And the two ends of one mapping given two different numbers is one setting contradicting
// the other: the machine publishes it once.
if _, err := GivenPorts(forge, node(map[string]any{
"2222": float64(222), "22": float64(300)})); err == nil ||
!strings.Contains(err.Error(), "one mapping") {
t.Fatalf("the two ends of one mapping were given different ports: %v", err)
}
// Said at both ends with the same number, it is still said twice, and refused where every
// other repeated machine port is — as the inventory refuses it when the setting is stored,
// which is the layer that sees it first.
if _, err := GivenPorts(forge, node(map[string]any{
"2222": float64(222), "22": float64(222)})); err == nil ||
!strings.Contains(err.Error(), "to both its 22 and its 2222") {
t.Fatalf("one mapping given one machine port at both ends: %v", err)
}
// A number that names two different mappings names neither: which one to move is not said.
twice := aForge()
twice.Resources[0]["ports"] = []any{"22", "2222:22"}
if _, err := GivenPorts(twice, node(map[string]any{"22": float64(222)})); err == nil ||
!strings.Contains(err.Error(), "twice") {
t.Fatalf("a number naming two of the module's mappings was accepted: %v", err)
}
// And the same refusal when the number naming two mappings is not the one the setting used
// but the one the answer would be filed under. Here `80` is the module's own name for a
// mapping, and two mappings wear it; filing an answer there is one container's port standing
// where the other's is read, and both containers then publish it.
shared := mcat.Manifest{Module: "gallery",
Listens: []mcat.Listening{{Port: 80, From: mcat.FromMesh}},
Resources: []map[string]any{
{"id": "a", "type": "container", "name": "a", "ports": []any{"4001:80"}},
{"id": "b", "type": "container", "name": "b", "ports": []any{"4002:80"}}}}
if _, err := GivenPorts(shared, node(map[string]any{"4001": float64(1234)})); err == nil ||
!strings.Contains(err.Error(), "twice") {
t.Fatalf("two containers were put on one machine port: %v", err)
}
// A module whose mappings chain — one's machine side is another's container port — keeps them
// apart, because each is filed under the port the module names it by and neither name is
// shared. Given both, each moves on its own and neither overwrites the other.
chained := mcat.Manifest{Module: "chain",
Listens: []mcat.Listening{{Port: 80, From: mcat.FromMesh}, {Port: 9090, From: mcat.FromMesh}},
Resources: []map[string]any{{"id": "server", "type": "container", "name": "chain",
"ports": []any{"8080:80", "9090:8080"}}}}
both, err := GivenPorts(chained, node(map[string]any{"80": float64(1234), "9090": float64(5678)}))
if err != nil || !reflect.DeepEqual(both, map[int]int{80: 1234, 9090: 5678}) {
t.Fatalf("chained mappings were given %v: %v", both, err)
}
if moved := givenOuter("8080:80", both); moved != "1234:80" {
t.Fatalf("the first mapping moved to %q, and it was given 1234", moved)
}
if moved := givenOuter("9090:8080", both); moved != "5678:8080" {
t.Fatalf("the second mapping moved to %q, and it was given 5678", moved)
}
}
// And the number reaches everything derived from it. The fault this is written against moved the
// container's mapping alone: the filter opened the port the software had left, the adopted node's
// opening named it too, the guard refused it, and a consumer was sent to it.
func TestAGivenMachineSideReachesTheFilterTheOpeningAndTheConsumer(t *testing.T) {
forge := aForge()
given, err := GivenPorts(forge, []mcat.Layer{{From: "node anchor",
Placement: map[string]any{mcat.PortsSetting: map[string]any{"2222": float64(222)}}}})
if err != nil {
t.Fatalf("the machine side of a mapping cannot be given a port: %v", err)
}
r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{forge}}
with := mcat.Rendering{
Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, given)},
Given: map[string]map[int]int{"forge": given},
Mesh: []string{"10.77.0.1"},
Adopted: true,
OutwardLinks: []string{"eth0"},
TunnelInterface: "mesh0",
Taken: map[string]bool{"forge": true},
}
// What the runtime is handed: the machine's own port on the outside, the container's within.
composed, err := Compose(r, with)
if err != nil {
t.Fatalf("the forge does not compose: %v", err)
}
got := byID(composed.Resources)
if ports := got["forge.server"]["ports"]; !reflect.DeepEqual(ports, []any{"3000:3000", "222:22"}) {
t.Fatalf("the forge's container publishes %v", ports)
}
// What the filter would open, were the node converged.
rules, err := Rules(r, with)
if err != nil {
t.Fatal(err)
}
var opened []int
for _, rule := range rules {
opened = append(opened, rule.Port)
}
// Only the moved port is asserted: the forge's other port is a short form the real plan would
// allocate rather than read off the manifest, so its number here is portsAsThePlanWould's and
// not the mesh's.
if !slices.Contains(opened, 222) || slices.Contains(opened, 2222) {
t.Fatalf("the filter opens %v, not where the machine puts the forge", opened)
}
// And what it is declared instead, adopted: an opening on the machine's port, naming the
// container's port on the forwarded path — a published port is forwarded, never received.
opening := got[OpeningID("tcp", 222, mcat.PathForwarded)]
if opening == nil || opening["to"] != 22 || opening["from"] != mcat.OpeningFromMesh {
t.Fatalf("no opening for the port this node gave the forge: %v", keys(got))
}
for id := range got {
if strings.HasPrefix(id, "adoption.opening-tcp-2222-") {
t.Errorf("an opening for the port the forge was moved off: %s", id)
}
}
// The guard refuses it where the machine put it, and nothing where it used to be.
guard, _ := got[GuardID()]["content"].(string)
if !strings.Contains(guard, "222") || strings.Contains(guard, "2222") {
t.Fatalf("the guard does not follow the given port:\n%s", guard)
}
// And a consumer is sent to the same number, which is read from what the module serves.
if told := ServedOn(forge, "git-over-ssh", with.Ports["forge"])["port"]; told != 222 {
t.Fatalf("a consumer is told the forge answers on %v", told)
}
}
// And the mapping itself moves under either name, because Rendering.Given is a map anybody
// composing a declaration hands in: keyed by the machine side, which is what a module declaring
// 2222 calls its port, only the outside moves and the container's own port stays as written.
func TestAMappingIsMovedUnderEitherOfItsNames(t *testing.T) {
for _, c := range []struct {
written string
given map[int]int
want string
}{
{"2222:22", map[int]int{2222: 222}, "222:22"},
{"2222:22", map[int]int{22: 222}, "222:22"},
{"127.0.0.1:15672:15672/tcp", map[int]int{15672: 15673}, "127.0.0.1:15673:15672/tcp"},
{"2222:22", map[int]int{3000: 2999}, "2222:22"},
{"2222:22", nil, "2222:22"},
} {
if got := givenOuter(c.written, c.given); got != c.want {
t.Errorf("%s given %v is published as %s, want %s", c.written, c.given, got, c.want)
}
}
}
// The same on a node that was given nothing, which is where the derivation was never at fault:
// a long-form mapping is published where the manifest put it, and an adopted node opens that port
// on the forwarded path like any other. What broke it was the number reaching only the mapping.
func TestALongFormPortIsOpenedWhereTheManifestPublishesIt(t *testing.T) {
forge := aForge()
r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{forge}}
composed, err := Compose(r, mcat.Rendering{
Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, nil)},
Mesh: []string{"10.77.0.1"},
Adopted: true,
OutwardLinks: []string{"eth0"},
TunnelInterface: "mesh0",
})
if err != nil {
t.Fatal(err)
}
got := byID(composed.Resources)
opening := got[OpeningID("tcp", 2222, mcat.PathForwarded)]
if opening == nil || opening["to"] != 22 {
t.Fatalf("no opening for the forge's published ssh port: %v", keys(got))
}
}
@@ -0,0 +1,83 @@
package resolution
import (
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
func TestTheAgentAccountFactFallsBackToTheOperatorAndIsNeverRootOnlyWhenItsOwn(t *testing.T) {
facts := machineFacts(mcat.Resolution{Node: "anchor", Account: "ops"}, nil, "")
if facts["agent-account"] != "ops" || facts["agent-home"] != "/home/ops" || facts["agent-root"] != "" {
t.Errorf("with no agent account named, agents run as the operator: %v", facts)
}
facts = machineFacts(mcat.Resolution{Node: "anchor", Account: "ops", AccountHome: "/srv/ops"}, nil, "")
if facts["agent-home"] != "/srv/ops" {
t.Errorf("the operator's stated home is the agent's home when they are one account: %v", facts)
}
facts = machineFacts(mcat.Resolution{Node: "anchor", Account: "ops", AgentAccount: "agent"}, nil, "")
if facts["agent-account"] != "agent" || facts["agent-home"] != "/home/agent" || facts["agent-root"] != mcat.RootNever {
t.Errorf("a named agent account is the agents', never root: %v", facts)
}
if facts["account"] != "ops" {
t.Errorf("the operator account is still the operator's: %v", facts)
}
facts = machineFacts(mcat.Resolution{Node: "anchor", AgentAccount: "agent", AgentAccountHome: "/var/lib/agent"}, nil, "")
if facts["agent-home"] != "/var/lib/agent" || facts["agent-root"] != mcat.RootNever {
t.Errorf("an agent account with a stated home on a machine with no operator: %v", facts)
}
if _, has := machineFacts(mcat.Resolution{Node: "anchor"}, nil, "")["agent-account"]; has {
t.Error("a machine with no account at all names an agent account")
}
}
// The agent's module, in the shape the catalogue's declares it: the account, never root where it is its
// own; its directory under that home, owned by it.
const agentModule = `{"module": "agent", "version": "1", "resources": [
{"id": "account", "type": "user", "name": "${machine:agent-account}", "home": "${machine:agent-home}",
"root": "${machine:agent-root}"},
{"id": "home", "type": "directory", "path": "${machine:agent-home}/.agent", "mode": "0700",
"owner": "${machine:agent-account}"}
]}`
func TestTheAgentAccountIsDeclaredNeverRootOnlyToAnEngineThatJudgesIt(t *testing.T) {
m, err := decodeForTest([]byte(agentModule))
if err != nil {
t.Fatal(err)
}
compose := func(r mcat.Resolution, with mcat.Rendering) (user, home map[string]any) {
t.Helper()
r.Node, r.Modules = "anchor", []mcat.Manifest{m}
out, err := Declaration(r, with)
if err != nil {
t.Fatal(err)
}
return fileNamed(out, "agent.account"), fileNamed(out, "agent.home")
}
user, home := compose(mcat.Resolution{Account: "ops", AgentAccount: "agent"}, mcat.Rendering{JudgesRoot: true})
if user["name"] != "agent" || user[mcat.RootField] != mcat.RootNever || user["home"] != "/home/agent" {
t.Errorf("an engine that judges root is sent the agent account never to become root: %v", user)
}
if home["path"] != "/home/agent/.agent" || home["owner"] != "agent" {
t.Errorf("the agent's directory is under its own home, its own: %v", home)
}
user, _ = compose(mcat.Resolution{Account: "ops", AgentAccount: "agent", AgentAccountHome: "/srv/agent"}, mcat.Rendering{JudgesRoot: true})
if user["home"] != "/srv/agent" {
t.Errorf("an agent account named with a home of its own is made there: %v", user)
}
user, _ = compose(mcat.Resolution{Account: "ops", AgentAccount: "agent"}, mcat.Rendering{})
if _, sent := user[mcat.RootField]; sent || user["name"] != "agent" {
t.Errorf("an older engine, which parses strictly, is sent root: %v", user)
}
user, home = compose(mcat.Resolution{Account: "ops"}, mcat.Rendering{JudgesRoot: true})
if _, sent := user[mcat.RootField]; sent || user["name"] != "ops" {
t.Errorf("where agents run as the operator, root asserts nothing and is not sent: %v", user)
}
if home["path"] != "/home/ops/.agent" || home["owner"] != "ops" {
t.Errorf("with no agent account, the agent's directory is the operator's: %v", home)
}
}
@@ -0,0 +1,40 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// The artifact store's seat is one per mesh, read from the catalogue beside this checkout.
//
// **A second store anywhere is refused by name, not discovered as a consumer failure.** The seat
// was node-scoped, so a second `distribution` on another machine resolved cleanly there — and a
// node-scoped requirement with one candidate installs that candidate on the node, so anything that
// required the store's presence beside it would have raised a fresh, empty store on the wrong
// machine. Only afterwards did the mesh notice: `artifact-store` offered by two nodes, and every
// consumer elsewhere refusing to choose. The claim says it first, where the second store is
// assigned.
func TestASecondArtifactStoreAnywhereIsRefusedByName(t *testing.T) {
store := catalogueManifest(t, "distribution")
// The first store resolves as it always has.
if _, err := Resolve(shelf(store), []string{"distribution"}, workstation(), mcat.World{}); err != nil {
t.Fatalf("the store alone does not resolve: %v", err)
}
// A second one, on any other machine, is refused — and the refusal names the seat.
elsewhere := mcat.World{Held: []mcat.Held{{Claim: "mesh-artifact-store", Scope: mcat.ScopeMesh,
Node: "anchor", Module: "distribution"}}}
other := workstation()
other.Name = "laptop"
_, err := Resolve(shelf(store), []string{"distribution"}, other, elsewhere)
if err == nil {
t.Fatal("a second store was accepted on another machine; it would offer artifact-store a " +
"second time and every consumer elsewhere would refuse to choose")
}
if !strings.Contains(err.Error(), "mesh-artifact-store") || !strings.Contains(err.Error(), "one per mesh") {
t.Fatalf("refused without naming the seat: %v", err)
}
}
@@ -0,0 +1,43 @@
package resolution
import (
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// artifactSweepVerbs are the seat's verbs, with the controller's arguments so a caller changes only the seat it asks.
func artifactSweepVerbs() []mcat.Verb {
return []mcat.Verb{
{Name: "artifacts", Description: "Every artifact the mesh made that lives in its artifact store, each with its " +
"repository, digest, kind (image or archive) and state: kept, and why (a want names it, or one of the five " +
"newest builds of its module in the builder's log), eligible (made, kept for no reason, not yet let go), or " +
"unrecorded (in the store and named by nothing the mesh made: never removed). Reads only.",
Input: schema(map[string]string{
"repository": "only this repository's references, as <module>/<artifact> (the counts stay whole)",
"collected": "\"true\": list the collected references too (counted always)",
}, nil, "collected")},
{Name: "held", Description: "Whether the artifact store holds each reference given, asked with one HEAD each: " +
"held, gone (the store answered 404) or unknown with why. The builder asks it before it reuses a done key. " +
"Reads only.",
Input: listed(schema(map[string]string{
"references": "the artifacts' references, artifact-store://<module>/<artifact>/blobs/sha256:<hex>: a list, " +
"or one text separated by commas",
}, []string{"references"}), "references")},
{Name: "collect", Description: "The artifact store's sweep, now: what is eligible is let go of. Without confirm a " +
"dry run: what would be let go, nothing held or deleted. With confirm and why: every kept archive held first, " +
"then each eligible artifact let go of, oldest first, an index with the platform manifests no kept index " +
"names, each deletion recorded in the hand-act log. Bounded by most (500 by default, at most 5000) and 45 " +
"seconds; what is left is said. Never touches a digest the mesh did not make.",
Input: schema(map[string]string{
"confirm": "\"true\": let go of what is eligible (needs why); without it nothing is done",
"why": "with confirm: why, required and recorded",
"most": "let go of at most this many (500 by default, at most 5000)",
}, nil, "confirm")},
{Name: "mirrors", Description: "The bases the mesh copied into its artifact store, each kept (a kept build stood on " +
"it), eligible (only builds no longer kept stood on it) or unrecorded (no build the builder's log holds stood on " +
"it: never removed), by what the builder's log says each build stood on. Records nothing by hand.",
Input: schema(map[string]string{}, nil)},
{Name: "images", Description: "Every container image one machine's want and have name, each with the resources " +
"naming it: what the machine keeps when it prunes its images. Reads only.",
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})},
}
}
+65
View File
@@ -0,0 +1,65 @@
package resolution
import (
"fmt"
"strings"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// artifactsInto composes the artifact store's address into a resource's `image` and `source`.
//
// **Composed here, at the last moment before a machine, and stored nowhere.** A kept reference is
// routed through the store as this network reaches it now. A reference recorded with an address
// before references were kept without one is re-routed the same way — but only when the mesh
// built it (`with.Built` names every `<module>/<artifact>` it has), because a module may run an
// image from a public registry under its own name and that one is exactly where it says.
//
// A kept reference with no store to route it through is refused: sent as it is, the runtime would
// refuse the scheme on the machine, one push away from the reason.
//
// **What this does not reach: the images genesis pinned.** The installer builds the control plane
// and the builder before the mesh exists, pushes them itself and pins their manifests to
// `<registry>:<port>/mesh-controller@…` and `<registry>:<port>/mesh-builder@…` — single-segment
// repositories with no build record, so `with.Built` does not name them and they are left as
// written until each is rebuilt through the mesh, which records it by digest and path. Until then
// a registry that moves strands exactly those two on a recreate, and the control plane's is the
// one that cannot be repaired through the mesh. Rebuild both through `build` before moving the
// store (novox/hq 04-ISSUES/102, finding F4).
func artifactsInto(resource map[string]any, module string, with mcat.Rendering) error {
for _, key := range []string{"image", "source"} {
written, ok := resource[key].(string)
if !ok {
continue
}
if _, kept := mcat.InArtifactStore(written); kept {
if with.ArtifactStore == "" {
return fmt.Errorf(
"%s's %v names %s, which is in the mesh's artifact store, and this mesh has no "+
"artifact store on its network to fetch it from — nothing assigned offers "+
"%s, or the machine offering it is not on the private network",
module, resource["id"], written, mcat.ArtifactStoreProvision)
}
resource[key] = mcat.Routed(written, with.ArtifactStore)
continue
}
if with.ArtifactStore == "" {
continue
}
recorded := mcat.Recorded(written)
if recorded == written {
continue
}
path, _ := mcat.InArtifactStore(recorded)
repository := path
if at := strings.IndexAny(path, "@"); at >= 0 {
repository = path[:at]
} else if blobs := strings.Index(path, "/blobs/"); blobs >= 0 {
repository = path[:blobs]
}
if with.Built[repository] {
resource[key] = mcat.Routed(recorded, with.ArtifactStore)
}
}
return nil
}
@@ -0,0 +1,102 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
var digest = "sha256:" + strings.Repeat("d", 64)
// **The address is composed into a declaration, and the record never carries it.**
func TestAnImageTheMeshBuiltIsRoutedThroughTheStoreWhenDeclared(t *testing.T) {
m := mcat.Manifest{Module: "gitea", Version: "1", Resources: []map[string]any{
{"id": "server", "type": "container", "name": "mesh-gitea", "artifact": "server"},
{"id": "config", "type": "archive", "path": "/etc/gitea", "artifact": "config"},
{"id": "cache", "type": "container", "name": "mesh-gitea-cache", "image": "valkey/valkey@" + digest},
}, Build: &mcat.Build{Artifacts: []mcat.Artifact{
{Name: "server", Kind: mcat.ArtifactImage, From: "Dockerfile"},
{Name: "config", Kind: mcat.ArtifactArchive, From: "config"},
}}}
resolved, err := m.Resolve([]mcat.Built{
{Name: "server", Kind: mcat.ArtifactImage, Reference: mcat.ArtifactStoreScheme + "gitea/server@" + digest},
{Name: "config", Kind: mcat.ArtifactArchive, Reference: mcat.ArtifactStoreScheme + "gitea/config/blobs/" + digest, Digest: digest},
})
if err != nil {
t.Fatal(err)
}
r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{resolved}}
out, err := Declaration(r, mcat.Rendering{ArtifactStore: "anchor.internal:5101"})
if err != nil {
t.Fatal(err)
}
if got := fileNamed(out, "gitea.server")["image"]; got != "anchor.internal:5101/gitea/server@"+digest {
t.Errorf("the image the mesh built is fetched as %v", got)
}
if got := fileNamed(out, "gitea.config")["source"]; got != "http://anchor.internal:5101/v2/gitea/config/blobs/"+digest {
t.Errorf("the archive the mesh built is fetched from %v", got)
}
if got := fileNamed(out, "gitea.cache")["image"]; got != "valkey/valkey@"+digest {
t.Errorf("an image from a public registry was routed through the store: %v", got)
}
// The manifest the mesh holds still says what it is, not where it was fetched from.
if got := resolved.Resources[0]["image"]; got != mcat.ArtifactStoreScheme+"gitea/server@"+digest {
t.Errorf("composing wrote the address into the catalogue's copy: %v", got)
}
// And the store moves: the same record, another address, without a rebuild.
out, err = Declaration(r, mcat.Rendering{ArtifactStore: "laptop.internal:5000"})
if err != nil {
t.Fatal(err)
}
if got := fileNamed(out, "gitea.server")["image"]; got != "laptop.internal:5000/gitea/server@"+digest {
t.Errorf("after the store moved, the image is still fetched as %v", got)
}
}
func TestAnImageInTheStoreWithNoStoreToFetchItFromIsRefused(t *testing.T) {
m := mcat.Manifest{Module: "gitea", Version: "1", Resources: []map[string]any{
{"id": "server", "type": "container", "name": "mesh-gitea",
"image": mcat.ArtifactStoreScheme + "gitea/server@" + digest},
}}
_, err := Declaration(mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{m}}, mcat.Rendering{})
if err == nil || !strings.Contains(err.Error(), "no artifact store") {
t.Fatalf("a reference nothing can fetch was sent to a machine: %v", err)
}
}
// **A reference recorded with an address before this follows the store too** — when the mesh
// built it. A module running an image straight from a public registry under its own name is left
// exactly where it says: `quay.io/keycloak/keycloak` is not the mesh's, whatever it is called.
func TestAReferenceRecordedWithAnAddressFollowsTheStoreWhenTheMeshBuiltIt(t *testing.T) {
m := mcat.Manifest{Module: "keycloak", Version: "1", Resources: []map[string]any{
{"id": "server", "type": "container", "name": "mesh-keycloak",
"image": "anchor.internal:5100/keycloak/server@" + digest},
{"id": "upstream", "type": "container", "name": "mesh-keycloak-upstream",
"image": "quay.io/keycloak/keycloak@" + digest},
}}
r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{m}}
out, err := Declaration(r, mcat.Rendering{
ArtifactStore: "anchor.internal:5101",
Built: map[string]bool{"keycloak/server": true},
})
if err != nil {
t.Fatal(err)
}
if got := fileNamed(out, "keycloak.server")["image"]; got != "anchor.internal:5101/keycloak/server@"+digest {
t.Errorf("an image the mesh built, recorded with the old address, is fetched as %v", got)
}
if got := fileNamed(out, "keycloak.upstream")["image"]; got != "quay.io/keycloak/keycloak@"+digest {
t.Errorf("a public image was re-routed through the store: %v", got)
}
// Nothing known to be built: nothing re-routed, nothing refused.
out, err = Declaration(r, mcat.Rendering{ArtifactStore: "anchor.internal:5101"})
if err != nil {
t.Fatal(err)
}
if got := fileNamed(out, "keycloak.server")["image"]; got != "anchor.internal:5100/keycloak/server@"+digest {
t.Errorf("with no build record, a reference was rewritten: %v", got)
}
}
@@ -0,0 +1,46 @@
package resolution
import (
"fmt"
"regexp"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// ofBackup is where a holder places them: ${backup:<key>}. Loose inside the braces, so a misspelt key is found and
// refused rather than written out as text.
var ofBackup = regexp.MustCompile(`\$\{backup:([^}]*)\}`)
// backupKinds is the contribution kind each key of the backup placement renders.
var backupKinds = map[string]string{"lines": mcat.BackupKindBackup, "data": mcat.BackupKindData}
// backupPlacer is the machine's modules the backup placement is composed over, as the old contributions are.
type backupPlacer struct {
modules []mcat.Manifest
with mcat.Rendering
facts map[string]string
caps map[string]bool
unplaced *[]string
}
// placed is the text of one key for a holder, or what is wrong with naming it.
func (b backupPlacer) placed(holder mcat.Manifest, key string, item bool) (string, string) {
kind, known := backupKinds[key]
switch {
case item:
return "", "an item is kept per item; the machine's backup lines are placed only in a file"
case !known:
return "", fmt.Sprintf("the backup placement has `lines` and `data`, not %q", key)
case !holder.ClaimsSeat(mcat.BackupSeat):
return "", fmt.Sprintf("%s does not claim %s; the machine's backup lines are placed by its holder alone "+
"(novox/hq ADR 0212)", holder.Module, mcat.BackupSeat)
}
text, left, err := seatContributions(b.modules, holder, mcat.BackupSeat+":"+kind, b.with, b.facts, b.caps)
if b.unplaced != nil {
*b.unplaced = append(*b.unplaced, left...)
}
if err != nil {
return "", err.Error()
}
return text, ""
}
@@ -0,0 +1,114 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
const oldBackupText = "# lines\n${contribution:node-backup:backup}"
const oldDataText = "# data\n${contribution:node-backup:data}"
const placedBackupTxt = "# lines\n${backup:lines}"
const placedDataTxt = "# data\n${backup:data}"
const backupHolder = `{"module":"restic","version":"1","moved":true,"claims":[{"name":"node-backup","scope":"node"}],
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
"resources":[{"id":"declared","type":"file","path":"/etc/backups.conf","template":"files/backups"},
{"id":"data-declared","type":"file","path":"/etc/data.conf","template":"files/data"}]}`
func dataModule(name string) string {
return `{"module":"` + name + `","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"}],
"data":{"own":[{"id":"d","path":"${dir:d}","class":"irreplaceable","why":"the ` + name + ` data"}]}}`
}
// backupsOn is a machine of a holder whose files say these texts and the modules that declare data.
func backupsOn(t *testing.T, lines, data string, modules ...string) mcat.Resolution {
t.Helper()
all := []mcat.Manifest{held(t, backupHolder, builtWith("restic", map[string]string{"backups": lines, "data": data}))}
names := []string{"restic"}
for _, name := range modules {
all = append(all, mustParse(t, dataModule(name)))
names = append(names, name)
}
r, err := Resolve(shelf(all...), names, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
r.Account = "op"
return r
}
var backupModules = []string{"alpha", "beta", "gamma"}
// gammaLeftOut is a machine whose gamma does not place: its lines are kept, best effort.
func gammaLeftOut(with mcat.Rendering) mcat.Rendering {
with.NotPlaced = map[string]string{"gamma": "its placement setting does not read"}
return with
}
func TestTheBackupPlacementIsTheTextTheOldPlaceholdersRender(t *testing.T) {
// The old form is an unmoved holder: its placeholders are the controller's alone.
oldHolder := strings.Replace(backupHolder, `"moved":true,`, "", 1)
all := []mcat.Manifest{held(t, oldHolder, builtWith("restic", map[string]string{"backups": oldBackupText, "data": oldDataText}))}
for _, name := range backupModules {
all = append(all, mustParse(t, dataModule(name)))
}
old, err := Resolve(shelf(all...), append([]string{"restic"}, backupModules...), workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
old.Account = "op"
wantLines, wantData := fileOf(t, old, gammaLeftOut(mcat.Rendering{}), "restic.declared"), fileOf(t, old, gammaLeftOut(mcat.Rendering{}), "restic.data-declared")
if !strings.Contains(wantLines, "# alpha\npath ") || !strings.Contains(wantLines, "# gamma\n") || !strings.Contains(wantData, "item d") {
t.Fatalf("the old form renders\n%s\n%s", wantLines, wantData)
}
moved := backupsOn(t, placedBackupTxt, placedDataTxt, backupModules...)
// Not handed: the controller fills the new placeholders with the old text.
if got := fileOf(t, moved, gammaLeftOut(mcat.Rendering{}), "restic.declared"); got != wantLines {
t.Errorf("not handed, the lines are\n%s\nwant\n%s", got, wantLines)
}
if got := fileOf(t, moved, gammaLeftOut(mcat.Rendering{}), "restic.data-declared"); got != wantData {
t.Errorf("not handed, the data is\n%s\nwant\n%s", got, wantData)
}
// Handed: the plan carries the same text as placement, and the files are the client's.
c, _ := composeWith(t, moved, gammaLeftOut(handingTo("store:5000")))
p := c.Plans["restic"].Placement
if got := "# lines\n" + p["backup:lines"]; got != wantLines {
t.Errorf("handed, the plan's lines are\n%s\nwant\n%s", got, wantLines)
}
if got := "# data\n" + p["backup:data"]; got != wantData {
t.Errorf("handed, the plan's data is\n%s\nwant\n%s", got, wantData)
}
if len(p) != 2 {
t.Errorf("the plan's placement is %v", p)
}
}
func TestAnUnknownBackupKeyIsRefused(t *testing.T) {
r := backupsOn(t, "${backup:rows}", placedDataTxt, backupModules...)
if _, err := Compose(r, handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "backup:rows") {
t.Errorf("handed, an unknown key is %v", err)
}
if _, err := Declaration(r, mcat.Rendering{}); err == nil || !strings.Contains(err.Error(), "backup:rows") {
t.Errorf("not handed, an unknown key is %v", err)
}
}
func fileOf(t *testing.T, r mcat.Resolution, with mcat.Rendering, id string) string {
t.Helper()
out, err := Declaration(r, with)
if err != nil {
t.Fatal(err)
}
for _, res := range out {
if res["id"] == id {
return res["content"].(string)
}
}
t.Fatalf("%s was not composed", id)
return ""
}
@@ -0,0 +1,37 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// A module on the shelf from before ADR 0233, which writes its backup lines by hand and declares no
// data, is still backed up: its lines are placed as written, each module's under a comment naming it.
func TestHandWrittenBackupLinesOfAnOlderModuleArePlaced(t *testing.T) {
pg, err := decodeForTest([]byte(`{"module":"pg","version":"1",
"resources":[{"id":"dumps","type":"directory","path":"/srv/pg/dumps","mode":"0700"}],
"contributions":[{"seat":"node-backup","kind":"backup","content":"run pg-dump-all\npath ${dir:dumps}"}]}`))
if err != nil {
t.Fatal(err)
}
mail, err := decodeForTest([]byte(`{"module":"mail","version":"1",
"resources":[{"id":"spool","type":"directory","mode":"0700"}],
"contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:spool}"}]}`))
if err != nil {
t.Fatal(err)
}
placed, _, err := seatContributions([]mcat.Manifest{pg, mail}, mcat.Manifest{}, mcat.BackupSeat+":"+"backup", mcat.Rendering{}, nil, nil)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"# pg\nrun pg-dump-all\npath /srv/pg/dumps\n", "# mail\npath /var/lib/mail/spool\n"} {
if !strings.Contains(placed, want) {
t.Errorf("placed contributions lack %q:\n%s", want, placed)
}
}
if strings.Contains(placed, "${dir:") {
t.Errorf("a directory reached the holder unfilled:\n%s", placed)
}
}
+117
View File
@@ -0,0 +1,117 @@
package resolution
import (
"fmt"
"slices"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// keepBound holds every need for a provision that keeps its consumers' data at the provider its
// consumer was bound to, where the resolution chose another.
//
// A need with no record is a binding being made, and is left as resolved: it is recorded when it is
// first sent. A pin naming the provider the resolution chose is a person moving it, and is left too.
// Otherwise the recorded provider answers, if it still provides the provision — beside the consumer,
// or offered from elsewhere — and the move is returned as kept. **One that no longer does is refused,
// never answered by the provider chosen**: answering it there is exactly the silent move this exists
// to stop, and the consumer's data is still wherever it was.
func keepBound(needs []mcat.Needed, catalogue map[string]mcat.Manifest, node mcat.Node, world mcat.World,
keeps map[string]bool, here func(string) bool) ([]mcat.Needed, []mcat.KeptBinding, []string) {
var kept []mcat.KeptBinding
var problems []string
refused := map[[2]string]bool{}
out := make([]mcat.Needed, 0, len(needs))
for _, n := range needs {
if n.ByRecord || !keeps[n.Name] {
out = append(out, n)
continue
}
n.KeepsData = true
bound, recorded := world.Bound[n.For][n.Name]
chose := mcat.Chosen{Node: n.From, Module: n.Module}
if !recorded || sameProvider(bound, chose) {
out = append(out, n)
continue
}
if pin, pinned := world.Pinned[n.Name]; pinned && chosenMatches(pin, mcat.Provider{Node: chose.Node, Module: chose.Module}) {
out = append(out, n)
continue
}
held, ok, why := boundNeed(n, bound, catalogue, node, world, here)
if !ok {
if key := [2]string{n.For, n.Name}; !refused[key] {
refused[key] = true
problems = append(problems, fmt.Sprintf(
"%s on %s is bound to %s for %q, which keeps its data, and %s — it would move to %s, "+
"which holds none of it. Move its data and say so with `pin %s %s %s %s`, or give "+
"%s back what it provided",
n.For, node.Name, bound, n.Name, why, chose, node.Name, n.Name, chose.Node, chose.Module,
bound.Node))
}
continue
}
out = append(out, held)
kept = append(kept, mcat.KeptBinding{Machine: node.Name, Consumer: n.For, Provision: n.Name, Bound: bound, Would: chose})
}
return out, kept, problems
}
// sameProvider is whether a recorded provider is the one chosen. A record naming no module (none
// is written without one, but a person's hand might) matches any module on its node.
func sameProvider(bound, chose mcat.Chosen) bool {
return bound.Node == chose.Node && (bound.Module == "" || bound.Module == chose.Module)
}
// boundNeed is the need answered by the recorded provider, or why it cannot be.
func boundNeed(n mcat.Needed, bound mcat.Chosen, catalogue map[string]mcat.Manifest, node mcat.Node, world mcat.World,
here func(string) bool) (mcat.Needed, bool, string) {
held := mcat.Needed{Name: n.Name, For: n.For, Local: n.Local, KeepsData: true}
if bound.Node == node.Name {
m, known := catalogue[bound.Module]
if !known || !here(bound.Module) || !providesAt(m, n.Name, mcat.ScopeMesh) {
return mcat.Needed{}, false, fmt.Sprintf("%s no longer runs on %s", bound.Module, node.Name)
}
at := node.At
if at == "" {
at = "127.0.0.1"
}
held.From, held.At, held.Module = node.Name, at, m.Module
held.Serves, held.SharedOwn, held.Identity = servedByOne(m, n.Name), sharedByOne(m, n.Name), m.IdentityBoundOf(n.Name)
held.CredentialForm, held.CredentialTool = m.CredentialFormOf(n.Name), m.CredentialToolOf(n.Name)
return held, true, ""
}
matching := chosenAmong(bound, world.Offered[n.Name])
if len(matching) != 1 {
return mcat.Needed{}, false, fmt.Sprintf("%s no longer provides it", bound)
}
p := matching[0]
if node.At == "" || p.At == "" {
return mcat.Needed{}, false, fmt.Sprintf("%s and %s are not both on the private network", node.Name, p.Node)
}
identity := mcat.DefaultIdentityBound
if pm, known := catalogue[p.Module]; known {
held.SharedOwn, _ = pm.SharedCredentialOf(n.Name)
held.CredentialForm, held.CredentialTool = pm.CredentialFormOf(n.Name), pm.CredentialToolOf(n.Name)
identity = pm.IdentityBoundOf(n.Name)
}
held.From, held.At, held.Module, held.Serves, held.Identity = p.Node, p.At, p.Module, p.Serves, identity
held.LeftOut = p.LeftOut
return held, true, ""
}
// BindsFrom is the providers this resolution binds a pair credential's need to — the provision, the
// module that requires it and the credential's local name — answered by a machine rather than by a
// record. None means this machine states no such binding.
func BindsFrom(r mcat.Resolution, provision, module, local string) []string {
var from []string
for _, n := range r.Needs {
if n.ByRecord || n.Name != provision || n.For != module || n.Local != local {
continue
}
if !slices.Contains(from, n.From) {
from = append(from, n.From)
}
}
return from
}
@@ -0,0 +1,176 @@
package resolution
import (
"fmt"
"regexp"
"sort"
"strings"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// bound is where a module says a value from one of its bindings belongs:
// ${bound:<provision>:<key>}, and for an optional requirement the value it takes while absent,
// ${bound:<provision>:<key>|default:<value>} (novox/hq ADR 0330 §4). A default is never empty: an
// absent requirement is never rendered as an empty string.
var bound = regexp.MustCompile(`\$\{bound:([a-z0-9][a-z0-9.-]*[a-z0-9]):([a-z0-9][a-z0-9_-]*)(?:\|default:([^}\n]+))?\}`)
// knownFor is everything a module may name from one of its bindings.
//
// Three facts the mesh states about any provision, plus whatever the provider said it serves. A
// module may not reach a binding it does not have — the same boundary as a secret, for the same
// reason.
func knownFor(m mcat.Manifest, needs []mcat.Needed, node string) (map[string]map[string]string, error) {
out := map[string]map[string]string{}
for _, want := range m.Wants() {
for i := range needs {
n := needs[i]
if n.Name != want || n.For != m.Module {
continue
}
as := mcat.ConsumerIdentity(node, mcat.IdentitySource(m.Slug, m.Module))
values := map[string]string{
"at": n.At,
"from": n.From,
"as": as,
}
// What the provider derives for this consumer rather than for all of them
// (novox/hq ADR 0202). Filled here, the one place a provision and the module
// requiring it are both in hand.
served, err := ServedTo(n.Serves, as)
if err != nil {
return nil, fmt.Errorf("%s requires %s: %w", m.Module, want, err)
}
for key, value := range served {
// The provider's own vocabulary. Rendered plainly: a port is 5432, not 5432.000000,
// which is what a float would write and what a connection string would refuse.
values[key] = plainly(value)
}
out[want] = values
}
}
return out, nil
}
// withOwnNames adds a module's own composed names to what it may name from one binding:
// `${bound:<provision>:name}` and `:internal-name`, and for several contributions to one requirement
// `:name-<local>` / `:internal-name-<local>`. Set over anything the provider serves under those keys:
// what the module is called is the mesh's statement, not the provider's.
func withOwnNames(values map[string]string, own map[string]any) {
for _, key := range []string{"name", "internal-name"} {
if v, ok := own[key].(string); ok {
values[key] = v
}
}
many, _ := own["names"].(map[string]any)
for local, raw := range many {
names, _ := raw.(map[string]any)
for _, key := range []string{"name", "internal-name"} {
if v, ok := names[key].(string); ok {
values[key+"-"+local] = v
}
}
}
}
// plainly renders a served value as a program would expect to read it.
func plainly(value any) string {
switch v := value.(type) {
case string:
return v
case float64:
if v == float64(int64(v)) {
return fmt.Sprintf("%d", int64(v))
}
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%f", v), "0"), ".")
case bool:
return fmt.Sprintf("%t", v)
case nil:
return ""
default:
return fmt.Sprint(v)
}
}
// boundInto replaces a file's ${bound:…} placeholders with what the mesh knows.
//
// A placeholder naming something the module does not require, or a key the provider does not
// serve, is refused. Left as it was, the literal `${bound:x:y}` would be written into a
// configuration file and read as a value — a connection to a host called `${bound:x:y}`, failing
// somewhere that names neither the module nor the mesh.
//
// **An optional requirement left absent takes the default its placeholder names** (novox/hq ADR
// 0330 §4), and one that names none is refused rather than rendered empty. The module check refuses
// that already; this is the same rule where the file is made, for a manifest registered before it.
func boundInto(resource map[string]any, known map[string]map[string]string, module string, absent map[string]bool) error {
if fmt.Sprint(resource["type"]) != "file" {
return nil
}
content, ok := resource["content"].(string)
if !ok {
return nil
}
var refused error
filled := bound.ReplaceAllStringFunc(content, func(token string) string {
if refused != nil {
return token
}
m := bound.FindStringSubmatch(token)
provision, key, fallback := m[1], m[2], m[3]
values, has := known[provision]
if !has {
if absent[provision] {
if fallback != "" {
return fallback
}
refused = fmt.Errorf(
"%s has a file that says ${bound:%s:%s}, and %q is optional and absent here: it names no "+
"value for then (${bound:%s:%s|default:<value>}), so it would be written empty "+
"(novox/hq ADR 0330 §4)", module, provision, key, provision, provision, key)
return token
}
refused = fmt.Errorf(
"%s has a file that says ${bound:%s:%s}, and %s does not require %q. It may name %s",
module, provision, key, module, provision, orNothing(namesOfBindings(known)))
return token
}
value, said := values[key]
if !said {
refused = fmt.Errorf(
"%s asks its %s binding for %q, and what answers it says %s",
module, provision, key, orNothing(namesOfKeys(values)))
return token
}
return value
})
if refused != nil {
return refused
}
resource["content"] = filled
return nil
}
func namesOfBindings(known map[string]map[string]string) []string {
var names []string
for name := range known {
names = append(names, fmt.Sprintf("%q", name))
}
sort.Strings(names)
return names
}
func namesOfKeys(values map[string]string) []string {
var names []string
for key := range values {
names = append(names, fmt.Sprintf("%q", key))
}
sort.Strings(names)
return names
}
func orNothing(names []string) string {
if len(names) == 0 {
return "nothing"
}
return join(names)
}
@@ -0,0 +1,246 @@
package resolution
import (
"fmt"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// A consumer writes its own connection string (novox/hq 04-ISSUES/023).
//
// Everything needed for one now reaches the file that needs it: the address and port from what the
// provider serves, the name to present from what the mesh decided, the database name from what the
// module itself contributed, and the password through the sealed hole the host fills.
func TestAConsumerCanWriteAConnectionString(t *testing.T) {
r := mcat.Resolution{
Node: "workstation",
Modules: []mcat.Manifest{{
Module: "keycloak",
Requires: []string{"postgres-database"},
Secrets: map[string]string{"postgres-database": "/var/lib/keycloak/db.secret"},
Resources: []map[string]any{{
"id": "dbenv", "type": "file", "path": "/var/lib/keycloak/database.env",
"mode": "0600",
"content": "KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:" +
"${bound:postgres-database:port}/keycloak\n" +
"KC_DB_USERNAME=${bound:postgres-database:as}\n" +
"KC_DB_PASSWORD=${secret:postgres-database}\n",
}},
}},
Needs: []mcat.Needed{{
Name: "postgres-database", From: "anchor", At: "anchor.internal", For: "keycloak",
Sealed: "sealed-db", Serves: map[string]any{"port": float64(5432)},
}},
}
out, err := Declaration(r, mcat.Rendering{})
if err != nil {
t.Fatal(err)
}
file := fileNamed(out, "keycloak.dbenv")
if file == nil {
t.Fatalf("no file in %v", out)
}
got := file["content"].(string)
want := "KC_DB_URL=jdbc:postgresql://anchor.internal:5432/keycloak\n" +
"KC_DB_USERNAME=mesh_workstation_keycloak\n" +
"KC_DB_PASSWORD=${secret:postgres-database}\n"
if got != want {
t.Errorf("the consumer cannot connect with what it was given:\n got %q\nwant %q", got, want)
}
// The password is still a hole. Only the host may fill that one, and only on the machine.
if sealed, _ := file["secrets"].(map[string]any); sealed["postgres-database"] != "sealed-db" {
t.Errorf("the credential did not travel with the file: %v", file)
}
}
// A provider answered on its consumer's own machine is announced at the port the machine PUBLISHES
// it on, not the port the module declared (novox/hq 04-ISSUES/038).
//
// Issue 018 made the same-node provider announced at all; this is the promise after it — that the
// port a co-located consumer is told is the one actually listening at the address it is handed. The
// mesh assigns a bare `ports` mapping a host port (here the container's 5432 becomes 15432 on this
// machine), and it must announce 15432, or the consumer dials workstation.internal:5432 where the
// provider is not published. Cross-node consumers already got this (the plan re-derives the served
// facts with the provider's assignment); the same-node need was settled before the assignment and
// carried the declared port until now.
func TestASameNodeProviderIsAnnouncedAtThePortItIsPublishedOn(t *testing.T) {
provider := mcat.Manifest{
Module: "postgres", Version: "1",
Provides: mcat.FromAnywhere("postgres-database"),
Listens: []mcat.Listening{{Port: 5432, Protocol: "tcp", From: "mesh"}},
Serves: map[string]map[string]any{"postgres-database": {"port": 5432}},
Resources: []map[string]any{{
"id": "server", "type": "container", "name": "postgres",
"ports": []any{"5432"},
}},
}
consumer := mcat.Manifest{
Module: "keycloak", Version: "1", Requires: []string{"postgres-database"},
Resources: []map[string]any{{
"id": "dbenv", "type": "file", "path": "/var/lib/keycloak/database.env", "mode": "0600",
"content": "KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:" +
"${bound:postgres-database:port}/keycloak\n",
}},
}
r, err := Resolve(shelf(provider, consumer),
[]string{"postgres", "keycloak"}, reachable(), mcat.World{})
if err != nil {
t.Fatal(err)
}
// The mesh assigned the container's 5432 to 15432 on this machine.
out, err := Declaration(r, mcat.Rendering{Ports: map[string]map[int]int{"postgres": {5432: 15432}}})
if err != nil {
t.Fatal(err)
}
// Where the provider is actually published: the assigned host port, on the machine at large.
server := fileNamed(out, "postgres.server")
if server == nil {
t.Fatalf("the provider container is not in the declaration: %v", out)
}
if published := fmt.Sprint(server["ports"]); !strings.Contains(published, "15432:5432") {
t.Fatalf("the provider was not published on the assigned port: %v", server["ports"])
}
// What the consumer was told to dial: the SAME port, at the announced address — not the 5432 the
// module declared, which is closed on that address.
dbenv := fileNamed(out, "keycloak.dbenv")
if dbenv == nil {
t.Fatalf("the consumer's binding file is not in the declaration: %v", out)
}
got := dbenv["content"].(string)
want := "KC_DB_URL=jdbc:postgresql://workstation.internal:15432/keycloak\n"
if got != want {
t.Errorf("the consumer dials a port the provider does not listen on:\n got %q\nwant %q",
got, want)
}
}
// A port is 5432, not 5432.000000 — which is what a number decoded from JSON would write, and what
// every connection string in the world would refuse.
func TestAPortIsWrittenAsAPort(t *testing.T) {
if got := plainly(float64(5432)); got != "5432" {
t.Errorf("a port was rendered as %q", got)
}
if got := plainly("postgres"); got != "postgres" {
t.Errorf("a string was rendered as %q", got)
}
}
// The name the provider will create is the name the consumer is told to present. One derivation,
// so the two agree by construction rather than by two conventions written on the same day.
func TestTheProviderAndTheConsumerAgreeOnTheName(t *testing.T) {
provider, err := Resolve(shelf(mcat.Manifest{
Module: "postgres", Version: "1", Provides: mcat.FromAnywhere("postgres-database"),
Grants: map[string]string{"postgres-database": "/var/lib/postgres/grants"},
Receives: map[string]string{"postgres-database": "/var/lib/postgres/grants/mesh.json"},
}), []string{"postgres"}, reachable(), mcat.World{})
if err != nil {
t.Fatal(err)
}
out, err := Declaration(provider, mcat.Rendering{Grants: []mcat.Grant{{
Provision: "postgres-database", Consumer: "workstation", From: "keycloak",
Values: map[string]any{"name": "keycloak"}, Sealed: "sealed",
}}})
if err != nil {
t.Fatal(err)
}
var told string
for _, res := range out {
if res["path"] == "/var/lib/postgres/grants/mesh.json" {
told = res["content"].(string)
}
}
if !strings.Contains(told, `"as": "mesh_workstation_keycloak"`) {
t.Errorf("the provider was not told what to call the login: %s", told)
}
if told == "" {
t.Fatal("the provider was told nothing at all")
}
// Which is exactly what the consumer, on its own machine, is told to present.
if mcat.ConsumerIdentity("workstation", "keycloak") != "mesh_workstation_keycloak" {
t.Errorf("got %q", mcat.ConsumerIdentity("workstation", "keycloak"))
}
}
// A binding the module does not have is a typo, refused where it was written.
func TestAFileCannotNameABindingTheModuleDoesNotHave(t *testing.T) {
r := mcat.Resolution{
Node: "workstation",
Modules: []mcat.Manifest{{
Module: "keycloak", Requires: []string{"postgres-database"},
Resources: []map[string]any{{
"id": "conf", "type": "file", "path": "/etc/keycloak.conf",
"content": "host=${bound:s3-bucket:at}\n",
}},
}},
Needs: []mcat.Needed{{Name: "postgres-database", From: "anchor", At: "a", For: "keycloak"}},
}
_, err := Declaration(r, mcat.Rendering{})
if err == nil {
t.Fatal("a module read a binding it does not have")
}
if !strings.Contains(err.Error(), "postgres-database") {
t.Errorf("the refusal does not say what it could have named: %v", err)
}
}
// A key the provider does not serve is refused too. Written through, the literal placeholder
// becomes a hostname, and the failure names neither the module nor the mesh.
func TestAKeyTheProviderDoesNotServeIsRefused(t *testing.T) {
r := mcat.Resolution{
Node: "workstation",
Modules: []mcat.Manifest{{
Module: "keycloak", Requires: []string{"postgres-database"},
Resources: []map[string]any{{
"id": "conf", "type": "file", "path": "/etc/keycloak.conf",
"content": "sock=${bound:postgres-database:socket}\n",
}},
}},
Needs: []mcat.Needed{{
Name: "postgres-database", From: "anchor", At: "a", For: "keycloak",
Serves: map[string]any{"port": float64(5432)},
}},
}
_, err := Declaration(r, mcat.Rendering{})
if err == nil {
t.Fatal("a module read a key nothing serves, and the placeholder became a value")
}
if !strings.Contains(err.Error(), `"port"`) {
t.Errorf("the refusal does not say what is served: %v", err)
}
}
// A machine's resolver configuration must name its resolver by address — it cannot resolve the name
// of the thing it resolves names with (novox/hq ADR 0194). So a binding offers the providing
// machine's private address beside its name, and only when the machine has one.
func TestABindingOffersTheProvidersAddress(t *testing.T) {
consumer := func() mcat.Resolution {
return mcat.Resolution{
Node: "workstation",
Modules: []mcat.Manifest{{
Module: "resolv-conf",
Requires: []string{"wildcard-resolution"},
Resources: []map[string]any{{
"id": "resolv", "type": "file", "path": "/etc/resolv.conf", "mode": "0644",
"content": "nameserver ${bound:wildcard-resolution:address}\n",
}},
}},
Needs: []mcat.Needed{{Name: "wildcard-resolution", From: "anchor", At: "anchor.internal", For: "resolv-conf"}},
}
}
out, err := Declaration(consumer(), mcat.Rendering{Machines: map[string]string{"anchor.internal": "10.77.0.1"}})
if err != nil {
t.Fatal(err)
}
if got := fileNamed(out, "resolv-conf.resolv")["content"]; got != "nameserver 10.77.0.1\n" {
t.Fatalf("the resolver is not named by its address: %q", got)
}
// A machine with no address yet: refused, never written with a blank where the address belongs.
if _, err := Declaration(consumer(), mcat.Rendering{}); err == nil {
t.Fatal("a file naming an address the mesh does not have was composed")
}
}
+219
View File
@@ -0,0 +1,219 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
var incidentConsumers = []string{"board", "listings", "workflows", "agents", "game"}
func storeMesh() map[string]mcat.Manifest {
shelf := map[string]mcat.Manifest{
"store": {Module: "store", Version: "1",
Provides: []mcat.Offer{{Name: "postgres-database", Scope: mcat.ScopeMesh}},
Claims: []mcat.Claim{{Name: "mesh-store", Scope: mcat.ScopeMesh}},
Serves: map[string]map[string]any{"postgres-database": {"port": 5432}},
Grants: map[string]string{"postgres-database": "/grants"}},
"resolver": {Module: "resolver", Version: "1",
Provides: []mcat.Offer{{Name: "wildcard-resolution", Scope: mcat.ScopeMesh}},
Claims: []mcat.Claim{{Name: "mesh-dns-resolver", Scope: mcat.ScopeMesh}}},
"network": {Module: "network", Version: "1", Requires: []string{"wildcard-resolution"}},
}
for _, c := range incidentConsumers {
shelf[c] = mcat.Manifest{Module: c, Version: "1", Requires: []string{"postgres-database"}}
}
return shelf
}
// storeWorld is the rest of the mesh as the home server's plan sees it: the anchor runs a store and a
// resolver and holds both seats; the home server runs its own of each.
func storeWorld() mcat.World {
w := mcat.World{Offered: map[string][]mcat.Provider{
"postgres-database": {
{Node: "anchor", At: "anchor.internal", Module: "store", Serves: map[string]any{"port": 5432}},
{Node: "home", At: "home.internal", Module: "store", Serves: map[string]any{"port": 5434}},
},
"wildcard-resolution": {
{Node: "anchor", At: "anchor.internal", Module: "resolver"},
{Node: "home", At: "home.internal", Module: "resolver"},
},
}}
w.Held = []mcat.Held{
{Claim: "mesh-store", Scope: mcat.ScopeMesh, Node: "anchor", Module: "store"},
{Claim: "mesh-dns-resolver", Scope: mcat.ScopeMesh, Node: "anchor", Module: "resolver"},
}
w.Holdings = w.Held
return w
}
var home = mcat.Node{Name: "home", At: "home.internal"}
func homeAssigned() []string {
return append([]string{"store", "resolver", "network"}, incidentConsumers...)
}
func boundFrom(t *testing.T, r mcat.Resolution, consumer, provision string) mcat.Needed {
t.Helper()
for _, n := range r.Needs {
if n.For == consumer && n.Name == provision {
return n
}
}
t.Fatalf("no binding of %s for %s: %+v", consumer, provision, r.Needs)
return mcat.Needed{}
}
func TestTheIncidentAMachinesOwnStoreKeepsItsConsumersWhenTheSeatIsHeldElsewhere(t *testing.T) {
got, err := Resolve(storeMesh(), homeAssigned(), home, storeWorld())
if err != nil {
t.Fatal(err)
}
for _, c := range incidentConsumers {
if n := boundFrom(t, got, c, "postgres-database"); n.From != "home" || n.Module != "store" {
t.Errorf("%s bound to %s/%s; its data is on the store beside it (issue 273)", c, n.From, n.Module)
}
}
// And the resolver, which keeps nothing of anybody's, still answers from the seat's holder (258).
if n := boundFrom(t, got, "network", "wildcard-resolution"); n.From != "anchor" {
t.Errorf("the resolver bound to %s; the seat is held on anchor (issue 258)", n.From)
}
if len(got.Kept) != 0 {
t.Errorf("nothing was moved, and %d kept: %+v", len(got.Kept), got.Kept)
}
}
func TestTheIncidentWithEveryConsumerOnRecordStillMovesNothing(t *testing.T) {
w := storeWorld()
w.Bound = map[string]map[string]mcat.Chosen{}
for _, c := range incidentConsumers {
w.Bound[c] = map[string]mcat.Chosen{"postgres-database": {Node: "home", Module: "store"}}
}
got, err := Resolve(storeMesh(), homeAssigned(), home, w)
if err != nil {
t.Fatal(err)
}
for _, c := range incidentConsumers {
if n := boundFrom(t, got, c, "postgres-database"); n.From != "home" {
t.Errorf("%s bound to %s", c, n.From)
}
}
if len(got.Kept) != 0 {
t.Errorf("kept %+v", got.Kept)
}
}
func TestAPinElsewhereStillMovesAStoresConsumers(t *testing.T) {
w := storeWorld()
w.Pinned = map[string]mcat.Chosen{"postgres-database": {Node: "anchor", Module: "store"}}
w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}}
got, err := Resolve(storeMesh(), homeAssigned(), home, w)
if err != nil {
t.Fatal(err)
}
if n := boundFrom(t, got, "board", "postgres-database"); n.From != "anchor" || n.Module != "store" {
t.Errorf("bound to %s/%s; a person pinned it to anchor", n.From, n.Module)
}
if len(got.Kept) != 0 {
t.Errorf("a pin is a person's move, not one to keep: %+v", got.Kept)
}
}
// A consumer on a machine with no store of its own, bound to one store, when the seat moves to
// another: the holder would answer, and the binding stays.
func laptopWorld(holder string) mcat.World {
w := storeWorld()
w.Held = []mcat.Held{{Claim: "mesh-store", Scope: mcat.ScopeMesh, Node: holder, Module: "store"}}
w.Holdings = w.Held
return w
}
var laptop = mcat.Node{Name: "laptop", At: "laptop.internal"}
func TestABoundConsumerStaysWhenTheSeatsHolderChanges(t *testing.T) {
w := laptopWorld("home")
w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "anchor", Module: "store"}}}
got, err := Resolve(storeMesh(), []string{"board"}, laptop, w)
if err != nil {
t.Fatal(err)
}
n := boundFrom(t, got, "board", "postgres-database")
if n.From != "anchor" || n.At != "anchor.internal" || n.Serves["port"] != 5432 {
t.Fatalf("bound to %s at %s %v; its data is on anchor", n.From, n.At, n.Serves)
}
if len(got.Kept) != 1 {
t.Fatalf("the move was not said: %+v", got.Kept)
}
k := got.Kept[0]
if k.Bound != (mcat.Chosen{Node: "anchor", Module: "store"}) || k.Would != (mcat.Chosen{Node: "home", Module: "store"}) || k.Consumer != "board" {
t.Fatalf("kept %+v", k)
}
for _, want := range []string{"would move board's postgres-database from anchor/store to home/store",
"its data is on anchor/store", "pin laptop postgres-database home store", "move the data first"} {
if !strings.Contains(k.String(), want) {
t.Errorf("%q does not say %q", k.String(), want)
}
}
// Without a record it is a binding being made, and the holder answers it as before.
w.Bound = nil
got, err = Resolve(storeMesh(), []string{"board"}, laptop, w)
if err != nil {
t.Fatal(err)
}
if n := boundFrom(t, got, "board", "postgres-database"); n.From != "home" {
t.Errorf("a new consumer bound to %s; the seat is held on home", n.From)
}
}
func TestABoundConsumerWhoseProviderIsGoneIsRefusedNotMoved(t *testing.T) {
w := laptopWorld("anchor")
w.Offered["postgres-database"] = w.Offered["postgres-database"][:1] // only anchor's store is left
w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}}
_, err := Resolve(storeMesh(), []string{"board"}, laptop, w)
if err == nil {
t.Fatal("bound to home's store, which is gone, and answered by anchor's — the silent move")
}
for _, want := range []string{"board on laptop is bound to home/store", "home/store no longer provides it",
"pin laptop postgres-database anchor store"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("%q does not say %q", err, want)
}
}
}
func TestAMachinesOwnStoreUnassignedRefusesItsBoundConsumers(t *testing.T) {
w := storeWorld()
w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}}
_, err := Resolve(storeMesh(), []string{"board"}, home, w)
if err == nil || !strings.Contains(err.Error(), "store no longer runs on home") {
t.Fatalf("got %v", err)
}
}
// The first pass asks only what a machine offers, and is never refused by a binding.
func TestTheFirstPassKeepsNoBinding(t *testing.T) {
w := storeWorld()
w.Unchecked = true
w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "gone", Module: "store"}}}
if _, err := Resolve(storeMesh(), []string{"board"}, laptop, w); err != nil {
t.Fatal(err)
}
}
// An offer saying it keeps nothing is answered by the seat's holder as the resolver is, even where it
// grants a credential.
func TestAStoreSayingItKeepsNothingFollowsTheSeat(t *testing.T) {
shelf := storeMesh()
no := false
s := shelf["store"]
s.Provides = []mcat.Offer{{Name: "postgres-database", Scope: mcat.ScopeMesh, KeepsConsumerData: &no}}
shelf["store"] = s
got, err := Resolve(shelf, homeAssigned(), home, storeWorld())
if err != nil {
t.Fatal(err)
}
if n := boundFrom(t, got, "board", "postgres-database"); n.From != "anchor" {
t.Errorf("bound to %s; it keeps nothing, and the seat is held on anchor", n.From)
}
}
@@ -0,0 +1,49 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// The mesh's bus is one per mesh, read from the catalogue beside this checkout.
//
// **This is step 2's claim, and it is checked here rather than in a bed** (novox/hq ADR 0116):
// adoption puts the NATS server into the `mesh-broker` seat on a mesh that is already running,
// and the property that matters is that a second one anywhere is refused *when it is assigned*,
// not discovered later as two servers holding different halves of the mesh's traffic. A second
// bus is not a degraded mesh; it is two meshes that both believe they are the one.
func TestASecondMeshBusAnywhereIsRefusedByName(t *testing.T) {
nats := catalogueManifest(t, "nats")
if _, err := Resolve(shelf(nats), []string{"nats"}, workstation(), mcat.World{}); err != nil {
t.Fatalf("the bus alone does not resolve: %v", err)
}
elsewhere := mcat.World{Held: []mcat.Held{{Claim: "mesh-broker", Scope: mcat.ScopeMesh,
Node: "anchor", Module: "nats"}}}
other := workstation()
other.Name = "laptop"
_, err := Resolve(shelf(nats), []string{"nats"}, other, elsewhere)
if err == nil {
t.Fatal("a second bus was accepted on another machine")
}
if !strings.Contains(err.Error(), "mesh-broker") || !strings.Contains(err.Error(), "one per mesh") {
t.Fatalf("refused without naming the seat: %v", err)
}
}
// The seat is the server's role, not the product's name (novox/hq ADR 0079). A different
// implementation of the bus claims the same seat, and the mesh refuses it for the same reason —
// which is the property that lets the bus be replaced at all.
func TestTheSeatRefusesADifferentBusToo(t *testing.T) {
nats := catalogueManifest(t, "nats")
held := mcat.World{Held: []mcat.Held{{Claim: "mesh-broker", Scope: mcat.ScopeMesh,
Node: "anchor", Module: "some-other-broker"}}}
other := workstation()
other.Name = "laptop"
if _, err := Resolve(shelf(nats), []string{"nats"}, other, held); err == nil {
t.Fatal("the seat admitted a second holder because the module's name differed")
}
}
@@ -0,0 +1,255 @@
package resolution
import (
"encoding/json"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// onNetwork is a set of providers, all of them reachable. Written as a helper because a machine
// that cannot reach the one answering its requirement is its own case, tested separately.
// reachable is this machine, on the private network.
func reachable() mcat.Node {
n := workstation()
n.At = "workstation.internal"
return n
}
func onNetwork(nodes ...string) map[string][]mcat.Provider {
out := make([]mcat.Provider, 0, len(nodes))
for _, n := range nodes {
out = append(out, mcat.Provider{Node: n, At: n + ".internal", Module: "postgres"})
}
return map[string][]mcat.Provider{"postgres-database": out}
}
func brokeredShelf() map[string]mcat.Manifest {
return shelf(mcat.Manifest{Module: "postgres", Version: "1", Provides: mcat.FromAnywhere("postgres-database")}, mcat.Manifest{Module: "meshboard", Version: "1", Requires: []string{"postgres-database"}})
}
func TestADatabaseIsNotInstalledOnEveryMachineThatUsesOne(t *testing.T) {
// The fault this whole distinction exists for.
got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: onNetwork("anchor")})
if err != nil {
t.Fatal(err)
}
if have := strings.Join(names(got), " "); strings.Contains(have, "postgres") {
t.Fatalf("using a database installed one here: %s", have)
}
}
func TestWhatAMachineTakesFromElsewhereIsRecorded(t *testing.T) {
// It is the only part of a node's set that stops working when a *different* machine goes
// away, and it is where a credential will have to be handed back.
got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: onNetwork("anchor")})
if err != nil {
t.Fatal(err)
}
if len(got.Needs) != 1 {
t.Fatalf("got %v", got.Needs)
}
if got.Needs[0].Name != "postgres-database" || got.Needs[0].From != "anchor" {
t.Fatalf("got %v", got.Needs[0])
}
if got.Needs[0].For != "meshboard" {
t.Fatalf("it does not say what wanted it: %v", got.Needs[0])
}
}
func TestNothingInTheMeshProvidingItIsRefusedWithSomewhereToPutIt(t *testing.T) {
// Refused rather than installed here. Choosing a machine to put a database on is a decision
// with consequences, and nothing resolving a web application should make it silently.
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, workstation(), mcat.World{})
if err == nil {
t.Fatal("a database was found in a mesh that has none")
}
if !strings.Contains(err.Error(), "assign") || !strings.Contains(err.Error(), "postgres") {
t.Fatalf("the refusal does not say what to do: %v", err)
}
}
func TestTwoNodesProvidingItIsRefusedRatherThanPicked(t *testing.T) {
// Same rule as everywhere else. Picking one would be a guess about which database a person
// meant, and the wrong guess is somebody's data in the wrong place.
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: onNetwork("anchor", "archive")})
if err == nil {
t.Fatal("one of two databases was picked silently")
}
for _, want := range []string{"anchor", "archive", "pin"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("the refusal does not name %s: %v", want, err)
}
}
}
func TestSayingWhichOneSettlesIt(t *testing.T) {
got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{
Offered: onNetwork("anchor", "archive"),
Pinned: map[string]mcat.Chosen{"postgres-database": {Node: "archive", Module: "postgres"}},
})
if err != nil {
t.Fatal(err)
}
if len(got.Needs) != 1 || got.Needs[0].From != "archive" {
t.Fatalf("the choice was not taken: %v", got.Needs)
}
}
func TestBeingPointedAtAMachineThatDoesNotProvideItIsRefused(t *testing.T) {
// Rather than falling back to one that does. A fallback would quietly move somebody's data to
// a machine they did not choose, which is the whole reason the question is asked.
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{
Offered: onNetwork("anchor", "archive"),
Pinned: map[string]mcat.Chosen{"postgres-database": {Node: "somewhere-else", Module: "postgres"}},
})
if err == nil {
t.Fatal("a machine was silently given a different database from the one chosen")
}
if !strings.Contains(err.Error(), "somewhere-else") {
t.Fatalf("the refusal does not say what was chosen: %v", err)
}
}
func TestOneProviderDoesNotOverruleAChoice(t *testing.T) {
// A single answer is normally taken silently. Not when somebody said they wanted a different
// one -- that is the mesh overruling a person, which it does nowhere else.
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{
Offered: onNetwork("anchor"),
Pinned: map[string]mcat.Chosen{"postgres-database": {Node: "archive", Module: "postgres"}},
})
if err == nil {
t.Fatal("the only database was used although another was chosen")
}
if !strings.Contains(err.Error(), "only anchor/postgres provides it") {
t.Fatalf("the refusal does not say what is available: %v", err)
}
}
func TestACatalogueThatDisagreesAboutScopeIsRefused(t *testing.T) {
// If one module says a database is local and another says it is anywhere, the same
// requirement means two things depending on which one happens to answer it.
_, err := Resolve(shelf(mcat.Manifest{Module: "postgres", Version: "1", Provides: mcat.FromAnywhere("postgres-database")}, mcat.Manifest{Module: "sqlite", Version: "1", Provides: mcat.Offers("postgres-database")}, mcat.Manifest{Module: "meshboard", Version: "1", Requires: []string{"postgres-database"}}), []string{"meshboard"}, workstation(), mcat.World{})
if err == nil {
t.Fatal("a catalogue that disagrees about where a database lives was accepted")
}
if !strings.Contains(err.Error(), "two things") {
t.Fatalf("unhelpful refusal: %v", err)
}
}
func TestALocalRequirementIsStillAnsweredLocally(t *testing.T) {
// The change must not have made everything brokered. A shell is still installed here.
got, err := Resolve(shelf(mcat.Manifest{Module: "zsh", Version: "1", Provides: mcat.Offers("shell")}, mcat.Manifest{Module: "tools", Version: "1", Requires: []string{"shell"}}), []string{"tools"}, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
if have := strings.Join(names(got), " "); !strings.Contains(have, "zsh") {
t.Fatalf("a shell was not installed on the machine that needs one: %s", have)
}
}
func boundShelf() map[string]mcat.Manifest {
return shelf(mcat.Manifest{Module: "postgres", Version: "1", Provides: mcat.FromAnywhere("postgres-database"),
Serves: map[string]map[string]any{"postgres-database": {"port": 5432, "driver": "postgres"}}}, mcat.Manifest{Module: "meshboard", Version: "1", Requires: []string{"postgres-database"},
Binds: map[string]string{"postgres-database": "/etc/meshboard/database.json"}},
)
}
func binding(t *testing.T, out []map[string]any) map[string]any {
t.Helper()
for _, r := range out {
if r["path"] != "/etc/meshboard/database.json" {
continue
}
var parsed map[string]any
if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil {
t.Fatalf("what the app is told is not readable: %v", err)
}
return parsed
}
t.Fatalf("the app was told nothing: %v", out)
return nil
}
func TestAnAppIsToldWhereItsDatabaseIs(t *testing.T) {
// Knowing it needs the anchor's database is useless to the program that needs it unless the
// program is told. This is the whole point of the field.
got, err := Resolve(boundShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: map[string][]mcat.Provider{"postgres-database": {{Node: "anchor", At: "anchor.internal",
Serves: map[string]any{"port": 5432, "driver": "postgres"}}}}})
if err != nil {
t.Fatal(err)
}
told := binding(t, mustDeclare(t, got))
if told["from"] != "anchor" || told["at"] != "anchor.internal" {
t.Fatalf("it was not told where: %v", told)
}
serves, _ := told["serves"].(map[string]any)
if serves["port"] != float64(5432) || serves["driver"] != "postgres" {
t.Fatalf("it was not told how: %v", serves)
}
}
func TestItSaysWhereTheCredentialIsInstead(t *testing.T) {
// A missing field looks like a bug; a stated absence looks like a boundary. Somebody wiring
// this up must not spend an afternoon looking for the password field.
//
// It used to say only that the mesh had no way to issue one, which stopped being true when
// 021 was fixed. A stated absence is only useful while it is accurate — once it is not, it
// sends the reader somewhere there is nothing to find.
got, _ := Resolve(boundShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: map[string][]mcat.Provider{"postgres-database": {{Node: "anchor", At: "anchor.internal"}}}})
told := binding(t, mustDeclare(t, got))
note, _ := told["generated"].(string)
if !strings.Contains(note, "not here") || !strings.Contains(note, "secrets") {
t.Fatalf("the file does not say where the credential is instead: %v", note)
}
for key := range told {
if strings.Contains(key, "password") || strings.Contains(key, "secret") {
t.Fatalf("something that looks like a credential appeared: %q", key)
}
}
}
func TestTwoMachinesThatCannotReachEachOtherAreRefused(t *testing.T) {
// An app on one machine and a database on another that share no private network is a mesh
// that reports itself configured and does not work. Said here rather than discovered as a
// connection timing out.
_, err := Resolve(boundShelf(), []string{"meshboard"}, workstation(), mcat. // not on the network
World{Offered: map[string][]mcat.Provider{"postgres-database": {{Node: "anchor", At: "anchor.internal"}}}})
if err == nil {
t.Fatal("an app was pointed at a database it has no path to")
}
if !strings.Contains(err.Error(), "private network") {
t.Fatalf("the refusal does not say what is wrong: %v", err)
}
if !strings.Contains(err.Error(), meshNetwork) {
t.Fatalf("the refusal does not say what to assign: %v", err)
}
}
func TestTheProviderBeingOffTheNetworkIsAlsoRefused(t *testing.T) {
// Both directions, because the failure is identical from either end and the remedy differs.
_, err := Resolve(boundShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: map[string][]mcat.Provider{"postgres-database": {{Node: "anchor"}}}})
if err == nil {
t.Fatal("an app was pointed at a database that is not on the private network")
}
if !strings.Contains(err.Error(), "anchor") {
t.Fatalf("the refusal does not name the unreachable end: %v", err)
}
}
func TestBindingSomethingAnsweredHereWritesNothing(t *testing.T) {
// A file saying "it is on this node" is a fact nobody needs and one more thing to keep true.
got, err := Resolve(shelf(mcat.Manifest{Module: "zsh", Version: "1", Provides: mcat.Offers("shell")}, mcat.Manifest{Module: "tools", Version: "1", Requires: []string{"shell"},
Binds: map[string]string{"shell": "/etc/tools/shell.json"}},
), []string{"tools"}, reachable(), mcat.World{})
if err != nil {
t.Fatal(err)
}
for _, r := range mustDeclare(t, got) {
if r["path"] == "/etc/tools/shell.json" {
t.Fatalf("a binding was written for something on this machine: %v", r)
}
}
}
@@ -0,0 +1,48 @@
package resolution
import (
"fmt"
"sort"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// sortedShares is a requirement's shares as they are handed to the provider: sorted by `to` (a file whose lines move
// looks changed), each entry's methods sorted, and a copy so the manifest is not reordered.
func sortedShares(shares []mcat.BucketShare) []mcat.BucketShare {
out := make([]mcat.BucketShare, 0, len(shares))
for _, s := range shares {
methods := append([]string(nil), s.Methods...)
sort.Strings(methods)
out = append(out, mcat.BucketShare{To: s.To, Methods: methods})
}
sort.Slice(out, func(i, j int) bool { return out[i].To < out[j].To })
return out
}
// withShares adds this module's shares of the requirement to the values it gives the provider. A module whose own
// `contributes` already names `shares` is refused: the field is the only way to say it (ADR 0468 §8).
func withShares(m mcat.Manifest, requirement string, values map[string]any) (map[string]any, error) {
shares := m.RequireShares[requirement]
if _, named := values[mcat.SharesValue]; named && requirement == mcat.BucketRequirement {
return nil, fmt.Errorf("%s contributing to %s names `shares`; write it on its requirement instead (novox/hq ADR 0468 §8)",
m.Module, requirement)
}
if len(shares) == 0 {
return values, nil
}
out := make(map[string]any, len(values)+1)
for k, v := range values {
out[k] = v
}
listed := []any{}
for _, s := range sortedShares(shares) {
methods := []any{}
for _, method := range s.Methods {
methods = append(methods, method)
}
listed = append(listed, map[string]any{"to": s.To, "methods": methods})
}
out[mcat.SharesValue] = listed
return out, nil
}
@@ -0,0 +1,105 @@
package resolution
import (
"encoding/json"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
const filesWithShares = `{"module":"files","version":"1","requires":["shell",
{"name":"s3-bucket","shares":[{"to":"seat:node-files","methods":["put"]},{"to":"print-and-scan","methods":["get","put"]}]}]}`
func minioLike() mcat.Manifest {
return mcat.Manifest{Module: "minio", Version: "1", Provides: mcat.Offers("s3-bucket"),
Receives: map[string]string{"s3-bucket": "/grants/mesh.json"}}
}
func givenToMinio(t *testing.T, out []map[string]any) []mcat.Contribution {
t.Helper()
for _, r := range out {
if r["path"] != "/grants/mesh.json" {
continue
}
var parsed struct {
Given []mcat.Contribution `json:"given"`
}
if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil {
t.Fatal(err)
}
return parsed.Given
}
t.Fatalf("minio was given no file: %v", out)
return nil
}
func sharesOf(t *testing.T, values map[string]any) string {
t.Helper()
raw, err := json.Marshal(values["shares"])
if err != nil {
t.Fatal(err)
}
return string(raw)
}
const sortedSharesJSON = `[{"methods":["get","put"],"to":"print-and-scan"},{"methods":["put"],"to":"seat:node-files"}]`
// 1591/AC3: the values a consumer gives carry the shares sorted by `to`, remote (grant) and co-located alike.
func TestTheGrantCarriesTheSharesSorted(t *testing.T) {
m := mustParse(t, filesWithShares)
remote := mcat.Resolution{Node: "laptop", Modules: []mcat.Manifest{m}}
values, asks, err := ContributionsFrom(remote, "s3-bucket", "files", mcat.SettingsBy{})
if err != nil || !asks {
t.Fatalf("asks %v, err %v", asks, err)
}
if got := sharesOf(t, values); got != sortedSharesJSON {
t.Fatalf("the grant values carry %s", got)
}
// As the provider's file shows it, through a grant.
provider, err := Resolve(shelf(minioLike()), []string{"minio"}, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
out, err := Declaration(provider, mcat.Rendering{Grants: []mcat.Grant{{Provision: "s3-bucket", Consumer: "laptop", From: "files", Values: values}}})
if err != nil {
t.Fatal(err)
}
given := givenToMinio(t, out)
if len(given) != 1 || sharesOf(t, given[0].Values) != sortedSharesJSON {
t.Fatalf("the remote consumer's shares did not reach the file: %+v", given)
}
// Co-located: the consumer contributes to s3-bucket too, and no grant is passed.
co := mustParse(t, `{"module":"files","version":"1","contributes":{"s3-bucket":{"name":"files"}},
"requires":[{"name":"s3-bucket","shares":[{"to":"seat:node-files","methods":["put"]},{"to":"print-and-scan","methods":["put","get"]}]}]}`)
both, err := Resolve(shelf(minioLike(), co), []string{"files"}, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
out, err = Declaration(both, mcat.Rendering{})
if err != nil {
t.Fatal(err)
}
given = givenToMinio(t, out)
if len(given) != 1 || sharesOf(t, given[0].Values) != sortedSharesJSON || given[0].Values["name"] != "files" {
t.Fatalf("the co-located consumer's shares did not reach the file: %+v", given)
}
}
// 1591/AC5: a consumer whose contributes to s3-bucket names `shares` itself is refused.
func TestAConsumerCannotContributeSharesItself(t *testing.T) {
_, err := decodeForTest([]byte(`{"module":"files","version":"1",
"contributes":{"s3-bucket":{"shares":"x"}},
"requires":[{"name":"s3-bucket","shares":[{"to":"a","methods":["get"]}]}]}`))
if err == nil || !strings.Contains(err.Error(), "ADR 0468 §8") {
t.Errorf("both written, accepted: %v", err)
}
// Without the field the grant is still refused, so the provider is never told something the manifest did not say.
m := mcat.Manifest{Module: "files", Requires: []string{"s3-bucket"},
Contributes: map[string]map[string]any{"s3-bucket": {"shares": "x"}}}
r := mcat.Resolution{Node: "laptop", Modules: []mcat.Manifest{m}}
if _, _, err := ContributionsFrom(r, "s3-bucket", "files", mcat.SettingsBy{}); err == nil {
t.Error("a contributed `shares` reached the grant")
}
}
+20
View File
@@ -0,0 +1,20 @@
package resolution
import (
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// runByAResource is the artifacts one of a module's own resources names — a process that runs it,
// a step, an archive that unpacks it — by name.
func runByAResource(m mcat.Manifest) map[string]bool {
named := map[string]bool{}
for _, r := range m.Resources {
if a, ok := r["artifact"].(string); ok && a != "" {
named[a] = true
}
}
return named
}
// RunByAResource is the bundles of m one of its own resources runs (its service, not its tools).
func RunByAResource(m mcat.Manifest) map[string]bool { return runByAResource(m) }
+20
View File
@@ -0,0 +1,20 @@
package resolution
import (
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// busyInto composes a container's `busy` into the node-engine's words, or takes it away: for an engine that
// reads it, the command and its timeout written out; for one that does not, the field is not sent, and the
// container is replaced at once, as before.
func busyInto(resource map[string]any, with mcat.Rendering) {
if _, has := resource[mcat.BusyField]; !has {
return
}
if !with.ReadsBusy {
delete(resource, mcat.BusyField)
return
}
b, _, _ := mcat.ReadBusy(resource)
resource[mcat.BusyField] = map[string]any{"command": b.Command, "timeout": b.Timeout.String()}
}
+54
View File
@@ -0,0 +1,54 @@
package resolution
import (
"encoding/json"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
func busyManifest(t *testing.T, on string, busy any) []byte {
t.Helper()
extra := map[string]any{"id": "agent", "type": "container", "name": "app-agent",
"image": "registry.example/app" + healthDigest}
var m map[string]any
if err := json.Unmarshal(healthManifest(t, "", nil, extra), &m); err != nil {
t.Fatal(err)
}
for _, r := range m["resources"].([]any) {
if r := r.(map[string]any); r["id"] == on {
r[mcat.BusyField] = busy
}
}
out, err := json.Marshal(m)
if err != nil {
t.Fatal(err)
}
return out
}
func TestABusyIsSentOnlyToAnEngineThatReadsIt(t *testing.T) {
m, err := decodeForTest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"}))
if err != nil {
t.Fatal(err)
}
compose := func(with mcat.Rendering) []map[string]any {
out, err := Declaration(mcat.Resolution{Node: "laptop", Modules: []mcat.Manifest{m}}, with)
if err != nil {
t.Fatal(err)
}
return out
}
older := compose(mcat.Rendering{})
if b, sent := older[indexOfID(older, "app.agent")][mcat.BusyField]; sent {
t.Fatalf("busy was sent to an engine that does not read it: %v", b)
}
newer := compose(mcat.Rendering{ReadsBusy: true})
got, _ := newer[indexOfID(newer, "app.agent")][mcat.BusyField].(map[string]any)
if got["command"] != "mesh-builder busy" || got["timeout"] != "10s" {
t.Fatalf("busy composed as %v", got)
}
if _, has := m.Resources[len(m.Resources)-1][mcat.BusyField].(map[string]any)["timeout"]; has {
t.Error("composing wrote the timeout into the catalogue's own manifest")
}
}
@@ -0,0 +1,46 @@
package resolution
import (
"path/filepath"
"testing"
"github.com/novox/mesh-host/internal/beside"
)
// TestEveryCatalogueManifestParses runs the real catalogue through the real gate.
//
// Not a fixture: the point is whether the manifests as written are accepted by the control plane that
// will read them, and a copy of one manifest proves nothing about the other seventy-one.
// catalogueRoot is the catalogue these checks run over: in a merge check the clone the build seat put
// beside this one, elsewhere the copy captured in testdata/beside (internal/beside). A check that only
// ran when somebody remembered a variable was a check nobody ran (novox/hq issue 134), and one that read
// whatever checkout sat beside judged the machine, not the change (novox/hq issue 432): it never skips.
func catalogueRoot(t *testing.T) string {
t.Helper()
return beside.Dir(t, "mesh-catalog")
}
func TestEveryCatalogueManifestParses(t *testing.T) {
_, found := catalogueManifests(t)
named, routed := 0, 0
for _, r := range found {
m, err := r.manifest, r.err
if err != nil {
t.Errorf("%s: %v", filepath.Base(filepath.Dir(r.path)), err)
continue
}
for _, l := range m.Listens {
if l.Name != "" {
named++
}
}
for port := range RoutedPorts(m) {
_ = port
routed++
}
}
t.Logf("%d manifests, %d named endpoints, %d routed endpoints resolved", len(found), named, routed)
if named == 0 {
t.Fatal("no endpoint in the catalogue is named, so this proved nothing")
}
}
@@ -0,0 +1,46 @@
package resolution
import (
"path/filepath"
"sync"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// readManifest is one manifest of the catalogue as ReadTreeManifest read it.
type readManifest struct {
path string // the module.json
manifest mcat.Manifest
err error
}
var readOnce sync.Once
var readAll []readManifest
var readRoot string
// catalogueManifests is every manifest of the catalogue (catalogueRoot), read through ReadTreeManifest once
// per test binary and not once per test: the checks over the real catalogue each read and parsed every
// manifest again, and together took 1.5 s of mesh-sdk's unit bound of 2 s (issue 1756). What it returns is
// shared and read-only: a test that changes a manifest copies it first.
func catalogueManifests(t *testing.T) (string, []readManifest) {
t.Helper()
root := catalogueRoot(t)
readOnce.Do(func() {
readRoot = root
found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return
}
for _, p := range found {
m, err := mcat.ReadTreeManifest(root, filepath.Dir(p))
readAll = append(readAll, readManifest{path: p, manifest: m, err: err})
}
})
if len(readAll) == 0 {
t.Fatalf("no manifests under %s", root)
}
return readRoot, readAll
}
@@ -0,0 +1,70 @@
package resolution
import (
"path/filepath"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
"github.com/novox/mesh-host/internal/beside"
)
// **A machine trusts the mesh's authority because a module put its root there** (novox/hq ADR
// 0147, issue 129). The module carries a shell script and a unit, and both are worthless unless
// the mesh fills in where the authority is — which is the one thing about it the manifest cannot
// state, because the authority's address is a fact about the mesh and not about the module.
//
// So what is checked here is the rendering, not the parsing: the script the machine will run
// names the authority it was bound to, and the unit runs that script both ways. The verification
// itself — a plain client trusting an internal name on a machine holding this, and failing on one
// that does not — is the lab's, and cannot be had here.
func TestCaTrustRendersTheAuthorityItWasBoundTo(t *testing.T) {
m, err := mcat.ReadTreeManifest(beside.Dir(t, "mesh-catalog"), filepath.Join(beside.Catalogue(t), "ca-trust"))
if err != nil {
t.Fatalf("the trust module does not parse:\n%v", err)
}
r := mcat.Resolution{
Node: "workstation",
Modules: []mcat.Manifest{m},
Needs: []mcat.Needed{{
Name: "internal-acme-ca", From: "anchor", At: "anchor.internal", For: "ca-trust",
Serves: map[string]any{
"port": float64(9000), "path": "/acme/acme/directory", "roots": "/roots.pem",
},
}},
}
out, err := Declaration(r, mcat.Rendering{})
if err != nil {
t.Fatalf("the trust module could not be composed for a machine: %v", err)
}
script := fileNamed(out, "ca-trust.anchor")
if script == nil {
t.Fatalf("nothing writes the script the unit runs: %v", out)
}
body, _ := script["content"].(string)
if !strings.Contains(body, "https://anchor.internal:9000/roots.pem") {
t.Errorf("the script does not fetch from the authority it was bound to:\n%s", body)
}
if script["mode"] != "0755" {
t.Errorf("the script is written %v, which systemd cannot execute", script["mode"])
}
unit := fileNamed(out, "ca-trust.unit")
if unit == nil {
t.Fatalf("no unit: %v", out)
}
text, _ := unit["content"].(string)
// Both halves. A unit that only installs the anchor leaves a machine trusting an authority
// nobody assigned it to any more, which is the half issue 129 asked for by name.
for _, want := range []string{
"ExecStart=" + script["path"].(string) + " install",
"ExecStop=" + script["path"].(string) + " remove",
"RemainAfterExit=yes",
} {
if !strings.Contains(text, want) {
t.Errorf("the unit does not say %q:\n%s", want, text)
}
}
}
@@ -0,0 +1,33 @@
package resolution
import (
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// checkerVerbs are the checker seat's verbs: ask a try, read one, end one by hand. Optional until the checker serves them
// everywhere.
func checkerVerbs() []mcat.Verb {
return []mcat.Verb{
{Name: "try", Optional: true, Description: "Try a pushed head before its pull request is opened: the checker " +
"judges it as it judges a pull request's head, merged onto its trunk's head, its unit and integration " +
"levels through the build modules, and owes the forge nothing. Answers the try's id at once; try-read " +
"reads where it stands. A try of the same head that waits or runs is the answer to a second ask; at most " +
"4 wait or run at once, mesh-wide.",
Input: schema(map[string]string{"repository": "<owner>/<name>",
"head": "the full commit to try, 40 or 64 hex digits",
"trunk": "the trunk commit to merge it onto, or the trunk's branch for its head now; the trunk's head when absent",
"tests": "a go test -run pattern: only those tests run, and nothing is kept",
"level": "unit or integration: only this level; both when absent"},
[]string{"repository", "head"}),
Replaces: []string{"sh merge-check.sh", "docker run postgres"}},
{Name: "try-read", Optional: true, Description: "Where a try stands, as stored: queued, running, or its end " +
"(pass, fail, or error when it could not run), with what was said of it, the head and the trunk commit " +
"it was merged onto, the time it took and what failed first. Read-only.",
Input: schema(map[string]string{"id": "the try's id, try-<digits>"}, []string{"id"})},
{Name: "try-end", Optional: true, Description: "End a try that waits or runs, as error, by hand: say why (kept in " +
"what it says). Its place among the tries is free at once and the next try of its head asks anew with a new " +
"id. A try that ended already is refused.",
Input: schema(map[string]string{"id": "the try's id, try-<digits>", "why": "why it is ended, in words"},
[]string{"id", "why"})},
}
}
+86
View File
@@ -0,0 +1,86 @@
package resolution
import (
"sort"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// matches is whether this provider is the one chosen.
func chosenMatches(c mcat.Chosen, p mcat.Provider) bool {
return p.Node == c.Node && (c.Module == "" || p.Module == c.Module)
}
// among is every offered provider the choice names — one, when the choice is whole.
func chosenAmong(c mcat.Chosen, where []mcat.Provider) []mcat.Provider {
var out []mcat.Provider
for _, p := range where {
if chosenMatches(c, p) {
out = append(out, p)
}
}
return out
}
// nameOf is how a refusal names a provider: the node and the module on it.
func nameOf(p mcat.Provider) string {
return mcat.Chosen{Node: p.Node, Module: p.Module}.String()
}
// providerNames is every provider named, sorted, for a refusal to list.
func providerNames(where []mcat.Provider) []string {
out := make([]string, 0, len(where))
for _, p := range where {
out = append(out, nameOf(p))
}
sort.Strings(out)
return out
}
// providersHere is which modules in this node's own set offer a provision, sorted.
func providersHere(catalogue map[string]mcat.Manifest, here func(string) bool, want string) []string {
var out []string
for name, m := range catalogue {
if !here(name) {
continue
}
for _, o := range m.Offers() {
if o == want {
out = append(out, name)
break
}
}
}
sort.Strings(out)
return out
}
// servedByOne is what one provider beside the consumer says a consumer needs to know, or nothing.
//
// Serving is *whether* a need is created at all when the provider is on this same machine (novox/hq
// 04-ISSUES/038's sibling): a need never created is a binding the consumer never gets. The manifest
// alone answers that; the values are settled later, with the node's settings.
func servedByOne(m mcat.Manifest, want string) map[string]any {
if _, ok := m.Serves[want]; ok {
return ServedOn(m, want, nil)
}
return nil
}
// sharedByOne is the own secret that provider names as its credential (ADR 0158), or "" when it
// gives each consumer its own.
func sharedByOne(m mcat.Manifest, want string) string {
if own, shared := m.SharedCredentialOf(want); shared {
return own
}
return ""
}
func oneOf(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
@@ -0,0 +1,306 @@
package resolution
import (
"encoding/json"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// A root certificate, as a certificate authority serves one: a real, self-signed one made for these tests (its key
// thrown away), because the one setting that may hold lines must parse as a certificate (novox/hq issue 339).
const servedRoot = `-----BEGIN CERTIFICATE-----
MIIBPjCB8aADAgECAhRZG3p93hUUB5bz00uxhYo/nJnTQjAFBgMrZXAwFDESMBAG
A1UEAwwJdGVzdCByb290MCAXDTI2MTAwODIyMjE0NloYDzIxMjYwOTE0MjIyMTQ2
WjAUMRIwEAYDVQQDDAl0ZXN0IHJvb3QwKjAFBgMrZXADIQA0pt/ld+W0MXwBhPfO
cuAt56kIW6Qcn+4vqWpuvHTiqaNTMFEwHQYDVR0OBBYEFIjgaLk4OVGIOeZQiqnN
p9vhciFjMB8GA1UdIwQYMBaAFIjgaLk4OVGIOeZQiqnNp9vhciFjMA8GA1UdEwEB
/wQFMAMBAf8wBQYDK2VwA0EAl9uWeSM2XAV8u0reyV3BLRxNVik+4FCRO1QKPs2k
IlB1rK9oAOYManH+VFuBMI/JJ31ajSti81q4E0CSw8r5DQ==
-----END CERTIFICATE-----
`
// stepCA is an internal ACME authority: it answers `acme-ca` from anywhere in the mesh, and what a
// consumer must know is the directory URL and the root to trust. **The root is not knowable when
// the module is written** — it exists only once the CA has been initialised — so the manifest
// declares the key and the operator supplies the value as a setting, which is exactly the shape the
// cross-node path settles and the same-node path did not.
func stepCA() mcat.Manifest {
return mcat.Manifest{
Module: "step-ca", Version: "1",
Provides: mcat.FromAnywhere("acme-ca"),
Listens: []mcat.Listening{{Port: 9000, Protocol: "tcp", From: mcat.FromMesh}},
Serves: map[string]map[string]any{"acme-ca": {
"directory": "https://anchor.internal/acme/acme/directory",
// Declared empty: a manifest is the same on every mesh, and this mesh's root is not.
"root": "",
}},
Resources: []map[string]any{{
"id": "server", "type": "container", "name": "step-ca", "ports": []any{"9000"},
}},
}
}
// stepCAWithItsRoot is the authority whose root the operator supplies: the served key asks for it as
// `${setting:root}` (ADR 0155), the only way a setting reaches what a provider serves since a layer
// stopped replacing a served key (novox/hq issue 696; before it, the empty `root` was replaced whole).
func stepCAWithItsRoot() mcat.Manifest {
m := stepCA()
m.Serves["acme-ca"]["root"] = "${setting:root}"
return m
}
// routeProxy issues from that authority, so it must be told the root to verify it with.
func routeProxy() mcat.Manifest {
return mcat.Manifest{
Module: "route-proxy", Version: "1",
Requires: []string{"acme-ca"},
Provides: mcat.FromAnywhere("route"),
Binds: map[string]string{"acme-ca": "/var/lib/route-proxy/acme-ca.json"},
Receives: map[string]string{"route": "/var/lib/route-proxy/routes.json"},
Resources: []map[string]any{{
"id": "bundle", "type": "file", "path": "/var/lib/route-proxy/ca.pem", "mode": "0644",
"content": "${bound:acme-ca:root}",
}},
}
}
// A co-located provider's served VALUES reach its consumer, not just its served keys.
//
// The mesh walks a provider on ANOTHER machine and settles what it serves with that node's settings
// layers before offering it (cmd/mesh-controller plan.go, theRestOfTheMesh). A provider on the
// consumer's own machine was never settled at all: resolve.go's servedHere and declaration.go's
// here() both read the manifest and stop there. So a served value the operator supplied — the one
// kind of value a manifest cannot carry, because it is different on every mesh — arrived as the
// manifest's empty default.
//
// The consequence is silent and total: route-proxy wrote an empty CA bundle, fell back to the
// system trust store, could not verify the internal authority, and no certificate was ever issued.
func TestACoLocatedProvidersServedValuesReachItsConsumer(t *testing.T) {
r, err := Resolve(shelf(stepCAWithItsRoot(), routeProxy()),
[]string{"step-ca", "route-proxy"}, reachable(), mcat.World{})
if err != nil {
t.Fatal(err)
}
// What the operator set on the provider, on this node — the CA's root, which only exists once
// the CA has been initialised.
out, err := Declaration(r, mcat.Rendering{Settings: mcat.SettingsBy{
"step-ca": {{From: "the operator", Values: map[string]any{"root": servedRoot}}},
}})
if err != nil {
t.Fatal(err)
}
bundle := fileNamed(out, "route-proxy.bundle")
if bundle == nil {
t.Fatalf("the consumer was given no bundle at all: %v", out)
}
if got := bundle["content"]; got != servedRoot {
t.Errorf("the co-located consumer was not told the root it must trust:\n got %q\nwant %q",
got, servedRoot)
}
}
// And the binding file says the same thing, for a consumer that reads the binding rather than a
// substituted placeholder. The two are one fact and must not be able to disagree.
func TestACoLocatedConsumersBindingCarriesTheSettledValues(t *testing.T) {
r, err := Resolve(shelf(stepCAWithItsRoot(), routeProxy()),
[]string{"step-ca", "route-proxy"}, reachable(), mcat.World{})
if err != nil {
t.Fatal(err)
}
out, err := Declaration(r, mcat.Rendering{
Settings: mcat.SettingsBy{
"step-ca": {{From: "the operator", Values: map[string]any{"root": servedRoot}}},
},
// And the machine moved the provider's port while it was at it, so both halves of the
// cross-node derivation are exercised at once.
Ports: map[string]map[int]int{"step-ca": {9000: 19000}},
})
if err != nil {
t.Fatal(err)
}
binding := fileNamed(out, "route-proxy.bound-acme-ca")
if binding == nil {
t.Fatalf("the consumer was given no binding at all: %v", out)
}
var said struct {
Serves map[string]any `json:"serves"`
}
if err := json.Unmarshal([]byte(binding["content"].(string)), &said); err != nil {
t.Fatal(err)
}
if said.Serves["root"] != servedRoot {
t.Errorf("the binding does not carry the settled root: %q", said.Serves["root"])
}
if port, _ := asPort(said.Serves["port"]); port != 19000 {
t.Errorf("the binding does not carry the port the machine publishes: %v", said.Serves["port"])
}
}
// A provider PULLED IN rather than assigned is settled the same way.
//
// The resolver's same-node need is built during the walk, from whichever modules had been chosen by
// the time the requirement came up — so which path a co-located binding took depended on the order
// a person happened to assign things in. Settling after the closure is known removes that: both
// orders now produce the same file.
func TestACoLocatedProviderIsSettledWhicheverWayItWasPulledIn(t *testing.T) {
for _, assigned := range [][]string{
{"step-ca", "route-proxy"},
{"route-proxy", "step-ca"},
} {
r, err := Resolve(shelf(stepCAWithItsRoot(), routeProxy()), assigned, reachable(), mcat.World{})
if err != nil {
t.Fatal(err)
}
out, err := Declaration(r, mcat.Rendering{Settings: mcat.SettingsBy{
"step-ca": {{From: "the operator", Values: map[string]any{"root": servedRoot}}},
}})
if err != nil {
t.Fatal(err)
}
bundle := fileNamed(out, "route-proxy.bundle")
if bundle == nil || bundle["content"] != servedRoot {
t.Errorf("assigned %v: the consumer was not told the root", assigned)
}
}
}
// A same-node contribution names the port the MACHINE publishes, not the one the module declared.
//
// 04-ISSUES/038 fixed this for what a consumer is TOLD about a provider. This is the other
// direction: what a workload TELLS the provider about itself. gitea declares a bare container port
// 3000, the mesh publishes it as 20000:3000 — and gitea's route contribution still said 3000, so
// the proxy beside it dialled a port nothing listens on and answered 502 for every request.
//
// The redirect uses the CONTRIBUTING module's assignment: the port belongs to the workload, and
// using the provider's map would move it to wherever the proxy happens to be published.
func TestASameNodeContributionNamesThePortTheMachinePublishes(t *testing.T) {
gitea := mcat.Manifest{
Module: "gitea", Version: "1",
Listens: []mcat.Listening{{Port: 3000, Protocol: "tcp", From: mcat.FromMesh}},
Contributes: map[string]map[string]any{"route": {"label": "git", "port": 3000}},
Resources: []map[string]any{{
"id": "server", "type": "container", "name": "gitea", "ports": []any{"3000"},
}},
}
r, err := Resolve(shelf(gitea, routeProxy(), stepCA()),
[]string{"gitea", "route-proxy", "step-ca"}, reachable(), mcat.World{})
if err != nil {
t.Fatal(err)
}
out, err := Declaration(r, mcat.Rendering{
// The machine put gitea's 3000 on 20000, and published it that way.
Ports: map[string]map[int]int{"gitea": {3000: 20000}},
})
if err != nil {
t.Fatal(err)
}
// Where the workload is actually published.
server := fileNamed(out, "gitea.server")
if published := strings.Join(asStrings(server["ports"]), ","); published != "20000:3000" {
t.Fatalf("the workload was not published on the assigned port: %v", server["ports"])
}
// What the proxy beside it was told to dial: the SAME port.
routes := fileNamed(out, "route-proxy.received-route")
if routes == nil {
t.Fatalf("the proxy was given no routes file at all: %v", out)
}
var given struct {
Given []mcat.Contribution `json:"given"`
}
if err := json.Unmarshal([]byte(routes["content"].(string)), &given); err != nil {
t.Fatal(err)
}
if len(given.Given) != 1 {
t.Fatalf("expected one route, got %v", given.Given)
}
port, ok := asPort(given.Given[0].Values["port"])
if !ok || port != 20000 {
t.Errorf("the proxy was told to dial a port nothing listens on: %v",
given.Given[0].Values["port"])
}
}
// A contribution from ANOTHER machine is left exactly as it was.
//
// Its port belongs to that machine's assignment, which this node's map knows nothing about —
// applying this node's map to it would move a remote workload's port to wherever a local module of
// the same name happens to be published, which is worse than the fault being fixed.
func TestAContributionFromAnotherMachineKeepsItsOwnPort(t *testing.T) {
r, err := Resolve(shelf(routeProxy(), stepCA()),
[]string{"route-proxy", "step-ca"}, reachable(), mcat.World{})
if err != nil {
t.Fatal(err)
}
out, err := Declaration(r, mcat.Rendering{
// A same-named module on this machine, moved. The grant below is a laptop's, and must not
// be dragged along with it.
Ports: map[string]map[int]int{"gitea": {3000: 20000}},
Grants: []mcat.Grant{{
Provision: "route", Consumer: "laptop", From: "gitea", At: "laptop.internal",
Values: map[string]any{"name": "git.example", "port": 3000},
}},
})
if err != nil {
t.Fatal(err)
}
routes := fileNamed(out, "route-proxy.received-route")
var given struct {
Given []mcat.Contribution `json:"given"`
}
if err := json.Unmarshal([]byte(routes["content"].(string)), &given); err != nil {
t.Fatal(err)
}
if len(given.Given) != 1 {
t.Fatalf("expected one route, got %v", given.Given)
}
if port, _ := asPort(given.Given[0].Values["port"]); port != 3000 {
t.Errorf("another machine's contribution was rewritten with this machine's ports: %v",
given.Given[0].Values["port"])
}
}
// The cross-node half of the same fault: a grant assembled on the providing machine carries the
// consumer's declared port until the CONSUMER's machine's assignments are applied to it. The
// declaration layer cannot do it — those assignments are not this machine's, which is the line the
// test above holds — so the grant layer is handed them and does it there.
func TestAContributionIsRedirectedToWhereItsOwnMachinePublishedIt(t *testing.T) {
published := map[int]int{3000: 20000}
got := AtPublishedPort(map[string]any{"name": "git.example.tld", "port": 3000}, "forge", published)
if port, _ := asPort(got["port"]); port != 20000 {
t.Errorf("the proxy would dial a port that machine never published: %v", got["port"])
}
if got["name"] != "git.example.tld" {
t.Errorf("redirecting the port disturbed the rest of the contribution: %v", got)
}
// Idempotent, and silent about a port nobody moved: a module that pinned its own mapping has no
// assignment, and must come back exactly as it was written.
again := AtPublishedPort(got, "forge", published)
if port, _ := asPort(again["port"]); port != 20000 {
t.Errorf("applying it twice moved the port again: %v", again["port"])
}
pinned := AtPublishedPort(map[string]any{"port": 9070}, "office", published)
if port, _ := asPort(pinned["port"]); port != 9070 {
t.Errorf("a port the mesh never assigned was rewritten: %v", pinned["port"])
}
}
func asStrings(v any) []string {
listed, ok := v.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(listed))
for _, one := range listed {
out = append(out, strings.TrimSpace(plainly(one)))
}
return out
}
+312
View File
@@ -0,0 +1,312 @@
package resolution
import (
"fmt"
"regexp"
"slices"
"sort"
"strconv"
"strings"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
func itemKey(it mcat.Item) string { return mcat.ItemKey(it.Collection, it.Name) }
// order is the item's place among the items of one placeholder: its `order` attribute, or OrderDefault.
func itemOrder(it mcat.Item) int {
if n, err := strconv.Atoi(it.Attributes[mcat.OrderAttribute]); err == nil && n >= 0 && n <= 99 {
return n
}
return mcat.OrderDefault
}
// matches is whether the item's attributes carry every pair of the selector.
func itemMatches(it mcat.Item, where map[string]string) bool {
for k, v := range where {
if got, has := it.Attributes[k]; !has || got != v {
return false
}
}
return true
}
// itemsInOrder is the module's items by order, then name; the order the old form placed a module's pieces in.
func itemsInOrder(items []mcat.Item) []mcat.Item {
out := append([]mcat.Item(nil), items...)
sort.SliceStable(out, func(a, b int) bool {
if oa, ob := itemOrder(out[a]), itemOrder(out[b]); oa != ob {
return oa < ob
}
return out[a].Name < out[b].Name
})
return out
}
// oldCollectionNamespaces are the namespaces read as collections of their own name.
var oldCollectionNamespaces = []string{"shell", "environment", "contribution"}
// ofCollected is where a collector places a collection. Loose inside the braces, so a misspelt name is refused.
var ofCollected = regexp.MustCompile(`\$\{collected:([^}]*)\}`)
var attributeKey = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
var attributeValue = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`)
// splitSelector is a placeholder's name without its selector, and the selector: `widgets:bar=top,place=left` is
// `widgets` and `bar=top,place=left`. A collection's name holds no `=`, so the selector begins at the last colon
// before the first one.
func splitSelector(name string) (collection, selector string) {
eq := strings.Index(name, "=")
if eq < 0 {
return name, ""
}
colon := strings.LastIndex(name[:eq], ":")
if colon < 0 {
return name, ""
}
return name[:colon], name[colon+1:]
}
// selectorOf is a selector's pairs, or why it is none.
func selectorOf(selector string) (map[string]string, error) {
out := map[string]string{}
if selector == "" {
return out, nil
}
for _, pair := range strings.Split(selector, ",") {
k, v, ok := strings.Cut(pair, "=")
switch {
case !ok || !attributeKey.MatchString(k) || !attributeValue.MatchString(v):
return nil, fmt.Errorf("%q in %q is not <attribute>=<value>", pair, selector)
case out[k] != "":
return nil, fmt.Errorf("%q names %s twice", selector, k)
}
out[k] = v
}
return out, nil
}
// collectionOfPlaceholder is the collection a placeholder's namespace and key name, and whether they name one.
func collectionOfPlaceholder(namespace, key string) (string, bool) {
key, _ = splitSelector(key)
switch {
case namespace == mcat.CollectedNamespace && strings.HasPrefix(key, mcat.EnvironmentCollection+":"):
// The environment is one collection, and the rest of the name picks the rendering (issue 795).
return mcat.EnvironmentCollection, true
case namespace == mcat.CollectedNamespace:
return key, true
case namespace == "environment":
return mcat.EnvironmentCollection, true
case slices.Contains(oldCollectionNamespaces, namespace):
return namespace + ":" + key, true
}
return "", false
}
// oldCollection is an old namespace's collection by its name: its comment, or why no item may be contributed to it.
// ok is false for a name that is not an old namespace's.
//
// forItem is whether an item is to be contributed to it: an item may be contributed to a kind received as data,
// which a collector may place as a collection only where its shape says its pieces are text (design 57 L3).
func oldCollection(name string, forItem bool) (comment string, old bool, wrong error) {
namespace, key, _ := strings.Cut(name, ":")
if !slices.Contains(oldCollectionNamespaces, namespace) {
return "", false, nil
}
switch namespace {
case "shell":
target, slot, two := strings.Cut(key, ":")
if !two || !oneOf(contributionTargets(), target) || !oneOf(knownSlots, slot) {
return "", true, fmt.Errorf("%s is no slot: shell code is shell:<shell>:<slot>, the shell one of %s, "+
"the session's file one of %s or the power moment one of %s, and the slot one of %s", name,
strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", "), strings.Join(powerMoments, ", "),
strings.Join(knownSlots, ", "))
}
// shellCode's line before each module's code.
return "#", true, nil
case "environment":
if key == "" {
// The collection itself, which an item is contributed to; a placeholder names a rendering.
return "#", true, nil
}
if key != mcat.EnvironmentPOSIX && key != mcat.EnvironmentSystemd {
return "", true, fmt.Errorf("%s is no format: the environment is environment:%s or environment:%s",
name, mcat.EnvironmentPOSIX, mcat.EnvironmentSystemd)
}
return "#", true, nil
}
seat, kind, _ := strings.Cut(key, ":")
s, r, ok := receivable(seat, kind)
switch {
case !ok && s.Name == "":
return "", true, fmt.Errorf("%s names the seat %q, which the mesh does not define", name, seat)
case !ok:
return "", true, fmt.Errorf("%s: %s; %s", name, s.Name, kindsOf(s))
case r.Shape != nil && (forItem || collectableAsText(r)):
return r.Comment, true, nil
case r.Shape != nil:
return "", true, fmt.Errorf("%s is received by %s as data in its own shape, which no item of text is "+
"(novox/hq ADR 0255)", name, s.Name)
case r.Dirs:
return "", true, fmt.Errorf("%s names its contributors' directories, which the holder reads as paths "+
"the controller fills; it is not yet a collection (design 57 L1)", name)
case s.Name != canonicalSeat(seat):
return "", true, fmt.Errorf("%s names the seat by another name; a collection names it %s", name, s.Name)
}
return r.Comment, true, nil
}
// isOld is whether a collection's name is an old namespace's.
func isOld(name string) bool {
_, old, _ := oldCollection(name, true)
return old
}
// collectedPlaceholderProblems is what is wrong with one resource's ${collected:…}: placed only in a file's content,
// and naming a collection the module declares it collects.
func collectedPlaceholderProblems(m mcat.Manifest, r map[string]any) []string {
var problems []string
for _, field := range sortedKeys(r) {
v, ok := r[field].(string)
if !ok {
continue
}
for _, f := range ofCollected.FindAllStringSubmatch(v, -1) {
base, selector := splitSelector(f[1])
if _, err := selectorOf(selector); err != nil {
problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], err))
continue
}
switch {
case field != "content":
problems = append(problems, fmt.Sprintf("%s's resource %v names %s in its %s; a collection is placed "+
"only in a file's content", m.Module, r["id"], f[0], field))
case isOld(base):
if _, _, wrong := oldCollection(base, false); wrong != nil {
problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], wrong))
}
case !slices.ContainsFunc(m.Collections, func(c mcat.Collection) bool { return c.Name == base }):
problems = append(problems, fmt.Sprintf("%s's resource %v names %s, and %s does not collect %q: "+
"declare it under collections (novox/hq ADR 0349 §13)", m.Module, r["id"], f[0], m.Module, base))
}
}
}
return problems
}
// collectionComment is the comment of a collection a module's file names: declared, or the old namespace's.
func collectionComment(m mcat.Manifest, name string) (string, error) {
if comment, old, wrong := oldCollection(name, false); old {
return comment, wrong
}
for _, c := range m.Collections {
if c.Name == name {
return c.Comment, nil
}
}
return "", fmt.Errorf("%s names ${collected:%s} and does not collect it", m.Module, name)
}
// itemsLeftOutFor is itemsLeftOut for a placeholder that selects by attributes (where), on a machine with these
// capabilities: an item the selector does not take, or whose capability the machine lacks, is not left out but not
// wanted here.
func itemsLeftOutFor(modules []mcat.Manifest, holder mcat.Manifest, collection string, where map[string]string,
facts map[string]string, caps map[string]bool) []string {
var out []string
for _, m := range inModuleOrder(modules) {
for _, it := range itemsInOrder(m.Items) {
if it.Collection != collection || !itemMatches(it, where) || (it.IfCapability != "" && !caps[it.IfCapability]) {
continue
}
if _, rendered := heldText(it, facts); rendered && renderedOldWay(collection) {
continue
}
out = append(out, fmt.Sprintf("%s's item of %s is not placed: %s is filled the old way on this "+
"machine, from contributions, and an item only by the provisioner's client (design 57 L1)",
m.Module, itemKey(it), holder.Module))
}
}
return out
}
// oldFormsLeftOut says each piece of an old form a handed collector's collections would have held: the collector is
// filled from items alone, and the piece's module contributes none (ADR 0349 clause 17).
func oldFormsLeftOut(modules []mcat.Manifest, holder mcat.Manifest, collections []mcat.PlanCollection) []string {
var out []string
for _, c := range collections {
namespace, key, _ := strings.Cut(c.Name, ":")
for _, m := range inModuleOrder(modules) {
old := false
switch namespace {
case "shell":
target, slot, _ := strings.Cut(key, ":")
old = slices.ContainsFunc(m.Shell, func(s mcat.ShellCode) bool { return s.For == target && s.Slot == slot })
case "environment":
old = m.Environment != nil && (len(m.Environment.Variables) > 0 || len(m.Environment.Path) > 0)
case "contribution":
seat, kind, _ := strings.Cut(key, ":")
old = slices.ContainsFunc(manifestAllContributions(m), func(s mcat.SeatContribution) bool {
return canonicalSeat(s.Seat) == canonicalSeat(seat) && s.Kind == kind
})
}
if old {
out = append(out, fmt.Sprintf("%s's %s is not placed: %s is filled by the provisioner's client on "+
"this machine, from items, and %s contributes it the old way (design 57 L1)", m.Module, c.Name,
holder.Module, m.Module))
}
}
}
return out
}
// renderedOldWay is whether the controller renders a collection's items into a collector not handed.
func renderedOldWay(collection string) bool {
namespace, _, _ := strings.Cut(collection, ":")
return namespace == "shell" || namespace == "contribution" || namespace == mcat.EnvironmentCollection
}
// heldItem is one item of a module rendered the old way.
type heldItem struct {
item mcat.Item
text string
}
// heldItems is a module's items of the collection that the selector takes and the machine's capabilities allow, by
// order then name, each rendered as heldText does; the ones it cannot render are left out (itemsLeftOutFor says so).
func heldItems(m mcat.Manifest, collection string, where map[string]string, facts map[string]string,
caps map[string]bool) []heldItem {
if !renderedOldWay(collection) {
return nil
}
var out []heldItem
for _, it := range itemsInOrder(m.Items) {
if it.Collection != collection || !itemMatches(it, where) || (it.IfCapability != "" && !caps[it.IfCapability]) {
continue
}
if text, ok := heldText(it, facts); ok {
out = append(out, heldItem{item: it, text: text})
}
}
return out
}
// heldText is an item's text with the machine's facts filled, ending its line; false for an item whose text the mesh
// does not hold or which names anything but a machine fact this machine has.
func heldText(it mcat.Item, facts map[string]string) (string, bool) {
if it.Content == "" {
return "", false
}
text := it.Content
for _, f := range templatePlaceholder.FindAllStringSubmatch(text, -1) {
value, has := facts[f[2]]
if f[1] != "machine" || !has {
return "", false
}
text = strings.ReplaceAll(text, f[0], value)
}
if !strings.HasSuffix(text, "\n") {
text += "\n"
}
return text, true
}
@@ -0,0 +1,110 @@
package resolution
import (
"os"
"path/filepath"
"reflect"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
const collectedBarsText = "top\n${collected:contribution:node-bar:block:bar=top,place=status}" +
"bottom\n${collected:contribution:node-bar:block:bar=bottom,place=status}"
const collectedBars = `{"module":"bars","version":"1","moved":true,
"claims":[{"name":"node-bar","scope":"node","renders":{"block":"{{.shows}}"}}],
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
"resources":[{"id":"conf","type":"file","path":"/etc/bars.conf","template":"files/bars"}]}`
const itemsOfPower = `{"module":"power","version":"1","moved":true,
"items":[{"collection":"contribution:node-bar:block","name":"battery","if-capability":"battery","attributes":{"bar":"bottom","place":"status","order":"40"},"template":"files/battery"},
{"collection":"contribution:node-bar:block","name":"draw","attributes":{"bar":"bottom","place":"status","order":"45"},"template":"files/draw"}],
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`
func collectedBarsOn(t *testing.T, caps map[string]bool) mcat.Resolution {
t.Helper()
bars := held(t, collectedBars, builtWith("bars", map[string]string{"bars": collectedBarsText}))
power := held(t, itemsOfPower, builtWith("power", map[string]string{"battery": "B\n", "draw": "D ${machine:name}\n"}))
r, err := Resolve(shelf(bars, power), []string{"bars", "power"}, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
r.Account, r.Capabilities = "op", caps
return r
}
func TestACollectorMayNameTheBarsBlocksAsACollectionAndNoOtherDataKind(t *testing.T) {
bars := held(t, collectedBars, builtWith("bars", map[string]string{"bars": collectedBarsText}))
if _, err := Compose(collectedBarsOn(t, nil), handingTo("store:5000")); err != nil {
t.Fatalf("the handed collector is refused: %v", err)
}
if got := collectedPlaceholderProblems(bars, map[string]any{"id": "x",
"content": "${collected:contribution:node-bar:block:bar=top,place=status}"}); len(got) != 0 {
t.Fatalf("the check refuses it: %v", got)
}
// A data kind whose shape does not say its items are text stays refused.
_, plain, _ := receivable(mcat.BarSeat, mcat.BarKindBlock)
plain.Shape = &mcat.Shape{}
if collectableAsText(plain) {
t.Fatal("a shape not marked as text is collectable")
}
if _, _, err := oldCollection("contribution:node-bar:block", false); err != nil {
t.Fatal(err)
}
}
// The bars of the catalogue's i3status-rust, with the blocks of power's items, compose to the files the old shaped
// contribution made (issue 781). testdata/bars holds the catalogue's bars and block texts as of the move and the bars as
// composed before it, for a machine with neither capability, the battery, the power meter and both. Where the
// collector is not handed the controller fills the files; where it is, it names the items for the client, which
// fills the same files (mesh-catalog's provisioner client holds that half against the same golden files).
func TestTheBarsOfTheCatalogueAreTheSameHandedOrNot(t *testing.T) {
read := func(name string) string {
b, err := os.ReadFile(filepath.Join("testdata", "bars", name))
if err != nil {
t.Fatal(err)
}
return string(b)
}
collector := `{"module":"bars","version":"1","moved":true,"claims":[{"name":"node-bar","scope":"node"}],
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
"resources":[{"id":"top","type":"file","path":"/t","template":"files/top"},
{"id":"bottom","type":"file","path":"/b","template":"files/bottom"}]}`
power := `{"module":"power","version":"1","moved":true,
"items":[{"collection":"contribution:node-bar:block","name":"battery","if-capability":"battery","attributes":{"bar":"bottom","place":"status","order":"40"},"template":"files/battery"},
{"collection":"contribution:node-bar:block","name":"power-draw","if-capability":"power-meter","attributes":{"bar":"bottom","place":"status","order":"45"},"template":"files/draw"}],
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`
for name, caps := range map[string]map[string]bool{"none": nil, "battery": {"battery": true},
"power-meter": {"power-meter": true}, "both": {"battery": true, "power-meter": true}} {
bars := held(t, collector, builtWith("bars", map[string]string{"top": read("top-bar.toml"), "bottom": read("bottom-bar.toml")}))
blocks := held(t, power, builtWith("power", map[string]string{"battery": read("bar-battery"), "draw": read("bar-power-draw")}))
r, err := Resolve(shelf(bars, blocks), []string{"bars", "power"}, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
r.Account, r.Capabilities = "op", caps
// Not handed: the controller fills them.
for id, which := range map[string]string{"bars.top": "top", "bars.bottom": "bottom"} {
if got := composedFile(t, r, id); got != read(name+"."+which+"-bar.toml") {
t.Errorf("%s, %s bar, not handed:\n%s\nwant\n%s", name, which, got, read(name+"."+which+"-bar.toml"))
}
}
// Handed: the plan names the items the machine's capabilities allow, for the client to fill.
c, _ := composeWith(t, r, handingTo("store:5000"))
var names []string
for _, it := range c.Plans["power"].Items {
names = append(names, it.Name)
}
want := []string{}
if caps["battery"] {
want = append(want, "battery")
}
if caps["power-meter"] {
want = append(want, "power-draw")
}
if !reflect.DeepEqual(append([]string{}, names...), want) {
t.Errorf("%s: the plan names %v, want %v", name, names, want)
}
}
}
@@ -0,0 +1,57 @@
package resolution
import (
"os"
"path/filepath"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
func l2Moved(t *testing.T, parts ...string) string {
t.Helper()
raw, err := os.ReadFile(filepath.Join(append([]string{"testdata", "l2-moved"}, parts...)...))
if err != nil {
t.Fatal(err)
}
return string(raw)
}
func TestTheCollectorsL2MovedComposeAsBeforeWhereNothingIsHanded(t *testing.T) {
// Each moved contributor, its items read back from its templates archive as the mesh holds them.
items := map[string][]string{
"asus-zephyrus-g14": {"shell:after-wake:normal", "contribution:node-hotkeys:trigger"},
"claude-code": {"shell:zsh:normal"},
"mesh-cli": {"shell:zsh:normal"},
"powerlevel10k": {"shell:zsh:normal"},
"zsh-autosuggestions": {"shell:zsh:normal"},
"zsh-syntax-highlighting": {"shell:zsh:last"},
}
var modules []mcat.Manifest
for _, module := range sortedKeys(items) {
m := mcat.Manifest{Module: module, Moved: true}
for _, collection := range items[module] {
m.Items = append(m.Items, mcat.Item{Collection: collection,
Content: l2Moved(t, "items", module+"."+strings.ReplaceAll(collection, ":", "-"))})
}
modules = append(modules, m)
}
for _, c := range []struct{ module, seat string }{{"zsh", mcat.LoginShellSeat}, {"triggerhappy", mcat.HotkeysSeat},
{"power", mcat.PowerSeat}} {
holder := mcat.Manifest{Module: c.module, Moved: true, Claims: []mcat.Claim{{Name: c.seat}}}
resource := map[string]any{"id": "file", "type": "file", "path": "/etc/" + c.module,
"content": l2Moved(t, "templates", c.module)}
var unplaced []string
if err := contributionsInto(resource, holder, append([]mcat.Manifest{holder}, modules...),
map[string]string{"name": "g14"}, mcat.Rendering{}, map[string]bool{}, &unplaced); err != nil {
t.Fatalf("%s: %v", c.module, err)
}
if got, want := resource["content"], l2Moved(t, "want", c.module); got != want {
t.Errorf("%s's file is not what the old forms made:\n%s\nwant:\n%s", c.module, got, want)
}
if len(unplaced) != 0 {
t.Errorf("%s: unplaced %v", c.module, unplaced)
}
}
}
@@ -0,0 +1,36 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
const barCollector = `{"module":"bars","version":"1","moved":true,
"collections":[{"name":"blocks","comment":"#"}],
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
"resources":[{"id":"conf","type":"file","path":"/etc/bars.conf","template":"files/bars"}]}`
func TestASelectorIsRefusedWhenMalformedOrNamingACollectionNotCollected(t *testing.T) {
for placeholder, want := range map[string]string{
"${collected:blocks:bar}": "", // no equals sign: a collection named blocks:bar
"${collected:blocks:bar=top,bar=bottom}": "names bar twice",
"${collected:blocks:bar=}": "is not <attribute>=<value>",
"${collected:other:bar=top}": `does not collect "other"`,
} {
raw := strings.Replace(barCollector, `"template":"files/bars"`, `"content":"`+placeholder+`"`, 1)
raw = strings.Replace(raw, `"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},`, "", 1)
raw = strings.Replace(raw, `"moved":true,`, "", 1)
m, err := decodeForTest([]byte(raw))
if err == nil {
_, err = Declaration((mcat.Resolution{Node: "n", Modules: []mcat.Manifest{m}}), mcat.Rendering{})
}
if want == "" {
continue
}
if err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("%s: %v, want %q", placeholder, err, want)
}
}
}
@@ -0,0 +1,13 @@
package resolution
import (
"strings"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
func builtWith(module string, texts map[string]string) []mcat.Built {
return []mcat.Built{{Name: "files", Kind: mcat.ArtifactArchive,
Reference: mcat.ArtifactStoreScheme + module + "/files/blobs/sha256:" + strings.Repeat("e", 64),
Digest: "sha256:" + strings.Repeat("e", 64), Texts: texts}}
}
@@ -0,0 +1,184 @@
package resolution
import (
"fmt"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
type fake struct {
on map[string]bool
asked []string
err error
}
func (f *fake) Resources(node string) ([]map[string]any, bool, error) {
f.asked = append(f.asked, node)
if f.err != nil {
return nil, false, f.err
}
if !f.on[node] {
return nil, false, nil
}
return []map[string]any{{"id": "peers", "type": "file", "path": "/etc/x.conf",
"merge": mcat.MergeJSON, "content": `{"peer":"` + node + `"}`}}, true, nil
}
func computedShelf() map[string]mcat.Manifest {
return shelf(mcat.Manifest{Module: "mesh-network", Computed: "mesh-network",
Provides: mcat.Offers("private-network")})
}
func TestAComputedModuleIsAskedAboutTheNodeItIsFor(t *testing.T) {
// The generator gets a node name, not a plan. Everything it needs is the whole mesh, which
// it was built with — this is the one thing a node could never work out for itself.
got, err := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
gen := &fake{on: map[string]bool{"workstation": true}}
out, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": gen}})
if err != nil {
t.Fatal(err)
}
if len(gen.asked) != 1 || gen.asked[0] != "workstation" {
t.Fatalf("asked about %v, wanted workstation once", gen.asked)
}
if len(out) != 1 || !strings.Contains(out[0]["content"].(string), `"peer": "workstation"`) {
t.Fatalf("got %v", out)
}
}
func TestAMachineNobodyGaveItGetsNothing(t *testing.T) {
// The whole point of making the network a module. Before this, every machine with an address
// was on the private network and there was no way to say one should stay off.
got, err := Resolve(computedShelf(), nil, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
gen := &fake{on: map[string]bool{"workstation": true}}
out, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": gen}})
if err != nil {
t.Fatal(err)
}
if len(out) != 0 {
t.Fatalf("a machine that was assigned nothing got %d resource(s)", len(out))
}
if len(gen.asked) != 0 {
t.Fatalf("the generator was asked about %v, and nobody had asked for it", gen.asked)
}
}
func TestAssignedAndNotYetPartOfItIsNotAFailure(t *testing.T) {
// A machine given the network module before it has a place on it. Brief and ordinary — the
// answer is "nothing yet", and treating it as an error would make an ordering a fault.
got, _ := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), mcat.World{})
gen := &fake{on: map[string]bool{}}
out, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": gen}})
if err != nil {
t.Fatalf("refused a node that is not on the network yet: %v", err)
}
if len(out) != 0 {
t.Fatalf("got %v", out)
}
}
func TestAGeneratorThisControlPlaneDoesNotHaveIsRefused(t *testing.T) {
// Sending a machine a module with no files would look like it worked. Named in the message,
// because the only fix is a control plane that has it.
got, _ := Resolve(shelf(mcat.Manifest{Module: "weather", Computed: "the-weather"}), []string{"weather"}, workstation(), mcat.World{})
_, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": &fake{}}})
if err == nil {
t.Fatal("a module computed by nothing was accepted")
}
if !strings.Contains(err.Error(), "the-weather") {
t.Fatalf("the refusal does not name what is missing: %v", err)
}
}
func TestSettingsApplyToAComputedModuleToo(t *testing.T) {
// Being computed is about where the files come from, not about whether they are configurable.
// A line drawn there would be arbitrary and nobody could predict it.
got, _ := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), mcat.World{})
gen := &fake{on: map[string]bool{"workstation": true}}
out, err := Declaration(got, mcat.Rendering{
Generators: map[string]mcat.Generator{"mesh-network": gen},
Settings: mcat.SettingsBy{"mesh-network": {{From: "the mesh",
Values: map[string]any{"keepalive": 25}}}},
})
if err != nil {
t.Fatal(err)
}
content := out[0]["content"].(string)
if !strings.Contains(content, `"keepalive": 25`) {
t.Fatalf("the setting did not reach a computed file: %s", content)
}
if !strings.Contains(content, `"peer": "workstation"`) {
t.Fatalf("the setting replaced what the generator computed: %s", content)
}
}
func TestTwoWaysToBeOnAPrivateNetworkRefuseAndNameBoth(t *testing.T) {
// WireGuard is one way. The refusing rule is what makes a second one safe to add: assigning
// both is caught rather than producing a machine on two networks that each half-work.
_, err := Resolve(shelf(mcat.Manifest{Module: "mesh-network", Computed: "mesh-network",
Provides: mcat.Offers("private-network")}, mcat.Manifest{Module: "tailscale", Provides: mcat.Offers("private-network")}, mcat.Manifest{Module: "backups", Requires: []string{"private-network"}},
), []string{"backups"}, workstation(), mcat.World{})
if err == nil {
t.Fatal("two answers to one requirement were taken silently")
}
for _, want := range []string{"mesh-network", "tailscale", "private-network"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("the refusal does not name %s: %v", want, err)
}
}
}
// reloading answers the runtime's trust as the networking module once did (novox/hq ADR 0102; it no
// longer does, ADR 0222 — and beside the runtime's module it is refused, generated_collision_test): a file
// written into, and the runtime reloaded on it.
type reloading struct{}
func (reloading) Resources(node string) ([]map[string]any, bool, error) {
return []map[string]any{
{"id": "registry-trust", "type": "file", "path": "/etc/docker/daemon.json",
"merge": mcat.MergeJSON, "into": "json", "content": `{"insecure-registries":["r:5000"]}`},
{"id": "registry-trust-reload", "type": "service", "unit": "docker.service",
"state": "running", "reload-on": []string{"registry-trust"}},
}, true, nil
}
func TestWhatAServiceIsReloadedOnIsNamedAsTheHostWillSeeIt(t *testing.T) {
// Ids are prefixed with their module on the way out. An unprefixed reload-on would name a
// resource the host never sees, and the runtime would never be reloaded for its trust.
got, err := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
out, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": reloading{}}})
if err != nil {
t.Fatal(err)
}
var file, service map[string]any
for _, r := range out {
switch r["type"] {
case "file":
file = r
case "service":
service = r
}
}
if file == nil || service == nil {
t.Fatalf("got %v", out)
}
if file["into"] != "json" {
t.Errorf("into did not reach the host: %v", file)
}
if want := "[" + file["id"].(string) + "]"; fmt.Sprint(service["reload-on"]) != want {
t.Errorf("reload-on names %v, the file is %v", service["reload-on"], file["id"])
}
}
+181
View File
@@ -0,0 +1,181 @@
package resolution
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// inConfigForm is a module in the config form that registers: every value typed, described, defaulted or
// required, placed in a file or read at runtime, and its mergeable file naming its keys.
func inConfigForm() map[string]any {
return map[string]any{
"module": "notifier",
"version": "1",
"config": map[string]any{
"font-size": map[string]any{"type": "number", "description": "the notification's font size, in points",
"minimum": 6, "maximum": 40, "default": 10},
"width": map[string]any{"type": "number", "description": "the notification's width, in pixels",
"default": 250},
"position": map[string]any{"type": "string", "description": "the corner it shows in, a word",
"enum": []any{"top-right", "top-left"}, "default": "top-right"},
"theme": map[string]any{"type": "string", "description": "the colour theme, a lower-case name",
"pattern": "[a-z]+", "default": "dark"},
"history": map[string]any{"type": "number", "description": "how many notifications it keeps, a count",
"default": 20, "runtime": true},
"sender": map[string]any{"type": "string", "description": "the address it mails a digest from",
"required": true},
},
"resources": []any{
map[string]any{"id": "configuration", "type": "file", "path": "/x/notifierrc",
"content": "font = Inter ${config:font-size}\nwidth = ${config:width}\nfrom = ${config:sender}\n"},
map[string]any{"id": "rules", "type": "file", "path": "/x/rules.json", "merge": "json",
"takes-config": []any{"position", "theme"}, "content": "{\"position\": \"top-right\"}\n"},
map[string]any{"id": "notifier", "type": "service", "unit": "notifier.service",
"reload-on": []any{"configuration"}, "restart-on": []any{"rules"}},
},
}
}
func parseRaw(t *testing.T, m map[string]any) (mcat.Manifest, error) {
t.Helper()
raw, err := json.Marshal(m)
if err != nil {
t.Fatal(err)
}
return decodeForTest(raw)
}
// settingsDunst is the dunst module as it is written today; configDunst the same module rewritten from settings to
// config.
const settingsDunst = `{"module": "dunst", "version": "1",
"settings": {
"font-size": {"kind": "preference", "default": 10, "why": "10 points of Inter reads well on a screen of about 100 DPI"},
"width": {"kind": "preference", "default": 250, "why": "250 pixels fits a title of about forty characters at 10 points"}
},
"resources": [
{"id": "package", "type": "package", "package": "dunst"},
{"id": "configuration", "type": "file", "path": "/home/me/.config/dunst/dunstrc", "mode": "0644",
"content": "[global]\n font = Inter ${setting:font-size}\n width = ${setting:width}\n"},
{"id": "rules", "type": "file", "path": "/home/me/.config/dunst/rules.json", "merge": "json",
"takes-settings": [], "content": "{\"urgency\": \"low\"}\n"},
{"id": "notifier", "type": "service", "unit": "dunst.service", "reload-on": ["configuration"]}
]}`
const configDunst = `{"module": "dunst", "version": "1",
"config": {
"font-size": {"type": "number", "description": "the font size of a notification, in points", "minimum": 6,
"maximum": 40, "default": 10},
"width": {"type": "number", "description": "the width of a notification, in pixels", "minimum": 100,
"default": 250}
},
"resources": [
{"id": "package", "type": "package", "package": "dunst"},
{"id": "configuration", "type": "file", "path": "/home/me/.config/dunst/dunstrc", "mode": "0644",
"content": "[global]\n font = Inter ${config:font-size}\n width = ${config:width}\n"},
{"id": "rules", "type": "file", "path": "/home/me/.config/dunst/rules.json", "merge": "json",
"takes-config": [], "content": "{\"urgency\": \"low\"}\n"},
{"id": "notifier", "type": "service", "unit": "dunst.service", "reload-on": ["configuration"]}
]}`
// ${config:<key>} fills exactly as ${setting:<key>} does: the module rewritten from settings to config composes
// to the same bytes, with no layer, with the node's, and with the mesh's under the node's.
func TestAModuleRewrittenFromSettingsToConfigComposesByteIdentically(t *testing.T) {
composed := func(manifest string, layers []mcat.Layer) []byte {
t.Helper()
m, err := decodeForTest([]byte(manifest))
if err != nil {
t.Fatal(err)
}
r := anAdoptedAnchor()
r.Modules = append(r.Modules, m)
with := anchorRendering(false)
if layers != nil {
with.Settings["dunst"] = layers
}
c, err := Compose(r, with)
if err != nil {
t.Fatal(err)
}
if why, left := c.LeftOut["dunst"]; left {
t.Fatalf("dunst was left out: %s", why)
}
raw, err := json.Marshal(c)
if err != nil {
t.Fatal(err)
}
return raw
}
for _, layers := range [][]mcat.Layer{
nil,
{{From: "anchor", Values: map[string]any{"font-size": float64(13)}}},
{{From: mcat.MeshWideLayer, Values: map[string]any{"width": float64(300)}},
{From: "anchor", Values: map[string]any{"font-size": float64(12), "width": float64(320)}}},
} {
was, is := composed(settingsDunst, layers), composed(configDunst, layers)
if !bytes.Equal(was, is) {
t.Fatalf("composed differently with %v:\nsettings: %s\nconfig: %s", layers, was, is)
}
if !strings.Contains(string(is), "font = Inter 1") || strings.Contains(string(is), "${config:") {
t.Fatalf("not filled: %s", is)
}
}
}
// A required value nothing sets is refused at composition in the config form's words: ${config:<key>} and the
// config verb, never `settings set` (review of mesh-controller#320). The settings form's words are unchanged.
func TestAnUnsetRequiredValueIsRefusedInTheConfigFormsWords(t *testing.T) {
m, err := parseRaw(t, inConfigForm())
if err != nil {
t.Fatal(err)
}
r := anAdoptedAnchor()
r.Modules = append(r.Modules, m)
composed, err := Compose(r, anchorRendering(false))
if err != nil {
t.Fatal(err)
}
why, left := composed.LeftOut["notifier"]
if !left {
t.Fatal("a module whose required value nothing sets was not left out")
}
for _, said := range []string{"${config:sender}", `required config value "sender"`, "config verb"} {
if !strings.Contains(why, said) {
t.Fatalf("left out without %q: %s", said, why)
}
}
for _, unsaid := range []string{"${setting:", "settings set"} {
if strings.Contains(why, unsaid) {
t.Fatalf("left out in the settings form's words (%q): %s", unsaid, why)
}
}
var unset *UnsetSettingError
if err := unsetInConfigWords(settingInto(map[string]any{"type": "file", "content": "${setting:sender}"}, nil,
"notifier"), m); !errors.As(err, &unset) || unset.Setting != "sender" {
t.Fatalf("not typed as a value nobody gave: %v", err)
}
// A contribution's value too (issue 1126, seed-8: de-spiegel's route names were refused with ${setting:…}).
withRoute := inConfigForm()
withRoute["contributes"] = map[string]any{"route": map[string]any{"site": map[string]any{"name": "${config:sender}", "port": 80}}}
routed, err := parseRaw(t, withRoute)
if err != nil {
t.Fatal(err)
}
_, err = resolutionComposed(mcat.Resolution{}, routed, "route", routed.ContributesMany["route"]["site"], nil, "notifier contributing site to route")
if !errors.As(err, &unset) || unset.Module != "notifier" || !strings.Contains(err.Error(),
`notifier has a contribution site to route, in "name", that says ${config:sender}`) || strings.Contains(err.Error(), "${setting:") {
t.Fatalf("a contribution's unset value is not said in the config form's words: %v", err)
}
settingsForm := notifier()
settingsForm.Resources[0]["content"] = "from = ${setting:domain}\n"
err = unsetInConfigWords(settingInto(settingsForm.Resources[0], nil, "notifier"), settingsForm)
if err == nil || !strings.Contains(err.Error(), "`settings set notifier <file>`") {
t.Fatalf("the settings form's refusal changed: %v", err)
}
}
@@ -0,0 +1,267 @@
package resolution
import (
"fmt"
"regexp"
"sort"
"strings"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// consumerFact is `${consumer:<fact>}` or `${consumer:<fact>:<alphabet>}`.
var consumerFact = regexp.MustCompile(`\$\{consumer:([a-z][a-z0-9-]*)(?::([a-z][a-z0-9-]*))?\}`)
// consumerFacts are what a served value may name about the consumer it is being derived for.
// One entry, deliberately: the identity is the one thing about a consumer the mesh itself chose,
// so it is the one thing the mesh can hand to a provider without either end guessing.
var consumerFacts = []string{"as"}
// consumerAlphabets are the ways the mesh will write that identity. `dns` is the mesh's own
// identifier with its separator written `-` instead of `_` — the whole of the difference between
// the alphabet the mesh mints in and the one buckets, vhosts and hostnames accept.
var consumerAlphabets = []string{"dns"}
// ServedTo fills a provider's served values for one consumer.
//
// `as` is the identity the mesh minted for that consumer — the same string it is told to present
// as a login. Values with no placeholder are returned exactly as they were, and a block with no
// placeholder at all is returned unchanged, so this costs nothing for the providers that derive
// nothing.
//
// Only strings carry placeholders. A number, a boolean or a nested object is a value the provider
// stated outright, and is left alone.
func ServedTo(serves map[string]any, as string) (map[string]any, error) {
if len(serves) == 0 {
return serves, nil
}
var out map[string]any
for _, key := range sortedAnyKeys(serves) {
text, ok := serves[key].(string)
if !ok || !strings.Contains(text, "${consumer:") {
continue
}
filled, err := consumerInto(text, as)
if err != nil {
return nil, fmt.Errorf("the value served as %q: %w", key, err)
}
if out == nil {
// Copied only once something actually changes: the caller's map is the manifest's,
// and a provider that derives nothing must not have it rewritten underneath it.
out = make(map[string]any, len(serves))
for k, v := range serves {
out[k] = v
}
}
out[key] = filled
}
if out == nil {
return serves, nil
}
return out, nil
}
// consumerInto replaces every `${consumer:…}` in one value.
//
// **A fact or an alphabet the mesh does not have is refused, not left standing.** Written through,
// the literal `${consumer:as}` would reach a configuration file and be read as a bucket name,
// failing somewhere that names neither the module nor the mesh — the same reasoning `${bound:…}`
// is refused by (boundInto).
func consumerInto(value, as string) (string, error) {
var failed error
out := consumerFact.ReplaceAllStringFunc(value, func(match string) string {
parts := consumerFact.FindStringSubmatch(match)
fact, alphabet := parts[1], parts[2]
if fact != "as" {
if failed == nil {
failed = fmt.Errorf(
"says %s, and the mesh states %s about a consumer", match, orNothing(consumerFacts))
}
return match
}
switch alphabet {
case "":
return as
case "dns":
return asDNSLabel(as)
default:
if failed == nil {
failed = fmt.Errorf(
"says %s, and the mesh writes an identity as %s", match, orNothing(consumerAlphabets))
}
return match
}
})
if failed != nil {
return "", failed
}
return out, nil
}
// asDNSLabel writes a minted identity as a DNS label.
//
// The mesh's identities are already lower-case letters, digits and `_` (ConsumerIdentity), and
// already inside the bound of the provision serving them: a provider that serves one as a DNS label
// bounds it at 63 or less (CheckServes, novox/hq ADR 0225), and one that serves it at all without
// saying is held to twenty (IdentityBoundOf). So
// this is the separator and nothing else — no lower-casing of what is already lower case, no
// truncation to a limit the identity is already inside, no padding of a name that is already long
// enough. Each of those would be the mesh guessing at a rule it has not been given.
func asDNSLabel(as string) string {
return strings.ReplaceAll(as, "_", "-")
}
func sortedAnyKeys(values map[string]any) []string {
out := make([]string, 0, len(values))
for k := range values {
out = append(out, k)
}
sort.Strings(out)
return out
}
// derivedFor is what the provider on this machine derives for one consumer of one provision
// (novox/hq ADR 0202).
//
// Settled first, then derived: an operator may set a prefix on what the provider serves and the
// mesh still fills the consumer's half of it ([ADR 0174]). Only the keys that actually name the
// consumer are returned — the rest of a `serves` block is the same for every consumer and is
// already in the provider's own definition, so repeating it here would be a second copy to go
// stale.
//
// The first module in the resolved order that says it serves the provision answers, which is the
// choice servedOnThisMachine makes for the consumer's half. Nothing serving it on this machine is
// not an error: a contribution can reach a machine whose provider is a record or an adapter, and
// then there is nothing derived to tell.
func resolutionDerivedFor(r mcat.Resolution, provision, as, consumer, local string, settings mcat.SettingsBy) (map[string]any, error) {
for _, m := range r.Modules {
serves, said := m.Serves[provision]
if !said {
continue
}
var names map[string]any
for key, value := range serves {
if text, ok := value.(string); ok && strings.Contains(text, "${consumer:") {
if names == nil {
names = map[string]any{}
}
names[key] = value
}
}
if names == nil {
return nil, nil
}
// **A consumer that keeps several holders of this provision is refused** — this is issue
// 124's own failure one case to the side, and it would be just as quiet.
//
// Each holder gets its own login, `…_<local>` (ADR 0094), and a provider derives from the
// login, so it would make one resource per holder. The consumer's side has no such
// dimension: one binding file per provision, one `${bound:<provision>:<key>}`, both
// derived from the un-suffixed identity. So the provider would create the holder's
// resource and the consumer would be configured against a name nothing made — it would
// authenticate successfully and be refused on every object, which reads like a credential
// fault and is not one.
//
// Lifting this means giving the consumer's side a local dimension. That is a decision,
// not an omission, and until it is taken the mesh says so rather than guessing.
if local != "" {
return nil, fmt.Errorf(
"%s keeps several holders of %s (this one is %q), and %s derives %s for each "+
"consumer from the login the mesh minted. Each holder has its own login, and a "+
"consumer is told one value per requirement — so the two ends would name "+
"different things and nothing would compare them (novox/hq ADR 0202)",
consumer, local, provision, m.Module, orNothing(sortedAnyKeys(names)))
}
settled, err := Settle(names, WithDefaults(m, settings[m.Module]))
if err != nil {
return nil, fmt.Errorf("%s serving %s: %w", m.Module, provision, err)
}
derived, err := ServedTo(settled, as)
if err != nil {
return nil, fmt.Errorf("%s serving %s to %s: %w", m.Module, provision, as, err)
}
return derived, nil
}
return nil, nil
}
// notTranscribed refuses a consumer's file that writes out the value its provider derives for it,
// instead of asking for it (novox/hq ADR 0202, issue 124).
//
// **What would have caught the one wrong instance.** The object store's three consumers each wrote
// their bucket into their own configuration by hand. One of them named a predecessor's bucket, and
// nothing compared it to what the provider would actually create: the module would have
// authenticated successfully and been refused on every object, which reads like a credential fault
// and is not one. It looked authoritative for months.
//
// The test is exact and costs one string search: a definition whose file already contains the
// value the mesh is about to derive for it has written down somebody else's rule. It cannot be a
// coincidence — a derived value carries the identity the mesh minted for this very consumer on
// this very machine, which nothing else would spell out — and it cannot be checked afterwards,
// because after substitution every consumer's file contains it legitimately.
//
// Only values that actually name the consumer are judged. A provider that serves a constant under
// the same key serves the same constant to everyone, and a consumer repeating it is redundant
// rather than wrong.
func notTranscribed(resource map[string]any, known map[string]map[string]string, module string) error {
if fmt.Sprint(resource["type"]) != "file" {
return nil
}
content, ok := resource["content"].(string)
if !ok || content == "" {
return nil
}
for _, provision := range sortedKnown(known) {
values := known[provision]
identity := values["as"]
if identity == "" {
continue
}
for _, key := range sortedStringKeys(values) {
if key == "as" {
// The login is not derived from itself, and a consumer that must present it in a
// connection string legitimately has it from `${bound:…}` — which is what it will
// be after substitution, so this would judge the substitution, not the module.
continue
}
value := values[key]
if value == "" || !namesTheConsumer(value, identity) {
continue
}
if !strings.Contains(content, value) {
continue
}
return fmt.Errorf(
"%s writes %q into %v, and that is exactly what %s derives for it — a definition "+
"keeping its own copy of somebody else's naming rule is one that can disagree "+
"with it, silently. Say ${bound:%s:%s} and be told",
module, value, resource["id"], provision, provision, key)
}
}
return nil
}
// namesTheConsumer is whether a derived value was built from this consumer's identity — in the
// alphabet it was minted in, or as a DNS label. A value that does not contain it was not derived
// from it, whatever else it may be.
func namesTheConsumer(value, identity string) bool {
return strings.Contains(value, identity) || strings.Contains(value, asDNSLabel(identity))
}
func sortedKnown(known map[string]map[string]string) []string {
out := make([]string, 0, len(known))
for k := range known {
out = append(out, k)
}
sort.Strings(out)
return out
}
func sortedStringKeys(values map[string]string) []string {
out := make([]string, 0, len(values))
for k := range values {
out = append(out, k)
}
sort.Strings(out)
return out
}
+100
View File
@@ -0,0 +1,100 @@
package resolution
import (
"fmt"
"regexp"
"slices"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// ofConsumers is where a provider places its consumers' credentials. Loose inside the braces, so a misspelt name is
// refused rather than passed over.
var ofConsumers = regexp.MustCompile(`\$\{consumers:([^}]*)\}`)
// consumersPlaceholderProblems is what is wrong with one resource's ${consumers:…}: placed only in a file's content,
// for a provision the module provides, in a file only the provider reads and the client writes back.
func consumersPlaceholderProblems(m mcat.Manifest, r map[string]any) []string {
var problems []string
for _, field := range sortedKeys(r) {
v, ok := r[field].(string)
if !ok {
continue
}
for _, f := range ofConsumers.FindAllStringSubmatch(v, -1) {
switch {
case field != "content" || fmt.Sprint(r["type"]) != "file":
problems = append(problems, fmt.Sprintf("%s's resource %v names %s in its %s; a provider's consumers "+
"are placed only in a file's content (novox/hq ADR 0373)", m.Module, r["id"], f[0], field))
continue
case !slices.ContainsFunc(m.Provides, func(o mcat.Offer) bool { return o.Name == f[1] }):
problems = append(problems, fmt.Sprintf("%s's resource %v names %s, and %s does not provide %q: only a "+
"provider's template holds its consumers' credentials (novox/hq ADR 0373)", m.Module, r["id"], f[0],
m.Module, f[1]))
}
if mode := fmt.Sprint(r["mode"]); mode != "0600" && mode != "0400" {
problems = append(problems, fmt.Sprintf("%s's resource %v holds its consumers' credentials (%s) with "+
"mode %q: the provider alone reads it, 0600 or 0400 (novox/hq ADR 0373)", m.Module, r["id"], f[0],
r["mode"]))
}
if owner := ownershipOf(r); owner != mcat.RuntimeOwned {
problems = append(problems, fmt.Sprintf("%s's resource %v holds its consumers' credentials (%s) and is "+
"%s at runtime: it is owned, written back whenever it differs (novox/hq ADR 0373)", m.Module,
r["id"], f[0], owner))
}
}
}
return problems
}
// consumersHandedOnly says what a file naming ${consumers:…} is where it would be written the old way: the provisioner's
// client alone fills it, so on a machine that does not hand the module it is **left out** of the declaration, never
// written as that text or as an empty list the provider would read as no consumers, and never the reason the module is
// refused: a provider not handed there keeps working as it did, from its grant files (novox/hq ADR 0373, issue 780).
// A moved module judged from its repository's tree (WithTemplatesFrom: `module check`), on a machine
// that would hand it, has no templates archive to hand and is judged as it composes; there the placeholder is judged
// and taken out of the text, since what is composed so is never sent (judged answers whether the file is so judged).
func consumersHandedOnly(m mcat.Manifest, r map[string]any, with mcat.Rendering) (judged, leftOut bool) {
content, _ := r["content"].(string)
switch {
case ofConsumers.FindString(content) == "":
return false, false
case m.Moved && m.Handed == nil && with.Hands && with.Provisioner != "":
return true, false
}
return false, true
}
// consumersLeftOutLine is the plan's one line for a file left out because the module is not handed.
func consumersLeftOutLine(m mcat.Manifest, r map[string]any) string {
return fmt.Sprintf("%s's %v names ${consumers:…}, which the provisioner's client alone fills: left out, handed only "+
"(novox/hq ADR 0373)", m.Module, r["id"])
}
// consumersNotHanded is consumersHandedOnly as a refusal, for a caller that has no way to leave the file out.
func consumersNotHanded(m mcat.Manifest, r map[string]any, with mcat.Rendering) (judged bool, err error) {
judged, left := consumersHandedOnly(m, r, with)
if left {
return false, fmt.Errorf("%s's resource %v names %s, which the provisioner's client alone fills, and %s is not "+
"handed to it on this machine: it would be written as that text, or empty, and the provider would read no "+
"consumers (novox/hq ADR 0373)", m.Module, r["id"], ofConsumers.FindString(fmt.Sprint(r["content"])), m.Module)
}
return judged, nil
}
// consumersOfTemplate is what is wrong with a handed file's template naming ${consumers:…}, judged on the file's
// resource as if its text were inline.
func consumersOfTemplate(m mcat.Manifest, id, text string) []string {
for _, r := range m.Resources {
if fmt.Sprint(r["id"]) != id {
continue
}
withText := map[string]any{}
for k, v := range r {
withText[k] = v
}
withText["content"] = text
return consumersPlaceholderProblems(m, withText)
}
return nil
}
@@ -0,0 +1,311 @@
package resolution
import (
"encoding/json"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
func published(module, host string, port int) mcat.Manifest {
return mcat.Manifest{Module: module, Version: "1",
Contributes: map[string]map[string]any{
"reverse-proxy": {"host": host, "port": port},
}}
}
func proxy() mcat.Manifest {
return mcat.Manifest{Module: "traefik", Version: "1",
Provides: mcat.Offers("reverse-proxy"),
Receives: map[string]string{"reverse-proxy": "/etc/traefik/mesh.json"},
Resources: []map[string]any{
{"id": "up", "type": "service", "unit": "traefik", "state": "running",
"restart-on": []any{mcat.ReceivedID("reverse-proxy")}},
}}
}
// received digs the delivered contributions back out of a declaration.
func received(t *testing.T, out []map[string]any) []mcat.Contribution {
t.Helper()
for _, r := range out {
if r["path"] != "/etc/traefik/mesh.json" {
continue
}
body := r["content"].(string)
var parsed struct {
Requirement string `json:"requirement"`
Given []mcat.Contribution `json:"given"`
}
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
t.Fatalf("the file the proxy is given is not readable: %v\n%s", err, body)
}
if parsed.Requirement != "reverse-proxy" {
t.Fatalf("the file does not say what it is about: %q", parsed.Requirement)
}
return parsed.Given
}
t.Fatalf("the provider was given no file at all: %v", out)
return nil
}
func TestTheFileTheProviderGetsIsMachineReadable(t *testing.T) {
// It is written for a program, and the first version put a `//` header above the JSON — a
// file that says "do not edit" to a person and fails to parse for the thing meant to read it.
got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{})
for _, r := range mustDeclare(t, got) {
if r["path"] != "/etc/traefik/mesh.json" {
continue
}
var any map[string]any
if err := json.Unmarshal([]byte(r["content"].(string)), &any); err != nil {
t.Fatalf("the file is not parseable: %v\n%s", err, r["content"])
}
if note, _ := any["generated"].(string); !strings.Contains(note, "do not edit") {
// Inside the document rather than above it, so it reaches a person without
// breaking the parse.
t.Fatalf("the file does not say it is generated: %v", any["generated"])
}
return
}
t.Fatal("no received file")
}
func TestAModuleTellsItsProviderWhatItNeeds(t *testing.T) {
got, err := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
out, err := Declaration(got, mcat.Rendering{})
if err != nil {
t.Fatal(err)
}
given := received(t, out)
if len(given) != 1 || given[0].From != "board" {
t.Fatalf("the proxy was not told about board: %v", given)
}
if given[0].Values["host"] != "board" || given[0].Values["port"] != float64(8080) {
t.Fatalf("the contribution did not survive: %v", given[0].Values)
}
}
func TestContributingToSomethingIsRequiringIt(t *testing.T) {
// Asking to be published means a publisher must exist. A module that had to say both would
// eventually say one, and the failure would be a machine where nothing serves the route.
got, err := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(strings.Join(names(got), " "), "traefik") {
t.Fatalf("contributing to reverse-proxy did not bring one in: %v", names(got))
}
}
func TestNothingToContributeToIsRefused(t *testing.T) {
_, err := Resolve(shelf(published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{})
if err == nil {
t.Fatal("a module was published through a proxy that does not exist")
}
if !strings.Contains(err.Error(), "reverse-proxy") {
t.Fatalf("the refusal does not name what is missing: %v", err)
}
}
func TestEveryPublisherOnTheMachineIsInOneFile(t *testing.T) {
got, err := Resolve(shelf(proxy(),
published("board", "board", 8080),
published("archive", "archive", 9000),
), []string{"board", "archive"}, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
given := received(t, mustDeclare(t, got))
if len(given) != 2 {
t.Fatalf("the proxy was told about %d of 2: %v", len(given), given)
}
// Ordered by module, because this becomes a file and a file whose lines move about looks
// changed when nothing changed — which would restart the proxy for ever.
if given[0].From != "archive" || given[1].From != "board" {
t.Fatalf("the order is not stable: %v", given)
}
}
func TestAProviderWithNoConsumersStillGetsTheFile(t *testing.T) {
// Empty rather than absent. A provider that finds no file cannot tell "nothing asked for me"
// from "the mesh never wrote it", and those want completely different responses — the same
// rule the host follows about a service that does not exist.
got, err := Resolve(shelf(proxy()), []string{"traefik"}, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
if given := received(t, mustDeclare(t, got)); len(given) != 0 {
t.Fatalf("got %v", given)
}
}
func TestTheProviderCanReloadWhenTheRoutesChange(t *testing.T) {
// A proxy that got a new route and did not reload is a route that silently does not work.
// The same fault the private network had when a peer list changed under a running interface,
// which is why the received file has a name a module can point at.
got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{})
out := mustDeclare(t, got)
for _, r := range out {
if r["type"] != "service" {
continue
}
reflects, ok := r["restart-on"].([]any)
if !ok || len(reflects) != 1 {
t.Fatalf("the proxy does not reflect anything: %v", r)
}
if reflects[0] != "traefik."+mcat.ReceivedID("reverse-proxy") {
t.Fatalf("it reflects %v, which is not the file it was given", reflects[0])
}
return
}
t.Fatal("no service in the declaration")
}
func TestARouteCanBeSetPerMesh(t *testing.T) {
// The hostname is exactly the kind of thing that differs between one mesh and the next. A
// module whose route could not be set would have to be edited to be reused anywhere.
got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{})
out, err := Declaration(got, mcat.Rendering{Settings: mcat.SettingsBy{
"board": {{From: "the mesh", Values: map[string]any{"host": "dashboard"}}},
}})
if err != nil {
t.Fatal(err)
}
given := received(t, out)
if given[0].Values["host"] != "dashboard" {
t.Fatalf("the setting did not reach the route: %v", given[0].Values)
}
if given[0].Values["port"] != float64(8080) {
t.Fatalf("setting the host dropped the port: %v", given[0].Values)
}
}
func TestASettingReachesAContributionOnlyWhereItDeclaresTheKey(t *testing.T) {
// novox/hq 04-ISSUES/173: the mail module's site name, set so its environment file could read
// it, arrived in every route it contributed. A setting overrides a key the contribution
// declares and adds none — the provider reads the contribution as a contract.
got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{})
out, err := Declaration(got, mcat.Rendering{Settings: mcat.SettingsBy{
"board": {{From: "the mesh", Values: map[string]any{"host": "dashboard", "sitename": "Board"}}},
}})
if err != nil {
t.Fatal(err)
}
given := received(t, out)
if given[0].Values["host"] != "dashboard" {
t.Fatalf("the setting did not override the route's host: %v", given[0].Values)
}
if _, leaked := given[0].Values["sitename"]; leaked {
t.Fatalf("a setting the route never declared reached the proxy: %v", given[0].Values)
}
}
func provider() mcat.Manifest {
return mcat.Manifest{Module: "postgres", Version: "1",
Provides: mcat.FromAnywhere("postgres-database"),
Receives: map[string]string{"postgres-database": "/var/lib/postgres/grants/mesh.json"},
Grants: map[string]string{"postgres-database": "/var/lib/postgres/grants"},
}
}
func grantedTo(t *testing.T, out []map[string]any) []mcat.Contribution {
t.Helper()
for _, r := range out {
if r["path"] != "/var/lib/postgres/grants/mesh.json" {
continue
}
var parsed struct {
Given []mcat.Contribution `json:"given"`
}
if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil {
t.Fatal(err)
}
return parsed.Given
}
t.Fatalf("the provider was given no manifest: %v", out)
return nil
}
func TestAConsumersOwnContributionsAreStillThere(t *testing.T) {
// Cross-node grants are merged in with this machine's own, because from the provider's side
// they are the same thing — somebody wanting something — and a provider that had to read two
// lists would read one of them.
got, err := Resolve(shelf(proxy(), published("board", "board", 8080)),
[]string{"board"}, reachable(), mcat.World{})
if err != nil {
t.Fatal(err)
}
given := received(t, mustDeclare(t, got))
if len(given) != 1 || given[0].Node != "" {
t.Fatalf("a local contribution grew a node: %v", given)
}
}
func TestARequireOnlyConsumerOfAParameterlessProvisionStillAsks(t *testing.T) {
// A consumer that requires a parameterless provision (one whose serves names no consumer key —
// redis-cache, amqp) contributes no payload, but it still ASKS for the provision and must be
// granted a credential. Keying "asks" on contributions alone gave its grant From="" — read as
// withdrawn — so the provider never created the account and the consumer authenticated nowhere.
r := mcat.Resolution{
Node: "laptop",
Modules: []mcat.Manifest{{
Module: "amqp-ping",
Requires: []string{"amqp"},
}},
}
values, asks, err := ContributionsFrom(r, "amqp", "amqp-ping", mcat.SettingsBy{})
if err != nil {
t.Fatal(err)
}
if !asks {
t.Fatal("a require-only consumer was treated as not asking; its grant would be withdrawn and it would never be provisioned")
}
if values == nil {
t.Fatalf("asks is true but values is nil; expected an empty contribution, got %v", values)
}
}
func TestAConsumerThatStoppedAskingIsWithdrawn(t *testing.T) {
// Withdrawal is how a credential is taken away. The provisioner removes what nobody asks for,
// and it can only do that if the mesh stops asking — a consumer that was unassigned would
// otherwise keep a working login for ever, and nothing would say so.
//
// A grant with no asking module is exactly that state: the mesh still holds the secret,
// because it is sealed and unusable to the mesh anyway, and the machine no longer wants it.
got, err := Resolve(shelf(provider()), []string{"postgres"}, reachable(), mcat.World{})
if err != nil {
t.Fatal(err)
}
out, err := Declaration(got, mcat.Rendering{Grants: []mcat.Grant{
{Provision: "postgres-database", Consumer: "still-here", From: "meshboard",
Values: map[string]any{"name": "meshboard"}, Sealed: "c2VhbGVk"},
{Provision: "postgres-database", Consumer: "gone-away", Sealed: "c3RhbGU="},
}})
if err != nil {
t.Fatal(err)
}
given := grantedTo(t, out)
if len(given) != 1 || given[0].Node != "still-here" {
t.Fatalf("the provider is still told about a machine that stopped asking: %v", given)
}
// And no credential is written for it either, or the provisioner would find a file for a
// consumer its manifest does not mention and have to guess what that means.
for _, r := range out {
if path, _ := r["path"].(string); strings.Contains(path, "gone-away") {
t.Fatalf("a withdrawn consumer's credential is still delivered: %v", r)
}
}
}
@@ -0,0 +1,58 @@
package resolution
import (
"reflect"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
func TestAContributionDependsOnTheSeatThatPlacesIt(t *testing.T) {
env := mod("theme", nil, nil, nil)
env.Environment = &mcat.Environment{Variables: map[string]string{"GTK_THEME": "Adwaita:dark"}}
path := mod("toolchain", nil, nil, nil)
path.Environment = &mcat.Environment{Path: []mcat.PathEntry{{Entry: "/opt/x/bin"}}}
shell := mod("prompt", nil, nil, nil)
shell.Shell = []mcat.ShellCode{{For: "zsh", Slot: "first", Code: "true"}}
session := mod("wallpaper", nil, nil, nil)
session.Shell = []mcat.ShellCode{{For: "xinitrc", Slot: "normal", Code: "true"}}
resources := mod("bar", nil, nil, nil)
resources.Shell = []mcat.ShellCode{{For: "xresources", Slot: "normal", Code: "x: y"}}
empty := mod("nothing", nil, nil, nil)
empty.Environment = &mcat.Environment{}
cases := map[string]struct {
m mcat.Manifest
want []string
}{
"a variable": {env, []string{mcat.EnvironmentSeat}},
"a path entry": {path, []string{mcat.EnvironmentSeat}},
"shell code": {shell, []string{mcat.LoginShellSeat}},
"the session's start": {session, []string{mcat.DisplayServerSeat}},
"the session's X resources": {resources, []string{mcat.DisplayServerSeat}},
"an empty environment": {empty, nil},
}
for name, c := range cases {
got := DependsOn(nil, c.m)
if len(got) == 0 {
got = nil
}
if !reflect.DeepEqual(got, c.want) {
t.Errorf("%s depends on %v, want %v", name, got, c.want)
}
}
}
func TestAHolderMeetsItsOwnContribution(t *testing.T) {
// The display server's module contributes nothing to its own seat today, but the zsh module's
// environment contributions do go to another seat: a claim meets only the seat it names.
zsh := mod("zsh", nil, nil, nil, mcat.Claim{Name: mcat.LoginShellSeat})
zsh.Shell = []mcat.ShellCode{{For: "zsh", Slot: "normal", Code: "true"}}
zsh.Environment = &mcat.Environment{Variables: map[string]string{"EDITOR": "vim"}}
catalogue := map[string]mcat.Manifest{"zsh": zsh,
"node-env": mod("node-env", nil, nil, nil, mcat.Claim{Name: mcat.EnvironmentSeat})}
unheld := UnheldDependencies(catalogue, "laptop", []mcat.Manifest{zsh}, nil)
if len(unheld) != 1 || unheld[0].Seat != mcat.EnvironmentSeat {
t.Errorf("zsh alone: unheld %v, want only %s (its shell code is its own seat's)", unheld, mcat.EnvironmentSeat)
}
}
@@ -0,0 +1,52 @@
package resolution
import (
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// controlRecordsDecision is the record that made the two seats.
const controlRecordsDecision = "novox/hq ADR 0565"
// wantsVerbs are the assigner's verbs on mesh-wants (ADR 0565 clauses 7, 8, 9, 15).
func wantsVerbs() []mcat.Verb {
return []mcat.Verb{
{Name: "upgrade", Description: "Whether each module's new builds wait for a person: its policy (roll, together or record) " +
"and where it comes from (a person, the module, the bus, its irreplaceable data, the default); the bus is never " +
"rolled. With module, that one; with module, policy and why, a person's choice for it, recorded as a hand act; " +
"with policy default, the choice taken back. With compare, each node's own composition and the assigner's findings.",
Input: schema(map[string]string{
"module": "one module",
"policy": "with module: roll, together, record, or default to take a person's choice back",
"why": "with policy: why, required and kept",
"compare": "\"true\": each node's own composition, by its digest, and the assigner's findings",
}, nil, "compare")},
{Name: "bus", Description: "What a bus upgrade would do: the fingerprint of the bus module each node runs against the " +
"indexer's newest, and the last bus steps from the hand-act log and the assigner's puts of the bus. Read-only: " +
"the step itself is the put on the operator's warrant.",
Input: schema(map[string]string{}, nil)},
}
}
// handActsVerbs are mesh-hand-acts' verbs (ADR 0565 clause 10), as the controller's took them.
func handActsVerbs() []mcat.Verb {
return []mcat.Verb{
{Name: "hand-act", Description: "Record one act done by hand: what, why, its cause in a word or a condition's kind " +
"(the word a second act for the same reason uses) and the condition it addresses. Who did it is the caller the " +
"runtime proves, never said by the caller. Said as act-recorded.",
Input: schema(map[string]string{
"what": "what was done, in a line",
"why": "why it had to be done by hand",
"cause": "the cause in a word, or a condition's kind",
"condition": "the key of the condition it addressed, if any (optional)",
"verb": "the act's verb in the log: a repair when absent, `hand-act drill` for a drill, or a recorder's own",
}, []string{"what", "why"})},
{Name: "hand-acts", Description: "What was done by hand lately, over this log and the controller's: who, why and the " +
"cause of each, and the causes recorded twice within a fortnight (a healer is wanted).",
Input: schema(map[string]string{"days": "how many days back (default 14)", "cause": "only this cause",
"who": "only acts whose who contains this", "warrants": "\"true\": every warrant recorded"}, nil, "warrants")},
{Name: "warranted", Description: "Record what a module did on the operator's warrant: who chose, through which channel, " +
"with which proofs and which answer read from the router's own record of that module's ask, never from the " +
"caller; once per ask. Refused for an ask still open, ended without a choice, or not that module's.",
Input: schema(map[string]string{"asker": "the module that asked", "ask": "its ask's id"}, []string{"asker", "ask"})},
}
}
@@ -0,0 +1,19 @@
package resolution
import (
"encoding/json"
"slices"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// CarriesVaultSealed says whether module's own secrets are the core's credentials.
func CarriesVaultSealed(module string) bool {
return slices.Contains(mcat.CoreCredentialModules, module)
}
// vaultSealedOf is the vault's box for one of module's own secrets, if the rendering was given one.
func renderingVaultSealedOf(with mcat.Rendering, module, name string) (json.RawMessage, bool) {
box, ok := with.VaultSealed[module][name]
return box, ok && len(box) > 0
}
@@ -0,0 +1,24 @@
package resolution
import (
"reflect"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
func TestTheCoresOwnCredentialsAreTheBusTheProvisionerAndItsClient(t *testing.T) {
if want := []string{"mesh-provisioner", "mesh-provisioner-client", "nats"}; !reflect.DeepEqual(mcat.CoreCredentialModules, want) {
t.Fatalf("the core's modules are %v, want %v (mesh-vault's CoreModules is the same list)", mcat.CoreCredentialModules, want)
}
for _, m := range []string{"nats", "mesh-provisioner", "mesh-provisioner-client"} {
if !CarriesVaultSealed(m) {
t.Errorf("%s is not on the list", m)
}
}
for _, m := range []string{"postgres", "mesh-vault", "mesh-controller", ""} {
if CarriesVaultSealed(m) {
t.Errorf("%s is on the list", m)
}
}
}
+77
View File
@@ -0,0 +1,77 @@
package resolution
import (
"fmt"
"strings"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// derivedContributions is what a module's data section gives the backup holder: its backup lines and
// its items. Every item is listed, so a valuable one on a machine is measured whether or not it is
// copied; a cache is listed and not measured.
func manifestDerivedContributions(m mcat.Manifest) []mcat.SeatContribution {
items := m.DataItems()
if len(items) == 0 {
return nil
}
var lines []string
kept := map[string]bool{}
keep := func(path string) {
if !kept[path] {
kept[path] = true
lines = append(lines, "path "+path)
}
}
for _, it := range items {
if !it.BackedUp() {
continue
}
if it.Backup.IsDump() {
lines = append(lines, "run "+strings.TrimSpace(it.Backup.Dump))
if into, ok := m.DataItem(it.Backup.Into); ok {
keep(into.Path)
}
continue
}
keep(it.Path)
}
var described []string
for _, it := range items {
covered := "-"
switch {
case it.Backup.IsDump():
if into, ok := m.DataItem(it.Backup.Into); ok {
covered = into.Path
}
case it.BackedUp():
covered = it.Path
}
described = append(described, fmt.Sprintf("item %s %s %s %s %s %s", it.ID, it.Class, it.Path, covered,
it.Protection(), it.MeasuredBy()))
}
var out []mcat.SeatContribution
if len(lines) > 0 {
out = append(out, mcat.SeatContribution{Seat: mcat.BackupSeat, Kind: mcat.BackupKindBackup, Content: strings.Join(lines, "\n") + "\n"})
}
return append(out, mcat.SeatContribution{Seat: mcat.BackupSeat, Kind: mcat.BackupKindData, Content: strings.Join(described, "\n") + "\n"})
}
// allContributions is every contribution a module makes to a seat's holder: what it wrote, and what
// its data section derives. **One list**: a module that declares its data has its backup lines
// composed from it, and a backup line it still writes by hand is not placed — the catalogue check
// refuses it — so the holder never copies two lists that disagree.
func manifestAllContributions(m mcat.Manifest) []mcat.SeatContribution {
if m.Data == nil {
return m.Contributions
}
derived := manifestDerivedContributions(m)
out := make([]mcat.SeatContribution, 0, len(m.Contributions)+len(derived))
for _, c := range m.Contributions {
if s, known := mcat.SeatNamed(c.Seat); known && s.Name == mcat.BackupSeat {
continue
}
out = append(out, c)
}
return append(out, derived...)
}
@@ -0,0 +1,31 @@
package resolution
import (
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// DataItems is every data item the resolution's modules declare, placed on its node with the rendering given: the text
// the node-backup holder is handed, read. A module left out of the node's declaration keeps its items, as its data is
// still on the node (ADR 0262); an item that cannot be placed is said in unplaced and left out.
func DataItems(r mcat.Resolution, with mcat.Rendering) (items map[string][]mcat.PlacedDataItem, unplaced []string, err error) {
left := LeftOut(r, with.Settings, with.Adopted)
for module, why := range with.NotPlaced {
if _, already := left[module]; !already {
left[module] = why
}
}
modules := make([]mcat.Manifest, 0, len(r.Modules))
for _, m := range r.Modules {
if _, isLeft := left[m.Module]; isLeft {
m = backupView(m)
}
modules = append(modules, m)
}
text, unplaced, err := seatContributions(modules, mcat.Manifest{}, mcat.BackupSeat+":"+mcat.BackupKindData, with,
machineFacts(r, with.Names, with.MeshRange), r.Capabilities)
if err != nil {
return nil, unplaced, err
}
items, err = mcat.ParseDataItems(text)
return items, unplaced, err
}

Some files were not shown because too many files have changed in this diff Show More