Take the catalogue's composition rules into the node-engine

ADR 0593 §7: the composer with its refusals leaves mesh-sdk, so mesh-sdk
carries no composition rule. The catalogue functions only the
node-engine's composition reaches (Resolve, Resolution's Compose,
Declaration, DataItems, LeftOut, Rules and the rest, the settings,
placeholder and template refusals, the rendering helpers) are copied
with their tests into internal/compose/resolution; a method of a
mesh-sdk shape becomes a function taking it first. The composition
calls its own copies, and the captured catalogue its tests read lives
here now, shared by mesh-compose's tests.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
2026-11-02 19:15:55 +01:00
parent be0226b624
commit d68e241724
686 changed files with 35703 additions and 80 deletions
@@ -0,0 +1,10 @@
package resolution
func indexOf(out []map[string]any, id string) int {
for i, r := range out {
if r["id"] == id {
return i
}
}
return -1
}
@@ -0,0 +1,80 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// The fault 04-ISSUES/036 records: a media stack is several modules — a library server, the
// acquisition managers, a download client — that must share directories on one machine. Written
// as owned `directory` resources, two of them on one node are refused as rivalrous owners, which
// is right in general and wrong here: sharing those directories is the whole point of the stack.
//
// Declared as accesses to an operator-owned path (novox/hq ADR 0051), they co-resolve. An access
// is not a resource and never enters the duplicate-owner map, so this is the exact case the
// resolver refuses above when the same path is owned — and does not, when it is merely accessed.
func TestTwoModulesAccessingOnePathCoResolve(t *testing.T) {
a := mod("a", nil, nil, nil)
a.Accesses = []mcat.Access{{Path: "/services/media/downloads", Mode: mcat.AccessReadWrite}}
b := mod("b", nil, nil, nil)
b.Accesses = []mcat.Access{{Path: "/services/media/downloads", Mode: mcat.AccessRead}}
got, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), mcat.World{})
if err != nil {
t.Fatalf("two modules accessing one operator-owned path were refused: %v", err)
}
// And each accessing module's grant reaches the machine as its own `access` resource, so the
// host can find the path present or refuse — both naming the one operator-owned path.
var accesses int
for _, r := range mustDeclare(t, got) {
if r["type"] == "access" && r["path"] == "/services/media/downloads" {
accesses++
}
}
if accesses != 2 {
t.Errorf("expected both modules' accesses in the declaration, got %d", accesses)
}
}
// The mirror of the case above, so the sharing vocabulary does not weaken the rule it sits beside:
// two modules OWNING one path is still refused, exactly as before accesses existed.
func TestTwoModulesOwningOnePathAreStillRefused(t *testing.T) {
a := mod("a", nil, nil, nil)
a.Resources = []map[string]any{
{"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}}
b := mod("b", nil, nil, nil)
b.Resources = []map[string]any{
{"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}}
_, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), mcat.World{})
if err == nil {
t.Fatal("two modules owning the same directory were both assigned")
}
if !strings.Contains(err.Error(), "/services/media/movies") {
t.Errorf("the refusal does not name the path: %v", err)
}
}
// Shared data is the operator's, owned by no module (novox/hq ADR 0051). A module owning the path
// another module was told to expect the operator to provide would create and chown it — and the
// two intentions cannot both hold, so it is refused by name. This is what keeps the distinction
// machine-checkable rather than a convention nobody enforces.
func TestAModuleMayNotOwnWhatAnotherAccesses(t *testing.T) {
owns := mod("owns", nil, nil, nil)
owns.Resources = []map[string]any{
{"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}}
uses := mod("uses", nil, nil, nil)
uses.Accesses = []mcat.Access{{Path: "/services/media/movies", Mode: mcat.AccessRead}}
_, err := Resolve(shelf(owns, uses), []string{"owns", "uses"}, workstation(), mcat.World{})
if err == nil {
t.Fatal("a module owning a path another accesses was allowed")
}
if !strings.Contains(err.Error(), "/services/media/movies") ||
!strings.Contains(err.Error(), "the operator's") {
t.Errorf("the refusal does not explain the ownership conflict: %v", err)
}
}
+177
View File
@@ -0,0 +1,177 @@
package resolution
import (
"fmt"
"sort"
"strconv"
"strings"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// GuardID, GuardUnitID and GuardRunningID are the guard's resource identities. The installer
// raises the same three on an adopted genesis, so the first push finds them already there.
func GuardID() string { return mcat.AdoptionPrefix + "guard" }
func GuardUnitID() string { return mcat.AdoptionPrefix + "guard-unit" }
func GuardRunningID() string { return mcat.AdoptionPrefix + "guard-running" }
// GuardPackageID is the tool that loads the guard, declared first: a node joining adopted has
// no filter module and may have no nft at all, and a table nothing can load guards nothing.
func GuardPackageID() string { return mcat.AdoptionPrefix + "guard-package" }
// OpeningID is an opening's resource identity: its protocol, port and path say what it is.
func OpeningID(protocol string, port int, path string) string {
return fmt.Sprintf("%sopening-%s-%d-%s", mcat.AdoptionPrefix, protocol, port, path)
}
// Openings are what the mesh needs reachable on an adopted node, from the same inputs as the
// filter it would load were the node converged, each from where that filter would admit it.
//
// `rules` is Filtering's answer — every module's listens, the hub's port, the per-node exposure —
// and `foundation` is the ports the mesh itself needs, from everywhere. A rule for this machine
// only opens nothing. `published` maps a machine port a container publishes to the container's
// port: a published port is forwarded, not received, so its opening names the forwarded path and
// the port the packet is forwarded to.
func Openings(rules []mcat.Rule, foundation []int, published map[string]map[int]int) []map[string]any {
type key struct {
protocol string
port int
}
from := map[key]string{}
var order []key
widen := func(k key, f string) {
was, seen := from[k]
if !seen {
order = append(order, k)
}
if !seen || was != mcat.OpeningFromEverywhere {
from[k] = f
}
}
for _, rule := range rules {
switch rule.From {
case mcat.FromEverywhere:
widen(key{rule.Protocol, rule.Port}, mcat.OpeningFromEverywhere)
case mcat.FromMesh:
widen(key{rule.Protocol, rule.Port}, mcat.OpeningFromMesh)
case mcat.FromLocalNetwork:
// The private network's part only (novox/hq ADR 0340). An adopted node's found firewall
// stays, and it is what admits the machine's local network; an opening has no word for a
// local network, and widening it to everywhere would open the port to the internet.
widen(key{rule.Protocol, rule.Port}, mcat.OpeningFromMesh)
}
}
for _, port := range foundation {
widen(key{"tcp", port}, mcat.OpeningFromEverywhere)
}
sort.Slice(order, func(a, b int) bool {
if order[a].port != order[b].port {
return order[a].port < order[b].port
}
return order[a].protocol < order[b].protocol
})
out := make([]map[string]any, 0, len(order))
for _, k := range order {
opening := map[string]any{"type": "opening", "port": k.port, "protocol": k.protocol,
"from": from[k]}
if to, forwarded := published[k.protocol][k.port]; forwarded {
opening["id"] = OpeningID(k.protocol, k.port, mcat.PathForwarded)
opening["path"] = mcat.PathForwarded
opening["to"] = to
} else {
opening["id"] = OpeningID(k.protocol, k.port, mcat.PathIncoming)
opening["path"] = mcat.PathIncoming
}
out = append(out, opening)
}
return out
}
// AsGuard renders the mesh's refusal-only table for the given machine ports.
//
// It passes everything by default and holds nothing but a refusal, so it cannot close anything
// the machine serves; and it is the mesh's own table, so the found firewall reloading does not
// touch it. It refuses only packets addressed to this machine, and the ports except from the
// machine itself — its loopback and the container runtime's own networks — and from the private
// network, known by the interface a packet arrives on and never by its source address. At
// prerouting, ahead of the runtime's destination translation, so it matches the port the packet
// was sent to; in the inet family, so both address families.
//
// **The machine's own interfaces are named, where the derived filter names address ranges.** The
// filter accepts the container runtime's networks by CIDR; this excludes its bridges by name — lo,
// docker0, the br-* a compose network gets, and the mesh's own mesh0. A runtime whose bridge is
// named anything else (a podman or libvirt bridge, or a docker network created with a fixed name)
// would have its containers' traffic to a guarded port refused, which reads as the port being
// down. Names rather than addresses is deliberate: a source address can be claimed by whoever
// sends the packet, and this table exists to refuse what the found firewall never sees. Widening
// it means adding names here and in the installer's copy together, which the golden test holds to
// one text.
//
// The same text the installer raises on an adopted genesis; a test holds both to it.
func AsGuard(ports []int) string {
sorted := append([]int{}, ports...)
sort.Ints(sorted)
listed := make([]string, len(sorted))
for i, p := range sorted {
listed[i] = strconv.Itoa(p)
}
var b strings.Builder
b.WriteString("table inet mesh_guard {}\n")
b.WriteString("delete table inet mesh_guard\n")
b.WriteString("table inet mesh_guard {\n")
b.WriteString("\tchain prerouting {\n")
b.WriteString("\t\ttype filter hook prerouting priority raw; policy accept;\n")
// Only packets addressed to this machine: traffic it routes for others — a predecessor's hub,
// say — is never the guard's business (novox/hq ADR 0103).
fmt.Fprintf(&b, "\t\tfib daddr type local iifname != \"lo\" iifname != \"docker0\" "+
"iifname != \"br-*\" iifname != \"mesh0\" tcp dport { %s } drop\n",
strings.Join(listed, ", "))
b.WriteString("\t}\n")
b.WriteString("}\n")
return b.String()
}
// GuardUnitText is the unit that loads the guard. Stopping it deletes only its own table: never
// a flush, which would take the container runtime's rules and the found firewall with it.
func GuardUnitText() string {
return "[Unit]\n" +
"Description=The mesh's guard: refuses its own ports from outside (novox/hq ADR 0100)\n" +
// Early, before the network is up, and without the default dependencies that would
// order it after the network; stopped at shutdown like any unit.
"DefaultDependencies=no\n" +
"Wants=network-pre.target\n" +
"Before=network-pre.target shutdown.target\n" +
"Conflicts=shutdown.target\n" +
"\n" +
"[Service]\n" +
"Type=oneshot\n" +
"RemainAfterExit=yes\n" +
"ExecStart=nft -f " + mcat.GuardPath + "\n" +
"ExecReload=nft -f " + mcat.GuardPath + "\n" +
"ExecStop=nft delete table inet mesh_guard\n" +
"\n" +
"[Install]\n" +
"WantedBy=multi-user.target\n"
}
// GuardResources are the guard as four resources of the existing kinds: the tool that loads it,
// the table, the unit, and the unit running — reloaded when the table changes, so the new table
// replaces the old in one `nft -f` through the unit's ExecReload with no moment unguarded, and
// restarted only when the unit itself changes. Nothing when there is nothing to guard: an empty
// set is not a table nft loads.
func GuardResources(ports []int) []map[string]any {
if len(ports) == 0 {
return nil
}
return []map[string]any{
{"id": GuardPackageID(), "type": "package", "package": mcat.GuardPackage},
{"id": GuardID(), "type": "file", "path": mcat.GuardPath, "content": AsGuard(ports),
"mode": "0644"},
{"id": GuardUnitID(), "type": "file", "path": mcat.GuardUnitPath, "content": GuardUnitText(),
"mode": "0644"},
{"id": GuardRunningID(), "type": "service", "unit": mcat.GuardUnit, "state": "running",
"boot": "enabled", "restart-on": []any{GuardUnitID()}, "reload-on": []any{GuardID()}},
}
}
@@ -0,0 +1,629 @@
package resolution
import (
"encoding/json"
"reflect"
"slices"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// hub is the private network's generator on the hub: it opens the hub's port from anywhere.
type hub struct{}
func (hub) Resources(string) ([]map[string]any, bool, error) {
return []map[string]any{{"id": "config", "type": "file", "path": "/etc/wireguard/mesh0.conf",
"content": "[Interface]\n"}}, true, nil
}
func (hub) Listens(string) ([]mcat.Listening, error) {
return []mcat.Listening{{Port: 51820, Protocol: "udp", From: mcat.FromEverywhere}}, nil
}
// anAdoptedAnchor is the control-node's set: the store, the bus, the registry, the private network,
// a served module and the filter module.
func anAdoptedAnchor() mcat.Resolution {
return mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{
{Module: "network", Computed: "overlay"},
{Module: "postgres", Guards: []int{5432},
Listens: []mcat.Listening{{Port: 5432, From: mcat.FromMesh}},
Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-store",
"ports": []any{"5432:5432"}}}},
{Module: "lavinmq", Guards: []int{15672},
Listens: []mcat.Listening{{Port: 5671, From: mcat.FromMesh}, {Port: 5672, From: mcat.FromMesh}},
Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-broker",
"ports": []any{"5671:5671", "5672:5672", "127.0.0.1:15672:15672"}}}},
{Module: "distribution",
Listens: []mcat.Listening{{Port: 5000, From: mcat.FromMesh}},
Resources: []map[string]any{{"id": "store", "type": "container", "name": "registry",
"ports": []any{"5000"}}}},
{Module: "hello-web",
Listens: []mcat.Listening{{Port: 8080, From: mcat.FromEverywhere}},
Resources: []map[string]any{{"id": "server", "type": "container", "name": "hello-web",
"ports": []any{"8080"}}}},
{Module: "helper", Listens: []mcat.Listening{{Port: 9000, From: mcat.FromMachine}}},
{Module: "nftables", Filtering: &mcat.Filtering{Into: "/etc/nftables.conf"},
Resources: []map[string]any{{"id": "load", "type": "service", "unit": "mesh-filter.service",
"state": "running", "restart-on": []any{"filtering"}}}},
}}
}
func anchorRendering(adopted bool) mcat.Rendering {
return mcat.Rendering{
Generators: map[string]mcat.Generator{"overlay": hub{}},
Ports: map[string]map[int]int{"distribution": {5000: 5000}, "hello-web": {8080: 20001}},
Settings: mcat.SettingsBy{"distribution": {{From: "node anchor",
Placement: map[string]any{mcat.ExposeSetting: map[string]any{"5000": mcat.FromEverywhere}}}}},
Mesh: []string{"10.42.0.1"},
Foundation: []int{5671},
// What the machine reported faces outside, which every rule in the filter is written
// around (novox/hq ADR 0140).
OutwardLinks: []string{"eth0"},
TunnelInterface: "mesh0",
Adopted: adopted,
// Genesis takes the foundation's modules.
Taken: map[string]bool{"postgres": true, "lavinmq": true},
}
}
func byID(resources []map[string]any) map[string]map[string]any {
out := map[string]map[string]any{}
for _, r := range resources {
out[r["id"].(string)] = r
}
return out
}
func TestAnAdoptedNodeIsDeclaredOpeningsFromTheSameInputsAsTheFilter(t *testing.T) {
composed, err := Compose(anAdoptedAnchor(), anchorRendering(true))
if err != nil {
t.Fatal(err)
}
got := byID(composed.Resources)
want := map[string]map[string]any{
// The hub's port, from anywhere, received.
"adoption.opening-udp-51820-incoming": {"port": 51820, "protocol": "udp",
"from": "everywhere", "path": "incoming"},
// The store's port from the private network only, and forwarded: a container publishes it.
"adoption.opening-tcp-5432-forwarded": {"port": 5432, "protocol": "tcp", "from": "mesh",
"path": "forwarded", "to": 5432},
// The bus from anywhere: a node enrols over it before it has a private address.
"adoption.opening-tcp-5671-forwarded": {"port": 5671, "protocol": "tcp",
"from": "everywhere", "path": "forwarded", "to": 5671},
"adoption.opening-tcp-5672-forwarded": {"port": 5672, "protocol": "tcp", "from": "mesh",
"path": "forwarded", "to": 5672},
// The registry from anywhere, by its node's exposure setting.
"adoption.opening-tcp-5000-forwarded": {"port": 5000, "protocol": "tcp",
"from": "everywhere", "path": "forwarded", "to": 5000},
// A published port names the machine port and the container port it is forwarded to.
"adoption.opening-tcp-20001-forwarded": {"port": 20001, "protocol": "tcp",
"from": "everywhere", "path": "forwarded", "to": 8080},
}
for id, fields := range want {
opening, ok := got[id]
if !ok {
t.Errorf("no %s among %v", id, keys(got))
continue
}
if opening["type"] != "opening" {
t.Errorf("%s is a %v", id, opening["type"])
}
for k, v := range fields {
if opening[k] != v {
t.Errorf("%s: %s is %v, want %v", id, k, opening[k], v)
}
}
}
for id := range got {
if strings.HasPrefix(id, "adoption.opening-") && want[id] == nil {
t.Errorf("an opening nothing asked for: %s", id)
}
}
// A port for this machine only opens nothing, and the management port is not opened at all.
for id := range got {
if strings.Contains(id, "-9000-") || strings.Contains(id, "-15672-") {
t.Errorf("%s is opened", id)
}
}
// And openings come first, in the order the machine applies them.
if !strings.HasPrefix(composed.Resources[0]["id"].(string), "adoption.opening-") {
t.Errorf("openings are not first: %v", composed.Resources[0]["id"])
}
}
func TestAnAdoptedNodeLoadsNoFilterOfTheMeshs(t *testing.T) {
composed, err := Compose(anAdoptedAnchor(), anchorRendering(true))
if err != nil {
t.Fatal(err)
}
for _, r := range composed.Resources {
if r["path"] == "/etc/nftables.conf" || strings.HasPrefix(r["id"].(string), "nftables.") {
t.Fatalf("an adopted node is declared the filter module's %v", r["id"])
}
if content, _ := r["content"].(string); strings.Contains(content, "policy drop") {
t.Fatalf("an adopted node is declared a table that drops by default: %v", r["id"])
}
// Nothing but refusals: the only accept in the guard is its policy.
if content, _ := r["content"].(string); r["id"] == GuardID() &&
strings.Count(content, "accept") != 1 {
t.Fatalf("the guard holds an accept:\n%s", content)
}
}
got := byID(composed.Resources)
guard := got[GuardID()]
if guard == nil || got[GuardUnitID()] == nil || got[GuardRunningID()] == nil {
t.Fatalf("no guard: %v", keys(got))
}
if guard["content"] != AsGuard([]int{5432, 5672, 15672}) {
t.Fatalf("the guard does not guard the store, the broker and its management port:\n%s",
guard["content"])
}
// The tool that loads it comes first, and the table after it: a node joining adopted has no
// filter module and may have no nft.
pkg, table := -1, -1
for i, r := range composed.Resources {
switch r["id"] {
case GuardPackageID():
pkg = i
if r["type"] != "package" || r["package"] != "nftables" {
t.Fatalf("the guard's package is %v", r)
}
case GuardID():
table = i
}
}
if pkg < 0 || pkg > table {
t.Fatalf("nftables is not declared before the guard's table (%d, %d)", pkg, table)
}
// A changed table is reloaded — one `nft -f`, atomic — never restarted, which would delete the
// table and leave the ports unguarded until it is loaded again. Only a changed unit restarts.
if !reflect.DeepEqual(got[GuardRunningID()]["reload-on"], []any{GuardID()}) ||
!reflect.DeepEqual(got[GuardRunningID()]["restart-on"], []any{GuardUnitID()}) {
t.Fatalf("the guard is not reloaded on its table and restarted on its unit: %v",
got[GuardRunningID()])
}
// Nothing of the mesh's own is anybody's to hold.
for id, module := range composed.Owner {
if strings.HasPrefix(id, mcat.AdoptionPrefix) {
t.Fatalf("%s is owned by %s", id, module)
}
}
}
func TestAConvergedNodeIsDeclaredItsFilterAndNoOpenings(t *testing.T) {
composed, err := Compose(anAdoptedAnchor(), anchorRendering(false))
if err != nil {
t.Fatal(err)
}
got := byID(composed.Resources)
if got["nftables.filtering"] == nil || got["nftables.load"] == nil {
t.Fatalf("a converged node lost its filter: %v", keys(got))
}
for id := range got {
if strings.HasPrefix(id, mcat.AdoptionPrefix) {
t.Fatalf("a converged node is declared %s", id)
}
}
plain, err := Declaration(anAdoptedAnchor(), anchorRendering(false))
if err != nil {
t.Fatal(err)
}
a, _ := json.Marshal(plain)
b, _ := json.Marshal(composed.Resources)
if string(a) != string(b) {
t.Fatal("Compose and Declaration disagree on a converged node")
}
}
// The table the installer raises and the controller declares, character for character.
func TestTheGuardIsExactlyThisTable(t *testing.T) {
const golden = `table inet mesh_guard {}
delete table inet mesh_guard
table inet mesh_guard {
chain prerouting {
type filter hook prerouting priority raw; policy accept;
fib daddr type local iifname != "lo" iifname != "docker0" iifname != "br-*" iifname != "mesh0" tcp dport { 5432, 15672 } drop
}
}
`
if got := AsGuard([]int{15672, 5432}); got != golden {
t.Fatalf("the guard changed:\n%s", got)
}
const unit = `[Unit]
Description=The mesh's guard: refuses its own ports from outside (novox/hq ADR 0100)
DefaultDependencies=no
Wants=network-pre.target
Before=network-pre.target shutdown.target
Conflicts=shutdown.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=nft -f /etc/mesh/guard.nft
ExecReload=nft -f /etc/mesh/guard.nft
ExecStop=nft delete table inet mesh_guard
[Install]
WantedBy=multi-user.target
`
if got := GuardUnitText(); got != unit {
t.Fatalf("the guard's unit changed:\n%s", got)
}
if GuardResources(nil) != nil {
t.Fatal("a guard with nothing to guard is an empty set nft refuses to load")
}
}
func keys[V any](m map[string]V) []string {
return sortedKeys(m)
}
// novox/hq ADR 0100: the foundation's ports are the node's. Given 5433 for the store, every place
// that uses the port reads it from there: the container, the filter, the openings, the guard.
func TestAGivenPortIsUsedEverywhereThePortIs(t *testing.T) {
given := map[string]map[int]int{"postgres": {5432: 5433}, "lavinmq": {15672: 15673}}
for _, adopted := range []bool{true, false} {
with := anchorRendering(adopted)
with.Given = given
with.Ports["postgres"] = map[int]int{5432: 5433}
composed, err := Compose(anAdoptedAnchor(), with)
if err != nil {
t.Fatal(err)
}
got := byID(composed.Resources)
if ports := got["postgres.server"]["ports"]; !reflect.DeepEqual(ports, []any{"5433:5432"}) {
t.Fatalf("the store's container publishes %v", ports)
}
if ports := got["lavinmq.server"]["ports"]; !reflect.DeepEqual(ports,
[]any{"5671:5671", "5672:5672", "127.0.0.1:15673:15672"}) {
t.Fatalf("the broker's container publishes %v", ports)
}
if !adopted {
filter, _ := got["nftables.filtering"]["content"].(string)
if !strings.Contains(filter, "tcp dport 5433 accept") || strings.Contains(filter, "5432") {
t.Fatalf("the filter does not use the given port:\n%s", filter)
}
continue
}
if o := got["adoption.opening-tcp-5433-forwarded"]; o == nil || o["to"] != 5432 {
t.Fatalf("no opening for the given port: %v", keys(got))
}
if guard := got[GuardID()]["content"]; guard != AsGuard([]int{5433, 5672, 15673}) {
t.Fatalf("the guard does not guard the given ports:\n%s", guard)
}
}
}
// novox/hq ADR 0103: the guard is derived, and from taken modules only — every machine port a taken
// module publishes that the filter admits from the private network only, and the ports its
// manifest guards. A module assigned but not taken is not guarded: its port may still be the
// predecessor's.
func TestTheGuardIsDerivedFromTakenModulesOnly(t *testing.T) {
guardOf := func(with mcat.Rendering) string {
t.Helper()
composed, err := Compose(anAdoptedAnchor(), with)
if err != nil {
t.Fatal(err)
}
content, _ := byID(composed.Resources)[GuardID()]["content"].(string)
return content
}
// The broker taken, the store not: the broker's plain port follows from its listens (from
// the mesh, published), its management port from its manifest; the store is not guarded, and
// neither is the bus, which the mesh needs from everywhere.
with := anchorRendering(true)
with.Taken = map[string]bool{"lavinmq": true}
if got := guardOf(with); got != AsGuard([]int{5672, 15672}) {
t.Fatalf("the guard is not the taken broker's ports:\n%s", got)
}
// A taken module publishing a port admitted from everywhere is not guarded; one admitted from
// the mesh is. The registry is exposed everywhere on this node, and hello-web listens from
// everywhere.
with.Taken = map[string]bool{"distribution": true, "hello-web": true}
if got := guardOf(with); got != "" {
t.Fatalf("a port admitted from everywhere is guarded:\n%s", got)
}
with.Settings = nil
if got := guardOf(with); got != AsGuard([]int{5000}) {
t.Fatalf("the registry, from the mesh only, is not guarded:\n%s", got)
}
// Nothing taken, nothing guarded — and no guard at all rather than an empty set.
with = anchorRendering(true)
with.Taken = nil
if got := guardOf(with); got != "" {
t.Fatalf("an untaken store is guarded:\n%s", got)
}
// A given port is followed: where the machine put it is what is refused.
with = anchorRendering(true)
with.Given = map[string]map[int]int{"lavinmq": {5672: 5682, 15672: 15673}}
with.Ports["lavinmq"] = map[int]int{5671: 5671, 5672: 5682}
if got := guardOf(with); got != AsGuard([]int{5432, 5682, 15673}) {
t.Fatalf("the guard does not follow the given ports:\n%s", got)
}
}
// novox/hq ADR 0038: only a published port is the mesh's to move. A module that binds the machine
// itself listens where its software was told to, so giving it a machine port is refused.
func TestAGivenPortIsRefusedForAPortNoContainerPublishes(t *testing.T) {
onTheMachine := mcat.Manifest{Module: "daemon",
Listens: []mcat.Listening{{Port: 9000, From: mcat.FromMesh}}, Guards: []int{9000}}
layers := []mcat.Layer{{From: "node anchor",
Placement: map[string]any{mcat.PortsSetting: map[string]any{"9000": float64(9100)}}}}
_, err := GivenPorts(onTheMachine, layers)
if err == nil || !strings.Contains(err.Error(), "does not publish") {
t.Fatalf("a port no container publishes was given: %v", err)
}
}
// novox/hq ADR 0103: a guarded port this node is told to open to everyone is opened, not guarded.
// An opening from everywhere beside a guard dropping it is one statement refusing the other.
func TestAGuardedPortOpenedToEveryoneIsNotGuarded(t *testing.T) {
with := anchorRendering(true)
with.Settings["postgres"] = []mcat.Layer{{From: "node anchor",
Placement: map[string]any{mcat.ExposeSetting: map[string]any{"5432": mcat.FromEverywhere}}}}
composed, err := Compose(anAdoptedAnchor(), with)
if err != nil {
t.Fatal(err)
}
got := byID(composed.Resources)
if o := got["adoption.opening-tcp-5432-forwarded"]; o == nil || o["from"] != mcat.OpeningFromEverywhere {
t.Fatalf("the store's port is not opened to everyone: %v", o)
}
if guard, _ := got[GuardID()]["content"].(string); guard != AsGuard([]int{5672, 15672}) {
t.Fatalf("a port opened to everyone is still guarded:\n%s", guard)
}
}
// A module that publishes its ssh port the long way — `2222:22`, because the machine's own daemon
// holds 22 — and says it listens on the machine side of that mapping, which is what anything
// reaching it dials.
func aForge() mcat.Manifest {
return mcat.Manifest{Module: "forge",
Provides: []mcat.Offer{{Name: "git-over-ssh", Scope: mcat.ScopeMesh}},
Serves: map[string]map[string]any{"git-over-ssh": {"port": 2222}},
Listens: []mcat.Listening{{Port: 3000, From: mcat.FromMesh}, {Port: 2222, From: mcat.FromMesh}},
Resources: []map[string]any{{"id": "server", "type": "container", "name": "forge",
"ports": []any{"3000", "2222:22"}}}}
}
// portsAsThePlanWould is where this machine puts each of a module's ports, derived the way
// cmd/mesh-controller/plan.go derives it: a given port first, looked up by the port the module
// says it listens on, and otherwise wherever the manifest's own mapping already put it. Written
// here because everything below — the filter, the openings, the guard, what a consumer is told —
// reads that map, and a given port that the lookup does not find moves the container's mapping
// and nothing else.
//
// It stands in for the plan only where the plan does not allocate: a port the manifest already
// placed, or one a node was given. For a short form with no given port the real plan asks the
// inventory for a machine port and may come back with one from the pool, which needs a store and
// is what cmd/mesh-controller's own tests exercise. So an assertion here about such a port asserts
// this helper, not the mesh; keep the assertions to the ports under test.
func portsAsThePlanWould(m mcat.Manifest, given map[int]int) map[int]int {
out := map[int]int{}
for _, l := range m.Listens {
if at, is := given[l.Port]; is {
out[l.Port] = at
continue
}
at, _ := m.MachineSide(l.Port)
out[l.Port] = at
}
return out
}
// novox/hq ADR 0100 and 0038: the machine side of a long-form mapping is a port the module
// publishes, so a node may move it — and a module may name a mapping by either end.
func TestAGivenPortNamesEitherEndOfWhatTheModulePublishes(t *testing.T) {
forge := aForge()
node := func(v any) []mcat.Layer {
return []mcat.Layer{{From: "node anchor", Placement: map[string]any{mcat.PortsSetting: v}}}
}
// The machine side — the number this module says it listens on, and the one the predecessor
// had somewhere else. Answered under 2222, the end the module itself names, which is what
// every reader of this map asks for. One entry, not two: a second key for the same answer is
// an entry another mapping's reader could find instead.
given, err := GivenPorts(forge, node(map[string]any{"2222": float64(222)}))
if err != nil {
t.Fatalf("the machine side of a mapping cannot be given a port: %v", err)
}
if want := map[int]int{2222: 222}; !reflect.DeepEqual(given, want) {
t.Fatalf("the forge was given %v; the mapping it names is filed under %v", given, want)
}
// The container's own port names the same mapping and means the same thing — and is filed
// under the same name, because the module's name for it has not changed.
if inside, err := GivenPorts(forge, node(map[string]any{"22": float64(222)})); err != nil ||
!reflect.DeepEqual(inside, map[int]int{2222: 222}) {
t.Fatalf("the container's end of the mapping was given %v: %v", inside, err)
}
// A short form is published on the number it names, and is unchanged by any of this.
if short, err := GivenPorts(forge, node(map[string]any{"3000": float64(2999)})); err != nil ||
short[3000] != 2999 {
t.Fatalf("the short form was given %v: %v", short, err)
}
// A port no container publishes is still refused, in the same words.
if _, err := GivenPorts(forge, node(map[string]any{"9000": float64(9100)})); err == nil ||
!strings.Contains(err.Error(), "does not publish") {
t.Fatalf("a port the forge does not publish was given: %v", err)
}
// And the two ends of one mapping given two different numbers is one setting contradicting
// the other: the machine publishes it once.
if _, err := GivenPorts(forge, node(map[string]any{
"2222": float64(222), "22": float64(300)})); err == nil ||
!strings.Contains(err.Error(), "one mapping") {
t.Fatalf("the two ends of one mapping were given different ports: %v", err)
}
// Said at both ends with the same number, it is still said twice, and refused where every
// other repeated machine port is — as the inventory refuses it when the setting is stored,
// which is the layer that sees it first.
if _, err := GivenPorts(forge, node(map[string]any{
"2222": float64(222), "22": float64(222)})); err == nil ||
!strings.Contains(err.Error(), "to both its 22 and its 2222") {
t.Fatalf("one mapping given one machine port at both ends: %v", err)
}
// A number that names two different mappings names neither: which one to move is not said.
twice := aForge()
twice.Resources[0]["ports"] = []any{"22", "2222:22"}
if _, err := GivenPorts(twice, node(map[string]any{"22": float64(222)})); err == nil ||
!strings.Contains(err.Error(), "twice") {
t.Fatalf("a number naming two of the module's mappings was accepted: %v", err)
}
// And the same refusal when the number naming two mappings is not the one the setting used
// but the one the answer would be filed under. Here `80` is the module's own name for a
// mapping, and two mappings wear it; filing an answer there is one container's port standing
// where the other's is read, and both containers then publish it.
shared := mcat.Manifest{Module: "gallery",
Listens: []mcat.Listening{{Port: 80, From: mcat.FromMesh}},
Resources: []map[string]any{
{"id": "a", "type": "container", "name": "a", "ports": []any{"4001:80"}},
{"id": "b", "type": "container", "name": "b", "ports": []any{"4002:80"}}}}
if _, err := GivenPorts(shared, node(map[string]any{"4001": float64(1234)})); err == nil ||
!strings.Contains(err.Error(), "twice") {
t.Fatalf("two containers were put on one machine port: %v", err)
}
// A module whose mappings chain — one's machine side is another's container port — keeps them
// apart, because each is filed under the port the module names it by and neither name is
// shared. Given both, each moves on its own and neither overwrites the other.
chained := mcat.Manifest{Module: "chain",
Listens: []mcat.Listening{{Port: 80, From: mcat.FromMesh}, {Port: 9090, From: mcat.FromMesh}},
Resources: []map[string]any{{"id": "server", "type": "container", "name": "chain",
"ports": []any{"8080:80", "9090:8080"}}}}
both, err := GivenPorts(chained, node(map[string]any{"80": float64(1234), "9090": float64(5678)}))
if err != nil || !reflect.DeepEqual(both, map[int]int{80: 1234, 9090: 5678}) {
t.Fatalf("chained mappings were given %v: %v", both, err)
}
if moved := givenOuter("8080:80", both); moved != "1234:80" {
t.Fatalf("the first mapping moved to %q, and it was given 1234", moved)
}
if moved := givenOuter("9090:8080", both); moved != "5678:8080" {
t.Fatalf("the second mapping moved to %q, and it was given 5678", moved)
}
}
// And the number reaches everything derived from it. The fault this is written against moved the
// container's mapping alone: the filter opened the port the software had left, the adopted node's
// opening named it too, the guard refused it, and a consumer was sent to it.
func TestAGivenMachineSideReachesTheFilterTheOpeningAndTheConsumer(t *testing.T) {
forge := aForge()
given, err := GivenPorts(forge, []mcat.Layer{{From: "node anchor",
Placement: map[string]any{mcat.PortsSetting: map[string]any{"2222": float64(222)}}}})
if err != nil {
t.Fatalf("the machine side of a mapping cannot be given a port: %v", err)
}
r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{forge}}
with := mcat.Rendering{
Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, given)},
Given: map[string]map[int]int{"forge": given},
Mesh: []string{"10.77.0.1"},
Adopted: true,
OutwardLinks: []string{"eth0"},
TunnelInterface: "mesh0",
Taken: map[string]bool{"forge": true},
}
// What the runtime is handed: the machine's own port on the outside, the container's within.
composed, err := Compose(r, with)
if err != nil {
t.Fatalf("the forge does not compose: %v", err)
}
got := byID(composed.Resources)
if ports := got["forge.server"]["ports"]; !reflect.DeepEqual(ports, []any{"3000:3000", "222:22"}) {
t.Fatalf("the forge's container publishes %v", ports)
}
// What the filter would open, were the node converged.
rules, err := Rules(r, with)
if err != nil {
t.Fatal(err)
}
var opened []int
for _, rule := range rules {
opened = append(opened, rule.Port)
}
// Only the moved port is asserted: the forge's other port is a short form the real plan would
// allocate rather than read off the manifest, so its number here is portsAsThePlanWould's and
// not the mesh's.
if !slices.Contains(opened, 222) || slices.Contains(opened, 2222) {
t.Fatalf("the filter opens %v, not where the machine puts the forge", opened)
}
// And what it is declared instead, adopted: an opening on the machine's port, naming the
// container's port on the forwarded path — a published port is forwarded, never received.
opening := got[OpeningID("tcp", 222, mcat.PathForwarded)]
if opening == nil || opening["to"] != 22 || opening["from"] != mcat.OpeningFromMesh {
t.Fatalf("no opening for the port this node gave the forge: %v", keys(got))
}
for id := range got {
if strings.HasPrefix(id, "adoption.opening-tcp-2222-") {
t.Errorf("an opening for the port the forge was moved off: %s", id)
}
}
// The guard refuses it where the machine put it, and nothing where it used to be.
guard, _ := got[GuardID()]["content"].(string)
if !strings.Contains(guard, "222") || strings.Contains(guard, "2222") {
t.Fatalf("the guard does not follow the given port:\n%s", guard)
}
// And a consumer is sent to the same number, which is read from what the module serves.
if told := ServedOn(forge, "git-over-ssh", with.Ports["forge"])["port"]; told != 222 {
t.Fatalf("a consumer is told the forge answers on %v", told)
}
}
// And the mapping itself moves under either name, because Rendering.Given is a map anybody
// composing a declaration hands in: keyed by the machine side, which is what a module declaring
// 2222 calls its port, only the outside moves and the container's own port stays as written.
func TestAMappingIsMovedUnderEitherOfItsNames(t *testing.T) {
for _, c := range []struct {
written string
given map[int]int
want string
}{
{"2222:22", map[int]int{2222: 222}, "222:22"},
{"2222:22", map[int]int{22: 222}, "222:22"},
{"127.0.0.1:15672:15672/tcp", map[int]int{15672: 15673}, "127.0.0.1:15673:15672/tcp"},
{"2222:22", map[int]int{3000: 2999}, "2222:22"},
{"2222:22", nil, "2222:22"},
} {
if got := givenOuter(c.written, c.given); got != c.want {
t.Errorf("%s given %v is published as %s, want %s", c.written, c.given, got, c.want)
}
}
}
// The same on a node that was given nothing, which is where the derivation was never at fault:
// a long-form mapping is published where the manifest put it, and an adopted node opens that port
// on the forwarded path like any other. What broke it was the number reaching only the mapping.
func TestALongFormPortIsOpenedWhereTheManifestPublishesIt(t *testing.T) {
forge := aForge()
r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{forge}}
composed, err := Compose(r, mcat.Rendering{
Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, nil)},
Mesh: []string{"10.77.0.1"},
Adopted: true,
OutwardLinks: []string{"eth0"},
TunnelInterface: "mesh0",
})
if err != nil {
t.Fatal(err)
}
got := byID(composed.Resources)
opening := got[OpeningID("tcp", 2222, mcat.PathForwarded)]
if opening == nil || opening["to"] != 22 {
t.Fatalf("no opening for the forge's published ssh port: %v", keys(got))
}
}
@@ -0,0 +1,83 @@
package resolution
import (
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
func TestTheAgentAccountFactFallsBackToTheOperatorAndIsNeverRootOnlyWhenItsOwn(t *testing.T) {
facts := machineFacts(mcat.Resolution{Node: "anchor", Account: "ops"}, nil, "")
if facts["agent-account"] != "ops" || facts["agent-home"] != "/home/ops" || facts["agent-root"] != "" {
t.Errorf("with no agent account named, agents run as the operator: %v", facts)
}
facts = machineFacts(mcat.Resolution{Node: "anchor", Account: "ops", AccountHome: "/srv/ops"}, nil, "")
if facts["agent-home"] != "/srv/ops" {
t.Errorf("the operator's stated home is the agent's home when they are one account: %v", facts)
}
facts = machineFacts(mcat.Resolution{Node: "anchor", Account: "ops", AgentAccount: "agent"}, nil, "")
if facts["agent-account"] != "agent" || facts["agent-home"] != "/home/agent" || facts["agent-root"] != mcat.RootNever {
t.Errorf("a named agent account is the agents', never root: %v", facts)
}
if facts["account"] != "ops" {
t.Errorf("the operator account is still the operator's: %v", facts)
}
facts = machineFacts(mcat.Resolution{Node: "anchor", AgentAccount: "agent", AgentAccountHome: "/var/lib/agent"}, nil, "")
if facts["agent-home"] != "/var/lib/agent" || facts["agent-root"] != mcat.RootNever {
t.Errorf("an agent account with a stated home on a machine with no operator: %v", facts)
}
if _, has := machineFacts(mcat.Resolution{Node: "anchor"}, nil, "")["agent-account"]; has {
t.Error("a machine with no account at all names an agent account")
}
}
// The agent's module, in the shape the catalogue's declares it: the account, never root where it is its
// own; its directory under that home, owned by it.
const agentModule = `{"module": "agent", "version": "1", "resources": [
{"id": "account", "type": "user", "name": "${machine:agent-account}", "home": "${machine:agent-home}",
"root": "${machine:agent-root}"},
{"id": "home", "type": "directory", "path": "${machine:agent-home}/.agent", "mode": "0700",
"owner": "${machine:agent-account}"}
]}`
func TestTheAgentAccountIsDeclaredNeverRootOnlyToAnEngineThatJudgesIt(t *testing.T) {
m, err := decodeForTest([]byte(agentModule))
if err != nil {
t.Fatal(err)
}
compose := func(r mcat.Resolution, with mcat.Rendering) (user, home map[string]any) {
t.Helper()
r.Node, r.Modules = "anchor", []mcat.Manifest{m}
out, err := Declaration(r, with)
if err != nil {
t.Fatal(err)
}
return fileNamed(out, "agent.account"), fileNamed(out, "agent.home")
}
user, home := compose(mcat.Resolution{Account: "ops", AgentAccount: "agent"}, mcat.Rendering{JudgesRoot: true})
if user["name"] != "agent" || user[mcat.RootField] != mcat.RootNever || user["home"] != "/home/agent" {
t.Errorf("an engine that judges root is sent the agent account never to become root: %v", user)
}
if home["path"] != "/home/agent/.agent" || home["owner"] != "agent" {
t.Errorf("the agent's directory is under its own home, its own: %v", home)
}
user, _ = compose(mcat.Resolution{Account: "ops", AgentAccount: "agent", AgentAccountHome: "/srv/agent"}, mcat.Rendering{JudgesRoot: true})
if user["home"] != "/srv/agent" {
t.Errorf("an agent account named with a home of its own is made there: %v", user)
}
user, _ = compose(mcat.Resolution{Account: "ops", AgentAccount: "agent"}, mcat.Rendering{})
if _, sent := user[mcat.RootField]; sent || user["name"] != "agent" {
t.Errorf("an older engine, which parses strictly, is sent root: %v", user)
}
user, home = compose(mcat.Resolution{Account: "ops"}, mcat.Rendering{JudgesRoot: true})
if _, sent := user[mcat.RootField]; sent || user["name"] != "ops" {
t.Errorf("where agents run as the operator, root asserts nothing and is not sent: %v", user)
}
if home["path"] != "/home/ops/.agent" || home["owner"] != "ops" {
t.Errorf("with no agent account, the agent's directory is the operator's: %v", home)
}
}
@@ -0,0 +1,40 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// The artifact store's seat is one per mesh, read from the catalogue beside this checkout.
//
// **A second store anywhere is refused by name, not discovered as a consumer failure.** The seat
// was node-scoped, so a second `distribution` on another machine resolved cleanly there — and a
// node-scoped requirement with one candidate installs that candidate on the node, so anything that
// required the store's presence beside it would have raised a fresh, empty store on the wrong
// machine. Only afterwards did the mesh notice: `artifact-store` offered by two nodes, and every
// consumer elsewhere refusing to choose. The claim says it first, where the second store is
// assigned.
func TestASecondArtifactStoreAnywhereIsRefusedByName(t *testing.T) {
store := catalogueManifest(t, "distribution")
// The first store resolves as it always has.
if _, err := Resolve(shelf(store), []string{"distribution"}, workstation(), mcat.World{}); err != nil {
t.Fatalf("the store alone does not resolve: %v", err)
}
// A second one, on any other machine, is refused — and the refusal names the seat.
elsewhere := mcat.World{Held: []mcat.Held{{Claim: "mesh-artifact-store", Scope: mcat.ScopeMesh,
Node: "anchor", Module: "distribution"}}}
other := workstation()
other.Name = "laptop"
_, err := Resolve(shelf(store), []string{"distribution"}, other, elsewhere)
if err == nil {
t.Fatal("a second store was accepted on another machine; it would offer artifact-store a " +
"second time and every consumer elsewhere would refuse to choose")
}
if !strings.Contains(err.Error(), "mesh-artifact-store") || !strings.Contains(err.Error(), "one per mesh") {
t.Fatalf("refused without naming the seat: %v", err)
}
}
@@ -0,0 +1,43 @@
package resolution
import (
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// artifactSweepVerbs are the seat's verbs, with the controller's arguments so a caller changes only the seat it asks.
func artifactSweepVerbs() []mcat.Verb {
return []mcat.Verb{
{Name: "artifacts", Description: "Every artifact the mesh made that lives in its artifact store, each with its " +
"repository, digest, kind (image or archive) and state: kept, and why (a want names it, or one of the five " +
"newest builds of its module in the builder's log), eligible (made, kept for no reason, not yet let go), or " +
"unrecorded (in the store and named by nothing the mesh made: never removed). Reads only.",
Input: schema(map[string]string{
"repository": "only this repository's references, as <module>/<artifact> (the counts stay whole)",
"collected": "\"true\": list the collected references too (counted always)",
}, nil, "collected")},
{Name: "held", Description: "Whether the artifact store holds each reference given, asked with one HEAD each: " +
"held, gone (the store answered 404) or unknown with why. The builder asks it before it reuses a done key. " +
"Reads only.",
Input: listed(schema(map[string]string{
"references": "the artifacts' references, artifact-store://<module>/<artifact>/blobs/sha256:<hex>: a list, " +
"or one text separated by commas",
}, []string{"references"}), "references")},
{Name: "collect", Description: "The artifact store's sweep, now: what is eligible is let go of. Without confirm a " +
"dry run: what would be let go, nothing held or deleted. With confirm and why: every kept archive held first, " +
"then each eligible artifact let go of, oldest first, an index with the platform manifests no kept index " +
"names, each deletion recorded in the hand-act log. Bounded by most (500 by default, at most 5000) and 45 " +
"seconds; what is left is said. Never touches a digest the mesh did not make.",
Input: schema(map[string]string{
"confirm": "\"true\": let go of what is eligible (needs why); without it nothing is done",
"why": "with confirm: why, required and recorded",
"most": "let go of at most this many (500 by default, at most 5000)",
}, nil, "confirm")},
{Name: "mirrors", Description: "The bases the mesh copied into its artifact store, each kept (a kept build stood on " +
"it), eligible (only builds no longer kept stood on it) or unrecorded (no build the builder's log holds stood on " +
"it: never removed), by what the builder's log says each build stood on. Records nothing by hand.",
Input: schema(map[string]string{}, nil)},
{Name: "images", Description: "Every container image one machine's want and have name, each with the resources " +
"naming it: what the machine keeps when it prunes its images. Reads only.",
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})},
}
}
+65
View File
@@ -0,0 +1,65 @@
package resolution
import (
"fmt"
"strings"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// artifactsInto composes the artifact store's address into a resource's `image` and `source`.
//
// **Composed here, at the last moment before a machine, and stored nowhere.** A kept reference is
// routed through the store as this network reaches it now. A reference recorded with an address
// before references were kept without one is re-routed the same way — but only when the mesh
// built it (`with.Built` names every `<module>/<artifact>` it has), because a module may run an
// image from a public registry under its own name and that one is exactly where it says.
//
// A kept reference with no store to route it through is refused: sent as it is, the runtime would
// refuse the scheme on the machine, one push away from the reason.
//
// **What this does not reach: the images genesis pinned.** The installer builds the control plane
// and the builder before the mesh exists, pushes them itself and pins their manifests to
// `<registry>:<port>/mesh-controller@…` and `<registry>:<port>/mesh-builder@…` — single-segment
// repositories with no build record, so `with.Built` does not name them and they are left as
// written until each is rebuilt through the mesh, which records it by digest and path. Until then
// a registry that moves strands exactly those two on a recreate, and the control plane's is the
// one that cannot be repaired through the mesh. Rebuild both through `build` before moving the
// store (novox/hq 04-ISSUES/102, finding F4).
func artifactsInto(resource map[string]any, module string, with mcat.Rendering) error {
for _, key := range []string{"image", "source"} {
written, ok := resource[key].(string)
if !ok {
continue
}
if _, kept := mcat.InArtifactStore(written); kept {
if with.ArtifactStore == "" {
return fmt.Errorf(
"%s's %v names %s, which is in the mesh's artifact store, and this mesh has no "+
"artifact store on its network to fetch it from — nothing assigned offers "+
"%s, or the machine offering it is not on the private network",
module, resource["id"], written, mcat.ArtifactStoreProvision)
}
resource[key] = mcat.Routed(written, with.ArtifactStore)
continue
}
if with.ArtifactStore == "" {
continue
}
recorded := mcat.Recorded(written)
if recorded == written {
continue
}
path, _ := mcat.InArtifactStore(recorded)
repository := path
if at := strings.IndexAny(path, "@"); at >= 0 {
repository = path[:at]
} else if blobs := strings.Index(path, "/blobs/"); blobs >= 0 {
repository = path[:blobs]
}
if with.Built[repository] {
resource[key] = mcat.Routed(recorded, with.ArtifactStore)
}
}
return nil
}
@@ -0,0 +1,102 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
var digest = "sha256:" + strings.Repeat("d", 64)
// **The address is composed into a declaration, and the record never carries it.**
func TestAnImageTheMeshBuiltIsRoutedThroughTheStoreWhenDeclared(t *testing.T) {
m := mcat.Manifest{Module: "gitea", Version: "1", Resources: []map[string]any{
{"id": "server", "type": "container", "name": "mesh-gitea", "artifact": "server"},
{"id": "config", "type": "archive", "path": "/etc/gitea", "artifact": "config"},
{"id": "cache", "type": "container", "name": "mesh-gitea-cache", "image": "valkey/valkey@" + digest},
}, Build: &mcat.Build{Artifacts: []mcat.Artifact{
{Name: "server", Kind: mcat.ArtifactImage, From: "Dockerfile"},
{Name: "config", Kind: mcat.ArtifactArchive, From: "config"},
}}}
resolved, err := m.Resolve([]mcat.Built{
{Name: "server", Kind: mcat.ArtifactImage, Reference: mcat.ArtifactStoreScheme + "gitea/server@" + digest},
{Name: "config", Kind: mcat.ArtifactArchive, Reference: mcat.ArtifactStoreScheme + "gitea/config/blobs/" + digest, Digest: digest},
})
if err != nil {
t.Fatal(err)
}
r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{resolved}}
out, err := Declaration(r, mcat.Rendering{ArtifactStore: "anchor.internal:5101"})
if err != nil {
t.Fatal(err)
}
if got := fileNamed(out, "gitea.server")["image"]; got != "anchor.internal:5101/gitea/server@"+digest {
t.Errorf("the image the mesh built is fetched as %v", got)
}
if got := fileNamed(out, "gitea.config")["source"]; got != "http://anchor.internal:5101/v2/gitea/config/blobs/"+digest {
t.Errorf("the archive the mesh built is fetched from %v", got)
}
if got := fileNamed(out, "gitea.cache")["image"]; got != "valkey/valkey@"+digest {
t.Errorf("an image from a public registry was routed through the store: %v", got)
}
// The manifest the mesh holds still says what it is, not where it was fetched from.
if got := resolved.Resources[0]["image"]; got != mcat.ArtifactStoreScheme+"gitea/server@"+digest {
t.Errorf("composing wrote the address into the catalogue's copy: %v", got)
}
// And the store moves: the same record, another address, without a rebuild.
out, err = Declaration(r, mcat.Rendering{ArtifactStore: "laptop.internal:5000"})
if err != nil {
t.Fatal(err)
}
if got := fileNamed(out, "gitea.server")["image"]; got != "laptop.internal:5000/gitea/server@"+digest {
t.Errorf("after the store moved, the image is still fetched as %v", got)
}
}
func TestAnImageInTheStoreWithNoStoreToFetchItFromIsRefused(t *testing.T) {
m := mcat.Manifest{Module: "gitea", Version: "1", Resources: []map[string]any{
{"id": "server", "type": "container", "name": "mesh-gitea",
"image": mcat.ArtifactStoreScheme + "gitea/server@" + digest},
}}
_, err := Declaration(mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{m}}, mcat.Rendering{})
if err == nil || !strings.Contains(err.Error(), "no artifact store") {
t.Fatalf("a reference nothing can fetch was sent to a machine: %v", err)
}
}
// **A reference recorded with an address before this follows the store too** — when the mesh
// built it. A module running an image straight from a public registry under its own name is left
// exactly where it says: `quay.io/keycloak/keycloak` is not the mesh's, whatever it is called.
func TestAReferenceRecordedWithAnAddressFollowsTheStoreWhenTheMeshBuiltIt(t *testing.T) {
m := mcat.Manifest{Module: "keycloak", Version: "1", Resources: []map[string]any{
{"id": "server", "type": "container", "name": "mesh-keycloak",
"image": "anchor.internal:5100/keycloak/server@" + digest},
{"id": "upstream", "type": "container", "name": "mesh-keycloak-upstream",
"image": "quay.io/keycloak/keycloak@" + digest},
}}
r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{m}}
out, err := Declaration(r, mcat.Rendering{
ArtifactStore: "anchor.internal:5101",
Built: map[string]bool{"keycloak/server": true},
})
if err != nil {
t.Fatal(err)
}
if got := fileNamed(out, "keycloak.server")["image"]; got != "anchor.internal:5101/keycloak/server@"+digest {
t.Errorf("an image the mesh built, recorded with the old address, is fetched as %v", got)
}
if got := fileNamed(out, "keycloak.upstream")["image"]; got != "quay.io/keycloak/keycloak@"+digest {
t.Errorf("a public image was re-routed through the store: %v", got)
}
// Nothing known to be built: nothing re-routed, nothing refused.
out, err = Declaration(r, mcat.Rendering{ArtifactStore: "anchor.internal:5101"})
if err != nil {
t.Fatal(err)
}
if got := fileNamed(out, "keycloak.server")["image"]; got != "anchor.internal:5100/keycloak/server@"+digest {
t.Errorf("with no build record, a reference was rewritten: %v", got)
}
}
@@ -0,0 +1,46 @@
package resolution
import (
"fmt"
"regexp"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// ofBackup is where a holder places them: ${backup:<key>}. Loose inside the braces, so a misspelt key is found and
// refused rather than written out as text.
var ofBackup = regexp.MustCompile(`\$\{backup:([^}]*)\}`)
// backupKinds is the contribution kind each key of the backup placement renders.
var backupKinds = map[string]string{"lines": mcat.BackupKindBackup, "data": mcat.BackupKindData}
// backupPlacer is the machine's modules the backup placement is composed over, as the old contributions are.
type backupPlacer struct {
modules []mcat.Manifest
with mcat.Rendering
facts map[string]string
caps map[string]bool
unplaced *[]string
}
// placed is the text of one key for a holder, or what is wrong with naming it.
func (b backupPlacer) placed(holder mcat.Manifest, key string, item bool) (string, string) {
kind, known := backupKinds[key]
switch {
case item:
return "", "an item is kept per item; the machine's backup lines are placed only in a file"
case !known:
return "", fmt.Sprintf("the backup placement has `lines` and `data`, not %q", key)
case !holder.ClaimsSeat(mcat.BackupSeat):
return "", fmt.Sprintf("%s does not claim %s; the machine's backup lines are placed by its holder alone "+
"(novox/hq ADR 0212)", holder.Module, mcat.BackupSeat)
}
text, left, err := seatContributions(b.modules, holder, mcat.BackupSeat+":"+kind, b.with, b.facts, b.caps)
if b.unplaced != nil {
*b.unplaced = append(*b.unplaced, left...)
}
if err != nil {
return "", err.Error()
}
return text, ""
}
@@ -0,0 +1,114 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
const oldBackupText = "# lines\n${contribution:node-backup:backup}"
const oldDataText = "# data\n${contribution:node-backup:data}"
const placedBackupTxt = "# lines\n${backup:lines}"
const placedDataTxt = "# data\n${backup:data}"
const backupHolder = `{"module":"restic","version":"1","moved":true,"claims":[{"name":"node-backup","scope":"node"}],
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
"resources":[{"id":"declared","type":"file","path":"/etc/backups.conf","template":"files/backups"},
{"id":"data-declared","type":"file","path":"/etc/data.conf","template":"files/data"}]}`
func dataModule(name string) string {
return `{"module":"` + name + `","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"}],
"data":{"own":[{"id":"d","path":"${dir:d}","class":"irreplaceable","why":"the ` + name + ` data"}]}}`
}
// backupsOn is a machine of a holder whose files say these texts and the modules that declare data.
func backupsOn(t *testing.T, lines, data string, modules ...string) mcat.Resolution {
t.Helper()
all := []mcat.Manifest{held(t, backupHolder, builtWith("restic", map[string]string{"backups": lines, "data": data}))}
names := []string{"restic"}
for _, name := range modules {
all = append(all, mustParse(t, dataModule(name)))
names = append(names, name)
}
r, err := Resolve(shelf(all...), names, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
r.Account = "op"
return r
}
var backupModules = []string{"alpha", "beta", "gamma"}
// gammaLeftOut is a machine whose gamma does not place: its lines are kept, best effort.
func gammaLeftOut(with mcat.Rendering) mcat.Rendering {
with.NotPlaced = map[string]string{"gamma": "its placement setting does not read"}
return with
}
func TestTheBackupPlacementIsTheTextTheOldPlaceholdersRender(t *testing.T) {
// The old form is an unmoved holder: its placeholders are the controller's alone.
oldHolder := strings.Replace(backupHolder, `"moved":true,`, "", 1)
all := []mcat.Manifest{held(t, oldHolder, builtWith("restic", map[string]string{"backups": oldBackupText, "data": oldDataText}))}
for _, name := range backupModules {
all = append(all, mustParse(t, dataModule(name)))
}
old, err := Resolve(shelf(all...), append([]string{"restic"}, backupModules...), workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
old.Account = "op"
wantLines, wantData := fileOf(t, old, gammaLeftOut(mcat.Rendering{}), "restic.declared"), fileOf(t, old, gammaLeftOut(mcat.Rendering{}), "restic.data-declared")
if !strings.Contains(wantLines, "# alpha\npath ") || !strings.Contains(wantLines, "# gamma\n") || !strings.Contains(wantData, "item d") {
t.Fatalf("the old form renders\n%s\n%s", wantLines, wantData)
}
moved := backupsOn(t, placedBackupTxt, placedDataTxt, backupModules...)
// Not handed: the controller fills the new placeholders with the old text.
if got := fileOf(t, moved, gammaLeftOut(mcat.Rendering{}), "restic.declared"); got != wantLines {
t.Errorf("not handed, the lines are\n%s\nwant\n%s", got, wantLines)
}
if got := fileOf(t, moved, gammaLeftOut(mcat.Rendering{}), "restic.data-declared"); got != wantData {
t.Errorf("not handed, the data is\n%s\nwant\n%s", got, wantData)
}
// Handed: the plan carries the same text as placement, and the files are the client's.
c, _ := composeWith(t, moved, gammaLeftOut(handingTo("store:5000")))
p := c.Plans["restic"].Placement
if got := "# lines\n" + p["backup:lines"]; got != wantLines {
t.Errorf("handed, the plan's lines are\n%s\nwant\n%s", got, wantLines)
}
if got := "# data\n" + p["backup:data"]; got != wantData {
t.Errorf("handed, the plan's data is\n%s\nwant\n%s", got, wantData)
}
if len(p) != 2 {
t.Errorf("the plan's placement is %v", p)
}
}
func TestAnUnknownBackupKeyIsRefused(t *testing.T) {
r := backupsOn(t, "${backup:rows}", placedDataTxt, backupModules...)
if _, err := Compose(r, handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "backup:rows") {
t.Errorf("handed, an unknown key is %v", err)
}
if _, err := Declaration(r, mcat.Rendering{}); err == nil || !strings.Contains(err.Error(), "backup:rows") {
t.Errorf("not handed, an unknown key is %v", err)
}
}
func fileOf(t *testing.T, r mcat.Resolution, with mcat.Rendering, id string) string {
t.Helper()
out, err := Declaration(r, with)
if err != nil {
t.Fatal(err)
}
for _, res := range out {
if res["id"] == id {
return res["content"].(string)
}
}
t.Fatalf("%s was not composed", id)
return ""
}
@@ -0,0 +1,37 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// A module on the shelf from before ADR 0233, which writes its backup lines by hand and declares no
// data, is still backed up: its lines are placed as written, each module's under a comment naming it.
func TestHandWrittenBackupLinesOfAnOlderModuleArePlaced(t *testing.T) {
pg, err := decodeForTest([]byte(`{"module":"pg","version":"1",
"resources":[{"id":"dumps","type":"directory","path":"/srv/pg/dumps","mode":"0700"}],
"contributions":[{"seat":"node-backup","kind":"backup","content":"run pg-dump-all\npath ${dir:dumps}"}]}`))
if err != nil {
t.Fatal(err)
}
mail, err := decodeForTest([]byte(`{"module":"mail","version":"1",
"resources":[{"id":"spool","type":"directory","mode":"0700"}],
"contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:spool}"}]}`))
if err != nil {
t.Fatal(err)
}
placed, _, err := seatContributions([]mcat.Manifest{pg, mail}, mcat.Manifest{}, mcat.BackupSeat+":"+"backup", mcat.Rendering{}, nil, nil)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"# pg\nrun pg-dump-all\npath /srv/pg/dumps\n", "# mail\npath /var/lib/mail/spool\n"} {
if !strings.Contains(placed, want) {
t.Errorf("placed contributions lack %q:\n%s", want, placed)
}
}
if strings.Contains(placed, "${dir:") {
t.Errorf("a directory reached the holder unfilled:\n%s", placed)
}
}
+117
View File
@@ -0,0 +1,117 @@
package resolution
import (
"fmt"
"slices"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// keepBound holds every need for a provision that keeps its consumers' data at the provider its
// consumer was bound to, where the resolution chose another.
//
// A need with no record is a binding being made, and is left as resolved: it is recorded when it is
// first sent. A pin naming the provider the resolution chose is a person moving it, and is left too.
// Otherwise the recorded provider answers, if it still provides the provision — beside the consumer,
// or offered from elsewhere — and the move is returned as kept. **One that no longer does is refused,
// never answered by the provider chosen**: answering it there is exactly the silent move this exists
// to stop, and the consumer's data is still wherever it was.
func keepBound(needs []mcat.Needed, catalogue map[string]mcat.Manifest, node mcat.Node, world mcat.World,
keeps map[string]bool, here func(string) bool) ([]mcat.Needed, []mcat.KeptBinding, []string) {
var kept []mcat.KeptBinding
var problems []string
refused := map[[2]string]bool{}
out := make([]mcat.Needed, 0, len(needs))
for _, n := range needs {
if n.ByRecord || !keeps[n.Name] {
out = append(out, n)
continue
}
n.KeepsData = true
bound, recorded := world.Bound[n.For][n.Name]
chose := mcat.Chosen{Node: n.From, Module: n.Module}
if !recorded || sameProvider(bound, chose) {
out = append(out, n)
continue
}
if pin, pinned := world.Pinned[n.Name]; pinned && chosenMatches(pin, mcat.Provider{Node: chose.Node, Module: chose.Module}) {
out = append(out, n)
continue
}
held, ok, why := boundNeed(n, bound, catalogue, node, world, here)
if !ok {
if key := [2]string{n.For, n.Name}; !refused[key] {
refused[key] = true
problems = append(problems, fmt.Sprintf(
"%s on %s is bound to %s for %q, which keeps its data, and %s — it would move to %s, "+
"which holds none of it. Move its data and say so with `pin %s %s %s %s`, or give "+
"%s back what it provided",
n.For, node.Name, bound, n.Name, why, chose, node.Name, n.Name, chose.Node, chose.Module,
bound.Node))
}
continue
}
out = append(out, held)
kept = append(kept, mcat.KeptBinding{Machine: node.Name, Consumer: n.For, Provision: n.Name, Bound: bound, Would: chose})
}
return out, kept, problems
}
// sameProvider is whether a recorded provider is the one chosen. A record naming no module (none
// is written without one, but a person's hand might) matches any module on its node.
func sameProvider(bound, chose mcat.Chosen) bool {
return bound.Node == chose.Node && (bound.Module == "" || bound.Module == chose.Module)
}
// boundNeed is the need answered by the recorded provider, or why it cannot be.
func boundNeed(n mcat.Needed, bound mcat.Chosen, catalogue map[string]mcat.Manifest, node mcat.Node, world mcat.World,
here func(string) bool) (mcat.Needed, bool, string) {
held := mcat.Needed{Name: n.Name, For: n.For, Local: n.Local, KeepsData: true}
if bound.Node == node.Name {
m, known := catalogue[bound.Module]
if !known || !here(bound.Module) || !providesAt(m, n.Name, mcat.ScopeMesh) {
return mcat.Needed{}, false, fmt.Sprintf("%s no longer runs on %s", bound.Module, node.Name)
}
at := node.At
if at == "" {
at = "127.0.0.1"
}
held.From, held.At, held.Module = node.Name, at, m.Module
held.Serves, held.SharedOwn, held.Identity = servedByOne(m, n.Name), sharedByOne(m, n.Name), m.IdentityBoundOf(n.Name)
held.CredentialForm, held.CredentialTool = m.CredentialFormOf(n.Name), m.CredentialToolOf(n.Name)
return held, true, ""
}
matching := chosenAmong(bound, world.Offered[n.Name])
if len(matching) != 1 {
return mcat.Needed{}, false, fmt.Sprintf("%s no longer provides it", bound)
}
p := matching[0]
if node.At == "" || p.At == "" {
return mcat.Needed{}, false, fmt.Sprintf("%s and %s are not both on the private network", node.Name, p.Node)
}
identity := mcat.DefaultIdentityBound
if pm, known := catalogue[p.Module]; known {
held.SharedOwn, _ = pm.SharedCredentialOf(n.Name)
held.CredentialForm, held.CredentialTool = pm.CredentialFormOf(n.Name), pm.CredentialToolOf(n.Name)
identity = pm.IdentityBoundOf(n.Name)
}
held.From, held.At, held.Module, held.Serves, held.Identity = p.Node, p.At, p.Module, p.Serves, identity
held.LeftOut = p.LeftOut
return held, true, ""
}
// BindsFrom is the providers this resolution binds a pair credential's need to — the provision, the
// module that requires it and the credential's local name — answered by a machine rather than by a
// record. None means this machine states no such binding.
func BindsFrom(r mcat.Resolution, provision, module, local string) []string {
var from []string
for _, n := range r.Needs {
if n.ByRecord || n.Name != provision || n.For != module || n.Local != local {
continue
}
if !slices.Contains(from, n.From) {
from = append(from, n.From)
}
}
return from
}
@@ -0,0 +1,176 @@
package resolution
import (
"fmt"
"regexp"
"sort"
"strings"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// bound is where a module says a value from one of its bindings belongs:
// ${bound:<provision>:<key>}, and for an optional requirement the value it takes while absent,
// ${bound:<provision>:<key>|default:<value>} (novox/hq ADR 0330 §4). A default is never empty: an
// absent requirement is never rendered as an empty string.
var bound = regexp.MustCompile(`\$\{bound:([a-z0-9][a-z0-9.-]*[a-z0-9]):([a-z0-9][a-z0-9_-]*)(?:\|default:([^}\n]+))?\}`)
// knownFor is everything a module may name from one of its bindings.
//
// Three facts the mesh states about any provision, plus whatever the provider said it serves. A
// module may not reach a binding it does not have — the same boundary as a secret, for the same
// reason.
func knownFor(m mcat.Manifest, needs []mcat.Needed, node string) (map[string]map[string]string, error) {
out := map[string]map[string]string{}
for _, want := range m.Wants() {
for i := range needs {
n := needs[i]
if n.Name != want || n.For != m.Module {
continue
}
as := mcat.ConsumerIdentity(node, mcat.IdentitySource(m.Slug, m.Module))
values := map[string]string{
"at": n.At,
"from": n.From,
"as": as,
}
// What the provider derives for this consumer rather than for all of them
// (novox/hq ADR 0202). Filled here, the one place a provision and the module
// requiring it are both in hand.
served, err := ServedTo(n.Serves, as)
if err != nil {
return nil, fmt.Errorf("%s requires %s: %w", m.Module, want, err)
}
for key, value := range served {
// The provider's own vocabulary. Rendered plainly: a port is 5432, not 5432.000000,
// which is what a float would write and what a connection string would refuse.
values[key] = plainly(value)
}
out[want] = values
}
}
return out, nil
}
// withOwnNames adds a module's own composed names to what it may name from one binding:
// `${bound:<provision>:name}` and `:internal-name`, and for several contributions to one requirement
// `:name-<local>` / `:internal-name-<local>`. Set over anything the provider serves under those keys:
// what the module is called is the mesh's statement, not the provider's.
func withOwnNames(values map[string]string, own map[string]any) {
for _, key := range []string{"name", "internal-name"} {
if v, ok := own[key].(string); ok {
values[key] = v
}
}
many, _ := own["names"].(map[string]any)
for local, raw := range many {
names, _ := raw.(map[string]any)
for _, key := range []string{"name", "internal-name"} {
if v, ok := names[key].(string); ok {
values[key+"-"+local] = v
}
}
}
}
// plainly renders a served value as a program would expect to read it.
func plainly(value any) string {
switch v := value.(type) {
case string:
return v
case float64:
if v == float64(int64(v)) {
return fmt.Sprintf("%d", int64(v))
}
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%f", v), "0"), ".")
case bool:
return fmt.Sprintf("%t", v)
case nil:
return ""
default:
return fmt.Sprint(v)
}
}
// boundInto replaces a file's ${bound:…} placeholders with what the mesh knows.
//
// A placeholder naming something the module does not require, or a key the provider does not
// serve, is refused. Left as it was, the literal `${bound:x:y}` would be written into a
// configuration file and read as a value — a connection to a host called `${bound:x:y}`, failing
// somewhere that names neither the module nor the mesh.
//
// **An optional requirement left absent takes the default its placeholder names** (novox/hq ADR
// 0330 §4), and one that names none is refused rather than rendered empty. The module check refuses
// that already; this is the same rule where the file is made, for a manifest registered before it.
func boundInto(resource map[string]any, known map[string]map[string]string, module string, absent map[string]bool) error {
if fmt.Sprint(resource["type"]) != "file" {
return nil
}
content, ok := resource["content"].(string)
if !ok {
return nil
}
var refused error
filled := bound.ReplaceAllStringFunc(content, func(token string) string {
if refused != nil {
return token
}
m := bound.FindStringSubmatch(token)
provision, key, fallback := m[1], m[2], m[3]
values, has := known[provision]
if !has {
if absent[provision] {
if fallback != "" {
return fallback
}
refused = fmt.Errorf(
"%s has a file that says ${bound:%s:%s}, and %q is optional and absent here: it names no "+
"value for then (${bound:%s:%s|default:<value>}), so it would be written empty "+
"(novox/hq ADR 0330 §4)", module, provision, key, provision, provision, key)
return token
}
refused = fmt.Errorf(
"%s has a file that says ${bound:%s:%s}, and %s does not require %q. It may name %s",
module, provision, key, module, provision, orNothing(namesOfBindings(known)))
return token
}
value, said := values[key]
if !said {
refused = fmt.Errorf(
"%s asks its %s binding for %q, and what answers it says %s",
module, provision, key, orNothing(namesOfKeys(values)))
return token
}
return value
})
if refused != nil {
return refused
}
resource["content"] = filled
return nil
}
func namesOfBindings(known map[string]map[string]string) []string {
var names []string
for name := range known {
names = append(names, fmt.Sprintf("%q", name))
}
sort.Strings(names)
return names
}
func namesOfKeys(values map[string]string) []string {
var names []string
for key := range values {
names = append(names, fmt.Sprintf("%q", key))
}
sort.Strings(names)
return names
}
func orNothing(names []string) string {
if len(names) == 0 {
return "nothing"
}
return join(names)
}
@@ -0,0 +1,246 @@
package resolution
import (
"fmt"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// A consumer writes its own connection string (novox/hq 04-ISSUES/023).
//
// Everything needed for one now reaches the file that needs it: the address and port from what the
// provider serves, the name to present from what the mesh decided, the database name from what the
// module itself contributed, and the password through the sealed hole the host fills.
func TestAConsumerCanWriteAConnectionString(t *testing.T) {
r := mcat.Resolution{
Node: "workstation",
Modules: []mcat.Manifest{{
Module: "keycloak",
Requires: []string{"postgres-database"},
Secrets: map[string]string{"postgres-database": "/var/lib/keycloak/db.secret"},
Resources: []map[string]any{{
"id": "dbenv", "type": "file", "path": "/var/lib/keycloak/database.env",
"mode": "0600",
"content": "KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:" +
"${bound:postgres-database:port}/keycloak\n" +
"KC_DB_USERNAME=${bound:postgres-database:as}\n" +
"KC_DB_PASSWORD=${secret:postgres-database}\n",
}},
}},
Needs: []mcat.Needed{{
Name: "postgres-database", From: "anchor", At: "anchor.internal", For: "keycloak",
Sealed: "sealed-db", Serves: map[string]any{"port": float64(5432)},
}},
}
out, err := Declaration(r, mcat.Rendering{})
if err != nil {
t.Fatal(err)
}
file := fileNamed(out, "keycloak.dbenv")
if file == nil {
t.Fatalf("no file in %v", out)
}
got := file["content"].(string)
want := "KC_DB_URL=jdbc:postgresql://anchor.internal:5432/keycloak\n" +
"KC_DB_USERNAME=mesh_workstation_keycloak\n" +
"KC_DB_PASSWORD=${secret:postgres-database}\n"
if got != want {
t.Errorf("the consumer cannot connect with what it was given:\n got %q\nwant %q", got, want)
}
// The password is still a hole. Only the host may fill that one, and only on the machine.
if sealed, _ := file["secrets"].(map[string]any); sealed["postgres-database"] != "sealed-db" {
t.Errorf("the credential did not travel with the file: %v", file)
}
}
// A provider answered on its consumer's own machine is announced at the port the machine PUBLISHES
// it on, not the port the module declared (novox/hq 04-ISSUES/038).
//
// Issue 018 made the same-node provider announced at all; this is the promise after it — that the
// port a co-located consumer is told is the one actually listening at the address it is handed. The
// mesh assigns a bare `ports` mapping a host port (here the container's 5432 becomes 15432 on this
// machine), and it must announce 15432, or the consumer dials workstation.internal:5432 where the
// provider is not published. Cross-node consumers already got this (the plan re-derives the served
// facts with the provider's assignment); the same-node need was settled before the assignment and
// carried the declared port until now.
func TestASameNodeProviderIsAnnouncedAtThePortItIsPublishedOn(t *testing.T) {
provider := mcat.Manifest{
Module: "postgres", Version: "1",
Provides: mcat.FromAnywhere("postgres-database"),
Listens: []mcat.Listening{{Port: 5432, Protocol: "tcp", From: "mesh"}},
Serves: map[string]map[string]any{"postgres-database": {"port": 5432}},
Resources: []map[string]any{{
"id": "server", "type": "container", "name": "postgres",
"ports": []any{"5432"},
}},
}
consumer := mcat.Manifest{
Module: "keycloak", Version: "1", Requires: []string{"postgres-database"},
Resources: []map[string]any{{
"id": "dbenv", "type": "file", "path": "/var/lib/keycloak/database.env", "mode": "0600",
"content": "KC_DB_URL=jdbc:postgresql://${bound:postgres-database:at}:" +
"${bound:postgres-database:port}/keycloak\n",
}},
}
r, err := Resolve(shelf(provider, consumer),
[]string{"postgres", "keycloak"}, reachable(), mcat.World{})
if err != nil {
t.Fatal(err)
}
// The mesh assigned the container's 5432 to 15432 on this machine.
out, err := Declaration(r, mcat.Rendering{Ports: map[string]map[int]int{"postgres": {5432: 15432}}})
if err != nil {
t.Fatal(err)
}
// Where the provider is actually published: the assigned host port, on the machine at large.
server := fileNamed(out, "postgres.server")
if server == nil {
t.Fatalf("the provider container is not in the declaration: %v", out)
}
if published := fmt.Sprint(server["ports"]); !strings.Contains(published, "15432:5432") {
t.Fatalf("the provider was not published on the assigned port: %v", server["ports"])
}
// What the consumer was told to dial: the SAME port, at the announced address — not the 5432 the
// module declared, which is closed on that address.
dbenv := fileNamed(out, "keycloak.dbenv")
if dbenv == nil {
t.Fatalf("the consumer's binding file is not in the declaration: %v", out)
}
got := dbenv["content"].(string)
want := "KC_DB_URL=jdbc:postgresql://workstation.internal:15432/keycloak\n"
if got != want {
t.Errorf("the consumer dials a port the provider does not listen on:\n got %q\nwant %q",
got, want)
}
}
// A port is 5432, not 5432.000000 — which is what a number decoded from JSON would write, and what
// every connection string in the world would refuse.
func TestAPortIsWrittenAsAPort(t *testing.T) {
if got := plainly(float64(5432)); got != "5432" {
t.Errorf("a port was rendered as %q", got)
}
if got := plainly("postgres"); got != "postgres" {
t.Errorf("a string was rendered as %q", got)
}
}
// The name the provider will create is the name the consumer is told to present. One derivation,
// so the two agree by construction rather than by two conventions written on the same day.
func TestTheProviderAndTheConsumerAgreeOnTheName(t *testing.T) {
provider, err := Resolve(shelf(mcat.Manifest{
Module: "postgres", Version: "1", Provides: mcat.FromAnywhere("postgres-database"),
Grants: map[string]string{"postgres-database": "/var/lib/postgres/grants"},
Receives: map[string]string{"postgres-database": "/var/lib/postgres/grants/mesh.json"},
}), []string{"postgres"}, reachable(), mcat.World{})
if err != nil {
t.Fatal(err)
}
out, err := Declaration(provider, mcat.Rendering{Grants: []mcat.Grant{{
Provision: "postgres-database", Consumer: "workstation", From: "keycloak",
Values: map[string]any{"name": "keycloak"}, Sealed: "sealed",
}}})
if err != nil {
t.Fatal(err)
}
var told string
for _, res := range out {
if res["path"] == "/var/lib/postgres/grants/mesh.json" {
told = res["content"].(string)
}
}
if !strings.Contains(told, `"as": "mesh_workstation_keycloak"`) {
t.Errorf("the provider was not told what to call the login: %s", told)
}
if told == "" {
t.Fatal("the provider was told nothing at all")
}
// Which is exactly what the consumer, on its own machine, is told to present.
if mcat.ConsumerIdentity("workstation", "keycloak") != "mesh_workstation_keycloak" {
t.Errorf("got %q", mcat.ConsumerIdentity("workstation", "keycloak"))
}
}
// A binding the module does not have is a typo, refused where it was written.
func TestAFileCannotNameABindingTheModuleDoesNotHave(t *testing.T) {
r := mcat.Resolution{
Node: "workstation",
Modules: []mcat.Manifest{{
Module: "keycloak", Requires: []string{"postgres-database"},
Resources: []map[string]any{{
"id": "conf", "type": "file", "path": "/etc/keycloak.conf",
"content": "host=${bound:s3-bucket:at}\n",
}},
}},
Needs: []mcat.Needed{{Name: "postgres-database", From: "anchor", At: "a", For: "keycloak"}},
}
_, err := Declaration(r, mcat.Rendering{})
if err == nil {
t.Fatal("a module read a binding it does not have")
}
if !strings.Contains(err.Error(), "postgres-database") {
t.Errorf("the refusal does not say what it could have named: %v", err)
}
}
// A key the provider does not serve is refused too. Written through, the literal placeholder
// becomes a hostname, and the failure names neither the module nor the mesh.
func TestAKeyTheProviderDoesNotServeIsRefused(t *testing.T) {
r := mcat.Resolution{
Node: "workstation",
Modules: []mcat.Manifest{{
Module: "keycloak", Requires: []string{"postgres-database"},
Resources: []map[string]any{{
"id": "conf", "type": "file", "path": "/etc/keycloak.conf",
"content": "sock=${bound:postgres-database:socket}\n",
}},
}},
Needs: []mcat.Needed{{
Name: "postgres-database", From: "anchor", At: "a", For: "keycloak",
Serves: map[string]any{"port": float64(5432)},
}},
}
_, err := Declaration(r, mcat.Rendering{})
if err == nil {
t.Fatal("a module read a key nothing serves, and the placeholder became a value")
}
if !strings.Contains(err.Error(), `"port"`) {
t.Errorf("the refusal does not say what is served: %v", err)
}
}
// A machine's resolver configuration must name its resolver by address — it cannot resolve the name
// of the thing it resolves names with (novox/hq ADR 0194). So a binding offers the providing
// machine's private address beside its name, and only when the machine has one.
func TestABindingOffersTheProvidersAddress(t *testing.T) {
consumer := func() mcat.Resolution {
return mcat.Resolution{
Node: "workstation",
Modules: []mcat.Manifest{{
Module: "resolv-conf",
Requires: []string{"wildcard-resolution"},
Resources: []map[string]any{{
"id": "resolv", "type": "file", "path": "/etc/resolv.conf", "mode": "0644",
"content": "nameserver ${bound:wildcard-resolution:address}\n",
}},
}},
Needs: []mcat.Needed{{Name: "wildcard-resolution", From: "anchor", At: "anchor.internal", For: "resolv-conf"}},
}
}
out, err := Declaration(consumer(), mcat.Rendering{Machines: map[string]string{"anchor.internal": "10.77.0.1"}})
if err != nil {
t.Fatal(err)
}
if got := fileNamed(out, "resolv-conf.resolv")["content"]; got != "nameserver 10.77.0.1\n" {
t.Fatalf("the resolver is not named by its address: %q", got)
}
// A machine with no address yet: refused, never written with a blank where the address belongs.
if _, err := Declaration(consumer(), mcat.Rendering{}); err == nil {
t.Fatal("a file naming an address the mesh does not have was composed")
}
}
+219
View File
@@ -0,0 +1,219 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
var incidentConsumers = []string{"board", "listings", "workflows", "agents", "game"}
func storeMesh() map[string]mcat.Manifest {
shelf := map[string]mcat.Manifest{
"store": {Module: "store", Version: "1",
Provides: []mcat.Offer{{Name: "postgres-database", Scope: mcat.ScopeMesh}},
Claims: []mcat.Claim{{Name: "mesh-store", Scope: mcat.ScopeMesh}},
Serves: map[string]map[string]any{"postgres-database": {"port": 5432}},
Grants: map[string]string{"postgres-database": "/grants"}},
"resolver": {Module: "resolver", Version: "1",
Provides: []mcat.Offer{{Name: "wildcard-resolution", Scope: mcat.ScopeMesh}},
Claims: []mcat.Claim{{Name: "mesh-dns-resolver", Scope: mcat.ScopeMesh}}},
"network": {Module: "network", Version: "1", Requires: []string{"wildcard-resolution"}},
}
for _, c := range incidentConsumers {
shelf[c] = mcat.Manifest{Module: c, Version: "1", Requires: []string{"postgres-database"}}
}
return shelf
}
// storeWorld is the rest of the mesh as the home server's plan sees it: the anchor runs a store and a
// resolver and holds both seats; the home server runs its own of each.
func storeWorld() mcat.World {
w := mcat.World{Offered: map[string][]mcat.Provider{
"postgres-database": {
{Node: "anchor", At: "anchor.internal", Module: "store", Serves: map[string]any{"port": 5432}},
{Node: "home", At: "home.internal", Module: "store", Serves: map[string]any{"port": 5434}},
},
"wildcard-resolution": {
{Node: "anchor", At: "anchor.internal", Module: "resolver"},
{Node: "home", At: "home.internal", Module: "resolver"},
},
}}
w.Held = []mcat.Held{
{Claim: "mesh-store", Scope: mcat.ScopeMesh, Node: "anchor", Module: "store"},
{Claim: "mesh-dns-resolver", Scope: mcat.ScopeMesh, Node: "anchor", Module: "resolver"},
}
w.Holdings = w.Held
return w
}
var home = mcat.Node{Name: "home", At: "home.internal"}
func homeAssigned() []string {
return append([]string{"store", "resolver", "network"}, incidentConsumers...)
}
func boundFrom(t *testing.T, r mcat.Resolution, consumer, provision string) mcat.Needed {
t.Helper()
for _, n := range r.Needs {
if n.For == consumer && n.Name == provision {
return n
}
}
t.Fatalf("no binding of %s for %s: %+v", consumer, provision, r.Needs)
return mcat.Needed{}
}
func TestTheIncidentAMachinesOwnStoreKeepsItsConsumersWhenTheSeatIsHeldElsewhere(t *testing.T) {
got, err := Resolve(storeMesh(), homeAssigned(), home, storeWorld())
if err != nil {
t.Fatal(err)
}
for _, c := range incidentConsumers {
if n := boundFrom(t, got, c, "postgres-database"); n.From != "home" || n.Module != "store" {
t.Errorf("%s bound to %s/%s; its data is on the store beside it (issue 273)", c, n.From, n.Module)
}
}
// And the resolver, which keeps nothing of anybody's, still answers from the seat's holder (258).
if n := boundFrom(t, got, "network", "wildcard-resolution"); n.From != "anchor" {
t.Errorf("the resolver bound to %s; the seat is held on anchor (issue 258)", n.From)
}
if len(got.Kept) != 0 {
t.Errorf("nothing was moved, and %d kept: %+v", len(got.Kept), got.Kept)
}
}
func TestTheIncidentWithEveryConsumerOnRecordStillMovesNothing(t *testing.T) {
w := storeWorld()
w.Bound = map[string]map[string]mcat.Chosen{}
for _, c := range incidentConsumers {
w.Bound[c] = map[string]mcat.Chosen{"postgres-database": {Node: "home", Module: "store"}}
}
got, err := Resolve(storeMesh(), homeAssigned(), home, w)
if err != nil {
t.Fatal(err)
}
for _, c := range incidentConsumers {
if n := boundFrom(t, got, c, "postgres-database"); n.From != "home" {
t.Errorf("%s bound to %s", c, n.From)
}
}
if len(got.Kept) != 0 {
t.Errorf("kept %+v", got.Kept)
}
}
func TestAPinElsewhereStillMovesAStoresConsumers(t *testing.T) {
w := storeWorld()
w.Pinned = map[string]mcat.Chosen{"postgres-database": {Node: "anchor", Module: "store"}}
w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}}
got, err := Resolve(storeMesh(), homeAssigned(), home, w)
if err != nil {
t.Fatal(err)
}
if n := boundFrom(t, got, "board", "postgres-database"); n.From != "anchor" || n.Module != "store" {
t.Errorf("bound to %s/%s; a person pinned it to anchor", n.From, n.Module)
}
if len(got.Kept) != 0 {
t.Errorf("a pin is a person's move, not one to keep: %+v", got.Kept)
}
}
// A consumer on a machine with no store of its own, bound to one store, when the seat moves to
// another: the holder would answer, and the binding stays.
func laptopWorld(holder string) mcat.World {
w := storeWorld()
w.Held = []mcat.Held{{Claim: "mesh-store", Scope: mcat.ScopeMesh, Node: holder, Module: "store"}}
w.Holdings = w.Held
return w
}
var laptop = mcat.Node{Name: "laptop", At: "laptop.internal"}
func TestABoundConsumerStaysWhenTheSeatsHolderChanges(t *testing.T) {
w := laptopWorld("home")
w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "anchor", Module: "store"}}}
got, err := Resolve(storeMesh(), []string{"board"}, laptop, w)
if err != nil {
t.Fatal(err)
}
n := boundFrom(t, got, "board", "postgres-database")
if n.From != "anchor" || n.At != "anchor.internal" || n.Serves["port"] != 5432 {
t.Fatalf("bound to %s at %s %v; its data is on anchor", n.From, n.At, n.Serves)
}
if len(got.Kept) != 1 {
t.Fatalf("the move was not said: %+v", got.Kept)
}
k := got.Kept[0]
if k.Bound != (mcat.Chosen{Node: "anchor", Module: "store"}) || k.Would != (mcat.Chosen{Node: "home", Module: "store"}) || k.Consumer != "board" {
t.Fatalf("kept %+v", k)
}
for _, want := range []string{"would move board's postgres-database from anchor/store to home/store",
"its data is on anchor/store", "pin laptop postgres-database home store", "move the data first"} {
if !strings.Contains(k.String(), want) {
t.Errorf("%q does not say %q", k.String(), want)
}
}
// Without a record it is a binding being made, and the holder answers it as before.
w.Bound = nil
got, err = Resolve(storeMesh(), []string{"board"}, laptop, w)
if err != nil {
t.Fatal(err)
}
if n := boundFrom(t, got, "board", "postgres-database"); n.From != "home" {
t.Errorf("a new consumer bound to %s; the seat is held on home", n.From)
}
}
func TestABoundConsumerWhoseProviderIsGoneIsRefusedNotMoved(t *testing.T) {
w := laptopWorld("anchor")
w.Offered["postgres-database"] = w.Offered["postgres-database"][:1] // only anchor's store is left
w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}}
_, err := Resolve(storeMesh(), []string{"board"}, laptop, w)
if err == nil {
t.Fatal("bound to home's store, which is gone, and answered by anchor's — the silent move")
}
for _, want := range []string{"board on laptop is bound to home/store", "home/store no longer provides it",
"pin laptop postgres-database anchor store"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("%q does not say %q", err, want)
}
}
}
func TestAMachinesOwnStoreUnassignedRefusesItsBoundConsumers(t *testing.T) {
w := storeWorld()
w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "home", Module: "store"}}}
_, err := Resolve(storeMesh(), []string{"board"}, home, w)
if err == nil || !strings.Contains(err.Error(), "store no longer runs on home") {
t.Fatalf("got %v", err)
}
}
// The first pass asks only what a machine offers, and is never refused by a binding.
func TestTheFirstPassKeepsNoBinding(t *testing.T) {
w := storeWorld()
w.Unchecked = true
w.Bound = map[string]map[string]mcat.Chosen{"board": {"postgres-database": {Node: "gone", Module: "store"}}}
if _, err := Resolve(storeMesh(), []string{"board"}, laptop, w); err != nil {
t.Fatal(err)
}
}
// An offer saying it keeps nothing is answered by the seat's holder as the resolver is, even where it
// grants a credential.
func TestAStoreSayingItKeepsNothingFollowsTheSeat(t *testing.T) {
shelf := storeMesh()
no := false
s := shelf["store"]
s.Provides = []mcat.Offer{{Name: "postgres-database", Scope: mcat.ScopeMesh, KeepsConsumerData: &no}}
shelf["store"] = s
got, err := Resolve(shelf, homeAssigned(), home, storeWorld())
if err != nil {
t.Fatal(err)
}
if n := boundFrom(t, got, "board", "postgres-database"); n.From != "anchor" {
t.Errorf("bound to %s; it keeps nothing, and the seat is held on anchor", n.From)
}
}
@@ -0,0 +1,49 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// The mesh's bus is one per mesh, read from the catalogue beside this checkout.
//
// **This is step 2's claim, and it is checked here rather than in a bed** (novox/hq ADR 0116):
// adoption puts the NATS server into the `mesh-broker` seat on a mesh that is already running,
// and the property that matters is that a second one anywhere is refused *when it is assigned*,
// not discovered later as two servers holding different halves of the mesh's traffic. A second
// bus is not a degraded mesh; it is two meshes that both believe they are the one.
func TestASecondMeshBusAnywhereIsRefusedByName(t *testing.T) {
nats := catalogueManifest(t, "nats")
if _, err := Resolve(shelf(nats), []string{"nats"}, workstation(), mcat.World{}); err != nil {
t.Fatalf("the bus alone does not resolve: %v", err)
}
elsewhere := mcat.World{Held: []mcat.Held{{Claim: "mesh-broker", Scope: mcat.ScopeMesh,
Node: "anchor", Module: "nats"}}}
other := workstation()
other.Name = "laptop"
_, err := Resolve(shelf(nats), []string{"nats"}, other, elsewhere)
if err == nil {
t.Fatal("a second bus was accepted on another machine")
}
if !strings.Contains(err.Error(), "mesh-broker") || !strings.Contains(err.Error(), "one per mesh") {
t.Fatalf("refused without naming the seat: %v", err)
}
}
// The seat is the server's role, not the product's name (novox/hq ADR 0079). A different
// implementation of the bus claims the same seat, and the mesh refuses it for the same reason —
// which is the property that lets the bus be replaced at all.
func TestTheSeatRefusesADifferentBusToo(t *testing.T) {
nats := catalogueManifest(t, "nats")
held := mcat.World{Held: []mcat.Held{{Claim: "mesh-broker", Scope: mcat.ScopeMesh,
Node: "anchor", Module: "some-other-broker"}}}
other := workstation()
other.Name = "laptop"
if _, err := Resolve(shelf(nats), []string{"nats"}, other, held); err == nil {
t.Fatal("the seat admitted a second holder because the module's name differed")
}
}
@@ -0,0 +1,255 @@
package resolution
import (
"encoding/json"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// onNetwork is a set of providers, all of them reachable. Written as a helper because a machine
// that cannot reach the one answering its requirement is its own case, tested separately.
// reachable is this machine, on the private network.
func reachable() mcat.Node {
n := workstation()
n.At = "workstation.internal"
return n
}
func onNetwork(nodes ...string) map[string][]mcat.Provider {
out := make([]mcat.Provider, 0, len(nodes))
for _, n := range nodes {
out = append(out, mcat.Provider{Node: n, At: n + ".internal", Module: "postgres"})
}
return map[string][]mcat.Provider{"postgres-database": out}
}
func brokeredShelf() map[string]mcat.Manifest {
return shelf(mcat.Manifest{Module: "postgres", Version: "1", Provides: mcat.FromAnywhere("postgres-database")}, mcat.Manifest{Module: "meshboard", Version: "1", Requires: []string{"postgres-database"}})
}
func TestADatabaseIsNotInstalledOnEveryMachineThatUsesOne(t *testing.T) {
// The fault this whole distinction exists for.
got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: onNetwork("anchor")})
if err != nil {
t.Fatal(err)
}
if have := strings.Join(names(got), " "); strings.Contains(have, "postgres") {
t.Fatalf("using a database installed one here: %s", have)
}
}
func TestWhatAMachineTakesFromElsewhereIsRecorded(t *testing.T) {
// It is the only part of a node's set that stops working when a *different* machine goes
// away, and it is where a credential will have to be handed back.
got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: onNetwork("anchor")})
if err != nil {
t.Fatal(err)
}
if len(got.Needs) != 1 {
t.Fatalf("got %v", got.Needs)
}
if got.Needs[0].Name != "postgres-database" || got.Needs[0].From != "anchor" {
t.Fatalf("got %v", got.Needs[0])
}
if got.Needs[0].For != "meshboard" {
t.Fatalf("it does not say what wanted it: %v", got.Needs[0])
}
}
func TestNothingInTheMeshProvidingItIsRefusedWithSomewhereToPutIt(t *testing.T) {
// Refused rather than installed here. Choosing a machine to put a database on is a decision
// with consequences, and nothing resolving a web application should make it silently.
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, workstation(), mcat.World{})
if err == nil {
t.Fatal("a database was found in a mesh that has none")
}
if !strings.Contains(err.Error(), "assign") || !strings.Contains(err.Error(), "postgres") {
t.Fatalf("the refusal does not say what to do: %v", err)
}
}
func TestTwoNodesProvidingItIsRefusedRatherThanPicked(t *testing.T) {
// Same rule as everywhere else. Picking one would be a guess about which database a person
// meant, and the wrong guess is somebody's data in the wrong place.
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: onNetwork("anchor", "archive")})
if err == nil {
t.Fatal("one of two databases was picked silently")
}
for _, want := range []string{"anchor", "archive", "pin"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("the refusal does not name %s: %v", want, err)
}
}
}
func TestSayingWhichOneSettlesIt(t *testing.T) {
got, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{
Offered: onNetwork("anchor", "archive"),
Pinned: map[string]mcat.Chosen{"postgres-database": {Node: "archive", Module: "postgres"}},
})
if err != nil {
t.Fatal(err)
}
if len(got.Needs) != 1 || got.Needs[0].From != "archive" {
t.Fatalf("the choice was not taken: %v", got.Needs)
}
}
func TestBeingPointedAtAMachineThatDoesNotProvideItIsRefused(t *testing.T) {
// Rather than falling back to one that does. A fallback would quietly move somebody's data to
// a machine they did not choose, which is the whole reason the question is asked.
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{
Offered: onNetwork("anchor", "archive"),
Pinned: map[string]mcat.Chosen{"postgres-database": {Node: "somewhere-else", Module: "postgres"}},
})
if err == nil {
t.Fatal("a machine was silently given a different database from the one chosen")
}
if !strings.Contains(err.Error(), "somewhere-else") {
t.Fatalf("the refusal does not say what was chosen: %v", err)
}
}
func TestOneProviderDoesNotOverruleAChoice(t *testing.T) {
// A single answer is normally taken silently. Not when somebody said they wanted a different
// one -- that is the mesh overruling a person, which it does nowhere else.
_, err := Resolve(brokeredShelf(), []string{"meshboard"}, reachable(), mcat.World{
Offered: onNetwork("anchor"),
Pinned: map[string]mcat.Chosen{"postgres-database": {Node: "archive", Module: "postgres"}},
})
if err == nil {
t.Fatal("the only database was used although another was chosen")
}
if !strings.Contains(err.Error(), "only anchor/postgres provides it") {
t.Fatalf("the refusal does not say what is available: %v", err)
}
}
func TestACatalogueThatDisagreesAboutScopeIsRefused(t *testing.T) {
// If one module says a database is local and another says it is anywhere, the same
// requirement means two things depending on which one happens to answer it.
_, err := Resolve(shelf(mcat.Manifest{Module: "postgres", Version: "1", Provides: mcat.FromAnywhere("postgres-database")}, mcat.Manifest{Module: "sqlite", Version: "1", Provides: mcat.Offers("postgres-database")}, mcat.Manifest{Module: "meshboard", Version: "1", Requires: []string{"postgres-database"}}), []string{"meshboard"}, workstation(), mcat.World{})
if err == nil {
t.Fatal("a catalogue that disagrees about where a database lives was accepted")
}
if !strings.Contains(err.Error(), "two things") {
t.Fatalf("unhelpful refusal: %v", err)
}
}
func TestALocalRequirementIsStillAnsweredLocally(t *testing.T) {
// The change must not have made everything brokered. A shell is still installed here.
got, err := Resolve(shelf(mcat.Manifest{Module: "zsh", Version: "1", Provides: mcat.Offers("shell")}, mcat.Manifest{Module: "tools", Version: "1", Requires: []string{"shell"}}), []string{"tools"}, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
if have := strings.Join(names(got), " "); !strings.Contains(have, "zsh") {
t.Fatalf("a shell was not installed on the machine that needs one: %s", have)
}
}
func boundShelf() map[string]mcat.Manifest {
return shelf(mcat.Manifest{Module: "postgres", Version: "1", Provides: mcat.FromAnywhere("postgres-database"),
Serves: map[string]map[string]any{"postgres-database": {"port": 5432, "driver": "postgres"}}}, mcat.Manifest{Module: "meshboard", Version: "1", Requires: []string{"postgres-database"},
Binds: map[string]string{"postgres-database": "/etc/meshboard/database.json"}},
)
}
func binding(t *testing.T, out []map[string]any) map[string]any {
t.Helper()
for _, r := range out {
if r["path"] != "/etc/meshboard/database.json" {
continue
}
var parsed map[string]any
if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil {
t.Fatalf("what the app is told is not readable: %v", err)
}
return parsed
}
t.Fatalf("the app was told nothing: %v", out)
return nil
}
func TestAnAppIsToldWhereItsDatabaseIs(t *testing.T) {
// Knowing it needs the anchor's database is useless to the program that needs it unless the
// program is told. This is the whole point of the field.
got, err := Resolve(boundShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: map[string][]mcat.Provider{"postgres-database": {{Node: "anchor", At: "anchor.internal",
Serves: map[string]any{"port": 5432, "driver": "postgres"}}}}})
if err != nil {
t.Fatal(err)
}
told := binding(t, mustDeclare(t, got))
if told["from"] != "anchor" || told["at"] != "anchor.internal" {
t.Fatalf("it was not told where: %v", told)
}
serves, _ := told["serves"].(map[string]any)
if serves["port"] != float64(5432) || serves["driver"] != "postgres" {
t.Fatalf("it was not told how: %v", serves)
}
}
func TestItSaysWhereTheCredentialIsInstead(t *testing.T) {
// A missing field looks like a bug; a stated absence looks like a boundary. Somebody wiring
// this up must not spend an afternoon looking for the password field.
//
// It used to say only that the mesh had no way to issue one, which stopped being true when
// 021 was fixed. A stated absence is only useful while it is accurate — once it is not, it
// sends the reader somewhere there is nothing to find.
got, _ := Resolve(boundShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: map[string][]mcat.Provider{"postgres-database": {{Node: "anchor", At: "anchor.internal"}}}})
told := binding(t, mustDeclare(t, got))
note, _ := told["generated"].(string)
if !strings.Contains(note, "not here") || !strings.Contains(note, "secrets") {
t.Fatalf("the file does not say where the credential is instead: %v", note)
}
for key := range told {
if strings.Contains(key, "password") || strings.Contains(key, "secret") {
t.Fatalf("something that looks like a credential appeared: %q", key)
}
}
}
func TestTwoMachinesThatCannotReachEachOtherAreRefused(t *testing.T) {
// An app on one machine and a database on another that share no private network is a mesh
// that reports itself configured and does not work. Said here rather than discovered as a
// connection timing out.
_, err := Resolve(boundShelf(), []string{"meshboard"}, workstation(), mcat. // not on the network
World{Offered: map[string][]mcat.Provider{"postgres-database": {{Node: "anchor", At: "anchor.internal"}}}})
if err == nil {
t.Fatal("an app was pointed at a database it has no path to")
}
if !strings.Contains(err.Error(), "private network") {
t.Fatalf("the refusal does not say what is wrong: %v", err)
}
if !strings.Contains(err.Error(), meshNetwork) {
t.Fatalf("the refusal does not say what to assign: %v", err)
}
}
func TestTheProviderBeingOffTheNetworkIsAlsoRefused(t *testing.T) {
// Both directions, because the failure is identical from either end and the remedy differs.
_, err := Resolve(boundShelf(), []string{"meshboard"}, reachable(), mcat.World{Offered: map[string][]mcat.Provider{"postgres-database": {{Node: "anchor"}}}})
if err == nil {
t.Fatal("an app was pointed at a database that is not on the private network")
}
if !strings.Contains(err.Error(), "anchor") {
t.Fatalf("the refusal does not name the unreachable end: %v", err)
}
}
func TestBindingSomethingAnsweredHereWritesNothing(t *testing.T) {
// A file saying "it is on this node" is a fact nobody needs and one more thing to keep true.
got, err := Resolve(shelf(mcat.Manifest{Module: "zsh", Version: "1", Provides: mcat.Offers("shell")}, mcat.Manifest{Module: "tools", Version: "1", Requires: []string{"shell"},
Binds: map[string]string{"shell": "/etc/tools/shell.json"}},
), []string{"tools"}, reachable(), mcat.World{})
if err != nil {
t.Fatal(err)
}
for _, r := range mustDeclare(t, got) {
if r["path"] == "/etc/tools/shell.json" {
t.Fatalf("a binding was written for something on this machine: %v", r)
}
}
}
@@ -0,0 +1,48 @@
package resolution
import (
"fmt"
"sort"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// sortedShares is a requirement's shares as they are handed to the provider: sorted by `to` (a file whose lines move
// looks changed), each entry's methods sorted, and a copy so the manifest is not reordered.
func sortedShares(shares []mcat.BucketShare) []mcat.BucketShare {
out := make([]mcat.BucketShare, 0, len(shares))
for _, s := range shares {
methods := append([]string(nil), s.Methods...)
sort.Strings(methods)
out = append(out, mcat.BucketShare{To: s.To, Methods: methods})
}
sort.Slice(out, func(i, j int) bool { return out[i].To < out[j].To })
return out
}
// withShares adds this module's shares of the requirement to the values it gives the provider. A module whose own
// `contributes` already names `shares` is refused: the field is the only way to say it (ADR 0468 §8).
func withShares(m mcat.Manifest, requirement string, values map[string]any) (map[string]any, error) {
shares := m.RequireShares[requirement]
if _, named := values[mcat.SharesValue]; named && requirement == mcat.BucketRequirement {
return nil, fmt.Errorf("%s contributing to %s names `shares`; write it on its requirement instead (novox/hq ADR 0468 §8)",
m.Module, requirement)
}
if len(shares) == 0 {
return values, nil
}
out := make(map[string]any, len(values)+1)
for k, v := range values {
out[k] = v
}
listed := []any{}
for _, s := range sortedShares(shares) {
methods := []any{}
for _, method := range s.Methods {
methods = append(methods, method)
}
listed = append(listed, map[string]any{"to": s.To, "methods": methods})
}
out[mcat.SharesValue] = listed
return out, nil
}
@@ -0,0 +1,105 @@
package resolution
import (
"encoding/json"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
const filesWithShares = `{"module":"files","version":"1","requires":["shell",
{"name":"s3-bucket","shares":[{"to":"seat:node-files","methods":["put"]},{"to":"print-and-scan","methods":["get","put"]}]}]}`
func minioLike() mcat.Manifest {
return mcat.Manifest{Module: "minio", Version: "1", Provides: mcat.Offers("s3-bucket"),
Receives: map[string]string{"s3-bucket": "/grants/mesh.json"}}
}
func givenToMinio(t *testing.T, out []map[string]any) []mcat.Contribution {
t.Helper()
for _, r := range out {
if r["path"] != "/grants/mesh.json" {
continue
}
var parsed struct {
Given []mcat.Contribution `json:"given"`
}
if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil {
t.Fatal(err)
}
return parsed.Given
}
t.Fatalf("minio was given no file: %v", out)
return nil
}
func sharesOf(t *testing.T, values map[string]any) string {
t.Helper()
raw, err := json.Marshal(values["shares"])
if err != nil {
t.Fatal(err)
}
return string(raw)
}
const sortedSharesJSON = `[{"methods":["get","put"],"to":"print-and-scan"},{"methods":["put"],"to":"seat:node-files"}]`
// 1591/AC3: the values a consumer gives carry the shares sorted by `to`, remote (grant) and co-located alike.
func TestTheGrantCarriesTheSharesSorted(t *testing.T) {
m := mustParse(t, filesWithShares)
remote := mcat.Resolution{Node: "laptop", Modules: []mcat.Manifest{m}}
values, asks, err := ContributionsFrom(remote, "s3-bucket", "files", mcat.SettingsBy{})
if err != nil || !asks {
t.Fatalf("asks %v, err %v", asks, err)
}
if got := sharesOf(t, values); got != sortedSharesJSON {
t.Fatalf("the grant values carry %s", got)
}
// As the provider's file shows it, through a grant.
provider, err := Resolve(shelf(minioLike()), []string{"minio"}, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
out, err := Declaration(provider, mcat.Rendering{Grants: []mcat.Grant{{Provision: "s3-bucket", Consumer: "laptop", From: "files", Values: values}}})
if err != nil {
t.Fatal(err)
}
given := givenToMinio(t, out)
if len(given) != 1 || sharesOf(t, given[0].Values) != sortedSharesJSON {
t.Fatalf("the remote consumer's shares did not reach the file: %+v", given)
}
// Co-located: the consumer contributes to s3-bucket too, and no grant is passed.
co := mustParse(t, `{"module":"files","version":"1","contributes":{"s3-bucket":{"name":"files"}},
"requires":[{"name":"s3-bucket","shares":[{"to":"seat:node-files","methods":["put"]},{"to":"print-and-scan","methods":["put","get"]}]}]}`)
both, err := Resolve(shelf(minioLike(), co), []string{"files"}, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
out, err = Declaration(both, mcat.Rendering{})
if err != nil {
t.Fatal(err)
}
given = givenToMinio(t, out)
if len(given) != 1 || sharesOf(t, given[0].Values) != sortedSharesJSON || given[0].Values["name"] != "files" {
t.Fatalf("the co-located consumer's shares did not reach the file: %+v", given)
}
}
// 1591/AC5: a consumer whose contributes to s3-bucket names `shares` itself is refused.
func TestAConsumerCannotContributeSharesItself(t *testing.T) {
_, err := decodeForTest([]byte(`{"module":"files","version":"1",
"contributes":{"s3-bucket":{"shares":"x"}},
"requires":[{"name":"s3-bucket","shares":[{"to":"a","methods":["get"]}]}]}`))
if err == nil || !strings.Contains(err.Error(), "ADR 0468 §8") {
t.Errorf("both written, accepted: %v", err)
}
// Without the field the grant is still refused, so the provider is never told something the manifest did not say.
m := mcat.Manifest{Module: "files", Requires: []string{"s3-bucket"},
Contributes: map[string]map[string]any{"s3-bucket": {"shares": "x"}}}
r := mcat.Resolution{Node: "laptop", Modules: []mcat.Manifest{m}}
if _, _, err := ContributionsFrom(r, "s3-bucket", "files", mcat.SettingsBy{}); err == nil {
t.Error("a contributed `shares` reached the grant")
}
}
+20
View File
@@ -0,0 +1,20 @@
package resolution
import (
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// runByAResource is the artifacts one of a module's own resources names — a process that runs it,
// a step, an archive that unpacks it — by name.
func runByAResource(m mcat.Manifest) map[string]bool {
named := map[string]bool{}
for _, r := range m.Resources {
if a, ok := r["artifact"].(string); ok && a != "" {
named[a] = true
}
}
return named
}
// RunByAResource is the bundles of m one of its own resources runs (its service, not its tools).
func RunByAResource(m mcat.Manifest) map[string]bool { return runByAResource(m) }
+20
View File
@@ -0,0 +1,20 @@
package resolution
import (
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// busyInto composes a container's `busy` into the node-engine's words, or takes it away: for an engine that
// reads it, the command and its timeout written out; for one that does not, the field is not sent, and the
// container is replaced at once, as before.
func busyInto(resource map[string]any, with mcat.Rendering) {
if _, has := resource[mcat.BusyField]; !has {
return
}
if !with.ReadsBusy {
delete(resource, mcat.BusyField)
return
}
b, _, _ := mcat.ReadBusy(resource)
resource[mcat.BusyField] = map[string]any{"command": b.Command, "timeout": b.Timeout.String()}
}
+54
View File
@@ -0,0 +1,54 @@
package resolution
import (
"encoding/json"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
func busyManifest(t *testing.T, on string, busy any) []byte {
t.Helper()
extra := map[string]any{"id": "agent", "type": "container", "name": "app-agent",
"image": "registry.example/app" + healthDigest}
var m map[string]any
if err := json.Unmarshal(healthManifest(t, "", nil, extra), &m); err != nil {
t.Fatal(err)
}
for _, r := range m["resources"].([]any) {
if r := r.(map[string]any); r["id"] == on {
r[mcat.BusyField] = busy
}
}
out, err := json.Marshal(m)
if err != nil {
t.Fatal(err)
}
return out
}
func TestABusyIsSentOnlyToAnEngineThatReadsIt(t *testing.T) {
m, err := decodeForTest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"}))
if err != nil {
t.Fatal(err)
}
compose := func(with mcat.Rendering) []map[string]any {
out, err := Declaration(mcat.Resolution{Node: "laptop", Modules: []mcat.Manifest{m}}, with)
if err != nil {
t.Fatal(err)
}
return out
}
older := compose(mcat.Rendering{})
if b, sent := older[indexOfID(older, "app.agent")][mcat.BusyField]; sent {
t.Fatalf("busy was sent to an engine that does not read it: %v", b)
}
newer := compose(mcat.Rendering{ReadsBusy: true})
got, _ := newer[indexOfID(newer, "app.agent")][mcat.BusyField].(map[string]any)
if got["command"] != "mesh-builder busy" || got["timeout"] != "10s" {
t.Fatalf("busy composed as %v", got)
}
if _, has := m.Resources[len(m.Resources)-1][mcat.BusyField].(map[string]any)["timeout"]; has {
t.Error("composing wrote the timeout into the catalogue's own manifest")
}
}
@@ -0,0 +1,46 @@
package resolution
import (
"path/filepath"
"testing"
"github.com/novox/mesh-host/internal/beside"
)
// TestEveryCatalogueManifestParses runs the real catalogue through the real gate.
//
// Not a fixture: the point is whether the manifests as written are accepted by the control plane that
// will read them, and a copy of one manifest proves nothing about the other seventy-one.
// catalogueRoot is the catalogue these checks run over: in a merge check the clone the build seat put
// beside this one, elsewhere the copy captured in testdata/beside (internal/beside). A check that only
// ran when somebody remembered a variable was a check nobody ran (novox/hq issue 134), and one that read
// whatever checkout sat beside judged the machine, not the change (novox/hq issue 432): it never skips.
func catalogueRoot(t *testing.T) string {
t.Helper()
return beside.Dir(t, "mesh-catalog")
}
func TestEveryCatalogueManifestParses(t *testing.T) {
_, found := catalogueManifests(t)
named, routed := 0, 0
for _, r := range found {
m, err := r.manifest, r.err
if err != nil {
t.Errorf("%s: %v", filepath.Base(filepath.Dir(r.path)), err)
continue
}
for _, l := range m.Listens {
if l.Name != "" {
named++
}
}
for port := range RoutedPorts(m) {
_ = port
routed++
}
}
t.Logf("%d manifests, %d named endpoints, %d routed endpoints resolved", len(found), named, routed)
if named == 0 {
t.Fatal("no endpoint in the catalogue is named, so this proved nothing")
}
}
@@ -0,0 +1,46 @@
package resolution
import (
"path/filepath"
"sync"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// readManifest is one manifest of the catalogue as ReadTreeManifest read it.
type readManifest struct {
path string // the module.json
manifest mcat.Manifest
err error
}
var readOnce sync.Once
var readAll []readManifest
var readRoot string
// catalogueManifests is every manifest of the catalogue (catalogueRoot), read through ReadTreeManifest once
// per test binary and not once per test: the checks over the real catalogue each read and parsed every
// manifest again, and together took 1.5 s of mesh-sdk's unit bound of 2 s (issue 1756). What it returns is
// shared and read-only: a test that changes a manifest copies it first.
func catalogueManifests(t *testing.T) (string, []readManifest) {
t.Helper()
root := catalogueRoot(t)
readOnce.Do(func() {
readRoot = root
found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return
}
for _, p := range found {
m, err := mcat.ReadTreeManifest(root, filepath.Dir(p))
readAll = append(readAll, readManifest{path: p, manifest: m, err: err})
}
})
if len(readAll) == 0 {
t.Fatalf("no manifests under %s", root)
}
return readRoot, readAll
}
@@ -0,0 +1,70 @@
package resolution
import (
"path/filepath"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
"github.com/novox/mesh-host/internal/beside"
)
// **A machine trusts the mesh's authority because a module put its root there** (novox/hq ADR
// 0147, issue 129). The module carries a shell script and a unit, and both are worthless unless
// the mesh fills in where the authority is — which is the one thing about it the manifest cannot
// state, because the authority's address is a fact about the mesh and not about the module.
//
// So what is checked here is the rendering, not the parsing: the script the machine will run
// names the authority it was bound to, and the unit runs that script both ways. The verification
// itself — a plain client trusting an internal name on a machine holding this, and failing on one
// that does not — is the lab's, and cannot be had here.
func TestCaTrustRendersTheAuthorityItWasBoundTo(t *testing.T) {
m, err := mcat.ReadTreeManifest(beside.Dir(t, "mesh-catalog"), filepath.Join(beside.Catalogue(t), "ca-trust"))
if err != nil {
t.Fatalf("the trust module does not parse:\n%v", err)
}
r := mcat.Resolution{
Node: "workstation",
Modules: []mcat.Manifest{m},
Needs: []mcat.Needed{{
Name: "internal-acme-ca", From: "anchor", At: "anchor.internal", For: "ca-trust",
Serves: map[string]any{
"port": float64(9000), "path": "/acme/acme/directory", "roots": "/roots.pem",
},
}},
}
out, err := Declaration(r, mcat.Rendering{})
if err != nil {
t.Fatalf("the trust module could not be composed for a machine: %v", err)
}
script := fileNamed(out, "ca-trust.anchor")
if script == nil {
t.Fatalf("nothing writes the script the unit runs: %v", out)
}
body, _ := script["content"].(string)
if !strings.Contains(body, "https://anchor.internal:9000/roots.pem") {
t.Errorf("the script does not fetch from the authority it was bound to:\n%s", body)
}
if script["mode"] != "0755" {
t.Errorf("the script is written %v, which systemd cannot execute", script["mode"])
}
unit := fileNamed(out, "ca-trust.unit")
if unit == nil {
t.Fatalf("no unit: %v", out)
}
text, _ := unit["content"].(string)
// Both halves. A unit that only installs the anchor leaves a machine trusting an authority
// nobody assigned it to any more, which is the half issue 129 asked for by name.
for _, want := range []string{
"ExecStart=" + script["path"].(string) + " install",
"ExecStop=" + script["path"].(string) + " remove",
"RemainAfterExit=yes",
} {
if !strings.Contains(text, want) {
t.Errorf("the unit does not say %q:\n%s", want, text)
}
}
}
@@ -0,0 +1,33 @@
package resolution
import (
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// checkerVerbs are the checker seat's verbs: ask a try, read one, end one by hand. Optional until the checker serves them
// everywhere.
func checkerVerbs() []mcat.Verb {
return []mcat.Verb{
{Name: "try", Optional: true, Description: "Try a pushed head before its pull request is opened: the checker " +
"judges it as it judges a pull request's head, merged onto its trunk's head, its unit and integration " +
"levels through the build modules, and owes the forge nothing. Answers the try's id at once; try-read " +
"reads where it stands. A try of the same head that waits or runs is the answer to a second ask; at most " +
"4 wait or run at once, mesh-wide.",
Input: schema(map[string]string{"repository": "<owner>/<name>",
"head": "the full commit to try, 40 or 64 hex digits",
"trunk": "the trunk commit to merge it onto, or the trunk's branch for its head now; the trunk's head when absent",
"tests": "a go test -run pattern: only those tests run, and nothing is kept",
"level": "unit or integration: only this level; both when absent"},
[]string{"repository", "head"}),
Replaces: []string{"sh merge-check.sh", "docker run postgres"}},
{Name: "try-read", Optional: true, Description: "Where a try stands, as stored: queued, running, or its end " +
"(pass, fail, or error when it could not run), with what was said of it, the head and the trunk commit " +
"it was merged onto, the time it took and what failed first. Read-only.",
Input: schema(map[string]string{"id": "the try's id, try-<digits>"}, []string{"id"})},
{Name: "try-end", Optional: true, Description: "End a try that waits or runs, as error, by hand: say why (kept in " +
"what it says). Its place among the tries is free at once and the next try of its head asks anew with a new " +
"id. A try that ended already is refused.",
Input: schema(map[string]string{"id": "the try's id, try-<digits>", "why": "why it is ended, in words"},
[]string{"id", "why"})},
}
}
+86
View File
@@ -0,0 +1,86 @@
package resolution
import (
"sort"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// matches is whether this provider is the one chosen.
func chosenMatches(c mcat.Chosen, p mcat.Provider) bool {
return p.Node == c.Node && (c.Module == "" || p.Module == c.Module)
}
// among is every offered provider the choice names — one, when the choice is whole.
func chosenAmong(c mcat.Chosen, where []mcat.Provider) []mcat.Provider {
var out []mcat.Provider
for _, p := range where {
if chosenMatches(c, p) {
out = append(out, p)
}
}
return out
}
// nameOf is how a refusal names a provider: the node and the module on it.
func nameOf(p mcat.Provider) string {
return mcat.Chosen{Node: p.Node, Module: p.Module}.String()
}
// providerNames is every provider named, sorted, for a refusal to list.
func providerNames(where []mcat.Provider) []string {
out := make([]string, 0, len(where))
for _, p := range where {
out = append(out, nameOf(p))
}
sort.Strings(out)
return out
}
// providersHere is which modules in this node's own set offer a provision, sorted.
func providersHere(catalogue map[string]mcat.Manifest, here func(string) bool, want string) []string {
var out []string
for name, m := range catalogue {
if !here(name) {
continue
}
for _, o := range m.Offers() {
if o == want {
out = append(out, name)
break
}
}
}
sort.Strings(out)
return out
}
// servedByOne is what one provider beside the consumer says a consumer needs to know, or nothing.
//
// Serving is *whether* a need is created at all when the provider is on this same machine (novox/hq
// 04-ISSUES/038's sibling): a need never created is a binding the consumer never gets. The manifest
// alone answers that; the values are settled later, with the node's settings.
func servedByOne(m mcat.Manifest, want string) map[string]any {
if _, ok := m.Serves[want]; ok {
return ServedOn(m, want, nil)
}
return nil
}
// sharedByOne is the own secret that provider names as its credential (ADR 0158), or "" when it
// gives each consumer its own.
func sharedByOne(m mcat.Manifest, want string) string {
if own, shared := m.SharedCredentialOf(want); shared {
return own
}
return ""
}
func oneOf(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
@@ -0,0 +1,306 @@
package resolution
import (
"encoding/json"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// A root certificate, as a certificate authority serves one: a real, self-signed one made for these tests (its key
// thrown away), because the one setting that may hold lines must parse as a certificate (novox/hq issue 339).
const servedRoot = `-----BEGIN CERTIFICATE-----
MIIBPjCB8aADAgECAhRZG3p93hUUB5bz00uxhYo/nJnTQjAFBgMrZXAwFDESMBAG
A1UEAwwJdGVzdCByb290MCAXDTI2MTAwODIyMjE0NloYDzIxMjYwOTE0MjIyMTQ2
WjAUMRIwEAYDVQQDDAl0ZXN0IHJvb3QwKjAFBgMrZXADIQA0pt/ld+W0MXwBhPfO
cuAt56kIW6Qcn+4vqWpuvHTiqaNTMFEwHQYDVR0OBBYEFIjgaLk4OVGIOeZQiqnN
p9vhciFjMB8GA1UdIwQYMBaAFIjgaLk4OVGIOeZQiqnNp9vhciFjMA8GA1UdEwEB
/wQFMAMBAf8wBQYDK2VwA0EAl9uWeSM2XAV8u0reyV3BLRxNVik+4FCRO1QKPs2k
IlB1rK9oAOYManH+VFuBMI/JJ31ajSti81q4E0CSw8r5DQ==
-----END CERTIFICATE-----
`
// stepCA is an internal ACME authority: it answers `acme-ca` from anywhere in the mesh, and what a
// consumer must know is the directory URL and the root to trust. **The root is not knowable when
// the module is written** — it exists only once the CA has been initialised — so the manifest
// declares the key and the operator supplies the value as a setting, which is exactly the shape the
// cross-node path settles and the same-node path did not.
func stepCA() mcat.Manifest {
return mcat.Manifest{
Module: "step-ca", Version: "1",
Provides: mcat.FromAnywhere("acme-ca"),
Listens: []mcat.Listening{{Port: 9000, Protocol: "tcp", From: mcat.FromMesh}},
Serves: map[string]map[string]any{"acme-ca": {
"directory": "https://anchor.internal/acme/acme/directory",
// Declared empty: a manifest is the same on every mesh, and this mesh's root is not.
"root": "",
}},
Resources: []map[string]any{{
"id": "server", "type": "container", "name": "step-ca", "ports": []any{"9000"},
}},
}
}
// stepCAWithItsRoot is the authority whose root the operator supplies: the served key asks for it as
// `${setting:root}` (ADR 0155), the only way a setting reaches what a provider serves since a layer
// stopped replacing a served key (novox/hq issue 696; before it, the empty `root` was replaced whole).
func stepCAWithItsRoot() mcat.Manifest {
m := stepCA()
m.Serves["acme-ca"]["root"] = "${setting:root}"
return m
}
// routeProxy issues from that authority, so it must be told the root to verify it with.
func routeProxy() mcat.Manifest {
return mcat.Manifest{
Module: "route-proxy", Version: "1",
Requires: []string{"acme-ca"},
Provides: mcat.FromAnywhere("route"),
Binds: map[string]string{"acme-ca": "/var/lib/route-proxy/acme-ca.json"},
Receives: map[string]string{"route": "/var/lib/route-proxy/routes.json"},
Resources: []map[string]any{{
"id": "bundle", "type": "file", "path": "/var/lib/route-proxy/ca.pem", "mode": "0644",
"content": "${bound:acme-ca:root}",
}},
}
}
// A co-located provider's served VALUES reach its consumer, not just its served keys.
//
// The mesh walks a provider on ANOTHER machine and settles what it serves with that node's settings
// layers before offering it (cmd/mesh-controller plan.go, theRestOfTheMesh). A provider on the
// consumer's own machine was never settled at all: resolve.go's servedHere and declaration.go's
// here() both read the manifest and stop there. So a served value the operator supplied — the one
// kind of value a manifest cannot carry, because it is different on every mesh — arrived as the
// manifest's empty default.
//
// The consequence is silent and total: route-proxy wrote an empty CA bundle, fell back to the
// system trust store, could not verify the internal authority, and no certificate was ever issued.
func TestACoLocatedProvidersServedValuesReachItsConsumer(t *testing.T) {
r, err := Resolve(shelf(stepCAWithItsRoot(), routeProxy()),
[]string{"step-ca", "route-proxy"}, reachable(), mcat.World{})
if err != nil {
t.Fatal(err)
}
// What the operator set on the provider, on this node — the CA's root, which only exists once
// the CA has been initialised.
out, err := Declaration(r, mcat.Rendering{Settings: mcat.SettingsBy{
"step-ca": {{From: "the operator", Values: map[string]any{"root": servedRoot}}},
}})
if err != nil {
t.Fatal(err)
}
bundle := fileNamed(out, "route-proxy.bundle")
if bundle == nil {
t.Fatalf("the consumer was given no bundle at all: %v", out)
}
if got := bundle["content"]; got != servedRoot {
t.Errorf("the co-located consumer was not told the root it must trust:\n got %q\nwant %q",
got, servedRoot)
}
}
// And the binding file says the same thing, for a consumer that reads the binding rather than a
// substituted placeholder. The two are one fact and must not be able to disagree.
func TestACoLocatedConsumersBindingCarriesTheSettledValues(t *testing.T) {
r, err := Resolve(shelf(stepCAWithItsRoot(), routeProxy()),
[]string{"step-ca", "route-proxy"}, reachable(), mcat.World{})
if err != nil {
t.Fatal(err)
}
out, err := Declaration(r, mcat.Rendering{
Settings: mcat.SettingsBy{
"step-ca": {{From: "the operator", Values: map[string]any{"root": servedRoot}}},
},
// And the machine moved the provider's port while it was at it, so both halves of the
// cross-node derivation are exercised at once.
Ports: map[string]map[int]int{"step-ca": {9000: 19000}},
})
if err != nil {
t.Fatal(err)
}
binding := fileNamed(out, "route-proxy.bound-acme-ca")
if binding == nil {
t.Fatalf("the consumer was given no binding at all: %v", out)
}
var said struct {
Serves map[string]any `json:"serves"`
}
if err := json.Unmarshal([]byte(binding["content"].(string)), &said); err != nil {
t.Fatal(err)
}
if said.Serves["root"] != servedRoot {
t.Errorf("the binding does not carry the settled root: %q", said.Serves["root"])
}
if port, _ := asPort(said.Serves["port"]); port != 19000 {
t.Errorf("the binding does not carry the port the machine publishes: %v", said.Serves["port"])
}
}
// A provider PULLED IN rather than assigned is settled the same way.
//
// The resolver's same-node need is built during the walk, from whichever modules had been chosen by
// the time the requirement came up — so which path a co-located binding took depended on the order
// a person happened to assign things in. Settling after the closure is known removes that: both
// orders now produce the same file.
func TestACoLocatedProviderIsSettledWhicheverWayItWasPulledIn(t *testing.T) {
for _, assigned := range [][]string{
{"step-ca", "route-proxy"},
{"route-proxy", "step-ca"},
} {
r, err := Resolve(shelf(stepCAWithItsRoot(), routeProxy()), assigned, reachable(), mcat.World{})
if err != nil {
t.Fatal(err)
}
out, err := Declaration(r, mcat.Rendering{Settings: mcat.SettingsBy{
"step-ca": {{From: "the operator", Values: map[string]any{"root": servedRoot}}},
}})
if err != nil {
t.Fatal(err)
}
bundle := fileNamed(out, "route-proxy.bundle")
if bundle == nil || bundle["content"] != servedRoot {
t.Errorf("assigned %v: the consumer was not told the root", assigned)
}
}
}
// A same-node contribution names the port the MACHINE publishes, not the one the module declared.
//
// 04-ISSUES/038 fixed this for what a consumer is TOLD about a provider. This is the other
// direction: what a workload TELLS the provider about itself. gitea declares a bare container port
// 3000, the mesh publishes it as 20000:3000 — and gitea's route contribution still said 3000, so
// the proxy beside it dialled a port nothing listens on and answered 502 for every request.
//
// The redirect uses the CONTRIBUTING module's assignment: the port belongs to the workload, and
// using the provider's map would move it to wherever the proxy happens to be published.
func TestASameNodeContributionNamesThePortTheMachinePublishes(t *testing.T) {
gitea := mcat.Manifest{
Module: "gitea", Version: "1",
Listens: []mcat.Listening{{Port: 3000, Protocol: "tcp", From: mcat.FromMesh}},
Contributes: map[string]map[string]any{"route": {"label": "git", "port": 3000}},
Resources: []map[string]any{{
"id": "server", "type": "container", "name": "gitea", "ports": []any{"3000"},
}},
}
r, err := Resolve(shelf(gitea, routeProxy(), stepCA()),
[]string{"gitea", "route-proxy", "step-ca"}, reachable(), mcat.World{})
if err != nil {
t.Fatal(err)
}
out, err := Declaration(r, mcat.Rendering{
// The machine put gitea's 3000 on 20000, and published it that way.
Ports: map[string]map[int]int{"gitea": {3000: 20000}},
})
if err != nil {
t.Fatal(err)
}
// Where the workload is actually published.
server := fileNamed(out, "gitea.server")
if published := strings.Join(asStrings(server["ports"]), ","); published != "20000:3000" {
t.Fatalf("the workload was not published on the assigned port: %v", server["ports"])
}
// What the proxy beside it was told to dial: the SAME port.
routes := fileNamed(out, "route-proxy.received-route")
if routes == nil {
t.Fatalf("the proxy was given no routes file at all: %v", out)
}
var given struct {
Given []mcat.Contribution `json:"given"`
}
if err := json.Unmarshal([]byte(routes["content"].(string)), &given); err != nil {
t.Fatal(err)
}
if len(given.Given) != 1 {
t.Fatalf("expected one route, got %v", given.Given)
}
port, ok := asPort(given.Given[0].Values["port"])
if !ok || port != 20000 {
t.Errorf("the proxy was told to dial a port nothing listens on: %v",
given.Given[0].Values["port"])
}
}
// A contribution from ANOTHER machine is left exactly as it was.
//
// Its port belongs to that machine's assignment, which this node's map knows nothing about —
// applying this node's map to it would move a remote workload's port to wherever a local module of
// the same name happens to be published, which is worse than the fault being fixed.
func TestAContributionFromAnotherMachineKeepsItsOwnPort(t *testing.T) {
r, err := Resolve(shelf(routeProxy(), stepCA()),
[]string{"route-proxy", "step-ca"}, reachable(), mcat.World{})
if err != nil {
t.Fatal(err)
}
out, err := Declaration(r, mcat.Rendering{
// A same-named module on this machine, moved. The grant below is a laptop's, and must not
// be dragged along with it.
Ports: map[string]map[int]int{"gitea": {3000: 20000}},
Grants: []mcat.Grant{{
Provision: "route", Consumer: "laptop", From: "gitea", At: "laptop.internal",
Values: map[string]any{"name": "git.example", "port": 3000},
}},
})
if err != nil {
t.Fatal(err)
}
routes := fileNamed(out, "route-proxy.received-route")
var given struct {
Given []mcat.Contribution `json:"given"`
}
if err := json.Unmarshal([]byte(routes["content"].(string)), &given); err != nil {
t.Fatal(err)
}
if len(given.Given) != 1 {
t.Fatalf("expected one route, got %v", given.Given)
}
if port, _ := asPort(given.Given[0].Values["port"]); port != 3000 {
t.Errorf("another machine's contribution was rewritten with this machine's ports: %v",
given.Given[0].Values["port"])
}
}
// The cross-node half of the same fault: a grant assembled on the providing machine carries the
// consumer's declared port until the CONSUMER's machine's assignments are applied to it. The
// declaration layer cannot do it — those assignments are not this machine's, which is the line the
// test above holds — so the grant layer is handed them and does it there.
func TestAContributionIsRedirectedToWhereItsOwnMachinePublishedIt(t *testing.T) {
published := map[int]int{3000: 20000}
got := AtPublishedPort(map[string]any{"name": "git.example.tld", "port": 3000}, "forge", published)
if port, _ := asPort(got["port"]); port != 20000 {
t.Errorf("the proxy would dial a port that machine never published: %v", got["port"])
}
if got["name"] != "git.example.tld" {
t.Errorf("redirecting the port disturbed the rest of the contribution: %v", got)
}
// Idempotent, and silent about a port nobody moved: a module that pinned its own mapping has no
// assignment, and must come back exactly as it was written.
again := AtPublishedPort(got, "forge", published)
if port, _ := asPort(again["port"]); port != 20000 {
t.Errorf("applying it twice moved the port again: %v", again["port"])
}
pinned := AtPublishedPort(map[string]any{"port": 9070}, "office", published)
if port, _ := asPort(pinned["port"]); port != 9070 {
t.Errorf("a port the mesh never assigned was rewritten: %v", pinned["port"])
}
}
func asStrings(v any) []string {
listed, ok := v.([]any)
if !ok {
return nil
}
out := make([]string, 0, len(listed))
for _, one := range listed {
out = append(out, strings.TrimSpace(plainly(one)))
}
return out
}
+312
View File
@@ -0,0 +1,312 @@
package resolution
import (
"fmt"
"regexp"
"slices"
"sort"
"strconv"
"strings"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
func itemKey(it mcat.Item) string { return mcat.ItemKey(it.Collection, it.Name) }
// order is the item's place among the items of one placeholder: its `order` attribute, or OrderDefault.
func itemOrder(it mcat.Item) int {
if n, err := strconv.Atoi(it.Attributes[mcat.OrderAttribute]); err == nil && n >= 0 && n <= 99 {
return n
}
return mcat.OrderDefault
}
// matches is whether the item's attributes carry every pair of the selector.
func itemMatches(it mcat.Item, where map[string]string) bool {
for k, v := range where {
if got, has := it.Attributes[k]; !has || got != v {
return false
}
}
return true
}
// itemsInOrder is the module's items by order, then name; the order the old form placed a module's pieces in.
func itemsInOrder(items []mcat.Item) []mcat.Item {
out := append([]mcat.Item(nil), items...)
sort.SliceStable(out, func(a, b int) bool {
if oa, ob := itemOrder(out[a]), itemOrder(out[b]); oa != ob {
return oa < ob
}
return out[a].Name < out[b].Name
})
return out
}
// oldCollectionNamespaces are the namespaces read as collections of their own name.
var oldCollectionNamespaces = []string{"shell", "environment", "contribution"}
// ofCollected is where a collector places a collection. Loose inside the braces, so a misspelt name is refused.
var ofCollected = regexp.MustCompile(`\$\{collected:([^}]*)\}`)
var attributeKey = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
var attributeValue = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`)
// splitSelector is a placeholder's name without its selector, and the selector: `widgets:bar=top,place=left` is
// `widgets` and `bar=top,place=left`. A collection's name holds no `=`, so the selector begins at the last colon
// before the first one.
func splitSelector(name string) (collection, selector string) {
eq := strings.Index(name, "=")
if eq < 0 {
return name, ""
}
colon := strings.LastIndex(name[:eq], ":")
if colon < 0 {
return name, ""
}
return name[:colon], name[colon+1:]
}
// selectorOf is a selector's pairs, or why it is none.
func selectorOf(selector string) (map[string]string, error) {
out := map[string]string{}
if selector == "" {
return out, nil
}
for _, pair := range strings.Split(selector, ",") {
k, v, ok := strings.Cut(pair, "=")
switch {
case !ok || !attributeKey.MatchString(k) || !attributeValue.MatchString(v):
return nil, fmt.Errorf("%q in %q is not <attribute>=<value>", pair, selector)
case out[k] != "":
return nil, fmt.Errorf("%q names %s twice", selector, k)
}
out[k] = v
}
return out, nil
}
// collectionOfPlaceholder is the collection a placeholder's namespace and key name, and whether they name one.
func collectionOfPlaceholder(namespace, key string) (string, bool) {
key, _ = splitSelector(key)
switch {
case namespace == mcat.CollectedNamespace && strings.HasPrefix(key, mcat.EnvironmentCollection+":"):
// The environment is one collection, and the rest of the name picks the rendering (issue 795).
return mcat.EnvironmentCollection, true
case namespace == mcat.CollectedNamespace:
return key, true
case namespace == "environment":
return mcat.EnvironmentCollection, true
case slices.Contains(oldCollectionNamespaces, namespace):
return namespace + ":" + key, true
}
return "", false
}
// oldCollection is an old namespace's collection by its name: its comment, or why no item may be contributed to it.
// ok is false for a name that is not an old namespace's.
//
// forItem is whether an item is to be contributed to it: an item may be contributed to a kind received as data,
// which a collector may place as a collection only where its shape says its pieces are text (design 57 L3).
func oldCollection(name string, forItem bool) (comment string, old bool, wrong error) {
namespace, key, _ := strings.Cut(name, ":")
if !slices.Contains(oldCollectionNamespaces, namespace) {
return "", false, nil
}
switch namespace {
case "shell":
target, slot, two := strings.Cut(key, ":")
if !two || !oneOf(contributionTargets(), target) || !oneOf(knownSlots, slot) {
return "", true, fmt.Errorf("%s is no slot: shell code is shell:<shell>:<slot>, the shell one of %s, "+
"the session's file one of %s or the power moment one of %s, and the slot one of %s", name,
strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", "), strings.Join(powerMoments, ", "),
strings.Join(knownSlots, ", "))
}
// shellCode's line before each module's code.
return "#", true, nil
case "environment":
if key == "" {
// The collection itself, which an item is contributed to; a placeholder names a rendering.
return "#", true, nil
}
if key != mcat.EnvironmentPOSIX && key != mcat.EnvironmentSystemd {
return "", true, fmt.Errorf("%s is no format: the environment is environment:%s or environment:%s",
name, mcat.EnvironmentPOSIX, mcat.EnvironmentSystemd)
}
return "#", true, nil
}
seat, kind, _ := strings.Cut(key, ":")
s, r, ok := receivable(seat, kind)
switch {
case !ok && s.Name == "":
return "", true, fmt.Errorf("%s names the seat %q, which the mesh does not define", name, seat)
case !ok:
return "", true, fmt.Errorf("%s: %s; %s", name, s.Name, kindsOf(s))
case r.Shape != nil && (forItem || collectableAsText(r)):
return r.Comment, true, nil
case r.Shape != nil:
return "", true, fmt.Errorf("%s is received by %s as data in its own shape, which no item of text is "+
"(novox/hq ADR 0255)", name, s.Name)
case r.Dirs:
return "", true, fmt.Errorf("%s names its contributors' directories, which the holder reads as paths "+
"the controller fills; it is not yet a collection (design 57 L1)", name)
case s.Name != canonicalSeat(seat):
return "", true, fmt.Errorf("%s names the seat by another name; a collection names it %s", name, s.Name)
}
return r.Comment, true, nil
}
// isOld is whether a collection's name is an old namespace's.
func isOld(name string) bool {
_, old, _ := oldCollection(name, true)
return old
}
// collectedPlaceholderProblems is what is wrong with one resource's ${collected:…}: placed only in a file's content,
// and naming a collection the module declares it collects.
func collectedPlaceholderProblems(m mcat.Manifest, r map[string]any) []string {
var problems []string
for _, field := range sortedKeys(r) {
v, ok := r[field].(string)
if !ok {
continue
}
for _, f := range ofCollected.FindAllStringSubmatch(v, -1) {
base, selector := splitSelector(f[1])
if _, err := selectorOf(selector); err != nil {
problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], err))
continue
}
switch {
case field != "content":
problems = append(problems, fmt.Sprintf("%s's resource %v names %s in its %s; a collection is placed "+
"only in a file's content", m.Module, r["id"], f[0], field))
case isOld(base):
if _, _, wrong := oldCollection(base, false); wrong != nil {
problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], wrong))
}
case !slices.ContainsFunc(m.Collections, func(c mcat.Collection) bool { return c.Name == base }):
problems = append(problems, fmt.Sprintf("%s's resource %v names %s, and %s does not collect %q: "+
"declare it under collections (novox/hq ADR 0349 §13)", m.Module, r["id"], f[0], m.Module, base))
}
}
}
return problems
}
// collectionComment is the comment of a collection a module's file names: declared, or the old namespace's.
func collectionComment(m mcat.Manifest, name string) (string, error) {
if comment, old, wrong := oldCollection(name, false); old {
return comment, wrong
}
for _, c := range m.Collections {
if c.Name == name {
return c.Comment, nil
}
}
return "", fmt.Errorf("%s names ${collected:%s} and does not collect it", m.Module, name)
}
// itemsLeftOutFor is itemsLeftOut for a placeholder that selects by attributes (where), on a machine with these
// capabilities: an item the selector does not take, or whose capability the machine lacks, is not left out but not
// wanted here.
func itemsLeftOutFor(modules []mcat.Manifest, holder mcat.Manifest, collection string, where map[string]string,
facts map[string]string, caps map[string]bool) []string {
var out []string
for _, m := range inModuleOrder(modules) {
for _, it := range itemsInOrder(m.Items) {
if it.Collection != collection || !itemMatches(it, where) || (it.IfCapability != "" && !caps[it.IfCapability]) {
continue
}
if _, rendered := heldText(it, facts); rendered && renderedOldWay(collection) {
continue
}
out = append(out, fmt.Sprintf("%s's item of %s is not placed: %s is filled the old way on this "+
"machine, from contributions, and an item only by the provisioner's client (design 57 L1)",
m.Module, itemKey(it), holder.Module))
}
}
return out
}
// oldFormsLeftOut says each piece of an old form a handed collector's collections would have held: the collector is
// filled from items alone, and the piece's module contributes none (ADR 0349 clause 17).
func oldFormsLeftOut(modules []mcat.Manifest, holder mcat.Manifest, collections []mcat.PlanCollection) []string {
var out []string
for _, c := range collections {
namespace, key, _ := strings.Cut(c.Name, ":")
for _, m := range inModuleOrder(modules) {
old := false
switch namespace {
case "shell":
target, slot, _ := strings.Cut(key, ":")
old = slices.ContainsFunc(m.Shell, func(s mcat.ShellCode) bool { return s.For == target && s.Slot == slot })
case "environment":
old = m.Environment != nil && (len(m.Environment.Variables) > 0 || len(m.Environment.Path) > 0)
case "contribution":
seat, kind, _ := strings.Cut(key, ":")
old = slices.ContainsFunc(manifestAllContributions(m), func(s mcat.SeatContribution) bool {
return canonicalSeat(s.Seat) == canonicalSeat(seat) && s.Kind == kind
})
}
if old {
out = append(out, fmt.Sprintf("%s's %s is not placed: %s is filled by the provisioner's client on "+
"this machine, from items, and %s contributes it the old way (design 57 L1)", m.Module, c.Name,
holder.Module, m.Module))
}
}
}
return out
}
// renderedOldWay is whether the controller renders a collection's items into a collector not handed.
func renderedOldWay(collection string) bool {
namespace, _, _ := strings.Cut(collection, ":")
return namespace == "shell" || namespace == "contribution" || namespace == mcat.EnvironmentCollection
}
// heldItem is one item of a module rendered the old way.
type heldItem struct {
item mcat.Item
text string
}
// heldItems is a module's items of the collection that the selector takes and the machine's capabilities allow, by
// order then name, each rendered as heldText does; the ones it cannot render are left out (itemsLeftOutFor says so).
func heldItems(m mcat.Manifest, collection string, where map[string]string, facts map[string]string,
caps map[string]bool) []heldItem {
if !renderedOldWay(collection) {
return nil
}
var out []heldItem
for _, it := range itemsInOrder(m.Items) {
if it.Collection != collection || !itemMatches(it, where) || (it.IfCapability != "" && !caps[it.IfCapability]) {
continue
}
if text, ok := heldText(it, facts); ok {
out = append(out, heldItem{item: it, text: text})
}
}
return out
}
// heldText is an item's text with the machine's facts filled, ending its line; false for an item whose text the mesh
// does not hold or which names anything but a machine fact this machine has.
func heldText(it mcat.Item, facts map[string]string) (string, bool) {
if it.Content == "" {
return "", false
}
text := it.Content
for _, f := range templatePlaceholder.FindAllStringSubmatch(text, -1) {
value, has := facts[f[2]]
if f[1] != "machine" || !has {
return "", false
}
text = strings.ReplaceAll(text, f[0], value)
}
if !strings.HasSuffix(text, "\n") {
text += "\n"
}
return text, true
}
@@ -0,0 +1,110 @@
package resolution
import (
"os"
"path/filepath"
"reflect"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
const collectedBarsText = "top\n${collected:contribution:node-bar:block:bar=top,place=status}" +
"bottom\n${collected:contribution:node-bar:block:bar=bottom,place=status}"
const collectedBars = `{"module":"bars","version":"1","moved":true,
"claims":[{"name":"node-bar","scope":"node","renders":{"block":"{{.shows}}"}}],
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
"resources":[{"id":"conf","type":"file","path":"/etc/bars.conf","template":"files/bars"}]}`
const itemsOfPower = `{"module":"power","version":"1","moved":true,
"items":[{"collection":"contribution:node-bar:block","name":"battery","if-capability":"battery","attributes":{"bar":"bottom","place":"status","order":"40"},"template":"files/battery"},
{"collection":"contribution:node-bar:block","name":"draw","attributes":{"bar":"bottom","place":"status","order":"45"},"template":"files/draw"}],
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`
func collectedBarsOn(t *testing.T, caps map[string]bool) mcat.Resolution {
t.Helper()
bars := held(t, collectedBars, builtWith("bars", map[string]string{"bars": collectedBarsText}))
power := held(t, itemsOfPower, builtWith("power", map[string]string{"battery": "B\n", "draw": "D ${machine:name}\n"}))
r, err := Resolve(shelf(bars, power), []string{"bars", "power"}, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
r.Account, r.Capabilities = "op", caps
return r
}
func TestACollectorMayNameTheBarsBlocksAsACollectionAndNoOtherDataKind(t *testing.T) {
bars := held(t, collectedBars, builtWith("bars", map[string]string{"bars": collectedBarsText}))
if _, err := Compose(collectedBarsOn(t, nil), handingTo("store:5000")); err != nil {
t.Fatalf("the handed collector is refused: %v", err)
}
if got := collectedPlaceholderProblems(bars, map[string]any{"id": "x",
"content": "${collected:contribution:node-bar:block:bar=top,place=status}"}); len(got) != 0 {
t.Fatalf("the check refuses it: %v", got)
}
// A data kind whose shape does not say its items are text stays refused.
_, plain, _ := receivable(mcat.BarSeat, mcat.BarKindBlock)
plain.Shape = &mcat.Shape{}
if collectableAsText(plain) {
t.Fatal("a shape not marked as text is collectable")
}
if _, _, err := oldCollection("contribution:node-bar:block", false); err != nil {
t.Fatal(err)
}
}
// The bars of the catalogue's i3status-rust, with the blocks of power's items, compose to the files the old shaped
// contribution made (issue 781). testdata/bars holds the catalogue's bars and block texts as of the move and the bars as
// composed before it, for a machine with neither capability, the battery, the power meter and both. Where the
// collector is not handed the controller fills the files; where it is, it names the items for the client, which
// fills the same files (mesh-catalog's provisioner client holds that half against the same golden files).
func TestTheBarsOfTheCatalogueAreTheSameHandedOrNot(t *testing.T) {
read := func(name string) string {
b, err := os.ReadFile(filepath.Join("testdata", "bars", name))
if err != nil {
t.Fatal(err)
}
return string(b)
}
collector := `{"module":"bars","version":"1","moved":true,"claims":[{"name":"node-bar","scope":"node"}],
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
"resources":[{"id":"top","type":"file","path":"/t","template":"files/top"},
{"id":"bottom","type":"file","path":"/b","template":"files/bottom"}]}`
power := `{"module":"power","version":"1","moved":true,
"items":[{"collection":"contribution:node-bar:block","name":"battery","if-capability":"battery","attributes":{"bar":"bottom","place":"status","order":"40"},"template":"files/battery"},
{"collection":"contribution:node-bar:block","name":"power-draw","if-capability":"power-meter","attributes":{"bar":"bottom","place":"status","order":"45"},"template":"files/draw"}],
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`
for name, caps := range map[string]map[string]bool{"none": nil, "battery": {"battery": true},
"power-meter": {"power-meter": true}, "both": {"battery": true, "power-meter": true}} {
bars := held(t, collector, builtWith("bars", map[string]string{"top": read("top-bar.toml"), "bottom": read("bottom-bar.toml")}))
blocks := held(t, power, builtWith("power", map[string]string{"battery": read("bar-battery"), "draw": read("bar-power-draw")}))
r, err := Resolve(shelf(bars, blocks), []string{"bars", "power"}, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
r.Account, r.Capabilities = "op", caps
// Not handed: the controller fills them.
for id, which := range map[string]string{"bars.top": "top", "bars.bottom": "bottom"} {
if got := composedFile(t, r, id); got != read(name+"."+which+"-bar.toml") {
t.Errorf("%s, %s bar, not handed:\n%s\nwant\n%s", name, which, got, read(name+"."+which+"-bar.toml"))
}
}
// Handed: the plan names the items the machine's capabilities allow, for the client to fill.
c, _ := composeWith(t, r, handingTo("store:5000"))
var names []string
for _, it := range c.Plans["power"].Items {
names = append(names, it.Name)
}
want := []string{}
if caps["battery"] {
want = append(want, "battery")
}
if caps["power-meter"] {
want = append(want, "power-draw")
}
if !reflect.DeepEqual(append([]string{}, names...), want) {
t.Errorf("%s: the plan names %v, want %v", name, names, want)
}
}
}
@@ -0,0 +1,57 @@
package resolution
import (
"os"
"path/filepath"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
func l2Moved(t *testing.T, parts ...string) string {
t.Helper()
raw, err := os.ReadFile(filepath.Join(append([]string{"testdata", "l2-moved"}, parts...)...))
if err != nil {
t.Fatal(err)
}
return string(raw)
}
func TestTheCollectorsL2MovedComposeAsBeforeWhereNothingIsHanded(t *testing.T) {
// Each moved contributor, its items read back from its templates archive as the mesh holds them.
items := map[string][]string{
"asus-zephyrus-g14": {"shell:after-wake:normal", "contribution:node-hotkeys:trigger"},
"claude-code": {"shell:zsh:normal"},
"mesh-cli": {"shell:zsh:normal"},
"powerlevel10k": {"shell:zsh:normal"},
"zsh-autosuggestions": {"shell:zsh:normal"},
"zsh-syntax-highlighting": {"shell:zsh:last"},
}
var modules []mcat.Manifest
for _, module := range sortedKeys(items) {
m := mcat.Manifest{Module: module, Moved: true}
for _, collection := range items[module] {
m.Items = append(m.Items, mcat.Item{Collection: collection,
Content: l2Moved(t, "items", module+"."+strings.ReplaceAll(collection, ":", "-"))})
}
modules = append(modules, m)
}
for _, c := range []struct{ module, seat string }{{"zsh", mcat.LoginShellSeat}, {"triggerhappy", mcat.HotkeysSeat},
{"power", mcat.PowerSeat}} {
holder := mcat.Manifest{Module: c.module, Moved: true, Claims: []mcat.Claim{{Name: c.seat}}}
resource := map[string]any{"id": "file", "type": "file", "path": "/etc/" + c.module,
"content": l2Moved(t, "templates", c.module)}
var unplaced []string
if err := contributionsInto(resource, holder, append([]mcat.Manifest{holder}, modules...),
map[string]string{"name": "g14"}, mcat.Rendering{}, map[string]bool{}, &unplaced); err != nil {
t.Fatalf("%s: %v", c.module, err)
}
if got, want := resource["content"], l2Moved(t, "want", c.module); got != want {
t.Errorf("%s's file is not what the old forms made:\n%s\nwant:\n%s", c.module, got, want)
}
if len(unplaced) != 0 {
t.Errorf("%s: unplaced %v", c.module, unplaced)
}
}
}
@@ -0,0 +1,36 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
const barCollector = `{"module":"bars","version":"1","moved":true,
"collections":[{"name":"blocks","comment":"#"}],
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
"resources":[{"id":"conf","type":"file","path":"/etc/bars.conf","template":"files/bars"}]}`
func TestASelectorIsRefusedWhenMalformedOrNamingACollectionNotCollected(t *testing.T) {
for placeholder, want := range map[string]string{
"${collected:blocks:bar}": "", // no equals sign: a collection named blocks:bar
"${collected:blocks:bar=top,bar=bottom}": "names bar twice",
"${collected:blocks:bar=}": "is not <attribute>=<value>",
"${collected:other:bar=top}": `does not collect "other"`,
} {
raw := strings.Replace(barCollector, `"template":"files/bars"`, `"content":"`+placeholder+`"`, 1)
raw = strings.Replace(raw, `"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},`, "", 1)
raw = strings.Replace(raw, `"moved":true,`, "", 1)
m, err := decodeForTest([]byte(raw))
if err == nil {
_, err = Declaration((mcat.Resolution{Node: "n", Modules: []mcat.Manifest{m}}), mcat.Rendering{})
}
if want == "" {
continue
}
if err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("%s: %v, want %q", placeholder, err, want)
}
}
}
@@ -0,0 +1,13 @@
package resolution
import (
"strings"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
func builtWith(module string, texts map[string]string) []mcat.Built {
return []mcat.Built{{Name: "files", Kind: mcat.ArtifactArchive,
Reference: mcat.ArtifactStoreScheme + module + "/files/blobs/sha256:" + strings.Repeat("e", 64),
Digest: "sha256:" + strings.Repeat("e", 64), Texts: texts}}
}
@@ -0,0 +1,184 @@
package resolution
import (
"fmt"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
type fake struct {
on map[string]bool
asked []string
err error
}
func (f *fake) Resources(node string) ([]map[string]any, bool, error) {
f.asked = append(f.asked, node)
if f.err != nil {
return nil, false, f.err
}
if !f.on[node] {
return nil, false, nil
}
return []map[string]any{{"id": "peers", "type": "file", "path": "/etc/x.conf",
"merge": mcat.MergeJSON, "content": `{"peer":"` + node + `"}`}}, true, nil
}
func computedShelf() map[string]mcat.Manifest {
return shelf(mcat.Manifest{Module: "mesh-network", Computed: "mesh-network",
Provides: mcat.Offers("private-network")})
}
func TestAComputedModuleIsAskedAboutTheNodeItIsFor(t *testing.T) {
// The generator gets a node name, not a plan. Everything it needs is the whole mesh, which
// it was built with — this is the one thing a node could never work out for itself.
got, err := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
gen := &fake{on: map[string]bool{"workstation": true}}
out, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": gen}})
if err != nil {
t.Fatal(err)
}
if len(gen.asked) != 1 || gen.asked[0] != "workstation" {
t.Fatalf("asked about %v, wanted workstation once", gen.asked)
}
if len(out) != 1 || !strings.Contains(out[0]["content"].(string), `"peer": "workstation"`) {
t.Fatalf("got %v", out)
}
}
func TestAMachineNobodyGaveItGetsNothing(t *testing.T) {
// The whole point of making the network a module. Before this, every machine with an address
// was on the private network and there was no way to say one should stay off.
got, err := Resolve(computedShelf(), nil, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
gen := &fake{on: map[string]bool{"workstation": true}}
out, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": gen}})
if err != nil {
t.Fatal(err)
}
if len(out) != 0 {
t.Fatalf("a machine that was assigned nothing got %d resource(s)", len(out))
}
if len(gen.asked) != 0 {
t.Fatalf("the generator was asked about %v, and nobody had asked for it", gen.asked)
}
}
func TestAssignedAndNotYetPartOfItIsNotAFailure(t *testing.T) {
// A machine given the network module before it has a place on it. Brief and ordinary — the
// answer is "nothing yet", and treating it as an error would make an ordering a fault.
got, _ := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), mcat.World{})
gen := &fake{on: map[string]bool{}}
out, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": gen}})
if err != nil {
t.Fatalf("refused a node that is not on the network yet: %v", err)
}
if len(out) != 0 {
t.Fatalf("got %v", out)
}
}
func TestAGeneratorThisControlPlaneDoesNotHaveIsRefused(t *testing.T) {
// Sending a machine a module with no files would look like it worked. Named in the message,
// because the only fix is a control plane that has it.
got, _ := Resolve(shelf(mcat.Manifest{Module: "weather", Computed: "the-weather"}), []string{"weather"}, workstation(), mcat.World{})
_, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": &fake{}}})
if err == nil {
t.Fatal("a module computed by nothing was accepted")
}
if !strings.Contains(err.Error(), "the-weather") {
t.Fatalf("the refusal does not name what is missing: %v", err)
}
}
func TestSettingsApplyToAComputedModuleToo(t *testing.T) {
// Being computed is about where the files come from, not about whether they are configurable.
// A line drawn there would be arbitrary and nobody could predict it.
got, _ := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), mcat.World{})
gen := &fake{on: map[string]bool{"workstation": true}}
out, err := Declaration(got, mcat.Rendering{
Generators: map[string]mcat.Generator{"mesh-network": gen},
Settings: mcat.SettingsBy{"mesh-network": {{From: "the mesh",
Values: map[string]any{"keepalive": 25}}}},
})
if err != nil {
t.Fatal(err)
}
content := out[0]["content"].(string)
if !strings.Contains(content, `"keepalive": 25`) {
t.Fatalf("the setting did not reach a computed file: %s", content)
}
if !strings.Contains(content, `"peer": "workstation"`) {
t.Fatalf("the setting replaced what the generator computed: %s", content)
}
}
func TestTwoWaysToBeOnAPrivateNetworkRefuseAndNameBoth(t *testing.T) {
// WireGuard is one way. The refusing rule is what makes a second one safe to add: assigning
// both is caught rather than producing a machine on two networks that each half-work.
_, err := Resolve(shelf(mcat.Manifest{Module: "mesh-network", Computed: "mesh-network",
Provides: mcat.Offers("private-network")}, mcat.Manifest{Module: "tailscale", Provides: mcat.Offers("private-network")}, mcat.Manifest{Module: "backups", Requires: []string{"private-network"}},
), []string{"backups"}, workstation(), mcat.World{})
if err == nil {
t.Fatal("two answers to one requirement were taken silently")
}
for _, want := range []string{"mesh-network", "tailscale", "private-network"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("the refusal does not name %s: %v", want, err)
}
}
}
// reloading answers the runtime's trust as the networking module once did (novox/hq ADR 0102; it no
// longer does, ADR 0222 — and beside the runtime's module it is refused, generated_collision_test): a file
// written into, and the runtime reloaded on it.
type reloading struct{}
func (reloading) Resources(node string) ([]map[string]any, bool, error) {
return []map[string]any{
{"id": "registry-trust", "type": "file", "path": "/etc/docker/daemon.json",
"merge": mcat.MergeJSON, "into": "json", "content": `{"insecure-registries":["r:5000"]}`},
{"id": "registry-trust-reload", "type": "service", "unit": "docker.service",
"state": "running", "reload-on": []string{"registry-trust"}},
}, true, nil
}
func TestWhatAServiceIsReloadedOnIsNamedAsTheHostWillSeeIt(t *testing.T) {
// Ids are prefixed with their module on the way out. An unprefixed reload-on would name a
// resource the host never sees, and the runtime would never be reloaded for its trust.
got, err := Resolve(computedShelf(), []string{"mesh-network"}, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
out, err := Declaration(got, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": reloading{}}})
if err != nil {
t.Fatal(err)
}
var file, service map[string]any
for _, r := range out {
switch r["type"] {
case "file":
file = r
case "service":
service = r
}
}
if file == nil || service == nil {
t.Fatalf("got %v", out)
}
if file["into"] != "json" {
t.Errorf("into did not reach the host: %v", file)
}
if want := "[" + file["id"].(string) + "]"; fmt.Sprint(service["reload-on"]) != want {
t.Errorf("reload-on names %v, the file is %v", service["reload-on"], file["id"])
}
}
+181
View File
@@ -0,0 +1,181 @@
package resolution
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// inConfigForm is a module in the config form that registers: every value typed, described, defaulted or
// required, placed in a file or read at runtime, and its mergeable file naming its keys.
func inConfigForm() map[string]any {
return map[string]any{
"module": "notifier",
"version": "1",
"config": map[string]any{
"font-size": map[string]any{"type": "number", "description": "the notification's font size, in points",
"minimum": 6, "maximum": 40, "default": 10},
"width": map[string]any{"type": "number", "description": "the notification's width, in pixels",
"default": 250},
"position": map[string]any{"type": "string", "description": "the corner it shows in, a word",
"enum": []any{"top-right", "top-left"}, "default": "top-right"},
"theme": map[string]any{"type": "string", "description": "the colour theme, a lower-case name",
"pattern": "[a-z]+", "default": "dark"},
"history": map[string]any{"type": "number", "description": "how many notifications it keeps, a count",
"default": 20, "runtime": true},
"sender": map[string]any{"type": "string", "description": "the address it mails a digest from",
"required": true},
},
"resources": []any{
map[string]any{"id": "configuration", "type": "file", "path": "/x/notifierrc",
"content": "font = Inter ${config:font-size}\nwidth = ${config:width}\nfrom = ${config:sender}\n"},
map[string]any{"id": "rules", "type": "file", "path": "/x/rules.json", "merge": "json",
"takes-config": []any{"position", "theme"}, "content": "{\"position\": \"top-right\"}\n"},
map[string]any{"id": "notifier", "type": "service", "unit": "notifier.service",
"reload-on": []any{"configuration"}, "restart-on": []any{"rules"}},
},
}
}
func parseRaw(t *testing.T, m map[string]any) (mcat.Manifest, error) {
t.Helper()
raw, err := json.Marshal(m)
if err != nil {
t.Fatal(err)
}
return decodeForTest(raw)
}
// settingsDunst is the dunst module as it is written today; configDunst the same module rewritten from settings to
// config.
const settingsDunst = `{"module": "dunst", "version": "1",
"settings": {
"font-size": {"kind": "preference", "default": 10, "why": "10 points of Inter reads well on a screen of about 100 DPI"},
"width": {"kind": "preference", "default": 250, "why": "250 pixels fits a title of about forty characters at 10 points"}
},
"resources": [
{"id": "package", "type": "package", "package": "dunst"},
{"id": "configuration", "type": "file", "path": "/home/me/.config/dunst/dunstrc", "mode": "0644",
"content": "[global]\n font = Inter ${setting:font-size}\n width = ${setting:width}\n"},
{"id": "rules", "type": "file", "path": "/home/me/.config/dunst/rules.json", "merge": "json",
"takes-settings": [], "content": "{\"urgency\": \"low\"}\n"},
{"id": "notifier", "type": "service", "unit": "dunst.service", "reload-on": ["configuration"]}
]}`
const configDunst = `{"module": "dunst", "version": "1",
"config": {
"font-size": {"type": "number", "description": "the font size of a notification, in points", "minimum": 6,
"maximum": 40, "default": 10},
"width": {"type": "number", "description": "the width of a notification, in pixels", "minimum": 100,
"default": 250}
},
"resources": [
{"id": "package", "type": "package", "package": "dunst"},
{"id": "configuration", "type": "file", "path": "/home/me/.config/dunst/dunstrc", "mode": "0644",
"content": "[global]\n font = Inter ${config:font-size}\n width = ${config:width}\n"},
{"id": "rules", "type": "file", "path": "/home/me/.config/dunst/rules.json", "merge": "json",
"takes-config": [], "content": "{\"urgency\": \"low\"}\n"},
{"id": "notifier", "type": "service", "unit": "dunst.service", "reload-on": ["configuration"]}
]}`
// ${config:<key>} fills exactly as ${setting:<key>} does: the module rewritten from settings to config composes
// to the same bytes, with no layer, with the node's, and with the mesh's under the node's.
func TestAModuleRewrittenFromSettingsToConfigComposesByteIdentically(t *testing.T) {
composed := func(manifest string, layers []mcat.Layer) []byte {
t.Helper()
m, err := decodeForTest([]byte(manifest))
if err != nil {
t.Fatal(err)
}
r := anAdoptedAnchor()
r.Modules = append(r.Modules, m)
with := anchorRendering(false)
if layers != nil {
with.Settings["dunst"] = layers
}
c, err := Compose(r, with)
if err != nil {
t.Fatal(err)
}
if why, left := c.LeftOut["dunst"]; left {
t.Fatalf("dunst was left out: %s", why)
}
raw, err := json.Marshal(c)
if err != nil {
t.Fatal(err)
}
return raw
}
for _, layers := range [][]mcat.Layer{
nil,
{{From: "anchor", Values: map[string]any{"font-size": float64(13)}}},
{{From: mcat.MeshWideLayer, Values: map[string]any{"width": float64(300)}},
{From: "anchor", Values: map[string]any{"font-size": float64(12), "width": float64(320)}}},
} {
was, is := composed(settingsDunst, layers), composed(configDunst, layers)
if !bytes.Equal(was, is) {
t.Fatalf("composed differently with %v:\nsettings: %s\nconfig: %s", layers, was, is)
}
if !strings.Contains(string(is), "font = Inter 1") || strings.Contains(string(is), "${config:") {
t.Fatalf("not filled: %s", is)
}
}
}
// A required value nothing sets is refused at composition in the config form's words: ${config:<key>} and the
// config verb, never `settings set` (review of mesh-controller#320). The settings form's words are unchanged.
func TestAnUnsetRequiredValueIsRefusedInTheConfigFormsWords(t *testing.T) {
m, err := parseRaw(t, inConfigForm())
if err != nil {
t.Fatal(err)
}
r := anAdoptedAnchor()
r.Modules = append(r.Modules, m)
composed, err := Compose(r, anchorRendering(false))
if err != nil {
t.Fatal(err)
}
why, left := composed.LeftOut["notifier"]
if !left {
t.Fatal("a module whose required value nothing sets was not left out")
}
for _, said := range []string{"${config:sender}", `required config value "sender"`, "config verb"} {
if !strings.Contains(why, said) {
t.Fatalf("left out without %q: %s", said, why)
}
}
for _, unsaid := range []string{"${setting:", "settings set"} {
if strings.Contains(why, unsaid) {
t.Fatalf("left out in the settings form's words (%q): %s", unsaid, why)
}
}
var unset *UnsetSettingError
if err := unsetInConfigWords(settingInto(map[string]any{"type": "file", "content": "${setting:sender}"}, nil,
"notifier"), m); !errors.As(err, &unset) || unset.Setting != "sender" {
t.Fatalf("not typed as a value nobody gave: %v", err)
}
// A contribution's value too (issue 1126, seed-8: de-spiegel's route names were refused with ${setting:…}).
withRoute := inConfigForm()
withRoute["contributes"] = map[string]any{"route": map[string]any{"site": map[string]any{"name": "${config:sender}", "port": 80}}}
routed, err := parseRaw(t, withRoute)
if err != nil {
t.Fatal(err)
}
_, err = resolutionComposed(mcat.Resolution{}, routed, "route", routed.ContributesMany["route"]["site"], nil, "notifier contributing site to route")
if !errors.As(err, &unset) || unset.Module != "notifier" || !strings.Contains(err.Error(),
`notifier has a contribution site to route, in "name", that says ${config:sender}`) || strings.Contains(err.Error(), "${setting:") {
t.Fatalf("a contribution's unset value is not said in the config form's words: %v", err)
}
settingsForm := notifier()
settingsForm.Resources[0]["content"] = "from = ${setting:domain}\n"
err = unsetInConfigWords(settingInto(settingsForm.Resources[0], nil, "notifier"), settingsForm)
if err == nil || !strings.Contains(err.Error(), "`settings set notifier <file>`") {
t.Fatalf("the settings form's refusal changed: %v", err)
}
}
@@ -0,0 +1,267 @@
package resolution
import (
"fmt"
"regexp"
"sort"
"strings"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// consumerFact is `${consumer:<fact>}` or `${consumer:<fact>:<alphabet>}`.
var consumerFact = regexp.MustCompile(`\$\{consumer:([a-z][a-z0-9-]*)(?::([a-z][a-z0-9-]*))?\}`)
// consumerFacts are what a served value may name about the consumer it is being derived for.
// One entry, deliberately: the identity is the one thing about a consumer the mesh itself chose,
// so it is the one thing the mesh can hand to a provider without either end guessing.
var consumerFacts = []string{"as"}
// consumerAlphabets are the ways the mesh will write that identity. `dns` is the mesh's own
// identifier with its separator written `-` instead of `_` — the whole of the difference between
// the alphabet the mesh mints in and the one buckets, vhosts and hostnames accept.
var consumerAlphabets = []string{"dns"}
// ServedTo fills a provider's served values for one consumer.
//
// `as` is the identity the mesh minted for that consumer — the same string it is told to present
// as a login. Values with no placeholder are returned exactly as they were, and a block with no
// placeholder at all is returned unchanged, so this costs nothing for the providers that derive
// nothing.
//
// Only strings carry placeholders. A number, a boolean or a nested object is a value the provider
// stated outright, and is left alone.
func ServedTo(serves map[string]any, as string) (map[string]any, error) {
if len(serves) == 0 {
return serves, nil
}
var out map[string]any
for _, key := range sortedAnyKeys(serves) {
text, ok := serves[key].(string)
if !ok || !strings.Contains(text, "${consumer:") {
continue
}
filled, err := consumerInto(text, as)
if err != nil {
return nil, fmt.Errorf("the value served as %q: %w", key, err)
}
if out == nil {
// Copied only once something actually changes: the caller's map is the manifest's,
// and a provider that derives nothing must not have it rewritten underneath it.
out = make(map[string]any, len(serves))
for k, v := range serves {
out[k] = v
}
}
out[key] = filled
}
if out == nil {
return serves, nil
}
return out, nil
}
// consumerInto replaces every `${consumer:…}` in one value.
//
// **A fact or an alphabet the mesh does not have is refused, not left standing.** Written through,
// the literal `${consumer:as}` would reach a configuration file and be read as a bucket name,
// failing somewhere that names neither the module nor the mesh — the same reasoning `${bound:…}`
// is refused by (boundInto).
func consumerInto(value, as string) (string, error) {
var failed error
out := consumerFact.ReplaceAllStringFunc(value, func(match string) string {
parts := consumerFact.FindStringSubmatch(match)
fact, alphabet := parts[1], parts[2]
if fact != "as" {
if failed == nil {
failed = fmt.Errorf(
"says %s, and the mesh states %s about a consumer", match, orNothing(consumerFacts))
}
return match
}
switch alphabet {
case "":
return as
case "dns":
return asDNSLabel(as)
default:
if failed == nil {
failed = fmt.Errorf(
"says %s, and the mesh writes an identity as %s", match, orNothing(consumerAlphabets))
}
return match
}
})
if failed != nil {
return "", failed
}
return out, nil
}
// asDNSLabel writes a minted identity as a DNS label.
//
// The mesh's identities are already lower-case letters, digits and `_` (ConsumerIdentity), and
// already inside the bound of the provision serving them: a provider that serves one as a DNS label
// bounds it at 63 or less (CheckServes, novox/hq ADR 0225), and one that serves it at all without
// saying is held to twenty (IdentityBoundOf). So
// this is the separator and nothing else — no lower-casing of what is already lower case, no
// truncation to a limit the identity is already inside, no padding of a name that is already long
// enough. Each of those would be the mesh guessing at a rule it has not been given.
func asDNSLabel(as string) string {
return strings.ReplaceAll(as, "_", "-")
}
func sortedAnyKeys(values map[string]any) []string {
out := make([]string, 0, len(values))
for k := range values {
out = append(out, k)
}
sort.Strings(out)
return out
}
// derivedFor is what the provider on this machine derives for one consumer of one provision
// (novox/hq ADR 0202).
//
// Settled first, then derived: an operator may set a prefix on what the provider serves and the
// mesh still fills the consumer's half of it ([ADR 0174]). Only the keys that actually name the
// consumer are returned — the rest of a `serves` block is the same for every consumer and is
// already in the provider's own definition, so repeating it here would be a second copy to go
// stale.
//
// The first module in the resolved order that says it serves the provision answers, which is the
// choice servedOnThisMachine makes for the consumer's half. Nothing serving it on this machine is
// not an error: a contribution can reach a machine whose provider is a record or an adapter, and
// then there is nothing derived to tell.
func resolutionDerivedFor(r mcat.Resolution, provision, as, consumer, local string, settings mcat.SettingsBy) (map[string]any, error) {
for _, m := range r.Modules {
serves, said := m.Serves[provision]
if !said {
continue
}
var names map[string]any
for key, value := range serves {
if text, ok := value.(string); ok && strings.Contains(text, "${consumer:") {
if names == nil {
names = map[string]any{}
}
names[key] = value
}
}
if names == nil {
return nil, nil
}
// **A consumer that keeps several holders of this provision is refused** — this is issue
// 124's own failure one case to the side, and it would be just as quiet.
//
// Each holder gets its own login, `…_<local>` (ADR 0094), and a provider derives from the
// login, so it would make one resource per holder. The consumer's side has no such
// dimension: one binding file per provision, one `${bound:<provision>:<key>}`, both
// derived from the un-suffixed identity. So the provider would create the holder's
// resource and the consumer would be configured against a name nothing made — it would
// authenticate successfully and be refused on every object, which reads like a credential
// fault and is not one.
//
// Lifting this means giving the consumer's side a local dimension. That is a decision,
// not an omission, and until it is taken the mesh says so rather than guessing.
if local != "" {
return nil, fmt.Errorf(
"%s keeps several holders of %s (this one is %q), and %s derives %s for each "+
"consumer from the login the mesh minted. Each holder has its own login, and a "+
"consumer is told one value per requirement — so the two ends would name "+
"different things and nothing would compare them (novox/hq ADR 0202)",
consumer, local, provision, m.Module, orNothing(sortedAnyKeys(names)))
}
settled, err := Settle(names, WithDefaults(m, settings[m.Module]))
if err != nil {
return nil, fmt.Errorf("%s serving %s: %w", m.Module, provision, err)
}
derived, err := ServedTo(settled, as)
if err != nil {
return nil, fmt.Errorf("%s serving %s to %s: %w", m.Module, provision, as, err)
}
return derived, nil
}
return nil, nil
}
// notTranscribed refuses a consumer's file that writes out the value its provider derives for it,
// instead of asking for it (novox/hq ADR 0202, issue 124).
//
// **What would have caught the one wrong instance.** The object store's three consumers each wrote
// their bucket into their own configuration by hand. One of them named a predecessor's bucket, and
// nothing compared it to what the provider would actually create: the module would have
// authenticated successfully and been refused on every object, which reads like a credential fault
// and is not one. It looked authoritative for months.
//
// The test is exact and costs one string search: a definition whose file already contains the
// value the mesh is about to derive for it has written down somebody else's rule. It cannot be a
// coincidence — a derived value carries the identity the mesh minted for this very consumer on
// this very machine, which nothing else would spell out — and it cannot be checked afterwards,
// because after substitution every consumer's file contains it legitimately.
//
// Only values that actually name the consumer are judged. A provider that serves a constant under
// the same key serves the same constant to everyone, and a consumer repeating it is redundant
// rather than wrong.
func notTranscribed(resource map[string]any, known map[string]map[string]string, module string) error {
if fmt.Sprint(resource["type"]) != "file" {
return nil
}
content, ok := resource["content"].(string)
if !ok || content == "" {
return nil
}
for _, provision := range sortedKnown(known) {
values := known[provision]
identity := values["as"]
if identity == "" {
continue
}
for _, key := range sortedStringKeys(values) {
if key == "as" {
// The login is not derived from itself, and a consumer that must present it in a
// connection string legitimately has it from `${bound:…}` — which is what it will
// be after substitution, so this would judge the substitution, not the module.
continue
}
value := values[key]
if value == "" || !namesTheConsumer(value, identity) {
continue
}
if !strings.Contains(content, value) {
continue
}
return fmt.Errorf(
"%s writes %q into %v, and that is exactly what %s derives for it — a definition "+
"keeping its own copy of somebody else's naming rule is one that can disagree "+
"with it, silently. Say ${bound:%s:%s} and be told",
module, value, resource["id"], provision, provision, key)
}
}
return nil
}
// namesTheConsumer is whether a derived value was built from this consumer's identity — in the
// alphabet it was minted in, or as a DNS label. A value that does not contain it was not derived
// from it, whatever else it may be.
func namesTheConsumer(value, identity string) bool {
return strings.Contains(value, identity) || strings.Contains(value, asDNSLabel(identity))
}
func sortedKnown(known map[string]map[string]string) []string {
out := make([]string, 0, len(known))
for k := range known {
out = append(out, k)
}
sort.Strings(out)
return out
}
func sortedStringKeys(values map[string]string) []string {
out := make([]string, 0, len(values))
for k := range values {
out = append(out, k)
}
sort.Strings(out)
return out
}
+100
View File
@@ -0,0 +1,100 @@
package resolution
import (
"fmt"
"regexp"
"slices"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// ofConsumers is where a provider places its consumers' credentials. Loose inside the braces, so a misspelt name is
// refused rather than passed over.
var ofConsumers = regexp.MustCompile(`\$\{consumers:([^}]*)\}`)
// consumersPlaceholderProblems is what is wrong with one resource's ${consumers:…}: placed only in a file's content,
// for a provision the module provides, in a file only the provider reads and the client writes back.
func consumersPlaceholderProblems(m mcat.Manifest, r map[string]any) []string {
var problems []string
for _, field := range sortedKeys(r) {
v, ok := r[field].(string)
if !ok {
continue
}
for _, f := range ofConsumers.FindAllStringSubmatch(v, -1) {
switch {
case field != "content" || fmt.Sprint(r["type"]) != "file":
problems = append(problems, fmt.Sprintf("%s's resource %v names %s in its %s; a provider's consumers "+
"are placed only in a file's content (novox/hq ADR 0373)", m.Module, r["id"], f[0], field))
continue
case !slices.ContainsFunc(m.Provides, func(o mcat.Offer) bool { return o.Name == f[1] }):
problems = append(problems, fmt.Sprintf("%s's resource %v names %s, and %s does not provide %q: only a "+
"provider's template holds its consumers' credentials (novox/hq ADR 0373)", m.Module, r["id"], f[0],
m.Module, f[1]))
}
if mode := fmt.Sprint(r["mode"]); mode != "0600" && mode != "0400" {
problems = append(problems, fmt.Sprintf("%s's resource %v holds its consumers' credentials (%s) with "+
"mode %q: the provider alone reads it, 0600 or 0400 (novox/hq ADR 0373)", m.Module, r["id"], f[0],
r["mode"]))
}
if owner := ownershipOf(r); owner != mcat.RuntimeOwned {
problems = append(problems, fmt.Sprintf("%s's resource %v holds its consumers' credentials (%s) and is "+
"%s at runtime: it is owned, written back whenever it differs (novox/hq ADR 0373)", m.Module,
r["id"], f[0], owner))
}
}
}
return problems
}
// consumersHandedOnly says what a file naming ${consumers:…} is where it would be written the old way: the provisioner's
// client alone fills it, so on a machine that does not hand the module it is **left out** of the declaration, never
// written as that text or as an empty list the provider would read as no consumers, and never the reason the module is
// refused: a provider not handed there keeps working as it did, from its grant files (novox/hq ADR 0373, issue 780).
// A moved module judged from its repository's tree (WithTemplatesFrom: `module check`), on a machine
// that would hand it, has no templates archive to hand and is judged as it composes; there the placeholder is judged
// and taken out of the text, since what is composed so is never sent (judged answers whether the file is so judged).
func consumersHandedOnly(m mcat.Manifest, r map[string]any, with mcat.Rendering) (judged, leftOut bool) {
content, _ := r["content"].(string)
switch {
case ofConsumers.FindString(content) == "":
return false, false
case m.Moved && m.Handed == nil && with.Hands && with.Provisioner != "":
return true, false
}
return false, true
}
// consumersLeftOutLine is the plan's one line for a file left out because the module is not handed.
func consumersLeftOutLine(m mcat.Manifest, r map[string]any) string {
return fmt.Sprintf("%s's %v names ${consumers:…}, which the provisioner's client alone fills: left out, handed only "+
"(novox/hq ADR 0373)", m.Module, r["id"])
}
// consumersNotHanded is consumersHandedOnly as a refusal, for a caller that has no way to leave the file out.
func consumersNotHanded(m mcat.Manifest, r map[string]any, with mcat.Rendering) (judged bool, err error) {
judged, left := consumersHandedOnly(m, r, with)
if left {
return false, fmt.Errorf("%s's resource %v names %s, which the provisioner's client alone fills, and %s is not "+
"handed to it on this machine: it would be written as that text, or empty, and the provider would read no "+
"consumers (novox/hq ADR 0373)", m.Module, r["id"], ofConsumers.FindString(fmt.Sprint(r["content"])), m.Module)
}
return judged, nil
}
// consumersOfTemplate is what is wrong with a handed file's template naming ${consumers:…}, judged on the file's
// resource as if its text were inline.
func consumersOfTemplate(m mcat.Manifest, id, text string) []string {
for _, r := range m.Resources {
if fmt.Sprint(r["id"]) != id {
continue
}
withText := map[string]any{}
for k, v := range r {
withText[k] = v
}
withText["content"] = text
return consumersPlaceholderProblems(m, withText)
}
return nil
}
@@ -0,0 +1,311 @@
package resolution
import (
"encoding/json"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
func published(module, host string, port int) mcat.Manifest {
return mcat.Manifest{Module: module, Version: "1",
Contributes: map[string]map[string]any{
"reverse-proxy": {"host": host, "port": port},
}}
}
func proxy() mcat.Manifest {
return mcat.Manifest{Module: "traefik", Version: "1",
Provides: mcat.Offers("reverse-proxy"),
Receives: map[string]string{"reverse-proxy": "/etc/traefik/mesh.json"},
Resources: []map[string]any{
{"id": "up", "type": "service", "unit": "traefik", "state": "running",
"restart-on": []any{mcat.ReceivedID("reverse-proxy")}},
}}
}
// received digs the delivered contributions back out of a declaration.
func received(t *testing.T, out []map[string]any) []mcat.Contribution {
t.Helper()
for _, r := range out {
if r["path"] != "/etc/traefik/mesh.json" {
continue
}
body := r["content"].(string)
var parsed struct {
Requirement string `json:"requirement"`
Given []mcat.Contribution `json:"given"`
}
if err := json.Unmarshal([]byte(body), &parsed); err != nil {
t.Fatalf("the file the proxy is given is not readable: %v\n%s", err, body)
}
if parsed.Requirement != "reverse-proxy" {
t.Fatalf("the file does not say what it is about: %q", parsed.Requirement)
}
return parsed.Given
}
t.Fatalf("the provider was given no file at all: %v", out)
return nil
}
func TestTheFileTheProviderGetsIsMachineReadable(t *testing.T) {
// It is written for a program, and the first version put a `//` header above the JSON — a
// file that says "do not edit" to a person and fails to parse for the thing meant to read it.
got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{})
for _, r := range mustDeclare(t, got) {
if r["path"] != "/etc/traefik/mesh.json" {
continue
}
var any map[string]any
if err := json.Unmarshal([]byte(r["content"].(string)), &any); err != nil {
t.Fatalf("the file is not parseable: %v\n%s", err, r["content"])
}
if note, _ := any["generated"].(string); !strings.Contains(note, "do not edit") {
// Inside the document rather than above it, so it reaches a person without
// breaking the parse.
t.Fatalf("the file does not say it is generated: %v", any["generated"])
}
return
}
t.Fatal("no received file")
}
func TestAModuleTellsItsProviderWhatItNeeds(t *testing.T) {
got, err := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
out, err := Declaration(got, mcat.Rendering{})
if err != nil {
t.Fatal(err)
}
given := received(t, out)
if len(given) != 1 || given[0].From != "board" {
t.Fatalf("the proxy was not told about board: %v", given)
}
if given[0].Values["host"] != "board" || given[0].Values["port"] != float64(8080) {
t.Fatalf("the contribution did not survive: %v", given[0].Values)
}
}
func TestContributingToSomethingIsRequiringIt(t *testing.T) {
// Asking to be published means a publisher must exist. A module that had to say both would
// eventually say one, and the failure would be a machine where nothing serves the route.
got, err := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(strings.Join(names(got), " "), "traefik") {
t.Fatalf("contributing to reverse-proxy did not bring one in: %v", names(got))
}
}
func TestNothingToContributeToIsRefused(t *testing.T) {
_, err := Resolve(shelf(published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{})
if err == nil {
t.Fatal("a module was published through a proxy that does not exist")
}
if !strings.Contains(err.Error(), "reverse-proxy") {
t.Fatalf("the refusal does not name what is missing: %v", err)
}
}
func TestEveryPublisherOnTheMachineIsInOneFile(t *testing.T) {
got, err := Resolve(shelf(proxy(),
published("board", "board", 8080),
published("archive", "archive", 9000),
), []string{"board", "archive"}, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
given := received(t, mustDeclare(t, got))
if len(given) != 2 {
t.Fatalf("the proxy was told about %d of 2: %v", len(given), given)
}
// Ordered by module, because this becomes a file and a file whose lines move about looks
// changed when nothing changed — which would restart the proxy for ever.
if given[0].From != "archive" || given[1].From != "board" {
t.Fatalf("the order is not stable: %v", given)
}
}
func TestAProviderWithNoConsumersStillGetsTheFile(t *testing.T) {
// Empty rather than absent. A provider that finds no file cannot tell "nothing asked for me"
// from "the mesh never wrote it", and those want completely different responses — the same
// rule the host follows about a service that does not exist.
got, err := Resolve(shelf(proxy()), []string{"traefik"}, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
if given := received(t, mustDeclare(t, got)); len(given) != 0 {
t.Fatalf("got %v", given)
}
}
func TestTheProviderCanReloadWhenTheRoutesChange(t *testing.T) {
// A proxy that got a new route and did not reload is a route that silently does not work.
// The same fault the private network had when a peer list changed under a running interface,
// which is why the received file has a name a module can point at.
got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{})
out := mustDeclare(t, got)
for _, r := range out {
if r["type"] != "service" {
continue
}
reflects, ok := r["restart-on"].([]any)
if !ok || len(reflects) != 1 {
t.Fatalf("the proxy does not reflect anything: %v", r)
}
if reflects[0] != "traefik."+mcat.ReceivedID("reverse-proxy") {
t.Fatalf("it reflects %v, which is not the file it was given", reflects[0])
}
return
}
t.Fatal("no service in the declaration")
}
func TestARouteCanBeSetPerMesh(t *testing.T) {
// The hostname is exactly the kind of thing that differs between one mesh and the next. A
// module whose route could not be set would have to be edited to be reused anywhere.
got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{})
out, err := Declaration(got, mcat.Rendering{Settings: mcat.SettingsBy{
"board": {{From: "the mesh", Values: map[string]any{"host": "dashboard"}}},
}})
if err != nil {
t.Fatal(err)
}
given := received(t, out)
if given[0].Values["host"] != "dashboard" {
t.Fatalf("the setting did not reach the route: %v", given[0].Values)
}
if given[0].Values["port"] != float64(8080) {
t.Fatalf("setting the host dropped the port: %v", given[0].Values)
}
}
func TestASettingReachesAContributionOnlyWhereItDeclaresTheKey(t *testing.T) {
// novox/hq 04-ISSUES/173: the mail module's site name, set so its environment file could read
// it, arrived in every route it contributed. A setting overrides a key the contribution
// declares and adds none — the provider reads the contribution as a contract.
got, _ := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), mcat.World{})
out, err := Declaration(got, mcat.Rendering{Settings: mcat.SettingsBy{
"board": {{From: "the mesh", Values: map[string]any{"host": "dashboard", "sitename": "Board"}}},
}})
if err != nil {
t.Fatal(err)
}
given := received(t, out)
if given[0].Values["host"] != "dashboard" {
t.Fatalf("the setting did not override the route's host: %v", given[0].Values)
}
if _, leaked := given[0].Values["sitename"]; leaked {
t.Fatalf("a setting the route never declared reached the proxy: %v", given[0].Values)
}
}
func provider() mcat.Manifest {
return mcat.Manifest{Module: "postgres", Version: "1",
Provides: mcat.FromAnywhere("postgres-database"),
Receives: map[string]string{"postgres-database": "/var/lib/postgres/grants/mesh.json"},
Grants: map[string]string{"postgres-database": "/var/lib/postgres/grants"},
}
}
func grantedTo(t *testing.T, out []map[string]any) []mcat.Contribution {
t.Helper()
for _, r := range out {
if r["path"] != "/var/lib/postgres/grants/mesh.json" {
continue
}
var parsed struct {
Given []mcat.Contribution `json:"given"`
}
if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil {
t.Fatal(err)
}
return parsed.Given
}
t.Fatalf("the provider was given no manifest: %v", out)
return nil
}
func TestAConsumersOwnContributionsAreStillThere(t *testing.T) {
// Cross-node grants are merged in with this machine's own, because from the provider's side
// they are the same thing — somebody wanting something — and a provider that had to read two
// lists would read one of them.
got, err := Resolve(shelf(proxy(), published("board", "board", 8080)),
[]string{"board"}, reachable(), mcat.World{})
if err != nil {
t.Fatal(err)
}
given := received(t, mustDeclare(t, got))
if len(given) != 1 || given[0].Node != "" {
t.Fatalf("a local contribution grew a node: %v", given)
}
}
func TestARequireOnlyConsumerOfAParameterlessProvisionStillAsks(t *testing.T) {
// A consumer that requires a parameterless provision (one whose serves names no consumer key —
// redis-cache, amqp) contributes no payload, but it still ASKS for the provision and must be
// granted a credential. Keying "asks" on contributions alone gave its grant From="" — read as
// withdrawn — so the provider never created the account and the consumer authenticated nowhere.
r := mcat.Resolution{
Node: "laptop",
Modules: []mcat.Manifest{{
Module: "amqp-ping",
Requires: []string{"amqp"},
}},
}
values, asks, err := ContributionsFrom(r, "amqp", "amqp-ping", mcat.SettingsBy{})
if err != nil {
t.Fatal(err)
}
if !asks {
t.Fatal("a require-only consumer was treated as not asking; its grant would be withdrawn and it would never be provisioned")
}
if values == nil {
t.Fatalf("asks is true but values is nil; expected an empty contribution, got %v", values)
}
}
func TestAConsumerThatStoppedAskingIsWithdrawn(t *testing.T) {
// Withdrawal is how a credential is taken away. The provisioner removes what nobody asks for,
// and it can only do that if the mesh stops asking — a consumer that was unassigned would
// otherwise keep a working login for ever, and nothing would say so.
//
// A grant with no asking module is exactly that state: the mesh still holds the secret,
// because it is sealed and unusable to the mesh anyway, and the machine no longer wants it.
got, err := Resolve(shelf(provider()), []string{"postgres"}, reachable(), mcat.World{})
if err != nil {
t.Fatal(err)
}
out, err := Declaration(got, mcat.Rendering{Grants: []mcat.Grant{
{Provision: "postgres-database", Consumer: "still-here", From: "meshboard",
Values: map[string]any{"name": "meshboard"}, Sealed: "c2VhbGVk"},
{Provision: "postgres-database", Consumer: "gone-away", Sealed: "c3RhbGU="},
}})
if err != nil {
t.Fatal(err)
}
given := grantedTo(t, out)
if len(given) != 1 || given[0].Node != "still-here" {
t.Fatalf("the provider is still told about a machine that stopped asking: %v", given)
}
// And no credential is written for it either, or the provisioner would find a file for a
// consumer its manifest does not mention and have to guess what that means.
for _, r := range out {
if path, _ := r["path"].(string); strings.Contains(path, "gone-away") {
t.Fatalf("a withdrawn consumer's credential is still delivered: %v", r)
}
}
}
@@ -0,0 +1,58 @@
package resolution
import (
"reflect"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
func TestAContributionDependsOnTheSeatThatPlacesIt(t *testing.T) {
env := mod("theme", nil, nil, nil)
env.Environment = &mcat.Environment{Variables: map[string]string{"GTK_THEME": "Adwaita:dark"}}
path := mod("toolchain", nil, nil, nil)
path.Environment = &mcat.Environment{Path: []mcat.PathEntry{{Entry: "/opt/x/bin"}}}
shell := mod("prompt", nil, nil, nil)
shell.Shell = []mcat.ShellCode{{For: "zsh", Slot: "first", Code: "true"}}
session := mod("wallpaper", nil, nil, nil)
session.Shell = []mcat.ShellCode{{For: "xinitrc", Slot: "normal", Code: "true"}}
resources := mod("bar", nil, nil, nil)
resources.Shell = []mcat.ShellCode{{For: "xresources", Slot: "normal", Code: "x: y"}}
empty := mod("nothing", nil, nil, nil)
empty.Environment = &mcat.Environment{}
cases := map[string]struct {
m mcat.Manifest
want []string
}{
"a variable": {env, []string{mcat.EnvironmentSeat}},
"a path entry": {path, []string{mcat.EnvironmentSeat}},
"shell code": {shell, []string{mcat.LoginShellSeat}},
"the session's start": {session, []string{mcat.DisplayServerSeat}},
"the session's X resources": {resources, []string{mcat.DisplayServerSeat}},
"an empty environment": {empty, nil},
}
for name, c := range cases {
got := DependsOn(nil, c.m)
if len(got) == 0 {
got = nil
}
if !reflect.DeepEqual(got, c.want) {
t.Errorf("%s depends on %v, want %v", name, got, c.want)
}
}
}
func TestAHolderMeetsItsOwnContribution(t *testing.T) {
// The display server's module contributes nothing to its own seat today, but the zsh module's
// environment contributions do go to another seat: a claim meets only the seat it names.
zsh := mod("zsh", nil, nil, nil, mcat.Claim{Name: mcat.LoginShellSeat})
zsh.Shell = []mcat.ShellCode{{For: "zsh", Slot: "normal", Code: "true"}}
zsh.Environment = &mcat.Environment{Variables: map[string]string{"EDITOR": "vim"}}
catalogue := map[string]mcat.Manifest{"zsh": zsh,
"node-env": mod("node-env", nil, nil, nil, mcat.Claim{Name: mcat.EnvironmentSeat})}
unheld := UnheldDependencies(catalogue, "laptop", []mcat.Manifest{zsh}, nil)
if len(unheld) != 1 || unheld[0].Seat != mcat.EnvironmentSeat {
t.Errorf("zsh alone: unheld %v, want only %s (its shell code is its own seat's)", unheld, mcat.EnvironmentSeat)
}
}
@@ -0,0 +1,52 @@
package resolution
import (
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// controlRecordsDecision is the record that made the two seats.
const controlRecordsDecision = "novox/hq ADR 0565"
// wantsVerbs are the assigner's verbs on mesh-wants (ADR 0565 clauses 7, 8, 9, 15).
func wantsVerbs() []mcat.Verb {
return []mcat.Verb{
{Name: "upgrade", Description: "Whether each module's new builds wait for a person: its policy (roll, together or record) " +
"and where it comes from (a person, the module, the bus, its irreplaceable data, the default); the bus is never " +
"rolled. With module, that one; with module, policy and why, a person's choice for it, recorded as a hand act; " +
"with policy default, the choice taken back. With compare, each node's own composition and the assigner's findings.",
Input: schema(map[string]string{
"module": "one module",
"policy": "with module: roll, together, record, or default to take a person's choice back",
"why": "with policy: why, required and kept",
"compare": "\"true\": each node's own composition, by its digest, and the assigner's findings",
}, nil, "compare")},
{Name: "bus", Description: "What a bus upgrade would do: the fingerprint of the bus module each node runs against the " +
"indexer's newest, and the last bus steps from the hand-act log and the assigner's puts of the bus. Read-only: " +
"the step itself is the put on the operator's warrant.",
Input: schema(map[string]string{}, nil)},
}
}
// handActsVerbs are mesh-hand-acts' verbs (ADR 0565 clause 10), as the controller's took them.
func handActsVerbs() []mcat.Verb {
return []mcat.Verb{
{Name: "hand-act", Description: "Record one act done by hand: what, why, its cause in a word or a condition's kind " +
"(the word a second act for the same reason uses) and the condition it addresses. Who did it is the caller the " +
"runtime proves, never said by the caller. Said as act-recorded.",
Input: schema(map[string]string{
"what": "what was done, in a line",
"why": "why it had to be done by hand",
"cause": "the cause in a word, or a condition's kind",
"condition": "the key of the condition it addressed, if any (optional)",
"verb": "the act's verb in the log: a repair when absent, `hand-act drill` for a drill, or a recorder's own",
}, []string{"what", "why"})},
{Name: "hand-acts", Description: "What was done by hand lately, over this log and the controller's: who, why and the " +
"cause of each, and the causes recorded twice within a fortnight (a healer is wanted).",
Input: schema(map[string]string{"days": "how many days back (default 14)", "cause": "only this cause",
"who": "only acts whose who contains this", "warrants": "\"true\": every warrant recorded"}, nil, "warrants")},
{Name: "warranted", Description: "Record what a module did on the operator's warrant: who chose, through which channel, " +
"with which proofs and which answer read from the router's own record of that module's ask, never from the " +
"caller; once per ask. Refused for an ask still open, ended without a choice, or not that module's.",
Input: schema(map[string]string{"asker": "the module that asked", "ask": "its ask's id"}, []string{"asker", "ask"})},
}
}
@@ -0,0 +1,19 @@
package resolution
import (
"encoding/json"
"slices"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// CarriesVaultSealed says whether module's own secrets are the core's credentials.
func CarriesVaultSealed(module string) bool {
return slices.Contains(mcat.CoreCredentialModules, module)
}
// vaultSealedOf is the vault's box for one of module's own secrets, if the rendering was given one.
func renderingVaultSealedOf(with mcat.Rendering, module, name string) (json.RawMessage, bool) {
box, ok := with.VaultSealed[module][name]
return box, ok && len(box) > 0
}
@@ -0,0 +1,24 @@
package resolution
import (
"reflect"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
func TestTheCoresOwnCredentialsAreTheBusTheProvisionerAndItsClient(t *testing.T) {
if want := []string{"mesh-provisioner", "mesh-provisioner-client", "nats"}; !reflect.DeepEqual(mcat.CoreCredentialModules, want) {
t.Fatalf("the core's modules are %v, want %v (mesh-vault's CoreModules is the same list)", mcat.CoreCredentialModules, want)
}
for _, m := range []string{"nats", "mesh-provisioner", "mesh-provisioner-client"} {
if !CarriesVaultSealed(m) {
t.Errorf("%s is not on the list", m)
}
}
for _, m := range []string{"postgres", "mesh-vault", "mesh-controller", ""} {
if CarriesVaultSealed(m) {
t.Errorf("%s is on the list", m)
}
}
}
+77
View File
@@ -0,0 +1,77 @@
package resolution
import (
"fmt"
"strings"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// derivedContributions is what a module's data section gives the backup holder: its backup lines and
// its items. Every item is listed, so a valuable one on a machine is measured whether or not it is
// copied; a cache is listed and not measured.
func manifestDerivedContributions(m mcat.Manifest) []mcat.SeatContribution {
items := m.DataItems()
if len(items) == 0 {
return nil
}
var lines []string
kept := map[string]bool{}
keep := func(path string) {
if !kept[path] {
kept[path] = true
lines = append(lines, "path "+path)
}
}
for _, it := range items {
if !it.BackedUp() {
continue
}
if it.Backup.IsDump() {
lines = append(lines, "run "+strings.TrimSpace(it.Backup.Dump))
if into, ok := m.DataItem(it.Backup.Into); ok {
keep(into.Path)
}
continue
}
keep(it.Path)
}
var described []string
for _, it := range items {
covered := "-"
switch {
case it.Backup.IsDump():
if into, ok := m.DataItem(it.Backup.Into); ok {
covered = into.Path
}
case it.BackedUp():
covered = it.Path
}
described = append(described, fmt.Sprintf("item %s %s %s %s %s %s", it.ID, it.Class, it.Path, covered,
it.Protection(), it.MeasuredBy()))
}
var out []mcat.SeatContribution
if len(lines) > 0 {
out = append(out, mcat.SeatContribution{Seat: mcat.BackupSeat, Kind: mcat.BackupKindBackup, Content: strings.Join(lines, "\n") + "\n"})
}
return append(out, mcat.SeatContribution{Seat: mcat.BackupSeat, Kind: mcat.BackupKindData, Content: strings.Join(described, "\n") + "\n"})
}
// allContributions is every contribution a module makes to a seat's holder: what it wrote, and what
// its data section derives. **One list**: a module that declares its data has its backup lines
// composed from it, and a backup line it still writes by hand is not placed — the catalogue check
// refuses it — so the holder never copies two lists that disagree.
func manifestAllContributions(m mcat.Manifest) []mcat.SeatContribution {
if m.Data == nil {
return m.Contributions
}
derived := manifestDerivedContributions(m)
out := make([]mcat.SeatContribution, 0, len(m.Contributions)+len(derived))
for _, c := range m.Contributions {
if s, known := mcat.SeatNamed(c.Seat); known && s.Name == mcat.BackupSeat {
continue
}
out = append(out, c)
}
return append(out, derived...)
}
@@ -0,0 +1,31 @@
package resolution
import (
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// DataItems is every data item the resolution's modules declare, placed on its node with the rendering given: the text
// the node-backup holder is handed, read. A module left out of the node's declaration keeps its items, as its data is
// still on the node (ADR 0262); an item that cannot be placed is said in unplaced and left out.
func DataItems(r mcat.Resolution, with mcat.Rendering) (items map[string][]mcat.PlacedDataItem, unplaced []string, err error) {
left := LeftOut(r, with.Settings, with.Adopted)
for module, why := range with.NotPlaced {
if _, already := left[module]; !already {
left[module] = why
}
}
modules := make([]mcat.Manifest, 0, len(r.Modules))
for _, m := range r.Modules {
if _, isLeft := left[m.Module]; isLeft {
m = backupView(m)
}
modules = append(modules, m)
}
text, unplaced, err := seatContributions(modules, mcat.Manifest{}, mcat.BackupSeat+":"+mcat.BackupKindData, with,
machineFacts(r, with.Names, with.MeshRange), r.Capabilities)
if err != nil {
return nil, unplaced, err
}
items, err = mcat.ParseDataItems(text)
return items, unplaced, err
}
+16
View File
@@ -0,0 +1,16 @@
package resolution
import (
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
func mustParse(t *testing.T, raw string) mcat.Manifest {
t.Helper()
m, err := decodeForTest([]byte(raw))
if err != nil {
t.Fatalf("refused: %v", err)
}
return m
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,84 @@
package resolution
import (
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// The mesh's user list reaches the module holding the bus, and nothing else.
//
// Three refusals and one delivery, because each of the refusals would be silent in a different way:
// a module that asked and was given it could read every credential on the bus; a bus given an empty
// file refuses every connection in the mesh and looks like a machine problem; and a bus that never
// asked gets nothing rather than a file it does not read.
func TestTheMeshsUserListGoesOnlyToTheModuleHoldingTheBus(t *testing.T) {
theBus := func() mcat.Manifest {
return mcat.Manifest{
Module: "nats", Version: "1",
Claims: []mcat.Claim{{Name: "mesh-broker", Scope: mcat.ScopeMesh}},
BusUsers: "/var/lib/nats-module/conf/accounts.conf",
Resources: []map[string]any{},
}
}
on := func(t *testing.T, m mcat.Manifest, with mcat.Rendering) ([]map[string]any, error) {
t.Helper()
return Declaration(mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{m}}, with)
}
t.Run("the holder is given it", func(t *testing.T) {
resources, err := on(t, theBus(), mcat.Rendering{BusUsers: "accounts { MESH { users = [] } }"})
if err != nil {
t.Fatal(err)
}
// Prefixed with the module it came from, like every resource: two modules may reasonably
// both call something "config", and without the prefix the second would silently replace
// the first.
var found map[string]any
for _, r := range resources {
if r["id"] == "nats."+mcat.BusUsersID() {
found = r
}
}
if found == nil {
t.Fatalf("the bus was given no user list: %+v", resources)
}
if found["path"] != "/var/lib/nats-module/conf/accounts.conf" {
t.Errorf("written to %v rather than where the module asked", found["path"])
}
if found["mode"] != "0600" {
t.Errorf("mode %v: a list of every user in the mesh belongs to the one process that "+
"needs it", found["mode"])
}
})
t.Run("a module that does not claim the seat is refused", func(t *testing.T) {
m := theBus()
m.Claims = nil
if _, err := on(t, m, mcat.Rendering{BusUsers: "accounts {}"}); err == nil {
t.Fatal("a module that claims nothing was handed every user's password hash")
}
})
t.Run("the holder with nothing composed is refused", func(t *testing.T) {
if _, err := on(t, theBus(), mcat.Rendering{}); err == nil {
t.Fatal("the bus was given an empty user list, so it would refuse every connection in " +
"the mesh and look like a machine problem")
}
})
t.Run("a module that did not ask gets nothing", func(t *testing.T) {
m := theBus()
m.BusUsers = ""
resources, err := on(t, m, mcat.Rendering{BusUsers: "accounts {}"})
if err != nil {
t.Fatal(err)
}
for _, r := range resources {
if r["id"] == "nats."+mcat.BusUsersID() {
t.Fatal("a module that asked for no user list was given one")
}
}
})
}
@@ -0,0 +1,9 @@
package resolution
import (
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// decodeForTest builds a manifest for a test as every reader reads one (Decode); the judge's own tests are in
// mesh-delivery's judge (novox/hq ADR 0578 §4).
func decodeForTest(raw []byte) (mcat.Manifest, error) { return mcat.Decode(raw) }
@@ -0,0 +1,74 @@
package resolution
import (
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// deliveryRemovedInR6 is the row of a verb release 6 removed from the delivery seat.
func deliveryRemovedInR6(name string) mcat.Verb {
return mcat.Verb{Name: name, Description: "Removed in R6 (ADR 0539): refused by name, naming its replacement: " + mcat.RemovedDeliveryVerbs[name], Input: schema(nil, nil), Optional: true}
}
// deliveryVerbs are the delivery seat's tools: what reads the merges and checks, and the acts of a person and healer H2
// on a merge (ADR 0539 clause 17).
func deliveryVerbs() []mcat.Verb {
return []mcat.Verb{
{Name: "deliveries", Description: "Every delivery not final, and those that ended in the last day, one line " +
"each: its id (owner/repository@commit), its state, its group, what it waits for and since when. Narrowed " +
"by state, repository or group.",
Input: schema(map[string]string{"state": "one state, e.g. delivering or held",
"repository": "owner/repository", "group": "a group's id (its branch name)",
"all": "\"true\": the final ones of the last thirty days too"}, nil, "all")},
deliveryRemovedInR6("times"),
{Name: "show", Description: "One delivery or group whole: its delivery plan (what it builds, what each " +
"machine receives, what is not an ordinary send), every transition with when and why, the machine " +
"steps of its walk, its group and its order.",
Input: schema(map[string]string{"id": "a delivery's id, or a group's"}, []string{"id"})},
{Name: "groups", Description: "Every delivery group: its members in order, why each pair is ordered " +
"(declared, built by, version skew, engine before controller, by name), its composed check and its " +
"state, derived from its members.",
Input: schema(map[string]string{"all": "\"true\": the groups that ended too"}, nil, "all")},
deliveryRemovedInR6("what-if"),
{Name: "checks", Description: "What the mesh's checks said of a pull request's head or of one commit: each " +
"of the commit's mesh statuses (mesh/check, the one a main requires from R4e, hq's mesh/repo-check, " +
"mesh/delivery, …) with its state, " +
"description and when it was set; the merge check's full verdict as the controller said it — each " +
"layer's summary, the machine that ran it, when, its build id and its report; and whether the branch's " +
"protection would let it merge, every required status being success.",
Input: schema(map[string]string{"repository": "owner/repository",
"number": "a pull request's number: its head is read",
"commit": "a commit's sha, or the start of one, instead of a pull request"}, []string{"repository"}),
// Added after the seat's first holder shipped: optional until mesh-delivery serves it everywhere.
Optional: true},
{Name: "table", Description: "The state table every delivery runs by: each transition with its guard, " +
"each state's bound and what healer H2 may do once it has passed; and the machine steps' table.",
Input: schema(map[string]string{}, nil)},
{Name: "stalled", Description: "Every delivery held past its state's bound, with the transition the table " +
"lets healer H2 take for it, or none.",
Input: schema(map[string]string{}, nil)},
{Name: "recheck", Description: "Check a rejected or ready delivery again: it goes back to proposed and the " +
"controller is asked for its check. With why.",
Input: schema(map[string]string{"id": "the delivery's id", "why": "why, kept with the transition"},
[]string{"id", "why"})},
deliveryRemovedInR6("release"),
deliveryRemovedInR6("stop"),
{Name: "close", Description: "Healer H2's verb: take the one transition the table names for a delivery held " +
"past its bound. Refused for any other.",
Input: schema(map[string]string{"id": "the delivery's id", "why": "the condition it answers"},
[]string{"id", "why"})},
deliveryRemovedInR6("retire-history"),
{Name: "settle", Description: "Ask the operator to settle deliveries held because their merge reached the " +
"trunk without a passing check: one ask on the operator's channel for all of them, as an Approve is asked. " +
"The verb moves nothing; on the operator's answer each goes to merged, and whether it is on the machines " +
"is derived as for any merge (novox/hq ADR 0556). Refused for a delivery not held, or held for another " +
"reason, naming why.",
Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"ids", "why"},
"properties": map[string]any{
"ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "minItems": 1,
"description": "the held deliveries' ids (owner/repository@commit)"},
"why": map[string]any{"type": "string", "description": "why they may be settled, shown to the operator"},
}},
// Added after the seat's release 6 holder shipped: optional until the gate serves it everywhere.
Optional: true},
}
}
@@ -0,0 +1,312 @@
package resolution
import (
"encoding/json"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// store is an object store in the shape minio has: it serves a region and a port to everyone, and
// a bucket named for whoever is asking.
func store() mcat.Manifest {
return mcat.Manifest{
Module: "store", Version: "1",
Provides: mcat.FromAnywhere("s3-bucket"),
Listens: []mcat.Listening{{Port: 9000, Protocol: "tcp", From: mcat.FromMesh}},
Serves: map[string]map[string]any{"s3-bucket": {
"region": "eu-west",
"bucket": "${consumer:as:dns}",
}},
Receives: map[string]string{"s3-bucket": "/var/lib/store/grants/mesh.json"},
Grants: map[string]string{"s3-bucket": "/var/lib/store/grants"},
Resources: []map[string]any{{
"id": "server", "type": "container", "name": "store", "ports": []any{"9000"},
}},
}
}
// files is a consumer that writes the bucket into its own configuration — which is the thing it
// could not do before, and had to transcribe.
func files() mcat.Manifest {
return mcat.Manifest{
Module: "files", Version: "1", Slug: "files",
Requires: []string{"s3-bucket"},
Binds: map[string]string{"s3-bucket": "/var/lib/files/store.json"},
Secrets: map[string]string{"s3-bucket": "/var/lib/files/store.secret"},
Resources: []map[string]any{{
"id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600",
"content": "BUCKET=${bound:s3-bucket:bucket}\nREGION=${bound:s3-bucket:region}\n",
}},
}
}
// pics is a second consumer of the same provider on the same machine: two derivations, neither
// the other's.
func pics() mcat.Manifest {
return mcat.Manifest{
Module: "pics", Version: "1", Slug: "pics",
Requires: []string{"s3-bucket"},
Binds: map[string]string{"s3-bucket": "/var/lib/pics/store.json"},
Secrets: map[string]string{"s3-bucket": "/var/lib/pics/store.secret"},
Resources: []map[string]any{{
"id": "env", "type": "file", "path": "/var/lib/pics/env", "mode": "0600",
"content": "BUCKET=${bound:s3-bucket:bucket}\n",
}},
}
}
// The three places the derived value lands must agree, because agreeing is the whole point: the
// consumer's own file, the binding it reads as JSON, and the provider's contributions entry.
func TestADerivedValueReachesBothEndsAndAgrees(t *testing.T) {
r, err := Resolve(shelf(store(), files()), []string{"store", "files"}, reachable(), mcat.World{})
if err != nil {
t.Fatal(err)
}
out, err := Declaration(r, mcat.Rendering{Grants: []mcat.Grant{{
Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
Values: map[string]any{}, Sealed: "c2VhbGVk",
}}})
if err != nil {
t.Fatal(err)
}
// The mesh minted this identity for the consumer; the bucket is that identity as a DNS label.
// Derived here with the mesh's own function, so the test cannot agree with a wrong rule.
as := mcat.ConsumerIdentity("workstation", mcat.IdentitySource("files", "files"))
want := strings.ReplaceAll(as, "_", "-")
if want == as || !strings.Contains(as, "_") {
t.Fatalf("the mesh's identity %q has no separator to rewrite; this test proves nothing", as)
}
env := fileNamed(out, "files.env")
if env == nil {
t.Fatalf("the consumer was given no file: %v", out)
}
if got := env["content"].(string); !strings.Contains(got, "BUCKET="+want+"\n") {
t.Errorf("the consumer's own file was not told the bucket:\n%s\nwant BUCKET=%s", got, want)
}
binding := fileNamed(out, "files.bound-s3-bucket")
if binding == nil {
t.Fatalf("the consumer was given no binding: %v", out)
}
var said struct {
Serves map[string]any `json:"serves"`
}
if err := json.Unmarshal([]byte(binding["content"].(string)), &said); err != nil {
t.Fatal(err)
}
if said.Serves["bucket"] != want {
t.Errorf("the binding says the bucket is %q, want %q", said.Serves["bucket"], want)
}
// And what is the same for everybody is still the same for everybody.
if said.Serves["region"] != "eu-west" {
t.Errorf("the binding lost what the provider serves to all: %v", said.Serves)
}
given := storeGrants(t, out)
if len(given) != 1 {
t.Fatalf("the provider was told about %d consumer(s): %v", len(given), given)
}
if given[0].Derived["bucket"] != want {
t.Errorf("the provider was told the bucket is %v, and the consumer was told %q — "+
"the two ends disagree, which is the whole failure", given[0].Derived["bucket"], want)
}
// Only the per-consumer half. The region is the same for everyone and is already in the
// provider's own definition; repeating it here would be a copy to go stale.
if _, carried := given[0].Derived["region"]; carried {
t.Errorf("the provider was handed back what it already says for everyone: %v", given[0].Derived)
}
}
// Two consumers of one provider on one machine get two buckets, and neither gets the other's.
func TestTwoConsumersOfOneProviderGetTheirOwnDerivation(t *testing.T) {
r, err := Resolve(shelf(store(), files(), pics()),
[]string{"store", "files", "pics"}, reachable(), mcat.World{})
if err != nil {
t.Fatal(err)
}
out, err := Declaration(r, mcat.Rendering{Grants: []mcat.Grant{
{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
Values: map[string]any{}, Sealed: "c2VhbGVk"},
{Provision: "s3-bucket", Consumer: "workstation", From: "pics", Slug: "pics",
Values: map[string]any{}, Sealed: "c2VhbGVk"},
}})
if err != nil {
t.Fatal(err)
}
forFiles := strings.ReplaceAll(mcat.ConsumerIdentity("workstation", mcat.IdentitySource("files", "files")), "_", "-")
forPics := strings.ReplaceAll(mcat.ConsumerIdentity("workstation", mcat.IdentitySource("pics", "pics")), "_", "-")
if forFiles == forPics {
t.Fatal("the two consumers were given the same identity; this test proves nothing")
}
if got := fileNamed(out, "files.env")["content"].(string); !strings.Contains(got, "BUCKET="+forFiles+"\n") {
t.Errorf("files was not given its own bucket:\n%s", got)
}
if got := fileNamed(out, "pics.env")["content"].(string); !strings.Contains(got, "BUCKET="+forPics+"\n") {
t.Errorf("pics was not given its own bucket:\n%s", got)
}
var buckets []any
for _, g := range storeGrants(t, out) {
buckets = append(buckets, g.Derived["bucket"])
}
if len(buckets) != 2 || buckets[0] == buckets[1] {
t.Errorf("the provider was told %v; it must be told one bucket per consumer", buckets)
}
}
// An operator may still set what the provider serves where its definition asks (${setting:…}), and
// the mesh still derives the rest: the setting is filled first, then the consumer's half. (Until
// novox/hq issue 696 the layer replaced the served bucket whole; a layer replaces no served key now.)
func TestASettingComposesWithADerivedValue(t *testing.T) {
prefixed := store()
prefixed.Serves["s3-bucket"]["bucket"] = "${setting:bucket-prefix}${consumer:as:dns}"
r, err := Resolve(shelf(prefixed, files()), []string{"store", "files"}, reachable(), mcat.World{})
if err != nil {
t.Fatal(err)
}
out, err := Declaration(r, mcat.Rendering{
Settings: mcat.SettingsBy{"store": {{From: "the operator",
Values: map[string]any{"bucket-prefix": "team-"}}}},
Grants: []mcat.Grant{{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
Values: map[string]any{}, Sealed: "c2VhbGVk"}},
})
if err != nil {
t.Fatal(err)
}
want := "team-" + strings.ReplaceAll(mcat.ConsumerIdentity("workstation", mcat.IdentitySource("files", "files")), "_", "-")
if got := fileNamed(out, "files.env")["content"].(string); !strings.Contains(got, "BUCKET="+want+"\n") {
t.Errorf("the operator's prefix did not survive the derivation:\n%s\nwant BUCKET=%s", got, want)
}
if given := storeGrants(t, out); given[0].Derived["bucket"] != want {
t.Errorf("the provider was told %v, the consumer %q", given[0].Derived["bucket"], want)
}
}
// `dns` is checked against an identity the mesh actually mints, not an invented string.
func TestTheDNSAlphabetIsTheMintedIdentityWithItsSeparatorRewritten(t *testing.T) {
as := mcat.ConsumerIdentity("anchor", mcat.IdentitySource("ncloud", "nextcloud"))
if err := mcat.CheckIdentity("anchor", mcat.IdentitySource("ncloud", "nextcloud")); err != nil {
t.Fatalf("the mesh would not mint this identity at all: %v", err)
}
label := asDNSLabel(as)
if strings.Contains(label, "_") {
t.Errorf("%q is not a DNS label", label)
}
if strings.ReplaceAll(label, "-", "_") != as {
t.Errorf("%q is not %q with its separator rewritten", label, as)
}
}
// The check that would have caught the one wrong instance: a consumer that writes the derived
// value into its own definition instead of asking for it is refused, whether it transcribed the
// right answer or a predecessor's.
func TestAConsumerThatTranscribesWhatItsProviderDerivesIsRefused(t *testing.T) {
as := mcat.ConsumerIdentity("workstation", mcat.IdentitySource("files", "files"))
transcribed := strings.ReplaceAll(as, "_", "-")
m := files()
m.Resources = []map[string]any{{
"id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600",
// Exactly what the provider will create — correct today, and a copy of a rule that is
// not this module's.
"content": "BUCKET=" + transcribed + "\n",
}}
r, err := Resolve(shelf(store(), m), []string{"store", "files"}, reachable(), mcat.World{})
if err != nil {
t.Fatal(err)
}
_, err = Declaration(r, mcat.Rendering{Grants: []mcat.Grant{{
Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
Values: map[string]any{}, Sealed: "c2VhbGVk",
}}})
if err == nil {
t.Fatal("a definition holding its own copy of the provider's naming rule was accepted")
}
if !strings.Contains(err.Error(), "${bound:s3-bucket:bucket}") {
t.Errorf("the refusal does not say what to write instead: %v", err)
}
// And a constant the provider serves to everyone is not a transcription: repeating it is
// redundant, not wrong, and refusing it would be the mesh policing style.
m.Resources = []map[string]any{{
"id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600",
"content": "REGION=eu-west\n",
}}
r, err = Resolve(shelf(store(), m), []string{"store", "files"}, reachable(), mcat.World{})
if err != nil {
t.Fatal(err)
}
if _, err := Declaration(r, mcat.Rendering{Grants: []mcat.Grant{{
Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
Values: map[string]any{}, Sealed: "c2VhbGVk",
}}}); err != nil {
t.Errorf("a value the provider serves to everyone was judged a transcription: %v", err)
}
}
func storeGrants(t *testing.T, out []map[string]any) []mcat.Contribution {
t.Helper()
for _, r := range out {
if r["path"] != "/var/lib/store/grants/mesh.json" {
continue
}
var parsed struct {
Given []mcat.Contribution `json:"given"`
}
if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil {
t.Fatal(err)
}
return parsed.Given
}
t.Fatalf("the provider was given no contributions file: %v", out)
return nil
}
// A consumer that keeps SEVERAL holders of one provision is refused, rather than told one thing
// while its provider is told another.
//
// **This is issue 124's own failure, one case to the side.** The mesh gives each holder its own
// login — `mesh_node_mod_<local>` (ADR 0094) — and the provider derives from the login, so it
// would make one resource per holder. The consumer's side has no such dimension: there is one
// binding file per provision and one `${bound:<provision>:<key>}`, both derived from the
// un-suffixed identity. So the provider would create `…-mod-cold` and the consumer would be
// configured against `…-mod`: it would authenticate successfully and be refused on every object,
// which is exactly the fault this whole record exists to end.
//
// Refused, loudly, at the one place that can see both halves. Lifting it means giving the
// consumer's side a local dimension, which is a decision and not an omission.
func TestAConsumerWithSeveralHoldersOfADerivingProviderIsRefused(t *testing.T) {
m := files()
// Two holders of the one provision, the shape ADR 0094 gives a module that keeps several.
m.Secrets = nil
m.SecretsMany = map[string]map[string]string{"s3-bucket": {
"hot": "/var/lib/files/hot.secret",
"cold": "/var/lib/files/cold.secret",
}}
m.Resources = []map[string]any{{
"id": "env", "type": "file", "path": "/var/lib/files/env", "mode": "0600",
"content": "BUCKET=${bound:s3-bucket:bucket}\n",
}}
r, err := Resolve(shelf(store(), m), []string{"store", "files"}, reachable(), mcat.World{})
if err != nil {
t.Fatal(err)
}
_, err = Declaration(r, mcat.Rendering{Grants: []mcat.Grant{
{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
Local: "hot", Values: map[string]any{}, Sealed: "c2VhbGVk"},
{Provision: "s3-bucket", Consumer: "workstation", From: "files", Slug: "files",
Local: "cold", Values: map[string]any{}, Sealed: "c2VhbGVk"},
}})
if err == nil {
t.Fatal("a consumer with several holders of a deriving provider was accepted; " +
"its two ends would have disagreed in silence")
}
for _, want := range []string{"files", "s3-bucket", "bucket"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the refusal does not name %q: %v", want, err)
}
}
}
+239
View File
@@ -0,0 +1,239 @@
package resolution
import (
"fmt"
"regexp"
"sort"
"strings"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// defaultDataRoot is where module data lands when a node states no root of its own.
const defaultDataRoot = "/var/lib"
// placeOwn is the assignment's own root, <root>/<module> — to-be 27's one directory per
// assignment, which every other placed thing of the module sits beneath.
const placeOwn = "."
// placeMesh is where the mesh keeps what it writes for the module, <root>/mesh/<module>.
const placeMesh = "mesh"
// dirRef is how a module names one of its placed directories: ${dir:<id>}.
var dirRef = regexp.MustCompile(`\$\{dir:([a-z0-9][a-z0-9-]*)\}`)
// dataRoot is the root this node keeps placed directories under.
func dataRoot(with mcat.Rendering) string {
if root := strings.TrimRight(strings.TrimSpace(with.DataRoot), "/"); root != "" {
return root
}
return defaultDataRoot
}
// dirsFor is every placed directory of a module, id → the path it resolves to on this node.
//
// A pathless directory saying `"place": "."` is the assignment's own root, <root>/<module>; one
// saying `"place": "mesh"` is the mesh's directory for the module, <root>/mesh/<module>; one
// saying neither is <root>/<module>/<id>. At most one of each place makes sense; nothing enforces
// one, because two ids resolving to one path is a mistake the module's own files make visible
// immediately.
//
// A stated path may itself begin with a placed reference — `${dir:mesh-state}/state` — and is
// filled after the directories it can name are resolved; one level, because a directory beneath
// a placed one is the whole of what an adopted layout needs (issue 174's `state` and `out`).
func dirsFor(m mcat.Manifest, with mcat.Rendering) map[string]string {
dirs := map[string]string{}
var beneath []map[string]any
// The assignment's placement wins over both (novox/hq issue 153). Refused elsewhere when
// malformed; here an invalid setting simply places nothing.
placed, _ := mcat.Places(m, with.Settings[m.Module])
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "directory" {
continue
}
id := fmt.Sprint(r["id"])
if p, said := placed[id]; said {
dirs[id] = p.Path
continue
}
if path, stated := r["path"].(string); stated && path != "" {
if strings.HasPrefix(path, "${dir:") {
beneath = append(beneath, r)
continue
}
dirs[id] = strings.TrimRight(path, "/")
continue
}
switch place, _ := r["place"].(string); place {
case placeOwn:
dirs[id] = dataRoot(with) + "/" + m.Module
case placeMesh:
dirs[id] = dataRoot(with) + "/mesh/" + m.Module
default:
dirs[id] = dataRoot(with) + "/" + m.Module + "/" + id
}
}
for _, r := range beneath {
path := strings.TrimRight(r["path"].(string), "/")
// A reference to no directory is left as written and refused where the resource is
// placed (dirInto), with the message that names what exists.
filled, _ := dirFill(path, dirs, m.Module)
dirs[fmt.Sprint(r["id"])] = filled
}
return dirs
}
// dirFill resolves every ${dir:…} in one string, or refuses a reference naming no directory.
func dirFill(s string, dirs map[string]string, module string) (string, error) {
var missing error
out := dirRef.ReplaceAllStringFunc(s, func(ref string) string {
id := dirRef.FindStringSubmatch(ref)[1]
path, has := dirs[id]
if !has {
missing = fmt.Errorf(
"%s says ${dir:%s}, and %s declares no directory %q. It declares %s",
module, id, module, id, orNothing(namesOfDirs(dirs)))
return ref
}
return path
})
return out, missing
}
// placedManifest is the manifest with every path the mesh resolves already resolved: the maps
// naming where bindings, credentials and contributions land are filled against this node's
// placed directories, so everything downstream — the generated binding files, the sealed
// secrets, the grant directories — reads a concrete place and learns nothing new.
func placedManifest(m mcat.Manifest, with mcat.Rendering) (mcat.Manifest, error) {
dirs := dirsFor(m, with)
fillMap := func(in map[string]string) (map[string]string, error) {
if len(in) == 0 {
return in, nil
}
out := make(map[string]string, len(in))
for key, value := range in {
filled, err := dirFill(value, dirs, m.Module)
if err != nil {
return nil, err
}
out[key] = filled
}
return out, nil
}
var err error
if m.Receives, err = fillMap(m.Receives); err != nil {
return m, err
}
if m.Binds, err = fillMap(m.Binds); err != nil {
return m, err
}
if m.Secrets, err = fillMap(m.Secrets); err != nil {
return m, err
}
if len(m.OwnSecrets) > 0 {
own := make(mcat.OwnSecrets, len(m.OwnSecrets))
for name, s := range m.OwnSecrets {
filled, err := dirFill(s.Path, dirs, m.Module)
if err != nil {
return m, err
}
own[name] = mcat.OwnSecret{Path: filled, Taken: s.Taken, IssuedBy: s.IssuedBy, Prompt: s.Prompt, Owner: s.Owner}
}
m.OwnSecrets = own
}
if m.Grants, err = fillMap(m.Grants); err != nil {
return m, err
}
if len(m.SecretsMany) > 0 {
many := make(map[string]map[string]string, len(m.SecretsMany))
for to, locals := range m.SecretsMany {
if many[to], err = fillMap(locals); err != nil {
return m, err
}
}
m.SecretsMany = many
}
return m, nil
}
// dirInto places a resource: a pathless directory is given the path the mesh resolved for it,
// and every ${dir:…} the resource carries — in its path, its content, its mounts, its
// environment and its env-files — becomes that path.
//
// A reference naming no directory of this module is refused. Left as written, the literal
// `${dir:x}` would reach the machine as a path, and the runtime would create and mount a
// directory called `${dir:x}` — real, wrong, and named after the mistake.
func dirInto(resource map[string]any, dirs map[string]string, module string) error {
fill := func(s string) (string, error) { return dirFill(s, dirs, module) }
if fmt.Sprint(resource["type"]) == "directory" {
id := fmt.Sprint(resource["id"])
if path, stated := resource["path"].(string); !stated || path == "" {
resource["path"] = dirs[id]
}
// Said in the catalogue, not on the machine: the host parses strictly and knows no
// such field — resolved, the place IS the path.
delete(resource, "place")
}
var err error
if path, ok := resource["path"].(string); ok {
if resource["path"], err = fill(path); err != nil {
return err
}
}
if content, ok := resource["content"].(string); ok {
if resource["content"], err = fill(content); err != nil {
return err
}
}
// Nested values are rebuilt, never written into: the resource is a shallow copy of the
// manifest's own map, and the manifest is composed once per node — a fill written in place
// would leave the first node's paths inside every later composition.
if volumes, ok := resource["volumes"].([]any); ok {
filled := make([]any, len(volumes))
for i, v := range volumes {
filled[i] = v
if mount, ok := v.(string); ok {
if filled[i], err = fill(mount); err != nil {
return err
}
}
}
resource["volumes"] = filled
}
if env, ok := resource["env"].(map[string]any); ok {
filled := make(map[string]any, len(env))
for key, v := range env {
filled[key] = v
if value, ok := v.(string); ok {
if filled[key], err = fill(value); err != nil {
return err
}
}
}
resource["env"] = filled
}
if files, ok := resource["env-file"].([]any); ok {
filled := make([]any, len(files))
for i, v := range files {
filled[i] = v
if path, ok := v.(string); ok {
if filled[i], err = fill(path); err != nil {
return err
}
}
}
resource["env-file"] = filled
}
return nil
}
func namesOfDirs(dirs map[string]string) []string {
var names []string
for id := range dirs {
names = append(names, fmt.Sprintf("%q", id))
}
sort.Strings(names)
return names
}
@@ -0,0 +1,271 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
func placedModule() mcat.Manifest {
return mcat.Manifest{
Module: "photos",
Resources: []map[string]any{
{"id": "data", "type": "directory", "mode": "0700"},
{"id": "server-env", "type": "file", "path": "${dir:data}/server.env",
"content": "STORE=${dir:data}/objects\n"},
{"id": "server", "type": "container", "name": "photos-server",
"volumes": []any{"${dir:data}:/data"},
"env": map[string]any{"DATA": "${dir:data}/objects"},
"env-file": []any{"${dir:data}/server.env"}},
},
}
}
func TestAPathlessDirectoryResolvesUnderTheNodesRoot(t *testing.T) {
m := placedModule()
dirs := dirsFor(m, mcat.Rendering{})
if dirs["data"] != "/var/lib/photos/data" {
t.Fatalf("the default root is /var/lib and the shape is <root>/<module>/<id>; got %q", dirs["data"])
}
dirs = dirsFor(m, mcat.Rendering{DataRoot: "/tank/nox/"})
if dirs["data"] != "/tank/nox/photos/data" {
t.Fatalf("a node's own root is honoured, trailing slash and all; got %q", dirs["data"])
}
}
func TestDirReferencesBecomeThePlaceInEveryField(t *testing.T) {
m := placedModule()
dirs := dirsFor(m, mcat.Rendering{})
directory := shallowCopy(m.Resources[0])
if err := dirInto(directory, dirs, m.Module); err != nil {
t.Fatal(err)
}
if directory["path"] != "/var/lib/photos/data" {
t.Fatalf("a pathless directory receives its resolved path; got %v", directory["path"])
}
file := shallowCopy(m.Resources[1])
if err := dirInto(file, dirs, m.Module); err != nil {
t.Fatal(err)
}
if file["path"] != "/var/lib/photos/data/server.env" {
t.Fatalf("a file's path names the place; got %v", file["path"])
}
if file["content"] != "STORE=/var/lib/photos/data/objects\n" {
t.Fatalf("a file's content names the place; got %v", file["content"])
}
container := shallowCopy(m.Resources[2])
if err := dirInto(container, dirs, m.Module); err != nil {
t.Fatal(err)
}
if container["volumes"].([]any)[0] != "/var/lib/photos/data:/data" {
t.Fatalf("a mount names the place; got %v", container["volumes"])
}
if container["env"].(map[string]any)["DATA"] != "/var/lib/photos/data/objects" {
t.Fatalf("an environment value names the place; got %v", container["env"])
}
if container["env-file"].([]any)[0] != "/var/lib/photos/data/server.env" {
t.Fatalf("an env-file names the place; got %v", container["env-file"])
}
}
func TestAStatedPathIsThePlacementAndStillAnswersByName(t *testing.T) {
m := placedModule()
// The adopted-machine case: data that must sit where the predecessor already put it.
m.Resources[0]["path"] = "/services/mssql/data/"
dirs := dirsFor(m, mcat.Rendering{})
if dirs["data"] != "/services/mssql/data" {
t.Fatalf("a stated path wins over the root, trimmed; got %q", dirs["data"])
}
container := shallowCopy(m.Resources[2])
if err := dirInto(container, dirs, m.Module); err != nil {
t.Fatal(err)
}
if container["volumes"].([]any)[0] != "/services/mssql/data:/data" {
t.Fatalf("references follow the placement; got %v", container["volumes"])
}
}
func TestFillingForOneNodeLeaksIntoNoOther(t *testing.T) {
m := placedModule()
first := shallowCopy(m.Resources[2])
if err := dirInto(first, dirsFor(m, mcat.Rendering{DataRoot: "/first"}), m.Module); err != nil {
t.Fatal(err)
}
second := shallowCopy(m.Resources[2])
if err := dirInto(second, dirsFor(m, mcat.Rendering{DataRoot: "/second"}), m.Module); err != nil {
t.Fatal(err)
}
if got := second["volumes"].([]any)[0]; got != "/second/photos/data:/data" {
t.Fatalf("the second composition must see the manifest, not the first fill; got %v", got)
}
if m.Resources[2]["volumes"].([]any)[0] != "${dir:data}:/data" {
t.Fatalf("the manifest itself stays a template; got %v", m.Resources[2]["volumes"])
}
}
func TestAReferenceToNoDirectoryRefusesAtCompositionToo(t *testing.T) {
m := placedModule()
container := shallowCopy(m.Resources[2])
container["env"] = map[string]any{"DATA": "${dir:date}"}
err := dirInto(container, dirsFor(m, mcat.Rendering{}), m.Module)
if err == nil || !strings.Contains(err.Error(), `"date"`) || !strings.Contains(err.Error(), `"data"`) {
t.Fatalf("the refusal names the mistake and what exists; got %v", err)
}
}
func TestTheAssignmentsOwnRootIsAPlace(t *testing.T) {
m := mcat.Manifest{Module: "mailu", Resources: []map[string]any{
{"id": "state", "type": "directory", "place": ".", "mode": "0700"},
{"id": "data-mail", "type": "directory"},
}}
dirs := dirsFor(m, mcat.Rendering{})
if dirs["state"] != "/var/lib/mailu" {
t.Fatalf("place %q is the assignment's root; got %q", ".", dirs["state"])
}
if dirs["data-mail"] != "/var/lib/mailu/data-mail" {
t.Fatalf("everything else sits beneath it; got %q", dirs["data-mail"])
}
root := shallowCopy(m.Resources[0])
if err := dirInto(root, dirs, m.Module); err != nil {
t.Fatal(err)
}
if root["path"] != "/var/lib/mailu" {
t.Fatalf("the root receives its path; got %v", root["path"])
}
if _, still := root["place"]; still {
t.Fatal("place must never reach the host, which parses strictly")
}
}
func TestTheManifestsMapsArePlaced(t *testing.T) {
m := mcat.Manifest{
Module: "photos",
Resources: []map[string]any{
{"id": "state", "type": "directory", "place": "."},
},
Binds: map[string]string{"route": "${dir:state}/route.json"},
Secrets: map[string]string{"mongodb-database": "${dir:state}/database.secret"},
OwnSecrets: mcat.OwnSecrets{"admin-key": {Path: "${dir:state}/admin-key.secret"}},
Receives: map[string]string{"route": "${dir:state}/grants/mesh.json"},
}
placed, err := placedManifest(m, mcat.Rendering{})
if err != nil {
t.Fatal(err)
}
if placed.Binds["route"] != "/var/lib/photos/route.json" {
t.Fatalf("binds are placed; got %v", placed.Binds)
}
if placed.Secrets["mongodb-database"] != "/var/lib/photos/database.secret" {
t.Fatalf("secrets are placed; got %v", placed.Secrets)
}
if placed.OwnSecrets["admin-key"].Path != "/var/lib/photos/admin-key.secret" {
t.Fatalf("own-secrets are placed; got %v", placed.OwnSecrets)
}
if placed.Receives["route"] != "/var/lib/photos/grants/mesh.json" {
t.Fatalf("receives are placed; got %v", placed.Receives)
}
if m.Binds["route"] != "${dir:state}/route.json" {
t.Fatalf("the manifest itself stays a template; got %v", m.Binds)
}
}
func TestTheMeshsDirectoryForAModuleIsAPlace(t *testing.T) {
// novox/hq issue 174. What the mesh writes for a module — its bus credential, its bindings —
// is the mesh's plumbing under <root>/mesh/<module>, and the definition names it by id.
m := mcat.Manifest{Module: "umami",
Resources: []map[string]any{
{"id": "mesh-state", "type": "directory", "place": "mesh"},
{"id": "state", "type": "directory", "place": "."},
{"id": "server", "type": "container", "image": "x@sha256:aa",
"volumes": []any{"${dir:mesh-state}/broker:/run/secrets/broker:ro"}},
},
OwnSecrets: mcat.OwnSecrets{"broker": {Path: "${dir:mesh-state}/broker"}},
Binds: map[string]string{"route": "${dir:state}/route.json"},
}
dirs := dirsFor(m, mcat.Rendering{})
if dirs["mesh-state"] != "/var/lib/mesh/umami" || dirs["state"] != "/var/lib/umami" {
t.Fatalf("the mesh's directory sits beside the module's, not in it; got %v", dirs)
}
dirs = dirsFor(m, mcat.Rendering{DataRoot: "/srv"})
if dirs["mesh-state"] != "/srv/mesh/umami" {
t.Fatalf("a node's root moves the mesh's files with the module's; got %v", dirs)
}
placed, err := placedManifest(m, mcat.Rendering{})
if err != nil {
t.Fatal(err)
}
if placed.OwnSecrets["broker"].Path != "/var/lib/mesh/umami/broker" {
t.Fatalf("own-secrets are placed under the mesh's directory; got %v", placed.OwnSecrets)
}
container := shallowCopy(m.Resources[2])
if err := dirInto(container, dirsFor(m, mcat.Rendering{}), m.Module); err != nil {
t.Fatal(err)
}
if container["volumes"].([]any)[0] != "/var/lib/mesh/umami/broker:/run/secrets/broker:ro" {
t.Fatalf("the mount's host side is placed; got %v", container["volumes"])
}
}
func TestTwoModulesPlacedRootsAreNoCollision(t *testing.T) {
a := mcat.Manifest{Module: "gitea", Resources: []map[string]any{
{"id": "state", "type": "directory", "place": "."},
{"id": "env", "type": "file", "path": "${dir:state}/server.env", "content": ""},
}}
b := mcat.Manifest{Module: "nextcloud", Resources: []map[string]any{
{"id": "state", "type": "directory", "place": "."},
{"id": "env", "type": "file", "path": "${dir:state}/server.env", "content": ""},
}}
if got := checkResources([]mcat.Manifest{a, b}); len(got) != 0 {
t.Fatalf("alike templates are different places; got %v", got)
}
// And the real collision is still caught: a module stating another's placed root.
c := mcat.Manifest{Module: "squatter", Resources: []map[string]any{
{"id": "nest", "type": "directory", "path": "/var/lib/gitea"},
}}
got := checkResources([]mcat.Manifest{a, c})
if len(got) != 1 || !strings.Contains(got[0], `"/var/lib/gitea"`) {
t.Fatalf("a stated path on a placed root collides; got %v", got)
}
}
func shallowCopy(resource map[string]any) map[string]any {
copied := map[string]any{}
for k, v := range resource {
copied[k] = v
}
return copied
}
func TestADirectoryBeneathAPlacedOneIsPlacedWithIt(t *testing.T) {
// An adopted layout keeps a subdirectory the predecessor made under the mesh's directory
// (issue 174: a forge's runtime state, a manager's output). Stated as beneath the placed one,
// it moves with it — a node's root moves both, and the definition names no host path.
m := mcat.Manifest{Module: "gitea", Resources: []map[string]any{
{"id": "mesh-state", "type": "directory", "place": "mesh"},
{"id": "runtime-state", "type": "directory", "path": "${dir:mesh-state}/state"},
{"id": "server", "type": "container", "image": "x@sha256:aa",
"volumes": []any{"${dir:runtime-state}:/data"}},
}}
dirs := dirsFor(m, mcat.Rendering{DataRoot: "/srv"})
if dirs["runtime-state"] != "/srv/mesh/gitea/state" {
t.Fatalf("the subdirectory follows the placed one; got %v", dirs)
}
sub := shallowCopy(m.Resources[1])
if err := dirInto(sub, dirs, m.Module); err != nil {
t.Fatal(err)
}
if sub["path"] != "/srv/mesh/gitea/state" {
t.Fatalf("the directory resource itself is resolved; got %v", sub["path"])
}
container := shallowCopy(m.Resources[2])
if err := dirInto(container, dirs, m.Module); err != nil {
t.Fatal(err)
}
if container["volumes"].([]any)[0] != "/srv/mesh/gitea/state:/data" {
t.Fatalf("a reference to the subdirectory resolves whole; got %v", container["volumes"])
}
}
+7
View File
@@ -0,0 +1,7 @@
// Package resolution is the node-engine's resolution and rendering of a machine's modules from their manifests: which
// modules a node runs and why (Resolve), what each composes to (Compose, Declaration, DataItems), what is left out and
// why, and every refusal of a composition, settings and templates included. It moved here from mesh-sdk's catalogue
// package (novox/hq ADR 0593 §1, §7): only the node-engine composes, so mesh-sdk carries no composition rule. The
// manifest's shapes and the indexer's judgement stay in mesh-sdk; a method of a mesh-sdk shape that composes is a
// function here taking that shape first (Compose(r, with) for Resolution.Compose).
package resolution
+119
View File
@@ -0,0 +1,119 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
func networkingShelf(extra ...mcat.Manifest) map[string]mcat.Manifest {
base := []mcat.Manifest{
{Module: "networking", Requires: []string{"private-network", "name-resolution"}},
{Module: "mesh-wireguard", Computed: "mesh-wireguard",
Provides: mcat.Offers("private-network", "mesh-addressing"),
Claims: []mcat.Claim{{Name: "the-private-network", Scope: mcat.ScopeNode}}},
{Module: "mesh-names", Computed: "mesh-names",
Provides: mcat.Offers("name-resolution"), Requires: []string{"mesh-addressing"}},
}
return shelf(append(base, extra...)...)
}
func TestOneWordBringsUpTheNetwork(t *testing.T) {
// The case that has to stay easy. Nothing is asked, because with one answer to each
// requirement there was never a question.
got, err := Resolve(networkingShelf(), []string{"networking"}, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
have := strings.Join(names(got), " ")
for _, want := range []string{"networking", "mesh-wireguard", "mesh-names"} {
if !strings.Contains(have, want) {
t.Fatalf("assigning networking did not bring in %s: %s", want, have)
}
}
}
func TestASecondVPNTurnsItIntoAChoice(t *testing.T) {
// And the choice is offered rather than made. A default here would be the flavor field coming
// back under another name.
_, err := Resolve(networkingShelf(mcat.Manifest{Module: "tailscale", Provides: mcat.Offers("private-network"),
Claims: []mcat.Claim{{Name: "the-private-network", Scope: mcat.ScopeNode}}},
), []string{"networking"}, workstation(), mcat.World{})
if err == nil {
t.Fatal("two VPNs and one was picked silently")
}
for _, want := range []string{"mesh-wireguard", "tailscale"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("the refusal does not name %s: %v", want, err)
}
}
}
func TestChoosingIsAssigning(t *testing.T) {
// No second verb. Assigning the one you want answers the requirement, and the bundle takes it.
got, err := Resolve(networkingShelf(mcat.Manifest{Module: "tailscale",
Provides: mcat.Offers("private-network", "name-resolution"),
Claims: []mcat.Claim{{Name: "the-private-network", Scope: mcat.ScopeNode}}},
), []string{"networking", "tailscale"}, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
have := strings.Join(names(got), " ")
if !strings.Contains(have, "tailscale") {
t.Fatalf("the chosen VPN is not in the set: %s", have)
}
if strings.Contains(have, "mesh-wireguard") {
t.Fatalf("choosing tailscale still installed WireGuard: %s", have)
}
}
func TestChoosingOneVPNCannotDragTheOtherBackIn(t *testing.T) {
// This happened. The person chose tailscale; the names module required the mesh's own
// addressing; only WireGuard provides that; so both were installed and nobody was told.
//
// The claim is what catches it. Providing a private network is not the singular part — a
// machine could run two VPNs for two purposes — but being *the* one the mesh runs over is.
_, err := Resolve(networkingShelf(mcat.Manifest{Module: "tailscale", Provides: mcat.Offers("private-network"),
Claims: []mcat.Claim{{Name: "the-private-network", Scope: mcat.ScopeNode}}},
), []string{"networking", "tailscale"}, workstation(), mcat.World{})
if err == nil {
t.Fatal("a machine was given two private networks without being told")
}
if !strings.Contains(err.Error(), "the-private-network") {
t.Fatalf("the refusal does not say what collided: %v", err)
}
}
func TestNamesNeedTheMeshsOwnAddresses(t *testing.T) {
// Names are computed from addresses the mesh handed out. Over a VPN that hands out its own,
// the mesh has nothing to write, so the names module requires the addressing rather than a
// private network in general — otherwise a machine gets a hosts file full of addresses that
// mean nothing on it.
_, err := Resolve(shelf(mcat.Manifest{Module: "mesh-names", Computed: "mesh-names",
Provides: mcat.Offers("name-resolution"), Requires: []string{"mesh-addressing"}}, mcat.Manifest{Module: "tailscale", Provides: mcat.Offers("private-network")},
), []string{"mesh-names", "tailscale"}, workstation(), mcat.World{})
if err == nil {
t.Fatal("the mesh's names were installed over a VPN whose addresses it does not hand out")
}
if !strings.Contains(err.Error(), "mesh-addressing") {
t.Fatalf("the refusal does not say what is missing: %v", err)
}
}
func TestARequirementWantedTwiceIsReportedOnce(t *testing.T) {
// Two identical lines make a person hunt for the difference between them before realising
// there is none.
_, err := Resolve(shelf(mcat.Manifest{Module: "one", Requires: []string{"shell"}}, mcat.Manifest{Module: "two", Requires: []string{"shell"}}, mcat.Manifest{Module: "bash", Provides: mcat.Offers("shell")}, mcat.Manifest{Module: "zsh", Provides: mcat.Offers("shell")}), []string{"one", "two"}, workstation(), mcat.World{})
if err == nil {
t.Fatal("two shells and one was picked silently")
}
if n := strings.Count(err.Error(), `"shell" is wanted by`); n != 1 {
t.Fatalf("the same requirement was reported %d times:\n%v", n, err)
}
}
@@ -0,0 +1,143 @@
package resolution
import (
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// aMediaServer is the shape one port number per key cannot express: two endpoints of different kinds.
// A web surface a proxy serves under a subdomain, and a protocol port clients dial directly because
// the client expects that number.
func aMediaServer() mcat.Manifest {
return mcat.Manifest{
Module: "media",
Listens: []mcat.Listening{
{Name: "web", Port: 80, From: mcat.FromMesh, Why: "the app, behind the proxy"},
{Name: "stream", Port: 32400, From: mcat.FromEverywhere, Fixed: true,
Why: "the client dials this number; the protocol chose it"},
},
Contributes: map[string]map[string]any{
"route": {"label": "media", mcat.RouteEndpoint: "web"},
},
// Both endpoints are published by its container, which is what lets a machine port be given
// for either: the mesh moves a port the module publishes, never one it merely listens on.
Resources: []map[string]any{
{"id": "server", "type": "container", "name": "media",
"ports": []any{"80", "32400"}},
},
}
}
// And the routed set is read through the name, so the endpoint the proxy serves is known without a
// reader joining two numbers.
func TestTheRoutedEndpointIsFoundByName(t *testing.T) {
routed := RoutedPorts(aMediaServer())
if !routed[80] {
t.Fatalf("the routed endpoint was not found by name: %v", routed)
}
// And the directly-dialled one is not routed, which is what lets its reach govern its port.
if routed[32400] {
t.Fatalf("the endpoint clients dial directly reads as routed: %v", routed)
}
}
// **Two endpoints of different shapes, configured as themselves.** The web endpoint's reach asks for
// names and leaves its port to the proxy; the stream endpoint's reach governs its port, because
// clients dial it and there is no name.
func TestTwoEndpointsOfDifferentShapesAreConfiguredSeparately(t *testing.T) {
m := aMediaServer()
settings := mcat.SettingsBy{"media": {{From: "node anchor", Values: map[string]any{mcat.ReachSetting: map[string]any{"80": mcat.ReachBoth, "32400": mcat.ReachPublic}}}}}
r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{m},
PublicDomain: "example.test", At: "anchor.internal"}
rules, err := Rules(r, mcat.Rendering{Settings: settings})
if err != nil {
t.Fatal(err)
}
for _, rule := range rules {
switch rule.Port {
case 80:
if rule.From != mcat.FromMesh {
t.Fatalf("the routed endpoint's port opened to %q; the proxy is how it is reached",
rule.From)
}
case 32400:
if rule.From != mcat.FromEverywhere {
t.Fatalf("the directly-dialled endpoint's port is %q, want anywhere", rule.From)
}
}
}
// And the routed one carries both names, asked for by the same statement.
given, err := resolutionContributions(r, settings, nil, nil)
if err != nil {
t.Fatal(err)
}
var public, internal string
for _, c := range given["route"] {
public, _ = c.Values["name"].(string)
internal, _ = c.Values["internal-name"].(string)
}
if public != "media.example.test" || internal != "media.anchor.internal" {
t.Fatalf("names are %q and %q, want both", public, internal)
}
}
// **A route that names an endpoint still carries that endpoint's port.**
//
// Everything downstream reads the port: the provider is told where to reach the consumer, and the
// redirection that turns a declared port into the number the machine published is keyed on it. A route
// naming only its endpoint left the proxy with no port, and a proxy with no port has nothing to dial.
//
// Caught after the catalogue had already been changed to name endpoints, and before the mesh picked
// those manifests up — which is the only reason nothing broke.
func TestARouteNamingAnEndpointStillCarriesItsPort(t *testing.T) {
m := aMediaServer()
r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{m},
PublicDomain: "example.test", At: "anchor.internal"}
given, err := resolutionContributions(r, nil, nil, nil)
if err != nil {
t.Fatal(err)
}
var saw bool
for _, c := range given["route"] {
saw = true
port, ok := asPort(c.Values["port"])
if !ok {
t.Fatalf("the route carries no port, so the proxy has nothing to dial: %v", c.Values)
}
if port != 80 {
t.Fatalf("the route carries port %d, want the web endpoint's 80", port)
}
}
if !saw {
t.Fatal("the module contributed no route")
}
}
// And the declared port, not the machine one: the redirection to where the machine published it
// happens later and is keyed on the declared number, so filling the machine port in here would be
// redirected twice or not at all.
func TestTheEndpointsDeclaredPortIsFilledInNotTheMachineOne(t *testing.T) {
m := aMediaServer()
values := map[string]any{mcat.RouteEndpoint: "web", "label": "media"}
portOfEndpoint(values, endpointPorts(m))
if got, _ := asPort(values["port"]); got != 80 {
t.Fatalf("filled in port %d, want the declared 80", got)
}
// Then the ordinary redirection puts it where the machine published it.
moved := atMachinePort(values, m.Module, map[string]map[int]int{"media": {80: 20009}})
if got, _ := asPort(moved["port"]); got != 20009 {
t.Fatalf("after redirection the port is %d, want the machine's 20009", got)
}
}
// A route that repeats a port keeps it, because that is the older shape and still read.
func TestARouteThatRepeatsItsPortKeepsIt(t *testing.T) {
values := map[string]any{mcat.RouteEndpoint: "web", "port": 8080}
portOfEndpoint(values, map[string]int{"web": 80})
if got, _ := asPort(values["port"]); got != 8080 {
t.Fatalf("the port it stated was overwritten with %d", got)
}
}
@@ -0,0 +1,98 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
func configured(block map[string]any) mcat.SettingsBy {
return mcat.SettingsBy{"media": {{From: "node anchor",
Placement: map[string]any{mcat.EndpointsSetting: block}}}}
}
// **One block per endpoint, saying all three things.** The machine port, the subdomain and the reach
// were `ports`, the route's label and `reach`, each keyed by a port number, so configuring a module
// with two endpoints of different shapes meant knowing which number was which.
func TestAnEndpointsBlockSaysPortLabelAndReach(t *testing.T) {
m := aMediaServer()
// stream's reach NARROWS what the manifest says — the manifest has it from anywhere, the
// assignment says internal. Chosen deliberately: a reach that agrees with the manifest proves
// nothing about whether the block was read at all.
settings := configured(map[string]any{
"web": map[string]any{"port": 20009, "label": "cinema", "reach": mcat.ReachBoth},
"stream": map[string]any{"reach": mcat.ReachInternal},
})
// The machine port, where the mapping is read.
given, err := GivenPorts(m, settings["media"])
if err != nil {
t.Fatal(err)
}
if given[80] != 20009 {
t.Fatalf("the web endpoint is on machine port %d, want 20009: %v", given[80], given)
}
// The reach, where the filter reads it.
r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{m},
PublicDomain: "example.test", At: "anchor.internal"}
rules, err := Rules(r, mcat.Rendering{Settings: settings})
if err != nil {
t.Fatal(err)
}
for _, rule := range rules {
if rule.Port == 32400 && rule.From != mcat.FromMesh {
t.Fatalf("the directly-dialled endpoint is %q; the assignment narrowed it to the private "+
"network and the manifest's 'anywhere' should not win", rule.From)
}
if rule.Port == 80 && rule.From != mcat.FromMesh {
t.Fatalf("the routed endpoint's port opened to %q; the proxy is how it is reached", rule.From)
}
}
// And the subdomain, where the name is composed — the assignment's, not the module's.
nodes, err := resolutionContributions(r, settings, nil, nil)
if err != nil {
t.Fatal(err)
}
for _, c := range nodes["route"] {
if got, _ := c.Values["name"].(string); got != "cinema.example.test" {
t.Fatalf("the public name is %q, want the label the assignment gave", got)
}
if got, _ := c.Values["internal-name"].(string); got != "cinema.anchor.internal" {
t.Fatalf("the internal name is %q, want the label the assignment gave", got)
}
}
}
// A block that says only a reach leaves the port to the mesh and the label to the module, which is the
// ordinary case and must not require writing the other two.
func TestABlockMaySayOnlyAReach(t *testing.T) {
m := aMediaServer()
settings := configured(map[string]any{"web": map[string]any{"reach": mcat.ReachInternal}})
r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{m},
PublicDomain: "example.test", At: "anchor.internal"}
nodes, err := resolutionContributions(r, settings, nil, nil)
if err != nil {
t.Fatal(err)
}
for _, c := range nodes["route"] {
if got, _ := c.Values["name"].(string); got != "" {
t.Fatalf("an internal endpoint composed the public name %q", got)
}
// The module's own label, untouched.
if got, _ := c.Values["internal-name"].(string); got != "media.anchor.internal" {
t.Fatalf("the internal name is %q, want the module's own label", got)
}
}
}
// **Two places giving one endpoint a machine port is the confusion this key exists to end.**
func TestAnEndpointGivenAPortTwiceIsRefused(t *testing.T) {
m := aMediaServer()
_, err := GivenPorts(m, []mcat.Layer{{From: "node anchor", Placement: map[string]any{mcat.EndpointsSetting: map[string]any{"web": map[string]any{"port": 20009}}, mcat.PortsSetting: map[string]any{"80": 30000}}}})
if err == nil || !strings.Contains(err.Error(), "published once") {
t.Fatalf("an endpoint given two machine ports was accepted: %v", err)
}
}
@@ -0,0 +1,11 @@
package resolution
import (
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// engineSeat is the seat's row in the closed set.
func engineSeat() mcat.Seat {
return mcat.Seat{Name: mcat.EngineSeat, Scope: mcat.ScopeNode, Serves: mcat.EngineVerbs(), Emits: append([]string(nil), mcat.EngineEvents...),
HeldByTheEngine: true, Decision: "novox/hq ADR 0563"}
}
@@ -0,0 +1,67 @@
package resolution
import (
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// enrolmentDecision is the record that made the seat.
const enrolmentDecision = "novox/hq ADR 0587"
// enrolmentVerbs are the seat's verbs: what reads all it holds and does by hand all it does (ADR 0587 §8). Not optional:
// the seat is new with its holder, so no holder of before serves fewer.
func enrolmentVerbs() []mcat.Verb {
str := func(description string) map[string]any {
return map[string]any{"type": "string", "description": description}
}
return []mcat.Verb{
{Name: mcat.EnrolmentVerbIssue, Description: "Issue a one-time token for a new machine, on the operator's Approve: " +
"refused at once, naming every finding, for a name that is not a node name or that a node record, a placement " +
"key or an open enrolment holds, and for every call before the controller's lease is handed away. Otherwise " +
"reserves the lowest free private-network address, asks the operator under the seat's own name and, on the " +
"warrant, adds the node to placement with its tunnel key, asks the vault for the token and says enrolment-issued. " +
"The token itself is read only through tokens.",
Input: schema(map[string]string{
"node": "the new machine's name in the mesh",
"why": "why it joins, shown to the operator with the ask",
"expires": "how long the token lasts, a duration such as 1h or 24h: one hour when not given, at most 24 hours",
// The operator's decision of 2026-11-02 01:05: the tunnel key is given at issue, so on the Approve the node
// enters placement with it and the hub peers it before redeem.
"overlay_key": "the new machine's private network (tunnel) public key, as `nox-mesh-host key` prints it",
}, []string{"node", "why", "overlay_key"})},
{Name: mcat.EnrolmentVerbRedeem, Description: "Spend a token: asked by a joining node-engine dialled as enrol.<node> " +
"alone, with the public halves of its keys and its identity key's proof over them. Spends the token, asks the " +
"vault for the node's account and certificate, writes the node into placement, and answers the account sealed " +
"to the node, its certificate and the vault's public half, signed by the vault. Asked again with the same keys " +
"it finishes what is not done and answers the same; with other keys it is refused.",
Input: map[string]any{"type": "object", "additionalProperties": false,
"required": []string{"node", "public_key", "sealing_key", "proof"},
"properties": map[string]any{
"node": str("the node the token was issued for"),
"public_key": str("the identity key's public half, base64"),
"overlay_key": str("the private network key's public half"),
"sealing_key": str("the sealing key's public half, which the account is sealed to"),
"serving_key": str("the serving key's public half, which the certificate binds"),
"profile": map[string]any{"type": "object", "description": "what the machine is, as its record says it"},
"tunnel": map[string]any{"type": "object", "description": "a tunnel found on the machine: everything but its private key"},
"proof": str("the identity key's signature over the redeem's proof bytes (delivery.RedeemProof), base64"),
}}},
{Name: mcat.EnrolmentVerbConfirm, Description: "Confirm an enrolment: asked by the node-engine dialled as its own " +
"node login alone, the runtime's proof of that caller being the confirmation. Says node-enrolled and ends the " +
"enrolment.",
Input: schema(map[string]string{}, nil)},
{Name: mcat.EnrolmentVerbRevoke, Description: "End an enrolment at any stage, with why, without an Approve: withdraws " +
"an open ask, deletes the node's key and its address, has the vault retire an account it already minted for " +
"it, records the act as a hand act and says enrolment-revoked.",
Input: schema(map[string]string{"node": "the node", "why": "why, recorded with the hand act"},
[]string{"node", "why"})},
{Name: mcat.EnrolmentVerbTokens, Description: "Read every open enrolment, or one: its stage, expiry, ask and revision, " +
"the bucket's fill against its cap. For a node at live, its " +
"token sealed to the operator key, asked of the vault at that moment and relayed as the vault sealed it. With " +
"history, the ended enrolments from the seat's events, as far back as the events are kept.",
Input: schema(map[string]string{"node": "one node", "history": "\"true\": the ended enrolments too"},
nil, "history")},
{Name: mcat.EnrolmentVerbAgain, Description: "Raise an issue's ask again when the operator's channel does not hold it, " +
"and nothing more.",
Input: schema(map[string]string{"node": "the node whose ask is raised again"}, []string{"node"})},
}
}
@@ -0,0 +1,554 @@
package resolution
import (
"fmt"
"regexp"
"sort"
"strings"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
var knownShells = []string{"zsh", "bash", "fish"}
var knownSlots = []string{"first", "normal", "last"}
// sessionFiles are the two files of the graphical session's start that read no directory, so a
// contribution to them is a slot rather than a drop-in (novox/hq ADR 0208 §4): `xinitrc` is POSIX
// code the session's start runs, `xresources` X resources merged at its start. Placed by the
// display server's holder, as a shell's slots are placed by the login shell's.
var sessionFiles = []string{"xinitrc", "xresources"}
// powerMoments are the moments of a machine's power a module may run code at (novox/hq ADR
// 0211 §3): POSIX code run as root by node-power's holder, in module order, each piece bounded.
var powerMoments = []string{"after-boot", "before-sleep", "after-wake", "before-shutdown", "on-mains", "on-battery"}
// contributionTargets is every name a contribution's `for` may take.
func contributionTargets() []string {
out := append(append([]string(nil), knownShells...), sessionFiles...)
return append(out, powerMoments...)
}
// placerOf is the seat whose holder places a contribution for this target (novox/hq ADR 0204,
// ADR 0208 §4).
func placerOf(target string) string {
if oneOf(sessionFiles, target) {
return mcat.DisplayServerSeat
}
if oneOf(powerMoments, target) {
return mcat.PowerSeat
}
return mcat.LoginShellSeat
}
var ofEnvironment = regexp.MustCompile(`\$\{environment:([^}]*)\}`)
var ofShell = regexp.MustCompile(`\$\{shell:([^}]*)\}`)
// ofContributed is either kind of contributed text, matched together so both fill in one pass.
var ofContributed = regexp.MustCompile(`\$\{(shell|contribution|backup):([^}]*)\}`)
// literalRule is why a value must be literal, said with every refusal of one.
const literalRule = "a value is literal, so a POSIX shell and the service manager read it alike, and " +
"names the machine only through the mesh's own ${machine:…} facts (novox/hq ADR 0203)"
// literalProblem is why a value cannot be written, unquoted by either reader, as the same string in
// both formats — or nothing. A `$` would expand differently in each; a quote or a backslash is
// quoting in one and a character in the other; a line break ends the line in both.
func literalProblem(v string) string {
switch {
case strings.ContainsAny(v, `'"`):
return "holds a quote"
case strings.Contains(v, `\`):
return "holds a backslash"
case strings.ContainsAny(v, "\n\r"):
return "holds a line break"
case strings.ContainsRune(v, 0):
return "holds a NUL"
case strings.Contains(ofMachine.ReplaceAllString(v, ""), "$"):
return "holds a $ that is not one of the machine's ${machine:…} facts"
}
return ""
}
// placeholderProblems is what is wrong with one resource's ${environment:…} and ${shell:…}.
//
// **The seat authorises it, not the placeholder** (novox/hq ADR 0203 §5, ADR 0204 §3), as the seat
// authorises the bus's user list: a module that does not hold the account's environment writing it
// would be a second writer of a file there is one of, and a module that does not hold the login
// shell writing every module's shell code would be a second shell.
func placeholderProblems(m mcat.Manifest, r map[string]any) []string {
var problems []string
for _, field := range sortedKeys(r) {
s, ok := r[field].(string)
if !ok {
continue
}
env := ofEnvironment.FindAllStringSubmatch(s, -1)
code := ofShell.FindAllStringSubmatch(s, -1)
if len(env)+len(code) == 0 {
continue
}
if field != "content" {
// Placed only where a file's bytes are, which is where every one of them is meant to go:
// a path or an owner holding several lines of shell is nothing the host could act on.
problems = append(problems, fmt.Sprintf(
"%s's resource %v names %s in its %s; the environment and the shell's code are placed "+
"only in a file's content", m.Module, r["id"], placeholderOf(env, code), field))
continue
}
for _, e := range env {
if e[1] != mcat.EnvironmentPOSIX && e[1] != mcat.EnvironmentSystemd {
problems = append(problems, fmt.Sprintf(
"%s's resource %v names %s; the environment is ${environment:%s} or ${environment:%s}",
m.Module, r["id"], e[0], mcat.EnvironmentPOSIX, mcat.EnvironmentSystemd))
}
}
if len(env) > 0 && !m.ClaimsSeat(mcat.EnvironmentSeat) {
problems = append(problems, fmt.Sprintf(
"%s's resource %v names %s and %s does not claim %s; the account's environment is "+
"written by that seat's holder alone (novox/hq ADR 0203)",
m.Module, r["id"], env[0][0], m.Module, mcat.EnvironmentSeat))
}
// Each placeholder judged by its own target: a shell's code is the login shell's holder's to
// place (ADR 0204), the session's files the display server's (ADR 0208 §4) — and a holder of
// one placing the other's would be a second writer of a file there is one of.
refusedFor := map[string]bool{}
for _, c := range code {
target, slot, two := strings.Cut(c[1], ":")
if !two || !oneOf(contributionTargets(), target) || !oneOf(knownSlots, slot) {
problems = append(problems, fmt.Sprintf(
"%s's resource %v names %s; shell code is ${shell:<shell>:<slot>}, the shell one of "+
"%s, the session's file one of %s or the power moment one of %s, and the slot one of %s",
m.Module, r["id"], c[0], strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", "),
strings.Join(powerMoments, ", "), strings.Join(knownSlots, ", ")))
continue
}
seat := placerOf(target)
if m.ClaimsSeat(seat) || refusedFor[seat] {
continue
}
refusedFor[seat] = true
if seat == mcat.PowerSeat {
problems = append(problems, fmt.Sprintf(
"%s's resource %v names %s and %s does not claim %s; every module's code for a power "+
"moment is placed by the power seat's holder alone (novox/hq ADR 0211)",
m.Module, r["id"], c[0], m.Module, seat))
continue
}
if seat == mcat.DisplayServerSeat {
problems = append(problems, fmt.Sprintf(
"%s's resource %v names %s and %s does not claim %s; every module's %s is placed by "+
"the display server's holder alone (novox/hq ADR 0208)",
m.Module, r["id"], c[0], m.Module, seat, target))
continue
}
problems = append(problems, fmt.Sprintf(
"%s's resource %v names %s and %s does not claim %s; every module's shell code is "+
"placed by the login shell's holder alone (novox/hq ADR 0204)",
m.Module, r["id"], c[0], m.Module, seat))
}
}
return problems
}
func placeholderOf(env, code [][]string) string {
if len(env) > 0 {
return env[0][0]
}
return code[0][0]
}
// contributedEnvironment is one node's environment, gathered and in the order it is written.
type contributedEnvironment struct {
// variables is by module in name order, each module's sorted by name.
variables []setBy
// start and end are PATH's entries in their final order, each once.
start, end []placedOn
}
type setBy struct {
module string
names []string
values map[string]string
}
type placedOn struct {
module, entry string
}
// inModuleOrder is the modules sorted by name — the order contributions are written in (novox/hq
// ADR 0203, ADR 0204), so the same set composes byte for byte whatever order they were assigned in.
func inModuleOrder(modules []mcat.Manifest) []mcat.Manifest {
out := append([]mcat.Manifest(nil), modules...)
sort.SliceStable(out, func(a, b int) bool { return out[a].Module < out[b].Module })
return out
}
// variablesSetOnce refuses a variable two modules on one node both set (novox/hq ADR 0203 §5),
// naming both. Neither is chosen: whichever was written last would win in one reader and not
// necessarily in the other, and the module that lost would not be told.
func variablesSetOnce(modules []mcat.Manifest) error {
setter := map[string]string{}
for _, m := range inModuleOrder(modules) {
e := environmentOf(m)
if e == nil {
continue
}
for _, n := range sortedKeys(e.Variables) {
if first, taken := setter[n]; taken {
return fmt.Errorf(
"%s and %s both set %s on this machine; the account has one environment, so one "+
"of them must stop setting it (novox/hq ADR 0203)", first, m.Module, n)
}
setter[n] = m.Module
}
}
return nil
}
// environmentOn gathers every module's environment on a node, with the machine's facts in place.
//
// A PATH entry two modules both add is written once, where the first puts it: two toolchains
// sharing ~/.local/bin is ordinary, and nothing about it is in conflict.
func environmentOn(modules []mcat.Manifest, facts map[string]string) (contributedEnvironment, error) {
var env contributedEnvironment
if err := variablesSetOnce(modules); err != nil {
return env, err
}
placed := map[string]bool{}
for _, m := range inModuleOrder(modules) {
e := environmentOf(m)
if e == nil {
continue
}
if len(e.Variables) > 0 {
set := setBy{module: m.Module, values: map[string]string{}}
for _, n := range sortedKeys(e.Variables) {
v, err := factsIn(e.Variables[n], facts, m.Module, n)
if err != nil {
return env, err
}
set.names = append(set.names, n)
set.values[n] = v
}
env.variables = append(env.variables, set)
}
for _, p := range e.Path {
entry, err := factsIn(p.Entry, facts, m.Module, "a PATH entry")
if err != nil {
return env, err
}
if strings.Contains(entry, ":") {
return env, fmt.Errorf("%s puts %q on PATH on this machine, which holds a colon, PATH's own separator",
m.Module, entry)
}
if placed[entry] {
continue
}
placed[entry] = true
if p.At == mcat.PathAtEnd {
env.end = append(env.end, placedOn{m.Module, entry})
} else {
env.start = append(env.start, placedOn{m.Module, entry})
}
}
}
return env, nil
}
// environmentOf is a module's environment as the old form and the items of the environment collection say it: an
// item's template is the variable's value or the PATH entry, and its attributes the kind, the name and where on PATH
// (design 57 L3, issue 795). A moved contributor's items are rendered here into a collector not handed, as the
// provisioner's client renders them into one that is. An item whose text the mesh does not hold is not here; it is
// said as not placed (itemsLeftOutFor).
func environmentOf(m mcat.Manifest) *mcat.Environment {
var out *mcat.Environment
if m.Environment != nil {
out = &mcat.Environment{Variables: map[string]string{}, Path: append([]mcat.PathEntry(nil), m.Environment.Path...)}
for k, v := range m.Environment.Variables {
out.Variables[k] = v
}
}
for _, it := range itemsInOrder(m.Items) {
if it.Collection != mcat.EnvironmentCollection || it.Content == "" {
continue
}
if out == nil {
out = &mcat.Environment{Variables: map[string]string{}}
}
value := strings.TrimSuffix(it.Content, "\n")
switch it.Attributes["kind"] {
case "variable":
out.Variables[it.Attributes["name"]] = value
case "path":
out.Path = append(out.Path, mcat.PathEntry{Entry: value, At: it.Attributes["at"]})
}
}
return out
}
// factsIn resolves a contributed value's ${machine:…} facts with this machine's — first, before
// either format is written, so both say the same thing (novox/hq ADR 0203).
func factsIn(v string, facts map[string]string, module, what string) (string, error) {
for _, key := range machineUsed(v) {
value, has := facts[key]
if !has {
return "", fmt.Errorf("%s sets %s to a value that says ${machine:%s}, and this machine says %s",
module, what, key, orNothing(namesOfFacts(facts)))
}
v = strings.ReplaceAll(v, fmt.Sprintf("${machine:%s}", key), value)
}
// Judged again once filled: a fact is the mesh's, and still has to be a literal both readers
// take alike.
if why := literalProblem(v); why != "" {
return "", fmt.Errorf("%s sets %s to %q on this machine, which %s — %s", module, what, v, why, literalRule)
}
return v, nil
}
// posix is the environment as lines a POSIX shell sources (novox/hq ADR 0203 §3): every variable
// exported, every PATH entry added only when it is missing, so sourcing the file twice — a login
// shell that starts another — changes nothing. POSIX sh only, because sh, bash and zsh all read it.
//
// The start entries are written last-first: each is put in front of PATH, so the last written ends
// up first, and the result reads in module order, then the order each module declared.
func (e contributedEnvironment) posix() string {
var b strings.Builder
for _, set := range e.variables {
fmt.Fprintf(&b, "# %s\n", set.module)
for _, n := range set.names {
fmt.Fprintf(&b, "export %s='%s'\n", n, set.values[n])
}
}
named := ""
for i := len(e.start) - 1; i >= 0; i-- {
p := e.start[i]
if p.module != named {
fmt.Fprintf(&b, "# %s\n", p.module)
named = p.module
}
fmt.Fprintf(&b, "case \":${PATH}:\" in *':%s:'*) ;; *) PATH='%s'\"${PATH:+:${PATH}}\" ;; esac\n",
p.entry, p.entry)
}
named = ""
for _, p := range e.end {
if p.module != named {
fmt.Fprintf(&b, "# %s\n", p.module)
named = p.module
}
fmt.Fprintf(&b, "case \":${PATH}:\" in *':%s:'*) ;; *) PATH=\"${PATH:+${PATH}:}\"'%s' ;; esac\n",
p.entry, p.entry)
}
if len(e.start)+len(e.end) > 0 {
b.WriteString("export PATH\n")
}
return b.String()
}
// systemd is the same environment as the service manager's environment.d reads it (novox/hq ADR
// 0203 §3), for the account's user manager and so for everything a graphical session starts. Read
// once per manager start, so it needs no guard against running twice; the account's existing PATH
// sits between the start and the end entries.
func (e contributedEnvironment) systemd() string {
var b strings.Builder
for _, set := range e.variables {
fmt.Fprintf(&b, "# %s\n", set.module)
for _, n := range set.names {
fmt.Fprintf(&b, "%s=%s\n", n, set.values[n])
}
}
if len(e.start) > 0 {
fmt.Fprintf(&b, "# %s\nPATH=%s${PATH:+:$PATH}\n", modulesOf(e.start), entriesOf(e.start))
}
if len(e.end) > 0 {
fmt.Fprintf(&b, "# %s\nPATH=${PATH:+$PATH:}%s\n", modulesOf(e.end), entriesOf(e.end))
}
return b.String()
}
// modulesOf names who contributed a line holding several modules' entries, in the order they appear.
func modulesOf(entries []placedOn) string {
var names []string
seen := map[string]bool{}
for _, p := range entries {
if !seen[p.module] {
seen[p.module] = true
names = append(names, p.module)
}
}
return strings.Join(names, ", ")
}
func entriesOf(entries []placedOn) string {
out := make([]string, len(entries))
for i, p := range entries {
out[i] = p.entry
}
return strings.Join(out, ":")
}
// oldFormOfCollected is a file's text with each `${collected:contribution:<seat>:<kind>:…}` of a kind received as data
// read as the old `${contribution:<seat>:<kind>:…}`: where the collector is not handed, the controller renders the
// kind's items as it rendered its contributions, so the two forms place the same text (design 57 L3, issue 781).
func oldFormOfCollected(content string) string {
return ofCollected.ReplaceAllStringFunc(content, func(placeholder string) string {
name := ofCollected.FindStringSubmatch(placeholder)[1]
base, _ := splitSelector(name)
if format, is := strings.CutPrefix(base, mcat.EnvironmentCollection+":"); is {
return "${environment:" + format + "}"
}
if !strings.HasPrefix(base, "contribution:") {
return placeholder
}
seat, kind, _ := strings.Cut(strings.TrimPrefix(base, "contribution:"), ":")
if _, r, ok := receivable(seat, kind); !ok || !collectableAsText(r) {
return placeholder
}
return "${" + name + "}"
})
}
// shellCodeFor is shellCodeOn on a machine with these capabilities, which an item's `if-capability` is judged by. The
// pieces are placed by order (an item's `order` attribute, the default for the old form), then module, as the
// provisioner's client places them.
func shellCodeFor(modules []mcat.Manifest, shell, slot string, facts map[string]string, caps map[string]bool) string {
var blocks []textBlock
for _, m := range inModuleOrder(modules) {
var old strings.Builder
for _, c := range m.Shell {
if c.For != shell || c.Slot != slot {
continue
}
if old.Len() == 0 {
fmt.Fprintf(&old, "# %s\n", m.Module)
}
old.WriteString(c.Code)
if !strings.HasSuffix(c.Code, "\n") {
old.WriteString("\n")
}
}
blocks = append(blocks, moduleBlocks(m, "#", old.String(), heldItems(m, "shell:"+shell+":"+slot, nil, facts, caps))...)
}
return joinBlocks(blocks)
}
// textBlock is a stretch of a collector's text and the order it is placed by.
type textBlock struct {
order int
text string
}
// moduleBlocks is one module's pieces of a placeholder: what its old form made (placed by the default order), and
// each of its held items under a line naming the module, as the provisioner's client writes one per item. An item
// of the default order that follows the old form's text shares its line, as a module's item always did.
func moduleBlocks(m mcat.Manifest, comment, old string, items []heldItem) []textBlock {
var blocks []textBlock
if old != "" {
blocks = append(blocks, textBlock{mcat.OrderDefault, old})
}
for i, h := range items {
text := h.text
if i == 0 && old != "" && itemOrder(h.item) == mcat.OrderDefault {
blocks[0].text += text
continue
}
blocks = append(blocks, textBlock{itemOrder(h.item), fmt.Sprintf("%s %s\n", comment, m.Module) + text})
}
return blocks
}
// joinBlocks is the blocks by order, those of one order in the order given (module order).
func joinBlocks(blocks []textBlock) string {
sort.SliceStable(blocks, func(a, b int) bool { return blocks[a].order < blocks[b].order })
var b strings.Builder
for _, blk := range blocks {
b.WriteString(blk.text)
}
return b.String()
}
// contributionsInto fills a holder's file with the node's environment and its shell code.
//
// **Last, after every other placeholder pass, and in one pass each.** Shell code is contributed text
// in a shell's own syntax — `${XDG_CACHE_HOME:-$HOME/.cache}`, `${(%):-%n}` — and the rendered
// environment holds `${PATH:+…}`: a scanner for the mesh's own placeholders that ran after these
// were in place would read the shell's expansions as the mesh's and refuse them, or fill a
// `${machine:…}` some module wrote for its shell to see. So nothing runs after them, the environment
// is filled before the shell's code is, and each is replaced in a single pass over what the holder
// wrote, so a contributed piece is never scanned again.
func contributionsInto(resource map[string]any, m mcat.Manifest, modules []mcat.Manifest, facts map[string]string, with mcat.Rendering, caps map[string]bool, unplaced *[]string) error {
problems := append(placeholderProblems(m, resource), seatPlaceholderProblems(m, resource)...)
if problems = append(problems, collectedPlaceholderProblems(m, resource)...); len(problems) > 0 {
return fmt.Errorf("%s", problems[0])
}
judged, err := consumersNotHanded(m, resource, with)
if err != nil {
return err
}
if judged {
resource["content"] = ofConsumers.ReplaceAllString(fmt.Sprint(resource["content"]), "")
}
content, ok := resource["content"].(string)
if !ok {
return nil
}
content = oldFormOfCollected(content)
// **An item is the provisioner client's** (design 57 L1): a collector filled here, the old way, holds the old
// forms alone, and each item of its collections is said as not placed. A collection of the new form is empty.
if unplaced != nil {
for _, f := range templatePlaceholder.FindAllStringSubmatch(content, -1) {
if collection, is := collectionOfPlaceholder(f[1], f[2]); is {
_, selector := splitSelector(f[2])
where, _ := selectorOf(selector)
*unplaced = append(*unplaced, itemsLeftOutFor(modules, m, collection, where, facts, caps)...)
}
}
}
content = ofCollected.ReplaceAllString(content, "")
if ofEnvironment.MatchString(content) {
env, err := environmentOn(modules, facts)
if err != nil {
return err
}
content = ofEnvironment.ReplaceAllStringFunc(content, func(placeholder string) string {
if ofEnvironment.FindStringSubmatch(placeholder)[1] == mcat.EnvironmentSystemd {
return env.systemd()
}
return env.posix()
})
}
// Shell code and seat contributions in one pass (novox/hq ADR 0212): both are contributed text
// the controller does not read, so neither may be scanned after the other is in place — a
// contributed line that happened to spell the other's placeholder would be filled.
if ofContributed.MatchString(content) {
var failed error
content = ofContributed.ReplaceAllStringFunc(content, func(placeholder string) string {
found := ofContributed.FindStringSubmatch(placeholder)
first, second, _ := strings.Cut(found[2], ":")
if found[1] == mcat.BackupNamespace {
placed, problem := backupPlacer{modules, with, facts, caps, unplaced}.placed(m, found[2], false)
if problem != "" && failed == nil {
failed = fmt.Errorf("%s names %s: %s", m.Module, placeholder, problem)
}
return placed
}
if found[1] == "contribution" {
placed, left, err := seatContributions(modules, m, found[2], with, facts, caps)
if err != nil && failed == nil {
failed = err
}
if unplaced != nil {
*unplaced = append(*unplaced, left...)
}
return placed
}
return shellCodeFor(modules, first, second, facts, caps)
})
if failed != nil {
return failed
}
}
resource["content"] = content
return nil
}
@@ -0,0 +1,238 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// contributors is a fixed set of contributions, in no particular order: what the renderings are
// asserted against byte for byte. go-toolchain and zsh both put ~/.local/bin on PATH, which is the
// ordinary case of two modules sharing a directory, and is written once.
func contributors() []mcat.Manifest {
return []mcat.Manifest{
{Module: "zsh", Environment: &mcat.Environment{
Variables: map[string]string{"XDG_CONFIG_HOME": "${machine:account-home}/.config", "EDITOR": "vim"},
Path: []mcat.PathEntry{
{Entry: "${machine:account-home}/.local/bin", At: mcat.PathAtStart},
{Entry: "${machine:account-home}/bin", At: mcat.PathAtStart},
{Entry: "/opt/scripts", At: mcat.PathAtEnd},
},
}},
{Module: "go-toolchain", Environment: &mcat.Environment{
Variables: map[string]string{"GOPATH": "${machine:account-home}/go"},
Path: []mcat.PathEntry{
{Entry: "${machine:account-home}/go/bin", At: mcat.PathAtStart},
{Entry: "/usr/local/go/bin", At: mcat.PathAtStart},
{Entry: "${machine:account-home}/.local/bin", At: mcat.PathAtStart},
},
}},
{Module: "agent", Environment: &mcat.Environment{
Variables: map[string]string{"DISABLE_AUTOUPDATER": "1"},
Path: []mcat.PathEntry{{Entry: "/opt/agent/bin", At: mcat.PathAtEnd}},
}},
// A module contributing nothing is in the set and writes nothing.
{Module: "postgres"},
}
}
var operatorFacts = map[string]string{"name": "workstation", "account": "op", "account-home": "/home/op"}
// The final PATH this set composes, around whatever the account had: the start entries in module
// order and then declared order, the account's own, then the end entries.
const composedPOSIX = `# agent
export DISABLE_AUTOUPDATER='1'
# go-toolchain
export GOPATH='/home/op/go'
# zsh
export EDITOR='vim'
export XDG_CONFIG_HOME='/home/op/.config'
# zsh
case ":${PATH}:" in *':/home/op/bin:'*) ;; *) PATH='/home/op/bin'"${PATH:+:${PATH}}" ;; esac
# go-toolchain
case ":${PATH}:" in *':/home/op/.local/bin:'*) ;; *) PATH='/home/op/.local/bin'"${PATH:+:${PATH}}" ;; esac
case ":${PATH}:" in *':/usr/local/go/bin:'*) ;; *) PATH='/usr/local/go/bin'"${PATH:+:${PATH}}" ;; esac
case ":${PATH}:" in *':/home/op/go/bin:'*) ;; *) PATH='/home/op/go/bin'"${PATH:+:${PATH}}" ;; esac
# agent
case ":${PATH}:" in *':/opt/agent/bin:'*) ;; *) PATH="${PATH:+${PATH}:}"'/opt/agent/bin' ;; esac
# zsh
case ":${PATH}:" in *':/opt/scripts:'*) ;; *) PATH="${PATH:+${PATH}:}"'/opt/scripts' ;; esac
export PATH
`
const composedSystemd = `# agent
DISABLE_AUTOUPDATER=1
# go-toolchain
GOPATH=/home/op/go
# zsh
EDITOR=vim
XDG_CONFIG_HOME=/home/op/.config
# go-toolchain, zsh
PATH=/home/op/go/bin:/usr/local/go/bin:/home/op/.local/bin:/home/op/bin${PATH:+:$PATH}
# agent, zsh
PATH=${PATH:+$PATH:}/opt/agent/bin:/opt/scripts
`
func TestTheEnvironmentRendersForAPOSIXShellByteForByte(t *testing.T) {
env, err := environmentOn(contributors(), operatorFacts)
if err != nil {
t.Fatal(err)
}
if got := env.posix(); got != composedPOSIX {
t.Fatalf("the POSIX rendering is\n%s\nnot\n%s", got, composedPOSIX)
}
}
func TestTheEnvironmentRendersForTheServiceManagerByteForByte(t *testing.T) {
env, err := environmentOn(contributors(), operatorFacts)
if err != nil {
t.Fatal(err)
}
if got := env.systemd(); got != composedSystemd {
t.Fatalf("the environment.d rendering is\n%s\nnot\n%s", got, composedSystemd)
}
}
// Nothing contributed renders nothing, in both formats — not an empty `export PATH`.
func TestNoContributionsRenderNothing(t *testing.T) {
env, err := environmentOn([]mcat.Manifest{{Module: "postgres"}}, operatorFacts)
if err != nil {
t.Fatal(err)
}
if env.posix() != "" || env.systemd() != "" {
t.Fatalf("an empty environment rendered %q and %q", env.posix(), env.systemd())
}
}
// A ${machine:…} fact the machine does not have is refused naming the module, as a file's is.
func TestAContributedFactTheMachineLacksIsRefused(t *testing.T) {
_, err := environmentOn(contributors(), map[string]string{"name": "server"})
if err == nil || !strings.Contains(err.Error(), "go-toolchain sets GOPATH") ||
!strings.Contains(err.Error(), "${machine:account-home}") {
t.Fatalf("a missing account home was not refused by name: %v", err)
}
}
// ADR 0203 §5: two modules setting one variable are refused, both named — neither silently wins.
func TestAVariableTwoModulesSetIsRefusedNamingBoth(t *testing.T) {
modules := append(contributors(), mcat.Manifest{Module: "neovim", Environment: &mcat.Environment{
Variables: map[string]string{"EDITOR": "nvim"}}})
_, err := environmentOn(modules, operatorFacts)
if err == nil || err.Error() != "neovim and zsh both set EDITOR on this machine; the account has one "+
"environment, so one of them must stop setting it (novox/hq ADR 0203)" {
t.Fatalf("a variable set twice was not refused naming both: %v", err)
}
// And at composition, whether or not the node holds the environment.
r := mcat.Resolution{Node: "workstation", Account: "op", Modules: modules}
if _, err := Declaration(r, mcat.Rendering{}); err == nil || !strings.Contains(err.Error(), "neovim and zsh both set EDITOR") {
t.Fatalf("composition accepted a variable set twice: %v", err)
}
}
// shells contributes code for several shells and slots, in no order.
func shells() []mcat.Manifest {
return []mcat.Manifest{
{Module: "zsh-syntax-highlighting", Shell: []mcat.ShellCode{
{For: "zsh", Slot: "last", Code: "source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh"},
}},
{Module: "powerlevel10k", Shell: []mcat.ShellCode{
{For: "zsh", Slot: "first", Code: "if [[ -r \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\" ]]; then\n" +
" source \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\"\nfi\n"},
{For: "zsh", Slot: "normal", Code: "source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme"},
{For: "zsh", Slot: "normal", Code: "[[ -f ~/.local/share/powerlevel10k/p10k.zsh ]] && source ~/.local/share/powerlevel10k/p10k.zsh"},
}},
{Module: "zsh-autosuggestions", Shell: []mcat.ShellCode{
{For: "zsh", Slot: "normal", Code: "source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh"},
{For: "bash", Slot: "normal", Code: "echo not for zsh"},
}},
{Module: "direnv", Shell: []mcat.ShellCode{
{For: "fish", Slot: "last", Code: "direnv hook fish | source"},
{For: "bash", Slot: "last", Code: "eval \"$(direnv hook bash)\""},
}},
}
}
// The holder of node-login-shell, as WP3's zsh module writes its block, with its own zsh around the
// slots — which holds `${…}` of the shell's own that no mesh pass may touch either.
func zshHolder() mcat.Manifest {
return mcat.Manifest{Module: "zsh", Claims: []mcat.Claim{{Name: mcat.LoginShellSeat, Scope: mcat.ScopeNode}},
Resources: []map[string]any{
{"id": "zshrc", "type": "file", "path": "${machine:account-home}/.zshrc", "content": "" +
"${shell:zsh:first}" +
"PROMPT='%n@%m ${PWD/#$HOME/~} '\n" +
"${shell:zsh:normal}" +
"alias ll='ls -l'\n" +
"${shell:zsh:last}"},
}}
}
// The case the ordering exists for: contributed zsh code full of `${…}` reaches the file byte for
// byte, because the shell's code is placed after every other placeholder pass and in one pass — a
// scanner for the mesh's placeholders that ran after it would read `${XDG_CACHE_HOME:-…}` and
// `${(%):-%n}` as the mesh's, or fill a `${machine:…}` some module wrote for its shell to see.
func TestShellCodeReachesTheHoldersFileByteForByte(t *testing.T) {
modules := append(shells(), zshHolder(), mcat.Manifest{Module: "sly", Shell: []mcat.ShellCode{
{For: "zsh", Slot: "last", Code: "echo ${machine:account-home} ${secret:x} ${shell:zsh:first} ${environment:posix}"},
}})
r := mcat.Resolution{Node: "workstation", Account: "op", Modules: modules}
out, err := Declaration(r, mcat.Rendering{})
if err != nil {
t.Fatal(err)
}
var zshrc map[string]any
for _, res := range out {
if res["id"] == "zsh.zshrc" {
zshrc = res
}
}
if zshrc == nil {
t.Fatalf("the holder's file was not composed: %v", out)
}
if zshrc["path"] != "/home/op/.zshrc" {
t.Fatalf("the holder's own placeholders were not filled first: %v", zshrc["path"])
}
want := "# powerlevel10k\n" +
"if [[ -r \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\" ]]; then\n" +
" source \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\"\nfi\n" +
"PROMPT='%n@%m ${PWD/#$HOME/~} '\n" +
"# powerlevel10k\n" +
"source ~/.local/share/powerlevel10k/powerlevel10k.zsh-theme\n" +
"[[ -f ~/.local/share/powerlevel10k/p10k.zsh ]] && source ~/.local/share/powerlevel10k/p10k.zsh\n" +
"# zsh-autosuggestions\n" +
"source /usr/share/zsh/plugins/zsh-autosuggestions/zsh-autosuggestions.zsh\n" +
"alias ll='ls -l'\n" +
"# sly\n" +
"echo ${machine:account-home} ${secret:x} ${shell:zsh:first} ${environment:posix}\n" +
"# zsh-syntax-highlighting\n" +
"source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh\n"
if got := zshrc["content"]; got != want {
t.Fatalf("the holder's .zshrc is\n%s\nnot\n%s", got, want)
}
}
// The holder of node-environment places both renderings, and they are the same as rendered alone.
func TestTheEnvironmentHolderPlacesBothRenderings(t *testing.T) {
holder := mcat.Manifest{Module: "node-env", Claims: []mcat.Claim{{Name: mcat.EnvironmentSeat, Scope: mcat.ScopeNode}},
Resources: []map[string]any{
{"id": "posix", "type": "file", "path": "${machine:account-home}/.config/mesh/environment.sh",
"content": "# The mesh's environment.\n${environment:posix}"},
{"id": "systemd", "type": "file", "path": "${machine:account-home}/.config/environment.d/50-mesh.conf",
"content": "${environment:systemd}"},
}}
r := mcat.Resolution{Node: "workstation", Account: "op", Modules: append(contributors(), holder)}
out, err := Declaration(r, mcat.Rendering{})
if err != nil {
t.Fatal(err)
}
by := map[string]any{}
for _, res := range out {
by[res["id"].(string)] = res["content"]
}
if by["node-env.posix"] != "# The mesh's environment.\n"+composedPOSIX {
t.Fatalf("the POSIX file is\n%v", by["node-env.posix"])
}
if by["node-env.systemd"] != composedSystemd {
t.Fatalf("the environment.d file is\n%v", by["node-env.systemd"])
}
}
@@ -0,0 +1,19 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
func TestTwoModulesSettingOneVariableAreRefused(t *testing.T) {
// A module that sets a variable twice, in two modules, is refused as the old form refused it.
a := mcat.Manifest{Module: "a", Items: []mcat.Item{{Collection: "environment", Content: "1\n",
Attributes: map[string]string{"kind": "variable", "name": "X"}}}}
b := mcat.Manifest{Module: "b", Items: []mcat.Item{{Collection: "environment", Content: "2\n",
Attributes: map[string]string{"kind": "variable", "name": "X"}}}}
if err := variablesSetOnce([]mcat.Manifest{a, b}); err == nil || !strings.Contains(err.Error(), "both set X") {
t.Errorf("two modules setting X: %v", err)
}
}
+152
View File
@@ -0,0 +1,152 @@
package resolution
import (
"encoding/json"
"fmt"
"path"
"strings"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
const filesAsAccount = "as the node's operator account, through sudo -n where its rights do not reach; a place a " +
"secret is kept, or a known credential file, is refused by name"
const overrideWord = "a reason, to change a path a module owns anyway (optional)"
// filesVerbs is the contract every holder of node-files serves (novox/hq ADR 0394 §1): the reads of node-reads,
// with their descriptions restated for the operator's account, and the writes.
func filesVerbs() []mcat.Verb {
var verbs []mcat.Verb
for _, v := range readsVerbs() {
v.Description = strings.Replace(v.Description,
"Read as the holder's own account, which holds no secret: a component it may not reach says so.",
"", 1)
v.Description = strings.TrimSpace(v.Description) + " Read " + filesAsAccount + "."
verbs = append(verbs, v)
}
write := func(name, description string, in map[string]string, required []string, optional []string, replaces ...string) mcat.Verb {
in["reason"] = "why the file is changed, for the audit trail"
in["override"] = overrideWord
return mcat.Verb{Name: name, Description: description + " Refuses a path a module owns " +
"unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.",
Input: schema(in, append(required, "reason"), append(optional, "override")...), Replaces: replaces}
}
return append(verbs,
write("write", "Write one text file whole, replacing what it holds; the old content goes to the node's trash.",
map[string]string{"path": "an absolute path", "content": "the text to write",
"mode": "the file's mode, such as 0644 (optional)"},
[]string{"path", "content"}, []string{"mode"}, "tee", ">"),
write("copy", "Copy a file or directory to a path on this node. A target on another node (<node>:/path) is "+
"refused: copying between nodes comes in step 2 (issue 903). An existing target is overwritten, the old one "+
"kept in the node's trash for 7 days.",
map[string]string{"from": "an absolute path", "to": "an absolute path on this node"},
[]string{"from", "to"}, nil, "cp", "cp -a", "ssh <node> cp"),
write("move", "Move or rename a file or directory. A target on another node is refused: copying between nodes "+
"comes in step 2 (issue 903). An existing target is overwritten, the old one kept in the node's trash for 7 days.",
map[string]string{"from": "an absolute path", "to": "an absolute path"},
[]string{"from", "to"}, nil, "mv"),
write("delete", "Delete a file or directory into the node's trash, where restore can bring it back.",
map[string]string{"path": "an absolute path", "recursive": "\"true\": delete a directory with what it holds"},
[]string{"path"}, []string{"recursive"}, "rm", "rm -r"),
write("mkdir", "Make a directory.",
map[string]string{"path": "an absolute path", "parents": "\"true\": make the missing parents too"},
[]string{"path"}, []string{"parents"}, "mkdir", "mkdir -p"),
write("chmod", "Change the mode of a file or directory.",
map[string]string{"path": "an absolute path", "mode": "the mode, such as 0644 or u+x",
"recursive": "\"true\": also everything under a directory"},
[]string{"path", "mode"}, []string{"recursive"}, "chmod"),
write("chown", "Change the owner and group of a file or directory.",
map[string]string{"path": "an absolute path", "owner": "an account's name or number",
"group": "a group's name or number (optional)", "recursive": "\"true\": also everything under a directory"},
[]string{"path", "owner"}, []string{"group", "recursive"}, "chown", "chgrp"), mcat.Verb{Name: "upload", Description: "Send one file of this node into a bucket of the object store, over a short signed link " +
"and never over the bus; answers the key (the file's SHA-256 when none is given), the size and the SHA-256. " +
"At most 100 MiB. A place a secret is kept, a known credential file, or a link to one is refused by name, " +
"before anything is sent. Read " + filesAsAccount + ".",
Input: schema(map[string]string{"path": "an absolute path to a file", "bucket": "the bucket the file goes into",
"key": "the object's key (optional; the file's SHA-256 when not given)"}, []string{"path", "bucket"})}, mcat.
// download is a plain Verb, not through write(): write() types its optional arguments as true/false
// switches, which a hash and a reason are not (issue 1719).
Verb{Name: "download", Description: "Write one object of a bucket to a file of this node, over a short signed link and " +
"never over the bus; the old file goes to the node's trash. At most 100 MiB. The bytes must hash to sha256, or to " +
"the key when the key is a SHA-256; otherwise no file is written. Refuses a path a module owns " +
"unless override names a reason; done as the node's operator account, through sudo -n where its rights do not reach.",
Input: schema(map[string]string{"bucket": "the bucket", "key": "the object's key", "path": "an absolute path",
"sha256": "the SHA-256 the bytes must have (optional; the key when that is a SHA-256)",
"reason": "why the file is changed, for the audit trail",
"override": overrideWord}, []string{"bucket", "key", "path", "reason"})}, mcat.Verb{Name: "trash", Description: "What the node's trash holds: each deleted or replaced item with its id, " +
"original path and when it went.",
Input: schema(map[string]string{}, nil), Replaces: []string{"ls ~/.local/share/Trash"}}, write("restore", "Put an item of the node's trash back where it was; refuses when something is there now.",
map[string]string{"id": "an id from trash"}, []string{"id"}, nil, "trash-restore"),
)
}
// ownedPaths is every path a module owns on this node, as the holder is told (novox/hq ADR 0394 §5): the
// paths every seat declares (ADR 0287) with the module holding the seat here, and the paths and files each
// module's manifest places (declaredPaths). A seat's path wins where both name one.
func resolutionOwnedPaths(r mcat.Resolution) map[string]string {
owned := map[string]string{}
for _, m := range r.Modules {
for _, p := range sortedKeys(declaredPaths(m)) {
owned[p] = m.Module + " (its own tools)"
}
}
for _, s := range mcat.Seats() {
for _, sp := range s.Paths {
for _, m := range r.Modules {
if m.ClaimsSeat(s.Name) && declaresPath(m, path.Clean(sp.Path)) && !resolutionStepsBackHere(r, m, sp) {
owned[sp.Path] = m.Module + " (" + s.Name + ")"
}
}
}
}
return owned
}
// stepsBackHere says m's seat path steps back on this machine (novox/hq ADR 0287): it names a seat whose holder here
// declares the path too, so that holder owns it, as stepsBack leaves m's own rendering of it out. Without it the owner
// told was the last seat in the order of the seat table, which the controller reads sorted by name and a node in the
// order compiled (issue 1126, C3: /etc/resolv.conf, node-uplink against node-resolver).
func resolutionStepsBackHere(r mcat.Resolution, m mcat.Manifest, sp mcat.SeatPath) bool {
if sp.StepsBackFor == "" {
return false
}
for _, o := range r.Modules {
if o.Module != m.Module && holdsSeat(o, sp.StepsBackFor) && declaresPath(o, path.Clean(sp.Path)) {
return true
}
}
return false
}
// filesWords is what the holder of node-files is given beside its own words: the node's secret places and the
// paths modules own here.
func resolutionFilesWords(r mcat.Resolution, m mcat.Manifest, words map[string]string, with mcat.Rendering) (map[string]string, error) {
if !m.ClaimsSeat(mcat.FilesSeat) {
return words, nil
}
places, err := resolutionSecretPlaces(r, with)
if err != nil {
return nil, err
}
places2, err := json.Marshal(places)
if err != nil {
return nil, err
}
owned, err := json.Marshal(resolutionOwnedPaths(r))
if err != nil {
return nil, err
}
if words == nil {
words = map[string]string{}
}
for _, w := range []string{mcat.ReadsSecretPlaces, mcat.FilesOwnedPaths} {
if _, has := words[w]; has {
return nil, fmt.Errorf("%s gives its own %s; the node's secret places and owned paths are the mesh's to "+
"say (novox/hq ADR 0394 §5)", m.Module, w)
}
}
words[mcat.ReadsSecretPlaces] = string(places2)
words[mcat.FilesOwnedPaths] = string(owned)
return words, nil
}
+75
View File
@@ -0,0 +1,75 @@
package resolution
import (
"encoding/json"
"slices"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// issue 900, ADR 0394 §4: the holder is told the secret places and the paths modules own on its node.
func TestTheHolderOfNodeFilesIsToldSecretPlacesAndOwnedPaths(t *testing.T) {
files := mcat.Manifest{Module: "files", Version: "1", Claims: []mcat.Claim{{Name: mcat.FilesSeat}},
OwnSecrets: mcat.OwnSecrets{"broker": {Path: "/var/lib/files/broker"}}}
host := mcat.Manifest{Module: "hostnames", Version: "1", Claims: []mcat.Claim{{Name: "node-hostname"}},
Resources: []map[string]any{{"id": "hosts", "type": "file", "path": "/etc/hosts"}}}
app := mcat.Manifest{Module: "app", Version: "1",
Resources: []map[string]any{{"id": "conf", "type": "file", "path": "/etc/app/app.conf"}}}
r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{files, host, app}}
words, err := resolutionFilesWords(r, files, nil, mcat.Rendering{})
if err != nil {
t.Fatal(err)
}
var places []string
if err := json.Unmarshal([]byte(words[mcat.ReadsSecretPlaces]), &places); err != nil || len(places) == 0 {
t.Errorf("no secret places: %v %v", err, words)
}
var owned map[string]string
if err := json.Unmarshal([]byte(words[mcat.FilesOwnedPaths]), &owned); err != nil {
t.Fatalf("no owned paths: %v (%v)", err, words)
}
if !strings.HasPrefix(owned["/etc/hosts"], "hostnames") {
t.Errorf("/etc/hosts is owned by %q, want hostnames", owned["/etc/hosts"])
}
if !strings.HasPrefix(owned["/etc/app/app.conf"], "app") {
t.Errorf("a placed file is owned by %q, want app", owned["/etc/app/app.conf"])
}
// Issue 1126, C3: a seat path that steps back for a seat held here is that holder's, whatever the order of the seat
// table (the controller reads it sorted by name, a node in the order compiled).
resolver := mcat.Manifest{Module: "systemd-resolved", Version: "1", Claims: []mcat.Claim{{Name: mcat.ResolverSeat}},
Resources: []map[string]any{{"id": "stub", "type": "file", "path": mcat.ResolverFile}}}
uplink := mcat.Manifest{Module: "networkmanager", Version: "1", Claims: []mcat.Claim{{Name: "node-uplink"}},
Resources: []map[string]any{{"id": "resolv", "type": "file", "path": mcat.ResolverFile}}}
compiled := seats
t.Cleanup(func() { seats = compiled })
for _, order := range []string{"compiled", "reversed"} {
if order == "reversed" {
seats = append([]mcat.Seat(nil), compiled...)
slices.Reverse(seats)
}
both := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{files, uplink, resolver}}
w, err := resolutionFilesWords(both, files, nil, mcat.Rendering{})
if err != nil {
t.Fatal(err)
}
var o map[string]string
if err := json.Unmarshal([]byte(w[mcat.FilesOwnedPaths]), &o); err != nil || o[mcat.ResolverFile] != "systemd-resolved ("+mcat.ResolverSeat+")" {
t.Errorf("seat table %s: %s is owned by %q, want systemd-resolved (%s)", order, mcat.ResolverFile, o[mcat.ResolverFile], mcat.ResolverSeat)
}
}
seats = compiled
// Issue 1126, C5 and seed-8: a module carried as it runs (compose.Carried) owns its manifest's paths (OwnsPaths), as
// the controller tells them, and not the paths its running resources are at (a directory the mesh placed).
carried := mcat.Manifest{Module: "de-spiegel", Version: "1", OwnsPaths: []string{"/var/lib/de-spiegel/server.env"},
Resources: []map[string]any{{"id": "state", "type": "directory", "path": "/var/lib/de-spiegel"}}}
w, err := resolutionFilesWords(mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{files, carried}}, files, nil, mcat.Rendering{})
if err != nil || !strings.Contains(w[mcat.FilesOwnedPaths], `"/var/lib/de-spiegel/server.env":"de-spiegel (its own tools)"`) ||
strings.Contains(w[mcat.FilesOwnedPaths], `"/var/lib/de-spiegel":`) {
t.Errorf("a carried module does not own its manifest's paths alone: %v %v", w[mcat.FilesOwnedPaths], err)
}
if w, _ := resolutionFilesWords(r, app, nil, mcat.Rendering{}); w[mcat.FilesOwnedPaths] != "" {
t.Error("a module not holding node-files is told the owned paths")
}
}
+894
View File
@@ -0,0 +1,894 @@
package resolution
import (
"fmt"
"slices"
"sort"
"strconv"
"strings"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// Filtering is the whole rule set for the node this resolved for.
//
// **Derived from what is assigned here, and from the overlay's shape** — a node's open ports are
// a consequence of what runs on it, not a second list kept in step by hand. Nothing else opens a
// port: **what is not declared is closed**, which is the property that makes the derivation worth
// having rather than merely tidy.
func Filtering(r mcat.Resolution, computed map[string]mcat.Generator, ports map[string]map[int]int, exposure map[string]map[int]string) ([]mcat.Rule, error) {
// Keyed by what actually distinguishes an opening. Two modules wanting :443 from the mesh is
// one rule with two sources; one wanting it from the mesh and another from anywhere is two,
// and they are collapsed below -- deliberately, and only in the widening direction.
type opening struct {
port int
protocol string
from string
}
found := map[opening]*mcat.Rule{}
for _, m := range r.Modules {
// What a module says, and what was computed for it on this machine. The second is how a
// hub's own port is derived: it is a fact about this machine's place in the mesh, which
// the module cannot know and the mesh cannot avoid knowing.
opens := m.Listens
if m.Computed != "" {
if generator, known := computed[m.Computed].(mcat.OpensPorts); known {
also, err := generator.Listens(r.Node)
if err != nil {
// **Refused, not treated as 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.
return nil, fmt.Errorf(
"%s could not say what %s opens, so no rule set can be computed for it: %w",
m.Module, r.Node, err)
}
opens = append(append([]mcat.Listening{}, opens...), also...)
}
}
for _, l := range opens {
// **The port the machine actually publishes on** (novox/hq ADR 0038). A module says
// the port its software uses; the mesh chooses where the machine puts it. A rule
// naming the first would open a port nothing listens on and leave the real one shut,
// which is a firewall that reports success and blocks the service.
port := l.Port
if at, known := ports[m.Module][l.Port]; known {
port = at
}
// The source, with any per-node exposure setting applied. The override names the port
// the module declares, so it travels with that port to wherever the machine publishes
// it (novox/hq ADR 0046): the same module is internal on one node and public on another.
from := l.From
if override, set := exposure[m.Module][l.Port]; set {
from = override
}
at := opening{port: port, protocol: l.At(), from: from}
rule, seen := found[at]
if !seen {
rule = &mcat.Rule{Port: port, Protocol: l.At(), From: from}
found[at] = rule
}
rule.Because = append(rule.Because, m.Module)
rule.Why = append(rule.Why, l.Why)
}
}
out := make([]mcat.Rule, 0, len(found))
for _, rule := range found {
out = append(out, *rule)
}
// By port, then protocol, then source. A rule set that reorders itself between two runs of an
// unchanged mesh is a diff nobody can read, and every consumer of this compares one to the
// last.
sort.Slice(out, func(a, b int) bool {
if out[a].Port != out[b].Port {
return out[a].Port < out[b].Port
}
if out[a].Protocol != out[b].Protocol {
return out[a].Protocol < out[b].Protocol
}
return out[a].From < out[b].From
})
return widest(out), nil
}
// Exposure reads a module's per-node exposure overrides from its settings: declared-port → source.
//
// It refuses an override for a port the module does not listen on, or to a source that is not a
// real one — an exposure setting that reaches no port, or names a source nothing enforces, is the
// "reads as a restriction and is none" fault this whole mechanism exists to prevent (novox/hq
// ADR 0043/0045). A module with no `expose` setting yields nothing and keeps its manifest defaults.
func Exposure(m mcat.Manifest, layers []mcat.Layer) (map[int]string, error) {
listened := make(map[int]bool, len(m.Listens))
for _, l := range m.Listens {
listened[l.Port] = true
}
out := map[int]string{}
for _, layer := range layers {
raw, ok := layer.Placement[mcat.ExposeSetting]
if !ok {
continue
}
entries, ok := raw.(map[string]any)
if !ok {
return nil, fmt.Errorf("%s: %s is a { port: source } map, and %q set it to something else",
m.Module, mcat.ExposeSetting, layer.From)
}
for portText, value := range entries {
port, err := strconv.Atoi(portText)
if err != nil {
return nil, fmt.Errorf("%s exposes %q, which is not a port", m.Module, portText)
}
if !listened[port] {
return nil, fmt.Errorf(
"%s exposes port %d, which it does not listen on — the setting reaches nothing",
m.Module, port)
}
source, ok := value.(string)
if !ok || (source != mcat.FromMesh && source != mcat.FromEverywhere && source != mcat.FromMachine &&
source != mcat.FromLocalNetwork) {
return nil, fmt.Errorf("%s exposes port %d as %v; it is %q, %q, %q or %q",
m.Module, port, value, mcat.FromMesh, mcat.FromEverywhere, mcat.FromMachine, mcat.FromLocalNetwork)
}
out[port] = source
}
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}
// widest drops a rule that another already covers.
//
// A port open to anywhere is not additionally restricted by a second rule opening it to the mesh:
// keeping both would render two lines where the narrower one restricts nothing, which is exactly
// the appearance-of-restriction this mechanism exists to remove. The narrower rule's sources are
// carried onto the wider one, so the module that wanted it is still named.
func widest(rules []mcat.Rule) []mcat.Rule {
// The local network is the private network and the machine's own local networks (novox/hq ADR
// 0340 §4), so it covers `mesh` and is covered by `anywhere`.
reach := map[string]int{mcat.FromMachine: 0, mcat.FromMesh: 1, mcat.FromLocalNetwork: 2, mcat.FromEverywhere: 3}
var out []mcat.Rule
for _, rule := range rules {
covered := false
for i := range out {
if out[i].Port != rule.Port || out[i].Protocol != rule.Protocol {
continue
}
if reach[out[i].From] >= reach[rule.From] {
out[i].Because = append(out[i].Because, rule.Because...)
out[i].Why = append(out[i].Why, rule.Why...)
covered = true
break
}
// The other way round: this one is wider, so it replaces what is there and takes its
// sources with it.
rule.Because = append(rule.Because, out[i].Because...)
rule.Why = append(rule.Why, out[i].Why...)
out = append(out[:i], out[i+1:]...)
break
}
if !covered {
out = append(out, rule)
}
}
return out
}
// AsNftablesWithLocal is AsNftables with the machine's local networks: the networks it reported on a
// link facing outside that count (novox/hq ADR 0340, JudgeLocalNetworks), which a rule from
// `local-network` admits on its own port alone, beside the private network. None is not widened to
// anywhere and not dropped silently: the rule set says the port wanted the local network and found none.
func AsNftablesWithLocal(rules []mcat.Rule, mesh []string, outward bool, foundation []int,
outwardLinks []string, tunnel string, local []string) string {
// The links that are not this machine's own: the ones facing outside, and the mesh's tunnel.
// Traffic arriving on any of them is admitted only by a rule below; traffic arriving anywhere
// else is this machine's own guest and is not something the mesh has a position on.
//
// The tunnel is named here deliberately. Treating it as "not outside" would make a port nothing
// declares reachable from every machine in the mesh, which is the derivation abandoned.
quoted := make([]string, 0, len(outwardLinks)+1)
for _, link := range outwardLinks {
quoted = append(quoted, fmt.Sprintf("%q", link))
}
if tunnel != "" {
quoted = append(quoted, fmt.Sprintf("%q", tunnel))
}
inward := strings.Join(quoted, ", ")
var b strings.Builder
b.WriteString("# Computed by the mesh from what is assigned to this node.\n")
b.WriteString("# Edits are lost on the next declaration; change a module's listens instead.\n\n")
// Replacing this table and nothing else, every time it is loaded. Declared as an empty table
// first so the delete cannot fail on a machine that has never loaded one — the alternative
// idiom, `flush ruleset`, empties every table on the machine, including the ones the container
// runtime writes for its bridges. Reloading the mesh's rules must not stop every container.
b.WriteString("table inet mesh {}\ndelete table inet mesh\n\n")
b.WriteString("table inet mesh {\n")
b.WriteString("\tchain input {\n")
// Dropping by default is the whole point, and the three lines under it are what keep dropping
// by default from meaning "unreachable": a reply to something this node started, its own
// loopback, and the diagnostic that tells an operator the machine is up and refusing rather
// than gone.
b.WriteString("\t\ttype filter hook input priority filter; policy drop;\n")
b.WriteString("\t\tct state established,related accept\n")
b.WriteString("\t\tct state invalid drop\n")
b.WriteString("\t\tiif lo accept\n")
// **Anything on this machine may call anything on this machine.**
//
// Local is not a boundary this mesh draws. A service running here is callable by everything else
// running here, whatever form either takes — a package with a unit, a binary, a container. Whether
// a caller sits in a container was never meant to change the answer, and the only reason it did was
// that this chain asked about addresses: a caller on the machine carries the machine's address, a
// caller in one of its containers carries a bridge address, and a rule naming the former silently
// refused the latter.
//
// Measured: a module reaching its database on this machine's own name timed out for eleven hours
// while the machine itself could reach it, and the mesh called the machine healthy throughout
// (novox/hq 04-ISSUES/145).
//
// Asked by the link it arrives on rather than the address it comes from: anything that did not
// arrive from outside this machine, and did not arrive over the private network, is this machine's
// own. One rule for every service here, in place of a line per port that only ever covered the
// ports somebody remembered to think about.
if inward != "" {
b.WriteString(fmt.Sprintf("\t\tiifname != { %s } accept\n", inward))
}
b.WriteString("\t\ticmp type echo-request accept\n")
b.WriteString("\t\ticmpv6 type { echo-request, nd-neighbor-solicit, nd-neighbor-advert, nd-router-advert } accept\n")
// **What this machine's own guests must be able to ask it** (novox/hq ADR 0140). A guest gets
// its address and its names from this machine, over the link it is on, and those two questions
// arrive at the input chain like any other. Denied, the guest never gets an address and never
// resolves a name — which is not "a closed port" but a network that does not work at all, and it
// is this machine's own guest asking.
//
// Asked for by the link it arrives on rather than by the address it comes from, for the reason
// the forward chain below no longer names an address: a range describes one machine and goes
// stale in silence. Anything arriving from outside, or over the tunnel, is not a guest of this
// machine and asks through a port somebody declared, like everything else.
if len(inward) > 0 {
b.WriteString("\t\t# this machine's own guests asking it for an address and for names\n")
b.WriteString(fmt.Sprintf("\t\tiifname != { %s } udp dport { 53, 67 } accept\n", inward))
b.WriteString(fmt.Sprintf("\t\tiifname != { %s } tcp dport 53 accept\n", inward))
}
// **ssh, always, and not because a module asked.**
//
// Every other line in this chain is derived from what is assigned here, which is the whole
// point of the mechanism. This one is not, and the exception is deliberate: the rules are
// computed from what the mesh knows, a mesh part-way through adopting a machine knows almost
// nothing, and the first port to close is the one used to fix it. The session that loads these
// rules survives on conntrack until it drops, and then the machine is reached from a rescue
// console (novox/hq issue 047).
//
// From the private network always, because that is how machines reach each other. From
// everywhere when this machine faces outward, because that is the only way in on the day the
// private network is what broke.
if four, six := byFamily(mesh); len(four) > 0 || len(six) > 0 {
b.WriteString("\n\t\t# ssh, from the mesh — never derived, never closed\n")
if len(four) > 0 {
b.WriteString(fmt.Sprintf("\t\tip saddr { %s } tcp dport %d accept\n",
strings.Join(four, ", "), mcat.SSHPort))
}
if len(six) > 0 {
b.WriteString(fmt.Sprintf("\t\tip6 saddr { %s } tcp dport %d accept\n",
strings.Join(six, ", "), mcat.SSHPort))
}
}
// From everywhere when this machine faces outward — and also when the mesh has no addresses to
// name yet.
//
// **A machine early in being adopted is the one this matters most for, and the one where the
// rule above emits nothing.** Before the private network exists there is no mesh address to
// allow from, so restricting to it restricts to nothing: the chain drops by default and ssh is
// simply shut. That is the first machine anybody adopts, reached over the network, with the
// port needed to fix it closed by the act of adopting it. Never leaving this chain without an
// ssh rule is worth more than the narrower rule it would have had.
if four, six := byFamily(mesh); outward || (len(four) == 0 && len(six) == 0) {
why := "and from outside, because this machine faces it"
if !outward {
why = "and from anywhere, because this mesh has no addresses to narrow it to yet"
}
b.WriteString("\t\t# " + why + "\n")
b.WriteString(fmt.Sprintf("\t\ttcp dport %d accept\n", mcat.SSHPort))
}
// The mesh's own ports, from anywhere.
//
// Not narrowed to the private network, because the machines that need this most are the ones
// not on it yet: a node enrols BEFORE it has an address here, over the ordinary network, and a
// rule allowing only the private network would close the door being knocked on.
if len(foundation) > 0 {
b.WriteString("\n\t\t# the mesh's own — never derived, never closed\n")
for _, port := range foundation {
b.WriteString(fmt.Sprintf("\t\ttcp dport %d accept\n", port))
}
}
if len(rules) > 0 {
b.WriteString("\n")
}
for _, rule := range rules {
for i, module := range rule.Because {
why := ""
if i < len(rule.Why) && rule.Why[i] != "" {
why = " — " + rule.Why[i]
}
b.WriteString(fmt.Sprintf("\t\t# %s%s\n", module, why))
}
switch rule.From {
case mcat.FromMachine:
// Nothing. A port bound for something else on the same machine is reached over
// loopback, which is already accepted above -- and rendering a rule for it would open
// it to the network, which is the opposite of what the manifest asked for.
b.WriteString(fmt.Sprintf("\t\t# %s/%d is this machine only; loopback already covers it\n",
rule.Protocol, rule.Port))
case mcat.FromMesh:
if len(mesh) == 0 {
// Said, and impossible to render. Left as a comment rather than silently widened
// to anywhere or silently dropped: one of those opens a port nobody asked to
// open, and the other closes one somebody did.
b.WriteString(fmt.Sprintf(
"\t\t# %s/%d wanted the mesh, and this node knows no mesh addresses; closed\n",
rule.Protocol, rule.Port))
break
}
four, six := byFamily(mesh)
if len(four) > 0 {
b.WriteString(fmt.Sprintf("\t\tip saddr { %s } %s dport %d accept\n",
strings.Join(four, ", "), rule.Protocol, rule.Port))
}
if len(six) > 0 {
b.WriteString(fmt.Sprintf("\t\tip6 saddr { %s } %s dport %d accept\n",
strings.Join(six, ", "), rule.Protocol, rule.Port))
}
case mcat.FromLocalNetwork:
// The private network and the machine's own local networks, on this port alone (novox/hq
// ADR 0340 §5).
four, six := byFamily(mesh)
lfour, lsix := byFamily(local)
four, six = append(four, lfour...), append(six, lsix...)
if len(lfour) == 0 && len(lsix) == 0 {
b.WriteString(fmt.Sprintf(
"\t\t# %s/%d wanted the local network, and this machine reported no private network "+
"on a link facing outside; admitted from no local network\n", rule.Protocol, rule.Port))
}
if len(four) == 0 && len(six) == 0 {
b.WriteString(fmt.Sprintf(
"\t\t# %s/%d wanted the local network, and this node knows neither mesh addresses nor a "+
"local network; closed\n", rule.Protocol, rule.Port))
break
}
if len(four) > 0 {
b.WriteString(fmt.Sprintf("\t\tip saddr { %s } %s dport %d accept\n",
strings.Join(four, ", "), rule.Protocol, rule.Port))
}
if len(six) > 0 {
b.WriteString(fmt.Sprintf("\t\tip6 saddr { %s } %s dport %d accept\n",
strings.Join(six, ", "), rule.Protocol, rule.Port))
}
case mcat.FromEverywhere:
b.WriteString(fmt.Sprintf("\t\t%s dport %d accept\n", rule.Protocol, rule.Port))
}
}
b.WriteString("\t}\n")
// Nothing this node sends is filtered, and it is stated rather than left to nftables' default
// so that reading this file answers the question instead of requiring the reader to know it.
b.WriteString("\tchain output {\n\t\ttype filter hook output priority filter; policy accept;\n\t}\n")
// **A forward chain, because without one this firewall does not cover container ports.**
//
// It used to have none, on the reasoning that a drop here would drop container traffic the
// runtime explicitly allowed and stop every container on the node. The first half of that is
// true. The conclusion was not: a published port is redirected and then *forwarded*, so it
// never reaches the input chain above, and a firewall with no forward chain says nothing at all
// about the ports most worth protecting. Rehearsed on three machines: loading these rules
// refused a port on the host and left a published container port reachable (novox/hq issue 047).
//
// **What it constrains is traffic arriving from OUTSIDE this machine, and nothing else**
// (novox/hq ADR 0140).
//
// It used to deny everything here and then allow the machine's own containers back by naming
// the address ranges they sit on — two ranges fixed in this file and the rest recorded per
// machine. Every way of keeping that list correct failed. A constant describes one machine. A
// recorded range goes stale in silence and cannot tell a network the mesh made from one a
// predecessor left behind. Generating it from the modules would have put half this rule set on
// the machine.
//
// The list should not exist, because the mesh has no position on a container reaching outward:
// that is not a port opened to anybody. So traffic that did not arrive from outside is accepted
// in one line, and what did arrive from outside is allowed only where a rule below admits it.
//
// The tunnel is not "not outside". Accepting everything off it would make a port nothing
// declares reachable from any machine in the mesh, which is the derivation abandoned — so it is
// named here beside the outward links, and traffic arriving on it meets the rules below like
// anything else.
b.WriteString("\tchain forward {\n")
b.WriteString("\t\ttype filter hook forward priority filter; policy drop;\n")
b.WriteString("\t\tct state established,related accept\n")
b.WriteString("\t\tct state invalid drop\n")
b.WriteString("\n")
// Only when there is a link to name. An empty set is a line nftables refuses, and a rule set
// that does not load is a machine filtering nothing while its unit reports success — so the
// chain denies rather than renders nonsense. Composing a declaration for a machine that has
// named none is refused upstream, so this is a floor and not a path anything travels.
if inward != "" {
b.WriteString("\t\t# this machine's own guests reaching outward: not a port opened to anybody\n")
b.WriteString(fmt.Sprintf("\t\tiifname != { %s } accept\n", inward))
}
// **The mesh passing through, not arriving.** A machine the mesh routes through — the hub, for
// every path between machines that are not co-located (novox/hq ADR 0007) — relays a packet that
// came in on the tunnel and leaves on it again, addressed to another machine of the mesh. That is
// no port of this machine's: the machine it is for filters it against its own rules. Without
// this, the chain below judged a relayed packet by this machine's own published ports, so two
// machines behind the hub reached each other only on ports the hub happened to publish for itself
// (novox/hq issue 196). In and out on the tunnel both: a packet off the tunnel for this machine's
// own containers leaves by a bridge, and still meets the rules below.
if tunnel != "" {
b.WriteString("\t\t# the mesh passing through to another of its machines, which filters it itself\n")
b.WriteString(fmt.Sprintf("\t\tiifname %q oifname %q accept\n", tunnel, tunnel))
}
if len(rules) > 0 {
b.WriteString("\n")
}
for _, rule := range rules {
for i, module := range rule.Because {
why := ""
if i < len(rule.Why) && rule.Why[i] != "" {
why = " — " + rule.Why[i]
}
b.WriteString(fmt.Sprintf("\t\t# %s%s\n", module, why))
}
// **Matched on where the packet was originally addressed, not where it is going now.**
// By the time a packet reaches this chain the runtime has already rewritten its
// destination to the container's own port, so matching the port a rule names would match
// nothing. Conntrack remembers what was asked for, which is the port the machine
// publishes and the only one a client ever knew.
switch rule.From {
case mcat.FromMachine:
b.WriteString(fmt.Sprintf("\t\t# %s/%d is this machine only, and is not forwarded\n",
rule.Protocol, rule.Port))
case mcat.FromMesh:
four, six := byFamily(mesh)
if len(four) > 0 {
b.WriteString(fmt.Sprintf("\t\tip saddr { %s } ct original proto-dst %d accept\n",
strings.Join(four, ", "), rule.Port))
}
if len(six) > 0 {
b.WriteString(fmt.Sprintf("\t\tip6 saddr { %s } ct original proto-dst %d accept\n",
strings.Join(six, ", "), rule.Port))
}
case mcat.FromLocalNetwork:
four, six := byFamily(mesh)
lfour, lsix := byFamily(local)
four, six = append(four, lfour...), append(six, lsix...)
if len(four) > 0 {
b.WriteString(fmt.Sprintf("\t\tip saddr { %s } ct original proto-dst %d accept\n",
strings.Join(four, ", "), rule.Port))
}
if len(six) > 0 {
b.WriteString(fmt.Sprintf("\t\tip6 saddr { %s } ct original proto-dst %d accept\n",
strings.Join(six, ", "), rule.Port))
}
case mcat.FromEverywhere:
b.WriteString(fmt.Sprintf("\t\tct original proto-dst %d accept\n", rule.Port))
}
}
// The foundation ports, forwarded — for the same reason they are in the input chain, and the
// same reason the module rules above are here too: the broker is a published container port,
// so a cross-node dial to it is redirected and *forwarded*, never reaching the input chain
// (novox/hq issue 047's lesson, applied to the foundation this time). Without this a joined
// node reaches the broker only while its first connection's conntrack entry survives — and a
// broker restart, which adopting the foundation's own broker causes, drops the entry and the
// node can never receive another declaration (novox/hq issue 063). From anywhere, matching
// the input rule: a node enrolling has no overlay address yet.
if len(foundation) > 0 {
b.WriteString("\n\t\t# the mesh's own — never derived, never closed\n")
for _, port := range foundation {
b.WriteString(fmt.Sprintf("\t\tct original proto-dst %d accept\n", port))
}
}
b.WriteString("\t}\n")
b.WriteString("}\n")
return b.String()
}
// byFamily splits addresses into the two nftables understands separately.
//
// `ip saddr` and `ip6 saddr` are different matches, and one set holding both families is a syntax
// error — which means the whole file fails to load and the machine filters nothing while the
// service reports a configuration fault. A mesh that is entirely one family renders one line, and
// a mixed one renders both rather than dropping half its nodes.
func byFamily(addresses []string) (four []string, six []string) {
for _, a := range addresses {
if strings.Contains(a, ":") {
six = append(six, a)
continue
}
four = append(four, a)
}
return four, six
}
// GivenPorts reads a module's given machine ports from its settings: software port → machine port.
//
// Refused from a mesh-wide layer — a port is a fact about one machine, and one number for every
// machine is the collision this exists to avoid — and for a port the module's containers do not
// publish.
//
// **Only a published port is the mesh's to move** (novox/hq ADR 0038). A container's mapping is
// what translates; a module binding the machine's network directly binds the number its software
// was configured with, and moving that number would put it in the filter, in the openings and in
// what consumers are told while the software still listens on the old one — a port that reads as
// moved and is not.
//
// **Either name of a mapping names it; the module's own name answers.** A short form publishes one
// number, which is the container's port and the machine's at once. A mapping written the long way
// — `"2222:22"` — has two, and a module reasonably declares it listens on either: the port its
// software uses, or the port the machine already serves on. A setting may name either end, because
// both are true of the same mapping. The answer comes back under the end the **module** names in
// its `listens`, which is the number every reader of this map holds: the ports map, the filter, the
// openings, what a consumer is told, and the mapping the runtime is handed. Keyed one way and read
// the other, the setting moved the container's mapping and nothing else — a firewall, a set of
// openings and a consumer all pointing at a port the software had left.
//
// One entry per mapping, never two. A second key for the same answer is not a convenience: where
// two mappings share a number, it is an entry one of them writes over the other's, and the reader
// that finds the survivor disagrees with the reader that recomputes it.
func GivenPorts(m mcat.Manifest, layers []mcat.Layer) (map[int]int, error) {
// An endpoint's own block may put it on a machine port, which is the same thing `ports` says about
// the number rather than about the endpoint (novox/hq ADR 0138). Collected first and then let the
// older key be read, which refuses a port said twice.
byName, err := mcat.Endpoints(m, layers)
if err != nil {
return nil, err
}
named := map[int]int{}
for name, ep := range byName {
if ep.Port == 0 {
continue
}
named[endpointPorts(m)[name]] = ep.Port
}
// Every name a setting may use, and the mapping it names.
names := map[int][]publishing{}
for _, p := range publishedPorts(m) {
names[p.inner] = append(names[p.inner], p)
if p.machine != p.inner {
names[p.machine] = append(names[p.machine], p)
}
}
// And the names the module itself uses. A mapping's answer is filed under these, because they
// are what every reader asks for; a mapping the module names at neither end is filed under its
// machine side, where nothing looks, which is correct — nothing serves it.
declares := map[int]bool{}
for _, l := range m.Listens {
declares[l.Port] = true
}
chose := map[int]int{} // the port a setting named → the machine port it gave it
out := map[int]int{} // the port the module names → the machine port it is on
by := map[int]int{} // and which of the setting's ports put it there, for the refusal
for _, layer := range layers {
raw, ok := layer.Placement[mcat.PortsSetting]
if !ok {
continue
}
if layer.From == mcat.MeshWideLayer {
return nil, fmt.Errorf("%s: %s is given per node — a port is a fact about one "+
"machine; set it with --node", m.Module, mcat.PortsSetting)
}
entries, ok := raw.(map[string]any)
if !ok {
return nil, fmt.Errorf("%s: %s is a { port: machine-port } map, and %q set it to "+
"something else", m.Module, mcat.PortsSetting, layer.From)
}
for portText, value := range entries {
port, err := strconv.Atoi(portText)
if err != nil {
return nil, fmt.Errorf("%s gives %q a port, which is not a port", m.Module, portText)
}
publishes, known := names[port]
if !known {
return nil, fmt.Errorf("%s gives port %d a machine port, and no container of its "+
"publishes %d — the mesh cannot move a port the module does not publish; the "+
"software would go on listening where it was told to", m.Module, port, port)
}
if err := oneMapping(m.Module, port, publishes); err != nil {
return nil, err
}
at, ok := asPort(value)
if !ok || at < 1 || at > 65535 {
return nil, fmt.Errorf("%s gives port %d the machine port %v, which is not a port",
m.Module, port, value)
}
if at == mcat.SSHPort {
return nil, fmt.Errorf("%s gives port %d the machine port %d, which is ssh's — the "+
"one port a machine may never lose", m.Module, port, at)
}
chose[port] = at
}
}
// One holder per machine port, within the module too.
holder := map[int]int{}
for _, port := range sortedPorts(chose) {
at := chose[port]
if other, twice := holder[at]; twice {
return nil, fmt.Errorf("%s gives machine port %d to both its %d and its %d", m.Module,
at, min(port, other), max(port, other))
}
holder[at] = port
}
// Filed under the module's own names for the mapping — every one it uses, so a module that
// says it listens on both ends is answered at both, and under the machine side when it names
// neither.
for _, port := range sortedPorts(chose) {
at, mapping := chose[port], names[port][0]
keys := []int{}
for _, end := range []int{mapping.machine, mapping.inner} {
if declares[end] && !slices.Contains(keys, end) {
keys = append(keys, end)
}
}
if len(keys) == 0 {
keys = []int{mapping.machine}
}
for _, key := range keys {
// The key must name this mapping and no other, or the entry is one mapping's answer
// standing where another's is read.
if err := oneMapping(m.Module, key, names[key]); err != nil {
return nil, err
}
// And one machine port per mapping: `{"22": 222, "2222": 300}` is two numbers for the
// one thing the machine publishes, and neither is more right.
if was, twice := out[key]; twice && was != at {
return nil, fmt.Errorf("%s gives %s the machine ports %d and %d — its %d and its "+
"%d are the two ends of one mapping, and it is published once", m.Module,
mapping, min(was, at), max(was, at), min(by[key], port), max(by[key], port))
}
out[key], by[key] = at, port
}
}
// And what the endpoints' own blocks put them on. Refused rather than merged where both keys name
// one endpoint: two places giving a port is the confusion this key exists to end.
for wanted, at := range named {
if was, twice := out[wanted]; twice && was != at {
return nil, fmt.Errorf(
"%s puts its port %d on %d through %s and on %d through %s — one endpoint, two "+
"machine ports, and it is published once. Keep the endpoint's own block",
m.Module, wanted, at, mcat.EndpointsSetting, was, mcat.PortsSetting)
}
out[wanted] = at
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}
// oneMapping refuses a number that names two of a module's mappings — `"22"` beside `"2222:22"`,
// say, or `"4001:80"` beside `"4002:80"` read by their shared `80`. Refused rather than picked:
// moving one of them and leaving the other is a mapping the operator did not ask for and cannot
// see, and the two readings differ.
func oneMapping(module string, port int, publishes []publishing) error {
for _, other := range publishes[1:] {
if other != publishes[0] {
return fmt.Errorf("%s gives port %d a machine port, and its containers publish %d "+
"twice — as %s and as %s; which one to move is not said", module, port, port,
publishes[0], other)
}
}
return nil
}
// publishing is one mapping a module's container writes: the port the container itself uses, and
// the machine port the manifest put it on — the same number for a short form, which the runtime
// publishes on the port it names.
type publishing struct{ machine, inner int }
func (p publishing) String() string {
if p.machine == p.inner {
return strconv.Itoa(p.inner)
}
return fmt.Sprintf("%d:%d", p.machine, p.inner)
}
// publishedPorts is every mapping a module's containers publish, whichever form it is written in.
func publishedPorts(m mcat.Manifest) []publishing {
var out []publishing
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "container" {
continue
}
listed, _ := r["ports"].([]any)
for _, entry := range listed {
written := strings.TrimSpace(fmt.Sprint(entry))
if machine, inner, _, ok := mapping(written); ok {
out = append(out, publishing{machine: machine, inner: inner})
continue
}
if cut := strings.LastIndex(written, "/"); cut >= 0 {
written = written[:cut]
}
if n, err := strconv.Atoi(strings.TrimSpace(written)); err == nil {
out = append(out, publishing{machine: n, inner: n})
}
}
}
return out
}
// sortedPorts is a settings map's ports in order, so a refusal reads the same on every run.
func sortedPorts(of map[int]int) []int {
out := make([]int, 0, len(of))
for port := range of {
out = append(out, port)
}
sort.Ints(out)
return out
}
// reaches is every value, in the order a refusal lists them.
var reaches = []string{mcat.ReachMachine, mcat.ReachInternal, mcat.ReachPublic, mcat.ReachBoth, mcat.ReachLocalNetwork}
// RoutedPorts are the ports a module serves through a proxy, taken from its route contributions.
//
// **A routed endpoint's port is how the proxy reaches it, and nothing else.** That is ADR 0045's
// decision and it is older than reach: a public service listens `from: mesh`, only the proxy reaches
// it, and it is exposed by name. So `public` on a routed endpoint asks for a public *name*; opening
// that port to the world as well would undo the arrangement the proxy exists for.
//
// Measured before this was written, not reasoned: a module's routed name answered from the internet
// over TLS while its machine-side port was refused from the same place. The port is not the path.
func RoutedPorts(m mcat.Manifest) map[int]bool {
out := map[int]bool{}
note := func(values map[string]any) {
// **The endpoint it serves, by name where it says one.** A route repeating a port number is
// the older shape and still read: 35 of the catalogue's 36 route entries name a port their
// module declares a listen on (novox/hq ADR 0138).
if name, ok := values[mcat.RouteEndpoint].(string); ok {
if port, found := mcat.EndpointPort(m, name); found {
out[port] = true
return
}
}
if port, ok := asPort(values["port"]); ok {
out[port] = true
}
}
if values, ok := m.Contributes["route"]; ok {
note(values)
}
for _, values := range m.ContributesMany["route"] {
note(values)
}
return out
}
// FilterSource is the source a reach means to the packet filter.
//
// `public` and `both` are the same here. A reach that opened a port to the mesh and not to the world
// would be `internal`; there is no reach that opens it to the world and *not* to the mesh, because a
// filter cannot express "everyone except these" and nobody has asked for it.
func FilterSource(reach string) (string, bool) {
switch reach {
case mcat.ReachMachine:
return mcat.FromMachine, true
case mcat.ReachInternal:
return mcat.FromMesh, true
case mcat.ReachPublic, mcat.ReachBoth:
return mcat.FromEverywhere, true
case mcat.ReachLocalNetwork:
return mcat.FromLocalNetwork, true
default:
return "", false
}
}
// WantsPublicName is whether a reach asks for the route's public name to be composed.
func WantsPublicName(reach string) bool { return reach == mcat.ReachPublic || reach == mcat.ReachBoth }
// WantsInternalName is whether a reach asks for the route's internal name to be composed.
func WantsInternalName(reach string) bool {
return reach == mcat.ReachInternal || reach == mcat.ReachBoth || reach == mcat.ReachLocalNetwork
}
// Reaches reads a module's per-node reach settings: declared port → how far it reaches.
//
// It refuses a reach for a port the module does not listen on, or a value that is not one of the
// four — the "reads as a restriction and is none" fault this whole mechanism exists to prevent
// (novox/hq ADR 0043/0045). It also refuses a port that `expose` names as well: the two say the same
// thing in different words, and a module whose reach and exposure disagree would have the filter
// following one and the names following the other, which is the very confusion ADR 0138 removes.
//
// A module with no `reach` setting yields nothing, and everything behaves exactly as before: the
// filter follows the manifest's `from`, and both names are composed. That is what keeps every machine
// already running unchanged until an assignment says otherwise.
func Reaches(m mcat.Manifest, layers []mcat.Layer) (map[int]string, error) {
listened := make(map[int]bool, len(m.Listens))
for _, l := range m.Listens {
listened[l.Port] = true
}
exposed, err := Exposure(m, layers)
if err != nil {
return nil, err
}
out := map[int]string{}
// What an endpoint's own block says, which is the same statement in the shape that names the
// endpoint rather than its port (novox/hq ADR 0138). Read first so the older key, which says less,
// cannot quietly win over the newer one that says more.
blocks, err := mcat.Endpoints(m, layers)
if err != nil {
return nil, err
}
declared := endpointPorts(m)
for name, ep := range blocks {
if ep.Reach == "" {
continue
}
out[declared[name]] = ep.Reach
}
for _, layer := range layers {
raw, ok := layer.Placement[mcat.ReachSetting]
if !ok {
continue
}
entries, ok := raw.(map[string]any)
if !ok {
return nil, fmt.Errorf("%s: %s is a { port: reach } map, and %q set it to something else",
m.Module, mcat.ReachSetting, layer.From)
}
for portText, value := range entries {
port, err := strconv.Atoi(portText)
if err != nil {
return nil, fmt.Errorf("%s says how far %q reaches, which is not a port", m.Module, portText)
}
if !listened[port] {
return nil, fmt.Errorf(
"%s says how far port %d reaches, which it does not listen on — the setting "+
"reaches nothing", m.Module, port)
}
reach, ok := value.(string)
if !ok || !slices.Contains(reaches, reach) {
return nil, fmt.Errorf("%s says port %d reaches %v; a reach is %s",
m.Module, port, value, strings.Join(reaches, ", "))
}
if _, both := exposed[port]; both {
return nil, fmt.Errorf(
"%s sets both %s and %s for port %d. They say the same thing in different "+
"words, and the filter would follow one while its names followed the other "+
"— which is what %s exists to stop. Keep %s",
m.Module, mcat.ReachSetting, mcat.ExposeSetting, port, mcat.ReachSetting, mcat.ReachSetting)
}
out[port] = reach
}
}
if len(out) == 0 {
return nil, nil
}
return out, nil
}
// spokenEndpoints is what a module's endpoints are called, as a refusal lists them — so a reader who
// named one wrongly is one edit from right, and a module that has named none is told so.
func spokenEndpoints(m mcat.Manifest) string {
names := make([]string, 0, len(m.Listens))
for _, l := range m.Listens {
if name := strings.TrimSpace(l.Name); name != "" {
names = append(names, name)
}
}
if len(names) == 0 {
return "no endpoints by name"
}
sort.Strings(names)
return strings.Join(names, ", ")
}
@@ -0,0 +1,379 @@
package resolution
import (
"errors"
"fmt"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// A machine that has not said which links face outside is sent no filter, and the refusal names the
// module that would have loaded it so the reader knows what is being withheld.
func TestAMachineThatNamedNoOutwardLinkIsSentNoFilter(t *testing.T) {
r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{
{Module: "nftables", Filtering: &mcat.Filtering{Into: "/etc/mesh/filter.nft"}},
{Module: "web", Listens: []mcat.Listening{{Port: 443, From: mcat.FromEverywhere}}},
}}
_, err := Declaration(r, mcat.Rendering{Mesh: []string{"198.51.100.2"}, TunnelInterface: "mesh0"})
if err == nil {
t.Fatal("a machine that named no outward link was sent a filter written around none")
}
for _, want := range []string{"anchor", "nftables", "face outside"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("the refusal does not say %q: %v", want, err)
}
}
}
// And a machine that names none but loads no filter is not refused: there is nothing to write.
func TestAMachineWithNoFilterModuleIsNotRefused(t *testing.T) {
r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{
{Module: "web", Listens: []mcat.Listening{{Port: 443, From: mcat.FromEverywhere}}},
}}
if _, err := Declaration(r, mcat.Rendering{Mesh: []string{"198.51.100.2"}}); err != nil {
t.Fatalf("a machine that loads no filter was refused one: %v", err)
}
}
// The rule set is the machine's, not the asking module's.
func TestTheModuleAskingForTheRuleSetGetsEveryModulesPorts(t *testing.T) {
r := mcat.Resolution{Modules: []mcat.Manifest{
{Module: "firewall", Filtering: &mcat.Filtering{Into: "/etc/mesh/filter.nft"}},
{Module: "web", Listens: []mcat.Listening{{Port: 443, From: mcat.FromEverywhere}}},
}}
out, err := Declaration(r, mcat.Rendering{Mesh: []string{"198.51.100.2"},
OutwardLinks: []string{"eth0"}, TunnelInterface: "mesh0"})
if err != nil {
t.Fatalf("declaration: %v", err)
}
var content string
for _, res := range out {
if res["path"] == "/etc/mesh/filter.nft" {
content, _ = res["content"].(string)
}
}
if content == "" {
t.Fatal("the module that asked for the rule set did not receive one")
}
if !strings.Contains(content, "dport 443 accept") {
t.Fatalf("the rule set holds only the asking module's ports:\n%s", content)
}
}
// The host does not sort, so the order the mesh writes is the order a machine applies.
//
// A module's service or container routinely depends on a file the mesh computed — a certificate,
// a credential, a rule set. Written after the service, the service is applied first, fails, and
// the next reconcile fixes it. A fault that repairs itself on the second attempt is worse than one
// that does not: what gets remembered is that it works.
func TestWhatTheMeshComputesIsAppliedBeforeWhatTheModuleDeclared(t *testing.T) {
r := mcat.Resolution{Modules: []mcat.Manifest{{
Module: "firewall",
Filtering: &mcat.Filtering{Into: "/etc/nftables.conf"},
Resources: []map[string]any{
{"id": "load", "type": "service", "unit": "nftables.service", "state": "running",
"restart-on": []any{"filtering"}},
},
}}}
out, err := Declaration(r, mcat.Rendering{Mesh: []string{"198.51.100.2"},
OutwardLinks: []string{"eth0"}, TunnelInterface: "mesh0"})
if err != nil {
t.Fatalf("declaration: %v", err)
}
var order []string
for _, res := range out {
order = append(order, fmt.Sprint(res["id"]))
}
if len(order) != 2 {
t.Fatalf("expected the rule set and the service, got %v", order)
}
if order[0] != "firewall.filtering" {
t.Fatalf("the service is applied before the file it reflects: %v", order)
}
}
// The same, for a module whose resources are computed elsewhere — that branch replaces the
// module's resources wholesale, and merging in the wrong place would throw away its credentials.
func TestAComputedModuleStillGetsWhatTheMeshMadeForIt(t *testing.T) {
r := mcat.Resolution{Modules: []mcat.Manifest{{
Module: "networking", Computed: "mesh-network",
OwnSecrets: mcat.OwnSecrets{"key": {Path: "/var/lib/mesh/key"}},
}}}
out, err := Declaration(r, mcat.Rendering{
Needed: map[string]map[string]string{"networking": {"key": "sealed"}},
Generators: map[string]mcat.Generator{"mesh-network": computedOnce{}},
})
if err != nil {
t.Fatalf("declaration: %v", err)
}
var ids []string
for _, res := range out {
ids = append(ids, fmt.Sprint(res["id"]))
}
if len(ids) == 0 || ids[0] != "networking."+mcat.NeedID("key") {
t.Fatalf("a computed module lost the secret the mesh made for it, or applies it late: %v", ids)
}
}
type computedOnce struct{}
func (computedOnce) Resources(string) ([]map[string]any, bool, error) {
return []map[string]any{{"id": "wg", "type": "file", "path": "/etc/wireguard/wg0.conf",
"content": "x", "mode": "0600"}}, true, nil
}
// A consumer bound to something answered on its own machine still has to be told where it is.
//
// The location is a fact nobody needs — "it is here" — and everything beside it is not: the
// binding carries what the provider said a consumer must know, which is the port. A build machine
// sharing a node with the registry it pushes to sat in a loop saying it could not read its own
// binding, because none was written.
func TestSomethingAnsweredOnThisMachineIsStillBound(t *testing.T) {
r := mcat.Resolution{
Node: "anchor", At: "anchor.internal",
Modules: []mcat.Manifest{
{Module: "registry", Provides: []mcat.Offer{{Name: "artifact-store", Scope: mcat.ScopeMesh}},
Serves: map[string]map[string]any{"artifact-store": {"port": 5000}}},
{Module: "builder", Requires: []string{"artifact-store"},
Binds: map[string]string{"artifact-store": "/var/lib/builder/store.json"}},
},
}
out, err := Declaration(r, mcat.Rendering{})
if err != nil {
t.Fatalf("declaration: %v", err)
}
var content string
for _, res := range out {
if res["path"] == "/var/lib/builder/store.json" {
content, _ = res["content"].(string)
}
}
if content == "" {
t.Fatal("nothing was written, so the consumer cannot find a provider on its own machine")
}
if !strings.Contains(content, "5000") {
t.Fatalf("the binding does not carry the port, which is the part nobody can guess:\n%s", content)
}
if !strings.Contains(content, "anchor.internal") {
t.Fatalf("the binding does not say where to reach it:\n%s", content)
}
}
// And a machine not on the private network still reaches itself.
func TestAMachineOffTheNetworkIsBoundToItselfByAnAddressThatWorks(t *testing.T) {
r := mcat.Resolution{
Node: "alone",
Modules: []mcat.Manifest{
{Module: "registry", Provides: []mcat.Offer{{Name: "artifact-store", Scope: mcat.ScopeMesh}},
Serves: map[string]map[string]any{"artifact-store": {"port": 5000}}},
{Module: "builder", Requires: []string{"artifact-store"},
Binds: map[string]string{"artifact-store": "/var/lib/builder/store.json"}},
},
}
out, err := Declaration(r, mcat.Rendering{})
if err != nil {
t.Fatalf("declaration: %v", err)
}
for _, res := range out {
if res["path"] != "/var/lib/builder/store.json" {
continue
}
content, _ := res["content"].(string)
if !strings.Contains(content, "127.0.0.1") {
t.Fatalf("bound by a name nothing on this machine resolves:\n%s", content)
}
return
}
t.Fatal("nothing was written")
}
// cannotSay is a generator that does not know what a machine opens.
type cannotSay struct{}
func (cannotSay) Resources(string) ([]map[string]any, bool, error) { return nil, true, nil }
func (cannotSay) Listens(string) ([]mcat.Listening, error) {
return nil, errors.New("this machine's endpoint has no port in it")
}
// A generator that cannot say is refused, not read as silence.
//
// Closing a port on the evidence of a failure to look is how a machine is severed by a fault
// somewhere else entirely — and the machine it would sever is the hub.
func TestAGeneratorThatCannotSayRefusesTheRuleSet(t *testing.T) {
_, err := Filtering(mcat.Resolution{Node: "anchor",
Modules: []mcat.Manifest{{Module: "networking", Computed: "mesh-network"}},
}, map[string]mcat.Generator{"mesh-network": cannotSay{}}, nil, nil)
if err == nil {
t.Fatal("a machine whose open ports could not be computed was given a rule set anyway")
}
if !strings.Contains(err.Error(), "anchor") {
t.Fatalf("the refusal does not name the machine: %v", err)
}
}
// A module may reflect a file another module put on the machine.
//
// The case this exists for: a resolver restarting when the mesh rewrites the names. Those are
// computed by the mesh and belong to its module, not to the daemon's — so without this the
// daemon would serve the names it started with for ever, while every check reported success.
func TestAServiceCanReflectAnotherModulesFile(t *testing.T) {
out, err := Declaration(mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{{
Module: "dnsmasq",
Resources: []map[string]any{
{"id": "config", "type": "file", "path": "/etc/dnsmasq.conf",
"content": "x", "mode": "0644"},
{"id": "run", "type": "service", "unit": "dnsmasq.service", "state": "running",
"restart-on": []any{"config", "mesh-resolver.nodes"}},
},
}}}, mcat.Rendering{})
if err != nil {
t.Fatal(err)
}
for _, r := range out {
if r["type"] != "service" {
continue
}
reflects := r["restart-on"].([]any)
var own, other bool
for _, id := range reflects {
switch id.(string) {
case "dnsmasq.config":
own = true
case "mesh-resolver.nodes":
other = true
}
}
if !own {
t.Fatalf("its own file was not prefixed with its module: %v", reflects)
}
if !other {
t.Fatalf("a file it named in full was prefixed again and now points at nothing: %v",
reflects)
}
return
}
t.Fatal("no service in the declaration")
}
// What answers a requirement is applied before what asked for it.
//
// The host does not sort, so the order written here is the order a machine applies. Selection
// walks outward from what was assigned, which puts a consumer first — and a service that reads a
// file another module writes then starts before the file exists, fails, and is fixed by the next
// reconcile. That is the worst shape a fault can take: what gets remembered is that it works.
func TestWhatAnsweredARequirementIsAppliedFirst(t *testing.T) {
got, err := Resolve(map[string]mcat.Manifest{
"dnsmasq": {Module: "dnsmasq", Requires: []string{"resolver-data"}},
"data": {Module: "data", Provides: []mcat.Offer{{Name: "resolver-data"}}},
}, []string{"dnsmasq"}, mcat.Node{Name: "anchor"}, mcat.World{})
if err != nil {
t.Fatal(err)
}
if got.Modules[0].Module != "data" {
t.Fatalf("the consumer is applied before what it requires: %v", named(got))
}
}
// Through a chain, not only one step: a machine applies what the thing it needs needs, first.
func TestAChainOfRequirementsIsAppliedFromTheBottom(t *testing.T) {
got, err := Resolve(map[string]mcat.Manifest{
"top": {Module: "top", Requires: []string{"middle-thing"}},
"middle": {Module: "middle", Provides: []mcat.Offer{{Name: "middle-thing"}}, Requires: []string{"bottom-thing"}},
"bottom": {Module: "bottom", Provides: []mcat.Offer{{Name: "bottom-thing"}}},
}, []string{"top"}, mcat.Node{Name: "anchor"}, mcat.World{})
if err != nil {
t.Fatal(err)
}
order := named(got)
if order[0] != "bottom" || order[1] != "middle" || order[2] != "top" {
t.Fatalf("a chain was not applied from the bottom: %v", order)
}
}
// Modules with no relation keep the order selection gave them — assigned first, then what they
// pulled in. A sort that reshuffled unrelated modules would make every declaration's diff
// unreadable for no gain.
func TestUnrelatedModulesKeepTheirOrder(t *testing.T) {
got, err := Resolve(map[string]mcat.Manifest{
"one": {Module: "one"},
"two": {Module: "two"},
"three": {Module: "three"},
}, []string{"one", "two", "three"}, mcat.Node{Name: "anchor"}, mcat.World{})
if err != nil {
t.Fatal(err)
}
order := named(got)
if order[0] != "one" || order[1] != "two" || order[2] != "three" {
t.Fatalf("unrelated modules were reshuffled: %v", order)
}
}
// Two modules requiring each other are both applied rather than refused: a cycle here is not a
// machine that cannot work, and refusing would make a cooperating pair impossible to assign.
func TestTwoModulesRequiringEachOtherAreStillApplied(t *testing.T) {
got, err := Resolve(map[string]mcat.Manifest{
"a": {Module: "a", Provides: []mcat.Offer{{Name: "from-a"}}, Requires: []string{"from-b"}},
"b": {Module: "b", Provides: []mcat.Offer{{Name: "from-b"}}, Requires: []string{"from-a"}},
}, []string{"a"}, mcat.Node{Name: "anchor"}, mcat.World{})
if err != nil {
t.Fatal(err)
}
if len(got.Modules) != 2 {
t.Fatalf("a cooperating pair was not applied: %v", named(got))
}
}
func named(r mcat.Resolution) []string {
var out []string
for _, m := range r.Modules {
out = append(out, m.Module)
}
return out
}
// A port's source is a per-node setting, not a manifest constant: the same module is internal on
// one machine and public on another (novox/hq ADR 0046). postgres listens from the mesh by default;
// a setting on one node exposes it to anywhere, and the rule set follows.
func TestExposureSettingOverridesAListensSource(t *testing.T) {
postgres := mcat.Manifest{Module: "postgres", Version: "1",
Listens: []mcat.Listening{{Port: 5432, From: mcat.FromMesh, Why: "the database"}}}
base, err := Filtering(mcat.Resolution{Node: "ace", Modules: []mcat.Manifest{postgres}}, nil, nil, nil)
if err != nil {
t.Fatalf("filtering: %v", err)
}
if len(base) != 1 || base[0].From != mcat.FromMesh {
t.Fatalf("without a setting, the default source is the mesh: %+v", base)
}
expose := map[string]map[int]string{"postgres": {5432: mcat.FromEverywhere}}
exposed, err := Filtering(mcat.Resolution{Node: "novox", Modules: []mcat.Manifest{postgres}}, nil, nil, expose)
if err != nil {
t.Fatalf("filtering: %v", err)
}
if len(exposed) != 1 || exposed[0].From != mcat.FromEverywhere {
t.Fatalf("the setting did not open the port to anywhere: %+v", exposed)
}
}
// Exposure refuses a setting that names a port the module does not listen on, or a source that is
// not a real one — a setting reaching nothing is worse than none (novox/hq ADR 0043/0046).
func TestExposureRefusesAPortNotListenedOnAndABadSource(t *testing.T) {
postgres := mcat.Manifest{Module: "postgres", Listens: []mcat.Listening{{Port: 5432, From: mcat.FromMesh}}}
layer := func(port, source string) []mcat.Layer {
return []mcat.Layer{{From: "node", Placement: map[string]any{mcat.ExposeSetting: map[string]any{port: source}}}}
}
if _, err := Exposure(postgres, layer("5432", mcat.FromEverywhere)); err != nil {
t.Fatalf("a valid exposure was refused: %v", err)
}
if _, err := Exposure(postgres, layer("6379", mcat.FromEverywhere)); err == nil {
t.Error("a port the module does not listen on was accepted")
}
if _, err := Exposure(postgres, layer("5432", "everyone")); err == nil {
t.Error("a source that is not mesh/anywhere/machine was accepted")
}
}
@@ -0,0 +1,270 @@
package resolution
import (
"encoding/json"
"fmt"
"path/filepath"
"reflect"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
"github.com/novox/mesh-host/internal/beside"
)
// The catalogue's foundation modules as they are — in a merge check the catalogue cloned beside it,
// elsewhere the copy captured in testdata/beside (internal/beside, novox/hq issue 432) — parsed by the
// real parser (novox/hq ADR 0100):
// the store and the broker say which of their ports the mesh guards on an adopted node, and the
// filter module loads its table through a unit of its own whose stop deletes only that table.
// moduleTree is where a module is read from: the catalogue beside, with the catalogue as the root. In
// mesh-controller it first looked in the repository's own modules/<module>, where build-agent and
// route-proxy live whole (novox/hq ADR 0420 §1, issue 1019); mesh-sdk has no such directory, and the
// tests that read those two modules stay in mesh-controller (issue 1379).
func moduleTree(t *testing.T, module string) (root, dir string) {
t.Helper()
return beside.Dir(t, "mesh-catalog"), filepath.Join(beside.Catalogue(t), module)
}
func catalogueManifest(t *testing.T, module string) mcat.Manifest {
t.Helper()
root, dir := moduleTree(t, module)
m, err := mcat.ReadTreeManifest(root, dir)
if err != nil {
t.Fatalf("%s does not parse:\n%v", module, err)
}
return m
}
func TestTheForgesPortIsGivenLikeAnyOtherProvidersPort(t *testing.T) {
forge := catalogueManifest(t, "gitea")
// The catalogue's number is a default and the node's setting moves it.
given, err := GivenPorts(forge, []mcat.Layer{{From: "anchor",
Placement: map[string]any{mcat.PortsSetting: map[string]any{"3000": float64(3100)}}}})
if err != nil {
t.Fatalf("the forge's port cannot be given on a node: %v", err)
}
if given[3000] != 3100 {
t.Fatalf("the forge was given %v", given)
}
// And every consumer of the package registry is told where the machine actually put it,
// because that is read from what the forge serves rather than written in the consumer.
if got := ServedOn(forge, "npm-package-registry", given)["port"]; got != 3100 {
t.Errorf("the package registry is served on %v, not the port this node gave it", got)
}
if got := ServedOn(forge, "npm-package-registry", nil)["port"]; got != float64(3000) {
t.Errorf("without a setting the forge serves %v, not the catalogue's port", got)
}
// And so is where a repository on it is cloned from (novox/hq ADR 0111), for the same reason:
// a build composes the URL from what the forge serves, so a given port is a followed port.
if got := ServedOn(forge, "git", given)["port"]; got != 3100 {
t.Errorf("git is served on %v, not the port this node gave the forge", got)
}
}
// The forge holds the seats it answers for (novox/hq ADR 0110, 0111), parsed by the real parser —
// which refuses a delivering seat claimed by a module that does not provide what it delivers.
func TestTheForgeHoldsTheNpmAndGitSeats(t *testing.T) {
forge := catalogueManifest(t, "gitea")
holds := map[string]bool{}
for _, c := range forge.Claims {
holds[c.Name] = true
}
for _, seat := range []string{"npm-package-registry", "git"} {
if !holds[seat] {
t.Errorf("gitea does not claim the %s seat: %+v", seat, forge.Claims)
}
}
git := ServedOn(forge, "git", nil)
if git["scheme"] != "http" || git["port"] != float64(3000) {
t.Errorf("gitea serves nothing a clone URL can be composed from: %v", git)
}
npm := ServedOn(forge, "npm-package-registry", nil)
if npm["npm-path"] != "/api/packages/novox/npm/" {
t.Errorf("gitea no longer says where its npm registry is: %v", npm)
}
}
// **And the forge's own address follows it**, composed from the manifest in the catalogue beside
// this checkout (novox/hq 04-ISSUES/088).
//
// The forge is reached a third way that neither test above covers: by its own code, over the
// machine's loopback, told where to go in its environment. The `2999:3000` mapping that lets the
// forge go on binding 3000 does nothing for a caller dialling the machine — so a literal there is
// wrong on every node whose assignment differs, and wrong for a second reason on a node given the
// port (ADR 0100). Composed through the whole path, because what proves the placeholder resolves
// in an `env` at all is a declaration, not a manifest.
func TestTheForgesOwnAddressFollowsThePortTheNodeGaveIt(t *testing.T) {
forge, err := catalogueManifest(t, "gitea").Resolve(forgeBuilt(catalogueManifest(t, "gitea")))
if err != nil {
t.Fatalf("the forge's manifest does not resolve against its own build: %v", err)
}
r := mcat.Resolution{Node: "anchor", Account: "op", PublicDomain: "example.org",
Modules: []mcat.Manifest{forge, theRuntime(t)}, Needs: []mcat.Needed{
{Name: "postgres-database", For: "gitea", From: "anchor", At: "127.0.0.1",
Serves: map[string]any{"port": float64(5432)}, Sealed: "sealed-db"},
{Name: "route", For: "gitea", From: "anchor"},
{Name: "secret", For: "gitea", From: "anchor", Local: "internal-token", Sealed: "sealed-token"},
{Name: "secret", For: "gitea", From: "anchor", Local: "admin", Sealed: "sealed-admin"},
}}
// The number this node was given for the forge — the one the machine it is about to run on
// already publishes.
out, err := Declaration(r, mcat.Rendering{ArtifactStore: "anchor.internal:5101",
Needed: map[string]map[string]string{mcat.RuntimeModule: {"broker": "sealed-broker"}},
Given: map[string]map[int]int{"gitea": {3000: 2999}},
})
if err != nil {
t.Fatalf("the forge does not compose: %v", err)
}
// What the machine publishes, and what the forge's sidecar is told to dial: one number.
server := fileNamed(out, "gitea.server")
if server == nil {
t.Fatalf("the forge's own container is not in the declaration: %v", out)
}
if published := fmt.Sprint(server["ports"]); !strings.Contains(published, "2999:3000") {
t.Fatalf("the forge is not published on the port this node gave it: %v", server["ports"])
}
// The forge's own code runs in the node's runtime (novox/hq ADR 0198), given its words there.
runtime := fileNamed(out, mcat.RuntimeModule+"."+RuntimeProcessID())
if runtime == nil {
t.Fatalf("the node's runtime is not in the declaration: %v", ids(out))
}
env, _ := runtime["env"].(map[string]string)
var given map[string]map[string]string
if err := json.Unmarshal([]byte(env[mcat.RuntimeToolEnv]), &given); err != nil {
t.Fatalf("the runtime's %s is not JSON: %q", mcat.RuntimeToolEnv, env[mcat.RuntimeToolEnv])
}
if given["gitea"]["MESH_GITEA_URL"] != "http://127.0.0.1:2999" {
t.Fatalf("the forge's code dials %v while the machine publishes the forge on 2999 — "+
"whatever reads it dials a dead port", given["gitea"]["MESH_GITEA_URL"])
}
}
// gitea's own sshd is unmodified — the module's own internal port is 22, the number in
// `listens`, the same convention every other module in the catalogue uses (its internal port,
// not an invented identity). Composed from the manifest in the catalogue beside this checkout,
// because what the mesh publishes is a fact about what the module actually writes.
func declaredGiteaSsh(t *testing.T, given map[int]int) map[string]any {
t.Helper()
forge := catalogueManifest(t, "gitea")
resolved, err := forge.Resolve(forgeBuilt(forge))
if err != nil {
t.Fatalf("the forge's manifest does not resolve against its own build: %v", err)
}
r := mcat.Resolution{Node: "anchor", Account: "op", PublicDomain: "example.org",
Modules: []mcat.Manifest{resolved, theRuntime(t)}, Needs: []mcat.Needed{
{Name: "postgres-database", For: "gitea", From: "anchor", At: "127.0.0.1",
Serves: map[string]any{"port": float64(5432)}, Sealed: "sealed-db"},
{Name: "route", For: "gitea", From: "anchor"},
{Name: "secret", For: "gitea", From: "anchor", Local: "internal-token", Sealed: "sealed-token"},
{Name: "secret", For: "gitea", From: "anchor", Local: "admin", Sealed: "sealed-admin"},
}}
givenPorts := map[int]int{3000: 3000}
for k, v := range given {
givenPorts[k] = v
}
out, err := Declaration(r, mcat.Rendering{ArtifactStore: "anchor.internal:5101",
Needed: map[string]map[string]string{mcat.RuntimeModule: {"broker": "sealed-broker"}},
Ports: map[string]map[int]int{"gitea": givenPorts},
Given: map[string]map[int]int{"gitea": given},
})
if err != nil {
t.Fatalf("the forge does not compose: %v", err)
}
server := fileNamed(out, "gitea.server")
if server == nil {
t.Fatalf("the forge's own container is not in the declaration: %v", out)
}
return server
}
// **The forge publishes ssh at the mesh's own fixed convention by default** (novox/hq ADR 0100).
//
// `222` is the mesh's own public convention for the forge's ssh, written directly in the
// manifest's `ports` — every node the forge has run on used the same number, so it needs no
// per-node setting to reach it.
func TestTheForgesSshPortIsTheMeshsFixedConventionByDefault(t *testing.T) {
forge := catalogueManifest(t, "gitea")
// Nothing was given — no node moved this port — which is the ordinary answer: the mesh only
// reports what a setting moved, and the manifest's own `222:22` needs no move to be reached.
given, err := GivenPorts(forge, nil)
if err != nil {
t.Fatalf("the forge's ssh port cannot be given on a node: %v", err)
}
if len(given) != 0 {
t.Fatalf("nothing moved the forge's ssh port, yet it was given %v", given)
}
server := declaredGiteaSsh(t, given)
if published := fmt.Sprint(server["ports"]); !strings.Contains(published, "222:22") {
t.Fatalf("the forge is published on %v, not its own fixed convention", server["ports"])
}
}
// **A node whose predecessor served git on a different number can still be told to leave it
// there.** The setting names the port the module itself listens on — 22, gitea's own sshd, the
// same number `listens` uses — not the mesh's own default machine-side number, so moving it does
// not require guessing what the manifest happens to default to.
func TestANodeMayGiveTheForgesSshPortADifferentNumber(t *testing.T) {
forge := catalogueManifest(t, "gitea")
given, err := GivenPorts(forge, []mcat.Layer{{From: "anchor",
Placement: map[string]any{mcat.PortsSetting: map[string]any{"22": float64(9022)}}}})
if err != nil {
t.Fatalf("the forge's ssh port cannot be moved on a node: %v", err)
}
if want := map[int]int{22: 9022}; !reflect.DeepEqual(given, want) {
t.Fatalf("the forge was given %v, and the setting named %v", given, want)
}
server := declaredGiteaSsh(t, given)
if published := fmt.Sprint(server["ports"]); !strings.Contains(published, "9022:22") ||
strings.Contains(published, "222:22") {
t.Fatalf("the forge is published on %v, not the port this node gave it", server["ports"])
}
}
// forgeBuilt is a build of every artifact the forge's manifest declares: its code bundle and, since the
// catalogue added it (novox/hq ADR 0251 §4), its npm registry's. Named from the manifest, so a third
// artifact does not break every forge test again.
func forgeBuilt(forge mcat.Manifest) []mcat.Built {
var out []mcat.Built
for _, a := range forge.Build.Artifacts {
out = append(out, mcat.Built{Name: a.Name, Kind: mcat.ArtifactBundle,
Reference: mcat.ArtifactStoreScheme + "gitea/" + a.Name + "/blobs/" + bundleDigest, Digest: bundleDigest})
}
return out
}
// catalogueResolved is a catalogue module as the mesh holds it once built: the manifest as written, resolved against
// what the build produced besides the archive its templates are packed into, which carries the text read back from the
// module's files/ directory as the real build reads it (novox/hq ADR 0349 §2) — so a module whose file text moved into
// templates composes with it, and one that did not is resolved as it was.
func catalogueResolved(t *testing.T, module string, built ...mcat.Built) mcat.Manifest {
t.Helper()
root, dir := moduleTree(t, module)
m, err := mcat.ReadTreeManifestWritten(root, dir)
if err != nil {
t.Fatalf("%s does not parse:\n%v", module, err)
}
if name := mcat.TemplatesArtifact(m); name != "" {
read, err := mcat.ReadTemplates(m, dir)
if err != nil {
t.Fatalf("%s: %v", module, err)
}
// By the path inside the archive, as the build reads it back, not by the path in the module's directory.
texts := map[string]string{}
for path, text := range read {
texts[strings.TrimPrefix(path, m.TemplatesDir()+"/")] = text
}
built = append(built, mcat.Built{Name: name, Kind: mcat.ArtifactArchive,
Reference: mcat.ArtifactStoreScheme + module + "/" + name + "/blobs/" + bundleDigest, Digest: bundleDigest,
Texts: texts})
}
held, err := m.Resolve(built)
if err != nil {
t.Fatalf("%s does not resolve against its own build: %v", module, err)
}
return held
}
@@ -0,0 +1,81 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
func runtimesOwn() mcat.Manifest {
return mcat.Manifest{Module: "docker", Resources: []map[string]any{
{"id": "daemon", "type": "file", "path": "/etc/docker/daemon.json", "into": "json",
"content": `{"live-restore": true}`},
{"id": "runtime", "type": "service", "unit": "docker.service", "state": "running",
"reload-on": []any{"daemon"}},
}}
}
func TestAGeneratedResourceCollidingWithAModulesIsRefused(t *testing.T) {
r := mcat.Resolution{Node: "workstation", Modules: []mcat.Manifest{
{Module: "mesh-network", Computed: "mesh-network", Provides: mcat.Offers("private-network")},
runtimesOwn(),
}}
_, err := Declaration(r, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": reloading{}}})
if err == nil {
t.Fatal("a generated resource declaring the runtime's file beside the runtime's module was accepted")
}
for _, want := range []string{"mesh-network", "docker", "/etc/docker/daemon.json", "docker.service"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the refusal does not name %s: %v", want, err)
}
}
}
func TestAGeneratedResourceBesideAModulesOwnIsComposed(t *testing.T) {
r := mcat.Resolution{Node: "workstation", Modules: []mcat.Manifest{
{Module: "mesh-network", Computed: "mesh-network", Provides: mcat.Offers("private-network")},
runtimesOwn(),
}}
gen := &fake{on: map[string]bool{"workstation": true}}
out, err := Declaration(r, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": gen}})
if err != nil {
t.Fatalf("disjoint resources were refused: %v", err)
}
if fileNamed(out, "docker.daemon") == nil {
t.Fatalf("the runtime's own file is missing: %v", out)
}
// And a machine not on the network yet generates nothing, which collides with nothing.
if _, err := Declaration(r, mcat.Rendering{Generators: map[string]mcat.Generator{"mesh-network": &fake{}}}); err != nil {
t.Fatalf("a machine off the network was refused: %v", err)
}
}
// The catalogue's container runtime module states the mesh's registry itself (ADR 0222).
func TestTheRuntimesModuleTrustsTheMeshsRegistry(t *testing.T) {
docker := catalogueManifest(t, "docker")
r := mcat.Resolution{Node: "workstation", Modules: []mcat.Manifest{docker}}
out, err := Declaration(r, mcat.Rendering{
SeatReach: map[string]string{"mesh-artifact-store": "anchor.internal:5100"},
})
if err != nil {
t.Fatal(err)
}
daemon := fileNamed(out, "docker.daemon")
if daemon == nil || daemon["into"] != "json" {
t.Fatalf("the runtime's file is not written into: %v", daemon)
}
content, _ := daemon["content"].(string)
if !strings.Contains(content, `"insecure-registries": ["anchor.internal:5100"]`) ||
!strings.Contains(content, `"live-restore": true`) {
t.Fatalf("the runtime's file says %q", content)
}
out, err = Declaration(r, mcat.Rendering{})
if err != nil {
t.Fatal(err)
}
if content, _ := fileNamed(out, "docker.daemon")["content"].(string); strings.Contains(content, "insecure-registries") {
t.Fatalf("with no store on the network, the runtime is told %q", content)
}
}
@@ -0,0 +1,105 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// aProviderWithABundle is a provider whose code is a bundle the node's runtime runs — the shape
// every TypeScript provisioner has since ADR 0198.
func aProviderWithABundle() mcat.Manifest {
return mcat.Manifest{
Module: "mongodb", Version: "1",
Provides: mcat.FromAnywhere("mongodb-database"),
Receives: map[string]string{"mongodb-database": "/var/lib/mongodb/grants/mesh.json"},
Grants: map[string]string{"mongodb-database": "/var/lib/mongodb/grants"},
Bundles: []mcat.Bundle{{Name: "code", Language: "typescript"}},
Resources: []map[string]any{{
"id": "server", "type": "container", "name": "mongodb-server",
"image": "mongo@sha256:" + strings.Repeat("a", 64),
}},
}
}
func TestAGrantSecretIsOwnedByTheAccountThatProvisions(t *testing.T) {
r, err := Resolve(shelf(aProviderWithABundle()), []string{"mongodb"}, reachable(), mcat.World{})
if err != nil {
t.Fatal(err)
}
r.Account = "operator"
out, err := Declaration(r, mcat.Rendering{Grants: []mcat.Grant{{
Provision: "mongodb-database", Consumer: "workstation", From: "photos", Slug: "photos",
Values: map[string]any{}, Sealed: "c2VhbGVk",
}}})
if err != nil {
t.Fatal(err)
}
var secret map[string]any
for _, res := range out {
if res["type"] == "file" && strings.HasSuffix(fmtPath(res), ".secret") {
secret = res
}
}
if secret == nil {
t.Fatalf("no grant secret was composed at all: %v", out)
}
if got := secret["owner"]; got != "operator" {
t.Fatalf("the grant secret at %v belongs to %v; the provisioner runs as %q and a "+
"root-owned 0600 file is one it cannot read — which is silent, because the harness "+
"calls it \"not readable yet\"", fmtPath(secret), got, "operator")
}
// A grant whose offer presents no credential (form none, issue 1126) carries nothing sealed: no secret file is
// written for it, as none is for a need with no credential, and the provider still learns who asked.
out, err = Declaration(r, mcat.Rendering{Grants: []mcat.Grant{{
Provision: "mongodb-database", Consumer: "workstation", From: "photos", Slug: "photos", Values: map[string]any{}}}})
if err != nil {
t.Fatal(err)
}
asked := false
for _, res := range out {
if res["type"] == "file" && strings.HasSuffix(fmtPath(res), ".secret") {
t.Errorf("a grant with no credential writes %v", res)
}
asked = asked || fmtPath(res) == "/var/lib/mongodb/grants/mesh.json"
}
if !asked {
t.Errorf("the provider no longer learns who asked: %v", out)
}
}
// And a provider whose code still runs in a container keeps the owner it declares, so this
// changes nothing for the modules the runtime has not taken.
func TestAContainerProvidersGrantSecretKeepsItsDeclaredOwner(t *testing.T) {
m := aProviderWithABundle()
m.Bundles = nil
m.SecretsOwner = "65534:65534"
r, err := Resolve(shelf(m), []string{"mongodb"}, reachable(), mcat.World{})
if err != nil {
t.Fatal(err)
}
r.Account = "operator"
out, err := Declaration(r, mcat.Rendering{Grants: []mcat.Grant{{
Provision: "mongodb-database", Consumer: "workstation", From: "photos", Slug: "photos",
Values: map[string]any{}, Sealed: "c2VhbGVk",
}}})
if err != nil {
t.Fatal(err)
}
for _, res := range out {
if res["type"] == "file" && strings.HasSuffix(fmtPath(res), ".secret") {
if got := res["owner"]; got != "65534:65534" {
t.Fatalf("a container provider's grant secret belongs to %v, not what it declares", got)
}
return
}
}
t.Fatal("no grant secret was composed")
}
func fmtPath(r map[string]any) string {
p, _ := r["path"].(string)
return p
}
@@ -0,0 +1,228 @@
package resolution
import (
"fmt"
"strings"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// graphicalSessionSeats are the eleven, in the order ADR 0208's table reads, each with the verbs
// research 026/05 starts it with. Three have none yet: the bar (which receives blocks instead,
// ADR 0255), the compositor and the secret
// service are roles a second holder competes for, and nothing has needed to ask them anything.
func graphicalSessionSeats() []mcat.Seat {
const decided = "novox/hq ADR 0208"
return []mcat.Seat{
{Name: mcat.LoginManagerSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.Verb{
{Name: "sessions", Description: "The sessions the login manager offers on this machine, and which " +
"one the operator account starts by default.",
Input: schema(map[string]string{}, nil)},
}},
{Name: mcat.DisplayServerSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.Verb{
{Name: "displays", Description: "The monitors connected now, each with its identity, its modes and " +
"where it is placed; and the layout profile in force, if one matches.",
Input: schema(map[string]string{}, nil)},
// Profiles are keyed by the monitors' identities and are the operator's data (ADR 0208 §6).
{Name: "layout", Description: "The monitor layout profiles, keyed by the connected monitors' " +
"identities: list them, save the current arrangement under a name, or apply one.",
Input: withEnum(schema(map[string]string{
"action": "list, save or apply",
"name": "the profile to save or apply (save and apply only)",
}, []string{"action"}), "action", "list", "save", "apply")},
}},
// It receives window-manager configuration lines from every other module (novox/hq ADR 0212):
// bindings, start-up commands, rules — placed by the session's holder, never written into
// its directory by the contributor.
{Name: mcat.DisplaySessionSeat, Scope: mcat.ScopeNode, Decision: decided,
Receives: []mcat.Receivable{{Kind: "config", Comment: "#"}}, Serves: []mcat.Verb{
{Name: "reload", Description: "Reload the session's configuration in place, keeping its windows.",
Input: schema(map[string]string{}, nil)},
{Name: "workspaces", Description: "The session's workspaces: each one's name, output, and whether " +
"it is visible or focused.",
Input: schema(map[string]string{}, nil)},
{Name: "windows", Description: "The session's windows: each one's title, class, workspace and " +
"whether it has focus; narrowed to one workspace when named.",
Input: schema(map[string]string{"workspace": "one workspace (optional)"}, nil)},
}},
{Name: mcat.TerminalEmulatorSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.Verb{
{Name: "open", Description: "Open a terminal window in the operator's session, running a command " +
"or the login shell, in a directory or the account's home.",
Input: schema(map[string]string{
"command": "what to run in it (optional; the login shell when absent)",
"directory": "where it starts (optional; the account's home when absent)",
}, nil)},
}},
{Name: mcat.LauncherSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.Verb{
{Name: "menu", Description: "Put a menu of choices in front of the operator and answer with the " +
"one chosen, or nothing when the menu was dismissed — the dmenu-compatible contract.",
Input: map[string]any{"type": "object", "required": []string{"choices"},
"properties": map[string]any{
"choices": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
"description": "the lines to choose between, in order"},
"prompt": map[string]any{"type": "string", "description": "what the menu asks (optional)"},
}}},
// A value the operator types and nobody sees (novox/hq ADR 0259 §10): a hidden prompt whose answer
// is sealed to the asker's key, so it is never plaintext on the bus or in any call's record.
// **Optional while its holders catch up** (ADR 0246): rofi serves it once this is live.
{Name: "secret", Optional: true, Description: "Ask the operator for a value in a prompt that " +
"does not show what is typed, and answer it sealed to the key the asker gives — never in " +
"the clear — or cancelled when the prompt was dismissed or not answered in time.",
// By name, never by words (the review of 2026-10-09, M4): the holder writes the prompt from the
// module, the secret and the machine, and says the controller asks — the bus lets nobody else
// ask it (broker.ControllerOnly) — so no caller puts words of its own before the operator.
Input: schema(map[string]string{
"module": "the module whose own secret is asked for",
"secret": "the own secret's name",
"node": "the machine the module runs on",
"seal_to": "the asker's public sealing key: the answer is sealed to it",
"timeout_seconds": "give up after this long (optional)",
}, []string{"module", "secret", "node", "seal_to"})},
}},
{Name: mcat.NotifierSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.Verb{
{Name: "send", Description: "Show the operator a notification.",
Input: withEnum(schema(map[string]string{
"title": "the notification's summary",
"body": "its text (optional)",
"urgency": "low, normal (the default) or critical",
}, []string{"title"}), "urgency", "low", "normal", "critical")},
{Name: "history", Description: "The notifications shown lately, newest first.",
Input: schema(map[string]string{"limit": "how many (optional, default 20)"}, nil)},
}},
{Name: mcat.LockScreenSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.Verb{
{Name: "lock", Description: "Lock the operator's session now.",
Input: schema(map[string]string{}, nil)},
}},
{Name: mcat.ClipboardSeat, Scope: mcat.ScopeNode, Decision: decided, Serves: []mcat.Verb{
{Name: "history", Description: "What the clipboard held lately, newest first.",
Input: schema(map[string]string{"limit": "how many (optional, default 20)"}, nil)},
{Name: "copy", Description: "Put text on the operator's clipboard.",
Input: schema(map[string]string{"text": "the text"}, []string{"text"})},
}},
// It receives blocks as data (novox/hq ADR 0255): what a block shows, said for any bar, which
// the holder renders in its own grammar. A module that knows something about the machine — the
// power module its battery — gives the block; the bar knows no machine's hardware.
{Name: mcat.BarSeat, Scope: mcat.ScopeNode, Decision: decided,
Receives: []mcat.Receivable{{Kind: mcat.BarKindBlock, Comment: "#", Offered: true, Shape: barBlockShape()}}},
{Name: mcat.CompositorSeat, Scope: mcat.ScopeNode, Decision: decided},
{Name: mcat.SecretServiceSeat, Scope: mcat.ScopeNode, Decision: decided},
}
}
// withEnum narrows one string property of a schema to the values it may take, so a caller is told
// the choices by the schema rather than by a refusal.
func withEnum(s map[string]any, property string, values ...string) map[string]any {
props := s["properties"].(map[string]any)
p := props[property].(map[string]any)
p["enum"] = values
return s
}
// barShows is what a contributed block may show, and the options each takes — the bar-neutral
// vocabulary every holder of node-bar renders. A new entry is a decision, and every holder's template
// renders it (the shape's examples) before it is accepted.
var barShows = map[string]map[string]barOption{
// The machine's battery: its charge, and how long it lasts or takes to fill.
"battery": {"device": {kind: "text"}},
// One line a command prints, run every interval seconds: what the bar cannot know itself.
"command": {"command": {kind: "text", required: true}, "interval": {kind: "seconds", required: true}},
// A value the contributor keeps on the bus (novox/hq ADR 0255): its state `<module>.<name>` and the
// key, this machine's name when absent. The value is an object whose `text` is what is shown. The
// holder is granted to read it and shows it however its bar shows a changing value; no file and
// no command passes between the two modules.
"state": {"state": {kind: "text", required: true}, "key": {kind: "text"}},
}
// barBlockReads is the state and key a block shows, if any; an empty key is the machine's own.
func barBlockReads(data map[string]any) []mcat.StateRead {
if data["shows"] != "state" {
return nil
}
options, _ := data["options"].(map[string]any)
s, ok := options["state"].(string)
if !ok || s == "" {
return nil
}
key, _ := options["key"].(string)
return []mcat.StateRead{{Read: s, Key: key}}
}
type barOption struct {
kind string // text, or seconds (a whole number from 1 to 86400)
required bool
}
// barBlockShape is the shape of a block (novox/hq ADR 0255): which bar, where on it, in what order,
// what it shows, and that kind's options.
//
// - bar: top or bottom;
// - place: resources (beside the processor, memory and disks) or status (beside the sound, before the
// clock) — the two places every holder keeps for contributed blocks;
// - order: 0–99 within the place, 50 when absent;
// - shows: battery or command; options: what that kind takes.
func barBlockShape() *mcat.Shape {
return &mcat.Shape{
Text: true,
Places: []mcat.PlaceField{{Field: "bar", Values: []string{"bottom", "top"}},
{Field: "place", Values: []string{"resources", "status"}}},
Order: "order",
Check: barBlockProblems,
Reads: barBlockReads,
Examples: []map[string]any{
{"bar": "bottom", "place": "status", "shows": "battery"},
{"bar": "bottom", "place": "status", "shows": "battery", "options": map[string]any{"device": "BAT0"}},
{"bar": "bottom", "place": "status", "shows": "command",
"options": map[string]any{"command": "echo \"a line\"", "interval": float64(5)}},
{"bar": "bottom", "place": "status", "shows": "state", "options": map[string]any{"state": "power.draw"}},
{"bar": "bottom", "place": "status", "shows": "state",
"options": map[string]any{"state": "power.draw", "key": "a-machine"}},
},
}
}
func barBlockProblems(data map[string]any) []string {
var problems []string
for field := range data {
if !oneOf([]string{"bar", "place", "order", "shows", "options"}, field) {
problems = append(problems, fmt.Sprintf("a block has no field %q; it has bar, place, order, shows and options", field))
}
}
shows, _ := data["shows"].(string)
takes, known := barShows[shows]
if !known {
return append(problems, fmt.Sprintf("a block shows %v; it shows one of %s", data["shows"],
strings.Join(sortedKeys(barShows), ", ")))
}
options := map[string]any{}
if raw, has := data["options"]; has {
o, ok := raw.(map[string]any)
if !ok {
return append(problems, "a block's options are an object")
}
options = o
}
for name, v := range options {
opt, ok := takes[name]
if !ok {
problems = append(problems, fmt.Sprintf("a %s block has no option %q; it takes %s", shows, name,
orNothing(sortedKeys(takes))))
continue
}
switch opt.kind {
case "text":
if t, isText := v.(string); !isText || strings.TrimSpace(t) == "" {
problems = append(problems, fmt.Sprintf("a %s block's %s is text", shows, name))
}
case "seconds":
if _, whole := wholeIn(v, 1, 86400); !whole {
problems = append(problems, fmt.Sprintf("a %s block's %s is a whole number of seconds from 1 to 86400", shows, name))
}
}
}
for _, name := range sortedKeys(takes) {
if _, has := options[name]; takes[name].required && !has {
problems = append(problems, fmt.Sprintf("a %s block needs the option %s", shows, name))
}
}
return problems
}
@@ -0,0 +1,74 @@
package resolution
import (
"reflect"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
func displayServer(name, display string, claims ...string) mcat.Manifest {
m := mcat.Manifest{Module: name, Provides: []mcat.Offer{{Name: display, Reach: mcat.ReachMachine}}}
for _, c := range claims {
m.Claims = append(m.Claims, mcat.Claim{Name: c})
}
return m
}
func windowManager() mcat.Manifest {
return mcat.Manifest{Module: "i3", Requires: []string{"x11-display"}}
}
// §3: a display is resolved on the requiring module's own node.
func TestAMachineReachRequirementResolvesToTheProviderOnItsOwnNode(t *testing.T) {
cat := shelf(windowManager(), displayServer("xorg", "x11-display", mcat.DisplayServerSeat))
got, err := Resolve(cat, []string{"xorg", "i3"}, workstation(), mcat.World{})
if err != nil {
t.Fatalf("i3 beside xorg did not resolve: %v", err)
}
if !reflect.DeepEqual(names(got), []string{"xorg", "i3"}) && !reflect.DeepEqual(names(got), []string{"i3", "xorg"}) {
t.Errorf("resolved %v", names(got))
}
}
// §3: never answered by installing a provider, and never by another machine's.
func TestAMachineReachRequirementIsNotPulledInNorAnsweredFromAnotherNode(t *testing.T) {
cat := shelf(windowManager(),
displayServer("xorg", "x11-display", mcat.DisplayServerSeat),
displayServer("xwayland", "x11-display"))
// Another machine runs xorg and says so to the world; it does not count.
world := mcat.World{Offered: map[string][]mcat.Provider{
"x11-display": {{Node: "laptop", At: "laptop.mesh", Module: "xorg"}}}}
_, err := Resolve(cat, []string{"i3"}, workstation(), world)
if err == nil {
t.Fatal("i3 resolved on a machine with no display of its own")
}
for _, want := range []string{
`"x11-display" is wanted by i3`, "usable only on the machine that provides it",
"assign one to workstation", "xorg (holds node-display-server)", "xwayland",
} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the refusal does not say %q:\n%v", want, err)
}
}
// With a single provider in the catalogue too: one candidate is still not a choice to make
// for somebody, unlike a node-scoped provision without the machine's reach.
_, err = Resolve(shelf(windowManager(), displayServer("xorg", "x11-display", mcat.DisplayServerSeat)),
[]string{"i3"}, workstation(), mcat.World{})
if err == nil {
t.Fatal("xorg was pulled in for i3")
}
// Not in the first pass, whose refusals take the machine off the network.
if _, err := Resolve(cat, []string{"i3"}, workstation(), mcat.World{Unchecked: true}); err != nil {
t.Errorf("the first pass refused: %v", err)
}
}
func TestACatalogueDisagreeingAboutAProvisionsReachIsRefused(t *testing.T) {
cat := shelf(windowManager(), displayServer("xorg", "x11-display"), mcat.Manifest{Module: "fake-x", Provides: mcat.Offers("x11-display")})
_, err := Resolve(cat, []string{"xorg", "i3"}, workstation(), mcat.World{})
if err == nil || !strings.Contains(err.Error(), `the catalogue disagrees about "x11-display"`) {
t.Fatalf("a provision with and without the machine's reach gave %v", err)
}
}
+127
View File
@@ -0,0 +1,127 @@
package resolution
import (
"reflect"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
func aContainerWithGroups(groups ...any) map[string]any {
return map[string]any{"id": "server", "type": "container", "name": "n8n", "image": "x@sha256:" + strings.Repeat("a", 64),
"groups": groups}
}
// 1636/AC3: a manifest container with groups: ["${setting:media-gid}"] resolves to the number the setting says.
func TestAContainersGroupIsFilledFromTheSetting(t *testing.T) {
r := aContainerWithGroups("${setting:media-gid}", float64(2001))
layers := []mcat.Layer{{From: "ace", Values: map[string]any{"media-gid": float64(2000)}}}
if err := settingInto(r, layers, "n8n"); err != nil {
t.Fatal(err)
}
if want := []any{2000, 2001}; !reflect.DeepEqual(r["groups"], want) {
t.Fatalf("composed as %#v, want %#v", r["groups"], want)
}
// A value the operator wrote as text is read as the number it spells.
r = aContainerWithGroups("${setting:media-gid}")
if err := settingInto(r, []mcat.Layer{{From: "ace", Values: map[string]any{"media-gid": "2000"}}}, "n8n"); err != nil {
t.Fatal(err)
}
if want := []any{2000}; !reflect.DeepEqual(r["groups"], want) {
t.Fatalf("composed as %#v, want %#v", r["groups"], want)
}
}
// 1636/AC3: an unset value refuses the module by name, and the key and the remedy are named; nothing is dropped.
func TestAnUnsetGroupRefusesTheModuleByName(t *testing.T) {
r := aContainerWithGroups("${setting:media-gid}")
err := settingInto(r, nil, "n8n")
if err == nil || !strings.Contains(err.Error(), "n8n") || !strings.Contains(err.Error(), "media-gid") {
t.Fatalf("want a refusal naming n8n and media-gid, got %v", err)
}
if got, _ := r["groups"].([]any); len(got) != 1 || got[0] != "${setting:media-gid}" {
t.Fatalf("the resource was changed: %#v", r["groups"])
}
}
// 1636/AC2: a value that is not a whole number above zero cannot be a group.
func TestAGroupThatIsNotAWholeNumberAboveZeroIsRefused(t *testing.T) {
for _, bad := range []any{"media", "-1", "0", float64(0), float64(2000.5), "2000:2000", "", true} {
r := aContainerWithGroups("${setting:media-gid}")
err := settingInto(r, []mcat.Layer{{From: "ace", Values: map[string]any{"media-gid": bad}}}, "n8n")
if err == nil || !strings.Contains(err.Error(), "media-gid") {
t.Errorf("value %#v: want a refusal naming the key, got %v", bad, err)
}
}
}
// 1636/AC3 composed for a machine, the way a push writes it: n8n's group is the number the machine's config value
// says, written as a number, and the module is left out by name when the machine says none.
func TestAComposedMachineGetsTheGroupTheConfigValueNames(t *testing.T) {
manifest, err := parseRaw(t, map[string]any{
"module": "n8n", "version": "1",
"config": map[string]any{"media-gid": map[string]any{"type": "number", "minimum": 1, "required": true,
"description": "the group that owns the media library on this machine"}},
"resources": []any{aContainerWithGroups("${config:media-gid}")},
})
if err != nil {
t.Fatal(err)
}
r := anAdoptedAnchor()
r.Modules = append(r.Modules, manifest)
with := anchorRendering(false)
with.Settings["n8n"] = []mcat.Layer{{From: "anchor", Values: map[string]any{"media-gid": float64(2000)}}}
composed, err := Compose(r, with)
if err != nil {
t.Fatal(err)
}
if why, left := composed.LeftOut["n8n"]; left {
t.Fatalf("left out: %s", why)
}
if got := byID(composed.Resources)["n8n.server"]["groups"]; !reflect.DeepEqual(got, []any{2000}) {
t.Fatalf("composed groups %#v", got)
}
without := anchorRendering(false)
composed, err = Compose(r, without)
if err == nil {
why, left := composed.LeftOut["n8n"]
if !left || !strings.Contains(why, "media-gid") {
t.Fatalf("a machine that says no group kept n8n or left it out without the key: %q (%v)", why, left)
}
} else if !strings.Contains(err.Error(), "media-gid") {
t.Fatalf("refused without the key: %v", err)
}
}
// The node-engine reads a container's groups only when it reports the capability (novox/hq ADR 0480 clause 7, issue 1638):
// a module with a container that names groups is hosted on a machine that reports container-groups, and left out of one
// that does not, the capability named, so the field is never sent to an engine that would refuse it.
func TestAModuleWhoseContainerNamesGroupsIsHeldOnAMachineThatDoesNotReadThem(t *testing.T) {
n8n := mcat.Manifest{Module: "n8n", Resources: []map[string]any{aContainerWithGroups("${config:media-gid}")}}
plain := mcat.Manifest{Module: "plain", Resources: []map[string]any{
{"id": "server", "type": "container", "name": "p", "image": "x@sha256:" + strings.Repeat("b", 64)}}}
for _, m := range []mcat.Manifest{n8n, plain} {
if got := impliedCapabilities(m); (m.Module == "n8n") != reflect.DeepEqual(got, []string{mcat.ContainerGroups}) {
t.Errorf("%s implies %v", m.Module, got)
}
}
shelf := shelf(n8n, plain)
reads := mcat.Node{Name: "ace", Capabilities: map[string]bool{"container-runtime": true, mcat.ContainerGroups: true}}
got, err := Resolve(shelf, []string{"n8n", "plain"}, reads, mcat.World{})
if err != nil || len(got.Unhostable) != 0 || len(got.Modules) != 2 {
t.Fatalf("a machine that reads groups: %v, held %+v, hosted %v", err, got.Unhostable, names(got))
}
old := mcat.Node{Name: "g14", Capabilities: map[string]bool{"container-runtime": true}}
got, err = Resolve(shelf, []string{"n8n", "plain"}, old, mcat.World{})
if err != nil {
t.Fatal(err)
}
if len(got.Unhostable) != 1 || got.Unhostable[0].Module != "n8n" || !reflect.DeepEqual(got.Unhostable[0].Missing, []string{mcat.ContainerGroups}) {
t.Fatalf("n8n was not held, naming the capability: %+v", got.Unhostable)
}
if h := names(got); len(h) != 1 || h[0] != "plain" {
t.Errorf("the module without groups is not the one hosted: %v", h)
}
}
+665
View File
@@ -0,0 +1,665 @@
package resolution
import (
"encoding/json"
"errors"
"fmt"
"maps"
"regexp"
"slices"
"sort"
"strings"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// EncodeContributed is f as the bytes PlanProvision.Contributed carries, refused where DecodeContributed would refuse
// them, so a plan never carries what its reader cannot take.
func EncodeContributed(f mcat.ContributedForm) (json.RawMessage, error) {
f.V = mcat.ContributedVersion
raw, err := json.Marshal(f)
if err != nil {
return nil, err
}
if _, err := mcat.DecodeContributed(raw); err != nil {
return nil, err
}
return raw, nil
}
// provisionsOf is, from a resolution's needs, each provision module requires and its provider, by provision then
// local name. A need answered by a record (a licence) or by no module names no provider on a machine and is left
// out: the vault has no provider's value to make for it.
func provisionsOf(needs []mcat.Needed, module string, handed bool, as string, contributes map[string]map[string]any,
named map[string]bool) []mcat.PlanProvision {
var out []mcat.PlanProvision
for _, n := range needs {
// **The one rule** (Keyed, issue 774): what the seat keys and the want waits for.
if n.For != module || !Keyed(handed, n) {
continue
}
p := mcat.PlanProvision{Provision: n.Name, Local: n.Local, Provider: n.From, ProviderModule: n.Module,
CredentialForm: n.CredentialForm, CredentialTool: n.CredentialTool, Contributes: maps.Clone(contributes[n.Name]),
At: n.At, From: n.From, As: as}
// **A credential only a template names** (issue 788): named like a file's, so the client fills it from the
// vault's row for this holder.
if (named[n.Name] || named[mcat.SecretLocal(n.Name, n.Local)]) && n.IssuedToHolder {
p.Name = mcat.SecretLocal(n.Name, n.Local)
}
out = append(out, p)
}
return out
}
// sortProvisions orders a plan's provisions by provision, then local name.
func sortProvisions(ps []mcat.PlanProvision) {
sort.SliceStable(ps, func(i, j int) bool {
if ps[i].Provision != ps[j].Provision {
return ps[i].Provision < ps[j].Provision
}
return ps[i].Local < ps[j].Local
})
}
// ownershipOf is who owns a file at runtime, by its form, as the node-engine maps the old forms (mesh-host
// declaration.File.Ownership): written into is managed, created once is a seed, any other is owned.
func ownershipOf(r map[string]any) string {
switch {
case r["into"] != nil && fmt.Sprint(r["into"]) != "":
return mcat.RuntimeManaged
case r["create-once"] == true:
return mcat.RuntimeSeed
}
return mcat.RuntimeOwned
}
// takesOf is how the program reading a file takes a change, by what names the file among the module's resources:
// restart-on wins over reload-on; empty when nothing names it (the program reads it when it uses it).
func takesOf(m mcat.Manifest, id string) string {
takes := ""
for _, r := range m.Resources {
if slices.Contains(stringsIn(r["restart-on"]), id) {
return "restart-on"
}
if slices.Contains(stringsIn(r["reload-on"]), id) {
takes = "reload-on"
}
}
return takes
}
// handing is whether this composition hands the module's templated files to the provisioner: handed (Handing), with
// the templates its build kept. A module the vault judged handed whose build kept none is refused where its secrets
// are placed ("nothing would fill it"), never composed with values the vault does not answer.
func renderingHanding(with mcat.Rendering, m mcat.Manifest) bool {
return Handing(with, m) && m.Handed != nil
}
// Handing is whether a machine composed with this rendering hands the module to the provisioner, its files and
// its own secrets (design 57 P6, V4): a moved module there, assigned or only pulled in by another (issue 765), on a
// machine that can take it. The controller makes and seals none of such a module's secrets for that machine: its
// client asks the vault.
//
// **Where the vault judged the module, its word is the rule** (Rendering.Assigned, issue 774): the vault leaves a
// module's own secrets out of `own` exactly when the plan in its assignment hands it (AssignmentHands), so a node
// composing itself hands exactly those, whatever its own reading of the manifest and the machine says. On 2026-10-31
// postgres on novox was held: its plan handed its secrets, the vault answered none of them, and the node, judging by
// the manifest and the machine, composed them itself and refused for want of `reader`. Two readers, one rule.
func Handing(with mcat.Rendering, m mcat.Manifest) bool {
if hands, judged := with.Assigned[m.Module]; judged {
return hands
}
return m.Moved && m.Handed != nil && with.Hands && with.Provisioner != ""
}
// requiredByWords begins why a module only pulled in by another is on a machine (Resolution.Because): `required by
// <module>`.
const requiredByWords = "required by "
// RequiredBy is the module that requires module on this machine when nothing assigned it there, only pulled it in
// (issue 765): what its value in the assignments mirror names. Empty for a module assigned there, or not there.
func RequiredBy(r mcat.Resolution, module string) string {
by, _ := strings.CutPrefix(r.Because[module], requiredByWords)
if by == r.Because[module] {
return ""
}
return by
}
// handedSecretFile is a handed module's own secret file as the declaration carries it: placed, the secret's
// alone (0600 unless the manifest says otherwise), owned at runtime, without a value; the client fills it from the
// vault.
func handedSecretFile(r map[string]any, m mcat.Manifest, dirs, accessPaths, facts map[string]string,
placed map[string]mcat.Placement) (map[string]any, mcat.PlanSecret, error) {
name := fmt.Sprint(r[mcat.HandedSecretField])
out := map[string]any{}
for k, v := range r {
out[k] = v
}
delete(out, mcat.HandedSecretField)
delete(out, "sealed")
if err := dirInto(out, dirs, m.Module); err != nil {
return nil, mcat.PlanSecret{}, err
}
if err := accessInto(out, accessPaths, m.Module); err != nil {
return nil, mcat.PlanSecret{}, err
}
ownerInto(out, placed)
if err := machineInto(out, facts, m.Module); err != nil {
return nil, mcat.PlanSecret{}, err
}
if out["mode"] == nil {
out["mode"] = "0600"
}
out[mcat.OwnerAtRuntimeField] = mcat.RuntimeOwned
return out, mcat.PlanSecret{Name: name, Path: fmt.Sprint(out["path"])}, nil
}
// templatePlaceholder is a placeholder as a template writes it: its namespace and its key.
var templatePlaceholder = regexp.MustCompile(`\$\{([a-z][a-z-]*):([^}\s$]+)\}`)
// configNamespaces are the namespaces the config domain answers: `config`, and `setting`, which the manifest's
// reading writes it as (toSettingsForm).
var configNamespaces = []string{"config", mcat.SettingField}
// mergesFile is whether a file resource says `merge` or `takes-settings`: the layers' settings are merged into it.
func mergesFile(r map[string]any) bool {
_, merges := r["merge"]
_, takes := r[mcat.TakesSettingsField]
return merges || takes
}
// mergingIn is whether the module's resource id is a merging file (mergesFile).
func mergingIn(m mcat.Manifest, id string) bool {
for _, r := range m.Resources {
if fmt.Sprint(r["id"]) == id {
return mergesFile(r)
}
}
return false
}
// handedFile is a moved module's templated file as a handing declaration carries it: placed (its path and owner
// filled by placement alone), saying who owns it at runtime, without its text. And its line of the plan.
func handedFile(r map[string]any, m mcat.Manifest, dirs, accessPaths, facts map[string]string, placed map[string]mcat.Placement,
with mcat.Rendering) (map[string]any, mcat.PlanFile, error) {
id := fmt.Sprint(r["id"])
out := map[string]any{}
for k, v := range r {
out[k] = v
}
// **No text, and nothing filled into it**: the client fills it from the template; the declaration says where
// it goes and how it is written.
delete(out, "content")
delete(out, mcat.TrustedField)
delete(out, mcat.NamesOnPurpose)
delete(out, mcat.SecretsInEnvironment)
if err := dirInto(out, dirs, m.Module); err != nil {
return nil, mcat.PlanFile{}, err
}
if err := accessInto(out, accessPaths, m.Module); err != nil {
return nil, mcat.PlanFile{}, err
}
ownerInto(out, placed)
if err := machineInto(out, facts, m.Module); err != nil {
return nil, mcat.PlanFile{}, err
}
out[mcat.OwnerAtRuntimeField] = ownershipOf(out)
path := fmt.Sprint(out["path"])
return out, mcat.PlanFile{ID: id, Path: path, Template: m.Handed.Templates[id],
OwnerAtRuntime: fmt.Sprint(out[mcat.OwnerAtRuntimeField]), Takes: takesOf(m, id)}, nil
}
// placementOf is the value of every placement placeholder the module's handed templates name, keyed as the
// placeholder names it (`machine:account`), each filled by the same pass that fills it in a declaration. A
// template naming a value only another domain answers — a secret, a binding, a contribution — is refused by
// name: the client fills config and placement, and the rest move with their own packages (design 57).
func placementOf(node string, m mcat.Manifest, texts, items map[string]string, dirs, accessPaths, facts map[string]string,
with mcat.Rendering, moved map[string]bool, backups backupPlacer) (map[string]string, []string, []string, []mcat.PlanCollection, error) {
out := map[string]string{}
var secrets, required []string
var problems []string
var collections []mcat.PlanCollection
all := map[string]string{}
templateOf := map[string]string{}
for id, text := range texts {
all[id], templateOf[id] = text, m.Handed.Templates[id]
}
for key, text := range items {
all["item:"+key], templateOf["item:"+key] = text, m.Handed.Items[key]
}
judgedConsumers := map[string]bool{}
for _, id := range sortedKeys(all) {
item := strings.HasPrefix(id, "item:")
for _, found := range templatePlaceholder.FindAllStringSubmatch(all[id], -1) {
kind, key := found[1], found[2]
if collection, is := collectionOfPlaceholder(kind, key); is {
// **A collection its file names, read by the client from the items there** (design 57 L1).
comment, err := collectionComment(m, collection)
_, selector := splitSelector(key)
_, selErr := selectorOf(selector)
switch {
case selErr != nil && !item:
problems = append(problems, fmt.Sprintf("%s: its template %s names %s: %v", m.Module,
templateOf[id], found[0], selErr))
case item:
problems = append(problems, fmt.Sprintf("%s: its item's template %s names %s: an item is not a "+
"collector", m.Module, templateOf[id], found[0]))
case err != nil:
problems = append(problems, fmt.Sprintf("%s: its template %s names %s: %v", m.Module,
templateOf[id], found[0], err))
case !slices.ContainsFunc(collections, func(c mcat.PlanCollection) bool { return c.Name == collection }):
collections = append(collections, mcat.PlanCollection{Name: collection, Comment: comment})
}
continue
}
switch {
case slices.Contains(configNamespaces, kind):
continue
case kind == mcat.ConsumersNamespace:
// **A provider's consumers' credentials** (ADR 0373): its machine's client fills them from the vault,
// for a provision the module provides, in a file only it reads (consumersPlaceholderProblems).
if item {
problems = append(problems, fmt.Sprintf("%s: its item's template %s names %s: an item is kept in a "+
"bucket, where no secret is (novox/hq ADR 0349 §7)", m.Module, templateOf[id], found[0]))
} else if !judgedConsumers[id] {
judgedConsumers[id] = true
problems = append(problems, consumersOfTemplate(m, id, all[id])...)
}
continue
case item && (kind == "secret" || kind == "bound"):
problems = append(problems, fmt.Sprintf("%s: its item's template %s names %s: an item is kept in a "+
"bucket, where no secret is (novox/hq ADR 0349 §7)", m.Module, templateOf[id], found[0]))
continue
case kind == "secret":
// **The module's own secret, by name** (design 57 V4): its machine's client asks the vault for it.
// A requirement's credential is a binding's, and moves with the providers (design 57 B1–B3).
if _, own := m.OwnSecrets.Plain()[key]; !own && moved[key] {
// **A credential it requires, from a provider that has moved** (issue 788): the client fills it
// from the vault's row for this holder, which the plan names.
if !slices.Contains(required, key) {
required = append(required, key)
}
} else if !own {
problems = append(problems, fmt.Sprintf("%s: its template %s names %s, which is no secret of its "+
"own; a credential it requires moves with its provider (design 57 B1–B3)", m.Module,
templateOf[id], found[0]))
} else if !slices.Contains(secrets, key) {
secrets = append(secrets, key)
}
continue
case kind == "bound" && moved[strings.SplitN(key, ":", 2)[0]]:
// **What its moved provider told it** (issue 788): the client fills it from the provider's entry.
continue
case !slices.Contains(mcat.PlacementKinds, kind):
if known(kind) {
problems = append(problems, fmt.Sprintf("%s: its template %s names %s, and the provisioner's "+
"client fills only config, secrets, collections and placement until that domain moves "+
"(design 57)", m.Module, templateOf[id], found[0]))
}
continue
}
name := kind + ":" + key
if _, done := out[name]; done {
continue
}
if kind == mcat.BackupNamespace {
// **What the machine's modules back up** (design 57 L3, issue 794): the text the old contribution
// placeholders render, from the same function, over the modules placed on this machine.
value, problem := backups.placed(m, key, item)
if problem != "" {
problems = append(problems, fmt.Sprintf("%s: its template %s names %s: %s", m.Module,
templateOf[id], found[0], problem))
continue
}
out[name] = value
continue
}
value, err := placed(node, found[0], m, dirs, accessPaths, facts, with)
if err != nil {
problems = append(problems, err.Error())
continue
}
out[name] = value
}
}
if len(problems) > 0 {
sort.Strings(problems)
return nil, nil, nil, nil, errors.New(strings.Join(problems, "; "))
}
sort.Slice(collections, func(a, b int) bool { return collections[a].Name < collections[b].Name })
return out, secrets, required, collections, nil
}
// known is whether a namespace is one the mesh fills somewhere.
func known(kind string) bool {
for _, f := range fillers {
if f.namespace == kind {
return true
}
}
return false
}
// placed is one placement placeholder's value, filled by the passes that fill it in a file's content.
func placed(node, placeholder string, m mcat.Manifest, dirs, accessPaths, facts map[string]string, with mcat.Rendering) (string, error) {
r := map[string]any{"id": "placement", "type": "file", "path": "/placement", "content": placeholder}
if err := dirInto(r, dirs, m.Module); err != nil {
return "", err
}
if err := accessInto(r, accessPaths, m.Module); err != nil {
return "", err
}
if err := portInto(r, m.Module, m.Listens, with); err != nil {
return "", err
}
if err := seatInto(r, m.Module, with); err != nil {
return "", err
}
if err := machineInto(r, facts, m.Module); err != nil {
return "", err
}
value := fmt.Sprint(r["content"])
if value == placeholder {
return "", fmt.Errorf("%s: %s is not answered by placement on %s", m.Module, placeholder, node)
}
return value, nil
}
// routedTemplates is the templates archive as this node fetches it, through the artifact store it reaches.
func routedTemplates(m mcat.Manifest, with mcat.Rendering) (mcat.PlanTemplates, error) {
r := map[string]any{"id": "templates", "source": m.Handed.Source}
if err := artifactsInto(r, m.Module, with); err != nil {
return mcat.PlanTemplates{}, err
}
return mcat.PlanTemplates{Source: fmt.Sprint(r["source"]), Digest: m.Handed.Digest}, nil
}
// handedFiles gathers, over one composition, the files handed to the provisioner and each handed module's plan.
type handedFiles struct {
paths []string
plans map[string]mcat.Plan
// secrets is each handed module's own secrets its declaration names, by name: the path of the file holding one
// alone, or "" for one only its templates name.
secrets map[string]map[string]string
// provisions is each handed module's credentials of what it requires that the vault keeps for it (issue 757).
provisions map[string][]mcat.PlanProvision
// contributes is each handed module's manifest `contributes`, by the requirement it is about (issue 776).
contributes map[string]map[string]map[string]any
// identities is who each handed module is at its providers' ends (issue 788), and required the credentials of
// what it requires that its templates name.
identities map[string]string
required map[string]map[string]bool
// names is the names composed for each handed module through each requirement it contributes to (issue 792).
names map[string]map[string]map[string]string
// keyedOnly is each module with a plan only for what Keyed keys of a consumer not handed: a provision its
// provider's helper answers (issue 774), such as a route.
keyedOnly map[string]bool
// contributed is what each handed module gives each requirement's provider, encoded (PlanProvision.Contributed).
contributed map[string]map[string]json.RawMessage
}
// contribute records what a handed module gives one requirement's provider, encoded; nothing for none.
func (h *handedFiles) contribute(module, provision string, raw json.RawMessage) {
if len(raw) == 0 {
return
}
if h.contributed == nil {
h.contributed = map[string]map[string]json.RawMessage{}
}
if h.contributed[module] == nil {
h.contributed[module] = map[string]json.RawMessage{}
}
h.contributed[module][provision] = raw
}
// keyed starts the plan of a module begin started none for, when the composition keys any of its needs as a consumer
// not handed (Keyed, issue 774): one whose provider's helper answers a provision of it (grafana's route). A module
// handed that keeps no template or item (a held one's stub, issue 1126) is not handed here either: what it keeps
// stays the controller's but for what a helper answers. Its plan names provisions alone: no templates, files or
// secrets. Answers whether it started one.
func (h *handedFiles) keyed(node string, m mcat.Manifest, needs []mcat.Needed) bool {
if _, started := h.plans[m.Module]; started {
return false
}
if !slices.ContainsFunc(needs, func(n mcat.Needed) bool { return n.For == m.Module && Keyed(false, n) }) {
return false
}
if h.identities == nil {
h.identities = map[string]string{}
h.required = map[string]map[string]bool{}
}
h.identities[m.Module] = mcat.ConsumerIdentity(node, mcat.IdentitySource(m.Slug, m.Module))
h.plans[m.Module] = mcat.Plan{}
if h.keyedOnly == nil {
h.keyedOnly = map[string]bool{}
}
h.keyedOnly[m.Module] = true
if len(m.Contributes) > 0 {
if h.contributes == nil {
h.contributes = map[string]map[string]map[string]any{}
}
h.contributes[m.Module] = m.Contributes
}
return true
}
// name records the names composed for a handed module through one requirement, as its binding file carries them.
func (h *handedFiles) name(module, provision string, own map[string]any) {
values := map[string]string{}
withOwnNames(values, own)
if len(values) == 0 {
return
}
if h.names == nil {
h.names = map[string]map[string]map[string]string{}
}
if h.names[module] == nil {
h.names[module] = map[string]map[string]string{}
}
h.names[module][provision] = values
}
// begin starts a module's plan where this composition hands it, and answers the ids of the files handed: none for
// a module that is not handed here. The plan's templates and placement values are composed now; each file is added
// as it is composed.
//
// **Assigned or only pulled in by another** (issue 765): the plan travels in the module's value of the assignments
// mirror, `<node>.<module>`, which the client reads (inventory.Provisioning); a module pulled in has a value there of
// its own, which says what pulled it in.
func (h *handedFiles) begin(node string, m mcat.Manifest, dirs, accessPaths, facts map[string]string,
with mcat.Rendering, caps map[string]bool, needs []mcat.Needed, backups backupPlacer) (map[string]bool, error) {
if !renderingHanding(with, m) {
return nil, nil
}
texts := map[string]string{}
ids := map[string]bool{}
for _, r := range m.Resources {
id := fmt.Sprint(r["id"])
if _, named := m.Handed.Templates[id]; !named {
continue
}
if mergesFile(r) {
// **A merging file is filled the old way** (issue 831, ADR 0349 clause 17): handing it needs the client
// to merge the layers, which nothing builds yet. Its text stays, the controller fills and merges it.
continue
}
if fmt.Sprint(r["type"]) != "file" {
return nil, fmt.Errorf("%s: %s is a %v and its build kept a template for it; only a file is filled "+
"from one", m.Module, id, r["type"])
}
text, _ := r["content"].(string)
texts[id] = text
ids[id] = true
}
for _, id := range sortedKeys(m.Handed.Templates) {
if !ids[id] && !mergingIn(m, id) {
return nil, fmt.Errorf("%s: its build kept a template for %s, which it does not declare", m.Module, id)
}
}
// **Its items, by template** (design 57 L1): the client fills each and records it for this node.
items := map[string]string{}
var planned []mcat.PlanItem
for _, it := range m.Items {
template, kept := m.Handed.Items[itemKey(it)]
if !kept {
return nil, fmt.Errorf("%s: its build kept no template for its item of %s", m.Module, itemKey(it))
}
// **A capability the machine lacks leaves the item out of its plan** (design 57 L3): the client judges none.
if it.IfCapability != "" && !caps[it.IfCapability] {
continue
}
items[itemKey(it)] = it.Content
planned = append(planned, mcat.PlanItem{Collection: it.Collection, Name: it.Name,
Attributes: it.Attributes, Template: template})
}
if len(ids) == 0 && len(planned) == 0 {
return nil, nil
}
templates, err := routedTemplates(m, with)
if err != nil {
return nil, err
}
// What it requires from a provider that has moved (issue 788): its templates may name what that provider tells it.
moved := map[string]bool{}
for _, n := range needs {
if n.For == m.Module && n.ProviderMoved {
moved[n.Name] = true
// A credential kept per local name (issue 793) is named by its local in a template.
moved[mcat.SecretLocal(n.Name, n.Local)] = true
}
}
placement, secrets, required, collections, err := placementOf(node, m, texts, items, dirs, accessPaths, facts,
with, moved, backups)
if err != nil {
return nil, err
}
if h.identities == nil {
h.identities = map[string]string{}
h.required = map[string]map[string]bool{}
}
h.identities[m.Module] = mcat.ConsumerIdentity(node, mcat.IdentitySource(m.Slug, m.Module))
h.required[m.Module] = map[string]bool{}
for _, name := range required {
h.required[m.Module][name] = true
}
for _, name := range secrets {
h.secret(m.Module, mcat.PlanSecret{Name: name})
}
plan := mcat.Plan{Templates: templates, Items: planned, Collections: collections}
if len(placement) > 0 {
plan.Placement = placement
}
h.plans[m.Module] = plan
if len(m.Contributes) > 0 {
if h.contributes == nil {
h.contributes = map[string]map[string]map[string]any{}
}
h.contributes[m.Module] = m.Contributes
}
return ids, nil
}
// add is one handed file of a module, composed. A path handed twice is refused: the node-engine writes a filled
// file for exactly one resource, and the client's write names only the path (mesh-host internal/apply).
func (h *handedFiles) add(module string, f mcat.PlanFile) error {
if slices.Contains(h.paths, f.Path) {
return fmt.Errorf("%s: %s is handed to the provisioner twice on this machine; a filled file has one "+
"resource and one writer (novox/hq ADR 0349 §5)", module, f.Path)
}
plan := h.plans[module]
plan.Files = append(plan.Files, f)
h.plans[module] = plan
h.paths = append(h.paths, f.Path)
return nil
}
// secret is one of a handed module's own secrets its declaration names; a file holding it alone is a filled file.
func (h *handedFiles) secret(module string, sec mcat.PlanSecret) {
if h.secrets == nil {
h.secrets = map[string]map[string]string{}
}
if h.secrets[module] == nil {
h.secrets[module] = map[string]string{}
}
if sec.Path != "" || h.secrets[module][sec.Name] == "" {
h.secrets[module][sec.Name] = sec.Path
}
}
// addSecretFile is a handed module's own secret file, composed: a filled file, and its secret in the plan.
func (h *handedFiles) addSecretFile(module string, sec mcat.PlanSecret) error {
if slices.Contains(h.paths, sec.Path) {
return fmt.Errorf("%s: %s is handed to the provisioner twice on this machine; a filled file has one "+
"resource and one writer (novox/hq ADR 0349 §5)", module, sec.Path)
}
h.paths = append(h.paths, sec.Path)
h.secret(module, sec)
return nil
}
// addProvisionFile is a handed module's credential file for something it requires, which the vault keeps for it
// alone (issue 757): a filled file, and the credential in the plan.
func (h *handedFiles) addProvisionFile(module string, pr mcat.PlanProvision) error {
if slices.Contains(h.paths, pr.Path) {
return fmt.Errorf("%s: %s is handed to the provisioner twice on this machine; a filled file has one "+
"resource and one writer (novox/hq ADR 0349 §5)", module, pr.Path)
}
h.paths = append(h.paths, pr.Path)
if h.provisions == nil {
h.provisions = map[string][]mcat.PlanProvision{}
}
h.provisions[module] = append(h.provisions[module], pr)
return nil
}
// finished is every plan with its secrets, by name, and what its module requires, each with its provider.
func (h *handedFiles) finished(needs []mcat.Needed) map[string]mcat.Plan {
if len(h.plans) == 0 {
return nil
}
for module, plan := range h.plans {
// **Every provision it requires, with its provider** (issue 760); a credential the vault keeps for it alone
// is that provision's entry, with its name and file (issue 757).
provisions := provisionsOf(needs, module, !h.keyedOnly[module], h.identities[module], h.contributes[module],
h.required[module])
for _, credential := range h.provisions[module] {
i := slices.IndexFunc(provisions, func(p mcat.PlanProvision) bool {
return p.Provision == credential.Provision && p.Local == credential.Local
})
if i < 0 {
credential.As = h.identities[module]
provisions = append(provisions, credential)
continue
}
provisions[i].Name, provisions[i].Path = credential.Name, credential.Path
}
for i := range provisions {
provisions[i].Names = maps.Clone(h.names[module][provisions[i].Provision])
}
sortProvisions(provisions)
// **What the consumer contributes, on the provision's first entry** (issue 774): a provider serving from the
// plans alone reads each contribution once, however many local names the requirement has.
gave := map[string]bool{}
for i := range provisions {
if raw := h.contributed[module][provisions[i].Provision]; len(raw) > 0 && !gave[provisions[i].Provision] {
gave[provisions[i].Provision] = true
provisions[i].Contributed = slices.Clone(raw)
}
}
plan.Provisions = provisions
h.plans[module] = plan
}
for module, named := range h.secrets {
plan, ok := h.plans[module]
if !ok {
continue
}
plan.Secrets = nil
for _, name := range sortedKeys(named) {
plan.Secrets = append(plan.Secrets, mcat.PlanSecret{Name: name, Path: named[name]})
}
h.plans[module] = plan
}
return h.plans
}
@@ -0,0 +1,146 @@
package resolution
import (
"reflect"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// gitea's server.env, as the catalogue keeps it: its database as the provider told it and the consumer's own login.
const giteaServerEnv = "GITEA__security__INTERNAL_TOKEN=${secret:internal-token}\n" +
"GITEA__database__DB_TYPE=postgres\n" +
"GITEA__database__HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\n" +
"GITEA__database__NAME=${bound:postgres-database:as}\n" +
"GITEA__database__USER=${bound:postgres-database:as}\n" +
"GITEA__database__PASSWD=${secret:postgres-database}\n"
// databaseConsumer is a handed module, with the template gitea's server.env is, requiring a database from a provider
// that has moved or not (issue 788); slug is the module's identity source when it declares one.
func databaseConsumer(t *testing.T, slug string, moved bool) mcat.Resolution {
t.Helper()
raw := strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`,
`"requires":["postgres-database"],"own-secrets":{"internal-token":"${dir:state}/internal-token"}`, 1)
if slug != "" {
raw = strings.Replace(raw, `"version":"1",`, `"version":"1","slug":"`+slug+`",`, 1)
}
return withDatabase(t, raw, giteaServerEnv, moved)
}
// withDatabase resolves the module in raw, whose template is text, against a provider of postgres-database that has
// moved or not.
func withDatabase(t *testing.T, raw, text string, moved bool) mcat.Resolution {
t.Helper()
r, err := Resolve(shelf(held(t, raw, builtBot(text)), mcat.Manifest{Module: "postgres", Version: "1", Provides: []mcat.Offer{{Name: "postgres-database", Scope: mcat.ScopeNode}}}),
[]string{"bot"}, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
r.Account = "op"
// Answered on this machine, it states no need; one from the provider's machine is stated, as the plan does.
r.Needs = []mcat.Needed{{Name: "postgres-database", For: "bot", Module: "postgres", From: "novox", At: "10.7.0.1",
CredentialForm: mcat.CredentialFormPair, IssuedToHolder: moved, ProviderMoved: moved}}
return r
}
// The forge's server.env composes for a handed module when what it requires has moved, its plan entry carrying the
// provider's address, machine and the consumer's identity as today's binding file gives them; and is refused with the
// text it has always had when the provider has not.
func TestAHandedModuleMayNameWhatItRequiresWhereItsProviderHasMoved(t *testing.T) {
c, got := composeWith(t, databaseConsumer(t, "", true), handingTo("store:5000"))
if strings.Contains(got, "sealed") {
t.Fatalf("a handed module's declaration carries a sealed value: %s", got)
}
want := []mcat.PlanProvision{{Name: "postgres-database", Provision: "postgres-database", Provider: "novox",
ProviderModule: "postgres", CredentialForm: mcat.CredentialFormPair, At: "10.7.0.1", From: "novox",
As: mcat.ConsumerIdentity("workstation", "bot")}}
if !reflect.DeepEqual(c.Plans["bot"].Provisions, want) {
t.Fatalf("the plan names %+v, want %+v", c.Plans["bot"].Provisions, want)
}
for _, unmoved := range []mcat.Resolution{databaseConsumer(t, "", false)} {
_, err := Compose(unmoved, handingTo("store:5000"))
if err == nil || !strings.Contains(err.Error(), "which is no secret of its own; a credential it requires moves "+
"with its provider (design 57 B1–B3)") {
t.Fatalf("a provider not moved, the credential: %v", err)
}
bound := strings.Replace(giteaServerEnv, "${secret:postgres-database}", "x", 1)
r := withDatabase(t, strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`,
`"requires":["postgres-database"],"own-secrets":{"internal-token":"${dir:state}/internal-token"}`, 1),
bound, false)
_, err = Compose(r, handingTo("store:5000"))
if err == nil || !strings.Contains(err.Error(), "the provisioner's client fills only config, secrets, "+
"collections and placement until that domain moves (design 57)") {
t.Fatalf("a provider not moved, the binding: %v", err)
}
}
}
// A handed module's credentials the vault keeps as one login per local name (issue 793): gitea's internal token and
// admin password, which the `secret` provision hands as `${secret:<local>}`. Each is a plan entry naming its local,
// its file declared placed and without a value; the template naming the local is allowed.
func TestAHandedModulesLocalCredentialsOfTheVaultAreNamedByLocalInItsPlan(t *testing.T) {
raw := strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`,
`"requires":["secret"],"secrets":{"secret":{"internal-token":"${dir:state}/internal-token.secret",`+
`"admin":"${dir:state}/admin.secret"}},"own-secrets":{"token":"${dir:state}/token"}`, 1)
r, err := Resolve(shelf(held(t, raw, builtBot("T=${secret:internal-token}\n")), mcat.Manifest{Module: "vault", Version: "1", Provides: []mcat.Offer{{Name: "secret", Scope: mcat.ScopeNode}}}),
[]string{"bot"}, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
r.Account = "op"
r.Needs = []mcat.Needed{
{Name: "secret", Local: "internal-token", For: "bot", Module: "vault", From: "novox",
CredentialForm: mcat.CredentialFormPair, IssuedToHolder: true, ProviderMoved: true},
{Name: "secret", Local: "admin", For: "bot", Module: "vault", From: "novox",
CredentialForm: mcat.CredentialFormPair, IssuedToHolder: true, ProviderMoved: true},
}
c, got := composeWith(t, r, handingTo("store:5000"))
if strings.Contains(got, "sealed") {
t.Fatalf("the declaration carries a sealed value: %s", got)
}
var names []string
for _, p := range c.Plans["bot"].Provisions {
if p.Provision != "secret" || p.Provider != "novox" || p.Path == "" || p.Name != p.Local {
t.Errorf("the plan entry is %+v", p)
}
names = append(names, p.Name)
}
if !reflect.DeepEqual(names, []string{"admin", "internal-token"}) {
t.Fatalf("the plan names %v, want admin and internal-token", names)
}
}
// **Issue 969: a key its provider serves only at run time.** A moved module's file may name `${bound:…}` of a
// provision whose provider tells it at run time (ssh-client's known_host of the forge). Where the machine takes
// handed files the client fills it and the module composes; where it does not the controller must fill it from what
// the provider serves statically, cannot, and refuses it with the very words an unmarked module is refused with
// (ADR 0349 §17: the old way, byte for byte).
func TestAHandedModuleNamingARunTimeBoundKeyComposesOnlyWhereItIsHanded(t *testing.T) {
text := "Host ${bound:postgres-database:at}\n KnownHost ${bound:postgres-database:known_host}\n"
moved := strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`,
`"requires":["postgres-database"],"own-secrets":{"token":"${dir:state}/token"}`, 1)
unmoved := strings.Replace(moved, `"moved":true,`, ``, 1)
if unmoved == moved {
t.Fatal("the twin is still marked moved")
}
c, got := composeWith(t, withDatabase(t, moved, text, true), handingTo("store:5000"))
if strings.Contains(got, "known_host") || len(c.Filled) == 0 {
t.Fatalf("handed, the module is filled by the client and the controller names nothing: %v %s", c.Filled, got)
}
for name, with := range map[string]mcat.Rendering{
"an engine that does not take a filled file alone": {Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000"},
"no provisioner client on the machine": {Hands: true, ArtifactStore: "store:5000"},
} {
_, err := Compose(withDatabase(t, moved, text, true), sealedForBot(with))
if err == nil || !strings.Contains(err.Error(), `asks its postgres-database binding for "known_host"`) {
t.Fatalf("%s: the key is not refused: %v", name, err)
}
_, before := Compose(withDatabase(t, unmoved, text, false), sealedForBot(with))
if before == nil || before.Error() != err.Error() {
t.Fatalf("%s: refused otherwise than the unmarked module: %v / %v", name, err, before)
}
}
}
@@ -0,0 +1,48 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
const handedProvider = `{"module":"db","version":"1","moved":true,
"provides":[{"name":"postgres-database","scope":"mesh","identity":{"max":63,"in":"a PostgreSQL role"}}],
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
"resources":[
{"id":"state","type":"directory","mode":"0700","place":"."},
{"id":"consumers","type":"file","path":"${dir:state}/consumers.json","mode":"0600","template":"files/consumers.json"}]}`
const consumersText = "${consumers:postgres-database}"
func builtProvider(text string) []mcat.Built {
return []mcat.Built{{Name: "files", Kind: mcat.ArtifactArchive,
Reference: mcat.ArtifactStoreScheme + "db/files/blobs/sha256:" + strings.Repeat("f", 64),
Digest: "sha256:" + strings.Repeat("f", 64), Texts: map[string]string{"consumers.json": text}}}
}
func TestWhatAConsumersPlaceholderMayNotBeIsRefused(t *testing.T) {
for why, c := range map[string]struct{ raw, text, want string }{
"a provision the module does not provide": {handedProvider, "${consumers:cache}", "does not provide"},
"a module providing nothing": {strings.Replace(handedProvider, `"provides":[{"name":"postgres-database","scope":"mesh","identity":{"max":63,"in":"a PostgreSQL role"}}],`,
"", 1), consumersText, "does not provide"},
"a file others may read": {strings.Replace(handedProvider, `"mode":"0600","template"`,
`"mode":"0640","template"`, 1), consumersText, "0600"},
"a file the program keeps": {strings.Replace(handedProvider, `"mode":"0600","template"`,
`"mode":"0600","create-once":true,"template"`, 1), consumersText, "owned"},
} {
m, err := decodeForTest([]byte(c.raw))
if err != nil {
t.Fatalf("%s: %v", why, err)
}
resolved, err := m.Resolve(builtProvider(c.text))
if err == nil {
r := notifierOn(t, resolved)
_, err = Compose(r, handingTo("store:5000"))
}
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: %v, want a refusal saying %q", why, err, c.want)
}
}
}
@@ -0,0 +1,111 @@
package resolution
import (
"reflect"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// notifierConsumer is the notifier requiring a mesh-scoped database, marked moved when moved, with its credential
// kept under two local names (ADR 0094) when locals is set.
func notifierConsumer(t *testing.T, moved, locals bool) mcat.Manifest {
t.Helper()
raw := handedNotifier
if moved {
raw = notifierMoved()
}
add := `"requires":["postgres-database"],`
if locals {
add += `"secrets":{"postgres-database":{"hot":"/var/lib/notify/hot","cold":"/var/lib/notify/cold"}},`
}
raw = strings.Replace(raw, `"build":`, add+`"build":`, 1)
return held(t, raw, builtNotifier())
}
// twoStores is a mesh where two machines each run a store providing the database, the consumer pinned to one.
func twoStores(pin string) mcat.World {
return mcat.World{Offered: map[string][]mcat.Provider{"postgres-database": {
{Node: "anchor", At: "anchor.internal", Module: "store", Serves: map[string]any{"port": 5432}},
{Node: "home", At: "home.internal", Module: "store-two", Serves: map[string]any{"port": 5434}},
}}, Pinned: map[string]mcat.Chosen{"postgres-database": {Node: pin}}}
}
func consumerOn(t *testing.T, m mcat.Manifest, w mcat.World) mcat.Resolution {
t.Helper()
node := workstation()
node.At = "workstation.internal"
stores := shelf(m, mcat.Manifest{Module: "store", Version: "1", Provides: []mcat.Offer{{Name: "postgres-database", Scope: mcat.ScopeMesh,
Credential: &mcat.OfferCredential{Form: mcat.CredentialFormPair}}},
Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}}, mcat.Manifest{Module: "store-two", Version: "1", Provides: []mcat.Offer{{Name: "postgres-database", Scope: mcat.ScopeMesh, Credential: &mcat.OfferCredential{Form: mcat.CredentialFormPair}}},
Serves: map[string]map[string]any{"postgres-database": {"port": 5434}}})
r, err := Resolve(stores, []string{m.Module}, node, w)
if err != nil {
t.Fatal(err)
}
r.Account = "op"
return r
}
// A consumer's plan entry carries the names the mesh composed for it through that provision (issue 792): what its
// binding file names as `${bound:<provision>:name|internal-name|name-<local>|internal-name-<local>}`, so a moved
// provider's helper copies them into the consumer's entry and the client fills the same text. A consumer
// contributing no label has the entry it had.
func TestAHandedModulesPlanEntryCarriesTheNamesComposedForIt(t *testing.T) {
m := notifierConsumer(t, true, false)
m.ContributesMany = map[string]map[string]map[string]any{
"postgres-database": {"web": {"label": "git"}, "api": {"label": "git-api"}}}
r := consumerOn(t, m, twoStores("anchor"))
r.PublicDomain = "example.org"
c, _ := composeWith(t, r, handingTo("store:5000"))
got := c.Plans["notify"].Provisions
if len(got) != 1 {
t.Fatalf("the plan names %+v", got)
}
want := map[string]string{"name-web": "git.example.org", "name-api": "git-api.example.org"}
own, err := resolutionOwnNames(r, m, "postgres-database", nil)
if err != nil {
t.Fatal(err)
}
file := map[string]string{}
withOwnNames(file, own)
for k, v := range file {
if strings.HasPrefix(k, "internal-") {
want[k] = v
}
}
if !reflect.DeepEqual(got[0].Names, want) {
t.Fatalf("the entry carries %v, the binding file names %v", got[0].Names, want)
}
}
// A plan entry names the tool of an offer whose credential the provider's software issues (issue 928), so the vault
// asks that tool for each holder; an offer of any other form names none.
func TestAHandedModulesPlanNamesTheCredentialToolOfASoftwareIssuedOffer(t *testing.T) {
for credential, want := range map[string]string{
``: "",
`,"credential":{"own":"admin"}`: "",
`,"credential":{"issued-by":"outside"}`: "",
`,"credential":{"issued-by":"software","tool":"gitea_agent_token"}`: "gitea_agent_token",
} {
raw := `{"module":"store","version":"1","provides":[{"name":"postgres-database","scope":"mesh"` +
credential + `}],"serves":{"postgres-database":{"port":5432}}`
if strings.Contains(credential, `"own"`) {
raw += `,"own-secrets":{"admin":{"path":"/var/lib/store/admin","taken":"at-start"}}`
}
node := workstation()
node.At = "workstation.internal"
consumer := notifierConsumer(t, true, false)
r, err := Resolve(shelf(consumer, mustParse(t, raw+`}`)), []string{consumer.Module}, node, twoStores("anchor"))
if err != nil {
t.Fatal(err)
}
r.Account = "op"
c, _ := composeWith(t, r, handingTo("store:5000"))
got := c.Plans["notify"].Provisions
if len(got) != 1 || got[0].CredentialTool != want {
t.Errorf("an offer with %q: the plan names %+v, want the tool %q", credential, got, want)
}
}
}
@@ -0,0 +1,55 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// pulledInBy is a module that requires what raw's module provides, so resolving it alone pulls that module in.
const pulledInBy = `{"module":"desk","version":"1","requires":["pulled-in-here"]}`
// providing is raw's module providing what desk requires.
func providing(raw string) string {
return strings.Replace(raw, `"version":"1",`, `"version":"1","provides":[{"name":"pulled-in-here"}],`, 1)
}
// onWorkstation resolves m on the workstation either assigned beside desk, or pulled in by desk alone.
func onWorkstation(t *testing.T, m mcat.Manifest, assigned bool) mcat.Resolution {
t.Helper()
desk, err := decodeForTest([]byte(pulledInBy))
if err != nil {
t.Fatal(err)
}
want := []string{"desk"}
if assigned {
want = append(want, m.Module)
}
r, err := Resolve(shelf(m, desk), want, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
why := map[bool]string{true: "assigned", false: "required by desk"}[assigned]
if r.Because[m.Module] != why {
t.Fatalf("%s is on the workstation because %q, want %q", m.Module, r.Because[m.Module], why)
}
r.Account = "op"
return r
}
// What requires a module pulled in is said by name, for its value in the assignments mirror; nothing for one
// assigned.
func TestAPulledInModuleSaysWhatRequiresIt(t *testing.T) {
m := held(t, providing(notifierMoved()), builtNotifier())
if by := RequiredBy(onWorkstation(t, m, false), "notify"); by != "desk" {
t.Errorf("pulled in, notify is required by %q, want desk", by)
}
assigned := onWorkstation(t, m, true)
if by := RequiredBy(assigned, "notify"); by != "" {
t.Errorf("assigned, notify is required by %q", by)
}
if by := RequiredBy(assigned, "absent"); by != "" {
t.Errorf("a module not on the machine is required by %q", by)
}
}
@@ -0,0 +1,78 @@
package resolution
import (
"reflect"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
const handedBot = `{"module":"bot","version":"1",
"own-secrets":{"token":"${dir:state}/token"},
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
"resources":[
{"id":"state","type":"directory","mode":"0700","place":"."},
{"id":"conf","type":"file","path":"/etc/bot/bot.conf","mode":"0640","template":"files/bot.conf"},
{"id":"daemon","type":"process","name":"bot","run":["bot"],"env":{"BOT_TOKEN_FILE":"${dir:state}/token"}}]}`
const botText = "token = ${secret:token}\nhome = ${machine:account-home}\n"
func botMoved() string {
return strings.Replace(handedBot, `"version":"1",`, `"version":"1","moved":true,`, 1)
}
func builtBot(text string) []mcat.Built {
return []mcat.Built{{Name: "files", Kind: mcat.ArtifactArchive,
Reference: mcat.ArtifactStoreScheme + "bot/files/blobs/sha256:" + strings.Repeat("e", 64),
Digest: "sha256:" + strings.Repeat("e", 64), Texts: map[string]string{"bot.conf": text}}}
}
// What the controller seals for the bot when it fills it the old way: a stand-in sealed value.
func sealedForBot(with mcat.Rendering) mcat.Rendering {
with.Needed = map[string]map[string]string{"bot": {"token": "sealed-to-the-node"}}
return with
}
func TestAHandedModulesSecretsAreNamedAndNoValueIsSealed(t *testing.T) {
r := notifierOn(t, held(t, botMoved(), builtBot(botText)))
c, got := composeWith(t, r, handingTo("store:5000"))
if strings.Contains(got, "sealed") || strings.Contains(got, "${secret:") {
t.Fatalf("a handed module's declaration carries a sealed value or a secret placeholder: %s", got)
}
var token map[string]any
for _, res := range c.Resources {
if res["id"] == "bot.needs-token" {
token = res
}
}
if token == nil {
t.Fatalf("the token's file is not declared: %s", got)
}
if token["path"] == nil || strings.Contains(token["path"].(string), "${") || token["mode"] != "0600" ||
token["owner-at-runtime"] != "owned" || token[mcat.HandedSecretField] != nil {
t.Fatalf("the token's file is declared %v: placed, 0600, owned at runtime, no value", token)
}
path := token["path"].(string)
if !reflect.DeepEqual(c.Filled, []string{path, "/etc/bot/bot.conf"}) {
t.Fatalf("the filled files are %v", c.Filled)
}
if want := []mcat.PlanSecret{{Name: "token", Path: path}}; !reflect.DeepEqual(c.Plans["bot"].Secrets, want) {
t.Fatalf("the plan names secrets %+v, want %+v", c.Plans["bot"].Secrets, want)
}
// Issue 774 (postgres on novox, 2026-10-31): where the vault judged it handed, its secrets are the vault's even on a
// machine the node reads as not taking a filled file, and nothing is refused for a value the vault never answers.
judged, err := Compose(r, mcat.Rendering{ArtifactStore: "store:5000", Assigned: map[string]bool{"bot": true}})
if err != nil {
t.Fatalf("a module the vault judged handed is refused: %v", err)
}
if want := []mcat.PlanSecret{{Name: "token", Path: path}}; !reflect.DeepEqual(judged.Plans["bot"].Secrets, want) {
t.Errorf("judged handed by the vault, the plan names secrets %+v, want %+v", judged.Plans["bot"].Secrets, want)
}
// A process naming the token's file still restarts when its client writes it.
for _, res := range c.Resources {
if res["id"] == "bot.daemon" && !strings.Contains(strings.Join(stringsIn(res["restart-on"]), " "), "bot.needs-token") {
t.Errorf("the daemon does not restart on its token's file: %v", res["restart-on"])
}
}
}
@@ -0,0 +1,57 @@
package resolution
import (
"encoding/json"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
const handedNotifier = `{"module":"notify","version":"1",
"config":{"width":{"type":"number","description":"How wide a notification is, in pixels.","default":250}},
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
"resources":[
{"id":"configuration-dir","type":"directory","path":"${machine:account-home}/.config/notify","owner":"${machine:account}","mode":"0755"},
{"id":"configuration","type":"file","path":"${machine:account-home}/.config/notify/rc","owner":"${machine:account}","mode":"0644","template":"files/rc"},
{"id":"legacy","type":"file","path":"/etc/notify.conf","content":"plain = yes\n"},
{"id":"notifier","type":"service","unit":"notify.service","scope":"user","user":"${machine:account}","reload-on":["configuration"]}]}`
const notifierText = "width = ${config:width}\nhome = ${machine:account-home}\n"
func notifierMoved() string {
return strings.Replace(handedNotifier, `"version":"1",`, `"version":"1","moved":true,`, 1)
}
func builtNotifier() []mcat.Built {
return []mcat.Built{{Name: "files", Kind: mcat.ArtifactArchive,
Reference: mcat.ArtifactStoreScheme + "notify/files/blobs/sha256:" + strings.Repeat("d", 64),
Digest: "sha256:" + strings.Repeat("d", 64), Texts: map[string]string{"rc": notifierText}}}
}
func notifierOn(t *testing.T, m mcat.Manifest) mcat.Resolution {
t.Helper()
r, err := Resolve(shelf(m), []string{m.Module}, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
r.Account = "op"
return r
}
func composeWith(t *testing.T, r mcat.Resolution, with mcat.Rendering) (mcat.Composed, string) {
t.Helper()
c, err := Compose(r, with)
if err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(c.Resources)
if err != nil {
t.Fatal(err)
}
return c, string(raw)
}
func handingTo(store string) mcat.Rendering {
return mcat.Rendering{Hands: true, Provisioner: "mesh-provisioner-client", ArtifactStore: store}
}
+54
View File
@@ -0,0 +1,54 @@
package resolution
import (
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// healthInto composes a resource's `health` into the node-engine's words, or takes it away (to-be 48 §2,
// §3): for an engine that reads it, the endpoint becomes the port this machine published it on and the
// defaults are written out; for one that does not — older and strict — the field is not sent, and the
// resource is judged by liveness alone, as before.
func healthInto(resource map[string]any, m mcat.Manifest, with mcat.Rendering) {
if _, has := resource[mcat.HealthField]; !has {
return
}
if !with.ReadsHealth {
delete(resource, mcat.HealthField)
return
}
h, _, _ := mcat.ReadHealth(resource)
out := map[string]any{"kind": h.Kind, "interval": h.Interval.String(), "timeout": h.Timeout.String(),
"looks": h.Looks, "grace": h.Grace.String()}
if h.Endpoint != "" {
out["endpoint"] = h.Endpoint
if port, ok := mcat.EndpointPort(m, h.Endpoint); ok {
out["port"] = renderingMachinePort(with, m.Module, port)
}
}
if h.Kind == mcat.HealthHTTP {
path := h.Path
if path == "" {
path = "/"
}
out["path"] = path
if h.Status != 0 {
out["status"] = h.Status
}
if h.Body != "" {
out["body"] = h.Body
}
if h.Scheme != "" {
out["scheme"] = h.Scheme
}
}
if h.Command != "" {
out["command"] = h.Command
}
if h.Tool != "" {
out["tool"] = h.Tool
}
if h.Needs != "" {
out["needs"] = h.Needs
}
resource[mcat.HealthField] = out
}
+104
View File
@@ -0,0 +1,104 @@
package resolution
import (
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// healthDecision is the record that made the four seats.
const healthDecision = "novox/hq ADR 0564"
// conditionsVerbs are the condition keeper's verbs (ADR 0564 clauses 3, 9, 12).
func conditionsVerbs() []mcat.Verb {
return []mcat.Verb{
{Name: "conditions", Description: "What is wrong with the mesh now: every open condition, urgent first, then oldest, " +
"raised from the observers' findings (the watchman, the self-check, the healers, the assigner) and cleared when " +
"the finding goes, never by hand. Given a key, that one whole with its evidence; with history, every transition " +
"lately.",
Input: schema(map[string]string{
"key": "a condition's key: that one whole; with history, only its transitions",
"scope": "only this scope: machine, plan, call, build, merge, provider, seat, bus, core, probe, mesh, delivery or module",
"severity": "only urgent, or only warning",
"machine": "only those about this machine",
"history": "\"true\": every raising, change, silence and clearing lately, oldest first",
"days": "with history: how many days back (default 7, at most 90)",
}, nil, "history")},
{Name: "silence", Description: "Stop one condition's messages for a while, a hand act with why: it stays open and in " +
"status, and the silence is kept as its transition in history.",
Input: schema(map[string]string{
"key": "the condition's key",
"for": "how long: 30m, 4h, 2d; at most 7d",
"why": "why, required and recorded",
"cause": "the cause in a word (the condition's kind when absent)",
}, []string{"key", "for", "why"})},
{Name: "resync", Description: "Read every observer's findings again and raise and clear what differs from the open " +
"conditions: the recovery of a keeper that missed a change.",
Input: schema(map[string]string{}, nil)},
}
}
// watchdogsVerbs are the watchman's verbs (ADR 0564 clauses 4, 5, 12).
func watchdogsVerbs() []mcat.Verb {
return []mcat.Verb{
{Name: "signals", Description: "Every watchdog of the signals table the watchman keeps, each a key that expires unless " +
"its signal renews it: its row, what renews it, its bound, the age of its newest signal, and the finding its " +
"expiry wrote.",
Input: schema(map[string]string{"row": "one row, e.g. S1"}, nil)},
{Name: "stuck", Description: "Every want, build or step the watchman says stopped moving past its bound, with since " +
"when and the words of its finding.",
Input: schema(map[string]string{"machine": "only those on this machine"}, nil)},
{Name: "presence", Description: "Every machine and tool runner the watchman sees connected, with its last word, and " +
"those it says silent; a machine that said it sleeps is shown as sleeping, not silent.",
Input: schema(map[string]string{"machine": "one machine"}, nil)},
{Name: "dead-letters", Description: "Every message a consumer on the bus gave up on, kept in DEAD_LETTERS: whose " +
"consumer, the subject, how often it was handed over and when it was given up, newest first. With id: that one " +
"whole. With deliver or drop, a hand act with why.",
Input: schema(map[string]string{
"id": "a dead letter's id, as the list gives it: that one whole, with its message",
"consumer": "a consumer's name, or <stream>.<consumer>: only what that one gave up on",
"limit": "how many to list, newest first (default 50); only when listing",
"deliver": "a dead letter's id: deliver it again to the consumer that gave it up (needs why)",
"drop": "a dead letter's id: drop it for good (needs why)",
"why": "with deliver or drop: why, required and recorded",
"cause": "with deliver or drop: the cause in a word (dead-letter when absent)",
}, nil)},
{Name: "resync", Description: "Rebuild presence from the bus's connections, and the pulses and findings from the keys " +
"the watchman watches, and take every dead-letter notice waiting: the recovery of a watchman that missed a change.",
Input: schema(map[string]string{}, nil)},
}
}
// selfCheckVerbs are the self-check's verbs (ADR 0564 clause 6).
func selfCheckVerbs() []mcat.Verb {
return []mcat.Verb{
{Name: "doctor", Description: "The self-check: the last run's verdict at once, each probe passed, failed, deferred " +
"(its facts not on the bus yet, named) or could not run, and how long ago. With run, a run now; with probe, one " +
"probe's verdict whole. Runs every five minutes on its own; each failure is a finding.",
Input: schema(map[string]string{
"run": "\"true\": run every probe now and answer the verdict",
"probe": "one probe's id (D6): its verdict alone, with every finding",
}, nil, "run")},
{Name: "probes", Description: "The registry: what each probe asserts, where it reads its facts, and the finding it writes.",
Input: schema(map[string]string{}, nil)},
}
}
// healersVerbs are the healers' verbs (ADR 0564 clauses 7, 8).
func healersVerbs() []mcat.Verb {
return []mcat.Verb{
{Name: "healers", Description: "The healers: each registered response to one kind of condition, its repair, its " +
"budget and what happens when it is spent; every act taken lately with its outcome, and the mesh-wide brake.",
Input: schema(map[string]string{"days": "how many days of acts back (default 7)"}, nil)},
{Name: "heal", Description: "Take one healer's act for one open condition now, within its budget and the brake.",
Input: schema(map[string]string{
"key": "the condition's key",
"why": "why now, kept with the act",
}, []string{"key", "why"})},
{Name: "drill", Description: "Record a drill: something broken on purpose to see the mesh raise and clear it, with " +
"what it tests. A drill is never a repair: no healer is wanted for it however often it is run.",
Input: schema(map[string]string{
"what": "what was done on purpose, in a line",
"why": "what the drill tests",
"condition": "the key of the condition the drill is meant to raise, if any (optional)",
}, []string{"what", "why"})},
}
}
+104
View File
@@ -0,0 +1,104 @@
package resolution
import (
"encoding/json"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
const healthDigest = "@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
// healthManifest is a module of one web container, one running service and one step, with the health
// given on the container (or on the resource named by on).
func healthManifest(t *testing.T, on string, health map[string]any, more ...map[string]any) []byte {
t.Helper()
resources := []map[string]any{
{"id": "server", "type": "container", "name": "app-server", "image": "registry.example/app" + healthDigest,
"ports": []any{"8080"}},
{"id": "daemon", "type": "service", "unit": "app.service", "state": "running"},
{"id": "seed", "type": "container", "name": "app-seed", "image": "registry.example/app" + healthDigest,
"run-once": true},
{"id": "sweep", "type": "container", "name": "app-sweep", "image": "registry.example/app" + healthDigest,
"schedule": "0 3 * * *"},
}
resources = append(resources, more...)
for _, r := range resources {
if r["id"] == on && health != nil {
r["health"] = health
}
}
m := map[string]any{"module": "app", "requires": []any{"postgres-database"}, "tools": []any{"app_status"},
"listens": []any{
map[string]any{"name": "web", "port": 8080, "from": "mesh"},
map[string]any{"name": "beacon", "port": 9999, "protocol": "udp", "from": "mesh"},
},
"resources": resources}
raw, err := json.Marshal(m)
if err != nil {
t.Fatal(err)
}
return raw
}
func TestHealthIsComposedAsThePortThisMachineGaveTheEndpoint(t *testing.T) {
m, err := decodeForTest(healthManifest(t, "server", map[string]any{"kind": "http", "endpoint": "web", "path": "/healthz",
"needs": "postgres-database"}))
if err != nil {
t.Fatal(err)
}
compose := func(with mcat.Rendering) map[string]any {
out, err := Declaration(mcat.Resolution{Node: "laptop", Modules: []mcat.Manifest{m}}, with)
if err != nil {
t.Fatal(err)
}
at := indexOfID(out, "app.server")
if at < 0 {
t.Fatal("the container was lost")
}
return out[at]
}
// An engine older than the field is not sent it: it would refuse the whole declaration.
if h, sent := compose(mcat.Rendering{Ports: map[string]map[int]int{"app": {8080: 31001}}})["health"]; sent {
t.Fatalf("health was sent to an engine that does not read it: %v", h)
}
got := compose(mcat.Rendering{ReadsHealth: true, Ports: map[string]map[int]int{"app": {8080: 31001}}})["health"].(map[string]any)
if got["port"] != 31001 || got["endpoint"] != "web" || got["path"] != "/healthz" || got["needs"] != "postgres-database" {
t.Errorf("composed as %v", got)
}
if got["interval"] != "30s" || got["timeout"] != "5s" || got["looks"] != 3 || got["grace"] != "1m0s" {
t.Errorf("the defaults were not written out: %v", got)
}
// The port this machine gives the endpoint moves, and the check moves with it.
moved := compose(mcat.Rendering{ReadsHealth: true, Ports: map[string]map[int]int{"app": {8080: 31002}}})["health"].(map[string]any)
if moved["port"] != 31002 {
t.Errorf("after the port moved the check still dials %v", moved["port"])
}
// And the catalogue's manifest is untouched by composing it.
if _, ok := m.Resources[0]["health"].(map[string]any)["port"]; ok {
t.Error("composing wrote the port into the catalogue's own manifest")
}
}
// What the controller composes the node-engine takes: every kind, composed for an engine that reads it,
// passes the engine's own validator (mesh-host/validate) — one set of words on both sides.
func TestEveryComposedHealthIsOneTheNodeEngineTakes(t *testing.T) {
for _, h := range []map[string]any{
{"kind": "http", "endpoint": "web", "path": "/healthz", "status": 200, "body": "ok", "needs": "postgres-database"},
{"kind": "tcp", "endpoint": "web", "interval": "10s", "timeout": "2s", "looks": 2, "grace": "0s"},
{"kind": "runtime"},
{"kind": "exec", "command": "pg_isready -q"},
} {
m, err := decodeForTest(healthManifest(t, "server", h,
map[string]any{"id": "admin", "type": "service", "unit": "app-admin.service", "state": "running",
"health": map[string]any{"kind": "tool", "tool": "app_status"}}))
if err != nil {
t.Fatal(err)
}
m.Resources[1]["health"] = map[string]any{"kind": "unit"}
if _, err := Declaration((mcat.Resolution{Node: "laptop", Modules: []mcat.Manifest{m}}), mcat.Rendering{ReadsHealth: true,
Ports: map[string]map[int]int{"app": {8080: 31001}}}); err != nil {
t.Fatal(err)
}
}
}
@@ -0,0 +1,110 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
func busSeatDelivering(t *testing.T, delivers string) {
t.Helper()
was := mcat.Seats()
t.Cleanup(func() { mcat.UseSeats(was) })
mcat.UseSeats([]mcat.Seat{{Name: "mesh-broker", Scope: mcat.ScopeMesh, Delivers: delivers, Decision: "test"}})
}
func oldBroker() mcat.Manifest {
return mcat.Manifest{Module: "old-broker", Provides: []mcat.Offer{{Name: "mesh-bus", Scope: mcat.ScopeMesh}},
Claims: []mcat.Claim{{Name: "mesh-broker", Scope: mcat.ScopeMesh}}}
}
func newBroker() mcat.Manifest {
return mcat.Manifest{Module: "new-broker", Provides: []mcat.Offer{{Name: "mesh-bus", Scope: mcat.ScopeMesh}},
Claims: []mcat.Claim{{Name: "mesh-broker", Scope: mcat.ScopeMesh}}}
}
// Nothing on record: exactly the old rule. One claimant holds; two are refused.
func TestWithNoHolderOnRecordTheSoleClaimantHoldsAndTwoAreRefused(t *testing.T) {
busSeatDelivering(t, "mesh-bus")
node := mcat.Node{Name: "anchor"}
held, problems := checkClaims([]mcat.Manifest{oldBroker()}, node, nil, nil)
if len(problems) != 0 || len(held) != 1 || held[0].Module != "old-broker" {
t.Fatalf("a sole claimant did not hold the seat: held=%v problems=%v", held, problems)
}
_, problems = checkClaims([]mcat.Manifest{oldBroker(), newBroker()}, node, nil, nil)
if len(problems) != 1 || !strings.Contains(problems[0], "both claim") {
t.Fatalf("two claimants with nothing on record were not refused: %v", problems)
}
}
// With a holder on record, the other eligible assignment is silent: not refused, and not holding.
func TestTheHolderOnRecordHoldsAndTheOtherClaimantStandsBesideIt(t *testing.T) {
busSeatDelivering(t, "mesh-bus")
node := mcat.Node{Name: "anchor"}
record := []mcat.Held{{Claim: "mesh-broker", Scope: mcat.ScopeMesh, Node: "anchor", Module: "new-broker"}}
held, problems := checkClaims([]mcat.Manifest{oldBroker(), newBroker()}, node, nil, record)
if len(problems) != 0 {
t.Fatalf("the assignment beside the holder was refused: %v", problems)
}
if len(held) != 1 || held[0].Module != "new-broker" {
t.Fatalf("the holder on record is not the one holding: %v", held)
}
}
// The record names a node too: an eligible module on another machine holds nothing, and its
// machine's set still resolves.
func TestAHolderOnRecordElsewhereLeavesThisMachinesClaimantSilent(t *testing.T) {
busSeatDelivering(t, "mesh-bus")
record := []mcat.Held{{Claim: "mesh-broker", Scope: mcat.ScopeMesh, Node: "anchor", Module: "new-broker"}}
held, problems := checkClaims([]mcat.Manifest{oldBroker()}, mcat.Node{Name: "laptop"}, nil, record)
if len(problems) != 0 || len(held) != 0 {
t.Fatalf("a claimant elsewhere than the recorded holder was not simply silent: held=%v problems=%v",
held, problems)
}
}
// A machine being moved is handed its membership for the new bus as a sealed file in its own
// declaration — the machine's, not any module's (design 28, task 5.2).
func TestAMembershipForTheNewBusIsComposedAsASealedFile(t *testing.T) {
r := mcat.Resolution{Node: "anchor"}
got, err := Compose(r, mcat.Rendering{BusMembership: "sealed-blob"})
if err != nil {
t.Fatal(err)
}
var found map[string]any
for _, res := range got.Resources {
if res["id"] == BusMembershipID() {
found = res
}
}
if found == nil {
t.Fatalf("no membership resource in %v", got.Resources)
}
if found["path"] != mcat.BusMembershipPath || found["sealed"] != "sealed-blob" || found["mode"] != "0600" {
t.Fatalf("the membership is not a sealed 0600 file where the host reads it: %v", found)
}
// And a machine not being moved is handed nothing.
got, _ = Compose(r, mcat.Rendering{})
for _, res := range got.Resources {
if res["id"] == BusMembershipID() {
t.Fatal("a machine with no membership on record was handed one")
}
}
}
func TestARefusalWithNothingOnRecordNamesTheHandover(t *testing.T) {
busSeatDelivering(t, "mesh-bus")
elsewhere := []mcat.Held{{Claim: "mesh-broker", Scope: mcat.ScopeMesh, Node: "anchor", Module: "old-broker"}}
_, problems := checkClaims([]mcat.Manifest{newBroker()}, mcat.Node{Name: "laptop"}, elsewhere, nil)
if len(problems) != 1 {
t.Fatalf("two derived claimants across machines were not refused: %v", problems)
}
if !strings.Contains(problems[0], "`seat mesh-broker --to anchor/old-broker`") {
t.Fatalf("the refusal does not name the handover that records the holder: %s", problems[0])
}
}
@@ -0,0 +1,53 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// The catalogue's module: /etc/hostname is the operator's `hostname` setting. Without it the module is
// left out, naming the key — the mesh never renames a machine on its own — and a mesh-wide setting
// naming ${machine:name} gives every machine its mesh name.
func TestTheMachinesNameIsItsSetting(t *testing.T) {
cat := map[string]mcat.Manifest{"hostname": catalogueManifest(t, "hostname")}
got, err := Resolve(cat, []string{"hostname"}, mcat.Node{Name: "ace", At: "ace.internal"}, mcat.World{})
if err != nil {
t.Fatal(err)
}
machines := map[string]string{"ace.internal": "10.42.0.2"}
nameOf := func(settings mcat.SettingsBy) (string, string) {
t.Helper()
composed, err := Compose(got, mcat.Rendering{Names: machines, Machines: machines, Suffix: "internal",
Settings: settings})
if err != nil {
t.Fatal(err)
}
if why := composed.LeftOut["hostname"]; why != "" {
return "", why
}
for _, r := range composed.Resources {
if r["path"] == "/etc/hostname" {
return r["content"].(string), ""
}
}
t.Fatal("no /etc/hostname composed")
return "", ""
}
if _, why := nameOf(nil); !strings.Contains(why, "hostname") {
t.Errorf("with no setting the machine's name was written, or left out for another reason: %q", why)
}
if name, why := nameOf(mcat.SettingsBy{"hostname": {{From: "ace", Values: map[string]any{"hostname": "Ace"}}}}); name != "Ace\n" {
t.Errorf("the operator's name for the machine gave %q (%s)", name, why)
}
mesh := mcat.Layer{From: "the mesh", Values: map[string]any{"hostname": "${machine:name}"}}
if name, why := nameOf(mcat.SettingsBy{"hostname": {mesh}}); name != "ace\n" {
t.Errorf("a mesh-wide ${machine:name} gave %q (%s)", name, why)
}
node := mcat.Layer{From: "ace", Values: map[string]any{"hostname": "Ace"}}
if name, why := nameOf(mcat.SettingsBy{"hostname": {mesh, node}}); name != "Ace\n" {
t.Errorf("a machine's own name over the mesh-wide one gave %q (%s)", name, why)
}
}
+66
View File
@@ -0,0 +1,66 @@
package resolution
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"sort"
"strings"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// jailsInto composes every jail declared by the modules on a node into the files the holder writes:
// one jail file (all stanzas, so the fail2ban service restarts on a single resource) and one filter
// file per jail (its failregex, which fail2ban references by the jail's name).
//
// Owned by the holder, because the directory is: two modules writing into one fail2ban is the
// collision the holder model exists to prevent. Empty when nothing declares a jail — then the file
// is written empty rather than absent, so removing the last jail is an ordinary change the service
// restarts on rather than a file that vanishes.
func jailsInto(modules []mcat.Manifest, j *mcat.Jailing) []map[string]any {
type declared struct {
module string
jail mcat.Jail
}
var jails []declared
for _, m := range modules {
for _, jail := range m.Jails {
jails = append(jails, declared{m.Module, jail})
}
}
// A stable order the host applies as given (ADR 0005), and so the same set composes byte for
// byte every time rather than differing by map iteration.
sort.Slice(jails, func(a, b int) bool { return jails[a].jail.Name < jails[b].jail.Name })
var composed strings.Builder
composed.WriteString("# The mesh's jails, composed from the modules this node runs. Do not edit —\n")
composed.WriteString("# replaced whenever the node's modules change (novox/hq to-be 31).\n")
out := make([]map[string]any, 0, len(jails)+1)
for _, d := range jails {
// **The filter's digest rides in the jail file.** fail2ban is restarted when this file
// changes, and the filter is a file of its own: a module that changed only what a failure
// looks like rewrote the filter on disk and left the running jail on the old pattern, with
// nothing said (novox/hq issue 191's rollout found it on gitea's sshd). Naming the filter's
// digest here makes a changed pattern a changed jail file, so the restart the service
// already takes on it covers the filter too.
sum := sha256.Sum256([]byte(d.jail.Failregex))
fmt.Fprintf(&composed, "\n# from %s, filter %s\n[%s]\nenabled = true\nfilter = %s\n%s\n",
d.module, hex.EncodeToString(sum[:])[:12], d.jail.Name, d.jail.Name,
strings.TrimRight(d.jail.Jail, "\n"))
// The filter is a file of its own, named as the jail's filter= references it.
out = append(out, map[string]any{
"id": "filter-" + d.jail.Name,
"type": "file", "path": strings.TrimRight(j.FilterInto, "/") + "/" + d.jail.Name + ".conf",
"mode": "0644",
"content": "# Generated by the mesh (from module " + d.module + "). Do not edit.\n" +
"[Definition]\nfailregex = " + d.jail.Failregex + "\n",
})
}
// The one jail file, first, with the fixed id the fail2ban service names in its restart-on.
return append([]map[string]any{{
"id": mcat.ComposedJailsID(), "type": "file", "path": j.Into, "mode": "0644",
"content": composed.String(),
}}, out...)
}
@@ -0,0 +1,74 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// A node's fail2ban jails are composed from the modules it runs (novox/hq to-be 31): the holder
// (jailing) gathers every module's declared jail into one jail file and a filter file per jail.
func TestJailsAreComposedFromTheNodesModules(t *testing.T) {
modules := []mcat.Manifest{
{Module: "fail2ban", Jailing: &mcat.Jailing{Into: "/etc/fail2ban/jail.d/mesh-composed.conf", FilterInto: "/etc/fail2ban/filter.d"}},
{Module: "postgres", Jails: []mcat.Jail{{Name: "postgres-auth", Failregex: "auth failed from <HOST>", Jail: "port = 5432\nmaxretry = 5"}}},
}
files := jailsInto(modules, modules[0].Jailing)
by := map[string]map[string]any{}
for _, f := range files {
by[f["id"].(string)] = f
}
jail := by[mcat.ComposedJailsID()]
if jail == nil || jail["path"] != "/etc/fail2ban/jail.d/mesh-composed.conf" {
t.Fatalf("the composed jail file was not written: %v", jail)
}
body := jail["content"].(string)
if !strings.Contains(body, "[postgres-auth]") || !strings.Contains(body, "filter = postgres-auth") ||
!strings.Contains(body, "port = 5432") {
t.Fatalf("the postgres jail stanza was not composed in:\n%s", body)
}
filter := by["filter-postgres-auth"]
if filter == nil || filter["path"] != "/etc/fail2ban/filter.d/postgres-auth.conf" {
t.Fatalf("the jail's filter file was not written: %v", filter)
}
if !strings.Contains(filter["content"].(string), "failregex = auth failed from <HOST>") {
t.Fatalf("the failregex was not written: %v", filter["content"])
}
}
// A holder whose node runs no jail-declaring module still gets the file, empty — so removing the
// last jail is a change the service restarts on, not a file that vanishes.
func TestTheComposedJailFileIsWrittenEvenWhenEmpty(t *testing.T) {
files := jailsInto([]mcat.Manifest{{Module: "fail2ban"}}, &mcat.Jailing{Into: "/x", FilterInto: "/f"})
if len(files) != 1 || files[0]["id"] != mcat.ComposedJailsID() {
t.Fatalf("the empty composed jail file was not written alone: %v", files)
}
}
// A changed pattern restarts fail2ban (novox/hq issue 191's rollout): the service restarts when the
// composed jail file changes, and the filter is a file of its own, so the jail file names the
// filter's digest. Changing only the failregex must change the jail file; the same pattern must not.
func TestAChangedFilterChangesTheJailFile(t *testing.T) {
jailFile := func(failregex string) string {
modules := []mcat.Manifest{
{Module: "fail2ban", Jailing: &mcat.Jailing{Into: "/etc/fail2ban/jail.d/mesh.conf", FilterInto: "/etc/fail2ban/filter.d"}},
{Module: "gitea", Jails: []mcat.Jail{{Name: "gitea", Failregex: failregex, Jail: "port = 222"}}},
}
for _, f := range jailsInto(modules, modules[0].Jailing) {
if f["id"] == mcat.ComposedJailsID() {
return f["content"].(string)
}
}
t.Fatal("no composed jail file")
return ""
}
before := jailFile("web login failed from <HOST>")
if again := jailFile("web login failed from <HOST>"); again != before {
t.Errorf("the same pattern composed a different jail file, which would restart fail2ban for nothing")
}
if after := jailFile("web login failed from <HOST>\n Invalid user .* from <HOST>"); after == before {
t.Errorf("a changed pattern left the jail file as it was, so fail2ban keeps the old filter:\n%s", after)
}
}
+59
View File
@@ -0,0 +1,59 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// A module that keeps the operator-sealed secrets is handed the export as a file, at 0600, and a
// module that does not keep them is handed nothing — the export goes to the vault and nowhere else.
func TestOnlyAModuleThatKeepsGetsTheExport(t *testing.T) {
vault := mcat.Manifest{Module: "mesh-vault", Version: "1", Provides: mcat.FromAnywhere("secret"),
Keeps: "/var/lib/mesh-vault/root"}
other := mcat.Manifest{Module: "zsh", Version: "1", Provides: mcat.Offers("shell")}
got, err := Resolve(shelf(vault, other), []string{"mesh-vault", "zsh"}, mcat.Node{Name: "anchor", At: "10.0.0.1", Capabilities: map[string]bool{}}, mcat.World{})
if err != nil {
t.Fatal(err)
}
kept := &mcat.KeptExport{Export: 1, OperatorKey: "OPERATOR", Fingerprint: "sha256:abcd",
Kept: []mcat.Kept{{Node: "anchor", Module: "postgres", Name: "superuser", Origin: "accepted", Sealed: "CIPHERTEXT", Key: "OPERATOR"}}}
out, err := Declaration(got, mcat.Rendering{Kept: kept})
if err != nil {
t.Fatal(err)
}
var files int
for _, r := range out {
if r["path"] != "/var/lib/mesh-vault/root/export.json" {
continue
}
files++
if r["mode"] != "0600" {
t.Errorf("the export is written at mode %v, and it is the mesh's root secrets, sealed or not", r["mode"])
}
content, _ := r["content"].(string)
for _, want := range []string{`"operator-key": "OPERATOR"`, `"sealed": "CIPHERTEXT"`, `"module": "postgres"`} {
if !strings.Contains(content, want) {
t.Errorf("the export lacks %s:\n%s", want, content)
}
}
if id, _ := r["id"].(string); !strings.Contains(id, "mesh-vault") {
t.Errorf("the export's resource id %q does not carry its module", id)
}
}
if files != 1 {
t.Fatalf("%d export files; one module keeps them", files)
}
// No operator key yet: the vault is declared without the file, not with an empty one.
out, err = Declaration(got, mcat.Rendering{})
if err != nil {
t.Fatal(err)
}
for _, r := range out {
if r["path"] == "/var/lib/mesh-vault/root/export.json" {
t.Fatal("an export was written with nothing to export")
}
}
}
+14
View File
@@ -0,0 +1,14 @@
package resolution
import (
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// Keyed is whether a need is a provision of the seat mesh-provisioner: a key, an entry in its consumer's plan, and a
// wait of the consumer's want. consumerHanded is whether the composition hands the consumer to the provisioner.
func Keyed(consumerHanded bool, n mcat.Needed) bool {
if n.ByRecord || n.Module == "" || n.From == "" {
return false
}
return consumerHanded || n.AnsweredByHelper
}
+142
View File
@@ -0,0 +1,142 @@
package resolution
import (
"encoding/json"
"reflect"
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// A module not handed has a plan for what a provider's helper answers of it, and for nothing else (issue 774: grafana
// had no plan, so no route key, and route-proxy served no route for it); a need the controller still answers stays out.
func TestAModuleNotHandedHasAPlanForWhatAHelperAnswers(t *testing.T) {
consumer := notifierConsumer(t, false, false)
consumer.Requires = append(consumer.Requires, "route")
consumer.Contributes = map[string]map[string]any{"route": {"host": "notify.example.org"}}
consumer.ContributesMany = map[string]map[string]map[string]any{"route": {"api": {"label": "notify-api", "port": 9000}}}
node := workstation()
node.At, node.PublicDomain = "workstation.internal", "example.org"
proxy := mcat.Manifest{Module: "proxy", Version: "1", Provides: []mcat.Offer{{Name: "route", Scope: mcat.ScopeMesh, AnsweredBy: mcat.AnsweredByHelper}}}
store := mcat.Manifest{Module: "store", Version: "1", Provides: []mcat.Offer{{Name: "postgres-database", Scope: mcat.ScopeMesh,
Credential: &mcat.OfferCredential{Form: mcat.CredentialFormPair}}}, Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}}
w := twoStores("anchor")
w.Offered["route"] = []mcat.Provider{{Node: "anchor", At: "anchor.internal", Module: "proxy"}}
r, err := Resolve(shelf(consumer, proxy, store), []string{consumer.Module}, node, w)
if err != nil {
t.Fatal(err)
}
r.Account = "op"
for i, n := range r.Needs {
// As the plan sets it (compose's answeredByHelper).
r.Needs[i].AnsweredByHelper = n.Name == "route"
}
_, before := composeWith(t, r, mcat.Rendering{})
c, got := composeWith(t, r, handingTo("store:5000"))
if got != before {
t.Fatalf("the plan changed what is composed:\nbefore: %s\nnow: %s", before, got)
}
plan, ok := c.Plans["notify"]
if !ok || len(plan.Provisions) != 1 || plan.Provisions[0].Provision != "route" || plan.Provisions[0].ProviderModule != "proxy" {
t.Fatalf("the plan is %+v (%v), want the route alone", plan, ok)
}
if plan.Templates != (mcat.PlanTemplates{}) || plan.Files != nil || plan.Secrets != nil ||
!strings.Contains(plan.Provisions[0].As, "notify") || plan.Provisions[0].Contributes["host"] != "notify.example.org" {
t.Fatalf("the plan names %+v", plan)
}
// What it contributes, composed as a received file carried it, and where its machine is, as opaque bytes the
// provider's own contract reads (issue 774, research 072 row 34): route-proxy serves the route from the plan alone.
given, err := mcat.DecodeContributed(plan.Provisions[0].Contributed)
if err != nil || given.At != "workstation.internal" || len(given.Each) != 2 || given.Each[0].Local != "" ||
string(given.Each[0].Values) != `{"host":"notify.example.org"}` || given.Each[1].Local != "api" ||
string(given.Each[1].Values) != `{"internal-name":"notify-api.anchor.internal","label":"notify-api","name":"notify-api.example.org","port":9000}` {
t.Fatalf("the plan gives %+v (%v)", given, err)
}
// Its port is where the machine publishes it, the number ${port:9000} gives the module and its declaration publishes,
// never the software's own where the placement moved it (2026-10-31: route-proxy dialled gitea's 3000, published at
// 20000): its provider dials what the plan says. Where a plan carries it, a port the module does not listen on is
// refused by module, node and port.
for _, p := range []struct {
name string
ports map[string]map[int]int
given map[string]map[int]int
planned bool
port float64
refused string
}{
{"assigned by the placement (gitea 3000 at 20000)", map[string]map[int]int{"notify": {9000: 20009}}, nil, true, 20009, ""},
{"given by the node over the placement", map[string]map[int]int{"notify": {9000: 20009}},
map[string]map[int]int{"notify": {9000: 9900}}, true, 9900, ""},
{"a bare published port the placement holds none for keeps its number (influxdb 8086)",
map[string]map[int]int{"notify": {9000: 9000}}, nil, true, 9000, ""},
{"a mapping the module fixed (as mailu 7080:80)", map[string]map[int]int{"notify": {9000: 7080}}, nil, true, 7080, ""},
{"a port the module does not listen on", nil, nil, true, 0,
"notify contributing to route: its port 9000 on workstation is none of the ports it listens on"},
{"a declaration with no ports leaves it as written", nil, nil, false, 9000, ""},
} {
with := handingTo("store:5000")
with.Ports, with.Given, with.Planned = p.ports, p.given, p.planned
c, err := Compose(r, with)
if p.refused != "" {
if err == nil || !strings.Contains(err.Error(), p.refused) {
t.Errorf("%s: composed with %v, want refused with %q", p.name, err, p.refused)
}
continue
}
if err != nil {
t.Errorf("%s: %v", p.name, err)
continue
}
given, err := mcat.DecodeContributed(c.Plans["notify"].Provisions[0].Contributed)
var api map[string]any
if err == nil {
err = json.Unmarshal(given.Each[1].Values, &api)
}
if err != nil || api["port"] != p.port || given.At != "workstation.internal" {
t.Errorf("%s: the plan gives %v at %q (%v), want port %v", p.name, api, given.At, err, p.port)
}
}
// Read strictly: a form the SDK does not write is refused, whatever its contributions say.
for raw, refused := range map[string]string{
`{"v":2,"each":[{"values":{}}]}`: "version 2",
`{"v":1,"each":[]}`: "no contribution",
`{"v":1,"each":[{"values":{}}],"route":{}}`: "unknown field",
`{"v":1,"each":[{"values":{}},{"values":{}}]}`: "twice",
`{"v":1,"each":[{"local":"api","values":["a"]}]}`: "not a JSON object",
`{"v":1,"each":[{"values":{}}]} {}`: "more than one",
`{"v":1,"at":"10.0.0.2","each":[{"values":{"x":true}}]}`: "",
} {
_, err := mcat.DecodeContributed(json.RawMessage(raw))
if (refused == "") != (err == nil) || err != nil && !strings.Contains(err.Error(), refused) {
t.Errorf("DecodeContributed(%s) = %v, want %q", raw, err, refused)
}
}
// And it hands nothing: Plan.Hands, the one rule every reader of a plan asks, by each part a plan can name.
if plan.Hands() {
t.Fatalf("a plan naming the route alone hands its module: %+v", plan)
}
for _, c := range []struct {
name string
plan mcat.Plan
hands bool
}{
{"empty", mcat.Plan{}, false},
{"provisions alone", mcat.Plan{Provisions: plan.Provisions}, false},
{"a digest without an archive", mcat.Plan{Templates: mcat.PlanTemplates{Digest: "sha256:1"}}, false},
{"templates alone", mcat.Plan{Templates: mcat.PlanTemplates{Source: "notify.tar", Digest: "sha256:1"}}, true},
{"files alone", mcat.Plan{Files: []mcat.PlanFile{{ID: "conf", Path: "/etc/notify.conf"}}}, true},
{"secrets alone", mcat.Plan{Secrets: []mcat.PlanSecret{{Name: "admin"}}}, true},
{"items alone", mcat.Plan{Items: []mcat.PlanItem{{Collection: "routes", Template: "route.tmpl"}}}, true},
{"secrets and provisions", mcat.Plan{Secrets: []mcat.PlanSecret{{Name: "admin"}}, Provisions: plan.Provisions}, true},
} {
if got := c.plan.Hands(); got != c.hands {
t.Errorf("%s: Hands() is %v, want %v", c.name, got, c.hands)
}
}
if want := mcat.Awaited(consumer, func(p string) []mcat.Manifest {
return map[string][]mcat.Manifest{"route": {proxy}, "postgres-database": {store}}[p]
}); !reflect.DeepEqual(want, []string{"route"}) {
t.Fatalf("the assigner waits for %v, and the plan keys the route alone", want)
}
}
@@ -0,0 +1,25 @@
package resolution
import (
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
func aChannel(module, kind string) mcat.Manifest {
return mcat.Manifest{Module: module, Claims: []mcat.Claim{
{Name: "channel", Scope: mcat.ScopeMesh, Kind: kind}, {Name: "intake", Scope: mcat.ScopeMesh, Kind: kind}}}
}
// Two kinds on one machine are two holders, and one kind on two machines is a second claimant.
func TestEachKindIsItsOwnHolderWhenResolved(t *testing.T) {
modules := []mcat.Manifest{aChannel("telegram", "telegram"), aChannel("desk-channel", "desktop")}
held, problems := checkClaims(modules, mcat.Node{Name: "anchor"}, nil, nil)
if len(problems) > 0 || len(held) != 4 {
t.Fatalf("two kinds on one machine: held %v, problems %v", held, problems)
}
_, problems = checkClaims([]mcat.Manifest{aChannel("telegram", "telegram")}, mcat.Node{Name: "home"}, held, nil)
if len(problems) == 0 {
t.Fatal("one kind held on two machines was not refused")
}
}
@@ -0,0 +1,38 @@
package resolution
import (
"encoding/json"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// LaidSource says whether a value resolved from source is laid over a module's files: mesh config, node config and the
// module's own value are; a manifest's default is not, since it is the composition's already and a taken key is laid
// only when a layer sets it, never from a default (TakesSettingsField); an unset value is not, since a required one is
// refused by the composition by name. **The one rule for every composer** (issue 1126, C2): the controller's config
// read and a node composing its own lay the same values, so the two compose the same files.
func LaidSource(source string) bool {
switch source {
case mcat.SourceMesh, mcat.SourceNode, mcat.SourceModule:
return true
}
return false
}
// LaidValue is one key of mesh-config_resolved read as a composer lays it: its value, and true when its source is laid
// (LaidSource) and it holds a value; false for a default, an unset value, a source not known and a value that cannot be
// read.
func LaidValue(raw []byte) (any, bool) {
var r struct {
Value json.RawMessage `json:"value"`
Source string `json:"source"`
}
if json.Unmarshal(raw, &r) != nil || !LaidSource(r.Source) || len(r.Value) == 0 {
return nil, false
}
var v any
if json.Unmarshal(r.Value, &v) != nil {
return nil, false
}
return v, true
}
+259
View File
@@ -0,0 +1,259 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
func aModelUser() mcat.Manifest {
return mcat.Manifest{Module: "assistant", Requires: []string{"model-access"},
Binds: map[string]string{"model-access": "/etc/assistant/model.json"},
Secrets: map[string]string{"model-access": "/etc/assistant/key"}}
}
// A provision answered by a record rather than a node.
//
// novox/hq ADR 0024: a hosted model is on nobody's machine and is reached over the public
// internet, so the rule that refuses two ends sharing no private network must not apply. This
// node is deliberately not on the private network at all — under the old rule that alone would
// refuse it.
func TestAProvisionAnsweredByARecordDoesNotNeedAPrivateNetwork(t *testing.T) {
got, err := Resolve(
map[string]mcat.Manifest{"assistant": aModelUser()},
[]string{"assistant"}, mcat.Node{Name: "workstation"}, mcat.World{
Licences: map[string][]mcat.Record{"model-access": {{Name: "personal",
Serves: map[string]any{"model": "a-model"}}}},
Using: map[string]map[string]mcat.Record{"assistant": {"model-access": {Name: "personal",
Serves: map[string]any{"model": "a-model"}}}},
})
if err != nil {
t.Fatalf("a machine off the private network could not be given model access: %v", err)
}
if len(got.Needs) != 1 {
t.Fatalf("the licence was not recorded as something this node takes: %+v", got.Needs)
}
if !got.Needs[0].ByRecord {
t.Fatal("the licence was treated as a machine, so the reachability rule would apply to it")
}
if got.Needs[0].From != "personal" {
t.Fatalf("the licence is not named by what a person calls it: %+v", got.Needs[0])
}
}
// Refused when the consumer has not said which — and the refusal names the candidates and the
// command, because ADR 0024 warns this will be felt: a mesh holding three ways to reach a model
// refuses every consumer that has not chosen.
func TestAConsumerThatHasNotSaidWhichLicenceIsRefusedWithTheCandidates(t *testing.T) {
_, err := Resolve(
map[string]mcat.Manifest{"assistant": aModelUser()},
[]string{"assistant"}, mcat.Node{Name: "workstation"}, mcat.World{Licences: map[string][]mcat.Record{"model-access": {
{Name: "personal"}, {Name: "the-organisation"},
}}})
if err == nil {
t.Fatal("a consumer was given model access without anybody saying which")
}
said := err.Error()
for _, want := range []string{"personal", "the-organisation", "licence use"} {
if !strings.Contains(said, want) {
t.Fatalf("the refusal does not name %q, so it is correct and unusable:\n%s", want, said)
}
}
}
// A model the mesh runs itself answers it locally, and a record is not consulted.
func TestAModelInTheMeshsOwnSetAnswersItWithoutALicence(t *testing.T) {
got, err := Resolve(
map[string]mcat.Manifest{
"assistant": aModelUser(),
"ollama": {Module: "ollama",
Provides: []mcat.Offer{{Name: "model-access", Scope: mcat.ScopeNode}}},
},
[]string{"assistant", "ollama"}, mcat.Node{Name: "workstation"}, mcat.World{Licences: map[string][]mcat.Record{"model-access": {{Name: "personal"}}}})
if err != nil {
t.Fatalf("a machine running its own model was asked to choose a licence: %v", err)
}
for _, n := range got.Needs {
if n.ByRecord {
t.Fatal("a record was used although the answer was on this machine")
}
}
}
// A key that was never supplied is refused by name rather than silently not written.
//
// The mesh discarded the plaintext when the key was accepted and cannot seal another, so a
// machine that resolved cleanly would receive no file and fail at whatever read it.
func TestAModuleOnALicenceWithNoKeyIsRefusedRatherThanLeftEmpty(t *testing.T) {
r := mcat.Resolution{
Node: "workstation",
Modules: []mcat.Manifest{aModelUser()},
Needs: []mcat.Needed{{Name: "model-access", From: "personal", ByRecord: true, For: "assistant"}},
}
_, err := Declaration(r, mcat.Rendering{})
if err == nil {
t.Fatal("a module was given a licence with no key, so it receives nothing and fails later")
}
if !strings.Contains(err.Error(), "licence key personal") {
t.Fatalf("the refusal does not say how to fix it: %v", err)
}
}
// And with a key, both files arrive: what is public, and what is not.
func TestALicenceDeliversWhatIsPublicAndWhatIsSealed(t *testing.T) {
r := mcat.Resolution{
Node: "workstation",
Modules: []mcat.Manifest{aModelUser()},
Needs: []mcat.Needed{{Name: "model-access", From: "personal", ByRecord: true, For: "assistant",
Serves: map[string]any{"model": "a-model"}, Sealed: "sealed-blob"}},
}
out, err := Declaration(r, mcat.Rendering{})
if err != nil {
t.Fatal(err)
}
files := map[string]map[string]any{}
for _, res := range out {
if path, ok := res["path"].(string); ok {
files[path] = res
}
}
bound, given := files["/etc/assistant/model.json"]
if !given {
t.Fatal("the consumer was not told what it needs to know that is not secret")
}
content, _ := bound["content"].(string)
if !strings.Contains(content, "a-model") {
t.Fatalf("the binding does not carry what the licence serves:\n%s", content)
}
// The binding says it is a record rather than leaving an empty address, which a reader would
// take for something the mesh failed to fill in.
if !strings.Contains(content, "not a machine") {
t.Fatalf("the binding leaves an empty address with no explanation:\n%s", content)
}
key, delivered := files["/etc/assistant/key"]
if !delivered {
t.Fatal("the key was not delivered")
}
if key["sealed"] != "sealed-blob" {
t.Fatalf("the key is not the sealed one: %+v", key)
}
// And never in the open. The whole arrangement is that what travels is unreadable by
// everything between here and the machine.
if strings.Contains(content, "sealed-blob") {
t.Fatal("the key was written into the public file as well")
}
}
// One machine's unresolvable module must not remove it from the private network.
//
// The pass that answers *what does this node offer* takes a failed resolution to mean it learned
// nothing — so refusing an unanswerable requirement there makes the machine disappear, and every
// other machine is then told, wrongly, that the two of them share no network. A wrong answer about
// a machine nobody asked about, caused by a fault on a third.
func TestAnUnanswerableRequirementDoesNotRemoveAMachineFromTheMesh(t *testing.T) {
shelf := map[string]mcat.Manifest{
"assistant": aModelUser(),
"networking": {Module: "networking",
Provides: []mcat.Offer{{Name: "mesh-network", Scope: mcat.ScopeNode}}},
}
// The first pass: what does this machine offer? It is assigned something nothing answers.
got, err := Resolve(shelf, []string{"assistant", "networking"}, mcat.Node{Name: "laptop"}, mcat.World{Unchecked: true})
if err != nil {
t.Fatalf("a machine with one unanswerable requirement was lost entirely: %v", err)
}
var offers bool
for _, m := range got.Modules {
for _, o := range m.Offers() {
if o == "mesh-network" {
offers = true
}
}
}
if !offers {
t.Fatal("the machine's own network module was not seen, so it looks off the network")
}
// And the second pass, where the question is actually being asked, still refuses it.
if _, err := Resolve(shelf, []string{"assistant", "networking"}, mcat.Node{Name: "laptop"}, mcat.World{}); err == nil {
t.Fatal("the requirement nothing answers was accepted when it was actually asked")
}
}
// A same-node provider that mints no credential but serves a port the consumer cannot guess must
// still deliver that port. A `local-model` provider (ollama) provides `model-access` at node scope
// and serves a port and a model name, minting nothing; a co-located consumer requires and binds it
// and has a file that names `${bound:model-access:port}`. The served facts never reached the
// consumer's needs — node scope set `local`, not `brokered`, so the delivering branch was skipped —
// and the consumer's file was refused for naming a provision it "did not require". The endpoint is
// keyless, but a port is still a fact nobody can invent.
func TestAKeylessSameNodeProviderStillDeliversWhatItServes(t *testing.T) {
provider := mcat.Manifest{Module: "local-model",
Provides: []mcat.Offer{{Name: "model-access", Scope: mcat.ScopeNode}},
Serves: map[string]map[string]any{
"model-access": {"port": 11434, "model": "a-model"}}}
consumer := mcat.Manifest{Module: "assistant", Requires: []string{"model-access"},
Binds: map[string]string{"model-access": "/etc/assistant/model.json"}}
// node.At empty: a single-node deployment with no private network. The delivered binding must
// still carry a usable address — loopback, because a machine off the network still reaches
// itself, and an empty `at` would be written into the consumer's file as a host that resolves
// to nothing.
got, err := Resolve(
map[string]mcat.Manifest{"local-model": provider, "assistant": consumer},
[]string{"assistant", "local-model"}, mcat.Node{Name: "workstation"}, mcat.World{})
if err != nil {
t.Fatalf("a keyless same-node model endpoint was refused: %v", err)
}
var found *mcat.Needed
for i := range got.Needs {
if got.Needs[i].Name == "model-access" && got.Needs[i].For == "assistant" {
found = &got.Needs[i]
}
}
if found == nil {
t.Fatalf("the served endpoint was not delivered to the consumer at all: %+v", got.Needs)
}
if found.ByRecord {
t.Fatal("a same-node endpoint was treated as a record, so the reachability rule would apply")
}
if found.At == "" {
t.Fatalf("the binding carries no address, so its file names a host that resolves to nothing: %+v", found)
}
if found.At != "127.0.0.1" {
t.Fatalf("off the private network the address must fall back to loopback, got %q", found.At)
}
if got, want := plainly(found.Serves["port"]), "11434"; got != want {
t.Fatalf("the port the consumer cannot guess was not delivered: got %q want %q", got, found.Serves)
}
if found.Serves["model"] != "a-model" {
t.Fatalf("the extra served fact was not delivered: %+v", found.Serves)
}
// End to end: a file that names ${bound:model-access:...} is filled rather than refused, which
// is the whole failure this fixes — boundInto reads knownFor, and knownFor reads the needs.
consumer.Resources = []map[string]any{{
"id": "env", "type": "file", "path": "/etc/assistant/.env",
"content": "OPENAI_BASE_URL=http://${bound:model-access:at}:${bound:model-access:port}/v1\n",
}}
got, err = Resolve(
map[string]mcat.Manifest{"local-model": provider, "assistant": consumer},
[]string{"assistant", "local-model"}, mcat.Node{Name: "workstation"}, mcat.World{})
if err != nil {
t.Fatalf("resolution refused the consumer with a bound file: %v", err)
}
out, err := Declaration(got, mcat.Rendering{})
if err != nil {
t.Fatalf("the declaration refused the consumer's bound file: %v", err)
}
var env string
for _, res := range out {
if res["path"] == "/etc/assistant/.env" {
env, _ = res["content"].(string)
}
}
if want := "http://127.0.0.1:11434/v1"; !strings.Contains(env, want) {
t.Fatalf("the bound file was not filled with the served endpoint, want %q:\n%s", want, env)
}
}
+117
View File
@@ -0,0 +1,117 @@
package resolution
import (
"fmt"
"net/netip"
"slices"
"sort"
"strings"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// privateRanges are the only ranges a local network may lie in (ADR 0340 §2): RFC 1918 for IPv4 and the
// unique local addresses for IPv6. A network counts only when it lies wholly inside one of them.
var privateRanges = []struct {
prefix netip.Prefix
name string
}{
{netip.MustParsePrefix("10.0.0.0/8"), "a private range (RFC 1918, 10.0.0.0/8)"},
{netip.MustParsePrefix("172.16.0.0/12"), "a private range (RFC 1918, 172.16.0.0/12)"},
{netip.MustParsePrefix("192.168.0.0/16"), "a private range (RFC 1918, 192.168.0.0/16)"},
{netip.MustParsePrefix("fc00::/7"), "the IPv6 unique local range (fc00::/7)"},
}
// JudgeLocalNetworks says, for each network a machine reported, whether it counts as its local network.
//
// **A public subnet never counts** (ADR 0340 §2): a network counts only when it lies wholly in a private
// range, so `local-network` can never open a port to the internet — a machine sitting on a public /24 has
// that /24 judged and refused, never rendered. A link-local network does not count either. And only a
// link the machine reported as facing outside it counts (§3): the private network's own link and a
// container bridge are this machine's own, not its local network, whatever their addresses.
//
// Sorted by link and then network, one entry per (link, network), so what `node` shows and what the
// filter is rendered from do not depend on the order a machine happened to say them in.
func JudgeLocalNetworks(attached []mcat.AttachedNetwork, outward []string, tunnel string) []mcat.LocalNetwork {
seen := map[[2]string]bool{}
var out []mcat.LocalNetwork
for _, a := range attached {
key := [2]string{a.Link, a.Network}
if seen[key] {
continue
}
seen[key] = true
out = append(out, judgeOne(a, outward, tunnel))
}
sort.Slice(out, func(i, j int) bool {
if out[i].Link != out[j].Link {
return out[i].Link < out[j].Link
}
return out[i].Network < out[j].Network
})
return out
}
func judgeOne(a mcat.AttachedNetwork, outward []string, tunnel string) mcat.LocalNetwork {
j := mcat.LocalNetwork{Link: a.Link, Network: a.Network}
prefix, err := netip.ParsePrefix(strings.TrimSpace(a.Network))
if err != nil {
j.Why = "not a network the mesh can read (" + err.Error() + ")"
return j
}
prefix = prefix.Masked()
j.Network = prefix.String()
switch {
case tunnel != "" && a.Link == tunnel:
j.Why = "the private network's own link, not the local network"
return j
case !slices.Contains(outward, a.Link):
j.Why = fmt.Sprintf("%s does not face outside this machine (a bridge or a link of its own)", a.Link)
return j
case prefix.Addr().IsLoopback():
j.Why = "loopback"
return j
case prefix.Addr().IsLinkLocalUnicast():
j.Why = "link-local, never counted"
return j
}
for _, r := range privateRanges {
if r.prefix.Contains(prefix.Addr()) && prefix.Bits() >= r.prefix.Bits() {
j.Counts = true
j.Why = "in " + r.name
return j
}
}
if prefix.Addr().IsPrivate() {
// Overlaps a private range without lying wholly inside it (a /7 around 10.0.0.0/8, say).
j.Why = "not wholly in a private range, so never counted"
return j
}
j.Why = "public, never counted: local-network may not open a port to the internet"
return j
}
// CountedLocalNetworks is the networks that count, as the filter renders them: sorted, without repeats.
func CountedLocalNetworks(judged []mcat.LocalNetwork) []string {
var out []string
for _, j := range judged {
if j.Counts && !slices.Contains(out, j.Network) {
out = append(out, j.Network)
}
}
sort.Strings(out)
return out
}
// LocalNetworkPorts is every rule admitted from `local-network`, with the networks it resolved to.
func LocalNetworkPorts(rules []mcat.Rule, networks []string) []mcat.LocalNetworkPort {
var out []mcat.LocalNetworkPort
for _, rule := range rules {
if rule.From != mcat.FromLocalNetwork {
continue
}
out = append(out, mcat.LocalNetworkPort{Port: rule.Port, Protocol: rule.Protocol,
Because: rule.Because, Networks: networks})
}
return out
}
@@ -0,0 +1,183 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// The home server's case (novox/hq issue 154): it sits on 192.168.1.0/24, and also on a public /24 a
// provider handed it, beside the private network's own link and a container bridge.
var homeServer = []mcat.AttachedNetwork{
{Link: "eth0", Network: "192.168.1.10/24"},
{Link: "eth1", Network: "203.0.113.7/24"},
{Link: "eth0", Network: "fe80::1/64"},
{Link: "eth0", Network: "fd12:3456:789a::5/64"},
{Link: "wg-mesh", Network: "10.10.0.4/24"},
{Link: "docker0", Network: "172.17.0.1/16"},
}
func judged(t *testing.T) []mcat.LocalNetwork {
t.Helper()
return JudgeLocalNetworks(homeServer, []string{"eth0", "eth1"}, "wg-mesh")
}
// **A public subnet never counts** (ADR 0340 §2), and only a private one on a link facing outside does
// (§3): the tunnel's and a bridge's are this machine's own, whatever their addresses.
func TestOnlyAPrivateNetworkOnALinkFacingOutsideCounts(t *testing.T) {
got := CountedLocalNetworks(judged(t))
want := []string{"192.168.1.0/24", "fd12:3456:789a::/64"}
if strings.Join(got, " ") != strings.Join(want, " ") {
t.Fatalf("counted %v, want %v", got, want)
}
for _, j := range judged(t) {
if j.Why == "" {
t.Errorf("%s on %s is judged without a why", j.Network, j.Link)
}
switch j.Network {
case "203.0.113.0/24":
if j.Counts || !strings.Contains(j.Why, "public") {
t.Errorf("the public subnet is judged %v (%s), want never counted as public", j.Counts, j.Why)
}
case "fe80::/64":
if j.Counts || !strings.Contains(j.Why, "link-local") {
t.Errorf("a link-local network is judged %v (%s)", j.Counts, j.Why)
}
case "10.10.0.0/24", "172.17.0.0/16":
if j.Counts {
t.Errorf("%s on %s counts, and it is not the local network", j.Network, j.Link)
}
}
}
}
// A network overlapping a private range without lying wholly inside it does not count either, so a
// wide prefix around a private range cannot carry the internet with it.
func TestANetworkNotWhollyInAPrivateRangeDoesNotCount(t *testing.T) {
for _, n := range []string{"10.0.0.0/7", "172.0.0.0/8", "100.64.0.0/10", "0.0.0.0/0", "::/0"} {
got := JudgeLocalNetworks([]mcat.AttachedNetwork{{Link: "eth0", Network: n}}, []string{"eth0"}, "")
if len(got) != 1 || got[0].Counts {
t.Errorf("%s counts as a local network: %+v", n, got)
}
}
}
// **The filter admits the local network on that port's rule alone** (§5), beside the private network,
// in both chains, and never the public subnet.
func TestALocalNetworkPortIsAdmittedFromItsNetworksAndTheMesh(t *testing.T) {
rules := []mcat.Rule{
{Port: 1883, Protocol: "tcp", From: mcat.FromLocalNetwork, Because: []string{"mosquitto"}},
{Port: 5432, Protocol: "tcp", From: mcat.FromMesh, Because: []string{"postgres"}},
}
local := CountedLocalNetworks(judged(t))
out := AsNftablesWithLocal(rules, []string{"10.10.0.1", "10.10.0.4"}, false, nil,
[]string{"eth0", "eth1"}, "wg-mesh", local)
for _, want := range []string{
"ip saddr { 10.10.0.1, 10.10.0.4, 192.168.1.0/24 } tcp dport 1883 accept",
"ip6 saddr { fd12:3456:789a::/64 } tcp dport 1883 accept",
"ip saddr { 10.10.0.1, 10.10.0.4, 192.168.1.0/24 } ct original proto-dst 1883 accept",
// The mesh-only port is not widened by the machine having a local network.
"ip saddr { 10.10.0.1, 10.10.0.4 } tcp dport 5432 accept",
} {
if !strings.Contains(out, want) {
t.Errorf("the rule set lacks %q:\n%s", want, out)
}
}
if strings.Contains(out, "203.0.113") {
t.Errorf("the public subnet reached the rule set:\n%s", out)
}
if strings.Contains(out, "tcp dport 1883 accept\n") && !strings.Contains(out, "saddr") {
t.Errorf("1883 is open to anywhere:\n%s", out)
}
for _, line := range strings.Split(out, "\n") {
if strings.TrimSpace(line) == "tcp dport 1883 accept" {
t.Fatalf("1883 is open to anywhere:\n%s", out)
}
}
}
// **Nothing resolved is closed and said** (§6): a machine that reported no private network admits the
// port from the private network only, and the rule set says it found none — never widened to anywhere.
func TestNoLocalNetworkIsSaidAndNotWidened(t *testing.T) {
rules := []mcat.Rule{{Port: 1883, Protocol: "tcp", From: mcat.FromLocalNetwork, Because: []string{"mosquitto"}}}
out := AsNftablesWithLocal(rules, []string{"10.10.0.1"}, false, nil, []string{"eth0"}, "wg-mesh", nil)
if !strings.Contains(out, "1883 wanted the local network, and this machine reported no private network") {
t.Errorf("the rule set does not say the local network resolved to nothing:\n%s", out)
}
if !strings.Contains(out, "ip saddr { 10.10.0.1 } tcp dport 1883 accept") {
t.Errorf("the port is not admitted from the private network:\n%s", out)
}
for _, line := range strings.Split(out, "\n") {
if strings.TrimSpace(line) == "tcp dport 1883 accept" {
t.Fatalf("an unresolved local network was widened to anywhere:\n%s", out)
}
}
said := LocalNetworkPorts(rules, nil)[0].Said()
if !strings.Contains(said, "none") {
t.Errorf("plan says %q, want it to say no local network was found", said)
}
}
// **A fourth reach** (§4, changing ADR 0138 §4): `internal` with the local networks added to what the
// filter opens — the internal name, not the public one.
func TestTheLocalNetworkReachIsInternalWithTheLocalNetworkAdded(t *testing.T) {
public, internal := namesFor(t, aRoutedWeb(), reachSet(mcat.ReachLocalNetwork))
if public != "" || internal != "app.anchor.internal" {
t.Fatalf("names are %q and %q, want the internal name only", public, internal)
}
bare := aRoutedWeb()
bare.Contributes = nil
r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{bare}}
rules, err := Rules(r, mcat.Rendering{Settings: reachSet(mcat.ReachLocalNetwork)})
if err != nil {
t.Fatalf("rules: %v", err)
}
if len(rules) != 1 || rules[0].From != mcat.FromLocalNetwork {
t.Fatalf("reach local-network made the filter say %+v", rules)
}
}
// Two modules wanting one port, one from the mesh and one from the local network, render one rule from
// the local network: it covers the mesh, and anywhere covers it.
func TestTheLocalNetworkSitsBetweenTheMeshAndAnywhere(t *testing.T) {
got := widest([]mcat.Rule{
{Port: 53, Protocol: "udp", From: mcat.FromMesh, Because: []string{"a"}},
{Port: 53, Protocol: "udp", From: mcat.FromLocalNetwork, Because: []string{"b"}},
})
if len(got) != 1 || got[0].From != mcat.FromLocalNetwork || len(got[0].Because) != 2 {
t.Fatalf("got %+v", got)
}
got = widest(append(got, mcat.Rule{Port: 53, Protocol: "udp", From: mcat.FromEverywhere, Because: []string{"c"}}))
if len(got) != 1 || got[0].From != mcat.FromEverywhere {
t.Fatalf("got %+v", got)
}
}
// On an adopted node the opening is the private network's part only: the found firewall admits the
// local network, and an opening that widened to everywhere would open the port to the internet.
func TestAnAdoptedNodeOpensTheLocalNetworkPortToTheMeshOnly(t *testing.T) {
out := Openings([]mcat.Rule{{Port: 1883, Protocol: "tcp", From: mcat.FromLocalNetwork}}, nil, nil)
if len(out) != 1 || out[0]["from"] != mcat.OpeningFromMesh {
t.Fatalf("openings %v", out)
}
}
// A global unicast IPv6 subnet on a link facing outside is public, and never counts: the IPv6 side of the
// rule that `local-network` cannot open a port to the internet (ADR 0340 §2).
func TestAGlobalIPv6SubnetIsPublicAndNeverCounts(t *testing.T) {
got := JudgeLocalNetworks([]mcat.AttachedNetwork{{Link: "eth0", Network: "2001:db8:1234::7/48"}}, []string{"eth0"}, "wg-mesh")
if len(got) != 1 {
t.Fatalf("judged %+v", got)
}
if got[0].Counts {
t.Fatalf("the global IPv6 subnet %s counts as a local network", got[0].Network)
}
if got[0].Network != "2001:db8:1234::/48" || !strings.Contains(got[0].Why, "public, never counted") {
t.Fatalf("judged %s with why %q, want it named public and never counted", got[0].Network, got[0].Why)
}
if n := CountedLocalNetworks(got); len(n) != 0 {
t.Fatalf("counted %v", n)
}
}
@@ -0,0 +1,153 @@
package resolution
import (
"fmt"
"regexp"
"sort"
"strings"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// ofMachine is where a module says a fact about the machine underneath it belongs:
// ${machine:<key>}.
var ofMachine = regexp.MustCompile(`\$\{machine:([a-z0-9][a-z0-9_-]*)\}`)
// machineUsed are the keys a file's content asks for, first appearance first.
func machineUsed(content string) []string {
var used []string
seen := map[string]bool{}
for _, m := range ofMachine.FindAllStringSubmatch(content, -1) {
if key := m[1]; !seen[key] {
seen[key] = true
used = append(used, key)
}
}
return used
}
// machineFacts is what a module may name about the machine it was assigned to.
//
// `at` is absent rather than empty when the machine is not on the private network. A module asking
// to be reached at an address that does not exist is a misconfiguration, and it is said here —
// where the module and the machine are both named — rather than discovered later as a certificate
// nobody can verify.
//
// `address` is what `at` resolves to, read from the names the control plane composed — the same map
// the hosts file and the resolver's wildcards are written from, so a file naming the machine's
// address and the file every other machine reaches it by cannot disagree. Absent, like `at`, when
// the machine is off the network or the mesh has not placed it.
func machineFacts(r mcat.Resolution, names map[string]string, meshRange string) map[string]string {
out := map[string]string{"name": r.Node}
if r.At != "" {
out["at"] = r.At
if address := names[r.At]; address != "" {
out["address"] = address
}
}
// The private network's whole range — a mesh-wide fact, not this machine's, but named here
// because a module cannot know it and sometimes must (an intrusion filter that must never ban a
// tunnel peer). Absent when the mesh has no range to give.
if meshRange != "" {
out["mesh-range"] = meshRange
}
// The operator's login on this machine and where its home is (novox/hq to-be 29), so a module
// that writes operator config names the account and its home rather than a value it cannot know.
// Absent when no operator account is known — a headless box a person never logs into.
if r.Account != "" {
out["account"] = r.Account
out["account-home"] = accountHomeOf(r.Account, r.AccountHome)
}
// The account agents run as here, and whether it must never become root (novox/hq ADR 0266). The agent
// account where the node names one; the operator account otherwise, so a module writing the agent's
// home names one fact on every machine. `agent-root` is "never" only for an account of the agents' own:
// the user resource naming it then asks the node-engine to judge it, and on a machine where agents run
// as the operator it is empty, asserting nothing — the operator's account may become root there.
if agent, home := resolutionAgentAccount(r); agent != "" {
out["agent-account"] = agent
out["agent-home"] = home
out["agent-root"] = ""
if r.AgentAccount != "" {
out["agent-root"] = mcat.RootNever
}
}
return out
}
// agentAccount is the account agents run as on this machine and its home: the agent account when the node
// names one (novox/hq ADR 0266), else the operator account; empty when neither is known.
func resolutionAgentAccount(r mcat.Resolution) (string, string) {
if r.AgentAccount != "" {
return r.AgentAccount, agentHomeOf(r.AgentAccount, r.AgentAccountHome)
}
if r.Account != "" {
return r.Account, accountHomeOf(r.Account, r.AccountHome)
}
return "", ""
}
// agentHomeOf is where the agent account's home is: what was stored, or /home/<account>. Never /root: the
// agent account is never root.
func agentHomeOf(account, home string) string {
if home != "" {
return home
}
return "/home/" + account
}
// accountHomeOf is where an account's home is: what was stored, or the derived default — /root for
// root, /home/<account> otherwise. The one place the default is written, so a fact and the store
// cannot disagree about it.
func accountHomeOf(account, home string) string {
if home != "" {
return home
}
if account == "root" {
return "/root"
}
return "/home/" + account
}
// machineInto replaces a file's ${machine:…} placeholders with what the mesh knows about the
// machine the module was assigned to.
//
// A key the mesh does not hold is refused, for the same reason a binding's is: left alone, the
// literal would be written into a configuration file and read as a value.
func machineInto(resource map[string]any, facts map[string]string, module string) error {
// Content, and now the path and owner too: a module that writes into a person's home names it
// with ${machine:account-home} and ${machine:account}, which it cannot know until assigned
// (novox/hq to-be 29), the same reason its content names ${machine:address}. And the name a
// `user` shape sets the login shell of, and the user a user-scoped unit or a process runs as:
// the shell module makes the operator's account its holder's login shell, and the desktop's
// watchers run as that account (novox/hq ADR 0176, ADR 0177) — neither can name the person. And a
// user's `root`: the agent's module declares the account agents run as with ${machine:agent-root},
// "never" only where that account is the agents' own (novox/hq ADR 0266), and its `home`, so an account
// the operator named with a home of its own is made there (${machine:agent-home}); the node-engine reads a
// user's home only when it creates the account, so an existing one is never moved.
for _, field := range []string{"path", "owner", "content", "name", "user", "root", "home"} {
s, ok := resource[field].(string)
if !ok {
continue
}
for _, key := range machineUsed(s) {
value, has := facts[key]
if !has {
return fmt.Errorf(
"%s has a %s that says ${machine:%s}, and this machine says %s",
module, field, key, orNothing(namesOfFacts(facts)))
}
s = strings.ReplaceAll(s, fmt.Sprintf("${machine:%s}", key), value)
resource[field] = s
}
}
return nil
}
func namesOfFacts(facts map[string]string) []string {
out := make([]string, 0, len(facts))
for k := range facts {
out = append(out, k)
}
sort.Strings(out)
return out
}
@@ -0,0 +1,122 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// anchored is a machine on the private network, which is where a name worth certifying comes from.
func anchored() mcat.Node {
n := workstation()
n.At = "workstation.internal"
return n
}
// authority is a module that must put its own machine's name into a file it writes.
func authority() mcat.Manifest {
return mcat.Manifest{
Module: "authority", Version: "1",
Provides: mcat.FromAnywhere("acme-ca"),
Serves: map[string]map[string]any{"acme-ca": {}},
Resources: []map[string]any{{
"id": "init", "type": "file", "path": "/var/lib/authority/init.env",
"content": "NAMES=${machine:at},${machine:name},localhost\n",
}},
}
}
func TestAModuleNamesTheMachineItWasAssignedTo(t *testing.T) {
got, err := Resolve(shelf(authority()), []string{"authority"}, anchored(), mcat.World{})
if err != nil {
t.Fatal(err)
}
content := ""
for _, r := range mustDeclare(t, got) {
if strings.HasSuffix(plainly(r["id"]), "init") {
content = plainly(r["content"])
}
}
if content == "" {
t.Fatal("the authority's file was not declared")
}
if strings.Contains(content, "${machine:") {
t.Fatalf("the placeholder was written into the file as a value: %q", content)
}
// The machine's own name on the private network — the one a consumer is handed as `at`, and so
// the one a certificate has to carry.
if !strings.Contains(content, "workstation.internal") {
t.Errorf("the file does not name the address consumers reach it at: %q", content)
}
}
// A machine not on the private network has no such address, and a module asking to be reached at
// one is named here rather than left to fail later as a certificate nobody can verify.
func TestAnAddressAMachineDoesNotHaveIsRefused(t *testing.T) {
got, err := Resolve(shelf(authority()), []string{"authority"}, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
if _, err := Declaration(got, mcat.Rendering{}); err == nil {
t.Fatal("an address the machine does not have was substituted rather than refused")
} else if !strings.Contains(err.Error(), "at") {
t.Errorf("the refusal does not name what was asked for: %v", err)
}
}
// A module that must give software the machine's ADDRESS rather than its name — a resolver pointing
// the container runtime at itself, whose list of resolvers cannot be a name — says
// ${machine:address}, and gets what the machine's name resolves to on the private network: the same
// address every other machine's hosts file carries for it.
func TestAModuleNamesTheAddressBehindItsMachinesName(t *testing.T) {
pointing := mcat.Manifest{Module: "pointing", Version: "1", Resources: []map[string]any{{
"id": "runtime", "type": "file", "path": "/etc/runtime.json",
"content": `{"dns":["${machine:address}"]}`,
}}}
got, err := Resolve(shelf(pointing), []string{"pointing"}, anchored(), mcat.World{})
if err != nil {
t.Fatal(err)
}
out, err := Declaration(got, mcat.Rendering{Names: map[string]string{
"workstation.internal": "10.42.0.7", "anchor.internal": "10.42.0.1"}})
if err != nil {
t.Fatal(err)
}
if content := plainly(out[0]["content"]); content != `{"dns":["10.42.0.7"]}` {
t.Fatalf("the machine's address was not the one its name resolves to: %q", content)
}
// Off the network there is no such address, and the refusal says what the machine does have —
// rather than a placeholder written into the runtime's file and read as an address.
got, err = Resolve(shelf(pointing), []string{"pointing"}, workstation(), mcat.World{})
if err != nil {
t.Fatal(err)
}
if _, err := Declaration(got, mcat.Rendering{}); err == nil || !strings.Contains(err.Error(), "${machine:address}") {
t.Fatalf("a machine off the network was given an address, or refused for another reason: %v", err)
}
}
// The mesh's private range is offered as ${machine:mesh-range}, so a module names it rather than
// hardcoding a value it cannot know (novox/hq ADR 0112) — the fail2ban ignoreip is the case.
func TestAModuleNamesTheMeshRange(t *testing.T) {
facts := machineFacts(mcat.Resolution{Node: "anchor", At: "anchor.internal"}, map[string]string{"anchor.internal": "10.10.0.1"}, "10.10.0.0/24")
if facts["mesh-range"] != "10.10.0.0/24" {
t.Fatalf("the mesh range is not a machine fact: %v", facts)
}
res := map[string]any{"type": "file", "id": "jail", "content": "ignoreip = 127.0.0.1/8 ${machine:mesh-range}\n"}
if err := machineInto(res, facts, "fail2ban"); err != nil {
t.Fatal(err)
}
if got := res["content"].(string); !strings.Contains(got, "10.10.0.0/24") || strings.Contains(got, "${machine:") {
t.Fatalf("the mesh range was not written in: %q", got)
}
// A mesh with no range gives no such fact, and a file that names it is refused rather than
// left with a literal placeholder in it.
none := machineFacts(mcat.Resolution{Node: "anchor"}, nil, "")
if _, has := none["mesh-range"]; has {
t.Fatal("a mesh with no range still offered one")
}
}
+23
View File
@@ -0,0 +1,23 @@
package resolution
import (
"sort"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
// OffersIssuedOutside is whether any module of a catalogue offers the provision with a credential issued outside
// the mesh (novox/hq ADR 0337), and the first such module by name.
func OffersIssuedOutside(shelf map[string]mcat.Manifest, provision string) (string, bool) {
var names []string
for name, m := range shelf {
if m.IssuedOutsideOf(provision) {
names = append(names, name)
}
}
if len(names) == 0 {
return "", false
}
sort.Strings(names)
return names[0], true
}
+56
View File
@@ -0,0 +1,56 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
func containersOf(t *testing.T, r mcat.Resolution, with mcat.Rendering) []map[string]any {
t.Helper()
out, err := Declaration(r, with)
if err != nil {
t.Fatal(err)
}
var found []map[string]any
for _, res := range out {
if res["type"] == "container" {
found = append(found, res)
}
}
return found
}
func namesOf(r map[string]any) []string {
var out []string
if given, ok := r["hosts"].([]any); ok {
for _, h := range given {
out = append(out, h.(string))
}
}
return out
}
// No resource is given a `hosts` key it did not declare. A file or a service carrying one is a
// declaration the host refuses outright — it takes no unknown field — so an invented key breaks
// the whole machine rather than one resource.
func TestNothingIsGivenNamesItDidNotDeclare(t *testing.T) {
out, err := Declaration(mcat.Resolution{Node: "laptop", Modules: []mcat.Manifest{{
Module: "app",
Resources: []map[string]any{
{"id": "conf", "type": "file", "path": "/etc/app.conf", "content": "x", "mode": "0644"},
{"id": "run", "type": "service", "unit": "app.service", "state": "running"},
{"id": "web", "type": "container", "name": "web",
"image": "registry.example/web@sha256:" + strings.Repeat("a", 64)},
},
}}}, mcat.Rendering{Names: map[string]string{"anchor.internal": "10.42.0.1"}})
if err != nil {
t.Fatal(err)
}
for _, r := range out {
if _, given := r["hosts"]; given {
t.Fatalf("a %v was given names it never declared: %v", r["type"], r)
}
}
}
+79
View File
@@ -0,0 +1,79 @@
package resolution
import (
"strings"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
)
func needy() mcat.Manifest {
return mcat.Manifest{
Module: "postgres", Version: "1",
OwnSecrets: mcat.OwnSecrets{"superuser": {Path: "/var/lib/mesh/postgres/superuser"}},
Resources: []map[string]any{
{"id": "store", "type": "container", "name": "mesh-postgres", "image": "postgres@sha256:x"},
},
}
}
func TestAModulesOwnSecretLandsSealed(t *testing.T) {
got, err := Resolve(shelf(needy()), []string{"postgres"}, reachable(), mcat.World{})
if err != nil {
t.Fatal(err)
}
out, err := Declaration(got, mcat.Rendering{
Needed: map[string]map[string]string{"postgres": {"superuser": "c2VhbGVk"}},
})
if err != nil {
t.Fatal(err)
}
for _, r := range out {
if r["path"] != "/var/lib/mesh/postgres/superuser" {
continue
}
if r["sealed"] != "c2VhbGVk" {
t.Fatalf("got %v", r)
}
if r["content"] != nil {
t.Fatal("a module's own secret was written in the clear")
}
return
}
t.Fatalf("no secret was written: %v", out)
}
func TestADeclaredNeedThatWasNotMadeIsRefused(t *testing.T) {
// Skipping it would start a database with no password it knows, which fails to authenticate
// three layers from the machine reporting it.
got, _ := Resolve(shelf(needy()), []string{"postgres"}, reachable(), mcat.World{})
_, err := Declaration(got, mcat.Rendering{})
if err == nil {
t.Fatal("a module needing a secret was declared without one")
}
if !strings.Contains(err.Error(), "superuser") {
t.Fatalf("the refusal does not name what is missing: %v", err)
}
}
func TestAModuleMayNeedSeveralThings(t *testing.T) {
// A password and a token, say. Telling them apart is the module's business, not the mesh's.
m := needy()
m.OwnSecrets["replication"] = mcat.OwnSecret{Path: "/var/lib/mesh/postgres/replication"}
got, _ := Resolve(shelf(m), []string{"postgres"}, reachable(), mcat.World{})
out, err := Declaration(got, mcat.Rendering{Needed: map[string]map[string]string{
"postgres": {"superuser": "b25l", "replication": "dHdv"},
}})
if err != nil {
t.Fatal(err)
}
seen := map[string]string{}
for _, r := range out {
if path, ok := r["path"].(string); ok && strings.Contains(path, "/var/lib/mesh/postgres/") {
seen[path], _ = r["sealed"].(string)
}
}
if len(seen) != 2 || seen["/var/lib/mesh/postgres/superuser"] == seen["/var/lib/mesh/postgres/replication"] {
t.Fatalf("two needs did not land as two secrets: %v", seen)
}
}
@@ -0,0 +1,41 @@
package resolution
import (
"encoding/json"
"os"
"path/filepath"
"testing"
mcat "git.novox.be/novox/mesh-sdk/go/catalogue"
"github.com/novox/mesh-host/internal/beside"
)
// theCatalogue is every manifest of the catalogue internal/beside finds, parsed the way registration
// parses one.
func theCatalogue(t *testing.T) []mcat.Manifest {
t.Helper()
root := beside.Catalogue(t)
entries, err := os.ReadDir(root)
if err != nil {
t.Fatal(err)
}
var out []mcat.Manifest
for _, e := range entries {
if !e.IsDir() {
continue
}
raw, err := os.ReadFile(filepath.Join(root, e.Name(), "module.json"))
if err != nil {
continue
}
var m mcat.Manifest
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("%s: %v", e.Name(), err)
}
out = append(out, m)
}
if len(out) == 0 {
t.Fatalf("no manifests under %s, so this proved nothing", root)
}
return out
}

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