Delete the first half of internal/catalogue's tests: mesh-sdk's go/catalogue holds them (issue 1381)
The tests that read this repository's own files (controller_is_a_process, genesis_image, genesis_registry and foundation_manifests' registry test) live on in internal/ownmanifest; engine_guard_test and the rest are in mesh-sdk (issue 1379).
This commit is contained in:
@@ -1,166 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq issue 213 (ADR 0188 §1, §3): a module's own Go service is a bundle the host runs as a
|
||||
// process, not an image. Each test holds one thing that had to change in the composer for the
|
||||
// controller to be declared that way.
|
||||
|
||||
const aServiceDigest = "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
|
||||
|
||||
// aServiceModule is the controller's shape in miniature: it answers tools of its own, its code is a
|
||||
// Go bundle a process runs as an account it declares, its secrets belong to that account, it
|
||||
// prepares its state, and its process replaces the container it used to run as.
|
||||
func aServiceModule(t *testing.T) Manifest {
|
||||
t.Helper()
|
||||
raw := `{
|
||||
"module": "svc", "version": "1", "tools": ["status"], "prepares": true,
|
||||
"own-secrets": {"store": "${dir:state}/store"},
|
||||
"secrets-owner": "svc",
|
||||
"resources": [
|
||||
{"id": "state", "type": "directory", "mode": "0700", "place": "mesh", "owner": "svc"},
|
||||
{"id": "service", "type": "process", "name": "svc", "artifact": "code",
|
||||
"run": ["./svc", "serve"], "user": "svc", "replaces": ["server"],
|
||||
"env": {"SVC_STORE_FILE": "${dir:state}/store", "SVC_STORE_PORT": "${seat:mesh-store:5432}"}},
|
||||
{"id": "account", "type": "user", "name": "svc", "shell": "/usr/bin/nologin", "home": "/var/lib/svc"}
|
||||
],
|
||||
"build": {"artifacts": [{"name": "code", "kind": "bundle", "language": "go", "system": "arch",
|
||||
"from": "cmd/svc", "binary": "svc"}]}
|
||||
}`
|
||||
m, err := ParseManifest([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("the service's manifest is refused: %v", err)
|
||||
}
|
||||
resolved, err := m.Resolve([]Built{{Name: "code", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + "svc/code@" + aServiceDigest, Digest: aServiceDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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 {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// A module that declares tools has every bundle served by the node's runtime unless it says
|
||||
// otherwise — and the controller declares the verbs it answers as tools. Its service bundle is run
|
||||
// by its own process; launched a second time by the runtime it would be a second controller
|
||||
// pretending to be an MCP server.
|
||||
func TestABundleItsOwnProcessRunsIsNotServedByTheRuntime(t *testing.T) {
|
||||
m := aServiceModule(t)
|
||||
if len(m.Bundles) != 1 {
|
||||
t.Fatalf("the service's bundle was not kept: %+v", m.Bundles)
|
||||
}
|
||||
if loads := m.Bundles[0].Loads; len(loads) != 0 {
|
||||
t.Fatalf("the runtime would launch the service's own bundle as tools: %v", loads)
|
||||
}
|
||||
// And a bundle no resource runs still is served, as a module declaring tools always had it.
|
||||
tools := Manifest{Module: "t", Version: "1", Tools: []string{"x"},
|
||||
Build: &Build{Artifacts: []Artifact{{Name: "tools", Kind: ArtifactBundle, Language: "go",
|
||||
System: "arch", From: "cmd/t"}}}}
|
||||
resolved, err := tools.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + "t/tools@" + aServiceDigest, Digest: aServiceDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loads := resolved.Bundles[0].Loads; len(loads) != 1 || loads[0] != "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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatReplacesMayNameIsRefusedNearItsAuthor(t *testing.T) {
|
||||
for what, resource := range map[string]string{
|
||||
"a container": `{"id":"c","type":"container","name":"c","image":"x@` + aServiceDigest + `","replaces":["old"]}`,
|
||||
"a step": `{"id":"p","type":"process","name":"p","run":["./p"],"run-once":true,"replaces":["old"]}`,
|
||||
"something declared": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["p"]}`,
|
||||
"another module's": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["other.old"]}`,
|
||||
"not a list": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":"old"}`,
|
||||
} {
|
||||
raw := `{"module":"m","version":"1","resources":[` + resource + `]}`
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "replace") {
|
||||
t.Errorf("replaces on %s was accepted: %v", what, err)
|
||||
}
|
||||
}
|
||||
ok := `{"module":"m","version":"1","resources":[{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["old"]}]}`
|
||||
if _, err := ParseManifest([]byte(ok)); err != nil {
|
||||
t.Errorf("a process replacing what its module no longer declares was refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,108 +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)
|
||||
}
|
||||
}
|
||||
|
||||
// A manifest states an access's extent, the way a listening port states its source. An absolute
|
||||
// path and a mode of read or read-write; anything else is refused rather than acted on wrongly.
|
||||
func TestAnAccessIsValidated(t *testing.T) {
|
||||
good := []byte(`{"module":"m","accesses":[{"path":"/services/media","mode":"read-write"}]}`)
|
||||
if _, err := ParseManifest(good); err != nil {
|
||||
t.Fatalf("a valid access was refused: %v", err)
|
||||
}
|
||||
|
||||
// Mode is optional and narrows to read — the safe default, because the danger with an access
|
||||
// is being given more than was meant, not less.
|
||||
bare := []byte(`{"module":"m","accesses":[{"path":"/services/media"}]}`)
|
||||
m, err := ParseManifest(bare)
|
||||
if err != nil {
|
||||
t.Fatalf("an access with no mode was refused: %v", err)
|
||||
}
|
||||
if m.Accesses[0].At() != AccessRead {
|
||||
t.Errorf("an access with no mode is %q, want %q", m.Accesses[0].At(), AccessRead)
|
||||
}
|
||||
|
||||
relative := []byte(`{"module":"m","accesses":[{"path":"services/media","mode":"read"}]}`)
|
||||
if _, err := ParseManifest(relative); err == nil {
|
||||
t.Error("a relative access path was accepted")
|
||||
}
|
||||
|
||||
wrong := []byte(`{"module":"m","accesses":[{"path":"/services/media","mode":"append"}]}`)
|
||||
if _, err := ParseManifest(wrong); err == nil {
|
||||
t.Error("an access with an unknown mode was accepted")
|
||||
}
|
||||
}
|
||||
@@ -1,683 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0100: on an adopted node the found firewall stays in force. The mesh declares
|
||||
// openings where it would have loaded a filter, and guards its own ports in a table that only
|
||||
// refuses.
|
||||
|
||||
// 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 {
|
||||
return Resolution{Node: "anchor", Modules: []Manifest{
|
||||
{Module: "network", Computed: "overlay"},
|
||||
{Module: "postgres", Guards: []int{5432},
|
||||
Listens: []Listening{{Port: 5432, From: FromMesh}},
|
||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-store",
|
||||
"ports": []any{"5432:5432"}}}},
|
||||
{Module: "lavinmq", Guards: []int{15672},
|
||||
Listens: []Listening{{Port: 5671, From: FromMesh}, {Port: 5672, From: FromMesh}},
|
||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-broker",
|
||||
"ports": []any{"5671:5671", "5672:5672", "127.0.0.1:15672:15672"}}}},
|
||||
{Module: "distribution",
|
||||
Listens: []Listening{{Port: 5000, From: FromMesh}},
|
||||
Resources: []map[string]any{{"id": "store", "type": "container", "name": "registry",
|
||||
"ports": []any{"5000"}}}},
|
||||
{Module: "hello-web",
|
||||
Listens: []Listening{{Port: 8080, From: FromEverywhere}},
|
||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "hello-web",
|
||||
"ports": []any{"8080"}}}},
|
||||
{Module: "helper", Listens: []Listening{{Port: 9000, From: FromMachine}}},
|
||||
{Module: "nftables", Filtering: &Filtering{Into: "/etc/nftables.conf"},
|
||||
Resources: []map[string]any{{"id": "load", "type": "service", "unit": "mesh-filter.service",
|
||||
"state": "running", "restart-on": []any{"filtering"}}}},
|
||||
}}
|
||||
}
|
||||
|
||||
func anchorRendering(adopted bool) 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 {
|
||||
out[r["id"].(string)] = r
|
||||
}
|
||||
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 TestAGuardedPortMustBeAPort(t *testing.T) {
|
||||
if _, err := ParseManifest([]byte(`{"module":"postgres","guards":[5432]}`)); err != nil {
|
||||
t.Fatalf("guards is refused: %v", err)
|
||||
}
|
||||
if _, err := ParseManifest([]byte(`{"module":"postgres","guards":[0]}`)); err == nil {
|
||||
t.Fatal("guarding port 0 was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAGivenPortIsTheNodesAndReachesSomething(t *testing.T) {
|
||||
store := anAdoptedAnchor().Modules[1]
|
||||
node := func(v any) []Layer {
|
||||
return []Layer{{From: "anchor", Placement: map[string]any{PortsSetting: v}}}
|
||||
}
|
||||
if got, err := GivenPorts(store, node(map[string]any{"5432": float64(5433)})); err != nil ||
|
||||
got[5432] != 5433 {
|
||||
t.Fatalf("a node's given port was not read: %v %v", got, err)
|
||||
}
|
||||
if _, err := GivenPorts(store, []Layer{{From: MeshWideLayer,
|
||||
Placement: map[string]any{PortsSetting: map[string]any{"5432": float64(5433)}}}}); err == nil {
|
||||
t.Fatal("a port given for the whole mesh was accepted")
|
||||
}
|
||||
if _, err := GivenPorts(store, node(map[string]any{"6000": float64(6001)})); err == nil {
|
||||
t.Fatal("a port the module neither listens on, publishes nor guards was given")
|
||||
}
|
||||
if _, err := GivenPorts(store, node(map[string]any{"5432": float64(70000)})); err == nil {
|
||||
t.Fatal("a machine port that is not a port was given")
|
||||
}
|
||||
if _, err := GivenPorts(store, node(map[string]any{"5432": float64(22)})); err == nil {
|
||||
t.Fatal("ssh's port was given")
|
||||
}
|
||||
broker := anAdoptedAnchor().Modules[2]
|
||||
if _, err := GivenPorts(broker, node(map[string]any{"5671": float64(5700),
|
||||
"5672": float64(5700)})); err == nil {
|
||||
t.Fatal("one machine port was given for two of the module's ports")
|
||||
}
|
||||
if stray := UnusedSettings(store, node(map[string]any{"5432": float64(5433)})); len(stray) != 0 {
|
||||
t.Fatalf("a given port is called stray: %v", stray)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
got := Published([]map[string]any{{"type": "container", "ports": []any{
|
||||
"127.0.0.1:15672:15672", "[::1]:8080:80", "localhost:9090:90",
|
||||
"[::]:8443:443", "0.0.0.0:5000:5000", "5353:53/udp"}}})
|
||||
want := map[string]map[int]int{"tcp": {8443: 443, 5000: 5000}, "udp": {5353: 53}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
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,119 +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 := ParseManifest([]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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRuntimeIsToldTheAgentAccount(t *testing.T) {
|
||||
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}}
|
||||
envOf := func(r Resolution) map[string]string {
|
||||
t.Helper()
|
||||
r.Node, r.Modules = "anchor", []Manifest{aToolsModule(t, "nftables", "tools/index.js"), theRuntime(t)}
|
||||
out, err := r.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
process := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())
|
||||
if process == nil {
|
||||
t.Fatal("no runtime process was composed")
|
||||
}
|
||||
return process["env"].(map[string]string)
|
||||
}
|
||||
env := envOf(Resolution{Account: "ops", AgentAccount: "agent"})
|
||||
if env[RuntimeAgentAccount] != "agent" || env[RuntimeAgentHome] != "/home/agent" || env[RuntimeOperatorAccount] != "ops" {
|
||||
t.Errorf("the runtime is not told whom agents run as: %v", env)
|
||||
}
|
||||
env = envOf(Resolution{Account: "ops"})
|
||||
if env[RuntimeAgentAccount] != "ops" || env[RuntimeAgentHome] != "/home/ops" {
|
||||
t.Errorf("with no agent account, agents run as the operator: %v", env)
|
||||
}
|
||||
env = envOf(Resolution{})
|
||||
if _, set := env[RuntimeAgentAccount]; set {
|
||||
t.Errorf("a machine with no account names an agent account: %v", env)
|
||||
}
|
||||
if problems := bundleEnvProblems("x", Artifact{Name: "b", Kind: ArtifactBundle, Loads: []string{"x"},
|
||||
Env: map[string]string{RuntimeAgentAccount: "me"}}); len(problems) == 0 {
|
||||
t.Error("a bundle may tell the runtime whom agents run as")
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/beside"
|
||||
)
|
||||
|
||||
// **The word does not come back through a manifest** (novox/hq ADR 0131). A module that wants
|
||||
// messaging wants the mesh's bus, reached through the sdk and named by the `mesh-broker` seat. Naming
|
||||
// the old wire protocol asks for the one server being retired, so both directions are refused at the
|
||||
// parser — this is judged from the manifest alone, no store needed.
|
||||
|
||||
func TestAManifestProvidingAmqpIsRefused(t *testing.T) {
|
||||
raw := []byte(`{"module":"old-broker","version":"1","provides":[{"name":"amqp","scope":"mesh"}]}`)
|
||||
_, err := ParseManifest(raw)
|
||||
if err == nil || !strings.Contains(err.Error(), `provides "amqp", which is not a provision`) {
|
||||
t.Fatalf("a module providing amqp was not refused, or not for the reason: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAManifestRequiringAmqpIsRefused(t *testing.T) {
|
||||
raw := []byte(`{"module":"forwarder","version":"1","requires":["amqp"]}`)
|
||||
_, err := ParseManifest(raw)
|
||||
if err == nil || !strings.Contains(err.Error(), `requires "amqp", which is not a provision`) {
|
||||
t.Fatalf("a module requiring amqp was not refused, or not for the reason: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// And the catalogue (internal/beside) names it nowhere — the three modules that did are removed under
|
||||
// design 28 task 5.4, not converted.
|
||||
func TestNoCatalogueManifestNamesAmqp(t *testing.T) {
|
||||
modules, err := filepath.Glob(filepath.Join(beside.Catalogue(t), "*", "module.json"))
|
||||
if err != nil || len(modules) == 0 {
|
||||
t.Fatalf("no manifests in the catalogue, so this proved nothing: %v", err)
|
||||
}
|
||||
for _, path := range modules {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), `"amqp"`) {
|
||||
t.Errorf("%s names amqp, which is not a provision (novox/hq ADR 0131)",
|
||||
filepath.Base(filepath.Dir(path)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import "testing"
|
||||
|
||||
// A claim written before the rename still holds (novox/hq ADR 0122, ADR 0156): with the store's
|
||||
// aliases loaded, the former name resolves to the seat.
|
||||
func TestTheArtifactStoresFormerNameResolvesToIt(t *testing.T) {
|
||||
was := aliases
|
||||
t.Cleanup(func() { aliases = was })
|
||||
UseAliases(map[string]string{"the-artifact-store": "mesh-artifact-store"})
|
||||
seat, known := SeatNamed("the-artifact-store")
|
||||
if !known || seat.Name != "mesh-artifact-store" || seat.Delivers != "artifact-store" {
|
||||
t.Fatalf("the former name did not resolve: %+v %v", seat, known)
|
||||
}
|
||||
if _, known := SeatNamed("mesh-artifact-store"); !known {
|
||||
t.Fatal("the seat is not in the set under its name")
|
||||
}
|
||||
}
|
||||
@@ -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,137 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A build is recorded by what it is; where it is pushed is composed where it is used (novox/hq
|
||||
// 04-ISSUES/102).
|
||||
|
||||
var digest = "sha256:" + strings.Repeat("d", 64)
|
||||
|
||||
func TestAReferenceIsRecordedWithoutTheStoresAddress(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"anchor.internal:5100/gitea/server@" + digest: ArtifactStoreScheme + "gitea/server@" + digest,
|
||||
"localhost:5000/gitea/server@" + digest: ArtifactStoreScheme + "gitea/server@" + digest,
|
||||
"http://anchor.internal:5100/v2/gitea/config/blobs/" + digest: ArtifactStoreScheme + "gitea/config/blobs/" + digest,
|
||||
ArtifactStoreScheme + "gitea/server@" + digest: ArtifactStoreScheme + "gitea/server@" + digest,
|
||||
digest: digest,
|
||||
"@novox/sdk@1.2.3": "@novox/sdk@1.2.3",
|
||||
"gitea/gitea@" + digest: "gitea/gitea@" + digest,
|
||||
"https://registry.example/v2/gitea/config/blobs/" + digest: "https://registry.example/v2/gitea/config/blobs/" + digest,
|
||||
}
|
||||
for announced, want := range cases {
|
||||
if got := Recorded(announced); got != want {
|
||||
t.Errorf("Recorded(%q) = %q, want %q", announced, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestARecordedReferenceIsRoutedThroughTheStoreAsItIsNow(t *testing.T) {
|
||||
if got := Routed(ArtifactStoreScheme+"gitea/server@"+digest, "anchor.internal:5101"); got != "anchor.internal:5101/gitea/server@"+digest {
|
||||
t.Errorf("an image is fetched as %q", got)
|
||||
}
|
||||
if got := Routed(ArtifactStoreScheme+"gitea/config/blobs/"+digest, "anchor.internal:5101"); got != "http://anchor.internal:5101/v2/gitea/config/blobs/"+digest {
|
||||
t.Errorf("an archive is fetched as %q", got)
|
||||
}
|
||||
if got := Routed("gitea/gitea@"+digest, "anchor.internal:5101"); got != "gitea/gitea@"+digest {
|
||||
t.Errorf("a reference that is not the store's was routed: %q", got)
|
||||
}
|
||||
// One recorded before references were kept without their address follows the store too.
|
||||
if got := Rerouted("anchor.internal:5100/gitea/server@"+digest, "anchor.internal:5101"); got != "anchor.internal:5101/gitea/server@"+digest {
|
||||
t.Errorf("a reference recorded with the old address stays there: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// **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,126 +0,0 @@
|
||||
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,47 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A backup contribution written by hand still names its module's own directories; one it does not
|
||||
// declare is refused where it is written rather than reaching the holder as the literal text. (The
|
||||
// catalogue check refuses a hand-written backup line at all since ADR 0233; parsing still reads one,
|
||||
// because a module on the shelf was written before.)
|
||||
func TestABackupNamingAnUndeclaredDirectoryIsRefused(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"pg","version":"1",
|
||||
"contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:nowhere}"}]}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "nowhere") {
|
||||
t.Fatalf("a backup of an undeclared directory was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 := ParseManifest([]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 := ParseManifest([]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)
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The module that provides the artifact store may not be delivered through it.
|
||||
//
|
||||
// Building publishes to the store and the builder will not start without one, so a module that
|
||||
// provides the store and also builds something asks the mesh to put an artifact into the thing
|
||||
// that artifact is needed to create. On a mesh new enough to have no registry, that is a build
|
||||
// that never returns (novox/hq 04-ISSUES/029).
|
||||
func TestTheArtifactStoreCannotBeDeliveredThroughItself(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"registry","version":"1",` +
|
||||
`"provides":[{"name":"artifact-store","scope":"mesh"}],` +
|
||||
`"build":{"artifacts":[{"name":"registry","kind":"upstream","from":"registry:2"}]},` +
|
||||
`"resources":[{"id":"store","type":"container","name":"mesh-registry",` +
|
||||
`"artifact":"registry","ports":["5000:5000"]}]}`))
|
||||
if err == nil {
|
||||
t.Fatal("a registry module that builds its own image was accepted; the build has " +
|
||||
"nowhere to publish until the module it belongs to is already running")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "artifact-store") {
|
||||
t.Fatalf("refused without naming the provision the cycle turns on: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Naming the image directly is the way out, and must stay accepted.
|
||||
func TestAnArtifactStoreThatNamesItsImageIsAccepted(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"registry","version":"1",` +
|
||||
`"provides":[{"name":"artifact-store","scope":"mesh"}],` +
|
||||
`"resources":[{"id":"store","type":"container","name":"mesh-registry",` +
|
||||
`"image":"registry@sha256:` +
|
||||
`266f282fabd7cd3df053ee7c658c77b42380d1a2f0d8e5a1c0d7a6d5b5c4a3b2",` +
|
||||
`"ports":["5000:5000"]}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("the one way an artifact store can be delivered was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// And an ordinary module still builds whatever it likes.
|
||||
func TestAModuleThatDoesNotProvideTheStoreStillBuilds(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"forge","version":"1",` +
|
||||
`"build":{"artifacts":[{"name":"forge","kind":"upstream","from":"gitea/gitea:1.22"}]},` +
|
||||
`"resources":[{"id":"run","type":"container","name":"forge","artifact":"forge"}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("an ordinary module was caught by a rule about the artifact store: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A mapping that names an address still names the port, and the machine side is still the middle.
|
||||
//
|
||||
// The foundation bundle writes "127.0.0.1:5432:5432" today, so the shape is not hypothetical.
|
||||
// Found in review: the first cut split on the first colon, read "127.0.0.1" as the machine port,
|
||||
// failed to parse it, and silently skipped the mapping — which put the filter back on the
|
||||
// declared port, the exact fault MachineSide was written to end.
|
||||
func TestAnAddressedMappingStillNamesThePort(t *testing.T) {
|
||||
m := Manifest{Module: "store", Resources: []map[string]any{
|
||||
{"type": "container", "id": "server", "ports": []any{"127.0.0.1:8080:80"}},
|
||||
}}
|
||||
for _, named := range []int{8080, 80} {
|
||||
at, mayAssign := m.MachineSide(named)
|
||||
if mayAssign {
|
||||
t.Fatalf("%d was reassigned though the manifest published it explicitly", named)
|
||||
}
|
||||
if at != 8080 {
|
||||
t.Fatalf("naming %d gave %d; the machine side of 127.0.0.1:8080:80 is 8080", named, at)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
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 {
|
||||
t.Fatalf("an ordinary consumer was refused: %v", err)
|
||||
}
|
||||
if err := CheckIdentity(strings.Repeat("n", 40), strings.Repeat("m", 40)); err == nil {
|
||||
t.Fatal("an identity that providers would shorten was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// Two modules on one node get two identities, which is the whole point of both issues.
|
||||
func TestTwoModulesOnOneNodeAreTwoIdentities(t *testing.T) {
|
||||
if ConsumerIdentity("anchor", "gitea") == ConsumerIdentity("anchor", "keycloak") {
|
||||
t.Fatal("two consumers on one machine share an identity")
|
||||
}
|
||||
// And one module on two nodes, likewise.
|
||||
if ConsumerIdentity("anchor", "gitea") == ConsumerIdentity("laptop", "gitea") {
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A consumer of a provision that keeps its data stays bound where its data is (novox/hq ADR 0232,
|
||||
// issue 273).
|
||||
//
|
||||
// The incident, exactly: a machine runs its own store and five consumers of it; the mesh's store seat
|
||||
// is held by the store on another machine. Issue 258's rule — the seat's holder elsewhere answers
|
||||
// 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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if o.KeepsConsumerData == nil || *o.KeepsConsumerData {
|
||||
t.Fatalf("read %+v", o)
|
||||
}
|
||||
out, err := json.Marshal(o)
|
||||
if err != nil || !strings.Contains(string(out), `"keeps-consumer-data":false`) {
|
||||
t.Fatalf("wrote %s, %v", out, err)
|
||||
}
|
||||
yes, no := true, false
|
||||
granting := Manifest{Module: "g", Provides: []Offer{{Name: "p", Scope: ScopeMesh}}, Grants: map[string]string{"p": "/g"}}
|
||||
if !granting.KeepsConsumerData("p") {
|
||||
t.Error("a provider granting each consumer a credential keeps what it writes, unsaid")
|
||||
}
|
||||
if (Manifest{Module: "r", Provides: []Offer{{Name: "p", Scope: ScopeMesh}}}).KeepsConsumerData("p") {
|
||||
t.Error("a provider granting nothing keeps nothing, unsaid")
|
||||
}
|
||||
granting.Provides[0].KeepsConsumerData = &no
|
||||
if granting.KeepsConsumerData("p") {
|
||||
t.Error("what an offer says, it gets")
|
||||
}
|
||||
said := Manifest{Module: "s", Provides: []Offer{{Name: "p", Scope: ScopeMesh, KeepsConsumerData: &yes}}}
|
||||
if !said.KeepsConsumerData("p") || !KeepsConsumerData(map[string]Manifest{"s": said, "r": {Module: "r",
|
||||
Provides: []Offer{{Name: "p", Scope: ScopeMesh, KeepsConsumerData: &no}}}}, "p") {
|
||||
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,87 +0,0 @@
|
||||
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.
|
||||
|
||||
// **A seat and the interface it delivers are different names, and renaming one must not rename
|
||||
// the other** (novox/hq ADR 0118). This nearly went wrong: the seats were renamed to the `mesh-*`
|
||||
// prefix, and a blanket search-and-replace also renamed `npm-package-registry` and `git` where
|
||||
// they are *provisions* — which a consumer requires and a provider offers. The tests failed with
|
||||
// "the package registry is served on <nil>", which does not say "you renamed an interface".
|
||||
func TestRenamingASeatDidNotRenameTheInterfaceItDelivers(t *testing.T) {
|
||||
for _, pair := range []struct{ seat, delivers string }{
|
||||
{"git", "git"},
|
||||
{"npm-package-registry", "npm-package-registry"},
|
||||
{"mesh-artifact-store", "artifact-store"},
|
||||
{"mesh-store", "postgres-database"},
|
||||
{"mesh-broker", "mesh-bus"},
|
||||
} {
|
||||
s, known := SeatNamed(pair.seat)
|
||||
if !known {
|
||||
t.Fatalf("%q is not a seat", pair.seat)
|
||||
}
|
||||
if s.Delivers != pair.delivers {
|
||||
t.Errorf("the %s seat delivers %q, expected %q — renaming the seat moved the "+
|
||||
"interface with it, and every consumer requiring it would stop resolving",
|
||||
pair.seat, s.Delivers, pair.delivers)
|
||||
}
|
||||
// **Three of these deliberately share a name with what they deliver**, and that is not an
|
||||
// incomplete rename. Renaming a seat that delivers a provision cascades to every consumer
|
||||
// requiring it, with a mesh-wide window where a holder stops resolving mid-flight — so the
|
||||
// trunk deferred exactly those three (novox/hq ADR 0121) while renaming the node-scoped ones.
|
||||
// What this test is for is the other direction: that renaming a seat never moves the
|
||||
// interface, which once produced "the package registry is served on <nil>".
|
||||
}
|
||||
}
|
||||
|
||||
// A manifest written against an old seat name is told what it became rather than refused as
|
||||
// unknown. **That map is the controller's store now, not this package** (novox/hq ADR 0122): a
|
||||
// rename is a row, so the courtesy survives a rename nobody recompiled for. Checked where the
|
||||
// table is read, not here, where there is no longer a hardcoded list to check against.
|
||||
@@ -1,332 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Where the answer to a requirement is allowed to live.
|
||||
//
|
||||
// A shell, a display server and a private network have to be on the machine that needs them. A
|
||||
// database does not — it runs somewhere and is reached over the network. Both were written
|
||||
// `requires`, so both were answered the same way, and the second answer was to install PostgreSQL
|
||||
// on every machine that runs a web application.
|
||||
|
||||
// onNetwork is a set of providers, all of them reachable. Written as a helper because a machine
|
||||
// that cannot reach the one answering its requirement is its own case, tested separately.
|
||||
// reachable is this machine, on the private network.
|
||||
func reachable() Node {
|
||||
n := workstation()
|
||||
n.At = "workstation.internal"
|
||||
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.
|
||||
m, err := ParseManifest([]byte(`{"module":"zsh","version":"1","provides":["shell"]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m.Provides) != 1 || m.Provides[0].At() != ScopeNode {
|
||||
t.Fatalf("a plain name is no longer node-scoped: %v", m.Provides)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASiteScopedProvisionIsRefused(t *testing.T) {
|
||||
// Meaningful for a claim — one DHCP server per segment — and not yet meaningful for a
|
||||
// provision, because nothing knows how to reach "the one at my site".
|
||||
_, err := ParseManifest([]byte(
|
||||
`{"module":"dns","version":"1","provides":[{"name":"resolver","scope":"site"}]}`))
|
||||
if err == nil {
|
||||
t.Fatal("a site-scoped provision was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "site") {
|
||||
t.Fatalf("unhelpful refusal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBindingWhatYouDoNotRequireIsRefused(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"app","version":"1",
|
||||
"binds":{"postgres-database":"/etc/app/db.json"}}`))
|
||||
if err == nil {
|
||||
t.Fatal("a module was told about something it never asked for")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "does not require") {
|
||||
t.Fatalf("unhelpful refusal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServingWhatYouDoNotProvideIsRefused(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"app","version":"1",
|
||||
"serves":{"postgres-database":{"port":5432}}}`))
|
||||
if err == nil {
|
||||
t.Fatal("a module served something it does not provide")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "does not provide") {
|
||||
t.Fatalf("unhelpful refusal: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// bundleFor is a manifest whose one artifact is a bundle in the given language and system.
|
||||
func bundleFor(language, system string) Manifest {
|
||||
return Manifest{Module: "a-component", Build: &Build{Artifacts: []Artifact{
|
||||
{Name: "binary", Kind: ArtifactBundle, Language: language, System: system},
|
||||
}}}
|
||||
}
|
||||
|
||||
func problemsOf(t *testing.T, m Manifest) string {
|
||||
t.Helper()
|
||||
return strings.Join(m.Build.problems(m.Module), "\n")
|
||||
}
|
||||
|
||||
// **A language that compiles to a binary must say which system.**
|
||||
//
|
||||
// A binary is pinned to one operating system at link time, so a host refuses to touch a machine it
|
||||
// was not built for. An artifact that says nothing would be compiled for whatever the build machine
|
||||
// happened to be — which reads as portable and is not, and is the fault this check exists for.
|
||||
func TestABinaryMustSayWhichSystemItIsFor(t *testing.T) {
|
||||
got := problemsOf(t, bundleFor("go", ""))
|
||||
if !strings.Contains(got, "says no system") {
|
||||
t.Fatalf("a compiled bundle with no system was accepted:\n%s", got)
|
||||
}
|
||||
// And the refusal names what it could have said, so a reader is one edit from right.
|
||||
for _, system := range []string{"alpine", "android", "arch"} {
|
||||
if !strings.Contains(got, system) {
|
||||
t.Fatalf("the refusal does not name %q as a choice:\n%s", system, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestABinaryThatNamesASystemIsAccepted(t *testing.T) {
|
||||
if got := problemsOf(t, bundleFor("go", "arch")); got != "" {
|
||||
t.Fatalf("a compiled bundle naming a system was refused:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A system the mesh does not build for is refused where it is written. These are the host's own
|
||||
// names, not a compiler's: the difference between two of them is a C library rather than a kernel,
|
||||
// so a value that looks like an operating system to a toolchain is still wrong here.
|
||||
func TestASystemTheMeshDoesNotBuildForIsRefused(t *testing.T) {
|
||||
for _, wrong := range []string{"linux", "debian", "darwin"} {
|
||||
got := problemsOf(t, bundleFor("go", wrong))
|
||||
if !strings.Contains(got, "and a system is") {
|
||||
t.Fatalf("%q was accepted as a system:\n%s", wrong, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **And a language that runs anywhere must not name one.** A system that decides nothing reads as
|
||||
// though it did, which is the same fault as a restriction that restricts nothing (novox/hq ADR 0045).
|
||||
func TestAPortableBundleMayNotNameASystem(t *testing.T) {
|
||||
got := problemsOf(t, bundleFor("typescript", "arch"))
|
||||
if !strings.Contains(got, "runs anywhere") {
|
||||
t.Fatalf("a portable bundle was allowed to name a system:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPortableBundleNamingNoSystemIsAccepted(t *testing.T) {
|
||||
if got := problemsOf(t, bundleFor("typescript", "")); got != "" {
|
||||
t.Fatalf("an ordinary bundle was refused:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// One component, one artifact per system: the shape the mesh's own binaries are declared in, and the
|
||||
// reason the target is the artifact's rather than the recipe's.
|
||||
func TestOneArtifactPerSystemIsAccepted(t *testing.T) {
|
||||
m := Manifest{Module: "the-host", Build: &Build{Artifacts: []Artifact{
|
||||
{Name: "arch", Kind: ArtifactBundle, Language: "go", System: "arch"},
|
||||
{Name: "alpine", Kind: ArtifactBundle, Language: "go", System: "alpine"},
|
||||
{Name: "android", Kind: ArtifactBundle, Language: "go", System: "android"},
|
||||
}}}
|
||||
if got := problemsOf(t, m); got != "" {
|
||||
t.Fatalf("one artifact per system was refused:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The manifest in a repository and the manifest the mesh holds are two documents.
|
||||
//
|
||||
// A digest is not knowable until something is built, so a repository carrying one would be a
|
||||
// repository whose file is wrong the moment anybody edits anything — and the mesh would be pinning
|
||||
// a value nobody could have checked.
|
||||
|
||||
func buildable() Manifest {
|
||||
return Manifest{
|
||||
Module: "meshboard", Version: "1",
|
||||
Build: &Build{Artifacts: []Artifact{
|
||||
{Name: "server", Kind: ArtifactImage, From: "Dockerfile"},
|
||||
{Name: "theme", Kind: ArtifactArchive, From: "files"},
|
||||
}},
|
||||
Resources: []map[string]any{
|
||||
{"id": "svc", "type": "container", "name": "meshboard", "artifact": "server"},
|
||||
{"id": "look", "type": "archive", "path": "/opt/meshboard", "artifact": "theme"},
|
||||
{"id": "dir", "type": "directory", "path": "/opt/meshboard"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func wasBuilt() []Built {
|
||||
return []Built{
|
||||
{Name: "server", Kind: ArtifactImage,
|
||||
Reference: "registry.invalid/meshboard@sha256:" + strings.Repeat("a", 64)},
|
||||
{Name: "theme", Kind: ArtifactArchive,
|
||||
Reference: "https://store.invalid/theme.tar.gz",
|
||||
Digest: "sha256:" + strings.Repeat("b", 64)},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAResourceNamingAnArtifactBecomesOneNamingTheThing(t *testing.T) {
|
||||
got, err := buildable().Resolve(wasBuilt())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range got.Resources {
|
||||
if r["artifact"] != nil {
|
||||
// A build-time word the host has never heard of. Leaving it would be a field the
|
||||
// strict decoder refuses on the machine, at the worst moment.
|
||||
t.Fatalf("%v still names an artifact: %v", r["id"], r)
|
||||
}
|
||||
}
|
||||
if got.Resources[0]["image"] != "registry.invalid/meshboard@sha256:"+strings.Repeat("a", 64) {
|
||||
t.Fatalf("the image was not filled in: %v", got.Resources[0])
|
||||
}
|
||||
if got.Resources[1]["source"] != "https://store.invalid/theme.tar.gz" ||
|
||||
got.Resources[1]["digest"] != "sha256:"+strings.Repeat("b", 64) {
|
||||
t.Fatalf("the archive was not filled in: %v", got.Resources[1])
|
||||
}
|
||||
// And a resource that names nothing is untouched.
|
||||
if got.Resources[2]["path"] != "/opt/meshboard" || len(got.Resources[2]) != 3 {
|
||||
t.Fatalf("an ordinary resource was changed: %v", got.Resources[2])
|
||||
}
|
||||
// The built manifest carries no build section: it is the derived document, and something
|
||||
// holding both would invite somebody to build from it again.
|
||||
if got.Build != nil {
|
||||
t.Fatal("the built manifest still says how to build itself")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnArtifactDeclaredAndNotBuiltIsRefused(t *testing.T) {
|
||||
// A build that did not do what the manifest asked. Said here rather than by a machine later
|
||||
// reporting an empty image.
|
||||
_, err := buildable().Resolve(wasBuilt()[:1])
|
||||
if err == nil {
|
||||
t.Fatal("a manifest resolved with an artifact missing")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "theme") {
|
||||
t.Fatalf("the refusal does not name what is missing: %v", err)
|
||||
}
|
||||
// And it must blame the build rather than the resource. A resource asking for something that
|
||||
// does not exist is a manifest fault; a build not producing what the manifest declared is a
|
||||
// build fault, and telling somebody to fix the wrong one costs an afternoon.
|
||||
if !strings.Contains(err.Error(), "the build did not produce") {
|
||||
t.Fatalf("the refusal blames the wrong thing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAResourceNamingSomethingTheModuleDoesNotBuildIsRefused(t *testing.T) {
|
||||
m := buildable()
|
||||
m.Resources = append(m.Resources,
|
||||
map[string]any{"id": "other", "type": "archive", "path": "/x", "artifact": "nothing"})
|
||||
if _, err := m.Resolve(wasBuilt()); err == nil {
|
||||
t.Fatal("a resource naming an artifact nobody builds was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleThatBuildsNothingIsOrdinary(t *testing.T) {
|
||||
// Most of what a person installs is configuration. Requiring an empty build section would be
|
||||
// a field that exists to be left blank.
|
||||
m := Manifest{Module: "shell", Version: "1", Resources: []map[string]any{
|
||||
{"id": "rc", "type": "file", "path": "/etc/zsh/zshrc", "content": "setopt"},
|
||||
}}
|
||||
got, err := m.Resolve(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Resources) != 1 {
|
||||
t.Fatalf("got %v", got.Resources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoArtifactsWithOneNameAreRefused(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
|
||||
{"name":"x","kind":"image","from":"Dockerfile"},
|
||||
{"name":"x","kind":"archive","from":"files"}]}}`))
|
||||
if err == nil {
|
||||
t.Fatal("two artifacts with one name were accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "could mean either") {
|
||||
t.Fatalf("unhelpful refusal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnArtifactBuiltFromOutsideItsRepositoryIsRefused(t *testing.T) {
|
||||
// A build reads its own repository and nothing else. A path leaving it would make what gets
|
||||
// built depend on whatever happens to be on the machine doing the building.
|
||||
for _, from := range []string{"/etc/passwd", "../elsewhere"} {
|
||||
_, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
|
||||
{"name":"x","kind":"archive","from":"` + from + `"}]}}`))
|
||||
if err == nil {
|
||||
t.Fatalf("%q was accepted as a build input", from)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "outside its own repository") {
|
||||
t.Fatalf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnArtifactOfAnUnknownKindIsRefused(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
|
||||
{"name":"x","kind":"binary","from":"main.go"}]}}`))
|
||||
if err == nil {
|
||||
t.Fatal("an artifact of an unknown kind was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPackageParsesLikeABundleAndNeedsALanguage(t *testing.T) {
|
||||
// The SDK's shape: a package built from the module's own directory, naming a language.
|
||||
m, err := ParseManifest([]byte(`{"module":"mesh-sdk","version":"1","slug":"sdk",
|
||||
"build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript"}]},
|
||||
"resources":[]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("the SDK's package manifest did not parse: %v", err)
|
||||
}
|
||||
if m.Build.Artifacts[0].Kind != ArtifactPackage {
|
||||
t.Fatalf("expected a package artifact, got %q", m.Build.Artifacts[0].Kind)
|
||||
}
|
||||
|
||||
// A package that names what it is built from is refused, exactly as a bundle is: it is built
|
||||
// from the module's own directory.
|
||||
if _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
|
||||
{"name":"lib","kind":"package","language":"typescript","from":"Dockerfile"}]}}`)); err == nil {
|
||||
t.Fatal("a package naming a source was accepted")
|
||||
}
|
||||
// A package with no language cannot choose a toolchain.
|
||||
if _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
|
||||
{"name":"lib","kind":"package"}]}}`)); err == nil {
|
||||
t.Fatal("a package with no language was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAResourceNamingAPackageIsRefused(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(`{"module":"a","version":"1","slug":"a",
|
||||
"build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript"}]},
|
||||
"resources":[{"id":"svc","type":"container","name":"a","artifact":"lib"}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
_, err = m.Resolve([]Built{{Name: "lib", Kind: ArtifactPackage, Reference: "@novox/a@1.0.0"}})
|
||||
if err == nil {
|
||||
t.Fatal("a resource backed by a package was accepted; a package is not a resource")
|
||||
}
|
||||
}
|
||||
|
||||
// An image names the Go package its recipe compiles (novox/hq ADR 0267): within the tree it is built in,
|
||||
// and only an image says one.
|
||||
func TestOnlyAnImageNamesThePackageItCompilesWithinItsTree(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
|
||||
{"name":"x","kind":"image","from":"Dockerfile","compiles":"cmd/x"}]}}`))
|
||||
if err != nil || m.Build.Artifacts[0].Compiles != "cmd/x" {
|
||||
t.Fatalf("an image naming its package was refused or lost it: %v", err)
|
||||
}
|
||||
for _, c := range []struct{ artifact, says string }{
|
||||
{`{"name":"x","kind":"archive","from":"files","compiles":"cmd/x"}`, "Only an image"},
|
||||
{`{"name":"x","kind":"image","from":"Dockerfile","compiles":"../x"}`, "leaves the tree"},
|
||||
{`{"name":"x","kind":"image","from":"Dockerfile","compiles":"/x"}`, "leaves the tree"},
|
||||
{`{"name":"x","kind":"image","from":"Dockerfile","compiles":"a/../../x"}`, "leaves the tree"},
|
||||
} {
|
||||
_, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[` + c.artifact + `]}}`))
|
||||
if err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%s: %v, wanted a refusal saying %q", c.artifact, err, c.says)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/beside"
|
||||
)
|
||||
|
||||
// The module holding mesh-broker is the bus, and its account is granted the bus's snapshot API and
|
||||
// nothing else (novox/hq ADR 0235). Anything it declared to say or hear on the bus would be granted
|
||||
// nothing, so it is refused at the parser rather than silently dropped.
|
||||
func TestTheBussAccountSaysNothingOnTheBus(t *testing.T) {
|
||||
raw := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],
|
||||
"claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"},
|
||||
"emits":["something.happened"]}`)
|
||||
_, err := ParseManifest(raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "granted the bus's snapshot API and nothing else") {
|
||||
t.Fatalf("a bus module declaring what it emits was not refused, or not for the reason: %v", err)
|
||||
}
|
||||
|
||||
// Its tools bundle says what it emits (novox/hq ADR 0357): the node's runtime carries the bundle and is
|
||||
// granted the module's events, so a bus module that ships one may declare them.
|
||||
bundled := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],
|
||||
"claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"},
|
||||
"emits":["bus.slow-consumer"],"tools":["bus_streams"],
|
||||
"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/t","binary":"t"}]}}`)
|
||||
if _, err := ParseManifest(bundled); err != nil {
|
||||
t.Fatalf("a bus module whose tools bundle declares what it emits was refused: %v", err)
|
||||
}
|
||||
|
||||
quiet := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],
|
||||
"claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"},
|
||||
"tools":["bus_streams"]}`)
|
||||
m, err := ParseManifest(quiet)
|
||||
if err != nil {
|
||||
t.Fatalf("a bus module with an account and tools was refused: %v", err)
|
||||
}
|
||||
if !m.ClaimsSeat(BrokerSeat) {
|
||||
t.Fatal("it does not read as holding the broker seat")
|
||||
}
|
||||
}
|
||||
|
||||
// The catalogue's bus protects its streams by the snapshot, not as live files: its streams' item is a
|
||||
// dump into its snapshots, it has the account the dump runs as, and the dump runs the snapshot
|
||||
// program in the bus's own container.
|
||||
func TestTheCataloguesBusIsBackedUpBySnapshot(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join(beside.Catalogue(t), "nats", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := ParseManifest(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, account := m.OwnSecrets["broker"]; !account {
|
||||
t.Fatal("the bus declares no account, so its snapshot could not reach it")
|
||||
}
|
||||
var found bool
|
||||
if m.Data == nil {
|
||||
t.Fatal("the bus declares no data")
|
||||
}
|
||||
for _, it := range m.Data.Own {
|
||||
if it.ID != "jetstream" {
|
||||
continue
|
||||
}
|
||||
found = true
|
||||
if !it.Backup.IsDump() || it.Backup.Into != "snapshots" {
|
||||
t.Fatalf("the bus's streams are protected by %+v, not a snapshot into its snapshots", it.Backup)
|
||||
}
|
||||
if !strings.Contains(it.Backup.Dump, "mesh-nats-snapshot snapshot") {
|
||||
t.Fatalf("the dump does not run the snapshot program: %s", it.Backup.Dump)
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("the bus declares no item for its streams")
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/validate"
|
||||
)
|
||||
|
||||
// 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 TestABusyIsHeldToItsShape(t *testing.T) {
|
||||
if _, err := ParseManifest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"})); err != nil {
|
||||
t.Fatalf("a well-formed busy was refused: %v", err)
|
||||
}
|
||||
if _, err := ParseManifest(busyManifest(t, "agent", map[string]any{"command": "x", "timeout": "30s"})); err != nil {
|
||||
t.Fatalf("a busy with its timeout was refused: %v", err)
|
||||
}
|
||||
for name, c := range map[string]struct {
|
||||
on string
|
||||
busy any
|
||||
says string
|
||||
}{
|
||||
"text": {"agent", "mesh-builder busy", "it is an object"},
|
||||
"no command": {"agent", map[string]any{"timeout": "5s"}, "names no command"},
|
||||
"two lines": {"agent", map[string]any{"command": "a\nb"}, "one line"},
|
||||
"a long timeout": {"agent", map[string]any{"command": "x", "timeout": "5m"}, "at most 1m0s"},
|
||||
"an unknown key": {"agent", map[string]any{"command": "x", "every": "1m"}, `says "every"`},
|
||||
"on a step": {"seed", map[string]any{"command": "x"}, "not a container that stays up"},
|
||||
"on a service": {"daemon", map[string]any{"command": "x"}, "not a container that stays up"},
|
||||
} {
|
||||
_, err := ParseManifest(busyManifest(t, c.on, c.busy))
|
||||
if err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%s: want a refusal saying %q, got %v", name, c.says, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestABusyIsSentOnlyToAnEngineThatReadsIt(t *testing.T) {
|
||||
m, err := ParseManifest(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)
|
||||
}
|
||||
// And the engine's own validator takes the container with it.
|
||||
body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": newer})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if problems := validate.Declaration(body); len(problems) > 0 {
|
||||
t.Fatalf("the node-engine's validator refuses the composed busy: %v", problems)
|
||||
}
|
||||
if _, has := m.Resources[len(m.Resources)-1][BusyField].(map[string]any)["timeout"]; has {
|
||||
t.Error("composing wrote the timeout into the catalogue's own manifest")
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/beside"
|
||||
)
|
||||
|
||||
// TestEveryCatalogueManifestParses runs the real catalogue through the real gate.
|
||||
//
|
||||
// Not a fixture: the point is whether the manifests as written are accepted by the control plane that
|
||||
// will read them, and a copy of one manifest proves nothing about the other seventy-one.
|
||||
// catalogueRoot is the catalogue these checks run over: in a merge check the clone the build seat put
|
||||
// beside this one, elsewhere the copy captured in testdata/beside (internal/beside). A check that only
|
||||
// ran when somebody remembered a variable was a check nobody ran (novox/hq issue 134), and one that read
|
||||
// whatever checkout sat beside judged the machine, not the change (novox/hq issue 432): it never skips.
|
||||
func catalogueRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
return beside.Dir(t, "mesh-catalog")
|
||||
}
|
||||
|
||||
func TestEveryCatalogueManifestParses(t *testing.T) {
|
||||
root := catalogueRoot(t)
|
||||
found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil || len(found) == 0 {
|
||||
t.Fatalf("no manifests under %s: %v", root, err)
|
||||
}
|
||||
named, routed := 0, 0
|
||||
for _, p := range found {
|
||||
m, err := ReadTreeManifest(root, filepath.Dir(p))
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", filepath.Base(filepath.Dir(p)), 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")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoCatalogueManifestNamesAnInstallation is ADR 0112's check, run over the real catalogue: no
|
||||
// definition names a domain or a public address the mesh acts on, and every value that must for now
|
||||
// carries its reason (novox/hq ADR 0155, issue 134). The list it prints is the one that shrinks.
|
||||
func TestNoCatalogueManifestNamesAnInstallation(t *testing.T) {
|
||||
root := catalogueRoot(t)
|
||||
found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil || len(found) == 0 {
|
||||
t.Fatalf("no manifests under %s: %v", root, err)
|
||||
}
|
||||
var named []string
|
||||
for _, p := range found {
|
||||
m, err := ReadTreeManifest(root, filepath.Dir(p))
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", p, err)
|
||||
continue
|
||||
}
|
||||
named = append(named, InstallationProblems(m)...)
|
||||
}
|
||||
if len(named) > 0 {
|
||||
t.Fatalf("%d value(s) name an installation:\n %s", len(named), strings.Join(named, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// TestEveryCatalogueModuleDeclaresItsData is ADR 0233's check over the real catalogue (it replaced ADR
|
||||
// 0214's store list): every provider that grants says what it keeps for its consumers, nothing writes a
|
||||
// backup line by hand, every directory a container writes is declared, and every irreplaceable item is
|
||||
// backed up — so a store added is a store backed up, without a list of stores to keep in step.
|
||||
func TestEveryCatalogueModuleDeclaresItsData(t *testing.T) {
|
||||
root := catalogueRoot(t)
|
||||
found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil || len(found) == 0 {
|
||||
t.Fatalf("no manifests under %s: %v", root, err)
|
||||
}
|
||||
shelf := Shelf{}
|
||||
granting := 0
|
||||
for _, p := range found {
|
||||
m, err := ReadTreeManifest(root, filepath.Dir(p))
|
||||
if err != nil {
|
||||
continue // TestEveryCatalogueManifestParses says why
|
||||
}
|
||||
if len(m.Grants) > 0 {
|
||||
granting++
|
||||
}
|
||||
shelf[m.Module] = m
|
||||
}
|
||||
for _, problem := range DataProblems(shelf) {
|
||||
t.Error(problem)
|
||||
}
|
||||
if granting == 0 {
|
||||
t.Fatal("no module in the catalogue grants a provision, so this proved nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEveryCatalogueIdentityFitsWhatItRequires is ADR 0225's check over the real catalogue: every
|
||||
// module's identity, on the longest machine name, fits the bound of every provision it wants. An
|
||||
// overflow fails here, in the pull request that introduces it, rather than on the provider's machine
|
||||
// the first time a real machine's name meets the module's (issue 263).
|
||||
func TestEveryCatalogueIdentityFitsWhatItRequires(t *testing.T) {
|
||||
root := catalogueRoot(t)
|
||||
found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
||||
if err != nil || len(found) == 0 {
|
||||
t.Fatalf("no manifests under %s: %v", root, err)
|
||||
}
|
||||
shelf := Shelf{}
|
||||
for _, p := range found {
|
||||
m, err := ReadTreeManifest(root, filepath.Dir(p))
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", p, err)
|
||||
}
|
||||
shelf[m.Module] = m
|
||||
}
|
||||
for _, p := range IdentityProblems(shelf, DefaultLongestMachine) {
|
||||
t.Error(p)
|
||||
}
|
||||
// The night it was found: the resolver provision bounds nothing, and the object store still 20.
|
||||
if dns, ok := shelf["dnsmasq"]; ok {
|
||||
if b := dns.IdentityBoundOf("wildcard-resolution"); b.Bounded() {
|
||||
t.Errorf("the resolver provision bounds its consumers' identities: %+v", b)
|
||||
}
|
||||
}
|
||||
if store, ok := shelf["minio"]; ok {
|
||||
if b := store.IdentityBoundOf("s3-bucket"); b.Max != 20 {
|
||||
t.Errorf("the object store's access key is not bounded at 20: %+v", b)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/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,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
|
||||
}
|
||||
@@ -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,158 +0,0 @@
|
||||
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 := ParseManifest([]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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemNamesAttributesAndCapabilitiesAreJudged(t *testing.T) {
|
||||
item := func(extra string) string {
|
||||
return `{"module":"p","moved":true,"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},` +
|
||||
`"items":[` + extra + `]}`
|
||||
}
|
||||
for items, want := range map[string]string{
|
||||
`{"collection":"c","name":"a","template":"files/a"},{"collection":"c","template":"files/b"}`: "has no name",
|
||||
`{"collection":"c","name":"a","template":"files/a"},{"collection":"c","name":"a","template":"files/b"}`: "twice",
|
||||
`{"collection":"c","name":"Big","template":"files/a"}`: "is not a name",
|
||||
`{"collection":"c","attributes":{"order":"100"},"template":"files/a"}`: "0 to 99",
|
||||
`{"collection":"c","attributes":{"bar":"a b"},"template":"files/a"}`: "is not a value",
|
||||
`{"collection":"c","if-capability":"batery","template":"files/a"}`: "no machine reports",
|
||||
`{"collection":"contribution:node-bar:block","attributes":{"bar":"left"},"template":"files/a"}`: "bar=left",
|
||||
`{"collection":"contribution:node-bar:block","attributes":{"colour":"red"},"template":"files/a"}`: `"colour" is none of them`,
|
||||
`{"collection":"c","name":"a","template":"files/a","colour":"red"}`: "colour",
|
||||
} {
|
||||
_, err := ParseManifest([]byte(item(items)))
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%s: %v, want %q", items, err, want)
|
||||
}
|
||||
}
|
||||
ok := item(`{"collection":"contribution:node-bar:block","name":"a","attributes":{"bar":"top","place":"status","order":"5"},"if-capability":"battery","template":"files/a"}`)
|
||||
if _, err := ParseManifest([]byte(ok)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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,179 +0,0 @@
|
||||
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
|
||||
// names a collection it collects, a contributor an item from a template of its own, and a handed composition plans
|
||||
// both for the node's provisioner client and renders neither. The three old namespaces read as collections of their
|
||||
// own name. Each file is filled one way: a piece of the other form on the machine is said as not placed.
|
||||
|
||||
const (
|
||||
collectorZsh = `{"module":"zsh","version":"1","moved":true,
|
||||
"claims":[{"name":"node-login-shell","scope":"node"}],
|
||||
"collections":[{"name":"widgets","comment":"#"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[{"id":"rc","type":"file","path":"${machine:account-home}/.zshrc","owner":"${machine:account}",
|
||||
"mode":"0644","template":"files/zshrc"}]}`
|
||||
zshrcText = "# zsh\n${shell:zsh:normal}${collected:widgets}"
|
||||
contributorText = "source ${machine:account-home}/.p10k.zsh\n"
|
||||
contributorP10k = `{"module":"prompt","version":"1","moved":true,
|
||||
"items":[{"collection":"shell:zsh:normal","template":"files/zsh"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`
|
||||
oldFormAliases = `{"module":"aliases","version":"1",
|
||||
"shell":[{"for":"zsh","slot":"normal","code":"alias ll='ls -l'\n"}]}`
|
||||
)
|
||||
|
||||
func builtWith(module string, texts map[string]string) []Built {
|
||||
return []Built{{Name: "files", Kind: ArtifactArchive,
|
||||
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 := ParseManifest([]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)
|
||||
}
|
||||
}
|
||||
|
||||
// An item the controller cannot render the old way (it names a value but a machine fact) is said as not placed.
|
||||
func TestAHeldItemNamingMoreThanAFactIsSaidNotPlacedTheOldWay(t *testing.T) {
|
||||
zsh := held(t, collectorZsh, builtWith("zsh", map[string]string{"zshrc": zshrcText}))
|
||||
if _, ok := heldItemText(Manifest{Module: "p", Items: []Item{{Collection: "shell:zsh:normal",
|
||||
Content: "export THEME=${config:theme}\n"}}}, "shell:zsh:normal", map[string]string{}); ok {
|
||||
t.Fatal("an item naming a config value was rendered by the controller")
|
||||
}
|
||||
if _, ok := heldItemText(Manifest{Module: "p", Items: []Item{{Collection: "shell:zsh:normal",
|
||||
Content: "cd ${machine:account-home}\n"}}}, "shell:zsh:normal", map[string]string{}); ok {
|
||||
t.Fatal("an item naming a fact this machine lacks was rendered")
|
||||
}
|
||||
if _, ok := heldItemText(Manifest{Module: "p", Items: []Item{{Collection: "widgets", Content: "x\n"}}},
|
||||
"widgets", nil); ok {
|
||||
t.Fatal("an item of a collection of the new form was rendered the old way")
|
||||
}
|
||||
left := itemsLeftOut([]Manifest{zsh, {Module: "p", Items: []Item{{Collection: "shell:zsh:normal",
|
||||
Content: "export THEME=${config:theme}\n"}}}}, zsh, "shell:zsh:normal", map[string]string{})
|
||||
if len(left) != 1 || !strings.Contains(left[0], "p's item of shell:zsh:normal is not placed") {
|
||||
t.Fatalf("left out: %v", left)
|
||||
}
|
||||
}
|
||||
|
||||
// Each old namespace L2 moves renders a moved contributor's item exactly as its old form did, where the collector is
|
||||
// not handed: the same contributor written both ways composes the same bytes (design 57 L2, issue 681).
|
||||
func TestAMovedContributorsItemRendersAsItsOldFormDid(t *testing.T) {
|
||||
code := "# two lines\nalias a=b"
|
||||
asOld := Manifest{Module: "laptop", Shell: []ShellCode{{For: "zsh", Slot: "normal", Code: code}},
|
||||
Contributions: []SeatContribution{{Seat: HotkeysSeat, Kind: "trigger", Content: "KEY_F21 1 touchpad\n"}}}
|
||||
asItem := Manifest{Module: "laptop", Moved: true, Items: []Item{{Collection: "shell:zsh:normal", Content: code},
|
||||
{Collection: "contribution:node-hotkeys:trigger", Content: "KEY_F21 1 touchpad\n"}}}
|
||||
other := Manifest{Module: "zz-other", Shell: []ShellCode{{For: "zsh", Slot: "normal", Code: "x=1\n"}},
|
||||
Contributions: []SeatContribution{{Seat: HotkeysSeat, Kind: "trigger", Content: "KEY_MUTE 1 mute\n"}}}
|
||||
first := Manifest{Module: "aa-first", Shell: []ShellCode{{For: "zsh", Slot: "normal", Code: "y=2\n"}}}
|
||||
for _, mods := range [][]Manifest{{other, first}, {}} {
|
||||
was := append(append([]Manifest(nil), mods...), asOld)
|
||||
is := append(append([]Manifest(nil), mods...), asItem)
|
||||
if a, b := shellCodeOn(was, "zsh", "normal", nil), shellCodeOn(is, "zsh", "normal", nil); a != b || a == "" {
|
||||
t.Errorf("shell: the old form made %q, the item %q", a, b)
|
||||
}
|
||||
a, _, errA := seatContributions(was, hotkeysHolder(), "node-hotkeys:trigger", Rendering{}, nil, nil)
|
||||
b, left, errB := seatContributions(is, hotkeysHolder(), "node-hotkeys:trigger", Rendering{}, nil, nil)
|
||||
if errA != nil || errB != nil || a != b || a == "" || len(left) != 0 {
|
||||
t.Errorf("hotkeys: the old form made %q, the item %q (%v %v %v)", a, b, errA, errB, left)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionsAndItemsAreJudged(t *testing.T) {
|
||||
for name, c := range map[string]struct{ raw, says string }{
|
||||
"an item of a module not moved": {`{"module":"m","version":"1",
|
||||
"items":[{"collection":"shell:zsh:normal","template":"files/x"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "is marked moved"},
|
||||
"two items to one collection": {`{"module":"m","version":"1","moved":true,
|
||||
"items":[{"collection":"a","template":"files/x"},{"collection":"a","template":"files/y"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "contributes to a twice"},
|
||||
"an item to no slot": {`{"module":"m","version":"1","moved":true,
|
||||
"items":[{"collection":"shell:zsh:middle","template":"files/x"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "is no slot"},
|
||||
"an item outside the templates": {`{"module":"m","version":"1","moved":true,
|
||||
"items":[{"collection":"a","template":"x"}],
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "is not a file under files/"},
|
||||
"an item naming a secret": {`{"module":"m","version":"1",
|
||||
"items":[{"collection":"a","content":"t=${secret:token}"}]}`, "where no secret is"},
|
||||
"a collection not declared": {`{"module":"m","version":"1",
|
||||
"resources":[{"id":"f","type":"file","path":"/f","content":"${collected:a}"}]}`, "does not collect"},
|
||||
"an old namespace declared": {`{"module":"m","version":"1","collections":[{"name":"shell:zsh:normal"}],
|
||||
"resources":[{"id":"f","type":"file","path":"/f","content":"x"}]}`, "read from its placeholder"},
|
||||
"a collection placed nowhere": {`{"module":"m","version":"1","collections":[{"name":"a"}],
|
||||
"resources":[{"id":"f","type":"file","path":"/f","content":"x"}]}`, "no file of its places it"},
|
||||
"a collection in a path": {`{"module":"m","version":"1","collections":[{"name":"a"}],
|
||||
"resources":[{"id":"f","type":"file","path":"/${collected:a}","content":"${collected:a}"}]}`,
|
||||
"only in a file's content"},
|
||||
} {
|
||||
_, err := ParseManifest([]byte(c.raw))
|
||||
if err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%s: %v, want it to say %q", name, err, c.says)
|
||||
}
|
||||
}
|
||||
if _, err := ParseManifest([]byte(collectorZsh)); err != nil {
|
||||
t.Errorf("a collector: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,205 +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 TestAModuleIsEitherWrittenOrComputedNotBoth(t *testing.T) {
|
||||
// Otherwise nobody could say where a given file on a machine came from.
|
||||
_, err := ParseManifest([]byte(`{"module":"mesh-network","version":"1",
|
||||
"computed":"mesh-network",
|
||||
"resources":[{"id":"a","type":"package","package":"wireguard-tools"}]}`))
|
||||
if err == nil {
|
||||
t.Fatal("a module that both ships files and has them computed was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "one or the other") {
|
||||
t.Fatalf("unhelpful refusal: %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"])
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A takes-config entry may lay a value under the name the file's software reads (issue 751): the key stays a
|
||||
// config key, lower-case, and the file names its own variable, such as ATLASSIAN_URL.
|
||||
|
||||
// renaming is inConfigForm with its rules file taking `url` as NOTIFIER_URL, a value the code reading the file
|
||||
// falls back on when the file lacks it.
|
||||
func renaming() map[string]any {
|
||||
m := inConfigForm()
|
||||
m["config"].(map[string]any)["url"] = map[string]any{"type": "string",
|
||||
"description": "the address the notifier posts to, a URL; unset, the notifier's own environment says it",
|
||||
"default": ""}
|
||||
resourceOf(m, "rules")["takes-config"] = []any{"position", "theme",
|
||||
map[string]any{"key": "url", "as": "NOTIFIER_URL"}}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// A layer said before its module moved names the file's own spelling, such as nodered's timeZone on one machine.
|
||||
// Until K5 moves the layer and renames the key, that spelling still reaches the file, so the move changes no
|
||||
// machine's file; the config key wins where one layer says both.
|
||||
func TestALayerStillSayingTheFilesNameReachesItUntilItsMove(t *testing.T) {
|
||||
m, err := parseRaw(t, renaming())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, said := range []string{"url", "NOTIFIER_URL"} {
|
||||
if stray := UnusedSettings(m, []Layer{{From: "ace", Values: map[string]any{said: "https://x.test"}}}); len(stray) > 0 {
|
||||
t.Fatalf("%s, which the rules file takes, is said stray: %v", said, stray)
|
||||
}
|
||||
got := rulesComposed(t, m, Layer{From: "ace", Values: map[string]any{said: "https://x.test"}})
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal([]byte(got), &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if doc["NOTIFIER_URL"] != "https://x.test" || doc["url"] != nil {
|
||||
t.Fatalf("a layer saying %s made the file %s", said, got)
|
||||
}
|
||||
}
|
||||
both := rulesComposed(t, m, Layer{From: "ace", Values: map[string]any{"url": "https://key.test", "NOTIFIER_URL": "https://old.test"}})
|
||||
if !strings.Contains(both, "https://key.test") || strings.Contains(both, "old.test") {
|
||||
t.Fatalf("where one layer says both, the config key did not win: %s", both)
|
||||
}
|
||||
later := rulesComposed(t, m, Layer{From: "mesh", Values: map[string]any{"url": "https://mesh.test"}},
|
||||
Layer{From: "ace", Values: map[string]any{"NOTIFIER_URL": "https://ace.test"}})
|
||||
if !strings.Contains(later, "https://ace.test") {
|
||||
t.Fatalf("a later layer's old spelling did not beat an earlier layer's key: %s", later)
|
||||
}
|
||||
// Who may set it is the same under either spelling: the rules file is trusted, so both are the terminal's.
|
||||
terminal := TerminalKeys(m)
|
||||
for _, k := range []string{"url", "NOTIFIER_URL"} {
|
||||
if !listHolds(terminal, k) {
|
||||
t.Fatalf("%s is not the terminal's in a trusted file: %v", k, terminal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func listHolds(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestARenamedEntryIsWrittenBackAsItWasSaid(t *testing.T) {
|
||||
m, err := parseRaw(t, renaming())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"takes-config":["position","theme",{"as":"NOTIFIER_URL","key":"url"}]`) {
|
||||
t.Fatalf("written back as %s", raw)
|
||||
}
|
||||
if _, err := ParseManifest(raw); err != nil {
|
||||
t.Fatalf("read again: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARenamedEntryIsRefusedWhereItCouldNotHold(t *testing.T) {
|
||||
entry := func(m map[string]any, e any) map[string]any {
|
||||
resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", e}
|
||||
return m
|
||||
}
|
||||
refused(t, entry(renaming(), map[string]any{"key": "url"}), "notifier's rules", `"key" and "as"`)
|
||||
refused(t, entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER_URL", "when": "set"}), `"key" and "as"`)
|
||||
refused(t, entry(renaming(), map[string]any{"key": "URL", "as": "NOTIFIER_URL"}), "not a usable key")
|
||||
refused(t, entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER URL"}), "not a usable name")
|
||||
refused(t, entry(renaming(), map[string]any{"key": "url", "as": "endpoints"}), `"endpoints"`, "the mesh reads")
|
||||
refused(t, entry(renaming(), map[string]any{"key": "url", "as": "theme"}), `"theme"`, "twice")
|
||||
refused(t, entry(renaming(), map[string]any{"key": "theme", "as": "THEME"}), `"theme"`, "twice")
|
||||
refused(t, entry(renaming(), map[string]any{"key": "nothing", "as": "NOTHING"}), `"nothing"`, "declares no such value")
|
||||
|
||||
kept := entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER_URL"})
|
||||
resourceOf(kept, "rules")["protected"] = []any{"NOTIFIER_URL"}
|
||||
refused(t, kept, `"NOTIFIER_URL"`, "protected")
|
||||
|
||||
// Laid only when set, so the code's own fallback holds: never required.
|
||||
required := renaming()
|
||||
declaration(required, "url")["required"] = true
|
||||
delete(declaration(required, "url"), "default")
|
||||
refused(t, required, `"url"`, "only when it is set", "never required")
|
||||
}
|
||||
|
||||
func TestARenamedEntryIsTheConfigFormsOnly(t *testing.T) {
|
||||
m := map[string]any{
|
||||
"module": "notifier", "version": "1",
|
||||
"resources": []any{
|
||||
map[string]any{"id": "rules", "type": "file", "path": "/x/rules.json", "merge": "json", "content": "{}",
|
||||
"takes-settings": []any{map[string]any{"key": "url", "as": "NOTIFIER_URL"}}},
|
||||
},
|
||||
}
|
||||
refused(t, m, "notifier's rules", "config form")
|
||||
}
|
||||
@@ -1,484 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A module declares its config (novox/hq ADR 0348 §1, §4, §5, §6, §8, §11; design 57 package K1, issue 660):
|
||||
// one registration refusal per rule of design 57's K1, the old names read as aliases, and a module rewritten
|
||||
// from settings to config composing byte-identically.
|
||||
|
||||
// inConfigForm is a module in the config form that registers: every value typed, described, defaulted or
|
||||
// required, placed in a file or read at runtime, and its mergeable file naming its keys.
|
||||
func inConfigForm() map[string]any {
|
||||
return map[string]any{
|
||||
"module": "notifier",
|
||||
"version": "1",
|
||||
"config": map[string]any{
|
||||
"font-size": map[string]any{"type": "number", "description": "the notification's font size, in points",
|
||||
"minimum": 6, "maximum": 40, "default": 10},
|
||||
"width": map[string]any{"type": "number", "description": "the notification's width, in pixels",
|
||||
"default": 250},
|
||||
"position": map[string]any{"type": "string", "description": "the corner it shows in, a word",
|
||||
"enum": []any{"top-right", "top-left"}, "default": "top-right"},
|
||||
"theme": map[string]any{"type": "string", "description": "the colour theme, a lower-case name",
|
||||
"pattern": "[a-z]+", "default": "dark"},
|
||||
"history": map[string]any{"type": "number", "description": "how many notifications it keeps, a count",
|
||||
"default": 20, "runtime": true},
|
||||
"sender": map[string]any{"type": "string", "description": "the address it mails a digest from",
|
||||
"required": true},
|
||||
},
|
||||
"resources": []any{
|
||||
map[string]any{"id": "configuration", "type": "file", "path": "/x/notifierrc",
|
||||
"content": "font = Inter ${config:font-size}\nwidth = ${config:width}\nfrom = ${config:sender}\n"},
|
||||
map[string]any{"id": "rules", "type": "file", "path": "/x/rules.json", "merge": "json",
|
||||
"takes-config": []any{"position", "theme"}, "content": "{\"position\": \"top-right\"}\n"},
|
||||
map[string]any{"id": "notifier", "type": "service", "unit": "notifier.service",
|
||||
"reload-on": []any{"configuration"}, "restart-on": []any{"rules"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// declaration is one value of inConfigForm's config, to change in place.
|
||||
func declaration(m map[string]any, key string) map[string]any {
|
||||
return m["config"].(map[string]any)[key].(map[string]any)
|
||||
}
|
||||
|
||||
func resourceOf(m map[string]any, id string) map[string]any {
|
||||
for _, r := range m["resources"].([]any) {
|
||||
if r.(map[string]any)["id"] == id {
|
||||
return r.(map[string]any)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseRaw(t *testing.T, m map[string]any) (Manifest, error) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return ParseManifest(raw)
|
||||
}
|
||||
|
||||
// refused is that the manifest is refused with every one of these words.
|
||||
func refused(t *testing.T, m map[string]any, words ...string) {
|
||||
t.Helper()
|
||||
_, err := parseRaw(t, m)
|
||||
if err == nil {
|
||||
t.Fatalf("registered; wanted a refusal saying %q", words)
|
||||
}
|
||||
for _, w := range words {
|
||||
if !strings.Contains(err.Error(), w) {
|
||||
t.Fatalf("refused without %q:\n%v", w, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleInTheConfigFormRegisters(t *testing.T) {
|
||||
m, err := parseRaw(t, inConfigForm())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !m.DeclaresConfig() {
|
||||
t.Fatal("a module declaring config is not read as in the config form")
|
||||
}
|
||||
d := m.Settings["font-size"]
|
||||
if d.Type != "number" || d.Description == "" || d.Minimum == nil || *d.Minimum != 6 || d.Default != float64(10) {
|
||||
t.Fatalf("font-size read as %+v", d)
|
||||
}
|
||||
if s := m.Settings["sender"]; !s.Required || s.Default != nil {
|
||||
t.Fatalf("sender read as %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 1: a value without type or description.
|
||||
func TestConfigRefusesAValueWithoutTypeOrDescription(t *testing.T) {
|
||||
m := inConfigForm()
|
||||
delete(declaration(m, "width"), "type")
|
||||
refused(t, m, `notifier's config value "width"`, "no type")
|
||||
m = inConfigForm()
|
||||
declaration(m, "width")["type"] = "colour"
|
||||
refused(t, m, `"width"`, `type "colour"`)
|
||||
m = inConfigForm()
|
||||
delete(declaration(m, "width"), "description")
|
||||
refused(t, m, `"width"`, "no description")
|
||||
}
|
||||
|
||||
// Rule 2: a value with neither a default nor required.
|
||||
func TestConfigRefusesAValueWithNeitherDefaultNorRequired(t *testing.T) {
|
||||
m := inConfigForm()
|
||||
delete(declaration(m, "width"), "default")
|
||||
refused(t, m, `"width"`, "neither a default nor required")
|
||||
m = inConfigForm()
|
||||
declaration(m, "sender")["default"] = "noreply@example.org"
|
||||
refused(t, m, `"sender"`, "both a default and required")
|
||||
}
|
||||
|
||||
// Rule 3: a default failing its validation.
|
||||
func TestConfigRefusesADefaultFailingItsValidation(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
key string
|
||||
value any
|
||||
said string
|
||||
}{
|
||||
{"font-size", 4, "below its minimum"},
|
||||
{"font-size", 41, "above its maximum"},
|
||||
{"font-size", "10", "not a number"},
|
||||
{"position", "bottom", "not one of"},
|
||||
{"theme", "Dark", "does not match"},
|
||||
} {
|
||||
m := inConfigForm()
|
||||
declaration(m, c.key)["default"] = c.value
|
||||
refused(t, m, `"`+c.key+`"`, c.said)
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 4: an object or a list not trusted.
|
||||
func TestConfigRefusesAnObjectOrListNotTrusted(t *testing.T) {
|
||||
for _, kind := range []string{"object", "list"} {
|
||||
m := inConfigForm()
|
||||
def := any(map[string]any{"a": 1})
|
||||
if kind == "list" {
|
||||
def = []any{"a"}
|
||||
}
|
||||
m["config"].(map[string]any)["rules"] = map[string]any{"type": kind, "description": "the rules, as " + kind,
|
||||
"default": def, "runtime": true}
|
||||
refused(t, m, `"rules"`, "is "+kind+" and not trusted")
|
||||
m["config"].(map[string]any)["rules"].(map[string]any)["trusted"] = true
|
||||
if _, err := parseRaw(t, m); err != nil {
|
||||
t.Fatalf("a trusted %s is refused: %v", kind, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// withPredefined is the list of predefined keys the mesh published, for the length of a test.
|
||||
func withPredefined(t *testing.T, keys PredefinedList) {
|
||||
t.Helper()
|
||||
was := ReplacePublished(keys)
|
||||
t.Cleanup(func() { ReplacePublished(was) })
|
||||
}
|
||||
|
||||
// Rule 5: a predefined key not on the list, or of another type. Where no list is present, a value naming one is
|
||||
// refused saying so.
|
||||
func TestConfigRefusesAPredefinedKeyNotOnTheListOrOfAnotherType(t *testing.T) {
|
||||
withPredefined(t, nil)
|
||||
m := inConfigForm()
|
||||
declaration(m, "font-size")["predefined"] = "font-size"
|
||||
refused(t, m, `"font-size"`, "the list of predefined keys", "is not present")
|
||||
|
||||
withPredefined(t, map[string]PredefinedKey{"font-size": {Type: "number"}, "accent": {Type: "string"}})
|
||||
if _, err := parseRaw(t, m); err != nil {
|
||||
t.Fatalf("a value falling back to a listed key of its type is refused: %v", err)
|
||||
}
|
||||
m = inConfigForm()
|
||||
declaration(m, "font-size")["predefined"] = "font-scale"
|
||||
refused(t, m, `"font-size"`, `"font-scale"`, "not on the list")
|
||||
m = inConfigForm()
|
||||
declaration(m, "font-size")["predefined"] = "accent"
|
||||
refused(t, m, `"font-size"`, `"accent"`, "is a string")
|
||||
}
|
||||
|
||||
// Rule 6: an untrusted value falling back to a trusted predefined key.
|
||||
func TestConfigRefusesAnUntrustedValueFallingBackToATrustedKey(t *testing.T) {
|
||||
withPredefined(t, nil)
|
||||
m := inConfigForm()
|
||||
declaration(m, "theme")["predefined"] = "theme"
|
||||
refused(t, m, `"theme"`, "is not present")
|
||||
|
||||
withPredefined(t, map[string]PredefinedKey{"theme": {Type: "string", Trusted: true}})
|
||||
refused(t, m, `"theme"`, "trusted", "is not")
|
||||
declaration(m, "theme")["trusted"] = true
|
||||
if _, err := parseRaw(t, m); err != nil {
|
||||
t.Fatalf("a trusted value falling back to a trusted key is refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 7: a value neither placed in a template nor read at runtime.
|
||||
func TestConfigRefusesAValueNeitherPlacedNorReadAtRuntime(t *testing.T) {
|
||||
m := inConfigForm()
|
||||
delete(declaration(m, "history"), "runtime")
|
||||
refused(t, m, `"history"`, "${config:history}", `"runtime": true`)
|
||||
// Taken by a mergeable file is placed.
|
||||
m = inConfigForm()
|
||||
if _, err := parseRaw(t, m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 8: a placeholder naming an undeclared value.
|
||||
func TestConfigRefusesAPlaceholderNamingAnUndeclaredValue(t *testing.T) {
|
||||
m := inConfigForm()
|
||||
resourceOf(m, "configuration")["content"] = "font = ${config:font-size}\nwidth = ${config:width}\n" +
|
||||
"from = ${config:sender}\ncolour = ${config:colour}\n"
|
||||
refused(t, m, "${config:colour}", "declares no such value")
|
||||
}
|
||||
|
||||
// Rule 9: a reload-on or restart-on naming a file the module does not declare.
|
||||
func TestConfigRefusesAReloadOrRestartOnNamingAnUndeclaredFile(t *testing.T) {
|
||||
m := inConfigForm()
|
||||
resourceOf(m, "notifier")["reload-on"] = []any{"configuration", "dunstrc"}
|
||||
refused(t, m, "reload-on", `"dunstrc"`, "declares no such")
|
||||
m = inConfigForm()
|
||||
resourceOf(m, "notifier")["restart-on"] = []any{"rule"}
|
||||
refused(t, m, "restart-on", `"rule"`)
|
||||
// Another module's file, and one the mesh makes for the module, are not the module's to declare.
|
||||
m = inConfigForm()
|
||||
resourceOf(m, "notifier")["restart-on"] = []any{"rules", "resolver.fact-zones", "secret-mail", "composed-jails"}
|
||||
if _, err := parseRaw(t, m); err != nil {
|
||||
t.Fatalf("refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 10: a moved module's mergeable file not naming its keys under takes-config.
|
||||
func TestConfigRefusesAMergeableFileNotNamingItsKeys(t *testing.T) {
|
||||
m := inConfigForm()
|
||||
delete(resourceOf(m, "rules"), "takes-config")
|
||||
// position and theme are then placed nowhere; say they are read at runtime so only the file's rule speaks.
|
||||
declaration(m, "position")["runtime"] = true
|
||||
declaration(m, "theme")["runtime"] = true
|
||||
refused(t, m, "rules", "names no takes-config")
|
||||
}
|
||||
|
||||
// Rule 11: a mesh word, puid or pgid in takes-config.
|
||||
func TestConfigRefusesAMeshWordOrAnAccountFactInTakesConfig(t *testing.T) {
|
||||
for _, word := range []string{"ports", "expose", "reach", "endpoints", "places", "accesses", "networks", "puid", "pgid"} {
|
||||
m := inConfigForm()
|
||||
resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", word}
|
||||
refused(t, m, `"`+word+`"`, "takes-config")
|
||||
}
|
||||
}
|
||||
|
||||
// A module uses one form or the other, never both.
|
||||
func TestAManifestMixingTheConfigAndSettingsFormsIsRefused(t *testing.T) {
|
||||
m := inConfigForm()
|
||||
resourceOf(m, "configuration")["content"] = "font = ${config:font-size}\nwidth = ${setting:width}\nfrom = ${config:sender}\n"
|
||||
refused(t, m, "mixes", "${setting:", "config")
|
||||
|
||||
m = inConfigForm()
|
||||
delete(resourceOf(m, "rules"), "takes-config")
|
||||
resourceOf(m, "rules")["takes-settings"] = []any{"position", "theme"}
|
||||
refused(t, m, "mixes", "takes-settings")
|
||||
|
||||
m = inConfigForm()
|
||||
m["settings"] = map[string]any{"colour": map[string]any{"kind": "preference", "default": "red", "why": "red"}}
|
||||
refused(t, m, "mixes", "settings")
|
||||
}
|
||||
|
||||
// The settings form keeps its own declaration; the config form's has no kind or why.
|
||||
func TestEachFormKeepsItsOwnDeclaration(t *testing.T) {
|
||||
m := inConfigForm()
|
||||
declaration(m, "width")["why"] = "fits a title"
|
||||
refused(t, m, `"width"`, "why")
|
||||
|
||||
err := parsed(t, Manifest{Module: "notifier",
|
||||
Settings: map[string]SettingDeclaration{"width": {Kind: KindPreference, Default: float64(250),
|
||||
Why: "fits a title", Type: "number"}},
|
||||
Resources: []map[string]any{{"id": "c", "type": "file", "path": "/x", "content": "${setting:width}"}}})
|
||||
if err == nil || !strings.Contains(err.Error(), "under config") {
|
||||
t.Fatalf("a typed value under settings: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// What the mesh holds is what was written: a module in the config form is written back in it, and read again
|
||||
// the same.
|
||||
func TestAConfigManifestIsWrittenBackInItsOwnForm(t *testing.T) {
|
||||
m, err := parseRaw(t, inConfigForm())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, said := range []string{`"config":`, `${config:font-size}`, `"takes-config":`, `"required":true`} {
|
||||
if !strings.Contains(string(raw), said) {
|
||||
t.Fatalf("written back without %s: %s", said, raw)
|
||||
}
|
||||
}
|
||||
for _, unsaid := range []string{`"settings":`, `${setting:`, `"takes-settings"`, `"kind"`, `"why"`, `"default":null`} {
|
||||
if strings.Contains(string(raw), unsaid) {
|
||||
t.Fatalf("written back with %s: %s", unsaid, raw)
|
||||
}
|
||||
}
|
||||
again, err := ParseManifest(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
twice, _ := json.Marshal(again)
|
||||
if !bytes.Equal(raw, twice) {
|
||||
t.Fatalf("read again it is written\n%s\nwhere it was\n%s", twice, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// A module in the settings form is written back byte for byte as before.
|
||||
func TestASettingsManifestIsWrittenBackAsBefore(t *testing.T) {
|
||||
d := SettingDeclaration{Kind: KindPreference, Default: float64(10), Why: "readable"}
|
||||
raw, err := json.Marshal(d)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(raw) != `{"kind":"preference","default":10,"why":"readable"}` {
|
||||
t.Fatalf("written as %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// settingsDunst is the dunst module as it is written today; configDunst the same module rewritten from settings to
|
||||
// config.
|
||||
const settingsDunst = `{"module": "dunst", "version": "1",
|
||||
"settings": {
|
||||
"font-size": {"kind": "preference", "default": 10, "why": "10 points of Inter reads well on a screen of about 100 DPI"},
|
||||
"width": {"kind": "preference", "default": 250, "why": "250 pixels fits a title of about forty characters at 10 points"}
|
||||
},
|
||||
"resources": [
|
||||
{"id": "package", "type": "package", "package": "dunst"},
|
||||
{"id": "configuration", "type": "file", "path": "/home/me/.config/dunst/dunstrc", "mode": "0644",
|
||||
"content": "[global]\n font = Inter ${setting:font-size}\n width = ${setting:width}\n"},
|
||||
{"id": "rules", "type": "file", "path": "/home/me/.config/dunst/rules.json", "merge": "json",
|
||||
"takes-settings": [], "content": "{\"urgency\": \"low\"}\n"},
|
||||
{"id": "notifier", "type": "service", "unit": "dunst.service", "reload-on": ["configuration"]}
|
||||
]}`
|
||||
|
||||
const configDunst = `{"module": "dunst", "version": "1",
|
||||
"config": {
|
||||
"font-size": {"type": "number", "description": "the font size of a notification, in points", "minimum": 6,
|
||||
"maximum": 40, "default": 10},
|
||||
"width": {"type": "number", "description": "the width of a notification, in pixels", "minimum": 100,
|
||||
"default": 250}
|
||||
},
|
||||
"resources": [
|
||||
{"id": "package", "type": "package", "package": "dunst"},
|
||||
{"id": "configuration", "type": "file", "path": "/home/me/.config/dunst/dunstrc", "mode": "0644",
|
||||
"content": "[global]\n font = Inter ${config:font-size}\n width = ${config:width}\n"},
|
||||
{"id": "rules", "type": "file", "path": "/home/me/.config/dunst/rules.json", "merge": "json",
|
||||
"takes-config": [], "content": "{\"urgency\": \"low\"}\n"},
|
||||
{"id": "notifier", "type": "service", "unit": "dunst.service", "reload-on": ["configuration"]}
|
||||
]}`
|
||||
|
||||
// ${config:<key>} fills exactly as ${setting:<key>} does: the module rewritten from settings to config composes
|
||||
// to the same bytes, with no layer, with the node's, and with the mesh's under the node's.
|
||||
func TestAModuleRewrittenFromSettingsToConfigComposesByteIdentically(t *testing.T) {
|
||||
composed := func(manifest string, layers []Layer) []byte {
|
||||
t.Helper()
|
||||
m, err := ParseManifest([]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 key a mergeable file takes is a config value like every other: one not declared under config is refused by
|
||||
// name (ADR 0348 §4, review of mesh-controller#320).
|
||||
func TestConfigRefusesATakesConfigKeyNotDeclared(t *testing.T) {
|
||||
m := inConfigForm()
|
||||
resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", "urgency"}
|
||||
refused(t, m, `"urgency"`, "takes-config", "declares no such value under config")
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// A value named after the operator's own (a url, a user, an address) is `required` where nothing falls back when
|
||||
// it is unset; where a mergeable file takes it and no placeholder asks for it, the file's own content (or the
|
||||
// module's code reading the file) falls back, so it keeps a default and stays optional, as it is today: the
|
||||
// operator's decision on issue 664's second question (issue 735).
|
||||
func TestAnOperatorNamedValueAFileFallsBackOnMayHaveADefault(t *testing.T) {
|
||||
m := inConfigForm()
|
||||
m["config"].(map[string]any)["url"] = map[string]any{"type": "string",
|
||||
"description": "the address the notifier posts to, a URL; empty for the one the file's software picks",
|
||||
"default": ""}
|
||||
resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", "url"}
|
||||
if _, err := parseRaw(t, m); err != nil {
|
||||
t.Fatalf("a taken value named after the operator's, with a default, was refused: %v", err)
|
||||
}
|
||||
|
||||
// A placeholder asking for it has no fallback but the default, which would put a value in the definition
|
||||
// where the operator's belongs (ADR 0112).
|
||||
asked := inConfigForm()
|
||||
asked["config"].(map[string]any)["url"] = map[string]any{"type": "string", "description": "a URL", "default": ""}
|
||||
resourceOf(asked, "rules")["takes-config"] = []any{"position", "theme", "url"}
|
||||
resourceOf(asked, "configuration")["content"] = "font = Inter ${config:font-size}\nwidth = ${config:width}\n" +
|
||||
"from = ${config:sender}\npost = ${config:url}\n"
|
||||
refused(t, asked, `"url"`, `a key naming "url" is the operator's value`)
|
||||
|
||||
// Read by the module's code alone, nothing in the definition says it falls back.
|
||||
runtime := inConfigForm()
|
||||
runtime["config"].(map[string]any)["url"] = map[string]any{"type": "string", "description": "a URL",
|
||||
"default": "", "runtime": true}
|
||||
refused(t, runtime, `"url"`, `a key naming "url" is the operator's value`)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 1279/AC4 (ADR 0443 §4): no module is given a way to ask where its consumer starts. A consumes entry is an event's
|
||||
// name; one that carries a key asking for the stream's history is refused by name, so the safe start is the only one.
|
||||
func TestAConsumesEntryAskingWhereItStartsIsRefused(t *testing.T) { // 1279/AC4
|
||||
for _, key := range []string{"from", "start", "deliver"} {
|
||||
raw := `{"module":"audit","version":"1","consumes":[{"event":"shop.order.placed","` + key + `":"beginning"}]}`
|
||||
_, err := ParseManifest([]byte(raw))
|
||||
if err == nil {
|
||||
t.Fatalf("a consumes entry with %q was accepted", key)
|
||||
}
|
||||
if !strings.Contains(err.Error(), `"`+key+`"`) || !strings.Contains(err.Error(), "consumes") {
|
||||
t.Errorf("the refusal for %q does not name the key and the field: %v", key, err)
|
||||
}
|
||||
}
|
||||
if _, err := ParseManifest([]byte(`{"module":"audit","version":"1","consumes":["shop.order.placed"]}`)); err != nil {
|
||||
t.Errorf("a plain consumes entry was refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,412 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The other half of an edge.
|
||||
//
|
||||
// `requires` says a thing must be there. It never said what to do with it — a web application
|
||||
// requiring a reverse proxy has to say *which name, which port*, and there was nowhere to put
|
||||
// that. The two modules that needed it most, the proxy and the VPN, went round the outside and
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReceivingWhatYouDoNotProvideIsRefused(t *testing.T) {
|
||||
// It would create a file nobody ever writes to, on a machine where nothing asked for it.
|
||||
_, err := ParseManifest([]byte(`{"module":"traefik","version":"1",
|
||||
"receives":{"reverse-proxy":"/etc/traefik/mesh.json"}}`))
|
||||
if err == nil {
|
||||
t.Fatal("a module received contributions to something it does not provide")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "does not provide") {
|
||||
t.Fatalf("unhelpful refusal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEmptyContributionIsRefused(t *testing.T) {
|
||||
// Either a mistake or a requirement written the long way round, and both are better said.
|
||||
_, err := ParseManifest([]byte(`{"module":"board","version":"1",
|
||||
"contributes":{"reverse-proxy":{}}}`))
|
||||
if err == nil {
|
||||
t.Fatal("a module contributed nothing and was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "require it") {
|
||||
t.Fatalf("the refusal does not say what to do instead: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
func provider() Manifest {
|
||||
return Manifest{Module: "postgres", Version: "1",
|
||||
Provides: FromAnywhere("postgres-database"),
|
||||
Receives: map[string]string{"postgres-database": "/var/lib/postgres/grants/mesh.json"},
|
||||
Grants: map[string]string{"postgres-database": "/var/lib/postgres/grants"},
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
if r["path"] != "/var/lib/postgres/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 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,136 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"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 TestAContributionIsMetByAHolderOnTheNodeAndRefusedWithout(t *testing.T) {
|
||||
holder := mod("node-env", nil, nil, nil, Claim{Name: EnvironmentSeat})
|
||||
contributor := mod("theme", nil, nil, nil)
|
||||
contributor.Environment = &Environment{Variables: map[string]string{"GTK_THEME": "Adwaita:dark"}}
|
||||
catalogue := map[string]Manifest{"node-env": holder, "theme": contributor}
|
||||
|
||||
if _, err := AssignRefusal(catalogue, "laptop", []string{"node-env"}, []string{"theme"}); err != nil {
|
||||
t.Fatalf("a contributor beside the holder is refused: %v", err)
|
||||
}
|
||||
_, err := AssignRefusal(catalogue, "laptop", nil, []string{"theme"})
|
||||
if err == nil {
|
||||
t.Fatal("a contributor on a node without the holder was accepted, and its contribution would be written nowhere")
|
||||
}
|
||||
if !strings.Contains(err.Error(), EnvironmentSeat) || !strings.Contains(err.Error(), "node-env") {
|
||||
t.Errorf("the refusal names neither the seat nor its holder: %v", err)
|
||||
}
|
||||
if _, err := AssignRefusal(catalogue, "laptop", nil, []string{"theme", "node-env"}); err != nil {
|
||||
t.Errorf("the contributor and the holder assigned together are refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoModulesMayDeclareOnePackage(t *testing.T) {
|
||||
pacman := withResources(mod("pacman", nil, nil, nil, Claim{Name: PackageManagerSeat}),
|
||||
res("package", "pacman-contrib"))
|
||||
bar := withResources(mod("bar", nil, nil, nil), res("package", "bar"), res("package", "pacman-contrib"))
|
||||
catalogue := map[string]Manifest{"pacman": pacman, "bar": bar}
|
||||
if err := CollisionRefusal(catalogue, "laptop", []string{"pacman"}, []string{"bar"}); err != nil {
|
||||
t.Fatalf("a second declaration of a package was refused (novox/hq ADR 0303): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPackageDeclaredPresentAndAbsentIsRefusedNamingBoth(t *testing.T) {
|
||||
gone := res("package", "ufw")
|
||||
gone["absent"] = true
|
||||
filter := withResources(mod("nftables", nil, nil, nil), gone)
|
||||
wants := withResources(mod("legacy", nil, nil, nil), res("package", "ufw"))
|
||||
other := withResources(mod("other", nil, nil, nil), res("package", "other"))
|
||||
catalogue := map[string]Manifest{"nftables": filter, "legacy": wants, "other": other}
|
||||
|
||||
err := CollisionRefusal(catalogue, "laptop", []string{"nftables"}, []string{"legacy"})
|
||||
if err == nil || !strings.Contains(err.Error(), "ufw") || !strings.Contains(err.Error(), "nftables") ||
|
||||
!strings.Contains(err.Error(), "legacy") || !strings.Contains(err.Error(), "ADR 0303") {
|
||||
t.Fatalf("a package declared present and absent was not refused naming both: %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "one owner per node") {
|
||||
t.Errorf("the refusal gives the one-owner rule, which no longer holds for packages: %v", err)
|
||||
}
|
||||
if err := CollisionRefusal(catalogue, "laptop", []string{"nftables"}, []string{"other"}); err != nil {
|
||||
t.Errorf("a module declaring nothing contradictory is refused: %v", err)
|
||||
}
|
||||
// A contradiction already on the node is status's, not a reason to refuse an unrelated assignment.
|
||||
if err := CollisionRefusal(catalogue, "laptop", []string{"nftables", "legacy"}, []string{"other"}); err != nil {
|
||||
t.Errorf("an unrelated assignment is refused for a contradiction already there: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodeForAPowerMomentDependsOnThePowerSeat(t *testing.T) {
|
||||
for _, moment := range powerMoments {
|
||||
m := mod("laptop", nil, nil, nil)
|
||||
m.Shell = []ShellCode{{For: moment, Slot: "normal", Code: "true"}}
|
||||
if got := DependsOn(nil, m); !reflect.DeepEqual(got, []string{PowerSeat}) {
|
||||
t.Errorf("code for %s depends on %v, want %s", moment, got, PowerSeat)
|
||||
}
|
||||
if p := m.shellProblems(); len(p) != 0 {
|
||||
t.Errorf("code for %s is refused: %v", moment, p)
|
||||
}
|
||||
}
|
||||
m := mod("laptop", nil, nil, nil)
|
||||
m.Shell = []ShellCode{{For: "after-lunch", Slot: "normal", Code: "true"}}
|
||||
if p := m.shellProblems(); len(p) == 0 {
|
||||
t.Error("code for a moment that does not exist was accepted")
|
||||
}
|
||||
if s, ok := SeatNamed(PowerSeat); !ok || s.Scope != ScopeNode {
|
||||
t.Errorf("%s is not a node seat of the mesh's own: %+v %v", PowerSeat, s, ok)
|
||||
}
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq issue 213: the controller is a Go program and was the one piece of the mesh's own Go
|
||||
// code still shipped as an image (ADR 0188 §1). Its own manifest, composed for the machine that runs
|
||||
// it, is a Go bundle run by the host as a process — and no container.
|
||||
func TestTheControllerIsAProcessAndNoContainer(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := ParseManifest(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("the controller's own manifest does not parse:\n%v", err)
|
||||
}
|
||||
if m.Build == nil || len(m.Build.Artifacts) != 1 {
|
||||
t.Fatalf("the controller builds %+v; it is one bundle", m.Build)
|
||||
}
|
||||
a := m.Build.Artifacts[0]
|
||||
if a.Kind != ArtifactBundle || a.Language != "go" || a.System == "" || BinaryOf(a) != "mesh-controller" {
|
||||
t.Fatalf("the controller's artifact is %+v, not a Go bundle naming its system and binary", a)
|
||||
}
|
||||
for _, c := range m.Capabilities {
|
||||
if c == "container-runtime" {
|
||||
t.Error("the controller still requires a container runtime on its machine")
|
||||
}
|
||||
}
|
||||
|
||||
digest := "sha256:" + strings.Repeat("c", 64)
|
||||
control, err := m.Resolve([]Built{{Name: a.Name, Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + "mesh-controller/" + a.Name + "@" + digest, Digest: digest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The node's runtime does not launch it: it serves its seat's verbs itself.
|
||||
if loads := control.Bundles[0].Loads; len(loads) != 0 {
|
||||
t.Errorf("the node's runtime would launch the controller as a tools bundle: %v", loads)
|
||||
}
|
||||
|
||||
needed := map[string]map[string]string{"mesh-controller": {}}
|
||||
for name := range m.OwnSecrets {
|
||||
needed["mesh-controller"][name] = "sealed-" + name
|
||||
}
|
||||
out, err := Resolution{Node: "anchor", Modules: []Manifest{control}}.Declaration(Rendering{
|
||||
Needed: needed, ArtifactStore: "anchor.internal:5100",
|
||||
Seats: map[string]map[int]int{"mesh-store": {5432: 6852}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("the controller does not compose: %v", err)
|
||||
}
|
||||
|
||||
var process, step map[string]any
|
||||
account, firstSecret := -1, -1
|
||||
for i, r := range out {
|
||||
switch {
|
||||
case r["type"] == "container":
|
||||
t.Errorf("the controller's declaration still runs a container: %v", r)
|
||||
case r["id"] == "mesh-controller.controller":
|
||||
process = r
|
||||
case r["id"] == "mesh-controller.controller-prepare":
|
||||
step = r
|
||||
if process != nil {
|
||||
t.Error("the controller's preparation is placed after the process it prepares for")
|
||||
}
|
||||
case r["type"] == "user" && r["name"] == "mesh-controller":
|
||||
account = i
|
||||
case strings.HasPrefix(fmt.Sprint(r["id"]), "mesh-controller.needs-") && firstSecret < 0:
|
||||
firstSecret = i
|
||||
}
|
||||
}
|
||||
if process == nil {
|
||||
t.Fatalf("the controller's process is not in its declaration: %v", out)
|
||||
}
|
||||
if run, _ := json.Marshal(process["run"]); string(run) != `["./mesh-controller","serve"]` {
|
||||
t.Errorf("the controller is run as %s, not its own bundle's binary", run)
|
||||
}
|
||||
if process["source"] != "anchor.internal:5100/mesh-controller/"+a.Name+"@"+digest || process["digest"] != digest {
|
||||
t.Errorf("the controller's bundle is fetched from %v (%v)", process["source"], process["digest"])
|
||||
}
|
||||
// The user: an account the host declares, which owns what the process reads.
|
||||
if process["user"] != "mesh-controller" || account < 0 {
|
||||
t.Errorf("the controller runs as %v, and the account declared is at %d", process["user"], account)
|
||||
}
|
||||
if firstSecret >= 0 && account > firstSecret {
|
||||
t.Error("the controller's secrets are written before the account they belong to exists")
|
||||
}
|
||||
for _, r := range out {
|
||||
if strings.HasPrefix(fmt.Sprint(r["id"]), "mesh-controller.needs-") && r["owner"] != "mesh-controller" {
|
||||
t.Errorf("%v belongs to %v, which the controller's process cannot read", r["id"], r["owner"])
|
||||
}
|
||||
}
|
||||
if dir := fileNamed(out, "mesh-controller.mesh-state"); dir == nil || dir["owner"] != "mesh-controller" {
|
||||
t.Errorf("the controller's state directory is not its account's to enter: %v", dir)
|
||||
}
|
||||
// Each mount became a path the process reads: nothing it is told is a path inside a container.
|
||||
state := fmt.Sprint(fileNamed(out, "mesh-controller.mesh-state")["path"])
|
||||
env, _ := process["env"].(map[string]any)
|
||||
for key, value := range env {
|
||||
v := fmt.Sprint(value)
|
||||
if strings.HasPrefix(v, "/run/secrets") || strings.HasPrefix(v, "/broker-tls") {
|
||||
t.Errorf("%s=%s is a path inside the container the controller no longer runs in", key, v)
|
||||
}
|
||||
if strings.HasSuffix(key, "_FILE") && !strings.HasPrefix(v, state+"/") {
|
||||
t.Errorf("%s=%s is not one of the files the mesh places for it", key, v)
|
||||
}
|
||||
}
|
||||
if env["MESH_BROKER_CERTIFICATE"] != "/var/lib/mesh-broker-tls/tls.crt" {
|
||||
t.Errorf("the controller reads the broker's certificate from %v", env["MESH_BROKER_CERTIFICATE"])
|
||||
}
|
||||
if env["MESH_STORE_INVENTORY_PORT"] != "6852" {
|
||||
t.Errorf("the controller is told the store is on %v; the node put it on 6852", env["MESH_STORE_INVENTORY_PORT"])
|
||||
}
|
||||
// The handover: the container it ran as goes only once this is running.
|
||||
if got, _ := json.Marshal(process["replaces"]); string(got) != `["mesh-controller.server"]` {
|
||||
t.Errorf("the controller's process replaces %s, not the container it ran as", got)
|
||||
}
|
||||
// And its state is prepared first, by the same program as the same account.
|
||||
if step == nil || step["run-once"] != true || step["user"] != "mesh-controller" {
|
||||
t.Fatalf("the controller's preparation is %v", step)
|
||||
}
|
||||
if run, _ := json.Marshal(step["run"]); string(run) != `["./mesh-controller","prepare"]` {
|
||||
t.Errorf("the controller's preparation runs %s", run)
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The core's own credentials travel sealed by the vault to their machine (novox/hq ADR 0349 §16, issue 755): the
|
||||
// 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 := ParseManifest([]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 {
|
||||
if r["id"] == "mesh-provisioner-client.needs-provisioner-broker" {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A store as a module now declares it (novox/hq ADR 0233): its own data by directory with a class and a
|
||||
// dump, and what it keeps for its consumers.
|
||||
const declaredStore = `{"module":"pg","version":"1",
|
||||
"provides":[{"name":"postgres-database","scope":"mesh"}],
|
||||
"grants":{"postgres-database":"${dir:grants}"},
|
||||
"data":{
|
||||
"own":[
|
||||
{"id":"store","path":"${dir:store}","class":"irreplaceable","backup":{"dump":"dump-all > ${dir:dumps}/all","into":"dumps"},"active":"1d"},
|
||||
{"id":"dumps","path":"${dir:dumps}","class":"rebuildable","backup":"none"}],
|
||||
"consumers":{"postgres-database":{"class":"irreplaceable","in":"store"}}},
|
||||
"resources":[
|
||||
{"id":"grants","type":"directory","mode":"0700"},
|
||||
{"id":"store","type":"directory","path":"/srv/store","mode":"0700"},
|
||||
{"id":"dumps","type":"directory","path":"${dir:store}/dumps","mode":"0700"},
|
||||
{"id":"server","type":"container","image":"x@sha256:aa","volumes":["${dir:store}:/var/lib/postgresql/data"]}]}`
|
||||
|
||||
func mustParse(t *testing.T, raw string) Manifest {
|
||||
t.Helper()
|
||||
m, err := ParseManifest([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("refused: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestADeclaredStoreParsesAndPassesTheCheck(t *testing.T) {
|
||||
m := mustParse(t, declaredStore)
|
||||
if problems := DataProblems(Shelf{"pg": m}); len(problems) > 0 {
|
||||
t.Fatalf("a store declaring its data was refused: %v", problems)
|
||||
}
|
||||
if !m.KeepsConsumerData("postgres-database") {
|
||||
t.Fatal("an irreplaceable consumer class does not keep the consumer's data")
|
||||
}
|
||||
if it, _ := m.DataItem("store"); !it.BackedUp() || it.BackupWithin() != DefaultBackupWithin || it.ActiveWithin().Hours() != 24 {
|
||||
t.Fatalf("the store item reads %+v", it)
|
||||
}
|
||||
if it, _ := m.DataItem("dumps"); it.BackedUp() {
|
||||
t.Fatal("a rebuildable item that says none is backed up")
|
||||
}
|
||||
}
|
||||
|
||||
// Every rule of the section itself, refused at parse in the words of the module.
|
||||
func TestTheDataSectionIsRefusedWhereItIsWrong(t *testing.T) {
|
||||
for _, c := range []struct{ name, data, want string }{
|
||||
{"a class it does not know", `{"own":[{"id":"a","path":"${dir:d}","class":"precious"}]}`, "not one the mesh protects by"},
|
||||
{"an access it does not have", `{"own":[{"id":"a","path":"${access:media}","class":"irreplaceable","redundancy":"an array"}]}`, "declares no access"},
|
||||
{"irreplaceable and protected by nothing", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","backup":"none"}]}`, "protected one way or the other"},
|
||||
{"a cache on redundancy", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","redundancy":"an array"}]}`, "a cache is not protected"},
|
||||
{"kept with something not required", `{"kept-by":{"s3-bucket":{"class":"irreplaceable"}}}`, "does not require"},
|
||||
{"a measure it does not know", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","measure":"du"}]}`, "walk, dataset or shallow"},
|
||||
{"a cache expecting writes", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","active":"1d"}]}`, "only irreplaceable and valuable"},
|
||||
{"a machine path", `{"own":[{"id":"a","path":"/srv/a","class":"cache"}]}`, "names no machine path"},
|
||||
{"a directory it does not declare", `{"own":[{"id":"a","path":"${dir:nowhere}","class":"cache"}]}`, "declares no directory"},
|
||||
{"a cache backed up", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","backup":"copy"}]}`, "disposable"},
|
||||
{"a dump into nothing", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","backup":{"dump":"x","into":"b"}}]}`, "not one of the module's data items"},
|
||||
{"a dump into itself", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","backup":{"dump":"x","into":"a"}}]}`, "into itself"},
|
||||
{"a bad duration", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","within":"soon"}]}`, "not a duration"},
|
||||
{"the same id twice", `{"own":[{"id":"a","path":"${dir:d}","class":"cache"},{"id":"a","path":"${dir:d}/x","class":"cache"}]}`, "declared twice"},
|
||||
{"own data of class none", `{"own":[{"id":"a","path":"${dir:d}","class":"none"}]}`, "only a provision"},
|
||||
{"consumers of something not provided", `{"consumers":{"s3-bucket":{"class":"cache"}}}`, "does not provide"},
|
||||
{"consumer data that lives nowhere", `{"consumers":{"q":{"class":"irreplaceable"}}}`, "says nowhere it lives"},
|
||||
{"consumer data in an item less precious", `{"own":[{"id":"a","path":"${dir:d}","class":"rebuildable"}],"consumers":{"q":{"class":"irreplaceable","in":"a"}}}`, "which is only rebuildable"},
|
||||
{"consumer data in something unknown", `{"consumers":{"q":{"class":"rebuildable","in":"elsewhere"}}}`, "neither one of its data items"},
|
||||
} {
|
||||
raw := `{"module":"m","version":"1","provides":["q"],"resources":[{"id":"d","type":"directory","mode":"0700"}],"data":` + c.data + `}`
|
||||
_, err := ParseManifest([]byte(raw))
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: %v, want %q", c.name, err, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The catalogue check: a granting provider says what it keeps, says it in one place, writes no backup
|
||||
// line by hand, and every directory a container writes is declared (novox/hq ADR 0233).
|
||||
func TestTheCatalogueCheckAsksEveryModuleForItsData(t *testing.T) {
|
||||
unsaid := mustParse(t, `{"module":"q","version":"1","provides":[{"name":"queue","scope":"mesh"}],
|
||||
"grants":{"queue":"${dir:g}"},"resources":[{"id":"g","type":"directory","mode":"0700"}]}`)
|
||||
onTheOffer := mustParse(t, `{"module":"r","version":"1","provides":[{"name":"resolver","scope":"mesh","keeps-consumer-data":false}]}`)
|
||||
byHand := mustParse(t, `{"module":"h","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"}],
|
||||
"contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:d}"}]}`)
|
||||
writes := mustParse(t, `{"module":"w","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"},
|
||||
{"id":"c","type":"directory","mode":"0700"},
|
||||
{"id":"app","type":"container","image":"x@sha256:aa","volumes":["${dir:d}:/data","${dir:c}:/etc/app:ro","${dir:d}/f:/f"]}]}`)
|
||||
problems := strings.Join(DataProblems(Shelf{"q": unsaid, "r": onTheOffer, "h": byHand, "w": writes}), "\n")
|
||||
for _, want := range []string{
|
||||
"q grants queue and does not say what it keeps",
|
||||
"r says keeps-consumer-data on its offer",
|
||||
"h's contribution 1 is a backup line written by hand",
|
||||
`w mounts its directory "d" into a container to be written`,
|
||||
} {
|
||||
if !strings.Contains(problems, want) {
|
||||
t.Errorf("the check does not say %q:\n%s", want, problems)
|
||||
}
|
||||
}
|
||||
if strings.Contains(problems, `"c"`) {
|
||||
t.Errorf("a read-only mount was taken for written data:\n%s", problems)
|
||||
}
|
||||
// The same module declaring the directory, as a cache, passes.
|
||||
declared := mustParse(t, `{"module":"w","version":"1","data":{"own":[{"id":"d","path":"${dir:d}","class":"cache"}]},
|
||||
"resources":[{"id":"d","type":"directory","mode":"0700"},
|
||||
{"id":"app","type":"container","image":"x@sha256:aa","volumes":["${dir:d}:/data"]}]}`)
|
||||
if p := DataProblems(Shelf{"w": declared}); len(p) > 0 {
|
||||
t.Fatalf("a declared directory is still refused: %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// Consumer data said to live in a provision the module requires is protected only as well as that
|
||||
// provision's provider protects its consumers' data.
|
||||
func TestConsumerDataInARequiredProvisionIsJudgedAgainstItsProvider(t *testing.T) {
|
||||
idp := mustParse(t, `{"module":"idp","version":"1","requires":["postgres-database"],
|
||||
"provides":[{"name":"oidc-client","scope":"mesh"}],"grants":{"oidc-client":"${dir:g}"},
|
||||
"resources":[{"id":"g","type":"directory","mode":"0700"}],
|
||||
"data":{"consumers":{"oidc-client":{"class":"irreplaceable","in":"postgres-database"}}}}`)
|
||||
cachy := mustParse(t, strings.Replace(declaredStore, `"consumers":{"postgres-database":{"class":"irreplaceable","in":"store"}}`,
|
||||
`"consumers":{"postgres-database":{"class":"cache"}}`, 1))
|
||||
if p := strings.Join(DataProblems(Shelf{"idp": idp, "pg": cachy}), "\n"); !strings.Contains(p, "not protected as irreplaceable") {
|
||||
t.Fatalf("irreplaceable data kept in a cache passed: %s", p)
|
||||
}
|
||||
if p := DataProblems(Shelf{"idp": idp, "pg": mustParse(t, declaredStore)}); len(p) > 0 {
|
||||
t.Fatalf("refused against a provider that keeps its consumers' data: %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// What a binding is sticky by follows the class (ADR 0232, generalised by 0233): a cache's consumers
|
||||
// move freely, a store's do not; an older definition saying nothing still follows its grant.
|
||||
func TestKeepingConsumerDataFollowsTheClass(t *testing.T) {
|
||||
for class, keeps := range map[string]bool{"irreplaceable": true, "valuable": true, "rebuildable": true, "cache": false, "none": false} {
|
||||
in := `,"in":"d"`
|
||||
own := `"own":[{"id":"d","path":"${dir:d}","class":"irreplaceable"}],`
|
||||
if !keeps {
|
||||
in, own = "", ""
|
||||
}
|
||||
m := mustParse(t, `{"module":"p","version":"1","provides":[{"name":"q","scope":"mesh"}],"grants":{"q":"${dir:d}"},
|
||||
"resources":[{"id":"d","type":"directory","mode":"0700"}],
|
||||
"data":{`+own+`"consumers":{"q":{"class":"`+class+`"`+in+`}}}}`)
|
||||
if m.KeepsConsumerData("q") != keeps {
|
||||
t.Errorf("class %s keeps consumer data: %v, want %v", class, !keeps, keeps)
|
||||
}
|
||||
shelf := map[string]Manifest{"p": m}
|
||||
if KeepsConsumerData(shelf, "q") != keeps {
|
||||
t.Errorf("class %s: the provision by name keeps: %v", class, !keeps)
|
||||
}
|
||||
}
|
||||
older := mustParse(t, `{"module":"p","version":"1","provides":[{"name":"q","scope":"mesh"}],"grants":{"q":"${dir:d}"},
|
||||
"resources":[{"id":"d","type":"directory","mode":"0700"}]}`)
|
||||
if !older.KeepsConsumerData("q") {
|
||||
t.Fatal("an older definition that grants no longer keeps its consumers' data")
|
||||
}
|
||||
}
|
||||
|
||||
// The backup holder's lines are derived: the dump runs and the directory it writes into is kept,
|
||||
// anything but a cache is copied by default — the standard nightly plan — an item on redundancy is not
|
||||
// copied, and every item is listed with its class and protection for the holder to measure and watch:
|
||||
// directories filled, a home directory filled from the machine, an operator's path from where the
|
||||
// assignment placed it. A backup line still written by hand beside a data section is not placed.
|
||||
func TestTheBackupHoldersLinesAreDerivedFromTheData(t *testing.T) {
|
||||
pg := mustParse(t, declaredStore)
|
||||
pg.Contributions = append(pg.Contributions, SeatContribution{Seat: BackupSeat, Kind: "backup", Content: "path /stale"})
|
||||
agent := mustParse(t, `{"module":"agent","version":"1",
|
||||
"data":{"own":[{"id":"home","path":"${dir:home}","class":"valuable"},{"id":"tmp","path":"${dir:home}/tmp","class":"cache"}]},
|
||||
"resources":[{"id":"home","type":"directory","path":"${machine:account-home}/.agent","mode":"0700"}]}`)
|
||||
media := mustParse(t, `{"module":"media","version":"1",
|
||||
"accesses":[{"id":"films","mode":"read"}],
|
||||
"data":{"own":[{"id":"films","path":"${access:films}","class":"irreplaceable","redundancy":"on a redundant array; no room for a copy","measure":"dataset"},
|
||||
{"id":"meta","path":"${dir:meta}","class":"rebuildable"}]},
|
||||
"resources":[{"id":"meta","type":"directory","mode":"0700"}]}`)
|
||||
facts := map[string]string{"account-home": "/home/op"}
|
||||
with := Rendering{Settings: SettingsBy{"media": {LayerOf("node", map[string]any{AccessesSetting: map[string]any{"films": "/tank/films"}})}}}
|
||||
backup, _, err := seatContributions([]Manifest{pg, agent, media}, Manifest{}, BackupSeat+":"+BackupKindBackup, with, facts, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "# agent\npath /home/op/.agent\n# media\npath /var/lib/media/meta\n# pg\nrun dump-all > /srv/store/dumps/all\npath /srv/store/dumps\n"
|
||||
if backup != want {
|
||||
t.Fatalf("backup lines:\n%s\nwant:\n%s", backup, want)
|
||||
}
|
||||
data, _, err := seatContributions([]Manifest{pg, agent, media}, Manifest{}, BackupSeat+":"+BackupKindData, with, facts, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want = "# agent\nitem home valuable /home/op/.agent /home/op/.agent backup walk\nitem tmp cache /home/op/.agent/tmp - none walk\n" +
|
||||
"# media\nitem films irreplaceable /tank/films - redundancy dataset\nitem meta rebuildable /var/lib/media/meta /var/lib/media/meta backup walk\n" +
|
||||
"# pg\nitem store irreplaceable /srv/store /srv/store/dumps backup walk\nitem dumps rebuildable /srv/store/dumps - none walk\n"
|
||||
if data != want {
|
||||
t.Fatalf("data lines:\n%s\nwant:\n%s", data, want)
|
||||
}
|
||||
if _, _, err := seatContributions([]Manifest{agent}, Manifest{}, BackupSeat+":"+BackupKindData, Rendering{}, nil, nil); err == nil {
|
||||
t.Fatal("a home directory with no account on the machine reached the holder as a placeholder")
|
||||
}
|
||||
// What keeps something irreplaceable depends on the machine's backup holder — it backs it up or
|
||||
// watches its array; valuable data alone is backed up where a holder is, and refused nowhere.
|
||||
if !slices.Contains(DependsOn(nil, media), BackupSeat) || !slices.Contains(DependsOn(nil, pg), BackupSeat) ||
|
||||
slices.Contains(DependsOn(nil, agent), BackupSeat) {
|
||||
t.Fatalf("depends: media %v, pg %v, agent %v", DependsOn(nil, media), DependsOn(nil, pg), DependsOn(nil, agent))
|
||||
}
|
||||
if it, _ := media.DataItem("films"); it.OwnedByModule() || it.Protection() != "redundancy" {
|
||||
t.Fatalf("an operator's path reads %+v", it)
|
||||
}
|
||||
}
|
||||
|
||||
// Data a consumer keeps with a provider as irreplaceable must be protected there: the check refuses a
|
||||
// provider that keeps its consumers' data as a cache, or in an item with no protection.
|
||||
func TestIrreplaceableDataKeptWithAProviderMustBeProtectedThere(t *testing.T) {
|
||||
photos := mustParse(t, `{"module":"photos","version":"1","requires":["s3-bucket"],
|
||||
"data":{"kept-by":{"s3-bucket":{"class":"irreplaceable"}}}}`)
|
||||
store := func(backup string) Manifest {
|
||||
return mustParse(t, `{"module":"objects","version":"1","provides":[{"name":"s3-bucket","scope":"mesh"}],
|
||||
"grants":{"s3-bucket":"${dir:g}"},
|
||||
"data":{"own":[{"id":"data","path":"${dir:data}","class":"valuable","backup":"`+backup+`"}],
|
||||
"consumers":{"s3-bucket":{"class":"valuable","in":"data"}}},
|
||||
"resources":[{"id":"g","type":"directory","mode":"0700"},{"id":"data","type":"directory","mode":"0700"}]}`)
|
||||
}
|
||||
if p := DataProblems(Shelf{"photos": photos, "objects": store("copy")}); len(p) > 0 {
|
||||
t.Fatalf("a provider backing its consumers' data up was refused: %v", p)
|
||||
}
|
||||
if p := strings.Join(DataProblems(Shelf{"photos": photos, "objects": store("none")}), "\n"); !strings.Contains(p, "neither backed up") {
|
||||
t.Fatalf("irreplaceable photos in an unprotected store passed: %s", p)
|
||||
}
|
||||
}
|
||||
@@ -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,164 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The delivery seat answers "what did the mesh's checks say of this pull request?" as a verb of its own,
|
||||
// `checks` (novox/hq ADR 0239): read by repository and a pull request's number or a commit.
|
||||
func TestTheDeliverySeatPromisesChecks(t *testing.T) {
|
||||
seat, ok := SeatNamed(DeliverySeat)
|
||||
if !ok {
|
||||
t.Fatal("the delivery seat is not in the set")
|
||||
}
|
||||
var checks *Verb
|
||||
for i := range seat.Serves {
|
||||
if seat.Serves[i].Name == "checks" {
|
||||
checks = &seat.Serves[i]
|
||||
}
|
||||
}
|
||||
if checks == nil {
|
||||
t.Fatalf("the delivery seat promises %v and not checks", VerbNames(seat.Serves))
|
||||
}
|
||||
props, _ := checks.Input["properties"].(map[string]any)
|
||||
for _, arg := range []string{"repository", "number", "commit"} {
|
||||
if _, has := props[arg]; !has {
|
||||
t.Errorf("checks takes no %q", arg)
|
||||
}
|
||||
}
|
||||
if req, _ := checks.Input["required"].([]string); !reflect.DeepEqual(req, []string{"repository"}) {
|
||||
t.Errorf("checks requires %v, wanted only the repository", req)
|
||||
}
|
||||
|
||||
// Added after the seat's holder shipped, it is optional: the holder serving the ten verbs before it still
|
||||
// holds the seat, and one serving all eleven does too — so the controller and the catalogue can move in
|
||||
// either order, and no check of the catalogue fails on a module nobody touched between the two.
|
||||
if !checks.Optional {
|
||||
t.Fatal("checks is a condition of holding before its holder serves it")
|
||||
}
|
||||
var before []string
|
||||
for _, v := range VerbNames(seat.Serves) {
|
||||
if v != "checks" {
|
||||
before = append(before, v)
|
||||
}
|
||||
}
|
||||
m := Manifest{Module: "mesh-delivery", Claims: []Claim{{Name: DeliverySeat, Scope: ScopeMesh, Serves: before}}}
|
||||
if err := CanHold(m, seat); err != nil {
|
||||
t.Fatalf("a holder without checks yet: %v", err)
|
||||
}
|
||||
m.Claims[0].Serves = VerbNames(seat.Serves)
|
||||
if err := CanHold(m, seat); err != nil {
|
||||
t.Fatalf("a holder serving every verb: %v", err)
|
||||
}
|
||||
// Every other verb is still a condition of holding.
|
||||
m.Claims[0].Serves = append([]string{"checks"}, before[1:]...)
|
||||
if err := CanHold(m, seat); err == nil || !strings.Contains(err.Error(), before[0]) {
|
||||
t.Fatalf("a holder without %s: %v", before[0], err)
|
||||
}
|
||||
}
|
||||
|
||||
// The seat retires history on a person's word (novox/hq issue 313): `retire-history`, with why, one delivery by
|
||||
// id or every held one, dry to see first. Added after the holder shipped, it is optional, so the holder that
|
||||
// does not serve it yet still holds the seat, and one that does is not refused for a verb the seat lacks.
|
||||
func TestTheDeliverySeatPromisesRetireHistoryOptionally(t *testing.T) {
|
||||
seat, _ := SeatNamed(DeliverySeat)
|
||||
var retire *Verb
|
||||
for i := range seat.Serves {
|
||||
if seat.Serves[i].Name == "retire-history" {
|
||||
retire = &seat.Serves[i]
|
||||
}
|
||||
}
|
||||
if retire == nil || !retire.Optional {
|
||||
t.Fatalf("the delivery seat promises %v, retire-history optionally", VerbNames(seat.Serves))
|
||||
}
|
||||
props, _ := retire.Input["properties"].(map[string]any)
|
||||
for _, arg := range []string{"why", "id", "repository", "dry"} {
|
||||
if _, has := props[arg]; !has {
|
||||
t.Errorf("retire-history takes no %q", arg)
|
||||
}
|
||||
}
|
||||
if req, _ := retire.Input["required"].([]string); !reflect.DeepEqual(req, []string{"why"}) {
|
||||
t.Errorf("retire-history requires %v, wanted only why", req)
|
||||
}
|
||||
var without []string
|
||||
for _, v := range VerbNames(seat.Serves) {
|
||||
if v != "retire-history" {
|
||||
without = append(without, v)
|
||||
}
|
||||
}
|
||||
m := Manifest{Module: "mesh-delivery", Claims: []Claim{{Name: DeliverySeat, Scope: ScopeMesh, Serves: without}}}
|
||||
if err := CanHold(m, seat); err != nil {
|
||||
t.Fatalf("a holder without retire-history yet: %v", err)
|
||||
}
|
||||
m.Claims[0].Serves = VerbNames(seat.Serves)
|
||||
if err := CanHold(m, seat); err != nil {
|
||||
t.Fatalf("a holder serving retire-history: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The seat says delivery times (novox/hq ADR 0282, issue 382): `times`, over some days or for one delivery. Added
|
||||
// after the holder shipped, it is optional, so the holder that does not serve it yet still holds the seat, and one
|
||||
// that does is not refused for a verb the seat lacks.
|
||||
func TestTheDeliverySeatPromisesTimesOptionally(t *testing.T) {
|
||||
seat, _ := SeatNamed(DeliverySeat)
|
||||
var times *Verb
|
||||
for i := range seat.Serves {
|
||||
if seat.Serves[i].Name == "times" {
|
||||
times = &seat.Serves[i]
|
||||
}
|
||||
}
|
||||
if times == nil || !times.Optional {
|
||||
t.Fatalf("the delivery seat promises %v, times optionally", VerbNames(seat.Serves))
|
||||
}
|
||||
props, _ := times.Input["properties"].(map[string]any)
|
||||
for _, arg := range []string{"days", "id"} {
|
||||
if _, has := props[arg]; !has {
|
||||
t.Errorf("times takes no %q", arg)
|
||||
}
|
||||
}
|
||||
var without []string
|
||||
for _, v := range VerbNames(seat.Serves) {
|
||||
if v != "times" {
|
||||
without = append(without, v)
|
||||
}
|
||||
}
|
||||
m := Manifest{Module: "mesh-delivery", Claims: []Claim{{Name: DeliverySeat, Scope: ScopeMesh, Serves: without}}}
|
||||
if err := CanHold(m, seat); err != nil {
|
||||
t.Fatalf("a holder without times yet: %v", err)
|
||||
}
|
||||
m.Claims[0].Serves = VerbNames(seat.Serves)
|
||||
if err := CanHold(m, seat); err != nil {
|
||||
t.Fatalf("a holder serving times: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The seat still promises `mark-ready`, optionally, for one more release (issue 1022); it was added by novox/hq issue 913: `mark-ready`, with the delivery's id, who reviewed it
|
||||
// and why, so a reviewer marks a pull request through one audited verb, label and comment in the order the forge
|
||||
// counts, instead of two raw forge calls. Added after the holder shipped, it is optional.
|
||||
func TestTheDeliverySeatPromisesMarkReadyOptionally(t *testing.T) {
|
||||
seat, _ := SeatNamed(DeliverySeat)
|
||||
var mark *Verb
|
||||
for i := range seat.Serves {
|
||||
if seat.Serves[i].Name == "mark-ready" {
|
||||
mark = &seat.Serves[i]
|
||||
}
|
||||
}
|
||||
if mark == nil || !mark.Optional {
|
||||
t.Fatalf("the delivery seat promises %v, mark-ready optionally", VerbNames(seat.Serves))
|
||||
}
|
||||
if req, _ := mark.Input["required"].([]string); !reflect.DeepEqual(req, []string{"id", "reviewed-by", "why"}) {
|
||||
t.Errorf("mark-ready requires %v, wanted id, reviewed-by and why", req)
|
||||
}
|
||||
var without []string
|
||||
for _, v := range VerbNames(seat.Serves) {
|
||||
if v != "mark-ready" {
|
||||
without = append(without, v)
|
||||
}
|
||||
}
|
||||
m := Manifest{Module: "mesh-delivery", Claims: []Claim{{Name: DeliverySeat, Scope: ScopeMesh, Serves: without}}}
|
||||
if err := CanHold(m, seat); err != nil {
|
||||
t.Fatalf("a holder without mark-ready yet: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,346 +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)
|
||||
}
|
||||
}
|
||||
|
||||
// A fact or an alphabet the mesh does not have is refused where the definition is, not where a
|
||||
// consumer happens to be resolved — and the refusal says what may be said instead.
|
||||
func TestAServedValueNamingSomethingTheMeshDoesNotHaveIsRefused(t *testing.T) {
|
||||
for _, c := range []struct{ value, says string }{
|
||||
{"${consumer:node}", "as"},
|
||||
{"${consumer:as:punycode}", "dns"},
|
||||
} {
|
||||
m := store()
|
||||
m.Serves["s3-bucket"]["bucket"] = c.value
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = ParseManifest(raw)
|
||||
if err == nil {
|
||||
t.Fatalf("%s was accepted", c.value)
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.value) {
|
||||
t.Errorf("the refusal of %s does not quote it: %v", c.value, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("the refusal of %s does not say what may be said (%q): %v", c.value, c.says, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// `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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,325 +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 TestAReferenceToNoDirectoryRefusesAtTheManifest(t *testing.T) {
|
||||
m := placedModule()
|
||||
m.Resources[2]["volumes"] = []any{"${dir:date}:/data"} // a typo, the likely shape
|
||||
problems := m.unknownDirRefs()
|
||||
if len(problems) != 1 || !strings.Contains(problems[0], `${dir:date}`) {
|
||||
t.Fatalf("a reference naming no directory is a manifest problem; got %v", problems)
|
||||
}
|
||||
if got := placedModule().unknownDirRefs(); len(got) != 0 {
|
||||
t.Fatalf("a correct definition has none; got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
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 TestAMapReferenceToNoDirectoryRefusesAtTheManifest(t *testing.T) {
|
||||
m := Manifest{
|
||||
Module: "photos",
|
||||
Resources: []map[string]any{{"id": "state", "type": "directory", "place": "."}},
|
||||
Binds: map[string]string{"route": "${dir:stat}/route.json"},
|
||||
}
|
||||
problems := m.unknownDirRefs()
|
||||
if len(problems) != 1 || !strings.Contains(problems[0], `${dir:stat}`) {
|
||||
t.Fatalf("a map naming no directory is a manifest problem; got %v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceIsValidatedAtTheManifest(t *testing.T) {
|
||||
both := Manifest{Module: "x", Resources: []map[string]any{
|
||||
{"id": "d", "type": "directory", "place": ".", "path": "/somewhere"},
|
||||
}}
|
||||
if got := both.unknownDirRefs(); len(got) != 1 || !strings.Contains(got[0], "both path and place") {
|
||||
t.Fatalf("path beside place refuses; got %v", got)
|
||||
}
|
||||
elsewhere := Manifest{Module: "x", Resources: []map[string]any{
|
||||
{"id": "f", "type": "file", "place": ".", "path": "/somewhere", "content": ""},
|
||||
}}
|
||||
if got := elsewhere.unknownDirRefs(); len(got) != 1 || !strings.Contains(got[0], "not a directory") {
|
||||
t.Fatalf("place on a file refuses; got %v", got)
|
||||
}
|
||||
wrong := Manifest{Module: "x", Resources: []map[string]any{
|
||||
{"id": "d", "type": "directory", "place": "sub/dir"},
|
||||
}}
|
||||
if got := wrong.unknownDirRefs(); len(got) != 1 || !strings.Contains(got[0], `the places are "."`) {
|
||||
t.Fatalf("a place that is neither root refuses; got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
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"},
|
||||
}
|
||||
if got := m.unknownDirRefs(); len(got) != 0 {
|
||||
t.Fatalf("place %q is a place; got %v", "mesh", got)
|
||||
}
|
||||
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"}},
|
||||
}}
|
||||
if got := m.unknownDirRefs(); len(got) != 0 {
|
||||
t.Fatalf("a path beneath a placed directory is well formed; got %v", got)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// aMediaServer is the shape one port number per key cannot express: two endpoints of different kinds.
|
||||
// A web surface a proxy serves under a subdomain, and a protocol port clients dial directly because
|
||||
// the client expects that number.
|
||||
func aMediaServer() Manifest {
|
||||
return Manifest{
|
||||
Module: "media",
|
||||
Listens: []Listening{
|
||||
{Name: "web", Port: 80, From: FromMesh, Why: "the app, behind the proxy"},
|
||||
{Name: "stream", Port: 32400, From: FromEverywhere, Fixed: true,
|
||||
Why: "the client dials this number; the protocol chose it"},
|
||||
},
|
||||
Contributes: map[string]map[string]any{
|
||||
"route": {"label": "media", RouteEndpoint: "web"},
|
||||
},
|
||||
// Both endpoints are published by its container, which is what lets a machine port be given
|
||||
// for either: the mesh moves a port the module publishes, never one it merely listens on.
|
||||
Resources: []map[string]any{
|
||||
{"id": "server", "type": "container", "name": "media",
|
||||
"ports": []any{"80", "32400"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// **A route names the endpoint it serves.** A route and a listen both carried a port and nothing said
|
||||
// they were the same thing; now one of them says so.
|
||||
func TestARouteNamesTheEndpointItServes(t *testing.T) {
|
||||
m := aMediaServer()
|
||||
if port, ok := EndpointPort(m, "web"); !ok || port != 80 {
|
||||
t.Fatalf("the web endpoint resolves to %d (%v), want 80", port, ok)
|
||||
}
|
||||
if port, ok := EndpointPort(m, "stream"); !ok || port != 32400 {
|
||||
t.Fatalf("the stream endpoint resolves to %d (%v), want 32400", port, ok)
|
||||
}
|
||||
if _, ok := EndpointPort(m, "absent"); ok {
|
||||
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 naming an endpoint the module does not declare reaches nothing, and is refused where it is
|
||||
// written rather than resolving to no port and serving nothing.
|
||||
func TestARouteNamingAnEndpointTheModuleLacksIsRefused(t *testing.T) {
|
||||
m := aMediaServer()
|
||||
m.Contributes["route"][RouteEndpoint] = "absent"
|
||||
got := strings.Join(RouteProblems(m), "\n")
|
||||
if !strings.Contains(got, "does not declare") {
|
||||
t.Fatalf("a route naming an absent endpoint was accepted:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// **Two endpoints called the same would make an assignment configure whichever was read last.** The
|
||||
// point of a name is that it identifies one thing.
|
||||
func TestTwoEndpointsWithOneNameAreRefused(t *testing.T) {
|
||||
m := Manifest{Module: "twice", Listens: []Listening{
|
||||
{Name: "web", Port: 80, From: FromMesh},
|
||||
{Name: "web", Port: 8080, From: FromMesh},
|
||||
}}
|
||||
got := strings.Join(endpointNameProblems(m), "\n")
|
||||
if !strings.Contains(got, "could mean either") {
|
||||
t.Fatalf("two endpoints with one name were accepted:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A name that is not a name is refused where it is written: it ends up in something a person types.
|
||||
func TestAnEndpointNameIsHeldToItsShape(t *testing.T) {
|
||||
for _, wrong := range []string{"Web", "web port", "3000", "-web", "web_surface"} {
|
||||
m := Manifest{Module: "odd", Listens: []Listening{{Name: wrong, Port: 80, From: FromMesh}}}
|
||||
if got := strings.Join(endpointNameProblems(m), "\n"); !strings.Contains(got, "a name is lowercase") {
|
||||
t.Fatalf("%q was accepted as an endpoint name:\n%s", wrong, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **Every endpoint in the catalogue is unnamed today, and must stay valid.** The word ships one
|
||||
// release before anything uses it.
|
||||
func TestAnUnnamedEndpointIsStillValid(t *testing.T) {
|
||||
m := Manifest{Module: "ordinary", Listens: []Listening{{Port: 443, From: FromEverywhere}}}
|
||||
if got := endpointNameProblems(m); len(got) != 0 {
|
||||
t.Fatalf("an unnamed endpoint was refused: %v", got)
|
||||
}
|
||||
if got := RouteProblems(m); len(got) != 0 {
|
||||
t.Fatalf("a module with no route was refused: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// **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)
|
||||
}
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func configured(block map[string]any) SettingsBy {
|
||||
return SettingsBy{"media": {{From: "node anchor",
|
||||
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{
|
||||
"admin": map[string]any{"reach": ReachInternal}})["media"])
|
||||
if err == nil || !strings.Contains(err.Error(), "does not declare") {
|
||||
t.Fatalf("configuring an absent endpoint was accepted: %v", err)
|
||||
}
|
||||
if err != nil && !strings.Contains(err.Error(), "stream") {
|
||||
t.Fatalf("the refusal does not name what the module declares: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAReachInABlockIsHeldToTheFourValues(t *testing.T) {
|
||||
_, err := Endpoints(aMediaServer(), configured(map[string]any{
|
||||
"web": map[string]any{"reach": "mesh"}})["media"])
|
||||
if err == nil || !strings.Contains(err.Error(), "a reach is") {
|
||||
t.Fatalf("a filter word was accepted as a reach: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// **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{
|
||||
EndpointsSetting: map[string]any{"web": map[string]any{"reach": ReachInternal}},
|
||||
ExposeSetting: map[string]any{"80": FromEverywhere},
|
||||
}}})
|
||||
if err == nil || !strings.Contains(err.Error(), "same thing in different words") {
|
||||
t.Fatalf("a reach said two ways was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A module whose endpoints are unnamed cannot be configured this way, and is told so rather than
|
||||
// having a block silently reach nothing — which is every module in the catalogue today.
|
||||
func TestAModuleWithNoNamedEndpointsIsToldSo(t *testing.T) {
|
||||
m := Manifest{Module: "media", Listens: []Listening{{Port: 80, From: FromMesh}}}
|
||||
_, err := Endpoints(m, configured(map[string]any{"web": map[string]any{"reach": ReachBoth}})["media"])
|
||||
if err == nil || !strings.Contains(err.Error(), "no endpoints by name") {
|
||||
t.Fatalf("a module with no named endpoints accepted a block: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/beside"
|
||||
)
|
||||
|
||||
// The controller refuses a definition's resolved path by the node-engine's own rules, no more (novox/hq issue
|
||||
// 496): the same lists, read here from mesh-host's internal/apply/placement_guard.go — in a merge check the clone
|
||||
// beside it at the commit the mesh runs, elsewhere the copy captured in testdata/beside. When the engine's lists
|
||||
// move, this fails until the controller's move with them, so the gate never refuses what the engine applies nor
|
||||
// passes what it refuses by path alone.
|
||||
func TestTheResolvedPathRulesAreTheNodeEnginesOwn(t *testing.T) {
|
||||
file := filepath.Join(beside.Dir(t, "mesh-host"), "internal", "apply", "placement_guard.go")
|
||||
parsed, err := parser.ParseFile(token.NewFileSet(), file, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("the node-engine's guard does not parse: %v", err)
|
||||
}
|
||||
lists := map[string][]string{}
|
||||
consts := map[string]string{}
|
||||
ast.Inspect(parsed, func(n ast.Node) bool {
|
||||
spec, ok := n.(*ast.ValueSpec)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
for i, name := range spec.Names {
|
||||
if i >= len(spec.Values) {
|
||||
continue
|
||||
}
|
||||
switch v := spec.Values[i].(type) {
|
||||
case *ast.CompositeLit:
|
||||
for _, elt := range v.Elts {
|
||||
if lit, ok := elt.(*ast.BasicLit); ok && lit.Kind == token.STRING {
|
||||
s, _ := strconv.Unquote(lit.Value)
|
||||
lists[name.Name] = append(lists[name.Name], s)
|
||||
}
|
||||
}
|
||||
case *ast.BasicLit:
|
||||
if v.Kind == token.STRING {
|
||||
consts[name.Name], _ = strconv.Unquote(v.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
for name, ours := range map[string][]string{
|
||||
"protectedRoots": protectedRoots, "forbiddenBelow": forbiddenBelow, "engineTrees": engineTrees,
|
||||
} {
|
||||
theirs := lists[name]
|
||||
if len(theirs) == 0 {
|
||||
t.Errorf("the node-engine's guard names no %s any more; read it and say where its rule went", name)
|
||||
continue
|
||||
}
|
||||
a, b := slices.Clone(ours), slices.Clone(theirs)
|
||||
slices.Sort(a)
|
||||
slices.Sort(b)
|
||||
if !slices.Equal(a, b) {
|
||||
t.Errorf("%s differs from the node-engine's:\n controller %v\n node-engine %v", name, a, b)
|
||||
}
|
||||
}
|
||||
if consts["engineModule"] != engineModule {
|
||||
t.Errorf("the node-engine's own module is %q there and %q here", consts["engineModule"], engineModule)
|
||||
}
|
||||
}
|
||||
@@ -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,424 +0,0 @@
|
||||
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.
|
||||
|
||||
// The environment.d rendering, read by the service manager's own generator where this machine has
|
||||
// one — the same reader an account's user manager runs, so the PATH it composes is the one asserted.
|
||||
//
|
||||
// The generator also reads the machine's own environment.d (/etc, /run, /usr/lib, /usr/local/lib), and
|
||||
// no option points it elsewhere: a desktop whose snapd appends its bin directory failed this, on main,
|
||||
// for a file the mesh never wrote (novox/hq issue 432). So the generator is run twice, once without the
|
||||
// mesh's file, and what the machine's own files make of PATH and set is held out of the verdict.
|
||||
//
|
||||
// A machine without the generator — the build seat's toolchain image holds no systemd — cannot judge
|
||||
// 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)\""},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0204 §3: a slot holds that shell's code only, in module order, each module's pieces in the
|
||||
// order it declared them under a line naming it; empty when nothing is contributed.
|
||||
func TestShellCodeLandsInItsSlotInModuleOrderForItsShellOnly(t *testing.T) {
|
||||
if got, want := shellCode(shells(), "zsh", "normal"), "# 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"; got != want {
|
||||
t.Fatalf("zsh's normal slot is\n%s\nnot\n%s", got, want)
|
||||
}
|
||||
if got, want := shellCode(shells(), "zsh", "last"), "# zsh-syntax-highlighting\n"+
|
||||
"source /usr/share/zsh/plugins/zsh-syntax-highlighting/zsh-syntax-highlighting.zsh\n"; got != want {
|
||||
t.Fatalf("zsh's last slot is\n%s\nnot\n%s", got, want)
|
||||
}
|
||||
if got, want := shellCode(shells(), "bash", "last"), "# direnv\neval \"$(direnv hook bash)\"\n"; got != want {
|
||||
t.Fatalf("bash's last slot is %q, not %q", got, want)
|
||||
}
|
||||
if got := shellCode(shells(), "fish", "first"); got != "" {
|
||||
t.Fatalf("a slot nobody contributed to holds %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 §5 and ADR 0204 §3: a placeholder outside the seat's holder is refused — by the parser,
|
||||
// which is what the catalogue check and registration run, and again at composition, in the same words.
|
||||
func TestAPlaceholderOutsideTheHolderIsRefused(t *testing.T) {
|
||||
for _, c := range []struct{ content, want string }{
|
||||
{"${environment:posix}", "toolchain's resource rc names ${environment:posix} and toolchain does not claim node-environment"},
|
||||
{"${shell:zsh:normal}", "toolchain's resource rc names ${shell:zsh:normal} and toolchain does not claim node-login-shell"},
|
||||
} {
|
||||
raw := `{"module":"toolchain","resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` + c.content + `"}]}`
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("the catalogue check accepted %s outside its holder: %v", c.content, err)
|
||||
}
|
||||
m := Manifest{Module: "toolchain", Resources: []map[string]any{
|
||||
{"id": "rc", "type": "file", "path": "/etc/rc", "content": c.content}}}
|
||||
r := Resolution{Node: "workstation", Account: "op", Modules: []Manifest{m}}
|
||||
if _, err := r.Declaration(Rendering{}); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("composition accepted %s outside its holder: %v", c.content, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A key nobody renders is refused, not left in the file as a literal.
|
||||
func TestAnUnknownPlaceholderKeyIsRefused(t *testing.T) {
|
||||
for _, c := range []struct{ content, want string }{
|
||||
{"${environment:foo}", "names ${environment:foo}; the environment is ${environment:posix} or ${environment:systemd}"},
|
||||
{"${shell:zsh:middle}", "names ${shell:zsh:middle}; shell code is ${shell:<shell>:<slot>}"},
|
||||
{"${shell:tcsh:first}", "names ${shell:tcsh:first}; shell code is ${shell:<shell>:<slot>}"},
|
||||
{"${shell:zsh}", "names ${shell:zsh}; shell code is ${shell:<shell>:<slot>}"},
|
||||
} {
|
||||
raw := `{"module":"holder","claims":[{"name":"node-environment","scope":"node"},{"name":"node-login-shell","scope":"node"}],` +
|
||||
`"resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` + c.content + `"}]}`
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s was accepted: %v", c.content, err)
|
||||
}
|
||||
}
|
||||
// And outside a file's content, where nothing could be placed.
|
||||
raw := `{"module":"holder","claims":[{"name":"node-environment","scope":"node"}],` +
|
||||
`"resources":[{"id":"rc","type":"file","path":"/etc/${environment:posix}","content":"x"}]}`
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil ||
|
||||
!strings.Contains(err.Error(), "names ${environment:posix} in its path; the environment and the shell's code are placed only in a file's content") {
|
||||
t.Errorf("a placeholder in a path was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// What ADR 0203 §2 allows a contribution to say, refused at parse when it says anything else.
|
||||
func TestAMalformedEnvironmentIsRefusedAtParse(t *testing.T) {
|
||||
for _, c := range []struct{ environment, want string }{
|
||||
{`{"variables":{"1X":"a"}}`, `tool sets the variable "1X", which is not a name a shell accepts`},
|
||||
{`{"variables":{"MY-VAR":"a"}}`, `tool sets the variable "MY-VAR", which is not a name a shell accepts`},
|
||||
{`{"variables":{"PATH":"/bin"}}`, `tool sets PATH as a variable; a module adds an entry under environment.path`},
|
||||
{`{"variables":{"A":"$HOME/x"}}`, `tool sets A to "$HOME/x", which holds a $ that is not one of the machine's ${machine:…} facts`},
|
||||
{`{"variables":{"A":"${HOME}/x"}}`, `tool sets A to "${HOME}/x", which holds a $`},
|
||||
{`{"variables":{"A":"it's"}}`, `tool sets A to "it's", which holds a quote`},
|
||||
{`{"variables":{"A":"say \"hi\""}}`, `which holds a quote`},
|
||||
{`{"variables":{"A":"a\\b"}}`, `which holds a backslash`},
|
||||
{`{"variables":{"A":"a\nb"}}`, `which holds a line break`},
|
||||
{`{"variables":{"A":"a\u0000b"}}`, `which holds a NUL`},
|
||||
{`{"path":[{"entry":"","at":"start"}]}`, `tool's PATH entry 1 names no directory`},
|
||||
{`{"path":[{"entry":"/a:/b","at":"start"}]}`, `tool puts "/a:/b" on PATH, which holds a colon`},
|
||||
{`{"path":[{"entry":"$HOME/bin","at":"start"}]}`, `tool puts "$HOME/bin" on PATH, which holds a $`},
|
||||
{`{"path":[{"entry":"/a","at":"middle"}]}`, `tool puts "/a" on PATH at "middle"; an entry goes at "start" or "end"`},
|
||||
{`{"path":[{"entry":"/a"}]}`, `tool puts "/a" on PATH at ""`},
|
||||
{`{"path":[{"entry":"/a","at":"start"},{"entry":"/a","at":"end"}]}`, `tool puts "/a" on PATH twice`},
|
||||
{`{"variables":{"A":"x"},"paths":[]}`, `unknown field "paths"`},
|
||||
} {
|
||||
_, err := ParseManifest([]byte(`{"module":"tool","environment":` + c.environment + `}`))
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: want %q, got %v", c.environment, c.want, err)
|
||||
}
|
||||
}
|
||||
// What is allowed: a literal, and the machine's own facts.
|
||||
if _, err := ParseManifest([]byte(`{"module":"tool","environment":{` +
|
||||
`"variables":{"GOPATH":"${machine:account-home}/go","DISABLE_X":"1","ANSWER":"a b+c=d"},` +
|
||||
`"path":[{"entry":"${machine:account-home}/go/bin","at":"start"},{"entry":"/opt/x","at":"end"}]}}`)); err != nil {
|
||||
t.Fatalf("a well-formed environment was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedShellCodeIsRefusedAtParse(t *testing.T) {
|
||||
for _, c := range []struct{ shell, want string }{
|
||||
{`[{"for":"tcsh","slot":"normal","code":"x"}]`, `tool's shell code 1 is for "tcsh"; the shells are zsh, bash, fish`},
|
||||
{`[{"for":"zsh","slot":"middle","code":"x"}]`, `tool's shell code 1 goes in the slot "middle"; the slots are first, normal, last`},
|
||||
{`[{"for":"zsh","slot":"last","code":"x"},{"for":"zsh","slot":"last","code":" \n"}]`, `tool's shell code 2 has no code`},
|
||||
{`[{"for":"zsh","slot":"last","code":"x","order":1}]`, `unknown field "order"`},
|
||||
} {
|
||||
_, err := ParseManifest([]byte(`{"module":"tool","shell":` + c.shell + `}`))
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: want %q, got %v", c.shell, c.want, err)
|
||||
}
|
||||
}
|
||||
// The code itself is never judged: a shell's own `${…}` is not the mesh's.
|
||||
if _, err := ParseManifest([]byte(`{"module":"tool","shell":[{"for":"zsh","slot":"first",` +
|
||||
`"code":"source \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\""}]}`)); err != nil {
|
||||
t.Fatalf("shell code was judged as if it were the mesh's: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
env, ok := SeatNamed("node-environment")
|
||||
if !ok || env.Scope != ScopeNode || env.Decision != "novox/hq ADR 0203" ||
|
||||
len(env.Serves)+len(env.Accepts)+len(env.Emits) != 0 || env.Delivers != "" {
|
||||
t.Fatalf("node-environment is not a node seat with no protocol: %+v (defined %v)", env, ok)
|
||||
}
|
||||
shell, ok := SeatNamed("node-login-shell")
|
||||
if !ok || shell.Scope != ScopeNode || shell.Decision != "novox/hq ADR 0204" {
|
||||
t.Fatalf("node-login-shell is not a node seat: %+v (defined %v)", shell, ok)
|
||||
}
|
||||
if len(shell.Serves) != 1 || shell.Serves[0].Name != "execute" || shell.Serves[0].Description == "" {
|
||||
t.Fatalf("the login shell serves %+v, not execute alone", shell.Serves)
|
||||
}
|
||||
props, _ := shell.Serves[0].Input["properties"].(map[string]any)
|
||||
required, _ := shell.Serves[0].Input["required"].([]string)
|
||||
if _, has := props["command"]; !has || len(required) != 1 || required[0] != "command" {
|
||||
t.Fatalf("execute does not require a command: %v", shell.Serves[0].Input)
|
||||
}
|
||||
if _, has := props["timeout_seconds"]; !has {
|
||||
t.Fatalf("execute takes no timeout: %v", props)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0204 §1: the login shell is the mesh's, so no module declares it — neither under the mesh's
|
||||
// name nor under the name a module gave it before.
|
||||
func TestNoModuleMayDeclareTheLoginShell(t *testing.T) {
|
||||
for _, n := range []string{"login-shell", "node-login-shell", "node-environment"} {
|
||||
raw := `{"module":"zsh","seats":[{"name":"` + n + `","scope":"node","serves":["execute"]}],"tools":["execute"]}`
|
||||
_, err := ParseManifest([]byte(raw))
|
||||
if err == nil {
|
||||
t.Errorf("a module declaring %q was accepted", n)
|
||||
}
|
||||
}
|
||||
got := strings.Join(declaredSeatProblems(Manifest{Module: "zsh",
|
||||
DefinesSeats: []SeatDeclaration{{Name: "login-shell", Scope: ScopeNode}}}), "; ")
|
||||
if !strings.Contains(got, `zsh declares a seat named "login-shell"; the login shell is the mesh's own seat node-login-shell`) {
|
||||
t.Fatalf("declaring login-shell was not refused by name: %q", got)
|
||||
}
|
||||
// And a shell module claiming the mesh's seat, serving execute, is what the seat is for.
|
||||
m, err := ParseManifest([]byte(`{"module":"zsh","tools":["execute"],` +
|
||||
`"claims":[{"name":"node-login-shell","scope":"node"}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := CanHold(m, Seat{Name: LoginShellSeat, Scope: ScopeNode, Serves: loginShellVerbs()}); err != nil {
|
||||
t.Fatalf("a shell module claiming the seat cannot hold it: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
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;
|
||||
// where it is, it names the collection for the client, which fills the same files (mesh-catalog's provisioner client
|
||||
// holds that half against the same golden files). testdata/environment holds the environment of the catalogue's five
|
||||
// contributors as the old form composed it, with all five and with zsh alone.
|
||||
|
||||
type envDecl struct{ module, item, name, value string }
|
||||
|
||||
func environmentOfTheCatalogue() []envDecl {
|
||||
return []envDecl{
|
||||
{"adwaita", "gtk-theme", "GTK_THEME", "Adwaita:dark"},
|
||||
{"adwaita", "gtk2-rc-files", "GTK2_RC_FILES", "/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc"},
|
||||
{"adwaita", "qt-qpa-platformtheme", "QT_QPA_PLATFORMTHEME", "qt6ct"},
|
||||
{"adwaita", "qt-style-override", "QT_STYLE_OVERRIDE", "Fusion"},
|
||||
{"adwaita", "qt-select", "QT_SELECT", "6"},
|
||||
{"adwaita", "xcursor-theme", "XCURSOR_THEME", "Adwaita"},
|
||||
{"adwaita", "xcursor-size", "XCURSOR_SIZE", "24"},
|
||||
{"clipmenu", "cm-selections", "CM_SELECTIONS", "clipboard"},
|
||||
{"clipmenu", "cm-max-clips", "CM_MAX_CLIPS", "500"},
|
||||
{"clipmenu", "cm-histlength", "CM_HISTLENGTH", "15"},
|
||||
{"i3", "xdg-current-desktop", "XDG_CURRENT_DESKTOP", "i3"},
|
||||
{"i3", "xdg-session-desktop", "XDG_SESSION_DESKTOP", "i3"},
|
||||
{"xterm", "terminal", "TERMINAL", "xterm"},
|
||||
{"zsh", "editor", "EDITOR", "vim"},
|
||||
{"zsh", "visual", "VISUAL", "vim"},
|
||||
{"zsh", "xdg-config-home", "XDG_CONFIG_HOME", "${machine:account-home}/.config"},
|
||||
{"zsh", "path-local-bin", "path:10", "${machine:account-home}/.local/bin"},
|
||||
{"zsh", "path-scripts", "path:20", "${machine:account-home}/scripts"},
|
||||
{"zsh", "path-scripts-bin", "path:30", "${machine:account-home}/scripts/bin"},
|
||||
}
|
||||
}
|
||||
|
||||
const environmentCollector = `{"module":"node-env","version":"1","moved":true,
|
||||
"claims":[{"name":"node-environment","scope":"node"}],
|
||||
"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 TestAnEnvironmentItemIsTypedAndNamesWhatItNeeds(t *testing.T) {
|
||||
item := func(attrs map[string]string) []string {
|
||||
return environmentItemProblems("x", Item{Collection: "environment", Attributes: attrs})
|
||||
}
|
||||
for _, ok := range []map[string]string{
|
||||
{"kind": "variable", "name": "GTK_THEME"},
|
||||
{"kind": "path", "at": "end", "order": "10"},
|
||||
} {
|
||||
if p := item(ok); len(p) != 0 {
|
||||
t.Errorf("%v is refused: %v", ok, p)
|
||||
}
|
||||
}
|
||||
for _, bad := range []map[string]string{
|
||||
nil, {"kind": "other"}, {"kind": "variable"}, {"kind": "variable", "name": "PATH"},
|
||||
{"kind": "path"}, {"kind": "path", "at": "middle"}, {"kind": "path", "at": "start", "name": "X"},
|
||||
{"kind": "variable", "name": "A", "color": "red"},
|
||||
} {
|
||||
if p := item(bad); len(p) == 0 {
|
||||
t.Errorf("%v is taken", bad)
|
||||
}
|
||||
}
|
||||
if !attributeFits("environment", "name", "GTK_THEME") || attributeFits("shell:zsh:normal", "name", "GTK_THEME") ||
|
||||
attributeFits("environment", "kind", "Variable") {
|
||||
t.Error("an upper-case name is the environment's name alone")
|
||||
}
|
||||
// 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,111 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// issue 900, novox/hq ADR 0394 §1: node-files is a node seat whose verbs say the commands they replace.
|
||||
func TestNodeFilesIsANodeSeatWithEveryVerbOfADR0394(t *testing.T) {
|
||||
s, ok := SeatNamed(FilesSeat)
|
||||
if !ok {
|
||||
t.Fatalf("the mesh defines no seat %s", FilesSeat)
|
||||
}
|
||||
if s.Scope != ScopeNode || s.Delivers != "" || !strings.Contains(s.Decision, "ADR 0394") {
|
||||
t.Errorf("%s: scope %q, delivers %q, decision %q", FilesSeat, s.Scope, s.Delivers, s.Decision)
|
||||
}
|
||||
have := map[string]bool{}
|
||||
replaced := map[string]string{}
|
||||
for _, v := range s.Serves {
|
||||
have[v.Name] = true
|
||||
if v.Optional {
|
||||
t.Errorf("%s.%s is optional", FilesSeat, v.Name)
|
||||
}
|
||||
if len(v.Replaces) == 0 {
|
||||
t.Errorf("%s.%s says nothing it replaces", FilesSeat, v.Name)
|
||||
}
|
||||
for _, c := range v.Replaces {
|
||||
replaced[c] = v.Name
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"stat", "readlink", "list", "read", "search", "account", "write", "copy", "move",
|
||||
"delete", "mkdir", "chmod", "chown", "trash", "restore"} {
|
||||
if !have[name] {
|
||||
t.Errorf("%s serves no verb %s", FilesSeat, name)
|
||||
}
|
||||
}
|
||||
for command, verb := range map[string]string{"cat": "read", "tee": "write", "cp": "copy", "mv": "move",
|
||||
"rm": "delete", "mkdir -p": "mkdir", "chmod": "chmod", "chgrp": "chown"} {
|
||||
if replaced[command] != verb {
|
||||
t.Errorf("%q is replaced by %q, want %s.%s", command, replaced[command], FilesSeat, verb)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// issue 900, ADR 0394 §2: a holder of node-files runs as the tool runner's account, so it declares no runs-as.
|
||||
func TestAHolderOfNodeFilesDeclaringRunsAsIsRefused(t *testing.T) {
|
||||
p := FilesHolderProblems(Manifest{Module: "files", RunsAs: "mesh-files", Claims: []Claim{{Name: FilesSeat}}})
|
||||
if len(p) != 1 || !strings.Contains(p[0], "0394") || !strings.Contains(p[0], "runs-as") {
|
||||
t.Errorf("a holder declaring runs-as was not refused: %v", p)
|
||||
}
|
||||
if p := FilesHolderProblems(Manifest{Module: "files", Claims: []Claim{{Name: FilesSeat}}}); len(p) != 0 {
|
||||
t.Errorf("a holder declaring none was refused: %v", p)
|
||||
}
|
||||
if p := FilesHolderProblems(Manifest{Module: "other", RunsAs: "x"}); len(p) != 0 {
|
||||
t.Errorf("a non-holder was judged: %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// 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"])
|
||||
}
|
||||
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)
|
||||
for _, v := range s.Serves {
|
||||
if v.Name != "restore" {
|
||||
continue
|
||||
}
|
||||
props, _ := v.Input["properties"].(map[string]any)
|
||||
for _, p := range []string{"id", "reason", "override"} {
|
||||
if _, has := props[p]; !has {
|
||||
t.Errorf("restore has no input %s", p)
|
||||
}
|
||||
}
|
||||
if req, _ := v.Input["required"].([]string); strings.Join(req, ",") != "id,reason" {
|
||||
t.Errorf("restore requires %v, want id and reason", req)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Error("no restore verb")
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The mesh's own ports survive a ruleset derived from modules that do not mention them.
|
||||
//
|
||||
// **The firewall is computed from what modules declare they listen on, and the foundation is not a
|
||||
// module** (novox/hq 04-ISSUES/052). So the broker's port — the one every machine dials to enrol
|
||||
// and to receive every declaration it is ever sent — was absent from every ruleset the mesh ever
|
||||
// generated, and nothing caught it: a mesh of one never dials its own broker across the network,
|
||||
// so the ruleset looks complete right up until a second machine tries to join and is refused by
|
||||
// the packet filter, during enrolment, before the mesh can report anything about it.
|
||||
func TestTheBrokersPortIsOpenedThoughNoModuleDeclaresIt(t *testing.T) {
|
||||
const brokerPort = 5671
|
||||
|
||||
// A machine on the private network, with one ordinary module rule, and nothing that mentions
|
||||
// the broker — which is every machine.
|
||||
rules := []Rule{{Port: 8080, From: FromMesh, Because: []string{"some-module"}}}
|
||||
out := AsNftables(rules, []string{"10.42.0.1"}, false, []int{brokerPort}, nil, "mesh0")
|
||||
|
||||
if !strings.Contains(out, "tcp dport 5671 accept") {
|
||||
t.Fatalf("the broker's port is not opened, so no machine could enrol:\n%s", out)
|
||||
}
|
||||
|
||||
// From anywhere, deliberately: a node enrols BEFORE it has an address on the private network,
|
||||
// so a rule narrowed to that network would close the door being knocked on.
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.Contains(line, "5671") && strings.Contains(line, "saddr") {
|
||||
t.Fatalf("the broker's port is narrowed to the private network, which a machine that "+
|
||||
"has not yet enrolled is not on:\n%s", line)
|
||||
}
|
||||
}
|
||||
|
||||
// And forwarded, not only accepted on input: the broker is a published container port, so a
|
||||
// cross-node dial is redirected and forwarded and never reaches the input chain. Without this
|
||||
// a joined node reaches the broker only until its first connection's conntrack entry drops —
|
||||
// which a broker restart (adopting the foundation's broker) causes — and then never receives
|
||||
// another declaration (novox/hq issue 063).
|
||||
if !strings.Contains(out, "ct original proto-dst 5671 accept") {
|
||||
t.Fatalf("the broker's port is not forwarded, so a DNAT'd broker is unreachable "+
|
||||
"cross-node after it restarts:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// And a mesh that was never told about a broker still gets a ruleset, rather than an empty one or
|
||||
// a panic. A control plane in that state cannot issue tokens either, which is where it surfaces.
|
||||
func TestNoBrokerMeansNoFoundationRuleRatherThanNoRuleset(t *testing.T) {
|
||||
out := AsNftables(nil, []string{"10.42.0.1"}, false, nil, nil, "mesh0")
|
||||
if !strings.Contains(out, "table inet mesh") {
|
||||
t.Fatalf("no ruleset at all:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "never derived, never closed\n\t\ttcp dport") {
|
||||
t.Fatalf("a foundation rule was written for a mesh with no broker:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -1,920 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A manifest key nothing reads is worse than a key that is refused: five HAL manifests carried
|
||||
// `scope:`, which read as a restriction and restricted nothing (novox/hq 04-ISSUES/003). The host's
|
||||
// declaration parser has always refused unknown keys; manifests never did.
|
||||
func TestAManifestKeyNothingReadsIsRefused(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"web","scope":"mesh"}`))
|
||||
if err == nil {
|
||||
t.Fatal("a manifest with a key nothing reads was accepted, which is how scope: survived")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "scope") {
|
||||
t.Fatalf("refused without naming the key, which leaves the author guessing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The same discipline must not refuse a manifest that is merely ordinary.
|
||||
func TestAnOrdinaryManifestIsStillAccepted(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(
|
||||
`{"module":"web","listens":[{"port":443,"from":"anywhere","why":"the site"}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("an ordinary manifest was refused: %v", err)
|
||||
}
|
||||
if len(m.Listens) != 1 || m.Listens[0].At() != "tcp" {
|
||||
t.Fatalf("listens did not survive parsing: %+v", m.Listens)
|
||||
}
|
||||
}
|
||||
|
||||
// A rule with no source is open, and must say so rather than appear to restrict something.
|
||||
func TestAPortWithNoSourceIsRefused(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"web","listens":[{"port":443}]}`))
|
||||
if err == nil {
|
||||
t.Fatal("a port that never said who may reach it was accepted; it reads as a restriction")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "from where") {
|
||||
t.Fatalf("refused without saying what is missing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASourceNobodyDefinedIsRefused(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"web","listens":[{"port":443,"from":"lan"}]}`))
|
||||
if err == nil {
|
||||
t.Fatal("a source the mesh cannot render was accepted, and would be silently dropped")
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Every rule names what caused it, and both causes survive.
|
||||
func TestTwoModulesWantingOnePortAreBothNamed(t *testing.T) {
|
||||
r := Resolution{Modules: []Manifest{
|
||||
{Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere, Why: "the site"}}},
|
||||
{Module: "board", Listens: []Listening{{Port: 443, From: FromEverywhere, Why: "the board"}}},
|
||||
}}
|
||||
rules := mustFilter(t, r, nil)
|
||||
if len(rules) != 1 {
|
||||
t.Fatalf("one port became %d rules", len(rules))
|
||||
}
|
||||
if len(rules[0].Because) != 2 {
|
||||
t.Fatalf("a module that wanted this port open is not named: %+v", rules[0])
|
||||
}
|
||||
// The consequence, which is the reason this matters: removing web must not read as closing 443.
|
||||
nft := AsNftables(rules, nil, false, nil, nil, "mesh0")
|
||||
if !strings.Contains(nft, "web") || !strings.Contains(nft, "board") {
|
||||
t.Fatalf("the rendered rule set does not name both sources:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// 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])
|
||||
}
|
||||
}
|
||||
|
||||
// What is not declared is closed.
|
||||
func TestWhatNoModuleDeclaredIsClosed(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}},
|
||||
}}, nil), []string{"198.51.100.2"}, false, nil, nil, "mesh0")
|
||||
// Naming the chain, not just the policy: the forward chain drops too, and an assertion on
|
||||
// "policy drop" alone passes while the input chain accepts everything. It did, once, here.
|
||||
if !strings.Contains(nft, "type filter hook input priority filter; policy drop;") {
|
||||
t.Fatalf("the input chain does not drop by default, so nothing is filtered:\n%s", nft)
|
||||
}
|
||||
// Some port nothing asked for. **Not 22**, which this used to use: ssh is now the one line in
|
||||
// the chain that nothing derives, because a mesh that has been assigned almost nothing computes
|
||||
// a firewall that shuts the port used to fix it (novox/hq issue 047).
|
||||
if strings.Contains(nft, "dport 9999") {
|
||||
t.Fatalf("a port no module declared was opened:\n%s", nft)
|
||||
}
|
||||
// Dropping by default must not mean unreachable: the node's own outbound link to the broker
|
||||
// is the one connection the whole mesh runs on, and it is a reply to something it started.
|
||||
if !strings.Contains(nft, "ct state established,related accept") {
|
||||
t.Fatalf("a node applying this would lose its own link to the mesh:\n%s", nft)
|
||||
}
|
||||
if !strings.Contains(nft, "iif lo accept") {
|
||||
t.Fatalf("loopback is filtered, which breaks everything on the machine:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// Loading it twice must be the same as loading it once, and must not disturb anything else.
|
||||
//
|
||||
// `flush ruleset` would do the first and not the second: it empties every table on the machine,
|
||||
// including the ones the container runtime writes for its bridges.
|
||||
func TestReloadingReplacesOnlyTheMeshsOwnRules(t *testing.T) {
|
||||
nft := AsNftables(nil, nil, false, nil, nil, "mesh0")
|
||||
if strings.Contains(nft, "flush ruleset") {
|
||||
t.Fatalf("loading the rule set empties every table on the machine:\n%s", nft)
|
||||
}
|
||||
if !strings.Contains(nft, "delete table inet mesh") {
|
||||
t.Fatalf("loading it a second time would add to what is there rather than replace it:\n%s", nft)
|
||||
}
|
||||
// The delete must not fail on a machine that has never loaded one, or the service does not
|
||||
// start and the machine filters nothing while reporting a configuration error.
|
||||
if !strings.Contains(nft, "table inet mesh {}\ndelete table inet mesh") {
|
||||
t.Fatalf("the delete has nothing to delete on a machine loading this for the first time:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// The rule set governs what is forwarded too, or it does not govern container ports at all.
|
||||
//
|
||||
// **This reverses an earlier decision, and keeps the thing that decision was protecting.** There was
|
||||
// no forward chain, on the reasoning that dropping there stops container traffic the runtime
|
||||
// allowed — every container on the node, control plane included. That much is true. What it missed
|
||||
// is that a published port is redirected and then forwarded, so it never reaches the input chain,
|
||||
// and a firewall without a forward chain is silent about exactly the ports worth protecting
|
||||
// (novox/hq issue 047).
|
||||
//
|
||||
// So the chain exists and denies by default, and the runtime's own networks are allowed explicitly
|
||||
// — which is how the system being replaced has been doing it on these machines for months.
|
||||
func TestWhatIsForwardedIsGovernedToo(t *testing.T) {
|
||||
nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil, nil, "mesh0")
|
||||
if !strings.Contains(nft, "hook forward priority filter; policy drop") {
|
||||
t.Fatalf("forwarded traffic is not governed, so container ports are open:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// And this machine's own guests keep working, which is the whole reason the chain was left out
|
||||
// before — by not being mentioned (novox/hq ADR 0140).
|
||||
//
|
||||
// It used to be done by naming the address ranges they sit on: two fixed here and the rest recorded
|
||||
// per machine. That list broke a workstation's containers at a flip and could not be made correct,
|
||||
// because a range describes one machine and cannot tell a network the mesh made from one a
|
||||
// predecessor left behind. What replaced it is a single line about the links traffic arrives on.
|
||||
func TestThisMachinesOwnGuestsKeepWorkingWithoutBeingNamed(t *testing.T) {
|
||||
nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil, []string{"eth0"}, "mesh0")
|
||||
if !strings.Contains(nft, `iifname != { "eth0", "mesh0" } accept`) {
|
||||
t.Fatalf("what did not arrive from outside is not accepted, so this machine's own guests "+
|
||||
"reach nothing:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// No address of a machine's own networks appears anywhere in a rendered filter.
|
||||
//
|
||||
// This is the assertion that fails against the previous behaviour, and it is why it is written on
|
||||
// the text rather than on an outcome: the two ranges were a constant in this file, so nothing but
|
||||
// reading the output catches one creeping back in.
|
||||
func TestNoNetworkOfTheMachinesOwnIsNamed(t *testing.T) {
|
||||
nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil, []string{"eth0"}, "mesh0")
|
||||
for _, gone := range []string{"172.16.0.0/12", "192.168.128.0/17", "saddr 192.168", "saddr 172."} {
|
||||
if strings.Contains(nft, gone) {
|
||||
t.Fatalf("%q is named, and a range describes one machine and goes stale in silence:\n%s",
|
||||
gone, nft)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **The tunnel is constrained, not treated as inside.**
|
||||
//
|
||||
// Accepting everything arriving over the private network would make a port nothing declares
|
||||
// reachable from every machine in the mesh — the derivation abandoned, and a rule that reads as a
|
||||
// restriction while restricting nothing. So the tunnel is named beside the outward links, and
|
||||
// traffic arriving on it meets the declared rules like anything else.
|
||||
func TestTheTunnelIsConstrainedLikeAnOutwardLink(t *testing.T) {
|
||||
nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil, []string{"eth0"}, "mesh0")
|
||||
line := `iifname != { "eth0", "mesh0" } accept`
|
||||
if !strings.Contains(nft, line) {
|
||||
t.Fatalf("the tunnel is not constrained, so an undeclared port is reachable from any "+
|
||||
"machine in the mesh:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// A machine with two links facing outside has both constrained. Asserted on the one line, because a
|
||||
// rule covering one and not the other would leave a machine filtering half of what reaches it.
|
||||
func TestEveryOutwardLinkIsConstrained(t *testing.T) {
|
||||
nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil, []string{"eth0", "wlan0"}, "mesh0")
|
||||
if !strings.Contains(nft, `iifname != { "eth0", "wlan0", "mesh0" } accept`) {
|
||||
t.Fatalf("not every outward link is constrained:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// A guest asks its host for an address and for names, and those two arrive at the input chain. Asked
|
||||
// for by the link they arrive on, so a resolver bound anywhere but an outward link keeps answering.
|
||||
func TestGuestsMayAskTheirHostForAnAddressAndNames(t *testing.T) {
|
||||
nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil, []string{"eth0"}, "mesh0")
|
||||
for _, want := range []string{
|
||||
`iifname != { "eth0", "mesh0" } udp dport { 53, 67 } accept`,
|
||||
`iifname != { "eth0", "mesh0" } tcp dport 53 accept`,
|
||||
} {
|
||||
if !strings.Contains(nft, want) {
|
||||
t.Fatalf("a guest cannot ask its host for an address or a name, which is not a closed "+
|
||||
"port but a network that does not work:\n%s", nft)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// With no link named at all the chain denies rather than rendering an empty set, which nftables
|
||||
// refuses — and a rule set that does not load is a machine filtering nothing while its unit reports
|
||||
// success. Composing a declaration for such a machine is refused upstream; this is the floor.
|
||||
func TestNoLinkNamedRendersNoCatchAllRatherThanAnEmptySet(t *testing.T) {
|
||||
nft := AsNftables(nil, []string{"198.51.100.2"}, false, nil, nil, "")
|
||||
if strings.Contains(nft, "{ }") || strings.Contains(nft, "iifname != {}") {
|
||||
t.Fatalf("an empty set is rendered, which nftables refuses:\n%s", nft)
|
||||
}
|
||||
if !strings.Contains(nft, "hook forward priority filter; policy drop") {
|
||||
t.Fatalf("the forward chain does not deny:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// A published port is matched by what the client asked for, not by where the packet ends up.
|
||||
//
|
||||
// The runtime rewrites the destination before this chain sees it, so a rule naming the published
|
||||
// port would match nothing and the port would stay shut while reporting itself open.
|
||||
func TestAPublishedPortIsMatchedByWhatWasAskedFor(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "web", Listens: []Listening{{Port: 8080, From: FromEverywhere}}},
|
||||
}}, nil), []string{"198.51.100.2"}, false, nil, nil, "mesh0")
|
||||
if !strings.Contains(nft, "ct original proto-dst 8080 accept") {
|
||||
t.Fatalf("the forwarded rule does not match the port a client asked for:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// And a mesh-scoped port stays mesh-scoped when it is reached through the runtime.
|
||||
func TestAMeshScopedPortIsMeshScopedWhenForwarded(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}},
|
||||
}}, nil), []string{"198.51.100.2"}, false, nil, nil, "mesh0")
|
||||
if !strings.Contains(nft, "ip saddr { 198.51.100.2 } ct original proto-dst 5432 accept") {
|
||||
t.Fatalf("a mesh-only port is reachable from anywhere once forwarded:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// "From the mesh" is the addresses the mesh actually has.
|
||||
func TestFromTheMeshIsTheNodesTheMeshKnows(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}},
|
||||
}}, nil), []string{"198.51.100.2", "198.51.100.3"}, false, nil, nil, "mesh0")
|
||||
if !strings.Contains(nft, "ip saddr { 198.51.100.2, 198.51.100.3 } tcp dport 5432 accept") {
|
||||
t.Fatalf("a mesh-scoped port was not restricted to the mesh's addresses:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// The case that must not be widened silently.
|
||||
func TestAMeshPortOnANodeWithNoMeshIsClosedAndSaysSo(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}},
|
||||
}}, nil), nil, false, nil, nil, "mesh0")
|
||||
if strings.Contains(nft, "dport 5432 accept") {
|
||||
t.Fatalf("a port meant for the mesh was opened to everything:\n%s", nft)
|
||||
}
|
||||
if !strings.Contains(nft, "closed") {
|
||||
t.Fatalf("it was closed silently, which is a fault nobody can find:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// A port bound for something else on the same machine must not reach the network.
|
||||
func TestAMachineScopedPortIsNotOpened(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "cache", Listens: []Listening{{Port: 6379, From: FromMachine}}},
|
||||
}}, nil), []string{"198.51.100.2"}, false, nil, nil, "mesh0")
|
||||
if strings.Contains(nft, "dport 6379 accept") {
|
||||
t.Fatalf("a port for this machine only was opened to the network:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAskingForTheRuleSetWithNowhereToPutItIsRefused(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"firewall","filtering":{"into":""}}`))
|
||||
if err == nil {
|
||||
t.Fatal("a module asked for the rule set and named no path; it would receive nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// nftables matches the two address families separately, and one set holding both is a syntax
|
||||
// error — so the file fails to load, the service reports a fault, and the machine filters nothing.
|
||||
func TestAMeshOnBothAddressFamiliesRendersBoth(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}},
|
||||
}}, nil), []string{"198.51.100.2", "2001:db8::2"}, false, nil, nil, "mesh0")
|
||||
if !strings.Contains(nft, "ip saddr { 198.51.100.2 } tcp dport 5432 accept") {
|
||||
t.Fatalf("the machines with v4 addresses were dropped:\n%s", nft)
|
||||
}
|
||||
if !strings.Contains(nft, "ip6 saddr { 2001:db8::2 } tcp dport 5432 accept") {
|
||||
t.Fatalf("the machines with v6 addresses were dropped:\n%s", nft)
|
||||
}
|
||||
// And never in one set, which is the syntax error this exists to avoid.
|
||||
if strings.Contains(nft, "198.51.100.2, 2001:db8::2") {
|
||||
t.Fatalf("both families are in one set, so the file will not load:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 }
|
||||
|
||||
func (opensOnHub) Resources(string) ([]map[string]any, bool, error) { return nil, true, nil }
|
||||
|
||||
func (o opensOnHub) Listens(node string) ([]Listening, error) {
|
||||
if node != o.hub {
|
||||
return nil, nil
|
||||
}
|
||||
return []Listening{{Port: 51820, Protocol: "udp", From: FromEverywhere,
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// ssh survives whatever the mesh does or does not know about this machine.
|
||||
//
|
||||
// **The one rule here that nothing derives.** Every other line comes from what is assigned, which
|
||||
// is the point of the mechanism — and a mesh part-way through adopting a machine has been assigned
|
||||
// almost nothing, so what it computes is a chain that drops the port used to fix it. The session
|
||||
// loading the rules lives on conntrack until it drops, and then the machine is reached from a
|
||||
// rescue console (novox/hq issue 047).
|
||||
func TestSSHIsOpenFromTheMeshEvenWhenNothingIsAssigned(t *testing.T) {
|
||||
nft := AsNftables(nil, []string{"198.51.100.2", "198.51.100.3"}, false, nil, nil, "mesh0")
|
||||
if !strings.Contains(nft, "ip saddr { 198.51.100.2, 198.51.100.3 } tcp dport 22 accept") {
|
||||
t.Fatalf("ssh is not open to the mesh, so a machine can lock everyone out:\n%s", nft)
|
||||
}
|
||||
// And not to the world, on a machine that does not face it.
|
||||
if strings.Contains(nft, "\t\ttcp dport 22 accept") {
|
||||
t.Fatalf("ssh is open to everywhere on a machine that faces inward only:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// And from outside as well, on a machine that faces outward — because that is the way in when the
|
||||
// private network is the thing that broke.
|
||||
func TestSSHIsOpenFromOutsideOnAMachineThatFacesIt(t *testing.T) {
|
||||
nft := AsNftables(nil, []string{"198.51.100.2"}, true, nil, nil, "mesh0")
|
||||
if !strings.Contains(nft, "\t\ttcp dport 22 accept") {
|
||||
t.Fatalf("a machine reachable from outside does not answer ssh there:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// A machine the mesh knows no addresses for still answers ssh.
|
||||
//
|
||||
// **This is the machine it matters most for.** Before the private network exists there is nothing
|
||||
// to narrow the rule to, so narrowing it shuts the port entirely — on the first machine anybody
|
||||
// adopts, reached over the network, closed by the act of adopting it.
|
||||
func TestSSHIsNeverLeftWithoutARule(t *testing.T) {
|
||||
nft := AsNftables(nil, nil, false, nil, nil, "mesh0")
|
||||
if !strings.Contains(nft, "tcp dport 22 accept") {
|
||||
t.Fatalf("a machine with no mesh addresses has no ssh rule, so adopting it locks it:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// chainBody is one chain's own lines, so an assertion cannot be satisfied by an identical line in
|
||||
// another chain.
|
||||
//
|
||||
// **Written because that happened.** The rule letting this machine's own callers through appears in the
|
||||
// input chain and, in the same words, in the forward chain. A test asserting on the whole rendered file
|
||||
// passed with the input chain's copy deleted — it was reading the forward chain's. ADR 0137's own tests
|
||||
// say to assert per chain body for exactly this reason, and this file was not doing it.
|
||||
func chainBody(t *testing.T, nft, chain string) string {
|
||||
t.Helper()
|
||||
open := "\tchain " + chain + " {"
|
||||
i := strings.Index(nft, open)
|
||||
if i < 0 {
|
||||
t.Fatalf("no chain %q in:\n%s", chain, nft)
|
||||
}
|
||||
rest := nft[i+len(open):]
|
||||
j := strings.Index(rest, "\n\t}")
|
||||
if j < 0 {
|
||||
t.Fatalf("chain %q does not close in:\n%s", chain, nft)
|
||||
}
|
||||
return rest[:j]
|
||||
}
|
||||
|
||||
// **Anything on this machine may call anything on this machine.**
|
||||
//
|
||||
// Local is not a boundary this mesh draws, and whether a caller sits in a container was never meant to
|
||||
// change the answer. It did, because the chain asked about addresses: a caller on the machine carries
|
||||
// the machine's address and a caller in one of its containers carries a bridge address, so a rule
|
||||
// naming the machines' own addresses silently refused every container on them.
|
||||
//
|
||||
// Measured: a module reaching its database on its own machine's name timed out for eleven hours while
|
||||
// the machine itself could reach it (novox/hq 04-ISSUES/145).
|
||||
func TestAnythingOnThisMachineMayCallAnythingOnIt(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "store", Listens: []Listening{{Port: 5432, From: FromMesh}}},
|
||||
{Module: "private", Listens: []Listening{{Port: 9999, From: FromMachine}}},
|
||||
}}, nil), []string{"10.10.0.1", "10.10.0.2"}, false, nil, []string{"eth0"}, "mesh0")
|
||||
|
||||
// In the INPUT chain, which is where a call to a service on this machine arrives. The forward
|
||||
// chain carries the same line in the same words, so asserting on the whole file proves nothing.
|
||||
input := chainBody(t, nft, "input")
|
||||
if !strings.Contains(input, `iifname != { "eth0", "mesh0" } accept`) {
|
||||
t.Fatalf("a caller on this machine cannot reach a service on it:\n%s", input)
|
||||
}
|
||||
// One rule, for every service here — not a line per port that only covers the ports somebody
|
||||
// remembered to think about.
|
||||
if strings.Contains(input, `iifname != { "eth0", "mesh0" } tcp dport 5432`) {
|
||||
t.Fatalf("the local allowance is still written per port:\n%s", input)
|
||||
}
|
||||
// And the private network still reaches what is exposed to it, which is a different question.
|
||||
if !strings.Contains(input, "ip saddr { 10.10.0.1, 10.10.0.2 } tcp dport 5432 accept") {
|
||||
t.Fatalf("the private network no longer reaches a service exposed to it:\n%s", input)
|
||||
}
|
||||
}
|
||||
|
||||
// The three reaches, as three lines. This is the whole of what the filter says about who may call what.
|
||||
func TestTheThreeReachesAreThreeLines(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "internal-only", Listens: []Listening{{Port: 5432, From: FromMesh}}},
|
||||
{Module: "public", Listens: []Listening{{Port: 443, From: FromEverywhere}}},
|
||||
}}, nil), []string{"10.10.0.1"}, false, nil, []string{"eth0"}, "mesh0")
|
||||
|
||||
input := chainBody(t, nft, "input")
|
||||
for what, want := range map[string]string{
|
||||
"on this machine": `iifname != { "eth0", "mesh0" } accept`,
|
||||
"over the private network": "ip saddr { 10.10.0.1 } tcp dport 5432 accept",
|
||||
"from anywhere": "tcp dport 443 accept",
|
||||
} {
|
||||
if !strings.Contains(input, want) {
|
||||
t.Fatalf("a caller %s cannot reach what is exposed to it (%q):\n%s", what, want, input)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A port open to everything needs no such line — it is already open to a guest.
|
||||
func TestAPublicPortNeedsNoGuestLine(t *testing.T) {
|
||||
nft := AsNftables(mustFilter(t, Resolution{Modules: []Manifest{
|
||||
{Module: "web", Listens: []Listening{{Port: 443, From: FromEverywhere}}},
|
||||
}}, nil), []string{"10.10.0.1"}, false, nil, []string{"eth0"}, "mesh0")
|
||||
if strings.Count(nft, `iifname != { "eth0", "mesh0" } tcp dport 443`) != 0 {
|
||||
t.Fatalf("a public port was given a guest line it does not need:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
// **The hub relays the mesh** (novox/hq ADR 0007, issue 196). Two machines that are not co-located
|
||||
// reach each other through the hub, so the hub forwards a packet that arrives on the tunnel and
|
||||
// leaves on it. The forward chain judged that packet by the hub's own published ports, and two
|
||||
// machines behind the hub reached each other only on the ports the hub happened to publish.
|
||||
//
|
||||
// Measured: from one home machine to another through the hub, 17 of 55 ports answered, and they
|
||||
// were exactly the hub's own; the SYN for the rest never left the hub.
|
||||
func TestTheMeshPassingThroughIsRelayedNotJudgedAsThisMachines(t *testing.T) {
|
||||
nft := AsNftables(nil, []string{"10.42.0.1", "10.42.0.2"}, false, nil, []string{"eth0"}, "mesh0")
|
||||
relay := `iifname "mesh0" oifname "mesh0" accept`
|
||||
if !strings.Contains(chainBody(t, nft, "forward"), relay) {
|
||||
t.Errorf("the forward chain does not relay the mesh through this machine:\n%s", chainBody(t, nft, "forward"))
|
||||
}
|
||||
// Relaying is not receiving: nothing in the input chain opens because of it.
|
||||
if strings.Contains(chainBody(t, nft, "input"), "oifname") {
|
||||
t.Errorf("the input chain names an outgoing interface, which no packet for this machine has:\n%s",
|
||||
chainBody(t, nft, "input"))
|
||||
}
|
||||
// And off the tunnel into this machine's own containers is still judged: the tunnel is not
|
||||
// accepted wholesale, only in and out on it.
|
||||
if strings.Contains(chainBody(t, nft, "forward"), `iifname "mesh0" accept`) {
|
||||
t.Errorf("the forward chain accepts everything off the tunnel:\n%s", chainBody(t, nft, "forward"))
|
||||
}
|
||||
|
||||
// A machine with no tunnel relays nothing, and names no interface it does not have.
|
||||
alone := AsNftables(nil, nil, false, nil, []string{"eth0"}, "")
|
||||
if strings.Contains(alone, "oifname") {
|
||||
t.Errorf("a machine with no tunnel was given a relay rule:\n%s", alone)
|
||||
}
|
||||
}
|
||||
@@ -1,368 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/beside"
|
||||
)
|
||||
|
||||
// The catalogue's foundation modules as they are — in a merge check the catalogue cloned beside it,
|
||||
// elsewhere the copy captured in testdata/beside (internal/beside, novox/hq issue 432) — parsed by the
|
||||
// real parser (novox/hq ADR 0100):
|
||||
// the store and the broker say which of their ports the mesh guards on an adopted node, and the
|
||||
// filter module loads its table through a unit of its own whose stop deletes only that table.
|
||||
// moduleTree is where a module is read from: this repository's own modules/<module> when it lives whole here
|
||||
// (novox/hq ADR 0420 §1, issue 1019: build-agent and route-proxy), with this repository as the tree root;
|
||||
// otherwise the catalogue beside, with the catalogue as the root.
|
||||
func moduleTree(t *testing.T, module string) (root, dir string) {
|
||||
t.Helper()
|
||||
own := filepath.Join("..", "..", "modules", module)
|
||||
if _, err := os.Stat(filepath.Join(own, "module.json")); err == nil {
|
||||
return filepath.Join("..", ".."), own
|
||||
}
|
||||
return beside.Dir(t, "mesh-catalog"), filepath.Join(beside.Catalogue(t), module)
|
||||
}
|
||||
|
||||
func catalogueManifest(t *testing.T, module string) Manifest {
|
||||
t.Helper()
|
||||
root, dir := moduleTree(t, module)
|
||||
m, err := ReadTreeManifest(root, dir)
|
||||
if err != nil {
|
||||
t.Fatalf("%s does not parse:\n%v", module, err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestTheStoreAndTheBrokerSayWhatTheMeshGuards(t *testing.T) {
|
||||
if got := catalogueManifest(t, "postgres").Guards; !reflect.DeepEqual(got, []int{5432}) {
|
||||
t.Errorf("postgres guards %v; the store's port must be refused from outside", got)
|
||||
}
|
||||
// The bus's monitoring port, not its client port: a node reaches the bus, nobody outside
|
||||
// reads its state (novox/hq ADR 0131 — the broker that guarded 15672 has left the catalogue).
|
||||
if got := catalogueManifest(t, "nats").Guards; !reflect.DeepEqual(got, []int{8222}) {
|
||||
t.Errorf("nats guards %v; the monitoring port must be refused from outside", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheFilterModuleNeverFlushesTheRuleset(t *testing.T) {
|
||||
m := catalogueManifest(t, "nftables")
|
||||
var unit, stock, load map[string]any
|
||||
for _, r := range m.Resources {
|
||||
switch r["id"] {
|
||||
case "unit":
|
||||
unit = r
|
||||
case "stock-unit-stop":
|
||||
stock = r
|
||||
case "load":
|
||||
load = r
|
||||
}
|
||||
}
|
||||
if load == nil || load["unit"] != "mesh-filter.service" {
|
||||
t.Fatalf("the filter is not loaded by its own unit: %v", load)
|
||||
}
|
||||
content, _ := unit["content"].(string)
|
||||
if unit == nil || unit["path"] != "/etc/systemd/system/mesh-filter.service" {
|
||||
t.Fatalf("the filter's unit is not written: %v", unit)
|
||||
}
|
||||
if strings.Contains(content, "flush") {
|
||||
t.Fatalf("stopping the filter flushes the whole ruleset — the runtime's and the found "+
|
||||
"firewall's with it:\n%s", content)
|
||||
}
|
||||
if !strings.Contains(content, "ExecStop=nft delete table inet mesh\n") ||
|
||||
!strings.Contains(content, "ExecStart=nft -f "+m.Filtering.Into+"\n") {
|
||||
t.Fatalf("the unit does not load the computed rule set and delete only its own table:\n%s",
|
||||
content)
|
||||
}
|
||||
// A node converged before the filter had its own unit still has the stock nftables.service
|
||||
// enabled, whose stop flushes the whole ruleset: a drop-in makes it delete only the mesh's
|
||||
// table, and the load is restarted on it so the host reloads units and the drop-in is read.
|
||||
if stock == nil || stock["path"] != "/etc/systemd/system/nftables.service.d/mesh.conf" ||
|
||||
!strings.HasSuffix(fmt.Sprint(stock["content"]),
|
||||
"[Service]\nExecStop=\nExecStop=nft delete table inet mesh\n") {
|
||||
t.Fatalf("the stock unit's stop is not replaced with deleting the mesh's table: %v", stock)
|
||||
}
|
||||
// A changed rule set is RELOADED — ExecReload replaces the table in one `nft -f`, so the node
|
||||
// is never unfiltered — and only the units themselves restart it, which is the one change a
|
||||
// reload cannot carry.
|
||||
if !reflect.DeepEqual(load["reload-on"], []any{"filtering"}) {
|
||||
t.Fatalf("the filter is restarted rather than reloaded when its rules change, leaving the "+
|
||||
"node unfiltered in between: %v", load)
|
||||
}
|
||||
if !reflect.DeepEqual(load["restart-on"], []any{"unit", "stock-unit-stop"}) {
|
||||
t.Fatalf("the filter is not restarted when its unit or the stock unit's drop-in changes: %v",
|
||||
load["restart-on"])
|
||||
}
|
||||
}
|
||||
|
||||
// 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 builder requires the registry the npm seat delivers, and carries no binding of its own.**
|
||||
//
|
||||
// It used to carry a hand-written binding because nothing provided a package registry to resolve
|
||||
// one from at genesis. The catalogue now requires it like any consumer, and ADR 0110 makes the
|
||||
// seat's holder the answer when more than one module provides it — so a carried copy would be a
|
||||
// second answer to the same question, free to drift from the first. Asserted gone, not merely
|
||||
// unused.
|
||||
//
|
||||
// The builder is the build-agent on every machine since novox/hq ADR 0190; this read the retired
|
||||
// `builder` and, finding no manifest, skipped unseen from then until novox/hq issue 432.
|
||||
func TestTheBuilderRequiresTheRegistryTheNpmSeatDelivers(t *testing.T) {
|
||||
builder := catalogueManifest(t, "build-agent")
|
||||
seat, _ := SeatNamed("npm-package-registry")
|
||||
var requires bool
|
||||
for _, r := range builder.Requires {
|
||||
requires = requires || r == seat.Delivers
|
||||
}
|
||||
if !requires {
|
||||
t.Fatalf("the builder does not require %q: %v", seat.Delivers, builder.Requires)
|
||||
}
|
||||
if builder.Binds[seat.Delivers] == "" {
|
||||
t.Errorf("the builder is not told where the registry is: binds %v", builder.Binds)
|
||||
}
|
||||
for _, r := range builder.Resources {
|
||||
if fmt.Sprint(r["id"]) == "package-binding" {
|
||||
t.Fatal("the builder carries its own package binding beside the one the mesh resolves")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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,29 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq issue 223: genesis raises the controller as a container built from this repository's own
|
||||
// Dockerfile, with no build arguments — the manifest no longer builds an image, so nothing passes a
|
||||
// base in. The Dockerfile's own default must therefore be a Go that builds this module, pinned by
|
||||
// digest, and the replacement the manifest's process names must be the container genesis raises.
|
||||
func TestGenesisCanBuildTheControllersImageAsItStands(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../Dockerfile")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !regexp.MustCompile(`(?m)^ARG GO_BASE=golang@sha256:[0-9a-f]{64}$`).Match(raw) {
|
||||
t.Fatal("the Dockerfile's default Go base is not pinned by digest; genesis builds it with no arguments")
|
||||
}
|
||||
makefile, err := os.ReadFile("../../Makefile")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pin := regexp.MustCompile(`golang@sha256:[0-9a-f]{64}`)
|
||||
if string(pin.Find(raw)) != string(pin.Find(makefile)) {
|
||||
t.Errorf("the Dockerfile and the Makefile build on different Go: %s, %s", pin.Find(raw), pin.Find(makefile))
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Genesis assigns the build agent before the package registry exists (novox/hq ADR 0336, issue 121),
|
||||
// judged over the real catalogue. Genesis's step 13 was refused with "nothing provides
|
||||
// \"npm-package-registry\", wanted by build-agent": the forge genesis raises at its packages step is a
|
||||
// container, not the gitea module, and provides nothing.
|
||||
|
||||
// agentBuilt is the build agent as the mesh holds it once built: its image pinned.
|
||||
func agentBuilt(t *testing.T) Manifest {
|
||||
t.Helper()
|
||||
return catalogueResolved(t, "build-agent", Built{Name: "server", Kind: ArtifactImage,
|
||||
Reference: "mesh-builder@sha256:" + strings.Repeat("a", 64)})
|
||||
}
|
||||
|
||||
func TestGenesisAssignsTheBuildAgentWithNothingProvidingTheRegistry(t *testing.T) {
|
||||
agent := agentBuilt(t)
|
||||
if !agent.IsOptional("npm-package-registry") {
|
||||
t.Fatalf("the build agent's npm-package-registry is hard (ADR 0336 §1): %v", agent.Requires)
|
||||
}
|
||||
if agent.IsOptional("artifact-store") {
|
||||
t.Fatal("the build agent's artifact-store is optional; it stays hard")
|
||||
}
|
||||
store := map[string][]Provider{"artifact-store": {
|
||||
{Node: "anchor", At: "anchor.internal", Module: "distribution", Serves: map[string]any{"port": float64(5000)}}}}
|
||||
books := shelf(agent, catalogueManifest(t, "gitea"), catalogueManifest(t, "distribution"))
|
||||
|
||||
// Genesis's node at step 13: the store answers, nothing provides the registry.
|
||||
got, err := Resolve(books, []string{"build-agent"}, reachable(), World{Offered: store})
|
||||
if err != nil {
|
||||
t.Fatalf("assigning the build agent with no registry was refused: %v", err)
|
||||
}
|
||||
if have := strings.Join(names(got), " "); have != "build-agent" {
|
||||
t.Fatalf("assigning the build agent put %s on the machine; nothing is installed for the registry", have)
|
||||
}
|
||||
if len(got.Absent) != 1 || got.Absent[0] != (Absent{Consumer: "build-agent", Requirement: "npm-package-registry"}) {
|
||||
t.Fatalf("the registry is not listed unbound for the build agent: %+v", got.Absent)
|
||||
}
|
||||
out, err := got.Declaration(Rendering{ArtifactStore: "anchor.internal:5000",
|
||||
Needed: map[string]map[string]string{"build-agent": {"broker": "sealed-broker"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("the build agent does not compose with the registry unbound: %v", err)
|
||||
}
|
||||
for _, id := range []string{"build-agent." + BoundID("npm-package-registry"), "build-agent." + SecretID("npm-package-registry"),
|
||||
"build-agent." + NeedID("genesis-package-registry")} {
|
||||
if fileNamed(out, id) != nil {
|
||||
t.Errorf("%s was delivered with the registry unbound and no genesis document given", id)
|
||||
}
|
||||
}
|
||||
|
||||
// The genesis document, once the installer seals it at the packages step (ADR 0336 §3).
|
||||
out, err = got.Declaration(Rendering{ArtifactStore: "anchor.internal:5000",
|
||||
Needed: map[string]map[string]string{"build-agent": {"broker": "sealed-broker", "genesis-package-registry": "sealed-genesis"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doc := fileNamed(out, "build-agent."+NeedID("genesis-package-registry"))
|
||||
if doc == nil || doc["sealed"] != "sealed-genesis" || !strings.HasSuffix(doc["path"].(string), "/genesis-package-registry.json") {
|
||||
t.Fatalf("the genesis document is not delivered where the agent reads it: %v", doc)
|
||||
}
|
||||
|
||||
// The gitea module assigned: the requirement binds, with no act on the build agent (ADR 0336 §2).
|
||||
registry := map[string][]Provider{"artifact-store": store["artifact-store"], "npm-package-registry": {
|
||||
{Node: "anchor", At: "anchor.internal", Module: "gitea",
|
||||
Serves: map[string]any{"port": float64(3000), "scheme": "http", "npm-path": "/api/packages/novox/npm/"}}}}
|
||||
bound, err := Resolve(books, []string{"build-agent"}, reachable(), World{Offered: registry})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n := boundFrom(t, bound, "build-agent", "npm-package-registry"); n.Module != "gitea" {
|
||||
t.Fatalf("bound to %s", n.Module)
|
||||
}
|
||||
if len(bound.Absent) != 0 {
|
||||
t.Fatalf("the registry is still listed unbound once gitea is assigned: %+v", bound.Absent)
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import "testing"
|
||||
|
||||
// A bundle is the module's own directory compiled whole, and naming a source would be describing its
|
||||
// own build. That holds for an interpreted language and cannot hold for a compiled one: a repository
|
||||
// written in Go carries several commands — the host and its bootstrap live in one — and "the module's
|
||||
// own directory" is then not a package at all (novox/hq 04-ISSUES/142).
|
||||
|
||||
func TestAGoBundleMayNameItsCommand(t *testing.T) {
|
||||
b := &Build{Artifacts: []Artifact{{
|
||||
Name: "host-arch", Kind: ArtifactBundle, Language: "go", System: "arch",
|
||||
From: "cmd/mesh-host",
|
||||
}}}
|
||||
if p := b.problems("mesh-host"); len(p) != 0 {
|
||||
t.Fatalf("a go bundle naming its command was refused: %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnInterpretedBundleStillMayNotNameASource(t *testing.T) {
|
||||
b := &Build{Artifacts: []Artifact{{
|
||||
Name: "tools", Kind: ArtifactBundle, Language: "typescript", From: "src",
|
||||
}}}
|
||||
p := b.problems("something")
|
||||
if len(p) == 0 {
|
||||
t.Fatal("an interpreted bundle naming what it is built from was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestACompiledBundleStillMustSayItsSystem(t *testing.T) {
|
||||
b := &Build{Artifacts: []Artifact{{
|
||||
Name: "host", Kind: ArtifactBundle, Language: "go", From: "cmd/mesh-host",
|
||||
}}}
|
||||
if len(b.problems("mesh-host")) == 0 {
|
||||
t.Fatal("a compiled bundle with no system was accepted")
|
||||
}
|
||||
}
|
||||
@@ -1,103 +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")
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0208: the graphical session is one module per piece, on the mesh's seats.
|
||||
|
||||
// §2: the eleven roles are the mesh's own node seats, each with the verbs it starts with.
|
||||
func TestTheGraphicalSessionsSeatsAreTheMeshsOwnWithTheirVerbs(t *testing.T) {
|
||||
want := map[string][]string{
|
||||
LoginManagerSeat: {"sessions"},
|
||||
DisplayServerSeat: {"displays", "layout"},
|
||||
DisplaySessionSeat: {"reload", "workspaces", "windows"},
|
||||
TerminalEmulatorSeat: {"open"},
|
||||
LauncherSeat: {"menu", "secret"},
|
||||
NotifierSeat: {"send", "history"},
|
||||
LockScreenSeat: {"lock"},
|
||||
ClipboardSeat: {"history", "copy"},
|
||||
BarSeat: nil,
|
||||
CompositorSeat: nil,
|
||||
SecretServiceSeat: nil,
|
||||
}
|
||||
for name, verbs := range want {
|
||||
s, ok := SeatNamed(name)
|
||||
if !ok {
|
||||
t.Errorf("%s is not in the mesh's set", name)
|
||||
continue
|
||||
}
|
||||
if s.Scope != ScopeNode || s.Decision != "novox/hq ADR 0208" {
|
||||
t.Errorf("%s is %s-scoped under %q", name, s.Scope, s.Decision)
|
||||
}
|
||||
var got []string
|
||||
for _, v := range s.Serves {
|
||||
got = append(got, v.Name)
|
||||
if v.Description == "" || v.Input["type"] != "object" {
|
||||
t.Errorf("%s.%s has no description or no object schema", name, v.Name)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(got, verbs) {
|
||||
t.Errorf("%s serves %v, want %v", name, got, verbs)
|
||||
}
|
||||
}
|
||||
// The launcher's menu takes a list, and the layout verb says its actions.
|
||||
menu, _ := SeatNamed(LauncherSeat)
|
||||
choices := menu.Serves[0].Input["properties"].(map[string]any)["choices"].(map[string]any)
|
||||
if choices["type"] != "array" {
|
||||
t.Errorf("menu's choices are %v, not a list", choices["type"])
|
||||
}
|
||||
display, _ := SeatNamed(DisplayServerSeat)
|
||||
action := display.Serves[1].Input["properties"].(map[string]any)["action"].(map[string]any)
|
||||
if !reflect.DeepEqual(action["enum"], []string{"list", "save", "apply"}) {
|
||||
t.Errorf("layout's actions are %v", action["enum"])
|
||||
}
|
||||
// And they survive the store's JSON, which is where the live set comes from.
|
||||
if _, err := json.Marshal(graphicalSessionSeats()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// §2: a module may claim one of them, and may not declare it as its own.
|
||||
func TestNoModuleMayDeclareAGraphicalSessionSeat(t *testing.T) {
|
||||
raw := `{"module":"xorg","seats":[{"name":"node-display-server","scope":"node"}]}`
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "mesh's own namespace") {
|
||||
t.Fatalf("a module declared node-display-server as its own: %v", 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 TestTheMachinesReachIsAProvisionsOnlyReachAndIsNodeScoped(t *testing.T) {
|
||||
for _, c := range []struct{ provides, want string }{
|
||||
{`{"name":"x11-display","reach":"internal"}`, `with reach "internal"; a provision's reach is "machine" or nothing`},
|
||||
{`{"name":"x11-display","scope":"mesh","reach":"machine"}`, `at scope "mesh" with the machine's reach`},
|
||||
} {
|
||||
_, err := ParseManifest([]byte(`{"module":"xorg","provides":[` + c.provides + `]}`))
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: want %q, got %v", c.provides, c.want, err)
|
||||
}
|
||||
}
|
||||
m, err := ParseManifest([]byte(`{"module":"xorg","provides":[{"name":"x11-display","reach":"machine"}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !m.Provides[0].MachineReach() {
|
||||
t.Fatal("the reach was not read")
|
||||
}
|
||||
back, _ := json.Marshal(m.Provides[0])
|
||||
if string(back) != `{"name":"x11-display","reach":"machine"}` {
|
||||
t.Errorf("written back as %s", back)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// §4: xinitrc and xresources slots, placed by the display server's holder alone.
|
||||
func TestTheSessionsFilesArePlacedByTheDisplayServersHolderAlone(t *testing.T) {
|
||||
xorg := Manifest{Module: "xorg", Claims: []Claim{{Name: DisplayServerSeat}},
|
||||
Shell: []ShellCode{{For: "xinitrc", Slot: "first", Code: "xset s off"}},
|
||||
Resources: []map[string]any{
|
||||
{"id": "xinitrc", "type": "file", "path": "/home/op/.xinitrc",
|
||||
"content": "${shell:xinitrc:first}${shell:xinitrc:normal}${shell:xinitrc:last}"},
|
||||
{"id": "xresources", "type": "file", "path": "/home/op/.Xresources",
|
||||
"content": "${shell:xresources:normal}"},
|
||||
}}
|
||||
i3 := Manifest{Module: "i3", Shell: []ShellCode{{For: "xinitrc", Slot: "last", Code: "exec i3"}}}
|
||||
theme := Manifest{Module: "theme", Shell: []ShellCode{
|
||||
{For: "xresources", Slot: "normal", Code: "Xft.dpi: 96"},
|
||||
{For: "zsh", Slot: "normal", Code: "not for the session"},
|
||||
}}
|
||||
r := Resolution{Node: "workstation", Account: "op", Modules: []Manifest{xorg, i3, theme}}
|
||||
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 got := by["xorg.xinitrc"]; got != "# xorg\nxset s off\n# i3\nexec i3\n" {
|
||||
t.Errorf("the .xinitrc is %q", got)
|
||||
}
|
||||
if got := by["xorg.xresources"]; got != "# theme\nXft.dpi: 96\n" {
|
||||
t.Errorf("the .Xresources is %q", got)
|
||||
}
|
||||
|
||||
// The contributions parse; the placeholders parse only in the holder.
|
||||
if _, err := ParseManifest([]byte(`{"module":"i3","shell":[{"for":"xinitrc","slot":"last","code":"exec i3"},` +
|
||||
`{"for":"xresources","slot":"normal","code":"i3.font: x"}]}`)); err != nil {
|
||||
t.Fatalf("a session contribution was refused: %v", err)
|
||||
}
|
||||
for _, c := range []struct{ claims, content, want string }{
|
||||
{``, "${shell:xinitrc:normal}", "does not claim node-display-server; every module's xinitrc is placed by the display server's holder alone"},
|
||||
{`{"name":"node-login-shell"}`, "${shell:xresources:normal}", "does not claim node-display-server"},
|
||||
{`{"name":"node-display-server"}`, "${shell:zsh:normal}", "does not claim node-login-shell"},
|
||||
{`{"name":"node-display-server"}`, "${shell:xsession:normal}", "the session's file one of xinitrc, xresources"},
|
||||
} {
|
||||
raw := `{"module":"holder","claims":[` + c.claims + `],"resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` +
|
||||
c.content + `"}]}`
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s with claims [%s]: want %q, got %v", c.content, c.claims, c.want, err)
|
||||
}
|
||||
}
|
||||
if _, err := ParseManifest([]byte(`{"module":"xorg","claims":[{"name":"node-display-server"}],` +
|
||||
`"resources":[{"id":"rc","type":"file","path":"/home/op/.xinitrc","content":"${shell:xinitrc:last}"}]}`)); err != nil {
|
||||
t.Errorf("the display server's holder could not place the session's slots: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
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" +
|
||||
"GITEA__database__HOST=${bound:postgres-database:at}:${bound:postgres-database:port}\n" +
|
||||
"GITEA__database__NAME=${bound:postgres-database:as}\n" +
|
||||
"GITEA__database__USER=${bound:postgres-database:as}\n" +
|
||||
"GITEA__database__PASSWD=${secret:postgres-database}\n"
|
||||
|
||||
// databaseConsumer is a handed module, with the template gitea's server.env is, requiring a database from a provider
|
||||
// that has moved or not (issue 788); slug is the module's identity source when it declares one.
|
||||
func databaseConsumer(t *testing.T, slug string, moved bool) 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A provider's consumers' credentials reach it in a file its machine's provisioner client fills (novox/hq ADR 0373,
|
||||
// issue 679): `${consumers:<provision>}`, which only a provider's template may hold, for a provision it provides, in a
|
||||
// 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 := ParseManifest([]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)
|
||||
}
|
||||
}
|
||||
|
||||
// What `module check` and the merge gate judge: the provider read from its tree, its text inline. Judged by the same
|
||||
// rules, a provider naming a provision it does not provide is refused there too, and one that does composes.
|
||||
func TestAProviderReadFromItsTreeIsJudgedByTheSameRules(t *testing.T) {
|
||||
read := func(text string) (Manifest, error) {
|
||||
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", "consumers.json"), []byte(text), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := ParseManifest([]byte(handedProvider))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return WithTemplatesFrom(m, dir)
|
||||
}
|
||||
filled, err := read(consumersText)
|
||||
if err != nil {
|
||||
t.Fatalf("a provider read from its tree is refused: %v", err)
|
||||
}
|
||||
if _, err := notifierOn(t, filled).Compose(handingTo("store:5000")); err != nil {
|
||||
t.Fatalf("a provider read from its tree does not compose where it would be handed: %v", err)
|
||||
}
|
||||
if _, err := read("${consumers:cache}"); err == nil || !strings.Contains(err.Error(), "does not provide") {
|
||||
t.Fatalf("a provider read from its tree naming a provision it does not provide: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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,274 +0,0 @@
|
||||
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": {
|
||||
{Node: "anchor", At: "anchor.internal", Module: "store", Serves: map[string]any{"port": 5432}},
|
||||
{Node: "home", At: "home.internal", Module: "store-two", Serves: map[string]any{"port": 5434}},
|
||||
}}, Pinned: map[string]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}},
|
||||
Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}},
|
||||
Manifest{Module: "store-two", Version: "1", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh}},
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Each entry names the credential form the provider's offer declares (issue 768), from the provider's manifest, so
|
||||
// the vault makes a first value in that form for a provider it holds no row of: one of its own secrets shared by
|
||||
// every consumer, a login per consumer when the offer says no credential, issued by its software, or given by a
|
||||
// person (the offer's own secret issued outside).
|
||||
func TestAHandedModulesPlanNamesTheCredentialFormOfItsProvidersOffer(t *testing.T) {
|
||||
storeOffering := func(credential string) Manifest {
|
||||
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"},` +
|
||||
`"token":{"path":"/var/lib/store/token","taken":"at-start","issued-by":"outside"}}`
|
||||
}
|
||||
return mustParse(t, raw+`}`)
|
||||
}
|
||||
for credential, want := range map[string]string{
|
||||
``: CredentialFormPair,
|
||||
`,"credential":{"own":"admin"}`: CredentialFormShared,
|
||||
`,"credential":{"own":"token"}`: CredentialFormGiven,
|
||||
`,"credential":{"issued-by":"outside"}`: CredentialFormGiven,
|
||||
`,"credential":{"issued-by":"software","tool":"store_issued_token"}`: CredentialFormIssued,
|
||||
} {
|
||||
node := workstation()
|
||||
node.At = "workstation.internal"
|
||||
consumer := notifierConsumer(t, true, false)
|
||||
r, err := Resolve(shelf(consumer, storeOffering(credential)), []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].CredentialForm != want {
|
||||
t.Errorf("an offer with %q: the plan names %+v, want the form %q", credential, got, want)
|
||||
}
|
||||
}
|
||||
if got := (Manifest{Module: "x"}).CredentialFormOf("postgres-database"); got != "" {
|
||||
t.Errorf("a module offering nothing has the credential form %q", 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"}}}
|
||||
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 := ParseManifest([]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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A module only pulled in by another is handed exactly like an assigned one (issue 765): its templated files, its
|
||||
// own secrets and its items go to the machine's provisioner client wherever the machine can take them. A module not
|
||||
// moved composes as before, pulled in or not.
|
||||
|
||||
// pulledInBy is a module that requires what raw's module provides, so resolving it alone pulls that module in.
|
||||
const pulledInBy = `{"module":"desk","version":"1","requires":["pulled-in-here"]}`
|
||||
|
||||
// providing is raw's module providing what desk requires.
|
||||
func providing(raw string) string {
|
||||
return strings.Replace(raw, `"version":"1",`, `"version":"1","provides":[{"name":"pulled-in-here"}],`, 1)
|
||||
}
|
||||
|
||||
// onWorkstation resolves m on the workstation either assigned beside desk, or pulled in by desk alone.
|
||||
func onWorkstation(t *testing.T, m Manifest, assigned bool) Resolution {
|
||||
t.Helper()
|
||||
desk, err := ParseManifest([]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()
|
||||
var out []map[string]any
|
||||
for _, r := range c.Resources {
|
||||
if strings.HasPrefix(r["id"].(string), module+".") {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
raw, err := json.Marshal(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
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,252 +0,0 @@
|
||||
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):
|
||||
// where the module is handed, its declaration carries no sealed value — each file holding a secret alone is placed
|
||||
// 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),
|
||||
Digest: "sha256:" + strings.Repeat("e", 64), Texts: map[string]string{"bot.conf": text}}}
|
||||
}
|
||||
|
||||
// What the controller seals for the bot when it fills it the old way: a stand-in sealed value.
|
||||
func sealedForBot(with 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)
|
||||
}
|
||||
// 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"},
|
||||
} {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWhatAHandedModuleCannotHaveFromTheVaultIsRefused(t *testing.T) {
|
||||
// A template naming a credential the module requires: a binding's, which moves with its provider.
|
||||
requires := strings.Replace(botMoved(), `"own-secrets":`, `"requires":["database"],"secrets":{"database":"/etc/bot/db"},"own-secrets":`, 1)
|
||||
r, err := Resolve(shelf(held(t, requires, builtBot("db = ${secret:database}\n")),
|
||||
Manifest{Module: "db", Version: "1", Provides: []Offer{{Name: "database", Scope: ScopeNode}}}),
|
||||
[]string{"bot"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Account = "op"
|
||||
if _, err := r.Compose(handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "moves with its provider") {
|
||||
t.Errorf("a template naming a required credential: %v", err)
|
||||
}
|
||||
// A file with its text inline naming a secret, in a handed module.
|
||||
inline := strings.Replace(botMoved(), `{"id":"daemon"`,
|
||||
`{"id":"legacy","type":"file","path":"/etc/bot/legacy","content":"t = ${secret:token}\n"},{"id":"daemon"`, 1)
|
||||
r = notifierOn(t, held(t, inline, builtBot(botText)))
|
||||
if _, err := r.Compose(handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "keep it in a template") {
|
||||
t.Errorf("an inline file naming a secret in a handed module: %v", err)
|
||||
}
|
||||
// A family and a secret issued outside the mesh are not handed by name yet: the mark is refused.
|
||||
for why, raw := range map[string]string{
|
||||
"a family": strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`, `"own-secrets":{"token":"${dir:state}/token","key-*":"${dir:state}/keys"}`, 1),
|
||||
"issued outside": strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`, `"own-secrets":{"token":{"path":"${dir:state}/token","issued-by":"outside"}}`, 1),
|
||||
} {
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "design 57 V4") {
|
||||
t.Errorf("a moved module with %s: %v", why, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
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,188 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMovedModulesMarkIsJudged(t *testing.T) {
|
||||
for name, raw := range map[string]string{
|
||||
"marked moved and naming no template": `{"module":"bare","version":"1","moved":true,
|
||||
"resources":[{"id":"conf","type":"file","path":"/etc/bare","content":"x"}]}`,
|
||||
"handed written in a repository": `{"module":"bare","version":"1","moved":true,
|
||||
"handed":{"source":"s","digest":"d","templates":{}},
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[{"id":"conf","type":"file","path":"/etc/bare","template":"files/bare"}]}`,
|
||||
"handed and not moved": `{"module":"bare","version":"1",
|
||||
"handed":{"source":"s","digest":"d","templates":{}},
|
||||
"resources":[{"id":"conf","type":"file","path":"/etc/bare","content":"x"}]}`,
|
||||
} {
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil {
|
||||
t.Errorf("%s: accepted", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 := ParseManifest([]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)
|
||||
}
|
||||
}
|
||||
@@ -1,217 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/validate"
|
||||
)
|
||||
|
||||
// A module says how each long-running resource is ready (novox/hq ADR 0240 rule 2, to-be 48 §8): `module
|
||||
// check` refuses each part out of its bounds, each endpoint named by a port or an address, a tool the
|
||||
// module does not serve, and a tool check alone — a test per refusal.
|
||||
|
||||
const healthDigest = "@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
|
||||
// healthManifest is a module of one web container, one running service and one step, with the health
|
||||
// given on the container (or on the resource named by on).
|
||||
func healthManifest(t *testing.T, on string, health map[string]any, more ...map[string]any) []byte {
|
||||
t.Helper()
|
||||
resources := []map[string]any{
|
||||
{"id": "server", "type": "container", "name": "app-server", "image": "registry.example/app" + healthDigest,
|
||||
"ports": []any{"8080"}},
|
||||
{"id": "daemon", "type": "service", "unit": "app.service", "state": "running"},
|
||||
{"id": "seed", "type": "container", "name": "app-seed", "image": "registry.example/app" + healthDigest,
|
||||
"run-once": true},
|
||||
{"id": "sweep", "type": "container", "name": "app-sweep", "image": "registry.example/app" + healthDigest,
|
||||
"schedule": "0 3 * * *"},
|
||||
}
|
||||
resources = append(resources, more...)
|
||||
for _, r := range resources {
|
||||
if r["id"] == on && health != nil {
|
||||
r["health"] = health
|
||||
}
|
||||
}
|
||||
m := map[string]any{"module": "app", "requires": []any{"postgres-database"}, "tools": []any{"app_status"},
|
||||
"listens": []any{
|
||||
map[string]any{"name": "web", "port": 8080, "from": "mesh"},
|
||||
map[string]any{"name": "beacon", "port": 9999, "protocol": "udp", "from": "mesh"},
|
||||
},
|
||||
"resources": resources}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func TestAWellFormedHealthIsAccepted(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", "grace": "4m", "looks": 2, "interval": "30s"},
|
||||
} {
|
||||
if _, err := ParseManifest(healthManifest(t, "server", h)); err != nil {
|
||||
t.Errorf("%v was refused: %v", h, err)
|
||||
}
|
||||
}
|
||||
if _, err := ParseManifest(healthManifest(t, "daemon", map[string]any{"kind": "unit"})); err != nil {
|
||||
t.Errorf("a service's own readiness was refused: %v", err)
|
||||
}
|
||||
// A tool beside a check of another kind on the module.
|
||||
raw := healthManifest(t, "server", map[string]any{"kind": "http", "endpoint": "web"},
|
||||
map[string]any{"id": "admin", "type": "service", "unit": "app-admin.service", "state": "running",
|
||||
"health": map[string]any{"kind": "tool", "tool": "app_status"}})
|
||||
if _, err := ParseManifest(raw); err != nil {
|
||||
t.Errorf("a tool check beside an http check was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryOutOfBoundsHealthIsRefusedByName(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
on string
|
||||
health map[string]any
|
||||
says string
|
||||
}{
|
||||
{"no kind", "server", map[string]any{"endpoint": "web"}, "with no kind"},
|
||||
{"an unknown kind", "server", map[string]any{"kind": "ping"}, `of kind "ping"`},
|
||||
{"a port", "server", map[string]any{"kind": "tcp", "port": 8080}, "names a port"},
|
||||
{"an address", "server", map[string]any{"kind": "http", "endpoint": "web", "address": "127.0.0.1"}, "names a address"},
|
||||
{"a url", "server", map[string]any{"kind": "http", "url": "http://localhost:8080/"}, "names a url"},
|
||||
{"an unknown key", "server", map[string]any{"kind": "tcp", "endpoint": "web", "retries": 3}, `"retries"`},
|
||||
{"no endpoint", "server", map[string]any{"kind": "http"}, "names no endpoint"},
|
||||
{"an undeclared endpoint", "server", map[string]any{"kind": "tcp", "endpoint": "admin"}, "does not declare"},
|
||||
{"a udp endpoint", "server", map[string]any{"kind": "tcp", "endpoint": "beacon"}, "a check connects over tcp"},
|
||||
{"an interval under the floor", "server", map[string]any{"kind": "tcp", "endpoint": "web", "interval": "5s", "timeout": "1s"}, "no more often than every 10s"},
|
||||
{"a timeout of the interval", "server", map[string]any{"kind": "tcp", "endpoint": "web", "interval": "10s", "timeout": "10s"}, "under its interval"},
|
||||
{"one failing look", "server", map[string]any{"kind": "tcp", "endpoint": "web", "looks": 1}, "at least 2"},
|
||||
{"a grace and looks past five minutes", "server", map[string]any{"kind": "tcp", "endpoint": "web", "grace": "4m", "looks": 3, "interval": "30s"}, "at most 5m0s"},
|
||||
{"a duration that is not one", "server", map[string]any{"kind": "tcp", "endpoint": "web", "interval": "often"}, "is a duration"},
|
||||
{"looks that are not a number", "server", map[string]any{"kind": "tcp", "endpoint": "web", "looks": "three"}, "whole number"},
|
||||
{"a path without a slash", "server", map[string]any{"kind": "http", "endpoint": "web", "path": "health"}, "starts with /"},
|
||||
{"a status that is not one", "server", map[string]any{"kind": "http", "endpoint": "web", "status": 700}, "is not one"},
|
||||
{"a scheme that is not one", "server", map[string]any{"kind": "http", "endpoint": "web", "scheme": "ftp"}, "http or https"},
|
||||
{"a status on a tcp check", "server", map[string]any{"kind": "tcp", "endpoint": "web", "status": 200}, "only an http check has"},
|
||||
{"an exec with no command", "server", map[string]any{"kind": "exec"}, "says no command"},
|
||||
{"a command on an http check", "server", map[string]any{"kind": "http", "endpoint": "web", "command": "true"}, "only an exec check runs"},
|
||||
{"a runtime check on a service", "daemon", map[string]any{"kind": "runtime"}, "only a container has"},
|
||||
{"a unit check on a container", "server", map[string]any{"kind": "unit"}, "a service's or a process's"},
|
||||
{"a tool the module does not serve", "server", map[string]any{"kind": "tool", "tool": "app_admin"}, "does not serve"},
|
||||
{"a tool check alone", "server", map[string]any{"kind": "tool", "tool": "app_status"}, "judges itself only by its own tool"},
|
||||
{"needs not required", "server", map[string]any{"kind": "tcp", "endpoint": "web", "needs": "redis"}, "does not require"},
|
||||
{"health on a step", "seed", map[string]any{"kind": "runtime"}, "does not stay up"},
|
||||
{"health on a schedule", "sweep", map[string]any{"kind": "runtime"}, "does not stay up"},
|
||||
{"health that is not an object", "server", nil, "is an object"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
raw := healthManifest(t, c.on, c.health)
|
||||
if c.health == nil {
|
||||
raw = []byte(strings.Replace(string(raw), `"name":"app-server"`, `"name":"app-server","health":"tcp"`, 1))
|
||||
}
|
||||
_, err := ParseManifest(raw)
|
||||
if err == nil {
|
||||
t.Fatalf("%s was accepted", c.name)
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("refused, but not for saying %q: %v", c.says, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthIsComposedAsThePortThisMachineGaveTheEndpoint(t *testing.T) {
|
||||
m, err := ParseManifest(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 := ParseManifest(healthManifest(t, "server", map[string]any{"kind": "runtime"}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := strings.Join(Undeclared(m), ","); got != "daemon" {
|
||||
t.Errorf("undeclared: %q; the step and the schedule are not long-running, the server declares", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATooledHealthIsGrantedToTheEngine(t *testing.T) {
|
||||
raw := healthManifest(t, "server", map[string]any{"kind": "http", "endpoint": "web"},
|
||||
map[string]any{"id": "admin", "type": "service", "unit": "app-admin.service", "state": "running",
|
||||
"health": map[string]any{"kind": "tool", "tool": "app_status"}})
|
||||
m, err := ParseManifest(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := HealthChecks(m); len(got) != 1 || got[0] != "app.app_status" {
|
||||
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 := ParseManifest(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"}
|
||||
out, err := Resolution{Node: "laptop", Modules: []Manifest{m}}.Declaration(Rendering{ReadsHealth: true,
|
||||
Ports: map[string]map[int]int{"app": {8080: 31001}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": out})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if problems := validate.Declaration(body); len(problems) > 0 {
|
||||
t.Errorf("%v composed into something the node-engine refuses: %v", h, problems)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// **A seat's holder on record settles who holds it, and lets the next holder stand beside the
|
||||
// current one** (novox/hq ADR 0131, design 28 task 5.3). Until the record existed, two assignments
|
||||
// whose modules both claimed a seat were refused outright — which left no way to hand a seat over
|
||||
// without a moment where nobody held it, and the control plane finds its own bus through one of
|
||||
// these seats. That moment was the outage of 2026-09-27.
|
||||
|
||||
func busSeatDelivering(t *testing.T, delivers string) {
|
||||
t.Helper()
|
||||
was := Seats()
|
||||
t.Cleanup(func() { UseSeats(was) })
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// A record naming a seat's former name still applies to it after a rename (ADR 0122).
|
||||
func TestAHolderRecordedUnderAFormerNameStillHolds(t *testing.T) {
|
||||
busSeatDelivering(t, "mesh-bus")
|
||||
wasAliases := aliases
|
||||
t.Cleanup(func() { UseAliases(wasAliases) })
|
||||
UseAliases(map[string]string{"the-broker": "mesh-broker"})
|
||||
record := []Held{{Claim: "the-broker", Scope: ScopeMesh, Node: "anchor", Module: "new-broker"}}
|
||||
|
||||
held, problems := checkClaims([]Manifest{oldBroker(), newBroker()}, Node{Name: "anchor"}, nil, record)
|
||||
if len(problems) != 0 || len(held) != 1 || held[0].Module != "new-broker" {
|
||||
t.Fatalf("a record under the former name did not settle the seat: 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")
|
||||
seat, _ := SeatNamed("mesh-broker")
|
||||
|
||||
if err := CanHold(newBroker(), seat); err != nil {
|
||||
t.Fatalf("a module that claims the seat and provides what it delivers was refused: %v", err)
|
||||
}
|
||||
noClaim := Manifest{Module: "quiet", Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh}}}
|
||||
if err := CanHold(noClaim, seat); err == nil || !strings.Contains(err.Error(), "does not claim") {
|
||||
t.Fatalf("a module that never claimed the seat was allowed to hold it: %v", err)
|
||||
}
|
||||
wrongScope := newBroker()
|
||||
wrongScope.Claims[0].Scope = ScopeNode
|
||||
if err := CanHold(wrongScope, seat); err == nil || !strings.Contains(err.Error(), "scope") {
|
||||
t.Fatalf("a claim at the wrong scope was allowed: %v", err)
|
||||
}
|
||||
cannotAnswer := Manifest{Module: "amqp-only", Provides: []Offer{{Name: "amqp", Scope: ScopeMesh}},
|
||||
Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}}
|
||||
if err := CanHold(cannotAnswer, seat); err == nil || !strings.Contains(err.Error(), `does not provide "mesh-bus"`) {
|
||||
t.Fatalf("a holder that cannot answer for the seat was allowed: %v", err)
|
||||
}
|
||||
// A holder's own tools need not be the seat's verbs: the claim may name what it serves for the
|
||||
// role (ADR 0160), and then only those count — and only the seat's verbs may be named.
|
||||
promising := seat
|
||||
promising.Serves = []Verb{{Name: "databases"}, {Name: "query"}}
|
||||
engine := newBroker()
|
||||
engine.Tools = []string{"engine_list_databases", "engine_query"}
|
||||
if err := CanHold(engine, promising); err == nil || !strings.Contains(err.Error(), "does not serve databases, query") {
|
||||
t.Fatalf("a holder whose tools are not the seat's verbs was allowed without saying what it serves: %v", err)
|
||||
}
|
||||
engine.Claims[0].Serves = []string{"databases", "query"}
|
||||
if err := CanHold(engine, promising); err != nil {
|
||||
t.Fatalf("a claim naming the seat's verbs was refused: %v", err)
|
||||
}
|
||||
engine.Claims[0].Serves = []string{"databases"}
|
||||
if err := CanHold(engine, promising); err == nil || !strings.Contains(err.Error(), "does not serve query") {
|
||||
t.Fatalf("a claim naming half the verbs was allowed: %v", err)
|
||||
}
|
||||
engine.Claims[0].Serves = []string{"databases", "query", "engine_query"}
|
||||
if err := CanHold(engine, promising); err == nil || !strings.Contains(err.Error(), "does not promise") {
|
||||
t.Fatalf("a claim naming a verb the seat never promised was allowed: %v", err)
|
||||
}
|
||||
// And the judgement follows the store's row, not a compiled copy.
|
||||
busSeatDelivering(t, "amqp")
|
||||
seat, _ = SeatNamed("mesh-broker")
|
||||
if err := CanHold(cannotAnswer, seat); err != nil {
|
||||
t.Fatalf("with the row saying amqp, an amqp provider was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
was := Seats()
|
||||
t.Cleanup(func() { UseSeats(was) })
|
||||
UseSeats([]Seat{{Name: "mesh-build-machine", Scope: ScopeMesh, Decision: "row"}})
|
||||
got, ok := SeatNamed("mesh-build-machine")
|
||||
if !ok || len(got.Accepts) == 0 {
|
||||
t.Fatalf("the build machine's seat lost what it accepts when loaded from the store: %+v", got)
|
||||
}
|
||||
if got.Decision != "row" {
|
||||
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,100 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0377 §3: a move under a module's own `record` rolls out when it recreates none of its containers
|
||||
// and moves none of its data, judged from the two builds' manifests.
|
||||
func TestWhatAMoveUnderRecordHoldsFor(t *testing.T) {
|
||||
base := func() Manifest {
|
||||
raw := `{"module":"mailu","tools":["mailu_status"],
|
||||
"requires":["postgres-database"],
|
||||
"data":{"own":[{"id":"mail","path":"${dir:data-mail}","class":"valuable"}]},
|
||||
"bundles":[{"name":"tools","source":"store/mailu-tools@sha256:aaa","digest":"sha256:aaa"}],
|
||||
"resources":[
|
||||
{"id":"state","type":"directory"},
|
||||
{"id":"mesh-state","type":"directory"},
|
||||
{"id":"data-mail","type":"directory"},
|
||||
{"id":"config-env","type":"file","path":"${dir:state}/mailu.env","content":"A=1"},
|
||||
{"id":"runtime-config","type":"file","path":"${dir:mesh-state}/config.json","content":"{}"},
|
||||
{"id":"inbox-note","type":"file","path":"${dir:data-mail}/README","content":"x"},
|
||||
{"id":"imap","type":"container","image":"registry/imap@sha256:1","volumes":["${dir:data-mail}:/mail"],
|
||||
"env-file":["${dir:state}/mailu.env"]},
|
||||
{"id":"watcher","type":"process","command":"watch"},
|
||||
{"id":"installation","type":"archive","path":"/opt/mailu-tools","artifact":"archive"},
|
||||
{"id":"guard","type":"package","package":"nftables"}]}`
|
||||
var m Manifest
|
||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
resource := func(m *Manifest, id string) map[string]any {
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatalf("no resource %s", id)
|
||||
return nil
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
change func(m *Manifest)
|
||||
holds string // empty: it rolls out
|
||||
}{
|
||||
{"nothing", func(*Manifest) {}, ""},
|
||||
{"a tools bundle", func(m *Manifest) {
|
||||
m.Bundles[0].Digest, m.Bundles[0].Source = "sha256:bbb", "store/mailu-tools@sha256:bbb"
|
||||
}, ""},
|
||||
{"the tool list", func(m *Manifest) { m.Tools = append(m.Tools, "mailu_queue") }, ""},
|
||||
{"a file no container mounts", func(m *Manifest) { resource(m, "runtime-config")["content"] = `{"a":1}` }, ""},
|
||||
{"a container's image", func(m *Manifest) { resource(m, "imap")["image"] = "registry/imap@sha256:2" }, "recreates 1 of mailu's 1"},
|
||||
{"a container's declaration", func(m *Manifest) { resource(m, "imap")["health"] = map[string]any{"kind": "runtime"} }, "recreates 1"},
|
||||
{"a container added", func(m *Manifest) {
|
||||
m.Resources = append(m.Resources, map[string]any{"id": "smtp", "type": "container", "image": "x"})
|
||||
}, "recreates"},
|
||||
{"a mounted file", func(m *Manifest) { resource(m, "config-env")["content"] = "A=2" }, "which its container imap mounts"},
|
||||
{"a file within declared data", func(m *Manifest) { resource(m, "inbox-note")["content"] = "y" }, "which its container imap mounts"},
|
||||
{"a directory holding declared data", func(m *Manifest) { resource(m, "data-mail")["mode"] = "0700" }, "data-mail"},
|
||||
{"the data declaration", func(m *Manifest) { m.Data.Own[0].Class = ClassIrreplaceable }, "data declaration"},
|
||||
{"a provider", func(m *Manifest) { m.Requires = append(m.Requires, "s3") }, "what it requires"},
|
||||
{"what it provides", func(m *Manifest) { m.Provides = append(m.Provides, Offer{Name: "smtp"}) }, "what it provides"},
|
||||
{"a process of a module with data", func(m *Manifest) { resource(m, "watcher")["command"] = "watch2" }, "long-running process watcher"},
|
||||
{"an archive resource", func(m *Manifest) { resource(m, "installation")["artifact"] = "archive2" }, "the archive installation"},
|
||||
{"a package resource", func(m *Manifest) { resource(m, "guard")["package"] = "nftables2" }, "the package guard"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
from, to := base(), base()
|
||||
c.change(&to)
|
||||
got := Holds(from, to)
|
||||
switch {
|
||||
case c.holds == "" && got != "":
|
||||
t.Fatalf("held: %s", got)
|
||||
case c.holds != "" && !strings.Contains(got, c.holds):
|
||||
t.Fatalf("holds %q; want it to say %q", got, c.holds)
|
||||
}
|
||||
})
|
||||
}
|
||||
// A data directory nothing mounts still holds its data.
|
||||
from, to := base(), base()
|
||||
from.Data.Own = append(from.Data.Own, DataItem{ID: "archive", Path: "${dir:mesh-state}/archive", Class: "valuable"})
|
||||
to.Data = &Data{Own: append([]DataItem(nil), from.Data.Own...)}
|
||||
resource(&to, "runtime-config")["path"] = "${dir:mesh-state}/archive/config.json"
|
||||
resource(&from, "runtime-config")["path"] = "${dir:mesh-state}/archive/config.json"
|
||||
resource(&to, "runtime-config")["content"] = "new"
|
||||
if got := Holds(from, to); !strings.Contains(got, "lies within its data archive") {
|
||||
t.Fatalf("a file within data nothing mounts: %q", got)
|
||||
}
|
||||
// A process of a module with no data of its own may change.
|
||||
from, to = base(), base()
|
||||
from.Data, to.Data = nil, nil
|
||||
resource(&to, "watcher")["command"] = "watch2"
|
||||
if got := Holds(from, to); got != "" {
|
||||
t.Fatalf("a process of a module with no data held: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -1,98 +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.
|
||||
|
||||
func withHostnameAlias(t *testing.T) {
|
||||
t.Helper()
|
||||
was := aliases
|
||||
t.Cleanup(func() { aliases = was })
|
||||
UseAliases(map[string]string{"node-hosts-file": "node-hostname"})
|
||||
}
|
||||
|
||||
func TestTheHostsFilesFormerNameResolvesToTheHostnameSeat(t *testing.T) {
|
||||
if _, known := SeatNamed("node-hostname"); !known {
|
||||
t.Fatal("node-hostname is not in the mesh's set")
|
||||
}
|
||||
withHostnameAlias(t)
|
||||
seat, known := SeatNamed("node-hosts-file")
|
||||
if !known || seat.Name != "node-hostname" || seat.Scope != ScopeNode {
|
||||
t.Fatalf("the former name did not resolve: %+v %v", seat, known)
|
||||
}
|
||||
var verbs []string
|
||||
for _, v := range seat.Serves {
|
||||
verbs = append(verbs, v.Name)
|
||||
}
|
||||
if strings.Join(verbs, " ") != "entries add remove" {
|
||||
t.Errorf("the renamed seat serves %v; its verbs are unchanged", verbs)
|
||||
}
|
||||
}
|
||||
|
||||
// One seat under either name: the module registered before the rename and the one after cannot both
|
||||
// hold it on one machine.
|
||||
func TestTheOldAndTheNewClaimantAreOneSeatOnAMachine(t *testing.T) {
|
||||
withHostnameAlias(t)
|
||||
cat := shelf(
|
||||
mod("hosts", nil, nil, nil, Claim{Name: "node-hosts-file"}),
|
||||
mod("hostname", nil, nil, nil, Claim{Name: "node-hostname"}),
|
||||
)
|
||||
_, err := Resolve(cat, []string{"hosts", "hostname"}, workstation(), World{})
|
||||
if err == nil || !strings.Contains(err.Error(), "node-hostname") {
|
||||
t.Errorf("hosts and hostname both held the machine's names on one machine: %v", err)
|
||||
}
|
||||
if _, err := Resolve(cat, []string{"hosts"}, workstation(), World{}); err != nil {
|
||||
t.Errorf("a machine still assigned hosts under the old name does not resolve: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 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,190 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Each test names the decision it defends: novox/hq ADR 0225, which refines ADR 0049 after issue 263.
|
||||
|
||||
// The provision a module requires sets the bound on its identity, not the tightest backend anywhere.
|
||||
func TestABoundIsTheProvisionsOwn(t *testing.T) {
|
||||
store := Manifest{Module: "objects", Provides: []Offer{{Name: "s3-bucket", Scope: ScopeMesh,
|
||||
Identity: &OfferIdentity{Max: 20, In: "an S3 access key"}}},
|
||||
Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}}
|
||||
database := Manifest{Module: "db", Provides: []Offer{{Name: "postgres-database", Scope: ScopeMesh,
|
||||
Identity: &OfferIdentity{Max: 63, In: "a PostgreSQL role"}}},
|
||||
Receives: map[string]string{"postgres-database": "/var/lib/mesh/db/mesh.json"}}
|
||||
if b := store.IdentityBoundOf("s3-bucket"); b.Max != 20 || b.In != "an S3 access key" {
|
||||
t.Errorf("an object store's stated bound was not taken: %+v", b)
|
||||
}
|
||||
if b := database.IdentityBoundOf("postgres-database"); b.Max != 63 {
|
||||
t.Errorf("a database's stated bound was not taken: %+v", b)
|
||||
}
|
||||
// mesh_workstation_keycloak is 25: refused by the object store, accepted by the database.
|
||||
if CheckIdentityWithin("workstation", "keycloak", store.IdentityBoundOf("s3-bucket")) == nil {
|
||||
t.Error("a 25-character identity fit a 20-character access key")
|
||||
}
|
||||
if err := CheckIdentityWithin("workstation", "keycloak", database.IdentityBoundOf("postgres-database")); err != nil {
|
||||
t.Errorf("a database consumer paid the object store's limit: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// What an offer leaves unsaid follows from whether its provider can keep a name at all.
|
||||
func TestAnUnstatedBoundFollowsWhatTheProviderIsTold(t *testing.T) {
|
||||
// Told nothing about its consumers — no receives, nothing served from their identity: no bound.
|
||||
// The resolver provision is exactly this, and its consumers paid an object store's limit (263).
|
||||
resolver := Manifest{Module: "resolver", Provides: []Offer{{Name: "wildcard-resolution", Scope: ScopeMesh}}}
|
||||
if b := resolver.IdentityBoundOf("wildcard-resolution"); b.Bounded() {
|
||||
t.Errorf("a provision that is told nothing of its consumers bounds them: %+v", b)
|
||||
}
|
||||
// Told each consumer and silent about its backend: the old global bound, not none.
|
||||
told := Manifest{Module: "told", Provides: []Offer{{Name: "thing", Scope: ScopeMesh}},
|
||||
Receives: map[string]string{"thing": "/var/lib/mesh/told/mesh.json"}}
|
||||
if b := told.IdentityBoundOf("thing"); b.Max != DefaultIdentityLimit {
|
||||
t.Errorf("a provider told its consumers and silent about its backend is not held to %d: %+v",
|
||||
DefaultIdentityLimit, b)
|
||||
}
|
||||
// Serving a value built from the identity is being told it, too.
|
||||
serving := Manifest{Module: "serving", Provides: []Offer{{Name: "bucket", Scope: ScopeMesh}},
|
||||
Serves: map[string]map[string]any{"bucket": {"name": "b-${consumer:as:dns}"}}}
|
||||
if b := serving.IdentityBoundOf("bucket"); b.Max != DefaultIdentityLimit {
|
||||
t.Errorf("a provider deriving a name from its consumers is not bounded: %+v", b)
|
||||
}
|
||||
// And `false` says it outright, even for a provider that receives.
|
||||
routes := Manifest{Module: "routes", Provides: []Offer{{Name: "route", Scope: ScopeMesh,
|
||||
Identity: &OfferIdentity{None: true}}},
|
||||
Receives: map[string]string{"route": "/var/lib/mesh/routes/mesh.json"}}
|
||||
if b := routes.IdentityBoundOf("route"); b.Bounded() {
|
||||
t.Errorf("`identity: false` still bounds: %+v", b)
|
||||
}
|
||||
}
|
||||
|
||||
// The field reads as written and writes back the same, and refuses what says nothing.
|
||||
func TestAnOffersIdentityIsParsedStrictly(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
`{"name":"s3-bucket","scope":"mesh","identity":{"max":20,"in":"an S3 access key"}}`,
|
||||
`{"name":"wildcard-resolution","scope":"mesh","identity":false}`,
|
||||
`{"name":"redis-cache","scope":"mesh","identity":{"in":"a Redis ACL user"}}`,
|
||||
} {
|
||||
var o Offer
|
||||
if err := json.Unmarshal([]byte(raw), &o); err != nil {
|
||||
t.Fatalf("%s: %v", raw, err)
|
||||
}
|
||||
back, err := json.Marshal(o)
|
||||
if err != nil || string(back) != raw {
|
||||
t.Errorf("did not round-trip:\n%s\n%s (%v)", raw, back, err)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{
|
||||
`{"name":"x","identity":true}`,
|
||||
`{"name":"x","identity":{"max":20,"in":"y","most":3}}`,
|
||||
} {
|
||||
var o Offer
|
||||
if err := json.Unmarshal([]byte(raw), &o); err == nil {
|
||||
t.Errorf("accepted %s", raw)
|
||||
}
|
||||
}
|
||||
bad := Manifest{Module: "bad", Version: "1", Provides: []Offer{
|
||||
{Name: "unsaid", Scope: ScopeMesh, Identity: &OfferIdentity{Max: 20}},
|
||||
{Name: "tiny", Scope: ScopeMesh, Identity: &OfferIdentity{Max: 4, In: "nothing usable"}},
|
||||
}}
|
||||
raw, err := json.Marshal(bad)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = ParseManifest(raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "without saying what keeps them") ||
|
||||
!strings.Contains(err.Error(), "the shortest the mesh makes") {
|
||||
t.Fatalf("a bound with no `in`, or too short for any identity, was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Refused before merge: the catalogue check judges each module's identity, on the longest machine
|
||||
// name, against the bound of every provision it wants — and names the module and the slug to set.
|
||||
func TestTheCatalogueCheckRefusesAnIdentityThatOverflowsWhatItRequires(t *testing.T) {
|
||||
shelf := Shelf{
|
||||
"objects": {Module: "objects", Provides: []Offer{{Name: "s3-bucket", Scope: ScopeMesh,
|
||||
Identity: &OfferIdentity{Max: 20, In: "an S3 access key"}}},
|
||||
Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}},
|
||||
"photoalbum": {Module: "photoalbum", Requires: []string{"s3-bucket"}},
|
||||
"files": {Module: "files", Requires: []string{"s3-bucket"}},
|
||||
}
|
||||
problems := IdentityProblems(shelf, 6)
|
||||
if len(problems) != 1 || !strings.Contains(problems[0], "photoalbum wants s3-bucket") ||
|
||||
!strings.Contains(problems[0], `"mesh_nnnnnn_photoalbum", 22`) ||
|
||||
!strings.Contains(problems[0], "`slug` of at most 8 characters") {
|
||||
t.Fatalf("one overflow, named with its remedy, was expected: %q", problems)
|
||||
}
|
||||
// A longer machine name refuses more: the check is about the mesh's machines, not one.
|
||||
if got := IdentityProblems(shelf, 10); len(got) != 2 {
|
||||
t.Fatalf("on a 10-character name both overflow (mesh_nnnnnnnnnn_files is 21): %q", got)
|
||||
}
|
||||
// And a slug is the remedy it names.
|
||||
album := shelf["photoalbum"]
|
||||
album.Slug = "album"
|
||||
shelf["photoalbum"] = album
|
||||
if got := IdentityProblems(shelf, 6); len(got) != 0 {
|
||||
t.Fatalf("a slug that fits is still refused: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Tonight's case (issue 263): networkmanager, no slug, requiring the mesh's resolver provision on a
|
||||
// machine with a six-character name. Under ADR 0049's one bound it was refused, and its provider's
|
||||
// whole machine with it; the resolver keeps no name, so it is not refused at all.
|
||||
func TestARequirementOnAKeylessProvisionComposesWithALongName(t *testing.T) {
|
||||
shelf := Shelf{
|
||||
"resolver": {Module: "resolver", Provides: []Offer{{Name: "wildcard-resolution", Scope: ScopeMesh}}},
|
||||
"networkmanager": {Module: "networkmanager", Requires: []string{"wildcard-resolution"}},
|
||||
}
|
||||
if CheckIdentity("laptop", "networkmanager") == nil {
|
||||
t.Fatal("the regression is not reproduced: mesh_laptop_networkmanager fits the old global bound")
|
||||
}
|
||||
if got := IdentityProblems(shelf, 6); len(got) != 0 {
|
||||
t.Fatalf("a requirement on a keyless provision was refused for its length: %q", got)
|
||||
}
|
||||
r := Resolution{Node: "laptop", Modules: []Manifest{shelf["networkmanager"]},
|
||||
Needs: []Needed{{Name: "wildcard-resolution", From: "anchor", For: "networkmanager",
|
||||
Identity: shelf["resolver"].IdentityBoundOf("wildcard-resolution")}}}
|
||||
if got := r.Overflowing(); len(got) != 0 {
|
||||
t.Fatalf("a keyless requirement is reported as overflowing: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The consumer's side of the same judgement the provider's composition makes: what `status` says.
|
||||
func TestAnOverflowingRequirementIsNamedFromTheConsumersSide(t *testing.T) {
|
||||
bound := IdentityBound{Max: 20, In: "an S3 access key"}
|
||||
r := Resolution{Node: "laptop",
|
||||
Modules: []Manifest{{Module: "photoalbum"}, {Module: "files"}, {Module: "gallery", Slug: "gal"}},
|
||||
Needs: []Needed{
|
||||
{Name: "s3-bucket", From: "anchor", For: "photoalbum", Identity: bound},
|
||||
{Name: "s3-bucket", From: "anchor", For: "photoalbum", Local: "second", Identity: bound},
|
||||
{Name: "s3-bucket", From: "anchor", For: "files", Identity: bound},
|
||||
{Name: "s3-bucket", From: "anchor", For: "gallery", Identity: bound},
|
||||
{Name: "licence", From: "records", For: "photoalbum", ByRecord: true, Identity: bound},
|
||||
}}
|
||||
got := r.Overflowing()
|
||||
if len(got) != 1 || got[0].Module != "photoalbum" || got[0].Identity != "mesh_laptop_photoalbum" ||
|
||||
got[0].Provider != "anchor" {
|
||||
t.Fatalf("one overflow, once, was expected: %+v", got)
|
||||
}
|
||||
if said := got[0].String(); !strings.Contains(said, "an S3 access key keeps 20") ||
|
||||
!strings.Contains(said, "slug") {
|
||||
t.Fatalf("the overflow does not say what keeps it or the remedy: %s", said)
|
||||
}
|
||||
}
|
||||
|
||||
// An identity served as a DNS label is never truncated, so the offer's bound must keep it in one.
|
||||
func TestAnIdentityServedAsADNSLabelIsBoundedToOne(t *testing.T) {
|
||||
serves := map[string]map[string]any{"bucket": {"name": "${consumer:as:dns}"}}
|
||||
wide := Manifest{Module: "wide", Serves: serves, Provides: []Offer{{Name: "bucket", Scope: ScopeMesh,
|
||||
Identity: &OfferIdentity{Max: 255, In: "a client id"}}}}
|
||||
if got := CheckServes(wide); len(got) != 1 || !strings.Contains(got[0], "a label keeps 63") {
|
||||
t.Fatalf("a 255-character bound on a DNS label passed: %q", got)
|
||||
}
|
||||
unsaid := Manifest{Module: "unsaid", Serves: serves, Provides: []Offer{{Name: "bucket", Scope: ScopeMesh}}}
|
||||
if got := CheckServes(unsaid); len(got) != 0 {
|
||||
t.Fatalf("the default bound (20) on a DNS label was refused: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import "testing"
|
||||
|
||||
// Each test names the decision it defends (novox/hq ADR 0017).
|
||||
|
||||
func TestASlugIsPreferredOverTheModuleName(t *testing.T) {
|
||||
// A module that declared a slug is identified by it, so a long name can be made to fit the
|
||||
// tightest backend without a hash (novox/hq ADR 0049).
|
||||
if got := IdentitySource("kc", "keycloak"); got != "kc" {
|
||||
t.Errorf("the slug was not preferred: %q", got)
|
||||
}
|
||||
if got := IdentitySource("", "redis"); got != "redis" {
|
||||
t.Errorf("without a slug, the name should be used: %q", got)
|
||||
}
|
||||
if ConsumerIdentity("anchor", IdentitySource("bkt", "bucketuser")) != "mesh_anchor_bkt" {
|
||||
t.Error("a slug did not shape the identity")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckIdentityFitsTheTightestBackend(t *testing.T) {
|
||||
// 20 is an S3 access key's limit (04-ISSUES/010), and the one that binds. mesh_anchor_keycloak
|
||||
// is exactly 20 and allowed; the un-slugged bucketuser is 22 and refused, with the remedy a slug.
|
||||
if err := CheckIdentity("anchor", "keycloak"); err != nil { // mesh_anchor_keycloak = 20
|
||||
t.Errorf("a 20-character identity was refused: %v", err)
|
||||
}
|
||||
if err := CheckIdentity("anchor", "bucketuser"); err == nil { // mesh_anchor_bucketuser = 22
|
||||
t.Error("an over-long identity was accepted")
|
||||
}
|
||||
// But with a slug it fits, and is accepted.
|
||||
if err := CheckIdentity("anchor", IdentitySource("bkt", "bucketuser")); err != nil {
|
||||
t.Errorf("a slugged identity that fits was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRefusalNamesTheRemedy(t *testing.T) {
|
||||
err := CheckIdentity("anchor", "bucketuser")
|
||||
if err == nil {
|
||||
t.Fatal("expected a refusal")
|
||||
}
|
||||
if !contains(err.Error(), "slug") {
|
||||
t.Errorf("the refusal did not point at the slug as the remedy: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A definition names no installation (novox/hq ADR 0112, ADR 0155). What the mesh acts on is judged;
|
||||
// prose is not; the world's registries and resolvers are the world's; a declared exception is a
|
||||
// reason a reader sees.
|
||||
func TestADefinitionNamingAnInstallationIsNamedBack(t *testing.T) {
|
||||
m := Manifest{Module: "idp", Resources: []map[string]any{
|
||||
{"id": "server", "type": "container", "image": "quay.io/keycloak/keycloak@sha256:aa",
|
||||
"env": map[string]any{"KC_HOSTNAME": "https://login.mesh-one.be"}},
|
||||
{"id": "env", "type": "file", "content": "REAL_IP_FROM=192.168.1.0/24,127.0.0.0/8,203.0.113.7,51.15.22.9\n"},
|
||||
}, Listens: []Listening{{Port: 8080, From: FromMesh, Why: "the login page; login.mesh-one.be is a route grant"}}}
|
||||
got := strings.Join(InstallationProblems(m), "\n")
|
||||
for _, want := range []string{
|
||||
"idp names login.mesh-one.be at resources[0].env.KC_HOSTNAME",
|
||||
"idp names the public address 51.15.22.9 at resources[1].content",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for _, mustNot := range []string{"quay.io", "why", "203.0.113.7", "192.168.1.0", "127.0.0.0"} {
|
||||
if strings.Contains(got, mustNot) {
|
||||
t.Errorf("%q was reported and should not be:\n%s", mustNot, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheWorldsNamesAreNotAnInstallations(t *testing.T) {
|
||||
m := Manifest{Module: "resolver", Resources: []map[string]any{
|
||||
{"id": "conf", "type": "file", "content": "server=1.1.1.1\nserver=8.8.8.8\nlisten=127.0.0.55\n"},
|
||||
{"id": "proxy", "type": "container", "image": "docker.io/library/traefik@sha256:bb"},
|
||||
{"id": "adapter", "type": "file", "content": "{\"machine\": \"host.docker.internal\"}\n"},
|
||||
{"id": "doc", "type": "file", "content": "root = https://git.example.tld/\n"},
|
||||
}}
|
||||
if got := InstallationProblems(m); len(got) != 0 {
|
||||
t.Fatalf("the world's names were reported: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANameMeantOnPurposeIsDeclaredWithItsReason(t *testing.T) {
|
||||
// The federation's public directory in a homeserver's config: the world's, said so, and a name
|
||||
// the map does not cover is still reported.
|
||||
m := Manifest{Module: "homeserver", Resources: []map[string]any{
|
||||
{"id": "conf", "type": "file", "content": "trusted_key_servers: matrix.org\nwell_known: https://mesh-one.be\n",
|
||||
NamesOnPurpose: map[string]any{"matrix.org": "the federation's public key server, the world's"}},
|
||||
}}
|
||||
got := InstallationProblems(m)
|
||||
if len(got) != 1 || !strings.Contains(got[0], "mesh-one.be") {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnImageFromAnInstallationsRegistryNeedsAReason(t *testing.T) {
|
||||
bare := Manifest{Module: "site", Resources: []map[string]any{
|
||||
{"id": "server", "type": "container", "image": "registry.mesh-one.be/org/site@sha256:cc"},
|
||||
}}
|
||||
if got := InstallationProblems(bare); len(got) != 1 || !strings.Contains(got[0], "registry.mesh-one.be") {
|
||||
t.Fatalf("an image on an installation's registry was not named: %v", got)
|
||||
}
|
||||
excepted := Manifest{Module: "site", Resources: []map[string]any{
|
||||
{"id": "server", "type": "container", "image": "registry.mesh-one.be/org/site@sha256:cc",
|
||||
NamesOnPurpose: map[string]any{"registry.mesh-one.be": "built outside the mesh until the site's repository is a build source here"}},
|
||||
}}
|
||||
if got := InstallationProblems(excepted); len(got) != 0 {
|
||||
t.Fatalf("a declared exception was still reported: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleNamedAfterADomainIsNamedBack(t *testing.T) {
|
||||
got := InstallationProblems(Manifest{Module: "mesh-one.be"})
|
||||
if len(got) != 1 || !strings.Contains(got[0], "named after mesh-one.be") {
|
||||
t.Fatalf("got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestABuildContextOnASeatNamesNoForge(t *testing.T) {
|
||||
m := Manifest{Module: "packager", Build: &Build{Artifacts: []Artifact{
|
||||
{Name: "server", Kind: "image", From: "Dockerfile",
|
||||
Context: &ArtifactContext{Seat: "git", Repository: "org/controller", Ref: "main"}},
|
||||
}}}
|
||||
if got := InstallationProblems(m); len(got) != 0 {
|
||||
t.Fatalf("a context on a seat was reported: %v", got)
|
||||
}
|
||||
m.Build.Artifacts[0].Context = &ArtifactContext{Repository: "https://git.mesh-one.be/org/controller.git"}
|
||||
if got := InstallationProblems(m); len(got) != 1 {
|
||||
t.Fatalf("a context by URL was not reported: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A manifest may say which tools its module calls (novox/hq ADR 0152), and the parser accepts the
|
||||
// two shapes the grant has: a named tool, and every tool.
|
||||
func TestAManifestMaySayWhatItInvokes(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(`{"module":"mesh-console","version":"1",` +
|
||||
`"invokes":["mesh-catalog.catalog_modules","*"]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m.Invokes) != 2 || m.Invokes[1] != "*" {
|
||||
t.Fatalf("invokes not read: %v", m.Invokes)
|
||||
}
|
||||
}
|
||||
|
||||
// An entry that names a module and no tool is refused at parse, in the manifest's words, rather than
|
||||
// at the composition of the bus's user list where it would stop the file for everybody.
|
||||
func TestAnInvokeThatNamesNoToolIsRefusedAtParse(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"mesh-console","version":"1","invokes":["shop"]}`))
|
||||
if err == nil {
|
||||
t.Fatal("an invoke naming no tool was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), `invokes "shop", which does not name a tool`) {
|
||||
t.Fatalf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A provision's credential may be issued by its provider's software (novox/hq ADR 0305, issue 526): the offer
|
||||
// names the tool that answers it and no own secret, and every other shape is refused at parse.
|
||||
func TestAnOfferMaySayItsSoftwareIssuesTheCredential(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(`{"module":"media","version":"1",
|
||||
"provides":[{"name":"media-api","scope":"mesh","credential":{"issued-by":"software","tool":"media_issued_credential"}}],
|
||||
"serves":{"media-api":{"port":32400}}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tool, issued := m.IssuedCredentialOf("media-api"); !issued || tool != "media_issued_credential" {
|
||||
t.Fatalf("the offer's issued credential was not read: %q %v", tool, issued)
|
||||
}
|
||||
if _, shared := m.SharedCredentialOf("media-api"); shared {
|
||||
t.Fatal("an issued credential was read as an own secret shared")
|
||||
}
|
||||
if _, issued := m.IssuedCredentialOf("other"); issued {
|
||||
t.Fatal("a provision the module does not offer reads as issued")
|
||||
}
|
||||
|
||||
for want, raw := range map[string]string{
|
||||
"the issuers an offer may name": `{"module":"d","version":"1",
|
||||
"provides":[{"name":"d-api","credential":{"issued-by":"vendor","tool":"d_token"}}]}`,
|
||||
"names no tool that answers it": `{"module":"d","version":"1",
|
||||
"provides":[{"name":"d-api","credential":{"issued-by":"software"}}]}`,
|
||||
"names its own secret": `{"module":"d","version":"1","own-secrets":{"token":{"path":"/p","taken":"at-start"}},
|
||||
"provides":[{"name":"d-api","credential":{"issued-by":"software","tool":"d_token","own":"token"}}]}`,
|
||||
"the issuers an offer may name ": `{"module":"d","version":"1",
|
||||
"provides":[{"name":"d-api","credential":{"tool":"d_token"}}]}`,
|
||||
} {
|
||||
want = strings.TrimSpace(want)
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("expected a refusal saying %q, got %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A provision's credential may be one only a person outside the mesh can give (novox/hq ADR 0337 §1, issue 164):
|
||||
// the offer says `issued-by: outside` and names neither an own secret nor a tool, and the parser refuses it beside
|
||||
// either. An offer that says `software` keeps ADR 0305's shape.
|
||||
func TestAnOfferMaySayAPersonOutsideTheMeshIssuesItsCredential(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(`{"module":"tv","version":"1",
|
||||
"provides":[{"name":"tv-api","scope":"mesh","credential":{"issued-by":"outside"}}],
|
||||
"serves":{"tv-api":{"port":8989}}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !m.IssuedOutsideOf("tv-api") {
|
||||
t.Fatal("the offer's credential issued outside was not read")
|
||||
}
|
||||
if m.IssuedOutsideOf("other") {
|
||||
t.Fatal("a provision the module does not offer reads as issued outside")
|
||||
}
|
||||
if _, issued := m.IssuedCredentialOf("tv-api"); issued {
|
||||
t.Fatal("a credential issued outside was read as issued by the software")
|
||||
}
|
||||
if _, shared := m.SharedCredentialOf("tv-api"); shared {
|
||||
t.Fatal("a credential issued outside was read as an own secret shared")
|
||||
}
|
||||
if who, offered := OffersIssuedOutside(map[string]Manifest{"tv": m}, "tv-api"); !offered || who != "tv" {
|
||||
t.Fatalf("the catalogue does not say tv offers tv-api issued outside: %q %v", who, offered)
|
||||
}
|
||||
|
||||
for want, raw := range map[string]string{
|
||||
"names its own secret": `{"module":"d","version":"1","own-secrets":{"key":{"path":"/p","taken":"at-start"}},
|
||||
"provides":[{"name":"d-api","credential":{"issued-by":"outside","own":"key"}}]}`,
|
||||
"names the tool": `{"module":"d","version":"1",
|
||||
"provides":[{"name":"d-api","credential":{"issued-by":"outside","tool":"d_key"}}]}`,
|
||||
"the issuers an offer may name": `{"module":"d","version":"1",
|
||||
"provides":[{"name":"d-api","credential":{"issued-by":"a-person"}}]}`,
|
||||
} {
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("expected a refusal saying %q, got %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A jail whose pattern names <HOST> twice is refused by name: fail2ban would read it as a duplicate
|
||||
// capture group and refuse its whole configuration, leaving the machine with no bans at all — which
|
||||
// is what happened on the control node the day this was built (novox/hq ADR 0179).
|
||||
func TestAJailsPatternNamesTheHostOncePerShape(t *testing.T) {
|
||||
twice := Manifest{Module: "route-proxy", Jails: []Jail{{
|
||||
Name: "route-proxy",
|
||||
Failregex: `^.*(?:refused for <HOST>:\d+|asked from <HOST>:\d+)$`,
|
||||
Jail: "backend = systemd",
|
||||
}}}
|
||||
problems := twice.jailProblems()
|
||||
if len(problems) != 1 || !strings.Contains(problems[0], "names <HOST> 2 times") {
|
||||
t.Fatalf("a pattern naming <HOST> twice was not refused by name: %v", problems)
|
||||
}
|
||||
if !strings.Contains(problems[0], "no bans at all") {
|
||||
t.Errorf("the refusal does not say what it costs: %q", problems[0])
|
||||
}
|
||||
|
||||
// The same two shapes, one pattern each, is how it is written — and is accepted.
|
||||
split := Manifest{Module: "route-proxy", Jails: []Jail{{
|
||||
Name: "route-proxy",
|
||||
Failregex: "^.*refused for <HOST>:\\d+$\n ^.*asked from <HOST>:\\d+$",
|
||||
Jail: "backend = systemd",
|
||||
}}}
|
||||
if problems := split.jailProblems(); len(problems) != 0 {
|
||||
t.Fatalf("one <HOST> per pattern was refused: %v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
// A jail with no name, no pattern, or a name another jail of the same module already took is refused.
|
||||
func TestAJailIsNamedOnceAndSaysWhatAFailureLooksLike(t *testing.T) {
|
||||
m := Manifest{Module: "mailu", Jails: []Jail{
|
||||
{Name: "", Failregex: "^.*rip=<HOST>$"},
|
||||
{Name: "mailu-front", Failregex: " "},
|
||||
{Name: "mailu-front", Failregex: "^.*rip=<HOST>$"},
|
||||
}}
|
||||
problems := strings.Join(m.jailProblems(), "\n")
|
||||
for _, want := range []string{"a jail with no name", "says nothing a failed attempt looks like", "two jails called"} {
|
||||
if !strings.Contains(problems, want) {
|
||||
t.Errorf("missing %q in:\n%s", want, problems)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,68 +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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeepsMustBeAnAbsolutePath(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"mesh-vault","version":"1","keeps":"root"}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "keeps") {
|
||||
t.Fatalf("a relative keeps path was not refused: %v", err)
|
||||
}
|
||||
if _, err := ParseManifest([]byte(`{"module":"mesh-vault","version":"1","keeps":"/var/lib/mesh-vault/root"}`)); err != nil {
|
||||
t.Fatalf("an absolute keeps path was refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"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"},
|
||||
State: []StateDeclaration{{Name: "asks"}}, RunsAs: "messenger", SecretsOwner: "messenger",
|
||||
DefinesSeats: []SeatDeclaration{
|
||||
{Name: "operator-channel", Scope: ScopeMesh, Accepts: []string{"ask", "cancel"}, Emits: []string{"decided"},
|
||||
ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"asks"},
|
||||
Serves: []Verb{{Name: "open"}, {Name: "history"}, {Name: "notify"}}},
|
||||
{Name: "channel", Scope: ScopeMesh, Kinded: true, Accepts: []string{"show", "edit", "send"}},
|
||||
{Name: "intake", Scope: ScopeMesh, Kinded: true, Emits: []string{"choice", "link"}, Proofs: []string{"code"}},
|
||||
},
|
||||
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}}}
|
||||
}
|
||||
|
||||
func TestTwoChannelsOfDifferentKindsHoldTheBenches(t *testing.T) {
|
||||
shelf := Shelf{"messenger": router(), "telegram": aChannel("telegram", "telegram"),
|
||||
"desk-channel": aChannel("desk-channel", "desktop")}
|
||||
if got := problemsFor(t, shelf); got != "" {
|
||||
t.Fatalf("two kinds were refused: %s", got)
|
||||
}
|
||||
for _, m := range shelf {
|
||||
if got := declaredSeatProblems(m); len(got) > 0 {
|
||||
t.Fatalf("%s: %v", m.Module, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestASecondClaimOfOneKindIsRefused(t *testing.T) {
|
||||
got := problemsFor(t, Shelf{"messenger": router(), "telegram": aChannel("telegram", "telegram"),
|
||||
"telegram-two": aChannel("telegram-two", "telegram")})
|
||||
if !strings.Contains(got, `of kind "telegram", which telegram already claims`) {
|
||||
t.Fatalf("a second holder of one kind stood: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKindedBenchNeedsAKindAndNoOtherSeatTakesOne(t *testing.T) {
|
||||
got := problemsFor(t, Shelf{"messenger": router(), "nameless": aChannel("nameless", "")})
|
||||
if !strings.Contains(got, "claims the kinded bench channel and names no kind") {
|
||||
t.Fatalf("a claim without a kind stood: %s", got)
|
||||
}
|
||||
odd := Manifest{Module: "odd", Claims: []Claim{{Name: "operator-channel", Scope: ScopeMesh, Kind: "telegram"}}}
|
||||
got = problemsFor(t, Shelf{"messenger": router(), "odd": odd})
|
||||
if !strings.Contains(got, "only a kinded bench takes a kind") {
|
||||
t.Fatalf("a kind on a seat that is not kinded stood: %s", got)
|
||||
}
|
||||
dotted := problemsFor(t, Shelf{"messenger": router(), "dotted": aChannel("dotted", "a.b")})
|
||||
if !strings.Contains(dotted, "not a usable name") {
|
||||
t.Fatalf("a kind that would widen a subject stood: %s", dotted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyChannelAndIntakeAreKinded(t *testing.T) {
|
||||
m := Manifest{Module: "x", DefinesSeats: []SeatDeclaration{{Name: "pager", Kinded: true, Accepts: []string{"page"}}}}
|
||||
if got := strings.Join(declaredSeatProblems(m), "; "); !strings.Contains(got, "only channel and intake are kinded") {
|
||||
t.Fatalf("another kinded bench was declared: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheNewRulesAreHeldToWhatTheSeatSays(t *testing.T) {
|
||||
m := Manifest{Module: "x", DefinesSeats: []SeatDeclaration{{Name: "thing", Accepts: []string{"do"},
|
||||
ByCaller: []string{"undo"}, Proofs: []string{"code"}, Records: []string{"nothing"}}}}
|
||||
got := strings.Join(declaredSeatProblems(m), "; ")
|
||||
for _, want := range []string{"names thing.undo by its caller, which the seat neither accepts nor emits",
|
||||
"declares proofs on thing, which is not kinded", `read its records "nothing", which it keeps no state of`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("not refused: %q in %s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
// A channel's capabilities come from the fixed vocabulary, and only a kinded claim carries any.
|
||||
func TestCapabilitiesAreTheVocabularysAndOnlyOnAKindedClaim(t *testing.T) {
|
||||
good := aChannel("telegram", "telegram")
|
||||
good.Claims[0].Capabilities = []string{"deliver", "choice", "verified-sender", "max-length:4096"}
|
||||
good.RunsAs = "telegram"
|
||||
if got := problemsFor(t, Shelf{"messenger": router(), "telegram": good}); got != "" {
|
||||
t.Fatalf("the vocabulary was refused: %s", got)
|
||||
}
|
||||
bad := aChannel("telegram", "telegram")
|
||||
bad.Claims[0].Capabilities = []string{"trusted", "max-length:lots"}
|
||||
got := problemsFor(t, Shelf{"messenger": router(), "telegram": bad})
|
||||
for _, w := range []string{`"trusted"`, `"max-length:lots"`} {
|
||||
if !strings.Contains(got, w+", which channel-capabilities/1 does not have") {
|
||||
t.Errorf("%s was not refused: %s", w, got)
|
||||
}
|
||||
}
|
||||
odd := Manifest{Module: "odd", Claims: []Claim{{Name: "operator-channel", Scope: ScopeMesh, Capabilities: []string{"deliver"}}}}
|
||||
if got := problemsFor(t, Shelf{"messenger": router(), "odd": odd}); !strings.Contains(got, "only a kinded bench's claim carries them") {
|
||||
t.Errorf("capabilities on a seat that is not kinded stood: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0259 §8: a module saying warrants, or speaking for a kind that proves its sender, runs as an
|
||||
// account of its own — never carried by the machine's runtime, which runs as the operator's account.
|
||||
func TestATrustedHolderMustRunAsAnAccountOfItsOwn(t *testing.T) {
|
||||
r := router()
|
||||
r.RunsAs = ""
|
||||
if got := problemsFor(t, Shelf{"messenger": r}); !strings.Contains(got, "messenger must run as an account of its own") {
|
||||
t.Errorf("a router on the machine's runtime stood: %s", got)
|
||||
}
|
||||
tg := aChannel("telegram", "telegram")
|
||||
tg.Claims[0].Capabilities = []string{"choice", "verified-sender"}
|
||||
if got := problemsFor(t, Shelf{"messenger": router(), "telegram": tg}); !strings.Contains(got, "telegram must run as an account of its own") {
|
||||
t.Errorf("a verified channel on the machine's runtime stood: %s", got)
|
||||
}
|
||||
desk := aChannel("desk-channel", "desktop")
|
||||
desk.Claims[0].Capabilities = []string{"choice"}
|
||||
if got := problemsFor(t, Shelf{"messenger": router(), "desk-channel": desk}); got != "" {
|
||||
t.Errorf("a channel proving nothing was held to it: %s", got)
|
||||
}
|
||||
// A kind that is `private` shows a link's code, which links an account as the operator: its holder is
|
||||
// trusted with it, so it runs as its own account too (the confirmation review of 2026-10-09).
|
||||
private := aChannel("desk-channel", "desktop")
|
||||
private.Claims[0].Capabilities = []string{"choice", "private"}
|
||||
if got := problemsFor(t, Shelf{"messenger": router(), "desk-channel": private}); !strings.Contains(got, "desk-channel must run as an account of its own") {
|
||||
t.Errorf("a private channel on the machine's runtime stood: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunsAsIsAnAccountOfTheModulesOwn(t *testing.T) {
|
||||
ok := Manifest{Module: "telegram", RunsAs: "telegram", SecretsOwner: "telegram",
|
||||
OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/telegram/broker"}},
|
||||
Resources: []map[string]any{{"id": "account", "type": "user", "name": "telegram"}}}
|
||||
if got := RunsAsProblems(ok); len(got) != 0 {
|
||||
t.Fatalf("a sound runs-as was refused: %v", got)
|
||||
}
|
||||
for want, change := range map[string]func(*Manifest){
|
||||
"never root": func(m *Manifest) { m.RunsAs, m.SecretsOwner = "root", "root" },
|
||||
"not an account name": func(m *Manifest) { m.RunsAs = "${machine:account}" },
|
||||
"which it does not make": func(m *Manifest) { m.Resources = nil },
|
||||
"declares no own secret": func(m *Manifest) { m.OwnSecrets = nil },
|
||||
"they are the account's own": func(m *Manifest) { m.SecretsOwner = "" },
|
||||
} {
|
||||
m := ok
|
||||
m.Resources = append([]map[string]any(nil), ok.Resources...)
|
||||
m.OwnSecrets = OwnSecrets{"broker": {Path: "/x"}}
|
||||
change(&m)
|
||||
if got := strings.Join(RunsAsProblems(m), "; "); !strings.Contains(got, want) {
|
||||
t.Errorf("want %q, got %q", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,195 +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)
|
||||
}
|
||||
}
|
||||
|
||||
// `local-network` is a source a manifest may name (§1, changing ADR 0045 §3), and an unknown source is
|
||||
// still refused.
|
||||
func TestAManifestMayListenFromTheLocalNetwork(t *testing.T) {
|
||||
if _, err := ParseManifest([]byte(`{"module":"mosquitto","version":"1",
|
||||
"listens":[{"port":1883,"from":"local-network"}]}`)); err != nil {
|
||||
t.Fatalf("a listen from the local network is refused: %v", err)
|
||||
}
|
||||
_, err := ParseManifest([]byte(`{"module":"mosquitto","version":"1",
|
||||
"listens":[{"port":1883,"from":"lan"}]}`))
|
||||
if err == nil || !strings.Contains(err.Error(), `from "lan"`) {
|
||||
t.Fatalf("a source the mesh cannot render is not refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// **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,41 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import "testing"
|
||||
|
||||
// The login shell's `execute` runs any command as the operator account, which can become root without a
|
||||
// person, so the operator withheld it on the control-node until a call to it needs a person's approval
|
||||
// (novox/hq ADR 0268). It is withheld per machine by the holder's own setting, so the seat must let a
|
||||
// holder hold it without serving the verb there: `execute` is optional (ADR 0246's mark), and stays so in
|
||||
// a seat row read back from the store, which never keeps the mark.
|
||||
func TestTheLoginShellsExecuteIsOptionalSoAMachineMayWithholdIt(t *testing.T) {
|
||||
check := func(t *testing.T) {
|
||||
t.Helper()
|
||||
seat, ok := SeatNamed(LoginShellSeat)
|
||||
if !ok {
|
||||
t.Fatal("node-login-shell is not defined")
|
||||
}
|
||||
if len(seat.Serves) != 1 || seat.Serves[0].Name != "execute" {
|
||||
t.Fatalf("the login shell promises %+v, not execute alone", seat.Serves)
|
||||
}
|
||||
if !seat.Serves[0].Optional {
|
||||
t.Fatal("execute is required, so a holder that withholds it on one machine could not hold the seat there")
|
||||
}
|
||||
withholding := Manifest{Module: "zsh", Version: "1", Claims: []Claim{{Name: LoginShellSeat, Scope: ScopeNode}}}
|
||||
if err := CanHold(withholding, seat); err != nil {
|
||||
t.Fatalf("a holder serving no execute is refused: %v", err)
|
||||
}
|
||||
serving := withholding
|
||||
serving.Claims = []Claim{{Name: LoginShellSeat, Scope: ScopeNode, Serves: []string{"execute"}}}
|
||||
if err := CanHold(serving, seat); err != nil {
|
||||
t.Fatalf("a holder serving execute is refused: %v", err)
|
||||
}
|
||||
}
|
||||
t.Run("compiled", check)
|
||||
t.Run("read back from the store", func(t *testing.T) {
|
||||
before := seats
|
||||
t.Cleanup(func() { seats = before })
|
||||
UseSeats([]Seat{{Name: LoginShellSeat, Scope: ScopeNode, Decision: "novox/hq ADR 0204",
|
||||
Serves: []Verb{{Name: "execute"}}}})
|
||||
check(t)
|
||||
})
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A module declares the logs it keeps (novox/hq ADR 0297 §1): a log by its bare name, or with its cap
|
||||
// in MiB; the default cap where none is said.
|
||||
func TestAManifestMaySayWhatLogsItKeeps(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(`{"module":"mesh-issues","version":"1",` +
|
||||
`"logs":["changes",{"name":"moves","max-mib":2048}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m.Logs) != 2 || m.Logs[0].Name != "changes" || m.Logs[1].Name != "moves" {
|
||||
t.Fatalf("logs not read: %+v", m.Logs)
|
||||
}
|
||||
if m.Logs[0].Cap() != LogDefaultMiB || m.Logs[0].Cap() != 1024 || m.Logs[1].Cap() != 2048 {
|
||||
t.Fatalf("caps read as %d and %d", m.Logs[0].Cap(), m.Logs[1].Cap())
|
||||
}
|
||||
out, _ := json.Marshal(m.Logs)
|
||||
if string(out) != `["changes",{"name":"moves","max-mib":2048}]` {
|
||||
t.Fatalf("written back as %s", out)
|
||||
}
|
||||
for _, edge := range []string{`{"name":"a","max-mib":1}`, `{"name":"a","max-mib":8192}`} {
|
||||
if _, err := ParseManifest([]byte(`{"module":"a","version":"1","logs":[` + edge + `]}`)); err != nil {
|
||||
t.Errorf("%s is inside the caps and was refused: %v", edge, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The catalogue check refuses a log outside the caps, with a name that is not one plain token, with a
|
||||
// field the mesh does not know, or kept by a module whose own name is not one plain token.
|
||||
func TestALogIsNamedLocallyAndCapped(t *testing.T) {
|
||||
for _, c := range []struct{ manifest, says string }{
|
||||
{`{"module":"a","version":"1","logs":["mesh.changes"]}`, `keeps log "mesh.changes": a log is named locally`},
|
||||
{`{"module":"a","version":"1","logs":["my_changes"]}`, `keeps log "my_changes"`},
|
||||
{`{"module":"a","version":"1","logs":["Changes"]}`, `keeps log "Changes"`},
|
||||
{`{"module":"a","version":"1","logs":["c","c"]}`, `keeps log "c" twice`},
|
||||
{`{"module":"a","version":"1","logs":[{"name":"c","max-mib":8193}]}`, `between 1 and 8192 MiB`},
|
||||
{`{"module":"a","version":"1","logs":[{"name":"c","max-mib":-1}]}`, `between 1 and 8192 MiB`},
|
||||
{`{"module":"a","version":"1","logs":[{"name":"c","max-mib":0}]}`, `max-mib is between 1 and 8192, not 0`},
|
||||
{`{"module":"a","version":"1","logs":[{"name":"c","stream":"LOG_x"}]}`, `{name, max-mib}`},
|
||||
{`{"module":"a.b","version":"1","logs":["c"]}`, `no dot`},
|
||||
} {
|
||||
_, err := ParseManifest([]byte(c.manifest))
|
||||
if err == nil {
|
||||
t.Errorf("%s was accepted", c.manifest)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%s refused for the wrong reason: %v", c.manifest, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **Across the whole catalogue**: every log is named locally and capped within the mesh's caps.
|
||||
func TestEveryManifestsLogsAreLocalAndCapped(t *testing.T) {
|
||||
var problems []string
|
||||
for _, m := range theCatalogue(t) {
|
||||
problems = append(problems, LogProblems(m)...)
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
t.Fatalf("the catalogue's logs are not what ADR 0297 says:\n %s", strings.Join(problems, "\n "))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import "testing"
|
||||
|
||||
// A module with nothing that publishes binds what it binds, and the mesh may not move it.
|
||||
//
|
||||
// This is the case that made novox/hq 04-ISSUES/028's fix wrong on its first pass: a port was
|
||||
// assigned to every module that declared one, so a service listening directly had the rule set
|
||||
// opened on a number nothing was listening on, and its real port shut. The firewall reported
|
||||
// success and blocked the service, which is the exact failure the mechanism exists to prevent.
|
||||
func TestAPortNothingPublishesIsNotTheMeshsToMove(t *testing.T) {
|
||||
m := Manifest{Module: "talker", Listens: []Listening{{Port: 9101, From: FromMesh}}}
|
||||
at, mayAssign := m.MachineSide(9101)
|
||||
if mayAssign {
|
||||
t.Fatal("the mesh took a port it cannot move: nothing translates it, so assigning one " +
|
||||
"opens the wrong number and leaves the service unreachable")
|
||||
}
|
||||
if at != 9101 {
|
||||
t.Fatalf("a port nothing publishes reaches the machine where it binds, not at %d", at)
|
||||
}
|
||||
}
|
||||
|
||||
// A container publishing in short form is exactly the case the mesh may choose.
|
||||
func TestAContainerPublishingShortIsTheMeshsToChoose(t *testing.T) {
|
||||
m := Manifest{Module: "store", Resources: []map[string]any{
|
||||
{"type": "container", "id": "server", "ports": []any{"5432"}},
|
||||
}}
|
||||
if _, mayAssign := m.MachineSide(5432); !mayAssign {
|
||||
t.Fatal("a container's mapping is what translates a port, so this one is the mesh's to " +
|
||||
"choose; refusing it puts every module back on a number it guessed")
|
||||
}
|
||||
}
|
||||
|
||||
// A manifest that wrote its own mapping already chose, and the machine side is the outer one.
|
||||
func TestAMappingTheManifestWroteIsNotReassigned(t *testing.T) {
|
||||
m := Manifest{Module: "mail", Resources: []map[string]any{
|
||||
{"type": "container", "id": "front", "ports": []any{"7080:80"}},
|
||||
}}
|
||||
for _, named := range []int{7080, 80} {
|
||||
at, mayAssign := m.MachineSide(named)
|
||||
if mayAssign {
|
||||
t.Fatalf("%d was reassigned though the manifest published it explicitly, which "+
|
||||
"would open a rule on a port the container does not publish", named)
|
||||
}
|
||||
if at != 7080 {
|
||||
t.Fatalf("naming %d gave %d; the machine side of 7080:80 is 7080", named, at)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A port some other container publishes is not this port.
|
||||
func TestAPortNotInTheMappingIsNotFound(t *testing.T) {
|
||||
m := Manifest{Module: "mail", Resources: []map[string]any{
|
||||
{"type": "container", "id": "front", "ports": []any{"25", "7080:80"}},
|
||||
}}
|
||||
if at, mayAssign := m.MachineSide(993); mayAssign || at != 993 {
|
||||
t.Fatalf("993 is published by nothing here, so it binds where it binds: got %d, %v",
|
||||
at, mayAssign)
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/beside"
|
||||
)
|
||||
|
||||
// A bind mount the module never declared is refused where it is written (novox/hq 04-ISSUES/026,
|
||||
// ADR 0091). The container runtime creates a missing bind source itself, as root, with a mode it
|
||||
// picks, so the module's own owner and mode never reach the directory holding its data, and the
|
||||
// rule that keeps data when a module goes away (ADR 0030) does not cover it.
|
||||
|
||||
const aContainerMounting = `{"id":"server","type":"container","name":"store","image":"x@sha256:` +
|
||||
`0000000000000000000000000000000000000000000000000000000000000000","volumes":["%s:/inside"]}`
|
||||
|
||||
func manifestMounting(from string, beside string) []byte {
|
||||
res := aContainerMounting
|
||||
if beside != "" {
|
||||
res = beside + "," + res
|
||||
}
|
||||
return []byte(`{"module":"store","resources":[` + strings.Replace(res, "%s", from, 1) + `]}`)
|
||||
}
|
||||
|
||||
func TestAMountNothingDeclaresIsRefused(t *testing.T) {
|
||||
_, err := ParseManifest(manifestMounting("/services/store/data", ""))
|
||||
if err == nil {
|
||||
t.Fatal("a container mounting a path no resource declares was accepted; the runtime " +
|
||||
"would create it as root and the module's owner and mode would never apply")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "/services/store/data") {
|
||||
t.Fatalf("refused without naming the path, which leaves the author guessing: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Declaring it is enough — including declaring the directory above it.
|
||||
func TestAMountUnderADeclaredDirectoryIsAccepted(t *testing.T) {
|
||||
_, err := ParseManifest(manifestMounting("/services/store/data",
|
||||
`{"id":"state","type":"directory","path":"/services/store","mode":"0700"}`))
|
||||
if err != nil {
|
||||
t.Fatalf("a module that said where its data lives was refused anyway: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The operator's path, granted for use (ADR 0051), is declared by `accesses`, not by a directory the
|
||||
// module would then own.
|
||||
func TestAMountOfAnAccessedPathIsAccepted(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"store","accesses":[{"path":"/services/media/movies","mode":"read"}],` +
|
||||
`"resources":[` + strings.Replace(aContainerMounting, "%s", "/services/media/movies", 1) + `]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("a mount of a path the module declares it accesses was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The machine's facility — the container runtime's socket — is granted by the capability that names
|
||||
// it, and declaring it as the module's own directory would be a lie the host would act on. This is
|
||||
// the case the first version of this check refused, and was withdrawn for: the builder.
|
||||
func TestTheRuntimeSocketIsGrantedByTheCapabilityAndNotOtherwise(t *testing.T) {
|
||||
granted := `{"module":"builder","capabilities":["container-runtime"],"resources":[` +
|
||||
strings.Replace(aContainerMounting, "%s", "/var/run/docker.sock", 1) + `]}`
|
||||
if _, err := ParseManifest([]byte(granted)); err != nil {
|
||||
t.Fatalf("a module with the container-runtime capability may mount its socket: %v", err)
|
||||
}
|
||||
if _, err := ParseManifest(manifestMounting("/var/run/docker.sock", "")); err == nil {
|
||||
t.Fatal("a module mounted the container runtime's socket without declaring the capability, and was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// **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, _ := filepath.Glob(filepath.Join(beside.Catalogue(t), "*", "module.json"))
|
||||
if len(files) == 0 {
|
||||
t.Fatal("no manifests in the catalogue, so this proved nothing")
|
||||
}
|
||||
root := beside.Dir(t, "mesh-catalog") // once: it copies the captured tree
|
||||
for _, file := range files {
|
||||
if _, err := ReadTreeManifest(root, filepath.Dir(file)); err != nil {
|
||||
t.Errorf("%s: %v", filepath.Base(filepath.Dir(file)), 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 := ParseManifest([]byte(`{"module":"store","requires":["model-access"],` +
|
||||
`"binds":{"model-access":"/var/lib/store/model.json"},"resources":[` +
|
||||
strings.Replace(aContainerMounting, "%s", "/var/lib/store/model.json", 1) + `]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("a mount of the file the mesh writes a binding to was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The file a filter module's rule set is written to is declared by `filtering.into` (novox/hq ADR
|
||||
// 0169): the module's runtime mounts it to reload the mesh's own table, and nothing else declares it.
|
||||
func TestAMountOfTheFilterFileIsDeclaredByFilteringInto(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"nftables","filtering":{"into":"/etc/nftables.conf"},` +
|
||||
`"resources":[` + strings.Replace(aContainerMounting, "%s", "/etc/nftables.conf", 1) + `]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("a filter module mounting its own filter file was refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
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 {
|
||||
for _, h := range given {
|
||||
out = append(out, h.(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,75 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A provision names what the consumer is coupled to (novox/hq ADR 0027).
|
||||
//
|
||||
// The fault this defends against does not look like a fault: resolution succeeds, the module
|
||||
// deploys, and the first query fails somewhere else entirely. Every earlier test had exactly one
|
||||
// provider of each name, so no mismatch was expressible and none was caught.
|
||||
func TestAModuleMayNotProvideAGenericDatabase(t *testing.T) {
|
||||
for _, hidden := range []string{"database", "db", "sql", "sql-database"} {
|
||||
_, err := ParseManifest([]byte(`{"module":"postgres","version":"1",
|
||||
"provides":[{"name":"` + hidden + `","scope":"mesh"}]}`))
|
||||
if err == nil {
|
||||
t.Fatalf("providing %q was accepted; a requirement for it matches any engine", hidden)
|
||||
}
|
||||
for _, want := range []string{hidden, "postgres-database", "first query"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("refusing %q did not mention %q, so nobody learns what to write:\n%v",
|
||||
hidden, want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The rule is about coupling, not about specificity everywhere. Naming `route` after a particular
|
||||
// proxy would be the same error in the other direction.
|
||||
func TestARoleNameIsFineWhereTheConsumerCannotTellTheDifference(t *testing.T) {
|
||||
for _, role := range []string{"route", "resolver", "artifact-store", "postgres-database"} {
|
||||
if _, err := ParseManifest([]byte(`{"module":"m","version":"1",
|
||||
"provides":[{"name":"` + role + `","scope":"mesh"}]}`)); err != nil {
|
||||
t.Errorf("providing %q was refused, and it names what a consumer actually gets: %v",
|
||||
role, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A module may not declare an action, and it is refused where it was written.
|
||||
//
|
||||
// The host refuses one arriving over the link, which is the bound the whole security argument
|
||||
// rests on (novox/hq ADR 0005) and is enforced in the right place. But a module's resources reach
|
||||
// a machine *over* the link, so a manifest carrying an action used to be accepted here, stored,
|
||||
// resolved and pushed — and then refused on the machine, in a log, with nothing tying it back to
|
||||
// the manifest. Enforced at the far end only is enforced and not usable.
|
||||
func TestAModuleMayNotDeclareAnAction(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"probe","version":"1","resources":[
|
||||
{"id":"migrate","type":"action","command":["/bin/true"],"verify":["/bin/true"]}]}`))
|
||||
if err == nil {
|
||||
t.Fatal("a manifest declaring an action was accepted, and the machine would refuse it")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "may not") || !strings.Contains(err.Error(), "0005") {
|
||||
t.Errorf("the refusal does not say what rule it is: %v", err)
|
||||
}
|
||||
// And it says what to do instead, because "you may not" without an alternative is where a
|
||||
// module author stops.
|
||||
if !strings.Contains(err.Error(), "ships a program") {
|
||||
t.Errorf("the refusal names no alternative: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Every other shape a module may declare still passes, so the check above bounds one thing.
|
||||
func TestTheOtherShapesAreStillAccepted(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"probe","version":"1","resources":[
|
||||
{"id":"d","type":"directory","path":"/var/lib/probe","mode":"0700"},
|
||||
{"id":"f","type":"file","path":"/var/lib/probe/x","content":"x\n"},
|
||||
{"id":"n","type":"network","name":"probe"},
|
||||
{"id":"c","type":"container","name":"probe","image":"probe@sha256:` +
|
||||
`0000000000000000000000000000000000000000000000000000000000000000"}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("an ordinary manifest was refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,124 +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 TestANeedIsAnAbsolutePath(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"postgres","version":"1",
|
||||
"own-secrets":{"superuser":"superuser.txt"}}`))
|
||||
if err == nil {
|
||||
t.Fatal("a relative path was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "absolute path") {
|
||||
t.Fatalf("unhelpful refusal: %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)
|
||||
}
|
||||
}
|
||||
|
||||
// A manifest written against the old name is told what the field became.
|
||||
//
|
||||
// `needs` and `secrets` were both name-to-path and differed only in whose secret it was, so
|
||||
// reaching for the wrong one parsed cleanly and failed somewhere else entirely. Refusing the old
|
||||
// name with "unknown field" would be correct and unhelpful: whoever wrote it knew what they meant,
|
||||
// and the mesh knows what it is called now.
|
||||
func TestAManifestUsingTheOldNameIsToldTheNewOne(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(
|
||||
`{"module":"postgres","version":"1","needs":{"superuser":"/var/lib/superuser"}}`))
|
||||
if err == nil {
|
||||
t.Fatal("a manifest using the old name was accepted, so two fields now mean one thing")
|
||||
}
|
||||
for _, want := range []string{"needs", "own-secrets"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("the refusal does not mention %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// And an ordinary unknown key is still refused as one, rather than being guessed at.
|
||||
func TestAnInventedKeyIsNotTreatedAsARename(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"postgres","version":"1","nonsense":{}}`))
|
||||
if err == nil {
|
||||
t.Fatal("an invented key was accepted")
|
||||
}
|
||||
if strings.Contains(err.Error(), "is now called") {
|
||||
t.Fatalf("an invented key was reported as a rename: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/beside"
|
||||
)
|
||||
|
||||
// **A manifest holds no subject** (novox/hq design 29 §1).
|
||||
//
|
||||
// A module names its events, tools and seats locally, and the mesh derives where they land. The
|
||||
// property that buys: reorganise the subject space and every manifest in the catalogue is still
|
||||
// correct. It holds today by construction — nothing reads a subject from a manifest — and a rule
|
||||
// held by construction is one a later field breaks quietly, with the symptom appearing as a
|
||||
// permission that does not match a subject rather than as a manifest that was wrong.
|
||||
func TestNoManifestContainsASubject(t *testing.T) {
|
||||
root := beside.Catalogue(t)
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Anything in the mesh's own subject space, and anything shaped like a wire address.
|
||||
subject := regexp.MustCompile(`^(mesh|\$JS)\.[a-zA-Z0-9_*>.-]+$`)
|
||||
|
||||
var found []string
|
||||
var walk func(module string, path string, v any)
|
||||
walk = func(module, path string, v any) {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
if subject.MatchString(t) {
|
||||
found = append(found, module+" "+path+" = "+t)
|
||||
}
|
||||
case map[string]any:
|
||||
for k, inner := range t {
|
||||
walk(module, path+"."+k, inner)
|
||||
}
|
||||
case []any:
|
||||
for _, inner := range t {
|
||||
walk(module, path+"[]", inner)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checked := 0
|
||||
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 any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Errorf("%s: %v", e.Name(), err)
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
walk(e.Name(), "", m)
|
||||
}
|
||||
if checked == 0 {
|
||||
t.Fatal("no manifests read, so this proved nothing")
|
||||
}
|
||||
if len(found) > 0 {
|
||||
t.Errorf("a manifest names a subject, so reorganising the subject space would mean "+
|
||||
"editing the catalogue:\n %s", strings.Join(found, "\n "))
|
||||
}
|
||||
t.Logf("%d manifests hold no subject", checked)
|
||||
}
|
||||
|
||||
// **Every module's event names are what design 29 says, across the whole catalogue.**
|
||||
//
|
||||
// The rule above holds by construction and turned out to be weaker than it reads: a manifest holds
|
||||
// no subject, and every manifest in the catalogue still held the old bus's routing key, which
|
||||
// derives into a namespace nobody owns (novox/hq 04-ISSUES/127). Nothing failed — the services
|
||||
// started and none of them reacted. This is the check that was missing.
|
||||
func TestEveryManifestsEventNamesAreLocal(t *testing.T) {
|
||||
manifests := theCatalogue(t)
|
||||
|
||||
var problems []string
|
||||
for _, m := range manifests {
|
||||
problems = append(problems, EventProblems(m)...)
|
||||
}
|
||||
if len(problems) > 0 {
|
||||
t.Fatalf("the catalogue holds %d event name(s) the mesh would derive wrongly:\n %s",
|
||||
len(problems), strings.Join(problems, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// 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,216 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0247: a machine's own resolver is a node seat, held only where something requires
|
||||
// `split-dns`; where it is held it writes the resolver file and the uplink's holder steps back from it.
|
||||
|
||||
// The seat: node-scoped, decided by ADR 0247, and its three verbs required of every holder — a holder
|
||||
// exists only once the module serving them does, so nothing has to be optional while it catches up.
|
||||
func TestTheMachinesOwnResolverIsANodeSeatWithItsVerbs(t *testing.T) {
|
||||
s, ok := SeatNamed(ResolverSeat)
|
||||
if !ok {
|
||||
t.Fatalf("%s is not in the mesh's set", ResolverSeat)
|
||||
}
|
||||
if s.Scope != ScopeNode || s.Decision != "novox/hq ADR 0247" || s.Delivers != "" || s.Replicated {
|
||||
t.Errorf("%s is %+v; a node seat under ADR 0247 that delivers nothing", ResolverSeat, s)
|
||||
}
|
||||
var got []string
|
||||
for _, v := range s.Serves {
|
||||
got = append(got, v.Name)
|
||||
if v.Optional {
|
||||
t.Errorf("%s.%s is optional; its first holder serves it", ResolverSeat, v.Name)
|
||||
}
|
||||
if v.Description == "" || v.Input["type"] != "object" || len(v.Replaces) == 0 {
|
||||
t.Errorf("%s.%s has no description, no object schema or says it replaces nothing", ResolverSeat, v.Name)
|
||||
}
|
||||
}
|
||||
if strings.Join(got, " ") != "routes route unroute" {
|
||||
t.Errorf("%s serves %v, not routes, route and unroute", ResolverSeat, got)
|
||||
}
|
||||
// The verbs name a link, domains and servers, and never a VPN: the resolver knows nothing of one.
|
||||
for _, v := range s.Serves {
|
||||
if strings.Contains(strings.ToLower(v.Description), "forti") {
|
||||
t.Errorf("%s.%s names a VPN client: %s", ResolverSeat, v.Name, v.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"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) {
|
||||
seat, ok := SeatNamed("node-service-manager")
|
||||
if !ok {
|
||||
t.Fatal("node-service-manager is not a seat the mesh defines")
|
||||
}
|
||||
if seat.Scope != ScopeNode {
|
||||
t.Fatalf("the service manager is a role each machine has once, and the seat is %s-scoped", seat.Scope)
|
||||
}
|
||||
want := []string{"units", "status", "start", "stop", "restart", "enable", "disable", "journal", "failed", "reset-failed", "wanted-by", "unlink-dangling"}
|
||||
var got []string
|
||||
for _, v := range seat.Serves {
|
||||
got = append(got, v.Name)
|
||||
if v.Description == "" || v.Input == nil {
|
||||
t.Fatalf("%s is promised without a description or a schema", v.Name)
|
||||
}
|
||||
props, _ := v.Input["properties"].(map[string]any)
|
||||
if _, has := props["scope"]; !has {
|
||||
t.Fatalf("%s takes no scope, and a user unit could not be asked for", v.Name)
|
||||
}
|
||||
}
|
||||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("the seat serves %v, not %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// **`failed` joins the seat optional** (the operator's direction 2026-10-07): the holder running today,
|
||||
// which does not serve it, still holds the seat; the holder that serves it is not refused; and a verb
|
||||
// the seat does not promise is still refused, and one it requires is still required.
|
||||
func TestFailedIsAnOptionalVerbOfTheServiceManager(t *testing.T) {
|
||||
seat, _ := SeatNamed(ServiceManagerSeat)
|
||||
holder := func(serves ...string) Manifest {
|
||||
return Manifest{Module: "systemd", Version: "1", Claims: []Claim{{Name: ServiceManagerSeat, Scope: ScopeNode, Serves: serves}}}
|
||||
}
|
||||
eight := []string{"units", "status", "start", "stop", "restart", "enable", "disable", "journal",
|
||||
"reset-failed", "wanted-by", "unlink-dangling"}
|
||||
if err := CanHold(holder(eight...), seat); err != nil {
|
||||
t.Fatalf("a holder without failed is refused: %v", err)
|
||||
}
|
||||
if err := CanHold(holder(append(eight, "failed")...), seat); err != nil {
|
||||
t.Fatalf("a holder serving failed is refused: %v", err)
|
||||
}
|
||||
if err := CanHold(holder(append(eight, "fail")...), seat); err == nil || !strings.Contains(err.Error(), "does not promise") {
|
||||
t.Fatalf("a verb the seat does not promise was accepted: %v", err)
|
||||
}
|
||||
if err := CanHold(holder(eight[1:]...), seat); err == nil || !strings.Contains(err.Error(), "does not serve units") {
|
||||
t.Fatalf("a holder missing a required verb was accepted: %v", err)
|
||||
}
|
||||
optional := map[string]bool{"failed": true}
|
||||
for _, v := range seat.Serves {
|
||||
if v.Optional != optional[v.Name] {
|
||||
t.Errorf("%s optional: %v", v.Name, v.Optional)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// **`reset-failed`, `wanted-by` and `unlink-dangling` are required** (novox/hq issue 332, ADR 0246 step 3):
|
||||
// every holder serves them, so a holder serving only the nine verbs before them is refused by the verb it
|
||||
// lacks; a holder serving all twelve holds the seat; each replaces the shell command it names; and read
|
||||
// back from the store's row they stay required.
|
||||
func TestResetFailedWantedByAndUnlinkDanglingAreRequiredVerbsOfTheServiceManager(t *testing.T) {
|
||||
defer UseSeats(DefaultSeats())
|
||||
nine := []string{"units", "status", "start", "stop", "restart", "enable", "disable", "journal", "failed"}
|
||||
all := append(slices.Clone(nine), "reset-failed", "wanted-by", "unlink-dangling")
|
||||
holder := func(serves ...string) Manifest {
|
||||
return Manifest{Module: "systemd", Version: "1", Claims: []Claim{{Name: ServiceManagerSeat, Scope: ScopeNode, Serves: serves}}}
|
||||
}
|
||||
refusedWithout := func(seat Seat) {
|
||||
t.Helper()
|
||||
for _, verb := range []string{"reset-failed", "wanted-by", "unlink-dangling"} {
|
||||
serves := slices.DeleteFunc(slices.Clone(all), func(v string) bool { return v == verb })
|
||||
if err := CanHold(holder(serves...), seat); err == nil || !strings.Contains(err.Error(), "does not serve "+verb) {
|
||||
t.Errorf("a holder not serving %s holds the seat: %v", verb, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
seat, _ := SeatNamed(ServiceManagerSeat)
|
||||
if err := CanHold(holder(nine...), seat); err == nil {
|
||||
t.Fatal("a holder serving only the nine verbs before issue 332 holds the seat")
|
||||
}
|
||||
refusedWithout(seat)
|
||||
if err := CanHold(holder(all...), seat); err != nil {
|
||||
t.Fatalf("a holder serving all twelve is refused: %v", err)
|
||||
}
|
||||
if err := CanHold(holder(append(slices.Clone(all), "why-started")...), seat); err == nil || !strings.Contains(err.Error(), "does not promise") {
|
||||
t.Fatalf("a verb the seat does not promise was accepted: %v", err)
|
||||
}
|
||||
replaces := map[string]string{"reset-failed": "systemctl reset-failed", "wanted-by": "systemctl list-dependencies --reverse",
|
||||
"unlink-dangling": "rm ~/.config/systemd/user/*.wants/<unit>"}
|
||||
for _, v := range seat.Serves {
|
||||
want, ok := replaces[v.Name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if v.Optional {
|
||||
t.Errorf("%s is still optional", v.Name)
|
||||
}
|
||||
if !slices.Contains(v.Replaces, want) {
|
||||
t.Errorf("%s does not say it replaces %q: %v", v.Name, want, v.Replaces)
|
||||
}
|
||||
props, _ := v.Input["properties"].(map[string]any)
|
||||
if _, has := props["unit"]; !has {
|
||||
t.Errorf("%s takes no unit", v.Name)
|
||||
}
|
||||
if v.Name == "unlink-dangling" {
|
||||
// Removing is the person's act: their reason is required, and kept with the record.
|
||||
if required, _ := v.Input["required"].([]string); !slices.Contains(required, "why") {
|
||||
t.Errorf("unlink-dangling does not require why: %v", v.Input["required"])
|
||||
}
|
||||
}
|
||||
delete(replaces, v.Name)
|
||||
}
|
||||
if len(replaces) > 0 {
|
||||
t.Fatalf("the seat does not promise %v", replaces)
|
||||
}
|
||||
// The row as seeding stores it: every verb, the mark not a column.
|
||||
var rows []Seat
|
||||
for _, s := range DefaultSeats() {
|
||||
row := s
|
||||
row.Serves = nil
|
||||
for _, v := range s.Serves {
|
||||
row.Serves = append(row.Serves, Verb{Name: v.Name, Description: v.Description, Input: v.Input})
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
UseSeats(rows)
|
||||
seat, _ = SeatNamed(ServiceManagerSeat)
|
||||
refusedWithout(seat)
|
||||
if err := CanHold(holder(all...), seat); err != nil {
|
||||
t.Fatalf("read back from the row, a holder serving all twelve is refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The optional mark is not stored, so a seat set read back from the store's rows takes it from the
|
||||
// compiled seat: otherwise `failed`, seeded into the row, would come back required.
|
||||
func TestAnOptionalVerbStaysOptionalInASetReadFromTheStore(t *testing.T) {
|
||||
defer UseSeats(DefaultSeats())
|
||||
var rows []Seat
|
||||
for _, s := range DefaultSeats() {
|
||||
row := s
|
||||
row.Serves = nil
|
||||
for _, v := range s.Serves {
|
||||
row.Serves = append(row.Serves, Verb{Name: v.Name, Description: v.Description, Input: v.Input})
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
UseSeats(rows)
|
||||
seat, _ := SeatNamed(ServiceManagerSeat)
|
||||
holder := Manifest{Module: "systemd", Version: "1", Claims: []Claim{{Name: ServiceManagerSeat, Scope: ScopeNode,
|
||||
Serves: []string{"units", "status", "start", "stop", "restart", "enable", "disable", "journal",
|
||||
"reset-failed", "wanted-by", "unlink-dangling"}}}}
|
||||
if err := CanHold(holder, seat); err != nil {
|
||||
t.Fatalf("a set read from rows refuses a holder without the optional failed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,301 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A requirement a module can run without (novox/hq ADR 0330, issue 166). The shape of the issue:
|
||||
// grafana runs without a data source and requires `influxdb-api` softly; a mesh without influxdb
|
||||
// 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"}]}`)
|
||||
if strings.Join(m.Requires, " ") != "route influxdb-api oidc-client" {
|
||||
t.Fatalf("the requirements read as %v", m.Requires)
|
||||
}
|
||||
if m.IsOptional("route") || m.IsOptional("oidc-client") {
|
||||
t.Error("a requirement without `optional` read as optional; hard is the default (ADR 0330 §1)")
|
||||
}
|
||||
if !m.IsOptional("influxdb-api") {
|
||||
t.Error("a requirement marked optional read as hard")
|
||||
}
|
||||
// And written back as it was read, so a manifest through the store keeps its form.
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var again Manifest
|
||||
if err := json.Unmarshal(raw, &again); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !again.IsOptional("influxdb-api") || again.IsOptional("route") || len(again.Requires) != 3 {
|
||||
t.Fatalf("written and read back, it says %v optional %v (%s)", again.Requires, again.OptionalRequires, raw)
|
||||
}
|
||||
if strings.Contains(string(raw), `"name":`) {
|
||||
t.Errorf("a requirement was stored in the long form: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// The stored form reads on a controller older than optional requirements (review of mesh-controller
|
||||
// #281). Inventory.Catalogue stops at the first manifest it cannot read, so a long-form entry inside
|
||||
// `requires` would have stopped every plan after a rollback. Stored, `requires` is plain names, as main's
|
||||
// manifest type reads it, and which are optional sits beside it in a key such a controller sets aside.
|
||||
func TestTheStoredFormReadsWhereRequiresIsAListOfNames(t *testing.T) {
|
||||
m := mustParse(t, `{"module":"grafana","version":"1",
|
||||
"requires":["route",{"name":"influxdb-api","optional":true}]}`)
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// main's field, exactly: `Requires []string`.
|
||||
var older struct {
|
||||
Requires []string `json:"requires"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &older); err != nil {
|
||||
t.Fatalf("a controller reading requires as names cannot read the stored form: %v\n%s", err, raw)
|
||||
}
|
||||
if strings.Join(older.Requires, " ") != "route influxdb-api" {
|
||||
t.Fatalf("it reads the requirements as %v", older.Requires)
|
||||
}
|
||||
var keys map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &keys); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(keys[OptionalRequiresKey]) != `["influxdb-api"]` {
|
||||
t.Fatalf("which are optional is stored as %s", keys[OptionalRequiresKey])
|
||||
}
|
||||
// And this controller reads the stored form back the same, leniently and strictly.
|
||||
var back Manifest
|
||||
if err := json.Unmarshal(raw, &back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
strict, err := ParseManifest(raw)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, got := range []Manifest{back, strict} {
|
||||
if !got.IsOptional("influxdb-api") || got.IsOptional("route") || len(got.Requires) != 2 {
|
||||
t.Fatalf("read back as %v optional %v", got.Requires, got.OptionalRequires)
|
||||
}
|
||||
}
|
||||
// A stored list naming something the module does not require is refused.
|
||||
if _, err := ParseManifest([]byte(`{"module":"x","version":"1","requires":["route"],"optional-requires":["mqtt-topic"]}`)); err == nil {
|
||||
t.Error("an optional requirement the module does not require passed the check")
|
||||
}
|
||||
}
|
||||
|
||||
func TestARequirementsLongFormRefusesWhatItDoesNotKnow(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
`{"module":"x","version":"1","requires":[{"name":"influxdb-api","optinal":true}]}`,
|
||||
`{"module":"x","version":"1","requires":[{"optional":true}]}`,
|
||||
} {
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil {
|
||||
t.Errorf("accepted %s", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 TestTheModuleCheckRefusesAnOptionalPlaceholderThatCouldRenderEmpty(t *testing.T) {
|
||||
refused := map[string]string{
|
||||
"a placeholder without a default": `{"id":"ds","type":"file","path":"/a","mode":"0644",
|
||||
"content":"url: ${bound:influxdb-api:at}"}`,
|
||||
"a secret outside an only-when-bound file": `{"id":"s","type":"file","path":"/a","mode":"0400",
|
||||
"content":"${secret:influxdb-api}"}`,
|
||||
"only-when-bound on a hard requirement": `{"id":"r","type":"file","path":"/a","mode":"0644",
|
||||
"content":"x","only-when-bound":"route"}`,
|
||||
"only-when-bound on something not required": `{"id":"r","type":"file","path":"/a","mode":"0644",
|
||||
"content":"x","only-when-bound":"mqtt-topic"}`,
|
||||
"only-when-bound on a directory": `{"id":"d","type":"directory","mode":"0700",
|
||||
"only-when-bound":"influxdb-api"}`,
|
||||
"a default on a hard requirement": `{"id":"r","type":"file","path":"/a","mode":"0644",
|
||||
"content":"${bound:route:name|default:x}"}`,
|
||||
}
|
||||
for what, resource := range refused {
|
||||
if _, err := ParseManifest([]byte(optionalModule(resource))); err == nil {
|
||||
t.Errorf("%s passed the module check", what)
|
||||
} else if what == "a placeholder without a default" && !strings.Contains(err.Error(), "|default:") {
|
||||
t.Errorf("the refusal does not say how to write it: %v", err)
|
||||
}
|
||||
}
|
||||
accepted := map[string]string{
|
||||
"a placeholder with a default": `{"id":"ds","type":"file","path":"/a","mode":"0644",
|
||||
"content":"url: ${bound:influxdb-api:at|default:none}"}`,
|
||||
"a placeholder and a secret inside an only-when-bound file": `{"id":"ds","type":"file","path":"/a",
|
||||
"mode":"0400","content":"url: ${bound:influxdb-api:at}\npassword: ${secret:influxdb-api}",
|
||||
"only-when-bound":"influxdb-api"}`,
|
||||
}
|
||||
for what, resource := range accepted {
|
||||
if _, err := ParseManifest([]byte(optionalModule(resource))); err != nil {
|
||||
t.Errorf("%s was refused: %v", what, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user