Remove the composition from mesh-sdk: compose, its bus and runtime readers, takein, validate, the composer's run and command, overlay's generator and delivery's move judge now live in the node-engine, and the placement judge in the assigner (ADR 0593 §1, §6, §7)
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
@@ -3,8 +3,6 @@ package catalogue
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/validate"
|
||||
)
|
||||
|
||||
// A long-running container may say it is busy (novox/hq ADR 0335 §1): `module check` holds the field to its
|
||||
@@ -52,14 +50,6 @@ func TestABusyIsSentOnlyToAnEngineThatReadsIt(t *testing.T) {
|
||||
if got["command"] != "mesh-builder busy" || got["timeout"] != "10s" {
|
||||
t.Fatalf("busy composed as %v", got)
|
||||
}
|
||||
// And the engine's own validator takes the container with it.
|
||||
body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": newer})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if problems := validate.Declaration(body); len(problems) > 0 {
|
||||
t.Fatalf("the node-engine's validator refuses the composed busy: %v", problems)
|
||||
}
|
||||
if _, has := m.Resources[len(m.Resources)-1][BusyField].(map[string]any)["timeout"]; has {
|
||||
t.Error("composing wrote the timeout into the catalogue's own manifest")
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/validate"
|
||||
)
|
||||
|
||||
// A module says how each long-running resource is ready (novox/hq ADR 0240 rule 2, to-be 48 §8): `module
|
||||
@@ -147,17 +145,9 @@ func TestEveryComposedHealthIsOneTheNodeEngineTakes(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m.Resources[1]["health"] = map[string]any{"kind": "unit"}
|
||||
out, err := Resolution{Node: "laptop", Modules: []Manifest{m}}.Declaration(Rendering{ReadsHealth: true,
|
||||
Ports: map[string]map[int]int{"app": {8080: 31001}}})
|
||||
if err != nil {
|
||||
if _, err := (Resolution{Node: "laptop", Modules: []Manifest{m}}).Declaration(Rendering{ReadsHealth: true,
|
||||
Ports: map[string]map[int]int{"app": {8080: 31001}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": out})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if problems := validate.Declaration(body); len(problems) > 0 {
|
||||
t.Errorf("%v composed into something the node-engine refuses: %v", h, problems)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,6 @@ package catalogue
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/validate"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0366: a module that runs as its own account declares its runtime's health under one manifest
|
||||
@@ -88,13 +86,6 @@ func TestARuntimeHealthIsComposedOntoTheModulesOwnRuntime(t *testing.T) {
|
||||
if machine := fileNamed(out, RuntimeModule+"."+RuntimeProcessID()); machine[HealthField] != nil {
|
||||
t.Errorf("the machine's runtime was given the module's health: %v", machine[HealthField])
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": out})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if problems := validate.Declaration(body); len(problems) > 0 {
|
||||
t.Errorf("composed into something the node-engine refuses: %v", problems)
|
||||
}
|
||||
}
|
||||
// The catalogue's own manifest is not written into.
|
||||
if _, ok := keeper.RuntimeHealth.(map[string]any)["grace"]; ok {
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
// mesh-compose is mesh-sdk's composer command (novox/hq ADR 0482 clause 4, issue 1621): it reads a composer.Request on
|
||||
// standard input, validates every manifest with this mesh-sdk's reader, composes the machines named in each case and
|
||||
// writes one composer.Verdict on standard output. Built at any commit of mesh-sdk (or at the mesh-sdk a module pins),
|
||||
// it answers what that commit's composition answers, so a program that links one mesh-sdk can have another's judged.
|
||||
//
|
||||
// mesh-compose < request.json > verdict.json
|
||||
// mesh-compose -request request.json > verdict.json
|
||||
// mesh-compose -catalogue <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"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/composer"
|
||||
)
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
|
||||
}
|
||||
|
||||
type nodes []string
|
||||
|
||||
func (n *nodes) String() string { return strings.Join(*n, ",") }
|
||||
func (n *nodes) Set(v string) error { *n = append(*n, v); return nil }
|
||||
|
||||
// run is the command: its arguments, standard input, output and error; it answers the exit code.
|
||||
func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int {
|
||||
fs := flag.NewFlagSet("mesh-compose", flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
catalogueDir := fs.String("catalogue", "", "a catalogue checkout: <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
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"go/build"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/composer"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/internal/beside"
|
||||
)
|
||||
|
||||
// captured is every manifest of the catalogue captured in testdata/beside (internal/beside), raw, by module name.
|
||||
func captured(t *testing.T) map[string]composer.Manifest {
|
||||
t.Helper()
|
||||
read, err := readCatalogue(filepath.Dir(beside.Catalogue(t)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(read) < 100 {
|
||||
t.Fatalf("the captured catalogue holds %d manifests, fewer than the catalogue's", len(read))
|
||||
}
|
||||
return read
|
||||
}
|
||||
|
||||
// meshOf is a mesh of three machines over the captured catalogue, each assigned real modules, the input the assigner
|
||||
// composes with (nodes and their assignments, holdings from the claims): anchor runs the bus and the store, laptop a
|
||||
// desktop's modules, ace a module whose requirement is provided elsewhere.
|
||||
func meshOf() compose.Input {
|
||||
return compose.Input{Nodes: []compose.Node{
|
||||
{Name: "anchor", Assigned: []string{"nats", "postgres"}},
|
||||
{Name: "laptop", Assigned: []string{"sudo", "zsh"}},
|
||||
{Name: "ace", Assigned: []string{"mesh-reads", "nats"}},
|
||||
}}
|
||||
}
|
||||
|
||||
func request(t *testing.T, depth string) composer.Request {
|
||||
t.Helper()
|
||||
return composer.Request{V: composer.Version, Input: rawInput(meshOf()), Depth: depth, HoldingsFromClaims: true,
|
||||
Machines: []string{"anchor", "laptop", "ace"},
|
||||
Cases: []composer.Case{{Name: "trunk", Manifests: captured(t)}}}
|
||||
}
|
||||
|
||||
func runJSON(t *testing.T, args []string, in []byte) (int, composer.Verdict, string) {
|
||||
t.Helper()
|
||||
var out, errOut bytes.Buffer
|
||||
code := run(args, bytes.NewReader(in), &out, &errOut)
|
||||
var v composer.Verdict
|
||||
if code == 0 {
|
||||
if err := json.Unmarshal(out.Bytes(), &v); err != nil {
|
||||
t.Fatalf("the verdict is not JSON: %v\n%s", err, out.String())
|
||||
}
|
||||
}
|
||||
return code, v, errOut.String()
|
||||
}
|
||||
|
||||
func encode(t *testing.T, r composer.Request) []byte {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// 1621/AC1: the command reads the plain input and the manifests on standard input, validates every manifest, composes
|
||||
// the machines named and writes one verdict: per machine composes or the reason, per manifest accepted or the field
|
||||
// refused.
|
||||
func TestTheCommandWritesOneVerdictPerManifestAndMachine(t *testing.T) {
|
||||
r := request(t, composer.DepthResolve)
|
||||
r.Cases[0].Manifests["later"] = composer.Manifest{Raw: json.RawMessage(`{"module":"later","version":"1","shares":["x"]}`)}
|
||||
r.Cases[0].Manifests["refused"] = composer.Manifest{Raw: json.RawMessage(`{"module":"refused","version":"1"}`)}
|
||||
r.Cases[0].Verdicts = map[string]delivery.ManifestVerdict{"refused": {State: delivery.VerdictRefused, Problems: []string{"it is wrong"}}}
|
||||
code, v, stderr := runJSON(t, nil, encode(t, r))
|
||||
if code != 0 {
|
||||
t.Fatalf("exit %d: %s", code, stderr)
|
||||
}
|
||||
if len(v.Cases) != 1 || v.Cases[0].Name != "trunk" || v.SDK == "" {
|
||||
t.Fatalf("verdict = %+v", v)
|
||||
}
|
||||
c := v.Cases[0]
|
||||
// ADR 0578 §5, §6: the composer judges nothing; it sets an unknown field aside and names it, and holds a manifest
|
||||
// its verdict refuses, named in the indexer's words.
|
||||
if got := c.Manifests["later"]; !got.Accepted || !strings.Contains(got.SetAside, `"shares"`) {
|
||||
t.Errorf("the manifest with an unknown field reads %+v, want accepted naming the field set aside", got)
|
||||
}
|
||||
if got := c.Manifests["refused"]; got.Accepted || !strings.Contains(got.Refused, "refused the manifest of refused: it is wrong") {
|
||||
t.Errorf("the refused manifest reads %+v, want refused in the verdict's words", got)
|
||||
}
|
||||
if got := c.Manifests["nats"]; !got.Accepted {
|
||||
t.Errorf("nats reads %+v, want accepted", got)
|
||||
}
|
||||
if len(c.Machines) != 3 {
|
||||
t.Fatalf("machines = %v, want the three named", c.Machines)
|
||||
}
|
||||
for name, m := range c.Machines {
|
||||
if !m.Composes && m.Reason == "" {
|
||||
t.Errorf("%s does not compose and says no reason", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1621/AC3: over the captured catalogue and a mesh of three machines, the command's verdict equals, machine for
|
||||
// machine and manifest for manifest, what the linked reader, resolution and composition answer, at both depths.
|
||||
func TestTheCommandAnswersWhatTheLinkedCompositionAnswers(t *testing.T) {
|
||||
for _, depth := range []string{composer.DepthResolve, composer.DepthCompose} {
|
||||
r := request(t, depth)
|
||||
code, v, stderr := runJSON(t, nil, encode(t, r))
|
||||
if code != 0 {
|
||||
t.Fatalf("%s: exit %d: %s", depth, code, stderr)
|
||||
}
|
||||
in := meshOf()
|
||||
in.Catalogue = map[string]catalogue.Manifest{}
|
||||
for name, m := range r.Cases[0].Manifests {
|
||||
// A request without a list of predefined keys reads a fallback as the indexer does (Request.Predefined).
|
||||
parsed, err := catalogue.Decode(m.Raw)
|
||||
got := v.Cases[0].Manifests[name]
|
||||
if (err == nil) != got.Accepted || (err != nil && err.Error() != got.Refused) {
|
||||
t.Errorf("%s: the manifest of %s: linked %v, command %+v", depth, name, err, got)
|
||||
}
|
||||
if err == nil {
|
||||
in.Catalogue[name] = parsed
|
||||
}
|
||||
}
|
||||
in.Holdings = composer.HoldingsOf(in.Catalogue, in.Nodes)
|
||||
composed := 0
|
||||
for _, node := range r.Machines {
|
||||
got := v.Cases[0].Machines[node]
|
||||
var want composer.Machine
|
||||
if depth == composer.DepthResolve {
|
||||
if _, err := compose.Resolve(in, node); err != nil {
|
||||
want = composer.Machine{Reason: err.Error()}
|
||||
} else {
|
||||
want = composer.Machine{Composes: true}
|
||||
}
|
||||
} else {
|
||||
d, problems, err := compose.Compose(in, node)
|
||||
switch {
|
||||
case err != nil:
|
||||
want = composer.Machine{Reason: err.Error()}
|
||||
default:
|
||||
want = composer.Machine{Composes: len(problems) == 0, Problems: problems, Refusals: d.Refusals()}
|
||||
if len(problems) > 0 {
|
||||
want.Reason = "the node-engine's validator refuses the declaration"
|
||||
}
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("%s: %s: command %+v, linked %+v", depth, node, got, want)
|
||||
}
|
||||
if got.Composes {
|
||||
composed++
|
||||
} else {
|
||||
t.Logf("%s: %s does not compose: %s %v", depth, node, got.Reason, got.Problems)
|
||||
}
|
||||
}
|
||||
t.Logf("%s: %d of %d machines compose", depth, composed, len(r.Machines))
|
||||
}
|
||||
}
|
||||
|
||||
// 1621 note 6: a person composes a catalogue checkout (repository@ref checked out or archived: the command fetches
|
||||
// nothing) for a node and reads its declaration: each path with its kind, owner and mode.
|
||||
func TestACatalogueCheckoutIsComposedForANode(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "modules", "demo"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest := `{"module": "demo", "version": "1", "resources": [{"id": "state", "type": "directory", ` +
|
||||
`"path": "/var/lib/demo", "mode": "0750", "owner": "root"}]}`
|
||||
if err := os.WriteFile(filepath.Join(dir, "modules", "demo", "module.json"), []byte(manifest), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in := encode(t, composer.Request{V: composer.Version, Input: rawInput(compose.Input{Nodes: []compose.Node{{Name: "laptop", Assigned: []string{"demo"}}}})})
|
||||
code, v, stderr := runJSON(t, []string{"-catalogue", dir, "-node", "laptop", "-declarations"}, in)
|
||||
if code != 0 {
|
||||
t.Fatalf("exit %d: %s", code, stderr)
|
||||
}
|
||||
m := v.Cases[0].Machines["laptop"]
|
||||
want := []composer.Path{{Path: "/var/lib/demo", Kind: "directory", Owner: "root", Mode: "0750", Module: ""}}
|
||||
if !m.Composes || len(m.Declared) != 1 || m.Declared[0].Path != want[0].Path || m.Declared[0].Mode != "0750" ||
|
||||
m.Declared[0].Owner != "root" || m.Declared[0].Kind != "directory" {
|
||||
t.Fatalf("laptop = %+v, want it composed declaring %+v", m, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A run step hands the command no standard input: the request is read from the file -request names, and a file that is
|
||||
// not there is a request it cannot read.
|
||||
func TestTheRequestIsReadFromAFile(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "request.json")
|
||||
r := composer.Request{V: composer.Version, Cases: []composer.Case{{Name: "only", Manifests: map[string]composer.Manifest{
|
||||
"demo": {Raw: json.RawMessage(`{"module":"demo","version":"1"}`)}}}}}
|
||||
if err := os.WriteFile(file, encode(t, r), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, v, stderr := runJSON(t, []string{"-request", file}, nil)
|
||||
if code != 0 || len(v.Cases) != 1 || !v.Cases[0].Manifests["demo"].Accepted {
|
||||
t.Fatalf("exit %d (%s), verdict %+v", code, stderr, v)
|
||||
}
|
||||
if code, _, stderr := runJSON(t, []string{"-request", file + ".gone"}, nil); code != 1 || !strings.Contains(stderr, "cannot be read") {
|
||||
t.Errorf("a request file that is not there: exit %d, %q", code, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
// The exit codes: 1 for a request that cannot be read or run, 2 for wrong flags, each said on standard error.
|
||||
func TestTheExitCodesSayWhatFailed(t *testing.T) {
|
||||
unfilled := encode(t, composer.Request{V: composer.Version, Cases: []composer.Case{{Name: "c", Manifests: map[string]composer.Manifest{"x": {Digest: "abc"}}}}})
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
args []string
|
||||
in string
|
||||
code int
|
||||
says string
|
||||
}{
|
||||
{"not JSON", nil, "{", 1, "not JSON"},
|
||||
{"another version", nil, `{"v":2}`, 1, "version 2"},
|
||||
{"a digest left unfilled", nil, string(unfilled), 1, "digest"},
|
||||
{"an unknown depth", nil, `{"v":1,"depth":"deep"}`, 1, "depth"},
|
||||
{"nothing to read", nil, "", 1, "no request"},
|
||||
{"a wrong flag", []string{"-nope"}, "", 2, "nope"},
|
||||
{"an argument", []string{"extra"}, "", 2, "unexpected"},
|
||||
} {
|
||||
var out, errOut bytes.Buffer
|
||||
if got := run(c.args, strings.NewReader(c.in), &out, &errOut); got != c.code || !strings.Contains(errOut.String(), c.says) {
|
||||
t.Errorf("%s: exit %d saying %q, want %d saying %q", c.name, got, errOut.String(), c.code, c.says)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sdkPackages is every package of this module the command imports, directly or not, with the packages each imports.
|
||||
func sdkPackages(t *testing.T) map[string][]string {
|
||||
t.Helper()
|
||||
_, file, _, _ := runtime.Caller(0)
|
||||
root := filepath.Join(filepath.Dir(file), "..", "..")
|
||||
const module = "git.novox.be/novox/mesh-sdk/go"
|
||||
out := map[string][]string{}
|
||||
var walk func(path string)
|
||||
walk = func(path string) {
|
||||
if _, done := out[path]; done {
|
||||
return
|
||||
}
|
||||
rel := strings.TrimPrefix(strings.TrimPrefix(path, module), "/")
|
||||
pkg, err := build.Default.ImportDir(filepath.Join(root, rel), 0)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", path, err)
|
||||
}
|
||||
out[path] = pkg.Imports
|
||||
for _, imp := range pkg.Imports {
|
||||
if strings.HasPrefix(imp, module+"/") {
|
||||
walk(imp)
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(composer.Package)
|
||||
return out
|
||||
}
|
||||
|
||||
// 1621/AC2 and AC4: the command imports, through every package of mesh-sdk it reaches, only the standard library and
|
||||
// mesh-sdk's own packages (so `go build` from mesh-sdk's module needs nothing beyond its lock), and none that reaches
|
||||
// a bus, a store, a network service or another process.
|
||||
func TestTheCommandImportsNoBusStoreNetworkOrProcess(t *testing.T) {
|
||||
forbidden := []string{"net/http", "net/rpc", "net/smtp", "os/exec", "database/sql", "plugin", "syscall"}
|
||||
for path, imports := range sdkPackages(t) {
|
||||
for _, imp := range imports {
|
||||
std := !strings.Contains(strings.SplitN(imp, "/", 2)[0], ".")
|
||||
if !std && !strings.HasPrefix(imp, "git.novox.be/novox/mesh-sdk/go/") {
|
||||
t.Errorf("%s imports %s, which is neither the standard library nor mesh-sdk", path, imp)
|
||||
}
|
||||
for _, f := range forbidden {
|
||||
if imp == f {
|
||||
t.Errorf("%s imports %s", path, imp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1621/AC2: run with an empty network namespace, the command still writes its verdict: it needs no network. The test
|
||||
// runs its own binary again under `unshare --net` as an unprivileged user's namespace; where the kernel or the sandbox
|
||||
// allows no new namespace (a build module's run step is already one with no network), it says so and skips.
|
||||
func TestTheCommandRunsWithNoNetwork(t *testing.T) {
|
||||
if os.Getenv("MESH_COMPOSE_HELPER") == "1" {
|
||||
os.Exit(run(nil, os.Stdin, os.Stdout, os.Stderr))
|
||||
}
|
||||
unshare, err := exec.LookPath("unshare")
|
||||
if err != nil {
|
||||
t.Skip("no unshare on this machine, so no empty network namespace can be made here")
|
||||
}
|
||||
if out, err := exec.Command(unshare, "--net", "--map-root-user", "true").CombinedOutput(); err != nil {
|
||||
t.Skipf("this machine makes no unprivileged network namespace (%v: %s); a run step's own has no network", err, out)
|
||||
}
|
||||
r := request(t, composer.DepthResolve)
|
||||
cmd := exec.Command(unshare, "--net", "--map-root-user", os.Args[0], "-test.run=^TestTheCommandRunsWithNoNetwork$")
|
||||
cmd.Env = []string{"MESH_COMPOSE_HELPER=1"}
|
||||
cmd.Stdin = bytes.NewReader(encode(t, r))
|
||||
var out, errOut bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &out, &errOut
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("the command in an empty network namespace: %v: %s", err, errOut.String())
|
||||
}
|
||||
var v composer.Verdict
|
||||
if err := json.Unmarshal(out.Bytes(), &v); err != nil || len(v.Cases) != 1 || len(v.Cases[0].Machines) != 3 {
|
||||
t.Fatalf("no verdict from the command with no network: %v\n%s", err, out.String())
|
||||
}
|
||||
names := make([]string, 0, len(v.Cases[0].Machines))
|
||||
for n := range v.Cases[0].Machines {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
t.Logf("composed with no network: %v", names)
|
||||
}
|
||||
|
||||
// rawInput is a composition input as a request carries it.
|
||||
func rawInput(in compose.Input) json.RawMessage {
|
||||
raw, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -1,559 +0,0 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
)
|
||||
|
||||
// Compositions is one node's compositions (novox/hq ADR 0539 clauses 1 and 2): its declaration composed over in, as
|
||||
// the controller composes it, split into each module's composition at the fingerprint fps gives it and the node's own
|
||||
// (`<node>.node`), by key (delivery.ComposedKey). Each resource says its module, whatever the node reported it reads.
|
||||
// A module fps gives no fingerprint for is not composed and is named in skipped, so a caller composing one module
|
||||
// gets that one; but for a module the node runs inside its own composition (Node.InOwn, and the
|
||||
// private network's always), which fps names at no fingerprint of the new form: its resources go into the node's own,
|
||||
// their module taken off and their ids still naming it, and its filled paths with them, as the controller's composed
|
||||
// writer folds them (issue 1126). Each module's Data says whether its manifest declares data. The compositions are not
|
||||
// signed and not yet by digest: the writer does both (delivery.SignComposed, Composed.ByDigest).
|
||||
func Compositions(in Input, node string, fps map[string]string, mode Mode) (out map[string]delivery.Composed, skipped []string, err error) {
|
||||
out, skipped, _, err = compositions(in, node, fps, mode, false)
|
||||
return out, skipped, err
|
||||
}
|
||||
|
||||
// ErrStoodIn is a composition that holds a stand-in for a value the input does not carry (an own secret, a grant's
|
||||
// sealed copy, a vault box, the certificate, the bus's user list) or a port it does not give: what a send would make,
|
||||
// never what may be applied.
|
||||
var ErrStoodIn = errors.New("compose: the composition stands in for what its input does not carry")
|
||||
|
||||
// BusUsersStandIn is what a composition says it stood in for when its input carries no bus user list.
|
||||
const BusUsersStandIn = "the bus's user list"
|
||||
|
||||
// CompositionsApplied is Compositions for a node that applies what it composed itself (novox/hq ADR 0547 §2, issue
|
||||
// 1126): a declaration that stands in for anything (Declared.StoodIn, Foreseen, ForeseenPorts) is refused with
|
||||
// ErrStoodIn, naming each, so a stand-in is never applied as if it were the value; but for the bus's user list
|
||||
// (BusUsersStandIn), which the bus node holds out of every apply of a composition it made itself.
|
||||
func CompositionsApplied(in Input, node string, fps map[string]string) (map[string]delivery.Composed, []string, error) {
|
||||
out, skipped, _, err := compositions(in, node, fps, Reading, true)
|
||||
return out, skipped, err
|
||||
}
|
||||
|
||||
// CompositionsShadow is CompositionsApplied for a composition that is compared and never applied (a node's answer to a
|
||||
// placement candidate, novox/hq ADR 0559 §4): composed in the Foreseeing mode, so an own secret never issued (a module
|
||||
// the candidate adds) is a named stand-in rather than an error, and what it stands in for is answered (stood, sorted,
|
||||
// the bus's user list left out) rather than refused, so the caller judges each stand-in: a value not yet issued for a
|
||||
// module the candidate adds, or one missing for a module the node runs. A module whose every value is held composes
|
||||
// as CompositionsApplied composes it: the modes differ only in an own secret never issued.
|
||||
func CompositionsShadow(in Input, node string, fps map[string]string) (out map[string]delivery.Composed, skipped, stood []string, err error) {
|
||||
return compositions(in, node, fps, Foreseeing, false)
|
||||
}
|
||||
|
||||
// CompositionsIn is the node's compositions in mode with what they stand in for answered (stood, sorted, the bus's user
|
||||
// list left out) rather than refused: CompositionsShadow in Foreseeing, and in Reading what CompositionsApplied refuses
|
||||
// before it is refused, so a caller that applies refuses each stand-in to the module it is for alone (issue 1126).
|
||||
func CompositionsIn(in Input, node string, fps map[string]string, mode Mode) (out map[string]delivery.Composed, skipped, stood []string, err error) {
|
||||
return compositions(in, node, fps, mode, false)
|
||||
}
|
||||
|
||||
func compositions(in Input, node string, fps map[string]string, mode Mode, applied bool) (out map[string]delivery.Composed, skipped, stood []string, err error) {
|
||||
reading := in
|
||||
reading.Nodes = append([]Node(nil), in.Nodes...)
|
||||
found := false
|
||||
inOwn := map[string]bool{}
|
||||
held := map[string]bool{}
|
||||
for i := range reading.Nodes {
|
||||
if reading.Nodes[i].Name == node {
|
||||
reading.Nodes[i].ReadsModules = true
|
||||
found = true
|
||||
for m, ok := range reading.Nodes[i].InOwn {
|
||||
inOwn[m] = ok
|
||||
}
|
||||
for m, ok := range reading.Nodes[i].Held {
|
||||
held[m] = ok
|
||||
}
|
||||
}
|
||||
}
|
||||
// The private network's module is built by nobody: no fingerprint of the new form ever names it.
|
||||
inOwn[overlay.Name] = true
|
||||
folds := func(module string) bool {
|
||||
return module != "" && inOwn[module] && !strings.HasPrefix(fps[module], delivery.SourcePrefix2)
|
||||
}
|
||||
if !found {
|
||||
return nil, nil, nil, fmt.Errorf("compose: the mesh has no node %q", node)
|
||||
}
|
||||
// A folded module built by a build of the old form (Node.RunsOwn) is left to the node: composed from what runs of it
|
||||
// (Carried), so what it provides and requires still shapes the rest and what the others read of it (its published
|
||||
// ports, the paths it owns) is what runs, and its resources are then the ones the node runs.
|
||||
carried := map[string]bool{}
|
||||
var runsOwn *delivery.Composed
|
||||
for i := range reading.Nodes {
|
||||
n := &reading.Nodes[i]
|
||||
if n.Name != node || n.RunsOwn == nil {
|
||||
continue
|
||||
}
|
||||
runsOwn = n.RunsOwn
|
||||
candidates := map[string]bool{}
|
||||
for m := range inOwn {
|
||||
candidates[m] = true
|
||||
}
|
||||
for m := range held {
|
||||
candidates[m] = true
|
||||
}
|
||||
for m := range candidates {
|
||||
// A held module is carried as it runs wherever the node's own runs it, folded or leaving the fold for a
|
||||
// composition of its own (its want new, its names still unset): its stub is never what the node's own applies,
|
||||
// and what the node's own applies never drops what runs of it (issue 1126, review of the apply; issue 1945).
|
||||
heldHere := held[m] && RunsAnyOf(runsOwn, m)
|
||||
if !folds(m) && !heldHere {
|
||||
continue
|
||||
}
|
||||
man, ok := n.Kept[m]
|
||||
if !ok {
|
||||
man, ok = reading.Catalogue[m]
|
||||
}
|
||||
if !ok || (!NamesBuild(man) && !held[m]) {
|
||||
continue
|
||||
}
|
||||
carried[m] = true
|
||||
kept := make(map[string]catalogue.Manifest, len(n.Kept)+1)
|
||||
for k, v := range n.Kept {
|
||||
kept[k] = v
|
||||
}
|
||||
kept[m] = Carried(man, m, runsOwn)
|
||||
n.Kept = kept
|
||||
}
|
||||
}
|
||||
// A module composed as what runs of it contributes to the runtime what the runtime serves of it as it runs
|
||||
// (Node.RunsTools); one of its own account is served by a runtime of its own, from its manifest as before.
|
||||
for i := range reading.Nodes {
|
||||
n := &reading.Nodes[i]
|
||||
if n.Name != node || n.RunsTools == nil {
|
||||
continue
|
||||
}
|
||||
kept := make(map[string]catalogue.Manifest, len(n.Kept))
|
||||
for m, man := range n.Kept {
|
||||
if man.Stubbed && man.RunsAs == "" {
|
||||
man.Bundles = runningBundles(n.RunsTools[m], reading.Catalogue[m])
|
||||
}
|
||||
kept[m] = man
|
||||
}
|
||||
n.Kept = kept
|
||||
}
|
||||
isCarried := func(module, id string) bool {
|
||||
if module == "" {
|
||||
module, _ = declaration.ModuleGuess(id)
|
||||
}
|
||||
return carried[module]
|
||||
}
|
||||
d, problems, err := ComposeIn(reading, node, mode)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
return nil, nil, nil, fmt.Errorf("compose: %s's declaration is refused by the node-engine's validator: %v", node, problems)
|
||||
}
|
||||
for _, x := range d.StoodIn {
|
||||
// The bus's user list is the bus node's own to follow, written alone and never from a composition the node
|
||||
// made itself (ADR 0547 §7, §8): its stand-in is held out of the apply, not applied.
|
||||
if x != BusUsersStandIn {
|
||||
stood = append(stood, x)
|
||||
}
|
||||
}
|
||||
stood = append(stood, d.Foreseen...)
|
||||
for _, p := range d.ForeseenPorts {
|
||||
stood = append(stood, "port "+p)
|
||||
}
|
||||
sort.Strings(stood)
|
||||
stood = slices.Compact(stood) // an own secret is both stood in for and foreseen
|
||||
if applied && len(stood) > 0 {
|
||||
return nil, nil, nil, fmt.Errorf("%w on %s: %s", ErrStoodIn, node, strings.Join(stood, ", "))
|
||||
}
|
||||
body, err := d.Body()
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
var envelope struct {
|
||||
Resources []json.RawMessage `json:"resources"`
|
||||
Adoption *declaration.Adoption `json:"adoption"`
|
||||
Filled []string `json:"filled_files"`
|
||||
Provisioner string `json:"provisioner_account"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
var kept []json.RawMessage
|
||||
keptPaths := map[string]bool{}
|
||||
foldedPaths := map[string]bool{}
|
||||
left := map[string]bool{}
|
||||
for _, raw := range envelope.Resources {
|
||||
var head struct {
|
||||
ID string `json:"id"`
|
||||
Module string `json:"module"`
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &head); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if isCarried(head.Module, head.ID) {
|
||||
continue // what the stub composed of it: the node's own carries what runs of it instead
|
||||
}
|
||||
if folds(head.Module) {
|
||||
// Into the node's own, in its place among the node's resources, its module taken off (mesh-controller's
|
||||
// compositionsOf): the node's own composition is then the controller's, byte for byte.
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &fields); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
delete(fields, "module")
|
||||
if raw, err = json.Marshal(fields); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if head.Type == string(declaration.TypeFile) {
|
||||
foldedPaths[head.Path] = true
|
||||
}
|
||||
kept = append(kept, raw)
|
||||
continue
|
||||
}
|
||||
if _, ok := fps[head.Module]; head.Module != "" && !ok {
|
||||
left[head.Module] = true
|
||||
continue
|
||||
}
|
||||
if head.Type == string(declaration.TypeFile) {
|
||||
keptPaths[head.Path] = true
|
||||
}
|
||||
kept = append(kept, raw)
|
||||
}
|
||||
// What runs of each carried module, in the node's own composition, each after the resource it runs after.
|
||||
if len(carried) > 0 {
|
||||
var err error
|
||||
if kept, foldedPaths, err = carry(kept, foldedPaths, runsOwn, isCarried); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
for _, p := range runsOwn.Filled {
|
||||
if foldedPaths[p] && !slices.Contains(envelope.Filled, p) {
|
||||
envelope.Filled = append(envelope.Filled, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
// A filled file of a module's own composition goes with that module (SplitComposed); one of a folded module goes with
|
||||
// the node's own, after them, in the declaration's order; one of a module left out is dropped.
|
||||
var filled, ownFilled []string
|
||||
for _, p := range envelope.Filled {
|
||||
switch {
|
||||
case foldedPaths[p]:
|
||||
ownFilled = append(ownFilled, p)
|
||||
case keptPaths[p]:
|
||||
filled = append(filled, p)
|
||||
}
|
||||
}
|
||||
out, err = delivery.SplitComposed(node, kept, fps, filled,
|
||||
delivery.NodeOwn{Adoption: envelope.Adoption, Provisioner: envelope.Provisioner})
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
if len(ownFilled) > 0 {
|
||||
own, err := delivery.ComposedNodeKey(node)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
c := out[own]
|
||||
c.Filled = append(c.Filled, ownFilled...)
|
||||
out[own] = c
|
||||
}
|
||||
for key, c := range out {
|
||||
if held[c.Module] {
|
||||
delete(out, key) // its stub's: the node keeps what it last applied of it (Node.Held)
|
||||
continue
|
||||
}
|
||||
if m, ok := in.Catalogue[c.Module]; ok && c.Module != "" {
|
||||
c.Data = len(m.DataItems()) > 0
|
||||
out[key] = c
|
||||
}
|
||||
}
|
||||
for m := range left {
|
||||
skipped = append(skipped, m)
|
||||
}
|
||||
sort.Strings(skipped)
|
||||
return out, skipped, stood, nil
|
||||
}
|
||||
|
||||
// RunsAnyOf says runs (a composition as it runs) holds a resource of module, by its module or the module its id names.
|
||||
func RunsAnyOf(runs *delivery.Composed, module string) bool {
|
||||
if runs == nil {
|
||||
return false
|
||||
}
|
||||
for _, raw := range runs.Resources {
|
||||
var head struct {
|
||||
ID string `json:"id"`
|
||||
Module string `json:"module"`
|
||||
}
|
||||
if json.Unmarshal(raw, &head) != nil {
|
||||
continue
|
||||
}
|
||||
owner := head.Module
|
||||
if owner == "" {
|
||||
owner, _ = declaration.ModuleGuess(head.ID)
|
||||
}
|
||||
if owner == module {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ToolsRunning is, by module, the tools bundles that run on the node (Node.RunsTools): each bundle archive a module's
|
||||
// resource unpacks at its BundlePath, with what the runtime's own process loads of it and the words it hands it
|
||||
// (catalogue.RuntimeToolModules, catalogue.RuntimeToolEnv, the mesh's own words left out). A bundle the runtime loads
|
||||
// nothing of has no Loads: the runtime dropped it, and the composition reads its loads again from the module's build
|
||||
// (issue 1126, review: shanks had already applied the drop of systemd-resolved). Nil when running holds no runtime process.
|
||||
func ToolsRunning(running []delivery.Composed) map[string][]catalogue.Bundle {
|
||||
runtimeID := catalogue.RuntimeModule + "." + catalogue.RuntimeProcessID()
|
||||
type resource struct {
|
||||
ID string `json:"id"`
|
||||
Module string `json:"module"`
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
Source string `json:"source"`
|
||||
Digest string `json:"digest"`
|
||||
Env map[string]string `json:"env"`
|
||||
}
|
||||
var env map[string]string
|
||||
out := map[string][]catalogue.Bundle{}
|
||||
for _, c := range running {
|
||||
for _, raw := range c.Resources {
|
||||
var r resource
|
||||
if json.Unmarshal(raw, &r) != nil {
|
||||
continue
|
||||
}
|
||||
owner := r.Module
|
||||
if owner == "" {
|
||||
owner, _ = declaration.ModuleGuess(r.ID)
|
||||
}
|
||||
switch {
|
||||
case r.Type == "process" && r.ID == runtimeID && owner == catalogue.RuntimeModule:
|
||||
env = r.Env
|
||||
case r.Type == "archive" && owner != "" && strings.HasPrefix(r.ID, owner+"."+catalogue.BundleID("")):
|
||||
name := strings.TrimPrefix(r.ID, owner+"."+catalogue.BundleID(""))
|
||||
if r.Path == catalogue.BundlePath(owner, name) {
|
||||
out[owner] = append(out[owner], catalogue.Bundle{Name: name, Source: catalogue.Recorded(r.Source), Digest: r.Digest})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if env == nil {
|
||||
return nil
|
||||
}
|
||||
var words map[string]map[string]string
|
||||
_ = json.Unmarshal([]byte(env[catalogue.RuntimeToolEnv]), &words)
|
||||
for _, entry := range strings.Split(env[catalogue.RuntimeToolModules], ",") {
|
||||
module, path, ok := strings.Cut(entry, "=")
|
||||
if !ok || !strings.HasPrefix(path, catalogue.BundleRoot+"/"+module+"/") {
|
||||
continue
|
||||
}
|
||||
name, load, ok := strings.Cut(strings.TrimPrefix(path, catalogue.BundleRoot+"/"+module+"/"), "/")
|
||||
bundles := out[module]
|
||||
i := slices.IndexFunc(bundles, func(b catalogue.Bundle) bool { return b.Name == name })
|
||||
if !ok || i < 0 {
|
||||
continue // no archive of it runs: nothing to load
|
||||
}
|
||||
var own map[string]string
|
||||
for w, v := range words[module] {
|
||||
if w == catalogue.FilesOwnedPaths || w == catalogue.ReadsSecretPlaces {
|
||||
continue // the mesh's to say, composed again
|
||||
}
|
||||
if own == nil {
|
||||
own = map[string]string{}
|
||||
}
|
||||
own[w] = v
|
||||
}
|
||||
bundles[i].Loads = append(bundles[i].Loads, load)
|
||||
bundles[i].Env = own
|
||||
}
|
||||
for _, bundles := range out {
|
||||
sort.Slice(bundles, func(i, j int) bool { return bundles[i].Name < bundles[j].Name })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// runningBundles is the bundles of a module composed as what runs of it (Node.RunsTools): each that runs, with what the
|
||||
// runtime loads of it and its words; one the runtime loads nothing of takes its loads and words from the module's build
|
||||
// (man: its built bundles, else its build's artifacts, catalogue.BundleLoads), so a drop already applied is undone.
|
||||
func runningBundles(runs []catalogue.Bundle, man catalogue.Manifest) []catalogue.Bundle {
|
||||
var out []catalogue.Bundle
|
||||
for _, b := range runs {
|
||||
if len(b.Loads) == 0 {
|
||||
for _, built := range man.Bundles {
|
||||
if built.Name == b.Name {
|
||||
b.Loads, b.Env, b.Launchers, b.Binary = built.Loads, built.Env, built.Launchers, built.Binary
|
||||
}
|
||||
}
|
||||
if man.Build != nil {
|
||||
run := catalogue.RunByAResource(man)
|
||||
for _, a := range man.Build.Artifacts {
|
||||
if a.Kind == catalogue.ArtifactBundle && a.Name == b.Name {
|
||||
b.Loads, b.Env, b.Binary = catalogue.BundleLoads(man, a, run), a.Env, catalogue.BinaryOf(a)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// NamesBuild says a manifest names what a build makes: its build, or a resource naming an artifact. A node cannot
|
||||
// compose such a module folded into its own composition: the build of the old form it runs is held by the controller
|
||||
// alone (issue 1126).
|
||||
func NamesBuild(m catalogue.Manifest) bool {
|
||||
if m.Build != nil {
|
||||
return true
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if _, ok := r["artifact"]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Stub is m with nothing to build and no resource but its directories: what it provides, requires, serves and grants
|
||||
// still shape the rest of the composition, the places its own secrets, data and grants name (${dir:…}) still resolve,
|
||||
// and nothing else of its own is composed (issue 1126: nats's ${dir:mesh-state} on novox). A module refused in a
|
||||
// node's answer is composed so; a folded one the node runs is Carried.
|
||||
func Stub(m catalogue.Manifest) catalogue.Manifest {
|
||||
// The paths it owns stay its manifest's, as the controller tells the holder of node-files them (issue 1126, seed-8).
|
||||
if m.OwnsPaths == nil {
|
||||
m.OwnsPaths = catalogue.DeclaredPaths(m)
|
||||
}
|
||||
m.Build = nil
|
||||
// What it hands the provisioner is left out as it runs, not refused for want of a plan (catalogue.Manifest.Stubbed).
|
||||
m.Stubbed = true
|
||||
// Nor is a template its build kept for its own files handed: a stub carries none of them (issue 1126: novox's node
|
||||
// refused "postgres: its build kept a template for consumers, which it does not declare" with postgres held). Its
|
||||
// items stay, and the templates they render through: a held module's contributions to other modules' collections
|
||||
// (seat contributions, shell blocks, seat data) still reach their collectors.
|
||||
if m.Handed != nil {
|
||||
h := *m.Handed
|
||||
h.Templates = nil
|
||||
m.Handed = &h
|
||||
}
|
||||
var dirs []map[string]any
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] == "directory" {
|
||||
dirs = append(dirs, r)
|
||||
}
|
||||
}
|
||||
m.Resources = dirs
|
||||
return m
|
||||
}
|
||||
|
||||
// Carried is m, folded into its node's own composition at a build of the old form, as the node runs it (issue 1126,
|
||||
// C5): nothing to build; its containers and directories those the node's own composition (runs) carries of it, their
|
||||
// module's word taken off their ids, with the manifest's directories it does not run. So what other modules read of it
|
||||
// is what runs: the machine ports its containers publish (the filter's rules, the routes reaching it: MachineSide). The
|
||||
// paths it owns, which the holder of node-files is told, are its manifest's, as for its Stub: the controller composes
|
||||
// them from the manifest, not from every path a resource of it runs at (issue 1126, seed-8: f1-circuits, snake and
|
||||
// txt-game on ace, whose manifests leave their directories to the mesh). Its other resources are not taken, since the
|
||||
// composition makes some of them again from its manifest (its secrets' files) and one id twice is refused; none of them
|
||||
// is composed into what is applied either way (compositions leaves a carried module's out, and carries the ones that
|
||||
// run). What it provides, requires, listens on and grants is its manifest's. A module whose resources runs holds none
|
||||
// of is its Stub, as before.
|
||||
func Carried(m catalogue.Manifest, module string, runs *delivery.Composed) catalogue.Manifest {
|
||||
if runs == nil {
|
||||
return Stub(m)
|
||||
}
|
||||
var taken []map[string]any
|
||||
runsAny := false
|
||||
ids := map[string]bool{}
|
||||
for _, raw := range runs.Resources {
|
||||
var r map[string]any
|
||||
if json.Unmarshal(raw, &r) != nil {
|
||||
continue
|
||||
}
|
||||
id, _ := r["id"].(string)
|
||||
owner, _ := r["module"].(string)
|
||||
if owner == "" {
|
||||
owner, _ = declaration.ModuleGuess(id)
|
||||
}
|
||||
if owner != module {
|
||||
continue
|
||||
}
|
||||
runsAny = true
|
||||
if typ := r["type"]; typ != "container" && typ != "directory" {
|
||||
continue
|
||||
}
|
||||
r["id"] = strings.TrimPrefix(id, module+".")
|
||||
delete(r, "module")
|
||||
ids[r["id"].(string)] = true
|
||||
taken = append(taken, r)
|
||||
}
|
||||
if !runsAny {
|
||||
return Stub(m)
|
||||
}
|
||||
stub := Stub(m)
|
||||
for _, r := range stub.Resources {
|
||||
if id, _ := r["id"].(string); !ids[id] {
|
||||
taken = append(taken, r)
|
||||
}
|
||||
}
|
||||
stub.Resources = taken
|
||||
return stub
|
||||
}
|
||||
|
||||
// carry puts into the node's resources (kept, in the declaration's order) the resources of the node's own composition as
|
||||
// it runs (runs) that belong to a carried module, each after the resource it runs after, or first when that one is
|
||||
// not among them; a file among them is a folded path.
|
||||
func carry(kept []json.RawMessage, foldedPaths map[string]bool, runs *delivery.Composed, carried func(module, id string) bool) ([]json.RawMessage, map[string]bool, error) {
|
||||
idOf := func(raw json.RawMessage) (string, string, string, string, error) {
|
||||
var head struct {
|
||||
ID string `json:"id"`
|
||||
Module string `json:"module"`
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
err := json.Unmarshal(raw, &head)
|
||||
return head.ID, head.Module, head.Type, head.Path, err
|
||||
}
|
||||
present := map[string]bool{}
|
||||
for _, raw := range kept {
|
||||
if id, module, _, _, err := idOf(raw); err == nil && module == "" {
|
||||
present[id] = true
|
||||
}
|
||||
}
|
||||
// By the id of the resource of the node's own it runs after, "" for first: the last one before it that is composed.
|
||||
after := map[string][]json.RawMessage{}
|
||||
anchor := ""
|
||||
for _, raw := range runs.Resources {
|
||||
id, module, typ, path, err := idOf(raw)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("compose: a resource the node runs in its own composition: %w", err)
|
||||
}
|
||||
if !carried(module, id) {
|
||||
if module == "" && present[id] {
|
||||
anchor = id
|
||||
}
|
||||
continue
|
||||
}
|
||||
after[anchor] = append(after[anchor], raw)
|
||||
if typ == string(declaration.TypeFile) {
|
||||
foldedPaths[path] = true
|
||||
}
|
||||
}
|
||||
out := make([]json.RawMessage, 0, len(kept)+len(runs.Resources))
|
||||
out = append(out, after[""]...)
|
||||
for _, raw := range kept {
|
||||
out = append(out, raw)
|
||||
if id, module, _, _, err := idOf(raw); err == nil && module == "" {
|
||||
out = append(out, after[id]...)
|
||||
}
|
||||
}
|
||||
return out, foldedPaths, nil
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
)
|
||||
|
||||
func fpFor(c byte) string { return delivery.SourcePrefix2 + strings.Repeat(string(c), 64) }
|
||||
|
||||
// 1943/AC5: a node's compositions are its declaration as Compose gives it, split by the module each resource belongs
|
||||
// to: every resource once, each in its module's composition at the fingerprint given, the module's ownership said on
|
||||
// it; a module given no fingerprint is named as skipped and composed nowhere.
|
||||
func TestCompositionsAreTheDeclarationSplitByModule(t *testing.T) {
|
||||
in := aSmallMesh(t)
|
||||
d, _, err := Compose(in, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var want []string
|
||||
for _, r := range d.Resources {
|
||||
want = append(want, r["type"].(string)+":"+r["id"].(string))
|
||||
}
|
||||
sort.Strings(want)
|
||||
|
||||
fps := map[string]string{"mesh-wireguard": fpFor('a'), "files": fpFor('b')}
|
||||
out, skipped, err := Compositions(in, "laptop", fps, Foreseeing)
|
||||
if err != nil || len(skipped) != 0 {
|
||||
t.Fatalf("Compositions = %v, skipped %v", err, skipped)
|
||||
}
|
||||
var got []string
|
||||
for key, c := range out {
|
||||
node, module, fp, isNode, err := delivery.ParseComposedKey(key)
|
||||
if err != nil || node != "laptop" || module != c.Module || fp != c.FP || (isNode != (module == "")) {
|
||||
t.Errorf("key %s holds %s at %s (%v)", key, c.Module, c.FP, err)
|
||||
}
|
||||
if !isNode && fp != fps[module] {
|
||||
t.Errorf("%s is composed at %s, given %s", module, fp, fps[module])
|
||||
}
|
||||
if _, err := delivery.EncodeComposed(c); err != nil {
|
||||
t.Errorf("%s: %v", key, err)
|
||||
}
|
||||
for _, raw := range c.Resources {
|
||||
var r struct{ ID, Type, Module string }
|
||||
_ = json.Unmarshal(raw, &r)
|
||||
if r.Module != module {
|
||||
t.Errorf("%s holds %s of %q", key, r.ID, r.Module)
|
||||
}
|
||||
got = append(got, r.Type+":"+r.ID)
|
||||
}
|
||||
}
|
||||
sort.Strings(got)
|
||||
if strings.Join(got, " ") != strings.Join(want, " ") {
|
||||
t.Errorf("the compositions hold %v, the declaration %v", got, want)
|
||||
}
|
||||
|
||||
one, skipped, err := Compositions(in, "laptop", map[string]string{"mesh-wireguard": fpFor('a')}, Foreseeing)
|
||||
if err != nil || strings.Join(skipped, ",") != "files" {
|
||||
t.Fatalf("one module: %v, skipped %v", err, skipped)
|
||||
}
|
||||
for _, c := range one {
|
||||
if c.Module == "files" {
|
||||
t.Errorf("a module given no fingerprint was composed")
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1126: a module the node runs inside its own composition (Node.InOwn), and the private network's always,
|
||||
// given no fingerprint of the new form, is folded into the node's own as the controller's composed writer folds it:
|
||||
// every resource in its place, its module taken off, its id still naming it, nothing skipped.
|
||||
folding := in
|
||||
folding.Nodes = append([]Node(nil), in.Nodes...)
|
||||
for i := range folding.Nodes {
|
||||
if folding.Nodes[i].Name == "laptop" {
|
||||
folding.Nodes[i].InOwn = map[string]bool{"files": true}
|
||||
}
|
||||
}
|
||||
all, skipped, err := Compositions(folding, "laptop", nil, Foreseeing)
|
||||
nodeKey, _ := delivery.ComposedNodeKey("laptop")
|
||||
if err != nil || len(skipped) != 0 || len(all) != 1 || len(all[nodeKey].Resources) != len(want) {
|
||||
t.Fatalf("folded: %v, skipped %v, %d compositions, %d of %d resources in the node's own", err, skipped, len(all), len(all[nodeKey].Resources), len(want))
|
||||
}
|
||||
for i, raw := range all[nodeKey].Resources {
|
||||
var r map[string]any
|
||||
_ = json.Unmarshal(raw, &r)
|
||||
if _, says := r["module"]; says || r["id"] != d.Resources[i]["id"] {
|
||||
t.Errorf("folded resource %d is %v, the declaration's %v", i, r["id"], d.Resources[i]["id"])
|
||||
}
|
||||
}
|
||||
if _, err := delivery.EncodeComposed(all[nodeKey]); err != nil {
|
||||
t.Errorf("the node's own with the folded modules: %v", err)
|
||||
}
|
||||
if _, _, err := Compositions(in, "nowhere", fps, Foreseeing); err == nil {
|
||||
t.Errorf("a node the mesh does not have was composed")
|
||||
}
|
||||
|
||||
// Issue 1126, 2026-10-29: a module composed as what runs of it (folded at a build of the old form, or held) still
|
||||
// contributes to the runtime what the runtime serves of it as it runs (Node.RunsTools): its manifest at the
|
||||
// indexer's commit has no bundles (shanks's runtime lost the folded systemd-resolved, novox's the folded nats), and a
|
||||
// held module's new build's bundles are not delivered.
|
||||
dg := func(c byte) string { return "sha256:" + strings.Repeat(string(c), 64) }
|
||||
runtime := catalogue.Manifest{Module: catalogue.RuntimeModule, Version: "1",
|
||||
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/node-tools/broker"}},
|
||||
Bundles: []catalogue.Bundle{{Name: "runtime", Source: catalogue.ArtifactStoreScheme + "node-tools/runtime/blobs/" + dg('a'),
|
||||
Digest: dg('a'), Language: "go", Binary: "node-tools"}}}
|
||||
store := catalogue.Manifest{Module: "artifacts", Version: "1",
|
||||
Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}}
|
||||
lamp := catalogue.Manifest{Module: "lamp", Version: "1", Tools: []string{"on"},
|
||||
Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/lamp", "mode": "0700"}},
|
||||
Build: &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "tools", Kind: catalogue.ArtifactBundle,
|
||||
Language: "go", System: "arch", From: "cmd/lamp-tools", Env: map[string]string{"LAMP_CONF": "/var/lib/lamp/conf"}}}}}
|
||||
newLamp, err := lamp.Resolve([]catalogue.Built{{Name: "tools", Kind: catalogue.ArtifactBundle,
|
||||
Reference: catalogue.ArtifactStoreScheme + "lamp/tools/blobs/" + dg('c'), Digest: dg('c')}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
served := "lamp=" + catalogue.BundlePath("lamp", "tools") + "/lamp-tools"
|
||||
runtimeRuns := func(dropped bool) delivery.Composed {
|
||||
env := `"MESH_TOOL_MODULES":"` + served + `","MESH_TOOL_ENV":"{\"lamp\":{\"LAMP_CONF\":\"/var/lib/lamp/conf\"}}"`
|
||||
if dropped {
|
||||
env = `"MESH_TOOL_MODULES":""` // the drop already applied (shanks, 2026-10-29)
|
||||
}
|
||||
return delivery.Composed{Node: "anchor", Module: catalogue.RuntimeModule, Resources: []json.RawMessage{json.RawMessage(
|
||||
`{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{` + env + `}}`)}}
|
||||
}
|
||||
lampRuns := `{"id":"lamp.bundle-tools","type":"archive","source":"` + catalogue.ArtifactStoreScheme + `lamp/tools/blobs/` + dg('b') +
|
||||
`","digest":"` + dg('b') + `","path":"` + catalogue.BundlePath("lamp", "tools") + `"}`
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
set func(n *Node) delivery.Composed // and what runs of lamp
|
||||
fps map[string]string
|
||||
dropped bool
|
||||
}{
|
||||
{"folded at a build of the old form", func(n *Node) delivery.Composed {
|
||||
n.InOwn = map[string]bool{"lamp": true}
|
||||
n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}}
|
||||
return *n.RunsOwn
|
||||
}, map[string]string{}, false},
|
||||
{"folded, the runtime already dropped it", func(n *Node) delivery.Composed {
|
||||
n.InOwn = map[string]bool{"lamp": true}
|
||||
n.RunsOwn = &delivery.Composed{Node: "anchor", Resources: []json.RawMessage{json.RawMessage(lampRuns)}}
|
||||
return *n.RunsOwn
|
||||
}, map[string]string{}, true},
|
||||
{"held, its new build not delivered", func(n *Node) delivery.Composed {
|
||||
n.Held = map[string]bool{"lamp": true}
|
||||
lr := delivery.Composed{Node: "anchor", Module: "lamp", Resources: []json.RawMessage{json.RawMessage(
|
||||
strings.Replace(lampRuns, `"type"`, `"module":"lamp","type"`, 1))}}
|
||||
n.Kept = map[string]catalogue.Manifest{"lamp": Carried(newLamp, "lamp", &lr)}
|
||||
return lr
|
||||
}, map[string]string{"lamp": fpFor('1')}, false},
|
||||
} {
|
||||
tools := aMesh(t)
|
||||
tools.Catalogue[catalogue.RuntimeModule], tools.Catalogue["artifacts"], tools.Catalogue["lamp"] = runtime, store, lamp
|
||||
anchor := nodeOf(t, &tools, "anchor")
|
||||
anchor.Assigned = append(anchor.Assigned, "artifacts", catalogue.RuntimeModule, "lamp")
|
||||
tools.BusMinted = []string{"anchor." + catalogue.RuntimeModule}
|
||||
anchor.SealingKey = keyOf(t, "anchor")
|
||||
anchor.RunsTools = ToolsRunning([]delivery.Composed{runtimeRuns(row.dropped), row.set(anchor)})
|
||||
row.fps[overlay.Name], row.fps["artifacts"], row.fps[catalogue.RuntimeModule] = fpFor('a'), fpFor('5'), fpFor('e')
|
||||
out, _, _, err := CompositionsIn(tools, "anchor", row.fps, Foreseeing)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", row.name, err)
|
||||
}
|
||||
var env map[string]any
|
||||
for _, c := range out {
|
||||
for _, raw := range c.Resources {
|
||||
var r struct {
|
||||
ID string `json:"id"`
|
||||
Env map[string]any `json:"env"`
|
||||
}
|
||||
if json.Unmarshal(raw, &r) == nil && r.ID == "node-tools.runtime" {
|
||||
env = r.Env
|
||||
}
|
||||
}
|
||||
}
|
||||
if env[catalogue.RuntimeToolModules] != served || env[catalogue.RuntimeToolEnv] != `{"lamp":{"LAMP_CONF":"/var/lib/lamp/conf"}}` {
|
||||
t.Errorf("%s: the runtime serves %v with %v, want what runs of lamp, %s", row.name,
|
||||
env[catalogue.RuntimeToolModules], env[catalogue.RuntimeToolEnv], served)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1943/AC1: the upgrade policies and sources a composition's value may name are the catalogue's own words.
|
||||
func TestTheComposedPoliciesAreTheCatalogues(t *testing.T) {
|
||||
for _, p := range []string{catalogue.PolicyRoll, catalogue.PolicyTogether, catalogue.PolicyRecord} {
|
||||
for _, from := range []string{catalogue.FromPerson, catalogue.FromModule, catalogue.FromBus, catalogue.FromData, catalogue.FromDefault} {
|
||||
v := delivery.ComposedDigest{Digest: "sha256:" + strings.Repeat("a", 64), Judgement: delivery.JudgedNeither, Policy: p, PolicyFrom: from}
|
||||
if _, err := delivery.EncodeComposedDigest(v); err != nil {
|
||||
t.Errorf("%s from %s: %v", p, from, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if delivery.PolicyRecord != catalogue.PolicyRecord {
|
||||
t.Errorf("delivery.PolicyRecord = %q, the catalogue's %q", delivery.PolicyRecord, catalogue.PolicyRecord)
|
||||
}
|
||||
}
|
||||
@@ -1,498 +0,0 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// Mode says what a composition does about what a send would make. Both modes read what the Input holds and make nothing; they
|
||||
// differ as the controller's Reading and Foreseeing modes do.
|
||||
type Mode int
|
||||
|
||||
const (
|
||||
// Reading composes what is held and leaves out an own secret the Input holds none for: that machine cannot be running it.
|
||||
Reading Mode = iota
|
||||
// Foreseeing composes an own secret the next send would make with a stand-in and names it foreseen, while one the send would
|
||||
// refuse (a bus credential nobody issued, a given secret under an old key) is refused here, in the same words.
|
||||
Foreseeing
|
||||
// Planning composes for a provisioning plan alone (novox/hq ADR 0570 clause 2): every own secret the Input holds none
|
||||
// for stands in, named foreseen, and nothing is refused for a value, since a plan names secrets and never carries one.
|
||||
// The seat mesh-provisioner's holder composes so, reading no secret; a rendering in this mode is never applied.
|
||||
Planning
|
||||
)
|
||||
|
||||
// ForeseenCertificate and ForeseenBusUsers stand for what the Input does not carry, in a composition asked ahead of the send.
|
||||
// Never sent.
|
||||
const (
|
||||
ForeseenCertificate = "foreseen: issued by the next send"
|
||||
ForeseenBusUsers = "foreseen: composed from the bus records by the next send"
|
||||
)
|
||||
|
||||
// grantsFor is every credential this node must create, because something elsewhere uses it (plan.go grantsFor): the mirror of
|
||||
// what a consumer is given. A consumer whose identity overflows the provision's bound is left out of the grants and returned
|
||||
// beside them (novox/hq ADR 0225); one whose resolution binds it to another provider is withdrawn and returned beside them
|
||||
// (issue 274). Every other is granted.
|
||||
func (c *composer) grantsFor(node string) ([]catalogue.Grant, []catalogue.Overflow, []catalogue.Unbound, []string, error) {
|
||||
issued, err := c.rowsFor(node)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
onNetwork, err := c.whereEveryoneIs()
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
out := make([]catalogue.Grant, 0, len(issued))
|
||||
var withheld []catalogue.Overflow
|
||||
var unbound []catalogue.Unbound
|
||||
var foreseen []string
|
||||
for _, s := range issued {
|
||||
resolved, err := c.planFor(s.Consumer)
|
||||
switch {
|
||||
case unresolvable(err):
|
||||
// Their set does not resolve. Skipped rather than fatal: this node is not the place to report another machine's problem.
|
||||
continue
|
||||
case err != nil:
|
||||
// The mesh could not be asked what they wanted, which is not the same as their wanting nothing.
|
||||
return nil, nil, nil, nil, fmt.Errorf("what %s asked of %s cannot be read: %w", s.Consumer, s.Provision, err)
|
||||
}
|
||||
plan := resolved.Plan
|
||||
values, asks, err := plan.ContributionsFrom(s.Provision, s.ConsumerModule, resolved.Settings)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
// A port in there is the consumer's software port until this: the assignment that moved it is that machine's.
|
||||
consumer, err := c.in.node(s.Consumer)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, err
|
||||
}
|
||||
values = catalogue.AtPublishedPort(values, s.ConsumerModule, consumer.Ports[s.ConsumerModule])
|
||||
from := s.ConsumerModule
|
||||
if !asks {
|
||||
// That module no longer wants this: left empty, which is how a login is withdrawn rather than kept working for ever.
|
||||
from = ""
|
||||
}
|
||||
if bound := plan.BindsFrom(s.Provision, s.ConsumerModule, s.Local); from != "" && !slices.Contains(bound, node) {
|
||||
// It still asks, and not of this node: withdrawn exactly as a credential nobody asks for, and said.
|
||||
from = ""
|
||||
unbound = append(unbound, catalogue.Unbound{Provision: s.Provision, Provider: node,
|
||||
Consumer: s.Consumer, Module: s.ConsumerModule, Local: s.Local, BoundTo: bound})
|
||||
}
|
||||
// The consumer's identity slug, from its own manifest, carried on the grant so the provider derives the same login the
|
||||
// consumer does (novox/hq ADR 0049).
|
||||
slug := ""
|
||||
for _, mm := range plan.Modules {
|
||||
if mm.Module == s.ConsumerModule {
|
||||
slug = mm.Slug
|
||||
break
|
||||
}
|
||||
}
|
||||
if from != "" {
|
||||
bound := boundOfGrant(plan, s, node)
|
||||
source := catalogue.IdentitySource(slug, s.ConsumerModule)
|
||||
if catalogue.CheckIdentityWithin(s.Consumer, source, bound) != nil {
|
||||
withheld = append(withheld, catalogue.Overflow{Provision: s.Provision, Provider: node,
|
||||
Consumer: s.Consumer, Module: s.ConsumerModule,
|
||||
Identity: catalogue.ConsumerIdentity(s.Consumer, source), Bound: bound})
|
||||
continue
|
||||
}
|
||||
}
|
||||
sealed := s.ForProvider
|
||||
switch {
|
||||
case credentiallessRow(plan, s, node):
|
||||
// Its offer presents no credential (form none): the grant carries the consumer and nothing sealed, and
|
||||
// nothing stands in for it, so neither end waits for a value nobody makes (issue 1126).
|
||||
sealed = ""
|
||||
case sealed == "":
|
||||
sealed = catalogue.ForeseenSealed
|
||||
foreseen = append(foreseen, fmt.Sprintf("the grant of %s for %s on %s", s.Provision, s.ConsumerModule, s.Consumer))
|
||||
}
|
||||
out = append(out, catalogue.Grant{
|
||||
Provision: s.Provision, Consumer: s.Consumer, At: onNetwork[s.Consumer],
|
||||
From: from, Values: values, Slug: slug, Sealed: sealed, Local: s.Local})
|
||||
}
|
||||
return out, withheld, unbound, foreseen, nil
|
||||
}
|
||||
|
||||
// credentialless is whether a need's provider offers it with no credential (catalogue.CredentialFormNone): the vault
|
||||
// mints nothing for it, so nothing is asked of the vault, sealed into a grant or a file, or stood in for (issue 1126).
|
||||
func credentialless(n catalogue.Needed) bool { return n.CredentialForm == catalogue.CredentialFormNone }
|
||||
|
||||
// credentiallessRow is whether the consumer's resolution answers row's need from provider with an offer of form none.
|
||||
func credentiallessRow(consumer catalogue.Resolution, row Issued, provider string) bool {
|
||||
return slices.ContainsFunc(consumer.Needs, func(n catalogue.Needed) bool {
|
||||
return n.Name == row.Provision && n.For == row.ConsumerModule && n.Local == row.Local && n.From == provider &&
|
||||
credentialless(n)
|
||||
})
|
||||
}
|
||||
|
||||
// rowsFor is every pair credential this node's declaration must carry a grant for, in the order the controller reads them
|
||||
// (SecretsFrom: by provision, consumer, consumer module and local name): the ones the Input says were made, and the ones the same send
|
||||
// would make before it composes this node -- every need of a node that resolves (this one too), whose provider is this node, that is not
|
||||
// answered by a record and not a credential only a person gives and nobody gave. Those have no sealed copy (ForProvider empty), so the
|
||||
// grant is composed with a stand-in and named foreseen (plan.go: planFor makes the secret in every mode before grantsFor reads it).
|
||||
func (c *composer) rowsFor(node string) ([]Issued, error) {
|
||||
var rows []Issued
|
||||
for _, row := range c.in.Issued {
|
||||
if row.Provider == node {
|
||||
rows = append(rows, row)
|
||||
}
|
||||
}
|
||||
for _, other := range c.in.sorted() {
|
||||
resolved, err := c.planFor(other.Name)
|
||||
switch {
|
||||
case unresolvable(err):
|
||||
continue
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("what %s asked of %s cannot be read: %w", other.Name, node, err)
|
||||
}
|
||||
shelf := c.shelfFor(other)
|
||||
for _, n := range resolved.Plan.Needs {
|
||||
if n.From != node || n.ByRecord {
|
||||
continue
|
||||
}
|
||||
cred, held := c.credential(n, other.Name)
|
||||
if _, outside := issuedOutsideBy(shelf, n); cred.State == CredentialNotGiven || (outside && !(held && cred.State == CredentialHeld)) {
|
||||
continue
|
||||
}
|
||||
if slices.ContainsFunc(rows, func(r Issued) bool {
|
||||
return r.Provision == n.Name && r.Consumer == other.Name && r.ConsumerModule == n.For && r.Local == n.Local
|
||||
}) {
|
||||
continue
|
||||
}
|
||||
rows = append(rows, Issued{Provision: n.Name, Consumer: other.Name, ConsumerModule: n.For, Local: n.Local, Provider: node})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
a, b := rows[i], rows[j]
|
||||
switch {
|
||||
case a.Provision != b.Provision:
|
||||
return a.Provision < b.Provision
|
||||
case a.Consumer != b.Consumer:
|
||||
return a.Consumer < b.Consumer
|
||||
case a.ConsumerModule != b.ConsumerModule:
|
||||
return a.ConsumerModule < b.ConsumerModule
|
||||
}
|
||||
return a.Local < b.Local
|
||||
})
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// boundOfGrant is the identity bound the consumer's own resolution states for the requirement this grant answers. A requirement
|
||||
// not found there is held to the tightest bound the mesh knows rather than to none.
|
||||
func boundOfGrant(consumer catalogue.Resolution, s Issued, provider string) catalogue.IdentityBound {
|
||||
for _, n := range consumer.Needs {
|
||||
if n.Name == s.Provision && n.For == s.ConsumerModule && n.From == provider {
|
||||
return n.Identity
|
||||
}
|
||||
}
|
||||
return catalogue.DefaultIdentityBound
|
||||
}
|
||||
|
||||
// ownSecretRow is the row for one own secret of a module on a node.
|
||||
func (c *composer) ownSecretRow(node, module, name string) (OwnSecretRow, bool) {
|
||||
for _, r := range c.in.OwnSecrets {
|
||||
if r.Node == node && r.Module == module && r.Name == name {
|
||||
return r, true
|
||||
}
|
||||
}
|
||||
return OwnSecretRow{}, false
|
||||
}
|
||||
|
||||
// givenMember is one own secret a person gave on a machine and whether it is sealed to the key the machine holds now.
|
||||
type givenMember struct {
|
||||
Name string
|
||||
Sealed string
|
||||
Current bool
|
||||
}
|
||||
|
||||
// givenMembers is every member of a module's secret family given on a machine, by name (inv.GivenMembers): the mesh never
|
||||
// makes a member, so only a value a person gave is one; a machine with no sealing key holds none.
|
||||
func (c *composer) givenMembers(node Node, module, family string) []givenMember {
|
||||
if node.SealingKey == "" {
|
||||
return nil
|
||||
}
|
||||
prefix := catalogue.FamilyPrefix(family)
|
||||
var out []givenMember
|
||||
for _, r := range c.in.OwnSecrets {
|
||||
if r.Node == node.Name && r.Module == module && r.Origin == "accepted" && strings.HasPrefix(r.Name, prefix) {
|
||||
out = append(out, givenMember{Name: r.Name, Sealed: r.Sealed, Current: r.SealedTo == node.SealingKey})
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// givenOwnSecret is an own secret issued outside the mesh as a person gave it on a machine (inv.GivenOwnSecret): not given when
|
||||
// the value held was made by the mesh or the machine has no sealing key.
|
||||
func (c *composer) givenOwnSecret(node Node, module, name string) (givenMember, bool) {
|
||||
if node.SealingKey == "" {
|
||||
return givenMember{}, false
|
||||
}
|
||||
r, ok := c.ownSecretRow(node.Name, module, name)
|
||||
if !ok || r.Origin != "accepted" {
|
||||
return givenMember{}, false
|
||||
}
|
||||
return givenMember{Name: name, Sealed: r.Sealed, Current: r.SealedTo == node.SealingKey}, true
|
||||
}
|
||||
|
||||
// moduleSecretIfIssued is the read half of the controller's SecretForModule (inv.ModuleSecretIfIssued): the secret when one is
|
||||
// held and sealed to the key the machine holds now, and otherwise not held. A key it was sealed to that the machine no longer
|
||||
// has is reported as absent rather than as an error.
|
||||
func (c *composer) moduleSecretIfIssued(node Node, module, name string) (sealed string, held bool) {
|
||||
if node.SealingKey == "" {
|
||||
return "", false
|
||||
}
|
||||
r, ok := c.ownSecretRow(node.Name, module, name)
|
||||
if !ok || r.SealedTo != node.SealingKey {
|
||||
return "", false
|
||||
}
|
||||
return r.Sealed, true
|
||||
}
|
||||
|
||||
// wouldMakeSecretForModule says what the send would do about an own secret without doing it: nil when it would make one (or one
|
||||
// is held), and otherwise the very refusal it would meet (inv.WouldMakeSecretForModule).
|
||||
func (c *composer) wouldMakeSecretForModule(node Node, module, name string) error {
|
||||
if node.SealingKey == "" {
|
||||
return fmt.Errorf("%s needs a secret and %s has no sealing key, so nothing can be sealed to it", module, node.Name)
|
||||
}
|
||||
r, ok := c.ownSecretRow(node.Name, module, name)
|
||||
switch {
|
||||
case !ok:
|
||||
return nil
|
||||
case r.SealedTo != node.SealingKey && r.Origin == "accepted":
|
||||
return fmt.Errorf("%s on %s holds %q, which was given to the mesh rather than made by it, and %s has "+
|
||||
"since generated a new sealing key. The mesh cannot make another; issue it again", module, node.Name, name, node.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// givenUnderAnOldKey is the refusal for a value a person gave sealed to a key the machine no longer holds.
|
||||
func givenUnderAnOldKey(module, node, name string) error {
|
||||
return fmt.Errorf("%s on %s holds %q, which was given to the mesh rather than made by it, and %s has "+
|
||||
"since generated a new sealing key. The mesh cannot make another; give it again", module, node, name, node)
|
||||
}
|
||||
|
||||
// ownSecretsFor is each module's own secrets as this node composes them (plan.go renderingFor): the needed ones sealed to the
|
||||
// node, and, in the Foreseeing mode, the ones the next send would make named foreseen. A module handed to the provisioner has its
|
||||
// secrets from the vault through the client, so none of them is composed here.
|
||||
func (c *composer) ownSecretsFor(node Node, plan catalogue.Resolution, handing catalogue.Rendering, mode Mode) (
|
||||
needed map[string]map[string]string, foreseen map[string]map[string]bool, err error) {
|
||||
needed = map[string]map[string]string{}
|
||||
foreseen = map[string]map[string]bool{}
|
||||
mark := func(module, name string) {
|
||||
if foreseen[module] == nil {
|
||||
foreseen[module] = map[string]bool{}
|
||||
}
|
||||
foreseen[module][name] = true
|
||||
}
|
||||
for _, m := range plan.Modules {
|
||||
if handing.Handing(m) {
|
||||
continue
|
||||
}
|
||||
// A secret family is never made (novox/hq ADR 0283): each member a person gave on this machine is placed, and one not
|
||||
// given is nothing -- the module says it waits for it.
|
||||
for _, family := range m.OwnSecrets.Families() {
|
||||
for _, g := range c.givenMembers(node, m.Module, family) {
|
||||
if _, fam, ok := m.OwnSecrets.Lookup(g.Name); !ok || fam != family {
|
||||
continue // a longer family's member, or a name no longer of this family
|
||||
}
|
||||
if !g.Current {
|
||||
continue
|
||||
}
|
||||
if needed[m.Module] == nil {
|
||||
needed[m.Module] = map[string]string{}
|
||||
}
|
||||
needed[m.Module][g.Name] = g.Sealed
|
||||
if g.Sealed == "" {
|
||||
// Given, and its value is not in the Input: placed with a stand-in, named.
|
||||
needed[m.Module][g.Name] = catalogue.ForeseenSealed
|
||||
mark(m.Module, g.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
names := make([]string, 0)
|
||||
for name := range m.OwnSecrets.Plain() {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
own := m.OwnSecrets.Plain()[name]
|
||||
// An own secret issued outside the mesh is never made (novox/hq issue 401, ADR 0228; ADR 0283 for a family): only a
|
||||
// value a person gave is placed. One not given is nothing, and a value the mesh made for it before is not one either.
|
||||
if own.IssuedBy == catalogue.IssuedOutside {
|
||||
g, given := c.givenOwnSecret(node, m.Module, name)
|
||||
if !given || !g.Current {
|
||||
continue
|
||||
}
|
||||
if g.Sealed == "" {
|
||||
mark(m.Module, name)
|
||||
continue
|
||||
}
|
||||
if needed[m.Module] == nil {
|
||||
needed[m.Module] = map[string]string{}
|
||||
}
|
||||
needed[m.Module][name] = g.Sealed
|
||||
continue
|
||||
}
|
||||
sealed, held := c.moduleSecretIfIssued(node, m.Module, name)
|
||||
if !held && mode == Planning {
|
||||
mark(m.Module, name)
|
||||
continue
|
||||
}
|
||||
if !held && mode == Foreseeing {
|
||||
// Asked ahead of the send: what the send would do about it, by the send's own rules. Made by it -- a stand-in,
|
||||
// named; refused by it -- refused here, the same words (novox/hq issue 275). A bus account not yet issued is
|
||||
// foreseen, never refused: the vault mints it from the grants part's wanted list once the placement is written
|
||||
// on the operator's Approve (ADR 0561 §1, ADR 0567 §5), so a composition asked ahead of that cannot hold it
|
||||
// (issue 774, 2026-10-30: every proposal placing mesh-conditions, mesh-self-check or mesh-healers refused).
|
||||
if err := c.wouldMakeSecretForModule(node, m.Module, name); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
mark(m.Module, name)
|
||||
continue
|
||||
}
|
||||
if !held {
|
||||
// Never issued, so this machine cannot be running it: left out rather than invented.
|
||||
continue
|
||||
}
|
||||
if sealed == "" {
|
||||
// Held, and its value is not in the Input: composed with a stand-in, named.
|
||||
mark(m.Module, name)
|
||||
continue
|
||||
}
|
||||
if needed[m.Module] == nil {
|
||||
needed[m.Module] = map[string]string{}
|
||||
}
|
||||
needed[m.Module][name] = sealed
|
||||
}
|
||||
}
|
||||
return needed, foreseen, nil
|
||||
}
|
||||
|
||||
// CoreBoxWanted is a core credential whose box the Input does not hold for a machine: the next send asks the vault for it and
|
||||
// keeps the answer. It is returned for the caller to ask and keep; this package asks and keeps nothing.
|
||||
type CoreBoxWanted struct {
|
||||
Node string
|
||||
Module string
|
||||
Name string
|
||||
}
|
||||
|
||||
// coreModulesOf is every module of the plan on the core's list whose own secrets this machine's declaration carries, with their
|
||||
// plain own secret names, sorted: one handed to the provisioner has its secrets from the vault through the client, and one issued
|
||||
// outside the mesh is never the vault's to seal here.
|
||||
func coreModulesOf(modules []catalogue.Manifest, handed func(catalogue.Manifest) bool) map[string][]string {
|
||||
out := map[string][]string{}
|
||||
for _, m := range modules {
|
||||
if !catalogue.CarriesVaultSealed(m.Module) || (handed != nil && handed(m)) {
|
||||
continue
|
||||
}
|
||||
var names []string
|
||||
for name, own := range m.OwnSecrets.Plain() {
|
||||
if own.IssuedBy != catalogue.IssuedOutside {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
if len(names) > 0 {
|
||||
sort.Strings(names)
|
||||
out[m.Module] = names
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sealingKeyValid says a machine's sealing key, recorded raw in base64, is a raw X25519 key.
|
||||
func sealingKeyValid(raw string) error {
|
||||
b, err := base64.StdEncoding.DecodeString(strings.TrimSpace(raw))
|
||||
if err != nil || len(b) != 32 {
|
||||
return errors.New("the machine's sealing key is not a raw X25519 key")
|
||||
}
|
||||
_, err = ecdh.X25519().NewPublicKey(b)
|
||||
return err
|
||||
}
|
||||
|
||||
// coreBoxesFor decides each core credential of a machine in a composition that sends nothing and asks no vault (core_box.go
|
||||
// coreBoxesOf, in the modes that are not a send): the kept box while it is sealed to the key the machine holds now, and otherwise a
|
||||
// stand-in that the next send replaces by asking the vault -- returned in wanted for the caller to ask and keep. A machine with no
|
||||
// sealing key, or one whose key is not a key, leaves its module out, said.
|
||||
func (c *composer) coreBoxesFor(node Node, modules []catalogue.Manifest, handing catalogue.Rendering) (
|
||||
sealed map[string]map[string]json.RawMessage, notPlaced map[string]string, wanted []CoreBoxWanted) {
|
||||
core := coreModulesOf(modules, func(m catalogue.Manifest) bool { return handing.Handing(m) })
|
||||
sealed, notPlaced = map[string]map[string]json.RawMessage{}, map[string]string{}
|
||||
mods := make([]string, 0, len(core))
|
||||
for m := range core {
|
||||
mods = append(mods, m)
|
||||
}
|
||||
sort.Strings(mods)
|
||||
for _, module := range mods {
|
||||
if node.SealingKey == "" {
|
||||
notPlaced[module] = fmt.Sprintf("%s has no sealing key, so the vault can seal %s's credentials to "+
|
||||
"nothing there (novox/hq ADR 0349 §16)", node.Name, module)
|
||||
continue
|
||||
}
|
||||
if err := sealingKeyValid(node.SealingKey); err != nil {
|
||||
notPlaced[module] = fmt.Sprintf("%s's credentials cannot be asked of the vault: %v", module, err)
|
||||
continue
|
||||
}
|
||||
boxes := map[string]json.RawMessage{}
|
||||
for _, name := range core[module] {
|
||||
var kept *CoreBox
|
||||
for i := range c.in.KeptCoreBoxes {
|
||||
k := &c.in.KeptCoreBoxes[i]
|
||||
if k.Node == node.Name && k.Module == module && k.Name == name {
|
||||
kept = k
|
||||
}
|
||||
}
|
||||
if kept != nil && kept.SealedTo == node.SealingKey && len(kept.Box) > 0 {
|
||||
boxes[name] = kept.Box
|
||||
continue
|
||||
}
|
||||
boxes[name] = catalogue.ForeseenVaultBox
|
||||
wanted = append(wanted, CoreBoxWanted{Node: node.Name, Module: module, Name: name})
|
||||
}
|
||||
sealed[module] = boxes
|
||||
}
|
||||
return sealed, notPlaced, wanted
|
||||
}
|
||||
|
||||
// certificateFor is what the mesh certifies about one machine's internal name (plan.go certificateFor): refused for a machine that
|
||||
// never told the mesh what key it serves with, and for one not on the private network, which has no name to be certified for.
|
||||
// The certificate and the authority are public values the Input may carry; without them stand-ins are composed, and named.
|
||||
func (c *composer) certificateFor(node Node) (certificate, authority string, foreseen bool, err error) {
|
||||
if node.ServingKey == "" {
|
||||
return "", "", false, fmt.Errorf(
|
||||
"%s wants a certificate and has never told the mesh what key it serves with; it "+
|
||||
"joins again to report one", node.Name)
|
||||
}
|
||||
where, err := c.whereEveryoneIs()
|
||||
if err != nil {
|
||||
return "", "", false, err
|
||||
}
|
||||
if where[node.Name] == "" {
|
||||
return "", "", false, fmt.Errorf(
|
||||
"%s wants a certificate and is not on the private network, so it has no name inside "+
|
||||
"the mesh to be certified for", node.Name)
|
||||
}
|
||||
certificate, authority = node.Certificate, c.in.Authority
|
||||
if certificate == "" || authority == "" {
|
||||
return ForeseenCertificate, ForeseenCertificate, true, nil
|
||||
}
|
||||
return certificate, authority, false, nil
|
||||
}
|
||||
|
||||
// holdsTheBus says whether a node's set includes the module that asks for the bus's user list.
|
||||
func holdsTheBus(modules []catalogue.Manifest) bool {
|
||||
for _, m := range modules {
|
||||
if m.BusUsers != "" && m.ClaimsSeat("mesh-broker") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// CredentialKey names one pair credential by the ends it joins: the provision, the consumer's node and module, the
|
||||
// provider's node, and the local name where a module keeps several (Credential's and Issued's key).
|
||||
type CredentialKey struct {
|
||||
Provision string `json:"provision"`
|
||||
Consumer string `json:"consumer"`
|
||||
ConsumerModule string `json:"consumer_module"`
|
||||
Provider string `json:"provider"`
|
||||
Local string `json:"local,omitempty"`
|
||||
}
|
||||
|
||||
// CredentialsNeeded is every pair credential node's composition reads (novox/hq ADR 0547 §2, ADR 0550 §5, issue 1126):
|
||||
// as a consumer, each need of its resolution a provider's credential answers (a need answered by a record is a licence's,
|
||||
// not a pair's); as a provider, each credential it must carry a grant for (rowsFor). A node that composes its own asks the
|
||||
// vault for exactly these, sealed to its key, and composes with them; one it does not get is stood in for, which the
|
||||
// node's own apply refuses (CompositionsApplied). A need whose provider offers it with no credential (form none) is
|
||||
// in neither list: the vault mints nothing for it (issue 1126).
|
||||
func CredentialsNeeded(in Input, node string) (asConsumer, asProvider []CredentialKey, err error) {
|
||||
c := newComposer(in)
|
||||
resolved, err := c.resolution(node)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
for _, n := range resolved.Needs {
|
||||
if n.ByRecord || n.From == "" || credentialless(n) {
|
||||
continue
|
||||
}
|
||||
asConsumer = append(asConsumer, CredentialKey{Provision: n.Name, Consumer: node, ConsumerModule: n.For,
|
||||
Provider: n.From, Local: n.Local})
|
||||
}
|
||||
rows, err := c.rowsFor(node)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("compose: the credentials %s provides: %w", node, err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
// A grant whose offer presents no credential (form none) has none to ask for (issue 1126).
|
||||
if consumer, err := c.planFor(r.Consumer); err == nil && credentiallessRow(consumer.Plan, r, node) {
|
||||
continue
|
||||
}
|
||||
asProvider = append(asProvider, CredentialKey{Provision: r.Provision, Consumer: r.Consumer,
|
||||
ConsumerModule: r.ConsumerModule, Provider: node, Local: r.Local})
|
||||
}
|
||||
sortKeys(asConsumer)
|
||||
sortKeys(asProvider)
|
||||
return asConsumer, asProvider, nil
|
||||
}
|
||||
|
||||
func sortKeys(ks []CredentialKey) {
|
||||
sort.Slice(ks, func(i, j int) bool {
|
||||
a, b := ks[i], ks[j]
|
||||
switch {
|
||||
case a.Provision != b.Provision:
|
||||
return a.Provision < b.Provision
|
||||
case a.Provider != b.Provider:
|
||||
return a.Provider < b.Provider
|
||||
case a.Consumer != b.Consumer:
|
||||
return a.Consumer < b.Consumer
|
||||
case a.ConsumerModule != b.ConsumerModule:
|
||||
return a.ConsumerModule < b.ConsumerModule
|
||||
}
|
||||
return a.Local < b.Local
|
||||
})
|
||||
}
|
||||
@@ -1,549 +0,0 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
)
|
||||
|
||||
// secretValue is a value the Input carries that must never appear in anything the composition says.
|
||||
const secretValue = "SECRET-VALUE-THAT-MUST-NOT-LEAK"
|
||||
|
||||
func rendered(t *testing.T, in Input, node string, mode Mode) Rendered {
|
||||
t.Helper()
|
||||
got, err := Render(in, node, mode)
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
// anAssistant is mesh-controller's outside_not_given_test.go anAssistant (cmd/mesh-controller at 6c2a8d62): a module with a secret
|
||||
// the mesh makes and one only an outside party issues.
|
||||
func anAssistant() catalogue.Manifest {
|
||||
return catalogue.Manifest{Module: "assistant", Version: "1",
|
||||
OwnSecrets: catalogue.OwnSecrets{
|
||||
"password": {Path: "/var/lib/mesh/assistant/password", Taken: catalogue.TakenAtStart},
|
||||
"vendor-key": {Path: "/var/lib/mesh/assistant/vendor-key", Taken: catalogue.TakenAtStart, IssuedBy: catalogue.IssuedOutside},
|
||||
},
|
||||
Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/mesh/assistant", "mode": "0700"}}}
|
||||
}
|
||||
|
||||
func withAssistant(t *testing.T) Input {
|
||||
t.Helper()
|
||||
in := aMesh(t)
|
||||
in.Catalogue["assistant"] = anAssistant()
|
||||
laptop := nodeOf(t, &in, "laptop")
|
||||
laptop.Assigned = append(laptop.Assigned, "assistant")
|
||||
laptop.SealingKey = keyOf(t, "laptop")
|
||||
return in
|
||||
}
|
||||
|
||||
// Own secrets (plan.go renderingFor, novox/hq issue 275 and 401): one held and sealed to the machine's key is carried; one not held is
|
||||
// foreseen in the Foreseeing mode and left out in the Reading mode, and nothing is made.
|
||||
func TestAnOwnSecretHeldIsCarriedAndOneNotHeldIsForeseenOrLeftOut(t *testing.T) {
|
||||
mk := func() Input {
|
||||
in := withAssistant(t)
|
||||
in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made",
|
||||
SealedTo: nodeOf(t, &in, "laptop").SealingKey, Sealed: secretValue}}
|
||||
return in
|
||||
}
|
||||
in := mk()
|
||||
got := rendered(t, in, "laptop", Foreseeing)
|
||||
if got.Rendering.Needed["assistant"]["password"] != secretValue || len(got.Rendering.Foreseen) != 0 {
|
||||
t.Errorf("a held secret: needed %v foreseen %v", got.Rendering.Needed, got.Rendering.Foreseen)
|
||||
}
|
||||
// Not held: foreseen with a stand-in (Foreseeing), left out (Reading).
|
||||
in.OwnSecrets = nil
|
||||
got = rendered(t, in, "laptop", Foreseeing)
|
||||
if !got.Rendering.Foreseen["assistant"]["password"] || got.Rendering.Needed["assistant"]["password"] != "" ||
|
||||
len(got.Foreseen) != 1 || got.Foreseen[0] != "assistant/password" {
|
||||
t.Errorf("a secret the send makes: foreseen %v / %v needed %v", got.Rendering.Foreseen, got.Foreseen, got.Rendering.Needed)
|
||||
}
|
||||
got = rendered(t, in, "laptop", Reading)
|
||||
if len(got.Rendering.Needed["assistant"]) != 0 || len(got.Foreseen) != 0 || got.Rendering.Foreseen != nil {
|
||||
t.Errorf("Reading composed a secret that was never issued: %v %v", got.Rendering.Needed, got.Foreseen)
|
||||
}
|
||||
// Held but sealed to a key the machine no longer has is not held.
|
||||
in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made", SealedTo: "an-old-key", Sealed: secretValue}}
|
||||
if got := rendered(t, in, "laptop", Foreseeing); !got.Rendering.Foreseen["assistant"]["password"] {
|
||||
t.Errorf("a secret sealed to an old key is held: %v", got.Rendering.Needed)
|
||||
}
|
||||
// Held with no value in the Input: a stand-in, named.
|
||||
in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made", SealedTo: nodeOf(t, &in, "laptop").SealingKey}}
|
||||
if got := rendered(t, in, "laptop", Reading); !got.Rendering.Foreseen["assistant"]["password"] {
|
||||
t.Errorf("a held secret with no value is not named: %v", got.Rendering.Foreseen)
|
||||
}
|
||||
unchanged(t, mk, func(in Input) { rendered(t, in, "laptop", Foreseeing) })
|
||||
|
||||
// 1126 (ADR 0547 §2): a node applying what it composed itself never applies a stand-in: a held secret with no value in the
|
||||
// Input is refused, naming it; held with its value, it composes.
|
||||
fps := map[string]string{"assistant": "src2:" + strings.Repeat("a", 64), overlay.Name: "src2:" + strings.Repeat("b", 64)}
|
||||
if _, _, err := CompositionsApplied(in, "laptop", fps); !errors.Is(err, ErrStoodIn) || !strings.Contains(err.Error(), "assistant/password") {
|
||||
t.Errorf("a stand-in was not refused for a node's own apply: %v", err)
|
||||
}
|
||||
if out, _, err := CompositionsApplied(mk(), "laptop", fps); err != nil || len(out) == 0 {
|
||||
t.Errorf("a held secret with its value is refused for a node's own apply: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An own secret issued outside the mesh that nobody gave is never made (novox/hq issue 401, ADR 0228): nothing is composed or
|
||||
// foreseen for it; a value the mesh made for it is not one a person gave; a value given is placed once it is sealed to the key held now.
|
||||
func TestAnOutsideSecretIsPlacedOnlyOnceAPersonGaveIt(t *testing.T) {
|
||||
in := withAssistant(t)
|
||||
key := nodeOf(t, &in, "laptop").SealingKey
|
||||
for _, mode := range []Mode{Reading, Foreseeing} {
|
||||
got := rendered(t, in, "laptop", mode)
|
||||
if _, there := got.Rendering.Needed["assistant"]["vendor-key"]; there || got.Rendering.Foreseen["assistant"]["vendor-key"] {
|
||||
t.Fatalf("%v: a secret nobody gave is composed: %v %v", mode, got.Rendering.Needed, got.Rendering.Foreseen)
|
||||
}
|
||||
}
|
||||
in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "vendor-key", Origin: "made", SealedTo: key, Sealed: secretValue}}
|
||||
if _, there := rendered(t, in, "laptop", Foreseeing).Rendering.Needed["assistant"]["vendor-key"]; there {
|
||||
t.Error("a value the mesh made for a secret issued outside was placed")
|
||||
}
|
||||
in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "vendor-key", Origin: "accepted", SealedTo: key, Sealed: "the vendor's key, sealed"}}
|
||||
if got := rendered(t, in, "laptop", Foreseeing).Rendering.Needed["assistant"]["vendor-key"]; got != "the vendor's key, sealed" {
|
||||
t.Errorf("a value given is placed as %q", got)
|
||||
}
|
||||
in.OwnSecrets[0].SealedTo = "an-old-key"
|
||||
if _, there := rendered(t, in, "laptop", Foreseeing).Rendering.Needed["assistant"]["vendor-key"]; there {
|
||||
t.Error("a value given under an old key was placed")
|
||||
}
|
||||
}
|
||||
|
||||
// 1364/AC3 (the send's refusals, novox/hq issue 275): in the Foreseeing mode a given secret under an old key is refused in the
|
||||
// controller's words. No secret value is in an error. A bus account not yet issued is foreseen, issued or not (issue 774: the vault
|
||||
// mints it once the placement is written on the Approve, ADR 0561 §1, ADR 0567 §5; mesh-self-check's proposal on ace was refused
|
||||
// "has no bus credential: nothing was issued for ace.mesh-self-check").
|
||||
func TestTheSendsRefusalsAreRefusedInTheSendsWords(t *testing.T) {
|
||||
broker := catalogue.Manifest{Module: "mesh-self-check", Version: "1", RunsAs: "mesh-self-check",
|
||||
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/mesh-self-check/broker", Taken: catalogue.TakenAtStart}}}
|
||||
in := withAssistant(t)
|
||||
in.Catalogue["mesh-self-check"] = broker
|
||||
laptop := nodeOf(t, &in, "laptop")
|
||||
laptop.Assigned = append(laptop.Assigned, "mesh-self-check")
|
||||
for _, minted := range [][]string{nil, {"laptop.mesh-self-check"}} {
|
||||
in.BusMinted = minted
|
||||
got, err := Render(in, "laptop", Foreseeing)
|
||||
if err != nil || !got.Rendering.Foreseen["mesh-self-check"]["broker"] {
|
||||
t.Fatalf("minted %v: the bus account is not foreseen: %v", minted, err)
|
||||
}
|
||||
// Reading refuses nothing for it: the machine is not running it.
|
||||
if _, err := Render(in, "laptop", Reading); err != nil {
|
||||
t.Fatalf("Reading refused a secret the machine is not running: %v", err)
|
||||
}
|
||||
}
|
||||
// A given secret under an old key.
|
||||
in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "accepted", SealedTo: "an-old-key", Sealed: secretValue},
|
||||
{Node: "laptop", Module: "mesh-self-check", Name: "broker", Origin: "made", SealedTo: laptop.SealingKey, Sealed: secretValue}}
|
||||
_, err := Render(in, "laptop", Foreseeing)
|
||||
wantOld := "assistant on laptop holds \"password\", which was given to the mesh rather than made by it, and laptop has since generated a new " +
|
||||
"sealing key. The mesh cannot make another; issue it again"
|
||||
if err == nil || err.Error() != wantOld {
|
||||
t.Fatalf("got %v, want %q", err, wantOld)
|
||||
}
|
||||
if strings.Contains(err.Error(), secretValue) {
|
||||
t.Error("a secret value is in an error")
|
||||
}
|
||||
// A machine with no sealing key cannot be sealed to.
|
||||
laptop.SealingKey = ""
|
||||
in.OwnSecrets = nil
|
||||
if _, err := Render(in, "laptop", Foreseeing); err == nil || !strings.Contains(err.Error(), "laptop has no sealing key, so nothing can be sealed to it") {
|
||||
t.Fatalf("no sealing key: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// aGrantMesh is the objects provider on anchor and its consumer files on laptop, with the pair credential made.
|
||||
func aGrantMesh(t *testing.T) Input {
|
||||
t.Helper()
|
||||
in := theObjectsMesh(t, objects())
|
||||
in.Issued = []Issued{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", ForProvider: "sealed-to-anchor"}}
|
||||
return in
|
||||
}
|
||||
|
||||
// A credential made for a consumer is a grant on its provider's machine, carrying the consumer's values and the sealed copy; without
|
||||
// the sealed copy a stand-in is composed and named (novox/hq plan.go grantsFor).
|
||||
func TestAGrantIsCreatedForEachCredentialMadeForAConsumer(t *testing.T) {
|
||||
in := aGrantMesh(t)
|
||||
// 1126 (ADR 0550 §5): the credentials each node's composition reads, by their ends: laptop's files consumes anchor's
|
||||
// s3-bucket, and anchor grants it; the node composing its own asks the vault for exactly these.
|
||||
pair := CredentialKey{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor"}
|
||||
if consumer, provider, err := CredentialsNeeded(in, "laptop"); err != nil || len(provider) != 0 || len(consumer) != 1 || consumer[0] != pair {
|
||||
t.Errorf("laptop needs %v as consumer and %v as provider (%v), want %v as consumer", consumer, provider, err, pair)
|
||||
}
|
||||
if consumer, provider, err := CredentialsNeeded(in, "anchor"); err != nil || len(consumer) != 0 || len(provider) != 1 || provider[0] != pair {
|
||||
t.Errorf("anchor needs %v as consumer and %v as provider (%v), want %v as provider", consumer, provider, err, pair)
|
||||
}
|
||||
got := rendered(t, in, "anchor", Foreseeing)
|
||||
if len(got.Rendering.Grants) != 1 {
|
||||
t.Fatalf("grants are %+v", got.Rendering.Grants)
|
||||
}
|
||||
g := got.Rendering.Grants[0]
|
||||
if g.Provision != "s3-bucket" || g.Consumer != "laptop" || g.From != "files" || g.At != "laptop.internal" || g.Sealed != "sealed-to-anchor" {
|
||||
t.Errorf("the grant is %+v", g)
|
||||
}
|
||||
if len(got.Foreseen) != 0 || len(got.Withheld) != 0 || len(got.Unbound) != 0 {
|
||||
t.Errorf("foreseen %v withheld %v unbound %v", got.Foreseen, got.Withheld, got.Unbound)
|
||||
}
|
||||
in.Issued[0].ForProvider = ""
|
||||
got = rendered(t, in, "anchor", Foreseeing)
|
||||
if got.Rendering.Grants[0].Sealed != catalogue.ForeseenSealed || len(got.Foreseen) != 1 {
|
||||
t.Errorf("a grant with no sealed copy: %+v foreseen %v", got.Rendering.Grants, got.Foreseen)
|
||||
}
|
||||
// A credential made for a consumer on another provider is not this node's grant.
|
||||
if len(rendered(t, in, "laptop", Foreseeing).Rendering.Grants) != 0 {
|
||||
t.Error("the consumer is granted its own credential")
|
||||
}
|
||||
// A consumer whose set does not resolve is skipped, not fatal.
|
||||
nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "files")
|
||||
in.Catalogue["files"] = catalogue.Manifest{Module: "files", Version: "1", Requires: []string{"s3-bucket"},
|
||||
Claims: []catalogue.Claim{{Name: "x", Scope: catalogue.ScopeNode}}}
|
||||
in.Catalogue["twin"] = catalogue.Manifest{Module: "twin", Version: "1", Claims: []catalogue.Claim{{Name: "x", Scope: catalogue.ScopeNode}}}
|
||||
nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "twin")
|
||||
if got, err := Render(in, "anchor", Foreseeing); err != nil || len(got.Rendering.Grants) != 0 {
|
||||
t.Errorf("a consumer that does not resolve: %v %+v", err, got.Rendering.Grants)
|
||||
}
|
||||
}
|
||||
|
||||
// 1364/AC2: a consumer the grants leave out because its identity overflows the provision's bound is returned beside them, naming the
|
||||
// consumer and the provision (novox/hq ADR 0225), and is not granted.
|
||||
func TestAConsumerWhoseIdentityOverflowsIsLeftOutOfTheGrantsAndNamed(t *testing.T) {
|
||||
in := aGrantMesh(t)
|
||||
in.Nodes[1].Name = "a-machine-with-a-very-long-name"
|
||||
in.Issued[0].Consumer = "a-machine-with-a-very-long-name"
|
||||
got := rendered(t, in, "anchor", Foreseeing)
|
||||
if len(got.Rendering.Grants) != 0 || len(got.Withheld) != 1 {
|
||||
t.Fatalf("grants %+v withheld %+v", got.Rendering.Grants, got.Withheld)
|
||||
}
|
||||
w := got.Withheld[0]
|
||||
if w.Consumer != "a-machine-with-a-very-long-name" || w.Provision != "s3-bucket" || w.Provider != "anchor" || w.Module != "files" ||
|
||||
!strings.Contains(w.String(), "a-machine-with-a-very-long-name") || !strings.Contains(w.String(), "s3-bucket") {
|
||||
t.Errorf("the consumer left out is %+v / %s", w, w.String())
|
||||
}
|
||||
if len(got.Rendering.Withheld) != 1 {
|
||||
t.Errorf("the Rendering does not carry it: %+v", got.Rendering.Withheld)
|
||||
}
|
||||
}
|
||||
|
||||
// 1364/AC3: a credential on record for a consumer that its own resolution binds to another provider is withdrawn, named with where it is
|
||||
// bound (novox/hq issue 274): the grant stays, with no consumer module asking.
|
||||
func TestACredentialBoundToAnotherProviderIsWithdrawnAndSaidWhereItIsBound(t *testing.T) {
|
||||
in := aGrantMesh(t)
|
||||
second := objects()
|
||||
second.Module = "objects2"
|
||||
in.Catalogue["objects2"] = second
|
||||
in.Nodes = append(in.Nodes, Node{Name: "ace", Place: Place{Site: "there", Address: "10.77.0.3"},
|
||||
Assigned: []string{overlay.Name, "objects2"}, Capabilities: map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true}})
|
||||
nodeOf(t, &in, "laptop").Pins = map[string]catalogue.Chosen{"s3-bucket": {Node: "ace", Module: "objects2"}}
|
||||
got := rendered(t, in, "anchor", Foreseeing)
|
||||
if len(got.Unbound) != 1 {
|
||||
t.Fatalf("unbound %+v grants %+v", got.Unbound, got.Rendering.Grants)
|
||||
}
|
||||
u := got.Unbound[0]
|
||||
if u.Provision != "s3-bucket" || u.Provider != "anchor" || u.Consumer != "laptop" || u.Module != "files" ||
|
||||
len(u.BoundTo) != 1 || u.BoundTo[0] != "ace" || !strings.Contains(u.String(), "ace") {
|
||||
t.Errorf("unbound is %+v / %s", u, u.String())
|
||||
}
|
||||
if len(got.Rendering.Grants) != 1 || got.Rendering.Grants[0].From != "" {
|
||||
t.Errorf("the withdrawn credential is still asked for: %+v", got.Rendering.Grants)
|
||||
}
|
||||
}
|
||||
|
||||
func aCertificateMesh(t *testing.T) Input {
|
||||
t.Helper()
|
||||
in := aMesh(t)
|
||||
in.Catalogue["secure"] = catalogue.Manifest{Module: "secure", Version: "1", Certificate: &catalogue.Certificate{Into: "/etc/secure/tls.pem", Authority: "/etc/secure/ca.pem"}}
|
||||
laptop := nodeOf(t, &in, "laptop")
|
||||
laptop.Assigned = append(laptop.Assigned, "secure")
|
||||
return in
|
||||
}
|
||||
|
||||
// 1364/AC4: with no certificate and authority in the Input a stand-in is composed and named; given them, the composition carries them as
|
||||
// given. A machine that never reported a serving key, and one not on the private network, are refused in the controller's words.
|
||||
func TestACertificateIsCarriedAsGivenOrStoodInForAndNamed(t *testing.T) {
|
||||
in := aCertificateMesh(t)
|
||||
laptop := nodeOf(t, &in, "laptop")
|
||||
if _, err := Render(in, "laptop", Foreseeing); err == nil || err.Error() != "laptop wants a certificate and has never told the mesh what key it serves with; it joins again to report one" {
|
||||
t.Fatalf("no serving key: %v", err)
|
||||
}
|
||||
laptop.ServingKey = "serving-key"
|
||||
got := rendered(t, in, "laptop", Foreseeing)
|
||||
if got.Rendering.Certificate != ForeseenCertificate || got.Rendering.Authority != ForeseenCertificate || len(got.Foreseen) != 1 {
|
||||
t.Errorf("no certificate in the Input: %q %q foreseen %v", got.Rendering.Certificate, got.Rendering.Authority, got.Foreseen)
|
||||
}
|
||||
laptop.Certificate, in.Authority = "the-certificate", "the-authority"
|
||||
got = rendered(t, in, "laptop", Foreseeing)
|
||||
if got.Rendering.Certificate != "the-certificate" || got.Rendering.Authority != "the-authority" || len(got.Foreseen) != 0 {
|
||||
t.Errorf("a certificate given: %q %q foreseen %v", got.Rendering.Certificate, got.Rendering.Authority, got.Foreseen)
|
||||
}
|
||||
laptop.Place.Address = "" // placed nowhere: no name inside the mesh
|
||||
if _, err := Render(in, "laptop", Foreseeing); err == nil || err.Error() != "laptop wants a certificate and is not on the private network, so it has no name inside the mesh to be certified for" {
|
||||
t.Fatalf("off the network: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func aValidKey(t *testing.T, name string) string { return keyOf(t, name) }
|
||||
|
||||
// 1364/AC5 and AC6: a core credential the node needs and the Input holds no box for is composed with a stand-in and returned as one
|
||||
// to keep; a kept box sealed to the key held now is carried; a node-engine below the vault-box contract is sent none (and nothing is
|
||||
// asked of it); a machine with no sealing key has its core module left out, said.
|
||||
func TestACoreBoxTheInputHoldsNoneOfIsReturnedToKeepAndNothingIsWritten(t *testing.T) {
|
||||
mk := func() Input {
|
||||
in := aMesh(t)
|
||||
in.Catalogue["nats"] = catalogue.Manifest{Module: "nats", Version: "1",
|
||||
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/nats/broker", Taken: catalogue.TakenAtStart}}}
|
||||
laptop := nodeOf(t, &in, "laptop")
|
||||
laptop.Assigned = append(laptop.Assigned, "nats")
|
||||
laptop.SealingKey = aValidKey(t, "laptop")
|
||||
laptop.Stated, laptop.Contract = true, VaultBoxContract
|
||||
in.BusMinted = []string{"laptop.nats"}
|
||||
return in
|
||||
}
|
||||
in := mk()
|
||||
got := rendered(t, in, "laptop", Foreseeing)
|
||||
box := got.Rendering.VaultSealed["nats"]["broker"]
|
||||
if string(box) != string(catalogue.ForeseenVaultBox) || len(got.CoreBoxesWanted) != 1 ||
|
||||
got.CoreBoxesWanted[0] != (CoreBoxWanted{Node: "laptop", Module: "nats", Name: "broker"}) {
|
||||
t.Errorf("no box held: %s wanted %v", box, got.CoreBoxesWanted)
|
||||
}
|
||||
kept := json.RawMessage(`{"v":1,"eph":"e","iv":"i","tag":"t","ct":"c"}`)
|
||||
in.KeptCoreBoxes = []CoreBox{{Node: "laptop", Module: "nats", Name: "broker", Generation: 3, SealedTo: nodeOf(t, &in, "laptop").SealingKey, Box: kept}}
|
||||
got = rendered(t, in, "laptop", Foreseeing)
|
||||
if string(got.Rendering.VaultSealed["nats"]["broker"]) != string(kept) || len(got.CoreBoxesWanted) != 0 {
|
||||
t.Errorf("a kept box: %s wanted %v", got.Rendering.VaultSealed["nats"]["broker"], got.CoreBoxesWanted)
|
||||
}
|
||||
in.KeptCoreBoxes[0].SealedTo = "an-old-key"
|
||||
if got = rendered(t, in, "laptop", Foreseeing); len(got.CoreBoxesWanted) != 1 {
|
||||
t.Errorf("a box sealed to an old key is kept: %v", got.CoreBoxesWanted)
|
||||
}
|
||||
// Below the vault-box contract: none, and the controller's own seal as before.
|
||||
nodeOf(t, &in, "laptop").Contract = VaultBoxContract - 1
|
||||
if got = rendered(t, in, "laptop", Foreseeing); got.Rendering.VaultSealed != nil || len(got.CoreBoxesWanted) != 0 {
|
||||
t.Errorf("an engine below the contract is sent boxes: %v %v", got.Rendering.VaultSealed, got.CoreBoxesWanted)
|
||||
}
|
||||
// No sealing key: the module is left out, said.
|
||||
nodeOf(t, &in, "laptop").Contract, nodeOf(t, &in, "laptop").SealingKey = VaultBoxContract, ""
|
||||
got = rendered(t, in, "laptop", Reading)
|
||||
if why := got.Rendering.NotPlaced["nats"]; !strings.Contains(why, "laptop has no sealing key, so the vault can seal nats's credentials to nothing there") {
|
||||
t.Errorf("not placed: %v", got.Rendering.NotPlaced)
|
||||
}
|
||||
unchanged(t, mk, func(in Input) { rendered(t, in, "laptop", Foreseeing) })
|
||||
}
|
||||
|
||||
// 1364/AC1: the bus's user list is carried for the machine that runs the bus and for no other; without it a stand-in is composed and
|
||||
// named; the users left out for want of a credential are said; each machine's membership is carried. (The list is composed by the
|
||||
// controller from the bus records, which stay the controller's: see the commit message.)
|
||||
func TestTheBusUserListIsCarriedForTheMachineThatRunsTheBus(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
in.Catalogue["bus"] = catalogue.Manifest{Module: "bus", Version: "1", BusUsers: "/etc/bus/accounts.conf",
|
||||
Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh}}}
|
||||
anchor := nodeOf(t, &in, "anchor")
|
||||
anchor.Assigned = append(anchor.Assigned, "bus")
|
||||
in.BusUsers = "accounts { anchor.bus: hash }"
|
||||
in.BusMissing = []string{"laptop.files", "laptop.web"}
|
||||
in.BusMemberships = map[string]string{"laptop": "sealed-membership"}
|
||||
got := rendered(t, in, "anchor", Foreseeing)
|
||||
if got.Rendering.BusUsers != in.BusUsers || len(got.Foreseen) != 0 {
|
||||
t.Errorf("the list is %q foreseen %v", got.Rendering.BusUsers, got.Foreseen)
|
||||
}
|
||||
if len(got.Said) != 1 || got.Said[0] != "the bus's user list leaves out 2 user(s) the mesh has minted no credential for: laptop.files, laptop.web. Each is a user that cannot connect until one is issued" {
|
||||
t.Errorf("said %v", got.Said)
|
||||
}
|
||||
if other := rendered(t, in, "laptop", Foreseeing); other.Rendering.BusUsers != "" || other.Rendering.BusMembership != "sealed-membership" {
|
||||
t.Errorf("laptop: list %q membership %q", other.Rendering.BusUsers, other.Rendering.BusMembership)
|
||||
}
|
||||
in.BusUsers = ""
|
||||
got = rendered(t, in, "anchor", Foreseeing)
|
||||
if got.Rendering.BusUsers != ForeseenBusUsers || len(got.Foreseen) != 1 {
|
||||
t.Errorf("no list in the Input: %q foreseen %v", got.Rendering.BusUsers, got.Foreseen)
|
||||
}
|
||||
}
|
||||
|
||||
// 1364/AC7: nothing the composition says carries a secret value the Input holds.
|
||||
func TestNoSecretValueOfTheInputIsInWhatTheCompositionSays(t *testing.T) {
|
||||
in := aGrantMesh(t)
|
||||
in.Issued[0].ForProvider = secretValue
|
||||
laptop := nodeOf(t, &in, "laptop")
|
||||
in.Catalogue["assistant"] = anAssistant()
|
||||
laptop.Assigned = append(laptop.Assigned, "assistant")
|
||||
laptop.SealingKey = keyOf(t, "laptop")
|
||||
in.OwnSecrets = []OwnSecretRow{{Node: "laptop", Module: "assistant", Name: "password", Origin: "made", SealedTo: laptop.SealingKey, Sealed: secretValue}}
|
||||
in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialHeld, Sealed: secretValue}}
|
||||
for _, node := range []string{"anchor", "laptop"} {
|
||||
got := rendered(t, in, node, Foreseeing)
|
||||
for _, said := range append(append([]string{}, got.Foreseen...), got.Said...) {
|
||||
if strings.Contains(said, secretValue) {
|
||||
t.Errorf("%s: %q carries a secret value", node, said)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A consumer no credential was made for yet is granted with a stand-in and named foreseen, for every form whose credential
|
||||
// the vault mints or takes in; an offer of form none has no credential (issue 1126, 2026-11-02: mesh-cli on ace held for
|
||||
// "the grant of mcp-endpoint", whose provider node-tools offers form none): its grant carries the consumer and no
|
||||
// credential, nothing stands in on either side, and neither end asks the vault for one.
|
||||
func TestAConsumerNoCredentialWasMadeForYetIsGrantedWithAStandInUnlessItsOfferHasNone(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
form string
|
||||
cred *catalogue.OfferCredential
|
||||
}{
|
||||
{"undeclared", nil},
|
||||
{catalogue.CredentialFormPair, &catalogue.OfferCredential{Form: catalogue.CredentialFormPair}},
|
||||
{catalogue.CredentialFormShared, &catalogue.OfferCredential{Own: "access-key"}},
|
||||
{catalogue.CredentialFormNone, &catalogue.OfferCredential{Form: catalogue.CredentialFormNone}},
|
||||
} {
|
||||
in := aGrantMesh(t)
|
||||
in.Issued = nil
|
||||
provider := in.Catalogue["objects"]
|
||||
provider.Provides[0].Credential = tc.cred
|
||||
if tc.cred != nil && tc.cred.Own != "" {
|
||||
provider.OwnSecrets = catalogue.OwnSecrets{"access-key": {Path: "/var/lib/mesh/objects/access-key"}}
|
||||
}
|
||||
in.Catalogue["objects"] = provider
|
||||
none := tc.form == catalogue.CredentialFormNone
|
||||
got := rendered(t, in, "anchor", Foreseeing)
|
||||
if len(got.Rendering.Grants) != 1 {
|
||||
t.Fatalf("%s: grants are %+v", tc.form, got.Rendering.Grants)
|
||||
}
|
||||
g, wantSealed := got.Rendering.Grants[0], catalogue.ForeseenSealed
|
||||
if none {
|
||||
wantSealed = ""
|
||||
}
|
||||
if g.Provision != "s3-bucket" || g.Consumer != "laptop" || g.From != "files" || g.Sealed != wantSealed {
|
||||
t.Errorf("%s: the grant is %+v", tc.form, g)
|
||||
}
|
||||
if has(got.Foreseen, "the grant of s3-bucket for files on laptop") == none {
|
||||
t.Errorf("%s: foreseen %v", tc.form, got.Foreseen)
|
||||
}
|
||||
consumer, asProvider, err := CredentialsNeeded(in, "laptop")
|
||||
_, anchorProvides, err2 := CredentialsNeeded(in, "anchor")
|
||||
if err != nil || err2 != nil || len(asProvider) != 0 || (len(consumer) == 0) != none || (len(anchorProvides) == 0) != none {
|
||||
t.Errorf("%s: laptop asks %v, anchor provides %v (%v %v)", tc.form, consumer, anchorProvides, err, err2)
|
||||
}
|
||||
resolved, err := Resolve(in, "laptop")
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", tc.form, err)
|
||||
}
|
||||
if n := theFilesNeed(t, resolved); (n.Sealed == "") != none || (len(resolved.Foreseen) == 0) != none {
|
||||
t.Errorf("%s: the consumer's credential is %q, foreseen %v", tc.form, n.Sealed, resolved.Foreseen)
|
||||
}
|
||||
if lap := rendered(t, in, "laptop", Foreseeing); none && len(lap.Foreseen) != 0 {
|
||||
t.Errorf("%s: the consumer stands in for %v", tc.form, lap.Foreseen)
|
||||
}
|
||||
// Only the provider's own: the consumer is granted nothing, and nothing in the Input changed.
|
||||
if len(rendered(t, in, "laptop", Foreseeing).Rendering.Grants) != 0 {
|
||||
t.Errorf("%s: the consumer is granted its own credential", tc.form)
|
||||
}
|
||||
if len(in.Issued) != 0 {
|
||||
t.Errorf("%s: a row was added to the Input", tc.form)
|
||||
}
|
||||
// A row made long ago for an offer of form none (the controller's) carries no credential either.
|
||||
in.Issued = []Issued{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", ForProvider: "sealed-to-anchor"}}
|
||||
if g := rendered(t, in, "anchor", Foreseeing).Rendering.Grants; none && (len(g) != 1 || g[0].Sealed != "") {
|
||||
t.Errorf("%s: an old row's grant is %+v", tc.form, g)
|
||||
}
|
||||
}
|
||||
// A credential only a person gives and nobody has is no grant.
|
||||
in := aGrantMesh(t)
|
||||
in.Issued = nil
|
||||
in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialNotGiven}}
|
||||
if got := rendered(t, in, "anchor", Foreseeing); len(got.Rendering.Grants) != 0 {
|
||||
t.Errorf("a credential nobody gave is granted: %+v", got.Rendering.Grants)
|
||||
}
|
||||
}
|
||||
|
||||
// And a consumer whose identity overflows the provision's bound is judged before any credential is made for it (issue 263).
|
||||
func TestAnOverflowingConsumerNoCredentialWasMadeForYetIsWithheld(t *testing.T) {
|
||||
in := aGrantMesh(t)
|
||||
in.Issued = nil
|
||||
in.Catalogue["resolver"] = storeManifests()[1]
|
||||
in.Catalogue["networkmanager"] = catalogue.Manifest{Module: "networkmanager", Version: "1", Requires: []string{"wildcard-resolution", "s3-bucket"}}
|
||||
nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "networkmanager")
|
||||
nodeOf(t, &in, "anchor").Assigned = append(nodeOf(t, &in, "anchor").Assigned, "resolver")
|
||||
in.Holdings = []catalogue.Held{{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "resolver", Site: "here"}}
|
||||
got := rendered(t, in, "anchor", Foreseeing)
|
||||
if len(got.Withheld) != 1 || got.Withheld[0].Module != "networkmanager" {
|
||||
t.Fatalf("withheld %+v", got.Withheld)
|
||||
}
|
||||
}
|
||||
|
||||
// The grants come in the order the controller reads them, whatever order the Input gives its rows.
|
||||
func TestGrantsComeInTheOrderOfProvisionConsumerModuleAndLocal(t *testing.T) {
|
||||
in := aGrantMesh(t)
|
||||
in.Issued = []Issued{
|
||||
{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Local: "b", Provider: "anchor", ForProvider: "x"},
|
||||
{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Local: "a", Provider: "anchor", ForProvider: "x"},
|
||||
}
|
||||
rows, err := newComposer(in).rowsFor("anchor")
|
||||
// The credential the same send would make (no local name) comes first, then the two the Input gives, a before b.
|
||||
if err != nil || len(rows) != 3 || rows[0].Local != "" || rows[1].Local != "a" || rows[2].Local != "b" {
|
||||
t.Errorf("rows are %+v %v", rows, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Round 1, finding 4: a member of a secret family a person gave, whose sealed value the Input does not carry, is placed with a
|
||||
// stand-in and named foreseen, not dropped as if nobody gave it.
|
||||
func TestAGivenFamilyMemberWithoutItsValueIsAStandInAndNamedForeseen(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
in.Catalogue["mailer"] = catalogue.Manifest{Module: "mailer", Version: "1",
|
||||
OwnSecrets: catalogue.OwnSecrets{"credential-*": {Path: "/var/lib/mailer/credential-*.secret", Taken: catalogue.TakenAtStart, IssuedBy: catalogue.IssuedOutside}}}
|
||||
laptop := nodeOf(t, &in, "laptop")
|
||||
laptop.Assigned = append(laptop.Assigned, "mailer")
|
||||
laptop.SealingKey = keyOf(t, "laptop")
|
||||
in.OwnSecrets = []OwnSecretRow{
|
||||
{Node: "laptop", Module: "mailer", Name: "credential-a", Origin: "accepted", SealedTo: laptop.SealingKey},
|
||||
{Node: "laptop", Module: "mailer", Name: "credential-b", Origin: "accepted", SealedTo: laptop.SealingKey, Sealed: "b-sealed"},
|
||||
}
|
||||
got := rendered(t, in, "laptop", Foreseeing)
|
||||
needed := got.Rendering.Needed["mailer"]
|
||||
if needed["credential-a"] != catalogue.ForeseenSealed || needed["credential-b"] != "b-sealed" {
|
||||
t.Errorf("needed %v", needed)
|
||||
}
|
||||
if !has(got.Foreseen, "mailer/credential-a") || has(got.Foreseen, "mailer/credential-b") {
|
||||
t.Errorf("foreseen %v", got.Foreseen)
|
||||
}
|
||||
}
|
||||
|
||||
// Round 1, finding 5: the suffix internal names end in is a field of the Input, and the environment is never read.
|
||||
func TestTheInternalSuffixIsTheInputsAndNeverTheEnvironments(t *testing.T) {
|
||||
t.Setenv(overlay.SuffixVar, "from-the-environment")
|
||||
in := aGrantMesh(t)
|
||||
got := rendered(t, in, "anchor", Foreseeing)
|
||||
if got.Rendering.Suffix != "internal" || got.Rendering.Grants[0].At != "laptop.internal" {
|
||||
t.Errorf("by default: suffix %q, grant at %q", got.Rendering.Suffix, got.Rendering.Grants[0].At)
|
||||
}
|
||||
in.InternalSuffix = ".lan"
|
||||
got = rendered(t, in, "anchor", Foreseeing)
|
||||
if got.Rendering.Suffix != "lan" || got.Rendering.Grants[0].At != "laptop.lan" {
|
||||
t.Errorf("given: suffix %q, grant at %q", got.Rendering.Suffix, got.Rendering.Grants[0].At)
|
||||
}
|
||||
where, err := newComposer(in).whereEveryoneIs()
|
||||
if err != nil || where["anchor"] != "anchor.lan" {
|
||||
t.Errorf("where everyone is: %v %v", where, err)
|
||||
}
|
||||
if names := newComposer(in).namesInTheMesh(); names["anchor.lan"] == "" || names["anchor.internal"] != "" {
|
||||
t.Errorf("names: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
// Round 2: a need answered on the same machine is a grant too (catalogue resolve.go gives it From: the node's own name).
|
||||
func TestAConsumerOnTheProvidersOwnNodeIsGrantedWithAStandInAndNamedForeseen(t *testing.T) {
|
||||
in := aGrantMesh(t)
|
||||
in.Issued = nil
|
||||
laptop, anchor := nodeOf(t, &in, "laptop"), nodeOf(t, &in, "anchor")
|
||||
laptop.Assigned = []string{overlay.Name}
|
||||
anchor.Assigned = append(anchor.Assigned, "files")
|
||||
got := rendered(t, in, "anchor", Foreseeing)
|
||||
if len(got.Rendering.Grants) != 1 || got.Rendering.Grants[0].Consumer != "anchor" || got.Rendering.Grants[0].Sealed != catalogue.ForeseenSealed {
|
||||
t.Fatalf("grants are %+v", got.Rendering.Grants)
|
||||
}
|
||||
if !has(got.Foreseen, "the grant of s3-bucket for files on anchor") {
|
||||
t.Errorf("foreseen %v", got.Foreseen)
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
)
|
||||
|
||||
// aPublicKey is a key a machine could have reported, made as mesh-controller's mesh_for_test.go aPublicKey makes it.
|
||||
func aPublicKey(t *testing.T) string {
|
||||
t.Helper()
|
||||
k, err := ecdh.X25519().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes())
|
||||
}
|
||||
|
||||
// keys are made once per run, so two Inputs made the same way are equal.
|
||||
var keys = map[string]string{}
|
||||
|
||||
func keyOf(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
if keys[name] == "" {
|
||||
keys[name] = aPublicKey(t)
|
||||
}
|
||||
return keys[name]
|
||||
}
|
||||
|
||||
// theOverlayModule is the module the mesh provides for itself, as mesh-controller's providedModules makes it
|
||||
// (cmd/mesh-controller/modules.go at 6c2a8d62).
|
||||
func theOverlayModule(t *testing.T) catalogue.Manifest {
|
||||
t.Helper()
|
||||
var m catalogue.Manifest
|
||||
b, err := json.Marshal(overlay.Manifest())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// aMesh is the Input of mesh-controller's aMesh (cmd/mesh-controller/mesh_for_test.go at 6c2a8d62): two placed
|
||||
// machines, `anchor` the hub, both reporting container-runtime, wireguard and systemd, both assigned what puts them on the
|
||||
// private network, each at <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")
|
||||
}
|
||||
}
|
||||
@@ -1,582 +0,0 @@
|
||||
package frombus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Answer is a node's answer to its proposal's placement candidates (novox/hq ADR 0567 §3, ADR 0559 §4): s.Node composed in shadow with
|
||||
// s.Placements laid over the trunk's placement (compose.CompositionsShadow, its own sealed values and boxes through r as
|
||||
// for its own composition), compared with what it runs (delivery.CompareComposed). It applies nothing and writes
|
||||
// nothing; the proposal and the revision are the candidate's, said back in the answer.
|
||||
//
|
||||
// The node's own composition is compared only where the node runs one. fps gives the build each module is composed at: what the node runs, and for a module the candidate places that the
|
||||
// node does not run, the build the trunk holds; a placed module with none is added, with no build of it known. What the
|
||||
// composition stands in for is judged: a value of a module the proposal adds (on this node or another: its credential, own
|
||||
// secrets and boxes are made only once it is placed, ADR 0549 §5) is not yet issued, which is no refusal; any other
|
||||
// stand-in is a value missing for a module the node runs, a refusal naming it ("node" when it is the node's own).
|
||||
func Answer(ctx context.Context, r Reader, mesh Mesh, s Scope, fps map[string]string, running []delivery.Composed, proposal string, revision uint64) delivery.CandidateAnswer {
|
||||
a := delivery.CandidateAnswer{Proposal: proposal, Revision: revision}
|
||||
refuse := func(err error) delivery.CandidateAnswer {
|
||||
a.Refused = err.Error()
|
||||
a.Verdict = delivery.CandidateRefused
|
||||
return a
|
||||
}
|
||||
trunk, err := r.Placements(ctx)
|
||||
if err != nil {
|
||||
return refuse(fmt.Errorf("frombus: placement: %w", err))
|
||||
}
|
||||
if s.Running == nil {
|
||||
s.Running = running
|
||||
}
|
||||
// Every module it cannot compose is named, not the first only (issue 1126): a build it cannot read or take in, and
|
||||
// below, a module the composition refuses, each composed as a stub so the rest is composed and compared.
|
||||
failed := map[string]string{}
|
||||
in, err := input(ctx, r, mesh, s, func(module string, err error) { failed[module] = err.Error() }, nil)
|
||||
if err != nil {
|
||||
return refuse(err)
|
||||
}
|
||||
asConsumer, asProvider, err := compose.CredentialsNeeded(in, s.Node)
|
||||
if err != nil {
|
||||
return refuse(err)
|
||||
}
|
||||
// What the node runs, by module. A consumer's credential is asked only for a module it runs: the vault refuses the
|
||||
// whole ask for a consuming module not assigned to the node (mesh-vault's own), and a module the candidate adds is
|
||||
// assigned only after the merge (ADR 0549 §5), so its credential is left missing, a stand-in answered as not yet
|
||||
// issued under Added (review of L2b, round 1, finding 1).
|
||||
runs := map[string]bool{}
|
||||
for _, c := range running {
|
||||
if c.Module != "" {
|
||||
runs[c.Module] = true
|
||||
}
|
||||
}
|
||||
var consumed []compose.CredentialKey
|
||||
for _, k := range asConsumer {
|
||||
if runs[k.ConsumerModule] {
|
||||
consumed = append(consumed, k)
|
||||
}
|
||||
}
|
||||
asConsumer = consumed
|
||||
if len(asConsumer)+len(asProvider) > 0 {
|
||||
if in.Credentials, in.Issued, err = r.Credentials(ctx, s.Node, asConsumer, asProvider); err != nil {
|
||||
return refuse(fmt.Errorf("frombus: the credentials of %s: %w", s.Node, err))
|
||||
}
|
||||
}
|
||||
composed, skipped, stood, err := composeEach(in, s.Node, fps, false, s.Running, failed, ranOf(ctx, r, mesh, s.Running, fps))
|
||||
if err != nil {
|
||||
// Not one module's: the node's own (a value served elsewhere, the mesh's network), said once as the node's.
|
||||
if len(failed) == 0 {
|
||||
return refuse(err)
|
||||
}
|
||||
failed[delivery.HaveNodeWord] = err.Error()
|
||||
composed, skipped, stood = nil, nil, nil
|
||||
}
|
||||
// The node's own composition is judged only where the node runs one: one that runs none applies it only once a want
|
||||
// names it (the node agent's own composer hands it on that want alone), so a candidate does not add it.
|
||||
runsOwn := false
|
||||
for _, c := range running {
|
||||
runsOwn = runsOwn || c.Module == ""
|
||||
}
|
||||
if !runsOwn {
|
||||
for key, c := range composed {
|
||||
if c.Module == "" {
|
||||
delete(composed, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, whole := failed[delivery.HaveNodeWord]; whole {
|
||||
// Nothing composed to compare: every refusal is said, and nothing else.
|
||||
for _, m := range sortedKeys(failed) {
|
||||
a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: failed[m]})
|
||||
}
|
||||
heldAsToday(&a, s.HeldToday)
|
||||
a.Verdict = delivery.VerdictOf(a)
|
||||
return a
|
||||
}
|
||||
// A module the composition folds inside the node's own is judged there: the agent also says it runs by module, from
|
||||
// the declaration it kept before the switch (agent.Everything), which is no removal (issue 1126, seed #531).
|
||||
if len(s.InOwn) > 0 {
|
||||
var judged []delivery.Composed
|
||||
for _, c := range running {
|
||||
if c.Module == "" || !slices.Contains(s.InOwn, c.Module) {
|
||||
judged = append(judged, c)
|
||||
}
|
||||
}
|
||||
running = judged
|
||||
}
|
||||
cmp := delivery.CompareComposedWith(composed, running, s.SameValue)
|
||||
// The answer rides in the node's have key: its shown bodies are bounded, the rest named only.
|
||||
budget := MaxShown
|
||||
for i := range cmp.Differs {
|
||||
kept := cmp.Differs[i].Bodies[:0]
|
||||
for _, b := range cmp.Differs[i].Bodies {
|
||||
if n := len(b.Composed) + len(b.Running); n <= budget {
|
||||
budget -= n
|
||||
kept = append(kept, b)
|
||||
} else {
|
||||
kept = append(kept, delivery.ResourceBodies{ID: b.ID, Why: b.Why})
|
||||
a.Note = "some differing resources are named without their bodies: the answer holds at most " +
|
||||
fmt.Sprint(MaxShown) + " bytes of them"
|
||||
}
|
||||
}
|
||||
cmp.Differs[i].Bodies = kept
|
||||
}
|
||||
// A refused module was composed as a stub: what it composed is no difference; its refusal is said.
|
||||
notFailed := func(m string) bool { _, ok := failed[m]; return !ok }
|
||||
a.Same = slices.DeleteFunc(cmp.Same, func(m string) bool { return !notFailed(m) })
|
||||
a.Removed = slices.DeleteFunc(cmp.Removed, func(m string) bool { return !notFailed(m) })
|
||||
a.Differs = slices.DeleteFunc(cmp.Differs, func(d delivery.ModuleDiffers) bool { return !notFailed(d.Module) })
|
||||
cmp.Added = slices.DeleteFunc(cmp.Added, func(d delivery.ModuleAdded) bool { return !notFailed(d.Module) })
|
||||
skipped = slices.DeleteFunc(skipped, func(m string) bool { return !notFailed(m) })
|
||||
|
||||
// What the proposal adds, by node and module: placed by a candidate and not by the trunk on that node.
|
||||
added := map[string]bool{}
|
||||
for node, p := range s.Placements {
|
||||
if p == nil {
|
||||
continue
|
||||
}
|
||||
was := map[string]bool{}
|
||||
for _, m := range trunk[node].Modules {
|
||||
was[m.Module] = true
|
||||
}
|
||||
for _, m := range p.Modules {
|
||||
if !was[m.Module] {
|
||||
added[node+"/"+m.Module] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
unissued := map[string][]string{}
|
||||
refusals := map[string][]string{}
|
||||
for _, x := range stood {
|
||||
node, module := standsInFor(x, s.Node)
|
||||
switch {
|
||||
case node == s.Node && runs[module]:
|
||||
// A value missing for a module the node runs is a refusal, whatever the trunk places (the seed's trunk is
|
||||
// empty, and every module counts as the group's).
|
||||
refusals[module] = append(refusals[module], x)
|
||||
case module != "" && added[node+"/"+module]:
|
||||
if node == s.Node {
|
||||
unissued[module] = append(unissued[module], x)
|
||||
} // another node's addition: the module here that carries it differs, which the assigner judges
|
||||
case module == "" || node != s.Node:
|
||||
// The node's own, or a value this node holds for a module elsewhere (a grant it provides): said as the node's.
|
||||
refusals[delivery.HaveNodeWord] = append(refusals[delivery.HaveNodeWord], x)
|
||||
default:
|
||||
refusals[module] = append(refusals[module], x)
|
||||
}
|
||||
}
|
||||
addedHere := map[string]delivery.ModuleAdded{}
|
||||
for _, m := range cmp.Added {
|
||||
addedHere[m.Module] = m
|
||||
}
|
||||
for _, m := range skipped { // placed, and no build given to compose it at
|
||||
addedHere[m] = delivery.ModuleAdded{Module: m, Unissued: []string{"no build of " + m + " is known to compose it at"}}
|
||||
}
|
||||
for m, xs := range unissued {
|
||||
got := addedHere[m]
|
||||
got.Module = m
|
||||
got.Unissued = append(got.Unissued, xs...)
|
||||
addedHere[m] = got
|
||||
}
|
||||
for _, m := range sortedKeys(addedHere) {
|
||||
a.Added = append(a.Added, addedHere[m])
|
||||
}
|
||||
for _, m := range sortedKeys(refusals) {
|
||||
if _, isAdded := addedHere[m]; isAdded {
|
||||
continue // a module the candidate adds here: what it lacks is said under Added
|
||||
}
|
||||
if _, isFailed := failed[m]; isFailed {
|
||||
continue // said below, as why it could not be composed
|
||||
}
|
||||
a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: "stands in for " + strings.Join(refusals[m], ", ")})
|
||||
}
|
||||
for _, m := range sortedKeys(failed) {
|
||||
a.Refusals = append(a.Refusals, delivery.ModuleRefused{Module: m, Why: failed[m]})
|
||||
}
|
||||
sort.Slice(a.Refusals, func(i, j int) bool { return a.Refusals[i].Module < a.Refusals[j].Module })
|
||||
heldAsToday(&a, s.HeldToday)
|
||||
a.Verdict = delivery.VerdictOf(a)
|
||||
return a
|
||||
}
|
||||
|
||||
// heldAsToday moves into Held each refusal of a module the node keeps today as it runs (Scope.HeldToday, with how:
|
||||
// held, failed, running a build of the old form): the candidate keeps it as the node does, which is no change (issue
|
||||
// 1126: a seed reproduces today, and today keeps them so). Each says how it stands today (ModuleRefused.Today). No
|
||||
// module is held behind another (issue 1960): a module that reads from a held one composes with what it last offered.
|
||||
func heldAsToday(a *delivery.CandidateAnswer, today map[string]string) {
|
||||
if len(today) == 0 {
|
||||
return
|
||||
}
|
||||
kept := a.Refusals[:0]
|
||||
for _, r := range a.Refusals {
|
||||
if w, keep := today[r.Module]; keep {
|
||||
r.Today = w
|
||||
a.Held = append(a.Held, r)
|
||||
} else {
|
||||
kept = append(kept, r)
|
||||
}
|
||||
}
|
||||
a.Refusals = kept
|
||||
if len(a.Refusals) == 0 {
|
||||
a.Refusals = nil
|
||||
}
|
||||
}
|
||||
|
||||
// maxCulprits bounds the modules one refusal is tried on, each a whole composition.
|
||||
const maxCulprits = 8
|
||||
|
||||
// MaxShown bounds the bytes of shown resource bodies in one answer (delivery.ModuleDiffers.Bodies).
|
||||
const MaxShown = 256 << 10
|
||||
|
||||
var (
|
||||
grantStandIn = regexp.MustCompile(`^the grant of \S+ for (\S+) on (\S+)$`)
|
||||
// grantOfStandIn is the provision a grant's stand-in is of.
|
||||
grantOfStandIn = regexp.MustCompile(`^the grant of (\S+) for \S+ on \S+$`)
|
||||
needsStandIn = regexp.MustCompile(`^(\S+) on (\S+) needs `)
|
||||
)
|
||||
|
||||
// standsInFor is the node and module a stand-in of compose's words is for: an own secret `module/name`, a port
|
||||
// `port module:port`, a grant `the grant of <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 *catalogue.NoRuntimeToRunError
|
||||
return again == nil || errors.As(again, &none) && none.Held
|
||||
}
|
||||
return again == nil || again.Error() != err.Error()
|
||||
}
|
||||
|
||||
// leaveAll leaves out the whole of a held module's offer and its own secrets (a bare stub); leaveOwn its own secrets
|
||||
// alone, its offer kept; any other word leaves out what it contributes to that provision alone.
|
||||
//
|
||||
// leaveOwn is tried before leaveAll: a module held for an own secret never made (nextcloud's admin, issue 774,
|
||||
// 2026-10-30) keeps what it contributes, so its providers' received files keep it and their helpers never retire its
|
||||
// login while it is held.
|
||||
const (
|
||||
leaveAll = "*"
|
||||
leaveOwn = "own-secrets"
|
||||
)
|
||||
|
||||
// assignedOn is the modules placed on node, in placement order.
|
||||
func assignedOn(in compose.Input, node string) []string {
|
||||
for _, n := range in.Nodes {
|
||||
if n.Name == node {
|
||||
return n.Assigned
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// manifestOn is the manifest node composes module from: its Kept, else the catalogue's.
|
||||
func manifestOn(in compose.Input, node, module string) (catalogue.Manifest, bool) {
|
||||
for _, n := range in.Nodes {
|
||||
if n.Name == node {
|
||||
if man, ok := n.Kept[module]; ok {
|
||||
return man, true
|
||||
}
|
||||
}
|
||||
}
|
||||
man, ok := in.Catalogue[module]
|
||||
return man, ok
|
||||
}
|
||||
|
||||
// culprit is the module placed on node whose composition the error is of, and what of its offer to leave out: the
|
||||
// first, in placement order, whose stub (compose.Stub) makes the composition in mode succeed or fail otherwise (leave
|
||||
// ""); else the first whose stub without what it contributes to one provision does (leave that provision: de-spiegel's
|
||||
// routes, its names unset, and not its smtp account, issue 1960); else the first whose bare stub does (leaveAll: its
|
||||
// stub contributing nothing, its own secret never issued, issue 1126 seed-8). Empty when none does (the error is not
|
||||
// one module's). Modules already refused are passed over for their stub.
|
||||
func culprit(in compose.Input, node string, fps map[string]string, mode compose.Mode, failed map[string]string, err error) (string, string) {
|
||||
assigned := assignedOn(in, node)
|
||||
// Only the modules the error names: the words of a module's refusal name it. Each try is a whole composition, so a
|
||||
// refusal naming none is no module's, answered at once rather than after a composition per module (issue 1126: ace's
|
||||
// answer took 7m46s).
|
||||
var order []string
|
||||
for _, m := range assigned {
|
||||
if strings.Contains(err.Error(), m) && len(order) < maxCulprits {
|
||||
order = append(order, m)
|
||||
}
|
||||
}
|
||||
changes := func(m, leave string) bool {
|
||||
_, _, _, again := compose.CompositionsIn(stubbed(in, node, m, leave), node, fps, mode)
|
||||
return blames(m, again, err)
|
||||
}
|
||||
for _, m := range order {
|
||||
if _, done := failed[m]; done {
|
||||
continue
|
||||
}
|
||||
if changes(m, "") {
|
||||
return m, ""
|
||||
}
|
||||
}
|
||||
for _, m := range order {
|
||||
man, ok := manifestOn(in, node, m)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// One already held is tried again too: held for its build, its stub may still refuse (its own secret never
|
||||
// issued, issue 1126). A provision named in the error is tried first, each at one more composition.
|
||||
var to []string
|
||||
for p := range man.Contributes {
|
||||
to = append(to, p)
|
||||
}
|
||||
for p := range man.ContributesMany {
|
||||
if _, both := man.Contributes[p]; !both {
|
||||
to = append(to, p)
|
||||
}
|
||||
}
|
||||
sort.SliceStable(to, func(i, j int) bool {
|
||||
return strings.Contains(err.Error(), " "+to[i]) && !strings.Contains(err.Error(), " "+to[j])
|
||||
})
|
||||
for _, p := range to {
|
||||
if changes(m, p) {
|
||||
return m, p
|
||||
}
|
||||
}
|
||||
if isBare(man) {
|
||||
continue
|
||||
}
|
||||
if len(man.OwnSecrets) > 0 && changes(m, leaveOwn) {
|
||||
return m, leaveOwn
|
||||
}
|
||||
if changes(m, leaveAll) {
|
||||
return m, leaveAll
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// isBare says a stub contributes nothing and declares no own secret (stubbed, leaveAll).
|
||||
func isBare(m catalogue.Manifest) bool {
|
||||
return len(m.Contributes) == 0 && len(m.ContributesMany) == 0 && len(m.OwnSecrets) == 0
|
||||
}
|
||||
|
||||
// stubbed is in with module held on node as its stub alone (heldAs, nothing of it running), from the manifest node
|
||||
// composes it from, leave left out of its offer: a culprit's try.
|
||||
func stubbed(in compose.Input, node, module, leave string) compose.Input {
|
||||
man, ok := manifestOn(in, node, module)
|
||||
if !ok {
|
||||
return in
|
||||
}
|
||||
var left map[string]bool
|
||||
if leave != "" {
|
||||
left = map[string]bool{leave: true}
|
||||
}
|
||||
return heldAs(in, node, module, man, left, nil)
|
||||
}
|
||||
|
||||
// heldAs is in with module held on node (compose.Node.Held): composed from man as what runs of it (compose.Carried of
|
||||
// runs; its stub, compose.Stub, when runs is nil or holds nothing of it), as node's Kept; with what left names out of
|
||||
// its offer: a provision's contributions, with leaveOwn its own secrets alone, or with leaveAll everything it contributes
|
||||
// and its own secrets, none of a held module's own composition being applied, so what it has not been issued (restic's
|
||||
// repository on a node it never ran on) is no refusal of the rest.
|
||||
func heldAs(in compose.Input, node, module string, man catalogue.Manifest, left map[string]bool, runs *delivery.Composed) compose.Input {
|
||||
out := in
|
||||
out.Nodes = append([]compose.Node(nil), in.Nodes...)
|
||||
for i := range out.Nodes {
|
||||
if out.Nodes[i].Name != node {
|
||||
continue
|
||||
}
|
||||
if left[leaveAll] {
|
||||
man.Contributes, man.ContributesMany, man.OwnSecrets = nil, nil, nil
|
||||
} else if len(left) > 0 {
|
||||
if left[leaveOwn] {
|
||||
man.OwnSecrets = nil
|
||||
}
|
||||
contributes := map[string]map[string]any{}
|
||||
for p, v := range man.Contributes {
|
||||
if !left[p] {
|
||||
contributes[p] = v
|
||||
}
|
||||
}
|
||||
many := maps.Clone(man.ContributesMany)
|
||||
for p := range left {
|
||||
delete(many, p)
|
||||
}
|
||||
man.Contributes, man.ContributesMany = contributes, many
|
||||
}
|
||||
kept := make(map[string]catalogue.Manifest, len(out.Nodes[i].Kept)+1)
|
||||
for k, v := range out.Nodes[i].Kept {
|
||||
kept[k] = v
|
||||
}
|
||||
kept[module] = compose.Carried(man, module, runs)
|
||||
out.Nodes[i].Kept = kept
|
||||
held := make(map[string]bool, len(out.Nodes[i].Held)+1)
|
||||
for k, v := range out.Nodes[i].Held {
|
||||
held[k] = v
|
||||
}
|
||||
held[module] = true
|
||||
out.Nodes[i].Held = held
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,595 +0,0 @@
|
||||
package frombus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
)
|
||||
|
||||
// Issue 1126 (the seed's shadow, round 2): a node's answer names every module it cannot compose, not the first only;
|
||||
// and a module folded into its own composition that names a build of the old form (de-spiegel on novox, nats, photos,
|
||||
// systemd-resolved: their haves name a build-... id and no fingerprint) is left to the node, carried as it runs.
|
||||
func answerNamesEveryRefusalAndLeavesAnOldBuildToTheNode(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
image := "alpine@sha256:" + strings.Repeat("a", 64)
|
||||
manifest := func(module string) []byte {
|
||||
// Its data names its directory, as nats's names ${dir:mesh-state} (issue 1126: novox's own composition refused
|
||||
// "nats says ${dir:mesh-state}, and nats declares no directory mesh-state" when its stub held no directory).
|
||||
return []byte(`{"module":"` + module + `","version":"1","capabilities":["container-runtime"],` +
|
||||
`"own-secrets":{"token":"${dir:state}/token"},` +
|
||||
`"resources":[{"id":"state","type":"directory","path":"/var/lib/` + module + `","mode":"0700"},` +
|
||||
`{"id":"server","type":"container","name":"` + module + `","artifact":"site"}],` +
|
||||
`"build":{"artifacts":[{"name":"site","kind":"upstream","from":"` + image + `"}]}}`)
|
||||
}
|
||||
lemurs := []byte(`{"module":"lemurs","version":"1","resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`)
|
||||
fp := func(c string) string { return "src2:" + strings.Repeat(c, 64) }
|
||||
mk := func(placed ...string) *bus {
|
||||
p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}}
|
||||
for _, m := range placed {
|
||||
p.Modules = append(p.Modules, delivery.PlacedModule{Module: m})
|
||||
}
|
||||
b := &bus{placements: map[string]delivery.Placement{"n1": p},
|
||||
records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd", "container-runtime"}}},
|
||||
modules: map[string]delivery.Module{"lemurs": {Manifest: delivery.ManifestDigest(lemurs)}},
|
||||
manifests: map[string][]byte{delivery.ManifestDigest(lemurs): lemurs},
|
||||
builds: map[string]*delivery.BuilderBuild{}}
|
||||
for _, m := range []string{"web", "api", "spiegel"} {
|
||||
b.own = append(b.own, compose.OwnSecretRow{Node: "n1", Module: m, Name: "token", Origin: "made", SealedTo: "sk", Sealed: `"box"`})
|
||||
}
|
||||
for _, m := range []string{"web", "api", "spiegel"} {
|
||||
raw := manifest(m)
|
||||
b.manifests[delivery.ManifestDigest(raw)] = raw
|
||||
b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
|
||||
}
|
||||
// web and api are built, their keys done and waiting for the reference of their image; spiegel has no build of
|
||||
// the new form at all.
|
||||
for i, m := range []string{"web", "api"} {
|
||||
mod := b.modules[m]
|
||||
mod.FP2 = fp(string(rune('1' + i)))
|
||||
b.modules[m] = mod
|
||||
b.builds[m+" "+mod.FP2] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
|
||||
Artifacts: []delivery.BuiltArtifact{{Name: "site", Kind: "image", Digest: "sha256:" + strings.Repeat("b", 64)}}}
|
||||
}
|
||||
return b
|
||||
}
|
||||
given := func(b *bus) map[string]*delivery.Placement {
|
||||
p := b.placements["n1"]
|
||||
return map[string]*delivery.Placement{"n1": &p}
|
||||
}
|
||||
|
||||
// Every refusal: two builds taken in, and a module the composition refuses (its image never built), each named.
|
||||
b := mk("lemurs", "web", "api", "spiegel")
|
||||
a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b)},
|
||||
map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3)
|
||||
var refused []string
|
||||
for _, r := range a.Refusals {
|
||||
refused = append(refused, r.Module)
|
||||
}
|
||||
if a.Verdict != delivery.CandidateRefused || a.Refused != "" || !reflect.DeepEqual(refused, []string{"api", "spiegel", "web"}) {
|
||||
t.Errorf("every refusal: %+v, want api, spiegel and web refused, each named", a)
|
||||
}
|
||||
for _, r := range a.Refusals {
|
||||
want := map[string]string{"api": "waits for the reference of site", "web": "waits for the reference of site",
|
||||
"spiegel": "spiegel has not been built for this mesh"}[r.Module]
|
||||
if !strings.Contains(r.Why, want) {
|
||||
t.Errorf("%s refused as %q, want %q", r.Module, r.Why, want)
|
||||
}
|
||||
}
|
||||
if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": a}}); err != nil {
|
||||
t.Errorf("the answer is not one a have holds: %v (%s)", err, raw)
|
||||
}
|
||||
if lemursOK := slices.ContainsFunc(a.Added, func(m delivery.ModuleAdded) bool { return m.Module == "lemurs" }); !lemursOK {
|
||||
t.Errorf("lemurs, which composes, is not answered beside the refusals: %+v", a)
|
||||
}
|
||||
// Issue 1126: a module the node holds today that the candidate holds too is held as today, no refusal: with every
|
||||
// refused module held today the verdict is no refusal, and each is named under Held.
|
||||
today := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held", "spiegel": "held", "web": "held"}},
|
||||
map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3)
|
||||
if today.Verdict == delivery.CandidateRefused || len(today.Refusals) != 0 || len(today.Held) != 3 || today.Held[0].Why == "" {
|
||||
t.Errorf("held as today: %+v", today)
|
||||
}
|
||||
// A refused module the node does not hold today stays a refusal: holding only api today leaves spiegel and web refused.
|
||||
part := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), HeldToday: map[string]string{"api": "held"}},
|
||||
map[string]string{"lemurs": fp("e"), "web": fp("1"), "api": fp("2"), "spiegel": fp("3")}, nil, "p-h1", 3)
|
||||
if part.Verdict != delivery.CandidateRefused || len(part.Refusals) != 2 || len(part.Held) != 1 || part.Held[0].Module != "api" {
|
||||
t.Errorf("held as today in part: %+v", part)
|
||||
}
|
||||
// No module is held behind one kept today (issue 1960): mailu's own refusal stays a refusal beside de-spiegel's hold.
|
||||
if a := (delivery.CandidateAnswer{Refusals: []delivery.ModuleRefused{{Module: "de-spiegel", Why: "a value is unset"},
|
||||
{Module: "mailu", Why: "no build"}}}); true {
|
||||
heldAsToday(&a, map[string]string{"de-spiegel": "runs a build of the old form"})
|
||||
if len(a.Refusals) != 1 || a.Refusals[0].Module != "mailu" || len(a.Held) != 1 {
|
||||
t.Errorf("held as today spreads: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
// The fold: spiegel runs inside the node's own composition at a build of the old form. Its resources are carried as
|
||||
// they run, each after the one it runs after: the node's own is the one it runs, and the node composes itself.
|
||||
b = mk("lemurs", overlay.Name, "spiegel")
|
||||
p := b.placements["n1"]
|
||||
p.Decided.Endpoint = "n1.example:51820"
|
||||
b.placements["n1"] = p
|
||||
fps := map[string]string{"lemurs": fp("e")}
|
||||
stub := Scope{Node: "n1"}
|
||||
in, err := Input(ctx, b, Mesh{}, stub)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.Nodes[0].Kept = map[string]catalogue.Manifest{"spiegel": compose.Stub(in.Catalogue["spiegel"])}
|
||||
base, _, err := compose.Compositions(in, "n1", fps, compose.Reading)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A refusal naming no module placed here is no module's: no compose is spent stubbing each in turn (issue 1126: ace's
|
||||
// answer took 7m46s, one compose per module), though here a stub would compose.
|
||||
if m, _ := culprit(in, "n1", fps, compose.Foreseeing, map[string]string{}, fmt.Errorf("nothing in this mesh provides \"artifact-store\"")); m != "" {
|
||||
t.Errorf("a refusal naming no placed module was laid on %s", m)
|
||||
}
|
||||
ownKey, _ := delivery.ComposedNodeKey("n1")
|
||||
own := base[ownKey]
|
||||
carriedRes := []json.RawMessage{
|
||||
json.RawMessage(`{"id":"spiegel.state","type":"directory","path":"/var/lib/spiegel","mode":"0700"}`),
|
||||
json.RawMessage(`{"id":"spiegel.server","type":"container","name":"spiegel","image":"registry.example/spiegel@sha256:` + strings.Repeat("c", 64) + `"}`)}
|
||||
// They run after the node's first own resource.
|
||||
own.Resources = append(append(append([]json.RawMessage{}, own.Resources[:1]...), carriedRes...), own.Resources[1:]...)
|
||||
var running []delivery.Composed
|
||||
for k, c := range base {
|
||||
if k == ownKey {
|
||||
c = own
|
||||
}
|
||||
running = append(running, c)
|
||||
}
|
||||
scope := Scope{Node: "n1", InOwn: []string{"spiegel"}, Running: running}
|
||||
got, _, held, err := Compose(ctx, b, Mesh{}, scope, fps)
|
||||
if err == nil && len(held) > 0 {
|
||||
err = fmt.Errorf("modules held: %v", held)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("the node does not compose itself with spiegel folded at a build of the old form: %v", err)
|
||||
}
|
||||
gotOwn, _ := json.Marshal(got[ownKey].Resources)
|
||||
wantOwn, _ := json.Marshal(own.Resources)
|
||||
if string(gotOwn) != string(wantOwn) {
|
||||
t.Errorf("the node's own composition is\n%s\nwant what runs\n%s", gotOwn, wantOwn)
|
||||
}
|
||||
a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, running, "p-h1", 3)
|
||||
if a.Verdict != delivery.CandidateSame {
|
||||
t.Errorf("the fold's answer: %+v, want the same", a)
|
||||
}
|
||||
// (A) The agent also says spiegel runs by module, from the declaration kept before the switch (agent.Everything): a
|
||||
// module the composition folds is judged inside the node's own, not as removed (issue 1126, seed #531's answer).
|
||||
keptToo := append(append([]delivery.Composed{}, running...), delivery.Composed{Node: "n1", Module: "spiegel", Resources: carriedRes})
|
||||
if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, keptToo, "p-h1", 3); a.Verdict != delivery.CandidateSame || len(a.Removed) != 0 {
|
||||
t.Errorf("a folded module running from the kept declaration is answered %+v, want the same", a)
|
||||
}
|
||||
// (B) The node's own composition carries its bus membership as it runs it (the controller's, from its store): composed
|
||||
// the same, not 'in what runs, not composed' (seed #531, novox).
|
||||
membership := json.RawMessage(`{"id":"bus-membership","type":"file","path":"` + catalogue.BusMembershipPath + `","sealed":"{\"box\":\"m\"}","mode":"0600"}`)
|
||||
withMembership := make([]delivery.Composed, 0, len(running))
|
||||
for _, c := range running {
|
||||
if c.Module == "" {
|
||||
c.Resources = append(append([]json.RawMessage{}, c.Resources...), membership)
|
||||
}
|
||||
withMembership = append(withMembership, c)
|
||||
}
|
||||
if a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"spiegel"}}, fps, withMembership, "p-h1", 3); a.Verdict != delivery.CandidateSame {
|
||||
t.Errorf("the node's own with its bus membership is answered %+v, want the same", a)
|
||||
}
|
||||
// Without what runs, the fold composes spiegel from its manifest, as before, and the composition refuses it.
|
||||
if _, _, held, err := Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}}, fps); err != nil ||
|
||||
!strings.Contains(held["spiegel"], "spiegel has not been built for this mesh") {
|
||||
t.Errorf("composed spiegel without what runs: %v %v, want spiegel held alone", err, held)
|
||||
}
|
||||
|
||||
// (C) Issue 1126, C5: what the others read of a module carried as it runs is what runs, never its stub's: the filter
|
||||
// opens the machine port its running container publishes (de-spiegel's 20005 on novox, not its software's 8080; a
|
||||
// stub had no container, so snake's 80 on ace was opened beside the route proxy's). And C6: the operator export's file
|
||||
// a module that keeps it runs is carried as it runs (mesh-vault.kept on novox), since the node holds no export.
|
||||
listening := []byte(`{"module":"spiegel","version":"1","capabilities":["container-runtime"],` +
|
||||
`"listens":[{"port":8080,"from":"mesh","why":"the site"}],"own-secrets":{"token":"${dir:state}/token"},` +
|
||||
`"resources":[{"id":"state","type":"directory","path":"/var/lib/spiegel","mode":"0700"},` +
|
||||
`{"id":"server","type":"container","name":"spiegel","artifact":"site","ports":["8080"]}],` +
|
||||
`"build":{"artifacts":[{"name":"site","kind":"upstream","from":"` + image + `"}]}}`)
|
||||
nft := []byte(`{"module":"nft","version":"1","capabilities":["systemd"],"filtering":{"into":"/etc/nftables.conf"}}`)
|
||||
vault := []byte(`{"module":"vault","version":"1","capabilities":["systemd"],"keeps":"/var/lib/vault/root",` +
|
||||
`"resources":[{"id":"service","type":"service","unit":"vault.service","state":"running","boot":"enabled"}]}`)
|
||||
b = mk("lemurs", overlay.Name, "spiegel", "nft", "vault")
|
||||
for m, raw := range map[string][]byte{"spiegel": listening, "nft": nft, "vault": vault} {
|
||||
b.manifests[delivery.ManifestDigest(raw)] = raw
|
||||
b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
|
||||
}
|
||||
p = b.placements["n1"]
|
||||
p.Decided.Endpoint = "n1.example:51820"
|
||||
for i := range p.Modules {
|
||||
if p.Modules[i].Module == "spiegel" {
|
||||
p.Modules[i].Ports = map[string]int{"8080": 20005}
|
||||
}
|
||||
}
|
||||
b.placements["n1"] = p
|
||||
rec := b.records["n1"]
|
||||
rec.OutwardLinks = []string{"eth0"}
|
||||
b.records["n1"] = rec
|
||||
fps = map[string]string{"lemurs": fp("e"), "nft": fp("f"), "vault": fp("9")}
|
||||
if in, err = Input(ctx, b, Mesh{}, stub); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.Nodes[0].Kept = map[string]catalogue.Manifest{"spiegel": compose.Stub(in.Catalogue["spiegel"])}
|
||||
if base, _, err = compose.Compositions(in, "n1", fps, compose.Reading); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keptFile := json.RawMessage(`{"digest":"sha256:` + strings.Repeat("d", 64) + `","id":"vault.kept","mode":"0600","module":"vault","path":"/var/lib/vault/root/export.json","size":143604,"type":"file"}`)
|
||||
running = nil
|
||||
for _, c := range base {
|
||||
switch c.Module {
|
||||
case "":
|
||||
c.Resources = append(append([]json.RawMessage{}, c.Resources...),
|
||||
json.RawMessage(`{"id":"spiegel.state","type":"directory","path":"/var/lib/spiegel","mode":"0700"}`),
|
||||
json.RawMessage(`{"id":"spiegel.server","type":"container","name":"spiegel","image":"registry.example/spiegel@sha256:`+strings.Repeat("c", 64)+`","ports":["10.77.0.1:20005:8080"]}`),
|
||||
json.RawMessage(`{"id":"spiegel.server-env","type":"file","path":"/var/lib/spiegel/server.env","mode":"0600","content":"A=1\n"}`))
|
||||
case "vault":
|
||||
c.Resources = append(append([]json.RawMessage{}, c.Resources...), keptFile)
|
||||
}
|
||||
running = append(running, c)
|
||||
}
|
||||
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"spiegel"}, Running: running}, fps)
|
||||
if err == nil && len(held) > 0 {
|
||||
err = fmt.Errorf("modules held: %v", held)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("the node does not compose itself with spiegel carried: %v", err)
|
||||
}
|
||||
var rules string
|
||||
var keptComposed json.RawMessage
|
||||
for _, c := range got {
|
||||
for _, raw := range c.Resources {
|
||||
var r struct {
|
||||
ID string `json:"id"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &r)
|
||||
switch r.ID {
|
||||
case "nft." + catalogue.FilteringID():
|
||||
rules = r.Content
|
||||
case "vault." + catalogue.KeptID():
|
||||
keptComposed = raw
|
||||
}
|
||||
}
|
||||
}
|
||||
if !strings.Contains(rules, "# spiegel — the site") || !strings.Contains(rules, "dport 20005 accept") || strings.Contains(rules, "dport 8080") {
|
||||
t.Errorf("the filter does not open the machine port spiegel runs at:\n%s", rules)
|
||||
}
|
||||
// The paths a carried module owns are its manifest's, as the controller composes them, never what runs of it (issue
|
||||
// 1126, seed-8 #542: on ace, the folded f1-circuits, snake and txt-game, whose manifests leave their directories and
|
||||
// files to ${dir:…}, owned their /var/lib paths on the node and none on the controller, shifting node-tools'
|
||||
// MESH_OWNED_PATHS). f1-circuits' manifest at the indexer's commit (c0ea2c94) is captured; what runs of it is
|
||||
// composed from that manifest by the controller's rule (the directory at /var/lib/f1-circuits, its file, its container).
|
||||
for _, c := range running {
|
||||
if c.Module == "" && !reflect.DeepEqual(compose.Carried(in.Catalogue["spiegel"], "spiegel", &c).OwnsPaths, []string{"/var/lib/spiegel"}) {
|
||||
t.Errorf("spiegel carried owns %v, want its manifest's /var/lib/spiegel alone", compose.Carried(in.Catalogue["spiegel"], "spiegel", &c).OwnsPaths)
|
||||
}
|
||||
}
|
||||
f1raw, err := os.ReadFile("testdata/f1-circuits-c0ea2c94.module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f1, err := catalogue.Decode(f1raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f1runs := delivery.Composed{Node: "ace", Resources: []json.RawMessage{
|
||||
json.RawMessage(`{"id":"f1-circuits.state","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits","mode":"0700"}`),
|
||||
json.RawMessage(`{"id":"f1-circuits.cache","module":"f1-circuits","type":"directory","path":"/var/lib/f1-circuits/cache","mode":"0700"}`),
|
||||
json.RawMessage(`{"id":"f1-circuits.site-env","module":"f1-circuits","type":"file","path":"/var/lib/f1-circuits/site.env","mode":"0644","content":"F1_CIRCUITS_BASE_URL=https://f1.example\n"}`),
|
||||
json.RawMessage(`{"id":"f1-circuits.server","module":"f1-circuits","type":"container","name":"f1-circuits","image":"registry.example/f1@sha256:` + strings.Repeat("c", 64) + `"}`)}}
|
||||
if got, want := catalogue.DeclaredPaths(compose.Carried(f1, "f1-circuits", &f1runs)), catalogue.DeclaredPaths(f1); !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("f1-circuits carried owns %v, want what the controller composes from its manifest, %v", got, want)
|
||||
}
|
||||
// Issue 1126: a held module hands no template its build kept for a file it no longer carries (novox's node:
|
||||
// "postgres: its build kept a template for consumers"), but keeps its items and their templates, so what it
|
||||
// contributes to other modules' collections still reaches them.
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
handed *catalogue.Handed
|
||||
items []catalogue.Item
|
||||
}{
|
||||
{"templates and items", &catalogue.Handed{Templates: map[string]string{"consumers": "files/consumers.json"},
|
||||
Items: map[string]string{"routes": "files/routes.tmpl"}}, []catalogue.Item{{Collection: "routes", Content: "x"}}},
|
||||
{"items alone", nil, []catalogue.Item{{Collection: "routes", Content: "x"}}},
|
||||
} {
|
||||
kept := f1
|
||||
kept.Handed, kept.Items = row.handed, row.items
|
||||
st := compose.Stub(kept)
|
||||
if st.Handed != nil && st.Handed.Templates != nil {
|
||||
t.Errorf("%s: a stub hands templates %v", row.name, st.Handed.Templates)
|
||||
}
|
||||
if !reflect.DeepEqual(st.Items, row.items) {
|
||||
t.Errorf("%s: a stub collects %+v, want its items %+v", row.name, st.Items, row.items)
|
||||
}
|
||||
if row.handed != nil && (st.Handed == nil || !reflect.DeepEqual(st.Handed.Items, row.handed.Items) || row.handed.Templates == nil) {
|
||||
t.Errorf("%s: a stub hands item templates %+v, want %v and its manifest untouched", row.name, st.Handed, row.handed.Items)
|
||||
}
|
||||
}
|
||||
var k1, k2 any
|
||||
_ = json.Unmarshal(keptComposed, &k1)
|
||||
_ = json.Unmarshal(keptFile, &k2)
|
||||
if !reflect.DeepEqual(k1, k2) {
|
||||
t.Errorf("the operator export is composed as %s, want the file that runs %s", keptComposed, keptFile)
|
||||
}
|
||||
|
||||
// (D) Issue 1126, seed-8 (mesh-catalog #542): a module's own refusal refuses that module, never the node. novox runs
|
||||
// de-spiegel folded, and its manifest at the indexer's commit (e25e1beb, captured) asks four required config values
|
||||
// for its route names that nothing sets. novox answered the whole candidate refused ("de-spiegel contributing
|
||||
// praktijk to route says ${setting:praktijk-name} ...": its stub still contributed the route). Only de-spiegel is
|
||||
// refused, in the config form's words; the rest is judged. Issue 1960, novox's chain on 2026-10-29: the hold spread
|
||||
// to mailu (its smtp provider) and proxy (its route provider), and from them on. Neither is held now: proxy composes
|
||||
// without de-spiegel's routes, which do not compose, and mailu with its smtp account, which does.
|
||||
spiegel, err := os.ReadFile("testdata/de-spiegel-e25e1beb.module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
proxy := []byte(`{"module":"proxy","version":"1","capabilities":["systemd"],"provides":[{"name":"route","scope":"mesh"}],` +
|
||||
`"resources":[{"id":"service","type":"service","unit":"proxy.service","state":"running","boot":"enabled"}]}`)
|
||||
mailu := []byte(`{"module":"mailu","version":"1","capabilities":["systemd"],"provides":[{"name":"smtp","scope":"mesh"}],` +
|
||||
`"resources":[{"id":"service","type":"service","unit":"mailu.service","state":"running","boot":"enabled"}]}`)
|
||||
b = mk("lemurs", overlay.Name, "mailu", "proxy", "de-spiegel")
|
||||
for m, raw := range map[string][]byte{"de-spiegel": spiegel, "proxy": proxy, "mailu": mailu} {
|
||||
b.manifests[delivery.ManifestDigest(raw)] = raw
|
||||
b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
|
||||
}
|
||||
p = b.placements["n1"]
|
||||
p.Decided.Endpoint = "n1.example:51820"
|
||||
b.placements["n1"] = p
|
||||
fps = map[string]string{"lemurs": fp("e"), "proxy": fp("f"), "mailu": fp("d")}
|
||||
a = Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given(b), InOwn: []string{"de-spiegel"}}, fps, nil, "p-h1", 3)
|
||||
refused = nil
|
||||
for _, r := range a.Refusals {
|
||||
refused = append(refused, r.Module)
|
||||
if r.Module == "de-spiegel" && (!strings.Contains(r.Why, "${config:praktijk-name}") || strings.Contains(r.Why, "${setting:")) {
|
||||
t.Errorf("de-spiegel is refused in other words than its config form's: %s", r.Why)
|
||||
}
|
||||
}
|
||||
if a.Refused != "" || !reflect.DeepEqual(refused, []string{"de-spiegel"}) {
|
||||
t.Errorf("a module's own refusal: %+v, want de-spiegel refused alone, the node, mailu and proxy not", a)
|
||||
}
|
||||
for _, m := range []string{"lemurs", "mailu", "proxy"} {
|
||||
if !slices.ContainsFunc(a.Added, func(x delivery.ModuleAdded) bool { return x.Module == m }) {
|
||||
t.Errorf("%s, which composes, is not judged beside de-spiegel's refusal: %+v", m, a)
|
||||
}
|
||||
}
|
||||
|
||||
// (E) Issue 1126, the applying path (review of the seed): the composition novox applies follows the same rule. It
|
||||
// composed with no culprit, so de-spiegel's unset route names refused the whole node and every module it runs was
|
||||
// marked not composed. Now de-spiegel is held, its refusal said in its config form's words, with proxy, which
|
||||
// receives its routes; lemurs composes, and the node's own composition carries de-spiegel as it runs, so applying it
|
||||
// removes nothing of it.
|
||||
spiegelRuns := []json.RawMessage{
|
||||
json.RawMessage(`{"id":"de-spiegel.state","type":"directory","path":"/var/lib/de-spiegel","mode":"0755"}`),
|
||||
json.RawMessage(`{"id":"de-spiegel.server-env","type":"file","path":"/var/lib/de-spiegel/server.env","mode":"0600","content":"A=1\n"}`),
|
||||
json.RawMessage(`{"id":"de-spiegel.server","type":"container","name":"de-spiegel","image":"registry.example/de-spiegel@sha256:` + strings.Repeat("c", 64) + `"}`)}
|
||||
if in, err = Input(ctx, b, Mesh{}, stub); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.Nodes[0].Kept = map[string]catalogue.Manifest{"de-spiegel": compose.Stub(in.Catalogue["de-spiegel"])}
|
||||
in.Nodes[0].Kept["de-spiegel"] = func(m catalogue.Manifest) catalogue.Manifest {
|
||||
m.Contributes, m.ContributesMany = nil, nil
|
||||
return m
|
||||
}(in.Nodes[0].Kept["de-spiegel"])
|
||||
if base, _, err = compose.Compositions(in, "n1", fps, compose.Reading); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
running = nil
|
||||
for _, c := range base {
|
||||
if c.Module == "" {
|
||||
c.Resources = append(append([]json.RawMessage{}, c.Resources...), spiegelRuns...)
|
||||
}
|
||||
running = append(running, c)
|
||||
}
|
||||
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", InOwn: []string{"de-spiegel"}, Running: running}, fps)
|
||||
if err != nil {
|
||||
t.Fatalf("de-spiegel's own refusal refuses the node's whole composition: %v", err)
|
||||
}
|
||||
if len(held) != 1 || !strings.Contains(held["de-spiegel"], "${config:praktijk-name}") || !strings.Contains(held["de-spiegel"], "without what it contributes to route") {
|
||||
t.Errorf("held %v, want de-spiegel alone, in its config form's words, composed without its routes", held)
|
||||
}
|
||||
for _, m := range []string{"lemurs", "mailu", "proxy"} {
|
||||
if key, _ := delivery.ComposedKey("n1", m, fps[m]); got[key].Module != m {
|
||||
t.Errorf("%s is not composed beside de-spiegel's hold: %v", m, sortedKeys(got))
|
||||
}
|
||||
}
|
||||
carries := func(c delivery.Composed, what string) {
|
||||
t.Helper()
|
||||
ownRes, _ := json.Marshal(c.Resources)
|
||||
for _, raw := range spiegelRuns {
|
||||
var r struct{ ID string }
|
||||
_ = json.Unmarshal(raw, &r)
|
||||
if !strings.Contains(string(ownRes), `"id":"`+r.ID+`"`) {
|
||||
t.Errorf("%s: the node's own composition drops %s, which de-spiegel runs: %s", what, r.ID, ownRes)
|
||||
}
|
||||
}
|
||||
}
|
||||
carries(got[ownKey], "folded")
|
||||
// (F) Leaving the fold (review of the apply, round 1, finding 1): de-spiegel's want names a build of the new form,
|
||||
// so it is no longer folded, while its names are still unset and the node's own still runs it. Held, it is carried
|
||||
// in the node's own as it runs until a composition of its own is handed: else the node's own apply removes its
|
||||
// container, files and units (issue 1945 by a new path).
|
||||
fps["de-spiegel"] = fp("7")
|
||||
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: running}, fps)
|
||||
if err != nil || !strings.Contains(held["de-spiegel"], "${config:praktijk-name}") {
|
||||
t.Fatalf("de-spiegel leaving the fold: %v, held %v", err, held)
|
||||
}
|
||||
spiegelKey, _ := delivery.ComposedKey("n1", "de-spiegel", fps["de-spiegel"])
|
||||
if _, ok := got[spiegelKey]; ok {
|
||||
t.Errorf("held de-spiegel's own composition is handed")
|
||||
}
|
||||
carries(got[ownKey], "leaving the fold")
|
||||
|
||||
// (G) A held module that runs a composition of its own (review of the apply, round 1, finding 2): what the others
|
||||
// compose of it is what runs, never its stub's. site listens on 8080, placed at the machine's 20005, and its own
|
||||
// secret was never issued here: held, the filter must not open 8080 and drop the 20005 that runs. The filter, which
|
||||
// reads what site listens on, composes with it (issue 1960: a hold never spreads).
|
||||
site := []byte(`{"module":"site","version":"1","capabilities":["container-runtime"],` +
|
||||
`"listens":[{"port":8080,"from":"mesh","why":"the site"}],"own-secrets":{"key":"${dir:state}/key"},` +
|
||||
`"resources":[{"id":"state","type":"directory","path":"/var/lib/site","mode":"0700"},` +
|
||||
`{"id":"server","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}]}`)
|
||||
b = mk("lemurs", overlay.Name, "site", "nft")
|
||||
for m, raw := range map[string][]byte{"site": site, "nft": nft} {
|
||||
b.manifests[delivery.ManifestDigest(raw)] = raw
|
||||
b.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
|
||||
}
|
||||
p = b.placements["n1"]
|
||||
p.Decided.Endpoint = "n1.example:51820"
|
||||
for i := range p.Modules {
|
||||
if p.Modules[i].Module == "site" {
|
||||
p.Modules[i].Ports = map[string]int{"8080": 20005}
|
||||
}
|
||||
}
|
||||
b.placements["n1"] = p
|
||||
rec = b.records["n1"]
|
||||
rec.OutwardLinks = []string{"eth0"}
|
||||
b.records["n1"] = rec
|
||||
fps = map[string]string{"lemurs": fp("e"), "nft": fp("f"), "site": fp("8")}
|
||||
siteRuns := delivery.Composed{Node: "n1", Module: "site", FP: fps["site"], Resources: []json.RawMessage{
|
||||
json.RawMessage(`{"id":"site.state","module":"site","type":"directory","path":"/var/lib/site","mode":"0700"}`),
|
||||
json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["10.77.0.1:20005:8080"]}`)}}
|
||||
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps)
|
||||
nftKey, _ := delivery.ComposedKey("n1", "nft", fps["nft"])
|
||||
if err != nil || held["site"] == "" || held["nft"] != "" || got[nftKey].Module != "nft" {
|
||||
t.Fatalf("site, its own secret never issued: %v, held %v; want site held alone, and the filter composed", err, held)
|
||||
}
|
||||
if rules, _ := json.Marshal(got[nftKey].Resources); !strings.Contains(string(rules), "dport 20005") {
|
||||
t.Errorf("the filter drops held site's opening where it runs (20005): %s", rules)
|
||||
}
|
||||
for key, c := range got {
|
||||
for _, raw := range c.Resources {
|
||||
if strings.Contains(string(raw), "dport 8080") || (c.Module == "nft" && !strings.Contains(string(raw), "dport 20005") && strings.Contains(string(raw), "# site")) {
|
||||
t.Errorf("%s composes held site from its stub, not from what runs: %s", key, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
// (G2) Issue 1126, the spreading hold: a held module is no dependence of its neighbours. site, held, provides page, and
|
||||
// app requires and binds it: app composes against what runs of site and is not held (novox's seed held every postgres
|
||||
// consumer behind de-spiegel's hold, "what it composes of …, held, is not what runs of them").
|
||||
provider := []byte(strings.Replace(string(site), `"listens":`, `"provides":[{"name":"page","scope":"node"}],"serves":{"page":{"port":8080}},"listens":`, 1))
|
||||
app := []byte(`{"module":"app","version":"1","capabilities":["systemd"],"requires":["page"],"binds":{"page":"/etc/app/page.json"},` +
|
||||
`"resources":[{"id":"service","type":"service","unit":"app.service","state":"running","boot":"enabled"}]}`)
|
||||
b2 := mk("lemurs", overlay.Name, "site", "nft", "app")
|
||||
for m, raw := range map[string][]byte{"site": provider, "nft": nft, "app": app} {
|
||||
b2.manifests[delivery.ManifestDigest(raw)] = raw
|
||||
b2.modules[m] = delivery.Module{Manifest: delivery.ManifestDigest(raw)}
|
||||
}
|
||||
p2 := b2.placements["n1"]
|
||||
p2.Decided.Endpoint = "n1.example:51820"
|
||||
for i := range p2.Modules {
|
||||
if p2.Modules[i].Module == "site" {
|
||||
p2.Modules[i].Ports = map[string]int{"8080": 20005}
|
||||
}
|
||||
}
|
||||
b2.placements["n1"] = p2
|
||||
b2.records["n1"] = rec
|
||||
b2.held = true
|
||||
fps["app"] = fp("9")
|
||||
got, _, held, err = Compose(ctx, b2, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps)
|
||||
appKey, _ := delivery.ComposedKey("n1", "app", fps["app"])
|
||||
if _, composed := got[appKey]; err != nil || held["site"] == "" || held["app"] != "" || !composed {
|
||||
t.Errorf("a consumer of held site: %v, held %v; want site held, and app composed against what runs of it", err, held)
|
||||
}
|
||||
if bound, _ := json.Marshal(got[appKey].Resources); !strings.Contains(string(bound), "20005") {
|
||||
t.Errorf("app's binding does not reach held site where it runs (20005): %s", bound)
|
||||
}
|
||||
delete(fps, "app")
|
||||
// A held module's offer is left out by provision, never whole when one part composes (issue 1960): de-spiegel's
|
||||
// routes, its names unset, leave the proxy without them, and its smtp account stays with mailu.
|
||||
withProxy := compose.Input{Catalogue: map[string]catalogue.Manifest{},
|
||||
Nodes: []compose.Node{{Name: "n1", Assigned: []string{"feeder"}}}}
|
||||
feeder := catalogue.Manifest{Module: "feeder", Contributes: map[string]map[string]any{"route": {"name": "x"}, "smtp": {"account": "feeder"}},
|
||||
OwnSecrets: catalogue.OwnSecrets{"key": {Path: "/k"}}}
|
||||
for _, row := range []struct {
|
||||
left map[string]bool
|
||||
route, smtp, own bool
|
||||
}{{nil, true, true, true}, {map[string]bool{"route": true}, false, true, true}, {map[string]bool{leaveOwn: true}, true, true, false}, {map[string]bool{leaveAll: true}, false, false, false}} {
|
||||
k := heldAs(withProxy, "n1", "feeder", feeder, row.left, nil).Nodes[0].Kept["feeder"]
|
||||
if _, r := k.Contributes["route"]; r != row.route || (k.Contributes["smtp"] != nil) != row.smtp || (len(k.OwnSecrets) > 0) != row.own {
|
||||
t.Errorf("held leaving out %v offers %+v, own secrets %v", row.left, k.Contributes, k.OwnSecrets)
|
||||
}
|
||||
}
|
||||
// (H) Review of the apply, round 2: a held module is composed from its new manifest, so what the others read of its
|
||||
// manifest (its listens, what it contributes) may not be what runs. site's new manifest moves it to 9090 while the
|
||||
// build that runs (7) listens on 8080. The filter composes with what site last offered, its manifest at the build
|
||||
// that runs (issue 1960: a hold never spreads), so it opens 8080 and nothing applied opens 9090.
|
||||
moved := []byte(strings.Replace(string(site), `"port":8080`, `"port":9090`, 1))
|
||||
b.manifests[delivery.ManifestDigest(moved)] = moved
|
||||
b.modules["site"] = delivery.Module{Manifest: delivery.ManifestDigest(moved)}
|
||||
for i := range p.Modules {
|
||||
p.Modules[i].Ports = nil
|
||||
}
|
||||
b.placements["n1"] = p
|
||||
siteRuns.Resources[1] = json.RawMessage(`{"id":"site.server","module":"site","type":"container","name":"site","image":"registry.example/site@sha256:` + strings.Repeat("c", 64) + `","ports":["8080"]}`)
|
||||
siteRuns.FP = fp("7")
|
||||
b.manifests[delivery.ManifestDigest(site)] = site
|
||||
b.builds["site "+fp("7")] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: delivery.ManifestDigest(site)}
|
||||
got, _, held, err = Compose(ctx, b, Mesh{}, Scope{Node: "n1", Running: []delivery.Composed{siteRuns}}, fps)
|
||||
if err != nil || held["site"] == "" || held["nft"] != "" {
|
||||
t.Errorf("site moved to 9090 in its new manifest: %v, held %v; want site held alone", err, held)
|
||||
}
|
||||
if rules, _ := json.Marshal(got[nftKey].Resources); !strings.Contains(string(rules), "dport 8080") {
|
||||
t.Errorf("the filter drops what held site last offered (8080): %s", rules)
|
||||
}
|
||||
for key, c := range got {
|
||||
for _, raw := range c.Resources {
|
||||
if strings.Contains(string(raw), "dport 9090") {
|
||||
t.Errorf("%s opens 9090, which held site's new manifest names and nothing runs: %s", key, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A module another node cannot run in this composition offers nothing in it: the refusal names it and why
|
||||
// (issue 1126, probe #532: ace and g14 answered "nothing in this mesh provides artifact-store … assign distribution to a
|
||||
// node" while novox runs it). And a refusal no module's stub changes is answered at once, not after a compose per module
|
||||
// (ace took 7m46s).
|
||||
func otherNodeNotResolvedIsNamed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
prov := []byte(`{"module":"prov","version":"1","capabilities":["container-runtime"],"provides":[{"name":"thing","scope":"mesh"}]}`)
|
||||
cons := []byte(`{"module":"cons","version":"1","requires":["thing"]}`)
|
||||
// n2 runs prov, which needs a container runtime n2's record does not say it has: n2's set does not resolve here.
|
||||
b := &bus{placements: map[string]delivery.Placement{
|
||||
"n1": {Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1", Endpoint: "n1.example:51820"}, Modules: []delivery.PlacedModule{{Module: "cons"}, {Module: overlay.Name}}},
|
||||
"n2": {Node: "n2", Decided: delivery.NodeDecided{Address: "10.77.0.2"}, Modules: []delivery.PlacedModule{{Module: "prov"}, {Module: overlay.Name}}}},
|
||||
records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t)}, "n2": {Node: "n2", TunnelKey: publicKey(t)}},
|
||||
modules: map[string]delivery.Module{"prov": {Manifest: delivery.ManifestDigest(prov)}, "cons": {Manifest: delivery.ManifestDigest(cons)}},
|
||||
manifests: map[string][]byte{delivery.ManifestDigest(prov): prov, delivery.ManifestDigest(cons): cons}}
|
||||
given := map[string]*delivery.Placement{}
|
||||
for n, p := range b.placements {
|
||||
p := p
|
||||
given[n] = &p
|
||||
}
|
||||
a := Answer(ctx, b, Mesh{}, Scope{Node: "n1", Placements: given}, map[string]string{"cons": "src2:" + strings.Repeat("1", 64)}, nil, "p-h1", 1)
|
||||
if a.Verdict != delivery.CandidateRefused || !strings.Contains(a.Refused, "prov on n2 lacks container-runtime") {
|
||||
t.Errorf("a refusal for want of n2's provider = %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
// A node that reaches no artifact store says what its composition holds of one (issue 1126: ace and g14 answered
|
||||
// "reaches no artifact store", and nothing said why).
|
||||
func noStoreReachedSaysWhy(t *testing.T) {
|
||||
in := compose.Input{Catalogue: map[string]catalogue.Manifest{
|
||||
"distribution": {Module: "distribution", Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5000}}}},
|
||||
Nodes: []compose.Node{{Name: "g14", Assigned: []string{"adwaita"}}, {Name: "novox", Assigned: []string{"distribution"}}}}
|
||||
if got := storeSought(in); got != "the composition holds g14, novox; artifact-store is served by distribution on novox, none of it on the private network with a port" {
|
||||
t.Errorf("said %q", got)
|
||||
}
|
||||
in.Nodes = in.Nodes[:1]
|
||||
if got := storeSought(in); got != "the composition holds g14; no module placed there serves artifact-store" {
|
||||
t.Errorf("said %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
package frombus
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Export is one node's placement as the composition input the controller fills says it (novox/hq ADR 0555 §6, ADR 0567
|
||||
// §7): the inverse of Input for what a person decides, so the controller's one read-only verb `placement-export`
|
||||
// answers, from its own store by its own code, exactly what the seat's adopt proposes as the seed. Its answer must stay
|
||||
// stable for the same store, since adopt checks its digest again at the warrant.
|
||||
// What the machine observes (its record) is not in it.
|
||||
func Export(in compose.Input, node string) (delivery.Placement, error) {
|
||||
var n *compose.Node
|
||||
for i := range in.Nodes {
|
||||
if in.Nodes[i].Name == node {
|
||||
n = &in.Nodes[i]
|
||||
}
|
||||
}
|
||||
if n == nil {
|
||||
return delivery.Placement{}, fmt.Errorf("frombus: the input has no node %q", node)
|
||||
}
|
||||
// At the version a placement key is read at, with a list of modules even when none is placed: the export is the
|
||||
// seed's value, and the reader refuses a value of version 0 or without its modules.
|
||||
p := delivery.Placement{V: delivery.Version, Node: node, Modules: []delivery.PlacedModule{}, Decided: delivery.NodeDecided{
|
||||
Endpoint: n.Place.Endpoint, Site: n.Place.Site, Hub: n.Place.Hub, Address: n.Place.Address,
|
||||
PublicDomain: n.PublicDomain, Account: n.Account, AccountHome: n.AccountHome, AgentAccount: n.AgentAccount,
|
||||
AgentHome: n.AgentAccountHome, Adopted: n.Adopted, Taken: n.Taken}}
|
||||
// The tunnel found at the node's adoption: the one its private network took over, or, on the hub, the mesh's adopted
|
||||
// one whether or not the hub took it; and the names a person gave the hub's carried peers.
|
||||
if t, ok := in.Tunnels[node]; ok {
|
||||
rt := recordTunnelOf(t.Tunnel)
|
||||
p.Decided.Tunnel = &rt
|
||||
} else if in.Adopted != nil && in.Adopted.Hub == node {
|
||||
rt := recordTunnelOf(in.Adopted.Tunnel)
|
||||
p.Decided.Tunnel = &rt
|
||||
}
|
||||
if n.Place.Hub {
|
||||
for _, c := range in.CarriedPeers {
|
||||
if c.Named != "" {
|
||||
if p.Decided.PeerNames == nil {
|
||||
p.Decided.PeerNames = map[string]string{}
|
||||
}
|
||||
p.Decided.PeerNames[c.PublicKey] = c.Named
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, module := range n.Assigned {
|
||||
m := delivery.PlacedModule{Module: module}
|
||||
for port, at := range n.Ports[module] {
|
||||
if m.Ports == nil {
|
||||
m.Ports = map[string]int{}
|
||||
}
|
||||
m.Ports[strconv.Itoa(port)] = at
|
||||
}
|
||||
for provision, to := range n.Bound[module] {
|
||||
if m.Bindings == nil {
|
||||
m.Bindings = map[string]delivery.Provider{}
|
||||
}
|
||||
m.Bindings[provision] = delivery.Provider{Node: to.Node, Module: to.Module}
|
||||
}
|
||||
// A module in the settings form keeps its config values in its layers, not in mesh-config: they are exported with
|
||||
// the placement. A module in the config form has mesh-config's values laid into its layer, which are not.
|
||||
settingsForm := !in.Catalogue[module].DeclaresConfig()
|
||||
for _, layer := range n.Settings[module] {
|
||||
if layer.From == catalogue.MeshWideLayer {
|
||||
m.HasMesh, m.MeshWide = true, layer.Placement
|
||||
if settingsForm && len(layer.Values) > 0 {
|
||||
m.MeshValues = layer.Values
|
||||
}
|
||||
} else {
|
||||
m.HasLayer, m.Layer = true, layer.Placement
|
||||
if settingsForm && len(layer.Values) > 0 {
|
||||
m.Values = layer.Values
|
||||
}
|
||||
}
|
||||
}
|
||||
p.Modules = append(p.Modules, m)
|
||||
}
|
||||
for provision, to := range n.Pins {
|
||||
if p.Pins == nil {
|
||||
p.Pins = map[string]delivery.Provider{}
|
||||
}
|
||||
p.Pins[provision] = delivery.Provider{Node: to.Node, Module: to.Module}
|
||||
}
|
||||
for _, h := range in.Holdings {
|
||||
if h.Node == node {
|
||||
p.Holds = append(p.Holds, delivery.Holding{Claim: h.Claim, Scope: h.Scope, Module: h.Module, Site: h.Site, Kind: h.Kind})
|
||||
}
|
||||
}
|
||||
sort.SliceStable(p.Holds, func(i, j int) bool {
|
||||
if p.Holds[i].Claim != p.Holds[j].Claim {
|
||||
return p.Holds[i].Claim < p.Holds[j].Claim
|
||||
}
|
||||
return p.Holds[i].Module < p.Holds[j].Module
|
||||
})
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func recordTunnelOf(t compose.Tunnel) delivery.RecordTunnel {
|
||||
out := delivery.RecordTunnel{Interface: t.Interface, Unit: t.Unit, Config: t.Config, Port: t.Port, MTU: t.MTU,
|
||||
Address: t.Address, Range: t.Range, PublicKey: t.PublicKey}
|
||||
for _, peer := range t.Peers {
|
||||
out.Peers = append(out.Peers, delivery.RecordTunnelPeer{PublicKey: peer.PublicKey, Address: peer.Address})
|
||||
}
|
||||
return out
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,869 +0,0 @@
|
||||
package frombus
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
)
|
||||
|
||||
// bus is a Reader over values held in memory, counting whose config values were read.
|
||||
type bus struct {
|
||||
vaultAuthority string
|
||||
handed map[string]bool
|
||||
placements map[string]delivery.Placement
|
||||
records map[string]delivery.NodeRecord
|
||||
modules map[string]delivery.Module
|
||||
manifests map[string][]byte
|
||||
values map[string]map[string]any // by node.module
|
||||
valuesRead []string
|
||||
own []compose.OwnSecretRow
|
||||
ownErr error
|
||||
// asked is every consumer credential asked; the fake refuses the whole ask, as the vault's own does, for a consuming
|
||||
// module its placement does not put on the node.
|
||||
asked []compose.CredentialKey
|
||||
// builds are the builder's keys, by "<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("../../catalogue/testdata/catalogue-835bf22b-xterm.module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
digest := delivery.ManifestDigest(xterm)
|
||||
// A module in the settings form (plex and the media modules on one node, 2026-10-26): its config values are in its
|
||||
// layers, not mesh-config, so the placement carries them (MeshValues, Values) and mesh-config's are not read for it.
|
||||
media := []byte(`{"module":"media","version":"1","settings":{"lines":{"kind":"preference","default":0,"why":"kept"},` +
|
||||
`"name":{"kind":"preference","default":"x","why":"shown"}},"own-secrets":{"token":"/srv/media/token"},"resources":[{"id":"conf","type":"file",` +
|
||||
`"path":"/srv/media/conf","mode":"0644","content":"uid=${setting:lines} name=${setting:name}\n"}]}`)
|
||||
mediaDigest := delivery.ManifestDigest(media)
|
||||
keys := map[string]string{"anchor": publicKey(t), "laptop": publicKey(t)}
|
||||
caps := []string{"container-runtime", "systemd", "wireguard"}
|
||||
tunnel := &delivery.RecordTunnel{Interface: "wg0", PublicKey: keys["anchor"], Address: "10.77.0.1/24",
|
||||
Peers: []delivery.RecordTunnelPeer{{PublicKey: "peer", Address: "10.77.0.9/32"}, {PublicKey: keys["laptop"], Address: "10.77.0.2/32"}}}
|
||||
mk := func() *bus {
|
||||
return &bus{
|
||||
placements: map[string]delivery.Placement{
|
||||
"anchor": {Node: "anchor", Decided: delivery.NodeDecided{Endpoint: "anchor.example:51820", Site: "here", Hub: true, Address: "10.77.0.1",
|
||||
Tunnel: tunnel, PeerNames: map[string]string{"peer": "phone"}},
|
||||
Modules: []delivery.PlacedModule{{Module: overlay.Name}},
|
||||
Holds: []delivery.Holding{{Claim: "mesh-private-network", Scope: "mesh", Module: overlay.Name}}},
|
||||
"laptop": {Node: "laptop", Decided: delivery.NodeDecided{Endpoint: "laptop.example:51820", Site: "here", Address: "10.77.0.2"},
|
||||
Modules: []delivery.PlacedModule{{Module: overlay.Name},
|
||||
{Module: "xterm", HasLayer: true, Ports: map[string]int{"80": 8080}},
|
||||
{Module: "media", HasMesh: true, MeshValues: map[string]any{"name": "mesh"}, HasLayer: true,
|
||||
Layer: map[string]any{"places": map[string]any{"data": "/srv/data"}}, Values: map[string]any{"lines": float64(1000)}}}},
|
||||
},
|
||||
records: map[string]delivery.NodeRecord{
|
||||
"anchor": {Node: "anchor", TunnelKey: keys["anchor"], Capabilities: caps, Contract: 2},
|
||||
"laptop": {Node: "laptop", TunnelKey: keys["laptop"], Capabilities: caps, Authority: "the mesh's certificate"},
|
||||
},
|
||||
modules: map[string]delivery.Module{"xterm": {Manifest: digest}, "media": {Manifest: mediaDigest}, "unread": {}},
|
||||
manifests: map[string][]byte{digest: xterm, mediaDigest: media},
|
||||
values: map[string]map[string]any{"laptop.xterm": {"font": "mono"}, "anchor.xterm": {"font": "serif"},
|
||||
"laptop.media": {"lines": "not mesh-config's"}},
|
||||
own: []compose.OwnSecretRow{{Node: "laptop", Module: "media", Name: "token", Origin: "made", SealedTo: "sk", Sealed: "{}"}},
|
||||
}
|
||||
}
|
||||
|
||||
// What the controller's store gives for the same mesh, as compose's aMesh and SettingsFor build it.
|
||||
var private catalogue.Manifest
|
||||
raw, _ := json.Marshal(overlay.Manifest())
|
||||
if err := json.Unmarshal(raw, &private); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := catalogue.Decode(xterm)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsedMedia, err := catalogue.Decode(media)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
capsSet := map[string]bool{"container-runtime": true, "systemd": true, "wireguard": true}
|
||||
tun := compose.Tunnel{Interface: "wg0", PublicKey: keys["anchor"], Address: "10.77.0.1/24",
|
||||
Peers: []compose.TunnelPeer{{PublicKey: "peer", Address: "10.77.0.9/32"}, {PublicKey: keys["laptop"], Address: "10.77.0.2/32"}}}
|
||||
want := compose.Input{
|
||||
Catalogue: map[string]catalogue.Manifest{overlay.Name: private, "xterm": parsed, "media": parsedMedia},
|
||||
Nodes: []compose.Node{
|
||||
{Name: "anchor", Place: compose.Place{Key: keys["anchor"], Endpoint: "anchor.example:51820", Site: "here", Hub: true,
|
||||
Address: "10.77.0.1"}, Assigned: []string{overlay.Name}, Capabilities: capsSet, Contract: 2, Stated: true},
|
||||
{Name: "laptop", Place: compose.Place{Key: keys["laptop"], Endpoint: "laptop.example:51820", Site: "here",
|
||||
Address: "10.77.0.2"}, Assigned: []string{overlay.Name, "xterm", "media"}, Capabilities: capsSet,
|
||||
Ports: map[string]map[int]int{"xterm": {80: 8080}},
|
||||
Settings: map[string][]catalogue.Layer{"xterm": {{From: "laptop", Values: map[string]any{"font": "mono"}}},
|
||||
"media": {{From: catalogue.MeshWideLayer, Values: map[string]any{"name": "mesh"}},
|
||||
{From: "laptop", Values: map[string]any{"lines": float64(1000)}, Placement: map[string]any{"places": map[string]any{"data": "/srv/data"}}}}}},
|
||||
},
|
||||
Holdings: []catalogue.Held{{Claim: "mesh-private-network", Scope: "mesh", Node: "anchor", Module: overlay.Name}},
|
||||
OwnSecrets: []compose.OwnSecretRow{{Node: "laptop", Module: "media", Name: "token", Origin: "made", SealedTo: "sk", Sealed: "{}"}},
|
||||
// The same value as a core credential's box, for a core module (ADR 0349 §16).
|
||||
KeptCoreBoxes: []compose.CoreBox{{Node: "laptop", Module: "media", Name: "token", SealedTo: "sk", Box: json.RawMessage("{}")}},
|
||||
Tunnels: map[string]compose.FoundTunnel{"anchor": {Tunnel: tun}},
|
||||
Adopted: &compose.AdoptedTunnel{Tunnel: tun, Hub: "anchor", Adopted: true},
|
||||
// The controller's order is by address; laptop enrolled under its peer's key, and a person named the other.
|
||||
Authority: "the mesh's certificate",
|
||||
CarriedPeers: []compose.CarriedPeer{{PublicKey: keys["laptop"], Address: "10.77.0.2", EnrolledAs: "laptop"},
|
||||
{PublicKey: "peer", Address: "10.77.0.9", Named: "phone"}},
|
||||
}
|
||||
|
||||
b := mk()
|
||||
got, err := Input(ctx, b, Mesh{}, Scope{Node: "laptop"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
gj, _ := json.MarshalIndent(got, "", " ")
|
||||
wj, _ := json.MarshalIndent(want, "", " ")
|
||||
t.Fatalf("Input from the bus differs from the store's:\n got %s\nwant %s", gj, wj)
|
||||
}
|
||||
// L3 s8 (ADR 0550 §2): with the vault's authority published, the mesh's authority is the bundle of the controller's
|
||||
// and the vault's; with none, the controller's alone, as above.
|
||||
signing := mk()
|
||||
signing.vaultAuthority = "the vault's certificate"
|
||||
if bundled, err := Input(ctx, signing, Mesh{}, Scope{Node: "laptop"}); err != nil ||
|
||||
bundled.Authority != "the mesh's certificate\nthe vault's certificate\n" {
|
||||
t.Errorf("the authority with the vault's published: %q, %v", bundled.Authority, err)
|
||||
}
|
||||
// Issue 774: the modules the vault judged handed when it answered own are the node's to hand, and no other node's.
|
||||
judged := mk()
|
||||
judged.handed = map[string]bool{"media": true, "xterm": false}
|
||||
if in, err := Input(ctx, judged, Mesh{}, Scope{Node: "laptop"}); err != nil ||
|
||||
!reflect.DeepEqual(in.Nodes[1].Handed, judged.handed) || in.Nodes[0].Handed != nil {
|
||||
t.Errorf("the vault's handed verdicts: %+v, %v", in.Nodes, err)
|
||||
}
|
||||
if !reflect.DeepEqual(b.valuesRead, []string{"laptop." + overlay.Name, "laptop.xterm", "laptop.media"}) {
|
||||
t.Errorf("laptop read config values %v; a node reads its own slice alone", b.valuesRead)
|
||||
}
|
||||
|
||||
// The same composer over both: the compositions from the bus are the store's, byte for byte.
|
||||
fps := map[string]string{overlay.Name: "src2:" + strings.Repeat("a", 64), "xterm": "src2:" + strings.Repeat("b", 64),
|
||||
"media": "src2:" + strings.Repeat("c", 64)}
|
||||
fromBus, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop"}, fps)
|
||||
if err == nil && len(held) > 0 {
|
||||
err = fmt.Errorf("modules held: %v", held)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fromStore, _, err := compose.Compositions(want, "laptop", fps, compose.Reading)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fromBus) == 0 || !reflect.DeepEqual(fromBus, fromStore) {
|
||||
t.Fatalf("compositions differ or are empty: %d from the bus, %d from the store", len(fromBus), len(fromStore))
|
||||
}
|
||||
|
||||
// Issue 1126: a candidate placement equal to the node's composes what it runs, and CompareComposed says so; one
|
||||
// thing changed in the group's candidate of another node (the hub's endpoint) names exactly the module it moves
|
||||
// here. Placed puts the same placement over the store's input unchanged.
|
||||
running := make([]delivery.Composed, 0, len(fromBus))
|
||||
for _, c := range fromBus {
|
||||
running = append(running, c)
|
||||
}
|
||||
laptop := mk().placements["laptop"]
|
||||
asIs, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop}}, fps)
|
||||
if err == nil && len(held) > 0 {
|
||||
err = fmt.Errorf("modules held: %v", held)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := delivery.CompareComposed(asIs, running); len(c.Differs)+len(c.Added)+len(c.Removed) != 0 || len(c.Same) != len(running) {
|
||||
t.Errorf("the candidate equal to the placement: %+v", c)
|
||||
}
|
||||
moved := mk().placements["anchor"]
|
||||
moved.Decided.Endpoint = "anchor.example:51821"
|
||||
changed, _, held, err := Compose(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": &laptop, "anchor": &moved}}, fps)
|
||||
if err == nil && len(held) > 0 {
|
||||
err = fmt.Errorf("modules held: %v", held)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := delivery.CompareComposed(changed, running); len(c.Differs) != 1 || c.Differs[0].Module != overlay.Name || c.Differs[0].Resource != overlay.Name+".overlay-config" {
|
||||
t.Errorf("the hub's endpoint changed: %+v, want %s alone, naming its config", c, overlay.Name)
|
||||
}
|
||||
if c := delivery.CompareComposed(changed, running[:0]); len(c.Added) != len(changed) {
|
||||
t.Errorf("nothing runs: %+v", c)
|
||||
}
|
||||
|
||||
// ADR 0559 §4, the node's answer: the same; one module differing; a module the group adds, whose own secret is not
|
||||
// yet issued (no refusal); a value missing for a module the node runs (a refusal); a module the candidate removes.
|
||||
keeper := []byte(`{"module":"keeper","version":"1","own-secrets":{"password":"/var/lib/mesh/keeper/password"},` +
|
||||
`"resources":[{"id":"state","type":"directory","path":"/var/lib/mesh/keeper","mode":"0700"}]}`)
|
||||
lemurs := []byte(`{"module":"lemurs","version":"1","capabilities":["systemd"],"resources":[{"id":"service","type":"service","unit":"lemurs.service","state":"running","boot":"enabled"}]}`)
|
||||
// A provider and a consumer of a pair credential on the one node (compose's objects and files).
|
||||
objects, _ := json.Marshal(catalogue.Manifest{Module: "objects", Version: "1",
|
||||
Provides: []catalogue.Offer{{Name: "s3-bucket", Scope: catalogue.ScopeMesh, Identity: &catalogue.OfferIdentity{Max: 20, In: "an S3 access key"}}},
|
||||
Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}})
|
||||
files, _ := json.Marshal(catalogue.Manifest{Module: "files", Version: "1", Requires: []string{"s3-bucket"},
|
||||
Resources: []map[string]any{{"id": "config", "type": "file", "path": "/etc/files/config.json", "mode": "0600", "content": "{}", "merge": "json"}}})
|
||||
one := func(placed ...string) *bus {
|
||||
p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}}
|
||||
for _, m := range placed {
|
||||
p.Modules = append(p.Modules, delivery.PlacedModule{Module: m})
|
||||
}
|
||||
return &bus{placements: map[string]delivery.Placement{"n1": p},
|
||||
records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: keys["anchor"], SealingKey: "sk", Capabilities: []string{"systemd"}}},
|
||||
modules: map[string]delivery.Module{"keeper": {Manifest: delivery.ManifestDigest(keeper)}, "lemurs": {Manifest: delivery.ManifestDigest(lemurs)},
|
||||
"objects": {Manifest: delivery.ManifestDigest(objects)}, "files": {Manifest: delivery.ManifestDigest(files)}},
|
||||
manifests: map[string][]byte{delivery.ManifestDigest(keeper): keeper, delivery.ManifestDigest(lemurs): lemurs,
|
||||
delivery.ManifestDigest(objects): objects, delivery.ManifestDigest(files): files},
|
||||
own: []compose.OwnSecretRow{{Node: "n1", Module: "keeper", Name: "password", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}}
|
||||
}
|
||||
oneFps := map[string]string{"keeper": "src2:" + strings.Repeat("d", 64), "lemurs": "src2:" + strings.Repeat("e", 64),
|
||||
"objects": "src2:" + strings.Repeat("f", 64), "files": "src2:" + strings.Repeat("0", 64)}
|
||||
runs, _, held, err := Compose(ctx, one("keeper", "lemurs"), Mesh{}, Scope{Node: "n1"}, oneFps)
|
||||
if err == nil && len(held) > 0 {
|
||||
err = fmt.Errorf("modules held: %v", held)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var runsNow []delivery.Composed
|
||||
for _, c := range runs {
|
||||
runsNow = append(runsNow, c)
|
||||
}
|
||||
objectsRun, _, held, err := Compose(ctx, one("objects"), Mesh{}, Scope{Node: "n1"}, oneFps)
|
||||
if err == nil && len(held) > 0 {
|
||||
err = fmt.Errorf("modules held: %v", held)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var consumerRuns []delivery.Composed
|
||||
for _, c := range objectsRun {
|
||||
consumerRuns = append(consumerRuns, c)
|
||||
}
|
||||
withoutOwn := func(cs []delivery.Composed) []delivery.Composed {
|
||||
var out []delivery.Composed
|
||||
for _, c := range cs {
|
||||
if c.Module != "" {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
// What the controller runs on n1 for a module no want names (lemurs, at a build of the old form) and the private
|
||||
// network's (built by nobody): its composed writer (mesh-controller compositionsOf) folds their resources into the
|
||||
// node's own, each in its place, its module taken off; the rest split by module.
|
||||
tunnelled := func() *bus {
|
||||
b := one("keeper", "lemurs", overlay.Name)
|
||||
p := b.placements["n1"]
|
||||
p.Decided.Endpoint = "n1.example:51820"
|
||||
b.placements["n1"] = p
|
||||
return b
|
||||
}
|
||||
tunnelledAs := func() map[string]*delivery.Placement {
|
||||
p := tunnelled().placements["n1"]
|
||||
return map[string]*delivery.Placement{"n1": &p}
|
||||
}
|
||||
folded := func() []delivery.Composed {
|
||||
b := tunnelled()
|
||||
in, err := Input(ctx, b, Mesh{}, Scope{Node: "n1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reading := in
|
||||
reading.Nodes = append([]compose.Node(nil), in.Nodes...)
|
||||
reading.Nodes[0].ReadsModules = true
|
||||
d, _, err := compose.ComposeIn(reading, "n1", compose.Reading)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := d.Body()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var envelope struct {
|
||||
Resources []map[string]json.RawMessage `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var resources []json.RawMessage
|
||||
for _, r := range envelope.Resources {
|
||||
var module string
|
||||
_ = json.Unmarshal(r["module"], &module)
|
||||
if module == "lemurs" || module == overlay.Name {
|
||||
delete(r, "module")
|
||||
}
|
||||
raw, _ := json.Marshal(r)
|
||||
resources = append(resources, raw)
|
||||
}
|
||||
split, err := delivery.SplitComposed("n1", resources, map[string]string{"keeper": oneFps["keeper"]}, nil, delivery.NodeOwn{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out []delivery.Composed
|
||||
for _, c := range split {
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}()
|
||||
if got := InOwn(folded, tunnelled().placements["n1"]); !reflect.DeepEqual(got, []string{"lemurs", overlay.Name}) {
|
||||
t.Errorf("InOwn of the controller's composition = %v, want lemurs and %s", got, overlay.Name)
|
||||
}
|
||||
placedAs := func(modules ...string) map[string]*delivery.Placement {
|
||||
p := one(modules...).placements["n1"]
|
||||
return map[string]*delivery.Placement{"n1": &p}
|
||||
}
|
||||
for name, c := range map[string]struct {
|
||||
b *bus
|
||||
given map[string]*delivery.Placement
|
||||
running []delivery.Composed
|
||||
verdict string
|
||||
check func(delivery.CandidateAnswer) bool
|
||||
}{
|
||||
"the same": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), runsNow, delivery.CandidateSame,
|
||||
func(a delivery.CandidateAnswer) bool {
|
||||
return len(a.Same) == 3 && a.Proposal == "p-h1" && a.Revision == 7
|
||||
}},
|
||||
"the node runs no own composition": {one("keeper", "lemurs"), placedAs("keeper", "lemurs"), withoutOwn(runsNow), delivery.CandidateSame,
|
||||
func(a delivery.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }},
|
||||
"one removed": {one("keeper", "lemurs"), placedAs("keeper"), runsNow, delivery.CandidateDiffers,
|
||||
func(a delivery.CandidateAnswer) bool { return reflect.DeepEqual(a.Removed, []string{"lemurs"}) }},
|
||||
"one added, its secret not yet issued": {func() *bus { b := one("lemurs"); b.own = nil; return b }(), placedAs("keeper", "lemurs"),
|
||||
nil, delivery.CandidateDiffers, func(a delivery.CandidateAnswer) bool {
|
||||
for _, m := range a.Added {
|
||||
if m.Module == "keeper" {
|
||||
return reflect.DeepEqual(m.Unissued, []string{"keeper/password"}) && len(a.Refusals) == 0
|
||||
}
|
||||
}
|
||||
return false
|
||||
}},
|
||||
"a value missing for a module that runs": {func() *bus { b := one("keeper", "lemurs"); b.own = nil; return b }(), placedAs("keeper", "lemurs"),
|
||||
runsNow, delivery.CandidateRefused, func(a delivery.CandidateAnswer) bool {
|
||||
return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && strings.Contains(a.Refusals[0].Why, "keeper/password")
|
||||
}},
|
||||
// Review of L2b, round 1, finding 1: a consumer the candidate adds has no credential yet, and the vault refuses an
|
||||
// ask for a module not on the node; it is not asked, and the module is added, its credential not yet issued.
|
||||
"a consumer added, the vault not asked for it": {one("objects"), placedAs("objects", "files"), consumerRuns, delivery.CandidateDiffers,
|
||||
func(a delivery.CandidateAnswer) bool {
|
||||
return len(a.Added) == 1 && a.Added[0].Module == "files" && len(a.Added[0].Unissued) > 0 && len(a.Refusals) == 0
|
||||
}},
|
||||
// And with no trunk at all (the seed), a stand-in of a module the node runs is a refusal, not an addition.
|
||||
"the seed, a value missing for a module that runs": {func() *bus { b := one(); b.own = nil; return b }(), placedAs("keeper", "lemurs"),
|
||||
runsNow, delivery.CandidateRefused, func(a delivery.CandidateAnswer) bool {
|
||||
return len(a.Refusals) == 1 && a.Refusals[0].Module == "keeper" && len(a.Added) == 0
|
||||
}},
|
||||
"cannot compose": {one("keeper"), map[string]*delivery.Placement{"n1": {Node: "n2"}}, runsNow, delivery.CandidateRefused,
|
||||
func(a delivery.CandidateAnswer) bool { return a.Refused != "" }},
|
||||
// Issue 1126 (the seed's review): the private network's module and one at a build of the old form, which no want
|
||||
// names and the controller folds into the node's own, are folded there again: the node's own is the same, and
|
||||
// neither is added.
|
||||
"mesh-wireguard and an old-form module, folded": {tunnelled(), tunnelledAs(),
|
||||
folded, delivery.CandidateSame, func(a delivery.CandidateAnswer) bool { return len(a.Same) == 2 && len(a.Added) == 0 }},
|
||||
} {
|
||||
fps := oneFps
|
||||
if name == "mesh-wireguard and an old-form module, folded" {
|
||||
fps = map[string]string{"keeper": oneFps["keeper"]} // the node's wants: no build of lemurs or the private network
|
||||
}
|
||||
a := Answer(ctx, c.b, Mesh{}, Scope{Node: "n1", Placements: c.given, InOwn: InOwn(c.running, *c.given["n1"])}, fps, c.running, "p-h1", 7)
|
||||
if a.Verdict != c.verdict || !c.check(a) {
|
||||
t.Errorf("%s: %+v", name, a)
|
||||
}
|
||||
if raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": a}}); err != nil {
|
||||
t.Errorf("%s: the answer is not one a have holds: %v (%s)", name, err, raw)
|
||||
}
|
||||
}
|
||||
if got, err := Placed(want, map[string]*delivery.Placement{"laptop": &laptop}); err != nil || !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("Placed with the node's own placement changes the input: %v", err)
|
||||
}
|
||||
if got, err := Placed(want, map[string]*delivery.Placement{"laptop": nil}); err != nil || len(got.Nodes[1].Assigned) != 0 || got.Nodes[1].Place.Key != keys["laptop"] {
|
||||
t.Errorf("Placed with no placement: %+v, %v", got.Nodes[1], err)
|
||||
}
|
||||
if _, err := Placed(want, map[string]*delivery.Placement{"stranger": {Node: "stranger"}}); err == nil {
|
||||
t.Error("Placed a node the input holds no node for")
|
||||
}
|
||||
if _, err := Input(ctx, mk(), Mesh{}, Scope{Node: "laptop", Placements: map[string]*delivery.Placement{"laptop": {Node: "anchor"}}}); err == nil {
|
||||
t.Error("a given placement naming another node was taken")
|
||||
}
|
||||
|
||||
// ADR 0555 §6: Export is Input's inverse for what a person decides: the store's input exported, node by node, is the
|
||||
// placement the bus holds, and read back it is the store's input again.
|
||||
for node, placed := range mk().placements {
|
||||
got, err := Export(want, node)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gj, _ := delivery.EncodePlacement(got); !delivery.SamePlacement(got, placed) {
|
||||
pj, _ := delivery.EncodePlacement(placed)
|
||||
t.Errorf("%s exported as %s, want %s", node, gj, pj)
|
||||
}
|
||||
}
|
||||
if _, err := Export(want, "stranger"); err == nil {
|
||||
t.Error("a node the input lacks was exported")
|
||||
}
|
||||
|
||||
b = mk()
|
||||
if every, err := Input(ctx, b, Mesh{}, Scope{Every: true}); err != nil || every.OwnSecrets != nil {
|
||||
t.Fatalf("composing every node: %v, own secrets %v (the vault answers a node about itself alone)", err, every.OwnSecrets)
|
||||
}
|
||||
if !reflect.DeepEqual(b.valuesRead, []string{"anchor." + overlay.Name, "laptop." + overlay.Name, "laptop.xterm", "laptop.media"}) {
|
||||
// Every node's placed modules, and nothing placed nowhere: anchor places no xterm.
|
||||
t.Errorf("the check read %v", b.valuesRead)
|
||||
}
|
||||
|
||||
// A node none of whose modules declares an own secret composes without asking the vault (the review of L2: a node
|
||||
// with no secret composed nothing while the vault did not answer); one whose module does, refuses without it.
|
||||
quiet := mk()
|
||||
quiet.ownErr = errors.New("no responders")
|
||||
if _, err := Input(ctx, quiet, Mesh{}, Scope{Node: "anchor"}); err != nil {
|
||||
t.Errorf("anchor, with no own secret, needed the vault: %v", err)
|
||||
}
|
||||
if _, err := Input(ctx, quiet, Mesh{}, Scope{Node: "laptop"}); err == nil {
|
||||
t.Error("laptop's media, which declares an own secret, composed without the vault")
|
||||
}
|
||||
|
||||
// A module the node's want keeps at another build than the trunk's composes from that build's manifest, resolved with
|
||||
// what it made (issue 1126), on the composing node alone.
|
||||
kept := mk()
|
||||
oldXterm := []byte(`{"module":"xterm","version":"1","resources":[{"id":"tools","type":"process","name":"xterm-tools",` +
|
||||
`"artifact":"tools","run":["./xterm-tools"]}],"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/xterm-tools","binary":"xterm-tools"}]}}`)
|
||||
kept.manifests["old"] = oldXterm
|
||||
oldFP := "src2:" + strings.Repeat("9", 64)
|
||||
toolsRef := "artifact-store://xterm/tools/blobs/sha256:" + strings.Repeat("1", 64)
|
||||
kept.builds = map[string]*delivery.BuilderBuild{"xterm " + oldFP: {State: delivery.BuildDone, Manifest: "old",
|
||||
Artifacts: []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: toolsRef, Digest: "sha256:" + strings.Repeat("1", 64)}}}}
|
||||
parsedOld, err := catalogue.Decode(oldXterm)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keptBuilt, err := parsedOld.Resolve([]catalogue.Built{{Name: "tools", Kind: "bundle", Reference: toolsRef, Digest: "sha256:" + strings.Repeat("1", 64)}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in, err := Input(ctx, kept, Mesh{}, Scope{Node: "laptop", KeptAt: map[string]string{"xterm": oldFP}})
|
||||
if err != nil || !reflect.DeepEqual(in.Nodes[1].Kept, map[string]catalogue.Manifest{"xterm": keptBuilt}) || in.Nodes[0].Kept != nil {
|
||||
t.Errorf("the want's build is kept on the composing node alone: %v, %v", in.Nodes, err)
|
||||
}
|
||||
|
||||
// Issue 1126, the seed's refusals. restic and the provisioner's client as the catalogue holds them (mesh-catalog
|
||||
// c05aa5e6), restic moved with an own secret the vault leaves out (it hands it to the client, mesh-vault ownnode.go
|
||||
// handed): composed from the indexer's manifest restic is not handed and stands in for that secret, as novox's,
|
||||
// ace's, g14's and shanks's answers refused; composed from its build, it is handed, its templates archive read
|
||||
// where the node reaches the artifact store, and the composition is the one the controller's take-in gives.
|
||||
resticRaw, err := os.ReadFile("testdata/catalogue-c05aa5e6-restic.module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clientRaw, err := os.ReadFile("testdata/catalogue-c05aa5e6-mesh-provisioner-client.module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := []byte(`{"module":"store","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],` +
|
||||
`"serves":{"artifact-store":{"port":5000}}}`)
|
||||
texts := map[string]string{}
|
||||
var packed bytes.Buffer
|
||||
zw := gzip.NewWriter(&packed)
|
||||
tw := tar.NewWriter(zw)
|
||||
for _, f := range []string{"backups.conf", "data.conf", "retention.conf"} {
|
||||
text, err := os.ReadFile("testdata/catalogue-c05aa5e6-restic.files/" + f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
texts[f] = string(text)
|
||||
if err := tw.WriteHeader(&tar.Header{Name: "./" + f, Mode: 0o644, Size: int64(len(text)), Typeflag: tar.TypeReg}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write(text); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archiveDigest := delivery.ManifestDigest(packed.Bytes())
|
||||
digestOf := func(c string) string { return "sha256:" + strings.Repeat(c, 64) }
|
||||
movedFps := map[string]string{"restic": "src2:" + strings.Repeat("3", 64), "mesh-provisioner-client": "src2:" + strings.Repeat("4", 64),
|
||||
"store": "src2:" + strings.Repeat("5", 64)}
|
||||
resticArtifacts := []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Reference: "artifact-store://restic/tools/blobs/" + digestOf("6"), Digest: digestOf("6")},
|
||||
{Name: "files", Kind: "archive", Reference: "artifact-store://restic/files/blobs/" + archiveDigest, Digest: archiveDigest}}
|
||||
clientArtifacts := []delivery.BuiltArtifact{{Name: "client", Kind: "bundle", Reference: "artifact-store://mesh-provisioner-client/client/blobs/" + digestOf("7"), Digest: digestOf("7")}}
|
||||
movedMesh := func(built bool) *bus {
|
||||
b := one()
|
||||
p := b.placements["n1"]
|
||||
p.Modules = []delivery.PlacedModule{{Module: "store"}, {Module: "mesh-provisioner-client"}, {Module: "restic"}}
|
||||
p.Decided.Account, p.Decided.AccountHome = "operator", "/home/operator"
|
||||
b.placements["n1"] = p
|
||||
rec := b.records["n1"]
|
||||
rec.Contract, rec.Capabilities = compose.HandedContract, []string{"systemd", "package-manager"}
|
||||
b.records["n1"] = rec
|
||||
b.modules = map[string]delivery.Module{}
|
||||
b.manifests = map[string][]byte{}
|
||||
for module, raw := range map[string][]byte{"restic": resticRaw, "mesh-provisioner-client": clientRaw, "store": store} {
|
||||
b.modules[module] = delivery.Module{FP2: movedFps[module], Manifest: delivery.ManifestDigest(raw)}
|
||||
b.manifests[delivery.ManifestDigest(raw)] = raw
|
||||
}
|
||||
// The vault's own answer: the client's own secret; restic's is the client's to ask, so own leaves it out.
|
||||
b.own = []compose.OwnSecretRow{{Node: "n1", Module: "mesh-provisioner-client", Name: "provisioner-broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}
|
||||
if built {
|
||||
b.builds = map[string]*delivery.BuilderBuild{
|
||||
"restic " + movedFps["restic"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(resticRaw), Artifacts: resticArtifacts},
|
||||
"mesh-provisioner-client " + movedFps["mesh-provisioner-client"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(clientRaw), Artifacts: clientArtifacts},
|
||||
"store " + movedFps["store"]: {State: delivery.BuildDone, Manifest: delivery.ManifestDigest(store)},
|
||||
}
|
||||
b.archives = map[string][]byte{archiveDigest: packed.Bytes()}
|
||||
}
|
||||
return b
|
||||
}
|
||||
if out, _, held, err := Compose(ctx, movedMesh(false), Mesh{}, Scope{Node: "n1"}, movedFps); err != nil || !strings.Contains(held["restic"], `restic needs a secret called "repository"`) {
|
||||
t.Errorf("restic composed from the indexer's manifest: %v %v, want its own secret refusing restic alone", err, held)
|
||||
} else {
|
||||
for key, c := range out {
|
||||
if c.Module == "restic" {
|
||||
// Its stub's composition, applied, would remove what restic runs: a held module keeps its last one.
|
||||
t.Errorf("held restic is composed as %s, want none of its own composition handed", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
builtBus := movedMesh(true)
|
||||
fromBuild, _, held, err := Compose(ctx, builtBus, Mesh{}, Scope{Node: "n1"}, movedFps)
|
||||
if err == nil && len(held) > 0 {
|
||||
err = fmt.Errorf("modules held: %v", held)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("restic composed from its build: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(builtBus.fetched, []string{"http://127.0.0.1:5000/v2/restic/files/blobs/" + archiveDigest}) {
|
||||
t.Errorf("the templates archive was read at %v", builtBus.fetched)
|
||||
}
|
||||
resticKey, _ := delivery.ComposedKey("n1", "restic", movedFps["restic"])
|
||||
if c := fromBuild[resticKey]; len(c.Filled) != 4 { // its three templated files and its own secret
|
||||
t.Errorf("restic is not handed to the provisioner: its filled files are %v", c.Filled)
|
||||
}
|
||||
// A done key an artifact of which names no reference is waited for, in the controller's words (ADR 0498 §8).
|
||||
unreferenced := movedMesh(true)
|
||||
unreferenced.builds["restic "+movedFps["restic"]].Artifacts = []delivery.BuiltArtifact{{Name: "tools", Kind: "bundle", Digest: digestOf("6")}, resticArtifacts[1]}
|
||||
if _, _, held, err := Compose(ctx, unreferenced, Mesh{}, Scope{Node: "n1"}, movedFps); err != nil || !strings.Contains(held["restic"], "the build of restic is done and waits for the reference of tools") {
|
||||
t.Errorf("a done key with an unreferenced artifact: %v %v, want restic held alone", err, held)
|
||||
}
|
||||
// A module the node runs inside its own composition (the fold) composes from the indexer's manifest though a done
|
||||
// build of it is at the trunk's FP2: its build is not read.
|
||||
folding := movedMesh(true)
|
||||
foldIn, err := Input(ctx, folding, Mesh{}, Scope{Node: "n1", InOwn: []string{"mesh-provisioner-client"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
clientParsed, err := catalogue.Decode(clientRaw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if slices.Contains(folding.buildsRead, "mesh-provisioner-client") || !reflect.DeepEqual(foldIn.Catalogue["mesh-provisioner-client"], clientParsed) {
|
||||
t.Errorf("a folded module was taken from its build: builds read %v", folding.buildsRead)
|
||||
}
|
||||
// The controller's: its take-in resolves each manifest with the key's artifacts and the archive's texts, and composes
|
||||
// from those over the same input.
|
||||
storeIn, err := Input(ctx, movedMesh(false), Mesh{}, Scope{Node: "n1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for module, built := range map[string][]catalogue.Built{
|
||||
"restic": {{Name: "tools", Kind: "bundle", Reference: resticArtifacts[0].Reference, Digest: digestOf("6")},
|
||||
{Name: "files", Kind: "archive", Reference: resticArtifacts[1].Reference, Digest: archiveDigest, Texts: texts}},
|
||||
"mesh-provisioner-client": {{Name: "client", Kind: "bundle", Reference: clientArtifacts[0].Reference, Digest: digestOf("7")}},
|
||||
} {
|
||||
resolved, err := storeIn.Catalogue[module].Resolve(built)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Kept as its bytes and read again, as the controller's store holds a build's manifest.
|
||||
raw, _ := json.Marshal(resolved)
|
||||
if storeIn.Catalogue[module], err = catalogue.Decode(raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
storeIn.OwnSecrets = movedMesh(true).own
|
||||
fromStoreMoved, _, err := compose.Compositions(storeIn, "n1", movedFps, compose.Reading)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(fromBuild, fromStoreMoved) {
|
||||
gj, _ := json.MarshalIndent(fromBuild, "", " ")
|
||||
wj, _ := json.MarshalIndent(fromStoreMoved, "", " ")
|
||||
t.Errorf("composed from the build differs from the controller's take-in:\n got %s\nwant %s", gj, wj)
|
||||
}
|
||||
// ADR 0570 clause 2 (issue 1954 L6-P1): the seat mesh-provisioner's plans, composed from the bus alone and with no
|
||||
// secret read (the Planning mode), are the controller's handedPlans over its own input, which holds its secrets
|
||||
// (compose_sdk.go: Resolve, Render in Foreseeing, Generators, Plan.Compose), and restic, handed, has one.
|
||||
noSecrets := movedMesh(true)
|
||||
noSecrets.own = nil
|
||||
seat, err := Plans(ctx, noSecrets, Mesh{}, Scope{Node: "n1"})
|
||||
if err != nil {
|
||||
t.Fatalf("the plans from the bus: %v", err)
|
||||
}
|
||||
controllers, err := plansIn(storeIn, "n1", compose.Foreseeing)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, handed := seat["restic"]; !handed || !reflect.DeepEqual(seat, controllers) {
|
||||
gj, _ := json.MarshalIndent(seat, "", " ")
|
||||
wj, _ := json.MarshalIndent(controllers, "", " ")
|
||||
t.Errorf("the seat's plans differ from the controller's:\n got %s\nwant %s", gj, wj)
|
||||
}
|
||||
// ADR 0571 clause 3: the data items of n1's modules, placed, from the bus alone: what disk-load measures.
|
||||
items, unplaced, err := DataItems(ctx, noSecrets, Mesh{}, Scope{Node: "n1"})
|
||||
if err != nil || len(unplaced) > 0 || len(items) == 0 {
|
||||
t.Errorf("the data items from the bus are %v, unplaced %v: %v", items, unplaced, err)
|
||||
}
|
||||
for module, its := range items {
|
||||
for _, it := range its {
|
||||
if it.Module != module || !strings.HasPrefix(it.Path, "/") || it.Measure == "" {
|
||||
t.Errorf("%s's item %+v is not placed", module, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
// And the same through StatesReader, over the fake's states as a module's runtime gives them (one reader for
|
||||
// mesh-provisioner and disk-load, the lead 2026-10-29).
|
||||
viaStates, _, err := DataItems(ctx, StatesReader(statesOf(t, ctx, noSecrets), func(_ context.Context, url string) ([]byte, error) {
|
||||
for _, body := range noSecrets.archives {
|
||||
return body, nil
|
||||
}
|
||||
return nil, errors.New("no archive")
|
||||
}), Mesh{}, Scope{Node: "n1"})
|
||||
if err != nil || !reflect.DeepEqual(viaStates, items) {
|
||||
t.Errorf("through the states the data items are %v, %v; through the reader %v", viaStates, err, items)
|
||||
}
|
||||
|
||||
// And what a provider on another node serves from its settings (mailu's smtp domain, issue 1126: shanks refused
|
||||
// '${setting:domain} ... nothing sets "domain"'): read for that provider's served keys alone, through Served.
|
||||
mailer := []byte(`{"module":"mailer","version":"1","config":{"domain":{"type":"string","description":"the mail domain","required":true}},` +
|
||||
`"provides":[{"name":"smtp","scope":"mesh"}],"serves":{"smtp":{"port":587,"domain":"${config:domain}"}}}`)
|
||||
withMailer := func(set bool) *bus {
|
||||
b := mk()
|
||||
p := b.placements["anchor"]
|
||||
p.Modules = append(p.Modules, delivery.PlacedModule{Module: "mailer"})
|
||||
b.placements["anchor"] = p
|
||||
b.modules["mailer"] = delivery.Module{Manifest: delivery.ManifestDigest(mailer)}
|
||||
b.manifests[delivery.ManifestDigest(mailer)] = mailer
|
||||
if set {
|
||||
b.served = map[string]map[string]any{"anchor.mailer": {"domain": "example.org"}}
|
||||
}
|
||||
return b
|
||||
}
|
||||
if _, _, _, err := Compose(ctx, withMailer(false), Mesh{}, Scope{Node: "laptop"}, fps); err == nil || !strings.Contains(err.Error(), `nothing sets "domain"`) {
|
||||
t.Errorf("a provider's served setting nobody sets: %v", err)
|
||||
}
|
||||
served := withMailer(true)
|
||||
if _, _, held, err := Compose(ctx, served, Mesh{}, Scope{Node: "laptop"}, fps); err != nil || len(held) > 0 {
|
||||
t.Errorf("laptop with what anchor's mailer serves: %v %v", err, held)
|
||||
}
|
||||
if !reflect.DeepEqual(served.servedRead, []string{"anchor.mailer:domain"}) || slices.Contains(served.valuesRead, "anchor.mailer") {
|
||||
t.Errorf("laptop read %v of other nodes (and %v of config values): the served keys of anchor's mailer alone", served.servedRead, served.valuesRead)
|
||||
}
|
||||
|
||||
for name, c := range map[string]struct {
|
||||
change func(*bus)
|
||||
scope Scope
|
||||
says string
|
||||
}{
|
||||
"no record": {func(b *bus) { delete(b.records, "laptop") }, Scope{Node: "anchor"}, "laptop, whose agent has written no record"},
|
||||
"no placement": {func(b *bus) {}, Scope{Node: "stranger"}, "stranger has no placement"},
|
||||
"a manifest unread": {func(b *bus) { delete(b.manifests, digest) }, Scope{Node: "laptop"}, "the manifest of xterm"},
|
||||
"a kept one unbuilt": {func(b *bus) {}, Scope{Node: "laptop", KeptAt: map[string]string{"xterm": "src2:gone"}}, "holds no done build"},
|
||||
} {
|
||||
b := mk()
|
||||
c.change(b)
|
||||
if _, err := Input(ctx, b, Mesh{}, c.scope); err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%s: %v, want it to say %q", name, err, c.says)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mapStates are read states by name, key to value, as a module's runtime gives them.
|
||||
type mapStates map[string]map[string][]byte
|
||||
|
||||
func (m mapStates) Get(state, key string) ([]byte, bool, error) {
|
||||
v, ok := m[state][key]
|
||||
return v, ok, nil
|
||||
}
|
||||
|
||||
func (m mapStates) Keys(state string) ([]string, error) {
|
||||
var out []string
|
||||
for k := range m[state] {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// statesOf is b laid out as the states StatesReader reads.
|
||||
func statesOf(t *testing.T, ctx context.Context, b *bus) mapStates {
|
||||
t.Helper()
|
||||
out := mapStates{}
|
||||
put := func(state, key string, raw []byte, err error) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out[state] == nil {
|
||||
out[state] = map[string][]byte{}
|
||||
}
|
||||
out[state][key] = raw
|
||||
}
|
||||
placements, _ := b.Placements(ctx)
|
||||
for _, p := range placements {
|
||||
values, err := delivery.PlacementValues(p)
|
||||
for k, raw := range values {
|
||||
put(PlacementRead, k, raw, err)
|
||||
}
|
||||
}
|
||||
records, _ := b.Records(ctx)
|
||||
for node, r := range records {
|
||||
k, _ := delivery.HaveNodeKey(node)
|
||||
rec := r
|
||||
raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec})
|
||||
put(HaveRead, k, raw, err)
|
||||
}
|
||||
modules, _ := b.Modules(ctx)
|
||||
for name, m := range modules {
|
||||
raw, err := delivery.EncodeModule(m)
|
||||
put(ModulesRead, name, raw, err)
|
||||
var mf struct{ Module string }
|
||||
_ = json.Unmarshal(b.manifests[m.Manifest], &mf)
|
||||
k, _ := delivery.ManifestsKey(m.Manifest)
|
||||
raw, err = delivery.EncodeManifest(delivery.Manifest{Module: mf.Module, JSON: string(b.manifests[m.Manifest])})
|
||||
put(ManifestsRead, k, raw, err)
|
||||
}
|
||||
for key, build := range b.builds {
|
||||
module, fp, _ := strings.Cut(key, " ")
|
||||
k, _ := delivery.BuilderBuildKey(module, fp)
|
||||
raw, err := delivery.EncodeBuilderBuild(*build)
|
||||
put(BuildsRead, k, raw, err)
|
||||
}
|
||||
for nm, values := range b.values {
|
||||
for k, v := range values {
|
||||
value, _ := json.Marshal(v)
|
||||
raw, err := json.Marshal(map[string]any{"value": json.RawMessage(value), "source": catalogue.SourceModule})
|
||||
put(ResolvedRead, nm+"."+k, raw, err)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
// Package frombustest is a bus in memory for frombus's Reader, and a mesh on it whose composition hands a module to the
|
||||
// provisioner with a provision to make, so a module that composes plans from the bus (mesh-provisioner, novox/hq ADR
|
||||
// 0570 clause 2) tests against a real plan rather than one written by hand. The manifests are the catalogue's (mesh-catalog
|
||||
// c05aa5e6: restic and the provisioner's client) as frombus's own tests read them, restic given one requirement more.
|
||||
package frombustest
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
//go:embed testdata
|
||||
var testdata embed.FS
|
||||
|
||||
// Bus is a frombus.Reader over values held in memory. A credential asked is answered held when Held is set.
|
||||
type Bus struct {
|
||||
Placement map[string]delivery.Placement
|
||||
Record map[string]delivery.NodeRecord
|
||||
Module map[string]delivery.Module
|
||||
Manifests map[string][]byte
|
||||
Value map[string]map[string]any // by node.module
|
||||
Own []compose.OwnSecretRow
|
||||
Builds map[string]*delivery.BuilderBuild // by "<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)}
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
package frombustest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/fromruntime"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// ADR 0570 clause 2 (issue 1954; a3's H5): the in-memory mesh composes a plan from the bus that hands restic to the
|
||||
// provisioner and names its postgres-database, provided by postgres on n1. And one bad module must not drop its node
|
||||
// (issue 774): with a module placed beside it whose built manifest cannot be used, or whose plan refuses, PlansHeld
|
||||
// still hands restic's plan and names the bad one with why, where Plans refuses the whole node. A manifest the indexer
|
||||
// holds that the mesh refuses (power's bar item with attributes its bar places by none of, mesh-catalog ca8d6f33,
|
||||
// 2026-10-30) holds that module alone on its node, in the plans and in the node's composition (Compose), and on a node
|
||||
// it is not placed on holds nothing.
|
||||
func TestTheProvisionedMeshHandsAPlanWithItsProvision(t *testing.T) {
|
||||
fp := "src2:" + strings.Repeat("9", 64)
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
bad string // the indexer's manifest of a module "backup" placed on n1; empty places none
|
||||
// built is the manifest backup's done build names; empty gives it no build
|
||||
built string
|
||||
why string // words backup's hold says
|
||||
// elsewhere leaves backup in the indexer's modules and places it on no node: nothing is held.
|
||||
elsewhere bool
|
||||
// refused is the indexer's verdict on bad, a problem its judge found (novox/hq ADR 0578 §1): Plans then holds
|
||||
// backup too, and never ends the read.
|
||||
refused string
|
||||
// dataHeld says the data items' read holds backup too: a refusal it meets (a refused manifest, an unusable build,
|
||||
// an item composition refuses), not one only a plan meets.
|
||||
dataHeld bool
|
||||
// dataOnly checks the data items' read alone: the other reads blame restic, whose templates collect the item.
|
||||
dataOnly bool
|
||||
}{
|
||||
{name: "every module composes"},
|
||||
{name: "a built manifest that cannot be used",
|
||||
bad: `{"module":"backup","version":"1","build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`,
|
||||
built: `{"module":"backup","version":"1","requires":"amqp"}`, why: "backup", dataHeld: true},
|
||||
{name: "a plan that refuses",
|
||||
bad: `{"module":"backup","version":"1","resources":[{"id":"conf","type":"file","path":"/etc/backup.conf",` +
|
||||
`"mode":"0600","content":"key=${secret:nope}"}]}`, why: "${secret:nope}"},
|
||||
{name: "an indexer's manifest the mesh refuses", bad: unusableItem, why: `"shows" is none of them`,
|
||||
refused: `"shows" is none of them`, dataHeld: true},
|
||||
// ADR 0578 §3: an accepted manifest whose data item its composition refuses is held by the culprit rule, in every read.
|
||||
{name: "a data item its composition refuses",
|
||||
bad: `{"module":"backup","version":"1","data":{"own":[{"id":"d","path":"${dir:nope}","class":"cache"}]}}`,
|
||||
why: "nope", dataHeld: true, dataOnly: true},
|
||||
{name: "an indexer's manifest the mesh refuses, placed elsewhere", bad: unusableItem, elsewhere: true,
|
||||
refused: `"shows" is none of them`},
|
||||
} {
|
||||
b, err := Provisioned()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.bad != "" {
|
||||
if !row.elsewhere {
|
||||
p := b.Placement[Node]
|
||||
p.Modules = append(p.Modules, delivery.PlacedModule{Module: "backup"})
|
||||
b.Placement[Node] = p
|
||||
}
|
||||
digest := delivery.ManifestDigest([]byte(row.bad))
|
||||
b.Manifests[digest] = []byte(row.bad)
|
||||
b.Module["backup"] = delivery.Module{FP2: fp, Manifest: digest}
|
||||
if row.refused != "" {
|
||||
b.Module["backup"] = delivery.Module{FP2: fp, Manifest: digest, Verdict: delivery.VerdictRefused,
|
||||
Problems: []string{row.refused}}
|
||||
}
|
||||
if row.built != "" {
|
||||
builtDigest := delivery.ManifestDigest([]byte(row.built))
|
||||
b.Manifests[builtDigest] = []byte(row.built)
|
||||
b.Builds["backup "+fp] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: builtDigest,
|
||||
Artifacts: []delivery.BuiltArtifact{{Name: "files", Kind: "archive", Reference: "artifact-store://backup/files/blobs/x", Digest: "sha256:x"}}}
|
||||
}
|
||||
if row.dataOnly {
|
||||
items, _, iheld, err := frombus.DataItemsHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
|
||||
if why, ok := iheld["backup"]; err != nil || !ok || !strings.Contains(why, row.why) || len(items["restic"]) == 0 {
|
||||
t.Errorf("%s: data items %v held %v: %v", row.name, items, iheld, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
plans, err := frombus.Plans(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
|
||||
switch {
|
||||
case row.refused != "" && (err != nil || !planned(plans, "restic") || planned(plans, "backup")):
|
||||
t.Errorf("%s: Plans did not hold the refused manifest alone: %v %v", row.name, plans, err)
|
||||
case row.refused == "" && err == nil && !row.elsewhere:
|
||||
t.Errorf("%s: Plans composed the node; the row does not refuse it", row.name)
|
||||
}
|
||||
}
|
||||
if row.why != "" || row.bad == "" {
|
||||
// The node's own composition holds by the same rule: every other module composed, the bad one named.
|
||||
fps := FPs()
|
||||
if row.bad != "" {
|
||||
fps["backup"] = fp
|
||||
}
|
||||
out, _, held, err := frombus.Compose(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node}, fps)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: the node's composition: %v", row.name, err)
|
||||
}
|
||||
if key, _ := delivery.ComposedKey(Node, "restic", fps["restic"]); out[key].Module != "restic" {
|
||||
t.Errorf("%s: restic is not composed (held %v)", row.name, held)
|
||||
}
|
||||
if why, ok := held["backup"]; (row.bad == "" && len(held) > 0) || (row.bad != "" && (!ok || !strings.Contains(why, row.why) || len(held) != 1)) {
|
||||
t.Errorf("%s: the node's composition held %v, want backup alone, saying %q", row.name, held, row.why)
|
||||
}
|
||||
}
|
||||
plans, held, err := frombus.PlansHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", row.name, err)
|
||||
}
|
||||
restic, handed := plans["restic"]
|
||||
if !handed || len(restic.Provisions) != 1 || restic.Provisions[0].Provision != "postgres-database" ||
|
||||
restic.Provisions[0].Provider != Node || restic.Provisions[0].ProviderModule != "postgres" {
|
||||
t.Fatalf("%s: the plans are %+v (held %v)", row.name, plans, held)
|
||||
}
|
||||
// Issue 1954: the plans at the wanted builds and at the newest, over one read, are what PlansHeld answers for each,
|
||||
// and where the time went is said by phase.
|
||||
keptAt := FPs()
|
||||
at, newest, took := frombus.PlansHeldTwice(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node, KeptAt: keptAt})
|
||||
wantAt, heldAt, errAt := frombus.PlansHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node, KeptAt: keptAt})
|
||||
if !reflect.DeepEqual(at, frombus.HeldPlans{Plans: wantAt, Held: heldAt, Err: errAt}) ||
|
||||
!reflect.DeepEqual(newest, frombus.HeldPlans{Plans: plans, Held: held}) {
|
||||
t.Errorf("%s: twice composed %+v and %+v, want %v %v %v and %v %v", row.name, at, newest, wantAt, heldAt, errAt, plans, held)
|
||||
}
|
||||
if said := frombus.PhasesSaid(took); !strings.HasPrefix(said, "read ") || !strings.Contains(said, "plans at newest ") {
|
||||
t.Errorf("%s: the phases are said as %q", row.name, said)
|
||||
}
|
||||
if row.bad == "" || row.elsewhere {
|
||||
if len(held) > 0 {
|
||||
t.Errorf("%s: held %v", row.name, held)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if why, ok := held["backup"]; !ok || !strings.Contains(why, row.why) || len(held) != 1 {
|
||||
t.Errorf("%s: held %v, want backup alone, saying %q", row.name, held, row.why)
|
||||
}
|
||||
if _, planned := plans["backup"]; planned {
|
||||
t.Errorf("%s: the held module has a plan", row.name)
|
||||
}
|
||||
// The data items are read by the same rule (ADR 0578 §3: disk-load's read): the bad module held, the read whole.
|
||||
items, _, iheld, err := frombus.DataItemsHeld(context.Background(), b, frombus.Mesh{}, frombus.Scope{Node: Node})
|
||||
// A refusal only a plan meets (a file's secret) holds nothing here: data items render no such file.
|
||||
mustHold := row.dataHeld
|
||||
if why, ok := iheld["backup"]; err != nil || ok != mustHold || mustHold && !strings.Contains(why, row.why) || items["backup"] != nil {
|
||||
t.Errorf("%s: data items %v held %v: %v", row.name, items, iheld, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0580 clause 1: the watched reader reads a state it composes from out of the copy, a build by key each time it is
|
||||
// asked, and a manifest once per digest.
|
||||
byKey := map[string]int{}
|
||||
ws := stdio.WatchedStatesOver(func(state string, handle func(stdio.StateChange) error) error {
|
||||
byKey[state+" watched"]++
|
||||
return handle(stdio.StateChange{Key: "placement." + Node, Op: stdio.OpPut, Value: []byte(`{}`), Current: true})
|
||||
}, func(state, key string) ([]byte, bool, error) { byKey[state]++; return []byte(key), true, nil })
|
||||
w := fromruntime.Watched{WatchedStates: ws}
|
||||
for range 2 {
|
||||
_, _, _ = w.Get(frombus.BuildsRead, "restic."+fp)
|
||||
_, _, _ = w.Get(frombus.ManifestsRead, "sha256-d")
|
||||
if _, found, _ := w.Get(frombus.PlacementRead, "placement."+Node); !found {
|
||||
t.Error("the watched reader does not answer the placement from its copy")
|
||||
}
|
||||
}
|
||||
if byKey[frombus.BuildsRead] != 2 || byKey[frombus.ManifestsRead] != 1 || byKey[frombus.PlacementRead] != 0 ||
|
||||
byKey[frombus.PlacementRead+" watched"] != 1 {
|
||||
t.Errorf("the watched reader read %v, want a build by key each time, a manifest once, the placement watched once", byKey)
|
||||
}
|
||||
fetched := 0
|
||||
keep := fromruntime.KeptFetch(func(context.Context, string) ([]byte, error) { fetched++; return []byte("tar"), nil })
|
||||
for range 2 {
|
||||
if body, err := keep(context.Background(), "http://store/restic/files/blobs/sha256:a"); err != nil || string(body) != "tar" {
|
||||
t.Fatalf("the kept fetch answered %q, %v", body, err)
|
||||
}
|
||||
}
|
||||
if fetched != 1 {
|
||||
t.Errorf("a templates archive was fetched %d times, want once", fetched)
|
||||
}
|
||||
|
||||
// Issue 1954: the states reader takes a build in once per its content: composed again, its archive is not read again
|
||||
// and the plans are the same.
|
||||
b, err := Provisioned()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reader, archives := frombus.StatesReader(statesOf(t, b), func(_ context.Context, url string) ([]byte, error) {
|
||||
fetched++
|
||||
_, digest, _ := strings.Cut(url, "/blobs/")
|
||||
return b.Archives[digest], nil
|
||||
}), fetched
|
||||
var first map[string]catalogue.Plan
|
||||
for i := range 2 {
|
||||
plans, held, err := frombus.PlansHeld(context.Background(), reader, frombus.Mesh{}, frombus.Scope{Node: Node})
|
||||
if err != nil || len(held) > 0 || (i == 1 && !reflect.DeepEqual(plans, first)) || !planned(plans, "restic") {
|
||||
t.Fatalf("composed %v (held %v): %v", plans, held, err)
|
||||
}
|
||||
first = plans
|
||||
}
|
||||
if fetched-archives != 1 {
|
||||
t.Errorf("restic's archive was read %d times over two compositions, want once", fetched-archives)
|
||||
}
|
||||
}
|
||||
|
||||
// statesOf is the bus b as a module's read states hold it.
|
||||
func statesOf(t *testing.T, b *Bus) frombus.States {
|
||||
t.Helper()
|
||||
s := states{}
|
||||
put := func(state, key string, raw []byte, err error) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s[state] == nil {
|
||||
s[state] = map[string][]byte{}
|
||||
}
|
||||
s[state][key] = raw
|
||||
}
|
||||
for node, p := range b.Placement {
|
||||
raw, err := delivery.EncodePlacement(p)
|
||||
put(frombus.PlacementRead, "placement."+node, raw, err)
|
||||
}
|
||||
for node, rec := range b.Record {
|
||||
raw, err := delivery.EncodeHaveNode(delivery.HaveNode{Record: &rec})
|
||||
put(frombus.HaveRead, node+".node", raw, err)
|
||||
}
|
||||
for module, m := range b.Module {
|
||||
key, _ := delivery.ModulesKey(module)
|
||||
raw, err := delivery.EncodeModule(m)
|
||||
put(frombus.ModulesRead, key, raw, err)
|
||||
}
|
||||
for digest, man := range b.Manifests {
|
||||
key, _ := delivery.ManifestsKey(digest)
|
||||
raw, err := delivery.EncodeManifest(delivery.Manifest{Module: "m", JSON: string(man)})
|
||||
put(frombus.ManifestsRead, key, raw, err)
|
||||
}
|
||||
for at, build := range b.Builds {
|
||||
module, fp, _ := strings.Cut(at, " ")
|
||||
key, _ := delivery.BuilderBuildKey(module, fp)
|
||||
raw, err := delivery.EncodeBuilderBuild(*build)
|
||||
put(frombus.BuildsRead, key, raw, err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// states are read states in memory.
|
||||
type states map[string]map[string][]byte
|
||||
|
||||
func (s states) Get(state, key string) ([]byte, bool, error) {
|
||||
v, ok := s[state][key]
|
||||
return v, ok, nil
|
||||
}
|
||||
|
||||
func (s states) Keys(state string) ([]string, error) {
|
||||
var keys []string
|
||||
for k := range s[state] {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func planned[P any](plans map[string]P, module string) bool { _, ok := plans[module]; return ok }
|
||||
|
||||
// unusableItem is a manifest the mesh refuses as the node-engine did power's at mesh-catalog ca8d6f33: a bar block item
|
||||
// with attributes its bar places by none of.
|
||||
const unusableItem = `{"module":"backup","version":"1","moved":true,` +
|
||||
`"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},` +
|
||||
`"items":[{"collection":"contribution:node-bar:block",` +
|
||||
`"attributes":{"bar":"bottom","place":"status","shows":"state","state":"power.draw"},"template":"files/bar"}]}`
|
||||
Vendored
-60
@@ -1,60 +0,0 @@
|
||||
{
|
||||
"module": "mesh-provisioner-client",
|
||||
"version": "1",
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-provisioner",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
"own-secrets": {
|
||||
"provisioner-broker": "${dir:state}/broker"
|
||||
},
|
||||
"secrets-owner": "mesh-provisioner-client",
|
||||
"resources": [
|
||||
{
|
||||
"id": "account",
|
||||
"type": "user",
|
||||
"name": "mesh-provisioner-client",
|
||||
"shell": "/usr/bin/nologin",
|
||||
"home": "/var/lib/mesh-provisioner-client"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"owner": "mesh-provisioner-client",
|
||||
"place": ".",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "client",
|
||||
"type": "process",
|
||||
"name": "mesh-provisioner-client",
|
||||
"artifact": "client",
|
||||
"run": [
|
||||
"./mesh-provisioner-client"
|
||||
],
|
||||
"env": {
|
||||
"MESH_PROVISIONER_CREDENTIAL": "${dir:state}/broker",
|
||||
"MESH_PROVISIONER_STATE": "${dir:state}"
|
||||
},
|
||||
"user": "mesh-provisioner-client",
|
||||
"health": {
|
||||
"kind": "unit"
|
||||
}
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "client",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/mesh-provisioner-client",
|
||||
"binary": "mesh-provisioner-client"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
# What the modules on this machine back up, composed by the mesh (novox/hq to-be 43). Do not edit.
|
||||
${backup:lines}
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
# The data the modules on this machine declare, composed by the mesh (novox/hq ADR 0233). Do not edit.
|
||||
${backup:data}
|
||||
-6
@@ -1,6 +0,0 @@
|
||||
# How many restore points the night keeps, written by the mesh from this machine's settings keep-daily,
|
||||
# keep-weekly, keep-monthly (novox/hq issue 416) and keep-hand-taken-days (novox/hq issue 453). Replaced on every push; change the settings, never this file.
|
||||
daily=${config:keep-daily}
|
||||
weekly=${config:keep-weekly}
|
||||
monthly=${config:keep-monthly}
|
||||
hand=${config:keep-hand-taken-days}
|
||||
-199
@@ -1,199 +0,0 @@
|
||||
{
|
||||
"module": "restic",
|
||||
"version": "1",
|
||||
"moved": true,
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-backup",
|
||||
"scope": "node",
|
||||
"serves": [
|
||||
"backed-up",
|
||||
"now",
|
||||
"restore"
|
||||
]
|
||||
}
|
||||
],
|
||||
"emits": [
|
||||
"backed-up"
|
||||
],
|
||||
"tools": [
|
||||
"backup_health",
|
||||
"backup_delete_retired",
|
||||
"backup_deleted"
|
||||
],
|
||||
"own-secrets": {
|
||||
"repository": "${dir:state}/repository.secret"
|
||||
},
|
||||
"data": {
|
||||
"own": [
|
||||
{
|
||||
"id": "repository",
|
||||
"path": "${dir:repository}",
|
||||
"class": "rebuildable",
|
||||
"backup": "none",
|
||||
"why": "the restore points themselves: a copy of data kept elsewhere on this machine, never the only copy of anything; lost, the history goes and nothing live does"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"path": "${dir:state}",
|
||||
"class": "cache",
|
||||
"why": "what the last nights and measurements were; the next night writes it again"
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
"keep-daily": {
|
||||
"type": "number",
|
||||
"description": "How many nightly restore points are kept, a count from 1 to 9999: two weeks undo a mistake noticed within a fortnight. Read by the night, which thins nothing while it does not read (novox/hq issue 416); lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
|
||||
"minimum": 1,
|
||||
"maximum": 9999,
|
||||
"trusted": true,
|
||||
"default": 14
|
||||
},
|
||||
"keep-weekly": {
|
||||
"type": "number",
|
||||
"description": "How many weekly restore points are kept behind the dailies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
|
||||
"minimum": 1,
|
||||
"maximum": 9999,
|
||||
"trusted": true,
|
||||
"default": 8
|
||||
},
|
||||
"keep-monthly": {
|
||||
"type": "number",
|
||||
"description": "How many monthly restore points are kept behind the weeklies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
|
||||
"minimum": 1,
|
||||
"maximum": 9999,
|
||||
"trusted": true,
|
||||
"default": 6
|
||||
},
|
||||
"keep-hand-taken-days": {
|
||||
"type": "number",
|
||||
"description": "How many days a restore point taken by hand (node-backup.now, or the mesh's before a module moves) is kept at least, a count from 1 to 9999; it is kept until a later good night of its module exists, and this many days in any case, so the next thinning never takes the one taken against a risky change (novox/hq issue 453). Lowering it lets go of the older ones taken by hand at the next thinning, and that cannot be undone.",
|
||||
"minimum": 1,
|
||||
"maximum": 9999,
|
||||
"trusted": true,
|
||||
"default": 7
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": ".",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "repository",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "declared",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/backups.conf",
|
||||
"mode": "0600",
|
||||
"template": "files/backups.conf"
|
||||
},
|
||||
{
|
||||
"id": "data-declared",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/data.conf",
|
||||
"mode": "0600",
|
||||
"template": "files/data.conf"
|
||||
},
|
||||
{
|
||||
"id": "retention",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/retention.conf",
|
||||
"mode": "0644",
|
||||
"trusted": true,
|
||||
"template": "files/retention.conf"
|
||||
},
|
||||
{
|
||||
"id": "tool",
|
||||
"type": "package",
|
||||
"package": "restic"
|
||||
},
|
||||
{
|
||||
"id": "sqlite",
|
||||
"type": "package",
|
||||
"package": "sqlite"
|
||||
},
|
||||
{
|
||||
"id": "night",
|
||||
"type": "process",
|
||||
"name": "restic-night",
|
||||
"artifact": "tools",
|
||||
"run": [
|
||||
"./restic-backups",
|
||||
"night"
|
||||
],
|
||||
"user": "${machine:account}",
|
||||
"env": {
|
||||
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
|
||||
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
|
||||
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
|
||||
"MESH_BACKUP_STATE": "${dir:state}",
|
||||
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
|
||||
},
|
||||
"health": {
|
||||
"kind": "tool",
|
||||
"tool": "backup_health",
|
||||
"interval": "60s",
|
||||
"timeout": "20s",
|
||||
"looks": 2,
|
||||
"grace": "60s"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "measure",
|
||||
"type": "process",
|
||||
"name": "restic-measure",
|
||||
"artifact": "tools",
|
||||
"run": [
|
||||
"./restic-backups",
|
||||
"measure"
|
||||
],
|
||||
"user": "${machine:account}",
|
||||
"env": {
|
||||
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
|
||||
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
|
||||
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
|
||||
"MESH_BACKUP_STATE": "${dir:state}",
|
||||
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
|
||||
},
|
||||
"health": {
|
||||
"kind": "unit"
|
||||
}
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/restic-backups",
|
||||
"binary": "restic-backups",
|
||||
"loads": [
|
||||
"restic-backups"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
|
||||
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
|
||||
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
|
||||
"MESH_BACKUP_STATE": "${dir:state}",
|
||||
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,298 +0,0 @@
|
||||
package frombus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// aNode is a bus with n1 the hub, each of manifests placed on it and held at the indexer, in the order given.
|
||||
func aNode(t *testing.T, manifests ...string) (*bus, map[string]string) {
|
||||
t.Helper()
|
||||
p := delivery.Placement{Node: "n1", Decided: delivery.NodeDecided{Hub: true, Address: "10.77.0.1"}}
|
||||
b := &bus{records: map[string]delivery.NodeRecord{"n1": {Node: "n1", TunnelKey: publicKey(t), SealingKey: "sk", Capabilities: []string{"systemd"}}},
|
||||
modules: map[string]delivery.Module{}, manifests: map[string][]byte{}, builds: map[string]*delivery.BuilderBuild{}}
|
||||
fps := map[string]string{}
|
||||
for i, raw := range manifests {
|
||||
module := strings.SplitN(strings.TrimPrefix(raw, `{"module":"`), `"`, 2)[0]
|
||||
p.Modules = append(p.Modules, delivery.PlacedModule{Module: module})
|
||||
b.modules[module] = delivery.Module{Manifest: delivery.ManifestDigest([]byte(raw))}
|
||||
b.manifests[delivery.ManifestDigest([]byte(raw))] = []byte(raw)
|
||||
fps[module] = "src2:" + strings.Repeat(string(rune('1'+i)), 64)
|
||||
}
|
||||
b.placements = map[string]delivery.Placement{"n1": p}
|
||||
return b, fps
|
||||
}
|
||||
|
||||
// Issue 774, 2026-10-30 19:30: a node refused whole after holding a module says what it held and why, since the last
|
||||
// refusal can be of the held module's stub (node-tools held, then "its build produced 0 bundle(s)", its own why unsaid).
|
||||
func aNodeRefusedWholeSaysWhatItHeld(t *testing.T) {
|
||||
refused := `{"module":"power","version":"1","items":[{"collection":"contribution:node-bar:block","attributes":{"shows":"state"},"template":"files/a"}]}`
|
||||
needs := `{"module":"lamp","version":"1","requires":["nothing-provides-this"]}`
|
||||
b, fps := aNode(t, refused, needs)
|
||||
refuse(b, "power", `"shows" is none of them`)
|
||||
_, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1"}, fps)
|
||||
if err == nil || !strings.Contains(err.Error(), "nothing provides") || !strings.Contains(err.Error(), `held before: power: `) ||
|
||||
!strings.Contains(err.Error(), `"shows" is none of them`) {
|
||||
t.Errorf("the node refused whole with %v (held %v), want lamp's refusal and power held before, with its why", err, held)
|
||||
}
|
||||
}
|
||||
|
||||
// refuse writes the indexer's refusal of module's manifest into its modules value (novox/hq ADR 0578 §1).
|
||||
func refuse(b *bus, module string, problems ...string) {
|
||||
m := b.modules[module]
|
||||
m.Verdict, m.Problems = delivery.VerdictRefused, problems
|
||||
b.modules[module] = m
|
||||
}
|
||||
|
||||
// ADR 0578 §3: the bus reader never ends a whole read on a refused manifest. Input, Plans and DataItems compose the rest
|
||||
// and hold the refused module, named with the indexer's words in Refused; a field the decoder does not know is set aside
|
||||
// and named, and its module composed.
|
||||
func aRefusedManifestNeverEndsTheRead(t *testing.T) {
|
||||
ok := `{"module":"lamp","version":"1","resources":[{"id":"d","type":"directory","path":"/srv/lamp","mode":"0700"}],` +
|
||||
`"data":{"own":[{"id":"d","path":"${dir:d}","class":"valuable"}]}}`
|
||||
bad := `{"module":"power","version":"1","resources":[{"id":"d","type":"directory","path":"/srv/power","mode":"0700"}],` +
|
||||
`"data":{"own":[{"id":"d","path":"${dir:d}","class":"valuable"}]}}`
|
||||
later := `{"module":"later","version":"1","a-field-from-later":1}`
|
||||
b, _ := aNode(t, ok, bad, later)
|
||||
refuse(b, "power", "removes a stream it may not")
|
||||
ctx := context.Background()
|
||||
in, err := Input(ctx, b, Mesh{}, Scope{Node: "n1"})
|
||||
if err != nil {
|
||||
t.Fatalf("one refused manifest ended the read: %v", err)
|
||||
}
|
||||
if why := in.Refused["power"]; !strings.Contains(why, "refused the manifest of power: removes a stream it may not") ||
|
||||
len(in.Refused) != 1 {
|
||||
t.Fatalf("refused: %v", in.Refused)
|
||||
}
|
||||
if aside := in.SetAside["later"]; !strings.Contains(aside, "a-field-from-later") {
|
||||
t.Fatalf("set aside: %v", in.SetAside)
|
||||
}
|
||||
if !in.Catalogue["power"].Stubbed || in.Catalogue["lamp"].Stubbed || in.Catalogue["later"].Module != "later" {
|
||||
t.Fatalf("the catalogue: %+v", in.Catalogue)
|
||||
}
|
||||
if _, err := Plans(ctx, b, Mesh{}, Scope{Node: "n1"}); err != nil {
|
||||
t.Fatalf("plans: %v", err)
|
||||
}
|
||||
items, _, held, err := DataItemsHeld(ctx, b, Mesh{}, Scope{Node: "n1"})
|
||||
if err != nil || len(items["lamp"]) != 1 || items["power"] != nil || !strings.Contains(held["power"], "removes a stream") {
|
||||
t.Fatalf("data items %v held %v: %v", items, held, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 774: the culprit rule takes a module whose stub composes or fails otherwise; the runtime only where its stub
|
||||
// composes, since its stub always fails otherwise.
|
||||
func theRuntimeIsBlamedOnlyWhereItsStubComposes(t *testing.T) {
|
||||
err := errors.New("lamp refuses")
|
||||
for _, row := range []struct {
|
||||
module string
|
||||
again error
|
||||
blamed bool
|
||||
}{
|
||||
{"lamp", nil, true},
|
||||
{"lamp", errors.New("other"), true},
|
||||
{"lamp", errors.New("lamp refuses"), false},
|
||||
{catalogue.RuntimeModule, nil, true},
|
||||
{catalogue.RuntimeModule, errors.New("node-tools is assigned to n1 and its build produced 0 bundle(s)"), false},
|
||||
{catalogue.RuntimeModule, fmt.Errorf("compose: %w", &catalogue.NoRuntimeToRunError{Held: true}), true},
|
||||
{catalogue.RuntimeModule, &catalogue.NoRuntimeToRunError{}, false},
|
||||
} {
|
||||
if got := blames(row.module, row.again, err); got != row.blamed {
|
||||
t.Errorf("%s with %v: blamed %v, want %v", row.module, row.again, got, row.blamed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 774, 2026-10-30 19:30: the trunk's node-tools moved to a build not yet done, and ace, g14 and shanks composed
|
||||
// it from the indexer's manifest, which names no bundle: "its build produced 0 bundle(s)", every module refused. A
|
||||
// module that runs is now held until its build lands, the runtime composed as what runs of it; and a module wanted at
|
||||
// another build than the trunk's is composed from the build its want names.
|
||||
func aRunningModuleWaitsForItsBuild(t *testing.T) {
|
||||
dg := func(c string) string { return "sha256:" + strings.Repeat(c, 64) }
|
||||
tools := `{"module":"node-tools","version":"1","own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` +
|
||||
`"build":{"artifacts":[{"name":"runtime","kind":"bundle","language":"go","system":"arch","from":"cmd/node-tools"}]}}`
|
||||
lamp := `{"module":"lamp","version":"1","resources":[{"id":"service","type":"service","unit":"lamp.service","state":"running","boot":"enabled"}]}`
|
||||
store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}},` +
|
||||
`"receives":{"artifact-store":"/var/lib/artifacts/received.json"}}`
|
||||
old, next := "src2:"+strings.Repeat("a", 64), "src2:"+strings.Repeat("b", 64)
|
||||
running := []delivery.Composed{{Node: "n1", Module: catalogue.RuntimeModule, FP: old, Resources: []json.RawMessage{
|
||||
json.RawMessage(`{"id":"node-tools.runtime","module":"node-tools","type":"process","name":"node-tools","env":{"MESH_TOOL_MODULES":""}}`),
|
||||
json.RawMessage(`{"id":"node-tools.bundle-runtime","module":"node-tools","type":"archive","source":"artifact-store://node-tools/runtime/blobs/` +
|
||||
dg("1") + `","digest":"` + dg("1") + `","path":"` + catalogue.BundlePath("node-tools", "runtime") + `"}`)}}}
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
want string // the build node-tools' want names
|
||||
done bool // the trunk's build is done
|
||||
held string // words node-tools' hold says; empty: composed
|
||||
digest string // the runtime bundle's digest composed
|
||||
}{
|
||||
{name: "the trunk's build not done yet", want: next, held: "no done build yet"},
|
||||
{name: "wanted at the build that runs, the trunk's not done", want: old, digest: dg("1")},
|
||||
{name: "the trunk's build done", want: next, done: true, digest: dg("2")},
|
||||
} {
|
||||
b, fps := aNode(t, store, tools, lamp)
|
||||
b.held = true // every grant held, so a contribution's credential stands in for nothing
|
||||
b.own = []compose.OwnSecretRow{{Node: "n1", Module: "node-tools", Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}
|
||||
mod := b.modules["node-tools"]
|
||||
mod.FP2 = next
|
||||
b.modules["node-tools"] = mod
|
||||
b.builds["node-tools "+old] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
|
||||
Artifacts: []delivery.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("1"), Digest: dg("1")}}}
|
||||
b.builds["node-tools "+next] = &delivery.BuilderBuild{State: "building", Manifest: mod.Manifest}
|
||||
if row.done {
|
||||
b.builds["node-tools "+next] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
|
||||
Artifacts: []delivery.BuiltArtifact{{Name: "runtime", Kind: "bundle", Reference: "artifact-store://node-tools/runtime/blobs/" + dg("2"), Digest: dg("2")}}}
|
||||
}
|
||||
fps["node-tools"] = row.want
|
||||
out, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1", Running: running}, fps)
|
||||
if err != nil {
|
||||
t.Errorf("%s: the node is refused: %v", row.name, err)
|
||||
continue
|
||||
}
|
||||
if lampKey, _ := delivery.ComposedKey("n1", "lamp", fps["lamp"]); out[lampKey].Module != "lamp" {
|
||||
t.Errorf("%s: lamp is not composed (held %v)", row.name, held)
|
||||
}
|
||||
if why := held["node-tools"]; (row.held == "") != (why == "") || !strings.Contains(why, row.held) {
|
||||
t.Errorf("%s: node-tools held %q, want %q", row.name, why, row.held)
|
||||
}
|
||||
if row.digest != "" {
|
||||
key, _ := delivery.ComposedKey("n1", "node-tools", row.want)
|
||||
if c := out[key]; !strings.Contains(fmt.Sprint(rawStrings(c.Resources)), row.digest) {
|
||||
t.Errorf("%s: node-tools is composed as %v, want its bundle %s", row.name, rawStrings(c.Resources), row.digest)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func rawStrings(rs []json.RawMessage) []string {
|
||||
var out []string
|
||||
for _, r := range rs {
|
||||
out = append(out, string(r))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Issue 774: a refusal of a held module's stand-in leads with why the module was held, its first reason, as mesh-self-check's
|
||||
// on ace should have; a refusal naming no held module is as it was.
|
||||
func aHeldModulesFirstReasonLeads(t *testing.T) {
|
||||
held := map[string]string{"mesh-self-check": "mesh-self-check on ace has no bus credential: nothing was issued for ace.mesh-self-check"}
|
||||
for _, row := range []struct {
|
||||
err, want string
|
||||
}{
|
||||
{"mesh-self-check runs as its own account and declares no own secret broker",
|
||||
"mesh-self-check is held: mesh-self-check on ace has no bus credential: nothing was issued for ace.mesh-self-check; " +
|
||||
"its stand-in is then refused: mesh-self-check runs as its own account and declares no own secret broker"},
|
||||
{"ace is the hub and has no endpoint", "ace is the hub and has no endpoint"},
|
||||
} {
|
||||
err := errors.New(row.err)
|
||||
got := firstReason(err, held)
|
||||
if got.Error() != row.want || !errors.Is(got, err) {
|
||||
t.Errorf("refused %q, want %q", got, row.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 774, 2026-10-30 21:04: mesh-conditions and mesh-healers on novox and mesh-self-check on ace were placed before their
|
||||
// bus accounts were issued. Held for "needs a secret called broker and none was made", each one's bare stub was then
|
||||
// refused as "runs as its own account and declares no own secret broker", and that refusal failed every module of the
|
||||
// node (79 on novox, 74 on ace). A held module's stand-in is never refused by a rule about its own runtime or its own
|
||||
// secrets: the node composes every other module, and the held one keeps the words it was first held for.
|
||||
func aHeldModuleOfItsOwnAccountNeverRefusesTheNode(t *testing.T) {
|
||||
dg := "sha256:" + strings.Repeat("1", 64)
|
||||
tools := `{"module":"node-tools","version":"1","own-secrets":{"broker":"/var/lib/mesh/node-tools/broker"},` +
|
||||
`"build":{"artifacts":[{"name":"runtime","kind":"bundle","language":"go","system":"arch","from":"cmd/node-tools"}]}}`
|
||||
own := func(name string) string {
|
||||
return `{"module":"` + name + `","version":"1","runs-as":"` + name + `","secrets-owner":"` + name + `","tools":["status"],` +
|
||||
`"own-secrets":{"broker":"/var/lib/` + name + `/broker"},"contributes":{"artifact-store":{"name":"` + name + `"}},` +
|
||||
`"resources":[{"id":"account","type":"user","name":"` + name + `"}],` +
|
||||
`"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["tools/index.js"]}]}}`
|
||||
}
|
||||
lamp := `{"module":"lamp","version":"1","resources":[{"id":"service","type":"service","unit":"lamp.service","state":"running","boot":"enabled"}]}`
|
||||
store := `{"module":"artifacts","version":"1","provides":[{"name":"artifact-store","scope":"mesh"}],"serves":{"artifact-store":{"port":5100}},` +
|
||||
`"receives":{"artifact-store":"/var/lib/artifacts/received.json"}}`
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
issued []string // the own-account modules whose broker is issued
|
||||
building bool // node-tools' build is not done, and nothing of it runs
|
||||
held map[string]string // the modules held, each with words of the reason it was first held for
|
||||
// ungranted gives the provider a grants file and no consumer's grant is issued yet (a newly placed module's
|
||||
// usual state): a held module's contribution then stands in for its grant, and is left out (review round 1).
|
||||
ungranted bool
|
||||
}{
|
||||
{name: "none issued", held: map[string]string{"check": "broker", "heal": "broker"}},
|
||||
{name: "none issued, no grant issued", ungranted: true, held: map[string]string{"check": "broker", "heal": "broker"}},
|
||||
{name: "one issued", issued: []string{"heal"}, held: map[string]string{"check": "broker"}},
|
||||
{name: "both issued", issued: []string{"check", "heal"}},
|
||||
// The runtime held: its stand-in is not refused for its bundle; the modules it would run are held, not the node.
|
||||
{name: "the runtime's build not done", issued: []string{"check", "heal"}, building: true,
|
||||
held: map[string]string{"node-tools": "its build produced 0 bundle(s)", "check": "not here to run it from", "heal": "not here to run it from"}},
|
||||
} {
|
||||
provider := store
|
||||
if row.ungranted {
|
||||
provider = strings.Replace(store, `"receives":`, `"grants":{"artifact-store":"/var/lib/artifacts/grants"},"receives":`, 1)
|
||||
}
|
||||
b, fps := aNode(t, provider, tools, own("check"), lamp, own("heal"))
|
||||
// Every grant held, so the held modules' contributions are composed whole; the row ungranted issues none.
|
||||
b.held = !row.ungranted
|
||||
b.own = []compose.OwnSecretRow{{Node: "n1", Module: "node-tools", Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`}}
|
||||
for _, m := range row.issued {
|
||||
b.own = append(b.own, compose.OwnSecretRow{Node: "n1", Module: m, Name: "broker", Origin: "made", SealedTo: "sk", Sealed: `"box"`})
|
||||
}
|
||||
for _, m := range []string{"node-tools", "check", "heal"} {
|
||||
name := "tools"
|
||||
if m == "node-tools" {
|
||||
name = "runtime"
|
||||
}
|
||||
mod := b.modules[m]
|
||||
mod.FP2 = fps[m]
|
||||
b.modules[m] = mod
|
||||
b.builds[m+" "+fps[m]] = &delivery.BuilderBuild{State: delivery.BuildDone, Manifest: mod.Manifest,
|
||||
Artifacts: []delivery.BuiltArtifact{{Name: name, Kind: "bundle", Reference: "artifact-store://" + m + "/" + name + "/blobs/" + dg, Digest: dg}}}
|
||||
if m == "node-tools" && row.building {
|
||||
b.builds[m+" "+fps[m]] = &delivery.BuilderBuild{State: "building", Manifest: mod.Manifest}
|
||||
}
|
||||
}
|
||||
out, _, held, err := Compose(context.Background(), b, Mesh{}, Scope{Node: "n1"}, fps)
|
||||
if err != nil {
|
||||
t.Errorf("%s: the node is refused: %v", row.name, err)
|
||||
continue
|
||||
}
|
||||
// A held module keeps what it contributes (issue 774, 2026-10-31: nextcloud held for its admin secret dropped out
|
||||
// of postgres's received file, and postgres's helper would have locked its login): only its own secrets are left
|
||||
// out of its stand-in, never its contributions, so the provider's received file names every consumer.
|
||||
storeKey, _ := delivery.ComposedKey("n1", "artifacts", fps["artifacts"])
|
||||
// And never a stand-in for a grant not yet issued: the provider is applied, so a held module whose grant is
|
||||
// not issued is composed without that contribution, never with "foreseen: made by the next send".
|
||||
for key, c := range out {
|
||||
if got, _ := json.Marshal(c.Resources); strings.Contains(string(got), "foreseen") {
|
||||
t.Errorf("%s: %s applies a stand-in: %s", row.name, key, got)
|
||||
}
|
||||
}
|
||||
for _, m := range []string{"check", "heal"} {
|
||||
if row.ungranted {
|
||||
break
|
||||
}
|
||||
if got, _ := json.Marshal(out[storeKey].Resources); !strings.Contains(string(got), `\"from\": \"`+m+`\"`) {
|
||||
t.Errorf("%s: the provider's received file drops %s's contribution: %s", row.name, m, got)
|
||||
}
|
||||
}
|
||||
for _, m := range []string{"node-tools", "check", "lamp", "heal"} {
|
||||
key, _ := delivery.ComposedKey("n1", m, fps[m])
|
||||
_, composed := out[key]
|
||||
why, isHeld := held[m]
|
||||
want, wantHeld := row.held[m]
|
||||
if isHeld != wantHeld || composed == wantHeld {
|
||||
t.Errorf("%s: %s held %v (%q), composed %v; want held %v", row.name, m, isHeld, why, composed, wantHeld)
|
||||
} else if isHeld && !strings.Contains(why, want) {
|
||||
t.Errorf("%s: %s is held for %q, want its first reason, %q", row.name, m, why, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,204 +0,0 @@
|
||||
package frombus
|
||||
|
||||
// **The bus read through a module's declared `reads`** (the lead, 2026-10-29: one reader in place of the copies in
|
||||
// mesh-provisioner and disk-load): a Reader over the states a module reads, by a runtime's Get and Keys (a bundle's
|
||||
// stdio.State, a process's own), for a holder that composes plans or data items and reads no value: placement, each
|
||||
// node's record in its own have key, the indexer's modules and manifests, the builder's builds and mesh-config's resolved
|
||||
// values. No own secret and no credential is read (compose.Planning). Pure as the rest of this package: a bundle's
|
||||
// runtime states and an HTTP fetch are compose/fromruntime's.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// The states a module names in its manifest's `reads` to compose through StatesReader.
|
||||
const (
|
||||
PlacementRead = delivery.PlacementRead
|
||||
ModulesRead = delivery.IndexerModule + ".modules"
|
||||
ManifestsRead = delivery.IndexerModule + ".manifests"
|
||||
BuildsRead = delivery.BuilderModule + ".builds"
|
||||
HaveRead = "node-engine.have"
|
||||
ResolvedRead = "mesh-config.resolved"
|
||||
)
|
||||
|
||||
// StatesReads are those reads, in a manifest's order.
|
||||
var StatesReads = []string{PlacementRead, ModulesRead, ManifestsRead, BuildsRead, HaveRead, ResolvedRead}
|
||||
|
||||
// States is a module's read states as its runtime gives them: a key's value, false when it has none.
|
||||
type States interface {
|
||||
Get(state, key string) (value []byte, found bool, err error)
|
||||
Keys(state string) ([]string, error)
|
||||
}
|
||||
|
||||
// Fetch reads an archive at a URL (fromruntime.HTTPFetch over HTTP).
|
||||
type Fetch func(ctx context.Context, url string) ([]byte, error)
|
||||
|
||||
// StatesReader is the Reader over s; fetch reads a templates archive, nil for a holder that reads none.
|
||||
// It keeps what each build's take-in made, by the build's content (issue 1954), so a holder composing again and again
|
||||
// takes in only a build it has not seen.
|
||||
func StatesReader(s States, fetch Fetch) Reader {
|
||||
return statesReader{s: s, fetch: fetch, kept: &keptTakeIns{}}
|
||||
}
|
||||
|
||||
type statesReader struct {
|
||||
s States
|
||||
fetch Fetch
|
||||
kept *keptTakeIns
|
||||
}
|
||||
|
||||
func (r statesReader) takenIn(key string) ([]byte, bool) { return r.kept.takenIn(key) }
|
||||
|
||||
func (r statesReader) keepTakenIn(key string, raw []byte) { r.kept.keepTakenIn(key, raw) }
|
||||
|
||||
func (r statesReader) each(read string, visit func(key string, value []byte)) error {
|
||||
keys, err := r.s.Keys(read)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", read, err)
|
||||
}
|
||||
for _, k := range keys {
|
||||
v, found, err := r.s.Get(read, k)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s %s: %w", read, k, err)
|
||||
}
|
||||
if found {
|
||||
visit(k, v)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r statesReader) Placements(context.Context) (map[string]delivery.Placement, error) {
|
||||
keys := delivery.NewPlacementKeys()
|
||||
err := r.each(PlacementRead, func(k string, v []byte) {
|
||||
_, _ = keys.Put(k, v, 0, false) // a key that does not read places nothing, as before
|
||||
})
|
||||
return keys.All(), err
|
||||
}
|
||||
|
||||
func (r statesReader) Records(context.Context) (map[string]delivery.NodeRecord, error) {
|
||||
out := map[string]delivery.NodeRecord{}
|
||||
err := r.each(HaveRead, func(k string, v []byte) {
|
||||
if node, _, isNode, err := delivery.ParseHaveKey(k); err == nil && isNode {
|
||||
if h, err := delivery.DecodeHaveNode(v); err == nil && h.Record != nil {
|
||||
out[node] = *h.Record
|
||||
}
|
||||
}
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (r statesReader) Modules(context.Context) (map[string]delivery.Module, error) {
|
||||
out := map[string]delivery.Module{}
|
||||
err := r.each(ModulesRead, func(k string, v []byte) {
|
||||
if module, err := delivery.ParseModulesKey(k); err == nil {
|
||||
if m, err := delivery.DecodeModule(v); err == nil {
|
||||
out[module] = m
|
||||
}
|
||||
}
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (r statesReader) Manifest(_ context.Context, digest string) ([]byte, error) {
|
||||
key, err := delivery.ManifestsKey(digest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v, found, err := r.s.Get(ManifestsRead, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !found {
|
||||
return nil, fmt.Errorf("the indexer holds no manifest %s", digest)
|
||||
}
|
||||
m, err := delivery.DecodeManifest(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []byte(m.JSON), nil
|
||||
}
|
||||
|
||||
func (r statesReader) Values(_ context.Context, node, module string) (map[string]any, error) {
|
||||
var out map[string]any
|
||||
prefix := node + "." + module + "."
|
||||
err := r.each(ResolvedRead, func(k string, v []byte) {
|
||||
name, ok := strings.CutPrefix(k, prefix)
|
||||
if !ok || strings.Contains(name, ".") {
|
||||
return
|
||||
}
|
||||
if value, laid := catalogue.LaidValue(v); laid {
|
||||
if out == nil {
|
||||
out = map[string]any{}
|
||||
}
|
||||
out[name] = value
|
||||
}
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (statesReader) OwnSecrets(context.Context, string) ([]compose.OwnSecretRow, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (statesReader) Credentials(context.Context, string, []compose.CredentialKey, []compose.CredentialKey) ([]compose.Credential, []compose.Issued, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
func (r statesReader) Build(_ context.Context, module, fp string) (*delivery.BuilderBuild, error) {
|
||||
key, err := delivery.BuilderBuildKey(module, fp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
v, found, err := r.s.Get(BuildsRead, key)
|
||||
if err != nil || !found {
|
||||
return nil, err
|
||||
}
|
||||
b, err := delivery.DecodeBuilderBuild(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// archiveWithin bounds one read of a templates archive.
|
||||
const archiveWithin = 30 * time.Second
|
||||
|
||||
func (r statesReader) Archive(ctx context.Context, url, digest string) ([]byte, error) {
|
||||
if r.fetch == nil {
|
||||
return nil, fmt.Errorf("this holder reads no archive (%s)", url)
|
||||
}
|
||||
fetching, cancel := context.WithTimeout(ctx, archiveWithin)
|
||||
defer cancel()
|
||||
body, err := r.fetch(fetching, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sum := sha256.Sum256(body)
|
||||
if got := hex.EncodeToString(sum[:]); got != strings.TrimPrefix(digest, "sha256:") {
|
||||
return nil, fmt.Errorf("%s was recorded as %s and what arrived is %s", url, digest, got)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (r statesReader) Served(ctx context.Context, node, module string, keys []string) (map[string]any, error) {
|
||||
all, err := r.Values(ctx, node, module)
|
||||
var out map[string]any
|
||||
for _, k := range keys {
|
||||
if v, ok := all[k]; ok {
|
||||
if out == nil {
|
||||
out = map[string]any{}
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
{
|
||||
"module": "mesh-provisioner-client",
|
||||
"version": "1",
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-provisioner",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
"own-secrets": {
|
||||
"provisioner-broker": "${dir:state}/broker"
|
||||
},
|
||||
"secrets-owner": "mesh-provisioner-client",
|
||||
"resources": [
|
||||
{
|
||||
"id": "account",
|
||||
"type": "user",
|
||||
"name": "mesh-provisioner-client",
|
||||
"shell": "/usr/bin/nologin",
|
||||
"home": "/var/lib/mesh-provisioner-client"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"owner": "mesh-provisioner-client",
|
||||
"place": ".",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "client",
|
||||
"type": "process",
|
||||
"name": "mesh-provisioner-client",
|
||||
"artifact": "client",
|
||||
"run": [
|
||||
"./mesh-provisioner-client"
|
||||
],
|
||||
"env": {
|
||||
"MESH_PROVISIONER_CREDENTIAL": "${dir:state}/broker",
|
||||
"MESH_PROVISIONER_STATE": "${dir:state}"
|
||||
},
|
||||
"user": "mesh-provisioner-client",
|
||||
"health": {
|
||||
"kind": "unit"
|
||||
}
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "client",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/mesh-provisioner-client",
|
||||
"binary": "mesh-provisioner-client"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
# What the modules on this machine back up, composed by the mesh (novox/hq to-be 43). Do not edit.
|
||||
${backup:lines}
|
||||
@@ -1,2 +0,0 @@
|
||||
# The data the modules on this machine declare, composed by the mesh (novox/hq ADR 0233). Do not edit.
|
||||
${backup:data}
|
||||
@@ -1,6 +0,0 @@
|
||||
# How many restore points the night keeps, written by the mesh from this machine's settings keep-daily,
|
||||
# keep-weekly, keep-monthly (novox/hq issue 416) and keep-hand-taken-days (novox/hq issue 453). Replaced on every push; change the settings, never this file.
|
||||
daily=${config:keep-daily}
|
||||
weekly=${config:keep-weekly}
|
||||
monthly=${config:keep-monthly}
|
||||
hand=${config:keep-hand-taken-days}
|
||||
@@ -1,199 +0,0 @@
|
||||
{
|
||||
"module": "restic",
|
||||
"version": "1",
|
||||
"moved": true,
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-backup",
|
||||
"scope": "node",
|
||||
"serves": [
|
||||
"backed-up",
|
||||
"now",
|
||||
"restore"
|
||||
]
|
||||
}
|
||||
],
|
||||
"emits": [
|
||||
"backed-up"
|
||||
],
|
||||
"tools": [
|
||||
"backup_health",
|
||||
"backup_delete_retired",
|
||||
"backup_deleted"
|
||||
],
|
||||
"own-secrets": {
|
||||
"repository": "${dir:state}/repository.secret"
|
||||
},
|
||||
"data": {
|
||||
"own": [
|
||||
{
|
||||
"id": "repository",
|
||||
"path": "${dir:repository}",
|
||||
"class": "rebuildable",
|
||||
"backup": "none",
|
||||
"why": "the restore points themselves: a copy of data kept elsewhere on this machine, never the only copy of anything; lost, the history goes and nothing live does"
|
||||
},
|
||||
{
|
||||
"id": "state",
|
||||
"path": "${dir:state}",
|
||||
"class": "cache",
|
||||
"why": "what the last nights and measurements were; the next night writes it again"
|
||||
}
|
||||
]
|
||||
},
|
||||
"config": {
|
||||
"keep-daily": {
|
||||
"type": "number",
|
||||
"description": "How many nightly restore points are kept, a count from 1 to 9999: two weeks undo a mistake noticed within a fortnight. Read by the night, which thins nothing while it does not read (novox/hq issue 416); lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
|
||||
"minimum": 1,
|
||||
"maximum": 9999,
|
||||
"trusted": true,
|
||||
"default": 14
|
||||
},
|
||||
"keep-weekly": {
|
||||
"type": "number",
|
||||
"description": "How many weekly restore points are kept behind the dailies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
|
||||
"minimum": 1,
|
||||
"maximum": 9999,
|
||||
"trusted": true,
|
||||
"default": 8
|
||||
},
|
||||
"keep-monthly": {
|
||||
"type": "number",
|
||||
"description": "How many monthly restore points are kept behind the weeklies, a count from 1 to 9999, refused by the night like keep-daily; lowering it deletes the restore points beyond the new count at the next night, and that cannot be undone.",
|
||||
"minimum": 1,
|
||||
"maximum": 9999,
|
||||
"trusted": true,
|
||||
"default": 6
|
||||
},
|
||||
"keep-hand-taken-days": {
|
||||
"type": "number",
|
||||
"description": "How many days a restore point taken by hand (node-backup.now, or the mesh's before a module moves) is kept at least, a count from 1 to 9999; it is kept until a later good night of its module exists, and this many days in any case, so the next thinning never takes the one taken against a risky change (novox/hq issue 453). Lowering it lets go of the older ones taken by hand at the next thinning, and that cannot be undone.",
|
||||
"minimum": 1,
|
||||
"maximum": 9999,
|
||||
"trusted": true,
|
||||
"default": 7
|
||||
}
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": ".",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "repository",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "declared",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/backups.conf",
|
||||
"mode": "0600",
|
||||
"template": "files/backups.conf"
|
||||
},
|
||||
{
|
||||
"id": "data-declared",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/data.conf",
|
||||
"mode": "0600",
|
||||
"template": "files/data.conf"
|
||||
},
|
||||
{
|
||||
"id": "retention",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/retention.conf",
|
||||
"mode": "0644",
|
||||
"trusted": true,
|
||||
"template": "files/retention.conf"
|
||||
},
|
||||
{
|
||||
"id": "tool",
|
||||
"type": "package",
|
||||
"package": "restic"
|
||||
},
|
||||
{
|
||||
"id": "sqlite",
|
||||
"type": "package",
|
||||
"package": "sqlite"
|
||||
},
|
||||
{
|
||||
"id": "night",
|
||||
"type": "process",
|
||||
"name": "restic-night",
|
||||
"artifact": "tools",
|
||||
"run": [
|
||||
"./restic-backups",
|
||||
"night"
|
||||
],
|
||||
"user": "${machine:account}",
|
||||
"env": {
|
||||
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
|
||||
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
|
||||
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
|
||||
"MESH_BACKUP_STATE": "${dir:state}",
|
||||
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
|
||||
},
|
||||
"health": {
|
||||
"kind": "tool",
|
||||
"tool": "backup_health",
|
||||
"interval": "60s",
|
||||
"timeout": "20s",
|
||||
"looks": 2,
|
||||
"grace": "60s"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "measure",
|
||||
"type": "process",
|
||||
"name": "restic-measure",
|
||||
"artifact": "tools",
|
||||
"run": [
|
||||
"./restic-backups",
|
||||
"measure"
|
||||
],
|
||||
"user": "${machine:account}",
|
||||
"env": {
|
||||
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
|
||||
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
|
||||
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
|
||||
"MESH_BACKUP_STATE": "${dir:state}",
|
||||
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
|
||||
},
|
||||
"health": {
|
||||
"kind": "unit"
|
||||
}
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/restic-backups",
|
||||
"binary": "restic-backups",
|
||||
"loads": [
|
||||
"restic-backups"
|
||||
],
|
||||
"env": {
|
||||
"MESH_BACKUP_DECLARED": "${dir:state}/backups.conf",
|
||||
"MESH_BACKUP_REPOSITORY": "${dir:repository}",
|
||||
"MESH_BACKUP_PASSWORD_FILE": "${dir:state}/repository.secret",
|
||||
"MESH_BACKUP_STATE": "${dir:state}",
|
||||
"MESH_BACKUP_DATA": "${dir:state}/data.conf"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "files",
|
||||
"kind": "archive",
|
||||
"from": "files"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
{
|
||||
"module": "de-spiegel",
|
||||
"version": "1",
|
||||
"slug": "spiegel",
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"requires": [
|
||||
"route",
|
||||
"smtp"
|
||||
],
|
||||
"contributes": {
|
||||
"route": {
|
||||
"site": {
|
||||
"label": "de-spiegel",
|
||||
"port": 35621
|
||||
},
|
||||
"praktijk": {
|
||||
"name": "${config:praktijk-name}",
|
||||
"port": 35621
|
||||
},
|
||||
"praktijk-www": {
|
||||
"name": "${config:praktijk-www-name}",
|
||||
"redirect": "https://${config:praktijk-name}"
|
||||
},
|
||||
"psychologie": {
|
||||
"name": "${config:psychologie-name}",
|
||||
"port": 35621
|
||||
},
|
||||
"psychologie-www": {
|
||||
"name": "${config:psychologie-www-name}",
|
||||
"redirect": "https://${config:psychologie-name}"
|
||||
}
|
||||
},
|
||||
"smtp": {
|
||||
"account": "de-spiegel"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"route": "/var/lib/de-spiegel/route.json",
|
||||
"smtp": "/var/lib/de-spiegel/smtp.json"
|
||||
},
|
||||
"config": {
|
||||
"praktijk-name": {
|
||||
"type": "string",
|
||||
"description": "The public name the practice's site is served on; the operator's, with no default (novox/hq ADR 0112).",
|
||||
"trusted": true,
|
||||
"required": true
|
||||
},
|
||||
"praktijk-www-name": {
|
||||
"type": "string",
|
||||
"description": "The public name that redirects to the practice's site (its www form); the operator's, with no default (novox/hq ADR 0112).",
|
||||
"trusted": true,
|
||||
"required": true
|
||||
},
|
||||
"psychologie-name": {
|
||||
"type": "string",
|
||||
"description": "The public name the psychology practice's site is served on; the operator's, with no default (novox/hq ADR 0112).",
|
||||
"trusted": true,
|
||||
"required": true
|
||||
},
|
||||
"psychologie-www-name": {
|
||||
"type": "string",
|
||||
"description": "The public name that redirects to the psychology practice's site (its www form); the operator's, with no default (novox/hq ADR 0112).",
|
||||
"trusted": true,
|
||||
"required": true
|
||||
}
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"port": 35621,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the de-spiegel site and its /contact endpoint over http; the practice's three public names are route grants, and the proxy reaches every one of them here"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"path": "/var/lib/de-spiegel",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "server-env",
|
||||
"type": "file",
|
||||
"path": "/var/lib/de-spiegel/server.env",
|
||||
"mode": "0600",
|
||||
"content": "SMTP_HOST=${bound:smtp:at}\nSMTP_PORT=${bound:smtp:port}\nSMTP_AUTH_USER=de-spiegel@${bound:smtp:domain}\nSMTP_AUTH_PASS=${secret:smtp}\n"
|
||||
},
|
||||
{
|
||||
"id": "net",
|
||||
"type": "network",
|
||||
"name": "de-spiegel"
|
||||
},
|
||||
{
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "de-spiegel",
|
||||
"artifact": "site",
|
||||
"network": "de-spiegel",
|
||||
"env-file": [
|
||||
"/var/lib/de-spiegel/server.env"
|
||||
],
|
||||
"ports": [
|
||||
"35621"
|
||||
],
|
||||
"secrets-in-environment": "the application's own code reads SMTP_AUTH_USER/PASS from the environment (server/index.js); converting is this repository's change"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"on": [
|
||||
{
|
||||
"arg": "NODE_BASE",
|
||||
"image": "node@sha256:48e4b67d85f87bd551df43704e24d252f56cc5f8e9718841aace50f19948f0f9"
|
||||
}
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "site",
|
||||
"kind": "image",
|
||||
"from": "Dockerfile"
|
||||
}
|
||||
]
|
||||
},
|
||||
"secrets": {
|
||||
"smtp": "/var/lib/de-spiegel/smtp.secret"
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
{
|
||||
"module": "f1-circuits",
|
||||
"slug": "f1",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"requires": [
|
||||
"route"
|
||||
],
|
||||
"contributes": {
|
||||
"route": {
|
||||
"label": "f1-circuits",
|
||||
"endpoint": "web"
|
||||
}
|
||||
},
|
||||
"binds": {
|
||||
"route": "${dir:state}/route.json"
|
||||
},
|
||||
"listens": [
|
||||
{
|
||||
"name": "web",
|
||||
"port": 8000,
|
||||
"protocol": "tcp",
|
||||
"from": "mesh",
|
||||
"why": "the track-layout CDN, the race posters and the Kometa metadata over http; its public name is a route grant and the proxy reaches it here. Kometa fetches the metadata and every poster by that public name"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "state",
|
||||
"type": "directory",
|
||||
"mode": "0700",
|
||||
"place": "."
|
||||
},
|
||||
{
|
||||
"id": "cache",
|
||||
"type": "directory",
|
||||
"mode": "0700"
|
||||
},
|
||||
{
|
||||
"id": "site-env",
|
||||
"type": "file",
|
||||
"path": "${dir:state}/site.env",
|
||||
"mode": "0644",
|
||||
"content": "F1_CIRCUITS_BASE_URL=https://${bound:route:name}\n"
|
||||
},
|
||||
{
|
||||
"id": "server",
|
||||
"type": "container",
|
||||
"name": "f1-circuits",
|
||||
"artifact": "cdn",
|
||||
"ports": [
|
||||
"8000"
|
||||
],
|
||||
"env-file": [
|
||||
"${dir:state}/site.env"
|
||||
],
|
||||
"volumes": [
|
||||
"${dir:cache}:/app/cache"
|
||||
],
|
||||
"restart-on": [
|
||||
"site-env"
|
||||
]
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"on": [
|
||||
{
|
||||
"arg": "PYTHON_BASE",
|
||||
"image": "python@sha256:6d43704baacd1bfbe7c295d7f13079d5d8104ed33568873133f8fc69980419df"
|
||||
}
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "cdn",
|
||||
"kind": "image",
|
||||
"from": "service/Dockerfile"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
package frombus
|
||||
|
||||
// **A node's plans at two sets of builds, over one read** (issue 1954; novox/hq ADR 0570 clause 2, ADR 0580 clause 2):
|
||||
// the seat mesh-provisioner's holder composes each node at the builds its wants name and at the newest build of each
|
||||
// placed module. Both are composed from one read of the bus (placement, records, the indexer's modules and manifests,
|
||||
// values: base); only the builds the node's modules are composed from differ, each build read once for both, and a
|
||||
// build taken in once per content by a reader that keeps it (StatesReader). Where the time went is said by phase.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// HeldPlans is one scope's plans, as PlansHeld answers them: err a refusal that is no single module's.
|
||||
type HeldPlans struct {
|
||||
Plans map[string]catalogue.Plan
|
||||
Held map[string]string
|
||||
Err error
|
||||
}
|
||||
|
||||
// Phase is one step of a composition and how long it took.
|
||||
type Phase struct {
|
||||
Name string `json:"phase"`
|
||||
Took time.Duration `json:"took"`
|
||||
}
|
||||
|
||||
// PlansHeldTwice is PlansHeld at s and at s with no kept builds (each placed module at its newest build), over one
|
||||
// read of the bus: what each answers is what PlansHeld answers for that scope. A read that fails is both scopes' err.
|
||||
// took names each phase in order: the read, then each scope's builds and its plans.
|
||||
func PlansHeldTwice(ctx context.Context, r Reader, mesh Mesh, s Scope) (at, newest HeldPlans, took []Phase) {
|
||||
start := time.Now()
|
||||
mark := func(name string) {
|
||||
now := time.Now()
|
||||
took = append(took, Phase{Name: name, Took: now.Sub(start)})
|
||||
start = now
|
||||
}
|
||||
b, err := readBase(ctx, r, mesh, s)
|
||||
mark("read")
|
||||
builds := onceBuilds(r)
|
||||
newestScope := s
|
||||
newestScope.KeptAt = nil
|
||||
for _, sc := range []struct {
|
||||
scope Scope
|
||||
into *HeldPlans
|
||||
name string
|
||||
}{{s, &at, "wanted"}, {newestScope, &newest, "newest"}} {
|
||||
held := map[string]string{}
|
||||
sc.into.Held = held
|
||||
refuse := func(module string, err error) { held[module] = err.Error() }
|
||||
b.refuseOwn(refuse)
|
||||
if err != nil {
|
||||
sc.into.Err = err
|
||||
continue
|
||||
}
|
||||
in, err := b.finish(ctx, r, builds, mesh, sc.scope, b.cloned(), refuse, nil)
|
||||
mark("builds at " + sc.name)
|
||||
if err != nil {
|
||||
sc.into.Err = err
|
||||
continue
|
||||
}
|
||||
sc.into.Plans, sc.into.Err = plansHeldOf(in, s.Node, held)
|
||||
mark("plans at " + sc.name)
|
||||
}
|
||||
return at, newest, took
|
||||
}
|
||||
|
||||
// onceBuilds is r's Build, each module's build at a fingerprint read once: both scopes of one composition read the
|
||||
// builds of the same moment.
|
||||
func onceBuilds(r Reader) func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) {
|
||||
type answer struct {
|
||||
b *delivery.BuilderBuild
|
||||
err error
|
||||
}
|
||||
read := map[string]answer{}
|
||||
return func(ctx context.Context, module, fp string) (*delivery.BuilderBuild, error) {
|
||||
key := module + " " + fp
|
||||
if a, ok := read[key]; ok {
|
||||
return a.b, a.err
|
||||
}
|
||||
b, err := r.Build(ctx, module, fp)
|
||||
read[key] = answer{b, err}
|
||||
return b, err
|
||||
}
|
||||
}
|
||||
|
||||
// takenInKeeper is a Reader that keeps what a build's take-in made (StatesReader does): the resolved manifest's bytes,
|
||||
// by the content it was made from, so a composition made again on every change takes in only a build it has not seen.
|
||||
type takenInKeeper interface {
|
||||
takenIn(key string) ([]byte, bool)
|
||||
keepTakenIn(key string, raw []byte)
|
||||
}
|
||||
|
||||
// takenInKey is what a take-in is made from: the artifact store's address and the build's record (its manifest's digest, its artifacts by digest), read
|
||||
// whole; an archive is checked against its digest on every read, so the same record takes in to the same bytes.
|
||||
func takenInKey(module, fp, address string, b delivery.BuilderBuild) (string, bool) {
|
||||
raw, err := json.Marshal(b)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
sum := sha256.Sum256(append([]byte(module+" "+fp+" "+address+" "), raw...))
|
||||
return hex.EncodeToString(sum[:]), true
|
||||
}
|
||||
|
||||
// takenInAtMost bounds the take-ins a reader keeps; past it they are emptied, which costs only a take-in again.
|
||||
const takenInAtMost = 512
|
||||
|
||||
// keptTakeIns is a reader's kept take-ins.
|
||||
type keptTakeIns struct {
|
||||
mu sync.Mutex
|
||||
kept map[string][]byte
|
||||
}
|
||||
|
||||
func (k *keptTakeIns) takenIn(key string) ([]byte, bool) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
raw, ok := k.kept[key]
|
||||
return raw, ok
|
||||
}
|
||||
|
||||
func (k *keptTakeIns) keepTakenIn(key string, raw []byte) {
|
||||
k.mu.Lock()
|
||||
defer k.mu.Unlock()
|
||||
if k.kept == nil || len(k.kept) >= takenInAtMost {
|
||||
k.kept = map[string][]byte{}
|
||||
}
|
||||
k.kept[key] = raw
|
||||
}
|
||||
|
||||
// PhasesSaid says the phases in one line, each in milliseconds.
|
||||
func PhasesSaid(took []Phase) string {
|
||||
out := ""
|
||||
for i, p := range took {
|
||||
if i > 0 {
|
||||
out += ", "
|
||||
}
|
||||
out += fmt.Sprintf("%s %d ms", p.Name, p.Took.Milliseconds())
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
// Package fromruntime is compose/frombus's StatesReader over a bundle's runtime (the lead, 2026-10-29): the states a
|
||||
// module's manifest names in `reads` (frombus.StatesReads), read through stdio.State, and a templates archive fetched
|
||||
// over HTTP. Apart from frombus so that frombus stays pure for the mesh-compose command.
|
||||
package fromruntime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
|
||||
)
|
||||
|
||||
// States are a bundle's read states through its runtime.
|
||||
type States struct{}
|
||||
|
||||
// Get is a key's value, false when it has none.
|
||||
func (States) Get(state, key string) ([]byte, bool, error) {
|
||||
e, err := stdio.State(state).Get(key)
|
||||
if err != nil || e == nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return e.Value, true, nil
|
||||
}
|
||||
|
||||
// Keys are a state's keys.
|
||||
func (States) Keys(state string) ([]string, error) { return stdio.State(state).Keys() }
|
||||
|
||||
// Reader is frombus's Reader over this bundle's read states key by key, fetching archives over HTTP. A holder that
|
||||
// composes again and again reads through Watched instead (novox/hq ADR 0580).
|
||||
func Reader() frombus.Reader { return frombus.StatesReader(States{}, HTTPFetch) }
|
||||
|
||||
// Watched is frombus's States over a watched copy (novox/hq ADR 0580 clause 1): the states a composition reads whole
|
||||
// (placement, haves, modules, resolved values) from the copy, a build by key when asked, a manifest once per digest.
|
||||
type Watched struct{ *stdio.WatchedStates }
|
||||
|
||||
// Get is a key's value: a build read now, a manifest kept once read, any other state's from the copy.
|
||||
func (w Watched) Get(state, key string) ([]byte, bool, error) {
|
||||
switch state {
|
||||
case frombus.BuildsRead:
|
||||
return w.Direct(state, key)
|
||||
case frombus.ManifestsRead:
|
||||
return w.Once(state, key)
|
||||
}
|
||||
return w.WatchedStates.Get(state, key)
|
||||
}
|
||||
|
||||
// WatchedReader is frombus's Reader over w, fetching each templates archive over HTTP once (KeptFetch): a composition
|
||||
// made again on every change reads it again otherwise.
|
||||
func WatchedReader(w *stdio.WatchedStates) frombus.Reader {
|
||||
return frombus.StatesReader(Watched{w}, KeptFetch(HTTPFetch))
|
||||
}
|
||||
|
||||
// archivesAtMost bounds the archives KeptFetch keeps; past it they are emptied, which costs only a fetch again.
|
||||
const archivesAtMost = 64
|
||||
|
||||
// KeptFetch is fetch kept once per URL: an archive's URL names its blob by digest, so its body never changes under it
|
||||
// (frombus checks the digest on every read). A failed fetch is not kept.
|
||||
func KeptFetch(fetch frombus.Fetch) frombus.Fetch {
|
||||
var mu sync.Mutex
|
||||
kept := map[string][]byte{}
|
||||
return func(ctx context.Context, url string) ([]byte, error) {
|
||||
mu.Lock()
|
||||
body, ok := kept[url]
|
||||
mu.Unlock()
|
||||
if ok {
|
||||
return body, nil
|
||||
}
|
||||
body, err := fetch(ctx, url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mu.Lock()
|
||||
if len(kept) >= archivesAtMost {
|
||||
clear(kept)
|
||||
}
|
||||
kept[url] = body
|
||||
mu.Unlock()
|
||||
return body, nil
|
||||
}
|
||||
}
|
||||
|
||||
// HTTPFetch reads an archive over HTTP, at most 256 MiB.
|
||||
func HTTPFetch(ctx context.Context, url string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s answered %s", url, resp.Status)
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(resp.Body, 256<<20))
|
||||
}
|
||||
@@ -1,365 +0,0 @@
|
||||
// Package compose is the controller's composition of a machine, as a function over a plain input (novox/hq ADR 0453
|
||||
// clause 3, ADR 0446 clause 4): what the rest of the mesh offers and holds, the private network's generator, one
|
||||
// node's resolution and, in the issues after, its rendering and its declaration.
|
||||
//
|
||||
// **It never allocates, makes, writes, logs or raises.** The controller composes with a store behind it; this
|
||||
// package is its Reading and Foreseeing modes over data. Everything the controller reads from its store is a field of
|
||||
// Input, and everything a send would make or record (a port, a credential, a seat passed on, a node left out) is
|
||||
// returned for the caller to act on, never done. Input is never changed by a call. No field holds a secret value
|
||||
// the composition needs: sealed values are optional inputs, and without them the composition uses stand-ins named
|
||||
// foreseen. The logic is the controller's, ported from mesh-controller cmd/mesh-controller at commit
|
||||
// 6c2a8d62100cb7d491f8eb9a623e1eeea5ec0de1 (plan.go, network.go, holdings.go, seat_pass.go), in the same order, with the
|
||||
// same refusals in the same words, so that a comparison node for node is a fair one.
|
||||
//
|
||||
// It imports the standard library and mesh-sdk's own packages only.
|
||||
// It reads no environment variable: the range the mesh allocates from and the suffix of its internal names are fields of Input.
|
||||
package compose
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Input is everything the composition reads. Each field is what one read of the controller's inventory answers, named
|
||||
// beside it; the controller's adapter fills it from its stores and the assigner from the mesh's facts and buckets
|
||||
// (ADR 0446 clause 2).
|
||||
type Input struct {
|
||||
// Catalogue is the shelf: every module's manifest by name (inv.Catalogue), as it is to be composed. A recorded
|
||||
// module kept at the build a machine runs (keepRecorded) is that node's Kept, applied to its own resolution only (never to the rest of the mesh).
|
||||
Catalogue map[string]catalogue.Manifest
|
||||
// Nodes is every node the mesh has (inv.Nodes and inv.Overlays together, one row per node).
|
||||
Nodes []Node
|
||||
// Holdings is every seat whose holder is on record (inv.Holdings).
|
||||
Holdings []catalogue.Held
|
||||
// Licences is every licence the mesh holds, with the machines that use it (open.Licences); none for a mesh with no
|
||||
// licence.
|
||||
Licences []Licence
|
||||
|
||||
// OverlayCIDR is the range the mesh allocates from when no tunnel was adopted, as genesis was told it (the
|
||||
// controller's OverlayCIDRVar); empty means DefaultOverlayCIDR. The package reads no environment.
|
||||
OverlayCIDR string
|
||||
// InternalSuffix is what the mesh's internal names end in (the controller's MESH_INTERNAL_SUFFIX), with or without a leading
|
||||
// dot; empty means overlay.DefaultSuffix. A fact of the whole mesh, so it is in the Input and never read from the environment.
|
||||
InternalSuffix string
|
||||
// Tunnels is, by node name, the found tunnel whose key is the node's overlay key (inv.Tunnels).
|
||||
Tunnels map[string]FoundTunnel
|
||||
// Adopted is the hub's found tunnel and the hub's name, whether or not the hub took it (inv.AdoptedTunnel); nil
|
||||
// when no hub has one.
|
||||
Adopted *AdoptedTunnel
|
||||
// CarriedPeers is every peer of the adopted tunnel (inv.CarriedPeers).
|
||||
CarriedPeers []CarriedPeer
|
||||
// Joining is the nodes that hold a live enrolment token (inv.NodesWithALiveToken).
|
||||
Joining []string
|
||||
|
||||
// Credentials is how each pair credential a node takes from elsewhere stands, never its value (what inv.SecretFor,
|
||||
// SharedSecretFor and GivenPairSecret would answer): held, to be made by the next send, only a person's to give, or
|
||||
// refused. A need with no entry here is one the next send would make.
|
||||
Credentials []Credential
|
||||
|
||||
// Built is every repository the mesh has built (the keys of inv.Held): what tells a reference recorded with an address
|
||||
// from an image a module runs straight from a public registry.
|
||||
Built []string
|
||||
// OperatorExport is every operator-sealed secret in the mesh, for a module that keeps them (inv.OperatorExport): sealed
|
||||
// values the composition carries without opening. Nil when the mesh has no operator key.
|
||||
OperatorExport *catalogue.KeptExport
|
||||
// KeptRuns is, by node and module, the file of the export as that node runs it (its resource of a module's running
|
||||
// composition): what a node composing its own carries where it holds no OperatorExport (catalogue.Rendering.KeptRuns,
|
||||
// issue 1126, C6). Used only while OperatorExport is nil.
|
||||
KeptRuns map[string]map[string]map[string]any
|
||||
|
||||
// Issued is the credentials made for the consumers of each provider (inv.SecretsFrom): one row for each pair credential
|
||||
// that exists, by the provider node it is for. The grants a provider must create are read from these, so a credential not
|
||||
// made yet has no grant until the send that makes it. An assigner fills it from the facts snapshot's Grants of each
|
||||
// machine (go/facts, from format 2), which carry who each credential is for and never the credential.
|
||||
Issued []Issued
|
||||
// OwnSecrets is the own secrets held on each machine (the module_secret rows): who made each, and the key it is sealed to.
|
||||
// Their sealed values are optional.
|
||||
OwnSecrets []OwnSecretRow
|
||||
// KeptCoreBoxes is the vault's box last kept for each core credential on each machine (inv.KeptCoreBoxes).
|
||||
KeptCoreBoxes []CoreBox
|
||||
// BusUsers is the bus's user list as the controller composes it from the bus records and the hashes it holds (composeBusUsers),
|
||||
// for the machine that runs the bus: names and hashes, never passwords. Empty when the Input does not carry it, and then
|
||||
// the composition uses a stand-in, named foreseen. BusMissing are the users left out of it for want of a credential.
|
||||
BusUsers string
|
||||
BusMissing []string
|
||||
// BusMinted are the bus users the mesh has minted a password hash for (inv.BusUserHash): `<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
|
||||
}
|
||||
@@ -1,372 +0,0 @@
|
||||
// Package judge is how the seat mesh-placement's holder judges a proposal (novox/hq ADR 0567 §3, §4): which nodes must
|
||||
// answer it and what it changes on each (Intended), and the verdict of their answers (JudgeAnswers). Moved here from
|
||||
// the assigner, where it judged a catalogue pull request's placement (ADR 0559 §5), with a proposal in place of a pull
|
||||
// request's change. It holds no bus client and no timer: the holder gives it what it read.
|
||||
package judge
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// AnswerBound is how long the holder waits for a node's answer after its candidate was written (ADR 0567 §4): the time to
|
||||
// live of the node's pulse key (delivery.PulseAnswerKey), whose expiry names it silent, never passed. A variable so a test
|
||||
// on the bus can shorten it.
|
||||
var AnswerBound = 10 * time.Minute
|
||||
|
||||
// Intended is, by node, the modules whose composition the proposal changes, and the nodes that must answer it (§3).
|
||||
// current is the composition input with the current placement and laidInput the same with the proposal's placements
|
||||
// (laid, by node; nil removes the node's key) laid over it; nodes is every node to look at (every node with a record).
|
||||
// A node's set holds the modules the proposal adds, removes or whose placed entry differs on that node, and those whose
|
||||
// composition differs between the two inputs (delivery.CompareComposed), the node's own named "node". Must is every node
|
||||
// whose key the proposal changes and every other whose set is not empty, sorted. A node that has no key now gets an
|
||||
// empty set (§7: the seed is answered as the same). A node whose compositions cannot be made on either side keeps the
|
||||
// modules its entries name, said in notes; such a node is in Must only when its key changes.
|
||||
func Intended(current, laidInput *compose.Input, currentPlaced map[string]delivery.Placement, laid map[string]*delivery.Placement,
|
||||
nodes []string, fps map[string]string) (intended map[string]map[string]bool, must []string, notes []string) {
|
||||
intended = map[string]map[string]bool{}
|
||||
inMust := map[string]bool{}
|
||||
all := map[string]bool{}
|
||||
for _, n := range nodes {
|
||||
all[n] = true
|
||||
}
|
||||
for n := range laid {
|
||||
all[n] = true
|
||||
inMust[n] = true
|
||||
}
|
||||
names := make([]string, 0, len(all))
|
||||
for n := range all {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, node := range names {
|
||||
before, placed := currentPlaced[node]
|
||||
set := map[string]bool{}
|
||||
after, changesKey := laid[node]
|
||||
if changesKey && !placed {
|
||||
intended[node] = set // a node new to placement: every answer must be the same
|
||||
continue
|
||||
}
|
||||
if changesKey {
|
||||
var afterModules []delivery.PlacedModule
|
||||
if after != nil {
|
||||
afterModules = after.Modules
|
||||
}
|
||||
entries := map[string]delivery.PlacedModule{}
|
||||
for _, m := range before.Modules {
|
||||
entries[m.Module] = m
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, m := range afterModules {
|
||||
seen[m.Module] = true
|
||||
if e, ok := entries[m.Module]; !ok || !reflect.DeepEqual(e, m) {
|
||||
set[m.Module] = true
|
||||
}
|
||||
}
|
||||
for name := range entries {
|
||||
if !seen[name] {
|
||||
set[name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if placed && current != nil && laidInput != nil {
|
||||
was, _, _, errB := compose.CompositionsShadow(*current, node, fps)
|
||||
now, _, _, errA := compose.CompositionsShadow(*laidInput, node, fps)
|
||||
if errA == nil && errB == nil {
|
||||
running := make([]delivery.Composed, 0, len(was))
|
||||
for _, c := range was {
|
||||
running = append(running, c)
|
||||
}
|
||||
cmp := delivery.CompareComposed(now, running)
|
||||
for _, d := range cmp.Differs {
|
||||
set[d.Module] = true
|
||||
}
|
||||
for _, d := range cmp.Added {
|
||||
set[d.Module] = true
|
||||
}
|
||||
for _, m := range cmp.Removed {
|
||||
set[m] = true
|
||||
}
|
||||
} else if changesKey {
|
||||
notes = append(notes, fmt.Sprintf("what the proposal changes on %s is read from its placement alone: its "+
|
||||
"compositions cannot be made (now: %v; proposed: %v)", node, errB, errA))
|
||||
}
|
||||
}
|
||||
if len(set) > 0 {
|
||||
inMust[node] = true
|
||||
}
|
||||
if inMust[node] {
|
||||
intended[node] = set
|
||||
}
|
||||
}
|
||||
for n := range inMust {
|
||||
must = append(must, n)
|
||||
}
|
||||
sort.Strings(must)
|
||||
return intended, must, notes
|
||||
}
|
||||
|
||||
// JudgeAnswers is the verdict of a proposal's answers (§4). answers is by node its HaveNode.Candidates entry for the
|
||||
// proposal (nil when it has none) and revisions by node the revision of its candidate key: an answer counts when it
|
||||
// names the proposal and that revision. It fails on a refusal by a node that must answer, and on every node of Must with
|
||||
// no answer that counts once its pulse key has ended (pulsing false: the bound's expiry, heard on the bus); while it
|
||||
// stands it waits for them.
|
||||
// A difference fails nothing: one Intended computed is listed under Intended, one it did not under Unintended, both
|
||||
// shown in the ask's brief. The seed alone fails on any difference.
|
||||
func JudgeAnswers(p delivery.PlacementProposal, intended map[string]map[string]bool, answers map[string]*delivery.CandidateAnswer,
|
||||
revisions map[string]uint64, online map[string]bool, pulsing map[string]bool, now time.Time) delivery.PlacementVerdict {
|
||||
v := delivery.PlacementVerdict{At: now.UTC(), Intended: map[string][]string{}, Unintended: map[string][]string{}, Carried: map[string][]string{},
|
||||
Answers: map[string]delivery.CandidateAnswer{}}
|
||||
for node, set := range intended {
|
||||
for m := range set {
|
||||
v.Intended[node] = append(v.Intended[node], m)
|
||||
}
|
||||
sort.Strings(v.Intended[node])
|
||||
}
|
||||
must := p.Must
|
||||
if len(must) == 0 {
|
||||
must = p.Nodes()
|
||||
}
|
||||
for _, node := range must {
|
||||
a := answers[node]
|
||||
if a == nil || a.Proposal != p.ID || a.Revision == 0 || a.Revision != revisions[node] {
|
||||
v.Silent = append(v.Silent, node)
|
||||
continue
|
||||
}
|
||||
v.Answers[node] = *a
|
||||
if a.Refused != "" {
|
||||
v.Fails = append(v.Fails, fmt.Sprintf("%s cannot compose the proposal: %s", node, a.Refused))
|
||||
continue
|
||||
}
|
||||
for _, r := range a.Refusals {
|
||||
v.Fails = append(v.Fails, fmt.Sprintf("%s refuses the proposal for %s: %s", node, r.Module, r.Why))
|
||||
}
|
||||
want := intended[node]
|
||||
differs := func(module, words string) {
|
||||
if p.Seed {
|
||||
// The seed carries what its nodes found, each named in the brief for the operator who approves it.
|
||||
v.Carried[node] = append(v.Carried[node], words)
|
||||
return
|
||||
}
|
||||
if !want[module] {
|
||||
v.Unintended[node] = append(v.Unintended[node], module)
|
||||
}
|
||||
}
|
||||
for _, d := range a.Differs {
|
||||
differs(d.Module, differenceWords(d))
|
||||
}
|
||||
for _, d := range a.Added {
|
||||
differs(d.Module, "would run "+d.Module)
|
||||
}
|
||||
for _, m := range a.Removed {
|
||||
differs(m, "would stop running "+m)
|
||||
}
|
||||
sort.Strings(v.Unintended[node])
|
||||
}
|
||||
if len(v.Intended) == 0 {
|
||||
v.Intended = nil
|
||||
}
|
||||
if len(v.Unintended) == 0 {
|
||||
v.Unintended = nil
|
||||
}
|
||||
if len(v.Carried) == 0 {
|
||||
v.Carried = nil
|
||||
}
|
||||
if len(v.Answers) == 0 {
|
||||
v.Answers = nil
|
||||
}
|
||||
var waiting, gone []string
|
||||
for _, node := range v.Silent {
|
||||
named := node
|
||||
if online != nil && !online[node] {
|
||||
named += " (offline)"
|
||||
}
|
||||
if pulsing[node] {
|
||||
waiting = append(waiting, named)
|
||||
} else {
|
||||
gone = append(gone, named)
|
||||
}
|
||||
}
|
||||
if len(waiting) > 0 {
|
||||
v.Notes = append(v.Notes, fmt.Sprintf("waits for the answers of %s, each for %s from its candidate's write",
|
||||
strings.Join(waiting, ", "), AnswerBound))
|
||||
}
|
||||
if len(gone) > 0 {
|
||||
v.Fails = append(v.Fails, fmt.Sprintf("%s did not answer within %s of its candidate's write; judged again when it "+
|
||||
"answers", strings.Join(gone, ", "), AnswerBound))
|
||||
}
|
||||
switch {
|
||||
case len(v.Fails) > 0:
|
||||
v.State = delivery.VerdictFail
|
||||
case len(waiting) > 0:
|
||||
v.State = delivery.VerdictWaits
|
||||
default:
|
||||
v.State = delivery.VerdictPass
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// Brief is the verdict as the ask's brief shows it, by node (§5): what the proposal changes and what each node answered
|
||||
// beside it, marked, and what stays held as the node holds it today, with the proposal's why.
|
||||
func Brief(p delivery.PlacementProposal, v delivery.PlacementVerdict) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%s (proposed by %s)", p.Why, p.By)
|
||||
nodes := map[string]bool{}
|
||||
for n := range v.Intended {
|
||||
nodes[n] = true
|
||||
}
|
||||
for n := range v.Unintended {
|
||||
nodes[n] = true
|
||||
}
|
||||
for _, n := range p.Nodes() {
|
||||
nodes[n] = true
|
||||
}
|
||||
for n, a := range v.Answers {
|
||||
if len(a.Held) > 0 {
|
||||
nodes[n] = true
|
||||
}
|
||||
}
|
||||
for n := range v.Carried {
|
||||
nodes[n] = true
|
||||
}
|
||||
names := make([]string, 0, len(nodes))
|
||||
for n := range nodes {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, n := range names {
|
||||
fmt.Fprintf(&b, "; %s:", n)
|
||||
if m := v.Intended[n]; len(m) > 0 {
|
||||
fmt.Fprintf(&b, " changes %s", strings.Join(m, ", "))
|
||||
} else {
|
||||
b.WriteString(" composes the same")
|
||||
}
|
||||
if m := v.Unintended[n]; len(m) > 0 {
|
||||
fmt.Fprintf(&b, "; also differs, not computed: %s", strings.Join(m, ", "))
|
||||
}
|
||||
if m := v.Carried[n]; len(m) > 0 {
|
||||
fmt.Fprintf(&b, "; the seed carries: %s", strings.Join(m, "; "))
|
||||
}
|
||||
if held := v.Answers[n].Held; len(held) > 0 {
|
||||
var names []string
|
||||
for _, h := range held {
|
||||
today := h.Today
|
||||
if today == "" {
|
||||
today = "held"
|
||||
}
|
||||
names = append(names, h.Module+" ("+today+")")
|
||||
}
|
||||
fmt.Fprintf(&b, "; stays as today: %s", strings.Join(names, ", "))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// differenceWords names one module's difference: the module, the first field that differs in it with its value as it
|
||||
// runs and as the seed composes it, each cut to about 60 characters ("(value cut)" where a body shown is cut before the
|
||||
// field), and how many fields more; or the resource and why when no body is shown.
|
||||
func differenceWords(d delivery.ModuleDiffers) string {
|
||||
for _, b := range d.Bodies {
|
||||
if len(b.Paths) == 0 {
|
||||
continue
|
||||
}
|
||||
path := b.Paths[0]
|
||||
words := fmt.Sprintf("%s: %s %s → %s", d.Module, fieldName(b.Running, b.Composed, path),
|
||||
valueAt(b.Running, path), valueAt(b.Composed, path))
|
||||
if n := len(b.Paths) - 1; n > 0 {
|
||||
words += fmt.Sprintf(" (and %d more fields)", n)
|
||||
}
|
||||
return words
|
||||
}
|
||||
if d.Resource != "" {
|
||||
return fmt.Sprintf("%s: %s (%s)", d.Module, d.Resource, d.Why)
|
||||
}
|
||||
return fmt.Sprintf("%s: %s", d.Module, d.Why)
|
||||
}
|
||||
|
||||
// shownValue bounds a value named in a brief.
|
||||
const shownValue = 60
|
||||
|
||||
// walk follows a differing path (delivery.DifferingPaths: keys joined by dots, a key may hold dots, and a JSON value
|
||||
// written in a string is read as JSON) through a body, answering the keys it passed and the value at its end.
|
||||
func walk(body, path string) (keys []string, value any, ok bool) {
|
||||
var v any
|
||||
if json.Unmarshal([]byte(body), &v) != nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
rest := path
|
||||
for rest != "" {
|
||||
if s, isString := v.(string); isString {
|
||||
var inner any
|
||||
if json.Unmarshal([]byte(s), &inner) != nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
v = inner
|
||||
}
|
||||
switch t := v.(type) {
|
||||
case map[string]any:
|
||||
best := ""
|
||||
for k := range t {
|
||||
if (rest == k || strings.HasPrefix(rest, k+".")) && len(k) > len(best) {
|
||||
best = k
|
||||
}
|
||||
}
|
||||
if best == "" {
|
||||
return nil, nil, false
|
||||
}
|
||||
keys, v, rest = append(keys, best), t[best], strings.TrimPrefix(strings.TrimPrefix(rest, best), ".")
|
||||
case []any:
|
||||
seg, after, _ := strings.Cut(rest, ".")
|
||||
n, err := strconv.Atoi(seg)
|
||||
if err != nil || n < 0 || n >= len(t) {
|
||||
return nil, nil, false
|
||||
}
|
||||
keys, v, rest = append(keys, seg), t[n], after
|
||||
default:
|
||||
return nil, nil, false
|
||||
}
|
||||
}
|
||||
return keys, v, true
|
||||
}
|
||||
|
||||
// valueAt is the value at path in a shown body, cut to shownValue, or why it is not shown.
|
||||
func valueAt(body, path string) string {
|
||||
if body == "" {
|
||||
return "(none)"
|
||||
}
|
||||
_, v, ok := walk(body, path)
|
||||
if !ok {
|
||||
return "(value cut)"
|
||||
}
|
||||
s, isString := v.(string)
|
||||
if !isString {
|
||||
raw, _ := json.Marshal(v)
|
||||
s = string(raw)
|
||||
}
|
||||
if r := []rune(s); len(r) > shownValue {
|
||||
s = string(r[:shownValue-1]) + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// fieldName is the differing field as a person reads it: its parent's name and its own, `PARENT[key]`, from whichever
|
||||
// body reaches it; the path's end when neither does.
|
||||
func fieldName(running, composed, path string) string {
|
||||
for _, body := range []string{running, composed} {
|
||||
if keys, _, ok := walk(body, path); ok && len(keys) > 0 {
|
||||
if len(keys) == 1 {
|
||||
return keys[0]
|
||||
}
|
||||
return keys[len(keys)-2] + "[" + keys[len(keys)-1] + "]"
|
||||
}
|
||||
}
|
||||
return path
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package judge
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// ADR 0567 §3, §4 (rows moved from the assigner's check of a pull request's placement, ADR 0559 §5): what a proposal
|
||||
// changes by node, and the verdict of the answers: differences shown and failing nothing, a refusal and a silent node
|
||||
// failing, a later answer judging again, the seed failing on any difference.
|
||||
func TestJudge(t *testing.T) {
|
||||
current := map[string]delivery.Placement{"anchor": {Node: "anchor", Modules: []delivery.PlacedModule{{Module: "mail"}}}, "server": {Node: "server"}}
|
||||
laid := map[string]*delivery.Placement{
|
||||
"anchor": {Node: "anchor", Modules: []delivery.PlacedModule{{Module: "mail", Ports: map[string]int{"25": 2525}}, {Module: "web"}}},
|
||||
"laptop": {Node: "laptop", Modules: []delivery.PlacedModule{{Module: "web"}}}}
|
||||
got, must, _ := Intended(nil, nil, current, laid, []string{"anchor", "laptop", "server"}, nil)
|
||||
if !got["anchor"]["mail"] || !got["anchor"]["web"] || len(got["laptop"]) != 0 || !slices.Equal(must, []string{"anchor", "laptop"}) {
|
||||
t.Errorf("intended = %v, must %v: want anchor's mail and web, nothing on the laptop that has no key, server untouched", got, must)
|
||||
}
|
||||
|
||||
at := time.Date(2026, 10, 27, 2, 0, 0, 0, time.UTC)
|
||||
p := delivery.PlacementProposal{ID: "p-one", By: "op", Why: "move web", Written: at, Base: map[string]uint64{"anchor": 3, "laptop": 0},
|
||||
Must: []string{"anchor", "laptop"}}
|
||||
revisions := map[string]uint64{"anchor": 7, "laptop": 8}
|
||||
intended := map[string]map[string]bool{"anchor": {"web": true}}
|
||||
same := func(rev uint64) *delivery.CandidateAnswer {
|
||||
return &delivery.CandidateAnswer{Proposal: "p-one", Revision: rev, Verdict: delivery.CandidateSame, Same: []string{"mail"}}
|
||||
}
|
||||
differs := func(rev uint64, module string) *delivery.CandidateAnswer {
|
||||
return &delivery.CandidateAnswer{Proposal: "p-one", Revision: rev, Verdict: delivery.CandidateDiffers,
|
||||
Differs: []delivery.ModuleDiffers{{Module: module, Why: "the port differs"}}}
|
||||
}
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
seed bool
|
||||
answers map[string]*delivery.CandidateAnswer
|
||||
pulsing map[string]bool
|
||||
state string
|
||||
words string
|
||||
marked string
|
||||
}{
|
||||
{"all the same", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": same(8)}, nil, delivery.VerdictPass, "", ""},
|
||||
{"a module added where intended", false, map[string]*delivery.CandidateAnswer{"anchor": {Proposal: "p-one", Revision: 7, Verdict: delivery.CandidateDiffers,
|
||||
Added: []delivery.ModuleAdded{{Module: "web", Unissued: []string{"its own secret"}}}}, "laptop": same(8)}, nil, delivery.VerdictPass, "", ""},
|
||||
{"a difference not computed passes, marked", false, map[string]*delivery.CandidateAnswer{"anchor": differs(7, "mail"), "laptop": same(8)}, nil, delivery.VerdictPass, "", "mail"},
|
||||
{"the seed carries a difference, named", true, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": {Proposal: "p-one", Revision: 8,
|
||||
Verdict: delivery.CandidateDiffers, Differs: []delivery.ModuleDiffers{{Module: "node-tools", Resource: "node-tools.runtime", Why: "the resource differs",
|
||||
Bodies: []delivery.ResourceBodies{{ID: "node-tools.runtime", Paths: []string{"env.MESH_OWNED_PATHS./etc/resolv.conf", "env.MESH_TOOL_MODULES"},
|
||||
Running: `{"env":{"MESH_OWNED_PATHS":"{\"/etc/hosts\":\"hostname\",\"/etc/resolv.conf\":\"networkmanager (node-uplink)\"}"}}`,
|
||||
Composed: `{"env":{"MESH_OWNED_PATHS":"{\"/etc/hosts\":\"hostname\",\"/etc/resolv.conf\":\"systemd-resolved (node-resolver)\"}","MESH_TOOL_MODULES":"… (cut at 8192 bytes)`}}}}}}, nil, delivery.VerdictPass, "", ""},
|
||||
{"a refusal", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": {Proposal: "p-one", Revision: 8, Verdict: delivery.CandidateRefused,
|
||||
Refusals: []delivery.ModuleRefused{{Module: "web", Why: "no own secret"}}}}, nil, delivery.VerdictFail, "laptop refuses", ""},
|
||||
{"a node not answering, within its bound", false, map[string]*delivery.CandidateAnswer{"anchor": same(7)}, map[string]bool{"laptop": true}, delivery.VerdictWaits, "laptop (offline)", ""},
|
||||
{"an answer of an earlier revision, past its bound", false, map[string]*delivery.CandidateAnswer{"anchor": same(6), "laptop": same(8)}, nil, delivery.VerdictFail, "anchor did not answer", ""},
|
||||
{"a module held as today passes, shown in the brief", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": {Proposal: "p-one", Revision: 8,
|
||||
Verdict: delivery.CandidateSame, Held: []delivery.ModuleRefused{{Module: "spiegel", Why: "a required value is unset"}}}}, nil, delivery.VerdictPass, "", ""},
|
||||
{"its later answer", false, map[string]*delivery.CandidateAnswer{"anchor": same(7), "laptop": same(8)}, nil, delivery.VerdictPass, "", ""},
|
||||
} {
|
||||
q := p
|
||||
q.Seed = row.seed
|
||||
v := JudgeAnswers(q, intended, row.answers, revisions, map[string]bool{"anchor": true}, row.pulsing, at)
|
||||
all := strings.Join(append(append([]string{}, v.Fails...), v.Notes...), "; ")
|
||||
if v.State != row.state || !strings.Contains(all, row.words) {
|
||||
t.Errorf("%s: %s, %q", row.name, v.State, all)
|
||||
}
|
||||
if row.seed && row.state == delivery.VerdictPass && !strings.Contains(Brief(q, v), "the seed carries: node-tools: MESH_OWNED_PATHS[/etc/resolv.conf] networkmanager (node-uplink) → (value cut) (and 1 more fields)") {
|
||||
t.Errorf("%s: the brief does not name it: %s", row.name, Brief(q, v))
|
||||
}
|
||||
if row.seed && row.state == delivery.VerdictPass {
|
||||
whole := row.answers["laptop"].Differs[0]
|
||||
whole.Bodies = []delivery.ResourceBodies{{ID: "r", Paths: []string{"env.MESH_OWNED_PATHS./etc/resolv.conf"},
|
||||
Running: `{"env":{"MESH_OWNED_PATHS":"{\"/etc/resolv.conf\":\"networkmanager (node-uplink)\"}"}}`,
|
||||
Composed: `{"env":{"MESH_OWNED_PATHS":"{\"/etc/resolv.conf\":\"systemd-resolved (node-resolver)\"}"}}`}}
|
||||
if got := differenceWords(whole); got != "node-tools: MESH_OWNED_PATHS[/etc/resolv.conf] networkmanager (node-uplink) → systemd-resolved (node-resolver)" {
|
||||
t.Errorf("the difference is named %q", got)
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(row.name, "a module held as today") && !strings.Contains(Brief(q, v), "stays as today: spiegel (held)") {
|
||||
t.Errorf("%s: the brief does not show it: %s", row.name, Brief(q, v))
|
||||
}
|
||||
if row.marked != "" && (!slices.Contains(v.Unintended["anchor"], row.marked) || !strings.Contains(Brief(q, v), "not computed: "+row.marked)) {
|
||||
t.Errorf("%s: the difference is not marked: %+v, %s", row.name, v.Unintended, Brief(q, v))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,307 +0,0 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
)
|
||||
|
||||
// The private network: who is on it, where, and what they are called. Ported from mesh-controller
|
||||
// cmd/mesh-controller/network.go at 6c2a8d62.
|
||||
|
||||
// DefaultOverlayCIDR is the range the mesh allocates from when nothing says another.
|
||||
const DefaultOverlayCIDR = "10.42.0.0/16"
|
||||
|
||||
// composer is one composition over an Input: it reads Input, changes nothing in it, and keeps what the controller
|
||||
// said on its standard error as lines to return.
|
||||
type composer struct {
|
||||
in Input
|
||||
said []string
|
||||
seen map[string]bool
|
||||
// unhostable is, from the last look at the rest of the mesh, each module assigned to another node that this
|
||||
// composition finds that node cannot run: what it would have offered is missing here (issue 1126, probe #532).
|
||||
unhostable []string
|
||||
|
||||
// What one composition looks at again and again, each worked out once over its Input, which it never changes (issue
|
||||
// 1954: a node's plans resolved every machine of the mesh some two hundred times, 11.7 s of CPU for novox): who is
|
||||
// on the private network, and the rest of the mesh as each node sees it.
|
||||
onNetwork []Node
|
||||
onNetworkSet bool
|
||||
rest map[string]restOf
|
||||
plans map[string]planOf
|
||||
}
|
||||
|
||||
// restOf is theRestOfTheMesh's answer for one excluded node, kept: what it found unhostable beside it.
|
||||
type restOf struct {
|
||||
world catalogue.World
|
||||
passes Passes
|
||||
left []LeftOut
|
||||
unhostable []string
|
||||
err error
|
||||
}
|
||||
|
||||
func newComposer(in Input) *composer { return &composer{in: in, seen: map[string]bool{}} }
|
||||
|
||||
// say keeps a line the controller would have written to its log, once.
|
||||
func (c *composer) say(format string, args ...any) {
|
||||
line := fmt.Sprintf(format, args...)
|
||||
if c.seen[line] {
|
||||
return
|
||||
}
|
||||
c.seen[line] = true
|
||||
c.said = append(c.said, line)
|
||||
}
|
||||
|
||||
// suffix is what the mesh's internal names end in: the Input's, and overlay.DefaultSuffix when it gives none. The package never calls
|
||||
// overlay.Suffix or overlay.InternalName, which read the environment, so the assigner and the controller compose the same names
|
||||
// from the same Input.
|
||||
func (c *composer) suffix() string {
|
||||
if v := strings.TrimPrefix(strings.TrimSpace(c.in.InternalSuffix), "."); v != "" {
|
||||
return v
|
||||
}
|
||||
return overlay.DefaultSuffix
|
||||
}
|
||||
|
||||
// internalName is a node's name inside the mesh.
|
||||
func (c *composer) internalName(node string) string { return node + "." + c.suffix() }
|
||||
|
||||
// saidBeside is the lines already said and then every line the composer kept, once each, in the order they were found.
|
||||
func (c *composer) saidBeside(lines []string) []string {
|
||||
for _, l := range c.said {
|
||||
if !slices.Contains(lines, l) {
|
||||
lines = append(lines, l)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// overlayRange is the range the mesh allocates node addresses from: the adopted tunnel's first (novox/hq ADR 0105),
|
||||
// then the range genesis was told, then the default.
|
||||
func (c *composer) overlayRange() string {
|
||||
if a := c.in.Adopted; a != nil && a.Adopted {
|
||||
return a.Tunnel.Range
|
||||
}
|
||||
if v := strings.TrimSpace(c.in.OverlayCIDR); v != "" {
|
||||
return v
|
||||
}
|
||||
return DefaultOverlayCIDR
|
||||
}
|
||||
|
||||
// network builds the private network over the machines that resolved the module for it. A machine is on it because it
|
||||
// was given the module, or because it is joining with a key and an address (novox/hq ADR 0169), or because its own set
|
||||
// could not be resolved and it holds a place (a key and an address): that place is its last good one on the network, and
|
||||
// it does not change because its modules failed, so every other machine keeps its path to it -- the hub above all -- and
|
||||
// only its own declaration is refused (issue 1833). A machine refused with no place is left off.
|
||||
func (c *composer) network(on map[string]bool, refused map[string]string) (*overlay.Generator, error) {
|
||||
tunnels := c.in.Tunnels
|
||||
carried := c.in.CarriedPeers
|
||||
isJoining := map[string]bool{}
|
||||
for _, name := range c.in.Joining {
|
||||
isJoining[name] = true
|
||||
}
|
||||
var nodes []overlay.Node
|
||||
for _, node := range c.in.sorted() {
|
||||
p := node.Place
|
||||
placed := p.Key != "" && p.Address != ""
|
||||
_, wasRefused := refused[node.Name]
|
||||
if !on[node.Name] && !((isJoining[node.Name] || wasRefused) && placed) {
|
||||
continue
|
||||
}
|
||||
if !on[node.Name] && wasRefused && !isJoining[node.Name] {
|
||||
c.say("%s could not be resolved and keeps its place on the private network at %s, so the others keep their path to it",
|
||||
node.Name, p.Address)
|
||||
}
|
||||
n := overlay.Node{
|
||||
Name: node.Name, Key: p.Key, Endpoint: p.Endpoint,
|
||||
Site: p.Site, Hub: p.Hub, Address: p.Address,
|
||||
}
|
||||
if t, takes := tunnels[node.Name]; takes && t.NodeAdopted {
|
||||
// Only an adopted node is told to take the found unit over. A hub whose placement disagrees with the tunnel is
|
||||
// refused, not composed.
|
||||
if wrong := disagrees(p, t.Tunnel); wrong != "" {
|
||||
return nil, fmt.Errorf("%s takes over the tunnel on %s and its placement disagrees with it: %s. "+
|
||||
"Re-place it — `overlay place %s --hub --endpoint <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 := catalogue.Resolve(c.in.Catalogue, n.Assigned,
|
||||
catalogue.Node{Name: n.Name, Site: n.Place.Site, Capabilities: n.Capabilities},
|
||||
catalogue.World{Unchecked: true, Holdings: c.in.Holdings})
|
||||
if err != nil {
|
||||
if n.Place.Key != "" {
|
||||
c.say("%s could not be resolved and keeps its place on the private network at %s, so the others keep their path to it",
|
||||
n.Name, n.Place.Address)
|
||||
out = append(out, n)
|
||||
continue
|
||||
}
|
||||
c.say("%s is not counted as on the network: it does not resolve: %v", n.Name, err)
|
||||
continue
|
||||
}
|
||||
for _, m := range got.Modules {
|
||||
for _, offered := range m.Offers() {
|
||||
if offered == overlay.Requirement {
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,306 +0,0 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
)
|
||||
|
||||
// onTheTunnel makes the hub of an Input what mesh-controller's network_test.go makes of anchor in
|
||||
// TestTheOverlaysRangeIsTheAdoptedTunnels: adopted, enrolled with the found tunnel's key, and presenting the tunnel
|
||||
// (Tunnel{wg0, wg-quick@wg0, port 51900, 192.0.2.1/24, range 192.0.2.0/24, one peer PEER-TWO}). Placed on it when
|
||||
// replaced is true: its address the tunnel's, its endpoint on the tunnel's port, as `overlay place anchor --endpoint
|
||||
// 198.51.100.10:51900 --site hosting --hub` leaves it.
|
||||
func onTheTunnel(t *testing.T, in *Input, replaced bool) {
|
||||
t.Helper()
|
||||
const key = "THE-TUNNELS-KEY========================="
|
||||
tunnel := Tunnel{Interface: "wg0", Unit: "wg-quick@wg0", Config: "/etc/wireguard/wg0.conf", Port: 51900,
|
||||
Address: "192.0.2.1/24", Range: "192.0.2.0/24", PublicKey: key,
|
||||
Peers: []TunnelPeer{{PublicKey: "PEER-TWO", Address: "192.0.2.2"}}}
|
||||
hub := nodeOf(t, in, "anchor")
|
||||
hub.Adopted = true
|
||||
hub.Place.Key = key
|
||||
if replaced {
|
||||
hub.Place.Address, hub.Place.Endpoint, hub.Place.Site = "192.0.2.1", "198.51.100.10:51900", "hosting"
|
||||
}
|
||||
in.Tunnels = map[string]FoundTunnel{"anchor": {Tunnel: tunnel, NodeAdopted: true}}
|
||||
in.Adopted = &AdoptedTunnel{Tunnel: tunnel, Hub: "anchor", Adopted: true}
|
||||
in.CarriedPeers = []CarriedPeer{{PublicKey: "PEER-TWO", Address: "192.0.2.2"}}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0105 (mesh-controller network_test.go TestTheOverlaysRangeIsTheAdoptedTunnels): the range every address is
|
||||
// composed from is the adopted tunnel's, read from the tunnel the hub holds, never stored anywhere else. The environment
|
||||
// value of the controller is Input.OverlayCIDR here.
|
||||
func TestTheOverlaysRangeIsTheAdoptedTunnels(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
in.OverlayCIDR = "10.99.0.0/16"
|
||||
if got := newComposer(in).overlayRange(); got != "10.99.0.0/16" {
|
||||
t.Fatalf("without an adopted tunnel the range is not what genesis said: %q", got)
|
||||
}
|
||||
in.OverlayCIDR = ""
|
||||
if got := newComposer(in).overlayRange(); got != DefaultOverlayCIDR {
|
||||
t.Fatalf("with nothing said the range is %q, not the default", got)
|
||||
}
|
||||
in.OverlayCIDR = "10.99.0.0/16"
|
||||
onTheTunnel(t, &in, true)
|
||||
if got := newComposer(in).overlayRange(); got != "192.0.2.0/24" {
|
||||
t.Fatalf("with an adopted tunnel the range is %q, not the tunnel's", got)
|
||||
}
|
||||
// A tunnel the hub found and did not take (its key is not the tunnel's) does not give its range.
|
||||
in.Adopted.Adopted = false
|
||||
if got := newComposer(in).overlayRange(); got != "10.99.0.0/16" {
|
||||
t.Fatalf("a tunnel the hub did not take gave its range: %q", got)
|
||||
}
|
||||
in.Adopted.Adopted = true
|
||||
|
||||
// And the hub's declaration carries the peer and the takeover.
|
||||
nodes, computed, err := Graph(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var hubNode overlay.Node
|
||||
for _, n := range nodes {
|
||||
if n.Name == "anchor" {
|
||||
hubNode = n
|
||||
}
|
||||
}
|
||||
if hubNode.TakesOver == nil || hubNode.TakesOver.Unit != "wg-quick@wg0" {
|
||||
t.Errorf("the hub is not told to take over the found tunnel: %+v", hubNode)
|
||||
}
|
||||
carried := false
|
||||
for _, p := range computed["anchor"] {
|
||||
if p.Key == "PEER-TWO" && p.Allowed == "192.0.2.2/32" {
|
||||
carried = true
|
||||
}
|
||||
}
|
||||
if !carried {
|
||||
t.Errorf("the hub's peer list does not carry the tunnel's peer: %+v", computed["anchor"])
|
||||
}
|
||||
}
|
||||
|
||||
// A takeover is composed only for a hub whose placement agrees with the tunnel (mesh-controller network_test.go
|
||||
// TestATakeoverIsNotComposedForAHubPlacedOffItsTunnel): an address or an endpoint port that differs would have the host stop
|
||||
// the found interface and raise the mesh's where no peer is listening.
|
||||
func TestATakeoverIsNotComposedForAHubPlacedOffItsTunnel(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
onTheTunnel(t, &in, false)
|
||||
// aMesh placed anchor at 10.77.0.1 on :51820, the record of a hub placed before it took the tunnel over.
|
||||
_, _, err := Graph(in)
|
||||
if err == nil {
|
||||
t.Fatal("a takeover was composed for a hub whose address and port are not the tunnel's")
|
||||
}
|
||||
for _, want := range []string{"10.77.0.1", "192.0.2.1", "51820", "51900", "overlay place anchor"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not say %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
// Re-placed on the tunnel, it composes.
|
||||
onTheTunnel(t, &in, true)
|
||||
if _, _, err := Graph(in); err != nil {
|
||||
t.Fatalf("re-placed on the tunnel, the graph still refuses: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1360/AC2: the generator gives the addresses, peers and names of aMesh: anchor the hub at 10.77.0.1, laptop at 10.77.0.2
|
||||
// peering through it, both written for their machines.
|
||||
func TestTheGeneratorGivesTheMeshItsAddressesAndPeers(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
gens, err := Generators(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g, ok := gens[overlay.Name].(*overlay.Generator)
|
||||
if !ok {
|
||||
t.Fatalf("the generator is %T", gens[overlay.Name])
|
||||
}
|
||||
addr := map[string]string{}
|
||||
for _, n := range g.Nodes() {
|
||||
addr[n.Name] = n.Address
|
||||
}
|
||||
if addr["anchor"] != "10.77.0.1" || addr["laptop"] != "10.77.0.2" || len(addr) != 2 {
|
||||
t.Fatalf("the addresses are %v", addr)
|
||||
}
|
||||
peers := g.Graph()
|
||||
if len(peers["laptop"]) != 1 || peers["laptop"][0].Name != "anchor" || peers["laptop"][0].Endpoint != "anchor.example:51820" {
|
||||
t.Errorf("laptop's peers are %+v, want the hub", peers["laptop"])
|
||||
}
|
||||
if len(peers["anchor"]) != 1 || peers["anchor"][0].Name != "laptop" || peers["anchor"][0].Allowed != "10.77.0.2/32" {
|
||||
t.Errorf("anchor's peers are %+v, want laptop", peers["anchor"])
|
||||
}
|
||||
for _, node := range []string{"anchor", "laptop"} {
|
||||
res, on, err := g.Resources(node)
|
||||
if err != nil || !on || len(res) == 0 {
|
||||
t.Errorf("%s is given no network: %v %v %d", node, on, err, len(res))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1360/AC3: a node not on the private network (it runs nothing that puts it there) gets no address and no network, and the
|
||||
// one that stayed keeps its own.
|
||||
func TestANodeNotOnThePrivateNetworkGetsNoAddressAndNoNetwork(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
nodeOf(t, &in, "laptop").Assigned = nil // it keeps its place and its address, and stops running the network
|
||||
gens, err := Generators(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := gens[overlay.Name].(*overlay.Generator)
|
||||
if len(g.Nodes()) != 1 || g.Nodes()[0].Name != "anchor" {
|
||||
t.Fatalf("the network is over %+v, want anchor alone", g.Nodes())
|
||||
}
|
||||
if res, on, err := g.Resources("laptop"); on || err != nil || len(res) != 0 {
|
||||
t.Errorf("a machine that left the network is given one: %v %v %v", res, on, err)
|
||||
}
|
||||
if _, in := g.Graph()["laptop"]; in {
|
||||
t.Error("a machine that left the network has peers")
|
||||
}
|
||||
}
|
||||
|
||||
// 1360/AC3: a node whose resolution is refused is said with today's words: the network missing its hub names the
|
||||
// machine that could not be resolved, which is almost always why. The hub here holds no complete place (no key): a refused
|
||||
// machine with one keeps it on the network (issue 1833), so only one without is missing from it.
|
||||
func TestARefusedNodeIsSaidWhenTheNetworkCannotBeComposed(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
nodeOf(t, &in, "anchor").Place.Key = ""
|
||||
one := catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
|
||||
two := catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
|
||||
in.Catalogue["rival-one"], in.Catalogue["rival-two"] = one, two
|
||||
hub := nodeOf(t, &in, "anchor")
|
||||
hub.Assigned = append(hub.Assigned, "rival-one", "rival-two") // its own set is incoherent: the hub does not resolve
|
||||
_, err := Generators(in)
|
||||
if err == nil {
|
||||
t.Fatal("a network without its hub was composed")
|
||||
}
|
||||
for _, want := range []string{overlay.ErrNoHub.Error(), "and 1 node(s) could not be resolved at all, which is probably why:", " anchor: "} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not say %q:\n%v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1360/AC4: Generators changes nothing in its Input.
|
||||
func TestGeneratorsChangesNothingInItsInput(t *testing.T) {
|
||||
unchanged(t, func() Input {
|
||||
in := aMesh(t)
|
||||
onTheTunnel(t, &in, true)
|
||||
in.Joining = []string{"laptop"}
|
||||
return in
|
||||
}, func(in Input) {
|
||||
if _, err := Generators(in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Each field of Input that the network reads reaches the generator: change it and the generator changes.
|
||||
func TestEachFieldOfTheInputReachesTheGenerator(t *testing.T) {
|
||||
addressesOf := func(in Input) map[string]string {
|
||||
t.Helper()
|
||||
gens, err := Generators(in)
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, n := range gens[overlay.Name].(*overlay.Generator).Nodes() {
|
||||
out[n.Name] = n.Address
|
||||
}
|
||||
return out
|
||||
}
|
||||
base := addressesOf(aMesh(t))
|
||||
if len(base) != 2 {
|
||||
t.Fatalf("the base mesh has %v", base)
|
||||
}
|
||||
t.Run("Catalogue: without the module offering it nobody is on the network", func(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
// The module stays known, so every machine still resolves: a machine that does not resolve keeps its place (issue 1833).
|
||||
in.Catalogue = map[string]catalogue.Manifest{overlay.Name: {Module: overlay.Name, Version: "1"}}
|
||||
if got := addressesOf(in); len(got) != 0 {
|
||||
t.Errorf("got %v", got)
|
||||
}
|
||||
})
|
||||
t.Run("Nodes.Place: an address", func(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
nodeOf(t, &in, "laptop").Place.Address = "10.77.0.9"
|
||||
if got := addressesOf(in); got["laptop"] != "10.77.0.9" {
|
||||
t.Errorf("got %v", got)
|
||||
}
|
||||
})
|
||||
t.Run("Nodes.Assigned", func(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
nodeOf(t, &in, "laptop").Assigned = nil
|
||||
if got := addressesOf(in); len(got) != 1 {
|
||||
t.Errorf("got %v", got)
|
||||
}
|
||||
})
|
||||
t.Run("Nodes.Capabilities: a capability the module needs", func(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
m := in.Catalogue[overlay.Name]
|
||||
m.Capabilities = []string{"wireguard"}
|
||||
in.Catalogue[overlay.Name] = m
|
||||
nodeOf(t, &in, "laptop").Capabilities = map[string]bool{}
|
||||
got, err := Generators(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := got[overlay.Name].(*overlay.Generator).Nodes(); len(n) != 1 || n[0].Name != "anchor" {
|
||||
t.Errorf("a machine without the capability is on the network: %+v", n)
|
||||
}
|
||||
})
|
||||
t.Run("OverlayCIDR", func(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
in.OverlayCIDR = "10.99.0.0/16"
|
||||
_, graph, err := Graph(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, p := range graph["laptop"] {
|
||||
found = found || p.Allowed == "10.99.0.0/16"
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("the hub's route is not the range given: %+v", graph["laptop"])
|
||||
}
|
||||
})
|
||||
t.Run("Tunnels and Adopted and CarriedPeers", func(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
onTheTunnel(t, &in, true)
|
||||
nodes, graph, err := Graph(in)
|
||||
if err != nil || len(nodes) != 2 {
|
||||
t.Fatalf("%v %v", nodes, err)
|
||||
}
|
||||
if len(graph["anchor"]) < 2 {
|
||||
t.Errorf("the hub's peers do not include the carried one: %+v", graph["anchor"])
|
||||
}
|
||||
})
|
||||
t.Run("Joining: a machine with a key and an address is on the network before it is anything else", func(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
in.Nodes = append(in.Nodes, Node{Name: "newcomer", Place: Place{Key: keyOf(t, "newcomer"), Address: "10.77.0.3"}})
|
||||
if got := addressesOf(in); got["newcomer"] != "" {
|
||||
t.Fatalf("a machine that runs nothing is on the network: %v", got)
|
||||
}
|
||||
in.Joining = []string{"newcomer"}
|
||||
if got := addressesOf(in); got["newcomer"] != "10.77.0.3" {
|
||||
t.Errorf("a joining machine is not on the network: %v", got)
|
||||
}
|
||||
})
|
||||
t.Run("Holdings: a holder on record settles a seat two assignments could hold", func(t *testing.T) {
|
||||
withSeat(t)
|
||||
in := aMesh(t)
|
||||
for _, name := range []string{"holder-one", "holder-two"} {
|
||||
in.Catalogue[name] = aBrokerClaiming(name)
|
||||
}
|
||||
laptop := nodeOf(t, &in, "laptop")
|
||||
laptop.Assigned = append(laptop.Assigned, "holder-one", "holder-two")
|
||||
// Without a key: a machine that does not resolve and holds a complete place keeps it on the network (issue 1833).
|
||||
laptop.Place.Key = ""
|
||||
if got := addressesOf(in); got["laptop"] != "" {
|
||||
t.Fatalf("a machine whose set claims one seat twice is on the network: %v", got)
|
||||
}
|
||||
in.Holdings = []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "laptop", Module: "holder-one"}}
|
||||
if got := addressesOf(in); got["laptop"] != "10.77.0.2" {
|
||||
t.Errorf("a holder on record did not settle the seat: %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/validate"
|
||||
)
|
||||
|
||||
// Declared is a declaration as a machine is sent it (the controller's sendable, sendable.go): its resources and, for an adopted
|
||||
// node, its mode and which modules were taken on it (novox/hq ADR 0100), with what the composition says beside it and never puts on
|
||||
// the wire.
|
||||
//
|
||||
// **Body is the only place the envelope is marshalled**, so the digest the mesh compares and the body validated are one.
|
||||
type Declared struct {
|
||||
Resources []map[string]any
|
||||
// Sequence, Epoch and Generation are what the Input gave the node: zero is not sent at all.
|
||||
Sequence int64
|
||||
Epoch uint64
|
||||
Generation int64
|
||||
// ReadsModules says the machine's node-engine reads a module per resource and a build per module (novox/hq issue 1145): only
|
||||
// then are they sent. ModuleOf is the composer's owner of each resource id and BuildIDs the build each module carries.
|
||||
ReadsModules bool
|
||||
ModuleOf map[string]string
|
||||
BuildIDs map[string]string
|
||||
// Adoption is nil for a converged node, and then the body is byte for byte what it was before adoption existed.
|
||||
Adoption *Adoption
|
||||
// Filled is the path of every file this declaration hands to the provisioner, and Provisioner the account its client runs as
|
||||
// (novox/hq ADR 0349): on the wire as `filled_files` and `provisioner_account`, both absent when nothing is handed.
|
||||
Filled []string
|
||||
Provisioner string
|
||||
// LeftOut is every module of the machine's set left out because a stored setting cannot compose with its definition (novox/hq
|
||||
// ADR 0163, rule 6), sorted; LeftOutWhy is why each was.
|
||||
LeftOut []string
|
||||
LeftOutWhy map[string]string
|
||||
|
||||
// Not on the wire:
|
||||
Received map[string]map[string][]catalogue.Contribution
|
||||
Mesh []string
|
||||
BusUsers string
|
||||
// Modules are the modules this declaration names, sorted.
|
||||
Modules []string
|
||||
// LocalNetwork is every port admitted from the local network and what it resolved to (novox/hq ADR 0340).
|
||||
LocalNetwork []catalogue.LocalNetworkPort
|
||||
Withheld []catalogue.Overflow
|
||||
Unbound []catalogue.Unbound
|
||||
Unplaced []string
|
||||
HandedOnly []string
|
||||
// RequiredBy is, for each handed module only pulled in by another, the module that requires it (issue 765).
|
||||
RequiredBy map[string]string
|
||||
// Foreseen is every own secret composed with a stand-in, as `module/name`; StoodIn is everything the composition stood in for
|
||||
// (an own secret, a grant's sealed copy, the certificate, the bus's user list); ForeseenPorts the ports the mesh may assign that
|
||||
// the Input holds none for. A declaration that has any is never sent: what a send would make, named, never invented.
|
||||
Foreseen []string
|
||||
StoodIn []string
|
||||
ForeseenPorts []string
|
||||
// CoreBoxesWanted are the core credentials whose box the send must ask the vault for and keep.
|
||||
CoreBoxesWanted []CoreBoxWanted
|
||||
// Passes are the seats the mesh would pass on, for the caller to record, and Said the lines found while composing.
|
||||
Passes Passes
|
||||
Said []string
|
||||
}
|
||||
|
||||
// Adoption is what an adopted node is told about its mode. Taken is every module taken on it that it runs; Untaken is, for every
|
||||
// module it runs that is not taken, the ids of every one of that module's resources: what the host keeps as found until the module
|
||||
// is taken (ADR 0103).
|
||||
type Adoption struct {
|
||||
Taken []string `json:"taken"`
|
||||
Untaken map[string][]string `json:"untaken,omitempty"`
|
||||
}
|
||||
|
||||
// Body is the declaration's bytes, as sent and as digested.
|
||||
func (d Declared) Body() ([]byte, error) {
|
||||
envelope := map[string]any{"declaration": 1, "resources": d.Resources}
|
||||
if d.ReadsModules {
|
||||
// The module of each resource the composer owns, and the build of each module (novox/hq issue 1145): to a machine that
|
||||
// said it reads them, and no other.
|
||||
named := make([]map[string]any, len(d.Resources))
|
||||
for n, r := range d.Resources {
|
||||
module, owned := d.ModuleOf[fmt.Sprint(r["id"])]
|
||||
if !owned {
|
||||
named[n] = r
|
||||
continue
|
||||
}
|
||||
with := make(map[string]any, len(r)+1)
|
||||
for k, v := range r {
|
||||
with[k] = v
|
||||
}
|
||||
with["module"] = module
|
||||
named[n] = with
|
||||
}
|
||||
envelope["resources"] = named
|
||||
if len(d.BuildIDs) > 0 {
|
||||
envelope["builds"] = d.BuildIDs
|
||||
}
|
||||
}
|
||||
if d.Adoption != nil {
|
||||
envelope["adoption"] = d.Adoption
|
||||
}
|
||||
if d.Sequence > 0 {
|
||||
envelope["sequence"] = d.Sequence
|
||||
}
|
||||
if d.Epoch > 0 {
|
||||
envelope["epoch"] = d.Epoch
|
||||
}
|
||||
if len(d.LeftOut) > 0 {
|
||||
envelope["left_out"] = d.LeftOut
|
||||
}
|
||||
if d.Generation > 0 {
|
||||
envelope["generation"] = d.Generation
|
||||
}
|
||||
if len(d.Filled) > 0 {
|
||||
envelope["filled_files"] = d.Filled
|
||||
envelope["provisioner_account"] = d.Provisioner
|
||||
}
|
||||
// An empty declaration is deliberate: the node owns nothing the mesh put there (novox/hq issue 127), and the host refuses an
|
||||
// empty body unless it is told the emptiness is meant.
|
||||
if len(d.Resources) == 0 {
|
||||
envelope["owns_nothing"] = true
|
||||
}
|
||||
return json.Marshal(envelope)
|
||||
}
|
||||
|
||||
// Refusals are the findings a check refuses a change for, beside the validator's problems: a consumer left out of the grants
|
||||
// because its identity overflows the provision's bound, and a credential bound to another consumer (novox/hq ADR 0446 clause 1).
|
||||
// Each names the consumer and the provision.
|
||||
func (d Declared) Refusals() []string {
|
||||
var out []string
|
||||
for _, o := range d.Withheld {
|
||||
out = append(out, o.String())
|
||||
}
|
||||
for _, u := range d.Unbound {
|
||||
out = append(out, u.String())
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Compose composes one node's declaration (the issue calls it Node; the Input already has a type of that name) over an Input -- as a push would, without the sending (the controller's
|
||||
// composedAndValidated in the Foreseeing mode) -- and runs the node-engine's own validator over its body. An error is that the
|
||||
// node did not compose (a set that does not compose satisfies IsUnresolvable); problems are what the validator refuses, in the
|
||||
// node-engine's words. It allocates, makes, keeps and writes nothing, and names what a send would do as foreseen.
|
||||
func Compose(in Input, node string) (Declared, []string, error) {
|
||||
return ComposeIn(in, node, Foreseeing)
|
||||
}
|
||||
|
||||
// ComposeIn is Compose in a chosen mode.
|
||||
func ComposeIn(in Input, node string, mode Mode) (Declared, []string, error) {
|
||||
c := newComposer(in)
|
||||
d, err := c.declaration(node, mode)
|
||||
if err != nil {
|
||||
return Declared{}, nil, err
|
||||
}
|
||||
body, err := d.Body()
|
||||
if err != nil {
|
||||
return Declared{}, nil, err
|
||||
}
|
||||
return d, validate.Declaration(body), nil
|
||||
}
|
||||
|
||||
// declaration is the controller's declarationWith over the plain Input.
|
||||
func (c *composer) declaration(nodeName string, mode Mode) (Declared, error) {
|
||||
resolved, err := c.planFor(nodeName)
|
||||
if err != nil {
|
||||
return Declared{}, err
|
||||
}
|
||||
gens, err := c.generators()
|
||||
if err != nil {
|
||||
return Declared{}, err
|
||||
}
|
||||
rendered, err := c.render(nodeName, mode)
|
||||
if err != nil {
|
||||
return Declared{}, err
|
||||
}
|
||||
with := rendered.Rendering
|
||||
with.Generators = gens
|
||||
plan := resolved.Plan
|
||||
composed, err := plan.Compose(with)
|
||||
if err != nil {
|
||||
return Declared{}, err
|
||||
}
|
||||
// Which ports the filter admits from the local network, and from which networks (novox/hq ADR 0340 clause 8): the same rules the
|
||||
// composition rendered, never sent.
|
||||
rules, err := plan.Rules(with)
|
||||
if err != nil {
|
||||
return Declared{}, err
|
||||
}
|
||||
node, err := c.in.node(nodeName)
|
||||
if err != nil {
|
||||
return Declared{}, err
|
||||
}
|
||||
out := Declared{Resources: composed.Resources, Adoption: adoptionFor(plan, node, composed),
|
||||
Received: composed.Received, Mesh: with.Mesh, BusUsers: with.BusUsers,
|
||||
LeftOut: sortedKeysOf(composed.LeftOut), LeftOutWhy: composed.LeftOut,
|
||||
Withheld: rendered.Withheld, Unbound: rendered.Unbound,
|
||||
Foreseen: composed.Foreseen, StoodIn: rendered.Foreseen, ForeseenPorts: rendered.ForeseenPorts,
|
||||
Unplaced: composed.Unplaced, HandedOnly: composed.HandedOnly, Modules: namedModules(plan, composed.LeftOut),
|
||||
Filled: composed.Filled, LocalNetwork: catalogue.LocalNetworkPorts(rules, with.LocalNetworks),
|
||||
CoreBoxesWanted: rendered.CoreBoxesWanted, Said: rendered.Said,
|
||||
Sequence: node.Sequence, Epoch: node.Epoch, Generation: node.Generation}
|
||||
if len(composed.Filled) > 0 {
|
||||
out.Provisioner = with.Provisioner
|
||||
}
|
||||
// A handed module only pulled in by another keeps what requires it, for its value of its own in the assignments mirror (issue 765).
|
||||
for module := range composed.Plans {
|
||||
if by := plan.RequiredBy(module); by != "" {
|
||||
if out.RequiredBy == nil {
|
||||
out.RequiredBy = map[string]string{}
|
||||
}
|
||||
out.RequiredBy[module] = by
|
||||
}
|
||||
}
|
||||
// And the module of each resource and the build of each module, to a machine whose node-engine said it reads them (novox/hq issue
|
||||
// 1145) and to no other.
|
||||
if node.ReadsModules {
|
||||
carried := c.carriedOn(node, plan, composed.LeftOut)
|
||||
out.ReadsModules, out.ModuleOf, out.BuildIDs = true, composed.Owner, c.wireBuilds(carried, node.Runs)
|
||||
}
|
||||
_, passes, left, err := c.theRestOfTheMesh("")
|
||||
if err != nil {
|
||||
return Declared{}, err
|
||||
}
|
||||
out.Passes = passes
|
||||
for _, l := range left {
|
||||
out.Said = append(out.Said, l.String())
|
||||
}
|
||||
out.Said = c.saidBeside(out.Said)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// carriedOn is the build of each module a declaration for the node carries (plan.go carriedOn, in a question that sends nothing): the
|
||||
// module's current build for each module in it, and for a module left out of it the build it was last sent, since the machine keeps
|
||||
// that one -- or nothing, when that is not known.
|
||||
func (c *composer) carriedOn(node Node, plan catalogue.Resolution, leftOut map[string]string) map[string]string {
|
||||
before := node.SentBuilds
|
||||
if !node.SentKnown {
|
||||
before = nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, m := range plan.Modules {
|
||||
if _, left := leftOut[m.Module]; left {
|
||||
if was, kept := before[m.Module]; kept {
|
||||
out[m.Module] = was
|
||||
}
|
||||
continue
|
||||
}
|
||||
out[m.Module] = c.in.CurrentBuilds[m.Module]
|
||||
// A module kept at the build the machine runs is carried at that build (plan.go carriedOn).
|
||||
if kept, ok := node.KeptBuilds[m.Module]; ok && kept != "" {
|
||||
out[m.Module] = kept
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// wireBuilds is the build of each carried module that goes on the wire (novox/hq issue 1145): the build its assignment names when
|
||||
// that assignment is of the commit carried, else the build made from that commit; a module with neither is not named.
|
||||
func (c *composer) wireBuilds(carried map[string]string, runs map[string]Run) map[string]string {
|
||||
out := map[string]string{}
|
||||
for module, commit := range carried {
|
||||
if commit == "" {
|
||||
continue
|
||||
}
|
||||
if ref, ok := runs[module]; ok && ref.Commit == commit && ref.Build != "" {
|
||||
out[module] = ref.Build
|
||||
continue
|
||||
}
|
||||
if build := c.in.BuildIDs[module][commit]; build != "" {
|
||||
out[module] = build
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// adoptionFor is the envelope computed from what was taken and who owns each resource: nil for a converged node. Every module the
|
||||
// node runs that is not taken is untaken -- including one pulled in by another rather than assigned.
|
||||
func adoptionFor(plan catalogue.Resolution, node Node, composed catalogue.Composed) *Adoption {
|
||||
if !node.Adopted {
|
||||
return nil
|
||||
}
|
||||
isTaken := map[string]bool{}
|
||||
for _, m := range node.Taken {
|
||||
isTaken[m] = true
|
||||
}
|
||||
out := &Adoption{Taken: []string{}}
|
||||
runs := map[string]bool{}
|
||||
for _, m := range plan.Modules {
|
||||
runs[m.Module] = true
|
||||
if isTaken[m.Module] {
|
||||
out.Taken = append(out.Taken, m.Module)
|
||||
}
|
||||
}
|
||||
sort.Strings(out.Taken)
|
||||
for _, r := range composed.Resources {
|
||||
id, _ := r["id"].(string)
|
||||
module, owned := composed.Owner[id]
|
||||
// Every kind, not only files and containers (novox/hq ADR 0103). What the mesh declares of its own is never held.
|
||||
if !owned || !runs[module] || isTaken[module] || strings.HasPrefix(id, catalogue.AdoptionPrefix) {
|
||||
continue
|
||||
}
|
||||
if out.Untaken == nil {
|
||||
out.Untaken = map[string][]string{}
|
||||
}
|
||||
out.Untaken[module] = append(out.Untaken[module], id)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sortedKeysOf is a map's keys, sorted: so what a declaration says it left out does not move for a reordering nobody made.
|
||||
func sortedKeysOf(m map[string]string) []string {
|
||||
if len(m) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// namedModules are the modules a declaration names: its machine's set, less what was left out of it.
|
||||
func namedModules(plan catalogue.Resolution, leftOut map[string]string) []string {
|
||||
out := make([]string, 0, len(plan.Modules))
|
||||
for _, m := range plan.Modules {
|
||||
if _, left := leftOut[m.Module]; !left {
|
||||
out = append(out, m.Module)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DataItems is every data item the modules placed on a node declare, placed there (novox/hq ADR 0571 clause 3): what the
|
||||
// node-backup holder is handed as `${backup:data}`, composed over the Input as Compose composes the node, so disk-load
|
||||
// reads the list from the bus with no composed file. Unplaced names each item left out and why.
|
||||
func DataItems(in Input, node string) (map[string][]catalogue.PlacedDataItem, []string, error) {
|
||||
c := newComposer(in)
|
||||
resolved, err := c.planFor(node)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
rendered, err := c.render(node, Planning)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return resolved.Plan.DataItems(rendered.Rendering)
|
||||
}
|
||||
@@ -1,424 +0,0 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
)
|
||||
|
||||
func resourceIDs(d Declared) []string {
|
||||
var ids []string
|
||||
for _, r := range d.Resources {
|
||||
ids = append(ids, r["type"].(string)+":"+r["id"].(string))
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func has(ids []string, want string) bool {
|
||||
for _, id := range ids {
|
||||
if id == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// aSmallMesh is three nodes, a provider and its consumer, a seat and the private network: aMesh with `ace` running a database and
|
||||
// a resolver holding the mesh's resolver seat on record (the providers and consumer of mesh-controller's facts_test.go
|
||||
// aMeshWithSecrets, the seat of bindings_test.go storeManifests).
|
||||
func aSmallMesh(t *testing.T) Input {
|
||||
t.Helper()
|
||||
in := threeNodes(t)
|
||||
for _, m := range storeManifests() {
|
||||
if m.Module == "resolver" {
|
||||
in.Catalogue[m.Module] = m
|
||||
}
|
||||
}
|
||||
ace := nodeOf(t, &in, "ace")
|
||||
ace.Assigned = append(ace.Assigned, "resolver")
|
||||
in.Holdings = []catalogue.Held{{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "ace", Module: "resolver", Site: "there"}}
|
||||
return in
|
||||
}
|
||||
|
||||
// 1298/AC1: each node of a small mesh composes, its body is accepted by the node-engine's validator, and it names the resources the
|
||||
// controller composes for the same case: the private network's files on the nodes that run it, the provider's received file, the
|
||||
// consumer's file.
|
||||
func TestEachNodeOfASmallMeshComposesAndItsBodyIsAcceptedByTheValidator(t *testing.T) {
|
||||
in := aSmallMesh(t)
|
||||
want := map[string][]string{
|
||||
"anchor": {"package:mesh-wireguard.overlay-tools", "file:mesh-wireguard.overlay-config", "service:mesh-wireguard.overlay-up", "file:objects.received-s3-bucket"},
|
||||
"laptop": {"package:mesh-wireguard.overlay-tools", "file:mesh-wireguard.overlay-config", "service:mesh-wireguard.overlay-up", "file:files.config"},
|
||||
"ace": nil,
|
||||
}
|
||||
for node, ids := range want {
|
||||
d, problems, err := Compose(in, node)
|
||||
if err != nil || len(problems) != 0 {
|
||||
t.Fatalf("%s: %v %v", node, err, problems)
|
||||
}
|
||||
for _, id := range ids {
|
||||
if !has(resourceIDs(d), id) {
|
||||
t.Errorf("%s does not name %s: %v", node, id, resourceIDs(d))
|
||||
}
|
||||
}
|
||||
body, err := d.Body()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var parsed struct {
|
||||
Declaration int `json:"declaration"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &parsed); err != nil || parsed.Declaration != 1 || len(parsed.Resources) != len(d.Resources) {
|
||||
t.Errorf("%s: body %s %v", node, body, err)
|
||||
}
|
||||
}
|
||||
// ace runs a resolver and a database and nothing the mesh declares of its own: an empty declaration says it owns nothing.
|
||||
d, _, _ := Compose(in, "ace")
|
||||
if body, _ := d.Body(); !strings.Contains(string(body), `"owns_nothing":true`) {
|
||||
t.Errorf("an empty declaration is not said to be deliberate: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 1298/AC2 (mesh-controller merge_gate_integration_test.go TestIssue236AManifestTheNodeEngineRefusesFailsThePullRequest, the manifest):
|
||||
// a service that omits its state is accepted by the catalogue's check and refused by the node-engine; Compose returns the validator's
|
||||
// problems in the node-engine's words and no error.
|
||||
func TestAManifestTheNodeEngineRefusesComesBackAsProblemsAndNoError(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
var lemurs catalogue.Manifest
|
||||
if err := json.Unmarshal([]byte(`{"module":"lemurs","version":"1","capabilities":["systemd"],
|
||||
"resources":[{"id":"service","type":"service","unit":"lemurs.service","boot":"enabled"}]}`), &lemurs); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in.Catalogue["lemurs"] = lemurs
|
||||
laptop := nodeOf(t, &in, "laptop")
|
||||
laptop.Assigned = append(laptop.Assigned, "lemurs")
|
||||
d, problems, err := Compose(in, "laptop")
|
||||
if err != nil {
|
||||
t.Fatalf("a refusal of the validator is an error: %v", err)
|
||||
}
|
||||
if len(problems) == 0 || !strings.Contains(strings.Join(problems, "\n"), "lemurs.service") {
|
||||
t.Fatalf("problems are %v", problems)
|
||||
}
|
||||
if !has(resourceIDs(d), "service:lemurs.service") {
|
||||
t.Errorf("the declaration does not carry the resource: %v", resourceIDs(d))
|
||||
}
|
||||
// And the same, as the validator says it of the body.
|
||||
body, _ := d.Body()
|
||||
if again := strings.Join(problems, "\n"); again == "" || !strings.Contains(string(body), "lemurs.service") {
|
||||
t.Errorf("the body does not name the service: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 1298/AC3: a node whose own set does not compose returns an error and no declaration, told apart from an unreadable Input.
|
||||
func TestANodeWhoseSetDoesNotComposeReturnsAnErrorAndNoDeclaration(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
in.Catalogue["rival-one"] = catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
|
||||
in.Catalogue["rival-two"] = catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
|
||||
laptop := nodeOf(t, &in, "laptop")
|
||||
laptop.Assigned = append(laptop.Assigned, "rival-one", "rival-two")
|
||||
d, problems, err := Compose(in, "laptop")
|
||||
if err == nil || !IsUnresolvable(err) || d.Resources != nil || problems != nil {
|
||||
t.Fatalf("a set that does not compose: %v %v %v", d.Resources, problems, err)
|
||||
}
|
||||
if _, _, err := Compose(in, "nowhere"); err == nil || IsUnresolvable(err) {
|
||||
t.Fatalf("a node the Input does not have: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1298/AC4: composing the same Input twice gives byte-identical bodies, and the Input is unchanged.
|
||||
func TestComposingTwiceGivesTheSameBytesAndChangesNothing(t *testing.T) {
|
||||
mk := func() Input {
|
||||
in := aSmallMesh(t)
|
||||
nodeOf(t, &in, "anchor").Account = "jochens"
|
||||
nodeOf(t, &in, "laptop").Sequence = 7
|
||||
return in
|
||||
}
|
||||
in := mk()
|
||||
for _, node := range []string{"anchor", "laptop", "ace"} {
|
||||
a, _, err := Compose(in, node)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _, err := Compose(in, node)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ab, _ := a.Body()
|
||||
bb, _ := b.Body()
|
||||
if string(ab) != string(bb) {
|
||||
t.Errorf("%s: two compositions differ:\n%s\n%s", node, ab, bb)
|
||||
}
|
||||
}
|
||||
unchanged(t, mk, func(in Input) {
|
||||
for _, node := range []string{"anchor", "laptop", "ace"} {
|
||||
if _, _, err := Compose(in, node); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 1298/AC5: what a send would make is named in the result as foreseen and never invented: an own secret, a port, a core box, a grant's
|
||||
// sealed copy.
|
||||
func TestWhatASendWouldMakeIsNamedAsForeseenAndNeverInvented(t *testing.T) {
|
||||
in := aGrantMesh(t)
|
||||
in.Issued[0].ForProvider = ""
|
||||
in.Catalogue["assistant"] = anAssistant()
|
||||
in.Catalogue["web"] = aWebModule()
|
||||
in.Catalogue["nats"] = catalogue.Manifest{Module: "nats", Version: "1",
|
||||
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/nats/broker", Taken: catalogue.TakenAtStart}}}
|
||||
anchor := nodeOf(t, &in, "anchor")
|
||||
anchor.Assigned = append(anchor.Assigned, "assistant", "web", "nats")
|
||||
anchor.SealingKey = keyOf(t, "anchor")
|
||||
anchor.Stated, anchor.Contract = true, VaultBoxContract
|
||||
in.BusMinted = []string{"anchor.nats"}
|
||||
d, problems, err := Compose(in, "anchor")
|
||||
if err != nil || len(problems) != 0 {
|
||||
t.Fatalf("%v %v", err, problems)
|
||||
}
|
||||
if !has(d.Foreseen, "assistant/password") || !has(d.Foreseen, "nats/broker") {
|
||||
t.Errorf("own secrets foreseen: %v", d.Foreseen)
|
||||
}
|
||||
if len(d.ForeseenPorts) != 1 || d.ForeseenPorts[0] != "web:8080" {
|
||||
t.Errorf("foreseen ports: %v", d.ForeseenPorts)
|
||||
}
|
||||
if len(d.CoreBoxesWanted) != 1 || d.CoreBoxesWanted[0].Module != "nats" {
|
||||
t.Errorf("core boxes wanted: %v", d.CoreBoxesWanted)
|
||||
}
|
||||
if !has(d.StoodIn, "the grant of s3-bucket for files on laptop") {
|
||||
t.Errorf("stood in for: %v", d.StoodIn)
|
||||
}
|
||||
body, _ := d.Body()
|
||||
if strings.Contains(string(body), secretValue) {
|
||||
t.Error("a secret value is in the body")
|
||||
}
|
||||
}
|
||||
|
||||
// 1298/AC6: the body carries the sequence, epoch and generation the Input gives, and the module of each resource and the carried builds
|
||||
// only to a node whose Input says it reads modules (novox/hq issue 1145).
|
||||
func TestTheBodyCarriesTheOrderTheInputGivesAndTheBuildsOnlyToAnEngineThatReadsThem(t *testing.T) {
|
||||
in := aSmallMesh(t)
|
||||
laptop := nodeOf(t, &in, "laptop")
|
||||
laptop.Sequence, laptop.Epoch, laptop.Generation = 42, 57, 9
|
||||
in.CurrentBuilds = map[string]string{"files": "commit-files", "mesh-wireguard": "commit-wg"}
|
||||
in.BuildIDs = map[string]map[string]string{"files": {"commit-files": "build-7"}}
|
||||
// An assignment of another commit than the one carried does not name the build; the build made from the commit carried does.
|
||||
laptop.Runs = map[string]Run{"mesh-wireguard": {Commit: "commit-wg", Build: "build-9"}, "files": {Commit: "an-older-commit", Build: "build-6"}}
|
||||
d, _, err := Compose(in, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := d.Body()
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(body, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["sequence"] != float64(42) || got["epoch"] != float64(57) || got["generation"] != float64(9) {
|
||||
t.Errorf("the order is %v %v %v", got["sequence"], got["epoch"], got["generation"])
|
||||
}
|
||||
if _, there := got["builds"]; there {
|
||||
t.Errorf("builds are sent to an engine that does not read them: %s", body)
|
||||
}
|
||||
for _, r := range got["resources"].([]any) {
|
||||
if _, there := r.(map[string]any)["module"]; there {
|
||||
t.Errorf("a module is named on a resource of an engine that does not read them: %v", r)
|
||||
}
|
||||
}
|
||||
laptop.ReadsModules = true
|
||||
d, _, err = Compose(in, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ = d.Body()
|
||||
got = nil
|
||||
if err := json.Unmarshal(body, &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
builds, _ := got["builds"].(map[string]any)
|
||||
if builds["files"] != "build-7" || builds["mesh-wireguard"] != "build-9" {
|
||||
t.Errorf("builds are %v", builds)
|
||||
}
|
||||
named := false
|
||||
for _, r := range got["resources"].([]any) {
|
||||
if r.(map[string]any)["id"] == "files.config" && r.(map[string]any)["module"] == "files" {
|
||||
named = true
|
||||
}
|
||||
}
|
||||
if !named {
|
||||
t.Errorf("a resource is not named for its module: %s", body)
|
||||
}
|
||||
// Zero is not sent at all.
|
||||
laptop.Sequence, laptop.Epoch, laptop.Generation = 0, 0, 0
|
||||
d, _, _ = Compose(in, "laptop")
|
||||
body, _ = d.Body()
|
||||
for _, key := range []string{`"sequence"`, `"epoch"`, `"generation"`} {
|
||||
if strings.Contains(string(body), key) {
|
||||
t.Errorf("%s is sent as zero: %s", key, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A module left out of a build set keeps the build it was last sent (novox/hq issue 259): the machine keeps that one.
|
||||
func TestAModuleLeftOutKeepsTheBuildItWasLastSent(t *testing.T) {
|
||||
c := newComposer(Input{CurrentBuilds: map[string]string{"a": "new-a", "b": "new-b"}})
|
||||
plan := catalogue.Resolution{Modules: []catalogue.Manifest{{Module: "a"}, {Module: "b"}, {Module: "c"}}}
|
||||
node := Node{SentBuilds: map[string]string{"b": "old-b", "c": "old-c"}, SentKnown: true}
|
||||
got := c.carriedOn(node, plan, map[string]string{"b": "its settings cannot compose"})
|
||||
if got["a"] != "new-a" || got["b"] != "old-b" || got["c"] != "" {
|
||||
t.Errorf("carried builds are %v", got)
|
||||
}
|
||||
node.SentKnown = false
|
||||
if got := c.carriedOn(node, plan, map[string]string{"b": "x"}); len(got) != 2 || got["b"] != "" {
|
||||
t.Errorf("when what was sent is not known: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An adopted node is told which modules were taken and, for every module it runs that is not, the ids of its resources
|
||||
// (novox/hq ADR 0100 and 0103); a converged node is told nothing, and its body is as it was before adoption.
|
||||
func TestAnAdoptedNodeIsToldWhatWasTakenAndWhatIsKept(t *testing.T) {
|
||||
in := aSmallMesh(t)
|
||||
d, _, err := Compose(in, "laptop")
|
||||
if err != nil || d.Adoption != nil {
|
||||
t.Fatalf("a converged node: %v %v", d.Adoption, err)
|
||||
}
|
||||
laptop := nodeOf(t, &in, "laptop")
|
||||
laptop.Adopted, laptop.Taken = true, []string{"mesh-wireguard"}
|
||||
d, problems, err := Compose(in, "laptop")
|
||||
if err != nil || len(problems) != 0 {
|
||||
t.Fatalf("%v %v", err, problems)
|
||||
}
|
||||
if d.Adoption == nil || len(d.Adoption.Taken) != 1 || d.Adoption.Taken[0] != "mesh-wireguard" ||
|
||||
len(d.Adoption.Untaken["files"]) == 0 || d.Adoption.Untaken["mesh-wireguard"] != nil {
|
||||
t.Fatalf("adoption is %+v", d.Adoption)
|
||||
}
|
||||
if body, _ := d.Body(); !strings.Contains(string(body), `"adoption"`) {
|
||||
t.Errorf("the adoption is not on the wire: %s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 263 (merge_gate_integration_test.go TestIssue263AnIdentityARealMachineNameOverflows...): a change makes the network manager
|
||||
// require the object store's provision; on a machine whose name is six characters its identity is 26 against a bound of 20. The
|
||||
// composition of the provider returns the refusal, naming the module and the provision.
|
||||
func TestIssue263AnIdentityARealMachineNameOverflowsIsARefusal(t *testing.T) {
|
||||
in := aGrantMesh(t)
|
||||
in.Catalogue["resolver"] = storeManifests()[1]
|
||||
in.Catalogue["networkmanager"] = catalogue.Manifest{Module: "networkmanager", Version: "1", Requires: []string{"wildcard-resolution", "s3-bucket"}}
|
||||
laptop, anchor := nodeOf(t, &in, "laptop"), nodeOf(t, &in, "anchor")
|
||||
laptop.Assigned = append(laptop.Assigned, "networkmanager")
|
||||
anchor.Assigned = append(anchor.Assigned, "resolver")
|
||||
in.Holdings = []catalogue.Held{{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "resolver", Site: "here"}}
|
||||
in.Issued = append(in.Issued, Issued{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "networkmanager", Provider: "anchor", ForProvider: "sealed"})
|
||||
d, problems, err := Compose(in, "anchor")
|
||||
if err != nil || len(problems) != 0 {
|
||||
t.Fatalf("%v %v", err, problems)
|
||||
}
|
||||
refusals := strings.Join(d.Refusals(), "\n")
|
||||
if !strings.Contains(refusals, "networkmanager") || !strings.Contains(refusals, "s3-bucket") || !strings.Contains(refusals, "laptop") {
|
||||
t.Fatalf("the refusal does not name the module, the provision and the machine: %q", refusals)
|
||||
}
|
||||
// And the consumer that fits is granted.
|
||||
if len(d.Withheld) != 1 || d.Withheld[0].Module != "networkmanager" {
|
||||
t.Errorf("withheld %+v", d.Withheld)
|
||||
}
|
||||
}
|
||||
|
||||
// Round 1, finding 1 (plan.go keepRecorded): a recorded module kept at the build a machine runs is that machine's own resolution
|
||||
// and no other's; the commit carried for it is the kept one.
|
||||
func TestAKeptModuleChangesOnlyTheResolutionOfTheNodeThatKeepsIt(t *testing.T) {
|
||||
in := aSmallMesh(t)
|
||||
in.Catalogue["tool"] = catalogue.Manifest{Module: "tool", Version: "2"}
|
||||
for _, name := range []string{"anchor", "laptop"} {
|
||||
n := nodeOf(t, &in, name)
|
||||
n.Assigned = append(n.Assigned, "tool")
|
||||
}
|
||||
anchor := nodeOf(t, &in, "anchor")
|
||||
anchor.Kept = map[string]catalogue.Manifest{"tool": {Module: "tool", Version: "1"}}
|
||||
versionOn := func(in Input, node string) string {
|
||||
t.Helper()
|
||||
got, err := Resolve(in, node)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range got.Plan.Modules {
|
||||
if m.Module == "tool" {
|
||||
return m.Version
|
||||
}
|
||||
}
|
||||
t.Fatalf("no tool on %s", node)
|
||||
return ""
|
||||
}
|
||||
if v := versionOn(in, "anchor"); v != "1" {
|
||||
t.Errorf("the node that keeps it runs version %s", v)
|
||||
}
|
||||
if v := versionOn(in, "laptop"); v != "2" {
|
||||
t.Errorf("another node runs version %s: a kept build leaked", v)
|
||||
}
|
||||
if got := in.Catalogue["tool"].Version; got != "2" {
|
||||
t.Errorf("the Input's catalogue was changed: %s", got)
|
||||
}
|
||||
// And the commit carried for it is the kept one.
|
||||
c := newComposer(in)
|
||||
plan := catalogue.Resolution{Modules: []catalogue.Manifest{{Module: "tool"}, {Module: "other"}}}
|
||||
in.CurrentBuilds = map[string]string{"tool": "current", "other": "current-other"}
|
||||
c = newComposer(in)
|
||||
node := Node{KeptBuilds: map[string]string{"tool": "kept-commit"}}
|
||||
if got := c.carriedOn(node, plan, nil); got["tool"] != "kept-commit" || got["other"] != "current-other" {
|
||||
t.Errorf("carried builds are %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Another node's kept build changes neither the rest of the mesh nor who is on the network: the controller reads both with the
|
||||
// current manifests, and only then keeps a recorded module for the node it composes (recorded_kept.go keepRecorded).
|
||||
func TestAnotherNodesKeptBuildDoesNotChangeTheRestOfTheMeshOrWhoIsOnTheNetwork(t *testing.T) {
|
||||
in := threeNodes(t)
|
||||
nodeOf(t, &in, "anchor").Kept = map[string]catalogue.Manifest{"objects": {Module: "objects", Version: "0"}, overlayName(): {Module: overlayName(), Version: "0"}}
|
||||
w, _, _, err := TheRestOfTheMesh(in, "laptop")
|
||||
if err != nil || len(providers(w, "s3-bucket")) != 1 {
|
||||
t.Errorf("a kept build changed what anchor offers: %v %v", providers(w, "s3-bucket"), err)
|
||||
}
|
||||
where, err := newComposer(in).whereEveryoneIs()
|
||||
if err != nil || where["anchor"] == "" {
|
||||
t.Errorf("a kept build took anchor off the network: %v %v", where, err)
|
||||
}
|
||||
}
|
||||
|
||||
func overlayName() string { return overlay.Name }
|
||||
|
||||
// Round 1, finding 6: the nodes the composition did not count as on the network are returned, said, not lost. laptop holds no
|
||||
// complete place (no key): one that does keeps it on the network (issue 1833) and is counted.
|
||||
func TestANodeNotCountedAsOnTheNetworkIsSaidInTheResult(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
nodeOf(t, &in, "laptop").Place.Key = ""
|
||||
in.Catalogue["rival-one"] = catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
|
||||
in.Catalogue["rival-two"] = catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
|
||||
laptop := nodeOf(t, &in, "laptop")
|
||||
laptop.Assigned = append(laptop.Assigned, "rival-one", "rival-two")
|
||||
said := func(lines []string) bool {
|
||||
for _, l := range lines {
|
||||
if strings.HasPrefix(l, "laptop is not counted as on the network") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
got, err := Render(in, "anchor", Foreseeing)
|
||||
if err != nil || !said(got.Said) {
|
||||
t.Errorf("rendered: %v %v", got.Said, err)
|
||||
}
|
||||
d, _, err := Compose(in, "anchor")
|
||||
if err != nil || !said(d.Said) {
|
||||
t.Errorf("declared: %v %v", d.Said, err)
|
||||
}
|
||||
n := 0
|
||||
for _, l := range d.Said {
|
||||
if strings.HasPrefix(l, "laptop is not counted") {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("said %d times: %v", n, d.Said)
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
)
|
||||
|
||||
// fourOnTheNetwork is aMesh and two more machines on the private network at the site `there`: `ace`, dialled at
|
||||
// ace.example:51820, at 10.77.0.3, and `box`, behind no endpoint, at 10.77.0.4. A module `office` requires
|
||||
// `document-server`, which nothing in the catalogue provides: the shape of issue 1833 (nextcloud on the hub wanting a
|
||||
// document server nobody offered).
|
||||
func fourOnTheNetwork(t *testing.T) Input {
|
||||
t.Helper()
|
||||
in := aMesh(t)
|
||||
in.Catalogue["office"] = catalogue.Manifest{Module: "office", Version: "1", Requires: []string{"document-server"}}
|
||||
caps := map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true}
|
||||
in.Nodes = append(in.Nodes,
|
||||
Node{Name: "ace", Place: Place{Key: keyOf(t, "ace"), Endpoint: "ace.example:51820", Site: "there", Address: "10.77.0.3"},
|
||||
Assigned: []string{overlay.Name}, Capabilities: caps},
|
||||
Node{Name: "box", Place: Place{Key: keyOf(t, "box"), Site: "there", Address: "10.77.0.4"},
|
||||
Assigned: []string{overlay.Name}, Capabilities: caps})
|
||||
return in
|
||||
}
|
||||
|
||||
// unresolvableBy makes one machine's own set fail to resolve: it is also given `office`, whose requirement nothing provides.
|
||||
func unresolvableBy(t *testing.T, in *Input, name string) {
|
||||
t.Helper()
|
||||
n := nodeOf(t, in, name)
|
||||
n.Assigned = append(n.Assigned, "office")
|
||||
}
|
||||
|
||||
// networkOf is the generator of an Input's private network, failing the test when it is not composed.
|
||||
func networkOf(t *testing.T, in Input) *overlay.Generator {
|
||||
t.Helper()
|
||||
gens, err := Generators(in)
|
||||
if err != nil {
|
||||
t.Fatalf("the private network was not composed: %v", err)
|
||||
}
|
||||
return gens[overlay.Name].(*overlay.Generator)
|
||||
}
|
||||
|
||||
func placesOf(g *overlay.Generator) map[string]overlay.Node {
|
||||
out := map[string]overlay.Node{}
|
||||
for _, n := range g.Nodes() {
|
||||
out[n.Name] = n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 1833/AC1: four machines, the hub's own set does not resolve; the other three compose their declarations, and the hub alone is
|
||||
// refused, with an error that is its own set failing and names what could not be resolved.
|
||||
func TestOneMachineThatDoesNotResolveIsRefusedAlone(t *testing.T) {
|
||||
in := fourOnTheNetwork(t)
|
||||
unresolvableBy(t, &in, "anchor")
|
||||
for _, node := range []string{"laptop", "ace", "box"} {
|
||||
d, problems, err := Compose(in, node)
|
||||
if err != nil || len(problems) != 0 {
|
||||
t.Errorf("%s does not compose because another machine does not resolve: %v %v", node, err, problems)
|
||||
continue
|
||||
}
|
||||
if !has(resourceIDs(d), "file:mesh-wireguard.overlay-config") {
|
||||
t.Errorf("%s is given no private network: %v", node, resourceIDs(d))
|
||||
}
|
||||
}
|
||||
_, _, err := Compose(in, "anchor")
|
||||
if err == nil || !IsUnresolvable(err) {
|
||||
t.Fatalf("the machine that does not resolve is not refused as its own set failing: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), `nothing provides "document-server", wanted by office`) {
|
||||
t.Errorf("the refusal does not name what could not be resolved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1833/AC2: the private network is composed with every machine on it: the hub that does not resolve keeps its place (key,
|
||||
// address, endpoint, site, hub role), so every other machine keeps its path through it.
|
||||
func TestAMachineThatDoesNotResolveKeepsItsPlaceOnTheNetwork(t *testing.T) {
|
||||
in := fourOnTheNetwork(t)
|
||||
unresolvableBy(t, &in, "anchor")
|
||||
got := placesOf(networkOf(t, in))
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("the network is over %v, want all four machines", got)
|
||||
}
|
||||
hub := got["anchor"]
|
||||
if !hub.Hub || hub.Key != keyOf(t, "anchor") || hub.Address != "10.77.0.1" || hub.Endpoint != "anchor.example:51820" || hub.Site != "here" {
|
||||
t.Errorf("the hub did not keep its place: %+v", hub)
|
||||
}
|
||||
nodes, graph, err := Graph(in)
|
||||
if err != nil || len(nodes) != 4 {
|
||||
t.Fatalf("the graph is not shown with every machine: %v %v", nodes, err)
|
||||
}
|
||||
for _, node := range []string{"ace", "box"} {
|
||||
through := false
|
||||
for _, p := range graph[node] {
|
||||
through = through || (p.Name == "anchor" && p.Endpoint == "anchor.example:51820")
|
||||
}
|
||||
if !through {
|
||||
t.Errorf("%s has no path through the hub: %+v", node, graph[node])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1833/AC3: a machine that does not resolve and was never placed (a new machine: no key, no address) is left off the network,
|
||||
// which is composed from the rest -- here with the hub that does not resolve on its last place -- and that machine alone is refused.
|
||||
func TestANewMachineThatDoesNotResolveIsLeftOffAndRefusedAlone(t *testing.T) {
|
||||
in := fourOnTheNetwork(t)
|
||||
unresolvableBy(t, &in, "anchor")
|
||||
in.Nodes = append(in.Nodes, Node{Name: "fresh", Place: Place{Site: "there"},
|
||||
Assigned: []string{overlay.Name, "office"}, Capabilities: map[string]bool{"wireguard": true, "systemd": true}})
|
||||
got := placesOf(networkOf(t, in))
|
||||
if _, on := got["fresh"]; on || len(got) != 4 {
|
||||
t.Fatalf("the network is over %v, want the four placed machines without fresh", got)
|
||||
}
|
||||
if _, _, err := Compose(in, "fresh"); err == nil || !IsUnresolvable(err) {
|
||||
t.Errorf("the new machine is not refused as its own set failing: %v", err)
|
||||
}
|
||||
if _, problems, err := Compose(in, "box"); err != nil || len(problems) != 0 {
|
||||
t.Errorf("another machine does not compose: %v %v", err, problems)
|
||||
}
|
||||
}
|
||||
|
||||
// 1833/AC4: a network that truly cannot be composed from what remains still fails: the hub does not resolve and has no
|
||||
// complete place (no key), so nothing is the hub; the refusal is today's, naming the machine that could not be resolved.
|
||||
func TestANetworkThatCannotBeComposedFromWhatRemainsStillFails(t *testing.T) {
|
||||
in := fourOnTheNetwork(t)
|
||||
unresolvableBy(t, &in, "anchor")
|
||||
nodeOf(t, &in, "anchor").Place.Key = ""
|
||||
_, err := Generators(in)
|
||||
if err == nil {
|
||||
t.Fatal("a network without a hub was composed")
|
||||
}
|
||||
for _, want := range []string{overlay.ErrNoHub.Error(), "and 1 node(s) could not be resolved at all, which is probably why:", " anchor: "} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not say %q:\n%v", want, err)
|
||||
}
|
||||
}
|
||||
if _, _, err := Compose(in, "laptop"); err == nil {
|
||||
t.Error("a declaration was composed over a network that cannot be")
|
||||
}
|
||||
}
|
||||
|
||||
// 1833/AC2, a failure the composition's unchecked pass refuses too (two modules claiming one seat on the hub, the 1360/AC3
|
||||
// shape): the hub keeps its place in what every other machine is sent besides the tunnel -- its internal name and its address
|
||||
// among the sources "from the mesh" -- and another machine still composes.
|
||||
func TestAMachineThatDoesNotResolveKeepsItsNameAndItsAddressInTheMesh(t *testing.T) {
|
||||
in := fourOnTheNetwork(t)
|
||||
in.Catalogue["rival-one"] = catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
|
||||
in.Catalogue["rival-two"] = catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
|
||||
hub := nodeOf(t, &in, "anchor")
|
||||
hub.Assigned = append(hub.Assigned, "rival-one", "rival-two")
|
||||
if _, err := Resolve(in, "anchor"); !IsUnresolvable(err) {
|
||||
t.Fatalf("the hub is meant not to resolve: %v", err)
|
||||
}
|
||||
c := newComposer(in)
|
||||
if got := c.namesInTheMesh()["anchor."+c.suffix()]; got != "10.77.0.1" {
|
||||
t.Errorf("the hub's name is not in the mesh's names: %v", c.namesInTheMesh())
|
||||
}
|
||||
if got := c.onThePrivateNetwork(); !slices.Contains(got, "10.77.0.1") || len(got) != 4 {
|
||||
t.Errorf("the sources from the mesh are %v, want all four machines with the hub", got)
|
||||
}
|
||||
if _, problems, err := Compose(in, "ace"); err != nil || len(problems) != 0 {
|
||||
t.Errorf("another machine does not compose: %v %v", err, problems)
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// Rendered is everything a node's declaration is composed with (plan.go renderingFor), over an Input: the place half (Places)
|
||||
// and the credential half. Nothing was allocated, made, kept or written; what a send would do is returned for the caller to do.
|
||||
type Rendered struct {
|
||||
Rendering catalogue.Rendering
|
||||
// ForeseenPorts are the ports the mesh may assign that the Input holds none for ("<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
|
||||
}
|
||||
@@ -1,543 +0,0 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
)
|
||||
|
||||
// The contracts of the node-engine's statement that gate what a machine is sent, as the controller's link package names them
|
||||
// (mesh-controller internal/link/protocol.go at 6c2a8d62). An older engine parses strictly and refuses a field it does not
|
||||
// know, whole.
|
||||
const (
|
||||
ReadinessContract = 2 // the engine reads a resource's `health` (novox/hq ADR 0240)
|
||||
RootContract = 3 // the engine judges a user's declared `root` (ADR 0266)
|
||||
BusyContract = 8 // the engine reads a container's `busy` (ADR 0335)
|
||||
HandedContract = 11 // the engine writes a filled file on the provisioner client's word alone
|
||||
VaultBoxContract = 12 // the engine opens the vault's box
|
||||
)
|
||||
|
||||
// The provisioner client's seat and own secret (the controller's broker.NodeProvisionerSeat and ProvisionerSecret).
|
||||
const (
|
||||
NodeProvisionerSeat = "node-provisioner"
|
||||
ProvisionerSecret = "provisioner-broker"
|
||||
)
|
||||
|
||||
// ErrPortTaken is the controller's refusal for a machine port held twice on one machine.
|
||||
var ErrPortTaken = errors.New("that port is already held on this machine")
|
||||
|
||||
// Placed is the place half of a rendering (plan.go renderingFor): where a machine puts what its modules need reachable, the
|
||||
// seats and who holds them, the names of the mesh, the networks, accounts and the operator's export. It is a catalogue.Rendering
|
||||
// with those fields set, and the ports it could not read named foreseen. Nothing was allocated.
|
||||
type Placed struct {
|
||||
Rendering catalogue.Rendering
|
||||
// ForeseenPorts are the ports the mesh may assign that the Input holds none for, as "<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 = catalogue.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 := catalogue.GivenPorts(m, settings[m.Module])
|
||||
if err != nil {
|
||||
// A given port its definition no longer publishes: the module is left out of the declaration, by name, when it is
|
||||
// composed (novox/hq ADR 0163, rule 6) -- never the machine refused here for it.
|
||||
continue
|
||||
}
|
||||
if g != nil {
|
||||
given[m.Module] = g
|
||||
}
|
||||
}
|
||||
holders := map[int]string{}
|
||||
for _, m := range plan.Modules {
|
||||
for _, at := range given[m.Module] {
|
||||
if other, twice := holders[at]; twice && other != m.Module {
|
||||
return nil, fmt.Errorf("%w: %s and %s are both given machine port %d on %s", ErrPortTaken, other, m.Module,
|
||||
at, node.Name)
|
||||
}
|
||||
holders[at] = m.Module
|
||||
}
|
||||
}
|
||||
|
||||
ports := map[string]map[int]int{}
|
||||
for _, m := range plan.Modules {
|
||||
for _, l := range m.Listens {
|
||||
if at, isGiven := given[m.Module][l.Port]; isGiven {
|
||||
if ports[m.Module] == nil {
|
||||
ports[m.Module] = map[int]int{}
|
||||
}
|
||||
ports[m.Module][l.Port] = at
|
||||
continue
|
||||
}
|
||||
// Only a port the module actually publishes is the mesh's to move. Recorded either way, because this map means where
|
||||
// this module's port is on this machine.
|
||||
where, mayAssign := m.MachineSide(l.Port)
|
||||
if mayAssign {
|
||||
// Whatever was chosen last time, and nothing if there was no last time.
|
||||
if at, known := already[m.Module][l.Port]; known {
|
||||
where = at
|
||||
} else {
|
||||
*foreseen = append(*foreseen, fmt.Sprintf("%s:%d", m.Module, l.Port))
|
||||
}
|
||||
}
|
||||
if ports[m.Module] == nil {
|
||||
ports[m.Module] = map[int]int{}
|
||||
}
|
||||
ports[m.Module][l.Port] = where
|
||||
}
|
||||
}
|
||||
r.Ports = ports
|
||||
return given, nil
|
||||
}
|
||||
|
||||
// onThePrivateNetwork is every node's address on the private network, sorted: the same set the names mean by it, so a rule
|
||||
// saying "from the mesh" admits exactly the machines the mesh names. A node with no address is left out, not rendered as an
|
||||
// empty source.
|
||||
func (c *composer) onThePrivateNetwork() []string {
|
||||
var out []string
|
||||
for _, p := range c.onTheNetwork() {
|
||||
if strings.TrimSpace(p.Place.Address) != "" {
|
||||
out = append(out, p.Place.Address)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// namesInTheMesh is every machine's internal name and the address behind it, for every machine on the private network, and
|
||||
// the carried peers the operator has named (novox/hq issue 112).
|
||||
func (c *composer) namesInTheMesh() map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, p := range c.onTheNetwork() {
|
||||
out[c.internalName(p.Name)] = p.Place.Address
|
||||
}
|
||||
for _, p := range c.in.CarriedPeers {
|
||||
if p.Named == "" || p.EnrolledAs != "" {
|
||||
continue
|
||||
}
|
||||
if _, taken := out[c.internalName(p.Named)]; taken {
|
||||
continue // a node of the mesh owns the name; the stale statement loses
|
||||
}
|
||||
out[c.internalName(p.Named)] = p.Address
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// artifactStoreOnNetwork is the machine on this network that offers the artifact store, and the port that machine put it on.
|
||||
func (c *composer) artifactStoreOnNetwork(on map[string]bool) (node, port string, found bool, err error) {
|
||||
providers := map[string]catalogue.Manifest{}
|
||||
for name, m := range c.in.Catalogue {
|
||||
if _, offers := m.Serves[catalogue.ArtifactStoreProvision]; offers {
|
||||
providers[name] = m
|
||||
}
|
||||
}
|
||||
if len(providers) == 0 {
|
||||
return "", "", false, nil
|
||||
}
|
||||
// In a stated order, so two machines offering it would always answer the same one.
|
||||
machines := make([]string, 0, len(on))
|
||||
for machine := range on {
|
||||
machines = append(machines, machine)
|
||||
}
|
||||
sort.Strings(machines)
|
||||
for _, machine := range machines {
|
||||
n, err := c.in.node(machine)
|
||||
if err != nil {
|
||||
return "", "", false, fmt.Errorf("reading what %s is assigned: %w", machine, err)
|
||||
}
|
||||
for _, a := range n.Assigned {
|
||||
m, offers := providers[a]
|
||||
if !offers {
|
||||
continue
|
||||
}
|
||||
serves, _, err := c.servedOnNode(machine, m, catalogue.ArtifactStoreProvision)
|
||||
if err != nil {
|
||||
return "", "", false, fmt.Errorf("reading where %s puts the artifact store: %w", machine, err)
|
||||
}
|
||||
if p, ok := serves["port"]; ok {
|
||||
return machine, fmt.Sprintf("%v", p), true, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", "", false, nil
|
||||
}
|
||||
|
||||
// ArtifactStoreAddress is the artifact store as node reaches it, by the rule its composition routes every kept reference
|
||||
// with (artifactStoreAddress): empty when node reaches none. A node composing its own modules reads a templates archive
|
||||
// there (issue 1126).
|
||||
func ArtifactStoreAddress(in Input, node string) (string, error) {
|
||||
return newComposer(in).artifactStoreAddress(node)
|
||||
}
|
||||
|
||||
// artifactStoreAddress is the artifact store as `forNode` reaches it: `<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 := catalogue.ZoneOn(m, layers, published, n.Name, at)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
zones = append(zones, *z)
|
||||
}
|
||||
}
|
||||
if problems := catalogue.ZonesProblems(zones, c.suffix(), public); len(problems) > 0 {
|
||||
return nil, fmt.Errorf("the mesh's zones cannot be forwarded:\n - %s", strings.Join(problems, "\n - "))
|
||||
}
|
||||
return zones, nil
|
||||
}
|
||||
|
||||
// replicatedHolders is, for each replicated mesh seat, every machine on the private network holding it, by internal name at its
|
||||
// private address (novox/hq ADR 0223): the holders on record, and only the sole claimant when there are none. A holder off
|
||||
// the private network is left out.
|
||||
func (c *composer) replicatedHolders() map[string]map[string]string {
|
||||
var replicated []catalogue.Seat
|
||||
for _, s := range catalogue.Seats() {
|
||||
if s.Replicated && s.Scope == catalogue.ScopeMesh {
|
||||
replicated = append(replicated, s)
|
||||
}
|
||||
}
|
||||
if len(replicated) == 0 {
|
||||
return nil
|
||||
}
|
||||
address := map[string]string{}
|
||||
for _, p := range c.onTheNetwork() {
|
||||
address[p.Name] = p.Place.Address
|
||||
}
|
||||
// Every module of the catalogue with the nodes it is assigned on, sorted, as the controller's Catalogued answers.
|
||||
on := map[string][]string{}
|
||||
for _, n := range c.in.sorted() {
|
||||
for _, module := range n.Assigned {
|
||||
on[module] = append(on[module], n.Name)
|
||||
}
|
||||
}
|
||||
names := make([]string, 0, len(c.in.Catalogue))
|
||||
for name := range c.in.Catalogue {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
out := map[string]map[string]string{}
|
||||
for _, seat := range replicated {
|
||||
var nodes []string
|
||||
for _, h := range c.in.Holdings {
|
||||
if hs, ok := catalogue.SeatNamed(h.Claim); ok && hs.Name == seat.Name && h.Scope == seat.Scope {
|
||||
nodes = append(nodes, h.Node)
|
||||
}
|
||||
}
|
||||
if len(nodes) == 0 {
|
||||
var derived []string
|
||||
for _, name := range names {
|
||||
for _, cl := range c.in.Catalogue[name].Claims {
|
||||
if cs, ok := catalogue.SeatNamed(cl.Name); ok && cs.Name == seat.Name && cl.At() == seat.Scope {
|
||||
derived = append(derived, on[name]...)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(derived) == 1 {
|
||||
nodes = derived
|
||||
}
|
||||
}
|
||||
at := map[string]string{}
|
||||
for _, n := range nodes {
|
||||
if address[n] != "" {
|
||||
at[c.internalName(n)] = address[n]
|
||||
}
|
||||
}
|
||||
out[seat.Name] = at
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// seatsOn is where a node put the holder of each mesh-scoped seat, by seat and by the port the holder's software uses: what
|
||||
// ${seat:...} answers with (novox/hq 04-ISSUES/102). Read for every module in the catalogue that claims a seat, in this node's set
|
||||
// or not; a holder in this node's set wins over one that is not.
|
||||
func (c *composer) seatsOn(node Node, inSet []catalogue.Manifest, adopted bool, taken map[string]bool) (map[string]map[int]int, error) {
|
||||
assigned := map[string]bool{}
|
||||
for _, m := range inSet {
|
||||
assigned[m.Module] = true
|
||||
}
|
||||
names := make([]string, 0, len(c.in.Catalogue))
|
||||
for name := range c.in.Catalogue {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
seats := map[string]map[int]int{}
|
||||
for _, name := range names {
|
||||
m := c.in.Catalogue[name]
|
||||
var claims []string
|
||||
for _, cl := range m.Claims {
|
||||
if cl.At() == catalogue.ScopeMesh {
|
||||
claims = append(claims, cl.Name)
|
||||
}
|
||||
}
|
||||
if len(claims) == 0 {
|
||||
continue
|
||||
}
|
||||
// Only a port the node was given or the mesh assigned, never the manifest's own number. And on an adopted node a holder
|
||||
// assigned but not yet taken is the found container, so its mesh-assigned ports do not count there; a given port does.
|
||||
ports, _, err := c.portsGivenOn(node.Name, m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if adopted && !taken[name] {
|
||||
layers := node.Settings[m.Module]
|
||||
ports = map[int]int{}
|
||||
if given, err := catalogue.GivenPorts(m, layers); err == nil {
|
||||
ports = given
|
||||
}
|
||||
}
|
||||
if len(ports) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, seat := range claims {
|
||||
if seats[seat] == nil {
|
||||
seats[seat] = map[int]int{}
|
||||
}
|
||||
for wanted, at := range ports {
|
||||
if _, said := seats[seat][wanted]; said && !assigned[name] {
|
||||
continue
|
||||
}
|
||||
seats[seat][wanted] = at
|
||||
}
|
||||
}
|
||||
}
|
||||
return seats, nil
|
||||
}
|
||||
|
||||
// localNetworksOf is a machine's attached networks judged (novox/hq ADR 0340): which count as its local network, from what it
|
||||
// last said of itself and the links it said face outside it. A machine that has said none judges to nothing.
|
||||
func localNetworksOf(n Node) []catalogue.LocalNetwork {
|
||||
if !n.NetworksSaid {
|
||||
return nil
|
||||
}
|
||||
return catalogue.JudgeLocalNetworks(n.Networks, n.OutwardLinks, overlay.Interface)
|
||||
}
|
||||
@@ -1,329 +0,0 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
)
|
||||
|
||||
// aWebModule publishes one port in the short form, which the mesh is free to move (MachineSide).
|
||||
func aWebModule() catalogue.Manifest {
|
||||
return catalogue.Manifest{Module: "web", Version: "1",
|
||||
Listens: []catalogue.Listening{{Name: "http", Port: 8080, From: catalogue.FromMesh}},
|
||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "web", "ports": []any{"8080"},
|
||||
"image": "registry.example/web@sha256:" + strings.Repeat("a", 64)}}}
|
||||
}
|
||||
|
||||
// aStoreModule is mesh-controller's sendable_integration_test.go TestConsumersAreToldTheGivenPort store (cmd/mesh-controller
|
||||
// at 6c2a8d62): publishing 5432:5432, offering a database to the mesh.
|
||||
func aStoreModule() catalogue.Manifest {
|
||||
return catalogue.Manifest{Module: "store", Version: "1",
|
||||
Provides: []catalogue.Offer{{Name: "database", Scope: catalogue.ScopeMesh}},
|
||||
Listens: []catalogue.Listening{{Port: 5432, From: catalogue.FromMesh}},
|
||||
Guards: []int{5432},
|
||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-store",
|
||||
"ports": []any{"5432:5432"},
|
||||
"image": "registry.example/pg@sha256:" + strings.Repeat("b", 64)}}}
|
||||
}
|
||||
|
||||
func placed(t *testing.T, in Input, node string) Placed {
|
||||
t.Helper()
|
||||
got, err := Places(in, node)
|
||||
if err != nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
// 1363/AC1 and AC2: a port the Input holds as allocated on the node is the one composed; with none, no number is invented: the
|
||||
// manifest's own is composed and the port is named foreseen, as the Foreseeing mode does.
|
||||
func TestAnAllocatedPortIsComposedAndAnUnallocatedOneIsNamedForeseenNotInvented(t *testing.T) {
|
||||
mk := func() Input {
|
||||
in := aMesh(t)
|
||||
in.Catalogue["web"] = aWebModule()
|
||||
nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "web")
|
||||
return in
|
||||
}
|
||||
in := mk()
|
||||
got := placed(t, in, "laptop")
|
||||
if p := got.Rendering.Ports["web"][8080]; p != 8080 {
|
||||
t.Errorf("a port nobody allocated is composed as %d, not as the manifest's own", p)
|
||||
}
|
||||
if len(got.ForeseenPorts) != 1 || got.ForeseenPorts[0] != "web:8080" {
|
||||
t.Errorf("foreseen ports are %v", got.ForeseenPorts)
|
||||
}
|
||||
nodeOf(t, &in, "laptop").Ports = map[string]map[int]int{"web": {8080: 20123}}
|
||||
got = placed(t, in, "laptop")
|
||||
if p := got.Rendering.Ports["web"][8080]; p != 20123 || len(got.ForeseenPorts) != 0 {
|
||||
t.Errorf("the allocated port is composed as %d (foreseen %v)", p, got.ForeseenPorts)
|
||||
}
|
||||
// A port the module does not publish is not the mesh's to move, so it is never foreseen.
|
||||
m := aWebModule()
|
||||
m.Resources = nil
|
||||
in.Catalogue["web"] = m
|
||||
nodeOf(t, &in, "laptop").Ports = nil
|
||||
got = placed(t, in, "laptop")
|
||||
if got.Rendering.Ports["web"][8080] != 8080 || len(got.ForeseenPorts) != 0 {
|
||||
t.Errorf("an unpublished port: %v foreseen %v", got.Rendering.Ports, got.ForeseenPorts)
|
||||
}
|
||||
unchanged(t, mk, func(in Input) { placed(t, in, "laptop") })
|
||||
// Only a composition for plans refuses a contribution whose port the module does not listen on (issue 774).
|
||||
for _, mode := range []Mode{Reading, Foreseeing, Planning} {
|
||||
if got := rendered(t, mk(), "laptop", mode).Rendering; got.Planned != (mode == Planning) {
|
||||
t.Errorf("mode %d composes with Planned %v", mode, got.Planned)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1363/AC3: a port the operator gave is used, over the allocated one, and a consumer elsewhere is told it
|
||||
// (mesh-controller sendable_integration_test.go TestConsumersAreToldTheGivenPort).
|
||||
func TestAPortTheOperatorGaveIsUsedAndNotReallocated(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
in.Catalogue["store"] = aStoreModule()
|
||||
in.Catalogue["app"] = catalogue.Manifest{Module: "app", Version: "1", Requires: []string{"database"}}
|
||||
anchor := nodeOf(t, &in, "anchor")
|
||||
anchor.Assigned = append(anchor.Assigned, "store")
|
||||
nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "app")
|
||||
anchor.Ports = map[string]map[int]int{"store": {5432: 20999}}
|
||||
anchor.Settings = map[string][]catalogue.Layer{"store": {{From: "anchor",
|
||||
Placement: map[string]any{catalogue.PortsSetting: map[string]any{"5432": 5433}}}}}
|
||||
|
||||
got := placed(t, in, "anchor")
|
||||
if p := got.Rendering.Ports["store"][5432]; p != 5433 {
|
||||
t.Errorf("the store is on %d, not the port it was given", p)
|
||||
}
|
||||
if g := got.Rendering.Given["store"][5432]; g != 5433 {
|
||||
t.Errorf("the given port is %d", g)
|
||||
}
|
||||
if len(got.ForeseenPorts) != 0 {
|
||||
t.Errorf("a given port is foreseen: %v", got.ForeseenPorts)
|
||||
}
|
||||
resolved, err := Resolve(in, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if told := need(t, resolved.Plan, "app", "database").Serves["port"]; told != 5433 {
|
||||
t.Errorf("the consumer is told the store is on %v", told)
|
||||
}
|
||||
|
||||
// One holder per machine port across the node's modules: two given the same refused, in the controller's words.
|
||||
in.Catalogue["store2"] = func() catalogue.Manifest {
|
||||
m := aStoreModule()
|
||||
m.Module, m.Provides = "store2", nil
|
||||
m.Resources = []map[string]any{{"id": "server", "type": "container", "name": "other", "ports": []any{"5432:5432"},
|
||||
"image": "registry.example/pg@sha256:" + strings.Repeat("c", 64)}}
|
||||
return m
|
||||
}()
|
||||
anchor.Assigned = append(anchor.Assigned, "store2")
|
||||
anchor.Settings["store2"] = []catalogue.Layer{{From: "anchor",
|
||||
Placement: map[string]any{catalogue.PortsSetting: map[string]any{"5432": 5433}}}}
|
||||
_, err = Places(in, "anchor")
|
||||
if err == nil || !errors.Is(err, ErrPortTaken) || !strings.Contains(err.Error(), "store and store2 are both given machine port 5433 on anchor") &&
|
||||
!strings.Contains(err.Error(), "store2 and store are both given machine port 5433 on anchor") {
|
||||
t.Fatalf("two modules given one machine port: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1363/AC4: the seats held and taken reach the Rendering: where each mesh-scoped seat's holder put its ports, from the node's
|
||||
// given and assigned ports, never the manifest's own; and on an adopted node a holder assigned but not taken counts only by its
|
||||
// given ports (novox/hq ADR 0100).
|
||||
func TestSeatsHeldAndTakenReachTheRendering(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
holder := aStoreModule()
|
||||
holder.Claims = []catalogue.Claim{{Name: "mesh-store", Scope: catalogue.ScopeMesh}}
|
||||
in.Catalogue["store"] = holder
|
||||
anchor := nodeOf(t, &in, "anchor")
|
||||
anchor.Assigned = append(anchor.Assigned, "store")
|
||||
// The shelf is read whole, in this node's set or not: laptop, running none, is told where the anchor's store is only by the
|
||||
// settings and ports of its own machine, which it has none of.
|
||||
anchor.Ports = map[string]map[int]int{"store": {5432: 20999}}
|
||||
got := placed(t, in, "anchor")
|
||||
if at := got.Rendering.Seats["mesh-store"][5432]; at != 20999 {
|
||||
t.Errorf("the seat's holder is told to be on %d", at)
|
||||
}
|
||||
if len(placed(t, in, "laptop").Rendering.Seats) != 0 {
|
||||
t.Errorf("laptop is told of a holder it has no port for: %v", placed(t, in, "laptop").Rendering.Seats)
|
||||
}
|
||||
// Adopted, and the holder assigned but not taken: the found container's ports, not the mesh's assignment.
|
||||
anchor.Adopted = true
|
||||
if len(placed(t, in, "anchor").Rendering.Seats) != 0 {
|
||||
t.Errorf("an untaken holder on an adopted node counts by the mesh's assignment: %v", placed(t, in, "anchor").Rendering.Seats)
|
||||
}
|
||||
anchor.Taken = []string{"store"}
|
||||
got = placed(t, in, "anchor")
|
||||
if at := got.Rendering.Seats["mesh-store"][5432]; at != 20999 || !got.Rendering.Adopted || !got.Rendering.Taken["store"] {
|
||||
t.Errorf("a taken holder: seats %v adopted %v taken %v", got.Rendering.Seats, got.Rendering.Adopted, got.Rendering.Taken)
|
||||
}
|
||||
}
|
||||
|
||||
// 1363/AC4: local networks and outward links (mesh-controller local_network_test.go TestNodeShowsWhichAttachedNetworksCountAndWhy,
|
||||
// the values): a private network on a link facing outside counts, a public subnet and the private network's own link never do; a
|
||||
// machine that said none judges to nothing.
|
||||
func TestLocalNetworksAndOutwardLinksReachTheRendering(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
laptop := nodeOf(t, &in, "laptop")
|
||||
laptop.OutwardLinks = []string{"eth0", "eth1"}
|
||||
laptop.Networks = []catalogue.AttachedNetwork{
|
||||
{Link: "eth0", Network: "192.168.1.10/24"}, {Link: "eth1", Network: "203.0.113.7/24"}, {Link: overlay.Interface, Network: "10.10.0.4/24"}}
|
||||
laptop.NetworksSaid = true
|
||||
got := placed(t, in, "laptop").Rendering
|
||||
if len(got.LocalNetworks) != 1 || got.LocalNetworks[0] != "192.168.1.0/24" {
|
||||
t.Errorf("the local networks are %v", got.LocalNetworks)
|
||||
}
|
||||
if len(got.OutwardLinks) != 2 || got.OutwardLinks[0] != "eth0" {
|
||||
t.Errorf("the outward links are %v", got.OutwardLinks)
|
||||
}
|
||||
laptop.NetworksSaid = false
|
||||
if got := placed(t, in, "laptop").Rendering; len(got.LocalNetworks) != 0 {
|
||||
t.Errorf("a machine that never reported has local networks %v", got.LocalNetworks)
|
||||
}
|
||||
}
|
||||
|
||||
// 1363/AC4: accounts, the names of the mesh, the mesh range and the operator's export reach the Rendering.
|
||||
func TestAccountsNamesAndTheOperatorsExportReachTheRendering(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
nodeOf(t, &in, "anchor").Account = "jochens"
|
||||
in.OperatorExport = &catalogue.KeptExport{}
|
||||
in.OverlayCIDR = "10.99.0.0/16"
|
||||
got := placed(t, in, "anchor").Rendering
|
||||
if got.Accounts["anchor"] != "jochens" || len(got.Accounts) != 1 {
|
||||
t.Errorf("accounts are %v", got.Accounts)
|
||||
}
|
||||
if got.Names["anchor.internal"] != "10.77.0.1" || got.Names["laptop.internal"] != "10.77.0.2" || len(got.Names) != 2 {
|
||||
t.Errorf("names are %v", got.Names)
|
||||
}
|
||||
if len(got.Machines) != 2 || got.Machines["laptop.internal"] != "10.77.0.2" {
|
||||
t.Errorf("machines are %v", got.Machines)
|
||||
}
|
||||
if len(got.Mesh) != 2 || got.Mesh[0] != "10.77.0.1" || got.Mesh[1] != "10.77.0.2" {
|
||||
t.Errorf("the mesh's addresses are %v", got.Mesh)
|
||||
}
|
||||
if got.MeshRange != "10.99.0.0/16" || got.Suffix != "internal" || got.TunnelInterface != overlay.Interface {
|
||||
t.Errorf("range %q suffix %q tunnel %q", got.MeshRange, got.Suffix, got.TunnelInterface)
|
||||
}
|
||||
if got.Kept != nil {
|
||||
t.Error("the operator's export reaches a node where nothing keeps it")
|
||||
}
|
||||
keeper := catalogue.Manifest{Module: "keeper", Version: "1", Keeps: "/var/lib/keeper/export.json"}
|
||||
in.Catalogue["keeper"] = keeper
|
||||
nodeOf(t, &in, "anchor").Assigned = append(nodeOf(t, &in, "anchor").Assigned, "keeper")
|
||||
if got := placed(t, in, "anchor").Rendering; got.Kept != in.OperatorExport {
|
||||
t.Errorf("a module that keeps the export is not given it: %v", got.Kept)
|
||||
}
|
||||
// And the names of a machine that left the network leave with it.
|
||||
nodeOf(t, &in, "laptop").Assigned = nil
|
||||
if got := placed(t, in, "anchor").Rendering; len(got.Names) != 1 || got.Names["laptop.internal"] != "" {
|
||||
t.Errorf("a machine off the network is named: %v", got.Names)
|
||||
}
|
||||
}
|
||||
|
||||
// The names the operator gave to peers of an adopted tunnel answer until they enrol (novox/hq issue 112); a node of the mesh owns
|
||||
// its name.
|
||||
func TestACarriedPeerIsNamedUntilItEnrols(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
in.CarriedPeers = []CarriedPeer{{PublicKey: "K1", Address: "192.0.2.5", Named: "printer"},
|
||||
{PublicKey: "K2", Address: "192.0.2.6", Named: "laptop"}, {PublicKey: "K3", Address: "192.0.2.7", Named: "late", EnrolledAs: "late"}}
|
||||
got := placed(t, in, "anchor").Rendering
|
||||
if got.Names["printer.internal"] != "192.0.2.5" || got.Names["laptop.internal"] != "10.77.0.2" || got.Names["late.internal"] != "" {
|
||||
t.Errorf("names are %v", got.Names)
|
||||
}
|
||||
}
|
||||
|
||||
// A node-engine older than a contract is not sent the field it would refuse (novox/hq ADR 0240 and after).
|
||||
func TestTheNodeEnginesContractGatesWhatItIsSent(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
laptop := nodeOf(t, &in, "laptop")
|
||||
if r := placed(t, in, "laptop").Rendering; r.ReadsHealth || r.JudgesRoot || r.ReadsBusy || r.Hands {
|
||||
t.Errorf("an engine that never stated anything reads %+v", r)
|
||||
}
|
||||
laptop.Stated, laptop.Contract = true, ReadinessContract
|
||||
if r := placed(t, in, "laptop").Rendering; !r.ReadsHealth || r.JudgesRoot || r.ReadsBusy || r.Hands {
|
||||
t.Errorf("an engine at the readiness contract reads %+v", r)
|
||||
}
|
||||
laptop.Contract = HandedContract
|
||||
if r := placed(t, in, "laptop").Rendering; !r.ReadsHealth || !r.JudgesRoot || !r.ReadsBusy || !r.Hands {
|
||||
t.Errorf("an engine at the handed contract reads %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// The artifact store as a node reaches it: over the private network by its holder's name and the port it put it on, or on loopback
|
||||
// for the holder alone while nothing is on the network (novox/hq 04-ISSUES/102).
|
||||
func TestTheArtifactStoreIsReachedOverTheNetworkOrOnLoopbackForItsHolder(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
in.Catalogue["artifacts"] = catalogue.Manifest{Module: "artifacts", Version: "1",
|
||||
Serves: map[string]map[string]any{catalogue.ArtifactStoreProvision: {"port": 5100}}}
|
||||
anchor := nodeOf(t, &in, "anchor")
|
||||
anchor.Assigned = append(anchor.Assigned, "artifacts")
|
||||
if got := placed(t, in, "laptop").Rendering.ArtifactStore; got != "anchor.internal:5100" {
|
||||
t.Errorf("laptop reaches the store at %q", got)
|
||||
}
|
||||
nodeOf(t, &in, "laptop").Assigned = nil
|
||||
anchor.Assigned = []string{"artifacts"} // off the network: the holder alone reaches it
|
||||
if got := placed(t, in, "anchor").Rendering.ArtifactStore; got != "127.0.0.1:5100" {
|
||||
t.Errorf("the holder reaches the store at %q", got)
|
||||
}
|
||||
if got := placed(t, in, "laptop").Rendering.ArtifactStore; got != "" {
|
||||
t.Errorf("a machine off the network reaches the store at %q", got)
|
||||
}
|
||||
in.Built = []string{"mesh-sdk"}
|
||||
if got := placed(t, in, "anchor").Rendering.Built; !got["mesh-sdk"] {
|
||||
t.Errorf("built is %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A zone a module declares is placed where the mesh put it: settled from that node's settings, at the node's private address, on
|
||||
// the port the answering listen is published on there (novox/hq ADR 0199); the same zone declared twice is refused, by name.
|
||||
func TestAZoneIsPlacedWhereTheMeshPutItAndTheSameZoneTwiceIsRefused(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
lab := catalogue.Manifest{Module: "lab", Version: "1", Zone: &catalogue.Zone{Name: "${setting:zone}", Listen: "dns"},
|
||||
Listens: []catalogue.Listening{{Name: "dns", Port: 53, Protocol: "udp", From: catalogue.FromMesh}}}
|
||||
in.Catalogue["lab"] = lab
|
||||
anchor := nodeOf(t, &in, "anchor")
|
||||
anchor.Assigned = append(anchor.Assigned, "lab")
|
||||
anchor.Settings = map[string][]catalogue.Layer{"lab": {{From: "anchor", Values: map[string]any{"zone": "Lab.Example."}}}}
|
||||
anchor.Ports = map[string]map[int]int{"lab": {53: 5353}}
|
||||
zones := placed(t, in, "laptop").Rendering.Zones
|
||||
if len(zones) != 1 || zones[0] != (catalogue.ZoneAt{Zone: "lab.example", Node: "anchor", Module: "lab", Address: "10.77.0.1", Port: 5353}) {
|
||||
t.Fatalf("zones are %+v", zones)
|
||||
}
|
||||
laptop := nodeOf(t, &in, "laptop")
|
||||
laptop.Assigned = append(laptop.Assigned, "lab")
|
||||
laptop.Settings = map[string][]catalogue.Layer{"lab": {{From: "laptop", Values: map[string]any{"zone": "lab.example"}}}}
|
||||
if _, err := Places(in, "laptop"); err == nil || !strings.Contains(err.Error(), "the mesh's zones cannot be forwarded") {
|
||||
t.Fatalf("one zone declared twice: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Every holder of a replicated seat is listed by internal name at its private address (novox/hq ADR 0223): the holders on record,
|
||||
// and only the sole claimant when there are none; a holder off the private network is left out.
|
||||
func TestEveryHolderOfAReplicatedSeatIsListedOnTheNetwork(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
resolver := catalogue.Manifest{Module: "resolver", Version: "1",
|
||||
Provides: []catalogue.Offer{{Name: "wildcard-resolution", Scope: catalogue.ScopeMesh}},
|
||||
Claims: []catalogue.Claim{{Name: "mesh-dns-resolver", Scope: catalogue.ScopeMesh}}}
|
||||
in.Catalogue["resolver"] = resolver
|
||||
anchor, laptop := nodeOf(t, &in, "anchor"), nodeOf(t, &in, "laptop")
|
||||
anchor.Assigned = append(anchor.Assigned, "resolver")
|
||||
// One claimant and nothing on record: the sole claimant is listed.
|
||||
if got := placed(t, in, "laptop").Rendering.Holders["mesh-dns-resolver"]; len(got) != 1 || got["anchor.internal"] != "10.77.0.1" {
|
||||
t.Errorf("the sole claimant: %v", got)
|
||||
}
|
||||
// Both on record: both are listed (two claimants with nothing on record would be refused at resolution).
|
||||
laptop.Assigned = append(laptop.Assigned, "resolver")
|
||||
in.Holdings = []catalogue.Held{
|
||||
{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "resolver"},
|
||||
{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "laptop", Module: "resolver"}}
|
||||
got := placed(t, in, "laptop").Rendering.Holders["mesh-dns-resolver"]
|
||||
if len(got) != 2 || got["anchor.internal"] != "10.77.0.1" || got["laptop.internal"] != "10.77.0.2" {
|
||||
t.Errorf("both holders on record: %v", got)
|
||||
}
|
||||
// A holder off the private network is left out: a resolver named at an address nothing answers waits out its timeout.
|
||||
in.Nodes = append(in.Nodes, Node{Name: "ace", Place: Place{Address: "10.77.0.3"}, Assigned: []string{"resolver"}})
|
||||
in.Holdings = append(in.Holdings, catalogue.Held{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "ace", Module: "resolver"})
|
||||
if got := placed(t, in, "laptop").Rendering.Holders["mesh-dns-resolver"]; len(got) != 2 || got["ace.internal"] != "" {
|
||||
t.Errorf("a holder off the network is listed: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -1,347 +0,0 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// LicenceProvision is what a module requires in order to be given a licence (the controller's licences.Provision).
|
||||
const LicenceProvision = "model-access"
|
||||
|
||||
// Resolved is what one node should run, from what was assigned to it, with the settings of everything that resolved.
|
||||
type Resolved struct {
|
||||
Plan catalogue.Resolution
|
||||
Settings catalogue.SettingsBy
|
||||
// Foreseen are the needs composed with a stand-in for a credential the next send makes (or holds with no value given
|
||||
// here), as "<module> on <node> needs <provision> from <provider>". Nothing was made.
|
||||
Foreseen []string
|
||||
// NotGiven are the words of each credential only a person gives that nobody has: the consumer still resolves without it
|
||||
// (novox/hq ADR 0337 clause 4).
|
||||
NotGiven []string
|
||||
}
|
||||
|
||||
// Resolve works out everything a node should run, from what was assigned to it (the controller's planFor, plan.go): the
|
||||
// resolution over the rest of the mesh, the credential of each thing the node takes from elsewhere, and the settings of
|
||||
// every module that resolved. It makes and writes nothing: a credential not held is composed with a stand-in and named
|
||||
// foreseen, one only a person gives and nobody has is left out and said, one the send would refuse is refused with the
|
||||
// controller's words. A failure to compose the node's own modules is an error that satisfies IsUnresolvable, told apart
|
||||
// from an unreadable Input (novox/hq 04-ISSUES/152).
|
||||
func Resolve(in Input, node string) (Resolved, error) {
|
||||
return newComposer(in).planFor(node)
|
||||
}
|
||||
|
||||
// IsUnresolvable reports whether err is a node's own set failing to compose, rather than the mesh being unable to answer.
|
||||
func IsUnresolvable(err error) bool { return unresolvable(err) }
|
||||
|
||||
// planFor is the controller's planFor, worked out once per node in one composition (issue 1954): each caller has its
|
||||
// own copy of what a caller may set (a need's fields, as Resolution.Compose sets its Serves), the rest only read.
|
||||
func (c *composer) planFor(nodeName string) (Resolved, error) {
|
||||
p, ok := c.plans[nodeName]
|
||||
if !ok {
|
||||
p.resolved, p.err = c.planForNow(nodeName)
|
||||
if c.plans == nil {
|
||||
c.plans = map[string]planOf{}
|
||||
}
|
||||
c.plans[nodeName] = p
|
||||
}
|
||||
if p.err != nil {
|
||||
return Resolved{}, p.err
|
||||
}
|
||||
return p.resolved.copied(), nil
|
||||
}
|
||||
|
||||
// planOf is planFor's answer for one node, kept.
|
||||
type planOf struct {
|
||||
resolved Resolved
|
||||
err error
|
||||
}
|
||||
|
||||
// copied is r with its own needs, words and settings lists: what a caller may change of it.
|
||||
func (r Resolved) copied() Resolved {
|
||||
out := r
|
||||
out.Plan.Needs = slices.Clone(r.Plan.Needs)
|
||||
out.Foreseen = slices.Clone(r.Foreseen)
|
||||
out.NotGiven = slices.Clone(r.NotGiven)
|
||||
if r.Settings != nil {
|
||||
out.Settings = make(catalogue.SettingsBy, len(r.Settings))
|
||||
for m, layers := range r.Settings {
|
||||
out.Settings[m] = slices.Clone(layers)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// planForNow is planFor worked out.
|
||||
func (c *composer) planForNow(nodeName string) (Resolved, error) {
|
||||
resolved, err := c.resolution(nodeName)
|
||||
if err != nil {
|
||||
return Resolved{}, err
|
||||
}
|
||||
node, err := c.in.node(nodeName)
|
||||
if err != nil {
|
||||
return Resolved{}, err
|
||||
}
|
||||
shelf := c.shelfFor(node)
|
||||
var out Resolved
|
||||
|
||||
// The credential for each thing this node takes from elsewhere.
|
||||
for i, n := range resolved.Needs {
|
||||
// Issued for this holder alone and kept by the vault (issue 757).
|
||||
resolved.Needs[i].IssuedToHolder = issuedToHolder(shelf, n)
|
||||
resolved.Needs[i].ProviderMoved = providerMoved(shelf, n)
|
||||
resolved.Needs[i].AnsweredByHelper = answeredByHelper(shelf, n)
|
||||
if n.ByRecord {
|
||||
// Answered by something the mesh holds, so there is no pair-wise secret between two machines.
|
||||
licence, _ := c.licence(n.From)
|
||||
if licence.Keys == nil {
|
||||
// The keys are not given: composed with a stand-in and named, not failed for a value only a send reads.
|
||||
resolved.Needs[i].Sealed = catalogue.ForeseenSealed
|
||||
out.Foreseen = append(out.Foreseen, fmt.Sprintf("%s on %s needs %s from %s", n.For, nodeName, n.Name, n.From))
|
||||
} else {
|
||||
// Given, and none for this holder: the mesh holds none.
|
||||
resolved.Needs[i].Sealed = licence.Keys[Holder{Node: nodeName, Module: n.For}]
|
||||
}
|
||||
// If this holder is the licence's manager, hand it the manager node's PUBLIC sealing key in its bound facts
|
||||
// (novox/hq ADR 0050).
|
||||
if pub := c.managerPublicKeyFor(licence, nodeName, n.For); pub != "" {
|
||||
serves := map[string]any{}
|
||||
for k, v := range resolved.Needs[i].Serves {
|
||||
serves[k] = v
|
||||
}
|
||||
serves["manager_public_key"] = pub
|
||||
resolved.Needs[i].Serves = serves
|
||||
// The manager holder: its empty pre-adoption refresh token is a waiting state, not a missing consumer key.
|
||||
resolved.Needs[i].Manager = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if credentialless(n) {
|
||||
// Its provider's offer presents no credential (form none): nothing is sealed, made or stood in for (issue 1126).
|
||||
continue
|
||||
}
|
||||
cred, held := c.credential(n, nodeName)
|
||||
_, outside := issuedOutsideBy(shelf, n)
|
||||
switch {
|
||||
case cred.State == CredentialRefused:
|
||||
// Said rather than skipped. A machine that resolves cleanly and receives no credential is one that will fail to
|
||||
// authenticate at some later, less obvious moment.
|
||||
return Resolved{}, fmt.Errorf("%s on %s needs %s from %s and no credential could be made for it: %s",
|
||||
n.For, nodeName, n.Name, n.From, cred.Why)
|
||||
case cred.State == CredentialNotGiven, outside && !(held && cred.State == CredentialHeld):
|
||||
// Not given, and the rest of the consumer runs (ADR 0337 clause 4): no credential file, so the module can tell
|
||||
// it was not given; nothing the mesh made stands in for it.
|
||||
why := cred.Why
|
||||
if why == "" {
|
||||
why = "nobody gave it"
|
||||
}
|
||||
out.NotGiven = append(out.NotGiven, fmt.Sprintf("%s on %s holds no %s from %s: %s", n.For, nodeName, n.Name, n.From, why))
|
||||
continue
|
||||
case cred.State == CredentialHeld && cred.Sealed != "":
|
||||
resolved.Needs[i].Sealed = cred.Sealed
|
||||
default:
|
||||
// Held with no value here, or none held: the next send makes it (or reads it), and a stand-in is composed.
|
||||
resolved.Needs[i].Sealed = catalogue.ForeseenSealed
|
||||
out.Foreseen = append(out.Foreseen, fmt.Sprintf("%s on %s needs %s from %s", n.For, nodeName, n.Name, n.From))
|
||||
}
|
||||
}
|
||||
|
||||
// Settings for everything that resolved, including modules nobody assigned directly: a requirement pulled in by
|
||||
// something else is still configurable.
|
||||
settings := catalogue.SettingsBy{}
|
||||
for _, m := range resolved.Modules {
|
||||
layers := node.Settings[m.Module]
|
||||
if len(layers) == 0 {
|
||||
continue
|
||||
}
|
||||
settings[m.Module] = append([]catalogue.Layer(nil), layers...)
|
||||
}
|
||||
out.Plan, out.Settings = resolved, settings
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// credential is how the pair credential of one need stands in the Input, and whether the Input names it at all.
|
||||
func (c *composer) credential(n catalogue.Needed, consumer string) (Credential, bool) {
|
||||
for _, cr := range c.in.Credentials {
|
||||
if cr.Provision == n.Name && cr.Consumer == consumer && cr.ConsumerModule == n.For && cr.Provider == n.From && cr.Local == n.Local {
|
||||
return cr, true
|
||||
}
|
||||
}
|
||||
return Credential{State: CredentialWouldMake}, false
|
||||
}
|
||||
|
||||
// licence is the licence of that name.
|
||||
func (c *composer) licence(name string) (Licence, bool) {
|
||||
for _, l := range c.in.Licences {
|
||||
if l.Name == name {
|
||||
return l, true
|
||||
}
|
||||
}
|
||||
return Licence{}, false
|
||||
}
|
||||
|
||||
// managerPublicKeyFor is the manager node's public sealing key, but only when (node, module) is the licence's manager holder
|
||||
// and empty otherwise (plan.go managerPublicKeyFor): delivered to the manager module in its bound facts so it can re-seal a
|
||||
// rotated refresh token to this node (novox/hq ADR 0050), and to no consumer.
|
||||
func (c *composer) managerPublicKeyFor(l Licence, node, module string) string {
|
||||
if l.Manager == (Holder{}) || node != l.Manager.Node || module != l.Manager.Module {
|
||||
return ""
|
||||
}
|
||||
n, err := c.in.node(node)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return n.SealingKey
|
||||
}
|
||||
|
||||
// issuedOutsideBy is whether a need's provision is offered by its provider with a credential issued outside the mesh
|
||||
// (novox/hq ADR 0337), and that provider module: the one answering it when known, else any that offers it so.
|
||||
func issuedOutsideBy(shelf map[string]catalogue.Manifest, n catalogue.Needed) (string, bool) {
|
||||
if n.Module != "" {
|
||||
if m, known := shelf[n.Module]; known {
|
||||
return n.Module, m.IssuedOutsideOf(n.Name)
|
||||
}
|
||||
}
|
||||
return catalogue.OffersIssuedOutside(shelf, n.Name)
|
||||
}
|
||||
|
||||
// issuedToHolder is whether the vault keeps a need's credential for its holder alone (issues 749, 757 and 779).
|
||||
func issuedToHolder(shelf map[string]catalogue.Manifest, n catalogue.Needed) bool {
|
||||
if n.ByRecord || n.Module == "" {
|
||||
return false
|
||||
}
|
||||
provider, known := shelf[n.Module]
|
||||
if !known || !movedProvider(provider) {
|
||||
return false
|
||||
}
|
||||
if _, issued := provider.IssuedCredentialOf(n.Name); issued {
|
||||
return true
|
||||
}
|
||||
return provider.CredentialFormOf(n.Name) == catalogue.CredentialFormPair
|
||||
}
|
||||
|
||||
// providerMoved is whether a need is answered by a module marked moved (issue 788), or by one whose offer of the provision
|
||||
// says the provider helper answers it (issue 792).
|
||||
func providerMoved(shelf map[string]catalogue.Manifest, n catalogue.Needed) bool {
|
||||
if n.ByRecord || n.Module == "" {
|
||||
return false
|
||||
}
|
||||
provider, known := shelf[n.Module]
|
||||
return known && (movedProvider(provider) || provider.AnsweredByHelperFor(n.Name))
|
||||
}
|
||||
|
||||
// answeredByHelper is whether a need's provider offers it answered by the provider helper (issue 792): keyed by the seat
|
||||
// mesh-provisioner for every consumer, handed or not (catalogue.Keyed, issue 774).
|
||||
func answeredByHelper(shelf map[string]catalogue.Manifest, n catalogue.Needed) bool {
|
||||
if n.ByRecord || n.Module == "" {
|
||||
return false
|
||||
}
|
||||
provider, known := shelf[n.Module]
|
||||
return known && provider.AnsweredByHelperFor(n.Name)
|
||||
}
|
||||
|
||||
// movedProvider is whether a provider's credentials are the vault's: the module is marked moved, or it holds the
|
||||
// mesh-vault seat (issue 793).
|
||||
func movedProvider(m catalogue.Manifest) bool {
|
||||
return m.Moved || slices.ContainsFunc(m.Claims, func(c catalogue.Claim) bool { return c.Name == "mesh-vault" })
|
||||
}
|
||||
|
||||
// resolution works out what one node should run, from what was assigned to it: the controller's planFor up to and
|
||||
// including the resolution (plan.go planFor), and no further. A failure to compose the node's own modules is wrapped as
|
||||
// notResolvable; every other failure is returned as it is.
|
||||
func (c *composer) resolution(nodeName string) (catalogue.Resolution, error) {
|
||||
node, err := c.in.node(nodeName)
|
||||
if err != nil {
|
||||
return catalogue.Resolution{}, err
|
||||
}
|
||||
shelf := c.shelfFor(node)
|
||||
world, _, left, err := c.theRestOfTheMesh(nodeName)
|
||||
if err != nil {
|
||||
return catalogue.Resolution{}, err
|
||||
}
|
||||
world.Pinned = node.Pins
|
||||
// Where each of its consumers of a provision that keeps data was last sent (novox/hq ADR 0232).
|
||||
world.Bound = node.Bound
|
||||
|
||||
onNetwork, err := c.whereEveryoneIs()
|
||||
if err != nil {
|
||||
return catalogue.Resolution{}, err
|
||||
}
|
||||
|
||||
// What this mesh can answer with a record rather than a machine, and which record each of this node's modules was
|
||||
// put on.
|
||||
world.Licences, world.Using = c.licencesFor(nodeName)
|
||||
|
||||
resolved, err := catalogue.Resolve(shelf, node.Assigned,
|
||||
catalogue.Node{Name: nodeName, Site: node.Place.Site, Capabilities: node.Capabilities,
|
||||
At: onNetwork[nodeName], PublicDomain: node.PublicDomain,
|
||||
Account: node.Account, AccountHome: node.AccountHome,
|
||||
AgentAccount: node.AgentAccount, AgentAccountHome: node.AgentAccountHome}, world)
|
||||
if err != nil {
|
||||
// What another node's set would have offered is missing when that set does not resolve here: said with the
|
||||
// refusal, so "nothing provides" is read beside why (issue 1126, probe #532).
|
||||
if len(left) > 0 {
|
||||
var why []string
|
||||
for _, l := range left {
|
||||
why = append(why, l.String())
|
||||
}
|
||||
err = fmt.Errorf("%w\n\nand %d node(s) offer nothing here, as they could not be resolved: %s", err, len(left), strings.Join(why, "; "))
|
||||
}
|
||||
if len(c.unhostable) > 0 {
|
||||
err = fmt.Errorf("%w\n\nand these modules offer nothing here, as their node cannot run them in this composition: %s",
|
||||
err, strings.Join(c.unhostable, "; "))
|
||||
}
|
||||
// The node's own set does not compose: the only failure a mesh-wide gatherer may pass over.
|
||||
return catalogue.Resolution{}, notResolvable{err}
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// shelfFor is the catalogue as one node composes its own set: the Input's, with each recorded or core module the node keeps at the
|
||||
// build it runs put in place of the current one (plan.go keepRecorded). A copy: the Input's catalogue is untouched. Only the node's own planFor and
|
||||
// resolution use it; the rest of the mesh and who is on the network are read with the current manifests, as the controller reads them
|
||||
// before it keeps a recorded module.
|
||||
func (c *composer) shelfFor(node Node) map[string]catalogue.Manifest {
|
||||
if len(node.Kept) == 0 {
|
||||
return c.in.Catalogue
|
||||
}
|
||||
shelf := make(map[string]catalogue.Manifest, len(c.in.Catalogue)+len(node.Kept))
|
||||
for name, m := range c.in.Catalogue {
|
||||
shelf[name] = m
|
||||
}
|
||||
for name, m := range node.Kept {
|
||||
shelf[name] = m
|
||||
}
|
||||
return shelf
|
||||
}
|
||||
|
||||
// licencesFor is what this node can be answered with by record, and what it was put on. A mesh with no licences at all
|
||||
// is the ordinary case and not an error.
|
||||
func (c *composer) licencesFor(node string) (map[string][]catalogue.Record, map[string]map[string]catalogue.Record) {
|
||||
all := c.in.Licences
|
||||
if len(all) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
offered := map[string][]catalogue.Record{}
|
||||
byName := map[string]catalogue.Record{}
|
||||
for _, one := range all {
|
||||
record := catalogue.Record{Name: one.Name, Serves: one.Serves}
|
||||
offered[LicenceProvision] = append(offered[LicenceProvision], record)
|
||||
byName[one.Name] = record
|
||||
}
|
||||
using := map[string]map[string]catalogue.Record{}
|
||||
for _, one := range all {
|
||||
for _, h := range one.Holders {
|
||||
if h.Node != node {
|
||||
continue
|
||||
}
|
||||
if using[h.Module] == nil {
|
||||
using[h.Module] = map[string]catalogue.Record{}
|
||||
}
|
||||
using[h.Module][LicenceProvision] = byName[one.Name]
|
||||
}
|
||||
}
|
||||
return offered, using
|
||||
}
|
||||
@@ -1,388 +0,0 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
)
|
||||
|
||||
// storeManifests is mesh-controller's bindings_test.go storeManifests (cmd/mesh-controller at 6c2a8d62): a store holding the
|
||||
// mesh-store seat, a resolver holding mesh-dns-resolver, a network that requires wildcard resolution, and a board that
|
||||
// requires a database.
|
||||
func storeManifests() []catalogue.Manifest {
|
||||
return []catalogue.Manifest{
|
||||
{Module: "store", Version: "1",
|
||||
Provides: []catalogue.Offer{{Name: "postgres-database", Scope: catalogue.ScopeMesh}},
|
||||
Claims: []catalogue.Claim{{Name: "mesh-store", Scope: catalogue.ScopeMesh}},
|
||||
Serves: map[string]map[string]any{"postgres-database": {"port": 5432}},
|
||||
Grants: map[string]string{"postgres-database": "/var/lib/mesh/store/grants"}},
|
||||
{Module: "resolver", Version: "1",
|
||||
Provides: []catalogue.Offer{{Name: "wildcard-resolution", Scope: catalogue.ScopeMesh}},
|
||||
Claims: []catalogue.Claim{{Name: "mesh-dns-resolver", Scope: catalogue.ScopeMesh}}},
|
||||
{Module: "network", Version: "1", Requires: []string{"wildcard-resolution"}},
|
||||
{Module: "board", Version: "1", Requires: []string{"postgres-database"}},
|
||||
}
|
||||
}
|
||||
|
||||
func need(t *testing.T, plan catalogue.Resolution, consumer, provision string) catalogue.Needed {
|
||||
t.Helper()
|
||||
for _, n := range plan.Needs {
|
||||
if n.For == consumer && n.Name == provision {
|
||||
return n
|
||||
}
|
||||
}
|
||||
t.Fatalf("no %s for %s: %+v", provision, consumer, plan.Needs)
|
||||
return catalogue.Needed{}
|
||||
}
|
||||
|
||||
// theStoreMesh is the mesh of mesh-controller's bindings_integration_test.go
|
||||
// TestTheIncidentAConsumerStaysBesideItsDataUntilAPersonPinsIt: the anchor's store and resolver hold the mesh's seats on
|
||||
// record; the laptop runs its own of each, and a network and a board.
|
||||
func theStoreMesh(t *testing.T) Input {
|
||||
t.Helper()
|
||||
in := aMesh(t)
|
||||
for _, m := range storeManifests() {
|
||||
in.Catalogue[m.Module] = m
|
||||
}
|
||||
anchor, laptop := nodeOf(t, &in, "anchor"), nodeOf(t, &in, "laptop")
|
||||
anchor.Assigned = append(anchor.Assigned, "store", "resolver")
|
||||
laptop.Assigned = append(laptop.Assigned, "store", "resolver", "network", "board")
|
||||
in.Holdings = []catalogue.Held{
|
||||
{Claim: "mesh-store", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "store", Site: "here"},
|
||||
{Claim: "mesh-dns-resolver", Scope: catalogue.ScopeMesh, Node: "anchor", Module: "resolver", Site: "here"},
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// 1362/AC7 and AC4 (bindings_integration_test.go): a consumer of a provision that keeps its data stays beside its data, one that
|
||||
// does not follows the seat; the laptop's store taken away, the consumer bound there on record is refused, not moved to the
|
||||
// anchor's empty one; a person's pin moves it.
|
||||
func TestAConsumerStaysBesideItsDataUntilAPersonPinsIt(t *testing.T) {
|
||||
in := theStoreMesh(t)
|
||||
got, err := Resolve(in, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := need(t, got.Plan, "board", "postgres-database"); n.From != "laptop" || n.Module != "store" || !n.KeepsData {
|
||||
t.Fatalf("the consumer was bound to %s/%s (keeps data: %v); its data is beside it", n.From, n.Module, n.KeepsData)
|
||||
}
|
||||
if n := need(t, got.Plan, "network", "wildcard-resolution"); n.From != "anchor" || n.KeepsData {
|
||||
t.Fatalf("the resolver was bound to %s (keeps data: %v); its seat is held on anchor (issue 258)", n.From, n.KeepsData)
|
||||
}
|
||||
|
||||
// Sent, and recorded: the binding to data. The laptop's store taken away: refused, not moved to the anchor's empty one.
|
||||
laptop := nodeOf(t, &in, "laptop")
|
||||
laptop.Bound = map[string]map[string]catalogue.Chosen{"board": {"postgres-database": {Node: "laptop", Module: "store"}}}
|
||||
laptop.Assigned = []string{overlay.Name, "resolver", "network", "board"}
|
||||
_, err = Resolve(in, "laptop")
|
||||
if err == nil || !IsUnresolvable(err) ||
|
||||
!strings.Contains(err.Error(), "board on laptop is bound to laptop/store") ||
|
||||
!strings.Contains(err.Error(), "pin laptop postgres-database anchor store") {
|
||||
t.Fatalf("the consumer's store went and it was answered elsewhere: %v", err)
|
||||
}
|
||||
|
||||
// A person pins the anchor's: it moves, and the resolution says where it was bound (Kept is empty, as it moves).
|
||||
laptop.Pins = map[string]catalogue.Chosen{"postgres-database": {Node: "anchor", Module: "store"}}
|
||||
got, err = Resolve(in, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := need(t, got.Plan, "board", "postgres-database"); n.From != "anchor" {
|
||||
t.Fatalf("pinned to the anchor and bound to %s", n.From)
|
||||
}
|
||||
}
|
||||
|
||||
// 1362/AC4: a data-keeping consumer bound on record to a provider elsewhere is kept on that provider, and the resolution says
|
||||
// so (Resolution.Kept) when the mesh would answer it otherwise.
|
||||
func TestADataKeepingConsumerBoundElsewhereIsKeptThereAndSaid(t *testing.T) {
|
||||
in := theStoreMesh(t)
|
||||
laptop := nodeOf(t, &in, "laptop")
|
||||
laptop.Assigned = []string{overlay.Name, "resolver", "network", "board"} // no store of its own
|
||||
laptop.Bound = map[string]map[string]catalogue.Chosen{"board": {"postgres-database": {Node: "anchor", Module: "store"}}}
|
||||
got, err := Resolve(in, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := need(t, got.Plan, "board", "postgres-database"); n.From != "anchor" || !n.KeepsData {
|
||||
t.Fatalf("the consumer is bound to %s (keeps data: %v), not where it was sent", n.From, n.KeepsData)
|
||||
}
|
||||
if len(got.Plan.Kept) != 0 {
|
||||
t.Errorf("a binding that was kept where it is is said as a kept move: %+v", got.Plan.Kept)
|
||||
}
|
||||
// And bound somewhere else on record while the mesh would answer from the anchor: kept, and said.
|
||||
laptop.Bound = map[string]map[string]catalogue.Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}}
|
||||
home := Node{Name: "home", Place: Place{Site: "there", Address: "10.77.0.7"}, Assigned: []string{overlay.Name, "store"},
|
||||
Capabilities: map[string]bool{"container-runtime": true, "wireguard": true, "systemd": true}}
|
||||
in.Nodes = append(in.Nodes, home)
|
||||
got, err = Resolve(in, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := need(t, got.Plan, "board", "postgres-database"); n.From != "home" {
|
||||
t.Errorf("the consumer was moved from where it was sent: %s", n.From)
|
||||
}
|
||||
}
|
||||
|
||||
// A provider module whose offer says only a person gives its credential (ADR 0337), and a consumer of it.
|
||||
func outsideObjects() catalogue.Manifest {
|
||||
m := objects()
|
||||
m.Provides[0].Credential = &catalogue.OfferCredential{IssuedBy: catalogue.IssuedOutside}
|
||||
return m
|
||||
}
|
||||
|
||||
func theObjectsMesh(t *testing.T, provider catalogue.Manifest) Input {
|
||||
t.Helper()
|
||||
in := aMesh(t)
|
||||
in.Catalogue[provider.Module], in.Catalogue["files"] = provider, files()
|
||||
nodeOf(t, &in, "anchor").Assigned = append(nodeOf(t, &in, "anchor").Assigned, provider.Module)
|
||||
nodeOf(t, &in, "laptop").Assigned = append(nodeOf(t, &in, "laptop").Assigned, "files")
|
||||
nodeOf(t, &in, "anchor").SealingKey = "anchor-key"
|
||||
nodeOf(t, &in, "laptop").SealingKey = "laptop-key"
|
||||
return in
|
||||
}
|
||||
|
||||
func theFilesNeed(t *testing.T, got Resolved) catalogue.Needed {
|
||||
t.Helper()
|
||||
return need(t, got.Plan, "files", "s3-bucket")
|
||||
}
|
||||
|
||||
// 1362/AC1: a pair credential the Input names as existing with no value, and one it names not at all, are composed with a
|
||||
// stand-in and named foreseen, and nothing is made; one with a value is composed with it and foreseen is empty.
|
||||
func TestAPairCredentialNotHeldIsComposedWithAStandInAndNamedForeseen(t *testing.T) {
|
||||
mk := func() Input { return theObjectsMesh(t, objects()) }
|
||||
for name, creds := range map[string][]Credential{
|
||||
"no entry": nil,
|
||||
"held with no value": {{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialHeld}},
|
||||
"to be made": {{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialWouldMake}},
|
||||
} {
|
||||
in := mk()
|
||||
in.Credentials = creds
|
||||
got, err := Resolve(in, "laptop")
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
if n := theFilesNeed(t, got); n.Sealed != catalogue.ForeseenSealed {
|
||||
t.Errorf("%s: the credential is %q, not the stand-in", name, n.Sealed)
|
||||
}
|
||||
if len(got.Foreseen) != 1 || got.Foreseen[0] != "files on laptop needs s3-bucket from anchor" {
|
||||
t.Errorf("%s: foreseen is %v", name, got.Foreseen)
|
||||
}
|
||||
}
|
||||
in := mk()
|
||||
in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor",
|
||||
State: CredentialHeld, Sealed: "sealed-to-laptop"}}
|
||||
got, err := Resolve(in, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := theFilesNeed(t, got); n.Sealed != "sealed-to-laptop" || len(got.Foreseen) != 0 {
|
||||
t.Errorf("a held credential: %q foreseen %v", n.Sealed, got.Foreseen)
|
||||
}
|
||||
unchanged(t, mk, func(in Input) {
|
||||
if _, err := Resolve(in, "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 1362/AC2: a credential only a person gives and nobody has is not given and not made: the consumer still resolves without it
|
||||
// (novox/hq ADR 0337 clause 4), said with the controller's words; one given is composed with it.
|
||||
func TestACredentialOnlyAPersonGivesIsNotMadeAndTheConsumerStillResolves(t *testing.T) {
|
||||
for name, creds := range map[string][]Credential{
|
||||
"no entry": nil,
|
||||
"to be made (never, for one issued outside)": {{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialWouldMake}},
|
||||
"not given": {{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor", State: CredentialNotGiven,
|
||||
Why: "the value held was made by the mesh, which objects's software would refuse"}},
|
||||
} {
|
||||
in := theObjectsMesh(t, outsideObjects())
|
||||
in.Credentials = creds
|
||||
got, err := Resolve(in, "laptop")
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
if n := theFilesNeed(t, got); n.Sealed != "" {
|
||||
t.Errorf("%s: a value stands in for what nobody gave: %q", name, n.Sealed)
|
||||
}
|
||||
if len(got.Foreseen) != 0 || len(got.NotGiven) != 1 || !strings.Contains(got.NotGiven[0], "files on laptop holds no s3-bucket from anchor: ") {
|
||||
t.Errorf("%s: foreseen %v not given %v", name, got.Foreseen, got.NotGiven)
|
||||
}
|
||||
if len(got.Plan.Modules) == 0 {
|
||||
t.Errorf("%s: the machine did not resolve", name)
|
||||
}
|
||||
}
|
||||
in := theObjectsMesh(t, outsideObjects())
|
||||
in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor",
|
||||
State: CredentialHeld, Sealed: "given-and-sealed"}}
|
||||
got, err := Resolve(in, "laptop")
|
||||
if err != nil || theFilesNeed(t, got).Sealed != "given-and-sealed" || len(got.NotGiven) != 0 {
|
||||
t.Fatalf("a value a person gave: %+v %v", got.NotGiven, err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1362/AC3: a credential the send would refuse, a given value sealed to a key a machine no longer has, refuses the resolution with
|
||||
// today's words, and is not a node whose set does not compose.
|
||||
func TestACredentialTheSendWouldRefuseRefusesTheResolutionInTheSendsWords(t *testing.T) {
|
||||
why := "files's \"s3-bucket\" credential from anchor was accepted from a person, and a sealing key at one end has changed since. " +
|
||||
"The mesh cannot re-seal a value it does not hold: accept it again with `secret accept laptop files s3-bucket --provider anchor`"
|
||||
in := theObjectsMesh(t, objects())
|
||||
in.Credentials = []Credential{{Provision: "s3-bucket", Consumer: "laptop", ConsumerModule: "files", Provider: "anchor",
|
||||
State: CredentialRefused, Why: why}}
|
||||
_, err := Resolve(in, "laptop")
|
||||
want := "files on laptop needs s3-bucket from anchor and no credential could be made for it: " + why
|
||||
if err == nil || err.Error() != want {
|
||||
t.Fatalf("got %v, want %q", err, want)
|
||||
}
|
||||
if IsUnresolvable(err) {
|
||||
t.Error("a refused credential is a node whose set does not compose")
|
||||
}
|
||||
}
|
||||
|
||||
// A record-answered need is filled with the licence key sealed to this holder, and the manager holder is handed the manager node's
|
||||
// public sealing key (novox/hq ADR 0050) and no other.
|
||||
func TestARecordAnsweredNeedGetsItsKeyAndTheManagerItsPublicKey(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
in.Catalogue["assistant"] = catalogue.Manifest{Module: "assistant", Version: "1", Requires: []string{LicenceProvision}}
|
||||
laptop := nodeOf(t, &in, "laptop")
|
||||
laptop.Assigned = append(laptop.Assigned, "assistant")
|
||||
laptop.SealingKey = "laptop-key"
|
||||
in.Licences = []Licence{{Name: "personal", Serves: map[string]any{"model": "x"},
|
||||
Holders: []Holder{{Node: "laptop", Module: "assistant"}},
|
||||
Keys: map[Holder]string{{Node: "laptop", Module: "assistant"}: "key-sealed-to-laptop"}}}
|
||||
got, err := Resolve(in, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := need(t, got.Plan, "assistant", LicenceProvision)
|
||||
if !n.ByRecord || n.Sealed != "key-sealed-to-laptop" || n.Manager || n.Serves["manager_public_key"] != nil {
|
||||
t.Fatalf("the record-answered need is %+v", n)
|
||||
}
|
||||
in.Licences[0].Manager = Holder{Node: "laptop", Module: "assistant"}
|
||||
got, err = Resolve(in, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n = need(t, got.Plan, "assistant", LicenceProvision)
|
||||
if !n.Manager || n.Serves["manager_public_key"] != "laptop-key" || n.Serves["model"] != "x" {
|
||||
t.Fatalf("the manager is %+v", n)
|
||||
}
|
||||
if len(got.Foreseen) != 0 || len(got.NotGiven) != 0 {
|
||||
t.Errorf("a record-answered need needs no pair credential: %v %v", got.Foreseen, got.NotGiven)
|
||||
}
|
||||
}
|
||||
|
||||
// 1362/AC5: a node whose own set does not compose returns an error callers can tell from an unreadable Input.
|
||||
func TestANodeWhoseOwnSetDoesNotComposeIsToldApartFromAnUnreadableInput(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
in.Catalogue["rival-one"] = catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
|
||||
in.Catalogue["rival-two"] = catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
|
||||
laptop := nodeOf(t, &in, "laptop")
|
||||
laptop.Assigned = append(laptop.Assigned, "rival-one", "rival-two")
|
||||
if _, err := Resolve(in, "laptop"); err == nil || !IsUnresolvable(err) {
|
||||
t.Fatalf("a set that does not compose: %v", err)
|
||||
}
|
||||
if _, err := Resolve(in, "nowhere"); err == nil || IsUnresolvable(err) || !errors.Is(err, ErrNoSuchNode) {
|
||||
t.Fatalf("a node the mesh does not have: %v", err)
|
||||
}
|
||||
in.Catalogue = nil
|
||||
if _, err := Resolve(in, "anchor"); err == nil || IsUnresolvable(err) {
|
||||
t.Fatalf("an Input without the catalogue: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1362/AC6: settings are returned for every resolved module that has layers in the Input, including one pulled in by another.
|
||||
func TestSettingsAreReturnedForEveryResolvedModuleIncludingOneThatWasPulledIn(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
in.Catalogue["lib"] = catalogue.Manifest{Module: "lib", Version: "1", Provides: catalogue.Offers("blob")}
|
||||
in.Catalogue["app"] = catalogue.Manifest{Module: "app", Version: "1", Requires: []string{"blob"}}
|
||||
laptop := nodeOf(t, &in, "laptop")
|
||||
laptop.Assigned = append(laptop.Assigned, "app")
|
||||
laptop.Settings = map[string][]catalogue.Layer{
|
||||
"lib": {{From: "mesh", Values: map[string]any{"x": 1}}},
|
||||
"app": {{From: "laptop", Values: map[string]any{"y": 2}}},
|
||||
"other": {{From: "laptop", Values: map[string]any{"z": 3}}}, // not resolved here: no settings come of it
|
||||
"empty": nil,
|
||||
}
|
||||
got, err := Resolve(in, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pulled := false
|
||||
for _, m := range got.Plan.Modules {
|
||||
pulled = pulled || m.Module == "lib"
|
||||
}
|
||||
if !pulled {
|
||||
t.Fatalf("lib was not pulled in: %+v", got.Plan.Modules)
|
||||
}
|
||||
if len(got.Settings) != 2 || got.Settings["lib"][0].Values["x"] != 1 || got.Settings["app"][0].Values["y"] != 2 {
|
||||
t.Errorf("settings are %+v", got.Settings)
|
||||
}
|
||||
// The settings returned are copies: changing them does not change the Input.
|
||||
got.Settings["app"][0].From = "changed"
|
||||
if laptop.Settings["app"][0].From != "laptop" {
|
||||
t.Error("the settings returned share the Input's layers")
|
||||
}
|
||||
}
|
||||
|
||||
// Round 1, finding 3: a licence key the Input does not carry (Keys not given) is composed with a stand-in and named foreseen, so the
|
||||
// node still composes; Keys given that lack the holder is the mesh holding none, and the need stays empty.
|
||||
func TestALicenceKeyTheInputDoesNotCarryIsAStandInAndNamedForeseen(t *testing.T) {
|
||||
in := aMesh(t)
|
||||
in.Catalogue["assistant"] = catalogue.Manifest{Module: "assistant", Version: "1", Requires: []string{LicenceProvision}}
|
||||
laptop := nodeOf(t, &in, "laptop")
|
||||
laptop.Assigned = append(laptop.Assigned, "assistant")
|
||||
in.Licences = []Licence{{Name: "personal", Serves: map[string]any{"model": "x"}, Holders: []Holder{{Node: "laptop", Module: "assistant"}}}}
|
||||
got, err := Resolve(in, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := need(t, got.Plan, "assistant", LicenceProvision); n.Sealed != catalogue.ForeseenSealed {
|
||||
t.Errorf("a key not given is composed as %q", n.Sealed)
|
||||
}
|
||||
if len(got.Foreseen) != 1 || !strings.Contains(got.Foreseen[0], "assistant on laptop needs model-access") {
|
||||
t.Errorf("foreseen %v", got.Foreseen)
|
||||
}
|
||||
// And a whole node composes with it.
|
||||
if _, problems, err := Compose(in, "laptop"); err != nil || len(problems) != 0 {
|
||||
t.Errorf("a node using a licence whose key is not carried: %v %v", err, problems)
|
||||
}
|
||||
// Keys given, and none for this holder: the mesh holds none.
|
||||
in.Licences[0].Keys = map[Holder]string{}
|
||||
got, err = Resolve(in, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := need(t, got.Plan, "assistant", LicenceProvision); n.Sealed != "" || len(got.Foreseen) != 0 {
|
||||
t.Errorf("a key the mesh holds none of: %q %v", n.Sealed, got.Foreseen)
|
||||
}
|
||||
}
|
||||
|
||||
// A consumer not handed of a provision whose provider's offer says the helper answers it has that provision in a plan of
|
||||
// its own (issue 774: grafana's route): the composition marks the need answered by the helper, and the plan keys it.
|
||||
func TestANeedAnsweredByTheHelperIsInTheConsumersPlan(t *testing.T) {
|
||||
in := theStoreMesh(t)
|
||||
store := in.Catalogue["store"]
|
||||
store.Provides = []catalogue.Offer{{Name: "postgres-database", Scope: catalogue.ScopeMesh, AnsweredBy: catalogue.AnsweredByHelper}}
|
||||
in.Catalogue["store"] = store
|
||||
got, err := Resolve(in, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := need(t, got.Plan, "board", "postgres-database"); !n.AnsweredByHelper {
|
||||
t.Fatalf("the need is not marked answered by the helper: %+v", n)
|
||||
}
|
||||
with := rendered(t, in, "laptop", Foreseeing).Rendering
|
||||
with.Hands, with.Provisioner = true, "mesh-provisioner-client"
|
||||
if with.Generators, err = newComposer(in).generators(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := got.Plan.Compose(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p := c.Plans["board"].Provisions; len(p) != 1 || p[0].Provision != "postgres-database" || p[0].ProviderModule != "store" {
|
||||
t.Fatalf("board's plan is %+v", c.Plans["board"])
|
||||
}
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// notResolvable marks the one failure in a node's resolution that is a statement about the node: its assigned modules
|
||||
// do not compose. Every other failure means the mesh could not be asked. Whoever gathers across the mesh skips a node
|
||||
// that cannot resolve and never a node that could not be read (novox/hq 04-ISSUES/152).
|
||||
type notResolvable struct{ err error }
|
||||
|
||||
func (n notResolvable) Error() string { return n.err.Error() }
|
||||
func (n notResolvable) Unwrap() error { return n.err }
|
||||
|
||||
// unresolvable reports whether err is a node's own set failing to compose.
|
||||
func unresolvable(err error) bool {
|
||||
var n notResolvable
|
||||
return errors.As(err, &n)
|
||||
}
|
||||
|
||||
// Passes is what a composition found that the mesh must record, and did not: each seat held once whose holder on record
|
||||
// is assigned nowhere that exactly one claimant takes over (novox/hq ADR 0449 clause 4), and the lines that say what was
|
||||
// found. The caller records a pass and writes its hand act; this package does neither.
|
||||
type Passes struct {
|
||||
// Passes are the seats to pass, in the order they were found.
|
||||
Passes []catalogue.SeatPass
|
||||
// Said are the lines the finding says (a seat two claimants could take, one nothing can).
|
||||
Said []string
|
||||
}
|
||||
|
||||
// LeftOut is a node that does not resolve in the view of the rest of the mesh, with why: it offers nothing and holds
|
||||
// nothing as far as every other machine's plan can tell (novox/hq issue 188).
|
||||
type LeftOut struct {
|
||||
Node string
|
||||
Err error
|
||||
}
|
||||
|
||||
// String is the line the controller says of it.
|
||||
func (l LeftOut) String() string {
|
||||
return fmt.Sprintf("%s is left out of the rest of the mesh: it does not resolve: %v", l.Node, l.Err)
|
||||
}
|
||||
|
||||
// TheRestOfTheMesh is what every other node holds and offers, as the controller works it out (plan.go theRestOfTheMesh):
|
||||
// two passes over the others, because what a node offers needs it resolved and resolving it may need what the mesh
|
||||
// offers. The first takes brokered requirements on trust and answers only what each node offers; the second answers
|
||||
// everything with that in hand. Nothing is declared from the first.
|
||||
//
|
||||
// It writes nothing. A seat the mesh passes on is returned in Passes for the caller to record; the World carries the
|
||||
// holdings as they stand after the passes, and Input's own are unchanged. A node left out is returned with its reason, in
|
||||
// the order the controller said them (the first pass's, then the second's when no node is excluded).
|
||||
func TheRestOfTheMesh(in Input, exclude string) (catalogue.World, Passes, []LeftOut, error) {
|
||||
c := newComposer(in)
|
||||
return c.theRestOfTheMesh(exclude)
|
||||
}
|
||||
|
||||
func (c *composer) theRestOfTheMesh(exclude string) (catalogue.World, Passes, []LeftOut, error) {
|
||||
r, ok := c.rest[exclude]
|
||||
if !ok {
|
||||
r.world, r.passes, r.left, r.err = c.theRestOfTheMeshNow(exclude)
|
||||
r.unhostable = c.unhostable
|
||||
if c.rest == nil {
|
||||
c.rest = map[string]restOf{}
|
||||
}
|
||||
c.rest[exclude] = r
|
||||
}
|
||||
// Each caller has its own copy of what it may set or add to; the maps and the slices within are only read.
|
||||
c.unhostable = slices.Clone(r.unhostable)
|
||||
return r.world, r.passes, slices.Clone(r.left), r.err
|
||||
}
|
||||
|
||||
// theRestOfTheMeshNow is theRestOfTheMesh worked out: every other machine's set resolved, twice.
|
||||
func (c *composer) theRestOfTheMeshNow(exclude string) (catalogue.World, Passes, []LeftOut, error) {
|
||||
shelf := c.in.Catalogue
|
||||
holdings := append([]catalogue.Held(nil), c.in.Holdings...)
|
||||
nodes := c.in.sorted()
|
||||
|
||||
onNetwork, err := c.whereEveryoneIs()
|
||||
if err != nil {
|
||||
return catalogue.World{}, Passes{}, nil, err
|
||||
}
|
||||
|
||||
type candidate struct {
|
||||
node catalogue.Node
|
||||
assigned []string
|
||||
pins map[string]catalogue.Chosen
|
||||
}
|
||||
var others []candidate
|
||||
// Every machine's assignments, before the holdings are read by any resolution (novox/hq ADR 0449 clause 4): a seat
|
||||
// held once whose holder on record is assigned on no machine passes to its only claimant here.
|
||||
everyAssigned := map[string][]string{}
|
||||
for _, n := range nodes {
|
||||
if len(n.Assigned) > 0 {
|
||||
everyAssigned[n.Name] = n.Assigned
|
||||
}
|
||||
}
|
||||
var passes Passes
|
||||
holdings, passes = passSeatsOn(holdings, assignmentsOf(shelf, everyAssigned))
|
||||
for _, n := range nodes {
|
||||
if n.Name == exclude {
|
||||
continue
|
||||
}
|
||||
theirs := everyAssigned[n.Name]
|
||||
if len(theirs) == 0 {
|
||||
continue
|
||||
}
|
||||
others = append(others, candidate{
|
||||
node: catalogue.Node{Name: n.Name, Site: n.Place.Site, Capabilities: n.Capabilities,
|
||||
At: onNetwork[n.Name], PublicDomain: n.PublicDomain},
|
||||
assigned: theirs, pins: n.Pins})
|
||||
}
|
||||
|
||||
var left []LeftOut
|
||||
offered := map[string][]catalogue.Provider{}
|
||||
var firstHeld []catalogue.Held
|
||||
c.unhostable = nil
|
||||
for _, o := range others {
|
||||
got, err := catalogue.Resolve(shelf, o.assigned, o.node, catalogue.World{Unchecked: true, Holdings: holdings})
|
||||
if err != nil {
|
||||
// Said, not skipped: a machine dropped here offers nothing and holds nothing.
|
||||
left = append(left, LeftOut{Node: o.node.Name, Err: err})
|
||||
continue
|
||||
}
|
||||
for _, u := range got.Unhostable {
|
||||
c.unhostable = append(c.unhostable, fmt.Sprintf("%s on %s lacks %s", u.Module, o.node.Name, strings.Join(u.Missing, ", ")))
|
||||
}
|
||||
firstHeld = append(firstHeld, got.Claims...)
|
||||
for _, m := range got.Modules {
|
||||
for _, name := range m.OffersAt(catalogue.ScopeMesh) {
|
||||
// What that module says a consumer needs to know, with that node's settings on it.
|
||||
serves, layers, err := c.servedOnNode(o.node.Name, m, name)
|
||||
if err != nil {
|
||||
return catalogue.World{}, Passes{}, nil, err
|
||||
}
|
||||
// And its own composed names, as that node composes them (novox/hq ADR 0346).
|
||||
serves, leftOut, err := got.ServedOwnNames(m, name, serves, catalogue.WithDefaults(m, layers))
|
||||
if err != nil {
|
||||
return catalogue.World{}, Passes{}, nil, err
|
||||
}
|
||||
offered[name] = append(offered[name], catalogue.Provider{
|
||||
Node: o.node.Name, At: o.node.At, Serves: serves, Module: m.Module, LeftOut: leftOut})
|
||||
}
|
||||
}
|
||||
}
|
||||
for k := range offered {
|
||||
sort.Slice(offered[k], func(i, j int) bool {
|
||||
return offered[k][i].Node < offered[k][j].Node
|
||||
})
|
||||
}
|
||||
|
||||
// The second pass is given the first pass's holdings (novox/hq ADR 0110).
|
||||
world := catalogue.World{Offered: offered, Held: firstHeld, Holdings: holdings}
|
||||
var held []catalogue.Held
|
||||
for _, o := range others {
|
||||
// Each machine is resolved with its own pins, as its plan is.
|
||||
theirs := world
|
||||
theirs.Pinned = o.pins
|
||||
got, err := catalogue.Resolve(shelf, o.assigned, o.node, theirs)
|
||||
if err != nil {
|
||||
// Said only for the whole-mesh view: with one machine excluded, the view is partial by design.
|
||||
if exclude == "" {
|
||||
left = append(left, LeftOut{Node: o.node.Name, Err: err})
|
||||
}
|
||||
continue
|
||||
}
|
||||
held = append(held, got.Claims...)
|
||||
}
|
||||
world.Held = held
|
||||
return world, passes, left, nil
|
||||
}
|
||||
|
||||
// passSeatsOn finds, in the composition that finds it, the new holder of each seat held once whose holder on record is
|
||||
// assigned nowhere and which has exactly one claimant able to hold it (novox/hq ADR 0449 clause 4), and answers the
|
||||
// holdings as they are after it, with the passes. Nothing is recorded.
|
||||
func passSeatsOn(holdings []catalogue.Held, assignments []catalogue.Assignment) ([]catalogue.Held, Passes) {
|
||||
passes, said := catalogue.SeatsToPassOn(holdings, assignments)
|
||||
out := Passes{Said: said}
|
||||
for _, p := range passes {
|
||||
out.Passes = append(out.Passes, p)
|
||||
var next []catalogue.Held
|
||||
for _, h := range holdings {
|
||||
if hs, ok := catalogue.SeatNamed(h.Claim); ok && hs.Name == p.Seat.Name {
|
||||
continue
|
||||
}
|
||||
next = append(next, h)
|
||||
}
|
||||
p.To.Site = p.From.Site
|
||||
holdings = append(next, p.To)
|
||||
}
|
||||
return holdings, out
|
||||
}
|
||||
|
||||
// assignmentsOf are the assignments of every machine, as the passing of a seat reads them. A module assigned and
|
||||
// missing from the shelf is still assigned: an assignment with no claims, so its holder is not taken for gone.
|
||||
func assignmentsOf(shelf map[string]catalogue.Manifest, assigned map[string][]string) []catalogue.Assignment {
|
||||
var out []catalogue.Assignment
|
||||
names := make([]string, 0, len(assigned))
|
||||
for node := range assigned {
|
||||
names = append(names, node)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, node := range names {
|
||||
for _, module := range assigned[node] {
|
||||
m, ok := shelf[module]
|
||||
if !ok {
|
||||
m = catalogue.Manifest{Module: module}
|
||||
}
|
||||
out = append(out, catalogue.Assignment{Node: node, Manifest: m})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// servedOnNode is what a module on a node tells a consumer of one of its provisions, with THAT node's ports on it: the
|
||||
// port the node was given over the one the mesh assigned over the manifest's own, settled with the node's settings
|
||||
// layers. The one derivation for every reader of a provider's address.
|
||||
func (c *composer) servedOnNode(node string, m catalogue.Manifest, provision string) (map[string]any, []catalogue.Layer, error) {
|
||||
ports, layers, err := c.portsGivenOn(node, m)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
serves := catalogue.ServedOn(m, provision, ports)
|
||||
if len(serves) > 0 {
|
||||
serves, err = catalogue.Settle(serves, catalogue.WithDefaults(m, layers))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
return serves, layers, nil
|
||||
}
|
||||
|
||||
// portsGivenOn is where a node puts a module's ports — assigned, then given over them — and the node's settings layers
|
||||
// for the module.
|
||||
func (c *composer) portsGivenOn(node string, m catalogue.Manifest) (map[int]int, []catalogue.Layer, error) {
|
||||
n, err := c.in.node(node)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ports := map[int]int{}
|
||||
for wanted, at := range n.Ports[m.Module] {
|
||||
ports[wanted] = at
|
||||
}
|
||||
layers := n.Settings[m.Module]
|
||||
if given, err := catalogue.GivenPorts(m, layers); err == nil {
|
||||
for wanted, at := range given {
|
||||
ports[wanted] = at
|
||||
}
|
||||
}
|
||||
return ports, layers, nil
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/overlay"
|
||||
)
|
||||
|
||||
// objects and files are the provider and consumer of mesh-controller's facts_test.go aMeshWithSecrets (cmd/mesh-controller at
|
||||
// 6c2a8d62): a module providing s3-bucket at mesh scope with an identity bound, and one requiring it.
|
||||
func objects() catalogue.Manifest {
|
||||
return catalogue.Manifest{Module: "objects", Version: "1",
|
||||
Provides: []catalogue.Offer{{Name: "s3-bucket", Scope: catalogue.ScopeMesh,
|
||||
Identity: &catalogue.OfferIdentity{Max: 20, In: "an S3 access key"}}},
|
||||
Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}}
|
||||
}
|
||||
|
||||
func files() catalogue.Manifest {
|
||||
return catalogue.Manifest{Module: "files", Version: "1", Requires: []string{"s3-bucket"},
|
||||
Resources: []map[string]any{{"id": "config", "type": "file", "path": "/etc/files/config.json",
|
||||
"mode": "0600", "content": "{}", "merge": "json"}}}
|
||||
}
|
||||
|
||||
// threeNodes is aMesh and a third node `ace` running a database, so the mesh has three nodes that each offer something:
|
||||
// anchor objects, ace a database, laptop a consumer of the first.
|
||||
func threeNodes(t *testing.T) Input {
|
||||
t.Helper()
|
||||
in := aMesh(t)
|
||||
db := catalogue.Manifest{Module: "pg", Version: "1", Provides: catalogue.FromAnywhere("postgres-database")}
|
||||
in.Catalogue["objects"], in.Catalogue["files"], in.Catalogue["pg"] = objects(), files(), db
|
||||
anchor, laptop := nodeOf(t, &in, "anchor"), nodeOf(t, &in, "laptop")
|
||||
anchor.Assigned = append(anchor.Assigned, "objects")
|
||||
laptop.Assigned = append(laptop.Assigned, "files")
|
||||
in.Nodes = append(in.Nodes, Node{Name: "ace", Place: Place{Site: "there", Address: "10.77.0.3"},
|
||||
Assigned: []string{"pg"}, Capabilities: map[string]bool{}})
|
||||
return in
|
||||
}
|
||||
|
||||
func providers(w catalogue.World, provision string) []string {
|
||||
var out []string
|
||||
for _, p := range w.Offered[provision] {
|
||||
out = append(out, p.Node+"/"+p.Module)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 1361/AC1: three nodes each offering a provision; the rest of the mesh, for one of them, offers every other node's and none of its
|
||||
// own, and holds what they hold.
|
||||
func TestTheRestOfTheMeshOffersEveryOtherNodesProvisionsAndNoneOfTheExcluded(t *testing.T) {
|
||||
in := threeNodes(t)
|
||||
w, passes, left, err := TheRestOfTheMesh(in, "laptop")
|
||||
if err != nil || len(left) != 0 || len(passes.Passes) != 0 {
|
||||
t.Fatalf("%v %v %+v", err, left, passes)
|
||||
}
|
||||
if got := providers(w, "s3-bucket"); len(got) != 1 || got[0] != "anchor/objects" {
|
||||
t.Errorf("s3-bucket is offered by %v", got)
|
||||
}
|
||||
if got := providers(w, "postgres-database"); len(got) != 1 || got[0] != "ace/pg" {
|
||||
t.Errorf("postgres-database is offered by %v", got)
|
||||
}
|
||||
if p := w.Offered["s3-bucket"][0]; p.At != "anchor.internal" {
|
||||
t.Errorf("anchor is at %q on the private network", p.At)
|
||||
}
|
||||
// Excluding anchor takes its offer away and keeps the others'.
|
||||
w, _, _, err = TheRestOfTheMesh(in, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := providers(w, "s3-bucket"); len(got) != 0 {
|
||||
t.Errorf("the excluded node's offer is in the world: %v", got)
|
||||
}
|
||||
if got := providers(w, "postgres-database"); len(got) != 1 {
|
||||
t.Errorf("another node's offer is missing: %v", got)
|
||||
}
|
||||
// With nobody excluded, every offer is there, and everyone's claims are held.
|
||||
w, _, _, err = TheRestOfTheMesh(in, "")
|
||||
if err != nil || len(providers(w, "s3-bucket")) != 1 || len(providers(w, "postgres-database")) != 1 {
|
||||
t.Fatalf("%v %v", providers(w, "s3-bucket"), err)
|
||||
}
|
||||
// Each machine is resolved with its own pins, as its plan is: the world is the same for any pin of the excluded node.
|
||||
nodeOf(t, &in, "laptop").Pins = map[string]catalogue.Chosen{"s3-bucket": {Node: "anchor", Module: "objects"}}
|
||||
if _, _, _, err := TheRestOfTheMesh(in, "laptop"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 1361/AC2: a node whose assigned set does not resolve is left out and named with today's reason, and the others still offer.
|
||||
func TestANodeWhoseSetDoesNotResolveIsLeftOutAndTheOthersStillOffer(t *testing.T) {
|
||||
in := threeNodes(t)
|
||||
rivalOne := catalogue.Manifest{Module: "rival-one", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
|
||||
rivalTwo := catalogue.Manifest{Module: "rival-two", Version: "1", Claims: []catalogue.Claim{{Name: "the-seat", Scope: catalogue.ScopeNode}}}
|
||||
in.Catalogue["rival-one"], in.Catalogue["rival-two"] = rivalOne, rivalTwo
|
||||
ace := nodeOf(t, &in, "ace")
|
||||
ace.Assigned = append(ace.Assigned, "rival-one", "rival-two")
|
||||
w, _, left, err := TheRestOfTheMesh(in, "laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(left) == 0 {
|
||||
t.Fatal("a node whose set does not resolve was not left out")
|
||||
}
|
||||
for _, l := range left {
|
||||
if l.Node != "ace" || !strings.Contains(l.String(), "ace is left out of the rest of the mesh: it does not resolve: ") {
|
||||
t.Errorf("left out %q", l.String())
|
||||
}
|
||||
}
|
||||
if got := providers(w, "s3-bucket"); len(got) != 1 {
|
||||
t.Errorf("the others do not offer: %v", got)
|
||||
}
|
||||
if got := providers(w, "postgres-database"); len(got) != 0 {
|
||||
t.Errorf("the node left out offers: %v", got)
|
||||
}
|
||||
// Said for the whole-mesh view in both passes (as the controller says it), and for a view with one machine excluded in the first only.
|
||||
_, _, whole, _ := TheRestOfTheMesh(in, "")
|
||||
if len(whole) != 2 {
|
||||
t.Errorf("the whole-mesh view says %d line(s), want the two passes'", len(whole))
|
||||
}
|
||||
if len(left) != 1 {
|
||||
t.Errorf("a view with one excluded says %d line(s), want the first pass's", len(left))
|
||||
}
|
||||
}
|
||||
|
||||
// 1361/AC3 and AC4 (mesh-controller seat_pass_test.go TestACompositionPassesTheSeatAndWritesOneHandActByTheController, in
|
||||
// the part that is not a write): a seat held once whose holder on record is assigned nowhere and one claimant: the seat
|
||||
// passes to that claimant in the World, and is returned as a pass; Input's holdings are unchanged.
|
||||
func TestASeatWhoseHolderIsAssignedNowhereGoesToItsOnlyClaimant(t *testing.T) {
|
||||
withSeat(t)
|
||||
mk := func() Input {
|
||||
return Input{
|
||||
Catalogue: map[string]catalogue.Manifest{"gate": aBrokerClaiming("gate")},
|
||||
Nodes: []Node{{Name: "novox", Place: Place{Site: "here"}, Assigned: []string{"gate"}}},
|
||||
Holdings: []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "novox", Module: "old-broker", Site: "here"}},
|
||||
}
|
||||
}
|
||||
in := mk()
|
||||
w, passes, _, err := TheRestOfTheMesh(in, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(w.Holdings) != 1 || w.Holdings[0].Module != "gate" || w.Holdings[0].Node != "novox" {
|
||||
t.Fatalf("the holdings after the composition do not name the gate: %+v", w.Holdings)
|
||||
}
|
||||
if len(passes.Passes) != 1 {
|
||||
t.Fatalf("passes=%d, want one", len(passes.Passes))
|
||||
}
|
||||
p := passes.Passes[0]
|
||||
if p.Seat.Name != "mesh-broker" || p.From.Module != "old-broker" || p.To.Module != "gate" || p.To.Node != "novox" {
|
||||
t.Errorf("the pass is %+v", p)
|
||||
}
|
||||
unchanged(t, mk, func(in Input) {
|
||||
if _, _, _, err := TheRestOfTheMesh(in, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 1317/AC2 (seat_pass_test.go TestACompositionWithTwoClaimantsRecordsNoneAndSaysSo): two claimants, no pass, the refusal said with
|
||||
// both and the remedy. 1317/AC3 and AC4 (TestACompositionChangesNothingWhileTheHolderIsAssignedOrNobodyClaims): nothing changes while
|
||||
// the holder is assigned, nor with no claimant.
|
||||
func TestNoSeatIsPassedForTwoClaimantsAnAssignedHolderOrNoClaimant(t *testing.T) {
|
||||
withSeat(t)
|
||||
held := []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "novox", Module: "old-broker"}}
|
||||
_, passes := passSeatsOn(held, []catalogue.Assignment{
|
||||
{Node: "novox", Manifest: aBrokerClaiming("gate")}, {Node: "ace", Manifest: aBrokerClaiming("other")}})
|
||||
said := strings.Join(passes.Said, "\n")
|
||||
if len(passes.Passes) != 0 || !strings.Contains(said, "gate") || !strings.Contains(said, "other") ||
|
||||
!strings.Contains(said, "seat mesh-broker --to") {
|
||||
t.Fatalf("passes %+v said %q", passes.Passes, said)
|
||||
}
|
||||
got, passes := passSeatsOn(held, []catalogue.Assignment{
|
||||
{Node: "novox", Manifest: aBrokerClaiming("old-broker")}, {Node: "novox", Manifest: aBrokerClaiming("gate")}})
|
||||
if len(passes.Passes) != 0 || len(got) != 1 || got[0].Module != "old-broker" {
|
||||
t.Fatalf("holder assigned: %+v %+v", got, passes)
|
||||
}
|
||||
got, passes = passSeatsOn(held, nil)
|
||||
if len(passes.Passes) != 0 || len(got) != 1 || got[0].Module != "old-broker" {
|
||||
t.Fatalf("no claimant: %+v %+v", got, passes)
|
||||
}
|
||||
}
|
||||
|
||||
// seat_pass_test.go TestAHolderAssignedButNotOnTheShelfIsNotTakenForGone.
|
||||
func TestAHolderAssignedButNotOnTheShelfIsNotTakenForGone(t *testing.T) {
|
||||
withSeat(t)
|
||||
as := assignmentsOf(map[string]catalogue.Manifest{"gate": aBrokerClaiming("gate")},
|
||||
map[string][]string{"novox": {"gate", "old-broker"}})
|
||||
passes, _ := catalogue.SeatsToPassOn(
|
||||
[]catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "novox", Module: "old-broker"}}, as)
|
||||
if len(passes) != 0 {
|
||||
t.Fatalf("a holder missing from the shelf lost its seat: %+v", passes)
|
||||
}
|
||||
}
|
||||
|
||||
// The not-resolving node of a view with a holder on record: holdings reach the first pass (novox/hq ADR 0131), so a node running
|
||||
// the next holder beside the current one is not refused as a second holder.
|
||||
func TestAHolderOnRecordSettlesTheSeatInTheRestOfTheMesh(t *testing.T) {
|
||||
withSeat(t)
|
||||
in := Input{
|
||||
Catalogue: map[string]catalogue.Manifest{"old-broker": aBrokerClaiming("old-broker"), "gate": aBrokerClaiming("gate")},
|
||||
Nodes: []Node{
|
||||
{Name: "novox", Assigned: []string{"old-broker", "gate"}},
|
||||
{Name: "ace", Assigned: []string{}}},
|
||||
Holdings: []catalogue.Held{{Claim: "mesh-broker", Scope: catalogue.ScopeMesh, Node: "novox", Module: "old-broker"}},
|
||||
}
|
||||
_, passes, left, err := TheRestOfTheMesh(in, "ace")
|
||||
if err != nil || len(left) != 0 || len(passes.Passes) != 0 {
|
||||
t.Fatalf("%v %v %+v", err, left, passes)
|
||||
}
|
||||
in.Holdings = nil
|
||||
_, _, left, err = TheRestOfTheMesh(in, "ace")
|
||||
if err != nil || len(left) == 0 {
|
||||
t.Fatalf("two claimants with nothing on record were not refused: %v %v", err, left)
|
||||
}
|
||||
}
|
||||
|
||||
// Each machine is resolved in the second pass with its own pins, as its plan is (novox/hq issue 188): a pin to a provider that
|
||||
// does not answer is its own refusal, so the machine is left out for it; the same machine without the pin is not.
|
||||
func TestEachNodeIsResolvedWithItsOwnPinsInTheRestOfTheMesh(t *testing.T) {
|
||||
in := threeNodes(t)
|
||||
in.Nodes = append(in.Nodes, Node{Name: "newt", Place: Place{Site: "there", Address: "10.77.0.4"}, Assigned: []string{overlay.Name, "files"}, Capabilities: map[string]bool{}})
|
||||
_, _, left, err := TheRestOfTheMesh(in, "")
|
||||
if err != nil || len(left) != 0 {
|
||||
t.Fatalf("without a pin the consumer is left out: %v %v", left, err)
|
||||
}
|
||||
nodeOf(t, &in, "newt").Pins = map[string]catalogue.Chosen{"s3-bucket": {Node: "ace", Module: "nothing"}}
|
||||
_, _, left, err = TheRestOfTheMesh(in, "")
|
||||
if err != nil || len(left) != 1 || left[0].Node != "newt" || !strings.Contains(left[0].String(), "was told to get \"s3-bucket\" from") {
|
||||
t.Fatalf("a pin to a provider that does not answer did not leave the consumer out: %v %v", left, err)
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// aBrokerClaiming is mesh-controller's seat_pass_test.go helper of the same name.
|
||||
func aBrokerClaiming(module string) catalogue.Manifest {
|
||||
return catalogue.Manifest{Module: module,
|
||||
Provides: []catalogue.Offer{{Name: "mesh-bus", Scope: catalogue.ScopeMesh}},
|
||||
Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh}}}
|
||||
}
|
||||
|
||||
// withSeat is mesh-controller's withBusSeat: a single mesh seat, mesh-broker, for the test.
|
||||
func withSeat(t *testing.T) {
|
||||
t.Helper()
|
||||
was := catalogue.Seats()
|
||||
t.Cleanup(func() { catalogue.UseSeats(was) })
|
||||
catalogue.UseSeats([]catalogue.Seat{{Name: "mesh-broker", Scope: catalogue.ScopeMesh, Delivers: "mesh-bus", Decision: "test"}})
|
||||
}
|
||||
+7
-180
@@ -1,6 +1,7 @@
|
||||
// Package composer is mesh-sdk's composition as one function over a plain request (novox/hq ADR 0482
|
||||
// clause 4, issue 1621): what the command mesh-compose runs, so a program that links any one mesh-sdk can have the
|
||||
// composition of another commit judged by building that commit's command and reading its verdict.
|
||||
// Package composer is the shape of a composition over a plain request (novox/hq ADR 0482 clause 4, issue 1621, as ADR
|
||||
// 0593 §7 changes it): the request the command mesh-compose reads and the verdict it answers. The command and the
|
||||
// composer that runs it are the node-engine's (mesh-host cmd/mesh-compose), built from its repository at a group's
|
||||
// head, so a change to its composition code is judged by that code.
|
||||
//
|
||||
// **It reads its request and nothing else.** No bus, store, network, file or environment: the manifests come inside the
|
||||
// request (raw, as their repository holds them), the rest of the mesh is a compose.Input, and the answer is a value.
|
||||
@@ -13,21 +14,17 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose/frombus"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/sdkversion"
|
||||
)
|
||||
|
||||
// Version is the version of a Request and of a Verdict; a reader refuses another.
|
||||
const Version = 1
|
||||
|
||||
// Package is the import path of the command, which a compose job builds at a commit (in mesh-sdk's own module, or in
|
||||
// a module that requires mesh-sdk, at the version it pins).
|
||||
const Package = "git.novox.be/novox/mesh-sdk/go/cmd/mesh-compose"
|
||||
// Package is the import path of the command, which a compose job builds at a commit of the node-engine's repository
|
||||
// (novox/hq ADR 0593 §7).
|
||||
const Package = "github.com/novox/mesh-host/cmd/mesh-compose"
|
||||
|
||||
// The depths of a composition.
|
||||
const (
|
||||
@@ -201,173 +198,3 @@ func Fill(r *Request, get func(digest string) ([]byte, error)) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run composes every case of r and answers the verdict, its SDK this binary's mesh-sdk. An error is a request it
|
||||
// cannot run (an unknown depth, a case named twice, a manifest left as a digest); a machine that does not compose is a
|
||||
// verdict.
|
||||
func Run(r Request) (Verdict, error) {
|
||||
depth := r.Depth
|
||||
if depth == "" {
|
||||
depth = DepthCompose
|
||||
}
|
||||
if depth != DepthResolve && depth != DepthCompose {
|
||||
return Verdict{}, fmt.Errorf("composer: the depth %q is not resolve or compose", r.Depth)
|
||||
}
|
||||
v := Verdict{V: Version, SDK: sdkversion.Version()}
|
||||
seen := map[string]bool{}
|
||||
for _, c := range r.Cases {
|
||||
if seen[c.Name] {
|
||||
return Verdict{}, fmt.Errorf("composer: the case %q is named twice", c.Name)
|
||||
}
|
||||
seen[c.Name] = true
|
||||
cv, err := runCase(r, c, depth)
|
||||
if err != nil {
|
||||
return Verdict{}, err
|
||||
}
|
||||
v.Cases = append(v.Cases, cv)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// parse decodes every manifest leniently and takes its verdict (novox/hq ADR 0578 §3, §5): the composer judges none. A
|
||||
// manifest refused by its verdict is held, composed as its stub without its items (as the bus reader holds one); bytes
|
||||
// that do not decode are left out. Both are named in reads.
|
||||
func parse(m map[string]Manifest, reads map[string]ManifestRead, verdicts map[string]delivery.ManifestVerdict) (map[string]catalogue.Manifest, error) {
|
||||
out := map[string]catalogue.Manifest{}
|
||||
for name, man := range m {
|
||||
if len(man.Raw) == 0 {
|
||||
return nil, fmt.Errorf("%w: %s (digest %s)", ErrUnfilled, name, man.Digest)
|
||||
}
|
||||
decoded, err := catalogue.Decode(man.Raw)
|
||||
read := ManifestRead{Accepted: err == nil, SetAside: decoded.SetAside()}
|
||||
if err != nil {
|
||||
read.Refused = fmt.Sprintf("the manifest of %s does not decode: %v", name, err)
|
||||
} else if v, ok := verdicts[name]; ok {
|
||||
if refused, _ := delivery.TakeManifestVerdict(&v); refused {
|
||||
if v.Module == "" {
|
||||
v.Module = name
|
||||
}
|
||||
read = ManifestRead{Refused: v.Why()}
|
||||
decoded.Items = nil
|
||||
decoded = compose.Stub(decoded)
|
||||
}
|
||||
}
|
||||
if reads != nil {
|
||||
reads[name] = read
|
||||
}
|
||||
if err == nil {
|
||||
out[name] = decoded
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func runCase(r Request, c Case, depth string) (CaseVerdict, error) {
|
||||
cv := CaseVerdict{Name: c.Name, Manifests: map[string]ManifestRead{}}
|
||||
shelf, err := parse(c.Manifests, cv.Manifests, c.Verdicts)
|
||||
if err != nil {
|
||||
return CaseVerdict{}, err
|
||||
}
|
||||
var input compose.Input
|
||||
if len(r.Input) > 0 {
|
||||
if err := json.Unmarshal(r.Input, &input); err != nil {
|
||||
return CaseVerdict{}, fmt.Errorf("composer: the request's input is not a composition input: %w", err)
|
||||
}
|
||||
}
|
||||
in, err := frombus.Placed(input, c.Placements)
|
||||
if err != nil {
|
||||
return CaseVerdict{}, fmt.Errorf("composer: the case %q: %w", c.Name, err)
|
||||
}
|
||||
in.Catalogue = shelf
|
||||
in.Nodes = slices.Clone(in.Nodes)
|
||||
in.Holdings = slices.Clone(in.Holdings)
|
||||
for i := range in.Nodes {
|
||||
in.Nodes[i].Kept = nil
|
||||
if kept := c.Kept[in.Nodes[i].Name]; len(kept) > 0 {
|
||||
k, err := parse(kept, nil, nil)
|
||||
if err != nil {
|
||||
return CaseVerdict{}, err
|
||||
}
|
||||
in.Nodes[i].Kept = k
|
||||
}
|
||||
}
|
||||
if r.HoldingsFromClaims {
|
||||
in.Holdings = append(in.Holdings, HoldingsOf(shelf, in.Nodes)...)
|
||||
}
|
||||
if len(r.Machines) == 0 {
|
||||
return cv, nil
|
||||
}
|
||||
cv.Machines = map[string]Machine{}
|
||||
for _, node := range r.Machines {
|
||||
cv.Machines[node] = machine(in, node, depth, r.Declarations)
|
||||
}
|
||||
return cv, nil
|
||||
}
|
||||
|
||||
// HoldingsOf is every seat an assigned module claims, held on the node it is assigned to, in node order: the holdings
|
||||
// of an input that has none of record (the assigner's, mesh-delivery-assigner internal/assign input).
|
||||
func HoldingsOf(shelf map[string]catalogue.Manifest, nodes []compose.Node) []catalogue.Held {
|
||||
var out []catalogue.Held
|
||||
sorted := slices.Clone(nodes)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
|
||||
for _, n := range sorted {
|
||||
for _, mod := range n.Assigned {
|
||||
for _, cl := range shelf[mod].Claims {
|
||||
scope := cl.Scope
|
||||
if seat, ok := catalogue.SeatNamed(cl.Name); ok && scope == "" {
|
||||
scope = seat.Scope
|
||||
}
|
||||
out = append(out, catalogue.Held{Claim: cl.Name, Scope: scope, Node: n.Name, Module: mod})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func machine(in compose.Input, node, depth string, declarations bool) Machine {
|
||||
if depth == DepthResolve {
|
||||
if _, err := compose.Resolve(in, node); err != nil {
|
||||
return Machine{Reason: err.Error()}
|
||||
}
|
||||
return Machine{Composes: true}
|
||||
}
|
||||
d, problems, err := compose.Compose(in, node)
|
||||
if err != nil {
|
||||
return Machine{Reason: err.Error()}
|
||||
}
|
||||
m := Machine{Composes: len(problems) == 0, Problems: problems, Refusals: d.Refusals()}
|
||||
if len(problems) > 0 {
|
||||
m.Reason = "the node-engine's validator refuses the declaration"
|
||||
}
|
||||
if declarations {
|
||||
m.Declared = pathsOf(d)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// pathsOf is every resource of a declaration that names a path, with its owner, group and mode as declared, sorted by
|
||||
// path.
|
||||
func pathsOf(d compose.Declared) []Path {
|
||||
var out []Path
|
||||
str := func(r map[string]any, key string) string {
|
||||
if v, ok := r[key]; ok && v != nil {
|
||||
return fmt.Sprint(v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
for _, r := range d.Resources {
|
||||
p := str(r, "path")
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, Path{Path: p, Kind: str(r, "type"), Owner: str(r, "owner"), Group: str(r, "group"),
|
||||
Mode: str(r, "mode"), Module: d.ModuleOf[str(r, "id")]})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Path != out[j].Path {
|
||||
return out[i].Path < out[j].Path
|
||||
}
|
||||
return out[i].Kind < out[j].Kind
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -3,14 +3,8 @@ package composer
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/compose"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
const demo = `{"module": "demo", "version": "1", "claims": [{"name": "node-files"}]}`
|
||||
@@ -32,9 +26,6 @@ func TestAManifestIsItsBytesOrItsDigest(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Run(back); !errors.Is(err, ErrUnfilled) {
|
||||
t.Fatalf("a digest left unfilled runs: %v", err)
|
||||
}
|
||||
asked := 0
|
||||
if err := Fill(&back, func(d string) ([]byte, error) {
|
||||
asked++
|
||||
@@ -53,112 +44,3 @@ func TestAManifestIsItsBytesOrItsDigest(t *testing.T) {
|
||||
t.Fatalf("a digest the indexer does not hold: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The input travels as JSON and comes back equal, so the command composes what its caller meant.
|
||||
func TestTheInputTravelsAsJSON(t *testing.T) {
|
||||
in := compose.Input{
|
||||
Nodes: []compose.Node{{Name: "anchor", Assigned: []string{"nats"}, Capabilities: map[string]bool{"systemd": true},
|
||||
Place: compose.Place{Endpoint: "anchor.example:51820", Site: "here", Hub: true, Address: "10.77.0.1"},
|
||||
Pins: map[string]catalogue.Chosen{"bus": {Node: "anchor", Module: "nats"}},
|
||||
Settings: map[string][]catalogue.Layer{"nats": {{Values: map[string]any{"port": float64(4222)}}}}}},
|
||||
Holdings: []catalogue.Held{{Claim: "mesh-broker", Scope: "mesh", Node: "anchor", Module: "nats"}},
|
||||
Issued: []compose.Issued{{Provision: "bus", Consumer: "laptop", ConsumerModule: "x", Provider: "anchor"}},
|
||||
}
|
||||
raw, err := json.Marshal(Request{V: Version, Input: rawInput(in)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := Decode(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got compose.Input
|
||||
if err := json.Unmarshal(back.Input, &got); err != nil || !reflect.DeepEqual(got, in) {
|
||||
t.Fatalf("the input came back as\n%+v (%v)\nwant\n%+v", got, err, in)
|
||||
}
|
||||
}
|
||||
|
||||
// The seats a node's assigned modules claim are held there when the request says the holdings come from the claims.
|
||||
func TestHoldingsComeFromTheClaims(t *testing.T) {
|
||||
m, err := catalogue.Decode([]byte(demo))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := HoldingsOf(map[string]catalogue.Manifest{"demo": m}, []compose.Node{{Name: "b", Assigned: []string{"demo"}}, {Name: "a", Assigned: []string{"demo"}}})
|
||||
if len(got) != 2 || got[0].Node != "a" || got[0].Claim != "node-files" || got[0].Scope != "node" {
|
||||
t.Fatalf("holdings = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A request the command cannot run is refused, never answered as a verdict: another version, a case named twice, an
|
||||
// unknown depth.
|
||||
func TestARequestItCannotRunIsRefused(t *testing.T) {
|
||||
if _, err := Decode([]byte(`{"v": 2}`)); err == nil {
|
||||
t.Error("a request of version 2 is read")
|
||||
}
|
||||
if _, err := Run(Request{V: Version, Cases: []Case{{Name: "a"}, {Name: "a"}}}); err == nil {
|
||||
t.Error("a case named twice runs")
|
||||
}
|
||||
if _, err := Run(Request{V: Version, Depth: "deep"}); err == nil {
|
||||
t.Error("an unknown depth runs")
|
||||
}
|
||||
// A case's own placement (issue 1126) composes over a node the input holds, and is refused for one it does not.
|
||||
placed := Request{V: Version, Input: rawInput(compose.Input{Nodes: []compose.Node{{Name: "a"}}}), Machines: []string{"a"}, Depth: DepthResolve,
|
||||
Cases: []Case{{Name: "trunk", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}},
|
||||
{Name: "change", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}},
|
||||
Placements: map[string]*delivery.Placement{"a": {Node: "a", Modules: []delivery.PlacedModule{{Module: "ghost"}}}}}}}
|
||||
if v, err := Run(placed); err != nil || !v.Cases[0].Machines["a"].Composes || v.Cases[1].Machines["a"].Composes ||
|
||||
!strings.Contains(v.Cases[1].Machines["a"].Reason, "ghost") {
|
||||
t.Errorf("a case composes its own placement: %+v, %v", v, err)
|
||||
}
|
||||
placed.Cases[1].Placements = map[string]*delivery.Placement{"z": {Node: "z"}}
|
||||
if _, err := Run(placed); err == nil || !strings.Contains(err.Error(), "holds no node for") {
|
||||
t.Errorf("a case placing a node the input lacks: %v", err)
|
||||
}
|
||||
v, err := Run(Request{V: Version, Cases: []Case{{Name: "only-read", Manifests: map[string]Manifest{"demo": {Raw: json.RawMessage(demo)}}}}})
|
||||
if err != nil || len(v.Cases) != 1 || !v.Cases[0].Manifests["demo"].Accepted || v.Cases[0].Machines != nil || v.SDK == "" {
|
||||
t.Fatalf("a request with no machines: %+v, %v", v, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The composer judges no manifest (novox/hq ADR 0578 §5): xterm's module.json from mesh-catalog at 835bf22b, whose
|
||||
// config value falls back to "font-size", is read whatever list of predefined keys a request carries (a group touching
|
||||
// xterm failed its composition on this, group check 18df81380db3); a module whose verdict in the input refuses it is
|
||||
// held, composed as its stub, and named in the verdict's words.
|
||||
func TestTheComposerTakesTheVerdictsOfItsInput(t *testing.T) {
|
||||
raw, err := os.ReadFile("../catalogue/testdata/catalogue-835bf22b-xterm.module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := Request{V: Version, Predefined: catalogue.PredefinedList{"something-else": {Type: "string", Description: "x"}},
|
||||
Cases: []Case{{Name: "change", Manifests: map[string]Manifest{"xterm": {Raw: raw}}}}}
|
||||
v, err := Run(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := v.Cases[0].Manifests["xterm"]; !got.Accepted {
|
||||
t.Fatalf("xterm: %+v, want accepted", got)
|
||||
}
|
||||
r.Cases[0].Verdicts = map[string]delivery.ManifestVerdict{"xterm": {Module: "xterm", State: delivery.VerdictRefused,
|
||||
Problems: []string{"falls back to a key not listed"}, Judge: "src2:j"}}
|
||||
shelf, err := parse(r.Cases[0].Manifests, map[string]ManifestRead{}, r.Cases[0].Verdicts)
|
||||
if err != nil || !shelf["xterm"].Stubbed {
|
||||
t.Fatalf("the refused module is not held as its stub: %+v %v", shelf["xterm"], err)
|
||||
}
|
||||
if v, err = Run(r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := v.Cases[0].Manifests["xterm"]; got.Accepted ||
|
||||
!strings.Contains(got.Refused, "the indexer's judge (src2:j) refused the manifest of xterm: falls back to a key not listed") {
|
||||
t.Fatalf("xterm refused by its verdict: %+v, want refused in the verdict's words", got)
|
||||
}
|
||||
}
|
||||
|
||||
// rawInput is a composition input as a request carries it.
|
||||
func rawInput(in compose.Input) json.RawMessage {
|
||||
raw, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
@@ -134,44 +134,3 @@ func sameAny(a, b any) bool {
|
||||
y, errB := json.Marshal(b)
|
||||
return errA == nil && errB == nil && bytes.Equal(x, y)
|
||||
}
|
||||
|
||||
// FitAnswer is a with its shown bodies cut until its JSON is within limit bytes (MaxAnswerBytes for the node's key):
|
||||
// first the bodies' texts, from the last differing module back, keeping their paths; then the paths too. What is cut
|
||||
// is said in the answer's note. The verdict and every list of modules are never cut.
|
||||
func FitAnswer(a CandidateAnswer, limit int) CandidateAnswer {
|
||||
size := func() int {
|
||||
raw, err := json.Marshal(a)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return len(raw)
|
||||
}
|
||||
if size() <= limit {
|
||||
return a
|
||||
}
|
||||
a.Differs = append([]ModuleDiffers(nil), a.Differs...)
|
||||
for i := range a.Differs {
|
||||
a.Differs[i].Bodies = append([]ResourceBodies(nil), a.Differs[i].Bodies...)
|
||||
}
|
||||
cutTexts, cutPaths := false, false
|
||||
for i := len(a.Differs) - 1; i >= 0 && size() > limit; i-- {
|
||||
for j := range a.Differs[i].Bodies {
|
||||
a.Differs[i].Bodies[j].Composed, a.Differs[i].Bodies[j].Running = "", ""
|
||||
}
|
||||
cutTexts = true
|
||||
}
|
||||
for i := len(a.Differs) - 1; i >= 0 && size() > limit; i-- {
|
||||
for j := range a.Differs[i].Bodies {
|
||||
a.Differs[i].Bodies[j].Paths = nil
|
||||
}
|
||||
cutPaths = true
|
||||
}
|
||||
why := "the bodies shown were cut to keep the answer within " + strconv.Itoa(limit>>10) + " KiB"
|
||||
if cutPaths {
|
||||
why += ", their paths too"
|
||||
}
|
||||
if cutTexts {
|
||||
a.Note = strings.TrimPrefix(a.Note+"; ", "; ") + why
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
@@ -1,365 +0,0 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
)
|
||||
|
||||
// The judgement of a move (ADR 0377 §3), as a pure function over two compositions of one module, so the node agent that
|
||||
// composes its own modules judges what it would apply (novox/hq ADR 0547 §3; issue 1126's S8). Moved from
|
||||
// mesh-controller cmd/mesh-controller/composed_judge.go at origin/main (the controller keeps its copy, unchanged, until
|
||||
// it loses the lease, ADR 0547 §1): judged, onlyTheUserList and sameResources, with their helpers, are its code.
|
||||
//
|
||||
// ADR 0377 §3 over two compositions of one module: **recreates** when a container resource is changed, added or
|
||||
// removed; **moves data** when, with no container changed, a directory or file resource is changed, added or removed
|
||||
// that a container of the module mounts or that holds or lies within one of its directories (where a module keeps its
|
||||
// data), when its `data` declaration changes, or, for a module that declares data, when one of its long-running
|
||||
// processes changes; **neither** otherwise, and for a module the node has never run.
|
||||
|
||||
// Judged is one move's judgement: what it does, its words, and whether only the bus's user list changes (ADR 0539 §7:
|
||||
// a file change and a reload of the bus, no move of it, never asked).
|
||||
type Judged struct {
|
||||
Judgement Judgement
|
||||
Words string
|
||||
UserListOnly bool
|
||||
}
|
||||
|
||||
// JudgeMove judges moving a node's module from the composition it runs to the one composed for it. Running is nil when the
|
||||
// node runs no composition of the module. A composition equal to the one running (resource for resource, each
|
||||
// canonical) is unchanged: no move, and no ask (ADR 0547 §3).
|
||||
func JudgeMove(running *Composed, composed Composed) Judged {
|
||||
if composed.Module == "" {
|
||||
return Judged{Judgement: JudgedNeither, Words: "the node's own composition"}
|
||||
}
|
||||
if running == nil {
|
||||
return Judged{Judgement: JudgedNeither, Words: "the node runs no composition of " + composed.Module}
|
||||
}
|
||||
if running.Data == composed.Data && SameComposedResources(*running, composed) {
|
||||
return Judged{Judgement: JudgedUnchanged, Words: "equal to the composition the node runs"}
|
||||
}
|
||||
if onlyTheUserList(*running, composed) {
|
||||
return Judged{Judgement: JudgedNeither, Words: "only the bus's user list changes", UserListOnly: true}
|
||||
}
|
||||
j, words := judged(*running, composed)
|
||||
return Judged{Judgement: j, Words: words}
|
||||
}
|
||||
|
||||
// busUsersSuffix ends the id of the bus module's user list as the composer writes it: `<module>.bus-users`.
|
||||
const busUsersSuffix = ".bus-users"
|
||||
|
||||
// onlyTheUserList says two compositions of a module differ, and only in its file of the bus's user list.
|
||||
func onlyTheUserList(from, to Composed) bool {
|
||||
if from.Data != to.Data {
|
||||
return false
|
||||
}
|
||||
before, after := judgedResourcesByID(from), judgedResourcesByID(to)
|
||||
if len(before) != len(after) {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
for id, now := range after {
|
||||
was, ok := before[id]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if bytes.Equal(was.raw, now.raw) {
|
||||
continue
|
||||
}
|
||||
if !strings.HasSuffix(id, busUsersSuffix) || now.kind != string(declaration.TypeFile) {
|
||||
return false
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// judged applies ADR 0377 §3 to two compositions of one module.
|
||||
func judged(from, to Composed) (Judgement, string) {
|
||||
before, after := judgedResourcesByID(from), judgedResourcesByID(to)
|
||||
if ids := changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeContainer) }); len(ids) > 0 {
|
||||
return JudgedRecreates, "it recreates " + strings.Join(ids, ", ")
|
||||
}
|
||||
places := dataPlaces(before, after)
|
||||
held := func(kind string) bool {
|
||||
return kind == string(declaration.TypeDirectory) || kind == string(declaration.TypeFile)
|
||||
}
|
||||
var moved []string
|
||||
for _, id := range changedOf(before, after, held) {
|
||||
r := before[id]
|
||||
if r == nil {
|
||||
r = after[id]
|
||||
}
|
||||
if withinPlaces(r.path, places) {
|
||||
moved = append(moved, id)
|
||||
}
|
||||
}
|
||||
if to.Data || from.Data {
|
||||
moved = append(moved, changedOf(before, after, func(kind string) bool { return kind == string(declaration.TypeProcess) })...)
|
||||
}
|
||||
// A data flag that differs is no move of data by itself: data moves only when a data item does (issue 1126, 2026-10-29:
|
||||
// a running composition read back from the kept declaration carries no flag, and mongodb's toolchain move asked a yes for
|
||||
// moving its data). It is said loudly in the words, and never hides an item that moved.
|
||||
if len(moved) > 0 {
|
||||
sort.Strings(moved)
|
||||
// A file the mesh makes for the module's provisions (what its consumers contribute, what it is bound to, the
|
||||
// secret it is given) is said as the consumers that join or leave, not as its data moving (issue 774,
|
||||
// 2026-10-31: postgres's yes said "where its data is kept" when nextcloud left its grants). Still a move: the
|
||||
// yes it asks is what keeps a consumer's login while that consumer is held.
|
||||
var kept, made []string
|
||||
for _, id := range moved {
|
||||
if who := consumersOf(id, before[id], after[id]); who != nil {
|
||||
made = append(made, who...)
|
||||
} else {
|
||||
kept = append(kept, id)
|
||||
}
|
||||
}
|
||||
var parts []string
|
||||
if len(kept) > 0 {
|
||||
parts = append(parts, "it changes where its data is kept: "+strings.Join(kept, ", "))
|
||||
}
|
||||
if len(made) > 0 {
|
||||
parts = append(parts, "consumers join or leave: "+strings.Join(made, ", "))
|
||||
}
|
||||
words := strings.Join(parts, "; ")
|
||||
if from.Data != to.Data {
|
||||
words += fmt.Sprintf("; its data flag differs too (running %v, composed %v)", from.Data, to.Data)
|
||||
}
|
||||
return JudgedMovesData, words
|
||||
}
|
||||
if from.Data != to.Data {
|
||||
return JudgedNeither, fmt.Sprintf("it recreates no container and moves no data; WARNING: its data flag differs "+
|
||||
"(running %v, composed %v) while no data item does, so one side's flag is wrong", from.Data, to.Data)
|
||||
}
|
||||
return JudgedNeither, "it recreates no container and moves no data"
|
||||
}
|
||||
|
||||
// ResourceChange is one resource that differs between two compositions of a module: its id, its kind, whether it is
|
||||
// added, removed or changed, and for a file the mesh makes for a provision (received-, bound-, secret-) the consumers
|
||||
// that join or leave, in words.
|
||||
type ResourceChange struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Change string `json:"change"`
|
||||
Consumers []string `json:"consumers,omitempty"`
|
||||
}
|
||||
|
||||
// ChangedResources is every resource added, removed or changed from running to composed, by id, each compared
|
||||
// canonical (issue 774: the engine's verb composition shows what a move waiting for a yes would change). Running nil
|
||||
// is every resource added.
|
||||
func ChangedResources(running *Composed, composed Composed) []ResourceChange {
|
||||
var from Composed
|
||||
if running != nil {
|
||||
from = *running
|
||||
}
|
||||
before, after := judgedResourcesByID(from), judgedResourcesByID(composed)
|
||||
var out []ResourceChange
|
||||
for _, id := range changedOf(before, after, func(string) bool { return true }) {
|
||||
was, now := before[id], after[id]
|
||||
c := ResourceChange{ID: id, Change: "changed"}
|
||||
switch {
|
||||
case was == nil:
|
||||
c.Change, c.Kind = "added", now.kind
|
||||
case now == nil:
|
||||
c.Change, c.Kind = "removed", was.kind
|
||||
default:
|
||||
c.Kind = now.kind
|
||||
}
|
||||
c.Consumers = consumersOf(id, was, now)
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// consumersOf is, for a changed file the mesh makes for a provision (received-, bound- or secret-, catalogue.ReceivedID,
|
||||
// BoundID, SecretID), what changes of it in words: each consumer that joins or leaves a received file, by the given
|
||||
// list's from and node; else that what it is given for the provision changes. Nil for any other resource.
|
||||
func consumersOf(id string, before, after *judgedResource) []string {
|
||||
_, local, _ := strings.Cut(id, ".")
|
||||
r := after
|
||||
if r == nil {
|
||||
r = before
|
||||
}
|
||||
if r == nil || r.kind != string(declaration.TypeFile) {
|
||||
return nil
|
||||
}
|
||||
if provision, ok := strings.CutPrefix(local, "received-"); ok {
|
||||
was, now := givenIn(before), givenIn(after)
|
||||
var out []string
|
||||
for _, c := range sortedGiven(was) {
|
||||
if !now[c] {
|
||||
out = append(out, c+" leaves "+provision)
|
||||
}
|
||||
}
|
||||
for _, c := range sortedGiven(now) {
|
||||
if !was[c] {
|
||||
out = append(out, c+" joins "+provision)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
out = append(out, "what "+provision+"'s consumers are given changes")
|
||||
}
|
||||
return out
|
||||
}
|
||||
for _, prefix := range []string{"bound-", "secret-"} {
|
||||
if provision, ok := strings.CutPrefix(local, prefix); ok {
|
||||
return []string{"what it is given for " + provision + " changes"}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// givenIn is the consumers a received file's content names, each as "<from> on <node>".
|
||||
func givenIn(r *judgedResource) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
if r == nil {
|
||||
return out
|
||||
}
|
||||
var file struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
var received struct {
|
||||
Given []struct {
|
||||
From string `json:"from"`
|
||||
Node string `json:"node"`
|
||||
} `json:"given"`
|
||||
}
|
||||
if json.Unmarshal(r.raw, &file) != nil || json.Unmarshal([]byte(file.Content), &received) != nil {
|
||||
return out
|
||||
}
|
||||
for _, g := range received.Given {
|
||||
out[g.From+" on "+g.Node] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedGiven(m map[string]bool) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// judgedResource is a resource as the judgement reads it: its kind, the path it acts on, what its containers mount,
|
||||
// and its canonical bytes.
|
||||
type judgedResource struct {
|
||||
kind string
|
||||
path string
|
||||
mounts []string
|
||||
raw []byte
|
||||
}
|
||||
|
||||
func judgedResourcesByID(c Composed) map[string]*judgedResource {
|
||||
out := map[string]*judgedResource{}
|
||||
for _, raw := range c.Resources {
|
||||
var head struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
// A container's volumes, `<path on the machine>:<path inside>[:<options>]`.
|
||||
Volumes []string `json:"volumes"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &head); err != nil {
|
||||
continue
|
||||
}
|
||||
r := &judgedResource{kind: head.Type, path: head.Path, raw: compactResource(raw)}
|
||||
for _, v := range head.Volumes {
|
||||
source, _, _ := strings.Cut(v, ":")
|
||||
r.mounts = append(r.mounts, source)
|
||||
}
|
||||
out[head.ID] = r
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// compactResource is a resource's bytes canonical: its keys sorted, its spacing dropped, its numbers as written, so two
|
||||
// compositions that say the same compare equal however each was written.
|
||||
func compactResource(raw []byte) []byte {
|
||||
var v any
|
||||
d := json.NewDecoder(bytes.NewReader(raw))
|
||||
d.UseNumber()
|
||||
if err := d.Decode(&v); err != nil {
|
||||
return raw
|
||||
}
|
||||
out, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return raw
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// changedOf are the ids of the resources of the kinds counted that are changed, added or removed, sorted.
|
||||
func changedOf(before, after map[string]*judgedResource, counted func(kind string) bool) []string {
|
||||
var out []string
|
||||
for id, now := range after {
|
||||
if !counted(now.kind) {
|
||||
continue
|
||||
}
|
||||
if was, ok := before[id]; !ok || !bytes.Equal(was.raw, now.raw) {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
for id, was := range before {
|
||||
if _, ok := after[id]; !ok && counted(was.kind) {
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// dataPlaces are the paths where the module keeps what a move must not lose: what its containers mount, and its
|
||||
// directories, before and after.
|
||||
func dataPlaces(before, after map[string]*judgedResource) []string {
|
||||
var out []string
|
||||
for _, rs := range []map[string]*judgedResource{before, after} {
|
||||
for _, r := range rs {
|
||||
if r.kind == string(declaration.TypeContainer) {
|
||||
out = append(out, r.mounts...)
|
||||
}
|
||||
if r.kind == string(declaration.TypeDirectory) && r.path != "" {
|
||||
out = append(out, r.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// withinPlaces says p is one of places, or lies within one, or holds one.
|
||||
func withinPlaces(p string, places []string) bool {
|
||||
if p == "" {
|
||||
return false
|
||||
}
|
||||
p = path.Clean(p)
|
||||
for _, place := range places {
|
||||
if place == "" || !strings.HasPrefix(place, "/") {
|
||||
continue
|
||||
}
|
||||
place = path.Clean(place)
|
||||
if p == place || strings.HasPrefix(p, place+"/") || strings.HasPrefix(place, p+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SameComposedResources says two compositions of a module hold the same resources in the same order, each compared
|
||||
// canonical.
|
||||
func SameComposedResources(a, b Composed) bool {
|
||||
if len(a.Resources) != len(b.Resources) {
|
||||
return false
|
||||
}
|
||||
for i := range a.Resources {
|
||||
if !bytes.Equal(compactResource(a.Resources[i]), compactResource(b.Resources[i])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func composedOf(module string, c byte, data bool, resources ...string) Composed {
|
||||
out := Composed{Node: "ace", Module: module, FP: SourcePrefix2 + strings.Repeat(string(c), 64), Data: data}
|
||||
for _, r := range resources {
|
||||
out.Resources = append(out.Resources, json.RawMessage(r))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The pairs mesh-controller's composed_judge_test.go captured (issue 1945 AC12), and the bus's user list, judged as the
|
||||
// controller judges them (ADR 0377 §3, ADR 0539 §7), by the node agent's function (ADR 0547 §3, issue 1126 S8).
|
||||
func TestJudgeMove(t *testing.T) {
|
||||
const (
|
||||
container = `{"id":"mesh-mail.server","type":"container","module":"mesh-mail","name":"mail","image":"mail@sha256:1111111111111111111111111111111111111111111111111111111111111111","volumes":["/var/lib/mesh-mail/spool:/spool"]}`
|
||||
container2 = `{"id":"mesh-mail.server","type":"container","module":"mesh-mail","name":"mail","image":"mail@sha256:2222222222222222222222222222222222222222222222222222222222222222","volumes":["/var/lib/mesh-mail/spool:/spool"]}`
|
||||
spool = `{"id":"mesh-mail.spool","type":"directory","module":"mesh-mail","path":"/var/lib/mesh-mail/spool"}`
|
||||
spoolMoved = `{"id":"mesh-mail.spool","type":"directory","module":"mesh-mail","path":"/srv/mesh-mail/spool"}`
|
||||
tools = `{"id":"mesh-mail.tools","type":"file","module":"mesh-mail","path":"/usr/lib/mesh-mail/tools.json","content":"a"}`
|
||||
tools2 = `{"id":"mesh-mail.tools","type":"file","module":"mesh-mail","path":"/usr/lib/mesh-mail/tools.json","content":"b"}`
|
||||
config = `{"id":"mesh-mail.conf","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/conf","content":"a"}`
|
||||
config2 = `{"id":"mesh-mail.conf","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/conf","content":"b"}`
|
||||
process = `{"id":"mesh-mail.daemon","type":"process","module":"mesh-mail","exec":"/usr/bin/a"}`
|
||||
process2 = `{"id":"mesh-mail.daemon","type":"process","module":"mesh-mail","exec":"/usr/bin/b"}`
|
||||
received = `{"id":"mesh-mail.received-smtp","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/received.json","content":"{\"given\":[{\"from\":\"cloud\",\"node\":\"novox\"},{\"from\":\"photos\",\"node\":\"novox\"}]}"}`
|
||||
received2 = `{"id":"mesh-mail.received-smtp","type":"file","module":"mesh-mail","path":"/var/lib/mesh-mail/spool/received.json","content":"{\"given\":[{\"from\":\"photos\",\"node\":\"novox\"},{\"from\":\"wiki\",\"node\":\"ace\"}]}"}`
|
||||
users = `{"id":"nats.bus-users","type":"file","module":"nats","path":"/etc/nats/users","content":"a"}`
|
||||
users2 = `{"id":"nats.bus-users","type":"file","module":"nats","path":"/etc/nats/users","content":"b"}`
|
||||
natsConf = `{"id":"nats.conf","type":"file","module":"nats","path":"/etc/nats/conf","content":"a"}`
|
||||
natsConf2 = `{"id":"nats.conf","type":"file","module":"nats","path":"/etc/nats/conf","content":"b"}`
|
||||
)
|
||||
mail := func(c byte, data bool, rs ...string) *Composed {
|
||||
m := composedOf("mesh-mail", c, data, rs...)
|
||||
return &m
|
||||
}
|
||||
bus := func(rs ...string) *Composed { m := composedOf("nats", 'b', false, rs...); return &m }
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
from, to *Composed
|
||||
want Judgement
|
||||
userList bool
|
||||
words string // what the words must say, when given
|
||||
}{
|
||||
{"a new image", mail('a', true, container, spool, tools), mail('b', true, container2, spool, tools), JudgedRecreates, false, ""},
|
||||
{"a container gone", mail('a', true, container, spool), mail('b', true, spool), JudgedRecreates, false, ""},
|
||||
{"its spool moved", mail('a', true, container, spool), mail('b', true, container, spoolMoved), JudgedMovesData, false, ""},
|
||||
{"a file in its spool", mail('a', true, container, spool, config), mail('b', true, container, spool, config2), JudgedMovesData, false, "it changes where its data is kept: mesh-mail.conf"},
|
||||
// A received file the mesh makes, in its data's place, names its consumers joining or leaving, not its data moving
|
||||
// (issue 774, 2026-10-31: postgres asked a yes for "where its data is kept" when nextcloud left its grants).
|
||||
{"a consumer leaves", mail('a', true, container, spool, received), mail('b', true, container, spool, received2), JudgedMovesData, false,
|
||||
"consumers join or leave: cloud on novox leaves smtp, wiki on ace joins smtp"},
|
||||
{"a consumer leaves and a file moves", mail('a', true, container, spool, received, config), mail('b', true, container, spool, received2, config2), JudgedMovesData, false,
|
||||
"it changes where its data is kept: mesh-mail.conf; consumers join or leave: cloud on novox leaves smtp, wiki on ace joins smtp"},
|
||||
{"its data flag alone", mail('a', false, container, spool), mail('b', true, container, spool), JudgedNeither, false, ""},
|
||||
{"its data flag and its spool", mail('a', false, container, spool), mail('b', true, container, spoolMoved), JudgedMovesData, false, ""},
|
||||
{"a process of a module with data", mail('a', true, spool, process), mail('b', true, spool, process2), JudgedMovesData, false, ""},
|
||||
{"a process of a module without", mail('a', false, process), mail('b', false, process2), JudgedNeither, false, ""},
|
||||
{"its tools only", mail('a', true, container, spool, tools), mail('b', true, container, spool, tools2), JudgedNeither, false, ""},
|
||||
{"never ran", nil, mail('b', true, container), JudgedNeither, false, ""},
|
||||
{"equal, written otherwise", mail('a', true, `{"type":"directory","id":"mesh-mail.spool","module":"mesh-mail","path":"/var/lib/mesh-mail/spool"}`), mail('b', true, spool), JudgedUnchanged, false, ""},
|
||||
{"only the user list", bus(natsConf, users), bus(natsConf, users2), JudgedNeither, true, ""},
|
||||
{"the user list and more", bus(natsConf, users), bus(natsConf2, users2), JudgedNeither, false, ""},
|
||||
} {
|
||||
got := JudgeMove(c.from, *c.to)
|
||||
if got.Judgement != c.want || got.UserListOnly != c.userList || got.Words == "" {
|
||||
t.Errorf("%s: %+v, want %s (user list only %v)", c.name, got, c.want, c.userList)
|
||||
}
|
||||
if c.words != "" && got.Words != c.words {
|
||||
t.Errorf("%s: says %q, want %q", c.name, got.Words, c.words)
|
||||
}
|
||||
if strings.Contains(c.name, "data flag alone") && !strings.Contains(got.Words, "WARNING: its data flag differs") {
|
||||
t.Errorf("%s: the flag's difference is not said: %q", c.name, got.Words)
|
||||
}
|
||||
}
|
||||
// The resources that differ, as the engine's verb composition lists them (issue 774): a provider's received file
|
||||
// names its consumers, a file changed and one added are said as such, and nothing equal is listed.
|
||||
changes := ChangedResources(mail('a', true, container, spool, received, config), *mail('b', true, container, spool, received2, config2, tools))
|
||||
if want := `[{mesh-mail.conf file changed []} {mesh-mail.received-smtp file changed [cloud on novox leaves smtp wiki on ace joins smtp]} {mesh-mail.tools file added []}]`; fmt.Sprint(changes) != want {
|
||||
t.Errorf("changed resources: %v, want %s", changes, want)
|
||||
}
|
||||
if got := ChangedResources(nil, *mail('b', true, spool)); len(got) != 1 || got[0].Change != "added" {
|
||||
t.Errorf("never ran: %v, want its one resource added", got)
|
||||
}
|
||||
if got := JudgeMove(nil, Composed{Node: "ace"}); got.Judgement != JudgedNeither {
|
||||
t.Errorf("the node's own composition: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -555,12 +555,6 @@ func TestPlacement(t *testing.T) {
|
||||
!strings.Contains(far.Differs[0].Bodies[0].Composed, "cut at") || strings.Contains(fmt.Sprint(far), "hunter") {
|
||||
t.Errorf("a difference past the cut: %+v", far.Differs)
|
||||
}
|
||||
// And an answer too large for the node's key is fitted: the texts go first, the verdict and the paths stay.
|
||||
big := CandidateAnswer{Proposal: "p-x1", Revision: 1, Verdict: CandidateDiffers, Differs: far.Differs}
|
||||
if fitted := FitAnswer(big, 4<<10); len(fitted.Differs[0].Bodies[0].Composed) != 0 || len(fitted.Differs[0].Bodies[0].Paths) != 2 ||
|
||||
fitted.Verdict != CandidateDiffers || !strings.Contains(fitted.Note, "cut") || big.Differs[0].Bodies[0].Composed == "" {
|
||||
t.Errorf("fitted as %+v", fitted)
|
||||
}
|
||||
if c, r := MaskedPair(json.RawMessage(`{"env":{"DB_PASSWORD":"hunter2","PORT":"80"},"list":[{"token":"t1"}]}`),
|
||||
json.RawMessage(`{"env":{"DB_PASSWORD":"hunter3","PORT":"81"},"list":[{"token":"t1"}]}`), nil); strings.Contains(c+r, "hunter") ||
|
||||
strings.Contains(c+r, `"t1"`) || !strings.Contains(r, `"PORT":"81"`) || !strings.Contains(c, "<masked: differs>") ||
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
package overlay
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The overlay is delivered as an ordinary declaration.
|
||||
//
|
||||
// novox/hq 08-connectivity: what the host receives is an interface configuration and a peer list,
|
||||
// as files. It does not compute them and it does not know what a private network is — the shapes
|
||||
// it already has are enough, which is why connectivity needs nothing new from tier 0.
|
||||
//
|
||||
// **No private key travels.** The configuration points at a file the node wrote from a key the
|
||||
// mesh has never seen, using WireGuard's own ability to set one after the interface is up. So the
|
||||
// control plane composes a complete configuration for a node it cannot impersonate.
|
||||
|
||||
// Interface is what the private network is called on a machine, and where the node's own key
|
||||
// lives. A constant rather than a setting: two nodes disagreeing about the name would produce a
|
||||
// mesh where each is configured correctly and nothing meets.
|
||||
const (
|
||||
Interface = "mesh0"
|
||||
ConfigPath = "/etc/wireguard/" + Interface + ".conf"
|
||||
Unit = "wg-quick@" + Interface
|
||||
DefaultKeyPath = "/var/lib/mesh-host/overlay.key"
|
||||
)
|
||||
|
||||
// Resource is one entry in a declaration, built here and read by the host.
|
||||
type Resource map[string]any
|
||||
|
||||
// Declaration is what the mesh sends a node to put it on the network.
|
||||
//
|
||||
// Three resources and nothing clever: the tools, the configuration, and the interface running. A
|
||||
// person can read it, which is the point — this is the first thing a node is ever told, and if it
|
||||
// is wrong the node is unreachable and the mistake has to be findable by eye.
|
||||
func Declaration(node Node, peers []Peer, keyPath string) ([]byte, error) {
|
||||
if node.Address == "" {
|
||||
return nil, fmt.Errorf("%s has no address on the overlay, so there is nothing to configure",
|
||||
node.Name)
|
||||
}
|
||||
if keyPath == "" {
|
||||
keyPath = DefaultKeyPath
|
||||
}
|
||||
|
||||
up := Resource{
|
||||
"id": "overlay-up", "type": "service", "unit": Unit,
|
||||
"state": "running",
|
||||
// Enabled, so the node comes back onto the network after a reboot without waiting to
|
||||
// be told again. A node whose overlay only exists while something is watching is not
|
||||
// a node that survives being switched off and on.
|
||||
"boot": "enabled",
|
||||
// And restarted when the peer list changes, because a running interface does not
|
||||
// re-read its configuration.
|
||||
//
|
||||
// This is the whole of it: a node joins, every existing node's peer list changes,
|
||||
// each file is replaced — and without this the service is already running, nothing
|
||||
// reloads it, and every node keeps a network that no longer matches the mesh. It
|
||||
// reports complete success. The lab found it the moment a third node arrived.
|
||||
//
|
||||
// Declared state rather than a command. The service must reflect the file; the host
|
||||
// works out that it does not. A command to restart would be an action, and the link
|
||||
// may not carry one (novox/hq ADR 0005) — the host refused exactly that, correctly,
|
||||
// which is how this shape was arrived at.
|
||||
"restart-on": []string{"overlay-config"},
|
||||
}
|
||||
if node.TakesOver != nil {
|
||||
// The private network takes over the tunnel it found (novox/hq ADR 0105): before this
|
||||
// unit starts, the host stops and disables the found one — never flushing it — and keeps
|
||||
// its configuration like any held file. The key is already the found one: the node took
|
||||
// it as its own overlay key when it enrolled, which is why the mesh's peer list for it
|
||||
// carries the found peers under the key they know.
|
||||
up["takes-over"] = map[string]any{
|
||||
"interface": node.TakesOver.Interface,
|
||||
"unit": node.TakesOver.Unit,
|
||||
"config": node.TakesOver.Config,
|
||||
}
|
||||
}
|
||||
|
||||
resources := []Resource{
|
||||
{
|
||||
"id": "overlay-tools", "type": "package", "package": "wireguard-tools",
|
||||
},
|
||||
{
|
||||
"id": "overlay-config", "type": "file", "path": ConfigPath,
|
||||
// Readable only by root: it lists every peer's key and endpoint, which is a map of
|
||||
// the mesh. Not secret in the way a private key is, and not something to leave
|
||||
// world-readable on a machine somebody else also uses.
|
||||
"mode": "0600",
|
||||
"content": config(node, peers, keyPath),
|
||||
},
|
||||
up,
|
||||
}
|
||||
|
||||
// The names used to be appended here, on the argument that a node with peers and no names is
|
||||
// half on the network. True, and the wrong place to fix it: names would be identical over a
|
||||
// different private network, so bundling them with WireGuard made one module out of two
|
||||
// things. They are their own module now, requiring this one — which is what keeps them
|
||||
// arriving together without pretending they are the same concern.
|
||||
return json.Marshal(map[string]any{"declaration": 1, "resources": resources})
|
||||
}
|
||||
|
||||
// config writes the interface file.
|
||||
//
|
||||
// Deliberately in the order a person would read it: who I am, then who I talk to, each with a
|
||||
// line saying why it is there. A generated file that cannot be understood by the person it
|
||||
// confuses is a generated file that gets edited by hand.
|
||||
func config(node Node, peers []Peer, keyPath string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("# Generated by the mesh. Do not edit — this file is replaced whenever the\n")
|
||||
b.WriteString("# peer graph changes, and an edit would survive until the next change and\n")
|
||||
b.WriteString("# then vanish, which is worse than not being applied at all.\n")
|
||||
fmt.Fprintf(&b, "#\n# node %s", node.Name)
|
||||
if node.Site != "" {
|
||||
fmt.Fprintf(&b, ", at %s", node.Site)
|
||||
}
|
||||
if !node.Reachable() {
|
||||
b.WriteString(", not dialable — it opens every path itself")
|
||||
}
|
||||
b.WriteString("\n\n[Interface]\n")
|
||||
fmt.Fprintf(&b, "Address = %s/32\n", node.Address)
|
||||
if node.Reachable() {
|
||||
if port := portOf(node.Endpoint); port != "" {
|
||||
fmt.Fprintf(&b, "ListenPort = %s\n", port)
|
||||
}
|
||||
} else if node.TakesOver != nil && node.TakesOver.Port != 0 {
|
||||
// Not dialable from the hub, but a LAN peer dials this node on the tunnel it took over,
|
||||
// so the mesh's interface must listen on that same port (novox/hq: a taken tunnel brings
|
||||
// its port). Without this the takeover guard refuses overlay-up, and re-placing the node
|
||||
// with an endpoint — the guard's suggested remedy — breaks a NAT'd node's path.
|
||||
fmt.Fprintf(&b, "ListenPort = %d\n", node.TakesOver.Port)
|
||||
}
|
||||
// The MTU the found tunnel carried, when it set one: a path tuned to 1380 (say) stalls TLS
|
||||
// and hangs transfers if the mesh's interface comes up at the 1420 default, and no ping shows
|
||||
// it (novox/hq: a taken tunnel carries its MTU).
|
||||
if node.TakesOver != nil && node.TakesOver.MTU != 0 {
|
||||
fmt.Fprintf(&b, "MTU = %d\n", node.TakesOver.MTU)
|
||||
}
|
||||
// The private key is set from a file the node wrote, so it never appears here and never
|
||||
// travelled. Everything else in this file came from the mesh; this one line is the node's.
|
||||
fmt.Fprintf(&b, "PostUp = wg set %%i private-key %s\n", keyPath)
|
||||
|
||||
if node.Hub {
|
||||
// A hub carries traffic *between* its spokes, and a Linux machine does not forward
|
||||
// packets unless it is told to. Without this every spoke reaches the hub perfectly and
|
||||
// no spoke reaches any other — which is exactly how it failed in the lab, and it looks
|
||||
// like a peering problem rather than a kernel setting.
|
||||
//
|
||||
// Here rather than in a separate resource because it is part of what being a hub means,
|
||||
// and because it should last exactly as long as the interface does: a machine that stops
|
||||
// being the hub should stop forwarding, and PostDown below is how that happens.
|
||||
b.WriteString("PostUp = sysctl -q -w net.ipv4.ip_forward=1\n")
|
||||
b.WriteString("PostDown = sysctl -q -w net.ipv4.ip_forward=0\n")
|
||||
|
||||
// And past the machine's own firewall, which on any node with a container runtime is
|
||||
// closed. Docker sets the FORWARD policy to DROP and inserts its chains, so the foundation
|
||||
// this mesh installs at tier 1 silently breaks the network it builds at tier 2: every
|
||||
// spoke reaches the hub, no spoke reaches any other, and every part of it reports
|
||||
// success. Found in the lab; nothing about it is visible from the mesh's own state.
|
||||
//
|
||||
// Inserted at the top so it precedes those chains, and removed on the way down so a
|
||||
// machine that stops being the hub stops carrying other people's traffic. Guarded on
|
||||
// iptables existing: a machine without it has no policy to get past.
|
||||
for _, direction := range []string{"-i", "-o"} {
|
||||
fmt.Fprintf(&b,
|
||||
"PostUp = command -v iptables >/dev/null && iptables -I FORWARD 1 %s %%i -j ACCEPT || true\n",
|
||||
direction)
|
||||
}
|
||||
for _, direction := range []string{"-i", "-o"} {
|
||||
fmt.Fprintf(&b,
|
||||
"PostDown = command -v iptables >/dev/null && iptables -D FORWARD %s %%i -j ACCEPT || true\n",
|
||||
direction)
|
||||
}
|
||||
}
|
||||
|
||||
for _, p := range peers {
|
||||
fmt.Fprintf(&b, "\n# %s — %s\n[Peer]\n", p.Name, p.Why)
|
||||
fmt.Fprintf(&b, "PublicKey = %s\n", p.Key)
|
||||
fmt.Fprintf(&b, "AllowedIPs = %s\n", p.Allowed)
|
||||
if p.Endpoint != "" {
|
||||
fmt.Fprintf(&b, "Endpoint = %s\n", p.Endpoint)
|
||||
} else {
|
||||
b.WriteString("# no endpoint: this peer cannot be dialled and opens the path itself\n")
|
||||
}
|
||||
if p.Keepalive {
|
||||
b.WriteString("PersistentKeepalive = 25\n")
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func portOf(endpoint string) string {
|
||||
if i := strings.LastIndex(endpoint, ":"); i >= 0 {
|
||||
return endpoint[i+1:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
package overlay
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func declarationFor(t *testing.T, node Node, peers []Peer) (string, []map[string]any) {
|
||||
t.Helper()
|
||||
raw, err := Declaration(node, peers, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var d struct {
|
||||
Declaration int `json:"declaration"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.Declaration != 1 {
|
||||
t.Fatalf("declaration version %d", d.Declaration)
|
||||
}
|
||||
for _, r := range d.Resources {
|
||||
if r["type"] == "file" {
|
||||
return r["content"].(string), d.Resources
|
||||
}
|
||||
}
|
||||
t.Fatal("the declaration has no configuration file in it")
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func TestNoPrivateKeyEverTravels(t *testing.T) {
|
||||
// The property the whole design rests on: the node generated its keypair and kept the private
|
||||
// half, so the mesh composes a configuration for a node it cannot impersonate. A private key
|
||||
// appearing here would mean the control plane had one — and a copy of its database would then
|
||||
// be every node's network identity.
|
||||
config, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"},
|
||||
[]Peer{{Name: "anchor", Key: "HUB", Allowed: "10.42.0.0/16", Endpoint: "198.51.100.1:51820"}})
|
||||
|
||||
if strings.Contains(config, "PrivateKey") {
|
||||
t.Error("the configuration carries a PrivateKey line; the mesh must never hold one")
|
||||
}
|
||||
if !strings.Contains(config, "private-key "+DefaultKeyPath) {
|
||||
t.Error("the configuration does not point at the key file the node wrote, so the " +
|
||||
"interface would come up with no key at all")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheDeclarationUsesOnlyShapesTheHostAlreadyHas(t *testing.T) {
|
||||
// Connectivity needs nothing new from tier 0, and that is worth holding: the host does not
|
||||
// know what a private network is, and should not learn.
|
||||
_, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil)
|
||||
|
||||
allowed := map[string]bool{"package": true, "file": true, "service": true,
|
||||
"directory": true, "container": true, "action": true}
|
||||
for _, r := range resources {
|
||||
if !allowed[r["type"].(string)] {
|
||||
t.Errorf("the overlay declaration uses %q, which the host does not have", r["type"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheInterfaceComesBackAfterAReboot(t *testing.T) {
|
||||
// A node whose overlay only exists while something is watching is not a node that survives
|
||||
// being switched off and on — and it would come back unreachable, which is the worst way to
|
||||
// come back.
|
||||
_, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil)
|
||||
for _, r := range resources {
|
||||
if r["type"] == "service" {
|
||||
if r["boot"] != "enabled" {
|
||||
t.Error("the overlay interface is not enabled at boot")
|
||||
}
|
||||
if r["state"] != "running" {
|
||||
t.Error("the overlay interface is not asked to be running")
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Error("nothing in the declaration brings the interface up")
|
||||
}
|
||||
|
||||
func TestTheConfigurationIsNotWorldReadable(t *testing.T) {
|
||||
// It lists every peer's key and endpoint, which is a map of the mesh. Not secret the way a
|
||||
// private key is, and not something to leave readable on a machine somebody else also uses.
|
||||
// The peer list specifically, not every file. `/etc/hosts` is in here too and must be
|
||||
// world-readable, or nothing on the machine resolves anything — a check that swept all files
|
||||
// would force it to 0600 and break the machine to protect a file that is not secret.
|
||||
_, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil)
|
||||
for _, r := range resources {
|
||||
if r["id"] == "overlay-config" && r["mode"] != "0600" {
|
||||
t.Errorf("the peer list is mode %v", r["mode"])
|
||||
}
|
||||
if r["id"] == "mesh-names" && r["mode"] != "0644" {
|
||||
t.Errorf("the name file is mode %v; nothing on the machine could read it", r["mode"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPeerThatCannotBeDialledSaysSo(t *testing.T) {
|
||||
// A [Peer] with no Endpoint is correct and looks like a mistake. Saying why stops somebody
|
||||
// helpfully adding one that cannot work.
|
||||
config, _ := declarationFor(t, Node{Name: "anchor", Key: "HUB", Address: "10.42.0.1",
|
||||
Endpoint: "198.51.100.1:51820", Hub: true},
|
||||
[]Peer{{Name: "laptop", Key: "PUB", Allowed: "10.42.0.2/32", Why: "routes through this hub"}})
|
||||
|
||||
if strings.Contains(config, "Endpoint =") {
|
||||
t.Error("an endpoint was written for a peer that has none")
|
||||
}
|
||||
if !strings.Contains(config, "cannot be dialled") {
|
||||
t.Error("the file does not say why that peer has no endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheFileSaysNotToEditIt(t *testing.T) {
|
||||
// It is replaced whenever the graph changes. An edit survives until then and vanishes, which
|
||||
// is worse than never being applied — the machine works, then stops, and nothing changed
|
||||
// that anybody remembers.
|
||||
config, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil)
|
||||
if !strings.Contains(config, "Do not edit") {
|
||||
t.Error("a generated file does not say it is generated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodeWithNoAddressIsRefused(t *testing.T) {
|
||||
// Rather than a configuration with a blank address, which wg-quick would reject on the
|
||||
// machine, at boot, where the failure is much harder to see.
|
||||
if _, err := Declaration(Node{Name: "laptop", Key: "PUB"}, nil, ""); err == nil {
|
||||
t.Fatal("a node with no overlay address was given a configuration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyAReachableNodeListens(t *testing.T) {
|
||||
// A ListenPort on a node nothing can dial is a port open for no reason.
|
||||
config, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil)
|
||||
if strings.Contains(config, "ListenPort") {
|
||||
t.Error("a node that cannot be dialled was told to listen")
|
||||
}
|
||||
config, _ = declarationFor(t, Node{Name: "anchor", Key: "HUB", Address: "10.42.0.1",
|
||||
Endpoint: "198.51.100.1:51820"}, nil)
|
||||
if !strings.Contains(config, "ListenPort = 51820") {
|
||||
t.Error("a reachable node does not listen on the port its endpoint names")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheServiceIsRestartedWhenThePeerListChanges(t *testing.T) {
|
||||
// The fault this exists to catch, found in the lab the moment a third node arrived: a running
|
||||
// WireGuard interface does not re-read its configuration. The file was replaced, the service
|
||||
// was already running so nothing reloaded it, and every existing node kept a network that no
|
||||
// longer matched the mesh — while looking entirely successful.
|
||||
//
|
||||
// So the declaration has to verify what the interface is *carrying*, not what the file says.
|
||||
//
|
||||
// And it must be declared state rather than a command: the host refuses an action arriving
|
||||
// over the link (novox/hq ADR 0005), correctly, which is how this shape was arrived at. There
|
||||
// is a test below that no action is ever in here.
|
||||
_, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"},
|
||||
[]Peer{{Name: "anchor", Key: "HUBKEY", Allowed: "10.42.0.0/16"}})
|
||||
|
||||
for _, r := range resources {
|
||||
if r["type"] != "service" {
|
||||
continue
|
||||
}
|
||||
reflects := fmt.Sprint(r["restart-on"])
|
||||
if !strings.Contains(reflects, "overlay-config") {
|
||||
t.Errorf("the interface does not restart when its configuration changes: %v", reflects)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Error("nothing in the declaration brings the interface up")
|
||||
}
|
||||
|
||||
func TestTheOverlayDeclarationCarriesNoAction(t *testing.T) {
|
||||
// The link may not carry an action, and the host refuses a declaration containing one — whole,
|
||||
// not in part. An overlay declaration with an action in it does not half-apply: it leaves the
|
||||
// node with no network at all.
|
||||
_, resources := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil)
|
||||
for _, r := range resources {
|
||||
if r["type"] == "action" {
|
||||
t.Errorf("the declaration contains an action (%v); the host will refuse the whole "+
|
||||
"thing and the node will have no network", r["id"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheHubForwardsAndNobodyElseDoes(t *testing.T) {
|
||||
// A hub carries traffic between its spokes, and Linux does not forward packets unless told
|
||||
// to. Without it every spoke reaches the hub perfectly and no spoke reaches any other — which
|
||||
// is how it failed in the lab, and it presents as a peering problem rather than a kernel
|
||||
// setting, so it is worth being sure of.
|
||||
hub, _ := declarationFor(t, Node{Name: "anchor", Key: "HUB", Address: "10.42.0.1",
|
||||
Endpoint: "198.51.100.1:51820", Hub: true}, nil)
|
||||
if !strings.Contains(hub, "ip_forward=1") {
|
||||
t.Error("the hub does not enable forwarding, so its spokes cannot reach each other")
|
||||
}
|
||||
if !strings.Contains(hub, "ip_forward=0") {
|
||||
t.Error("the hub never stops forwarding; a machine that stops being the hub would keep " +
|
||||
"passing traffic it is no longer part of")
|
||||
}
|
||||
|
||||
// And past the machine's own firewall. Any node with a container runtime has FORWARD set to
|
||||
// DROP by Docker, so enabling ip_forward alone changes nothing — which is exactly how it
|
||||
// failed, with every spoke reaching the hub and no spoke reaching any other.
|
||||
// Checked as PostUp specifically. "contains FORWARD" passes on the PostDown line alone,
|
||||
// which would leave a hub that tears down rules it never put up.
|
||||
if !strings.Contains(hub, "PostUp = command -v iptables") {
|
||||
t.Error("the hub does not open its own firewall, so a container runtime's DROP policy " +
|
||||
"silently eats everything it was supposed to carry")
|
||||
}
|
||||
if !strings.Contains(hub, "PostDown = command -v iptables") {
|
||||
t.Error("the hub never removes those rules, so a machine that stops being the hub keeps " +
|
||||
"passing traffic it is no longer part of")
|
||||
}
|
||||
|
||||
spoke, _ := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "10.42.0.2"}, nil)
|
||||
if strings.Contains(spoke, "FORWARD") {
|
||||
t.Error("a spoke was given forwarding rules it has no use for")
|
||||
}
|
||||
if strings.Contains(spoke, "ip_forward") {
|
||||
t.Error("a spoke was told to forward packets, which is not its job and widens what a " +
|
||||
"compromised one could do")
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0105: a node whose private network takes over the tunnel it found is told so on
|
||||
// the interface's service, and nothing else about the declaration changes — the key is already
|
||||
// the found one, taken at enrolment.
|
||||
func TestTakingOverAFoundTunnelIsSaidOnTheInterfacesService(t *testing.T) {
|
||||
node := Node{Name: "anchor", Key: "PUB", Address: "192.0.2.1", Hub: true,
|
||||
Endpoint: "198.51.100.1:51900",
|
||||
TakesOver: &TakeOver{Interface: "wg0", Unit: "wg-quick@wg0", Config: "/etc/wireguard/wg0.conf"}}
|
||||
config, resources := declarationFor(t, node, nil)
|
||||
|
||||
var up map[string]any
|
||||
for _, r := range resources {
|
||||
if r["type"] == "service" {
|
||||
up = r
|
||||
}
|
||||
}
|
||||
takes, ok := up["takes-over"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("the interface's service does not say what it takes over: %+v", up)
|
||||
}
|
||||
if takes["unit"] != "wg-quick@wg0" || takes["config"] != "/etc/wireguard/wg0.conf" || takes["interface"] != "wg0" {
|
||||
t.Errorf("the takeover names the wrong tunnel: %+v", takes)
|
||||
}
|
||||
if !strings.Contains(config, "ListenPort = 51900") {
|
||||
t.Errorf("the hub's interface does not listen on the tunnel's port:\n%s", config)
|
||||
}
|
||||
if strings.Contains(config, "PrivateKey") {
|
||||
t.Error("the found key travelled in the configuration; it is the node's own, set from its key file")
|
||||
}
|
||||
|
||||
_, plain := declarationFor(t, Node{Name: "laptop", Key: "PUB", Address: "192.0.2.4"}, nil)
|
||||
for _, r := range plain {
|
||||
if _, says := r["takes-over"]; says {
|
||||
t.Error("a node taking over nothing was told to take something over")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestATakenTunnelBringsItsListenPortEvenWhenNotDialable(t *testing.T) {
|
||||
// A home node behind NAT (no Endpoint, so not Reachable) that took over a tunnel must still
|
||||
// listen on that tunnel's port, because its LAN peers dial it there (novox/hq: a taken tunnel
|
||||
// brings its port). Without this the takeover guard refuses overlay-up.
|
||||
config, _ := declarationFor(t, Node{
|
||||
Name: "shanks", Key: "SPOKE", Address: "10.10.0.3",
|
||||
TakesOver: &TakeOver{Interface: "wg0", Unit: "wg-quick@wg0", Port: 51820},
|
||||
}, nil)
|
||||
|
||||
if !strings.Contains(config, "ListenPort = 51820") {
|
||||
t.Fatalf("a taken tunnel's port must be the mesh interface's ListenPort:\n%s", config)
|
||||
}
|
||||
if strings.Contains(config, "Endpoint =") {
|
||||
t.Error("a node that only listens for LAN peers must not advertise an endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodeWithNoTunnelAndNoEndpointStillListensOnNothing(t *testing.T) {
|
||||
// The guard against over-emitting: a plain spoke with neither an endpoint nor a taken tunnel
|
||||
// writes no ListenPort — it purely dials out.
|
||||
config, _ := declarationFor(t, Node{Name: "laptop", Key: "K", Address: "10.10.0.9"}, nil)
|
||||
if strings.Contains(config, "ListenPort") {
|
||||
t.Fatalf("a dial-only node needs no ListenPort:\n%s", config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATakenTunnelCarriesItsMTU(t *testing.T) {
|
||||
// A path tuned to a smaller MTU (1380 here) must survive the takeover — the mesh interface
|
||||
// comes up with the same MTU, or transfers hang silently (novox/hq: a taken tunnel carries
|
||||
// its MTU).
|
||||
config, _ := declarationFor(t, Node{
|
||||
Name: "shanks", Key: "SPOKE", Address: "10.10.0.3",
|
||||
TakesOver: &TakeOver{Interface: "wg0", Port: 51820, MTU: 1380},
|
||||
}, nil)
|
||||
if !strings.Contains(config, "MTU = 1380") {
|
||||
t.Fatalf("the tuned MTU was dropped:\n%s", config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoMTULineWhenTheTunnelSetNone(t *testing.T) {
|
||||
config, _ := declarationFor(t, Node{
|
||||
Name: "shanks", Key: "SPOKE", Address: "10.10.0.3",
|
||||
TakesOver: &TakeOver{Interface: "wg0", Port: 51820},
|
||||
}, nil)
|
||||
if strings.Contains(config, "MTU") {
|
||||
t.Fatalf("no MTU was found, so none should be written:\n%s", config)
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -1,5 +1,5 @@
|
||||
// Package overlay is the private network's generator, copied from mesh-controller internal/overlay at commit
|
||||
// 6c2a8d62100cb7d491f8eb9a623e1eeea5ec0de1 (novox/hq ADR 0453 clause 3, ADR 0446 clause 4): the files are unchanged but
|
||||
// for the import path of the catalogue, which is mesh-sdk's go/catalogue, and this note. mesh-controller keeps its own copy
|
||||
// until release 4b.
|
||||
// Package overlay is the private network's names and its manifest: the module the mesh provides for itself, the
|
||||
// requirement and seat it answers, the interface's name and files, and the mesh's internal names. Its generator, the
|
||||
// peer graph and the declaration it renders are the node-engine's own (novox/hq ADR 0593 §1, §7), in mesh-host's
|
||||
// internal/overlay.
|
||||
package overlay
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
package overlay
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// The private network as a module rather than as code beside the module system.
|
||||
//
|
||||
// A machine's peer list is derived from every other machine, so it differs on each one and
|
||||
// changes when any of them changes — there is nothing that could be written in a manifest. So the
|
||||
// module says its resources are computed, and this computes them.
|
||||
//
|
||||
// What that buys, beyond one mechanism instead of two: **a machine is on the private network
|
||||
// because it was assigned the module.** Before this, every machine with a key and an address was
|
||||
// on it, and there was no way to say a machine should stay off.
|
||||
//
|
||||
// It is worth saying what is *not* here, because networking looks like it should be foundational.
|
||||
// The host needs none of this. It has an address and a route to the broker before the mesh exists
|
||||
// — that is the machine's own networking — and the broker's address is carried in the enrolment
|
||||
// token rather than resolved. So the private network is something the mesh installs on top, like
|
||||
// anything else, and a node without it is an ordinary node that nothing reaches directly.
|
||||
|
||||
// What a module asks for when it needs machines to reach each other. **This is the name that
|
||||
// matters** — WireGuard is one way to answer it, and naming the requirement after the answer is
|
||||
// how a mesh ends up unable to have a second one.
|
||||
const Requirement = "private-network"
|
||||
|
||||
// Name is the module that answers it with WireGuard.
|
||||
//
|
||||
// **It writes no names.** A machine's mesh names are answered by the mesh's one resolver (novox/hq
|
||||
// ADR 0194), and /etc/hosts is the file of one module, the holder of `node-hostname` (ADR 0199,
|
||||
// ADR 0223): the controller writes into no file another seat's holder owns. If the mesh ever needs a line there, it
|
||||
// asks that holder to register it. This module asked for a `node-names` fact written into /etc/hosts
|
||||
// until 2026-10-05; the host gives that region back at the first push without it.
|
||||
const Name = "mesh-wireguard"
|
||||
|
||||
// Addressing is the mesh handing out addresses on the private network itself.
|
||||
//
|
||||
// The mesh's resolver answers names from it, which is why it requires this rather than a private
|
||||
// network in general. A different VPN that hands out its own addresses would come with its own
|
||||
// names — the mesh has nothing to answer about a machine whose address it did not choose.
|
||||
const Addressing = "mesh-addressing"
|
||||
|
||||
// TheNetwork is what a machine can only have one of.
|
||||
//
|
||||
// Providing a private network is not the singular part — a machine could reasonably run two VPNs
|
||||
// for two different purposes. Being **the** one the mesh runs over is singular, and without
|
||||
// saying so a person who chose a different VPN can still end up with this one dragged back in by
|
||||
// something that needed the mesh's own addresses. Which is exactly what happened, once.
|
||||
const TheNetwork = "the-private-network"
|
||||
|
||||
// **A resolver's data went the same way as the names.** Two constants used to sit here — a
|
||||
// `mesh-resolver` module that would write the machines as wildcards, and a `resolver-data`
|
||||
// requirement a daemon would ask for. Nothing ever provided or consumed either: a module that
|
||||
// answers names asks for the `node-zones` fact in its own manifest (catalogue.FactsInto) and
|
||||
// requires Addressing, since the file is made of the mesh's addresses and means nothing off the
|
||||
// network. A requirement nothing provides is refused at resolution, so leaving the names here
|
||||
// would only have documented a mechanism that does not exist.
|
||||
|
||||
// **There is no bundle any more** (novox/hq ADR 0226). A `networking` module requiring this one and
|
||||
// nothing else used to be what a machine was assigned, so that "get the network working" was one
|
||||
// word and a second VPN could be chosen by assigning it instead. The mesh has one private network,
|
||||
// every machine is on it, and the bundle was a second name for this module that every machine
|
||||
// carried. A machine is assigned Name directly; another VPN is still chosen by assigning it in its
|
||||
// place, which is all the bundle ever did.
|
||||
|
||||
// Generator answers what one node's network configuration is.
|
||||
type Generator struct {
|
||||
nodes []Node
|
||||
graph Graph
|
||||
// keyPath is where each node keeps the private half it generated. Named rather than carried:
|
||||
// the mesh has never seen it and never will.
|
||||
keyPath string
|
||||
// No registry. Being on the network is still what grants a machine the right to pull from the
|
||||
// mesh's artifact store in the clear (novox/hq ADR 0082), but the runtime's file is the runtime's
|
||||
// module's: the private network writes nothing into it, and that module states the registry
|
||||
// itself, told where the store is reached by ${seat:mesh-artifact-store:reach} (novox/hq ADR
|
||||
// 0222, issue 190).
|
||||
}
|
||||
|
||||
// From builds a generator over the machines that are part of the network.
|
||||
//
|
||||
// The nodes given are the ones assigned the module — not every node the mesh knows. A machine
|
||||
// that was never given it is absent from everybody's peer list and from the names, which is what
|
||||
// "not on the network" has to mean.
|
||||
func From(nodes []Node, cidr, keyPath string) (*Generator, error) {
|
||||
graph, err := Compute(nodes, cidr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Generator{nodes: nodes, graph: graph, keyPath: keyPath}, nil
|
||||
}
|
||||
|
||||
// Resources is one node's interface, peers and names.
|
||||
func (g *Generator) Resources(node string) ([]map[string]any, bool, error) {
|
||||
peers, part := g.graph[node]
|
||||
if !part {
|
||||
// Assigned and not yet placed on the network. Ordinary and brief, so it is an answer
|
||||
// rather than an error.
|
||||
return nil, false, nil
|
||||
}
|
||||
var self Node
|
||||
for _, n := range g.nodes {
|
||||
if n.Name == node {
|
||||
self = n
|
||||
}
|
||||
}
|
||||
|
||||
raw, err := Declaration(self, peers, g.keyPath)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
var parsed struct {
|
||||
Resources []map[string]any `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return parsed.Resources, true, nil
|
||||
}
|
||||
|
||||
// Nodes are the machines this generator was built over, so a caller can say who is on the network.
|
||||
func (g *Generator) Nodes() []Node { return g.nodes }
|
||||
|
||||
// Graph is the peer list per node, for showing.
|
||||
func (g *Generator) Graph() Graph { return g.graph }
|
||||
|
||||
// Manifest is the module the mesh provides for itself.
|
||||
//
|
||||
// It ships with the control plane rather than coming from a repository, because the thing that
|
||||
// computes it ships with the control plane. That is the only way it is unusual: it is assigned,
|
||||
// unassigned, resolved and settled exactly like a module somebody wrote.
|
||||
func Manifest() map[string]any {
|
||||
return map[string]any{
|
||||
"module": Name,
|
||||
"version": "1",
|
||||
"computed": Name,
|
||||
"provides": []string{Requirement, Addressing},
|
||||
"claims": []map[string]any{{"name": TheNetwork, "scope": "node"}},
|
||||
}
|
||||
}
|
||||
|
||||
// Empty is a network nobody is on.
|
||||
//
|
||||
// A mesh where no machine was given the module. Legitimate rather than broken — every node still
|
||||
// reaches the broker, which is what being in the mesh is — so it answers "not part of this" for
|
||||
// everyone instead of refusing for want of a hub.
|
||||
func Empty() *Generator { return &Generator{graph: Graph{}} }
|
||||
|
||||
// Listens is the port this node accepts the private network on, which only a hub has.
|
||||
//
|
||||
// **A fact about this machine's place in the mesh, not about the module.** Every machine on the
|
||||
// network runs the same module; a hub is dialled by every node at other sites and needs its port
|
||||
// open, and a machine that is not a hub dials out and needs nothing open at all. A static field in
|
||||
// a manifest is one answer for every machine that runs it, so it cannot say this — and the machine
|
||||
// it would get wrong is the one facing the public internet, which is the machine that most needs
|
||||
// filtering.
|
||||
//
|
||||
// The port is the one in the endpoint, which is also where the interface takes its ListenPort
|
||||
// from. One source, so a rule set cannot open a port the interface is not on.
|
||||
func (g *Generator) Listens(node string) ([]catalogue.Listening, error) {
|
||||
for _, n := range g.nodes {
|
||||
if n.Name != node {
|
||||
continue
|
||||
}
|
||||
if !n.Reachable() {
|
||||
// It dials out and nothing dials it. Opening a port here would be opening one on a
|
||||
// machine nothing connects to, which is not harmless — it is a rule with no source
|
||||
// that somebody later has to work out the reason for.
|
||||
return nil, nil
|
||||
}
|
||||
port := portOf(n.Endpoint)
|
||||
if port == "" {
|
||||
return nil, fmt.Errorf(
|
||||
"%s is reachable at %q and no port can be read from it, so what it must accept "+
|
||||
"the private network on is unknown", node, n.Endpoint)
|
||||
}
|
||||
number, err := strconv.Atoi(port)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s is reachable at %q, and %q is not a port", node, n.Endpoint, port)
|
||||
}
|
||||
return []catalogue.Listening{{
|
||||
Port: number, Protocol: "udp", From: catalogue.FromEverywhere,
|
||||
// From everywhere, and deliberately: a node at another site is not on the private
|
||||
// network until this port lets it on, so restricting this to the mesh would be a
|
||||
// rule that can never be satisfied by the thing it exists for.
|
||||
Why: "the private network — a node at another site has no other way in",
|
||||
}}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package overlay
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0222, issue 190: the runtime's file and service are the runtime's module's. The
|
||||
// private network writes nothing into either — being on it still grants the right to pull from the
|
||||
// mesh's store in the clear (ADR 0082), and the runtime's module states that trust itself.
|
||||
func TestTheNetworkWritesNothingOfTheRuntimes(t *testing.T) {
|
||||
nodes := []Node{
|
||||
{Name: "anchor", Site: "lab", Hub: true, Endpoint: "192.0.2.10:51820", Key: "k1", Address: "10.42.0.1"},
|
||||
{Name: "node2", Site: "lab", Key: "k2", Address: "10.42.0.2"},
|
||||
}
|
||||
g, err := From(nodes, "10.42.0.0/16", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, node := range []string{"anchor", "node2"} {
|
||||
resources, part, err := g.Resources(node)
|
||||
if err != nil || !part {
|
||||
t.Fatalf("%s: resources: %v part=%v", node, err, part)
|
||||
}
|
||||
for _, r := range resources {
|
||||
if r["path"] == "/etc/docker/daemon.json" || r["unit"] == "docker.service" {
|
||||
t.Errorf("%s: the private network declares the runtime's %v", node, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
// Package overlay computes the private network every node runs on.
|
||||
//
|
||||
// novox/hq 08-connectivity. This is control-plane work by definition: a peer list is derived from
|
||||
// every node at once, and no node has that. A node computes nothing about the mesh — it generates
|
||||
// a keypair, publishes the public half, and receives the rest.
|
||||
//
|
||||
// The shape is a hub, with direct peering between nodes at the same site. Not a full mesh, and
|
||||
// the reason is a property of WireGuard rather than a preference: there is no failover. A more
|
||||
// specific route to a dead endpoint blackholes; it does not fall back to the general one. So a
|
||||
// node gets exactly one path to any peer, because two would mean one of them silently swallowing
|
||||
// traffic.
|
||||
package overlay
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Node is one machine's place on the network, as the mesh holds it.
|
||||
type Node struct {
|
||||
Name string
|
||||
Key string
|
||||
Endpoint string
|
||||
Site string
|
||||
Hub bool
|
||||
Address string
|
||||
|
||||
// Carried are the peers of the tunnel this node took over (novox/hq ADR 0105): machines the
|
||||
// mesh has no record of, each known by the public key and the address the found tunnel routed
|
||||
// to it. Only a hub has any. They stay in its peer list until a node enrols with that key —
|
||||
// from then on the node is the peer.
|
||||
Carried []Carried
|
||||
// TakesOver names the found tunnel this node's private network replaces: its unit is stopped
|
||||
// and disabled, never flushed, and its configuration kept, before the mesh's interface comes
|
||||
// up with the found key. Nil on a node that raises the mesh's interface beside whatever it has.
|
||||
TakesOver *TakeOver
|
||||
}
|
||||
|
||||
// Carried is one peer of an adopted tunnel that has not enrolled: a peer of the tunnel, not a
|
||||
// node of the mesh.
|
||||
type Carried struct {
|
||||
Key string
|
||||
Address string
|
||||
}
|
||||
|
||||
// TakeOver is the found tunnel a node's private network takes over, as the host is told it.
|
||||
type TakeOver struct {
|
||||
Interface string
|
||||
Unit string
|
||||
Config string
|
||||
// MTU is the found tunnel's, when it set one — emitted so a tuned path keeps its MTU.
|
||||
MTU int
|
||||
// Port is the port the found tunnel listened on. The mesh's interface must listen on it too,
|
||||
// even on a node that is not dialable from the hub: a home node's LAN peers dial it there
|
||||
// (novox/hq: a taken tunnel brings its port). Listening is "a peer dials me here"; it is not
|
||||
// "the hub can dial me", which is Reachable — the two were conflated.
|
||||
Port int
|
||||
}
|
||||
|
||||
// HostPrefix is one address as a route: /32 for IPv4, /128 for IPv6.
|
||||
func HostPrefix(address string) string {
|
||||
if strings.Contains(address, ":") {
|
||||
return address + "/128"
|
||||
}
|
||||
return address + "/32"
|
||||
}
|
||||
|
||||
// CarriedName is how a carried peer is named in a peer list: it has no node name, so it is named
|
||||
// by the key its packets arrive under.
|
||||
func CarriedName(key string) string {
|
||||
short := key
|
||||
if len(short) > 8 {
|
||||
short = short[:8] + "…"
|
||||
}
|
||||
return "a peer of the tunnel (" + short + ")"
|
||||
}
|
||||
|
||||
// Reachable reports whether other nodes can dial this one. Declared, never inferred.
|
||||
func (n Node) Reachable() bool { return strings.TrimSpace(n.Endpoint) != "" }
|
||||
|
||||
// Peer is one entry in a node's peer list.
|
||||
type Peer struct {
|
||||
Name string
|
||||
Key string
|
||||
// Endpoint is empty when this peer cannot be dialled — it must dial us instead.
|
||||
Endpoint string
|
||||
// Allowed is what traffic goes down this tunnel. A single address for a direct peer; the
|
||||
// whole overlay for the hub, which is what makes it the route of last resort.
|
||||
Allowed string
|
||||
// Keepalive matters only on the side behind NAT: a node that cannot be dialled has to keep
|
||||
// the path open from its end, or the peer's first packet arrives at a mapping that has
|
||||
// already expired.
|
||||
Keepalive bool
|
||||
// Why this peer is in the list, for a person reading a generated file and wondering.
|
||||
Why string
|
||||
}
|
||||
|
||||
// Graph is every node's peer list.
|
||||
type Graph map[string][]Peer
|
||||
|
||||
// ErrNoHub means nobody has said which node is the hub.
|
||||
//
|
||||
// Its own error rather than an empty graph: a mesh with no hub has no path between sites, and
|
||||
// answering with "no peers" would look like a working mesh where nothing can reach anything.
|
||||
var ErrNoHub = errors.New("this mesh has no hub, so there is no path between sites")
|
||||
|
||||
// Compute derives every node's peer list.
|
||||
//
|
||||
// Nodes without a key or an address are skipped rather than refused: a node that has enrolled and
|
||||
// not yet been given a place on the network is an ordinary in-between state, and failing the whole
|
||||
// graph because one node is half-configured would mean no node gets a network.
|
||||
func Compute(nodes []Node, overlayCIDR string) (Graph, error) {
|
||||
var hub *Node
|
||||
usable := make([]Node, 0, len(nodes))
|
||||
for i := range nodes {
|
||||
n := nodes[i]
|
||||
if n.Key == "" || n.Address == "" {
|
||||
continue
|
||||
}
|
||||
usable = append(usable, n)
|
||||
if n.Hub {
|
||||
hub = &usable[len(usable)-1]
|
||||
}
|
||||
}
|
||||
if len(usable) == 0 {
|
||||
return Graph{}, nil
|
||||
}
|
||||
if hub == nil {
|
||||
return nil, ErrNoHub
|
||||
}
|
||||
if !hub.Reachable() {
|
||||
return nil, fmt.Errorf(
|
||||
"%s is the hub and has no endpoint, so nothing can dial it. The hub is the one node "+
|
||||
"that must be reachable from wherever the others are", hub.Name)
|
||||
}
|
||||
|
||||
graph := Graph{}
|
||||
for _, self := range usable {
|
||||
var peers []Peer
|
||||
|
||||
// A node may share a site with the hub, and then the hub is one peer rather than two.
|
||||
// Written before the loop because it changes what that loop may emit: WireGuard takes one
|
||||
// entry per public key, so a hub appearing twice is a configuration it refuses — and the
|
||||
// mesh would have produced it silently. The lab found this on the first two machines that
|
||||
// shared a site with their hub.
|
||||
hubIsHere := !self.Hub && self.Site != "" && self.Site == hub.Site
|
||||
|
||||
for _, other := range usable {
|
||||
if other.Name == self.Name || (hubIsHere && other.Name == hub.Name) {
|
||||
continue
|
||||
}
|
||||
// Two nodes at the same site peer directly — but only if one of them can be
|
||||
// dialled. If neither can, nobody opens the path, and the direct route is more
|
||||
// specific than the hub's, so it wins and blackholes. That is this design's own
|
||||
// stated hazard arriving in it: *a more specific route to a dead endpoint
|
||||
// blackholes; it does not fall back to the general one.*
|
||||
//
|
||||
// Found in the lab with two machines at one site behind no reachable address, which
|
||||
// is the ordinary shape of a home: they were given each other as peers, neither
|
||||
// could start, and they could not reach each other at all while both reached the hub
|
||||
// perfectly.
|
||||
if self.Site != "" && self.Site == other.Site && (self.Reachable() || other.Reachable()) {
|
||||
peers = append(peers, Peer{
|
||||
Name: other.Name, Key: other.Key,
|
||||
Endpoint: other.Endpoint,
|
||||
Allowed: other.Address + "/32",
|
||||
Keepalive: !self.Reachable(),
|
||||
Why: "at the same site",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if !self.Hub {
|
||||
// Everything else goes through the hub, including a node that roams. AllowedIPs is
|
||||
// the whole overlay, so this is the route of last resort — and because direct peers
|
||||
// above are single addresses, they win on specificity without either being ambiguous.
|
||||
why := "the hub — everything not at this site"
|
||||
if hubIsHere {
|
||||
// One entry doing both jobs: the direct path to a machine that happens to be
|
||||
// here, and the route to everywhere else. Splitting them would need two entries
|
||||
// for one key, which is the thing being avoided.
|
||||
why = "the hub, which is also at this site — everything goes here"
|
||||
}
|
||||
peers = append(peers, Peer{
|
||||
Name: hub.Name, Key: hub.Key,
|
||||
Endpoint: hub.Endpoint,
|
||||
Allowed: overlayCIDR,
|
||||
Keepalive: !self.Reachable(),
|
||||
Why: why,
|
||||
})
|
||||
} else {
|
||||
// The hub holds every node that does not share a site with it, because those nodes
|
||||
// route through it and it must know where to send the replies. Ones it cannot dial
|
||||
// will dial it.
|
||||
enrolled := map[string]bool{}
|
||||
for _, other := range usable {
|
||||
enrolled[other.Key] = true
|
||||
if other.Name == self.Name || (self.Site != "" && self.Site == other.Site) {
|
||||
continue
|
||||
}
|
||||
peers = append(peers, Peer{
|
||||
Name: other.Name, Key: other.Key,
|
||||
Endpoint: other.Endpoint,
|
||||
Allowed: other.Address + "/32",
|
||||
Why: "routes through this hub",
|
||||
})
|
||||
}
|
||||
// And every peer of the tunnel it took over that has not enrolled (novox/hq ADR
|
||||
// 0105): the same key and the same address the found tunnel had for it, so the
|
||||
// machine behind it cannot tell the tunnel changed hands. No endpoint — it dials in,
|
||||
// as it always did. Once a node enrols with that key, the node's entry above is the
|
||||
// peer, and WireGuard takes one entry per key.
|
||||
for _, c := range self.Carried {
|
||||
if enrolled[c.Key] {
|
||||
continue
|
||||
}
|
||||
peers = append(peers, Peer{
|
||||
Name: CarriedName(c.Key), Key: c.Key,
|
||||
Allowed: HostPrefix(c.Address),
|
||||
Why: "carried from the tunnel this hub took over — a peer of the tunnel, not yet a node of the mesh",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(peers, func(i, j int) bool { return peers[i].Name < peers[j].Name })
|
||||
graph[self.Name] = peers
|
||||
}
|
||||
return graph, nil
|
||||
}
|
||||
@@ -1,342 +0,0 @@
|
||||
package overlay
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const cidr = "10.42.0.0/16"
|
||||
|
||||
func at(name, site, address, endpoint string, hub bool) Node {
|
||||
return Node{Name: name, Key: "key-" + name, Site: site, Address: address,
|
||||
Endpoint: endpoint, Hub: hub}
|
||||
}
|
||||
|
||||
func peersOf(t *testing.T, g Graph, node string) map[string]Peer {
|
||||
t.Helper()
|
||||
out := map[string]Peer{}
|
||||
for _, p := range g[node] {
|
||||
out[p.Name] = p
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestEveryNodeReachesTheHub(t *testing.T) {
|
||||
// The property that makes this a network at all. Without it a node has no route to anything
|
||||
// it does not share a site with.
|
||||
g, err := Compute([]Node{
|
||||
at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true),
|
||||
at("laptop", "", "10.42.0.2", "", false),
|
||||
at("home", "house", "10.42.0.3", "", false),
|
||||
}, cidr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, node := range []string{"laptop", "home"} {
|
||||
hub, ok := peersOf(t, g, node)["anchor"]
|
||||
if !ok {
|
||||
t.Fatalf("%s has no route to the hub", node)
|
||||
}
|
||||
if hub.Allowed != cidr {
|
||||
t.Errorf("%s routes %s through the hub; it must be the whole overlay or the hub is "+
|
||||
"not a route of last resort", node, hub.Allowed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodesAtOneSitePeerDirectly(t *testing.T) {
|
||||
// Machines that share a site have a path that does not need the hub, and using it keeps their
|
||||
// traffic off a link that may be on another continent.
|
||||
g, err := Compute([]Node{
|
||||
at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true),
|
||||
at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false),
|
||||
at("server", "house", "10.42.0.3", "192.0.2.3:51820", false),
|
||||
}, cidr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
direct, ok := peersOf(t, g, "desk")["server"]
|
||||
if !ok {
|
||||
t.Fatal("two machines at one site do not peer directly")
|
||||
}
|
||||
if direct.Allowed != "10.42.0.3/32" {
|
||||
t.Errorf("the direct peer allows %s; a single address is what makes it win on "+
|
||||
"specificity over the hub's whole-overlay route", direct.Allowed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARoamingNodeGetsExactlyOnePath(t *testing.T) {
|
||||
// Hub-only, and not as a simplification. WireGuard has no failover: a more specific route to
|
||||
// a dead endpoint blackholes rather than falling back, so two paths would mean one of them
|
||||
// silently swallowing traffic.
|
||||
g, err := Compute([]Node{
|
||||
at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true),
|
||||
at("laptop", "", "10.42.0.2", "", false),
|
||||
at("other", "", "10.42.0.3", "", false),
|
||||
}, cidr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := len(g["laptop"]); got != 1 {
|
||||
t.Fatalf("a roaming node has %d peers; it gets exactly one path", got)
|
||||
}
|
||||
if g["laptop"][0].Name != "anchor" {
|
||||
t.Errorf("a roaming node's single path is %s, not the hub", g["laptop"][0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoRoamingNodesDoNotPeerWithEachOther(t *testing.T) {
|
||||
// An empty site is not a site. Nodes that roam have no shared location, and treating "" as
|
||||
// one would have every roaming machine try to dial every other, none of which can be dialled.
|
||||
g, err := Compute([]Node{
|
||||
at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true),
|
||||
at("laptop", "", "10.42.0.2", "", false),
|
||||
at("phone", "", "10.42.0.3", "", false),
|
||||
}, cidr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := peersOf(t, g, "laptop")["phone"]; ok {
|
||||
t.Error("two nodes with no site peered as though they shared one")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyTheSideThatCannotBeDialledKeepsThePathOpen(t *testing.T) {
|
||||
// Keepalive matters exactly once: on the node behind NAT. Without it the peer's first packet
|
||||
// arrives at a mapping that has already expired. On the reachable side it is pointless
|
||||
// traffic for ever.
|
||||
g, err := Compute([]Node{
|
||||
at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true),
|
||||
at("laptop", "", "10.42.0.2", "", false),
|
||||
}, cidr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !peersOf(t, g, "laptop")["anchor"].Keepalive {
|
||||
t.Error("a node that cannot be dialled does not keep its path open")
|
||||
}
|
||||
if peersOf(t, g, "anchor")["laptop"].Keepalive {
|
||||
t.Error("a reachable node sends keepalives it does not need")
|
||||
}
|
||||
|
||||
// And between two direct peers at one site, where whether to keep the path open depends on
|
||||
// which end you are. Checked here because the hub's own peer list happens not to set the
|
||||
// field at all, so asserting only against the hub tests an absence rather than the rule.
|
||||
same, err := Compute([]Node{
|
||||
at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true),
|
||||
at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false),
|
||||
at("server", "house", "10.42.0.3", "", false),
|
||||
}, cidr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !peersOf(t, same, "server")["desk"].Keepalive {
|
||||
t.Error("the peer that cannot be dialled does not keep the path open")
|
||||
}
|
||||
if peersOf(t, same, "desk")["server"].Keepalive {
|
||||
t.Error("the peer that can be dialled sends keepalives it does not need")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheHubKnowsEveryoneItRoutesFor(t *testing.T) {
|
||||
// The replies have to get back. A hub that does not hold a peer cannot answer it.
|
||||
g, err := Compute([]Node{
|
||||
at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true),
|
||||
at("laptop", "", "10.42.0.2", "", false),
|
||||
at("home", "house", "10.42.0.3", "", false),
|
||||
}, cidr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hub := peersOf(t, g, "anchor")
|
||||
for _, want := range []string{"laptop", "home"} {
|
||||
if _, ok := hub[want]; !ok {
|
||||
t.Errorf("the hub does not hold %s, so replies to it have nowhere to go", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMeshWithNoHubIsRefused(t *testing.T) {
|
||||
// Not an empty graph. A mesh with no hub has no path between sites, and answering "no peers"
|
||||
// would look like a working network in which nothing can reach anything — which is how the
|
||||
// old arrangement failed, silently, when nobody knew the address convention.
|
||||
_, err := Compute([]Node{
|
||||
at("a", "", "10.42.0.1", "", false),
|
||||
at("b", "", "10.42.0.2", "", false),
|
||||
}, cidr)
|
||||
if !errors.Is(err, ErrNoHub) {
|
||||
t.Fatalf("a mesh with no hub gave %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnreachableHubIsRefused(t *testing.T) {
|
||||
// The hub is the one node that must be dialable from wherever the others are. Left
|
||||
// unchecked, every node would be given a peer it can never reach and the mesh would look
|
||||
// configured and be silent.
|
||||
_, err := Compute([]Node{
|
||||
at("anchor", "datacentre", "10.42.0.1", "", true),
|
||||
at("laptop", "", "10.42.0.2", "", false),
|
||||
}, cidr)
|
||||
if err == nil {
|
||||
t.Fatal("a hub with no endpoint was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "must be reachable") {
|
||||
t.Errorf("the refusal does not say why: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodeWithNoPlaceYetIsSkippedRatherThanFatal(t *testing.T) {
|
||||
// A node that has enrolled and not yet been given an address is an ordinary in-between state.
|
||||
// Failing the whole graph over it would mean no node gets a network because one is half done.
|
||||
g, err := Compute([]Node{
|
||||
at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true),
|
||||
at("laptop", "", "10.42.0.2", "", false),
|
||||
{Name: "newcomer", Key: "", Address: ""},
|
||||
}, cidr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := g["newcomer"]; ok {
|
||||
t.Error("a node with no key or address was given a peer list")
|
||||
}
|
||||
if _, ok := peersOf(t, g, "laptop")["newcomer"]; ok {
|
||||
t.Error("a node with no key was put in somebody's peer list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNobodyPeersWithThemselves(t *testing.T) {
|
||||
g, err := Compute([]Node{
|
||||
at("anchor", "house", "10.42.0.1", "198.51.100.1:51820", true),
|
||||
at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false),
|
||||
}, cidr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for node, peers := range g {
|
||||
for _, p := range peers {
|
||||
if p.Name == node {
|
||||
t.Errorf("%s peers with itself", node)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHubAtYourOwnSiteAppearsOnceNotTwice(t *testing.T) {
|
||||
// WireGuard takes one entry per public key. A hub that shares a site with a spoke was being
|
||||
// emitted twice — once as a direct peer and once as the route of last resort — producing a
|
||||
// configuration the interface refuses, from a mesh that thought it had succeeded.
|
||||
//
|
||||
// Found in the lab on the first two machines that shared a site with their hub, which is the
|
||||
// ordinary case for a small mesh and was in none of the tests above.
|
||||
g, err := Compute([]Node{
|
||||
at("anchor", "lab", "10.42.0.1", "192.0.2.10:51820", true),
|
||||
at("laptop", "lab", "10.42.0.2", "", false),
|
||||
}, cidr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
seen := map[string]int{}
|
||||
for _, p := range g["laptop"] {
|
||||
seen[p.Key]++
|
||||
}
|
||||
for key, count := range seen {
|
||||
if count > 1 {
|
||||
t.Errorf("the key %s appears %d times; WireGuard takes one entry per key", key, count)
|
||||
}
|
||||
}
|
||||
|
||||
if len(g["laptop"]) != 1 {
|
||||
t.Fatalf("laptop has %d peers; the hub at its own site is one peer, not two", len(g["laptop"]))
|
||||
}
|
||||
// And that single entry has to carry everything, or the node keeps a direct path to the hub
|
||||
// and loses its route to everywhere else.
|
||||
if got := g["laptop"][0].Allowed; got != cidr {
|
||||
t.Errorf("the single entry allows %s; it is both the direct path and the route of last "+
|
||||
"resort, so it carries the whole overlay", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoUnreachableNodesAtOneSiteDoNotPeerDirectly(t *testing.T) {
|
||||
// Nobody would open the path, and the direct route is more specific than the hub's — so it
|
||||
// wins and blackholes. Both nodes would reach the hub perfectly and be unable to reach each
|
||||
// other, which is the worst arrangement available: it looks configured and is not.
|
||||
//
|
||||
// This is the design's own warning arriving in its implementation, and the lab found it with
|
||||
// two machines at one site behind no reachable address — the ordinary shape of a house.
|
||||
g, err := Compute([]Node{
|
||||
at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true),
|
||||
at("desk", "house", "10.42.0.2", "", false),
|
||||
at("server", "house", "10.42.0.3", "", false),
|
||||
}, cidr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, ok := peersOf(t, g, "desk")["server"]; ok {
|
||||
t.Error("two nodes that neither can be dialled were peered directly; neither could open " +
|
||||
"the path and the route is more specific than the hub's, so it blackholes")
|
||||
}
|
||||
// And they must still be able to reach each other — through the hub.
|
||||
if hub := peersOf(t, g, "desk")["anchor"]; hub.Allowed != cidr {
|
||||
t.Errorf("desk's route to everything else allows %s", hub.Allowed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneReachableNodeIsEnoughToPeerDirectly(t *testing.T) {
|
||||
// The other side of it: if either end can be dialled, the path can be opened, and using it
|
||||
// keeps their traffic off a hub that may be on another continent.
|
||||
g, err := Compute([]Node{
|
||||
at("anchor", "datacentre", "10.42.0.1", "198.51.100.1:51820", true),
|
||||
at("desk", "house", "10.42.0.2", "192.0.2.2:51820", false),
|
||||
at("server", "house", "10.42.0.3", "", false),
|
||||
}, cidr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := peersOf(t, g, "server")["desk"]; !ok {
|
||||
t.Error("a node did not peer with a reachable neighbour at its own site")
|
||||
}
|
||||
if _, ok := peersOf(t, g, "desk")["server"]; !ok {
|
||||
t.Error("the reachable node did not hold its unreachable neighbour, so replies have " +
|
||||
"nowhere to go")
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0105: a hub that took over the predecessor's tunnel carries every peer that tunnel
|
||||
// had, under the key and at the address the peer knows, until a node enrols with that key.
|
||||
func TestTheHubCarriesTheTunnelsPeersUntilTheyEnrol(t *testing.T) {
|
||||
hub := at("anchor", "hosting", "192.0.2.1", "198.51.100.1:51900", true)
|
||||
hub.Carried = []Carried{
|
||||
{Key: "key-home", Address: "192.0.2.2"},
|
||||
{Key: "key-workstation", Address: "192.0.2.3"},
|
||||
}
|
||||
// The machine behind key-home enrolled: it is a node now, at the address it kept.
|
||||
home := at("home", "house", "192.0.2.2", "", false)
|
||||
home.Key = "key-home"
|
||||
|
||||
g, err := Compute([]Node{hub, home}, "192.0.2.0/24")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
peers := peersOf(t, g, "anchor")
|
||||
carried, ok := peers[CarriedName("key-workstation")]
|
||||
if !ok {
|
||||
t.Fatalf("the hub does not carry the peer that has not enrolled: %+v", g["anchor"])
|
||||
}
|
||||
if carried.Allowed != "192.0.2.3/32" || carried.Endpoint != "" || carried.Key != "key-workstation" {
|
||||
t.Errorf("a carried peer is not the tunnel's own entry — same key, its one address, no endpoint: %+v", carried)
|
||||
}
|
||||
if _, twice := peers[CarriedName("key-home")]; twice {
|
||||
t.Error("a peer that enrolled is carried as well as listed as a node: WireGuard takes one entry per key")
|
||||
}
|
||||
if node, ok := peers["home"]; !ok || node.Key != "key-home" || node.Allowed != "192.0.2.2/32" {
|
||||
t.Errorf("the enrolled peer is not the node it became: %+v", node)
|
||||
}
|
||||
// Carried peers are the hub's business only: a spoke routes everything through the hub.
|
||||
if _, leaked := peersOf(t, g, "home")[CarriedName("key-workstation")]; leaked {
|
||||
t.Error("a carried peer appeared in a spoke's peer list")
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
package overlay
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
func networkOf(t *testing.T, nodes []Node) *Generator {
|
||||
t.Helper()
|
||||
made, err := From(nodes, "10.42.0.0/16", "/var/lib/mesh-host/overlay.key")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return made
|
||||
}
|
||||
|
||||
// A hub is dialled by every node at other sites, and needs its port open. A machine that is not a
|
||||
// hub dials out and needs nothing open at all.
|
||||
//
|
||||
// They are the same module, which is why a static field in a manifest cannot say it — and the
|
||||
// machine it gets wrong is the one facing the public internet, which is the machine that most
|
||||
// needs filtering.
|
||||
func TestOnlyAMachineThatCanBeDialledOpensTheOverlaysPort(t *testing.T) {
|
||||
network := networkOf(t, []Node{
|
||||
{Name: "anchor", Key: "a", Endpoint: "198.51.100.10:51820", Site: "one", Hub: true},
|
||||
{Name: "laptop", Key: "b", Site: "one"},
|
||||
})
|
||||
|
||||
opens, err := network.Listens("anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(opens) != 1 {
|
||||
t.Fatalf("the machine every other one dials opens %d ports", len(opens))
|
||||
}
|
||||
if opens[0].Port != 51820 || opens[0].At() != "udp" {
|
||||
t.Fatalf("the port is not the one the interface listens on: %+v", opens[0])
|
||||
}
|
||||
// From everywhere, and deliberately: a node at another site is not on the private network
|
||||
// until this port lets it on, so restricting it to the mesh would be a rule that can never be
|
||||
// satisfied by the thing it exists for.
|
||||
if opens[0].From != catalogue.FromEverywhere {
|
||||
t.Fatalf("the way onto the private network is restricted to the private network: %+v", opens[0])
|
||||
}
|
||||
if opens[0].Why == "" {
|
||||
t.Fatal("a port is opened and nothing says why, which is what makes a rule set unreadable")
|
||||
}
|
||||
|
||||
// And the machine nothing dials opens nothing. Not harmless to get wrong: a rule with no
|
||||
// reason is one somebody later has to work out the reason for.
|
||||
quiet, err := network.Listens("laptop")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(quiet) != 0 {
|
||||
t.Fatalf("a machine nothing dials opened %d port(s)", len(quiet))
|
||||
}
|
||||
}
|
||||
|
||||
// The port comes from the endpoint, which is where the interface takes its ListenPort from. One
|
||||
// source, so a rule set cannot open a port the interface is not on.
|
||||
func TestTheOpenedPortIsTheOneTheInterfaceListensOn(t *testing.T) {
|
||||
network := networkOf(t, []Node{
|
||||
{Name: "anchor", Key: "a", Endpoint: "198.51.100.10:60000", Site: "one", Hub: true},
|
||||
})
|
||||
opens, err := network.Listens("anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(opens) != 1 || opens[0].Port != 60000 {
|
||||
t.Fatalf("the rule set would open a port the interface is not on: %+v", opens)
|
||||
}
|
||||
|
||||
written, err := Declaration(Node{Name: "anchor", Key: "a", Address: "10.42.0.1",
|
||||
Endpoint: "198.51.100.10:60000", Hub: true}, nil, "/k")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(written), "ListenPort = 60000") {
|
||||
t.Fatalf("the interface and the rule set disagree about the port:\n%s", written)
|
||||
}
|
||||
}
|
||||
|
||||
// An endpoint with no port is refused rather than treated as a machine that opens nothing.
|
||||
//
|
||||
// A generator that cannot say what a machine opens is not one that says it opens nothing, and
|
||||
// closing a port on the evidence of a failure to look is how a machine is severed by a fault
|
||||
// somewhere else entirely.
|
||||
func TestAnEndpointWithNoPortIsRefusedRatherThanTakenAsSilence(t *testing.T) {
|
||||
network := networkOf(t, []Node{
|
||||
{Name: "anchor", Key: "a", Endpoint: "198.51.100.10", Site: "one", Hub: true},
|
||||
})
|
||||
if _, err := network.Listens("anchor"); err == nil {
|
||||
t.Fatal("a machine whose port could not be read was treated as opening nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// A machine the network has never heard of opens nothing, and that is an answer rather than an
|
||||
// error: a node assigned the module before it is placed is in exactly that state.
|
||||
func TestAMachineNotOnTheNetworkOpensNothing(t *testing.T) {
|
||||
network := networkOf(t, []Node{
|
||||
{Name: "anchor", Key: "a", Endpoint: "198.51.100.10:51820", Site: "one", Hub: true},
|
||||
})
|
||||
opens, err := network.Listens("a-stranger")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(opens) != 0 {
|
||||
t.Fatalf("a machine not on the network opened %d port(s)", len(opens))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package overlay
|
||||
|
||||
// The private network as a module rather than as code beside the module system.
|
||||
//
|
||||
// A machine's peer list is derived from every other machine, so it differs on each one and
|
||||
// changes when any of them changes — there is nothing that could be written in a manifest. So the
|
||||
// module says its resources are computed, and this computes them.
|
||||
//
|
||||
// What that buys, beyond one mechanism instead of two: **a machine is on the private network
|
||||
// because it was assigned the module.** Before this, every machine with a key and an address was
|
||||
// on it, and there was no way to say a machine should stay off.
|
||||
//
|
||||
// It is worth saying what is *not* here, because networking looks like it should be foundational.
|
||||
// The host needs none of this. It has an address and a route to the broker before the mesh exists
|
||||
// — that is the machine's own networking — and the broker's address is carried in the enrolment
|
||||
// token rather than resolved. So the private network is something the mesh installs on top, like
|
||||
// anything else, and a node without it is an ordinary node that nothing reaches directly.
|
||||
|
||||
// What a module asks for when it needs machines to reach each other. **This is the name that
|
||||
// matters** — WireGuard is one way to answer it, and naming the requirement after the answer is
|
||||
// how a mesh ends up unable to have a second one.
|
||||
const Requirement = "private-network"
|
||||
|
||||
// Name is the module that answers it with WireGuard.
|
||||
//
|
||||
// **It writes no names.** A machine's mesh names are answered by the mesh's one resolver (novox/hq
|
||||
// ADR 0194), and /etc/hosts is the file of one module, the holder of `node-hostname` (ADR 0199,
|
||||
// ADR 0223): the controller writes into no file another seat's holder owns. If the mesh ever needs a line there, it
|
||||
// asks that holder to register it. This module asked for a `node-names` fact written into /etc/hosts
|
||||
// until 2026-10-05; the host gives that region back at the first push without it.
|
||||
const Name = "mesh-wireguard"
|
||||
|
||||
// Addressing is the mesh handing out addresses on the private network itself.
|
||||
//
|
||||
// The mesh's resolver answers names from it, which is why it requires this rather than a private
|
||||
// network in general. A different VPN that hands out its own addresses would come with its own
|
||||
// names — the mesh has nothing to answer about a machine whose address it did not choose.
|
||||
const Addressing = "mesh-addressing"
|
||||
|
||||
// TheNetwork is what a machine can only have one of.
|
||||
//
|
||||
// Providing a private network is not the singular part — a machine could reasonably run two VPNs
|
||||
// for two different purposes. Being **the** one the mesh runs over is singular, and without
|
||||
// saying so a person who chose a different VPN can still end up with this one dragged back in by
|
||||
// something that needed the mesh's own addresses. Which is exactly what happened, once.
|
||||
const TheNetwork = "the-private-network"
|
||||
|
||||
// **A resolver's data went the same way as the names.** Two constants used to sit here — a
|
||||
// `mesh-resolver` module that would write the machines as wildcards, and a `resolver-data`
|
||||
// requirement a daemon would ask for. Nothing ever provided or consumed either: a module that
|
||||
// answers names asks for the `node-zones` fact in its own manifest (catalogue.FactsInto) and
|
||||
// requires Addressing, since the file is made of the mesh's addresses and means nothing off the
|
||||
// network. A requirement nothing provides is refused at resolution, so leaving the names here
|
||||
// would only have documented a mechanism that does not exist.
|
||||
|
||||
// Manifest is the module the mesh provides for itself.
|
||||
//
|
||||
// It ships with the control plane rather than coming from a repository, because the thing that
|
||||
// computes it ships with the control plane. That is the only way it is unusual: it is assigned,
|
||||
// unassigned, resolved and settled exactly like a module somebody wrote.
|
||||
func Manifest() map[string]any {
|
||||
return map[string]any{
|
||||
"module": Name,
|
||||
"version": "1",
|
||||
"computed": Name,
|
||||
"provides": []string{Requirement, Addressing},
|
||||
"claims": []map[string]any{{"name": TheNetwork, "scope": "node"}},
|
||||
}
|
||||
}
|
||||
|
||||
// Interface is what the private network is called on a machine, and where the node's own key
|
||||
// lives. A constant rather than a setting: two nodes disagreeing about the name would produce a
|
||||
// mesh where each is configured correctly and nothing meets.
|
||||
const (
|
||||
Interface = "mesh0"
|
||||
ConfigPath = "/etc/wireguard/" + Interface + ".conf"
|
||||
Unit = "wg-quick@" + Interface
|
||||
DefaultKeyPath = "/var/lib/mesh-host/overlay.key"
|
||||
)
|
||||
@@ -1,134 +0,0 @@
|
||||
// Package takein is the one rule that turns a builder's done key into the manifest the mesh composes a module from
|
||||
// (issue 1126): the module's manifest resolved with what its build made. It is mesh-controller's take-in of a done key
|
||||
// (cmd/mesh-controller/key_takein.go builtOfKey, unpackTexts and Manifest.Resolve) moved into the SDK, so a node agent
|
||||
// composing its own modules (novox/hq ADR 0547 §2) composes from the same built manifest the controller composes from:
|
||||
// every artifact a resource names resolved to its reference and digest, a TypeScript bundle's launchers, and, for a
|
||||
// moved module, each template's text read back from its templates archive and what its build keeps for the
|
||||
// provisioner (catalogue.Handed). Composing from the manifest the indexer read instead hands no moved module and
|
||||
// leaves every artifact unbuilt.
|
||||
//
|
||||
// It reads nothing itself: the archive is read through the caller's Archive, from wherever the caller reaches the
|
||||
// artifact store.
|
||||
package takein
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// Archive reads the archive a recorded reference names (catalogue.Recorded: the artifact store's address left out),
|
||||
// checked against its digest.
|
||||
type Archive func(ctx context.Context, reference, digest string) ([]byte, error)
|
||||
|
||||
// Manifest is m, as its manifest at b's fingerprint reads, resolved with what b made: the built manifest. b must be done
|
||||
// and every artifact must name its reference.
|
||||
// A templated module's templates archive is read through archive; a module without templates reads none, and archive
|
||||
// may then be nil.
|
||||
func Manifest(ctx context.Context, m catalogue.Manifest, b delivery.BuilderBuild, archive Archive) (catalogue.Manifest, error) {
|
||||
if b.State != delivery.BuildDone {
|
||||
return catalogue.Manifest{}, fmt.Errorf("takein: the build of %s is %s, not done: nothing to resolve it with", m.Module, b.State)
|
||||
}
|
||||
// **A done key whose artifact names no reference is waited for, never composed from** (novox/hq ADR 0498 §8), in the
|
||||
// controller's words (mesh-controller internal/builderkeys Key.Says): a key done before R4e, or a candidate's image or
|
||||
// package not yet built again for the trunk.
|
||||
var missing []string
|
||||
for _, a := range b.Artifacts {
|
||||
if strings.TrimSpace(a.Reference) == "" {
|
||||
missing = append(missing, a.Name)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return catalogue.Manifest{}, fmt.Errorf("takein: the build of %s is done and waits for the reference of %s",
|
||||
m.Module, strings.Join(missing, ", "))
|
||||
}
|
||||
built, err := Built(ctx, m, b.Artifacts, archive)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, err
|
||||
}
|
||||
resolved, err := m.Resolve(built)
|
||||
if err != nil {
|
||||
return catalogue.Manifest{}, fmt.Errorf("takein: the build of %s does not resolve: %w", m.Module, err)
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// Built is each of a done key's artifacts as catalogue.Manifest.Resolve reads it: its recorded reference and digest, a
|
||||
// TypeScript bundle's launchers by the build modules' one rule (catalogue.Launchers), and, for the archive a module packs
|
||||
// its templates into, each template's text read back from that archive.
|
||||
func Built(ctx context.Context, m catalogue.Manifest, artifacts []delivery.BuiltArtifact, archive Archive) ([]catalogue.Built, error) {
|
||||
declared := map[string]catalogue.Artifact{}
|
||||
if m.Build != nil {
|
||||
for _, a := range m.Build.Artifacts {
|
||||
declared[a.Name] = a
|
||||
}
|
||||
}
|
||||
templates := ""
|
||||
if m.Templated() {
|
||||
templates = catalogue.TemplatesArtifact(m)
|
||||
}
|
||||
var out []catalogue.Built
|
||||
for _, a := range artifacts {
|
||||
d := declared[a.Name]
|
||||
kind := a.Kind
|
||||
if kind == "" {
|
||||
kind = d.Kind
|
||||
}
|
||||
b := catalogue.Built{Name: a.Name, Kind: kind, Reference: catalogue.Recorded(a.Reference), Digest: a.Digest}
|
||||
if launchers := catalogue.Launchers(d.Language, d.Entrypoints); len(launchers) > 0 {
|
||||
b.Launchers = launchers
|
||||
}
|
||||
if a.Name == templates && templates != "" {
|
||||
if archive == nil {
|
||||
return nil, fmt.Errorf("takein: %s packs its templates into %q, and nothing here reads the artifact store",
|
||||
m.Module, a.Name)
|
||||
}
|
||||
body, err := archive(ctx, b.Reference, b.Digest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("takein: the templates archive %q of %s cannot be read from the artifact store: %w",
|
||||
a.Name, m.Module, err)
|
||||
}
|
||||
if b.Texts, err = UnpackTexts(body); err != nil {
|
||||
return nil, fmt.Errorf("takein: the templates archive %q of %s cannot be read back: %w", a.Name, m.Module, err)
|
||||
}
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UnpackTexts is every regular file of a packed templates archive (gzip over tar), by its path inside it.
|
||||
func UnpackTexts(body []byte) (map[string]string, error) {
|
||||
zipped, err := gzip.NewReader(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer zipped.Close()
|
||||
reader := tar.NewReader(zipped)
|
||||
texts := map[string]string{}
|
||||
for {
|
||||
header, err := reader.Next()
|
||||
if errors.Is(err, io.EOF) {
|
||||
return texts, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if header.Typeflag != tar.TypeReg {
|
||||
continue
|
||||
}
|
||||
text, err := io.ReadAll(io.LimitReader(reader, 16<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
texts[strings.TrimPrefix(header.Name, "./")] = string(text)
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
)
|
||||
|
||||
// The SDK's validator speaks the copied parser's vocabulary version (issue 1353 AC3).
|
||||
func TestVersionIsTheParsersVersion(t *testing.T) {
|
||||
if Version != declaration.Version {
|
||||
t.Fatalf("validate.Version = %d, declaration.Version = %d", Version, declaration.Version)
|
||||
}
|
||||
}
|
||||
|
||||
// A body of another declaration version is refused whole, by both the validator and the parser (AC3). The body is
|
||||
// the shape mesh-host's validate_test.go uses for its good declaration, with the version changed.
|
||||
func TestAnotherVersionIsRefusedWhole(t *testing.T) {
|
||||
body := `{"declaration": 99, "resources": [{"id": "d", "type": "directory", "path": "/var/lib/x", "mode": "0755"}]}`
|
||||
problems := Declaration([]byte(body))
|
||||
if len(problems) == 0 {
|
||||
t.Fatal("a declaration of another version was taken")
|
||||
}
|
||||
_, err := declaration.Parse([]byte(body))
|
||||
var refused *declaration.RefusalError
|
||||
if !errors.As(err, &refused) || strings.Join(refused.Problems, "\n") != strings.Join(problems, "\n") {
|
||||
t.Fatalf("the parser and the validator disagree: %v / %v", err, problems)
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
// Package validate is the node-engine's own judgement of a declaration, for whoever must know before
|
||||
// a declaration is sent whether a machine would take it (novox/hq to-be 45 §4, D1; §9, the merge gate).
|
||||
//
|
||||
// **One validator.** The controller composed declarations the host then refused whole — a manifest
|
||||
// the catalogue check passed (novox/hq issue 236), a consumer's identity too long for the machine's
|
||||
// names (issue 263) — because the only validator was the one the host runs as it applies. A second,
|
||||
// written in the controller from the host's rules, would drift from them the first time either
|
||||
// changed. So the host's own parsing is exported here, unchanged: what the controller's self-check and
|
||||
// the merge gate run is what every machine runs.
|
||||
//
|
||||
// Copied from mesh-host at commit 619e14aa8e572c5056e3d2a2412c7f8b364bf840 (validate/validate.go), unchanged but for
|
||||
// the import path of the parser, which is mesh-sdk's go/declaration (novox/hq ADR 0453 clause 3, ADR 0446 clause 4).
|
||||
// mesh-host keeps its own copy until release 4b.
|
||||
//
|
||||
// It judges a declaration as it arrives over the link: actions are refused, as the host refuses them
|
||||
// from the link (novox/hq ADR 0005). Nothing here touches a machine.
|
||||
package validate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/declaration"
|
||||
)
|
||||
|
||||
// Declaration is every problem the node-engine would refuse a declaration for, each in its own words;
|
||||
// nil when it would take it. The body is the declaration as the controller composes it — the bytes a
|
||||
// signature is made over — not the signed envelope.
|
||||
func Declaration(raw []byte) []string {
|
||||
_, err := declaration.Parse(raw)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
var refused *declaration.RefusalError
|
||||
if errors.As(err, &refused) {
|
||||
return refused.Problems
|
||||
}
|
||||
return []string{err.Error()}
|
||||
}
|
||||
|
||||
// Version is the declaration vocabulary this validator speaks: a declaration of another version is
|
||||
// refused whole by Declaration, as by the host.
|
||||
const Version = declaration.Version
|
||||
@@ -1,50 +0,0 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// **The validator a controller imports is the host's**: a declaration the host takes passes, and one
|
||||
// it refuses is refused with the host's own words, every problem at once.
|
||||
func TestTheValidatorIsTheHosts(t *testing.T) {
|
||||
good := `{"declaration": 1, "resources": [{"id": "d", "type": "directory", "path": "/var/lib/x", "mode": "0755"}]}`
|
||||
if problems := Declaration([]byte(good)); problems != nil {
|
||||
t.Fatalf("a declaration the host takes was refused: %v", problems)
|
||||
}
|
||||
// And one carrying the lease epoch and sequence the controller sends it under (novox/hq to-be 45
|
||||
// §6): a controller validating with this package may send them.
|
||||
ordered := `{"declaration": 1, "epoch": 57, "sequence": 3, "resources": [{"id": "d", "type": "directory", "path": "/var/lib/x", "mode": "0755"}]}`
|
||||
if problems := Declaration([]byte(ordered)); problems != nil {
|
||||
t.Fatalf("a declaration carrying its order was refused: %v", problems)
|
||||
}
|
||||
// And one carrying a file's owner-at-runtime and the files the provisioner fills (novox/hq ADR 0349 §5): the
|
||||
// reader takes them before any controller sends them.
|
||||
owned := `{"declaration": 1, "filled_files": ["/etc/x"], "resources": [{"id": "f", "type": "file", "path": "/etc/x", "content": "a", "owner-at-runtime": "owned"}]}`
|
||||
if problems := Declaration([]byte(owned)); problems != nil {
|
||||
t.Fatalf("a declaration saying who owns a file at runtime was refused: %v", problems)
|
||||
}
|
||||
// And one naming the provisioner client's account (novox/hq ADR 0349 §4).
|
||||
named := `{"declaration": 1, "provisioner_account": "mesh-node-provisioner", "filled_files": ["/etc/x"], "resources": [{"id": "f", "type": "file", "path": "/etc/x", "content": "a", "owner-at-runtime": "owned"}]}`
|
||||
if problems := Declaration([]byte(named)); problems != nil {
|
||||
t.Fatalf("a declaration naming the provisioner client's account was refused: %v", problems)
|
||||
}
|
||||
for _, bad := range []string{
|
||||
`{"declaration": 1, "provisioner_account": "root", "resources": [{"id": "f", "type": "file", "path": "/etc/x", "content": "a"}]}`,
|
||||
`{"declaration": 1, "filled_files": ["/etc/none"], "resources": [{"id": "f", "type": "file", "path": "/etc/x", "content": "a", "owner-at-runtime": "owned"}]}`,
|
||||
`{"declaration": 1, "resources": []}`,
|
||||
`{"declaration": 99, "resources": [{"id": "d", "type": "directory", "path": "/x"}]}`,
|
||||
`{"declaration": 1, "resources": [{"id": "d", "type": "nothing-the-host-knows"}]}`,
|
||||
`not json`,
|
||||
} {
|
||||
problems := Declaration([]byte(bad))
|
||||
if len(problems) == 0 {
|
||||
t.Errorf("a declaration the host refuses was passed: %s", bad)
|
||||
}
|
||||
for _, p := range problems {
|
||||
if strings.TrimSpace(p) == "" {
|
||||
t.Errorf("a problem said nothing, for %s", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user