catalogue: the composition's rules and refusals leave for the node-engine, so mesh-sdk renders no declaration and refuses no composition (ADR 0593 §7)
mesh/check failed: unit: git.novox.be/novox/mesh-sdk/go/process.TestStatePutWritesAKeyWhole: a Put through a short blackout: 0, mesh-mail's state send…
mesh/delivery-group group feat/sdk1 rejected: composed together: the checker's verdict at the members' heads (428d048dd50a): unit: git.novox.be/novox/mesh-sdk/…
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: unit: git.novox.be/novox/mesh-sdk/go/process.TestStatePutWritesAKeyWhole: a Put through a short blackout: 0, mesh-mail's state send…
mesh/delivery-group group feat/sdk1 rejected: composed together: the checker's verdict at the members' heads (428d048dd50a): unit: git.novox.be/novox/mesh-sdk/…
mesh/delivery closed: a newer head of the same pull request
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -42,17 +41,6 @@ func aServiceModule(t *testing.T) Manifest {
|
||||
return resolved
|
||||
}
|
||||
|
||||
func composeTheService(t *testing.T, with Rendering) []map[string]any {
|
||||
t.Helper()
|
||||
with.Needed = map[string]map[string]string{"svc": {"store": "sealed-store"}}
|
||||
with.ArtifactStore = "anchor.internal:5100"
|
||||
out, err := Resolution{Node: "anchor", Modules: []Manifest{aServiceModule(t)}}.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatalf("the service does not compose: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func indexOf(out []map[string]any, id string) int {
|
||||
for i, r := range out {
|
||||
if r["id"] == id {
|
||||
@@ -87,60 +75,3 @@ func TestABundleItsOwnProcessRunsIsNotServedByTheRuntime(t *testing.T) {
|
||||
t.Fatalf("a tools bundle nothing runs is no longer served: %v", loads)
|
||||
}
|
||||
}
|
||||
|
||||
// The account is created before anything is given to it. Its secrets are mesh-computed and so
|
||||
// placed before the module's own resources; given to a user the machine did not have yet, they were
|
||||
// refused on the first apply and the process started without them.
|
||||
func TestAModulesAccountComesBeforeWhatBelongsToIt(t *testing.T) {
|
||||
out := composeTheService(t, Rendering{})
|
||||
account, secret := indexOf(out, "svc.account"), indexOf(out, "svc."+NeedID("store"))
|
||||
if account < 0 || secret < 0 {
|
||||
t.Fatalf("the account or the secret is missing: %v", out)
|
||||
}
|
||||
if account > secret {
|
||||
t.Fatalf("the secret owned by svc is written before svc exists: account at %d, secret at %d",
|
||||
account, secret)
|
||||
}
|
||||
if owner := out[secret]["owner"]; owner != "svc" {
|
||||
t.Errorf("the secret belongs to %v, not the account its process runs as", owner)
|
||||
}
|
||||
}
|
||||
|
||||
// The process is the module's program; its preparation is the same program asked to prepare, as a
|
||||
// step before it — with the same account and environment, and handing nothing over.
|
||||
func TestAProcessIsPreparedByItsOwnProgram(t *testing.T) {
|
||||
out := composeTheService(t, Rendering{})
|
||||
step, process := indexOf(out, "svc.service-prepare"), indexOf(out, "svc.service")
|
||||
if step < 0 || process < 0 || step > process {
|
||||
t.Fatalf("the preparation is not a step before the process (%d, %d): %v", step, process, out)
|
||||
}
|
||||
s := out[step]
|
||||
if s["type"] != "process" || s["run-once"] != true || s["name"] != "svc-prepare" {
|
||||
t.Errorf("the preparation is not a run-once process: %v", s)
|
||||
}
|
||||
if run, _ := json.Marshal(s["run"]); string(run) != `["./svc","prepare"]` {
|
||||
t.Errorf("the preparation runs %s", run)
|
||||
}
|
||||
if s["user"] != "svc" || s["source"] != out[process]["source"] {
|
||||
t.Errorf("the preparation does not run the same bundle as the same account: %v", s)
|
||||
}
|
||||
if env, _ := s["env"].(map[string]any); env["SVC_STORE_FILE"] == nil {
|
||||
t.Errorf("the preparation is not given the process's environment: %v", s["env"])
|
||||
}
|
||||
if _, has := s["replaces"]; has {
|
||||
t.Errorf("the preparation would hand over what the process replaces: %v", s)
|
||||
}
|
||||
if _, has := s["args"]; has {
|
||||
t.Errorf("the preparation carries a container's args: %v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// What the process replaces is named as the host recorded it, `<module>.<id>`; unprefixed, the host
|
||||
// matches nothing and removes the container first, as before.
|
||||
func TestWhatAProcessReplacesIsNamedAsTheHostRecordedIt(t *testing.T) {
|
||||
out := composeTheService(t, Rendering{})
|
||||
p := out[indexOf(out, "svc.service")]
|
||||
if got, _ := json.Marshal(p["replaces"]); string(got) != `["svc.server"]` {
|
||||
t.Fatalf("the process replaces %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 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 = []Access{{Path: "/services/media/downloads", Mode: AccessReadWrite}}
|
||||
b := mod("b", nil, nil, nil)
|
||||
b.Accesses = []Access{{Path: "/services/media/downloads", Mode: AccessRead}}
|
||||
|
||||
got, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), 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(), 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 = []Access{{Path: "/services/media/movies", Mode: AccessRead}}
|
||||
|
||||
_, err := Resolve(shelf(owns, uses), []string{"owns", "uses"}, workstation(), 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)
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@ package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -32,87 +30,9 @@ const (
|
||||
GuardUnitPath = "/etc/systemd/system/" + GuardUnit
|
||||
)
|
||||
|
||||
// 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 AdoptionPrefix + "guard" }
|
||||
func GuardUnitID() string { return AdoptionPrefix + "guard-unit" }
|
||||
func GuardRunningID() string { return 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 AdoptionPrefix + "guard-package" }
|
||||
|
||||
// GuardPackage is the package that carries nft.
|
||||
const GuardPackage = "nftables"
|
||||
|
||||
// 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", 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 []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 != OpeningFromEverywhere {
|
||||
from[k] = f
|
||||
}
|
||||
}
|
||||
for _, rule := range rules {
|
||||
switch rule.From {
|
||||
case FromEverywhere:
|
||||
widen(key{rule.Protocol, rule.Port}, OpeningFromEverywhere)
|
||||
case FromMesh:
|
||||
widen(key{rule.Protocol, rule.Port}, OpeningFromMesh)
|
||||
case 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}, OpeningFromMesh)
|
||||
}
|
||||
}
|
||||
for _, port := range foundation {
|
||||
widen(key{"tcp", port}, 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, PathForwarded)
|
||||
opening["path"] = PathForwarded
|
||||
opening["to"] = to
|
||||
} else {
|
||||
opening["id"] = OpeningID(k.protocol, k.port, PathIncoming)
|
||||
opening["path"] = PathIncoming
|
||||
}
|
||||
out = append(out, opening)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Published is every port the given containers publish on the machine, by protocol and machine
|
||||
// port, mapped to the container's own port. A mapping bound to loopback is left out: nothing off
|
||||
// the machine reaches it, forwarded or not.
|
||||
@@ -146,90 +66,3 @@ func Published(resources []map[string]any) map[string]map[int]int {
|
||||
}
|
||||
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 " + GuardPath + "\n" +
|
||||
"ExecReload=nft -f " + 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": GuardPackage},
|
||||
{"id": GuardID(), "type": "file", "path": GuardPath, "content": AsGuard(ports),
|
||||
"mode": "0644"},
|
||||
{"id": GuardUnitID(), "type": "file", "path": GuardUnitPath, "content": GuardUnitText(),
|
||||
"mode": "0644"},
|
||||
{"id": GuardRunningID(), "type": "service", "unit": GuardUnit, "state": "running",
|
||||
"boot": "enabled", "restart-on": []any{GuardUnitID()}, "reload-on": []any{GuardID()}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -15,14 +12,6 @@ import (
|
||||
// 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) ([]Listening, error) {
|
||||
return []Listening{{Port: 51820, Protocol: "udp", From: 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() Resolution {
|
||||
@@ -51,24 +40,6 @@ func anAdoptedAnchor() Resolution {
|
||||
}}
|
||||
}
|
||||
|
||||
func anchorRendering(adopted bool) Rendering {
|
||||
return Rendering{
|
||||
Generators: map[string]Generator{"overlay": hub{}},
|
||||
Ports: map[string]map[int]int{"distribution": {5000: 5000}, "hello-web": {8080: 20001}},
|
||||
Settings: SettingsBy{"distribution": {{From: "node anchor",
|
||||
Placement: map[string]any{ExposeSetting: map[string]any{"5000": 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 {
|
||||
@@ -77,279 +48,6 @@ func byID(resources []map[string]any) map[string]map[string]any {
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAnAdoptedNodeIsDeclaredOpeningsFromTheSameInputsAsTheFilter(t *testing.T) {
|
||||
composed, err := anAdoptedAnchor().Compose(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 := anAdoptedAnchor().Compose(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, AdoptionPrefix) {
|
||||
t.Fatalf("%s is owned by %s", id, module)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAConvergedNodeIsDeclaredItsFilterAndNoOpenings(t *testing.T) {
|
||||
composed, err := anAdoptedAnchor().Compose(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, AdoptionPrefix) {
|
||||
t.Fatalf("a converged node is declared %s", id)
|
||||
}
|
||||
}
|
||||
plain, err := anAdoptedAnchor().Declaration(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 := anAdoptedAnchor().Compose(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 Rendering) string {
|
||||
t.Helper()
|
||||
composed, err := anAdoptedAnchor().Compose(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)
|
||||
}
|
||||
}
|
||||
|
||||
// A mapping bound to loopback is not published to anything off the machine — in either address
|
||||
// family — and an address's own colons never shift the ports.
|
||||
func TestPublishedLeavesOutLoopbackInBothFamilies(t *testing.T) {
|
||||
@@ -361,282 +59,3 @@ func TestPublishedLeavesOutLoopbackInBothFamilies(t *testing.T) {
|
||||
t.Fatalf("published is %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 := Manifest{Module: "daemon",
|
||||
Listens: []Listening{{Port: 9000, From: FromMesh}}, Guards: []int{9000}}
|
||||
layers := []Layer{{From: "node anchor",
|
||||
Placement: map[string]any{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"] = []Layer{{From: "node anchor",
|
||||
Placement: map[string]any{ExposeSetting: map[string]any{"5432": FromEverywhere}}}}
|
||||
composed, err := anAdoptedAnchor().Compose(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := byID(composed.Resources)
|
||||
if o := got["adoption.opening-tcp-5432-forwarded"]; o == nil || o["from"] != 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() Manifest {
|
||||
return Manifest{Module: "forge",
|
||||
Provides: []Offer{{Name: "git-over-ssh", Scope: ScopeMesh}},
|
||||
Serves: map[string]map[string]any{"git-over-ssh": {"port": 2222}},
|
||||
Listens: []Listening{{Port: 3000, From: FromMesh}, {Port: 2222, From: 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 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) []Layer {
|
||||
return []Layer{{From: "node anchor", Placement: map[string]any{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 := Manifest{Module: "gallery",
|
||||
Listens: []Listening{{Port: 80, From: 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 := Manifest{Module: "chain",
|
||||
Listens: []Listening{{Port: 80, From: FromMesh}, {Port: 9090, From: 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, []Layer{{From: "node anchor",
|
||||
Placement: map[string]any{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 := Resolution{Node: "anchor", Modules: []Manifest{forge}}
|
||||
with := 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 := r.Compose(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 := r.Rules(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, PathForwarded)]
|
||||
if opening == nil || opening["to"] != 22 || opening["from"] != 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 := Resolution{Node: "anchor", Modules: []Manifest{forge}}
|
||||
composed, err := r.Compose(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, PathForwarded)]
|
||||
if opening == nil || opening["to"] != 22 {
|
||||
t.Fatalf("no opening for the forge's published ssh port: %v", keys(got))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The account agents run as (novox/hq ADR 0266): a module names it as a machine fact — the agent account
|
||||
// where the node names one, the operator's otherwise — and asks the node-engine to judge it never to become
|
||||
// root only where it is the agents' own.
|
||||
|
||||
func TestTheAgentAccountFactFallsBackToTheOperatorAndIsNeverRootOnlyWhenItsOwn(t *testing.T) {
|
||||
facts := machineFacts(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(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(Resolution{Node: "anchor", Account: "ops", AgentAccount: "agent"}, nil, "")
|
||||
if facts["agent-account"] != "agent" || facts["agent-home"] != "/home/agent" || facts["agent-root"] != 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(Resolution{Node: "anchor", AgentAccount: "agent", AgentAccountHome: "/var/lib/agent"}, nil, "")
|
||||
if facts["agent-home"] != "/var/lib/agent" || facts["agent-root"] != RootNever {
|
||||
t.Errorf("an agent account with a stated home on a machine with no operator: %v", facts)
|
||||
}
|
||||
if _, has := machineFacts(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 Resolution, with Rendering) (user, home map[string]any) {
|
||||
t.Helper()
|
||||
r.Node, r.Modules = "anchor", []Manifest{m}
|
||||
out, err := r.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return fileNamed(out, "agent.account"), fileNamed(out, "agent.home")
|
||||
}
|
||||
|
||||
user, home := compose(Resolution{Account: "ops", AgentAccount: "agent"}, Rendering{JudgesRoot: true})
|
||||
if user["name"] != "agent" || user[RootField] != 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(Resolution{Account: "ops", AgentAccount: "agent", AgentAccountHome: "/srv/agent"}, 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(Resolution{Account: "ops", AgentAccount: "agent"}, Rendering{})
|
||||
if _, sent := user[RootField]; sent || user["name"] != "agent" {
|
||||
t.Errorf("an older engine, which parses strictly, is sent root: %v", user)
|
||||
}
|
||||
|
||||
user, home = compose(Resolution{Account: "ops"}, Rendering{JudgesRoot: true})
|
||||
if _, sent := user[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)
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 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(), 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 := World{Held: []Held{{Claim: "mesh-artifact-store", Scope: 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)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -57,60 +56,3 @@ func Routed(reference, address string) string {
|
||||
}
|
||||
return address + "/" + path
|
||||
}
|
||||
|
||||
// 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 Rendering) error {
|
||||
for _, key := range []string{"image", "source"} {
|
||||
written, ok := resource[key].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, kept := 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, ArtifactStoreProvision)
|
||||
}
|
||||
resource[key] = Routed(written, with.ArtifactStore)
|
||||
continue
|
||||
}
|
||||
if with.ArtifactStore == "" {
|
||||
continue
|
||||
}
|
||||
recorded := Recorded(written)
|
||||
if recorded == written {
|
||||
continue
|
||||
}
|
||||
path, _ := 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] = Routed(recorded, with.ArtifactStore)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -27,95 +27,3 @@ func TestAReferenceIsRecordedWithoutTheStoresAddress(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **The address is composed into a declaration, and the record never carries it.**
|
||||
func TestAnImageTheMeshBuiltIsRoutedThroughTheStoreWhenDeclared(t *testing.T) {
|
||||
m := 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: &Build{Artifacts: []Artifact{
|
||||
{Name: "server", Kind: ArtifactImage, From: "Dockerfile"},
|
||||
{Name: "config", Kind: ArtifactArchive, From: "config"},
|
||||
}}}
|
||||
resolved, err := m.Resolve([]Built{
|
||||
{Name: "server", Kind: ArtifactImage, Reference: ArtifactStoreScheme + "gitea/server@" + digest},
|
||||
{Name: "config", Kind: ArtifactArchive, Reference: ArtifactStoreScheme + "gitea/config/blobs/" + digest, Digest: digest},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{resolved}}
|
||||
|
||||
out, err := r.Declaration(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 != 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 = r.Declaration(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 := Manifest{Module: "gitea", Version: "1", Resources: []map[string]any{
|
||||
{"id": "server", "type": "container", "name": "mesh-gitea",
|
||||
"image": ArtifactStoreScheme + "gitea/server@" + digest},
|
||||
}}
|
||||
_, err := Resolution{Node: "anchor", Modules: []Manifest{m}}.Declaration(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 := 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 := Resolution{Node: "anchor", Modules: []Manifest{m}}
|
||||
out, err := r.Declaration(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 = r.Declaration(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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +1,4 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
// BackupNamespace is the placeholder kind of the machine's backup lines and data.
|
||||
const BackupNamespace = "backup"
|
||||
|
||||
// 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": BackupKindBackup, "data": BackupKindData}
|
||||
|
||||
// backupPlacer is the machine's modules the backup placement is composed over, as the old contributions are.
|
||||
type backupPlacer struct {
|
||||
modules []Manifest
|
||||
with 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 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(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, BackupSeat)
|
||||
}
|
||||
text, left, err := seatContributions(b.modules, holder, 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, ""
|
||||
}
|
||||
|
||||
@@ -1,126 +1,10 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A machine's backup lines are placement (design 57 L3, issue 794): the holder of node-backup names them as
|
||||
// `${backup:lines}` and `${backup:data}`, which a handed holder has its client fill from the plan and an unhanded
|
||||
// one has the controller fill as it fills `${contribution:node-backup:backup}` and `${...:data}`: the same text.
|
||||
|
||||
const (
|
||||
oldBackupText = "# lines\n${contribution:node-backup:backup}"
|
||||
oldDataText = "# data\n${contribution:node-backup:data}"
|
||||
placedBackupTxt = "# lines\n${backup:lines}"
|
||||
placedDataTxt = "# data\n${backup:data}"
|
||||
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) Resolution {
|
||||
t.Helper()
|
||||
all := []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(), 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 Rendering) 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 := []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(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old.Account = "op"
|
||||
wantLines, wantData := fileOf(t, old, gammaLeftOut(Rendering{}), "restic.declared"), fileOf(t, old, gammaLeftOut(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(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(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 TestAModuleAddedOrTakenAwayChangesTheBackupPlan(t *testing.T) {
|
||||
plan := func(modules ...string) map[string]string {
|
||||
c, _ := composeWith(t, backupsOn(t, placedBackupTxt, placedDataTxt, modules...), handingTo("store:5000"))
|
||||
return c.Plans["restic"].Placement
|
||||
}
|
||||
two, three := plan("alpha", "beta"), plan("alpha", "beta", "gamma")
|
||||
if reflect.DeepEqual(two, three) || strings.Contains(two["backup:lines"], "gamma") || !strings.Contains(three["backup:lines"], "# gamma\n") {
|
||||
t.Errorf("two modules give %v, three give %v", two, three)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnknownBackupKeyIsRefused(t *testing.T) {
|
||||
r := backupsOn(t, "${backup:rows}", placedDataTxt, backupModules...)
|
||||
if _, err := r.Compose(handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "backup:rows") {
|
||||
t.Errorf("handed, an unknown key is %v", err)
|
||||
}
|
||||
if _, err := r.Declaration(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 Resolution, with Rendering, id string) string {
|
||||
t.Helper()
|
||||
out, err := r.Declaration(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 ""
|
||||
}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 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([]Manifest{pg, mail}, Manifest{}, BackupSeat+":"+"backup", 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)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -25,99 +24,6 @@ func (k KeptBinding) String() string {
|
||||
k.Consumer, k.Provision, k.Bound, k.Would, k.Bound, k.Machine, k.Provision, k.Would.Node, k.Would.Module)
|
||||
}
|
||||
|
||||
// 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 []Needed, catalogue map[string]Manifest, node Node, world World,
|
||||
keeps map[string]bool, here func(string) bool) ([]Needed, []KeptBinding, []string) {
|
||||
var kept []KeptBinding
|
||||
var problems []string
|
||||
refused := map[[2]string]bool{}
|
||||
out := make([]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 := 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 && pin.matches(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, 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 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 Needed, bound Chosen, catalogue map[string]Manifest, node Node, world World,
|
||||
here func(string) bool) (Needed, bool, string) {
|
||||
held := 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, ScopeMesh) {
|
||||
return 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 := bound.among(world.Offered[n.Name])
|
||||
if len(matching) != 1 {
|
||||
return Needed{}, false, fmt.Sprintf("%s no longer provides it", bound)
|
||||
}
|
||||
p := matching[0]
|
||||
if node.At == "" || p.At == "" {
|
||||
return Needed{}, false, fmt.Sprintf("%s and %s are not both on the private network", node.Name, p.Node)
|
||||
}
|
||||
identity := 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, ""
|
||||
}
|
||||
|
||||
// Unbound is one consumer that still asks for a provision and whose own resolution binds it to
|
||||
// another provider than the one a pair credential on record was made with (novox/hq issue 274).
|
||||
//
|
||||
@@ -153,19 +59,3 @@ func (u Unbound) String() string {
|
||||
"login and keeps its data until `cleanup delete` (ADR 0230); the credential from %s stays on "+
|
||||
"record while that login does", who, u.Consumer, now, u.Provision, u.Provider, u.Provider, u.Provider)
|
||||
}
|
||||
|
||||
// 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 (r Resolution) BindsFrom(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
|
||||
}
|
||||
|
||||
@@ -1,171 +1,5 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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 Manifest, needs []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 := ConsumerIdentity(node, 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"
|
||||
|
||||
@@ -1,217 +1,10 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 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 := Resolution{
|
||||
Node: "workstation",
|
||||
Modules: []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: []Needed{{
|
||||
Name: "postgres-database", From: "anchor", At: "anchor.internal", For: "keycloak",
|
||||
Sealed: "sealed-db", Serves: map[string]any{"port": float64(5432)},
|
||||
}},
|
||||
}
|
||||
out, err := r.Declaration(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 := Manifest{
|
||||
Module: "postgres", Version: "1",
|
||||
Provides: FromAnywhere("postgres-database"),
|
||||
Listens: []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 := 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(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The mesh assigned the container's 5432 to 15432 on this machine.
|
||||
out, err := r.Declaration(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(Manifest{
|
||||
Module: "postgres", Version: "1", Provides: 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(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := provider.Declaration(Rendering{Grants: []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 ConsumerIdentity("workstation", "keycloak") != "mesh_workstation_keycloak" {
|
||||
t.Errorf("got %q", ConsumerIdentity("workstation", "keycloak"))
|
||||
}
|
||||
}
|
||||
|
||||
// A binding the module does not have is a typo, refused where it was written.
|
||||
func TestAFileCannotNameABindingTheModuleDoesNotHave(t *testing.T) {
|
||||
r := Resolution{
|
||||
Node: "workstation",
|
||||
Modules: []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: []Needed{{Name: "postgres-database", From: "anchor", At: "a", For: "keycloak"}},
|
||||
}
|
||||
_, err := r.Declaration(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 := Resolution{
|
||||
Node: "workstation",
|
||||
Modules: []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: []Needed{{
|
||||
Name: "postgres-database", From: "anchor", At: "a", For: "keycloak",
|
||||
Serves: map[string]any{"port": float64(5432)},
|
||||
}},
|
||||
}
|
||||
_, err := r.Declaration(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 name too long to stay distinct is refused before anything is created.
|
||||
func TestAnIdentityTooLongToBeDistinctIsRefused(t *testing.T) {
|
||||
if err := CheckIdentity("anchor", "keycloak"); err != nil {
|
||||
@@ -232,34 +25,3 @@ func TestTwoModulesOnOneNodeAreTwoIdentities(t *testing.T) {
|
||||
t.Fatal("one module on two machines shares an identity")
|
||||
}
|
||||
}
|
||||
|
||||
// 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() Resolution {
|
||||
return Resolution{
|
||||
Node: "workstation",
|
||||
Modules: []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: []Needed{{Name: "wildcard-resolution", From: "anchor", At: "anchor.internal", For: "resolv-conf"}},
|
||||
}
|
||||
}
|
||||
out, err := consumer().Declaration(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 := consumer().Declaration(Rendering{}); err == nil {
|
||||
t.Fatal("a file naming an address the mesh does not have was composed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,200 +14,6 @@ import (
|
||||
// before this machine's own provider — re-bound all five to the holder, each was made a fresh, empty
|
||||
// database there, and nothing said so.
|
||||
|
||||
var incidentConsumers = []string{"board", "listings", "workflows", "agents", "game"}
|
||||
|
||||
func storeMesh() map[string]Manifest {
|
||||
shelf := map[string]Manifest{
|
||||
"store": {Module: "store", Version: "1",
|
||||
Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh}},
|
||||
Claims: []Claim{{Name: "mesh-store", Scope: ScopeMesh}},
|
||||
Serves: map[string]map[string]any{"postgres-database": {"port": 5432}},
|
||||
Grants: map[string]string{"postgres-database": "/grants"}},
|
||||
"resolver": {Module: "resolver", Version: "1",
|
||||
Provides: []Offer{{Name: "wildcard-resolution", Scope: ScopeMesh}},
|
||||
Claims: []Claim{{Name: "mesh-dns-resolver", Scope: ScopeMesh}}},
|
||||
"network": {Module: "network", Version: "1", Requires: []string{"wildcard-resolution"}},
|
||||
}
|
||||
for _, c := range incidentConsumers {
|
||||
shelf[c] = 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() World {
|
||||
w := World{Offered: map[string][]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 = []Held{
|
||||
{Claim: "mesh-store", Scope: ScopeMesh, Node: "anchor", Module: "store"},
|
||||
{Claim: "mesh-dns-resolver", Scope: ScopeMesh, Node: "anchor", Module: "resolver"},
|
||||
}
|
||||
w.Holdings = w.Held
|
||||
return w
|
||||
}
|
||||
|
||||
var home = Node{Name: "home", At: "home.internal"}
|
||||
|
||||
func homeAssigned() []string {
|
||||
return append([]string{"store", "resolver", "network"}, incidentConsumers...)
|
||||
}
|
||||
|
||||
func boundFrom(t *testing.T, r Resolution, consumer, provision string) 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 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]Chosen{}
|
||||
for _, c := range incidentConsumers {
|
||||
w.Bound[c] = map[string]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]Chosen{"postgres-database": {Node: "anchor", Module: "store"}}
|
||||
w.Bound = map[string]map[string]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) World {
|
||||
w := storeWorld()
|
||||
w.Held = []Held{{Claim: "mesh-store", Scope: ScopeMesh, Node: holder, Module: "store"}}
|
||||
w.Holdings = w.Held
|
||||
return w
|
||||
}
|
||||
|
||||
var laptop = Node{Name: "laptop", At: "laptop.internal"}
|
||||
|
||||
func TestABoundConsumerStaysWhenTheSeatsHolderChanges(t *testing.T) {
|
||||
w := laptopWorld("home")
|
||||
w.Bound = map[string]map[string]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 != (Chosen{"anchor", "store"}) || k.Would != (Chosen{"home", "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]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]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]Chosen{"board": {"postgres-database": {Node: "gone", Module: "store"}}}
|
||||
if _, err := Resolve(storeMesh(), []string{"board"}, laptop, w); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnOfferSaysWhetherItKeepsConsumerData(t *testing.T) {
|
||||
var o Offer
|
||||
if err := json.Unmarshal([]byte(`{"name":"wildcard-resolution","scope":"mesh","keeps-consumer-data":false}`), &o); err != nil {
|
||||
@@ -238,20 +44,3 @@ func TestAnOfferSaysWhetherItKeepsConsumerData(t *testing.T) {
|
||||
t.Error("a name any provider says keeps data keeps data")
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = []Offer{{Name: "postgres-database", Scope: 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +1,9 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 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(), World{}); err != nil {
|
||||
t.Fatalf("the bus alone does not resolve: %v", err)
|
||||
}
|
||||
|
||||
elsewhere := World{Held: []Held{{Claim: "mesh-broker", Scope: 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 := World{Held: []Held{{Claim: "mesh-broker", Scope: 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")
|
||||
}
|
||||
}
|
||||
|
||||
// The old broker is gone from the catalogue (novox/hq ADR 0131, design 28 task 5.4), so it is no
|
||||
// longer a fixture here. That two eligible holders stand beside each other with one on record is
|
||||
// pinned in holdings_test.go against manifests this package owns.
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -22,155 +20,6 @@ func reachable() Node {
|
||||
return n
|
||||
}
|
||||
|
||||
func onNetwork(nodes ...string) map[string][]Provider {
|
||||
out := make([]Provider, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
out = append(out, Provider{Node: n, At: n + ".internal", Module: "postgres"})
|
||||
}
|
||||
return map[string][]Provider{"postgres-database": out}
|
||||
}
|
||||
|
||||
func brokeredShelf() map[string]Manifest {
|
||||
return shelf(
|
||||
Manifest{Module: "postgres", Version: "1", Provides: FromAnywhere("postgres-database")},
|
||||
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(),
|
||||
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(),
|
||||
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(), 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(),
|
||||
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(),
|
||||
World{
|
||||
Offered: onNetwork("anchor", "archive"),
|
||||
Pinned: map[string]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(),
|
||||
World{
|
||||
Offered: onNetwork("anchor", "archive"),
|
||||
Pinned: map[string]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(),
|
||||
World{
|
||||
Offered: onNetwork("anchor"),
|
||||
Pinned: map[string]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(
|
||||
Manifest{Module: "postgres", Version: "1", Provides: FromAnywhere("postgres-database")},
|
||||
Manifest{Module: "sqlite", Version: "1", Provides: Offers("postgres-database")},
|
||||
Manifest{Module: "meshboard", Version: "1", Requires: []string{"postgres-database"}},
|
||||
), []string{"meshboard"}, workstation(), 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(
|
||||
Manifest{Module: "zsh", Version: "1", Provides: Offers("shell")},
|
||||
Manifest{Module: "tools", Version: "1", Requires: []string{"shell"}},
|
||||
), []string{"tools"}, workstation(), 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 TestTheShortFormStillMeansHere(t *testing.T) {
|
||||
// `"provides": ["shell"]` must keep meaning what it meant, or every existing manifest
|
||||
// silently changes meaning.
|
||||
@@ -184,114 +33,3 @@ func TestTheShortFormStillMeansHere(t *testing.T) {
|
||||
}
|
||||
|
||||
// Being told, which is the difference between knowing and being able to.
|
||||
|
||||
func boundShelf() map[string]Manifest {
|
||||
return shelf(
|
||||
Manifest{Module: "postgres", Version: "1", Provides: FromAnywhere("postgres-database"),
|
||||
Serves: map[string]map[string]any{"postgres-database": {"port": 5432, "driver": "postgres"}}},
|
||||
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(),
|
||||
World{Offered: map[string][]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(),
|
||||
World{Offered: map[string][]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(), // not on the network
|
||||
World{Offered: map[string][]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(),
|
||||
World{Offered: map[string][]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(
|
||||
Manifest{Module: "zsh", Version: "1", Provides: Offers("shell")},
|
||||
Manifest{Module: "tools", Version: "1", Requires: []string{"shell"},
|
||||
Binds: map[string]string{"shell": "/etc/tools/shell.json"}},
|
||||
), []string{"tools"}, reachable(), 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// BucketShare is one entry of `shares` on a module's requirement of `s3-bucket` (novox/hq ADR 0468 §8): who else may
|
||||
@@ -82,43 +81,3 @@ func readRequireShares(raw json.RawMessage) (map[string][]BucketShare, error) {
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// 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 []BucketShare) []BucketShare {
|
||||
out := make([]BucketShare, 0, len(shares))
|
||||
for _, s := range shares {
|
||||
methods := append([]string(nil), s.Methods...)
|
||||
sort.Strings(methods)
|
||||
out = append(out, 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 Manifest, requirement string, values map[string]any) (map[string]any, error) {
|
||||
shares := m.RequireShares[requirement]
|
||||
if _, named := values[SharesValue]; named && requirement == 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[SharesValue] = listed
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -12,40 +12,6 @@ import (
|
||||
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() Manifest {
|
||||
return Manifest{Module: "minio", Version: "1", Provides: Offers("s3-bucket"),
|
||||
Receives: map[string]string{"s3-bucket": "/grants/mesh.json"}}
|
||||
}
|
||||
|
||||
func givenToMinio(t *testing.T, out []map[string]any) []Contribution {
|
||||
t.Helper()
|
||||
for _, r := range out {
|
||||
if r["path"] != "/grants/mesh.json" {
|
||||
continue
|
||||
}
|
||||
var parsed struct {
|
||||
Given []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/AC1: parsed, the shares are on the manifest; stored, `requires` is plain names and the side key holds them.
|
||||
func TestSharesParseAndAreStoredBesideRequires(t *testing.T) {
|
||||
m := mustParse(t, filesWithShares)
|
||||
@@ -104,62 +70,3 @@ func TestAMalformedShareIsRefused(t *testing.T) {
|
||||
t.Errorf("the stored form was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 := Resolution{Node: "laptop", Modules: []Manifest{m}}
|
||||
values, asks, err := remote.ContributionsFrom("s3-bucket", "files", 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(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := provider.Declaration(Rendering{Grants: []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(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err = both.Declaration(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 := Manifest{Module: "files", Requires: []string{"s3-bucket"},
|
||||
Contributes: map[string]map[string]any{"s3-bucket": {"shares": "x"}}}
|
||||
r := Resolution{Node: "laptop", Modules: []Manifest{m}}
|
||||
if _, _, err := r.ContributionsFrom("s3-bucket", "files", SettingsBy{}); err == nil {
|
||||
t.Error("a contributed `shares` reached the grant")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,6 +363,3 @@ func BundleLoads(m Manifest, a Artifact, run map[string]bool) []string {
|
||||
}
|
||||
return loads
|
||||
}
|
||||
|
||||
// RunByAResource is the bundles of m one of its own resources runs (its service, not its tools).
|
||||
func RunByAResource(m Manifest) map[string]bool { return runByAResource(m) }
|
||||
|
||||
@@ -67,18 +67,3 @@ func ReadBusy(r map[string]any) (Busy, bool, []string) {
|
||||
}
|
||||
return b, true, problems
|
||||
}
|
||||
|
||||
// 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 Rendering) {
|
||||
if _, has := resource[BusyField]; !has {
|
||||
return
|
||||
}
|
||||
if !with.ReadsBusy {
|
||||
delete(resource, BusyField)
|
||||
return
|
||||
}
|
||||
b, _, _ := ReadBusy(resource)
|
||||
resource[BusyField] = map[string]any{"command": b.Command, "timeout": b.Timeout.String()}
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A long-running container may say it is busy (novox/hq ADR 0335 §1): `module check` holds the field to its
|
||||
// shape near its author, and it is sent only to a node-engine that reads it, in words the engine's own
|
||||
// validator accepts.
|
||||
|
||||
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[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 Rendering) []map[string]any {
|
||||
out, err := Resolution{Node: "laptop", Modules: []Manifest{m}}.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
older := compose(Rendering{})
|
||||
if b, sent := older[indexOfID(older, "app.agent")][BusyField]; sent {
|
||||
t.Fatalf("busy was sent to an engine that does not read it: %v", b)
|
||||
}
|
||||
newer := compose(Rendering{ReadsBusy: true})
|
||||
got, _ := newer[indexOfID(newer, "app.agent")][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][BusyField].(map[string]any)["timeout"]; has {
|
||||
t.Error("composing wrote the timeout into the catalogue's own manifest")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/internal/beside"
|
||||
@@ -19,28 +18,3 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,44 +1,8 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// readManifest is one manifest of the catalogue as ReadTreeManifest read it.
|
||||
type readManifest struct {
|
||||
path string // the module.json
|
||||
manifest Manifest
|
||||
err error
|
||||
}
|
||||
|
||||
var (
|
||||
readOnce sync.Once
|
||||
readAll []readManifest
|
||||
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 := 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
|
||||
}
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/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 := 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 := Resolution{
|
||||
Node: "workstation",
|
||||
Modules: []Manifest{m},
|
||||
Needs: []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 := r.Declaration(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Chosen is the provider somebody named for a provision: the module, and the node it runs on. Both,
|
||||
// always (novox/hq #258) — a provision comes from a module, and the same module on two machines is
|
||||
// two answers, so neither half alone says which. Module is empty only on a record made before this
|
||||
@@ -20,76 +16,6 @@ func (c Chosen) String() string {
|
||||
return c.Node + "/" + c.Module
|
||||
}
|
||||
|
||||
// matches is whether this provider is the one chosen.
|
||||
func (c Chosen) matches(p 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 (c Chosen) among(where []Provider) []Provider {
|
||||
var out []Provider
|
||||
for _, p := range where {
|
||||
if c.matches(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 Provider) string {
|
||||
return Chosen{Node: p.Node, Module: p.Module}.String()
|
||||
}
|
||||
|
||||
// providerNames is every provider named, sorted, for a refusal to list.
|
||||
func providerNames(where []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]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 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 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 {
|
||||
|
||||
@@ -1,315 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A provider and its consumer on ONE machine, which is what ADR 0066's anchor is.
|
||||
//
|
||||
// **Every fault in this file is the same shape: the same-node path diverging from the cross-node
|
||||
// one.** A provider on another machine is walked by the control plane — its served facts are
|
||||
// re-derived from its manifest with that machine's port assignments and settled with that node's
|
||||
// settings layers — and only then handed to the consumer. A provider on the consumer's OWN machine
|
||||
// never passes through that walk, so every step of it had to be repeated here, and each step that
|
||||
// was not is a promise the co-located arrangement quietly breaks.
|
||||
//
|
||||
// 04-ISSUES/038 was the first of them (the port). These are the rest.
|
||||
|
||||
// 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() Manifest {
|
||||
return Manifest{
|
||||
Module: "step-ca", Version: "1",
|
||||
Provides: FromAnywhere("acme-ca"),
|
||||
Listens: []Listening{{Port: 9000, Protocol: "tcp", From: 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() 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() Manifest {
|
||||
return Manifest{
|
||||
Module: "route-proxy", Version: "1",
|
||||
Requires: []string{"acme-ca"},
|
||||
Provides: 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(), 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 := r.Declaration(Rendering{Settings: 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(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := r.Declaration(Rendering{
|
||||
Settings: 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(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := r.Declaration(Rendering{Settings: 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 := Manifest{
|
||||
Module: "gitea", Version: "1",
|
||||
Listens: []Listening{{Port: 3000, Protocol: "tcp", From: 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(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := r.Declaration(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 []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(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := r.Declaration(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: []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 []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
|
||||
}
|
||||
@@ -3,10 +3,6 @@ package catalogue
|
||||
import (
|
||||
"fmt"
|
||||
"path"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -37,39 +33,9 @@ func ItemKey(collection, name string) string {
|
||||
|
||||
func (it Item) key() string { return ItemKey(it.Collection, it.Name) }
|
||||
|
||||
// order is the item's place among the items of one placeholder: its `order` attribute, or OrderDefault.
|
||||
func (it Item) order() int {
|
||||
if n, err := strconv.Atoi(it.Attributes[OrderAttribute]); err == nil && n >= 0 && n <= 99 {
|
||||
return n
|
||||
}
|
||||
return OrderDefault
|
||||
}
|
||||
|
||||
// OrderAttribute is the attribute that orders items.
|
||||
const OrderAttribute = "order"
|
||||
|
||||
// matches is whether the item's attributes carry every pair of the selector.
|
||||
func (it Item) matches(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 []Item) []Item {
|
||||
out := append([]Item(nil), items...)
|
||||
sort.SliceStable(out, func(a, b int) bool {
|
||||
if oa, ob := out[a].order(), out[b].order(); oa != ob {
|
||||
return oa < ob
|
||||
}
|
||||
return out[a].Name < out[b].Name
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// Collection is one collection a module collects, and the comment its tool writes a line in: each contributor's item
|
||||
// is introduced by `<comment> <contributor>`, and by nothing without one.
|
||||
type Collection struct {
|
||||
@@ -86,120 +52,6 @@ const CollectedNamespace = "collected"
|
||||
// `${collected:environment:systemd}`, which is not a selector.
|
||||
const EnvironmentCollection = "environment"
|
||||
|
||||
// oldCollectionNamespaces are the namespaces read as collections of their own name.
|
||||
var oldCollectionNamespaces = []string{"shell", "environment", "contribution"}
|
||||
|
||||
var (
|
||||
// ofCollected is where a collector places a collection. Loose inside the braces, so a misspelt name is refused.
|
||||
ofCollected = regexp.MustCompile(`\$\{collected:([^}]*)\}`)
|
||||
attributeKey = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
||||
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 == CollectedNamespace && strings.HasPrefix(key, EnvironmentCollection+":"):
|
||||
// The environment is one collection, and the rest of the name picks the rendering (issue 795).
|
||||
return EnvironmentCollection, true
|
||||
case namespace == CollectedNamespace:
|
||||
return key, true
|
||||
case namespace == "environment":
|
||||
return 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 != EnvironmentPOSIX && key != EnvironmentSystemd {
|
||||
return "", true, fmt.Errorf("%s is no format: the environment is environment:%s or environment:%s",
|
||||
name, EnvironmentPOSIX, 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
|
||||
}
|
||||
|
||||
// itemTemplateProblem is what is wrong with an item's template path, or "".
|
||||
func itemTemplateProblem(m Manifest, name string) string {
|
||||
dir := m.TemplatesDir()
|
||||
@@ -210,157 +62,3 @@ func itemTemplateProblem(m Manifest, name string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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 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 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 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 []Manifest, holder 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 || !it.matches(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, it.key(), 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 []Manifest, holder Manifest, collections []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 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(m.allContributions(), func(s 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 == EnvironmentCollection
|
||||
}
|
||||
|
||||
// heldItem is one item of a module rendered the old way.
|
||||
type heldItem struct {
|
||||
item 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 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 || !it.matches(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 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
|
||||
}
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A bar's blocks are a collection of text (design 57 L3, issue 781): the holder of node-bar names the kind as
|
||||
// `${collected:contribution:node-bar:block:bar=<b>,place=<p>}`, which a handed collector has its client fill from the
|
||||
// items and an unhanded one has the controller fill as it fills the old placeholder.
|
||||
|
||||
const (
|
||||
collectedBarsText = "top\n${collected:contribution:node-bar:block:bar=top,place=status}" +
|
||||
"bottom\n${collected:contribution:node-bar:block:bar=bottom,place=status}"
|
||||
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"}]}`
|
||||
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) 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(), 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 := collectedBarsOn(t, nil).Compose(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(BarSeat, BarKindBlock)
|
||||
plain.Shape = &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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheBarsCollectionIsFilledTheOldWayWhereTheCollectorIsNotHanded(t *testing.T) {
|
||||
for _, caps := range []map[string]bool{nil, {"battery": true}} {
|
||||
got := composedFile(t, collectedBarsOn(t, caps), "bars.conf")
|
||||
want := "top\nbottom\n# power\nD workstation\n"
|
||||
if caps["battery"] {
|
||||
want = "top\nbottom\n# power\nB\n# power\nD workstation\n"
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("caps %v: the file is %q, want %q", caps, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheBarsCollectionIsNamedInTheHandedPlan(t *testing.T) {
|
||||
c, _ := composeWith(t, collectedBarsOn(t, map[string]bool{"battery": true}), handingTo("store:5000"))
|
||||
if !reflect.DeepEqual(c.Plans["bars"].Collections, []PlanCollection{{Name: "contribution:node-bar:block", Comment: "#"}}) {
|
||||
t.Fatalf("the plan collects %+v", c.Plans["bars"].Collections)
|
||||
}
|
||||
if n := len(c.Plans["power"].Items); n != 2 {
|
||||
t.Fatalf("the contributor's plan names %d items", n)
|
||||
}
|
||||
}
|
||||
|
||||
// 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(), 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The collectors design 57 L2 moved (issue 681), composed with nothing handed: zsh's rc, triggerhappy's triggers and
|
||||
// power's after-wake moment, from the catalogue's real templates and its moved contributors' real items, are byte
|
||||
// for byte what the controller composed from the old forms before the move (testdata/l2-moved: templates/ and items/
|
||||
// copied from mesh-catalog at the move, want/ composed at the feature branch before it).
|
||||
|
||||
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 []Manifest
|
||||
for _, module := range sortedKeys(items) {
|
||||
m := Manifest{Module: module, Moved: true}
|
||||
for _, collection := range items[module] {
|
||||
m.Items = append(m.Items, Item{Collection: collection,
|
||||
Content: l2Moved(t, "items", module+"."+strings.ReplaceAll(collection, ":", "-"))})
|
||||
}
|
||||
modules = append(modules, m)
|
||||
}
|
||||
for _, c := range []struct{ module, seat string }{{"zsh", LoginShellSeat}, {"triggerhappy", HotkeysSeat},
|
||||
{"power", PowerSeat}} {
|
||||
holder := Manifest{Module: c.module, Moved: true, Claims: []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([]Manifest{holder}, modules...),
|
||||
map[string]string{"name": "g14"}, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,131 +1,14 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// An item may carry attributes and a capability, a module may give several items to one collection, and a
|
||||
// placeholder may select by attributes (design 57 L3, issue 777). The controller leaves out of a plan an item whose
|
||||
// capability the machine lacks, accepts a selecting placeholder where it accepts the collection, and renders a
|
||||
// selected moved item into an old placeholder exactly as the contribution it replaces.
|
||||
|
||||
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"}]}`
|
||||
barsText = "top\n${collected:blocks:bar=top,place=status}bottom\n${collected:blocks:bar=bottom,place=status}${collected:blocks}"
|
||||
barContribute = `{"module":"power","version":"1","moved":true,
|
||||
"items":[{"collection":"blocks","name":"battery","if-capability":"battery","attributes":{"bar":"bottom","place":"status","order":"20"},"template":"files/battery"},
|
||||
{"collection":"blocks","name":"clock","attributes":{"bar":"top","place":"status"},"template":"files/clock"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`
|
||||
)
|
||||
|
||||
func blocksOn(t *testing.T, caps map[string]bool) Composed {
|
||||
t.Helper()
|
||||
bars := held(t, barCollector, builtWith("bars", map[string]string{"bars": barsText}))
|
||||
power := held(t, barContribute, builtWith("power", map[string]string{"battery": "B\n", "clock": "C\n"}))
|
||||
r, err := Resolve(shelf(bars, power), []string{"bars", "power"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account, r.Capabilities = "op", caps
|
||||
c, _ := composeWith(t, r, handingTo("store:5000"))
|
||||
return c
|
||||
}
|
||||
|
||||
func TestAnItemWhoseCapabilityTheMachineLacksIsLeftOutOfItsPlan(t *testing.T) {
|
||||
with := blocksOn(t, map[string]bool{"battery": true}).Plans["power"]
|
||||
want := []PlanItem{
|
||||
{Collection: "blocks", Name: "battery", Template: "battery",
|
||||
Attributes: map[string]string{"bar": "bottom", "place": "status", "order": "20"}},
|
||||
{Collection: "blocks", Name: "clock", Template: "clock", Attributes: map[string]string{"bar": "top", "place": "status"}},
|
||||
}
|
||||
if !reflect.DeepEqual(with.Items, want) {
|
||||
t.Fatalf("with the capability the plan carries %+v", with.Items)
|
||||
}
|
||||
without := blocksOn(t, map[string]bool{"seat": true}).Plans["power"]
|
||||
if !reflect.DeepEqual(without.Items, want[1:]) {
|
||||
t.Fatalf("without it the plan carries %+v", without.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACollectorSelectingByAttributesCollectsTheCollectionOnce(t *testing.T) {
|
||||
bars := blocksOn(t, nil).Plans["bars"]
|
||||
if !reflect.DeepEqual(bars.Collections, []PlanCollection{{Name: "blocks", Comment: "#"}}) {
|
||||
t.Fatalf("the plan collects %+v", bars.Collections)
|
||||
}
|
||||
}
|
||||
|
||||
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 = (Resolution{Node: "n", Modules: []Manifest{m}}).Declaration(Rendering{})
|
||||
}
|
||||
if want == "" {
|
||||
continue
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%s: %v, want %q", placeholder, err, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A moved contributor's items, selected by the old placeholder's pair, render in the old order (order, module, item
|
||||
// name) and with the same lines as the contributions they replace.
|
||||
func TestASelectedMovedItemRendersIntoTheOldBarPlaceholderAsTheContributionDid(t *testing.T) {
|
||||
piece := func(order, command string) string {
|
||||
return "[[block]]\nblock = \"custom\"\ncommand = \"" + command + "\"\ninterval = 5\n"
|
||||
}
|
||||
asOld := Manifest{Module: "power", Contributions: []SeatContribution{
|
||||
block(`{"bar":"bottom","place":"status","order":20,"shows":"command","options":{"command":"two","interval":5}}`),
|
||||
block(`{"bar":"bottom","place":"status","shows":"command","options":{"command":"one","interval":5}}`),
|
||||
block(`{"bar":"top","place":"resources","shows":"command","options":{"command":"up","interval":5}}`),
|
||||
}}
|
||||
asItems := Manifest{Module: "power", Moved: true, Items: []Item{
|
||||
{Collection: "contribution:node-bar:block", Name: "b", Content: piece("", "one"),
|
||||
Attributes: map[string]string{"bar": "bottom", "place": "status"}},
|
||||
{Collection: "contribution:node-bar:block", Name: "a", Content: piece("", "two"),
|
||||
Attributes: map[string]string{"bar": "bottom", "place": "status", "order": "20"}},
|
||||
{Collection: "contribution:node-bar:block", Name: "c", Content: piece("", "up"),
|
||||
Attributes: map[string]string{"bar": "top", "place": "resources"}},
|
||||
{Collection: "contribution:node-bar:block", Name: "d", Content: "x\n", IfCapability: "battery",
|
||||
Attributes: map[string]string{"bar": "bottom", "place": "status"}},
|
||||
}}
|
||||
other := Manifest{Module: "another", Contributions: []SeatContribution{
|
||||
block(`{"bar":"bottom","place":"status","order":20,"shows":"command","options":{"command":"mid","interval":5}}`)}}
|
||||
for _, caps := range []map[string]bool{nil, {"battery": true}} {
|
||||
was := composedFile(t, Resolution{Node: "n", Account: "op", Capabilities: caps,
|
||||
Modules: []Manifest{barHolder(), asOld, other}}, "a-bar.bottom")
|
||||
is := composedFile(t, Resolution{Node: "n", Account: "op", Capabilities: caps,
|
||||
Modules: []Manifest{barHolder(), asItems, other}}, "a-bar.bottom")
|
||||
// The old form puts the contributions of a module under one line naming it and the item form puts one under
|
||||
// each item: the same pieces in the same order, otherwise the same bytes.
|
||||
want := "own-first\nown-middle\n# another\n" + piece("", "mid") + "# power\n" + piece("", "two") +
|
||||
"# power\n" + piece("", "one")
|
||||
if caps["battery"] {
|
||||
want += "# power\nx\n"
|
||||
}
|
||||
want += "own-last\n"
|
||||
if is != want {
|
||||
t.Errorf("caps %v: the items render\n%s\nnot\n%s", caps, is, want)
|
||||
}
|
||||
if !strings.Contains(was, piece("", "two")) {
|
||||
t.Errorf("the contributions made\n%s", was)
|
||||
}
|
||||
}
|
||||
if got := composedFile(t, Resolution{Node: "n", Account: "op", Modules: []Manifest{barHolder(), asItems}}, "a-bar.top"); !strings.Contains(got, "\"up\"") || strings.Contains(got, `"one"`) {
|
||||
t.Errorf("the top bar renders\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A collection is declared and filled by the client (novox/hq ADR 0349 §13, design 57 L1, issue 684): a collector
|
||||
@@ -32,65 +30,3 @@ func builtWith(module string, texts map[string]string) []Built {
|
||||
Reference: ArtifactStoreScheme + module + "/files/blobs/sha256:" + strings.Repeat("e", 64),
|
||||
Digest: "sha256:" + strings.Repeat("e", 64), Texts: texts}}
|
||||
}
|
||||
|
||||
func collectionsOn(t *testing.T) Resolution {
|
||||
t.Helper()
|
||||
zsh := held(t, collectorZsh, builtWith("zsh", map[string]string{"zshrc": zshrcText}))
|
||||
prompt := held(t, contributorP10k, builtWith("prompt", map[string]string{"zsh": contributorText}))
|
||||
aliases, err := decodeForTest([]byte(oldFormAliases))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := Resolve(shelf(zsh, prompt, aliases), []string{"zsh", "prompt", "aliases"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
return r
|
||||
}
|
||||
|
||||
func TestAContributorsBuildKeepsItsItemsTemplateAndText(t *testing.T) {
|
||||
m := held(t, contributorP10k, builtWith("prompt", map[string]string{"zsh": contributorText}))
|
||||
if m.Handed == nil || !reflect.DeepEqual(m.Handed.Items, map[string]string{"shell:zsh:normal": "zsh"}) {
|
||||
t.Fatalf("the build kept %+v", m.Handed)
|
||||
}
|
||||
if !reflect.DeepEqual(m.Items, []Item{{Collection: "shell:zsh:normal", Content: contributorText}}) {
|
||||
t.Fatalf("the held items are %+v: the template's text, read back", m.Items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHandedCollectorAndContributorArePlannedAndNeitherIsRendered(t *testing.T) {
|
||||
c, got := composeWith(t, collectionsOn(t), handingTo("store:5000"))
|
||||
if strings.Contains(got, ".p10k.zsh") || strings.Contains(got, "alias ll") {
|
||||
t.Fatalf("the controller rendered a contribution into a handed collector: %s", got)
|
||||
}
|
||||
zsh, prompt := c.Plans["zsh"], c.Plans["prompt"]
|
||||
wantCollections := []PlanCollection{{Name: "shell:zsh:normal", Comment: "#"}, {Name: "widgets", Comment: "#"}}
|
||||
if !reflect.DeepEqual(zsh.Collections, wantCollections) || len(zsh.Items) != 0 {
|
||||
t.Fatalf("zsh's plan collects %+v and contributes %+v", zsh.Collections, zsh.Items)
|
||||
}
|
||||
if !reflect.DeepEqual(prompt.Items, []PlanItem{{Collection: "shell:zsh:normal", Template: "zsh"}}) ||
|
||||
len(prompt.Files) != 0 || prompt.Placement["machine:account-home"] != "/home/op" {
|
||||
t.Fatalf("prompt's plan is %+v: its item, by template, and the placement its template names", prompt)
|
||||
}
|
||||
// The old form on the same machine is not placed, and said so.
|
||||
if len(c.Unplaced) != 1 || !strings.Contains(c.Unplaced[0], "aliases's shell:zsh:normal is not placed") {
|
||||
t.Fatalf("unplaced: %v", c.Unplaced)
|
||||
}
|
||||
}
|
||||
|
||||
// A collector filled the old way holds the old forms and each moved contributor's item, rendered from the text the
|
||||
// mesh holds as its old form was (design 57 L2, issue 681), so the file loses no line where nothing is handed; a
|
||||
// collection of the new form stays empty.
|
||||
func TestACollectorFilledTheOldWayHoldsTheOldFormsAndEachHeldItem(t *testing.T) {
|
||||
c, got := composeWith(t, collectionsOn(t), Rendering{ArtifactStore: "store:5000"})
|
||||
if c.Plans != nil {
|
||||
t.Fatalf("handed on a machine that cannot take it: %v", c.Plans)
|
||||
}
|
||||
if !strings.Contains(got, `"content":"# zsh\n# aliases\nalias ll='ls -l'\n# prompt\nsource /home/op/.p10k.zsh\n"`) {
|
||||
t.Fatalf("the old way does not hold the old forms and the held item in module order: %s", got)
|
||||
}
|
||||
if len(c.Unplaced) != 0 {
|
||||
t.Fatalf("unplaced: %v", c.Unplaced)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A module whose files cannot be written in advance.
|
||||
//
|
||||
// The mesh's private network is the case: a machine's peer list is derived from every other
|
||||
// machine, so it differs on each one and changes when any of them changes. What matters in these
|
||||
// tests is not that it works, but that being computed changes *nothing else* about being a
|
||||
// module — it is assigned, resolved, settled and absent when nobody asked for it.
|
||||
|
||||
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": MergeJSON, "content": `{"peer":"` + node + `"}`}}, true, nil
|
||||
}
|
||||
|
||||
func computedShelf() map[string]Manifest {
|
||||
return shelf(Manifest{Module: "mesh-network", Computed: "mesh-network",
|
||||
Provides: 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(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gen := &fake{on: map[string]bool{"workstation": true}}
|
||||
out, err := got.Declaration(Rendering{Generators: map[string]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(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gen := &fake{on: map[string]bool{"workstation": true}}
|
||||
out, err := got.Declaration(Rendering{Generators: map[string]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(), World{})
|
||||
gen := &fake{on: map[string]bool{}}
|
||||
out, err := got.Declaration(Rendering{Generators: map[string]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(Manifest{Module: "weather", Computed: "the-weather"}), []string{"weather"}, workstation(), World{})
|
||||
|
||||
_, err := got.Declaration(Rendering{Generators: map[string]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(), World{})
|
||||
gen := &fake{on: map[string]bool{"workstation": true}}
|
||||
out, err := got.Declaration(Rendering{
|
||||
Generators: map[string]Generator{"mesh-network": gen},
|
||||
Settings: 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(
|
||||
Manifest{Module: "mesh-network", Computed: "mesh-network",
|
||||
Provides: Offers("private-network")},
|
||||
Manifest{Module: "tailscale", Provides: Offers("private-network")},
|
||||
Manifest{Module: "backups", Requires: []string{"private-network"}},
|
||||
), []string{"backups"}, workstation(), 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": 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(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := got.Declaration(Rendering{Generators: map[string]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"])
|
||||
}
|
||||
}
|
||||
@@ -21,68 +21,6 @@ func renaming() map[string]any {
|
||||
return m
|
||||
}
|
||||
|
||||
// rulesComposed is the rules file's content as the module's defaults and these layers make it.
|
||||
func rulesComposed(t *testing.T, m Manifest, layers ...Layer) string {
|
||||
t.Helper()
|
||||
all := layers
|
||||
if d, ok := Defaults(m); ok {
|
||||
all = append([]Layer{d}, layers...)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] != "rules" {
|
||||
continue
|
||||
}
|
||||
out, err := ApplySettings(r, all)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, left := out[TakesSettingsField]; left {
|
||||
t.Fatalf("the composed file still says %s: %v", TakesSettingsField, out)
|
||||
}
|
||||
return out["content"].(string)
|
||||
}
|
||||
t.Fatal("no rules file")
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestATakenValueIsLaidUnderTheFilesOwnName(t *testing.T) {
|
||||
m, err := parseRaw(t, renaming())
|
||||
if err != nil {
|
||||
t.Fatalf("a renamed takes-config entry was refused: %v", err)
|
||||
}
|
||||
got := rulesComposed(t, m, Layer{From: "ace", Values: map[string]any{"url": "https://hooks.example.test/n"}})
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal([]byte(got), &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if doc["NOTIFIER_URL"] != "https://hooks.example.test/n" {
|
||||
t.Fatalf("url was not laid as NOTIFIER_URL: %s", got)
|
||||
}
|
||||
if _, said := doc["url"]; said {
|
||||
t.Fatalf("url was laid under the config key too: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnsetRenamedValueLeavesTheFileAsItWas(t *testing.T) {
|
||||
plain := inConfigForm()
|
||||
base, err := parseRaw(t, plain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := parseRaw(t, renaming())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := rulesComposed(t, base)
|
||||
if got := rulesComposed(t, m); got != want {
|
||||
t.Fatalf("with no layer the file is\n%s\nwanted, as without the entry,\n%s", got, want)
|
||||
}
|
||||
other := Layer{From: "ace", Values: map[string]any{"theme": "light"}}
|
||||
if got, want := rulesComposed(t, m, other), rulesComposed(t, base, other); got != want {
|
||||
t.Fatalf("with url unset the file is\n%s\nwanted\n%s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func listHolds(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
|
||||
@@ -3,7 +3,6 @@ package catalogue
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -147,136 +146,6 @@ func TestASettingsManifestIsWrittenBackAsBefore(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 []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 := r.Compose(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 [][]Layer{
|
||||
nil,
|
||||
{{From: "anchor", Values: map[string]any{"font-size": float64(13)}}},
|
||||
{{From: 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 := r.Compose(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 = Resolution{}.composed(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)
|
||||
}
|
||||
}
|
||||
|
||||
// 1658/AC1: a value whose only placement is ${config:key} in an item's text is placed.
|
||||
func TestConfigCountsAnItemsTextAsAPlacement(t *testing.T) {
|
||||
m := map[string]any{
|
||||
|
||||
@@ -1,114 +1,9 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
@@ -117,149 +12,3 @@ func sortedAnyKeys(values map[string]any) []string {
|
||||
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 (r Resolution) derivedFor(provision, as, consumer, local string, settings 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
|
||||
}
|
||||
|
||||
@@ -1,101 +1,4 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// ConsumersNamespace is a provider's consumers' credentials' placeholder namespace, `${consumers:<provision>}`.
|
||||
const ConsumersNamespace = "consumers"
|
||||
|
||||
// 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 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 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 != 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 Manifest, r map[string]any, with 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 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 Manifest, r map[string]any, with 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 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
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -14,210 +13,6 @@ import (
|
||||
// opened a connection to the control plane's database, which is why every node held a credential
|
||||
// to it permanently.
|
||||
|
||||
func published(module, host string, port int) Manifest {
|
||||
return Manifest{Module: module, Version: "1",
|
||||
Contributes: map[string]map[string]any{
|
||||
"reverse-proxy": {"host": host, "port": port},
|
||||
}}
|
||||
}
|
||||
|
||||
func proxy() Manifest {
|
||||
return Manifest{Module: "traefik", Version: "1",
|
||||
Provides: 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{ReceivedID("reverse-proxy")}},
|
||||
}}
|
||||
}
|
||||
|
||||
// received digs the delivered contributions back out of a declaration.
|
||||
func received(t *testing.T, out []map[string]any) []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 []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(), 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(), World{})
|
||||
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := got.Declaration(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(), 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(), 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(), 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(), 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(), 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."+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(), World{})
|
||||
|
||||
out, err := got.Declaration(Rendering{Settings: 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(), World{})
|
||||
|
||||
out, err := got.Declaration(Rendering{Settings: 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)
|
||||
}
|
||||
}
|
||||
|
||||
// A provision answered from anywhere in the mesh has consumers on other machines, and the
|
||||
// provider has to know who they are. Contributions were node-local until this, which meant the
|
||||
// one case that most needed them was the one they did not reach.
|
||||
@@ -230,23 +25,6 @@ func provider() Manifest {
|
||||
}
|
||||
}
|
||||
|
||||
// oneGrant is a declaration with a single consumer on another machine.
|
||||
func oneGrant(t *testing.T) []map[string]any {
|
||||
t.Helper()
|
||||
got, err := Resolve(shelf(provider()), []string{"postgres"}, reachable(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := got.Declaration(Rendering{Grants: []Grant{{
|
||||
Provision: "postgres-database", Consumer: "workstation", From: "meshboard",
|
||||
Values: map[string]any{"name": "meshboard"}, Sealed: "c2VhbGVk",
|
||||
}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func grantedTo(t *testing.T, out []map[string]any) []Contribution {
|
||||
t.Helper()
|
||||
for _, r := range out {
|
||||
@@ -264,125 +42,3 @@ func grantedTo(t *testing.T, out []map[string]any) []Contribution {
|
||||
t.Fatalf("the provider was given no manifest: %v", out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAProviderIsToldAboutConsumersOnOtherMachines(t *testing.T) {
|
||||
// A database told to create a password and not who for can do nothing with it.
|
||||
given := grantedTo(t, oneGrant(t))
|
||||
if len(given) != 1 {
|
||||
t.Fatalf("got %v", given)
|
||||
}
|
||||
if given[0].Node != "workstation" || given[0].From != "meshboard" {
|
||||
t.Fatalf("it does not say who asked: %v", given[0])
|
||||
}
|
||||
if given[0].Values["name"] != "meshboard" {
|
||||
t.Fatalf("it does not say what was asked for: %v", given[0].Values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheManifestNamesTheFileRatherThanCarryingTheCredential(t *testing.T) {
|
||||
// The mesh discarded the value and could not put it here if it wanted to. What is here is
|
||||
// where to find it — and the readable half therefore stays readable.
|
||||
out := oneGrant(t)
|
||||
given := grantedTo(t, out)
|
||||
// Named after the machine and the module, because a consumer is both (novox/hq
|
||||
// 04-ISSUES/022). The machine alone, two modules on one node wrote to one path.
|
||||
if given[0].Secret != "/var/lib/postgres/grants/workstation.meshboard.secret" {
|
||||
t.Fatalf("the manifest does not name the credential's file: %q", given[0].Secret)
|
||||
}
|
||||
for _, r := range out {
|
||||
if r["path"] != "/var/lib/postgres/grants/mesh.json" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(r["content"].(string), "c2VhbGVk") {
|
||||
t.Fatal("the readable manifest carries the sealed credential")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheCredentialItselfLandsSealedBesideIt(t *testing.T) {
|
||||
for _, r := range oneGrant(t) {
|
||||
if r["path"] != "/var/lib/postgres/grants/workstation.meshboard.secret" {
|
||||
continue
|
||||
}
|
||||
if r["sealed"] != "c2VhbGVk" {
|
||||
t.Fatalf("got %v", r)
|
||||
}
|
||||
if r["content"] != nil {
|
||||
t.Fatal("a credential was written in the clear")
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("no credential file")
|
||||
}
|
||||
|
||||
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(), 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 := Resolution{
|
||||
Node: "laptop",
|
||||
Modules: []Manifest{{
|
||||
Module: "amqp-ping",
|
||||
Requires: []string{"amqp"},
|
||||
}},
|
||||
}
|
||||
values, asks, err := r.ContributionsFrom("amqp", "amqp-ping", 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(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := got.Declaration(Rendering{Grants: []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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0210 §3: a contribution is a dependency on the seat that receives it.
|
||||
|
||||
func TestAContributionDependsOnTheSeatThatPlacesIt(t *testing.T) {
|
||||
env := mod("theme", nil, nil, nil)
|
||||
env.Environment = &Environment{Variables: map[string]string{"GTK_THEME": "Adwaita:dark"}}
|
||||
path := mod("toolchain", nil, nil, nil)
|
||||
path.Environment = &Environment{Path: []PathEntry{{Entry: "/opt/x/bin"}}}
|
||||
shell := mod("prompt", nil, nil, nil)
|
||||
shell.Shell = []ShellCode{{For: "zsh", Slot: "first", Code: "true"}}
|
||||
session := mod("wallpaper", nil, nil, nil)
|
||||
session.Shell = []ShellCode{{For: "xinitrc", Slot: "normal", Code: "true"}}
|
||||
resources := mod("bar", nil, nil, nil)
|
||||
resources.Shell = []ShellCode{{For: "xresources", Slot: "normal", Code: "x: y"}}
|
||||
empty := mod("nothing", nil, nil, nil)
|
||||
empty.Environment = &Environment{}
|
||||
|
||||
cases := map[string]struct {
|
||||
m Manifest
|
||||
want []string
|
||||
}{
|
||||
"a variable": {env, []string{EnvironmentSeat}},
|
||||
"a path entry": {path, []string{EnvironmentSeat}},
|
||||
"shell code": {shell, []string{LoginShellSeat}},
|
||||
"the session's start": {session, []string{DisplayServerSeat}},
|
||||
"the session's X resources": {resources, []string{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, Claim{Name: LoginShellSeat})
|
||||
zsh.Shell = []ShellCode{{For: "zsh", Slot: "normal", Code: "true"}}
|
||||
zsh.Environment = &Environment{Variables: map[string]string{"EDITOR": "vim"}}
|
||||
catalogue := map[string]Manifest{"zsh": zsh,
|
||||
"node-env": mod("node-env", nil, nil, nil, Claim{Name: EnvironmentSeat})}
|
||||
unheld := UnheldDependencies(catalogue, "laptop", []Manifest{zsh}, nil)
|
||||
if len(unheld) != 1 || unheld[0].Seat != EnvironmentSeat {
|
||||
t.Errorf("zsh alone: unheld %v, want only %s (its shell code is its own seat's)", unheld, EnvironmentSeat)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// CoreCredentialModules are the modules whose own secrets are the core's credentials: the bus, mesh-provisioner and
|
||||
@@ -11,18 +10,9 @@ import (
|
||||
// for these alone.
|
||||
var CoreCredentialModules = []string{"mesh-provisioner", "mesh-provisioner-client", "nats"}
|
||||
|
||||
// CarriesVaultSealed says whether module's own secrets are the core's credentials.
|
||||
func CarriesVaultSealed(module string) bool { return slices.Contains(CoreCredentialModules, module) }
|
||||
|
||||
// VaultSealedField is the field of a file the vault sealed to its machine (mesh-host's File.VaultSealed).
|
||||
const VaultSealedField = "vault-sealed"
|
||||
|
||||
// ForeseenVaultBox stands for a core credential's box in a composition asked ahead of the send, when none is kept
|
||||
// yet: the send asks the vault for it. Never sent.
|
||||
var ForeseenVaultBox = json.RawMessage(`{"v":1,"eph":"","iv":"","tag":"","ct":"","foreseen":"asked of the vault by the next send"}`)
|
||||
|
||||
// vaultSealedOf is the vault's box for one of module's own secrets, if the rendering was given one.
|
||||
func (with Rendering) vaultSealedOf(module, name string) (json.RawMessage, bool) {
|
||||
box, ok := with.VaultSealed[module][name]
|
||||
return box, ok && len(box) > 0
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@ package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -11,35 +9,10 @@ import (
|
||||
// controller carries the vault's box and cannot open it. The list is fixed here, and the composition carries the box
|
||||
// in place of the controller's own sealed value only where the rendering was given one.
|
||||
|
||||
func TestTheCoresOwnCredentialsAreTheBusTheProvisionerAndItsClient(t *testing.T) {
|
||||
if want := []string{"mesh-provisioner", "mesh-provisioner-client", "nats"}; !reflect.DeepEqual(CoreCredentialModules, want) {
|
||||
t.Fatalf("the core's modules are %v, want %v (mesh-vault's CoreModules is the same list)", 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const provisionerClient = `{"module":"mesh-provisioner-client","version":"1",
|
||||
"own-secrets":{"provisioner-broker":"${dir:state}/broker"},
|
||||
"resources":[{"id":"state","type":"directory","mode":"0700","place":"."}]}`
|
||||
|
||||
func clientOn(t *testing.T) Resolution {
|
||||
t.Helper()
|
||||
m, err := decodeForTest([]byte(provisionerClient))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return notifierOn(t, m)
|
||||
}
|
||||
|
||||
func brokerFile(t *testing.T, c Composed) map[string]any {
|
||||
t.Helper()
|
||||
for _, r := range c.Resources {
|
||||
@@ -51,57 +24,3 @@ func brokerFile(t *testing.T, c Composed) map[string]any {
|
||||
}
|
||||
|
||||
var aVaultBox = json.RawMessage(`{"v":1,"eph":"ZQ==","iv":"aQ==","tag":"dA==","ct":"Yw=="}`)
|
||||
|
||||
func TestACoreCredentialCarriesTheVaultsBoxAndNoSealOfTheControllers(t *testing.T) {
|
||||
r := clientOn(t)
|
||||
with := Rendering{Needed: map[string]map[string]string{"mesh-provisioner-client": {"provisioner-broker": "nacl"}},
|
||||
VaultSealed: map[string]map[string]json.RawMessage{"mesh-provisioner-client": {"provisioner-broker": aVaultBox}}}
|
||||
c, got := composeWith(t, r, with)
|
||||
f := brokerFile(t, c)
|
||||
if f == nil {
|
||||
t.Fatalf("the credential's file is not declared: %s", got)
|
||||
}
|
||||
if f["sealed"] != nil || !reflect.DeepEqual(f["vault-sealed"], aVaultBox) || strings.Contains(got, `"nacl"`) {
|
||||
t.Fatalf("the file is %v: the vault's box alone, never the controller's seal", f)
|
||||
}
|
||||
// Without the controller's own value at all: the vault's box is enough.
|
||||
with.Needed = nil
|
||||
if c, _ := composeWith(t, r, with); brokerFile(t, c) == nil {
|
||||
t.Fatal("a core credential with the vault's box and no seal of the controller's was refused or dropped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestACoreModuleTheVaultGaveNothingForIsLeftOutWithWhy(t *testing.T) {
|
||||
r := clientOn(t)
|
||||
c, got := composeWith(t, r, Rendering{NotPlaced: map[string]string{
|
||||
"mesh-provisioner-client": "the vault did not answer for its credential"}})
|
||||
if brokerFile(t, c) != nil || strings.Contains(got, "mesh-provisioner-client.") {
|
||||
t.Fatalf("a core module the vault gave nothing for was placed: %s", got)
|
||||
}
|
||||
if !strings.Contains(c.LeftOut["mesh-provisioner-client"], "the vault did not answer") {
|
||||
t.Fatalf("left out %v", c.LeftOut)
|
||||
}
|
||||
}
|
||||
|
||||
// An engine below VaultBoxContract is rendered with neither field: its composition is the controller's sealed value,
|
||||
// byte for byte what it was.
|
||||
func TestWithoutTheVaultsBoxTheCompositionIsAsBefore(t *testing.T) {
|
||||
r := clientOn(t)
|
||||
with := Rendering{Needed: map[string]map[string]string{"mesh-provisioner-client": {"provisioner-broker": "nacl"}}}
|
||||
before, _ := composeWith(t, r, with)
|
||||
with.VaultSealed, with.NotPlaced = map[string]map[string]json.RawMessage{}, map[string]string{}
|
||||
after, _ := composeWith(t, r, with)
|
||||
if !reflect.DeepEqual(before, after) || brokerFile(t, before)["sealed"] != "nacl" {
|
||||
t.Fatalf("the composition changed: %v", brokerFile(t, after))
|
||||
}
|
||||
}
|
||||
|
||||
// Asked ahead of a send that has no box kept yet: a stand-in, named as foreseen, never sent.
|
||||
func TestAForeseenVaultBoxIsNamed(t *testing.T) {
|
||||
r := clientOn(t)
|
||||
c, _ := composeWith(t, r, Rendering{VaultSealed: map[string]map[string]json.RawMessage{
|
||||
"mesh-provisioner-client": {"provisioner-broker": ForeseenVaultBox}}})
|
||||
if !reflect.DeepEqual(c.Foreseen, []string{"mesh-provisioner-client/provisioner-broker"}) {
|
||||
t.Fatalf("foreseen %v", c.Foreseen)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,29 +64,3 @@ func ParseDataItems(text string) (map[string][]PlacedDataItem, error) {
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// 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 (r Resolution) DataItems(with Rendering) (items map[string][]PlacedDataItem, unplaced []string, err error) {
|
||||
left := r.LeftOut(with.Settings, with.Adopted)
|
||||
for module, why := range with.NotPlaced {
|
||||
if _, already := left[module]; !already {
|
||||
left[module] = why
|
||||
}
|
||||
}
|
||||
modules := make([]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, Manifest{}, BackupSeat+":"+BackupKindData, with,
|
||||
machineFacts(r, with.Names, with.MeshRange), r.Capabilities)
|
||||
if err != nil {
|
||||
return nil, unplaced, err
|
||||
}
|
||||
items, err = ParseDataItems(text)
|
||||
return items, unplaced, err
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,80 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import "testing"
|
||||
|
||||
// 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() Manifest {
|
||||
return Manifest{
|
||||
Module: "nats", Version: "1",
|
||||
Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}},
|
||||
BusUsers: "/var/lib/nats-module/conf/accounts.conf",
|
||||
Resources: []map[string]any{},
|
||||
}
|
||||
}
|
||||
|
||||
on := func(t *testing.T, m Manifest, with Rendering) ([]map[string]any, error) {
|
||||
t.Helper()
|
||||
return Resolution{Node: "anchor", Modules: []Manifest{m}}.Declaration(with)
|
||||
}
|
||||
|
||||
t.Run("the holder is given it", func(t *testing.T) {
|
||||
resources, err := on(t, theBus(), 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."+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, 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(), 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, Rendering{BusUsers: "accounts {}"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range resources {
|
||||
if r["id"] == "nats."+BusUsersID() {
|
||||
t.Fatal("a module that asked for no user list was given one")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,320 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// What a provider derives for each consumer, said once and delivered to both ends
|
||||
// (novox/hq ADR 0202, issue 124).
|
||||
//
|
||||
// The failure these are written against: the object store's provisioner derived each consumer's
|
||||
// bucket from the login the mesh minted, in its own code, and the mesh had no channel to tell the
|
||||
// consumer which bucket that was — so all three consumers wrote the answer into their own
|
||||
// definitions by hand. Two were right. One named a predecessor's bucket and would have
|
||||
// authenticated successfully and been refused on every object. Each of them also named the
|
||||
// machine the module happens to run on, which a definition may not do.
|
||||
|
||||
// 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() Manifest {
|
||||
return Manifest{
|
||||
Module: "store", Version: "1",
|
||||
Provides: FromAnywhere("s3-bucket"),
|
||||
Listens: []Listening{{Port: 9000, Protocol: "tcp", From: 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() Manifest {
|
||||
return 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() Manifest {
|
||||
return 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(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := r.Declaration(Rendering{Grants: []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 := ConsumerIdentity("workstation", 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(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := r.Declaration(Rendering{Grants: []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(ConsumerIdentity("workstation", IdentitySource("files", "files")), "_", "-")
|
||||
forPics := strings.ReplaceAll(ConsumerIdentity("workstation", 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(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := r.Declaration(Rendering{
|
||||
Settings: SettingsBy{"store": {{From: "the operator",
|
||||
Values: map[string]any{"bucket-prefix": "team-"}}}},
|
||||
Grants: []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(ConsumerIdentity("workstation", 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 := ConsumerIdentity("anchor", IdentitySource("ncloud", "nextcloud"))
|
||||
if err := CheckIdentity("anchor", 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 := ConsumerIdentity("workstation", 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(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = r.Declaration(Rendering{Grants: []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(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := r.Declaration(Rendering{Grants: []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) []Contribution {
|
||||
t.Helper()
|
||||
for _, r := range out {
|
||||
if r["path"] != "/var/lib/store/grants/mesh.json" {
|
||||
continue
|
||||
}
|
||||
var parsed struct {
|
||||
Given []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(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = r.Declaration(Rendering{Grants: []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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,233 +2,9 @@ package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// defaultDataRoot is where module data lands when a node states no root of its own.
|
||||
const defaultDataRoot = "/var/lib"
|
||||
|
||||
// The two places a pathless directory may name, beside its own id.
|
||||
const (
|
||||
// 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.
|
||||
placeOwn = "."
|
||||
// placeMesh is where the mesh keeps what it writes for the module, <root>/mesh/<module>.
|
||||
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 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 Manifest, with 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, _ := 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 Manifest, with Rendering) (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(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] = 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 {
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
// A directory the mesh places (novox/hq ADR 0112). These tests pin the contract: a pathless
|
||||
// directory resolves under the node's root, ${dir:…} names it from every field a host path can
|
||||
// live in, a stated path is the adopted-data placement and wins, an unknown reference refuses at
|
||||
// the manifest, and filling for one node never leaks into the next composition.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func placedModule() Manifest {
|
||||
return 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, 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, 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, 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, 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, Rendering{DataRoot: "/first"}), m.Module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := shallowCopy(m.Resources[2])
|
||||
if err := dirInto(second, dirsFor(m, 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, 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 := Manifest{Module: "mailu", Resources: []map[string]any{
|
||||
{"id": "state", "type": "directory", "place": ".", "mode": "0700"},
|
||||
{"id": "data-mail", "type": "directory"},
|
||||
}}
|
||||
dirs := dirsFor(m, 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 := 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: OwnSecrets{"admin-key": {Path: "${dir:state}/admin-key.secret"}},
|
||||
Receives: map[string]string{"route": "${dir:state}/grants/mesh.json"},
|
||||
}
|
||||
placed, err := placedManifest(m, 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 := 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: OwnSecrets{"broker": {Path: "${dir:mesh-state}/broker"}},
|
||||
Binds: map[string]string{"route": "${dir:state}/route.json"},
|
||||
}
|
||||
dirs := dirsFor(m, 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, 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, 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, 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 := Manifest{Module: "gitea", Resources: []map[string]any{
|
||||
{"id": "state", "type": "directory", "place": "."},
|
||||
{"id": "env", "type": "file", "path": "${dir:state}/server.env", "content": ""},
|
||||
}}
|
||||
b := 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([]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 := Manifest{Module: "squatter", Resources: []map[string]any{
|
||||
{"id": "nest", "type": "directory", "path": "/var/lib/gitea"},
|
||||
}}
|
||||
got := checkResources([]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 := 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, 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"])
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A domain module is a module with requirements and no files of its own.
|
||||
//
|
||||
// Most people want the network working and do not want to choose a VPN. Some want a particular
|
||||
// one. Both are the same mechanism: assigning `networking` takes the only answer to each of its
|
||||
// requirements silently, and the day there are two answers the resolver refuses and names them,
|
||||
// so choosing is assigning the one you want. There is no flavor field and nothing to configure.
|
||||
|
||||
func networkingShelf(extra ...Manifest) map[string]Manifest {
|
||||
base := []Manifest{
|
||||
{Module: "networking", Requires: []string{"private-network", "name-resolution"}},
|
||||
{Module: "mesh-wireguard", Computed: "mesh-wireguard",
|
||||
Provides: Offers("private-network", "mesh-addressing"),
|
||||
Claims: []Claim{{Name: "the-private-network", Scope: ScopeNode}}},
|
||||
{Module: "mesh-names", Computed: "mesh-names",
|
||||
Provides: 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(), 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(
|
||||
Manifest{Module: "tailscale", Provides: Offers("private-network"),
|
||||
Claims: []Claim{{Name: "the-private-network", Scope: ScopeNode}}},
|
||||
), []string{"networking"}, workstation(), 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(
|
||||
Manifest{Module: "tailscale",
|
||||
Provides: Offers("private-network", "name-resolution"),
|
||||
Claims: []Claim{{Name: "the-private-network", Scope: ScopeNode}}},
|
||||
), []string{"networking", "tailscale"}, workstation(), 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(
|
||||
Manifest{Module: "tailscale", Provides: Offers("private-network"),
|
||||
Claims: []Claim{{Name: "the-private-network", Scope: ScopeNode}}},
|
||||
), []string{"networking", "tailscale"}, workstation(), 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(
|
||||
Manifest{Module: "mesh-names", Computed: "mesh-names",
|
||||
Provides: Offers("name-resolution"), Requires: []string{"mesh-addressing"}},
|
||||
Manifest{Module: "tailscale", Provides: Offers("private-network")},
|
||||
), []string{"mesh-names", "tailscale"}, workstation(), 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(
|
||||
Manifest{Module: "one", Requires: []string{"shell"}},
|
||||
Manifest{Module: "two", Requires: []string{"shell"}},
|
||||
Manifest{Module: "bash", Provides: Offers("shell")},
|
||||
Manifest{Module: "zsh", Provides: Offers("shell")},
|
||||
), []string{"one", "two"}, workstation(), 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)
|
||||
}
|
||||
}
|
||||
@@ -41,118 +41,3 @@ func TestARouteNamesTheEndpointItServes(t *testing.T) {
|
||||
t.Fatal("an endpoint the module does not declare resolved to a port")
|
||||
}
|
||||
}
|
||||
|
||||
// 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 := SettingsBy{"media": {{From: "node anchor", Values: map[string]any{
|
||||
ReachSetting: map[string]any{"80": ReachBoth, "32400": ReachPublic},
|
||||
}}}}
|
||||
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{m},
|
||||
PublicDomain: "example.test", At: "anchor.internal"}
|
||||
rules, err := r.Rules(Rendering{Settings: settings})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, rule := range rules {
|
||||
switch rule.Port {
|
||||
case 80:
|
||||
if rule.From != 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 != 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 := r.contributions(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 := Resolution{Node: "anchor", Modules: []Manifest{m},
|
||||
PublicDomain: "example.test", At: "anchor.internal"}
|
||||
given, err := r.contributions(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{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{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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,82 +10,6 @@ func configured(block map[string]any) SettingsBy {
|
||||
Placement: map[string]any{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": ReachBoth},
|
||||
"stream": map[string]any{"reach": 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 := Resolution{Node: "anchor", Modules: []Manifest{m},
|
||||
PublicDomain: "example.test", At: "anchor.internal"}
|
||||
rules, err := r.Rules(Rendering{Settings: settings})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, rule := range rules {
|
||||
if rule.Port == 32400 && rule.From != 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 != 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 := r.contributions(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": ReachInternal}})
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{m},
|
||||
PublicDomain: "example.test", At: "anchor.internal"}
|
||||
nodes, err := r.contributions(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An endpoint the module does not declare reaches nothing, and the refusal says what it does declare.
|
||||
func TestConfiguringAnEndpointTheModuleLacksIsRefused(t *testing.T) {
|
||||
_, err := Endpoints(aMediaServer(), configured(map[string]any{
|
||||
@@ -106,18 +30,6 @@ func TestAReachInABlockIsHeldToTheFourValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// **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, []Layer{{From: "node anchor", Placement: map[string]any{
|
||||
EndpointsSetting: map[string]any{"web": map[string]any{"port": 20009}},
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// And the same for its reach, said once here and once through the older key.
|
||||
func TestAnEndpointWhoseReachIsAlsoExposedIsRefused(t *testing.T) {
|
||||
_, err := Endpoints(aMediaServer(), []Layer{{From: "node anchor", Placement: map[string]any{
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EnvironmentSeat and LoginShellSeat are the seats whose holders may place what the modules
|
||||
// contributed: the account's environment, and the login shell's code.
|
||||
const (
|
||||
@@ -51,8 +44,7 @@ type ShellCode struct {
|
||||
// The shells and slots a contribution may name (novox/hq ADR 0204). Closed, so a typo is a refusal
|
||||
// at the check rather than code that silently lands in no placeholder.
|
||||
var (
|
||||
knownShells = []string{"zsh", "bash", "fish"}
|
||||
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
|
||||
@@ -63,12 +55,6 @@ 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 {
|
||||
@@ -86,518 +72,3 @@ const (
|
||||
EnvironmentPOSIX = "posix"
|
||||
EnvironmentSystemd = "systemd"
|
||||
)
|
||||
|
||||
// ofEnvironment and ofShell are where a holder places what was contributed: ${environment:posix},
|
||||
// ${environment:systemd} and ${shell:<shell>:<slot>}. Loose inside the braces on purpose, so a
|
||||
// misspelt key is found and refused rather than left in a file as a literal nobody reads.
|
||||
var (
|
||||
ofEnvironment = regexp.MustCompile(`\$\{environment:([^}]*)\}`)
|
||||
ofShell = regexp.MustCompile(`\$\{shell:([^}]*)\}`)
|
||||
// ofContributed is either kind of contributed text, matched together so both fill in one pass.
|
||||
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 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] != EnvironmentPOSIX && e[1] != 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], EnvironmentPOSIX, EnvironmentSystemd))
|
||||
}
|
||||
}
|
||||
if len(env) > 0 && !m.ClaimsSeat(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, 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 == 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 == 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 []Manifest) []Manifest {
|
||||
out := append([]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 []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 []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 == 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 Manifest) *Environment {
|
||||
var out *Environment
|
||||
if m.Environment != nil {
|
||||
out = &Environment{Variables: map[string]string{}, Path: append([]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 != EnvironmentCollection || it.Content == "" {
|
||||
continue
|
||||
}
|
||||
if out == nil {
|
||||
out = &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, 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, 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 []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 Manifest, comment, old string, items []heldItem) []textBlock {
|
||||
var blocks []textBlock
|
||||
if old != "" {
|
||||
blocks = append(blocks, textBlock{OrderDefault, old})
|
||||
}
|
||||
for i, h := range items {
|
||||
text := h.text
|
||||
if i == 0 && old != "" && h.item.order() == OrderDefault {
|
||||
blocks[0].text += text
|
||||
continue
|
||||
}
|
||||
blocks = append(blocks, textBlock{h.item.order(), 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 Manifest, modules []Manifest, facts map[string]string, with 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] == 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] == 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
|
||||
}
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
//go:build integration
|
||||
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestThePOSIXEnvironmentSourcedTwiceLeavesPATHAsOnce(t *testing.T) {
|
||||
sh, err := exec.LookPath("sh")
|
||||
if err != nil {
|
||||
t.Skip("no sh on this machine")
|
||||
}
|
||||
script := "PATH=/usr/bin:/bin\n" + composedPOSIX + "once=$PATH\n" + composedPOSIX +
|
||||
`[ "$PATH" = "$once" ] || { echo "changed: $once -> $PATH"; exit 1; }` + "\n" +
|
||||
`echo "$PATH"; echo "$GOPATH"`
|
||||
out, err := exec.Command(sh, "-c", script).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("sourcing twice: %v\n%s", err, out)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||
want := "/home/op/go/bin:/usr/local/go/bin:/home/op/.local/bin:/home/op/bin:/usr/bin:/bin:/opt/agent/bin:/opt/scripts"
|
||||
if lines[0] != want {
|
||||
t.Fatalf("PATH is %s, not %s", lines[0], want)
|
||||
}
|
||||
if lines[1] != "/home/op/go" {
|
||||
t.Fatalf("GOPATH was not exported: %q", lines[1])
|
||||
}
|
||||
// And an entry the account already has stays where it is, and once.
|
||||
out, err = exec.Command(sh, "-c", "PATH=/opt/scripts:/usr/bin\n"+composedPOSIX+`echo "$PATH"`).CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
if got := strings.TrimSpace(string(out)); got !=
|
||||
"/home/op/go/bin:/usr/local/go/bin:/home/op/.local/bin:/home/op/bin:/opt/scripts:/usr/bin:/opt/agent/bin" {
|
||||
t.Fatalf("an entry already on PATH was added again or moved: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheServiceManagerReadsTheSystemdRenderingAsMeant(t *testing.T) {
|
||||
generator := "/usr/lib/systemd/user-environment-generators/30-systemd-environment-d-generator"
|
||||
if _, err := os.Stat(generator); err != nil {
|
||||
t.Skip("NOT JUDGED: no environment.d generator on this machine, so the service manager's reading " +
|
||||
"of the rendering is judged only where systemd runs; the rendering itself is held byte for byte " +
|
||||
"by TestTheEnvironmentRendersForTheServiceManagerByteForByte")
|
||||
}
|
||||
const base = "/usr/bin:/bin"
|
||||
read := func(conf string) map[string]string {
|
||||
t.Helper()
|
||||
config := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(config, "environment.d"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if conf != "" {
|
||||
if err := os.WriteFile(filepath.Join(config, "environment.d", "50-mesh.conf"), []byte(conf), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cmd := exec.Command(generator)
|
||||
cmd.Env = []string{"PATH=" + base, "HOME=" + config, "XDG_CONFIG_HOME=" + config}
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("%v\n%s", err, out)
|
||||
}
|
||||
vars := map[string]string{}
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if k, v, ok := strings.Cut(line, "="); ok {
|
||||
vars[k] = v
|
||||
}
|
||||
}
|
||||
return vars
|
||||
}
|
||||
machine, read50 := read(""), read(composedSystemd)
|
||||
|
||||
// What the machine's own files do to PATH: nothing, or append to it. One that replaces or prepends
|
||||
// leaves nothing this test can say about the mesh's file, and says so rather than guess.
|
||||
added, ok := strings.CutPrefix(machine["PATH"], base)
|
||||
if machine["PATH"] != "" && !ok {
|
||||
t.Skipf("NOT JUDGED: this machine's own environment.d sets PATH to %s, not %s with something after it, so "+
|
||||
"what the mesh's file adds cannot be told apart from it", machine["PATH"], base)
|
||||
}
|
||||
want := "/home/op/go/bin:/usr/local/go/bin:/home/op/.local/bin:/home/op/bin:/usr/bin:/bin:/opt/agent/bin:/opt/scripts" + added
|
||||
if read50["PATH"] != want || read50["GOPATH"] != "/home/op/go" {
|
||||
t.Fatalf("the service manager read PATH=%s GOPATH=%s, not PATH=%s GOPATH=/home/op/go (the machine's own "+
|
||||
"files add %q to PATH)", read50["PATH"], read50["GOPATH"], want, added)
|
||||
}
|
||||
}
|
||||
@@ -1,100 +1,12 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0203 (the account's environment is one module's, and every module
|
||||
// contributes to it) and ADR 0204 (shell code in named slots, placed by the login shell's holder).
|
||||
|
||||
// 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() []Manifest {
|
||||
return []Manifest{
|
||||
{Module: "zsh", Environment: &Environment{
|
||||
Variables: map[string]string{"XDG_CONFIG_HOME": "${machine:account-home}/.config", "EDITOR": "vim"},
|
||||
Path: []PathEntry{
|
||||
{Entry: "${machine:account-home}/.local/bin", At: PathAtStart},
|
||||
{Entry: "${machine:account-home}/bin", At: PathAtStart},
|
||||
{Entry: "/opt/scripts", At: PathAtEnd},
|
||||
},
|
||||
}},
|
||||
{Module: "go-toolchain", Environment: &Environment{
|
||||
Variables: map[string]string{"GOPATH": "${machine:account-home}/go"},
|
||||
Path: []PathEntry{
|
||||
{Entry: "${machine:account-home}/go/bin", At: PathAtStart},
|
||||
{Entry: "/usr/local/go/bin", At: PathAtStart},
|
||||
{Entry: "${machine:account-home}/.local/bin", At: PathAtStart},
|
||||
},
|
||||
}},
|
||||
{Module: "agent", Environment: &Environment{
|
||||
Variables: map[string]string{"DISABLE_AUTOUPDATER": "1"},
|
||||
Path: []PathEntry{{Entry: "/opt/agent/bin", At: 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Sourcing twice changes nothing (ADR 0203 §3): a login shell that starts another reads the file
|
||||
// again, and a PATH that grew each time would be the symptom. Run by a real `sh`, because the claim
|
||||
// is about what a shell does with the file, not about what the file looks like.
|
||||
@@ -111,149 +23,6 @@ func TestTheEnvironmentRendersForTheServiceManagerByteForByte(t *testing.T) {
|
||||
// this and says so: there the rendering is held byte for byte by
|
||||
// TestTheEnvironmentRendersForTheServiceManagerByteForByte, and this reading only where systemd runs.
|
||||
|
||||
// Nothing contributed renders nothing, in both formats — not an empty `export PATH`.
|
||||
func TestNoContributionsRenderNothing(t *testing.T) {
|
||||
env, err := environmentOn([]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(), Manifest{Module: "neovim", Environment: &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 := Resolution{Node: "workstation", Account: "op", Modules: modules}
|
||||
if _, err := r.Declaration(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() []Manifest {
|
||||
return []Manifest{
|
||||
{Module: "zsh-syntax-highlighting", Shell: []ShellCode{
|
||||
{For: "zsh", Slot: "last", Code: "source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh"},
|
||||
}},
|
||||
{Module: "powerlevel10k", Shell: []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: []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: []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() Manifest {
|
||||
return Manifest{Module: "zsh", Claims: []Claim{{Name: LoginShellSeat, Scope: 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(), Manifest{Module: "sly", Shell: []ShellCode{
|
||||
{For: "zsh", Slot: "last", Code: "echo ${machine:account-home} ${secret:x} ${shell:zsh:first} ${environment:posix}"},
|
||||
}})
|
||||
r := Resolution{Node: "workstation", Account: "op", Modules: modules}
|
||||
out, err := r.Declaration(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 := Manifest{Module: "node-env", Claims: []Claim{{Name: EnvironmentSeat, Scope: 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 := Resolution{Node: "workstation", Account: "op", Modules: append(contributors(), holder)}
|
||||
out, err := r.Declaration(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"])
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0203 §1 and ADR 0204 §1: both seats are the mesh's own, held once per machine; the login
|
||||
// shell's contract is `execute`, described and with a schema an agent can call.
|
||||
func TestTheSeatTableCarriesTheEnvironmentAndTheLoginShell(t *testing.T) {
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The session environment is a collection of typed items (design 57 L3, issue 795): a variable is an item with
|
||||
// kind=variable and name=<NAME>, a PATH entry one with kind=path and at=start|end, each a template whose text is the
|
||||
// value or the entry. Where node-env is not handed the controller renders the items as the old form was rendered;
|
||||
@@ -47,95 +38,3 @@ const environmentCollector = `{"module":"node-env","version":"1","moved":true,
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[{"id":"posix","type":"file","path":"/home/op/.config/mesh/environment.sh","template":"files/posix"},
|
||||
{"id":"systemd","type":"file","path":"/home/op/.config/environment.d/50-mesh.conf","template":"files/systemd"}]}`
|
||||
|
||||
// nodeEnvWith is node-env, its file placing the environment as plan says, with the declared contributors' items.
|
||||
func nodeEnvWith(t *testing.T, decls []envDecl, plan string) Resolution {
|
||||
t.Helper()
|
||||
collector := held(t, environmentCollector, builtWith("node-env", map[string]string{
|
||||
"posix": "# header\n" + plan, "systemd": "${environment:systemd}"}))
|
||||
mods := []Manifest{collector}
|
||||
order := []string{"node-env"}
|
||||
byModule := map[string][]envDecl{}
|
||||
var modules []string
|
||||
for _, d := range decls {
|
||||
if byModule[d.module] == nil {
|
||||
modules = append(modules, d.module)
|
||||
}
|
||||
byModule[d.module] = append(byModule[d.module], d)
|
||||
}
|
||||
for _, name := range modules {
|
||||
var items []string
|
||||
texts := map[string]string{}
|
||||
for _, d := range byModule[name] {
|
||||
attrs := fmt.Sprintf(`{"kind":"variable","name":%q}`, d.name)
|
||||
if order, isPath := strings.CutPrefix(d.name, "path:"); isPath {
|
||||
attrs = fmt.Sprintf(`{"kind":"path","at":"start","order":%q}`, order)
|
||||
}
|
||||
items = append(items, fmt.Sprintf(`{"collection":"environment","name":%q,"attributes":%s,"template":"files/env-%s"}`,
|
||||
d.item, attrs, d.item))
|
||||
texts["env-"+d.item] = d.value + "\n"
|
||||
}
|
||||
raw := fmt.Sprintf(`{"module":%q,"version":"1","moved":true,"items":[%s],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, name, strings.Join(items, ","))
|
||||
mods = append(mods, held(t, raw, builtWith(name, texts)))
|
||||
order = append(order, name)
|
||||
}
|
||||
r, err := Resolve(shelf(mods...), order, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
return r
|
||||
}
|
||||
|
||||
func TestTheEnvironmentOfTheCatalogueIsTheSameHandedOrNot(t *testing.T) {
|
||||
read := func(name string) string {
|
||||
b, err := os.ReadFile(filepath.Join("testdata", "environment", name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
for name, only := range map[string]string{"all": "", "zsh": "zsh"} {
|
||||
var decls []envDecl
|
||||
for _, d := range environmentOfTheCatalogue() {
|
||||
if only == "" || d.module == only {
|
||||
decls = append(decls, d)
|
||||
}
|
||||
}
|
||||
// Named as the collector names it, and as the old placeholder does.
|
||||
for _, plan := range []string{"${collected:environment:posix}", "${environment:posix}"} {
|
||||
r := nodeEnvWith(t, decls, plan)
|
||||
// Not handed: the controller renders each moved contributor's items as the old form was rendered.
|
||||
if got, want := composedFile(t, r, "node-env.posix"), "# header\n"+read(name+".posix"); got != want {
|
||||
t.Errorf("%s, %s, not handed, the POSIX file is\n%s\nwant\n%s", name, plan, got, want)
|
||||
}
|
||||
if got, want := composedFile(t, r, "node-env.systemd"), read(name+".systemd"); got != want {
|
||||
t.Errorf("%s, %s, not handed, the environment.d file is\n%s\nwant\n%s", name, plan, got, want)
|
||||
}
|
||||
// Handed: one collection, named for the client; each contributor's plan names its items.
|
||||
c, _ := composeWith(t, r, handingTo("store:5000"))
|
||||
if got := c.Plans["node-env"].Collections; !reflect.DeepEqual(got, []PlanCollection{{Name: "environment", Comment: "#"}}) {
|
||||
t.Errorf("%s: the plan collects %+v", name, got)
|
||||
}
|
||||
items, got := len(decls), 0
|
||||
for m := range c.Plans {
|
||||
got += len(c.Plans[m].Items)
|
||||
}
|
||||
if got != items {
|
||||
t.Errorf("%s: the plans name %d items, want %d", name, got, items)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoModulesSettingOneVariableAreRefused(t *testing.T) {
|
||||
// A module that sets a variable twice, in two modules, is refused as the old form refused it.
|
||||
a := Manifest{Module: "a", Items: []Item{{Collection: "environment", Content: "1\n",
|
||||
Attributes: map[string]string{"kind": "variable", "name": "X"}}}}
|
||||
b := Manifest{Module: "b", Items: []Item{{Collection: "environment", Content: "2\n",
|
||||
Attributes: map[string]string{"kind": "variable", "name": "X"}}}}
|
||||
if err := variablesSetOnce([]Manifest{a, b}); err == nil || !strings.Contains(err.Error(), "both set X") {
|
||||
t.Errorf("two modules setting X: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -89,73 +86,3 @@ func filesVerbs() []Verb {
|
||||
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 (r Resolution) ownedPaths() 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 Seats() {
|
||||
for _, sp := range s.Paths {
|
||||
for _, m := range r.Modules {
|
||||
if m.ClaimsSeat(s.Name) && declaresPath(m, path.Clean(sp.Path)) && !r.stepsBackHere(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 (r Resolution) stepsBackHere(m Manifest, sp 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 (r Resolution) filesWords(m Manifest, words map[string]string, with Rendering) (map[string]string, error) {
|
||||
if !m.ClaimsSeat(FilesSeat) {
|
||||
return words, nil
|
||||
}
|
||||
places, err := r.secretPlaces(with)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
places2, err := json.Marshal(places)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owned, err := json.Marshal(r.ownedPaths())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if words == nil {
|
||||
words = map[string]string{}
|
||||
}
|
||||
for _, w := range []string{ReadsSecretPlaces, 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[ReadsSecretPlaces] = string(places2)
|
||||
words[FilesOwnedPaths] = string(owned)
|
||||
return words, nil
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -46,71 +44,6 @@ func TestNodeFilesIsANodeSeatWithEveryVerbOfADR0394(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 := Manifest{Module: "files", Version: "1", Claims: []Claim{{Name: FilesSeat}},
|
||||
OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/files/broker"}}}
|
||||
host := Manifest{Module: "hostnames", Version: "1", Claims: []Claim{{Name: "node-hostname"}},
|
||||
Resources: []map[string]any{{"id": "hosts", "type": "file", "path": "/etc/hosts"}}}
|
||||
app := Manifest{Module: "app", Version: "1",
|
||||
Resources: []map[string]any{{"id": "conf", "type": "file", "path": "/etc/app/app.conf"}}}
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{files, host, app}}
|
||||
words, err := r.filesWords(files, nil, Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var places []string
|
||||
if err := json.Unmarshal([]byte(words[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[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 := Manifest{Module: "systemd-resolved", Version: "1", Claims: []Claim{{Name: ResolverSeat}},
|
||||
Resources: []map[string]any{{"id": "stub", "type": "file", "path": ResolverFile}}}
|
||||
uplink := Manifest{Module: "networkmanager", Version: "1", Claims: []Claim{{Name: "node-uplink"}},
|
||||
Resources: []map[string]any{{"id": "resolv", "type": "file", "path": ResolverFile}}}
|
||||
compiled := seats
|
||||
t.Cleanup(func() { seats = compiled })
|
||||
for _, order := range []string{"compiled", "reversed"} {
|
||||
if order == "reversed" {
|
||||
seats = append([]Seat(nil), compiled...)
|
||||
slices.Reverse(seats)
|
||||
}
|
||||
both := Resolution{Node: "anchor", Modules: []Manifest{files, uplink, resolver}}
|
||||
w, err := both.filesWords(files, nil, Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var o map[string]string
|
||||
if err := json.Unmarshal([]byte(w[FilesOwnedPaths]), &o); err != nil || o[ResolverFile] != "systemd-resolved ("+ResolverSeat+")" {
|
||||
t.Errorf("seat table %s: %s is owned by %q, want systemd-resolved (%s)", order, ResolverFile, o[ResolverFile], 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 := 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 := Resolution{Node: "anchor", Modules: []Manifest{files, carried}}.filesWords(files, nil, Rendering{})
|
||||
if err != nil || !strings.Contains(w[FilesOwnedPaths], `"/var/lib/de-spiegel/server.env":"de-spiegel (its own tools)"`) ||
|
||||
strings.Contains(w[FilesOwnedPaths], `"/var/lib/de-spiegel":`) {
|
||||
t.Errorf("a carried module does not own its manifest's paths alone: %v %v", w[FilesOwnedPaths], err)
|
||||
}
|
||||
if w, _ := r.filesWords(app, nil, Rendering{}); w[FilesOwnedPaths] != "" {
|
||||
t.Error("a module not holding node-files is told the owned paths")
|
||||
}
|
||||
}
|
||||
|
||||
// issue 900: restore, like every write verb, takes a reason and an override.
|
||||
func TestNodeFilesRestoreTakesIdAndReason(t *testing.T) {
|
||||
s, _ := SeatNamed(FilesSeat)
|
||||
|
||||
@@ -27,88 +27,6 @@ type Rule struct {
|
||||
Why []string
|
||||
}
|
||||
|
||||
// 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 (r Resolution) Filtering(computed map[string]Generator, ports map[string]map[int]int, exposure map[string]map[int]string) ([]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]*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].(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([]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 = &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([]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
|
||||
}
|
||||
|
||||
// ExposeSetting is the settings key that overrides a listen's source per node (novox/hq ADR 0046):
|
||||
//
|
||||
// {"expose": {"5432": "anywhere"}}
|
||||
@@ -166,43 +84,6 @@ func Exposure(m Manifest, layers []Layer) (map[int]string, error) {
|
||||
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 []Rule) []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{FromMachine: 0, FromMesh: 1, FromLocalNetwork: 2, FromEverywhere: 3}
|
||||
var out []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
|
||||
}
|
||||
|
||||
// AsNftables renders a rule set as a complete nftables configuration.
|
||||
//
|
||||
// Complete rather than a fragment: `nft -f` on this file replaces the table entirely, so what the
|
||||
@@ -216,345 +97,6 @@ func widest(rules []Rule) []Rule {
|
||||
// SSHPort is the one port a machine may never lose, whatever else it is told.
|
||||
const SSHPort = 22
|
||||
|
||||
// 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 []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, ", "), SSHPort))
|
||||
}
|
||||
if len(six) > 0 {
|
||||
b.WriteString(fmt.Sprintf("\t\tip6 saddr { %s } tcp dport %d accept\n",
|
||||
strings.Join(six, ", "), 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", 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 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 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 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 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 FromMachine:
|
||||
b.WriteString(fmt.Sprintf("\t\t# %s/%d is this machine only, and is not forwarded\n",
|
||||
rule.Protocol, rule.Port))
|
||||
case 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 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 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
|
||||
}
|
||||
|
||||
// PortsSetting is the settings key that gives a module's port a machine port on one node (novox/hq
|
||||
// ADR 0100):
|
||||
//
|
||||
@@ -569,224 +111,6 @@ const PortsSetting = "ports"
|
||||
// MeshWideLayer is what a layer set for the whole mesh is called, rather than for one node.
|
||||
const MeshWideLayer = "the mesh"
|
||||
|
||||
// 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 Manifest, layers []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 := 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[PortsSetting]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if layer.From == 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, 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, 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 == 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, EndpointsSetting, was, 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 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
|
||||
}
|
||||
|
||||
// ReachSetting is the settings key that says how far one of a module's endpoints reaches, per node
|
||||
// (novox/hq ADR 0138):
|
||||
//
|
||||
@@ -829,146 +153,6 @@ const (
|
||||
// reaches is every value, in the order a refusal lists them.
|
||||
var reaches = []string{ReachMachine, ReachInternal, ReachPublic, ReachBoth, 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 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[RouteEndpoint].(string); ok {
|
||||
if port, found := 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 ReachMachine:
|
||||
return FromMachine, true
|
||||
case ReachInternal:
|
||||
return FromMesh, true
|
||||
case ReachPublic, ReachBoth:
|
||||
return FromEverywhere, true
|
||||
case ReachLocalNetwork:
|
||||
return 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 == ReachPublic || reach == ReachBoth }
|
||||
|
||||
// WantsInternalName is whether a reach asks for the route's internal name to be composed.
|
||||
func WantsInternalName(reach string) bool {
|
||||
return reach == ReachInternal || reach == ReachBoth || reach == 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 Manifest, layers []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 := 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[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, 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, ReachSetting, ExposeSetting, port, ReachSetting, ReachSetting)
|
||||
}
|
||||
out[port] = reach
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// RouteEndpoint is the key a route contribution names the endpoint it serves with, instead of
|
||||
// repeating that endpoint's port (novox/hq ADR 0138).
|
||||
//
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -19,229 +17,8 @@ func TestAnOrdinaryManifestIsStillAccepted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The derivation is over the whole node, not one module.
|
||||
func TestTheRuleSetIsEveryAssignedModulesPorts(t *testing.T) {
|
||||
r := Resolution{Modules: []Manifest{
|
||||
{Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}},
|
||||
{Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}},
|
||||
}}
|
||||
rules := mustFilter(t, r, nil)
|
||||
if len(rules) != 2 {
|
||||
t.Fatalf("a node's rule set lost a module's ports: %+v", rules)
|
||||
}
|
||||
if rules[0].Port != 443 || rules[1].Port != 5432 {
|
||||
t.Fatalf("rules are not in a stable order, so every diff is unreadable: %+v", rules)
|
||||
}
|
||||
}
|
||||
|
||||
// Widening, and only widening.
|
||||
func TestAPortOpenToEveryoneIsNotAlsoRestrictedToTheMesh(t *testing.T) {
|
||||
r := Resolution{Modules: []Manifest{
|
||||
{Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}},
|
||||
{Module: "board", Listens: []Listening{{Port: 443, From: FromMesh}}},
|
||||
}}
|
||||
rules := mustFilter(t, r, nil)
|
||||
if len(rules) != 1 {
|
||||
t.Fatalf("the same port was rendered twice, once restricting nothing: %+v", rules)
|
||||
}
|
||||
if rules[0].From != FromEverywhere {
|
||||
t.Fatalf("the narrower rule won, which closes a port a module asked to open: %+v", rules[0])
|
||||
}
|
||||
if len(rules[0].Because) != 2 {
|
||||
t.Fatalf("the covered module was dropped along with its rule: %+v", rules[0])
|
||||
}
|
||||
}
|
||||
|
||||
// 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 := Resolution{Node: "anchor", Modules: []Manifest{
|
||||
{Module: "nftables", Filtering: &Filtering{Into: "/etc/mesh/filter.nft"}},
|
||||
{Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}},
|
||||
}}
|
||||
_, err := r.Declaration(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 := Resolution{Node: "anchor", Modules: []Manifest{
|
||||
{Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}},
|
||||
}}
|
||||
if _, err := r.Declaration(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 := Resolution{Modules: []Manifest{
|
||||
{Module: "firewall", Filtering: &Filtering{Into: "/etc/mesh/filter.nft"}},
|
||||
{Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}},
|
||||
}}
|
||||
out, err := r.Declaration(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 := Resolution{Modules: []Manifest{{
|
||||
Module: "firewall",
|
||||
Filtering: &Filtering{Into: "/etc/nftables.conf"},
|
||||
Resources: []map[string]any{
|
||||
{"id": "load", "type": "service", "unit": "nftables.service", "state": "running",
|
||||
"restart-on": []any{"filtering"}},
|
||||
},
|
||||
}}}
|
||||
out, err := r.Declaration(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 := Resolution{Modules: []Manifest{{
|
||||
Module: "networking", Computed: "mesh-network",
|
||||
OwnSecrets: OwnSecrets{"key": {Path: "/var/lib/mesh/key"}},
|
||||
}}}
|
||||
out, err := r.Declaration(Rendering{
|
||||
Needed: map[string]map[string]string{"networking": {"key": "sealed"}},
|
||||
Generators: map[string]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."+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 := Resolution{
|
||||
Node: "anchor", At: "anchor.internal",
|
||||
Modules: []Manifest{
|
||||
{Module: "registry", Provides: []Offer{{Name: "artifact-store", Scope: 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 := r.Declaration(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 := Resolution{
|
||||
Node: "alone",
|
||||
Modules: []Manifest{
|
||||
{Module: "registry", Provides: []Offer{{Name: "artifact-store", Scope: 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 := r.Declaration(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")
|
||||
}
|
||||
|
||||
// mustFilter is the rule set, refusing to continue if it could not be computed.
|
||||
func mustFilter(t *testing.T, r Resolution, computed map[string]Generator) []Rule {
|
||||
t.Helper()
|
||||
rules, err := r.Filtering(computed, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("no rule set could be computed: %v", err)
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
// opensOnHub is a generator that says a machine opens a port because of where it sits.
|
||||
type opensOnHub struct{ hub string }
|
||||
|
||||
@@ -255,240 +32,6 @@ func (o opensOnHub) Listens(node string) ([]Listening, error) {
|
||||
Why: "the private network"}}, nil
|
||||
}
|
||||
|
||||
// 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) ([]Listening, error) {
|
||||
return nil, errors.New("this machine's endpoint has no port in it")
|
||||
}
|
||||
|
||||
// A computed module contributes listens the way it contributes resources.
|
||||
//
|
||||
// A static field is one answer for every machine that runs the module, and a hub's own port is
|
||||
// not one of those: the machine that most needs filtering — the one facing the public internet —
|
||||
// is exactly the one a static answer gets wrong.
|
||||
func TestAComputedModuleOpensThePortItsMachineNeeds(t *testing.T) {
|
||||
network := Manifest{Module: "networking", Computed: "mesh-network"}
|
||||
gens := map[string]Generator{"mesh-network": opensOnHub{hub: "anchor"}}
|
||||
|
||||
onHub := mustFilter(t, Resolution{Node: "anchor", Modules: []Manifest{network}}, gens)
|
||||
if len(onHub) != 1 || onHub[0].Port != 51820 {
|
||||
t.Fatalf("the hub's own way onto the private network was not opened: %+v", onHub)
|
||||
}
|
||||
if onHub[0].Because[0] != "networking" {
|
||||
t.Fatalf("the rule does not name what caused it: %+v", onHub[0])
|
||||
}
|
||||
|
||||
// And the machine that dials out opens nothing, from the same module.
|
||||
elsewhere := mustFilter(t, Resolution{Node: "laptop", Modules: []Manifest{network}}, gens)
|
||||
if len(elsewhere) != 0 {
|
||||
t.Fatalf("a machine nothing dials opened a port because another machine needed one: %+v",
|
||||
elsewhere)
|
||||
}
|
||||
}
|
||||
|
||||
// What a module says and what was computed for it are both kept.
|
||||
func TestAComputedModulesOwnListensAreNotLost(t *testing.T) {
|
||||
network := Manifest{Module: "networking", Computed: "mesh-network",
|
||||
Listens: []Listening{{Port: 9, From: FromMesh, Why: "something the module always wants"}}}
|
||||
rules := mustFilter(t, Resolution{Node: "anchor", Modules: []Manifest{network}},
|
||||
map[string]Generator{"mesh-network": opensOnHub{hub: "anchor"}})
|
||||
if len(rules) != 2 {
|
||||
t.Fatalf("one of the two sources was dropped: %+v", rules)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 := Resolution{Node: "anchor",
|
||||
Modules: []Manifest{{Module: "networking", Computed: "mesh-network"}},
|
||||
}.Filtering(map[string]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 generator with nothing to say about ports is ordinary and must not be required to say so.
|
||||
func TestAGeneratorThatOpensNothingNeedsNoMethod(t *testing.T) {
|
||||
rules := mustFilter(t, Resolution{Node: "anchor",
|
||||
Modules: []Manifest{{Module: "names", Computed: "mesh-names"}}},
|
||||
map[string]Generator{"mesh-names": computedOnce{}})
|
||||
if len(rules) != 0 {
|
||||
t.Fatalf("a generator that says nothing about ports opened one: %+v", rules)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 := Resolution{Node: "anchor", Modules: []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"}},
|
||||
},
|
||||
}}}.Declaration(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]Manifest{
|
||||
"dnsmasq": {Module: "dnsmasq", Requires: []string{"resolver-data"}},
|
||||
"data": {Module: "data", Provides: []Offer{{Name: "resolver-data"}}},
|
||||
}, []string{"dnsmasq"}, Node{Name: "anchor"}, 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]Manifest{
|
||||
"top": {Module: "top", Requires: []string{"middle-thing"}},
|
||||
"middle": {Module: "middle", Provides: []Offer{{Name: "middle-thing"}}, Requires: []string{"bottom-thing"}},
|
||||
"bottom": {Module: "bottom", Provides: []Offer{{Name: "bottom-thing"}}},
|
||||
}, []string{"top"}, Node{Name: "anchor"}, 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]Manifest{
|
||||
"one": {Module: "one"},
|
||||
"two": {Module: "two"},
|
||||
"three": {Module: "three"},
|
||||
}, []string{"one", "two", "three"}, Node{Name: "anchor"}, 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]Manifest{
|
||||
"a": {Module: "a", Provides: []Offer{{Name: "from-a"}}, Requires: []string{"from-b"}},
|
||||
"b": {Module: "b", Provides: []Offer{{Name: "from-b"}}, Requires: []string{"from-a"}},
|
||||
}, []string{"a"}, Node{Name: "anchor"}, 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 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 := Manifest{Module: "postgres", Version: "1",
|
||||
Listens: []Listening{{Port: 5432, From: FromMesh, Why: "the database"}}}
|
||||
|
||||
base, err := Resolution{Node: "ace", Modules: []Manifest{postgres}}.Filtering(nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("filtering: %v", err)
|
||||
}
|
||||
if len(base) != 1 || base[0].From != FromMesh {
|
||||
t.Fatalf("without a setting, the default source is the mesh: %+v", base)
|
||||
}
|
||||
|
||||
expose := map[string]map[int]string{"postgres": {5432: FromEverywhere}}
|
||||
exposed, err := Resolution{Node: "novox", Modules: []Manifest{postgres}}.Filtering(nil, nil, expose)
|
||||
if err != nil {
|
||||
t.Fatalf("filtering: %v", err)
|
||||
}
|
||||
if len(exposed) != 1 || exposed[0].From != 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 := Manifest{Module: "postgres", Listens: []Listening{{Port: 5432, From: FromMesh}}}
|
||||
layer := func(port, source string) []Layer {
|
||||
return []Layer{{From: "node", Placement: map[string]any{ExposeSetting: map[string]any{port: source}}}}
|
||||
}
|
||||
|
||||
if _, err := Exposure(postgres, layer("5432", FromEverywhere)); err != nil {
|
||||
t.Fatalf("a valid exposure was refused: %v", err)
|
||||
}
|
||||
if _, err := Exposure(postgres, layer("6379", 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")
|
||||
}
|
||||
}
|
||||
|
||||
// chainBody is one chain's own lines, so an assertion cannot be satisfied by an identical line in
|
||||
// another chain.
|
||||
//
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -99,236 +98,3 @@ func TestTheFilterModuleNeverFlushesTheRuleset(t *testing.T) {
|
||||
// The package registry's port is the node's, like every other foundation port (novox/hq
|
||||
// 04-ISSUES/085, ADR 0100). The forge is reached through what it says it serves, and consumers —
|
||||
// the builder among them — are told that, rather than carrying a number of their own.
|
||||
|
||||
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, []Layer{{From: "anchor",
|
||||
Placement: map[string]any{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 := Resolution{Node: "anchor", Account: "op", PublicDomain: "example.org",
|
||||
Modules: []Manifest{forge, theRuntime(t)}, Needs: []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 := r.Declaration(Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{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, 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[RuntimeToolEnv]), &given); err != nil {
|
||||
t.Fatalf("the runtime's %s is not JSON: %q", RuntimeToolEnv, env[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 := Resolution{Node: "anchor", Account: "op", PublicDomain: "example.org",
|
||||
Modules: []Manifest{resolved, theRuntime(t)}, Needs: []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 := r.Declaration(Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{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, []Layer{{From: "anchor",
|
||||
Placement: map[string]any{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 Manifest) []Built {
|
||||
var out []Built
|
||||
for _, a := range forge.Build.Artifacts {
|
||||
out = append(out, Built{Name: a.Name, Kind: ArtifactBundle,
|
||||
Reference: 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 ...Built) Manifest {
|
||||
t.Helper()
|
||||
root, dir := moduleTree(t, module)
|
||||
m, err := ReadTreeManifestWritten(root, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("%s does not parse:\n%v", module, err)
|
||||
}
|
||||
if name := TemplatesArtifact(m); name != "" {
|
||||
read, err := 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, Built{Name: name, Kind: ArtifactArchive,
|
||||
Reference: 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
|
||||
}
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq issue 190, step 5 (ADR 0222): what the mesh computes for a module is held to the rule
|
||||
// every module is — no two modules on a machine declare one path, unit or name (a package may be declared by
|
||||
// several, novox/hq ADR 0303). The
|
||||
// private network once declared the container runtime's file and service beside the runtime's own
|
||||
// module, and nothing refused it, because the collision check only ever saw catalogue manifests.
|
||||
|
||||
func runtimesOwn() Manifest {
|
||||
return 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 := Resolution{Node: "workstation", Modules: []Manifest{
|
||||
{Module: "mesh-network", Computed: "mesh-network", Provides: Offers("private-network")},
|
||||
runtimesOwn(),
|
||||
}}
|
||||
_, err := r.Declaration(Rendering{Generators: map[string]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 := Resolution{Node: "workstation", Modules: []Manifest{
|
||||
{Module: "mesh-network", Computed: "mesh-network", Provides: Offers("private-network")},
|
||||
runtimesOwn(),
|
||||
}}
|
||||
gen := &fake{on: map[string]bool{"workstation": true}}
|
||||
out, err := r.Declaration(Rendering{Generators: map[string]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 := r.Declaration(Rendering{Generators: map[string]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 := Resolution{Node: "workstation", Modules: []Manifest{docker}}
|
||||
out, err := r.Declaration(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 = r.Declaration(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)
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A grant secret is read by whatever provisions, and that stopped being root (novox/hq issue 225).
|
||||
//
|
||||
// The mesh seals one credential per consumer beside the provider's contributions file. The
|
||||
// provider's harness reads both: the file to learn who asked, the secret to set their password.
|
||||
// While a module's own code ran in a container as root, a root-owned 0600 file was readable by
|
||||
// the thing that needed it. ADR 0198 moved that code under the node's runtime, which runs as the
|
||||
// operator's account — and the secret stayed root's.
|
||||
//
|
||||
// **The cost was silence.** The harness says `secret not readable yet`, which is true and
|
||||
// ordinary on the first pass, so four thousand refusals in three hours read as patience. No user
|
||||
// was ever created, and two consumers crash-looped against a database that had never heard of
|
||||
// them.
|
||||
//
|
||||
// The same reasoning is already written for a module's *own* secrets, three hundred lines above:
|
||||
// "a root-owned 0600 file is one that process cannot read". This is that rule reaching the other
|
||||
// kind of secret the mesh writes for a module.
|
||||
|
||||
// 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() Manifest {
|
||||
return Manifest{
|
||||
Module: "mongodb", Version: "1",
|
||||
Provides: 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: []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(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "operator"
|
||||
out, err := r.Declaration(Rendering{Grants: []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 = r.Declaration(Rendering{Grants: []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(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "operator"
|
||||
out, err := r.Declaration(Rendering{Grants: []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
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package catalogue
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -60,69 +59,3 @@ func TestTheGraphicalSessionsSeatsAreTheMeshsOwnWithTheirVerbs(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func displayServer(name, display string, claims ...string) Manifest {
|
||||
m := Manifest{Module: name, Provides: []Offer{{Name: display, Reach: ReachMachine}}}
|
||||
for _, c := range claims {
|
||||
m.Claims = append(m.Claims, Claim{Name: c})
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func windowManager() Manifest {
|
||||
return 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", DisplayServerSeat))
|
||||
got, err := Resolve(cat, []string{"xorg", "i3"}, workstation(), 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", DisplayServerSeat),
|
||||
displayServer("xwayland", "x11-display"))
|
||||
// Another machine runs xorg and says so to the world; it does not count.
|
||||
world := World{Offered: map[string][]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", DisplayServerSeat)),
|
||||
[]string{"i3"}, workstation(), 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(), 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"),
|
||||
Manifest{Module: "fake-x", Provides: Offers("x11-display")})
|
||||
_, err := Resolve(cat, []string{"xorg", "i3"}, workstation(), 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A container's supplementary groups may be an operator's value (novox/hq ADR 0480 clause 7, ADR 0348 §11,
|
||||
// issue 1636): `groups: ["${config:media-gid}"]`, each element a whole placeholder or a whole number, filled at
|
||||
// composition with the number the config value holds.
|
||||
|
||||
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 := []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, []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, []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"] = []Layer{{From: "anchor", Values: map[string]any{"media-gid": float64(2000)}}}
|
||||
composed, err := r.Compose(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 = r.Compose(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 := Manifest{Module: "n8n", Resources: []map[string]any{aContainerWithGroups("${config:media-gid}")}}
|
||||
plain := Manifest{Module: "plain", Resources: []map[string]any{
|
||||
{"id": "server", "type": "container", "name": "p", "image": "x@sha256:" + strings.Repeat("b", 64)}}}
|
||||
for _, m := range []Manifest{n8n, plain} {
|
||||
if got := impliedCapabilities(m); (m.Module == "n8n") != reflect.DeepEqual(got, []string{ContainerGroups}) {
|
||||
t.Errorf("%s implies %v", m.Module, got)
|
||||
}
|
||||
}
|
||||
shelf := shelf(n8n, plain)
|
||||
reads := Node{Name: "ace", Capabilities: map[string]bool{"container-runtime": true, ContainerGroups: true}}
|
||||
got, err := Resolve(shelf, []string{"n8n", "plain"}, reads, 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 := Node{Name: "g14", Capabilities: map[string]bool{"container-runtime": true}}
|
||||
got, err = Resolve(shelf, []string{"n8n", "plain"}, old, World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Unhostable) != 1 || got.Unhostable[0].Module != "n8n" || !reflect.DeepEqual(got.Unhostable[0].Missing, []string{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)
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -142,20 +138,6 @@ type ContributedOne struct {
|
||||
Values json.RawMessage `json:"values"`
|
||||
}
|
||||
|
||||
// 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 ContributedForm) (json.RawMessage, error) {
|
||||
f.V = ContributedVersion
|
||||
raw, err := json.Marshal(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := DecodeContributed(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// DecodeContributed reads PlanProvision.Contributed strictly: this version, no field it does not know, at least one
|
||||
// contribution, each local name once, each value a JSON object. What a value says is not judged here.
|
||||
func DecodeContributed(raw json.RawMessage) (ContributedForm, error) {
|
||||
@@ -189,40 +171,6 @@ func DecodeContributed(raw json.RawMessage) (ContributedForm, error) {
|
||||
return f, 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 []Needed, module string, handed bool, as string, contributes map[string]map[string]any,
|
||||
named map[string]bool) []PlanProvision {
|
||||
var out []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 := 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[SecretLocal(n.Name, n.Local)]) && n.IssuedToHolder {
|
||||
p.Name = 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 []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
|
||||
})
|
||||
}
|
||||
|
||||
// PlanSecret is one of the module's own secrets, by its name in the manifest; Path is the file holding it alone,
|
||||
// when the manifest's own-secrets places one.
|
||||
type PlanSecret struct {
|
||||
@@ -256,33 +204,6 @@ const (
|
||||
// `owner-at-runtime`).
|
||||
const OwnerAtRuntimeField = "owner-at-runtime"
|
||||
|
||||
// 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 RuntimeManaged
|
||||
case r["create-once"] == true:
|
||||
return RuntimeSeed
|
||||
}
|
||||
return 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 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
|
||||
}
|
||||
|
||||
// handedBy is what a moved module's build keeps, from its templates archive as built, or nil for a module not
|
||||
// moved.
|
||||
func handedBy(m Manifest, archive Built) *Handed {
|
||||
@@ -310,30 +231,6 @@ func handedBy(m Manifest, archive Built) *Handed {
|
||||
return h
|
||||
}
|
||||
|
||||
// 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 (with Rendering) handing(m Manifest) bool {
|
||||
return with.Handing(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 (with Rendering) Handing(m Manifest) bool {
|
||||
if hands, judged := with.Assigned[m.Module]; judged {
|
||||
return hands
|
||||
}
|
||||
return m.Moved && m.Handed != nil && with.Hands && with.Provisioner != ""
|
||||
}
|
||||
|
||||
// AssignmentHands is whether a module's value in the seat mesh-provisioner's plans (the assignments mirror's shape,
|
||||
// `{"provisioning": <Plan>}`) hands its module to the provisioner: Plan.Hands of the plan it carries, false with no
|
||||
// plan. A value that does not read is an error, never "not handed". The one rule the vault judges `own` by and a node
|
||||
@@ -348,20 +245,6 @@ func AssignmentHands(raw []byte) (bool, error) {
|
||||
return a.Provisioning != nil && a.Provisioning.Hands(), nil
|
||||
}
|
||||
|
||||
// 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 (r Resolution) RequiredBy(module string) string {
|
||||
by, _ := strings.CutPrefix(r.Because[module], requiredByWords)
|
||||
if by == r.Because[module] {
|
||||
return ""
|
||||
}
|
||||
return by
|
||||
}
|
||||
|
||||
// HandedProvisionField marks, between composing a handed module's credential file for something it requires and
|
||||
// placing it, one the vault keeps for this holder alone (issue 757): its value is the PlanProvision without its path.
|
||||
// Never on the wire.
|
||||
@@ -371,546 +254,8 @@ const HandedProvisionField = "handed-provision"
|
||||
// composition hands to the provisioner: its value is the secret's name. Never on the wire.
|
||||
const HandedSecretField = "handed-secret"
|
||||
|
||||
// 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 Manifest, dirs, accessPaths, facts map[string]string,
|
||||
placed map[string]Placement) (map[string]any, PlanSecret, error) {
|
||||
name := fmt.Sprint(r[HandedSecretField])
|
||||
out := map[string]any{}
|
||||
for k, v := range r {
|
||||
out[k] = v
|
||||
}
|
||||
delete(out, HandedSecretField)
|
||||
delete(out, "sealed")
|
||||
if err := dirInto(out, dirs, m.Module); err != nil {
|
||||
return nil, PlanSecret{}, err
|
||||
}
|
||||
if err := accessInto(out, accessPaths, m.Module); err != nil {
|
||||
return nil, PlanSecret{}, err
|
||||
}
|
||||
ownerInto(out, placed)
|
||||
if err := machineInto(out, facts, m.Module); err != nil {
|
||||
return nil, PlanSecret{}, err
|
||||
}
|
||||
if out["mode"] == nil {
|
||||
out["mode"] = "0600"
|
||||
}
|
||||
out[OwnerAtRuntimeField] = RuntimeOwned
|
||||
return out, 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", SettingField}
|
||||
|
||||
// SettingField is the settings form's word for a config placeholder's namespace.
|
||||
const SettingField = "setting"
|
||||
|
||||
// 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[TakesSettingsField]
|
||||
return merges || takes
|
||||
}
|
||||
|
||||
// mergingIn is whether the module's resource id is a merging file (mergesFile).
|
||||
func mergingIn(m 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 Manifest, dirs, accessPaths, facts map[string]string, placed map[string]Placement,
|
||||
with Rendering) (map[string]any, 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, TrustedField)
|
||||
delete(out, NamesOnPurpose)
|
||||
delete(out, SecretsInEnvironment)
|
||||
if err := dirInto(out, dirs, m.Module); err != nil {
|
||||
return nil, PlanFile{}, err
|
||||
}
|
||||
if err := accessInto(out, accessPaths, m.Module); err != nil {
|
||||
return nil, PlanFile{}, err
|
||||
}
|
||||
ownerInto(out, placed)
|
||||
if err := machineInto(out, facts, m.Module); err != nil {
|
||||
return nil, PlanFile{}, err
|
||||
}
|
||||
out[OwnerAtRuntimeField] = ownershipOf(out)
|
||||
path := fmt.Sprint(out["path"])
|
||||
return out, PlanFile{ID: id, Path: path, Template: m.Handed.Templates[id],
|
||||
OwnerAtRuntime: fmt.Sprint(out[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 Manifest, texts, items map[string]string, dirs, accessPaths, facts map[string]string,
|
||||
with Rendering, moved map[string]bool, backups backupPlacer) (map[string]string, []string, []string, []PlanCollection, error) {
|
||||
out := map[string]string{}
|
||||
var secrets, required []string
|
||||
var problems []string
|
||||
var collections []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 PlanCollection) bool { return c.Name == collection }):
|
||||
collections = append(collections, PlanCollection{Name: collection, Comment: comment})
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case slices.Contains(configNamespaces, kind):
|
||||
continue
|
||||
case kind == 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(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 == 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
|
||||
}
|
||||
|
||||
// PlacementKinds are the placeholder namespaces placement answers (ADR 0349 clause 2), as a plan's keys begin.
|
||||
var PlacementKinds = []string{"dir", "access", "port", "seat", "machine", BackupNamespace}
|
||||
|
||||
// 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 Manifest, dirs, accessPaths, facts map[string]string, with 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 Manifest, with Rendering) (PlanTemplates, error) {
|
||||
r := map[string]any{"id": "templates", "source": m.Handed.Source}
|
||||
if err := artifactsInto(r, m.Module, with); err != nil {
|
||||
return PlanTemplates{}, err
|
||||
}
|
||||
return 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]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][]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 Manifest, needs []Needed) bool {
|
||||
if _, started := h.plans[m.Module]; started {
|
||||
return false
|
||||
}
|
||||
if !slices.ContainsFunc(needs, func(n 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] = ConsumerIdentity(node, IdentitySource(m.Slug, m.Module))
|
||||
h.plans[m.Module] = 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 Manifest, dirs, accessPaths, facts map[string]string,
|
||||
with Rendering, caps map[string]bool, needs []Needed, backups backupPlacer) (map[string]bool, error) {
|
||||
if !with.handing(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 []PlanItem
|
||||
for _, it := range m.Items {
|
||||
template, kept := m.Handed.Items[it.key()]
|
||||
if !kept {
|
||||
return nil, fmt.Errorf("%s: its build kept no template for its item of %s", m.Module, it.key())
|
||||
}
|
||||
// **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[it.key()] = it.Content
|
||||
planned = append(planned, 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[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] = ConsumerIdentity(node, 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, PlanSecret{Name: name})
|
||||
}
|
||||
plan := 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 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 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 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 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][]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 []Needed) map[string]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 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, PlanSecret{Name: name, Path: named[name]})
|
||||
}
|
||||
h.plans[module] = plan
|
||||
}
|
||||
return h.plans
|
||||
}
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 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" +
|
||||
@@ -13,147 +7,3 @@ const giteaServerEnv = "GITEA__security__INTERNAL_TOKEN=${secret:internal-token}
|
||||
"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) 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) Resolution {
|
||||
t.Helper()
|
||||
r, err := Resolve(shelf(held(t, raw, builtBot(text)),
|
||||
Manifest{Module: "postgres", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeNode}}}),
|
||||
[]string{"bot"}, workstation(), 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 = []Needed{{Name: "postgres-database", For: "bot", Module: "postgres", From: "novox", At: "10.7.0.1",
|
||||
CredentialForm: 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 := []PlanProvision{{Name: "postgres-database", Provision: "postgres-database", Provider: "novox",
|
||||
ProviderModule: "postgres", CredentialForm: CredentialFormPair, At: "10.7.0.1", From: "novox",
|
||||
As: 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 []Resolution{databaseConsumer(t, "", false)} {
|
||||
_, err := unmoved.Compose(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 = r.Compose(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 module with a slug is told who it is as its binding file says, the slug first (issue 788).
|
||||
func TestAHandedModulesPlanEntryCarriesItsIdentityAsTheBindingFileGivesIt(t *testing.T) {
|
||||
c, _ := composeWith(t, databaseConsumer(t, "agent", true), handingTo("store:5000"))
|
||||
if got := c.Plans["bot"].Provisions[0].As; got != ConsumerIdentity("workstation", "agent") {
|
||||
t.Fatalf("the plan entry says %q", got)
|
||||
}
|
||||
// Nothing known of where the provider is: the entry says none.
|
||||
c, _ = composeWith(t, forgeReader(t, true, true), handingTo("store:5000"))
|
||||
if p := c.Plans["bot"].Provisions[0]; p.At != "" || p.From != "novox" {
|
||||
t.Fatalf("the entry says at %q from %q", p.At, p.From)
|
||||
}
|
||||
}
|
||||
|
||||
// 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")),
|
||||
Manifest{Module: "vault", Version: "1", Provides: []Offer{{Name: "secret", Scope: ScopeNode}}}),
|
||||
[]string{"bot"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
r.Needs = []Needed{
|
||||
{Name: "secret", Local: "internal-token", For: "bot", Module: "vault", From: "novox",
|
||||
CredentialForm: CredentialFormPair, IssuedToHolder: true, ProviderMoved: true},
|
||||
{Name: "secret", Local: "admin", For: "bot", Module: "vault", From: "novox",
|
||||
CredentialForm: 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]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 := withDatabase(t, moved, text, true).Compose(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 := withDatabase(t, unmoved, text, false).Compose(sealedForBot(with))
|
||||
if before == nil || before.Error() != err.Error() {
|
||||
t.Fatalf("%s: refused otherwise than the unmarked module: %v / %v", name, err, before)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A provider's consumers' credentials reach it in a file its machine's provisioner client fills (novox/hq ADR 0373,
|
||||
@@ -10,94 +9,8 @@ import (
|
||||
// file of mode 0600 owned at runtime; filled by the client alone, so a module not handed to it on a machine is
|
||||
// refused there rather than written the placeholder's text, or nothing.
|
||||
|
||||
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"}]}`
|
||||
consumersText = "${consumers:postgres-database}"
|
||||
)
|
||||
|
||||
func builtProvider(text string) []Built {
|
||||
return []Built{{Name: "files", Kind: ArtifactArchive,
|
||||
Reference: ArtifactStoreScheme + "db/files/blobs/sha256:" + strings.Repeat("f", 64),
|
||||
Digest: "sha256:" + strings.Repeat("f", 64), Texts: map[string]string{"consumers.json": text}}}
|
||||
}
|
||||
|
||||
func TestAHandedProvidersConsumersFileIsHandedToTheClient(t *testing.T) {
|
||||
r := notifierOn(t, held(t, handedProvider, builtProvider(consumersText)))
|
||||
c, got := composeWith(t, r, handingTo("store:5000"))
|
||||
if strings.Contains(got, "${consumers:") {
|
||||
t.Fatalf("the declaration carries the placeholder: %s", got)
|
||||
}
|
||||
plan := c.Plans["db"]
|
||||
if len(plan.Files) != 1 || plan.Files[0].Template != "consumers.json" || plan.Files[0].OwnerAtRuntime != RuntimeOwned {
|
||||
t.Fatalf("the plan fills %+v, want the consumers' file owned at runtime", plan.Files)
|
||||
}
|
||||
}
|
||||
|
||||
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 = r.Compose(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Where the provider is not handed, nothing fills the placeholder, and it is never written as its text or as an empty
|
||||
// list the provider would read as no consumers: the file is left out, said once, and the module composes exactly as
|
||||
// it would without the file, so a provider below the handing contract keeps working from its grant files (issue 780).
|
||||
func TestAProvidersConsumersFileIsLeftOutWhereItIsNotHanded(t *testing.T) {
|
||||
withFile := notifierOn(t, held(t, handedProvider, builtProvider(consumersText)))
|
||||
without := strings.Replace(handedProvider, `,
|
||||
{"id":"consumers","type":"file","path":"${dir:state}/consumers.json","mode":"0600","template":"files/consumers.json"}`, "", 1)
|
||||
if without == handedProvider {
|
||||
t.Fatal("the manifest without the file was not made")
|
||||
}
|
||||
bare := notifierOn(t, held(t, strings.Replace(strings.Replace(without, `"moved":true,`, "", 1),
|
||||
`"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},`, "", 1), nil))
|
||||
for name, with := range map[string]Rendering{
|
||||
"a node-engine that fills no file": {},
|
||||
"a node-engine that fills, no client": {Hands: true, ArtifactStore: "store:5000"},
|
||||
"a client, a node-engine that does not": {Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000"},
|
||||
} {
|
||||
c, got := composeWith(t, withFile, with)
|
||||
_, want := composeWith(t, bare, with)
|
||||
if got != want {
|
||||
t.Errorf("%s: composes\n%s\nwithout its file, it composed\n%s", name, got, want)
|
||||
}
|
||||
if strings.Contains(got, "consumers") {
|
||||
t.Errorf("%s: the declaration names the consumers' file: %s", name, got)
|
||||
}
|
||||
if len(c.HandedOnly) != 1 || !strings.Contains(c.HandedOnly[0], "left out, handed only") {
|
||||
t.Errorf("%s: said %q, want the one line saying it is left out", name, c.HandedOnly)
|
||||
}
|
||||
if len(c.Unplaced) != 0 || len(c.Filled) != 0 || len(c.Plans) != 0 {
|
||||
t.Errorf("%s: unplaced %v, filled %v, plans %v", name, c.Unplaced, c.Filled, c.Plans)
|
||||
}
|
||||
}
|
||||
// Where it is handed, the file is there and nothing is said.
|
||||
c, _ := composeWith(t, withFile, handingTo("store:5000"))
|
||||
if len(c.HandedOnly) != 0 || len(c.Filled) != 1 {
|
||||
t.Fatalf("handed: said %q, filled %v", c.HandedOnly, c.Filled)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// issue 831: a moved module's merging file is not handed (ADR 0349 clause 17, the lead's limit): the controller
|
||||
// fills and merges it as for a module not moved, and the module's other files stay handed.
|
||||
func TestAMovedModulesMergingFileIsFilledTheOldWayAndItsPlainFileIsHanded(t *testing.T) {
|
||||
raw := strings.Replace(handedNotifier, `{"id":"legacy"`,
|
||||
`{"id":"settings","type":"file","path":"${machine:account-home}/.config/notify/settings.json","owner":"${machine:account}","mode":"0644","template":"files/settings.json","merge":"json","takes-config":["width"]},
|
||||
{"id":"legacy"`, 1)
|
||||
moved := strings.Replace(raw, `"version":"1",`, `"version":"1","moved":true,`, 1)
|
||||
built := []Built{{Name: "files", Kind: ArtifactArchive,
|
||||
Reference: ArtifactStoreScheme + "notify/files/blobs/sha256:" + strings.Repeat("d", 64),
|
||||
Digest: "sha256:" + strings.Repeat("d", 64),
|
||||
Texts: map[string]string{"rc": notifierText, "settings.json": "{\"theme\": \"dark\"}\n"}}}
|
||||
cb, _ := composeWith(t, notifierOn(t, held(t, raw, built)), handingTo("store:5000"))
|
||||
c, got := composeWith(t, notifierOn(t, held(t, moved, built)), handingTo("store:5000"))
|
||||
var merged, plain map[string]any
|
||||
for _, r := range c.Resources {
|
||||
switch r["id"] {
|
||||
case "notify.settings":
|
||||
merged = r
|
||||
case "notify.configuration":
|
||||
plain = r
|
||||
}
|
||||
}
|
||||
if merged == nil || plain == nil {
|
||||
t.Fatalf("not composed: %s", got)
|
||||
}
|
||||
if text, _ := merged["content"].(string); !strings.Contains(text, `"theme"`) || merged["owner-at-runtime"] != nil {
|
||||
t.Fatalf("the merging file is not filled the old way: %v", merged)
|
||||
}
|
||||
if plain["content"] != nil || plain["owner-at-runtime"] != "owned" {
|
||||
t.Fatalf("the plain file is not handed: %v", plain)
|
||||
}
|
||||
for _, p := range c.Filled {
|
||||
if strings.HasSuffix(p, "settings.json") {
|
||||
t.Fatalf("the merging file is named among the filled files: %v", c.Filled)
|
||||
}
|
||||
}
|
||||
if len(c.Plans["notify"].Files) != 1 || c.Plans["notify"].Files[0].ID != "configuration" {
|
||||
t.Fatalf("the plan is %+v", c.Plans)
|
||||
}
|
||||
if len(c.Unplaced) > 0 {
|
||||
t.Fatalf("unplaced: %v", c.Unplaced)
|
||||
}
|
||||
// byte-identical to the not-moved composition
|
||||
for _, r := range cb.Resources {
|
||||
if r["id"] == "notify.settings" && !reflect.DeepEqual(r, merged) {
|
||||
t.Fatalf("the merging file differs from the not-moved composition:\nbefore %v\nnow %v", r, merged)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +1,11 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A handed module's plan names the provider serving each provision it requires (design 57 V5a, issue 760): the
|
||||
// module on the machine resolution chose for this consumer, and the local name it keeps the credential under where
|
||||
// it keeps several, so the vault, which reads the plan in the assignments mirror, sees a consumer bind and unbind
|
||||
// (novox/hq ADR 0349 clause 8). One entry per provision and local name, the same entry that names a credential
|
||||
// the vault keeps for it alone (issue 757).
|
||||
|
||||
// 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) 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) World {
|
||||
return World{Offered: map[string][]Provider{"postgres-database": {
|
||||
@@ -36,202 +13,3 @@ func twoStores(pin string) World {
|
||||
{Node: "home", At: "home.internal", Module: "store-two", Serves: map[string]any{"port": 5434}},
|
||||
}}, Pinned: map[string]Chosen{"postgres-database": {Node: pin}}}
|
||||
}
|
||||
|
||||
func consumerOn(t *testing.T, m Manifest, w World) Resolution {
|
||||
t.Helper()
|
||||
node := workstation()
|
||||
node.At = "workstation.internal"
|
||||
stores := shelf(m,
|
||||
Manifest{Module: "store", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh,
|
||||
Credential: &OfferCredential{Form: CredentialFormPair}}},
|
||||
Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}},
|
||||
Manifest{Module: "store-two", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh, Credential: &OfferCredential{Form: 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
|
||||
}
|
||||
|
||||
func TestAHandedModulesPlanNamesTheProviderServingEachProvision(t *testing.T) {
|
||||
c, _ := composeWith(t, consumerOn(t, notifierConsumer(t, true, false), twoStores("anchor")), handingTo("store:5000"))
|
||||
want := []PlanProvision{{Provision: "postgres-database", Provider: "anchor", ProviderModule: "store",
|
||||
CredentialForm: CredentialFormPair, At: "anchor.internal", From: "anchor", As: ConsumerIdentity("workstation", "notify")}}
|
||||
if got := c.Plans["notify"].Provisions; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("the plan names %+v, want %+v", got, want)
|
||||
}
|
||||
|
||||
// **A changed binding changes the plan**: pinned to the other store, the plan names it and not the first.
|
||||
c, _ = composeWith(t, consumerOn(t, notifierConsumer(t, true, false), twoStores("home")), handingTo("store:5000"))
|
||||
want = []PlanProvision{{Provision: "postgres-database", Provider: "home", ProviderModule: "store-two",
|
||||
CredentialForm: CredentialFormPair, At: "home.internal", From: "home", As: ConsumerIdentity("workstation", "notify")}}
|
||||
if got := c.Plans["notify"].Provisions; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("rebound, the plan names %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A consumer keeping one provider's credential under several local names is bound once per local name.
|
||||
func TestAHandedModulesPlanNamesEachLocalName(t *testing.T) {
|
||||
c, _ := composeWith(t, consumerOn(t, notifierConsumer(t, true, true), twoStores("anchor")), handingTo("store:5000"))
|
||||
want := []PlanProvision{
|
||||
{Provision: "postgres-database", Local: "cold", Provider: "anchor", ProviderModule: "store",
|
||||
CredentialForm: CredentialFormPair, At: "anchor.internal", From: "anchor", As: ConsumerIdentity("workstation", "notify")},
|
||||
{Provision: "postgres-database", Local: "hot", Provider: "anchor", ProviderModule: "store",
|
||||
CredentialForm: CredentialFormPair, At: "anchor.internal", From: "anchor", As: ConsumerIdentity("workstation", "notify")},
|
||||
}
|
||||
if got := c.Plans["notify"].Provisions; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("the plan names %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A module not moved, or moved where it is not handed, composes as before and has no plan.
|
||||
func TestAConsumerNotHandedComposesAsBefore(t *testing.T) {
|
||||
_, before := composeWith(t, consumerOn(t, notifierConsumer(t, false, false), twoStores("anchor")),
|
||||
handingTo("store:5000"))
|
||||
c, got := composeWith(t, consumerOn(t, notifierConsumer(t, true, false), twoStores("anchor")),
|
||||
Rendering{Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000"})
|
||||
if got != before {
|
||||
t.Fatalf("the moved consumer composes differently where it is not handed:\nbefore: %s\nnow: %s", before, got)
|
||||
}
|
||||
if c.Plans != nil {
|
||||
t.Fatalf("a consumer not handed has plans %+v", c.Plans)
|
||||
}
|
||||
}
|
||||
|
||||
// A credential the vault keeps for the holder alone is its provision's one entry, with its provider's module too.
|
||||
func TestAHandedModulesIssuedCredentialIsItsProvisionsOneEntry(t *testing.T) {
|
||||
r := forgeReader(t, true, true)
|
||||
r.Needs = append(r.Needs, Needed{Name: "s3-bucket", From: "ace", For: "bot", Module: "minio"},
|
||||
Needed{Name: "openai", From: "personal", For: "bot", ByRecord: true})
|
||||
c, _ := composeWith(t, r, handingTo("store:5000"))
|
||||
got := c.Plans["bot"].Provisions
|
||||
if len(got) != 2 || got[0].Provision != "forge-read" || got[0].Name != "forge-read" ||
|
||||
got[0].ProviderModule != "forge" || got[0].Path == "" ||
|
||||
!reflect.DeepEqual(got[1], PlanProvision{Provision: "s3-bucket", Provider: "ace", ProviderModule: "minio",
|
||||
From: "ace", As: ConsumerIdentity("workstation", "bot")}) {
|
||||
t.Fatalf("the plan names %+v: one entry per provision, the issued credential's with its name and file, "+
|
||||
"none for a licence", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A consumer's plan entry carries what its manifest contributes to that provision (issue 776), so the provider's
|
||||
// helper learns a database's name from the bus; a consumer contributing nothing has the entry it had.
|
||||
func TestAHandedModulesPlanEntryCarriesWhatItContributesToItsProvider(t *testing.T) {
|
||||
m := notifierConsumer(t, true, false)
|
||||
m.Contributes = map[string]map[string]any{"postgres-database": {"name": "nextcloud"}}
|
||||
c, _ := composeWith(t, consumerOn(t, m, twoStores("anchor")), handingTo("store:5000"))
|
||||
want := []PlanProvision{{Provision: "postgres-database", Provider: "anchor", ProviderModule: "store",
|
||||
CredentialForm: CredentialFormPair, Contributes: map[string]any{"name": "nextcloud"}, At: "anchor.internal", From: "anchor", As: ConsumerIdentity("workstation", "notify"),
|
||||
// What the binding file says as `${bound:postgres-database:name}` (issue 792).
|
||||
Names: map[string]string{"name": "nextcloud"},
|
||||
// And what a received file carried of it, composed, with where its machine is: opaque bytes (issue 774).
|
||||
Contributed: json.RawMessage(`{"v":1,"at":"workstation.internal","each":[{"values":{"name":"nextcloud"}}]}`)}}
|
||||
if got := c.Plans["notify"].Provisions; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("the plan names %+v, want %+v", got, want)
|
||||
}
|
||||
|
||||
m = notifierConsumer(t, true, false)
|
||||
c, _ = composeWith(t, consumerOn(t, m, twoStores("anchor")), handingTo("store:5000"))
|
||||
raw, err := json.Marshal(c.Plans["notify"].Provisions)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const before = `[{"provision":"postgres-database","provider":"anchor","provider-module":"store",` +
|
||||
`"credential-form":"pair","at":"anchor.internal","from":"anchor","as":"mesh_workstation_notify"}]`
|
||||
if string(raw) != before {
|
||||
t.Fatalf("a consumer contributing nothing has the entry %s, want %s", raw, before)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 := r.ownNames(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)
|
||||
}
|
||||
}
|
||||
|
||||
// The flag a provides entry carries, `"answered-by": "helper"`, is read and written back, and refused with any other
|
||||
// word (issue 792). It is no part of moving the module: the flag alone hands none of the module's files, and the
|
||||
// module composes byte for byte as it did without it.
|
||||
func TestAnOfferAnsweredByTheHelperIsReadAndHandsNoFile(t *testing.T) {
|
||||
with := strings.Replace(handedNotifier, `"version":"1",`,
|
||||
`"version":"1","provides":[{"name":"route","scope":"mesh","answered-by":"helper"}],`, 1)
|
||||
without := strings.Replace(handedNotifier, `"version":"1",`,
|
||||
`"version":"1","provides":[{"name":"route","scope":"mesh"}],`, 1)
|
||||
flagged := held(t, with, builtNotifier())
|
||||
if flagged.Moved || len(flagged.Provides) != 1 || flagged.Provides[0].AnsweredBy != AnsweredByHelper {
|
||||
t.Fatalf("the flag was read as %+v (moved %v)", flagged.Provides, flagged.Moved)
|
||||
}
|
||||
if flagged.Handed != nil {
|
||||
t.Fatalf("the flag alone kept what a moved module's build keeps: %+v", flagged.Handed)
|
||||
}
|
||||
raw, err := json.Marshal(flagged.Provides[0])
|
||||
if err != nil || !strings.Contains(string(raw), `"answered-by":"helper"`) {
|
||||
t.Fatalf("written back as %s: %v", raw, err)
|
||||
}
|
||||
if _, err := decodeForTest([]byte(strings.Replace(with, `"helper"`, `"harness"`, 1))); err == nil {
|
||||
t.Fatal("an unknown answerer was accepted")
|
||||
}
|
||||
c1, before := composeWith(t, notifierOn(t, held(t, without, builtNotifier())), handingTo("store:5000"))
|
||||
c2, got := composeWith(t, notifierOn(t, flagged), handingTo("store:5000"))
|
||||
if got != before || len(c2.Filled) > 0 || c2.Plans != nil || c1.Plans != nil {
|
||||
t.Fatalf("the flag changed what is composed or handed:\nbefore: %s\nnow: %s\n%v %v", before, got, c2.Filled, c2.Plans)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -19,29 +18,6 @@ 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 Manifest, assigned bool) 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(), 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
|
||||
}
|
||||
|
||||
// resourcesOf is the module's resources in a composition, as JSON.
|
||||
func resourcesOf(t *testing.T, c Composed, module string) string {
|
||||
t.Helper()
|
||||
@@ -57,65 +33,3 @@ func resourcesOf(t *testing.T, c Composed, module string) string {
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func TestAModulePulledInIsHandedLikeAnAssignedOne(t *testing.T) {
|
||||
for name, m := range map[string]struct {
|
||||
module string
|
||||
held Manifest
|
||||
with Rendering
|
||||
}{
|
||||
"its templated file": {"notify", held(t, providing(notifierMoved()), builtNotifier()), handingTo("store:5000")},
|
||||
"its own secret": {"bot", held(t, providing(botMoved()), builtBot(botText)),
|
||||
sealedForBot(handingTo("store:5000"))},
|
||||
"its item": {"prompt", held(t, providing(contributorP10k),
|
||||
builtWith("prompt", map[string]string{"zsh": contributorText})), handingTo("store:5000")},
|
||||
} {
|
||||
assigned, _ := composeWith(t, onWorkstation(t, m.held, true), m.with)
|
||||
pulled, got := composeWith(t, onWorkstation(t, m.held, false), m.with)
|
||||
plan, handed := pulled.Plans[m.module]
|
||||
if !handed {
|
||||
t.Errorf("%s: a pulled-in moved module is not handed: plans %+v", name, pulled.Plans)
|
||||
continue
|
||||
}
|
||||
if !reflect.DeepEqual(plan, assigned.Plans[m.module]) || !reflect.DeepEqual(pulled.Filled, assigned.Filled) {
|
||||
t.Errorf("%s: pulled in, its plan is %+v and the filled files %v; assigned, %+v and %v", name, plan,
|
||||
pulled.Filled, assigned.Plans[m.module], assigned.Filled)
|
||||
}
|
||||
if a, p := resourcesOf(t, assigned, m.module), resourcesOf(t, pulled, m.module); a != p {
|
||||
t.Errorf("%s: pulled in, its resources are\n%s\nassigned,\n%s", name, p, a)
|
||||
}
|
||||
if strings.Contains(got, "sealed-to-the-node") || strings.Contains(got, "width = ") {
|
||||
t.Errorf("%s: the controller filled or sealed something of a handed module: %s", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModulePulledInAndNotMovedComposesAsBefore(t *testing.T) {
|
||||
for name, m := range map[string]Manifest{
|
||||
"a file": held(t, providing(handedNotifier), builtNotifier()),
|
||||
"a secret": held(t, providing(handedBot), builtBot(botText)),
|
||||
} {
|
||||
_, before := composeWith(t, onWorkstation(t, m, false), sealedForBot(Rendering{ArtifactStore: "store:5000"}))
|
||||
c, got := composeWith(t, onWorkstation(t, m, false), sealedForBot(handingTo("store:5000")))
|
||||
if got != before || c.Plans != nil || len(c.Filled) > 0 {
|
||||
t.Errorf("%s: a pulled-in module not moved composes differently where the machine hands:\nbefore: %s\nnow: %s"+
|
||||
"\nplans %v, filled %v", name, before, got, c.Plans, c.Filled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 := onWorkstation(t, m, false).RequiredBy("notify"); by != "desk" {
|
||||
t.Errorf("pulled in, notify is required by %q, want desk", by)
|
||||
}
|
||||
assigned := onWorkstation(t, m, true)
|
||||
if by := assigned.RequiredBy("notify"); by != "" {
|
||||
t.Errorf("assigned, notify is required by %q", by)
|
||||
}
|
||||
if by := assigned.RequiredBy("absent"); by != "" {
|
||||
t.Errorf("a module not on the machine is required by %q", by)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A moved module's own secrets come from the vault (novox/hq ADR 0349 clauses 7 and 11, design 57 V4, issue 647):
|
||||
@@ -12,21 +9,6 @@ import (
|
||||
// without one and filled by the client, and the plan names every secret its templates and those files need, by
|
||||
// name. Where it is not handed, and for every module not moved, the composition is byte for byte what it was.
|
||||
|
||||
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"}}]}`
|
||||
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) []Built {
|
||||
return []Built{{Name: "files", Kind: ArtifactArchive,
|
||||
Reference: ArtifactStoreScheme + "bot/files/blobs/sha256:" + strings.Repeat("e", 64),
|
||||
@@ -38,212 +20,3 @@ func sealedForBot(with Rendering) 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[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 := []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 := r.Compose(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 := []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"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A secret only a template names is in the plan by name alone.
|
||||
func TestASecretOnlyATemplateNamesIsPlannedByName(t *testing.T) {
|
||||
raw := strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"},`,
|
||||
`"own-secrets":{"token":"${dir:state}/token","api-key":"${dir:state}/api-key"},`, 1)
|
||||
r := notifierOn(t, held(t, raw, builtBot(botText+"key = ${secret:api-key}\n")))
|
||||
c, _ := composeWith(t, r, handingTo("store:5000"))
|
||||
names := []string{}
|
||||
for _, s := range c.Plans["bot"].Secrets {
|
||||
names = append(names, s.Name)
|
||||
}
|
||||
if strings.Join(names, " ") != "api-key token" {
|
||||
t.Fatalf("the plan names %v", c.Plans["bot"].Secrets)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing changes for a module that has not moved, nor for a moved one where it is not handed: its secrets are
|
||||
// sealed into the declaration as before.
|
||||
func TestAMovedModulesSecretsAreSealedAsBeforeWhereItIsNotHanded(t *testing.T) {
|
||||
_, before := composeWith(t, notifierOn(t, held(t, handedBot, builtBot(botText))),
|
||||
sealedForBot(handingTo("store:5000")))
|
||||
if !strings.Contains(before, "sealed-to-the-node") {
|
||||
t.Fatalf("the unmoved module is not sealed the old way: %s", before)
|
||||
}
|
||||
for name, with := range map[string]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"},
|
||||
// Issue 774: where the vault judged the module not handed, the node composes it so, whatever else it reads.
|
||||
"the vault judged it not handed": {Hands: true, Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000",
|
||||
Assigned: map[string]bool{"bot": false}},
|
||||
} {
|
||||
c, got := composeWith(t, notifierOn(t, held(t, botMoved(), builtBot(botText))), sealedForBot(with))
|
||||
if got != before {
|
||||
t.Errorf("%s: the moved module composes differently from its unmarked twin:\nbefore: %s\nnow: %s",
|
||||
name, before, got)
|
||||
}
|
||||
if len(c.Filled) > 0 || c.Plans != nil {
|
||||
t.Errorf("%s: something was handed: %v %v", name, c.Filled, c.Plans)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A credential of what a handed module requires that the vault keeps for it alone (issue 757): the forge's read
|
||||
// token for one agent, issued by the forge's software per holder. Where the module is handed, its file is declared
|
||||
// placed and without a value, and the plan names it with its provision and provider; anywhere else, and for a
|
||||
// credential the mesh makes, the composition is what it was.
|
||||
func forgeReader(t *testing.T, moved bool, issued bool) Resolution {
|
||||
t.Helper()
|
||||
raw := strings.Replace(handedBot, `"own-secrets":`,
|
||||
`"requires":[{"name":"forge-read","optional":true}],"secrets":{"forge-read":"${dir:state}/forge.secret"},"own-secrets":`, 1)
|
||||
if moved {
|
||||
raw = strings.Replace(raw, `"version":"1",`, `"version":"1","moved":true,`, 1)
|
||||
}
|
||||
r := notifierOn(t, held(t, raw, builtBot(botText)))
|
||||
r.Needs = append(r.Needs, Needed{Name: "forge-read", From: "novox", For: "bot", Module: "forge",
|
||||
Sealed: "sealed-forge-read", IssuedToHolder: issued})
|
||||
return r
|
||||
}
|
||||
|
||||
func TestAHandedModulesCredentialIssuedForItIsNamedInItsPlanAndNoValueIsSealed(t *testing.T) {
|
||||
c, got := composeWith(t, forgeReader(t, true, true), handingTo("store:5000"))
|
||||
if strings.Contains(got, "sealed") || strings.Contains(got, HandedProvisionField) {
|
||||
t.Fatalf("a handed module's declaration carries a sealed value or the marker: %s", got)
|
||||
}
|
||||
var file map[string]any
|
||||
for _, res := range c.Resources {
|
||||
if res["id"] == "bot."+SecretID("forge-read") {
|
||||
file = res
|
||||
}
|
||||
}
|
||||
if file == nil {
|
||||
t.Fatalf("the credential's file is not declared: %s", got)
|
||||
}
|
||||
path, _ := file["path"].(string)
|
||||
if path == "" || strings.Contains(path, "${") || file["mode"] != "0600" || file["owner-at-runtime"] != "owned" {
|
||||
t.Fatalf("the credential's file is declared %v: placed, 0600, owned at runtime, no value", file)
|
||||
}
|
||||
if !strings.Contains(strings.Join(c.Filled, " "), path) {
|
||||
t.Fatalf("the credential's file is not among the filled files %v", c.Filled)
|
||||
}
|
||||
want := []PlanProvision{{Name: "forge-read", Provision: "forge-read", Provider: "novox", ProviderModule: "forge",
|
||||
Path: path, From: "novox", As: ConsumerIdentity("workstation", "bot")}}
|
||||
if !reflect.DeepEqual(c.Plans["bot"].Provisions, want) {
|
||||
t.Fatalf("the plan names %+v, want %+v", c.Plans["bot"].Provisions, want)
|
||||
}
|
||||
// Issue 1126 (novox's seed: "gitea: its credential of postgres-database is handed to the provisioner and the module
|
||||
// is not"): held, the module is composed as its stub, with no template and no item, so nothing is handed. Its
|
||||
// credential and its own secret are what runs of it, left out, and the composition is not refused.
|
||||
r := forgeReader(t, true, true)
|
||||
for i := range r.Modules {
|
||||
if r.Modules[i].Module == "bot" {
|
||||
h := *r.Modules[i].Handed
|
||||
h.Templates = nil
|
||||
r.Modules[i].Handed, r.Modules[i].Stubbed, r.Modules[i].Build = &h, true, nil
|
||||
r.Modules[i].Resources = slices.DeleteFunc(r.Modules[i].Resources, func(x map[string]any) bool { return x["type"] != "directory" })
|
||||
}
|
||||
}
|
||||
c, got = composeWith(t, r, handingTo("store:5000"))
|
||||
if strings.Contains(got, SecretID("forge-read")) || strings.Contains(got, NeedID("token")) || c.Plans["bot"].Provisions != nil {
|
||||
t.Fatalf("a held module's handed credential or own secret is composed: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACredentialIssuedForAHolderIsSealedAsBeforeWhereItsModuleIsNotHanded(t *testing.T) {
|
||||
_, before := composeWith(t, forgeReader(t, false, false), sealedForBot(handingTo("store:5000")))
|
||||
if !strings.Contains(before, "sealed-forge-read") {
|
||||
t.Fatalf("the unmoved module's credential is not sealed the old way: %s", before)
|
||||
}
|
||||
for why, r := range map[string]Resolution{
|
||||
"a module not moved": forgeReader(t, false, true),
|
||||
"a moved module whose credential is made": forgeReader(t, true, false),
|
||||
} {
|
||||
c, got := composeWith(t, r, sealedForBot(handingTo("store:5000")))
|
||||
if !strings.Contains(got, "sealed-forge-read") {
|
||||
t.Errorf("%s: the credential is not sealed the old way: %s", why, got)
|
||||
}
|
||||
// Its provider is named (issue 760), and no credential for the client to fill.
|
||||
if p, ok := c.Plans["bot"]; ok && slices.ContainsFunc(p.Provisions, func(pr PlanProvision) bool {
|
||||
return pr.Name != "" || pr.Path != ""
|
||||
}) {
|
||||
t.Errorf("%s: the plan names a credential to fill: %+v", why, p.Provisions)
|
||||
}
|
||||
}
|
||||
c, got := composeWith(t, forgeReader(t, true, true), sealedForBot(Rendering{Provisioner: "mesh-provisioner-client",
|
||||
ArtifactStore: "store:5000"}))
|
||||
if !strings.Contains(got, "sealed-forge-read") || c.Plans != nil {
|
||||
t.Errorf("where the engine does not take a filled file alone, the credential is not sealed the old way: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A handed consumer's own login from a provider that gives each consumer one (issue 779): the same entry as for an
|
||||
// issued credential, with the provider's form on it, and nothing sealed; where the consumer is not handed, the
|
||||
// login is sealed as it was.
|
||||
func TestAHandedConsumersOwnLoginFromAPerLoginProviderIsNamedInItsPlanAndNoValueIsSealed(t *testing.T) {
|
||||
pair := func(handed bool) Resolution {
|
||||
r := forgeReader(t, true, handed)
|
||||
r.Needs[len(r.Needs)-1].CredentialForm = CredentialFormPair
|
||||
return r
|
||||
}
|
||||
c, got := composeWith(t, pair(true), handingTo("store:5000"))
|
||||
if strings.Contains(got, "sealed") || strings.Contains(got, HandedProvisionField) {
|
||||
t.Fatalf("a handed consumer's declaration carries its login sealed or the marker: %s", got)
|
||||
}
|
||||
var path string
|
||||
for _, res := range c.Resources {
|
||||
if res["id"] == "bot."+SecretID("forge-read") {
|
||||
path, _ = res["path"].(string)
|
||||
if res["mode"] != "0600" || res["owner-at-runtime"] != "owned" || res["content"] != nil || res["sealed"] != nil {
|
||||
t.Fatalf("the login's file is declared %v: placed, 0600, owned at runtime, no value", res)
|
||||
}
|
||||
}
|
||||
}
|
||||
if path == "" || !strings.Contains(strings.Join(c.Filled, " "), path) {
|
||||
t.Fatalf("the login's file %q is not declared and filled: %v", path, c.Filled)
|
||||
}
|
||||
want := []PlanProvision{{Name: "forge-read", Provision: "forge-read", Provider: "novox", ProviderModule: "forge",
|
||||
CredentialForm: CredentialFormPair, Path: path, From: "novox", As: ConsumerIdentity("workstation", "bot")}}
|
||||
if !reflect.DeepEqual(c.Plans["bot"].Provisions, want) {
|
||||
t.Fatalf("the plan names %+v, want %+v", c.Plans["bot"].Provisions, want)
|
||||
}
|
||||
// Not marked as the vault's, the login is sealed as before.
|
||||
_, before := composeWith(t, pair(false), sealedForBot(handingTo("store:5000")))
|
||||
if !strings.Contains(before, "sealed-forge-read") {
|
||||
t.Fatalf("a consumer whose login the vault does not keep for it has it sealed no more: %s", before)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,170 +1,10 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A module marked moved hands its templated files to the provisioner (novox/hq ADR 0349 §4, §6, §17, design 57 P6,
|
||||
// issue 672): on a machine whose node-engine takes a filled file on the client's word and that runs a client, each
|
||||
// is declared placed and without its text, named among the filled files, and planned for the client; anywhere
|
||||
// else, and for every module not marked, the composition is byte for byte what it was.
|
||||
|
||||
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"]}]}`
|
||||
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() []Built {
|
||||
return []Built{{Name: "files", Kind: ArtifactArchive,
|
||||
Reference: 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 Manifest) Resolution {
|
||||
t.Helper()
|
||||
r, err := Resolve(shelf(m), []string{m.Module}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
return r
|
||||
}
|
||||
|
||||
func composeWith(t *testing.T, r Resolution, with Rendering) (Composed, string) {
|
||||
t.Helper()
|
||||
c, err := r.Compose(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) Rendering {
|
||||
return Rendering{Hands: true, Provisioner: "mesh-provisioner-client", ArtifactStore: store}
|
||||
}
|
||||
|
||||
func TestAMovedModulesBuildKeepsWhatTheProvisionerNeeds(t *testing.T) {
|
||||
m := held(t, notifierMoved(), builtNotifier())
|
||||
want := &Handed{Source: ArtifactStoreScheme + "notify/files/blobs/sha256:" + strings.Repeat("d", 64),
|
||||
Digest: "sha256:" + strings.Repeat("d", 64), Templates: map[string]string{"configuration": "rc"}}
|
||||
if !reflect.DeepEqual(m.Handed, want) {
|
||||
t.Fatalf("the build kept %+v, want %+v", m.Handed, want)
|
||||
}
|
||||
if notMoved := held(t, handedNotifier, builtNotifier()); notMoved.Handed != nil {
|
||||
t.Fatalf("a module not marked moved kept %+v", notMoved.Handed)
|
||||
}
|
||||
}
|
||||
|
||||
// Nothing changes for a module that has not moved, nor for a moved one where it cannot be handed.
|
||||
func TestAMovedModuleComposesAsBeforeWhereItIsNotHanded(t *testing.T) {
|
||||
_, before := composeWith(t, notifierOn(t, held(t, handedNotifier, builtNotifier())), handingTo("store:5000"))
|
||||
for name, with := range map[string]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"},
|
||||
} {
|
||||
c, got := composeWith(t, notifierOn(t, held(t, notifierMoved(), builtNotifier())), with)
|
||||
if got != before {
|
||||
t.Errorf("%s: the moved module composes differently from its unmarked twin:\nbefore: %s\nnow: %s",
|
||||
name, before, got)
|
||||
}
|
||||
if len(c.Filled) > 0 || c.Plans != nil {
|
||||
t.Errorf("%s: something was handed: %v %v", name, c.Filled, c.Plans)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(before, `width = 250`) {
|
||||
t.Fatalf("the unmarked module is not filled the old way: %s", before)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMovedModulesTemplatedFileIsHandedToTheProvisioner(t *testing.T) {
|
||||
c, got := composeWith(t, notifierOn(t, held(t, notifierMoved(), builtNotifier())), handingTo("store:5000"))
|
||||
var file, legacy, dir map[string]any
|
||||
for _, r := range c.Resources {
|
||||
switch r["id"] {
|
||||
case "notify.configuration":
|
||||
file = r
|
||||
case "notify.legacy":
|
||||
legacy = r
|
||||
case "notify.configuration-dir":
|
||||
dir = r
|
||||
}
|
||||
}
|
||||
if file == nil || legacy == nil || dir == nil {
|
||||
t.Fatalf("not composed: %s", got)
|
||||
}
|
||||
wantFile := map[string]any{"id": "notify.configuration", "type": "file", "path": "/home/op/.config/notify/rc",
|
||||
"owner": "op", "mode": "0644", "owner-at-runtime": "owned"}
|
||||
if !reflect.DeepEqual(file, wantFile) {
|
||||
t.Fatalf("the handed file is declared %v, want %v (placed, no text, owned at runtime)", file, wantFile)
|
||||
}
|
||||
if legacy["content"] != "plain = yes\n" || legacy["owner-at-runtime"] != nil {
|
||||
t.Fatalf("a file with its text inline is not composed the old way: %v", legacy)
|
||||
}
|
||||
if dir["path"] != "/home/op/.config/notify" {
|
||||
t.Fatalf("the module's other resources are not placed as before: %v", dir)
|
||||
}
|
||||
if strings.Contains(got, "width = ") {
|
||||
t.Fatalf("the controller filled the handed file: %s", got)
|
||||
}
|
||||
if !reflect.DeepEqual(c.Filled, []string{"/home/op/.config/notify/rc"}) {
|
||||
t.Fatalf("filled files %v", c.Filled)
|
||||
}
|
||||
want := map[string]Plan{"notify": {
|
||||
Templates: PlanTemplates{Source: "http://store:5000/v2/notify/files/blobs/sha256:" + strings.Repeat("d", 64),
|
||||
Digest: "sha256:" + strings.Repeat("d", 64)},
|
||||
Files: []PlanFile{{ID: "configuration", Path: "/home/op/.config/notify/rc", Template: "rc",
|
||||
OwnerAtRuntime: "owned", Takes: "reload-on"}},
|
||||
Placement: map[string]string{"machine:account-home": "/home/op"},
|
||||
}}
|
||||
if !reflect.DeepEqual(c.Plans, want) {
|
||||
t.Fatalf("the plans are %+v, want %+v", c.Plans, want)
|
||||
}
|
||||
}
|
||||
|
||||
// What `module check` and the merge gate judge: a moved module read from its tree, its text inline. It is still
|
||||
// marked moved, and composes as before, since nothing of its build's archive is there to hand.
|
||||
func TestAMovedModuleReadFromItsTreeIsJudgedAsItComposesUnhanded(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "files"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "files", "rc"), []byte(notifierText), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := decodeForTest([]byte(notifierMoved()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
filled, err := WithTemplatesFrom(m, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("a moved module read from its tree is refused: %v", err)
|
||||
}
|
||||
if !filled.Moved || filled.Handed != nil {
|
||||
t.Fatalf("read from its tree: moved %v, handed %+v", filled.Moved, filled.Handed)
|
||||
}
|
||||
_, got := composeWith(t, notifierOn(t, filled), handingTo("store:5000"))
|
||||
_, want := composeWith(t, notifierOn(t, held(t, handedNotifier, builtNotifier())), handingTo("store:5000"))
|
||||
if got != want {
|
||||
t.Fatalf("read from its tree it composes differently:\nwant: %s\ngot: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,52 +195,3 @@ func OwnRuntimeHealth(m Manifest) (map[string]any, bool) {
|
||||
}
|
||||
return map[string]any{"id": m.Module + "." + OwnRuntimeID(), "type": "process", HealthField: m.RuntimeHealth}, true
|
||||
}
|
||||
|
||||
// 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 Manifest, with Rendering) {
|
||||
if _, has := resource[HealthField]; !has {
|
||||
return
|
||||
}
|
||||
if !with.ReadsHealth {
|
||||
delete(resource, HealthField)
|
||||
return
|
||||
}
|
||||
h, _, _ := 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 := EndpointPort(m, h.Endpoint); ok {
|
||||
out["port"] = with.machinePort(m.Module, port)
|
||||
}
|
||||
}
|
||||
if h.Kind == 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[HealthField] = out
|
||||
}
|
||||
|
||||
@@ -67,45 +67,6 @@ func TestAWellFormedHealthIsAccepted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 Rendering) map[string]any {
|
||||
out, err := Resolution{Node: "laptop", Modules: []Manifest{m}}.Declaration(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(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(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(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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheUndeclaredAreTheLongRunningWithoutHealth(t *testing.T) {
|
||||
m, err := decodeForTest(healthManifest(t, "server", map[string]any{"kind": "runtime"}))
|
||||
if err != nil {
|
||||
@@ -128,26 +89,3 @@ func TestATooledHealthIsGrantedToTheEngine(t *testing.T) {
|
||||
t.Errorf("checks %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 := (Resolution{Node: "laptop", Modules: []Manifest{m}}).Declaration(Rendering{ReadsHealth: true,
|
||||
Ports: map[string]map[int]int{"app": {8080: 31001}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,59 +18,11 @@ func busSeatDelivering(t *testing.T, delivers string) {
|
||||
UseSeats([]Seat{{Name: "mesh-broker", Scope: ScopeMesh, Delivers: delivers, Decision: "test"}})
|
||||
}
|
||||
|
||||
func oldBroker() Manifest {
|
||||
return Manifest{Module: "old-broker", Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh}},
|
||||
Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}}
|
||||
}
|
||||
|
||||
func newBroker() Manifest {
|
||||
return Manifest{Module: "new-broker", Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh}},
|
||||
Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}}
|
||||
}
|
||||
|
||||
// Nothing on record: exactly the old rule. One claimant holds; two are refused.
|
||||
func TestWithNoHolderOnRecordTheSoleClaimantHoldsAndTwoAreRefused(t *testing.T) {
|
||||
busSeatDelivering(t, "mesh-bus")
|
||||
node := Node{Name: "anchor"}
|
||||
|
||||
held, problems := checkClaims([]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([]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 := Node{Name: "anchor"}
|
||||
record := []Held{{Claim: "mesh-broker", Scope: ScopeMesh, Node: "anchor", Module: "new-broker"}}
|
||||
|
||||
held, problems := checkClaims([]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 := []Held{{Claim: "mesh-broker", Scope: ScopeMesh, Node: "anchor", Module: "new-broker"}}
|
||||
|
||||
held, problems := checkClaims([]Manifest{oldBroker()}, 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)
|
||||
}
|
||||
}
|
||||
|
||||
// CanHold is the one judgement registration and the handover share, against the store's row.
|
||||
func TestCanHoldJudgesClaimScopeAndWhatTheSeatDelivers(t *testing.T) {
|
||||
busSeatDelivering(t, "mesh-bus")
|
||||
@@ -122,35 +74,6 @@ func TestCanHoldJudgesClaimScopeAndWhatTheSeatDelivers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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 := Resolution{Node: "anchor"}
|
||||
got, err := r.Compose(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"] != 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, _ = r.Compose(Rendering{})
|
||||
for _, res := range got.Resources {
|
||||
if res["id"] == BusMembershipID() {
|
||||
t.Fatal("a machine with no membership on record was handed one")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The store's seat rows have no protocol columns yet; loading them must not drop the protocol the
|
||||
// bus is derived from, or no role's work queue is ever raised (found live, 2026-09-28).
|
||||
func TestAStoreRowWithoutAProtocolKeepsTheCompiledOne(t *testing.T) {
|
||||
@@ -165,16 +88,3 @@ func TestAStoreRowWithoutAProtocolKeepsTheCompiledOne(t *testing.T) {
|
||||
t.Fatalf("the store's own columns were not kept: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARefusalWithNothingOnRecordNamesTheHandover(t *testing.T) {
|
||||
busSeatDelivering(t, "mesh-bus")
|
||||
elsewhere := []Held{{Claim: "mesh-broker", Scope: ScopeMesh, Node: "anchor", Module: "old-broker"}}
|
||||
|
||||
_, problems := checkClaims([]Manifest{newBroker()}, 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])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0223 part 3: a machine's names are one seat's. The `hosts` module holding
|
||||
// `node-hosts-file` became `hostname` holding `node-hostname`, which writes /etc/hostname beside the
|
||||
// machine's own lines in /etc/hosts. The seat was renamed (ADR 0122), so what claims the old name — a
|
||||
// manifest registered before the rename — still holds the one seat.
|
||||
|
||||
// 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]Manifest{"hostname": catalogueManifest(t, "hostname")}
|
||||
got, err := Resolve(cat, []string{"hostname"}, Node{Name: "ace", At: "ace.internal"}, World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
machines := map[string]string{"ace.internal": "10.42.0.2"}
|
||||
nameOf := func(settings SettingsBy) (string, string) {
|
||||
t.Helper()
|
||||
composed, err := got.Compose(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(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 := Layer{From: "the mesh", Values: map[string]any{"hostname": "${machine:name}"}}
|
||||
if name, why := nameOf(SettingsBy{"hostname": {mesh}}); name != "ace\n" {
|
||||
t.Errorf("a mesh-wide ${machine:name} gave %q (%s)", name, why)
|
||||
}
|
||||
node := Layer{From: "ace", Values: map[string]any{"hostname": "Ace"}}
|
||||
if name, why := nameOf(SettingsBy{"hostname": {mesh, node}}); name != "Ace\n" {
|
||||
t.Errorf("a machine's own name over the mesh-wide one gave %q (%s)", name, why)
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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 []Manifest, j *Jailing) []map[string]any {
|
||||
type declared struct {
|
||||
module string
|
||||
jail 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": ComposedJailsID(), "type": "file", "path": j.Into, "mode": "0644",
|
||||
"content": composed.String(),
|
||||
}}, out...)
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 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 := []Manifest{
|
||||
{Module: "fail2ban", Jailing: &Jailing{Into: "/etc/fail2ban/jail.d/mesh-composed.conf", FilterInto: "/etc/fail2ban/filter.d"}},
|
||||
{Module: "postgres", Jails: []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[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([]Manifest{{Module: "fail2ban"}}, &Jailing{Into: "/x", FilterInto: "/f"})
|
||||
if len(files) != 1 || files[0]["id"] != 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 := []Manifest{
|
||||
{Module: "fail2ban", Jailing: &Jailing{Into: "/etc/fail2ban/jail.d/mesh.conf", FilterInto: "/etc/fail2ban/filter.d"}},
|
||||
{Module: "gitea", Jails: []Jail{{Name: "gitea", Failregex: failregex, Jail: "port = 222"}}},
|
||||
}
|
||||
for _, f := range jailsInto(modules, modules[0].Jailing) {
|
||||
if f["id"] == 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)
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 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 := Manifest{Module: "mesh-vault", Version: "1", Provides: FromAnywhere("secret"),
|
||||
Keeps: "/var/lib/mesh-vault/root"}
|
||||
other := Manifest{Module: "zsh", Version: "1", Provides: Offers("shell")}
|
||||
got, err := Resolve(shelf(vault, other), []string{"mesh-vault", "zsh"},
|
||||
Node{Name: "anchor", At: "10.0.0.1", Capabilities: map[string]bool{}}, World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kept := &KeptExport{Export: 1, OperatorKey: "OPERATOR", Fingerprint: "sha256:abcd",
|
||||
Kept: []Kept{{Node: "anchor", Module: "postgres", Name: "superuser", Origin: "accepted", Sealed: "CIPHERTEXT", Key: "OPERATOR"}}}
|
||||
out, err := got.Declaration(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 = got.Declaration(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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,15 +9,6 @@ func (m Manifest) HandedByManifest() bool {
|
||||
return m.Moved && m.Handed != nil && !m.Stubbed && (len(m.Handed.Templates) > 0 || len(m.Items) > 0)
|
||||
}
|
||||
|
||||
// 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 Needed) bool {
|
||||
if n.ByRecord || n.Module == "" || n.From == "" {
|
||||
return false
|
||||
}
|
||||
return consumerHanded || n.AnsweredByHelper
|
||||
}
|
||||
|
||||
// Awaited is, of what consumer requires (not optionally), each provision Keyed answers yes for, judged from manifests
|
||||
// alone, as the assigner reads them: providers answers the modules providing a provision. A provision is a need when a
|
||||
// provider offers it to the mesh, or one on the consumer's machine serves facts about it (Resolve: one answered on the
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -69,135 +67,3 @@ func TestAwaitedIsWhatTheCompositionKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 := Manifest{Module: "proxy", Version: "1", Provides: []Offer{{Name: "route", Scope: ScopeMesh, AnsweredBy: AnsweredByHelper}}}
|
||||
store := Manifest{Module: "store", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh,
|
||||
Credential: &OfferCredential{Form: CredentialFormPair}}}, Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}}
|
||||
w := twoStores("anchor")
|
||||
w.Offered["route"] = []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, 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 != (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 := 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 := r.Compose(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 := 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 := 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 Plan
|
||||
hands bool
|
||||
}{
|
||||
{"empty", Plan{}, false},
|
||||
{"provisions alone", Plan{Provisions: plan.Provisions}, false},
|
||||
{"a digest without an archive", Plan{Templates: PlanTemplates{Digest: "sha256:1"}}, false},
|
||||
{"templates alone", Plan{Templates: PlanTemplates{Source: "notify.tar", Digest: "sha256:1"}}, true},
|
||||
{"files alone", Plan{Files: []PlanFile{{ID: "conf", Path: "/etc/notify.conf"}}}, true},
|
||||
{"secrets alone", Plan{Secrets: []PlanSecret{{Name: "admin"}}}, true},
|
||||
{"items alone", Plan{Items: []PlanItem{{Collection: "routes", Template: "route.tmpl"}}}, true},
|
||||
{"secrets and provisions", Plan{Secrets: []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 := Awaited(consumer, func(p string) []Manifest {
|
||||
return map[string][]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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The router of novox/hq ADR 0259: it declares the operator's seat and the two kinded benches.
|
||||
func router() Manifest {
|
||||
return Manifest{Module: "messenger", Tools: []string{"open", "history", "notify"},
|
||||
@@ -18,21 +14,3 @@ func router() Manifest {
|
||||
Claims: []Claim{{Name: "operator-channel", Scope: ScopeMesh}},
|
||||
Uses: []string{"channel"}}
|
||||
}
|
||||
|
||||
func aChannel(module, kind string) Manifest {
|
||||
return Manifest{Module: module, Claims: []Claim{
|
||||
{Name: "channel", Scope: ScopeMesh, Kind: kind}, {Name: "intake", Scope: 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 := []Manifest{aChannel("telegram", "telegram"), aChannel("desk-channel", "desktop")}
|
||||
held, problems := checkClaims(modules, 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([]Manifest{aChannel("telegram", "telegram")}, Node{Name: "home"}, held, nil)
|
||||
if len(problems) == 0 {
|
||||
t.Fatal("one kind held on two machines was not refused")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package catalogue
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// The sources mesh-config says a resolved value came from, in mesh-config_resolved (novox/hq ADR 0348 §9): a key of
|
||||
// it holds {"value": …, "source": …}.
|
||||
const (
|
||||
@@ -9,34 +7,3 @@ const (
|
||||
SourceNode = "node"
|
||||
SourceModule = "module"
|
||||
)
|
||||
|
||||
// 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 SourceMesh, SourceNode, 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
|
||||
}
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func aModelUser() Manifest {
|
||||
return 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]Manifest{"assistant": aModelUser()},
|
||||
[]string{"assistant"},
|
||||
Node{Name: "workstation"},
|
||||
World{
|
||||
Licences: map[string][]Record{"model-access": {{Name: "personal",
|
||||
Serves: map[string]any{"model": "a-model"}}}},
|
||||
Using: map[string]map[string]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]Manifest{"assistant": aModelUser()},
|
||||
[]string{"assistant"},
|
||||
Node{Name: "workstation"},
|
||||
World{Licences: map[string][]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]Manifest{
|
||||
"assistant": aModelUser(),
|
||||
"ollama": {Module: "ollama",
|
||||
Provides: []Offer{{Name: "model-access", Scope: ScopeNode}}},
|
||||
},
|
||||
[]string{"assistant", "ollama"},
|
||||
Node{Name: "workstation"},
|
||||
World{Licences: map[string][]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 := Resolution{
|
||||
Node: "workstation",
|
||||
Modules: []Manifest{aModelUser()},
|
||||
Needs: []Needed{{Name: "model-access", From: "personal", ByRecord: true, For: "assistant"}},
|
||||
}
|
||||
_, err := r.Declaration(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 := Resolution{
|
||||
Node: "workstation",
|
||||
Modules: []Manifest{aModelUser()},
|
||||
Needs: []Needed{{Name: "model-access", From: "personal", ByRecord: true, For: "assistant",
|
||||
Serves: map[string]any{"model": "a-model"}, Sealed: "sealed-blob"}},
|
||||
}
|
||||
out, err := r.Declaration(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]Manifest{
|
||||
"assistant": aModelUser(),
|
||||
"networking": {Module: "networking",
|
||||
Provides: []Offer{{Name: "mesh-network", Scope: ScopeNode}}},
|
||||
}
|
||||
// The first pass: what does this machine offer? It is assigned something nothing answers.
|
||||
got, err := Resolve(shelf, []string{"assistant", "networking"},
|
||||
Node{Name: "laptop"}, 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"},
|
||||
Node{Name: "laptop"}, 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 := Manifest{Module: "local-model",
|
||||
Provides: []Offer{{Name: "model-access", Scope: ScopeNode}},
|
||||
Serves: map[string]map[string]any{
|
||||
"model-access": {"port": 11434, "model": "a-model"}}}
|
||||
consumer := 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]Manifest{"local-model": provider, "assistant": consumer},
|
||||
[]string{"assistant", "local-model"},
|
||||
Node{Name: "workstation"},
|
||||
World{})
|
||||
if err != nil {
|
||||
t.Fatalf("a keyless same-node model endpoint was refused: %v", err)
|
||||
}
|
||||
|
||||
var found *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]Manifest{"local-model": provider, "assistant": consumer},
|
||||
[]string{"assistant", "local-model"},
|
||||
Node{Name: "workstation"},
|
||||
World{})
|
||||
if err != nil {
|
||||
t.Fatalf("resolution refused the consumer with a bound file: %v", err)
|
||||
}
|
||||
out, err := got.Declaration(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)
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,6 @@ package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -25,99 +22,6 @@ type LocalNetwork struct {
|
||||
Why string
|
||||
}
|
||||
|
||||
// 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 []AttachedNetwork, outward []string, tunnel string) []LocalNetwork {
|
||||
seen := map[[2]string]bool{}
|
||||
var out []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 AttachedNetwork, outward []string, tunnel string) LocalNetwork {
|
||||
j := 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 []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
|
||||
}
|
||||
|
||||
// LocalNetworkPort is one port the filter admits from `local-network`, and what that resolved to on this
|
||||
// machine (ADR 0340 §8): what `plan` shows for it.
|
||||
type LocalNetworkPort struct {
|
||||
@@ -128,19 +32,6 @@ type LocalNetworkPort struct {
|
||||
Networks []string
|
||||
}
|
||||
|
||||
// LocalNetworkPorts is every rule admitted from `local-network`, with the networks it resolved to.
|
||||
func LocalNetworkPorts(rules []Rule, networks []string) []LocalNetworkPort {
|
||||
var out []LocalNetworkPort
|
||||
for _, rule := range rules {
|
||||
if rule.From != FromLocalNetwork {
|
||||
continue
|
||||
}
|
||||
out = append(out, LocalNetworkPort{Port: rule.Port, Protocol: rule.Protocol,
|
||||
Because: rule.Because, Networks: networks})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Said is how `plan` says one such port: the networks, or that there are none and the port is admitted
|
||||
// from the private network only.
|
||||
func (p LocalNetworkPort) Said() string {
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 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 = []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) []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([]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 := []Rule{
|
||||
{Port: 1883, Protocol: "tcp", From: FromLocalNetwork, Because: []string{"mosquitto"}},
|
||||
{Port: 5432, Protocol: "tcp", From: 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 := []Rule{{Port: 1883, Protocol: "tcp", From: 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(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 := Resolution{Node: "anchor", Modules: []Manifest{bare}}
|
||||
rules, err := r.Rules(Rendering{Settings: reachSet(ReachLocalNetwork)})
|
||||
if err != nil {
|
||||
t.Fatalf("rules: %v", err)
|
||||
}
|
||||
if len(rules) != 1 || rules[0].From != 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([]Rule{
|
||||
{Port: 53, Protocol: "udp", From: FromMesh, Because: []string{"a"}},
|
||||
{Port: 53, Protocol: "udp", From: FromLocalNetwork, Because: []string{"b"}},
|
||||
})
|
||||
if len(got) != 1 || got[0].From != FromLocalNetwork || len(got[0].Because) != 2 {
|
||||
t.Fatalf("got %+v", got)
|
||||
}
|
||||
got = widest(append(got, Rule{Port: 53, Protocol: "udp", From: FromEverywhere, Because: []string{"c"}}))
|
||||
if len(got) != 1 || got[0].From != 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([]Rule{{Port: 1883, Protocol: "tcp", From: FromLocalNetwork}}, nil, nil)
|
||||
if len(out) != 1 || out[0]["from"] != 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([]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)
|
||||
}
|
||||
}
|
||||
@@ -1,102 +1,9 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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 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 := r.agentAccount(); agent != "" {
|
||||
out["agent-account"] = agent
|
||||
out["agent-home"] = home
|
||||
out["agent-root"] = ""
|
||||
if r.AgentAccount != "" {
|
||||
out["agent-root"] = RootNever
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RootNever is what a user resource's `root` says of an account that must never become root without a
|
||||
// person (novox/hq ADR 0266); the node-engine judges it.
|
||||
const RootNever = "never"
|
||||
|
||||
// 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 (r Resolution) agentAccount() (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.
|
||||
@@ -109,47 +16,3 @@ func accountHomeOf(account, home string) string {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0066 — a module that must name ITSELF to something else.
|
||||
//
|
||||
// The authority inside the mesh is the case. A proxy reaches it at the address the mesh handed
|
||||
// over, so its certificate has to be issued for that name — and it has no binding to learn the name
|
||||
// from, because it provides rather than requires. Written as a literal it would be one deployment's
|
||||
// machine name living in a manifest, which is the shape the decision exists to remove.
|
||||
|
||||
// anchored is a machine on the private network, which is where a name worth certifying comes from.
|
||||
func anchored() 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() Manifest {
|
||||
return Manifest{
|
||||
Module: "authority", Version: "1",
|
||||
Provides: 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(), 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(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := got.Declaration(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 := 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(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := got.Declaration(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(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := got.Declaration(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(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(Resolution{Node: "anchor"}, nil, "")
|
||||
if _, has := none["mesh-range"]; has {
|
||||
t.Fatal("a mesh with no range still offered one")
|
||||
}
|
||||
}
|
||||
@@ -305,22 +305,6 @@ func (m Manifest) IssuedOutsideOf(provision string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// 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]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
|
||||
}
|
||||
|
||||
// SharedCredentialOf is the own secret an offer of this module names as the provision's credential,
|
||||
// and whether it names one.
|
||||
func (m Manifest) SharedCredentialOf(provision string) (string, bool) {
|
||||
@@ -639,10 +623,10 @@ type Manifest struct {
|
||||
// read again — its templates filled from the tree — it is judged against the same list.
|
||||
predefined PredefinedList
|
||||
|
||||
// bestEffort marks the view of a left-out module that only its backup lines are made from
|
||||
// BestEffort marks the view of a left-out module that only its backup lines are made from
|
||||
// (backupView, novox/hq ADR 0262): a line of it that cannot be placed is said in the plan's list of
|
||||
// what could not be placed, never an error that would cost the whole machine its declaration.
|
||||
bestEffort bool
|
||||
BestEffort bool `json:"-"`
|
||||
|
||||
// Data is every kind of data this module keeps — its own, by directory, and what it keeps for
|
||||
// its consumers, by provision — each with a class the mesh protects and watches it by (novox/hq
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -41,17 +40,6 @@ func TestAMountOfAnAccessedPathIsAccepted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// **Every manifest in the catalogue (internal/beside) passes**, so the rule is not one the catalogue
|
||||
// is already breaking.
|
||||
func TestEveryCatalogueManifestDeclaresWhatItMounts(t *testing.T) {
|
||||
_, files := catalogueManifests(t)
|
||||
for _, f := range files {
|
||||
if f.err != nil {
|
||||
t.Errorf("%s: %v", filepath.Base(filepath.Dir(f.path)), f.err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Where a bound fact lands is the mesh's file too, and a container may mount it directly.
|
||||
func TestAMountOfABoundFactIsAccepted(t *testing.T) {
|
||||
_, err := decodeForTest([]byte(`{"module":"store","requires":["model-access"],` +
|
||||
|
||||
@@ -1,26 +1,5 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func containersOf(t *testing.T, r Resolution, with Rendering) []map[string]any {
|
||||
t.Helper()
|
||||
out, err := r.Declaration(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 {
|
||||
@@ -30,71 +9,3 @@ func namesOf(r map[string]any) []string {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// No mesh name is written into a container (novox/hq ADR 0148). It resolves them through its
|
||||
// machine's resolver at the moment it asks, so a name that moves is answered differently by the
|
||||
// next lookup, in every container, with nothing recreated.
|
||||
//
|
||||
// Checked the way the record says: the declaration a container gets does not move when the mesh's
|
||||
// roster does. A roster with one machine and a roster with three produce the same container, byte
|
||||
// for byte, so the digest a host computes from it cannot move either — which is what stopped one
|
||||
// name moving from replacing every container in the mesh (issue 151).
|
||||
func TestAContainerIsTheSameWhateverTheMeshsRosterSays(t *testing.T) {
|
||||
module := Manifest{Module: "app", Resources: []map[string]any{{"id": "web", "type": "container",
|
||||
"name": "web", "image": "registry.example/web@sha256:" + strings.Repeat("a", 64)}}}
|
||||
one := containersOf(t, Resolution{Node: "laptop", Modules: []Manifest{module}},
|
||||
Rendering{Names: map[string]string{"laptop.internal": "10.42.0.2"}})
|
||||
three := containersOf(t, Resolution{Node: "laptop", Modules: []Manifest{module}},
|
||||
Rendering{Names: map[string]string{
|
||||
"anchor.internal": "10.42.0.1", "laptop.internal": "10.42.0.2", "git.example.tld": "10.42.0.1",
|
||||
}})
|
||||
if len(one) != 1 || len(three) != 1 {
|
||||
t.Fatalf("expected one container each, got %d and %d", len(one), len(three))
|
||||
}
|
||||
if given := namesOf(three[0]); len(given) != 0 {
|
||||
t.Fatalf("the mesh's names were copied into the container: %v", given)
|
||||
}
|
||||
if !reflect.DeepEqual(one[0], three[0]) {
|
||||
t.Fatalf("the container moved with the roster:\n%v\n%v", one[0], three[0])
|
||||
}
|
||||
}
|
||||
|
||||
// The names a module declares for itself are its own: part of what the module is, kept exactly as
|
||||
// written, and the mesh does not know what they mean. They are the one thing in a container's
|
||||
// hosts that does move its identity, because they do not move when the mesh's roster does.
|
||||
func TestAContainersOwnNamesAreKeptAsWritten(t *testing.T) {
|
||||
got := containersOf(t, Resolution{Node: "laptop", Modules: []Manifest{{
|
||||
Module: "app",
|
||||
Resources: []map[string]any{{"id": "web", "type": "container", "name": "web",
|
||||
"image": "registry.example/web@sha256:" + strings.Repeat("a", 64),
|
||||
"hosts": []any{"something.else:203.0.113.9"}}},
|
||||
}}}, Rendering{Names: map[string]string{"anchor.internal": "10.42.0.1"}})
|
||||
|
||||
given := namesOf(got[0])
|
||||
if len(given) != 1 || given[0] != "something.else:203.0.113.9" {
|
||||
t.Fatalf("the container's own names were not kept as written: %v", given)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 := Resolution{Node: "laptop", Modules: []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)},
|
||||
},
|
||||
}}}.Declaration(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A secret a module needs in order to be itself.
|
||||
//
|
||||
// A database has a superuser password, a broker an administrator, a registry an account. None is
|
||||
// *for* anybody — it is not the credential a consumer is given, and the mechanism that hands
|
||||
// those out has a consumer in the middle of it.
|
||||
|
||||
func needy() Manifest {
|
||||
return Manifest{
|
||||
Module: "postgres", Version: "1",
|
||||
OwnSecrets: 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(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := got.Declaration(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(), World{})
|
||||
_, err := got.Declaration(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"] = OwnSecret{Path: "/var/lib/mesh/postgres/replication"}
|
||||
got, _ := Resolve(shelf(m), []string{"postgres"}, reachable(), World{})
|
||||
out, err := got.Declaration(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)
|
||||
}
|
||||
}
|
||||
@@ -73,33 +73,3 @@ func TestNoManifestContainsASubject(t *testing.T) {
|
||||
}
|
||||
t.Logf("%d manifests hold no subject", checked)
|
||||
}
|
||||
|
||||
// theCatalogue is every manifest of the catalogue internal/beside finds, parsed the way registration
|
||||
// parses one.
|
||||
func theCatalogue(t *testing.T) []Manifest {
|
||||
t.Helper()
|
||||
root := beside.Catalogue(t)
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out []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 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
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package catalogue
|
||||
|
||||
import "path"
|
||||
|
||||
// ResolverSeat is the machine's own resolver (novox/hq ADR 0247).
|
||||
const ResolverSeat = "node-resolver"
|
||||
|
||||
@@ -38,63 +36,3 @@ func resolverVerbs() []Verb {
|
||||
Replaces: []string{"resolvectl revert"}},
|
||||
}
|
||||
}
|
||||
|
||||
// holdsSeat says whether a module claims a seat, by its current name.
|
||||
func holdsSeat(m Manifest, seat string) bool {
|
||||
for _, c := range m.Claims {
|
||||
if canonicalSeat(c.Name) == seat {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// stepsBack is the module as it composes among the modules of one machine (novox/hq ADR 0287,
|
||||
// ADR 0247):
|
||||
// **where it holds a seat that names a path stepping back for another seat, and that other seat's holder
|
||||
// is on the machine and declares the path, its own rendering of the path is left out**, and nothing else
|
||||
// of it changes. Only a file the mesh renders for it steps back, as before: a resource on the path is
|
||||
// composed as declared (the declaration copies a module's resources whole), so an uplink holder declaring
|
||||
// the resolver file as a resource beside the resolver's holder is two owners of one path, and refused. The stepping back is the seat's definition — node-uplink stepping back from the resolver
|
||||
// file for node-resolver — so any other module declaring the path beside the owner's is still two owners
|
||||
// of one path, and is refused as before.
|
||||
func stepsBack(m Manifest, modules []Manifest) Manifest {
|
||||
drop := map[string]bool{}
|
||||
for _, c := range m.Claims {
|
||||
seat, known := SeatNamed(c.Name)
|
||||
if !known {
|
||||
continue
|
||||
}
|
||||
for _, p := range seat.Paths {
|
||||
if p.StepsBackFor == "" {
|
||||
continue
|
||||
}
|
||||
for _, o := range modules {
|
||||
if o.Module != m.Module && holdsSeat(o, p.StepsBackFor) && declaresPath(o, p.Path) {
|
||||
drop[p.Path] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(drop) == 0 {
|
||||
return m
|
||||
}
|
||||
facts := make(map[string]RosterFile, len(m.Facts))
|
||||
for name, f := range m.Facts {
|
||||
if !f.Home && drop[path.Clean(f.Path)] {
|
||||
continue
|
||||
}
|
||||
facts[name] = f
|
||||
}
|
||||
m.Facts = facts
|
||||
return m
|
||||
}
|
||||
|
||||
// steppedBack is every module of one machine as it composes there (stepsBack, each).
|
||||
func steppedBack(modules []Manifest) []Manifest {
|
||||
out := make([]Manifest, len(modules))
|
||||
for i, m := range modules {
|
||||
out[i] = stepsBack(m, modules)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -38,179 +38,3 @@ func TestTheMachinesOwnResolverIsANodeSeatWithItsVerbs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// localResolver is a stand-in holder: it claims the seat and renders the resolver file naming the
|
||||
// machine's own address. What is under test is the controller's rule, not the catalogue's module.
|
||||
func localResolver() Manifest {
|
||||
return Manifest{Module: "local-resolver", Version: "1",
|
||||
Claims: []Claim{{Name: ResolverSeat, Scope: ScopeNode}},
|
||||
Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile,
|
||||
Template: "# Managed by the mesh\n{{range .Machines}}{{if eq .Name $.Node}}nameserver {{.Address}}\n{{end}}{{end}}"}}}
|
||||
}
|
||||
|
||||
func splitDNSLaptop() Node {
|
||||
return Node{Name: "laptop", At: "laptop.internal", Capabilities: map[string]bool{
|
||||
"package-manager": true, "service-manager": true, "uplink-systemd-networkd": true}}
|
||||
}
|
||||
|
||||
// Where a module holds node-resolver, the machine is composed one resolver file, the holder's, naming
|
||||
// the machine's own address; the uplink's holder composes everything else it declares, and not that file.
|
||||
func TestWhereTheResolverIsHeldItWritesTheFileAndTheUplinkStepsBack(t *testing.T) {
|
||||
shelf := resolverShelf(t)
|
||||
shelf["local-resolver"] = localResolver()
|
||||
got, err := Resolve(shelf, []string{"dnsmasq", "systemd-networkd", "local-resolver"}, splitDNSLaptop(), World{})
|
||||
if err != nil {
|
||||
t.Fatalf("the resolver's holder and the uplink's were refused together: %v", err)
|
||||
}
|
||||
out, err := got.Declaration(Rendering{Names: twoMachines, Machines: twoMachines, Suffix: "internal",
|
||||
Holders: map[string]map[string]string{"mesh-dns-resolver": {"anchor.internal": "10.42.0.1"}},
|
||||
Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}},
|
||||
Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var files []string
|
||||
for _, r := range out {
|
||||
if r["path"] == ResolverFile {
|
||||
files = append(files, r["id"].(string))
|
||||
}
|
||||
}
|
||||
if strings.Join(files, " ") != "local-resolver.fact-resolvers" {
|
||||
t.Fatalf("the resolver file is composed as %v; once, the resolver's", files)
|
||||
}
|
||||
content := byID(out)["local-resolver.fact-resolvers"]["content"].(string)
|
||||
if !strings.Contains(content, "nameserver 10.42.0.2\n") || strings.Contains(content, "10.42.0.1") {
|
||||
t.Errorf("the resolver file does not name this machine's own resolver alone:\n%s", content)
|
||||
}
|
||||
// The uplink's holder is still composed: only the one file moved.
|
||||
uplinkComposed := false
|
||||
for _, r := range out {
|
||||
if id, _ := r["id"].(string); strings.HasPrefix(id, "systemd-networkd.") {
|
||||
uplinkComposed = true
|
||||
}
|
||||
}
|
||||
if !uplinkComposed {
|
||||
t.Errorf("the uplink's holder composed nothing once the resolver was held")
|
||||
}
|
||||
}
|
||||
|
||||
// Where nobody holds it, nothing changes: the uplink's holder writes the file, listing the mesh's
|
||||
// resolvers (ADR 0223) — every machine but the one that requires split-dns.
|
||||
func TestWithoutTheResolverTheUplinkWritesTheFileAsBefore(t *testing.T) {
|
||||
got, err := Resolve(resolverShelf(t), []string{"dnsmasq", "systemd-networkd"}, splitDNSLaptop(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := got.Declaration(Rendering{Names: twoMachines, Machines: twoMachines, Suffix: "internal",
|
||||
Holders: map[string]map[string]string{"mesh-dns-resolver": {"anchor.internal": "10.42.0.1"}},
|
||||
Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}},
|
||||
Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := byID(out)["systemd-networkd.fact-resolvers"]; r == nil || r["path"] != ResolverFile {
|
||||
t.Fatalf("without the resolver, the uplink's holder no longer writes the resolver file: %v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// Only the uplink's holder steps back. A third module rendering or declaring the file beside the
|
||||
// resolver's is two owners of one path, refused as before; and a module rendering it without holding
|
||||
// the seat is not the resolver, so the uplink's holder does not step back for it.
|
||||
func TestOnlyTheUplinkStepsBackForTheResolver(t *testing.T) {
|
||||
uplink := Manifest{Module: "uplink", Version: "1", Claims: []Claim{{Name: "node-uplink"}},
|
||||
Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile, Template: "nameserver 10.42.0.1\n"}}}
|
||||
if p := checkResources([]Manifest{uplink, localResolver()}); len(p) != 0 {
|
||||
t.Errorf("the uplink's holder and the resolver's were refused together: %v", p)
|
||||
}
|
||||
other := Manifest{Module: "other", Version: "1",
|
||||
Facts: map[string]RosterFile{"mine": {Path: ResolverFile, Template: "nameserver 10.42.0.9\n"}}}
|
||||
if p := checkResources([]Manifest{uplink, localResolver(), other}); len(p) == 0 {
|
||||
t.Error("a third module wrote the resolver file beside the resolver's")
|
||||
}
|
||||
if p := checkResources([]Manifest{uplink, other}); len(p) == 0 {
|
||||
t.Error("a module that does not hold node-resolver took the resolver file from the uplink's holder")
|
||||
}
|
||||
declared := Manifest{Module: "declared", Version: "1", Resources: []map[string]any{
|
||||
{"id": "mine", "type": "file", "path": ResolverFile, "content": "nameserver 10.42.0.9\n"}}}
|
||||
if p := checkResources([]Manifest{uplink, localResolver(), declared}); len(p) == 0 {
|
||||
t.Error("a module declaring the resolver file was let beside the resolver's")
|
||||
}
|
||||
// What steps back is the one file: the uplink's other facts stay.
|
||||
uplink.Facts["hosts"] = RosterFile{Path: "/etc/elsewhere", Template: "x"}
|
||||
if got := stepsBack(uplink, []Manifest{uplink, localResolver()}); len(got.Facts) != 1 || got.Facts["hosts"].Path == "" {
|
||||
t.Errorf("the uplink's holder lost more than the resolver file: %v", got.Facts)
|
||||
}
|
||||
if got := stepsBack(uplink, []Manifest{uplink}); len(got.Facts) != 2 {
|
||||
t.Errorf("the uplink's holder stepped back with no resolver held: %v", got.Facts)
|
||||
}
|
||||
}
|
||||
|
||||
// The catalogue as it is: every holder of node-resolver renders the resolver file as the mesh's own
|
||||
// (its header is how the uplink's verb and the node-engine read a file as the mesh's), and provides
|
||||
// split-dns at the machine's reach — a requirement is answered only on the same machine and never pulls
|
||||
// the resolver in. Skipped while the catalogue has no holder.
|
||||
func TestTheCataloguesResolverHoldersWriteTheFileAndProvideSplitDNS(t *testing.T) {
|
||||
held := 0
|
||||
for _, m := range theCatalogue(t) {
|
||||
if !holdsSeat(m, ResolverSeat) {
|
||||
continue
|
||||
}
|
||||
held++
|
||||
f, ok := m.Facts["resolvers"]
|
||||
if !ok || f.Path != ResolverFile || !strings.HasPrefix(f.Template, "# Managed by the mesh") {
|
||||
t.Errorf("%s holds %s and does not render the resolver file as the mesh's: %+v", m.Module, ResolverSeat, f)
|
||||
}
|
||||
provides := false
|
||||
for _, o := range m.Provides {
|
||||
if o.Name == "split-dns" && o.Reach == ReachMachine {
|
||||
provides = true
|
||||
}
|
||||
}
|
||||
if !provides {
|
||||
t.Errorf("%s holds %s and does not provide split-dns at the machine's reach", m.Module, ResolverSeat)
|
||||
}
|
||||
}
|
||||
if held == 0 {
|
||||
t.Skip("no module of the catalogue holds node-resolver yet")
|
||||
}
|
||||
}
|
||||
|
||||
// The catalogue's systemd-resolved, composed on a machine beside its uplink: the resolver file names the
|
||||
// machine's own private address alone, its kept copy is the same file, and resolved is given every mesh
|
||||
// resolver as its default route and the private address to listen on. Skipped without the catalogue.
|
||||
func TestTheCataloguesResolverComposesOnAMachine(t *testing.T) {
|
||||
shelf := resolverShelf(t)
|
||||
shelf["systemd-resolved"] = catalogueManifest(t, "systemd-resolved")
|
||||
got, err := Resolve(shelf, []string{"dnsmasq", "systemd-networkd", "systemd-resolved"}, splitDNSLaptop(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := got.Declaration(Rendering{Names: twoMachines, Machines: twoMachines, Suffix: "internal",
|
||||
Holders: map[string]map[string]string{"mesh-dns-resolver": {"anchor.internal": "10.42.0.1", "home.internal": "10.42.0.3"}},
|
||||
Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}},
|
||||
Settings: SettingsBy{"dnsmasq": {{From: "the mesh", Values: map[string]any{"listen-addresses": "127.0.0.1"}}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := byID(out)
|
||||
resolv, _ := ids["systemd-resolved.fact-resolvers"]["content"].(string)
|
||||
kept, _ := ids["systemd-resolved.fact-kept"]["content"].(string)
|
||||
if !strings.Contains(resolv, "\nnameserver 10.42.0.2\noptions timeout:1 attempts:2 edns0\n") || resolv != kept {
|
||||
t.Errorf("the resolver file does not name this machine's own resolver alone, or its copy differs:\n%s", resolv)
|
||||
}
|
||||
if ids["systemd-networkd.fact-resolvers"] != nil {
|
||||
t.Error("the uplink's holder still writes the resolver file beside the resolver's")
|
||||
}
|
||||
conf, _ := ids["systemd-resolved.fact-resolved"]["content"].(string)
|
||||
for _, want := range []string{"\nDNS=10.42.0.1 10.42.0.3 \n", "\nDNSStubListenerExtra=10.42.0.2\n", "\nFallbackDNS=\n"} {
|
||||
if !strings.Contains(conf, want) {
|
||||
t.Errorf("resolved's drop-in lacks %q:\n%s", want, conf)
|
||||
}
|
||||
}
|
||||
if s, _ := ids["systemd-resolved.fact-suffix"]["content"].(string); s != "internal\n" {
|
||||
t.Errorf("the mesh's domain is rendered as %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,33 +6,6 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A `user` shape and a user-scoped unit name the operator account the way a home file does
|
||||
// (novox/hq ADR 0176, ADR 0177): with ${machine:account}, resolved when the module is assigned.
|
||||
func TestAUserShapeAndAUserScopedUnitNameTheAccount(t *testing.T) {
|
||||
facts := map[string]string{"account": "ops", "account-home": "/home/ops"}
|
||||
login := map[string]any{"type": "user", "id": "login", "name": "${machine:account}", "shell": "/usr/bin/zsh"}
|
||||
if err := machineInto(login, facts, "zsh"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if login["name"] != "ops" {
|
||||
t.Fatalf("the user shape did not learn the account: %v", login["name"])
|
||||
}
|
||||
watcher := map[string]any{"type": "service", "id": "watcher", "unit": "i3-reload-watcher.service",
|
||||
"scope": "user", "user": "${machine:account}"}
|
||||
if err := machineInto(watcher, facts, "i3"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if watcher["user"] != "ops" {
|
||||
t.Fatalf("the user-scoped unit did not learn the account: %v", watcher["user"])
|
||||
}
|
||||
// A machine with no operator account refuses rather than writing the literal.
|
||||
err := machineInto(map[string]any{"type": "user", "id": "login", "name": "${machine:account}"},
|
||||
map[string]string{"address": "10.0.0.1"}, "zsh")
|
||||
if err == nil || !strings.Contains(err.Error(), "${machine:account}") {
|
||||
t.Fatalf("a user shape on a machine with no account was not refused by name: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The service manager is a seat of the mesh's own with the unit verbs as its contract (novox/hq
|
||||
// ADR 0177): every verb described, with a schema, taking a scope.
|
||||
func TestTheServiceManagerSeatServesTheUnitVerbs(t *testing.T) {
|
||||
|
||||
@@ -103,17 +103,6 @@ func optionalRequiresList(m Manifest) []string {
|
||||
// to without requiring, or requires as a bare name, is hard.
|
||||
func (m Manifest) IsOptional(requirement string) bool { return m.OptionalRequires[requirement] }
|
||||
|
||||
// wantsHard is whether this module wants the name as a hard requirement: required without
|
||||
// `optional`, or contributed to.
|
||||
func (m Manifest) wantsHard(name string) bool {
|
||||
for _, w := range m.Wants() {
|
||||
if w == name {
|
||||
return !m.IsOptional(name)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Absent is one optional requirement of one consumer that nothing assigned answers (ADR 0330 §3):
|
||||
// left unbound rather than refused, and listed by `plan` and `node` (§7).
|
||||
type Absent struct {
|
||||
@@ -122,59 +111,3 @@ type Absent struct {
|
||||
// Requirement is what it requires.
|
||||
Requirement string `json:"requirement"`
|
||||
}
|
||||
|
||||
// AbsentFor is the optional requirements of one module this resolution leaves unbound.
|
||||
func (r Resolution) AbsentFor(module string) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, a := range r.Absent {
|
||||
if a.Consumer == module {
|
||||
out[a.Requirement] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// absentOf is, for each requirement left absent, every module of the set that requires it
|
||||
// optionally, sorted by consumer then requirement.
|
||||
func absentOf(names map[string]bool, order []string, catalogue map[string]Manifest) []Absent {
|
||||
var out []Absent
|
||||
for _, module := range order {
|
||||
m := catalogue[module]
|
||||
for _, r := range m.Requires {
|
||||
if names[r] && m.IsOptional(r) {
|
||||
out = append(out, Absent{Consumer: module, Requirement: r})
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Consumer != out[j].Consumer {
|
||||
return out[i].Consumer < out[j].Consumer
|
||||
}
|
||||
return out[i].Requirement < out[j].Requirement
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// withoutAbsent drops the resources delivered only while a requirement is bound whose requirement is
|
||||
// absent here (ADR 0330 §5), and the field itself from every resource that keeps it.
|
||||
func withoutAbsent(resources []map[string]any, absent map[string]bool) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(resources))
|
||||
for _, r := range resources {
|
||||
when, has := r[OnlyWhenBound]
|
||||
if !has {
|
||||
out = append(out, r)
|
||||
continue
|
||||
}
|
||||
if absent[fmt.Sprint(when)] {
|
||||
continue
|
||||
}
|
||||
kept := make(map[string]any, len(r))
|
||||
for k, v := range r {
|
||||
if k != OnlyWhenBound {
|
||||
kept[k] = v
|
||||
}
|
||||
}
|
||||
out = append(out, kept)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -11,20 +11,6 @@ import (
|
||||
// could not assign grafana at all, and one with influxdb in its catalogue would have had it installed
|
||||
// for grafana.
|
||||
|
||||
func optionalShelf() map[string]Manifest {
|
||||
grafana := Manifest{Module: "grafana", Version: "1", Requires: []string{"influxdb-api", "shell"},
|
||||
OptionalRequires: map[string]bool{"influxdb-api": true, "shell": true}}
|
||||
return shelf(
|
||||
Manifest{Module: "influxdb", Version: "1", Provides: FromAnywhere("influxdb-api")},
|
||||
Manifest{Module: "zsh", Version: "1", Provides: Offers("shell")},
|
||||
grafana,
|
||||
// What wants the same names hard, for the cases where one consumer's optional is another's hard.
|
||||
Manifest{Module: "telegraf", Version: "1", Requires: []string{"influxdb-api"}},
|
||||
Manifest{Module: "editor", Version: "1", Requires: []string{"shell"}},
|
||||
Manifest{Module: "suite", Version: "1", Requires: []string{"editor"}},
|
||||
)
|
||||
}
|
||||
|
||||
func TestARequirementIsHardUnlessMarkedOptional(t *testing.T) {
|
||||
m := mustParse(t, `{"module":"grafana","version":"1",
|
||||
"requires":["route",{"name":"influxdb-api","optional":true},{"name":"oidc-client"}]}`)
|
||||
@@ -64,154 +50,3 @@ func TestARequirementsLongFormRefusesWhatItDoesNotKnow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnOptionalRequirementWithNoProviderIsAbsentAndNothingIsInstalled(t *testing.T) {
|
||||
// Exactly one catalogue module could answer each — the case where a hard requirement installs it.
|
||||
// Brokered (influxdb-api, from anywhere) and on this machine (shell); both passes.
|
||||
for _, world := range []World{{}, {Unchecked: true}} {
|
||||
got, err := Resolve(optionalShelf(), []string{"grafana"}, reachable(), world)
|
||||
if err != nil {
|
||||
t.Fatalf("an optional requirement with no provider refused the assignment: %v", err)
|
||||
}
|
||||
if have := strings.Join(names(got), " "); have != "grafana" {
|
||||
t.Fatalf("assigning grafana put %s on the machine; nothing is installed for an optional requirement (ADR 0330 §3)", have)
|
||||
}
|
||||
if world.Unchecked {
|
||||
continue
|
||||
}
|
||||
want := []Absent{{Consumer: "grafana", Requirement: "influxdb-api"}, {Consumer: "grafana", Requirement: "shell"}}
|
||||
if len(got.Absent) != 2 || got.Absent[0] != want[0] || got.Absent[1] != want[1] {
|
||||
t.Fatalf("absent: %+v, want %+v", got.Absent, want)
|
||||
}
|
||||
if len(got.Needs) != 0 {
|
||||
t.Fatalf("an absent requirement was given a binding: %+v", got.Needs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnOptionalRequirementWithAProviderBindsAsAHardOne(t *testing.T) {
|
||||
world := World{Offered: map[string][]Provider{"influxdb-api": {
|
||||
{Node: "anchor", At: "anchor.internal", Module: "influxdb", Serves: map[string]any{"port": 8086}}}}}
|
||||
got, err := Resolve(optionalShelf(), []string{"grafana", "zsh"}, reachable(), world)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := boundFrom(t, got, "grafana", "influxdb-api"); n.From != "anchor" || n.Module != "influxdb" {
|
||||
t.Fatalf("bound to %s/%s", n.From, n.Module)
|
||||
}
|
||||
if len(got.Absent) != 0 {
|
||||
t.Fatalf("a requirement with a provider was left absent: %+v", got.Absent)
|
||||
}
|
||||
// And the same assignment without the provider is absent: what binds it later is only the
|
||||
// provider being assigned, with no act on grafana (ADR 0330 §7).
|
||||
before, err := Resolve(optionalShelf(), []string{"grafana", "zsh"}, reachable(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(before.Absent) != 1 || before.Absent[0].Requirement != "influxdb-api" {
|
||||
t.Fatalf("absent before the provider was assigned: %+v", before.Absent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneConsumersOptionalRequirementIsAnothersHardOne(t *testing.T) {
|
||||
// telegraf wants influxdb-api hard: with nothing offering it, the assignment is refused as
|
||||
// before, naming what to assign — grafana's optional does not soften telegraf's.
|
||||
_, err := Resolve(optionalShelf(), []string{"grafana", "telegraf"}, reachable(), World{})
|
||||
if err == nil || !strings.Contains(err.Error(), "assign influxdb") {
|
||||
t.Fatalf("a hard requirement with no provider was not refused naming the candidate: %v", err)
|
||||
}
|
||||
// And the refusal names telegraf, whose remedy it is, not grafana, which met the name first.
|
||||
if !strings.Contains(err.Error(), "wanted by telegraf") {
|
||||
t.Errorf("the refusal does not name the module that wants it hard: %v", err)
|
||||
}
|
||||
// And a module pulled in later (suite → editor) that wants the shell hard installs it, even
|
||||
// though grafana, met first, wanted it only optionally — and grafana is then bound to it.
|
||||
got, err := Resolve(optionalShelf(), []string{"grafana", "suite"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if have := strings.Join(names(got), " "); !strings.Contains(have, "zsh") {
|
||||
t.Fatalf("a hard requirement met after an optional one was not installed: %s", have)
|
||||
}
|
||||
for _, a := range got.Absent {
|
||||
if a.Requirement == "shell" {
|
||||
t.Fatalf("the shell is installed and still listed absent: %+v", got.Absent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHardRequirementWithNoProviderIsStillRefused(t *testing.T) {
|
||||
_, err := Resolve(optionalShelf(), []string{"telegraf"}, reachable(), World{})
|
||||
if err == nil || !strings.Contains(err.Error(), "influxdb") {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func optionalModule(resources string) string {
|
||||
return `{"module":"grafana","version":"1",
|
||||
"requires":["route",{"name":"influxdb-api","optional":true}],
|
||||
"secrets":{"influxdb-api":"/var/lib/grafana/influxdb-api"},
|
||||
"resources":[` + resources + `]}`
|
||||
}
|
||||
|
||||
func absentGrafana(t *testing.T, absent bool) Resolution {
|
||||
t.Helper()
|
||||
m := mustParse(t, optionalModule(`
|
||||
{"id":"env","type":"file","path":"/var/lib/grafana/env","mode":"0644",
|
||||
"content":"INFLUX=${bound:influxdb-api:at|default:none}:${bound:influxdb-api:port|default:0}\n"},
|
||||
{"id":"ds","type":"file","path":"/var/lib/grafana/ds.yaml","mode":"0400",
|
||||
"content":"url: ${bound:influxdb-api:at}\npassword: ${secret:influxdb-api}\n","only-when-bound":"influxdb-api"}`))
|
||||
m.Requires = []string{"influxdb-api"}
|
||||
m.Binds = map[string]string{"influxdb-api": "/var/lib/grafana/influxdb.json"}
|
||||
r := Resolution{Node: "workstation", Modules: []Manifest{m}}
|
||||
if absent {
|
||||
r.Absent = []Absent{{Consumer: "grafana", Requirement: "influxdb-api"}}
|
||||
} else {
|
||||
r.Needs = []Needed{{Name: "influxdb-api", From: "anchor", At: "anchor.internal", For: "grafana",
|
||||
Module: "influxdb", Sealed: "sealed-influx", Serves: map[string]any{"port": float64(8086)}}}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func TestAnAbsentRequirementIsComposedWithItsDefaultsAndWithoutItsFiles(t *testing.T) {
|
||||
out, err := absentGrafana(t, true).Declaration(Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := fileNamed(out, "grafana.env")
|
||||
if env == nil || env["content"] != "INFLUX=none:0\n" {
|
||||
t.Fatalf("the defaults were not rendered: %v", env)
|
||||
}
|
||||
for _, id := range []string{"grafana.ds", "grafana." + SecretID("influxdb-api"), "grafana." + BoundID("influxdb-api")} {
|
||||
if fileNamed(out, id) != nil {
|
||||
t.Errorf("%s was delivered while influxdb-api is absent (ADR 0330 §5, §6)", id)
|
||||
}
|
||||
}
|
||||
for _, r := range out {
|
||||
if _, has := r[OnlyWhenBound]; has {
|
||||
t.Errorf("%v carries %s to the node-engine, which knows no such field", r["id"], OnlyWhenBound)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestABoundOptionalRequirementIsComposedAsAHardOne(t *testing.T) {
|
||||
out, err := absentGrafana(t, false).Declaration(Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if env := fileNamed(out, "grafana.env"); env == nil || env["content"] != "INFLUX=anchor.internal:8086\n" {
|
||||
t.Fatalf("the bound values were not rendered: %v", env)
|
||||
}
|
||||
ds := fileNamed(out, "grafana.ds")
|
||||
if ds == nil {
|
||||
t.Fatalf("the only-when-bound file was left out while bound: %v", out)
|
||||
}
|
||||
if _, has := ds[OnlyWhenBound]; has {
|
||||
t.Errorf("the file carries %s to the node-engine", OnlyWhenBound)
|
||||
}
|
||||
for _, id := range []string{"grafana." + SecretID("influxdb-api"), "grafana." + BoundID("influxdb-api")} {
|
||||
if fileNamed(out, id) == nil {
|
||||
t.Errorf("%s was not delivered while bound", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// An own secret issued outside the mesh that nobody gave (novox/hq issue 401, ADR 0228): the mesh makes no value for it,
|
||||
// so the composition places no file, a file of the module that names it says empty there, and nothing restarts on it.
|
||||
// Given, it is placed and filled like any own secret.
|
||||
|
||||
func outsideManifest(t *testing.T) Manifest {
|
||||
t.Helper()
|
||||
m, err := decodeForTest([]byte(`{"module":"assistant","version":"1",
|
||||
"own-secrets":{
|
||||
"password":{"path":"/var/lib/assistant/password.secret","taken":"at-start"},
|
||||
"vendor-key":{"path":"/var/lib/assistant/vendor-key.secret","taken":"at-start","issued-by":"outside"}},
|
||||
"resources":[
|
||||
{"id":"env","type":"file","path":"/var/lib/assistant/env","mode":"0600",
|
||||
"content":"PASSWORD=${secret:password}\nVENDOR_KEY=${secret:vendor-key}\n"},
|
||||
{"id":"server","type":"container","name":"assistant","image":"assistant:1",
|
||||
"env-file":["/var/lib/assistant/env"],
|
||||
"volumes":["/var/lib/assistant/vendor-key.secret:/run/vendor-key:ro"],
|
||||
"secrets-in-environment":"reads its settings from the environment only"}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func resourceAt(out []map[string]any, path string) map[string]any {
|
||||
for _, r := range out {
|
||||
if r["path"] == path {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resourceNamed(out []map[string]any, name string) map[string]any {
|
||||
for _, r := range out {
|
||||
if r["name"] == name {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAnOutsideSecretNotGivenIsNoFileAndAFileNamingItSaysEmpty(t *testing.T) {
|
||||
r := Resolution{Node: "server", Modules: []Manifest{outsideManifest(t)}}
|
||||
out, err := r.Declaration(Rendering{Needed: map[string]map[string]string{"assistant": {"password": "sealed-password"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("a module whose outside secret nobody gave did not compose: %v", err)
|
||||
}
|
||||
if got := resourceAt(out, "/var/lib/assistant/vendor-key.secret"); got != nil {
|
||||
t.Fatalf("a secret nobody gave was placed: %v", got)
|
||||
}
|
||||
env := resourceAt(out, "/var/lib/assistant/env")
|
||||
if env == nil {
|
||||
t.Fatal("the module's own file is not composed")
|
||||
}
|
||||
if content := env["content"].(string); content != "PASSWORD=${secret:password}\nVENDOR_KEY=\n" {
|
||||
t.Fatalf("the file names the secret nobody gave as %q", content)
|
||||
}
|
||||
secrets, _ := env["secrets"].(map[string]any)
|
||||
if len(secrets) != 1 || secrets["password"] != "sealed-password" {
|
||||
t.Fatalf("the file is filled from %v", secrets)
|
||||
}
|
||||
server := resourceNamed(out, "assistant")
|
||||
if on := strings.Join(stringsIn(server["restart-on"]), " "); strings.Contains(on, "vendor-key") {
|
||||
t.Fatalf("the container restarts on a secret nobody gave: %q", on)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnOutsideSecretGivenIsPlacedAndFilled(t *testing.T) {
|
||||
r := Resolution{Node: "server", Modules: []Manifest{outsideManifest(t)}}
|
||||
out, err := r.Declaration(Rendering{Needed: map[string]map[string]string{"assistant": {
|
||||
"password": "sealed-password", "vendor-key": "sealed-vendor-key"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
placed := resourceAt(out, "/var/lib/assistant/vendor-key.secret")
|
||||
if placed == nil || placed["sealed"] != "sealed-vendor-key" {
|
||||
t.Fatalf("the secret given is placed as %v", placed)
|
||||
}
|
||||
env := resourceAt(out, "/var/lib/assistant/env")
|
||||
if content := env["content"].(string); content != "PASSWORD=${secret:password}\nVENDOR_KEY=${secret:vendor-key}\n" {
|
||||
t.Fatalf("the file was changed: %q", content)
|
||||
}
|
||||
if secrets, _ := env["secrets"].(map[string]any); secrets["vendor-key"] != "sealed-vendor-key" {
|
||||
t.Fatalf("the file is filled from %v", secrets)
|
||||
}
|
||||
server := resourceNamed(out, "assistant")
|
||||
if on := strings.Join(stringsIn(server["restart-on"]), " "); !strings.Contains(on, "assistant."+NeedID("vendor-key")) {
|
||||
t.Fatalf("the container does not restart on the secret given: %q", on)
|
||||
}
|
||||
}
|
||||
|
||||
// A secret the mesh makes is still refused when it was not made: only an outside one is left out.
|
||||
func TestAnOwnSecretTheMeshMakesIsStillRefusedWhenNotMade(t *testing.T) {
|
||||
r := Resolution{Node: "server", Modules: []Manifest{outsideManifest(t)}}
|
||||
_, err := r.Declaration(Rendering{})
|
||||
if nm, ok := err.(*NotMadeError); !ok || nm.Name != "password" {
|
||||
t.Fatalf("a secret the mesh makes, not made: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A module that must know its own address — a login redirect, a canonical URL, an issuer — had it
|
||||
// written into its manifest as a literal (novox/hq 04-ISSUES/122): a domain in a definition, wrong on
|
||||
// every other machine. It is told instead, from the same composition the provider receives.
|
||||
|
||||
// selfAware contributes a labelled route, binds the requirement, and writes its own name into a file.
|
||||
func selfAware(label string) Manifest {
|
||||
m := labelled("board", label, 8080)
|
||||
m.Requires = []string{"reverse-proxy"}
|
||||
m.Binds = map[string]string{"reverse-proxy": "/var/lib/board/route.json"}
|
||||
m.Resources = []map[string]any{
|
||||
{"id": "conf", "type": "file", "path": "/var/lib/board/app.conf",
|
||||
"content": "root = https://${bound:reverse-proxy:name}/\ninternal = ${bound:reverse-proxy:internal-name}\n"},
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// servingProxy is proxy() as the catalogue's route providers are declared: the provision scoped to
|
||||
// the mesh, serving nothing a consumer must know (route-adapter, route-proxy: `"serves": {"route": {}}`).
|
||||
func servingProxy() Manifest {
|
||||
p := proxy()
|
||||
p.Provides = []Offer{{Name: "reverse-proxy", Scope: ScopeMesh}}
|
||||
p.Serves = map[string]map[string]any{"reverse-proxy": {}}
|
||||
return p
|
||||
}
|
||||
|
||||
// nodeProxy is the same provider scoped to its node, whose answer on the same machine comes from
|
||||
// `here` rather than from the mesh's needs — the other path a binding is written by.
|
||||
func nodeProxy() Manifest {
|
||||
p := proxy()
|
||||
p.Serves = map[string]map[string]any{"reverse-proxy": {"scheme": "http"}}
|
||||
return p
|
||||
}
|
||||
|
||||
// onBoth is a node with a public domain and a private-network address, so both names compose.
|
||||
func onBoth(domain string) Node {
|
||||
n := withDomain(domain)
|
||||
n.At = "anchor.internal"
|
||||
return n
|
||||
}
|
||||
|
||||
func fileAt(t *testing.T, out []map[string]any, path string) string {
|
||||
t.Helper()
|
||||
for _, r := range out {
|
||||
if r["path"] == path {
|
||||
return r["content"].(string)
|
||||
}
|
||||
}
|
||||
t.Fatalf("nothing was declared at %s", path)
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestAModuleIsToldTheNameItsProviderServes(t *testing.T) {
|
||||
got, err := Resolve(shelf(servingProxy(), selfAware("git")), []string{"traefik", "board"},
|
||||
onBoth("example.tld"), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := mustDeclare(t, got)
|
||||
served := received(t, out)[0].Values
|
||||
|
||||
var binding map[string]any
|
||||
if err := json.Unmarshal([]byte(fileAt(t, out, "/var/lib/board/route.json")), &binding); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if binding["name"] != served["name"] || binding["name"] != "git.example.tld" {
|
||||
t.Fatalf("the module was told %v, the provider serves %v", binding["name"], served["name"])
|
||||
}
|
||||
if binding["internal-name"] != served["internal-name"] || binding["internal-name"] == nil {
|
||||
t.Fatalf("internal name: module told %v, provider serves %v",
|
||||
binding["internal-name"], served["internal-name"])
|
||||
}
|
||||
|
||||
conf := fileAt(t, out, "/var/lib/board/app.conf")
|
||||
want := "root = https://git.example.tld/\ninternal = " + served["internal-name"].(string) + "\n"
|
||||
if conf != want {
|
||||
t.Fatalf("the file was rendered as\n%s\nwant\n%s", conf, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheNameAModuleIsToldFollowsTheNodesDomain(t *testing.T) {
|
||||
// The whole point: the same definition, two machines, two names — nothing edited.
|
||||
for _, domain := range []string{"example.tld", "other.example"} {
|
||||
got, err := Resolve(shelf(servingProxy(), selfAware("git")), []string{"traefik", "board"},
|
||||
onBoth(domain), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf := fileAt(t, mustDeclare(t, got), "/var/lib/board/app.conf")
|
||||
if !strings.HasPrefix(conf, "root = https://git."+domain+"/") {
|
||||
t.Fatalf("on %s the module wrote %q", domain, conf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleWithNoPublicNameIsNotToldOne(t *testing.T) {
|
||||
// No public domain on the node: nothing composed, so no `name` — and a file asking for one is
|
||||
// refused rather than rendered with a placeholder or an empty host.
|
||||
m := selfAware("git")
|
||||
m.Resources[0]["content"] = "root = https://${bound:reverse-proxy:name}/\n"
|
||||
got, err := Resolve(shelf(servingProxy(), m), []string{"traefik", "board"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := got.Declaration(Rendering{}); err == nil ||
|
||||
!strings.Contains(err.Error(), `"name"`) {
|
||||
t.Fatalf("a file asking for a name that was never composed was not refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleIsToldItsNameByANodeScopedProviderToo(t *testing.T) {
|
||||
m := selfAware("git")
|
||||
m.Resources[0]["content"] = "root = https://${bound:reverse-proxy:name}/\n"
|
||||
got, err := Resolve(shelf(nodeProxy(), m), []string{"board"},
|
||||
withDomain("example.tld"), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf := fileAt(t, mustDeclare(t, got), "/var/lib/board/app.conf")
|
||||
if !strings.HasPrefix(conf, "root = https://git.example.tld/") {
|
||||
t.Fatalf("a same-machine, node-scoped answer did not tell the module its name: %q", conf)
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// An own secret (or a family) may declare `owner` (novox/hq ADR 0436 §1): the account the files holding it
|
||||
// belong to on the machine, when that account is not the module's secrets-owner default. The fixture is
|
||||
// mesh-mail's future manifest shape (ADR 0436 §7): a process account `mesh-mail-send` the module makes, and its
|
||||
// own-secret family `mail-credential-*` owned by it.
|
||||
|
||||
// mailSenderManifest is the fixture every mail-sender issue builds on: the real parts of mesh-mail's manifest
|
||||
// taken from mesh-catalog/modules/mesh-mail/module.json, extended as ADR 0436 §7-§8 describe, with `own` naming
|
||||
// the account that owns the family's files.
|
||||
func mailSenderManifest(t *testing.T, own string) Manifest {
|
||||
t.Helper()
|
||||
ownSecrets := `"mail-credential-*":{"path":"/var/lib/mesh-mail/mail-credential-*.secret","issued-by":"outside"`
|
||||
if own != "" {
|
||||
ownSecrets += `,"owner":"` + own + `"`
|
||||
}
|
||||
ownSecrets += `}`
|
||||
m, err := decodeForTest([]byte(`{"module":"mesh-mail","version":"1","secrets-owner":"mesh-mail","own-secrets":{` +
|
||||
ownSecrets + `},"resources":[{"id":"account","type":"user","name":"mesh-mail-send"}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("a manifest with own=%q was refused: %v", own, err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// 1170/AC1: a family owned by the module's own account lands at 0600, owned by that account — and a module's
|
||||
// other own secrets (no per-secret owner declared) keep secrets-owner, unchanged.
|
||||
func TestAnOwnSecretsOwnerIsGivenToItsFiles(t *testing.T) {
|
||||
m := mailSenderManifest(t, "mesh-mail-send")
|
||||
m.OwnSecrets["smtp-relay-key"] = OwnSecret{Path: "/var/lib/mesh-mail/smtp-relay-key", Taken: TakenAtStart}
|
||||
r := Resolution{Node: "novox", Modules: []Manifest{m}}
|
||||
out, err := r.Declaration(Rendering{
|
||||
Needed: map[string]map[string]string{"mesh-mail": {"mail-credential-smtp": "sealed-credential", "smtp-relay-key": "sealed-relay"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("composing the declaration: %v", err)
|
||||
}
|
||||
var family, plain map[string]any
|
||||
for _, res := range out {
|
||||
if res["type"] != "file" {
|
||||
continue
|
||||
}
|
||||
switch res["path"] {
|
||||
case "/var/lib/mesh-mail/mail-credential-smtp.secret":
|
||||
family = res
|
||||
case "/var/lib/mesh-mail/smtp-relay-key":
|
||||
plain = res
|
||||
}
|
||||
}
|
||||
if family == nil {
|
||||
t.Fatalf("the family member was not composed at all: %v", out)
|
||||
}
|
||||
if got := family["owner"]; got != "mesh-mail-send" {
|
||||
t.Fatalf("the family member's owner: %v; want mesh-mail-send", got)
|
||||
}
|
||||
// review round 1, finding 7: asserted, not merely logged. ownedBy never sets "mode", so a sealed secret
|
||||
// file carries none here either — the host applies 0600 to every one regardless (novox/hq ADR 0086) —
|
||||
// and the owner clause must not have added one of its own that could override that.
|
||||
if _, has := family["mode"]; has {
|
||||
t.Fatalf("the family member carries its own mode %v; the owner clause must not set one, so the "+
|
||||
"host's 0600 for every sealed secret file stands", family["mode"])
|
||||
}
|
||||
if plain == nil {
|
||||
t.Fatalf("the module's other own secret was not composed at all: %v", out)
|
||||
}
|
||||
if got := plain["owner"]; got != "mesh-mail" {
|
||||
t.Fatalf("a secret with no per-secret owner kept %v; want the module's secrets-owner %q", got, "mesh-mail")
|
||||
}
|
||||
}
|
||||
|
||||
// 1170/AC3: a manifest with no owner on any own secret composes exactly as it did before this clause existed:
|
||||
// every secret file belongs to the module's secrets-owner (or nothing, when that is absent too).
|
||||
func TestNoOwnerOnAnySecretComposesAsMainDoes(t *testing.T) {
|
||||
m := mailSenderManifest(t, "")
|
||||
r := Resolution{Node: "novox", Modules: []Manifest{m}}
|
||||
out, err := r.Declaration(Rendering{
|
||||
Needed: map[string]map[string]string{"mesh-mail": {"mail-credential-smtp": "sealed-credential"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("composing the declaration: %v", err)
|
||||
}
|
||||
var found bool
|
||||
for _, res := range out {
|
||||
if res["type"] == "file" && res["path"] == "/var/lib/mesh-mail/mail-credential-smtp.secret" {
|
||||
found = true
|
||||
if got := res["owner"]; got != "mesh-mail" {
|
||||
t.Fatalf("with no per-secret owner declared, the file's owner: %v; want secrets-owner %q", got, "mesh-mail")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("the family member was not composed at all: %v", out)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user