diff --git a/internal/catalogue/outside_not_given_test.go b/internal/catalogue/outside_not_given_test.go deleted file mode 100644 index a63e3d8a..00000000 --- a/internal/catalogue/outside_not_given_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package catalogue - -import ( - "strings" - "testing" -) - -// An own secret issued outside the mesh that nobody gave (novox/hq issue 401, ADR 0228): the mesh makes no value for it, -// so the composition places no file, a file of the module that names it says empty there, and nothing restarts on it. -// Given, it is placed and filled like any own secret. - -func outsideManifest(t *testing.T) Manifest { - t.Helper() - m, err := ParseManifest([]byte(`{"module":"assistant","version":"1", - "own-secrets":{ - "password":{"path":"/var/lib/assistant/password.secret","taken":"at-start"}, - "vendor-key":{"path":"/var/lib/assistant/vendor-key.secret","taken":"at-start","issued-by":"outside"}}, - "resources":[ - {"id":"env","type":"file","path":"/var/lib/assistant/env","mode":"0600", - "content":"PASSWORD=${secret:password}\nVENDOR_KEY=${secret:vendor-key}\n"}, - {"id":"server","type":"container","name":"assistant","image":"assistant:1", - "env-file":["/var/lib/assistant/env"], - "volumes":["/var/lib/assistant/vendor-key.secret:/run/vendor-key:ro"], - "secrets-in-environment":"reads its settings from the environment only"}]}`)) - if err != nil { - t.Fatal(err) - } - return m -} - -func resourceAt(out []map[string]any, path string) map[string]any { - for _, r := range out { - if r["path"] == path { - return r - } - } - return nil -} - -func resourceNamed(out []map[string]any, name string) map[string]any { - for _, r := range out { - if r["name"] == name { - return r - } - } - return nil -} - -func TestAnOutsideSecretNotGivenIsNoFileAndAFileNamingItSaysEmpty(t *testing.T) { - r := Resolution{Node: "server", Modules: []Manifest{outsideManifest(t)}} - out, err := r.Declaration(Rendering{Needed: map[string]map[string]string{"assistant": {"password": "sealed-password"}}}) - if err != nil { - t.Fatalf("a module whose outside secret nobody gave did not compose: %v", err) - } - if got := resourceAt(out, "/var/lib/assistant/vendor-key.secret"); got != nil { - t.Fatalf("a secret nobody gave was placed: %v", got) - } - env := resourceAt(out, "/var/lib/assistant/env") - if env == nil { - t.Fatal("the module's own file is not composed") - } - if content := env["content"].(string); content != "PASSWORD=${secret:password}\nVENDOR_KEY=\n" { - t.Fatalf("the file names the secret nobody gave as %q", content) - } - secrets, _ := env["secrets"].(map[string]any) - if len(secrets) != 1 || secrets["password"] != "sealed-password" { - t.Fatalf("the file is filled from %v", secrets) - } - server := resourceNamed(out, "assistant") - if on := strings.Join(stringsIn(server["restart-on"]), " "); strings.Contains(on, "vendor-key") { - t.Fatalf("the container restarts on a secret nobody gave: %q", on) - } -} - -func TestAnOutsideSecretGivenIsPlacedAndFilled(t *testing.T) { - r := Resolution{Node: "server", Modules: []Manifest{outsideManifest(t)}} - out, err := r.Declaration(Rendering{Needed: map[string]map[string]string{"assistant": { - "password": "sealed-password", "vendor-key": "sealed-vendor-key"}}}) - if err != nil { - t.Fatal(err) - } - placed := resourceAt(out, "/var/lib/assistant/vendor-key.secret") - if placed == nil || placed["sealed"] != "sealed-vendor-key" { - t.Fatalf("the secret given is placed as %v", placed) - } - env := resourceAt(out, "/var/lib/assistant/env") - if content := env["content"].(string); content != "PASSWORD=${secret:password}\nVENDOR_KEY=${secret:vendor-key}\n" { - t.Fatalf("the file was changed: %q", content) - } - if secrets, _ := env["secrets"].(map[string]any); secrets["vendor-key"] != "sealed-vendor-key" { - t.Fatalf("the file is filled from %v", secrets) - } - server := resourceNamed(out, "assistant") - if on := strings.Join(stringsIn(server["restart-on"]), " "); !strings.Contains(on, "assistant."+NeedID("vendor-key")) { - t.Fatalf("the container does not restart on the secret given: %q", on) - } -} - -// A secret the mesh makes is still refused when it was not made: only an outside one is left out. -func TestAnOwnSecretTheMeshMakesIsStillRefusedWhenNotMade(t *testing.T) { - r := Resolution{Node: "server", Modules: []Manifest{outsideManifest(t)}} - _, err := r.Declaration(Rendering{}) - if nm, ok := err.(*NotMadeError); !ok || nm.Name != "password" { - t.Fatalf("a secret the mesh makes, not made: %v", err) - } -} diff --git a/internal/catalogue/own_name_test.go b/internal/catalogue/own_name_test.go deleted file mode 100644 index f77ac899..00000000 --- a/internal/catalogue/own_name_test.go +++ /dev/null @@ -1,130 +0,0 @@ -package catalogue - -import ( - "encoding/json" - "strings" - "testing" -) - -// A module that must know its own address — a login redirect, a canonical URL, an issuer — had it -// written into its manifest as a literal (novox/hq 04-ISSUES/122): a domain in a definition, wrong on -// every other machine. It is told instead, from the same composition the provider receives. - -// selfAware contributes a labelled route, binds the requirement, and writes its own name into a file. -func selfAware(label string) Manifest { - m := labelled("board", label, 8080) - m.Requires = []string{"reverse-proxy"} - m.Binds = map[string]string{"reverse-proxy": "/var/lib/board/route.json"} - m.Resources = []map[string]any{ - {"id": "conf", "type": "file", "path": "/var/lib/board/app.conf", - "content": "root = https://${bound:reverse-proxy:name}/\ninternal = ${bound:reverse-proxy:internal-name}\n"}, - } - return m -} - -// servingProxy is proxy() as the catalogue's route providers are declared: the provision scoped to -// the mesh, serving nothing a consumer must know (route-adapter, route-proxy: `"serves": {"route": {}}`). -func servingProxy() Manifest { - p := proxy() - p.Provides = []Offer{{Name: "reverse-proxy", Scope: ScopeMesh}} - p.Serves = map[string]map[string]any{"reverse-proxy": {}} - return p -} - -// nodeProxy is the same provider scoped to its node, whose answer on the same machine comes from -// `here` rather than from the mesh's needs — the other path a binding is written by. -func nodeProxy() Manifest { - p := proxy() - p.Serves = map[string]map[string]any{"reverse-proxy": {"scheme": "http"}} - return p -} - -// onBoth is a node with a public domain and a private-network address, so both names compose. -func onBoth(domain string) Node { - n := withDomain(domain) - n.At = "anchor.internal" - return n -} - -func fileAt(t *testing.T, out []map[string]any, path string) string { - t.Helper() - for _, r := range out { - if r["path"] == path { - return r["content"].(string) - } - } - t.Fatalf("nothing was declared at %s", path) - return "" -} - -func TestAModuleIsToldTheNameItsProviderServes(t *testing.T) { - got, err := Resolve(shelf(servingProxy(), selfAware("git")), []string{"traefik", "board"}, - onBoth("example.tld"), World{}) - if err != nil { - t.Fatal(err) - } - out := mustDeclare(t, got) - served := received(t, out)[0].Values - - var binding map[string]any - if err := json.Unmarshal([]byte(fileAt(t, out, "/var/lib/board/route.json")), &binding); err != nil { - t.Fatal(err) - } - if binding["name"] != served["name"] || binding["name"] != "git.example.tld" { - t.Fatalf("the module was told %v, the provider serves %v", binding["name"], served["name"]) - } - if binding["internal-name"] != served["internal-name"] || binding["internal-name"] == nil { - t.Fatalf("internal name: module told %v, provider serves %v", - binding["internal-name"], served["internal-name"]) - } - - conf := fileAt(t, out, "/var/lib/board/app.conf") - want := "root = https://git.example.tld/\ninternal = " + served["internal-name"].(string) + "\n" - if conf != want { - t.Fatalf("the file was rendered as\n%s\nwant\n%s", conf, want) - } -} - -func TestTheNameAModuleIsToldFollowsTheNodesDomain(t *testing.T) { - // The whole point: the same definition, two machines, two names — nothing edited. - for _, domain := range []string{"example.tld", "other.example"} { - got, err := Resolve(shelf(servingProxy(), selfAware("git")), []string{"traefik", "board"}, - onBoth(domain), World{}) - if err != nil { - t.Fatal(err) - } - conf := fileAt(t, mustDeclare(t, got), "/var/lib/board/app.conf") - if !strings.HasPrefix(conf, "root = https://git."+domain+"/") { - t.Fatalf("on %s the module wrote %q", domain, conf) - } - } -} - -func TestAModuleWithNoPublicNameIsNotToldOne(t *testing.T) { - // No public domain on the node: nothing composed, so no `name` — and a file asking for one is - // refused rather than rendered with a placeholder or an empty host. - m := selfAware("git") - m.Resources[0]["content"] = "root = https://${bound:reverse-proxy:name}/\n" - got, err := Resolve(shelf(servingProxy(), m), []string{"traefik", "board"}, workstation(), World{}) - if err != nil { - t.Fatal(err) - } - if _, err := got.Declaration(Rendering{}); err == nil || - !strings.Contains(err.Error(), `"name"`) { - t.Fatalf("a file asking for a name that was never composed was not refused: %v", err) - } -} - -func TestAModuleIsToldItsNameByANodeScopedProviderToo(t *testing.T) { - m := selfAware("git") - m.Resources[0]["content"] = "root = https://${bound:reverse-proxy:name}/\n" - got, err := Resolve(shelf(nodeProxy(), m), []string{"board"}, - withDomain("example.tld"), World{}) - if err != nil { - t.Fatal(err) - } - conf := fileAt(t, mustDeclare(t, got), "/var/lib/board/app.conf") - if !strings.HasPrefix(conf, "root = https://git.example.tld/") { - t.Fatalf("a same-machine, node-scoped answer did not tell the module its name: %q", conf) - } -} diff --git a/internal/catalogue/own_secret_owner_test.go b/internal/catalogue/own_secret_owner_test.go deleted file mode 100644 index fba354a0..00000000 --- a/internal/catalogue/own_secret_owner_test.go +++ /dev/null @@ -1,116 +0,0 @@ -package catalogue - -import ( - "strings" - "testing" -) - -// An own secret (or a family) may declare `owner` (novox/hq ADR 0436 §1): the account the files holding it -// belong to on the machine, when that account is not the module's secrets-owner default. The fixture is -// mesh-mail's future manifest shape (ADR 0436 §7): a process account `mesh-mail-send` the module makes, and its -// own-secret family `mail-credential-*` owned by it. - -// mailSenderManifest is the fixture every mail-sender issue builds on: the real parts of mesh-mail's manifest -// taken from mesh-catalog/modules/mesh-mail/module.json, extended as ADR 0436 §7-§8 describe, with `own` naming -// the account that owns the family's files. -func mailSenderManifest(t *testing.T, own string) Manifest { - t.Helper() - ownSecrets := `"mail-credential-*":{"path":"/var/lib/mesh-mail/mail-credential-*.secret","issued-by":"outside"` - if own != "" { - ownSecrets += `,"owner":"` + own + `"` - } - ownSecrets += `}` - m, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","secrets-owner":"mesh-mail","own-secrets":{` + - ownSecrets + `},"resources":[{"id":"account","type":"user","name":"mesh-mail-send"}]}`)) - if err != nil { - t.Fatalf("a manifest with own=%q was refused: %v", own, err) - } - return m -} - -// 1170/AC1: a family owned by the module's own account lands at 0600, owned by that account — and a module's -// other own secrets (no per-secret owner declared) keep secrets-owner, unchanged. -func TestAnOwnSecretsOwnerIsGivenToItsFiles(t *testing.T) { - m := mailSenderManifest(t, "mesh-mail-send") - m.OwnSecrets["smtp-relay-key"] = OwnSecret{Path: "/var/lib/mesh-mail/smtp-relay-key", Taken: TakenAtStart} - r := Resolution{Node: "novox", Modules: []Manifest{m}} - out, err := r.Declaration(Rendering{ - Needed: map[string]map[string]string{"mesh-mail": {"mail-credential-smtp": "sealed-credential", "smtp-relay-key": "sealed-relay"}}, - }) - if err != nil { - t.Fatalf("composing the declaration: %v", err) - } - var family, plain map[string]any - for _, res := range out { - if res["type"] != "file" { - continue - } - switch res["path"] { - case "/var/lib/mesh-mail/mail-credential-smtp.secret": - family = res - case "/var/lib/mesh-mail/smtp-relay-key": - plain = res - } - } - if family == nil { - t.Fatalf("the family member was not composed at all: %v", out) - } - if got := family["owner"]; got != "mesh-mail-send" { - t.Fatalf("the family member's owner: %v; want mesh-mail-send", got) - } - // review round 1, finding 7: asserted, not merely logged. ownedBy never sets "mode", so a sealed secret - // file carries none here either — the host applies 0600 to every one regardless (novox/hq ADR 0086) — - // and the owner clause must not have added one of its own that could override that. - if _, has := family["mode"]; has { - t.Fatalf("the family member carries its own mode %v; the owner clause must not set one, so the "+ - "host's 0600 for every sealed secret file stands", family["mode"]) - } - if plain == nil { - t.Fatalf("the module's other own secret was not composed at all: %v", out) - } - if got := plain["owner"]; got != "mesh-mail" { - t.Fatalf("a secret with no per-secret owner kept %v; want the module's secrets-owner %q", got, "mesh-mail") - } -} - -// 1170/AC2: an owner naming an account the module does not make, root, or the operator account's placeholder is -// refused, naming the secret and the account. -func TestAnOwnSecretsOwnerIsRefusedWhenItIsNotAnAccountTheModuleMakes(t *testing.T) { - for name, c := range map[string]struct{ owner, says string }{ - "root": {"root", "root"}, - "the operator account": {"${machine:account}", "operator account"}, - "an account not declared": {"not-made", "not-made"}, - } { - _, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{` + - `"mail-credential-*":{"path":"/var/lib/mesh-mail/mail-credential-*.secret","issued-by":"outside","owner":"` + - c.owner + `"}},"resources":[{"id":"account","type":"user","name":"mesh-mail-send"}]}`)) - if err == nil || !strings.Contains(err.Error(), "mail-credential-*") || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: %v; want a refusal naming the secret and %q", name, err, c.says) - } - } -} - -// 1170/AC3: a manifest with no owner on any own secret composes exactly as it did before this clause existed: -// every secret file belongs to the module's secrets-owner (or nothing, when that is absent too). -func TestNoOwnerOnAnySecretComposesAsMainDoes(t *testing.T) { - m := mailSenderManifest(t, "") - r := Resolution{Node: "novox", Modules: []Manifest{m}} - out, err := r.Declaration(Rendering{ - Needed: map[string]map[string]string{"mesh-mail": {"mail-credential-smtp": "sealed-credential"}}, - }) - if err != nil { - t.Fatalf("composing the declaration: %v", err) - } - var found bool - for _, res := range out { - if res["type"] == "file" && res["path"] == "/var/lib/mesh-mail/mail-credential-smtp.secret" { - found = true - if got := res["owner"]; got != "mesh-mail" { - t.Fatalf("with no per-secret owner declared, the file's owner: %v; want secrets-owner %q", got, "mesh-mail") - } - } - } - if !found { - t.Fatalf("the family member was not composed at all: %v", out) - } -} diff --git a/internal/catalogue/own_secret_prompt_test.go b/internal/catalogue/own_secret_prompt_test.go deleted file mode 100644 index f0d65750..00000000 --- a/internal/catalogue/own_secret_prompt_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package catalogue - -import ( - "encoding/json" - "strings" - "testing" -) - -// An own secret (or a family) may declare `prompt`, one line naming what it is for a desk's prompt in place of -// its bare name (novox/hq issue 789): reviewed in the module's pull request, and the controller validates it in -// the module check. -func TestAnOwnSecretsPromptParsesAndIsWrittenBack(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{ - "mail-credential-*":{"path":"/var/lib/mesh-mail/mail-credential-*.secret","issued-by":"outside", - "prompt":"the password of the mail account {member}"}, - "smtp-relay-key":{"path":"/var/lib/mesh-mail/smtp-relay-key","taken":"at-start", - "prompt":"the key for the outbound relay"}}}`)) - if err != nil { - t.Fatalf("a manifest with a well-formed prompt was refused: %v", err) - } - if got := m.OwnSecrets["mail-credential-*"].Prompt; got != "the password of the mail account {member}" { - t.Fatalf("the family's prompt: %q", got) - } - if got := m.OwnSecrets["smtp-relay-key"].Prompt; got != "the key for the outbound relay" { - t.Fatalf("the plain secret's prompt: %q", got) - } - // Written back the way it was read, so a registered manifest keeps its bytes. - out, err := json.Marshal(m.OwnSecrets) - if err != nil { - t.Fatal(err) - } - var again OwnSecrets - if err := json.Unmarshal(out, &again); err != nil { - t.Fatal(err) - } - if again["mail-credential-*"].Prompt != "the password of the mail account {member}" || - again["smtp-relay-key"].Prompt != "the key for the outbound relay" { - t.Fatalf("the round trip lost the prompt: %s", out) - } -} - -// No prompt declared is fine: the desk asks by name alone, as it does today. -func TestAnOwnSecretWithNoPromptIsFine(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"telegram","version":"1","own-secrets":{ - "telegram-token":{"path":"/var/lib/telegram/telegram-token","issued-by":"outside"}}}`)) - if err != nil { - t.Fatal(err) - } - if m.OwnSecrets["telegram-token"].Prompt != "" { - t.Fatalf("an undeclared prompt: %q", m.OwnSecrets["telegram-token"].Prompt) - } - out, err := json.Marshal(m.OwnSecrets) - if err != nil { - t.Fatal(err) - } - if strings.Contains(string(out), "prompt") { - t.Fatalf("an empty prompt was written: %s", out) - } -} - -// PromptMax counts characters, not bytes: a prompt of 100 multi-byte characters is well within it even -// though it holds well over 120 bytes (novox/hq issue 789). -func TestAPromptOfMultiByteCharactersIsCountedByCharacter(t *testing.T) { - prompt := strings.Repeat("é", 100) - if n := len(prompt); n <= 120 { - t.Fatalf("the test prompt is %d bytes, not over 120 as intended", n) - } - _, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{ - "smtp-relay-key":{"path":"/var/lib/mesh-mail/smtp-relay-key","taken":"at-start", - "prompt":` + jsonString(prompt) + `}}}`)) - if err != nil { - t.Fatalf("a 100-character prompt was refused: %v", err) - } -} - -// {member} is replaced only for a family's member: a single secret's prompt may not hold it (novox/hq issue 789). -func TestAPromptHoldingMemberOnASingleSecretIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{ - "smtp-relay-key":{"path":"/var/lib/mesh-mail/smtp-relay-key","taken":"at-start", - "prompt":"For {member}"}}}`)) - if err == nil || !strings.Contains(err.Error(), "{member} is replaced only for a family's member") { - t.Fatalf("a single secret's prompt holding {member}: %v; want a refusal saying so", err) - } -} - -// A prompt that is not one short line naming only {member} is refused by the module check (novox/hq issue 789). -func TestAMalformedPromptIsRefused(t *testing.T) { - for name, c := range map[string]struct{ prompt, says string }{ - "a newline": {"the password of\nthe mail account {member}", "one line"}, - "over 120 characters": {"the password of the mail account {member}, which this mesh needs to log in to " + - "send and receive mail for the operator's account on this machine today", "120 characters"}, - "a placeholder other than {member}": {"the password of the mail account {name}", "{member}"}, - "two placeholders, one unknown": {"the {kind} secret of the mail account {member}", "{member}"}, - } { - _, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{ - "mail-credential-*":{"path":"/var/lib/mesh-mail/mail-credential-*.secret","issued-by":"outside", - "prompt":` + jsonString(c.prompt) + `}}}`)) - if err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: %v; want a refusal saying %q", name, err, c.says) - } - } -} - -// jsonString quotes a Go string the way encoding/json would, for building a literal into a test's manifest JSON. -func jsonString(s string) string { - out, err := json.Marshal(s) - if err != nil { - panic(err) - } - return string(out) -} diff --git a/internal/catalogue/own_secret_taken_test.go b/internal/catalogue/own_secret_taken_test.go deleted file mode 100644 index 4ad016cc..00000000 --- a/internal/catalogue/own_secret_taken_test.go +++ /dev/null @@ -1,93 +0,0 @@ -package catalogue - -import ( - "encoding/json" - "strings" - "testing" -) - -// An own secret says how the module takes it (novox/hq ADR 0114, issue 180): a path alone says -// nothing of it, an object says `at-start` or `applied`, and the bytes the mesh holds are the bytes -// it was given either way. -func TestAnOwnSecretSaysHowItIsTaken(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"idp","version":"1","own-secrets":{ - "broker":"/var/lib/mesh/idp/broker", - "admin":{"path":"/var/lib/idp/admin.secret","taken":"applied"}, - "session":{"path":"/var/lib/idp/session.secret","taken":"at-start"}}}`)) - if err != nil { - t.Fatal(err) - } - if m.OwnSecrets["broker"] != (OwnSecret{Path: "/var/lib/mesh/idp/broker"}) { - t.Fatalf("a path alone is a path and nothing more: %+v", m.OwnSecrets["broker"]) - } - if m.OwnSecrets["admin"].Taken != TakenApplied || m.OwnSecrets["session"].Taken != TakenAtStart { - t.Fatalf("the word was not kept: %+v", m.OwnSecrets) - } - // Written back the way it was read, so a registered manifest keeps its bytes. - out, err := json.Marshal(m.OwnSecrets) - if err != nil { - t.Fatal(err) - } - var again OwnSecrets - if err := json.Unmarshal(out, &again); err != nil { - t.Fatal(err) - } - if len(again) != 3 || again["admin"].Taken != TakenApplied || again["broker"].Taken != "" { - t.Fatalf("the round trip changed the secrets: %s", out) - } - if !strings.Contains(string(out), `"broker":"/var/lib/mesh/idp/broker"`) { - t.Fatalf("a path alone is written back as a path: %s", out) - } -} - -func TestAnOwnSecretTakenSomeOtherWayIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"idp","version":"1","own-secrets":{ - "admin":{"path":"/var/lib/idp/admin.secret","taken":"sometimes"}}}`)) - if err == nil || !strings.Contains(err.Error(), `taken "sometimes"`) { - t.Fatalf("an unknown word for how a secret is taken was accepted: %v", err) - } - _, err = ParseManifest([]byte(`{"module":"idp","version":"1","own-secrets":{ - "admin":{"path":"/var/lib/idp/admin.secret","rotate":"yes"}}}`)) - if err == nil { - t.Fatal("an unknown field on an own secret was accepted") - } -} - -// A secret an outside party issues says so (novox/hq ADR 0228), is written back as it was read, and -// is the one at-start secret the mesh may not make; any other word for who issued it is refused. -func TestAnOwnSecretSaysWhenAnOutsidePartyIssuesIt(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"letta","version":"1","own-secrets":{ - "server-password":{"path":"/var/lib/letta/server-password","taken":"at-start"}, - "openai-api-key":{"path":"/var/lib/letta/openai-api-key","taken":"at-start","issued-by":"outside"}, - "telegram-token":{"path":"/var/lib/letta/telegram-token","issued-by":"outside"}, - "logflare":{"path":"/var/lib/letta/logflare","taken":"applied"}, - "broker":"/var/lib/mesh/letta/broker"}}`)) - if err != nil { - t.Fatal(err) - } - for name, may := range map[string]bool{"server-password": true, "openai-api-key": false, - "telegram-token": false, "logflare": false, "broker": false} { - if got := m.OwnSecrets[name].MeshMayMake(); got != may { - t.Errorf("%s: the mesh may make it = %v, want %v", name, got, may) - } - } - out, err := json.Marshal(m.OwnSecrets) - if err != nil { - t.Fatal(err) - } - var again OwnSecrets - if err := json.Unmarshal(out, &again); err != nil { - t.Fatal(err) - } - if again["openai-api-key"] != m.OwnSecrets["openai-api-key"] || again["telegram-token"] != m.OwnSecrets["telegram-token"] { - t.Fatalf("the round trip lost who issued it: %s", out) - } - if strings.Contains(string(out), `"taken":""`) { - t.Fatalf("a secret that says only who issued it gained an empty taken: %s", out) - } - _, err = ParseManifest([]byte(`{"module":"letta","version":"1","own-secrets":{ - "openai-api-key":{"path":"/var/lib/letta/openai-api-key","issued-by":"openai"}}}`)) - if err == nil || !strings.Contains(err.Error(), `issued by "openai"`) { - t.Fatalf("an unknown word for who issued a secret was accepted: %v", err) - } -} diff --git a/internal/catalogue/pin_names_module_test.go b/internal/catalogue/pin_names_module_test.go deleted file mode 100644 index 8191e5a4..00000000 --- a/internal/catalogue/pin_names_module_test.go +++ /dev/null @@ -1,115 +0,0 @@ -package catalogue - -import ( - "strings" - "testing" -) - -// Two modules on one node both provide acme-ca — public-acme (Let's Encrypt) and step-ca (the -// mesh's own authority) on novox — and a route-proxy elsewhere must get the public one (novox/hq -// #258). A pin names the module as well as the node, so that it can say which. - -func issuerShelf() map[string]Manifest { - return shelf( - Manifest{Module: "public-acme", Version: "1", Provides: FromAnywhere("acme-ca"), - Serves: map[string]map[string]any{"acme-ca": {"at": "acme-v02.api.letsencrypt.org"}}}, - Manifest{Module: "step-ca", Version: "1", Provides: FromAnywhere("acme-ca"), - Serves: map[string]map[string]any{"acme-ca": {"at": "novox.internal"}}}, - Manifest{Module: "route-proxy", Version: "1", Requires: []string{"acme-ca"}}, - ) -} - -func twoIssuersOnOneNode() map[string][]Provider { - return map[string][]Provider{"acme-ca": { - {Node: "novox", At: "novox.internal", Module: "public-acme", Serves: map[string]any{"at": "acme-v02.api.letsencrypt.org"}}, - {Node: "novox", At: "novox.internal", Module: "step-ca", Serves: map[string]any{"at": "novox.internal"}}, - }} -} - -func TestTwoProvidersOnOneNodeAreRefusedWithBothNamed(t *testing.T) { - // The refusal must name the pair, because a node alone cannot tell them apart. - _, err := Resolve(issuerShelf(), []string{"route-proxy"}, reachable(), - World{Offered: twoIssuersOnOneNode()}) - if err == nil { - t.Fatal("two providers on one node were resolved by picking") - } - for _, want := range []string{"novox/public-acme", "novox/step-ca", " "} { - if !strings.Contains(err.Error(), want) { - t.Fatalf("the refusal does not say %q: %v", want, err) - } - } -} - -func TestAPinNamesTheModule(t *testing.T) { - got, err := Resolve(issuerShelf(), []string{"route-proxy"}, reachable(), - World{Offered: twoIssuersOnOneNode(), - Pinned: map[string]Chosen{"acme-ca": {Node: "novox", Module: "public-acme"}}}) - if err != nil { - t.Fatal(err) - } - if len(got.Needs) != 1 || got.Needs[0].From != "novox" || got.Needs[0].Serves["at"] != "acme-v02.api.letsencrypt.org" { - t.Fatalf("the named module was not the one taken: %+v", got.Needs) - } -} - -func TestARecordNamingOnlyTheNodeIsRefusedWhenThatNodeAnswersTwice(t *testing.T) { - // A pin from before the module was asked for. It once took the last one listed — a coin flip. - _, err := Resolve(issuerShelf(), []string{"route-proxy"}, reachable(), - World{Offered: twoIssuersOnOneNode(), Pinned: map[string]Chosen{"acme-ca": {Node: "novox"}}}) - if err == nil { - t.Fatal("a node that answers twice was resolved by picking") - } - if !strings.Contains(err.Error(), "provides it 2 times") || !strings.Contains(err.Error(), "pin workstation acme-ca novox ") { - t.Fatalf("the refusal does not ask for the module: %v", err) - } -} - -func TestAPinNamingAModuleThatDoesNotProvideItIsRefused(t *testing.T) { - _, err := Resolve(issuerShelf(), []string{"route-proxy"}, reachable(), - World{Offered: twoIssuersOnOneNode(), Pinned: map[string]Chosen{"acme-ca": {Node: "novox", Module: "gitea"}}}) - if err == nil || !strings.Contains(err.Error(), "novox/gitea does not provide it") { - t.Fatalf("a module that does not provide it was not refused by name: %v", err) - } -} - -func TestTwoProvidersBesideTheConsumerAreRefusedUntilOneIsNamed(t *testing.T) { - // The same ambiguity on the consumer's own machine. This was settled by a map walk — random, - // per plan — which is how novox's own proxy got its issuer. - _, err := Resolve(issuerShelf(), []string{"route-proxy", "public-acme", "step-ca"}, reachable(), World{}) - if err == nil { - t.Fatal("two providers beside the consumer were resolved by picking") - } - if !strings.Contains(err.Error(), "workstation provides \"acme-ca\" 2 times") || !strings.Contains(err.Error(), "public-acme, step-ca") { - t.Fatalf("the refusal does not list them: %v", err) - } -} - -func TestAPinSettlesTwoProvidersBesideTheConsumer(t *testing.T) { - got, err := Resolve(issuerShelf(), []string{"route-proxy", "public-acme", "step-ca"}, reachable(), - World{Pinned: map[string]Chosen{"acme-ca": {Node: "workstation", Module: "public-acme"}}}) - if err != nil { - t.Fatal(err) - } - var found bool - for _, n := range got.Needs { - if n.Name == "acme-ca" { - found = true - if n.Serves["at"] != "acme-v02.api.letsencrypt.org" { - t.Fatalf("the named module was not the one taken: %+v", n) - } - } - } - if !found { - t.Fatalf("no need for acme-ca was created: %+v", got.Needs) - } -} - -func TestTheFirstPassDoesNotRefuseTwoProvidersBesideTheConsumer(t *testing.T) { - // The first pass answers only what a node offers. Refused there, the node vanishes from every - // other node's world — and the whole mesh loses its vault for an ambiguity one machine has to - // settle. The second pass is where it is refused, and the test above proves it is. - if _, err := Resolve(issuerShelf(), []string{"route-proxy", "public-acme", "step-ca"}, reachable(), - World{Unchecked: true}); err != nil { - t.Fatalf("the first pass refused what only the second may: %v", err) - } -} diff --git a/internal/catalogue/placed_paths_test.go b/internal/catalogue/placed_paths_test.go deleted file mode 100644 index 779c1512..00000000 --- a/internal/catalogue/placed_paths_test.go +++ /dev/null @@ -1,180 +0,0 @@ -package catalogue - -import ( - "encoding/json" - "os" - "path/filepath" - "reflect" - "strings" - "testing" -) - -// TestPlacedDirectoriesKeepTheirPaths is the check novox/hq issue 119 asks for before a definition -// stops naming where its data lives: a converted manifest, resolved on a node with the default root, -// names exactly the paths the manifest before it named. Data that a service is using must not move -// because a definition stopped saying where it was. -// -// Two checkouts: MESH_CATALOGUE_BEFORE, the catalogue as it was, and MESH_CATALOGUE, as it is now. -// Every module in both is resolved with the controller's own rule (dirsFor, dirFill) and compared -// whole — not only the directories, but every string a directory's id was written into. Both -// sides are resolved, so a manifest converted in two steps (issue 119, then issue 174) is judged -// against the paths it named, not the text it used to name them with. -func TestPlacedDirectoriesKeepTheirPaths(t *testing.T) { - before, after := os.Getenv("MESH_CATALOGUE_BEFORE"), os.Getenv("MESH_CATALOGUE") - if before == "" || after == "" { - t.Skip("set MESH_CATALOGUE_BEFORE and MESH_CATALOGUE to two catalogue checkouts to run this") - } - found, _ := filepath.Glob(filepath.Join(after, "modules", "*", "module.json")) - compared := 0 - for _, path := range found { - module := filepath.Base(filepath.Dir(path)) - old, err := os.ReadFile(filepath.Join(before, "modules", module, "module.json")) - if err != nil { - continue // new since; nothing to keep - } - now, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if string(old) == string(now) { - continue - } - m, err := ParseManifest(now) - if err != nil { - t.Errorf("%s: %v", module, err) - continue - } - earlier, err := ParseManifest(old) - if err != nil { - t.Errorf("%s before: %v", module, err) - continue - } - var was, is any - if err := json.Unmarshal(old, &was); err != nil { - t.Fatal(err) - } - if err := json.Unmarshal(now, &is); err != nil { - t.Fatal(err) - } - // Both sides resolved: the manifest before may itself already place some directories - // (issue 119's conversion), and what must not move is the path a machine sees. - was = resolvedTree(was, dirsFor(earlier, Rendering{}), module, t) - resolved := resolvedTree(is, dirsFor(m, Rendering{}), module, t) - // An access named by id resolves to the path the definition still carries as its default - // (issue 153) — the same rule as a placed directory: an assignment that says nothing moves - // nothing. - was = accessesResolved(was, earlier) - resolved = accessesResolved(resolved, m) - if !reflect.DeepEqual(was, resolved) { - wasJSON, _ := json.MarshalIndent(was, "", " ") - isJSON, _ := json.MarshalIndent(resolved, "", " ") - t.Errorf("%s: resolved on the default root, the converted manifest is not the one before it\n--- before\n%s\n--- resolved now\n%s", - module, firstDifference(string(wasJSON), string(isJSON)), "") - } - compared++ - } - t.Logf("%d converted manifest(s) resolve to the paths they named before", compared) -} - -// resolvedTree is the manifest as a machine would see it: every ${dir:…} filled, a pathless -// directory given the path it resolves to, and the placement word removed. -func resolvedTree(node any, dirs map[string]string, module string, t *testing.T) any { - switch v := node.(type) { - case map[string]any: - out := map[string]any{} - for k, child := range v { - if k == "place" { - continue - } - out[k] = resolvedTree(child, dirs, module, t) - } - if out["type"] == "directory" { - if _, has := out["path"]; !has { - if id, ok := out["id"].(string); ok { - out["path"] = dirs[id] - } - } - } - return out - case []any: - out := make([]any, len(v)) - for i, child := range v { - out[i] = resolvedTree(child, dirs, module, t) - } - return out - case string: - filled, err := dirFill(v, dirs, module) - if err != nil { - t.Error(err) - } - return filled - } - return node -} - -func firstDifference(a, b string) string { - al, bl := strings.Split(a, "\n"), strings.Split(b, "\n") - for i := range al { - if i >= len(bl) || al[i] != bl[i] { - from := i - 2 - if from < 0 { - from = 0 - } - to := i + 3 - if to > len(al) { - to = len(al) - } - bt := i + 3 - if bt > len(bl) { - bt = len(bl) - } - return "before:\n" + strings.Join(al[from:to], "\n") + "\nnow:\n" + strings.Join(bl[from:bt], "\n") - } - } - return "(the difference is beyond the shorter document)" -} - -// accessesResolved fills every ${access:} with the default path the definition carries for that -// access, and drops the id, so a manifest that names its accesses is compared by the paths a machine -// with no placement receives. -func accessesResolved(node any, m Manifest) any { - defaults := map[string]string{} - for _, a := range m.Accesses { - if a.ID != "" && a.Path != "" { - defaults[a.ID] = a.Path - } - } - var walk func(any) any - walk = func(n any) any { - switch v := n.(type) { - case map[string]any: - out := map[string]any{} - for k, child := range v { - if k == "id" { - if _, isAccess := v["mode"]; isAccess && v["type"] == nil { - if _, hasPath := v["path"]; hasPath { - continue - } - } - } - out[k] = walk(child) - } - return out - case []any: - out := make([]any, len(v)) - for i, child := range v { - out[i] = walk(child) - } - return out - case string: - return accessRef.ReplaceAllStringFunc(v, func(ref string) string { - if p, ok := defaults[accessRef.FindStringSubmatch(ref)[1]]; ok { - return p - } - return ref - }) - } - return n - } - return walk(node) -} diff --git a/internal/catalogue/placement_test.go b/internal/catalogue/placement_test.go deleted file mode 100644 index 1b19d70a..00000000 --- a/internal/catalogue/placement_test.go +++ /dev/null @@ -1,282 +0,0 @@ -package catalogue - -import ( - "strings" - "testing" -) - -// The case novox/hq issue 153 records: an adopted machine keeps its data where the predecessor put -// it — a library on its own pool that must never move, a configuration on a second disk owned by -// whoever the predecessor ran as. A definition may name none of that (ADR 0112); the assignment -// says it, by the ids the definition declared, and the machine receives concrete paths as always. -func placeable() Manifest { - m := mod("arr", nil, nil, nil) - m.Resources = []map[string]any{ - {"id": "state", "type": "directory", "place": ".", "mode": "0700"}, - {"id": "config", "type": "directory", "mode": "0755", "owner": "1000:1000"}, - {"id": "server", "type": "container", "name": "arr", "image": "arr:1", - "volumes": []any{"${dir:config}:/config", "${access:series}:/series", "${access:spool}:/downloads:ro"}, - "env": map[string]any{"SPOOL": "${access:spool}"}}, - } - m.Accesses = []Access{{ID: "series", Mode: AccessReadWrite}, {ID: "spool"}} - return m -} - -func placedBy(values map[string]any) Rendering { - return Rendering{Settings: SettingsBy{"arr": {LayerOf("node anchor", values)}}} -} - -func TestAnAssignmentPlacesDirectoriesAndAccesses(t *testing.T) { - got, err := Resolve(shelf(placeable()), []string{"arr"}, workstation(), World{}) - if err != nil { - t.Fatal(err) - } - out, err := got.Declaration(placedBy(map[string]any{ - PlacesSetting: map[string]any{ - "config": map[string]any{"path": "/services/arr/config/", "owner": "1001:2000"}, - }, - AccessesSetting: map[string]any{ - "series": "/storage/media/series", - "spool": "/storage/downloads", - }, - })) - if err != nil { - t.Fatal(err) - } - seen := map[string]map[string]any{} - for _, r := range out { - seen[r["id"].(string)] = r - } - config := seen["arr.config"] - if config["path"] != "/services/arr/config" || config["owner"] != "1001:2000" { - t.Fatalf("the placed directory is %v %v; want the assignment's path and owner", config["path"], config["owner"]) - } - if seen["arr.state"]["path"] != "/var/lib/arr" { - t.Fatalf("an unplaced directory left the default layout: %v", seen["arr.state"]["path"]) - } - var accesses []string - for _, r := range out { - if r["type"] == "access" { - accesses = append(accesses, r["path"].(string)+" "+r["mode"].(string)) - } - } - if strings.Join(accesses, ",") != "/storage/media/series read-write,/storage/downloads read" { - t.Fatalf("the accesses reached the machine as %v", accesses) - } - server := seen["arr.server"] - mounts := server["volumes"].([]any) - if mounts[0] != "/services/arr/config:/config" || mounts[1] != "/storage/media/series:/series" || - mounts[2] != "/storage/downloads:/downloads:ro" { - t.Fatalf("the mounts were not filled with the placed paths: %v", mounts) - } - if server["env"].(map[string]any)["SPOOL"] != "/storage/downloads" { - t.Fatalf("the environment was not filled: %v", server["env"]) - } -} - -// An access declared by id and placed by nobody resolves to nowhere, and that is refused with the -// setting to write — not mounted as the literal, not skipped. The refusal costs the module its -// place in the declaration, not the machine its declaration (novox/hq ADR 0163, rule 6). -func TestAnUnplacedAccessIsRefusedByName(t *testing.T) { - got, err := Resolve(shelf(placeable()), []string{"arr"}, workstation(), World{}) - if err != nil { - t.Fatal(err) - } - with := placedBy(map[string]any{ - AccessesSetting: map[string]any{"series": "/storage/media/series"}, - }) - composed, err := got.Compose(with) - if err != nil { - t.Fatal(err) - } - why := composed.LeftOut["arr"] - if why == "" || !strings.Contains(why, `"spool"`) || !strings.Contains(why, AccessesSetting) { - t.Fatalf("an access nobody placed was not refused by name: %v", composed.LeftOut) - } - if _, declared := byID(composed.Resources)["arr.server"]; declared { - t.Fatal("the module with the unplaced access was declared anyway") - } -} - -// Validated like endpoints: an id the module does not declare reaches nothing, and the refusal -// says what it does declare; a relative path and a non-numeric owner are refused too. -func TestPlacementsAreValidated(t *testing.T) { - m := placeable() - layers := func(values map[string]any) []Layer { return placedBy(values).Settings["arr"] } - - _, err := Places(m, layers(map[string]any{PlacesSetting: map[string]any{"data": "/mnt/data"}})) - if err == nil || !strings.Contains(err.Error(), "does not declare") || !strings.Contains(err.Error(), `"config"`) { - t.Fatalf("placing an undeclared directory was accepted: %v", err) - } - _, err = Places(m, layers(map[string]any{PlacesSetting: map[string]any{"config": "services/arr"}})) - if err == nil || !strings.Contains(err.Error(), "absolute") { - t.Fatalf("a relative placement was accepted: %v", err) - } - _, err = Places(m, layers(map[string]any{PlacesSetting: map[string]any{ - "config": map[string]any{"path": "/services/arr", "owner": "media"}}})) - if err == nil || !strings.Contains(err.Error(), "uid:gid") { - t.Fatalf("a non-numeric owner was accepted: %v", err) - } - _, err = AccessPlaces(m, layers(map[string]any{AccessesSetting: map[string]any{"movies": "/storage/media/movies"}})) - if err == nil || !strings.Contains(err.Error(), "does not declare") || !strings.Contains(err.Error(), `"series"`) { - t.Fatalf("placing an undeclared access was accepted: %v", err) - } - _, err = AccessPlaces(m, layers(map[string]any{AccessesSetting: map[string]any{"series": "media/series"}})) - if err == nil || !strings.Contains(err.Error(), "absolute") { - t.Fatalf("a relative access was accepted: %v", err) - } - // And the two keys are never stray: they are validated here, not merged into a file. - if stray := UnusedSettings(m, layers(map[string]any{ - PlacesSetting: map[string]any{"config": "/services/arr/config"}, - AccessesSetting: map[string]any{"series": "/storage/media/series"}, - })); len(stray) != 0 { - t.Fatalf("the placement keys were reported as unused: %v", stray) - } -} - -// A definition that carries a path still works, as the default the assignment may replace — and -// the assignment's placement wins where both say. -func TestADefinitionsPathIsTheDefaultTheAssignmentReplaces(t *testing.T) { - m := placeable() - m.Accesses = []Access{{ID: "series", Path: "/services/media/series", Mode: AccessReadWrite}, {ID: "spool", Path: "/services/media/downloads"}} - got, err := Resolve(shelf(m), []string{"arr"}, workstation(), World{}) - if err != nil { - t.Fatal(err) - } - out, err := got.Declaration(placedBy(map[string]any{ - PlacesSetting: map[string]any{"config": "/services/arr/config"}, - AccessesSetting: map[string]any{"series": "/storage/media/series"}, - })) - if err != nil { - t.Fatal(err) - } - var paths []string - for _, r := range out { - if r["type"] == "access" { - paths = append(paths, r["path"].(string)) - } - } - if strings.Join(paths, ",") != "/storage/media/series,/services/media/downloads" { - t.Fatalf("placed one, defaulted the other: got %v", paths) - } -} - -// A reference to an access the definition does not declare is refused where the author is. -func TestAnUnknownAccessReferenceIsRefusedAtParse(t *testing.T) { - _, err := ParseManifest([]byte(`{ - "module": "arr", "version": "1", - "accesses": [{"id": "series", "mode": "read-write"}], - "resources": [ - {"id": "state", "type": "directory", "place": ".", "mode": "0700"}, - {"id": "server", "type": "container", "name": "arr", "image": "arr:1", - "volumes": ["${access:movies}:/movies"]} - ]}`)) - if err == nil || !strings.Contains(err.Error(), "${access:movies}") { - t.Fatalf("a reference to an undeclared access was accepted: %v", err) - } - _, err = ParseManifest([]byte(`{"module": "arr", "version": "1", "accesses": [{"mode": "read"}]}`)) - if err == nil || !strings.Contains(err.Error(), "neither an id nor a path") { - t.Fatalf("an access with no id and no path was accepted: %v", err) - } -} - -// **Placement is read from the placement, never from a layer's values** (novox/hq ADR 0348 clause 11, issue -// 651). A `places` or `accesses` among the config values places nothing; the same in Placement does. A -// mergeable file of today's form still takes every key of the layer as it is said, placement too, so it -// composes as it did while placement was kept in the layer. -func TestPlacementIsReadFromThePlacementOnly(t *testing.T) { - m := placeable() - said := map[string]any{ - PlacesSetting: map[string]any{"config": "/services/arr/config"}, - AccessesSetting: map[string]any{"series": "/storage/media/series", "spool": "/storage/downloads"}, - "log": "warn", - } - among := []Layer{{From: "anchor", Values: said}} - if placed, err := Places(m, among); err != nil || placed != nil { - t.Fatalf("a place among the config values placed %v (%v)", placed, err) - } - if _, _, err := accessesFor(m, among); err == nil { - t.Fatal("an access among the config values placed it") - } - beside := []Layer{LayerOf("anchor", said)} - if beside[0].Values["log"] != "warn" || beside[0].Values[PlacesSetting] != nil || beside[0].Placement[PlacesSetting] == nil { - t.Fatalf("LayerOf split %+v", beside[0]) - } - if placed, err := Places(m, beside); err != nil || placed["config"].Path != "/services/arr/config" { - t.Fatalf("the placement did not place: %v (%v)", placed, err) - } - - file := map[string]any{"id": "runtime", "type": "file", "path": "/x", "content": `{"a":1}`, "merge": "json"} - whole, err := ApplySettings(file, among) - if err != nil { - t.Fatal(err) - } - split, err := ApplySettings(file, beside) - if err != nil { - t.Fatal(err) - } - if whole["content"] != split["content"] || !strings.Contains(split["content"].(string), `"places"`) { - t.Fatalf("today's mergeable file composes %q, was %q", split["content"], whole["content"]) - } -} - -// **The four words of where an assignment is reached are placement too** (novox/hq ADR 0348 clause 11, issue 736). -// `endpoints`, `expose`, `reach` and `ports` among a layer's config values say nothing to the filter, the ports or -// the names; the same in Placement do, and LayerOf puts them there. A mergeable file of today's form still takes -// every key of the layer as it is said, so it composes as it did while they were kept among the values. -func TestTheFourWordsAreReadFromThePlacementOnly(t *testing.T) { - m := Manifest{Module: "forge", Version: "1", - Listens: []Listening{{Name: "web", Port: 3000, Protocol: "tcp", From: FromMesh}, {Name: "ssh", Port: 22, Protocol: "tcp", From: FromMesh}, - {Name: "api", Port: 8086, Protocol: "tcp", From: FromMesh}}, - Resources: []map[string]any{{"id": "server", "type": "container", "image": "x", "ports": []any{"3000", "222:22", "8086"}}}} - said := map[string]any{ - ExposeSetting: map[string]any{"22": FromEverywhere}, - PortsSetting: map[string]any{"22": float64(2222)}, - ReachSetting: map[string]any{"3000": "internal"}, - EndpointsSetting: map[string]any{"api": map[string]any{"label": "influx", "port": float64(18086), "reach": "public"}}, - "log": "warn", - } - among := []Layer{{From: "anchor", Values: said}} - if e, err := Exposure(m, among); err != nil || e != nil { - t.Fatalf("an expose among the config values exposed %v (%v)", e, err) - } - if g, err := GivenPorts(m, among); err != nil || g != nil { - t.Fatalf("a ports among the config values gave %v (%v)", g, err) - } - if r, err := Reaches(m, among); err != nil || r != nil { - t.Fatalf("a reach among the config values reached %v (%v)", r, err) - } - if b, err := Endpoints(m, among); err != nil || len(b) != 0 { - t.Fatalf("an endpoints among the config values configured %v (%v)", b, err) - } - - beside := []Layer{LayerOf("anchor", said)} - if len(beside[0].Values) != 1 || beside[0].Values["log"] != "warn" || len(beside[0].Placement) != 4 { - t.Fatalf("LayerOf split %+v", beside[0]) - } - if e, err := Exposure(m, beside); err != nil || e[22] != FromEverywhere { - t.Fatalf("the placement did not expose: %v (%v)", e, err) - } - if g, err := GivenPorts(m, beside); err != nil || g[22] != 2222 || g[8086] != 18086 { - t.Fatalf("the placement did not give the ports: %v (%v)", g, err) - } - if r, err := Reaches(m, beside); err != nil || r[3000] != "internal" || r[8086] != "public" { - t.Fatalf("the placement did not reach: %v (%v)", r, err) - } - if b, err := Endpoints(m, beside); err != nil || b["api"].Label != "influx" { - t.Fatalf("the placement did not configure the endpoint: %v (%v)", b, err) - } - - file := map[string]any{"id": "runtime", "type": "file", "path": "/x", "content": `{"a":1}`, "merge": "json"} - whole, err := ApplySettings(file, among) - if err != nil { - t.Fatal(err) - } - split, err := ApplySettings(file, beside) - if err != nil { - t.Fatal(err) - } - if whole["content"] != split["content"] || !strings.Contains(split["content"].(string), `"endpoints"`) { - t.Fatalf("today's mergeable file composes %q, was %q", split["content"], whole["content"]) - } -} diff --git a/internal/catalogue/port_into_test.go b/internal/catalogue/port_into_test.go deleted file mode 100644 index 1e13cb56..00000000 --- a/internal/catalogue/port_into_test.go +++ /dev/null @@ -1,170 +0,0 @@ -package catalogue - -import ( - "reflect" - "strconv" - "strings" - "testing" -) - -// **A process binds the port the mesh gave it, not the one it wrote down.** -// -// A container never needed this: the mesh rewrites its `ports` into assigned:wanted, so the -// software binds the number it always bound and the machine publishes another. A process runs on -// the machine with nothing to rewrite, so without a way to ask, every process binds the number in -// its own configuration and two modules declaring the same one collide — which is the whole -// problem ADR 0038 exists to prevent, reintroduced by the resource kind that most needs it. -func TestAModuleIsToldWhichPortItWasGiven(t *testing.T) { - file := map[string]any{ - "type": "file", "id": "settings", - "content": "LISTEN=${port:8080}\n", - } - listens := []Listening{{Port: 8080, From: FromMesh}} - with := Rendering{Ports: map[string]map[int]int{"showcase": {8080: 21000}}} - - if err := portInto(file, "showcase", listens, with); err != nil { - t.Fatal(err) - } - if got := file["content"].(string); got != "LISTEN=21000\n" { - t.Fatalf("the module was not told its assigned port: %q", got) - } -} - -// With nothing assigned yet, it is told the port it asked about — so a mesh that has not made an -// assignment still composes something coherent rather than writing a zero. -func TestWithNoAssignmentAModuleIsToldWhatItAskedFor(t *testing.T) { - file := map[string]any{"type": "file", "content": "LISTEN=${port:8080}\n"} - if err := portInto(file, "showcase", []Listening{{Port: 8080}}, Rendering{}); err != nil { - t.Fatal(err) - } - if got := file["content"].(string); got != "LISTEN=8080\n" { - t.Fatalf("an unassigned port did not fall back to what was declared: %q", got) - } -} - -// **Asking about a port it never declared is refused**, and the refusal says what it did declare. -// The module is asking about something the mesh has no opinion on, and answering would be a guess -// written into a configuration file as a port number. -func TestAskingAboutAnUndeclaredPortIsRefused(t *testing.T) { - file := map[string]any{"type": "file", "content": "LISTEN=${port:9999}\n"} - err := portInto(file, "showcase", []Listening{{Port: 8080}}, Rendering{}) - if err == nil { - t.Fatal("a module was told a port it never said it listens on") - } - if !strings.Contains(err.Error(), "8080") { - t.Fatalf("the refusal does not say what would have worked: %v", err) - } -} - -// And a file mentioning no port is left exactly as it was. -func TestAFileWithNoPortIsUntouched(t *testing.T) { - file := map[string]any{"type": "file", "content": "GREETING=hello\n"} - if err := portInto(file, "showcase", nil, Rendering{}); err != nil { - t.Fatal(err) - } - if got := file["content"].(string); got != "GREETING=hello\n" { - t.Fatalf("a file with no port was changed: %q", got) - } -} - -// **A container that dials the machine is told the same thing, in its environment.** -// -// The forge's own sidecar reaches the forge over the machine's loopback (novox/hq 04-ISSUES/088). -// The `assigned:wanted` mapping that lets the forge itself go on binding 3000 does nothing for the -// caller: the caller dials the machine, so it must be given the machine's number — here one the -// node was given rather than one the mesh assigned, which is the case that makes the literal wrong -// even on a mesh that never allocates (ADR 0100). -func TestAContainerIsToldWhichPortItWasGiven(t *testing.T) { - sidecar := map[string]any{ - "type": "container", "id": "runtime", "name": "mesh-gitea", - "env": map[string]any{ - "MESH_GITEA_URL": "http://127.0.0.1:${port:3000}", - "MESH_ADMIN_USER": "mesh-admin", - }, - } - listens := []Listening{{Port: 3000, From: FromMesh}} - with := Rendering{Given: map[string]map[int]int{"gitea": {3000: 2999}}} - - if err := portInto(sidecar, "gitea", listens, with); err != nil { - t.Fatal(err) - } - env := sidecar["env"].(map[string]any) - if env["MESH_GITEA_URL"] != "http://127.0.0.1:2999" { - t.Fatalf("the sidecar dials %v, not the port this machine puts the forge on", - env["MESH_GITEA_URL"]) - } - if env["MESH_ADMIN_USER"] != "mesh-admin" { - t.Fatalf("filling one value changed another: %v", env) - } -} - -// **And the manifest it came out of is left alone.** -// -// An `env` map is the catalogue's, shared by every node running the module, and the resource -// around it is a shallow copy. Filled in place, the first node composed would write its own port -// into the catalogue and every node composed after it would be told that one — a fault that is -// invisible in a single composition and wrong in every mesh with two machines. -func TestFillingAContainersEnvironmentLeavesTheManifestAlone(t *testing.T) { - env := map[string]any{"MESH_GITEA_URL": "http://127.0.0.1:${port:3000}"} - manifest := map[string]any{"type": "container", "id": "runtime", "name": "mesh-gitea", "env": env} - - for _, at := range []int{2999, 3100} { - copied := map[string]any{} - for k, v := range manifest { - copied[k] = v - } - with := Rendering{Given: map[string]map[int]int{"gitea": {3000: at}}} - if err := portInto(copied, "gitea", []Listening{{Port: 3000}}, with); err != nil { - t.Fatal(err) - } - want := "http://127.0.0.1:" + strconv.Itoa(at) - if got := copied["env"].(map[string]any)["MESH_GITEA_URL"]; got != want { - t.Fatalf("the second node was told %v, not %v — the first composition wrote its own "+ - "port into the catalogue", got, want) - } - } - if env["MESH_GITEA_URL"] != "http://127.0.0.1:${port:3000}" { - t.Fatalf("the module's own manifest was edited: %v", env) - } -} - -// Asking in an environment about a port it never declared is refused exactly as a file's is, and -// the refusal names the container and the variable — there is no line number to go on. -func TestAContainerAskingAboutAnUndeclaredPortIsRefused(t *testing.T) { - sidecar := map[string]any{ - "type": "container", "id": "runtime", "name": "mesh-gitea", - "env": map[string]any{"MESH_GITEA_URL": "http://127.0.0.1:${port:9999}"}, - } - err := portInto(sidecar, "gitea", []Listening{{Port: 3000}}, Rendering{}) - if err == nil { - t.Fatal("a container was told a port its module never said it listens on") - } - for _, said := range []string{"mesh-gitea", "MESH_GITEA_URL", "3000"} { - if !strings.Contains(err.Error(), said) { - t.Errorf("the refusal does not say %q: %v", said, err) - } - } -} - -// And a container naming no port keeps the environment it was written with — the same map, so -// nothing is copied and no other node's composition is disturbed. -func TestAContainerWithNoPortPlaceholderKeepsItsEnvironment(t *testing.T) { - env := map[string]any{"MESH_GITEA_URL": "http://gitea:3000", "PORT": 3000} - sidecar := map[string]any{"type": "container", "name": "mesh-gitea", "env": env} - if err := portInto(sidecar, "gitea", []Listening{{Port: 3000}}, Rendering{ - Given: map[string]map[int]int{"gitea": {3000: 2999}}, - }); err != nil { - t.Fatal(err) - } - // A sibling reached over the runtime's own network is reached on the port INSIDE it, which the - // module does control: a literal there is right, and moving it would break the one address the - // mapping does not touch. - if got := sidecar["env"].(map[string]any)["MESH_GITEA_URL"]; got != "http://gitea:3000" { - t.Fatalf("an address on the runtime's own network was moved to the machine's port: %v", got) - } - // The same map, not a copy of it: a container that asks for nothing is left alone, and - // comparing the contents would say yes even to a rebuilt map. - if reflect.ValueOf(sidecar["env"]).Pointer() != reflect.ValueOf(env).Pointer() { - t.Fatalf("a container that asked for nothing had its environment rebuilt: %v", sidecar["env"]) - } -} diff --git a/internal/catalogue/predefined_test.go b/internal/catalogue/predefined_test.go deleted file mode 100644 index 015a8e58..00000000 --- a/internal/catalogue/predefined_test.go +++ /dev/null @@ -1,175 +0,0 @@ -package catalogue - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" -) - -// The list of predefined keys (novox/hq ADR 0348 §7, design 57 package K2, issue 661): read strictly, and every -// manifest naming a key judged against the list of the tree it is read in. - -// theFirstKeys is the catalogue's first list: font-size and agent-managed-settings. -const theFirstKeys = `{ - "font-size": {"type": "number", "description": "the size text is shown in, in points", "minimum": 6, "maximum": 72}, - "agent-managed-settings": {"type": "object", "description": "the managed settings of every agent session, a JSON object", "trusted": true} -}` - -func TestTheFirstListIsRead(t *testing.T) { - list, err := ParsePredefined([]byte(theFirstKeys)) - if err != nil { - t.Fatal(err) - } - if got := strings.Join(list.Keys(), ","); got != "agent-managed-settings,font-size" { - t.Fatalf("keys %s", got) - } - if k := list["font-size"]; k.Type != ConfigNumber || *k.Minimum != 6 || *k.Maximum != 72 || k.Trusted { - t.Fatalf("font-size read as %+v", k) - } - if k := list["agent-managed-settings"]; k.Type != ConfigObject || !k.Trusted { - t.Fatalf("agent-managed-settings read as %+v", k) - } - // One list is one text, however it was written. - a, _ := list.Canonical() - again, _ := ParsePredefined(a) - b, _ := again.Canonical() - if string(a) != string(b) { - t.Fatalf("canonical text moved:\n%s\n%s", a, b) - } -} - -func TestAListIsRefusedWholeForAnyWrongKey(t *testing.T) { - for _, c := range []struct{ file, says string }{ - {`[]`, "not a JSON object of keys"}, - {`null`, "not a JSON object of keys"}, - {`{"font-size": {"type": "number", "description": "points", "default": 10}}`, `unknown field "default"`}, - {`{"font-size": {"description": "points"}}`, "no type"}, - {`{"font-size": {"type": "decimal", "description": "points"}}`, `type "decimal"`}, - {`{"font-size": {"type": "number"}}`, "no description"}, - {`{"accent": {"type": "object", "description": "colours"}}`, "not trusted"}, - {`{"font-size": {"type": "number", "description": "points", "minimum": 9, "maximum": 6}}`, "above its maximum"}, - {`{"accent": {"type": "string", "description": "a name", "pattern": "("}}`, "not a regular expression"}, - {`{"accent": {"type": "boolean", "description": "on or off", "minimum": 1}}`, "minimum or maximum is for a number"}, - {`{"Font Size": {"type": "number", "description": "points"}}`, "not a usable key"}, - {`{"places": {"type": "string", "description": "where"}}`, "placement"}, - {`{"puid": {"type": "number", "description": "an id"}}`, "placement"}, - } { - if _, err := ParsePredefined([]byte(c.file)); err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: wanted a refusal saying %q, got %v", c.file, c.says, err) - } - } -} - -// fallingBackTo is inConfigForm with its font size falling back to a predefined key. -func fallingBackTo(t *testing.T, key string) []byte { - t.Helper() - m := inConfigForm() - declaration(m, "font-size")["predefined"] = key - raw, err := json.Marshal(m) - if err != nil { - t.Fatal(err) - } - return raw -} - -// A manifest naming font-size registers against the list; one naming a key not on it, or of another type, is -// refused — judged against the list given, whatever the mesh published. -func TestAManifestIsJudgedAgainstTheListOfItsTree(t *testing.T) { - withPredefined(t, nil) - list, err := ParsePredefined([]byte(theFirstKeys)) - if err != nil { - t.Fatal(err) - } - if _, err := ParseManifestWith(fallingBackTo(t, "font-size"), list); err != nil { - t.Fatalf("a value falling back to font-size is refused: %v", err) - } - if _, err := ParseManifestWith(fallingBackTo(t, "font-scale"), list); err == nil || - !strings.Contains(err.Error(), `"font-scale", which is not on the list`) { - t.Fatalf("a key not on the list: %v", err) - } - m := inConfigForm() - declaration(m, "theme")["predefined"] = "font-size" - raw, _ := json.Marshal(m) - if _, err := ParseManifestWith(raw, list); err == nil || !strings.Contains(err.Error(), "which is a number") { - t.Fatalf("a key of another type: %v", err) - } - // Where no tree is read, the published list judges: none published refuses. - if _, err := ParseManifest(fallingBackTo(t, "font-size")); err == nil || !strings.Contains(err.Error(), "is not present") { - t.Fatalf("judged against no list: %v", err) - } - withPredefined(t, list) - if _, err := ParseManifest(fallingBackTo(t, "font-size")); err != nil { - t.Fatalf("judged against the published list: %v", err) - } -} - -// A template's text filled from the tree keeps the list the manifest was judged against: read again, it is not -// judged against the published one. -func TestTemplatesFilledKeepTheListTheManifestWasJudgedAgainst(t *testing.T) { - withPredefined(t, nil) - list, _ := ParsePredefined([]byte(theFirstKeys)) - dir := t.TempDir() - if err := os.MkdirAll(filepath.Join(dir, DefaultTemplatesDir), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, DefaultTemplatesDir, "rc"), []byte("font = ${config:font-size}\n"), 0o644); err != nil { - t.Fatal(err) - } - m := inConfigForm() - declaration(m, "font-size")["predefined"] = "font-size" - r := resourceOf(m, "configuration") - delete(r, "content") - r["template"] = DefaultTemplatesDir + "/rc" - m["build"] = map[string]any{"artifacts": []any{map[string]any{"name": "files", "kind": "archive", "from": "files"}}} - declaration(m, "width")["runtime"] = true - declaration(m, "sender")["runtime"] = true - raw, _ := json.Marshal(m) - parsed, err := ParseManifestWith(raw, list) - if err != nil { - t.Fatal(err) - } - if _, err := WithTemplatesFrom(parsed, dir); err != nil { - t.Fatalf("filled from the tree, it was judged against another list: %v", err) - } -} - -// module check reads the list at the root of the repository above the manifest; no repository, no list. -func TestTheListIsFoundAtTheRepositoryRootAboveAManifest(t *testing.T) { - root := t.TempDir() - module := filepath.Join(root, "modules", "dunst") - if err := os.MkdirAll(module, 0o755); err != nil { - t.Fatal(err) - } - if list, err := PredefinedAbove(module); err != nil || list != nil { - t.Fatalf("outside a repository: %v %v", list, err) - } - if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil { - t.Fatal(err) - } - if list, err := PredefinedAbove(module); err != nil || list != nil { - t.Fatalf("a repository without the file: %v %v", list, err) - } - if err := os.WriteFile(filepath.Join(root, PredefinedFile), []byte(theFirstKeys), 0o644); err != nil { - t.Fatal(err) - } - list, err := PredefinedAbove(module) - if err != nil || len(list) != 2 { - t.Fatalf("the list above: %v %v", list, err) - } - if err := os.WriteFile(filepath.Join(root, PredefinedFile), []byte(`{"font-size": {}}`), 0o644); err != nil { - t.Fatal(err) - } - if _, err := PredefinedAbove(module); err == nil { - t.Fatal("a wrong list above was read") - } - if err := os.Remove(filepath.Join(root, PredefinedFile)); err != nil { - t.Fatal(err) - } - if err := os.Symlink("/etc/hostname", filepath.Join(root, PredefinedFile)); err == nil { - if _, err := PredefinedAbove(module); err == nil || !strings.Contains(err.Error(), "not a plain file") { - t.Fatalf("a link read as the list: %v", err) - } - } -} diff --git a/internal/catalogue/prepares_test.go b/internal/catalogue/prepares_test.go deleted file mode 100644 index eaae43bd..00000000 --- a/internal/catalogue/prepares_test.go +++ /dev/null @@ -1,141 +0,0 @@ -package catalogue - -import ( - "encoding/json" - "fmt" - "strings" - "testing" -) - -// A module version prepares its state before it runs (novox/hq ADR 0135). -// -// What the mesh derives is the module's own resource, run once with one word, placed immediately in -// front of the thing it prepares for. What matters in these tests is that the derivation is a copy -// rather than a second description: the failure it replaces was a hand-written step repeating six -// fields of the resource it preceded, each free to drift from it. - -func aPreparingModule() Manifest { - return Manifest{ - Module: "gitea", - Prepares: true, - Resources: []map[string]any{ - {"id": "state", "type": "directory", "path": "/var/lib/gitea", "mode": "0700"}, - {"id": "server", "type": "container", "name": "mesh-gitea-server", - "image": "gitea/gitea@" + digest, "ports": []any{"3000:3000"}}, - {"id": "runtime", "type": "container", "name": "mesh-gitea", - "image": ArtifactStoreScheme + "gitea/runtime@" + digest, "network": "host", - "env": map[string]any{"MESH_GITEA_STATE_DIR": "/run/state"}, - "volumes": []any{"/var/lib/gitea:/run/state:ro"}, - "ports": []any{"9000:9000"}}, - }, - } -} - -func declaredFor(t *testing.T, m Manifest) []map[string]any { - t.Helper() - // The manifest as the mesh holds it: a build resolved the module's own artifact into the - // reference it recorded, which is also how the composition knows whose code a resource runs. - out, err := Resolution{Node: "anchor", Modules: []Manifest{m}}.Declaration( - Rendering{ArtifactStore: "anchor.internal:5100"}) - if err != nil { - t.Fatal(err) - } - return out -} - -func idsOf(resources []map[string]any) []string { - var ids []string - for _, r := range resources { - ids = append(ids, fmt.Sprint(r["id"])) - } - return ids -} - -// The step runs the module's own code, and comes immediately before it — not before the upstream -// server the module packages, which may be the very thing the state lives in. -func TestThePreparationRunsTheModulesOwnCodeAndComesRightBeforeIt(t *testing.T) { - out := declaredFor(t, aPreparingModule()) - ids := idsOf(out) - at := -1 - for i, id := range ids { - if id == "gitea.runtime-prepare" { - at = i - } - } - if at < 0 { - t.Fatalf("nothing prepares this module's state: %v", ids) - } - // A module's name may contain a dot, so a resource's module is everything before the last one — - // which the derived id must not add to, or a machine reads the wrong owner from it. - if strings.Count("gitea.runtime-prepare", ".") != 1 { - t.Fatal("the derived id adds a dot, so what owns it cannot be read from it") - } - if ids[at+1] != "gitea.runtime" { - t.Fatalf("the preparation is not immediately before the module's own code: %v", ids) - } - for _, id := range ids[:at] { - if id == "gitea.runtime" { - t.Fatalf("the module's own code runs before its state is prepared: %v", ids) - } - } -} - -// It is given exactly what the module's own code is given. Asserted field by field against the -// resource it was derived from, because writing it twice is the fault this replaces. -func TestThePreparationIsGivenWhatTheModuleIsGiven(t *testing.T) { - out := declaredFor(t, aPreparingModule()) - declared := byID(out) - step, workload := declared["gitea.runtime-prepare"], declared["gitea.runtime"] - if step == nil || workload == nil { - t.Fatalf("expected both, got %v", idsOf(out)) - } - for _, field := range []string{"image", "network", "env", "volumes", "type"} { - if fmt.Sprint(step[field]) != fmt.Sprint(workload[field]) { - t.Errorf("the preparation's %s is %v and the module's is %v", field, step[field], workload[field]) - } - } - if once, _ := step["run-once"].(bool); !once { - t.Error("the preparation is not a step, so nothing waits for it and nothing is gated by it") - } - if fmt.Sprint(step["args"]) != fmt.Sprint([]any{PreparationArgument}) { - t.Errorf("the preparation is asked for as %v", step["args"]) - } - if fmt.Sprint(step["name"]) == fmt.Sprint(workload["name"]) { - t.Error("the preparation and the workload have one name, so one removes the other") - } - // A published port cannot be bound twice, and the version being replaced is still running. - if _, published := step["ports"]; published { - t.Errorf("the preparation publishes a port the running version holds: %v", step["ports"]) - } -} - -// A module that says nothing about preparing gets nothing, which is most modules. -func TestAModuleThatPreparesNothingGetsNoStep(t *testing.T) { - m := aPreparingModule() - m.Prepares = false - for _, id := range idsOf(declaredFor(t, m)) { - if id == "gitea.runtime-prepare" { - t.Fatal("a module that prepares nothing was given a preparation") - } - } -} - -// A module whose own code the mesh cannot find has nothing to ask, and saying so where the manifest -// is read beats a declaration that quietly prepares nothing. -func TestAModuleThatPreparesAndRunsNoneOfItsOwnCodeIsRefused(t *testing.T) { - m := Manifest{ - Module: "gitea", - Prepares: true, - Resources: []map[string]any{ - // Only the upstream server it packages: nothing here runs gitea's own code. - {"id": "server", "type": "container", "name": "mesh-gitea-server", "image": "gitea/gitea@" + digest}, - }, - } - raw, err := json.Marshal(m) - if err != nil { - t.Fatal(err) - } - if _, err := ParseManifest(raw); err == nil { - t.Fatal("a module that prepares its state with nothing of its own to run was accepted") - } -} diff --git a/internal/catalogue/print_rehearsal_test.go b/internal/catalogue/print_rehearsal_test.go deleted file mode 100644 index d2aa7eff..00000000 --- a/internal/catalogue/print_rehearsal_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package catalogue - -import ( - "os" - "testing" -) - -func TestPrintRehearsalRuleset(t *testing.T) { - if os.Getenv("PRINT_RULESET") == "" { - t.Skip("set PRINT_RULESET") - } - rules := mustFilter(t, Resolution{Modules: []Manifest{ - {Module: "pub", Listens: []Listening{{Port: 8099, From: FromMesh, Why: "the thing it serves"}}}, - }}, nil) - t.Log("\n" + AsNftables(rules, []string{"192.0.2.20"}, true, nil, nil, "mesh0")) -} diff --git a/internal/catalogue/process_bus_test.go b/internal/catalogue/process_bus_test.go deleted file mode 100644 index 5551ec17..00000000 --- a/internal/catalogue/process_bus_test.go +++ /dev/null @@ -1,191 +0,0 @@ -package catalogue - -import ( - "strings" - "testing" -) - -// A process resource may declare `bus` (novox/hq ADR 0436 §1): what it emits, the module's own work-queue -// streams it consumes, the module's own state it reads and writes, whether it reads the router's records of -// this module's own asks, and the tools and seat verbs it calls. The fixture is mesh-mail's future manifest -// shape (ADR 0436 §7, §8), copied from mesh-catalog/modules/mesh-mail/module.json and extended as settled by -// the lead: process id "send" is the manifest's own `id` (internal/catalogue/runtime.go:72-74 is the model for -// a process id); the account file uses the controller's real JSON tags (modules.go:1041-1056 issueWith). -func mailSenderProcessManifest(t *testing.T, bus string) string { - t.Helper() - process := `{"id":"send","type":"process","run":["./mesh-mail","send"],"user":"mesh-mail-send"` - if bus != "" { - process += `,"bus":` + bus - } - process += `}` - // The stream itself is plain here, not retention work-queue: 1179 checks only that a process names a - // stream the module owns, never its retention — that rule is 1178's (WorkQueueStreamProblems), and is - // tested against its own fixture there (work_queue_consumer_test.go) so this one stays accepted whether - // or not a process currently names "mail-sends" in bus.queues. - return `{"module":"mesh-mail","version":"1","emits":["mail.send.sent","mail.send.failed"],` + - `"state":["sends"],"streams":[{"name":"mail-sends","events":["mail.send.approved"],"max-age-days":1,` + - `"max-mib":64}],"resources":[{"id":"account","type":"user","name":"mesh-mail-send"},` + process + `]}` -} - -const mailSenderBus = `{"emits":["mail.send.sent","mail.send.failed"],"queues":["mail-sends"],` + - `"state":["sends"],"asks":true,"invokes":["seat:operator-channel.tell"]}` - -// 1179/AC1: a well-formed bus on a process whose account the module makes is accepted, and the catalogue -// gives it whole to the grant composition (ProcessBuses, which 1180 reads). -func TestAProcessDeclaresItsBusAndIsAccepted(t *testing.T) { - m, err := ParseManifest([]byte(mailSenderProcessManifest(t, mailSenderBus))) - if err != nil { - t.Fatalf("a well-formed process bus was refused: %v", err) - } - buses := m.ProcessBuses() - b, ok := buses["send"] - if !ok { - t.Fatalf("the process's bus was not given to the grant composition: %+v", buses) - } - if !strSliceEqual(b.Emits, []string{"mail.send.sent", "mail.send.failed"}) || - !strSliceEqual(b.Queues, []string{"mail-sends"}) || !strSliceEqual(b.State, []string{"sends"}) || - !b.Asks || !strSliceEqual(b.Invokes, []string{"seat:operator-channel.tell"}) { - t.Fatalf("the bus read as %+v", b) - } -} - -func strSliceEqual(a, b []string) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} - -// 1179/AC2: a bus on a process with no user, user root, or the operator account is refused naming the -// process and the account rule. -func TestAProcessBusAccountMustBeTheModulesOwn(t *testing.T) { - for name, c := range map[string]struct{ user, says string }{ - "no user": {"", "send"}, - "user root": {"root", "root"}, - "the operator account": {"${machine:account}", "operator"}, - } { - raw := `{"module":"mesh-mail","version":"1","emits":["mail.send.sent"],"state":["sends"],` + - `"streams":[{"name":"mail-sends","events":["mail.send.approved"],"retention":"work-queue",` + - `"max-deliveries":5}],"resources":[{"id":"account","type":"user","name":"mesh-mail-send"},` + - `{"id":"send","type":"process","run":["./mesh-mail","send"]` - if c.user != "" { - raw += `,"user":"` + c.user + `"` - } - raw += `,"bus":{"emits":["mail.send.sent"]}}]}` - _, err := ParseManifest([]byte(raw)) - if err == nil || !strings.Contains(err.Error(), "send") || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: %v; want a refusal naming the process and %q", name, err, c.says) - } - } -} - -// 1179/AC3: a bus naming a stream, state or event the module does not declare as its own is refused naming -// it. -func TestAProcessBusNamesOnlyTheModulesOwn(t *testing.T) { - for name, c := range map[string]struct{ bus, says string }{ - "another module's event": {`{"emits":["billing.paid"]}`, "billing.paid"}, - "another module's queue": {`{"queues":["other-queue"]}`, "other-queue"}, - "another module's state": {`{"state":["other-state"]}`, "other-state"}, - } { - _, err := ParseManifest([]byte(mailSenderProcessManifest(t, c.bus))) - if err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: %v; want a refusal naming %q", name, err, c.says) - } - } -} - -// 1179/AC4: a bus declaring serves, holds, claims or a seat is refused naming the key; a bus with invokes of -// a tool or a seat verb is accepted. -func TestAProcessBusNeverServesOrHoldsASeat(t *testing.T) { - for name, c := range map[string]struct{ bus, says string }{ - "serves": {`{"emits":["mail.send.sent"],"serves":["send"]}`, "serves"}, - "holds": {`{"emits":["mail.send.sent"],"holds":["mail-sender"]}`, "holds"}, - "claims": {`{"emits":["mail.send.sent"],"claims":["mail-sender"]}`, "claims"}, - "seat": {`{"emits":["mail.send.sent"],"seat":"mail-sender"}`, "seat"}, - } { - _, err := ParseManifest([]byte(mailSenderProcessManifest(t, c.bus))) - if err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: %v; want a refusal naming %q", name, err, c.says) - } - } - accepted := `{"emits":["mail.send.sent","mail.send.failed"],"invokes":["seat:operator-channel.tell","mail.status"]}` - if _, err := ParseManifest([]byte(mailSenderProcessManifest(t, accepted))); err != nil { - t.Fatalf("a bus invoking a tool and a seat verb was refused: %v", err) - } -} - -// 1179/AC5: a process declaring bus is composed with no bus key on the wire, and is otherwise byte for byte -// what main composes for the same process without bus. -func TestAProcessesBusNeverReachesTheWire(t *testing.T) { - withBus, err := ParseManifest([]byte(mailSenderProcessManifest(t, mailSenderBus))) - if err != nil { - t.Fatal(err) - } - withoutBus, err := ParseManifest([]byte(mailSenderProcessManifest(t, ""))) - if err != nil { - t.Fatal(err) - } - with := Rendering{Needed: map[string]map[string]string{}} - outWith, err := Resolution{Node: "novox", Modules: []Manifest{withBus}}.Declaration(with) - if err != nil { - t.Fatalf("composing with bus: %v", err) - } - outWithout, err := Resolution{Node: "novox", Modules: []Manifest{withoutBus}}.Declaration(with) - if err != nil { - t.Fatalf("composing without bus: %v", err) - } - var withProcess, withoutProcess map[string]any - for _, r := range outWith { - if r["id"] == "mesh-mail.send" { - withProcess = r - } - } - for _, r := range outWithout { - if r["id"] == "mesh-mail.send" { - withoutProcess = r - } - } - if withProcess == nil || withoutProcess == nil { - t.Fatalf("the process was not composed at all: with=%v without=%v", withProcess, withoutProcess) - } - if _, has := withProcess["bus"]; has { - t.Fatalf("the process resource carried a bus key onto the wire: %v", withProcess) - } - delete(withProcess, "bus") - if !mapsEqual(withProcess, withoutProcess) { - t.Fatalf("with bus declared, the process composed differently: %+v\nwant: %+v", withProcess, withoutProcess) - } -} - -func mapsEqual(a, b map[string]any) bool { - if len(a) != len(b) { - return false - } - for k, v := range a { - bv, ok := b[k] - if !ok { - return false - } - if av, ok := v.([]any); ok { - bbv, ok := bv.([]any) - if !ok || len(av) != len(bbv) { - return false - } - for i := range av { - if av[i] != bbv[i] { - return false - } - } - continue - } - if v != bv { - return false - } - } - return true -} diff --git a/internal/catalogue/process_env_test.go b/internal/catalogue/process_env_test.go deleted file mode 100644 index bbf03472..00000000 --- a/internal/catalogue/process_env_test.go +++ /dev/null @@ -1,80 +0,0 @@ -package catalogue - -import ( - "strings" - "testing" -) - -// **A process's environment is composed as a container's is** (novox/hq to-be 38 WP4c). -// -// A module's code moving out of its container becomes a process on the machine, and what its -// container's environment asked for — the port this machine gave the module, the place it put the -// module's directory — it still has to be told. Filled for a container and not for a process, the -// literal `${port:8080}` reached the process as its environment and was read as a port; the modules -// that moved first wrote their run-once steps an env file instead. -func processModule(env map[string]any) Manifest { - return Manifest{ - Module: "showcase", - Listens: []Listening{{Port: 8080, From: FromMesh}}, - Resources: []map[string]any{ - {"id": "data", "type": "directory", "mode": "0700"}, - {"id": "setup", "type": "process", "name": "showcase-setup", "run-once": true, - "run": []any{"/usr/bin/showcase", "setup"}, "env": env}, - }, - } -} - -func TestAProcessIsToldItsPortAndItsPlaceInItsEnvironment(t *testing.T) { - env := map[string]any{ - "SHOWCASE_URL": "http://127.0.0.1:${port:8080}", - "SHOWCASE_DATA": "${dir:data}/objects", - "SHOWCASE_DB": "127.0.0.1:${seat:mesh-store:5432}", - "GREETING": "hello", - } - out, err := Resolution{Node: "anchor", Modules: []Manifest{processModule(env)}}.Declaration(Rendering{ - Ports: map[string]map[int]int{"showcase": {8080: 21000}}, - Seats: map[string]map[int]int{"mesh-store": {5432: 6852}}, - }) - if err != nil { - t.Fatalf("a process asking for its port and its place does not compose: %v", err) - } - setup := fileNamed(out, "showcase.setup") - if setup == nil { - t.Fatalf("the process is not in the declaration: %v", out) - } - got, _ := setup["env"].(map[string]any) - for key, want := range map[string]string{ - "SHOWCASE_URL": "http://127.0.0.1:21000", - "SHOWCASE_DATA": "/var/lib/showcase/data/objects", - "SHOWCASE_DB": "127.0.0.1:6852", - "GREETING": "hello", - } { - if got[key] != want { - t.Errorf("the process is told %s=%v, want %q", key, got[key], want) - } - } - if env["SHOWCASE_URL"] != "http://127.0.0.1:${port:8080}" { - t.Fatalf("composing for one machine edited the module's own manifest: %v", env) - } -} - -// An unknown reference in a process's environment is refused as a container's is, naming the -// process and the variable — left alone, it would reach the machine as a literal. -func TestAProcessAskingAboutAnUndeclaredPortIsRefused(t *testing.T) { - env := map[string]any{"SHOWCASE_URL": "http://127.0.0.1:${port:9999}"} - _, err := Resolution{Node: "anchor", Modules: []Manifest{processModule(env)}}.Declaration(Rendering{}) - if err == nil { - t.Fatal("a process was told a port its module never said it listens on") - } - for _, said := range []string{"showcase-setup", "SHOWCASE_URL", "${port:9999}", "8080"} { - if !strings.Contains(err.Error(), said) { - t.Errorf("the refusal does not say %q: %v", said, err) - } - } - - env = map[string]any{"SHOWCASE_DATA": "${dir:date}/objects"} - if _, err := (Resolution{Node: "anchor", Modules: []Manifest{processModule(env)}}).Declaration(Rendering{}); err == nil || - !strings.Contains(err.Error(), "${dir:date}") { - t.Fatalf("a process naming no directory of its module was not refused: %v", err) - } -} diff --git a/internal/catalogue/provided_test.go b/internal/catalogue/provided_test.go deleted file mode 100644 index a4f9413b..00000000 --- a/internal/catalogue/provided_test.go +++ /dev/null @@ -1,144 +0,0 @@ -package catalogue_test - -import ( - "encoding/json" - "strings" - "testing" - - "github.com/novox/mesh-controller/internal/catalogue" - "github.com/novox/mesh-controller/internal/overlay" -) - -// The manifests the control plane actually ships, resolved. -// -// Written because the earlier tests built their own manifests and passed while the real one was -// missing a claim — a whole mechanism could have been absent from what ships and every test would -// still have been green. - -func provided(t *testing.T) map[string]catalogue.Manifest { - t.Helper() - out := map[string]catalogue.Manifest{} - for _, raw := range []map[string]any{ - overlay.Manifest(), - } { - b, err := json.Marshal(raw) - if err != nil { - t.Fatal(err) - } - m, err := catalogue.ParseManifest(b) - if err != nil { - t.Fatalf("a manifest this control plane ships is not valid: %v", err) - } - out[m.Module] = m - } - return out -} - -func TestTheShippedPrivateNetworkResolvesOnItsOwn(t *testing.T) { - // **Assigned directly** (novox/hq ADR 0226): the `networking` bundle that required it is - // retired, so the private network's own module is what a machine is given, and it must need - // nothing the control plane does not ship beside it. - got, err := catalogue.Resolve(provided(t), []string{overlay.Name}, catalogue.Node{Name: "workstation", Site: "house"}, catalogue.World{}) - if err != nil { - t.Fatalf("assigning %s does not work out of the box: %v", overlay.Name, err) - } - if len(got.Modules) != 1 || got.Modules[0].Module != overlay.Name { - t.Fatalf("assigning %s brought %v", overlay.Name, got.Modules) - } - - // **The network writes no names** (novox/hq ADR 0199): /etc/hosts is the hosts seat holder's - // file, and the mesh's resolver answers the machines' names. No fact of the network's module - // may name that file. - for _, m := range got.Modules { - for name, f := range m.Facts { - if f.Path == "/etc/hosts" { - t.Fatalf("the network's provider still writes /etc/hosts, as its %q fact", name) - } - } - } -} - -func TestTheControlPlaneShipsNoNetworkingBundle(t *testing.T) { - // ADR 0226: one module for the one private network. A bundle shipped beside it again would be - // a second name every machine carries for the same thing. - shipped := provided(t) - if len(shipped) != 1 { - t.Fatalf("the control plane ships %d modules, want only %s", len(shipped), overlay.Name) - } - if _, ok := shipped["networking"]; ok { - t.Fatal("the retired networking bundle is shipped again") - } -} - -func TestARefusalForWantOfThePrivateNetworkNamesItsModule(t *testing.T) { - // The hint in a refusal is a string in the resolver rather than an import of this package. It - // named `networking` until ADR 0226; a hint naming a module nobody ships sends a person to - // assign something that does not exist. - shelf := map[string]catalogue.Manifest{ - "app": {Module: "app", Version: "1", Requires: []string{"postgres-database"}}, - "postgres": {Module: "postgres", Version: "1", Provides: catalogue.FromAnywhere("postgres-database")}, - } - _, err := catalogue.Resolve(shelf, []string{"app"}, catalogue.Node{Name: "workstation"}, - catalogue.World{Offered: map[string][]catalogue.Provider{ - "postgres-database": {{Node: "anchor", At: "anchor.internal"}}}}) - if err == nil { - t.Fatal("an app off the private network was pointed at a database on it") - } - if !strings.Contains(err.Error(), "assign "+overlay.Name) { - t.Fatalf("the refusal does not name the private network's module %s: %v", overlay.Name, err) - } -} - -func TestAnotherVPNIsChosenByAssigningItInstead(t *testing.T) { - // What the bundle was for, kept without it: a machine given another VPN answers - // private-network from that VPN, and WireGuard is not dragged in by anything. - shipped := provided(t) - shelf := withTailscale(shipped) - shelf["needs-network"] = catalogue.Manifest{Module: "needs-network", Version: "1", - Requires: []string{overlay.Requirement}} - got, err := catalogue.Resolve(shelf, []string{"needs-network", "tailscale"}, - catalogue.Node{Name: "workstation", Site: "house"}, catalogue.World{}) - if err != nil { - t.Fatalf("choosing another VPN was refused: %v", err) - } - for _, m := range got.Modules { - if m.Module == overlay.Name { - t.Fatalf("the other VPN was chosen and WireGuard came anyway: %v", got.Modules) - } - } -} - -func TestTwoVPNsAssignedTogetherStillCollide(t *testing.T) { - // The claim still guards the case it was always for: both assigned EXPLICITLY, which is a - // machine with two private networks and a coin toss about which one a peer reaches it on. - shipped := provided(t) - _, err := catalogue.Resolve( - withTailscale(shipped), - []string{overlay.Name, "tailscale"}, - catalogue.Node{Name: "workstation", Site: "house"}, catalogue.World{}) - if err == nil { - t.Fatal("a machine was given two private networks and nobody was told") - } - if !strings.Contains(err.Error(), overlay.TheNetwork) { - t.Fatalf("the refusal does not say what collided: %v", err) - } -} - -// The names-need-addressing test went with the names module: names are a fact now, and a machine -// the mesh cannot place is simply left out of the file (facts_test.go) — which is the same -// protection, enforced where the file is written rather than by a provision refusing. - -func withTailscale(shelf map[string]catalogue.Manifest) map[string]catalogue.Manifest { - out := map[string]catalogue.Manifest{} - for k, v := range shelf { - out[k] = v - } - // Deliberately without name-resolution of its own, which is the case that used to install - // both VPNs: the names then needed the mesh's addressing, and only WireGuard has it. - out["tailscale"] = catalogue.Manifest{ - Module: "tailscale", Version: "1", - Provides: catalogue.Offers(overlay.Requirement), - Claims: []catalogue.Claim{{Name: overlay.TheNetwork, Scope: catalogue.ScopeNode}}, - } - return out -} diff --git a/internal/catalogue/public_issuer_test.go b/internal/catalogue/public_issuer_test.go deleted file mode 100644 index e80ddfb8..00000000 --- a/internal/catalogue/public_issuer_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package catalogue - -import ( - "os" - "path/filepath" - "testing" - - "github.com/novox/mesh-controller/internal/beside" -) - -// The public issuer is the proxy's own fact (novox/hq ADR 0226), and the folding of it must not -// move the proxy's account. -// -// route-proxy keeps each ACME authority's account and certificates in a directory named after a -// digest of the directory URL and of the root bundle its `trust` container copies in -// (examples/route-proxy, forThisAuthority). Until ADR 0226 the URL was rendered from a binding to -// `public-acme`'s `acme-ca`, spelled with the port. A URL spelled any other way — even the same -// authority without `:443` — or a root bundle from another image is a new authority to the proxy: -// a new account, and every routed name ordered again, on two machines at once, against Let's -// Encrypt's rate limits. So what the module now states is held to exactly what the binding rendered. - -// renderedBeforeTheFold is route-proxy's acme.env as the binding to public-acme rendered it on every -// machine running the proxy, read from the controller's plan on 2026-10-06. -const renderedBeforeTheFold = "ACME_DIRECTORY=https://acme-v02.api.letsencrypt.org:443/directory\n" + - "ACME_ROOTS=https://acme-v02.api.letsencrypt.org:443\n" + - "ACME_ROOTS_PATH=\n" - -// trustImage is the image whose system bundle becomes the public root the account directory is -// named after. Moving it renames that directory. -const trustImage = "alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b" - -func TestRouteProxyKeepsItsPublicAccountDirectory(t *testing.T) { - m := catalogueManifest(t, "route-proxy") - for _, r := range m.Requires { - if r == "acme-ca" { - t.Fatal("route-proxy still asks for acme-ca; the public issuer is its own fact (ADR 0226)") - } - } - - // As a build would leave it: each artifact a container names resolved to an image. Which image - // does not matter to the file checked here. - for _, res := range m.Resources { - if artifact, ok := res["artifact"].(string); ok { - delete(res, "artifact") - res["image"] = "registry.invalid/route-proxy/" + artifact + - "@sha256:1111111111111111111111111111111111111111111111111111111111111111" - } - } - r := Resolution{ - Node: "anchor", - Modules: []Manifest{m}, - Needs: []Needed{{ - Name: "internal-acme-ca", From: "home", At: "home.internal", For: "route-proxy", - Serves: map[string]any{ - "port": float64(9000), "path": "/acme/acme/directory", "roots": "/roots.pem", - }, - }}, - } - // Its own broker credential, sealed as the mesh would; what it is does not matter here. - out, err := r.Declaration(Rendering{Needed: map[string]map[string]string{ - "route-proxy": {"broker": "sealed"}}}) - if err != nil { - t.Fatalf("route-proxy could not be composed for a machine: %v", err) - } - env := fileNamed(out, "route-proxy.acme-env") - if env == nil { - t.Fatalf("nothing writes the public issuer's environment: %v", out) - } - if got, _ := env["content"].(string); got != renderedBeforeTheFold { - t.Fatalf("acme.env changed, which moves the proxy's account and reorders every certificate:\n"+ - "got %q\nwant %q", got, renderedBeforeTheFold) - } - if fileNamed(out, "route-proxy.bound-acme-ca") != nil { - t.Error("a binding to acme-ca is still written") - } - - var trust string - if m.Build != nil { - for _, a := range m.Build.Artifacts { - if a.Name == "trust" { - trust = a.From - } - } - } - if trust != trustImage { - t.Errorf("the trust image is %q, not %q: its system bundle is the public root the account "+ - "directory is named after, so moving it reorders every certificate. Move it only with a "+ - "plan for the account (ADR 0226)", trust, trustImage) - } -} - -// The modules ADR 0226 retired stay retired, and nothing asks for what they provided. A module is -// in the catalogue when its manifest is: a directory left behind with no manifest in it (a build's -// leftovers, untracked by git) is not a module, and failed this on a desktop (novox/hq issue 432). -func TestTheRetiredNetworkingModulesAreNotInTheCatalogue(t *testing.T) { - modules := beside.Catalogue(t) - for _, gone := range []string{"public-acme", "dhcpcd", "cloudflare-dns"} { - if _, err := os.Stat(filepath.Join(modules, gone, "module.json")); err == nil { - t.Errorf("%s is in the catalogue again; ADR 0226 retired it", gone) - } - } - entries, err := os.ReadDir(modules) - if err != nil { - t.Fatal(err) - } - for _, e := range entries { - raw, err := os.ReadFile(filepath.Join(modules, e.Name(), "module.json")) - if err != nil { - continue - } - m, err := ParseManifest(raw) - if err != nil { - continue // judged by the catalogue's own tests - } - for _, r := range m.Requires { - if r == "acme-ca" || r == "public-dns" { - t.Errorf("%s requires %q, which nothing in the catalogue provides since ADR 0226", - m.Module, r) - } - } - } -} diff --git a/internal/catalogue/published_ports_test.go b/internal/catalogue/published_ports_test.go deleted file mode 100644 index 48036d24..00000000 --- a/internal/catalogue/published_ports_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package catalogue - -import ( - "fmt" - "os" - "path/filepath" - "strconv" - "strings" - "testing" -) - -// A container publishes only a port its module declares (novox/hq issue 227). -// -// **The short form is a question the mesh answers.** `"80"` means *publish what the software -// calls 80*, and the mesh fills in the machine's half from the port it assigned -// ([ADR 0038](0038)). It can only assign one for a port the module declared in `listens` — so a -// container publishing a number that appears nowhere in `listens` gets no assignment, and -// `publishedOn` falls back to the number as written. It escapes to the machine. -// -// That is how the photo module asked for port 80 on the control node, where the reverse proxy -// holds it: it declared its web endpoint at 4001, published a bare 80, and the container never -// started. Four other modules publish 80 quite safely — because they declare 80, so the mesh -// gives them a machine port for it. The difference is the declaration, not the number. -// -// A mapping written the long way is a module pinning both halves on purpose and is left alone. -func TestEveryPublishedPortIsOneItsModuleDeclares(t *testing.T) { - root := catalogueRoot(t) - entries, err := os.ReadDir(filepath.Join(root, "modules")) - if err != nil { - t.Fatal(err) - } - var escaped []string - for _, entry := range entries { - if !entry.IsDir() { - continue - } - raw, err := os.ReadFile(filepath.Join(root, "modules", entry.Name(), "module.json")) - if err != nil { - continue - } - m, err := ParseManifest(raw) - if err != nil { - // Whether every manifest parses is TestEveryCatalogueManifestParses's question. - continue - } - declared := map[int]bool{} - for _, l := range m.Listens { - declared[l.Port] = true - } - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) != "container" { - continue - } - listed, _ := r["ports"].([]any) - for _, p := range listed { - written := strings.Split(fmt.Sprint(p), "/")[0] - if strings.Contains(written, ":") { - continue // pinned by hand, both halves, on purpose - } - port, err := strconv.Atoi(strings.TrimSpace(written)) - if err != nil || declared[port] { - continue - } - escaped = append(escaped, fmt.Sprintf( - "%s's %v publishes %d, and %s declares no such port — the mesh has nothing "+ - "to assign, so %d reaches the machine as written", - m.Module, r["id"], port, m.Module, port)) - } - } - } - if len(escaped) > 0 { - t.Fatalf("a container may publish only a port its module declares:\n - %s", - strings.Join(escaped, "\n - ")) - } -} diff --git a/internal/catalogue/reach_test.go b/internal/catalogue/reach_test.go deleted file mode 100644 index 66b620c4..00000000 --- a/internal/catalogue/reach_test.go +++ /dev/null @@ -1,206 +0,0 @@ -package catalogue - -import ( - "strings" - "testing" -) - -// a web module with one routed endpoint, the shape almost every routed module in the catalogue has. -func aRoutedWeb() Manifest { - return Manifest{ - Module: "web", - Listens: []Listening{{Port: 3000, From: FromMesh}}, - Contributes: map[string]map[string]any{ - "route": {"label": "app", "port": 3000}, - }, - } -} - -func reachSet(reach string) SettingsBy { - return SettingsBy{"web": {{From: "node anchor", - Placement: map[string]any{ReachSetting: map[string]any{"3000": reach}}}}} -} - -// namesFor renders the contribution a routed module makes and returns the two names it carries. -func namesFor(t *testing.T, m Manifest, settings SettingsBy) (public, internal string) { - t.Helper() - r := Resolution{Node: "anchor", Modules: []Manifest{m}, - PublicDomain: "example.test", At: "anchor.internal"} - given, err := r.contributions(settings, nil, nil) - if err != nil { - t.Fatalf("contributions: %v", err) - } - for _, c := range given["route"] { - p, _ := c.Values["name"].(string) - i, _ := c.Values["internal-name"].(string) - return p, i - } - t.Fatal("the module contributed no route") - return "", "" -} - -// **Nothing said composes both names, exactly as before.** This is the assertion that keeps every -// mesh already running identical until an assignment speaks, and it is the one that would break first -// if reach were read where it should not be. -func TestAnEndpointWithNoReachKeepsBothNames(t *testing.T) { - public, internal := namesFor(t, aRoutedWeb(), nil) - if public != "app.example.test" || internal != "app.anchor.internal" { - t.Fatalf("names are %q and %q, want both composed as before", public, internal) - } -} - -// An internal endpoint has an internal name and no public one — so the proxy serves it inside, and -// the public authority is never asked for a name nobody wanted. This is what "must not be public" -// could not say before. -func TestAnInternalEndpointHasNoPublicName(t *testing.T) { - public, internal := namesFor(t, aRoutedWeb(), reachSet(ReachInternal)) - if public != "" { - t.Fatalf("an internal endpoint composed the public name %q", public) - } - if internal != "app.anchor.internal" { - t.Fatalf("internal name is %q, want app.anchor.internal", internal) - } -} - -// And the mirror: a public endpoint gets the public name and not the internal one, so the mesh's own -// authority is not asked to certify a name the service is not reached by. -func TestAPublicEndpointHasNoInternalName(t *testing.T) { - public, internal := namesFor(t, aRoutedWeb(), reachSet(ReachPublic)) - if internal != "" { - t.Fatalf("a public endpoint composed the internal name %q", internal) - } - if public != "app.example.test" { - t.Fatalf("public name is %q, want app.example.test", public) - } -} - -func TestBothComposesBothNames(t *testing.T) { - public, internal := namesFor(t, aRoutedWeb(), reachSet(ReachBoth)) - if public == "" || internal == "" { - t.Fatalf("both should compose both names, got %q and %q", public, internal) - } -} - -// **The filter reads the same value — for an endpoint the proxy does not serve.** -// -// A routed endpoint's port is how the proxy reaches it and nothing else (ADR 0045): a public service -// listens from the mesh, only the proxy reaches it, and it is exposed by name. So on a routed -// endpoint the reach asks for a name and the port keeps what the manifest said. -func TestAnUnroutedEndpointsPortFollowsItsReach(t *testing.T) { - // The same module with its route taken away: now the port is the only way in, so reach governs it. - bare := aRoutedWeb() - bare.Contributes = nil - - for _, c := range []struct{ reach, want string }{ - {ReachInternal, FromMesh}, - {ReachPublic, FromEverywhere}, - {ReachBoth, FromEverywhere}, - {ReachMachine, FromMachine}, - } { - r := Resolution{Node: "anchor", Modules: []Manifest{bare}} - rules, err := r.Rules(Rendering{Settings: reachSet(c.reach)}) - if err != nil { - t.Fatalf("%s: rules: %v", c.reach, err) - } - found := false - for _, rule := range rules { - if rule.Port == 3000 { - found = true - if rule.From != c.want { - t.Fatalf("reach %q made the filter say %q, want %q", c.reach, rule.From, c.want) - } - } - } - if !found { - t.Fatalf("reach %q produced no rule for the port", c.reach) - } - } -} - -// **A public name does not open the machine's port**, which is the case that found this. -// -// A module whose routed name must be public and whose machine-side port must not be had no way to say -// so while one value drove both. Under one value it could not be expressed; the port would reopen. -func TestAPublicNameLeavesARoutedPortAsTheManifestSaid(t *testing.T) { - r := Resolution{Node: "anchor", Modules: []Manifest{aRoutedWeb()}, - PublicDomain: "example.test", At: "anchor.internal"} - rules, err := r.Rules(Rendering{Settings: reachSet(ReachPublic)}) - if err != nil { - t.Fatal(err) - } - for _, rule := range rules { - if rule.Port == 3000 && rule.From != FromMesh { - t.Fatalf("a public reach opened a routed port to %q; the proxy is how it is reached", - rule.From) - } - } - // And the name it asked for is there, so the reach was not simply ignored. - public, internal := namesFor(t, aRoutedWeb(), reachSet(ReachPublic)) - if public != "app.example.test" || internal != "" { - t.Fatalf("names are %q and %q, want the public one only", public, internal) - } -} - -// A reach for a port the module does not listen on reaches nothing, and is refused where it is -// written rather than accepted and ignored. -func TestAReachForAPortTheModuleDoesNotListenOnIsRefused(t *testing.T) { - _, err := Reaches(aRoutedWeb(), []Layer{{From: "node anchor", - Placement: map[string]any{ReachSetting: map[string]any{"9999": ReachInternal}}}}) - if err == nil || !strings.Contains(err.Error(), "reaches nothing") { - t.Fatalf("a reach naming an undeclared port was accepted: %v", err) - } -} - -// A value that is not a reach is refused, and the refusal names the four so a reader is one edit from -// right. "mesh" is the tempting wrong answer, because that is the filter's word for nearly the same -// thing. -func TestAValueThatIsNotAReachIsRefused(t *testing.T) { - for _, wrong := range []string{"mesh", "anywhere", "private", "true"} { - _, err := Reaches(aRoutedWeb(), []Layer{{From: "node anchor", - Placement: map[string]any{ReachSetting: map[string]any{"3000": wrong}}}}) - if err == nil || !strings.Contains(err.Error(), "a reach is") { - t.Fatalf("%q was accepted as a reach: %v", wrong, err) - } - } -} - -// **A port that says both reach and expose is refused.** They say the same thing in different words, -// and accepting both would have the filter follow one while the names followed the other — the -// disagreement ADR 0138 exists to remove, reintroduced by the migration away from the older word. -func TestReachAndExposeForOnePortAreRefused(t *testing.T) { - _, err := Reaches(aRoutedWeb(), []Layer{{From: "node anchor", Placement: map[string]any{ - ReachSetting: map[string]any{"3000": ReachInternal}, - ExposeSetting: map[string]any{"3000": FromEverywhere}, - }}}) - if err == nil || !strings.Contains(err.Error(), "same thing in different") { - t.Fatalf("a port set both ways was accepted: %v", err) - } -} - -// A path-level refusal carries no port: it is a rule about a name, not an endpoint, and it inherits -// whatever that name turned out to be. Narrowing the endpoint must not silently drop it. -func TestARuleWithNoPortIsLeftAlone(t *testing.T) { - m := aRoutedWeb() - m.ContributesMany = map[string]map[string]map[string]any{ - "route": {"refused": {"label": "app", "path": "/internal", "deny": true}}, - } - r := Resolution{Node: "anchor", Modules: []Manifest{m}, - PublicDomain: "example.test", At: "anchor.internal"} - given, err := r.contributions(reachSet(ReachInternal), nil, nil) - if err != nil { - t.Fatal(err) - } - var sawDeny bool - for _, c := range given["route"] { - if deny, _ := c.Values["deny"].(bool); deny { - sawDeny = true - // It keeps both, because it named no endpoint to be narrowed by. - if c.Values["name"] == nil || c.Values["internal-name"] == nil { - t.Fatalf("the path rule lost a name it shadows: %v", c.Values) - } - } - } - if !sawDeny { - t.Fatal("the path rule was dropped") - } -} diff --git a/internal/catalogue/reads_test.go b/internal/catalogue/reads_test.go deleted file mode 100644 index 69f314f3..00000000 --- a/internal/catalogue/reads_test.go +++ /dev/null @@ -1,137 +0,0 @@ -package catalogue - -import ( - "encoding/json" - "strings" - "testing" -) - -// novox/hq ADR 0310 §1, §2: node-reads is a node seat, and its verbs say the commands they replace, so -// mesh_search finds them by `cat`, `grep` and `getent passwd` (ADR 0245 §3). -func TestNodeReadsIsANodeSeatWhoseVerbsSayWhatTheyReplace(t *testing.T) { - s, ok := SeatNamed(ReadsSeat) - if !ok { - t.Fatalf("the mesh defines no seat %s", ReadsSeat) - } - if s.Scope != ScopeNode || s.Delivers != "" || !strings.Contains(s.Decision, "0310") { - t.Errorf("%s: scope %q, delivers %q, decision %q", ReadsSeat, s.Scope, s.Delivers, s.Decision) - } - replaced := map[string]string{} - for _, v := range s.Serves { - if v.Optional { - t.Errorf("%s.%s is optional: a new seat's holder serves every verb", ReadsSeat, v.Name) - } - if len(v.Replaces) == 0 { - t.Errorf("%s.%s says nothing it replaces", ReadsSeat, v.Name) - } - for _, c := range v.Replaces { - replaced[c] = v.Name - } - } - for command, verb := range map[string]string{"cat": "read", "grep": "search", "getent passwd": "account", - "getent group": "account", "id": "account", "stat": "stat", "readlink": "readlink", "ls": "list"} { - if replaced[command] != verb { - t.Errorf("%q is replaced by %q, want %s.%s", command, replaced[command], ReadsSeat, verb) - } - } -} - -// novox/hq ADR 0310 §3: a holder of node-reads reads as an account of its own; one the machine's runtime would -// carry — as the operator's account — is refused at parse. -func TestAHolderOfNodeReadsWithoutAnAccountOfItsOwnIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module": "reads", "version": "1", - "claims": [{"name": "node-reads", "scope": "node"}]}`)) - if err == nil || !strings.Contains(err.Error(), "runs-as") || !strings.Contains(err.Error(), "0310") { - t.Errorf("a holder of node-reads without runs-as was not refused for it: %v", err) - } - if p := ReadsHolderProblems(Manifest{Module: "reads", RunsAs: "mesh-reads", - Claims: []Claim{{Name: ReadsSeat}}}); len(p) != 0 { - t.Errorf("a holder running as its own account was refused: %v", p) - } -} - -// novox/hq ADR 0310 §5: the holder is told, by the composition, where its node keeps a secret — the directory -// of every module's own secret, every credential file and grant directory, the vault's directories and the -// place the operator-sealed secrets are kept — and no other module is told. -func TestTheHolderOfNodeReadsIsToldWhereItsNodeKeepsASecret(t *testing.T) { - with := Rendering{ArtifactStore: "anchor.internal:5101", - Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}, - "reads": {"broker": "own"}, "desk": {"broker": "own"}}} - goRuntime := Manifest{Module: RuntimeModule, Version: "1", - OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/mesh/" + RuntimeModule + "/broker"}}, - Build: &Build{Artifacts: []Artifact{{Name: "runtime", Kind: ArtifactBundle, Language: "go", - System: "arch", From: "cmd/node-tools"}}}} - goRuntime, err := goRuntime.Resolve([]Built{{Name: "runtime", Kind: ArtifactBundle, - Reference: ArtifactStoreScheme + RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}}) - if err != nil { - t.Fatal(err) - } - reads := aToolsModule(t, "reads", "tools/index.js") - reads.RunsAs, reads.SecretsOwner = "mesh-reads", "mesh-reads" - reads.Claims = []Claim{{Name: ReadsSeat}} - reads.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/mesh-reads/broker"}} - desk := aToolsModule(t, "desk", "tools/index.js") - desk.RunsAs, desk.SecretsOwner = "desk", "desk" - desk.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/desk/broker"}} - // The vault as the catalogue declares it: its directories have no path of their own, and the node places them. - vault, err := placedManifest(catalogueResolved(t, "mesh-vault", Built{Name: "code", Kind: ArtifactBundle, - Reference: ArtifactStoreScheme + "mesh-vault/code/blobs/" + bundleDigest, Digest: bundleDigest}), with) - if err != nil { - t.Fatal(err) - } - app := Manifest{Module: "app", Version: "1", Secrets: map[string]string{"postgres-database": "/etc/app/db.secret"}, - OwnSecrets: OwnSecrets{"token": {Path: "/home/ops/.config/app/token"}}} - r := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{reads, desk, vault, app, goRuntime}} - owns, err := r.ownRuntimes(with) - if err != nil { - t.Fatal(err) - } - var given map[string]map[string]string - for _, p := range owns { - if p["id"] == "reads."+OwnRuntimeID() { - if err := json.Unmarshal([]byte(p["env"].(map[string]string)[RuntimeToolEnv]), &given); err != nil { - t.Fatal(err) - } - } - if p["id"] == "desk."+OwnRuntimeID() && strings.Contains(p["env"].(map[string]string)[RuntimeToolEnv], ReadsSecretPlaces) { - t.Error("a module not holding node-reads is told the node's secret places") - } - } - var places []string - if err := json.Unmarshal([]byte(given["reads"][ReadsSecretPlaces]), &places); err != nil { - t.Fatalf("the holder is not told its node's secret places: %v (%v)", err, given) - } - want := []string{"/etc/app/db.secret", "/home/ops/.config/app", "/var/lib/desk", "/var/lib/mesh-reads", - "/var/lib/mesh-vault", "/var/lib/mesh/" + RuntimeModule} - if strings.Join(places, " ") != strings.Join(want, " ") { - t.Errorf("secret places:\n got %v\nwant %v", places, want) - } -} - -// The review of #257: a secret place the controller cannot resolve fails the composition, naming the module, -// rather than leaving the list one place shorter. -func TestASecretPlaceThatCannotBeResolvedFailsTheComposition(t *testing.T) { - r := Resolution{Node: "anchor", Modules: []Manifest{ - {Module: "app", Version: "1", Keeps: "${dir:keep}/sealed"}, - }} - if _, err := r.secretPlaces(Rendering{}); err == nil || !strings.Contains(err.Error(), "app") { - t.Errorf("an unresolved place was dropped: %v", err) - } - r.Modules[0].Keeps = "relative/place" - if _, err := r.secretPlaces(Rendering{}); err == nil { - t.Error("a relative place was dropped") - } -} - -// ADR 0310 §3: the account that reads is in no group beside its own; one put in a group is refused. -func TestTheReadsAccountIsInNoGroup(t *testing.T) { - m := Manifest{Module: "reads", RunsAs: "mesh-reads", Claims: []Claim{{Name: ReadsSeat}}, - Resources: []map[string]any{{"id": "account", "type": "user", "name": "mesh-reads", "groups": []any{"docker"}}}} - if p := ReadsHolderProblems(m); len(p) != 1 || !strings.Contains(p[0], "docker") { - t.Errorf("an account in docker was not refused: %v", p) - } - m.Resources[0]["groups"] = []any{} - if p := ReadsHolderProblems(m); len(p) != 0 { - t.Errorf("an account in no group was refused: %v", p) - } -} diff --git a/internal/catalogue/received_on_the_bus_test.go b/internal/catalogue/received_on_the_bus_test.go deleted file mode 100644 index 185b10bc..00000000 --- a/internal/catalogue/received_on_the_bus_test.go +++ /dev/null @@ -1,66 +0,0 @@ -package catalogue - -import ( - "encoding/json" - "reflect" - "testing" -) - -// What a provider receives is composed once, and issued twice: as its received file, and in its -// membership on the bus (novox/hq ADR 0167). The two are the same list, so a proxy reading the bus -// and one reading the file serve the same routes — including the port the machine published, which -// is the same-node fix the file already carries. -func TestWhatAProviderReceivesIsTheSameOnTheBusAsInItsFile(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) - } - composed, err := r.Compose(Rendering{Ports: map[string]map[int]int{"gitea": {3000: 20000}}}) - if err != nil { - t.Fatal(err) - } - - file := fileNamed(composed.Resources, "route-proxy.received-route") - if file == nil { - t.Fatal("the proxy was given no routes file") - } - var written struct { - Given []Contribution `json:"given"` - } - if err := json.Unmarshal([]byte(file["content"].(string)), &written); err != nil { - t.Fatal(err) - } - issued, said := composed.Received["route-proxy"]["route"] - if !said { - t.Fatalf("nothing is issued for the proxy to receive on the bus: %v", composed.Received) - } - // Compared as JSON, which is what both are once they leave the controller. - a, _ := json.Marshal(written.Given) - b, _ := json.Marshal(issued) - var fromFile, fromBus any - _ = json.Unmarshal(a, &fromFile) - _ = json.Unmarshal(b, &fromBus) - if !reflect.DeepEqual(fromFile, fromBus) { - t.Errorf("the bus and the file disagree about the routes:\nfile %s\nbus %s", a, b) - } - if len(issued) != 1 { - t.Fatalf("expected one route on the bus, got %v", issued) - } - if port, ok := asPort(issued[0].Values["port"]); !ok || port != 20000 { - t.Errorf("the bus carries a port nothing listens on: %v", issued[0].Values["port"]) - } - - // A module that receives nothing is issued nothing to receive. - if _, any := composed.Received["gitea"]; any { - t.Errorf("a module that receives nothing was issued something: %v", composed.Received["gitea"]) - } -} diff --git a/internal/catalogue/reflects_renamed_test.go b/internal/catalogue/reflects_renamed_test.go deleted file mode 100644 index e5bee0bd..00000000 --- a/internal/catalogue/reflects_renamed_test.go +++ /dev/null @@ -1,32 +0,0 @@ -package catalogue - -import ( - "reflect" - "testing" -) - -func TestReflectsRenamedReadsBothShapes(t *testing.T) { - // The list arrives as []any when the resource was parsed from JSON, and as []string when it - // was composed in code — the overlay's registry trust is the second kind. A shape that was - // skipped would leave the reference unprefixed, pointing at nothing, and the service would - // never restart while every check passed (novox/hq issues 042/048, the run-8 diagnosis). - parsed := reflectsRenamed("mesh-wireguard", []any{"registry-trust"}) - if !reflect.DeepEqual(parsed, []any{"mesh-wireguard.registry-trust"}) { - t.Fatalf("parsed shape: %v", parsed) - } - composed := reflectsRenamed("mesh-wireguard", []string{"registry-trust"}) - if !reflect.DeepEqual(composed, []any{"mesh-wireguard.registry-trust"}) { - t.Fatalf("composed shape: %v", composed) - } - - // A name already under a module keeps it: that is how a service reflects a file another - // module put on the machine. - kept := reflectsRenamed("resolver", []string{"mesh-wireguard.fact-node-names", "own-config"}) - if !reflect.DeepEqual(kept, []any{"mesh-wireguard.fact-node-names", "resolver.own-config"}) { - t.Fatalf("dotted name was not kept: %v", kept) - } - - if got := reflectsRenamed("any", nil); got != nil { - t.Fatalf("no list should rename to nothing, got %v", got) - } -} diff --git a/internal/catalogue/replaces_test.go b/internal/catalogue/replaces_test.go deleted file mode 100644 index 4b343205..00000000 --- a/internal/catalogue/replaces_test.go +++ /dev/null @@ -1,119 +0,0 @@ -package catalogue - -import ( - "strings" - "testing" -) - -// The verbs an agent works around most say what they replace (novox/hq ADR 0245): the journal, a -// restart, a unit's status, the hosts file — each the command the agent would otherwise type over ssh. -func TestTheVerbsWorkedAroundSayWhatTheyReplace(t *testing.T) { - want := map[string]string{ - ServiceManagerSeat + ".journal": "journalctl", - ServiceManagerSeat + ".restart": "systemctl restart", - ServiceManagerSeat + ".status": "systemctl status", - "node-hostname.add": "edit /etc/hosts", - ControllerSeatName + ".node": "hostnamectl", - } - for _, s := range DefaultSeats() { - for _, v := range s.Serves { - cmd, ok := want[s.Name+"."+v.Name] - if !ok { - continue - } - delete(want, s.Name+"."+v.Name) - found := false - for _, r := range v.Replaces { - found = found || r == cmd - } - if !found { - t.Errorf("%s.%s does not say it replaces %q: %v", s.Name, v.Name, cmd, v.Replaces) - } - } - } - for verb := range want { - t.Errorf("%s is not a verb of the compiled seats", verb) - } -} - -// Every command a seat's verb replaces is one short line, said once — what the guard and the search match. -func TestEveryReplacedCommandIsOneShortLine(t *testing.T) { - for _, s := range DefaultSeats() { - for _, v := range s.Serves { - seen := map[string]bool{} - for _, r := range v.Replaces { - if strings.TrimSpace(r) == "" || strings.ContainsAny(r, "\n\r") || len(r) > MaxReplaced || seen[r] { - t.Errorf("%s.%s replaces %q, which is not one short line said once", s.Name, v.Name, r) - } - seen[r] = true - } - } - } -} - -// A verb's definition in a manifest may say what it replaces, like the mesh's own. -func TestAVerbDefinitionCarriesWhatItReplaces(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"till","version":"1","tools":["refund"],` + - `"seats":[{"name":"shop-till","serves":[{"name":"refund","replaces":["psql -c refund"]}]}],` + - `"claims":[{"name":"shop-till","scope":"mesh"}]}`)) - if err != nil { - t.Fatal(err) - } - if got := m.DefinesSeats[0].Serves[0].Replaces; len(got) != 1 || got[0] != "psql -c refund" { - t.Fatalf("replaces not read: %v", got) - } -} - -// A module says what its own tools replace, and only for a tool it declares. -func TestAModuleSaysWhatItsOwnToolsReplace(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"docker","version":"1","tools":["docker_logs","docker_list"],` + - `"replaces":{"docker_logs":["docker logs"],"docker_list":["docker ps"]}}`)) - if err != nil { - t.Fatal(err) - } - if m.Replaces["docker_logs"][0] != "docker logs" || m.Replaces["docker_list"][0] != "docker ps" { - t.Fatalf("replaces not read: %v", m.Replaces) - } - for _, c := range []struct{ manifest, says string }{ - {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_lgos":["docker logs"]}}`, - "declares no such tool"}, - {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":[]}}`, "replaces nothing"}, - {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":[" "]}}`, "empty command"}, - {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":["docker logs\nrm -rf /"]}}`, - "more than one line"}, - {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":["docker logs","docker logs"]}}`, - "twice"}, - {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":["` + strings.Repeat("x", MaxReplaced+1) + `"]}}`, - "longer than"}, - } { - if _, err := ParseManifest([]byte(c.manifest)); err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: want a refusal saying %q, got %v", c.manifest, c.says, err) - } - } -} - -// A seat row the store seeded before `replaces` existed carries none; the working set takes the compiled -// one, so the `tools` answer says it on a mesh whose rows exist. -func TestASeededRowTakesWhatItsVerbsReplaceFromTheCompiledSeat(t *testing.T) { - was := Seats() - t.Cleanup(func() { UseSeats(was) }) - var row Seat - for _, s := range DefaultSeats() { - if s.Name == ServiceManagerSeat { - row = s - } - } - stored := make([]Verb, len(row.Serves)) - for i, v := range row.Serves { - v.Replaces = nil - stored[i] = v - } - row.Serves = stored - UseSeats([]Seat{row}) - got, _ := SeatNamed(ServiceManagerSeat) - for _, v := range got.Serves { - if v.Name == "journal" && (len(v.Replaces) == 0 || v.Replaces[0] != "journalctl") { - t.Fatalf("the seeded journal verb says it replaces %v", v.Replaces) - } - } -} diff --git a/internal/catalogue/replay166_test.go b/internal/catalogue/replay166_test.go deleted file mode 100644 index 7611666b..00000000 --- a/internal/catalogue/replay166_test.go +++ /dev/null @@ -1,44 +0,0 @@ -package catalogue - -import ( - "strings" - "testing" -) - -// Replay of novox/hq issue 166: every soft dependency had become a hard requirement. grafana runs without a -// data source, yet a manifest could not say so: a mesh without influxdb could not register a grafana that -// said its influxdb-api was optional, and with influxdb only in the catalogue, assigning grafana installed -// influxdb for it. Written only with what the catalogue had before its fix (mesh-controller #281): -// ParseManifest, Resolve, and the resolve tests' shelf, reachable machine and names, so it is laid over the -// commit before. -func TestReplay166(t *testing.T) { - grafana, err := ParseManifest([]byte(`{"module":"grafana","version":"1", - "requires":[{"name":"influxdb-api","optional":true}]}`)) - if err != nil { - t.Fatalf("a manifest cannot say grafana runs without influxdb-api: %v", err) - } - catalogue := shelf( - Manifest{Module: "influxdb", Version: "1", Provides: FromAnywhere("influxdb-api")}, - grafana, - ) - // Nothing in the mesh runs influxdb; it is only in the catalogue. - got, err := Resolve(catalogue, []string{"grafana"}, reachable(), World{}) - if err != nil { - t.Fatalf("grafana could not be assigned on a mesh without influxdb: %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", have) - } - if len(got.Needs) != 0 { - t.Fatalf("grafana was given a binding with nothing providing it: %+v", got.Needs) - } - // And once influxdb runs somewhere, grafana binds to it as before. - got, err = Resolve(catalogue, []string{"grafana"}, reachable(), World{Offered: map[string][]Provider{ - "influxdb-api": {{Node: "anchor", At: "anchor.internal", Module: "influxdb"}}}}) - if err != nil { - t.Fatal(err) - } - if len(got.Needs) != 1 || got.Needs[0].From != "anchor" || got.Needs[0].For != "grafana" { - t.Fatalf("grafana did not bind to influxdb on anchor: %+v", got.Needs) - } -} diff --git a/internal/catalogue/replay279_test.go b/internal/catalogue/replay279_test.go deleted file mode 100644 index c350a4b2..00000000 --- a/internal/catalogue/replay279_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package catalogue - -import "testing" - -// Replay of novox/hq issue 279 (2026-10-06): the media managers' recycle-bin folders were declared owned by -// a literal user and group, and on a machine whose puid and pgid settings named another account the apps -// refused them as not writable. A directory's owner could not follow the account its module runs as. Written -// only with what the catalogue had before its fix (mesh-controller #260): Compose, LeftOut, a setting layer, -// and the adoption tests' anchor, so it is laid over the commit before. -func TestReplay279(t *testing.T) { - r := anAdoptedAnchor() - r.Modules = append(r.Modules, Manifest{Module: "sonarr", - Resources: []map[string]any{ - {"id": "recycle-bin", "type": "directory", "path": "/srv/sonarr/recycle", "mode": "0755", - "owner": "${setting:puid}:${setting:pgid}"}, - {"id": "identity", "type": "file", "path": "/srv/sonarr/identity.env", "mode": "0644", - "content": "PUID=${setting:puid}\nPGID=${setting:pgid}\n"}, - }, - }) - with := anchorRendering(false) - with.Settings["sonarr"] = []Layer{{From: "node anchor", Values: map[string]any{"puid": "1001", "pgid": "2000"}}} - composed, err := r.Compose(with) - if err != nil { - t.Fatalf("the machine did not compose: %v", err) - } - if why, left := composed.LeftOut["sonarr"]; left { - t.Fatalf("the module was left out: %s", why) - } - got, ok := byID(composed.Resources)["sonarr.recycle-bin"] - if !ok { - t.Fatal("the recycle bin was not declared") - } - if got["owner"] != "1001:2000" { - t.Fatalf("the recycle bin reaches the node-engine owned by %v, not the account the module runs as (1001:2000)", got["owner"]) - } -} diff --git a/internal/catalogue/replay412_test.go b/internal/catalogue/replay412_test.go deleted file mode 100644 index 178667d1..00000000 --- a/internal/catalogue/replay412_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package catalogue - -import ( - "strings" - "testing" -) - -// Replay of novox/hq issue 412 (2026-10-10): which module owns /etc/hosts was enforced only after the fact, -// when two modules on one machine declared it; a module that did not claim node-hostname could declare -// /etc/hosts, and a module could claim node-hostname declaring none of its paths, and nothing refused either. -// Written only with what the catalogue had before its fix (mesh-controller #267): CatalogueProblems, Shelf, -// SeatNamed and CanHold, so it is laid over the commit before. -func TestReplay412(t *testing.T) { - stranger := Manifest{Module: "stranger", Version: "1", Resources: []map[string]any{ - {"id": "hosts", "type": "file", "path": "/etc/hosts", "into": "block", "content": "10.0.0.1 x\n"}}} - got := strings.Join(CatalogueProblems(Shelf{"stranger": stranger}), "; ") - if !strings.Contains(got, "node-hostname") { - t.Errorf("a module declaring /etc/hosts without claiming node-hostname was not refused: %q", got) - } - - seat, ok := SeatNamed("node-hostname") - if !ok { - t.Fatal("no node-hostname seat") - } - holder := Manifest{Module: "names", Version: "1", Claims: []Claim{{Name: "node-hostname", Scope: ScopeNode, - Serves: []string{"entries", "add", "remove"}}}} - err := CanHold(holder, seat) - if err == nil { - t.Fatal("a holder of node-hostname declaring neither /etc/hosts nor /etc/hostname was accepted") - } - for _, want := range []string{"/etc/hosts", "/etc/hostname"} { - if !strings.Contains(err.Error(), want) { - t.Errorf("the refusal does not name %s: %v", want, err) - } - } -} diff --git a/internal/catalogue/replays_test.go b/internal/catalogue/replays_test.go deleted file mode 100644 index 3439da3e..00000000 --- a/internal/catalogue/replays_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package catalogue - -import ( - "slices" - "testing" -) - -// **R298 — a seat's new verb lands in either repository first** (novox/hq issue 298). On 2026-10-07 the -// delivery seat gained `checks`: the seat is defined here and held by a module in the catalogue, and -// neither could merge first — a controller promising the verb refused the holder that did not serve it -// yet, and a holder serving it was refused by the controller that did not promise it. The outcome -// asserted: the holder as it was, and the holder serving `checks` too, both hold the delivery seat. -// -// Written only with what the catalogue package had before the fix (SeatNamed, VerbNames, CanHold), so the -// prover can lay it over that commit. -func TestReplay298ADeliverySeatHolderHoldsBeforeAndAfterItServesChecks(t *testing.T) { - seat, ok := SeatNamed(DeliverySeat) - if !ok { - t.Fatal("the delivery seat is not in the set") - } - var before []string - for _, v := range VerbNames(seat.Serves) { - if v != "checks" { - before = append(before, v) - } - } - holder := func(serves []string) Manifest { - return Manifest{Module: "mesh-delivery", Claims: []Claim{{Name: DeliverySeat, Scope: ScopeMesh, Serves: serves}}} - } - if err := CanHold(holder(before), seat); err != nil { - t.Fatalf("the holder as the catalogue had it, before it serves checks, is refused: %v", err) - } - if err := CanHold(holder(append(slices.Clone(before), "checks")), seat); err != nil { - t.Fatalf("the holder serving checks is refused — the catalogue cannot merge first: %v", err) - } -} diff --git a/internal/catalogue/resolve_keeps_references_test.go b/internal/catalogue/resolve_keeps_references_test.go deleted file mode 100644 index 24eb07c2..00000000 --- a/internal/catalogue/resolve_keeps_references_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package catalogue - -import ( - "strings" - "testing" -) - -// A resolved manifest keeps a built artifact's reference in the store-relative form, never the -// address the builder reached the store by (novox/hq ADR 0155): on 2026-10-02 the first bundle -// resolved on the mesh carried the store's host in bundles[0].source and registration refused it -// as naming an installation. Archive resources are the same kind of reference and get the same. -func TestAResolvedReferenceIsKeptNotRouted(t *testing.T) { - m, err := ParseManifest([]byte(`{ - "module": "sample", "version": "1", - "tools": ["one"], - "build": {"artifacts": [ - {"name": "code", "kind": "bundle", "language": "typescript", "entrypoints": ["tools/index.js"]}, - {"name": "files", "kind": "archive", "from": "files"} - ]}, - "resources": [{"id": "packed", "type": "archive", "path": "/opt/sample", "artifact": "files"}] - }`)) - if err != nil { - t.Fatal(err) - } - digest := "sha256:" + strings.Repeat("ab", 32) - resolved, err := m.Resolve([]Built{ - {Name: "code", Kind: ArtifactBundle, Reference: "http://store.example:5100/v2/sample/code/blobs/" + digest, Digest: digest}, - {Name: "files", Kind: ArtifactArchive, Reference: "http://store.example:5100/v2/sample/files/blobs/" + digest, Digest: digest}, - }) - if err != nil { - t.Fatal(err) - } - if got, want := resolved.Bundles[0].Source, ArtifactStoreScheme+"sample/code/blobs/"+digest; got != want { - t.Errorf("bundle source %q, want the kept form %q", got, want) - } - if got, want := resolved.Resources[0]["source"], ArtifactStoreScheme+"sample/files/blobs/"+digest; got != want { - t.Errorf("archive source %q, want the kept form %q", got, want) - } - if problems := InstallationProblems(resolved); len(problems) != 0 { - t.Errorf("a resolved manifest names an installation: %v", problems) - } -} diff --git a/internal/catalogue/resolve_loopback_test.go b/internal/catalogue/resolve_loopback_test.go deleted file mode 100644 index d9ca22cc..00000000 --- a/internal/catalogue/resolve_loopback_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package catalogue - -import "testing" - -// A MESH-SCOPED provider on the consumer's own node must deliver a usable address too. -// -// The sibling case — a node-scoped provider minting no credential — has had this fallback and a -// test for it for some time. This branch did not, and the difference is invisible until something -// puts the address into a string: the mesh's own artifact store is mesh-scoped and lives on the -// same machine as the builder that pushes to it, so the builder's environment was written as -// `MESH_REGISTRY=:5000`. Nothing refused it. The runtime did, several steps later, with -// `":5000/mesh-tools/build" is not a valid repository/tag` — a message about a tag, for a fault in -// how a binding was resolved. -// -// A machine off the private network still reaches itself. -func TestAMeshScopedProviderOnThisNodeStillCarriesAnAddress(t *testing.T) { - provider := Manifest{Module: "registry", - Provides: []Offer{{Name: "artifact-store", Scope: ScopeMesh}}, - Serves: map[string]map[string]any{ - "artifact-store": {"port": 5000}}} - consumer := Manifest{Module: "builder", Requires: []string{"artifact-store"}} - - // No `At`: a mesh with no private network raised, which is every mesh before somebody places - // its nodes on one — including the moment just after it is installed. - got, err := Resolve( - map[string]Manifest{"registry": provider, "builder": consumer}, - []string{"builder", "registry"}, - Node{Name: "anchor"}, - World{}) - if err != nil { - t.Fatalf("a mesh-scoped provision answered on this very node was refused: %v", err) - } - - var found *Needed - for i := range got.Needs { - if got.Needs[i].Name == "artifact-store" && got.Needs[i].For == "builder" { - found = &got.Needs[i] - } - } - if found == nil { - t.Fatalf("the store was not delivered to the builder at all: %+v", got.Needs) - } - if found.At == "" { - t.Fatalf("the binding carries no address, so anything composing a host from it gets none: %+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"]), "5000"; got != want { - t.Fatalf("the port was not delivered: got %q want %q", got, want) - } -} diff --git a/internal/catalogue/resolve_test.go b/internal/catalogue/resolve_test.go deleted file mode 100644 index 54b1e100..00000000 --- a/internal/catalogue/resolve_test.go +++ /dev/null @@ -1,509 +0,0 @@ -package catalogue - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "testing" -) - -func mod(name string, provides, requires, capabilities []string, claims ...Claim) Manifest { - return Manifest{Module: name, Provides: Offers(provides...), Requires: requires, - Capabilities: capabilities, Claims: claims} -} - -func shelf(ms ...Manifest) map[string]Manifest { - out := map[string]Manifest{} - for _, m := range ms { - out[m.Module] = m - } - return out -} - -func workstation() Node { - return Node{Name: "workstation", Site: "house", - Capabilities: map[string]bool{"seat": true, "container-runtime": true}} -} - -func names(r Resolution) []string { - var out []string - for _, m := range r.Modules { - out = append(out, m.Module) - } - return out -} - -func TestARequirementWithOneAnswerIsTakenSilently(t *testing.T) { - // `install i3` should bring in xorg without asking anybody anything, because there was no - // choice to make. This is what keeps the refusing rule from being tiresome. - got, err := Resolve(shelf( - mod("i3", nil, []string{"xorg"}, []string{"seat"}), - mod("xorg", []string{"display-server"}, nil, []string{"seat"}, Claim{Name: "the-seat"}), - ), []string{"i3"}, workstation(), World{}) - - if err != nil { - t.Fatal(err) - } - if len(got.Modules) != 2 { - t.Fatalf("resolved %v; i3 should have brought xorg with it", names(got)) - } - if got.Because["xorg"] == "assigned" { - t.Error("xorg is recorded as assigned; it was required") - } -} - -func TestARequirementWithSeveralAnswersIsRefusedAndNamed(t *testing.T) { - // The mesh does not pick. A default would be a choice made for somebody who finds out later, - // and naming the candidates is the whole remedy. - _, err := Resolve(shelf( - mod("editor", nil, []string{"shell"}, nil), - mod("bash", []string{"shell"}, nil, nil), - mod("zsh", []string{"shell"}, nil, nil), - mod("fish", []string{"shell"}, nil, nil), - ), []string{"editor"}, workstation(), World{}) - - if err == nil { - t.Fatal("a requirement with three answers was resolved without asking") - } - for _, want := range []string{"bash", "fish", "zsh", "choose one"} { - if !strings.Contains(err.Error(), want) { - t.Errorf("the refusal does not mention %q: %v", want, err) - } - } -} - -func TestARequirementWithNoAnswerIsRefused(t *testing.T) { - _, err := Resolve(shelf(mod("i3", nil, []string{"xorg"}, nil)), []string{"i3"}, workstation(), World{}) - - if err == nil || !strings.Contains(err.Error(), "nothing provides") { - t.Fatalf("a requirement nothing satisfies gave %v", err) - } -} - -func TestSeveralModulesMayProvideTheSameThingAndCoexist(t *testing.T) { - // Shells. Nothing is claimed, so any number may be assigned — which is the case that made - // "flavor" look necessary and turns out to need nothing at all. - got, err := Resolve(shelf( - mod("bash", []string{"shell"}, nil, nil), - mod("zsh", []string{"shell"}, nil, nil), - mod("fish", []string{"shell"}, nil, nil), - ), []string{"bash", "zsh", "fish"}, workstation(), World{}) - - if err != nil { - t.Fatalf("three shells could not coexist: %v", err) - } - if len(got.Modules) != 3 { - t.Errorf("resolved %v", names(got)) - } -} - -func TestTwoModulesClaimingOneThingAreRefused(t *testing.T) { - // xorg and wayland. Neither knows the other exists — the refusal comes from both claiming - // the seat, which is what lets a third display server be added without editing either. - _, err := Resolve(shelf( - mod("xorg", []string{"display-server"}, nil, nil, Claim{Name: "the-seat"}), - mod("wayland", []string{"display-server"}, nil, nil, Claim{Name: "the-seat"}), - ), []string{"xorg", "wayland"}, workstation(), World{}) - - if err == nil { - t.Fatal("two modules claiming the seat were both assigned") - } - if !strings.Contains(err.Error(), "the-seat") || !strings.Contains(err.Error(), "per node") { - t.Errorf("the refusal does not say what was claimed or how widely: %v", err) - } -} - -func TestAThirdModuleNeedsNoChangeToTheOthers(t *testing.T) { - // The property claims exist for. A third display server says what it claims and nothing else - // in the catalogue is touched — where pairwise exclusion would need xorg and wayland edited - // to know about it, and the edits would grow as the square of the count. - catalogue := shelf( - mod("xorg", []string{"display-server"}, nil, nil, Claim{Name: "the-seat"}), - mod("wayland", []string{"display-server"}, nil, nil, Claim{Name: "the-seat"}), - mod("mir", []string{"display-server"}, nil, nil, Claim{Name: "the-seat"}), - ) - for _, pair := range [][]string{{"xorg", "mir"}, {"wayland", "mir"}} { - if _, err := Resolve(catalogue, pair, workstation(), World{}); err == nil { - t.Errorf("%v were both assigned", pair) - } - } - if _, err := Resolve(catalogue, []string{"mir"}, workstation(), World{}); err != nil { - t.Errorf("the newcomer alone was refused: %v", err) - } -} - -func TestADirectlyAssignedModuleTheMachineCannotHostIsReportedNotRefused(t *testing.T) { - // A person put a display server on a seatless machine. The remedy differs from a missing module - // and the message has to say which — nothing can be installed to give a server a seat. But it is - // one module on the wrong machine, not a reason the whole node fails to resolve: it is kept out - // of what the node runs and reported as un-applied, so the healthy modules beside it still - // converge. - server := Node{Name: "server", Capabilities: map[string]bool{"container-runtime": true}} - got, err := Resolve(shelf(mod("xorg", nil, nil, []string{"seat"}, Claim{Name: "the-seat"})), []string{"xorg"}, server, World{}) - - if err != nil { - t.Fatalf("one un-hostable assignment refused the whole node: %v", err) - } - if len(got.Modules) != 0 { - t.Errorf("xorg was declared on a machine that cannot run it: %v", names(got)) - } - if len(got.Unhostable) != 1 || got.Unhostable[0].Module != "xorg" { - t.Fatalf("xorg was not reported as un-applied: %+v", got.Unhostable) - } - if len(got.Unhostable[0].Missing) != 1 || got.Unhostable[0].Missing[0] != "seat" { - t.Errorf("the missing capability was not named: %+v", got.Unhostable[0]) - } - if !strings.Contains(WrongMachine("xorg", "seat", "server"), "wrong machine") { - t.Errorf("the report reads like a missing module") - } -} - -func TestAnUnhostableModuleSomethingRequiresRefusesTheNode(t *testing.T) { - // The other side of the distinction. A module the machine cannot host that is *required* by - // something running here makes the set incoherent: the requiring module cannot have its - // requirement met on this machine, so it is refused rather than quietly declared without it. - server := Node{Name: "server", Capabilities: map[string]bool{"container-runtime": true}} - _, err := Resolve(shelf( - mod("desktop", nil, []string{"display-server"}, nil), - mod("xorg", []string{"display-server"}, nil, []string{"seat"}), - ), []string{"desktop"}, server, World{}) - - if err == nil { - t.Fatal("a node requiring a module the machine cannot host was resolved") - } - if !strings.Contains(err.Error(), "wrong machine") { - t.Errorf("the refusal reads like a missing module: %v", err) - } -} - -func TestOneUnhostableAssignmentDoesNotTakeDownTheHealthyModules(t *testing.T) { - // The bug the whole-mesh dry-run found: fail2ban declares a capability the host lacks, and its - // one un-hostable assignment refused the entire node's resolution — so a push refused to send - // the healthy modules beside it too. The healthy modules must still resolve and converge; the - // un-hostable one is reported as un-applied, neither silently dropped nor fatal to the rest. - server := Node{Name: "server", Capabilities: map[string]bool{"container-runtime": true}} - got, err := Resolve(shelf( - mod("web", nil, nil, nil), - mod("cache", nil, nil, nil), - mod("cron", nil, nil, nil), - // The one on the wrong machine: it needs a firewall the machine's profile does not report. - mod("fail2ban", nil, nil, []string{"firewall"}), - ), []string{"web", "cache", "cron", "fail2ban"}, server, World{}) - - if err != nil { - t.Fatalf("one un-hostable assignment refused the whole node: %v", err) - } - // The healthy three resolved. - if got := names(got); len(got) != 3 { - t.Fatalf("the healthy modules did not all resolve: %v", got) - } - for _, m := range got.Modules { - if m.Module == "fail2ban" { - t.Error("fail2ban was declared on a machine that cannot run it") - } - } - // The un-hostable one is reported, not silently dropped. - if len(got.Unhostable) != 1 || got.Unhostable[0].Module != "fail2ban" { - t.Fatalf("fail2ban was not reported as un-applied: %+v", got.Unhostable) - } - if len(got.Unhostable[0].Missing) != 1 || got.Unhostable[0].Missing[0] != "firewall" { - t.Errorf("the missing capability was not named: %+v", got.Unhostable[0]) - } -} - -func TestAMeshWideClaimIsHeldByOneNode(t *testing.T) { - // The hub, said as a claim rather than hard-coded. Another node already holds it, so this one - // cannot. - _, err := Resolve(shelf(mod("hub", nil, nil, nil, Claim{Name: "the-hub", Scope: ScopeMesh})), - []string{"hub"}, workstation(), - World{Held: []Held{{Claim: "the-hub", Scope: ScopeMesh, Node: "anchor", Module: "hub"}}}) - if err == nil { - t.Fatal("two nodes both hold a mesh-wide claim") - } - if !strings.Contains(err.Error(), "anchor") || !strings.Contains(err.Error(), "one per mesh") { - t.Errorf("the refusal does not say who holds it: %v", err) - } -} - -func TestAFoundationModuleCannotBeRaisedOnASecondNode(t *testing.T) { - // novox/hq 04-ISSUES/056. The store and broker are adopted in place on the control-node; each - // foundation module claims a mesh-scoped seat named after its server — the same mechanism that - // keeps one controller — so a second `assign` to another node is refused rather than silently - // raising a second postgres or broker that holds none of the first's data. - for _, tc := range []struct{ module, seat string }{ - {"postgres", "mesh-store"}, - {"lavinmq", "mesh-broker"}, - {"mesh-controller", "mesh-controller"}, - } { - _, err := Resolve( - shelf(mod(tc.module, nil, nil, nil, Claim{Name: tc.seat, Scope: ScopeMesh})), - []string{tc.module}, workstation(), - World{Held: []Held{{Claim: tc.seat, Scope: ScopeMesh, Node: "anchor", Module: tc.module}}}) - if err == nil { - t.Fatalf("%s was raised on a second node though %s is a mesh-wide seat", tc.module, tc.seat) - } - if !strings.Contains(err.Error(), "one per mesh") { - t.Errorf("%s: the refusal does not say it is one per mesh: %v", tc.module, err) - } - } -} - -func TestASiteClaimOnlyCollidesWithinThatSite(t *testing.T) { - // A DHCP server per segment. Two of them is a fault at one site and perfectly ordinary - // across two, and treating site as mesh would forbid the ordinary case. - catalogue := shelf(mod("dhcp", nil, nil, nil, Claim{Name: "dhcp", Scope: ScopeSite})) - - elsewhere := []Held{{Claim: "dhcp", Scope: ScopeSite, Node: "other", Module: "dhcp", Site: "house"}} - if _, err := Resolve(catalogue, []string{"dhcp"}, workstation(), World{Held: elsewhere}); err == nil { - t.Error("two DHCP servers at one site were allowed") - } - - faraway := []Held{{Claim: "dhcp", Scope: ScopeSite, Node: "other", Module: "dhcp", Site: "office"}} - if _, err := Resolve(catalogue, []string{"dhcp"}, workstation(), World{Held: faraway}); err != nil { - t.Errorf("a DHCP server at another site was treated as a collision: %v", err) - } -} - -func TestTwoModulesWritingOneFileAreRefusedWithoutAnyClaim(t *testing.T) { - // This conflict costs no manifest field: the mesh already holds every resource of every - // module, so two declaring one path are visible without either knowing the other exists. - a := mod("a", nil, nil, nil) - a.Resources = []map[string]any{{"id": "conf", "type": "file", "path": "/etc/thing.conf"}} - b := mod("b", nil, nil, nil) - b.Resources = []map[string]any{{"id": "conf", "type": "file", "path": "/etc/thing.conf"}} - - _, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), World{}) - if err == nil { - t.Fatal("two modules writing the same file were both assigned") - } - if !strings.Contains(err.Error(), "/etc/thing.conf") { - t.Errorf("the refusal does not name the file: %v", err) - } -} - -func TestResourceIdentitiesCarryTheirModule(t *testing.T) { - // Two modules may reasonably both call something "config". Without the prefix the second - // would silently replace the first, and the node would apply one of them and report success. - a := mod("a", nil, nil, nil) - a.Resources = []map[string]any{{"id": "config", "type": "file", "path": "/etc/a"}} - b := mod("b", nil, nil, nil) - b.Resources = []map[string]any{{"id": "config", "type": "file", "path": "/etc/b"}} - - got, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), World{}) - if err != nil { - t.Fatal(err) - } - seen := map[string]bool{} - for _, r := range mustDeclare(t, got) { - id := r["id"].(string) - if seen[id] { - t.Errorf("two resources share the identity %q", id) - } - seen[id] = true - } - if !seen["a.config"] || !seen["b.config"] { - t.Errorf("identities are not qualified by module: %v", seen) - } -} - -func TestWhatAServiceReflectsIsQualifiedToo(t *testing.T) { - // Otherwise it names a resource that no longer exists under that identity, and the service - // quietly stops being restarted when its own configuration changes. - m := mod("thing", nil, nil, nil) - m.Resources = []map[string]any{ - {"id": "conf", "type": "file", "path": "/etc/thing.conf"}, - {"id": "svc", "type": "service", "unit": "thing.service", "state": "running", - "restart-on": []any{"conf"}}, - } - got, err := Resolve(shelf(m), []string{"thing"}, workstation(), World{}) - if err != nil { - t.Fatal(err) - } - for _, r := range mustDeclare(t, got) { - if r["id"] == "thing.svc" { - if got := fmt.Sprint(r["restart-on"]); got != "[thing.conf]" { - t.Errorf("a service reflects %s, which is not a resource in the declaration", got) - } - return - } - } - t.Error("the service is missing from the declaration") -} - -func TestEveryReasonIsGivenAtOnce(t *testing.T) { - // Somebody resolving these fixes them in one pass or in three. Every reason that genuinely - // refuses the set is collected, rather than only the first: a claim two modules both take, a - // requirement nothing answers, and a requirement several answer without anybody choosing. - _, err := Resolve(shelf( - mod("xorg", nil, nil, nil, Claim{Name: "the-seat"}), - mod("wayland", nil, nil, nil, Claim{Name: "the-seat"}), - mod("i3", nil, []string{"compositor"}, nil), - mod("editor", nil, []string{"shell"}, nil), - mod("bash", []string{"shell"}, nil, nil), - mod("zsh", []string{"shell"}, nil, nil), - ), []string{"xorg", "wayland", "i3", "editor"}, workstation(), World{}) - - if err == nil { - t.Fatal("expected refusals") - } - if strings.Count(err.Error(), "\n - ") < 3 { - t.Errorf("only some problems were reported:\n%v", err) - } -} - -func TestACycleStopsRatherThanRunsAway(t *testing.T) { - // Two modules requiring each other is a mistake somebody makes, and it must produce an answer - // rather than a stack overflow. - got, err := Resolve(shelf( - mod("a", nil, []string{"b"}, nil), - mod("b", nil, []string{"a"}, nil), - ), []string{"a"}, workstation(), World{}) - - if err != nil { - t.Fatal(err) - } - if len(got.Modules) != 2 { - t.Errorf("a cycle resolved to %v", names(got)) - } -} - -func TestChoosingOneSatisfiesTheRequirement(t *testing.T) { - // The other half of refusing. "Choose one and assign it" has to actually work, or the remedy - // names three modules and then ignores the one you pick — which is how this read the first - // time it was used on a real mesh. - got, err := Resolve(shelf( - mod("editor", nil, []string{"shell"}, nil), - mod("bash", []string{"shell"}, nil, nil), - mod("zsh", []string{"shell"}, nil, nil), - mod("fish", []string{"shell"}, nil, nil), - ), []string{"editor", "zsh"}, workstation(), World{}) - - if err != nil { - t.Fatalf("choosing a shell did not satisfy the requirement for one: %v", err) - } - if len(got.Modules) != 2 { - t.Errorf("resolved %v; only the chosen shell should have come in", names(got)) - } -} - -func TestChoosingSeveralIsStillFine(t *testing.T) { - // And the choice is not exclusive. Nothing is claimed, so a person may have all three and - // the requirement is answered by whichever they picked. - got, err := Resolve(shelf( - mod("editor", nil, []string{"shell"}, nil), - mod("bash", []string{"shell"}, nil, nil), - mod("zsh", []string{"shell"}, nil, nil), - mod("fish", []string{"shell"}, nil, nil), - ), []string{"editor", "zsh", "bash", "fish"}, workstation(), World{}) - - if err != nil { - t.Fatal(err) - } - if len(got.Modules) != 4 { - t.Errorf("resolved %v", names(got)) - } -} - -func TestARequirementNamingAModuleMeansThatModule(t *testing.T) { - // i3 requires xorg and means xorg, not "anything calling itself a display server". Otherwise - // assigning wayland would silently satisfy i3 and the machine would come up with a window - // manager talking to nothing. - _, err := Resolve(shelf( - mod("i3", nil, []string{"xorg"}, nil), - mod("xorg", []string{"display-server"}, nil, nil), - mod("wayland", []string{"display-server", "xorg"}, nil, nil), - ), []string{"i3", "wayland"}, workstation(), World{}) - - // wayland claiming to provide "xorg" is a manifest saying something untrue; what matters is - // that a real xorg module still wins when it exists, and that the answer is not silent. - if err != nil && !strings.Contains(err.Error(), "xorg") { - t.Errorf("unexpected refusal: %v", err) - } -} - -func mustDeclare(t *testing.T, r Resolution) []map[string]any { - t.Helper() - out, err := r.Declaration(Rendering{}) - if err != nil { - t.Fatal(err) - } - return out -} - -// A requirement answered on the same machine is still a requirement (novox/hq 04-ISSUES/021). -// -// **The machine stops being a trust boundary once both ends are containers.** A consumer reaches -// its provider over TCP from its own container, and the database asks for a password exactly as it -// would from another machine. Before this, two such modules resolved cleanly with zero needs: no -// credential was made, the consumer's secret file was never written, and whatever read it failed -// somewhere else entirely. -// -// It went unnoticed because everything proven until then was cross-machine, which is the -// interesting case for a mesh and the rare one in practice. -func TestSomethingAnsweredOnThisMachineIsStillANeed(t *testing.T) { - provider := Manifest{ - Module: "postgres", Version: "1", - Provides: FromAnywhere("postgres-database"), - Serves: map[string]map[string]any{"postgres-database": {"port": 5432}}, - Grants: map[string]string{"postgres-database": "/var/lib/postgres/grants"}, - } - consumer := Manifest{ - Module: "keycloak", Version: "1", - Requires: []string{"postgres-database"}, - Secrets: map[string]string{"postgres-database": "/var/lib/keycloak/database.env"}, - } - got, err := Resolve(shelf(provider, consumer), []string{"postgres", "keycloak"}, - Node{Name: "anchor", At: "10.0.0.1", Capabilities: map[string]bool{}}, World{}) - if err != nil { - t.Fatal(err) - } - if len(got.Needs) != 1 { - t.Fatalf("%d need(s); a consumer of a brokered provision needs a credential wherever "+ - "the provider is", len(got.Needs)) - } - if got.Needs[0].From != "anchor" { - t.Errorf("the need names %q as the provider, and it is this machine", got.Needs[0].From) - } - // And it carries what the provider says a consumer must know, which a local provider never - // contributes through the world. - if got.Needs[0].Serves["port"] != 5432 { - t.Errorf("the need carries %v, and the provider serves port 5432", got.Needs[0].Serves) - } -} - -// A name nothing grants is unchanged: answered here means answered, and nothing more is owed. -func TestSomethingOrdinaryAnsweredHereNeedsNothing(t *testing.T) { - got, err := Resolve(shelf( - mod("zsh", []string{"shell"}, nil, nil), - mod("editor-user", nil, []string{"shell"}, nil), - ), []string{"zsh", "editor-user"}, - Node{Name: "anchor", At: "10.0.0.1", Capabilities: map[string]bool{}}, World{}) - if err != nil { - t.Fatal(err) - } - if len(got.Needs) != 0 { - t.Fatalf("a shell answered on this machine produced %d need(s)", len(got.Needs)) - } -} - -func TestTheControllersOwnManifestClaimsItsSeat(t *testing.T) { - // The seat test above fabricates manifests, so deleting the claim from the real module.json - // would fail nothing (novox/hq issue 059's review). This binds the one manifest this - // repository owns: the controller claims the mesh-scoped seat named after its server - // (ADR 0079), or the one-controller property is convention again. - raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) - if err != nil { - t.Fatalf("the controller's own manifest is unreadable: %v", err) - } - m, err := ParseManifest(raw) - if err != nil { - t.Fatalf("the controller's own manifest does not parse: %v", err) - } - for _, c := range m.Claims { - if c.Name == "mesh-controller" && c.At() == ScopeMesh { - return - } - } - t.Fatalf("module.json no longer claims the mesh-scoped mesh-controller seat: %+v", m.Claims) -} diff --git a/internal/catalogue/resolved_path_test.go b/internal/catalogue/resolved_path_test.go deleted file mode 100644 index 55b5519e..00000000 --- a/internal/catalogue/resolved_path_test.go +++ /dev/null @@ -1,127 +0,0 @@ -package catalogue - -// A definition's directories and files are judged at their resolved paths when the definition is (novox/hq issue -// 496). mesh-catalog #205 gave docker's `state` directory `"place": "."`, which resolves to /docker — -// /var/lib/docker, every container's filesystem. The merge gate composed every machine and passed it; only the -// node-engine refused it, at apply, and failed the walk. ParseManifest is what `module check`, registration and the -// merge gate's reading of a changed repository all run, so a refusal here is a refusal at each of them. - -import ( - "strings" - "testing" -) - -func TestADirectoryPlacedInDockersDataIsRefusedWhereTheDefinitionIsJudged(t *testing.T) { - // The #205 shape, as it was merged. - _, err := ParseManifest([]byte(`{"module": "docker", "version": "1", - "resources": [{"id": "state", "type": "directory", "place": "."}]}`)) - if err == nil { - t.Fatal("a directory resolving to /var/lib/docker was accepted; the node-engine refuses it at apply") - } - for _, said := range []string{"docker", `"state"`, "/var/lib/docker", "issue 496"} { - if !strings.Contains(err.Error(), said) { - t.Errorf("the refusal names the module, the resource, the path and why; %q is missing from %q", said, err) - } - } -} - -func TestTheMeshsPlaceForDockerIsAccepted(t *testing.T) { - // The fix #205 needed: the mesh's own directory for the module, /mesh/docker. - m, err := ParseManifest([]byte(`{"module": "docker", "version": "1", "resources": [ - {"id": "state", "type": "directory", "place": "mesh"}, - {"id": "marker", "type": "file", "path": "${dir:state}/applied", "content": "x"}]}`)) - if err != nil { - t.Fatalf("place %q is in the mesh's tree, where the mesh writes for every module: %v", "mesh", err) - } - if got := dirsFor(m, Rendering{}); got["state"] != "/var/lib/mesh/docker" { - t.Fatalf("got %v", got) - } -} - -// The node-engine's rules, for directories and — as the engine judges them since novox/hq issue 495 — files. -func TestADefinitionIsRefusedWhereTheNodeEngineRefusesItsPaths(t *testing.T) { - refused := map[string]string{ - "a stated directory in docker's data": `{"module": "sidecar", "version": "1", "resources": [ - {"id": "volumes", "type": "directory", "path": "/var/lib/docker/volumes/x"}]}`, - "a file in containerd's data": `{"module": "images", "version": "1", "resources": [ - {"id": "f", "type": "file", "path": "/var/lib/containerd/x", "content": "x"}]}`, - "a file beneath a placed directory that climbs out of it": `{"module": "docker", "version": "1", "resources": [ - {"id": "state", "type": "directory", "place": "mesh"}, - {"id": "f", "type": "file", "path": "${dir:state}/../../containers/x", "content": "x"}]}`, - "a file in /boot": `{"module": "grub", "version": "1", "resources": [ - {"id": "cfg", "type": "file", "path": "/boot/grub/custom.cfg", "content": "x"}]}`, - "a directory that is the mesh's whole tree": `{"module": "mesh", "version": "1", "resources": [ - {"id": "all", "type": "directory", "place": "."}]}`, - "a directory that holds /etc": `{"module": "x", "version": "1", "resources": [ - {"id": "d", "type": "directory", "path": "/"}]}`, - "a directory in the node-engine's own tree, by another module": `{"module": "intruder", "version": "1", - "resources": [{"id": "d", "type": "directory", "path": "/var/lib/mesh-host/x"}]}`, - } - for name, raw := range refused { - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "issue 496") { - t.Errorf("%s: accepted, or refused for another reason: %v", name, err) - } - } - - // What the engine applies, the mesh's own modules' paths among them (read from every machine's live plan): - // the machine's configuration, run directories, programs below /usr/local, the node-engine's own trees by its - // own module, an account's keys, a module's own root, and — not on the engine's lists, so not refused here — - // below /lib and at /storage, /data, /services and /var/lock. - accepted := map[string]string{ - "a unit in /etc": `{"module": "power", "version": "1", "resources": [ - {"id": "d", "type": "directory", "path": "/etc/systemd/system/x.service.d"}, - {"id": "u", "type": "file", "path": "/etc/systemd/system/x.service.d/a.conf", "content": "x"}]}`, - "a run directory": `{"module": "fail2ban", "version": "1", "resources": [ - {"id": "run-dir", "type": "directory", "path": "/var/run/fail2ban"}]}`, - "a program in /usr/local/bin": `{"module": "claude-code", "version": "1", "resources": [ - {"id": "start", "type": "file", "path": "/usr/local/bin/claude-agent", "content": "x"}]}`, - "the node-engine's launcher and state, by the node-engine": `{"module": "mesh-host", "version": "1", "resources": [ - {"id": "launcher", "type": "file", "path": "/usr/lib/nox-mesh-host/launch", "content": "x"}, - {"id": "state", "type": "directory", "path": "/var/lib/mesh-host"}]}`, - "an account's .ssh": `{"module": "ssh-client", "version": "1", "resources": [ - {"id": "ssh-dir", "type": "directory", "path": "/home/someone/.ssh"}, - {"id": "config", "type": "file", "path": "/home/someone/.ssh/config", "content": "x"}]}`, - "a module's own root": `{"module": "mailu", "version": "1", "resources": [ - {"id": "state", "type": "directory", "place": "."}]}`, - "a file below /lib": `{"module": "udev", "version": "1", "resources": [ - {"id": "rule", "type": "file", "path": "/lib/udev/rules.d/99-x.rules", "content": "x"}]}`, - "directories at /storage, /data, /services and /var/lock": `{"module": "roots", "version": "1", "resources": [ - {"id": "a", "type": "directory", "path": "/storage"}, {"id": "b", "type": "directory", "path": "/data"}, - {"id": "c", "type": "directory", "path": "/services"}, {"id": "d", "type": "directory", "path": "/var/lock"}]}`, - } - for name, raw := range accepted { - if _, err := ParseManifest([]byte(raw)); err != nil { - t.Errorf("%s: refused: %v", name, err) - } - } -} - -func TestAPathThroughAnAccessIsJudgedWithTheAccessFilledIn(t *testing.T) { - // A file's path may name an access; the access's default path is the definition's, so it is judged with it. - _, err := ParseManifest([]byte(`{"module": "backup", "version": "1", - "accesses": [{"id": "images", "path": "/var/lib/docker/volumes"}], - "resources": [{"id": "marker", "type": "file", "path": "${access:images}/marker", "content": "x"}]}`)) - if err == nil || !strings.Contains(err.Error(), "/var/lib/docker/volumes/marker") || - !strings.Contains(err.Error(), "issue 496") { - t.Fatalf("a file reaching Docker's data through an access's default path was accepted: %v", err) - } - // An access the definition gives no path is placed by a setting, which Places and AccessPlaces judge, and on - // the machine by the node-engine: nothing here to resolve it against, so nothing is refused for it. - if _, err := ParseManifest([]byte(`{"module": "backup", "version": "1", - "accesses": [{"id": "images"}], - "resources": [{"id": "marker", "type": "file", "path": "${access:images}/marker", "content": "x"}]}`)); err != nil { - t.Fatalf("an access placed only by a setting cannot be judged at the definition: %v", err) - } -} - -func TestComposingAMachineIsNeverStoppedByOneModulesPath(t *testing.T) { - // A module registered from outside the catalogue never passes the gate. Refusing its path while a machine's - // declaration is composed would fail the whole declaration and freeze every module on that machine; the - // node-engine fails only the one resource. So composition leaves it to the engine. - m := Manifest{Module: "docker", Version: "1", Resources: []map[string]any{ - {"id": "state", "type": "directory", "place": "."}, - }} - if _, err := (Resolution{Node: "anchor", Modules: []Manifest{m}}).Declaration(Rendering{}); err != nil { - t.Fatalf("the machine's declaration failed for one module's path: %v", err) - } -} diff --git a/internal/catalogue/resolver_file_is_the_uplinks_test.go b/internal/catalogue/resolver_file_is_the_uplinks_test.go deleted file mode 100644 index dc2de447..00000000 --- a/internal/catalogue/resolver_file_is_the_uplinks_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package catalogue - -import ( - "reflect" - "testing" -) - -// novox/hq ADR 0223 part 2: /etc/resolv.conf belongs to the module holding node-uplink. The seat that -// wrote it, node-resolver-config, and ADR 0220's dependency of it on the uplink retire: one owner for -// the file, and it is the program that would otherwise rewrite it. - -func TestTheResolverConfigSeatIsGone(t *testing.T) { - if _, known := SeatNamed("node-resolver-config"); known { - t.Error("node-resolver-config is still in the mesh's set; the uplink's holder writes the resolver file") - } - // An uplink's holder needs no seat beside it for the file: it is its own. - if got := DependsOn(nil, mod("networkmanager", nil, nil, nil, Claim{Name: "node-uplink"})); len(got) != 0 { - t.Errorf("an uplink holder with nothing declared depends on %v", got) - } -} - -// The catalogue as it is: nothing claims the retired seat, and every manager the mesh knows holds the -// uplink and writes the resolver file; beside them only a holder of node-resolver does (ADR 0247). -func TestTheCataloguesUplinksWriteTheResolverFileAndNothingElseDoes(t *testing.T) { - cat := map[string]Manifest{} - for _, m := range theCatalogue(t) { - cat[m.Module] = m - } - if got := PossibleHolders(cat, "node-uplink"); !reflect.DeepEqual(got, uplinks) { - t.Errorf("node-uplink can be held by %v, not %v", got, uplinks) - } - for name, m := range cat { - for _, c := range m.Claims { - if c.Name == "node-resolver-config" { - t.Errorf("%s still claims node-resolver-config", name) - } - } - _, writes := m.Facts["resolvers"] - isUplink := false - for _, u := range uplinks { - isUplink = isUplink || u == name - } - for _, f := range m.Facts { - // Or the machine's own resolver's, where it is held (novox/hq ADR 0247): the uplink's holder - // steps back from the file there, and nowhere else. - if f.Path == "/etc/resolv.conf" && !isUplink && !holdsSeat(m, ResolverSeat) { - t.Errorf("%s writes /etc/resolv.conf and holds neither the uplink nor the machine's resolver", name) - } - } - for _, r := range m.Resources { - if r["path"] == "/etc/resolv.conf" { - t.Errorf("%s declares /etc/resolv.conf as a file of its own", name) - } - } - if isUplink && !writes { - t.Errorf("%s holds the uplink and does not write the resolver file", name) - } - } -} diff --git a/internal/catalogue/resolver_manifests_test.go b/internal/catalogue/resolver_manifests_test.go deleted file mode 100644 index c3dfadd6..00000000 --- a/internal/catalogue/resolver_manifests_test.go +++ /dev/null @@ -1,327 +0,0 @@ -package catalogue - -import ( - "encoding/json" - "strings" - "testing" -) - -// The catalogue's resolver modules as they are, parsed by the real parser and composed as a -// machine would receive them (hal dnsmasq-app conversion, novox/hq 08-connectivity). -// -// The predecessor's resolver answered every name on a machine: the mesh's own itself, the rest -// forwarded to two fixed upstreams, with the machine's resolv.conf naming it alone and the -// container runtime pointed at its private-network address. These hold the mesh's modules to the -// same arrangement, and to the two things a resolver here must never do — read resolv.conf for -// its upstreams, or take an address systemd-resolved holds. - -// resolverShelf is the resolver, an uplink module that writes what the machine asks (novox/hq ADR -// 0223), and the container runtime beside something that answers `mesh-addressing`. -// The networking module that really does is composed in the controller and cannot be imported -// here, so a stand-in offers the same word; what is under test is the manifests, not the network. -func resolverShelf(t *testing.T) map[string]Manifest { - t.Helper() - shelf := map[string]Manifest{ - "net": {Module: "net", Version: "1", Provides: []Offer{{Name: "mesh-addressing"}}}, - } - for _, name := range []string{"dnsmasq", "systemd-networkd", "docker"} { - shelf[name] = catalogueManifest(t, name) - } - return shelf -} - -// twoMachines is what the control plane hands a rendering: internal names and their addresses. -var twoMachines = map[string]string{"anchor.internal": "10.42.0.1", "laptop.internal": "10.42.0.2"} - -// Its configuration forwards to the upstreams the predecessor's module shipped, and gets them from -// nowhere else: `no-resolv` is what makes the documented loop — the resolver finding its own -// address in resolv.conf and becoming its own upstream — impossible. -func TestTheResolverForwardsToFixedUpstreamsAndNeverReadsResolvConf(t *testing.T) { - m := catalogueManifest(t, "dnsmasq") - var config string - for _, r := range m.Resources { - if r["id"] == "config" { - config, _ = r["content"].(string) - } - } - if config == "" { - t.Fatal("the resolver has no configuration file") - } - for _, want := range []string{ - "\nno-resolv\n", "\nserver=1.1.1.1\n", "\nserver=8.8.8.8\n", - // The private address and loopback, never a LAN's (novox/hq ADR 0194): a device that is not a - // member cannot reach what the mesh's names point at. - "\nlisten-address=127.0.0.1\n", "\nlisten-address=${machine:address}\n", - - // No hosts file and no operator's files: the mesh's resolver answers every node (ADR 0199). - "\nno-hosts\n", - "\nconf-file=" + m.Facts["zones"].Path + "\n", - "\ndomain-needed\n", "\nbogus-priv\n", - "\nconf-file=" + m.Facts["node-zones"].Path + "\n", - } { - if !strings.Contains(config, want) { - t.Errorf("the resolver's configuration lacks %q:\n%s", strings.TrimSpace(want), config) - } - } - // Bound to each address once, at start, and never by bind-dynamic (novox/hq issue 348): bind-dynamic - // re-reads the machine's addresses on every change of any link and closes each listener that reading - // did not find, and a reading made while the container runtime tears down a bridge's virtual links - // fails, so the private address was left unbound for twenty minutes, the daemon running and silent. - // The catalogue's main binds once since mesh-catalog PR 161 (105cc7c0). - if !strings.Contains(config, "\nbind-interfaces\n") { - t.Errorf("the resolver's configuration does not bind its addresses once (bind-interfaces):\n%s", config) - } - if strings.Contains(config, "\nbind-dynamic\n") { - t.Errorf("the resolver's configuration says bind-dynamic, which closes its listeners when a bridge is "+ - "torn down (novox/hq issue 348):\n%s", config) - } - // By address and never by interface: dnsmasq admits a query by the interface it arrives on - // when told one, and a container's query to the private address arrives on the runtime's - // bridge — `interface=mesh0` dropped every such query, silently (novox/hq issue 110). - for _, line := range strings.Split(config, "\n") { - if strings.HasPrefix(line, "interface=") { - t.Errorf("the resolver answers by interface, so a container's query on a bridge is dropped: %s", line) - } - } - // Not .53 or .54, which systemd-resolved holds; and not .55 any more, which was a convention - // beside the one every machine already followed — the predecessor's resolv.conf says .1. - for _, taken := range []string{"127.0.0.53", "127.0.0.54", "127.0.0.55"} { - if strings.Contains(config, "listen-address="+taken) { - t.Errorf("the resolver listens on %s", taken) - } - } - // Never a directory or a file the operator keeps: a line written for one machine's programs would - // become an answer for every node (ADR 0199). - for _, never := range []string{"conf-dir=", "addn-hosts=", "listen-address=${setting:"} { - if strings.Contains(config, never) { - t.Errorf("the mesh's resolver still reads or listens on %q", never) - } - } - // And the file that decides what the machine asks names every one of the mesh's resolvers, by - // address, from the seat's holders, and no public one (ADR 0223): a resolver library that asks - // every listed server at once takes the first reply, and a public "no such name" for a mesh name - // won it. Written by every uplink module, since the uplink's holder owns the file. - for _, uplink := range uplinks { - fact, ok := catalogueManifest(t, uplink).Facts["resolvers"] - if !ok || fact.Path != "/etc/resolv.conf" { - t.Fatalf("%s's resolver file is not rendered from the roster: %+v", uplink, fact) - } - if !strings.Contains(fact.Template, `{{range index .Holders "mesh-dns-resolver"}}nameserver {{.Address}}`) { - t.Errorf("%s's resolv.conf does not list every holder of the mesh's resolver:\n%s", uplink, fact.Template) - } - for _, line := range strings.Split(fact.Template, "\n") { - if strings.HasPrefix(line, "nameserver ") && !strings.Contains(line, "{{") { - t.Errorf("%s's resolv.conf names a resolver of its own beside the mesh's: %s", uplink, line) - } - } - if !strings.Contains(fact.Template, "options timeout:1 attempts:2 edns0\n") { - t.Errorf("%s: a silent resolver is not passed over after one short wait:\n%s", uplink, fact.Template) - } - } -} - -// The resolver and what points the machine at it compose on one machine, and what arrives is the -// mesh's account of every machine as a wildcard, the suffix kept local, the daemon restarting on -// that file, the machine pointed at the resolver by address, and the runtime given no resolver of -// its own but kept running across a restart (ADR 0196). -func TestTheResolverAndWhatAsksItComposeOnOneMachine(t *testing.T) { - got, err := Resolve(resolverShelf(t), []string{"dnsmasq", "systemd-networkd", "docker"}, - Node{Name: "anchor", At: "anchor.internal", Capabilities: map[string]bool{ - "package-manager": true, "service-manager": true, "privileged": true, - "uplink-systemd-networkd": true}}, World{}) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(strings.Join(named(got), " "), "net") { - t.Fatalf("the resolver's data is the mesh's addresses, and nothing answering them was taken: %v", named(got)) - } - out, err := got.Declaration(Rendering{ - // Names is every name the mesh serves; Machines is the subset that is a node (novox/hq - // issue 111) — the resolver's zones read only the second, and in this scenario the two - // happen to be the same map, since nothing routed is part of it. - Names: twoMachines, Machines: twoMachines, Suffix: "internal", - Zones: []ZoneAt{{Zone: "incus", Address: "10.42.0.2", Port: 5353}}, - 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) - } - - ids := byID(out) - zones := ids["dnsmasq.fact-node-zones"] - if zones == nil || zones["path"] != "/etc/mesh-resolver/nodes.conf" { - t.Fatalf("the resolver was not given the machines where its configuration reads them: %v", zones) - } - content, _ := zones["content"].(string) - for _, want := range []string{ - "local=/internal/", "address=/anchor.internal/10.42.0.1", "address=/laptop.internal/10.42.0.2", - } { - if !strings.Contains(content, want) { - t.Errorf("the machines file lacks %q:\n%s", want, content) - } - } - - service := ids["dnsmasq.service"] - if service == nil { - t.Fatal("no resolver service composed") - } - reflects := map[string]bool{} - for _, id := range service["restart-on"].([]any) { - reflects[id.(string)] = true - } - if !reflects["dnsmasq.config"] || !reflects["dnsmasq.fact-node-zones"] || !reflects["dnsmasq.fact-zones"] { - t.Errorf("the daemon does not restart on its configuration, the machines and the zones: %v", service["restart-on"]) - } - if z, _ := ids["dnsmasq.fact-zones"]["content"].(string); !strings.Contains(z, "server=/incus/10.42.0.2#5353\n") { - t.Errorf("the resolver was not told to forward the zone to its answerer:\n%s", z) - } - - // The runtime's own file, written into (novox/hq ADR 0102) with one key, by the runtime's own - // module — a module does not write another software's configuration (issue 190): a restart keeps - // every container running. No `dns` — a container copies its machine's resolvers (ADR 0196), and - // neither the resolver nor what decides how the machine resolves writes the runtime's file. - if ids["dnsmasq.runtime-dns"] != nil { - t.Errorf("the resolver still writes the runtime's dns: %v", ids["dnsmasq.runtime-dns"]) - } - for id := range ids { - if strings.HasPrefix(id, "systemd-networkd.runtime") { - t.Errorf("what the machine asks still writes the runtime's file: %s", id) - } - } - runtime := ids["docker.daemon"] - if runtime == nil || runtime["path"] != "/etc/docker/daemon.json" || runtime["into"] != "json" { - t.Fatalf("live-restore is not written into the runtime's file: %v", runtime) - } - var keys map[string]any - if err := json.Unmarshal([]byte(runtime["content"].(string)), &keys); err != nil { - t.Fatalf("the runtime's keys are not JSON: %v", err) - } - // What the runtime is given is what its own module declares, and nothing a layer or another module - // adds. This used to say "live-restore and nothing else", to hold the line ADR 0196 drew when `dns` - // left the file; since the docker module gained log rotation as a preference (mesh-catalog #205 and - // #217, novox/hq issue 452) that frozen copy failed every check (novox/hq issue 498). So the keys are - // judged against the module's own daemon.json instead: live-restore is given, `dns` never is, and no - // key the module does not declare appears. - if keys["live-restore"] != true { - t.Errorf("the runtime is given %v; a restart must keep every container running (live-restore)", keys) - } - if _, ok := keys["dns"]; ok { - t.Errorf("the runtime is given a resolver of its own, %v; a container copies its machine's (ADR 0196)", keys["dns"]) - } - declared := daemonKeysDeclared(t) - for key := range keys { - if !declared[key] { - t.Errorf("the runtime is given %q, which the docker module does not declare: %v", key, keys) - } - } - // The log rotation preference, at its defaults, wherever the module declares it. The copy captured in - // testdata/beside predates it, so outside a merge check it is not there to judge. - if declared["log-driver"] { - opts, _ := keys["log-opts"].(map[string]any) - if keys["log-driver"] != "json-file" || opts["max-size"] != "100m" || opts["max-file"] != "10" { - t.Errorf("the runtime is given %v; its containers' logs are cut at 100m and ten are kept (novox/hq issue 452)", keys) - } - } - // The runtime is reloaded when that file changes, and never restarted: a restart stops every - // container on the machine (ADR 0102), and a reload is what turns live-restore on. - var reloaded bool - for _, r := range out { - if r["type"] != "service" || r["unit"] != "docker.service" { - continue - } - if _, restarts := r["restart-on"]; restarts { - t.Errorf("the runtime is ordered restarted, which stops every container (ADR 0102): %v", r) - } - for _, on := range asStrings(r["reload-on"]) { - if on == "docker.daemon" { - reloaded = true - } - } - } - if !reloaded { - t.Errorf("the runtime is not reloaded when its file changes, so live-restore never takes effect") - } - - resolv := ids["systemd-networkd.fact-resolvers"] - if resolv == nil || !strings.Contains(resolv["content"].(string), "\nnameserver 10.42.0.1\noptions ") { - t.Fatalf("the machine is not pointed at the resolver by address, and only at it: %v", resolv) - } -} - -// Two modules deciding what a machine asks are refused on one machine, as before — now that the file -// is the uplink's (novox/hq ADR 0223), by two things: a machine runs one network manager, and no other -// module may write the resolver file beside the uplink's, as a file or as a rendered fact. -func TestTwoThingsDecidingWhatAMachineAsksAreRefused(t *testing.T) { - shelf := resolverShelf(t) - shelf["networkmanager"] = catalogueManifest(t, "networkmanager") - node := Node{Name: "anchor", At: "anchor.internal", Capabilities: map[string]bool{ - "package-manager": true, "service-manager": true, - "uplink-systemd-networkd": true, "uplink-networkmanager": true}} - _, err := Resolve(shelf, []string{"dnsmasq", "systemd-networkd", "networkmanager"}, node, World{}) - if err == nil || !strings.Contains(err.Error(), "node-uplink") { - t.Fatalf("two network managers were both assigned to one machine: %v", err) - } - - // The second is made up: what is under test is that the resolver file has one owner, so any - // module asking the mesh to render it — or declaring it — will do. - for name, m := range map[string]Manifest{ - "a-rendered-one": {Module: "a-rendered-one", Version: "1", - Facts: map[string]RosterFile{"mine": {Path: "/etc/resolv.conf", Template: "nameserver 10.42.0.1\n"}}}, - "a-declared-one": {Module: "a-declared-one", Version: "1", Resources: []map[string]any{ - {"id": "mine", "type": "file", "path": "/etc/resolv.conf", "content": "nameserver 10.42.0.1\n"}}}, - } { - shelf := resolverShelf(t) - shelf[name] = m - _, err := Resolve(shelf, []string{"dnsmasq", "systemd-networkd", name}, node, World{}) - if err == nil || !strings.Contains(err.Error(), "/etc/resolv.conf") { - t.Errorf("%s wrote the resolver file beside the uplink's: %v", name, err) - } - } -} - -// A machine that is not on the private network has no address for the runtime to be pointed at. -// Refused where the module and the machine are both named, rather than a placeholder written into -// the runtime's file and read as an address. -func TestTheResolverOnAMachineOffTheNetworkIsRefused(t *testing.T) { - got, err := Resolve(resolverShelf(t), []string{"dnsmasq"}, Node{Name: "anchor"}, World{}) - if err != nil { - t.Fatal(err) - } - // Left out of the declaration and said, rather than composed listening nowhere: a module that - // cannot compose on a machine is kept as it is there, with the reason (hq ADR 0163). - composed, err := got.Compose(Rendering{Names: twoMachines, Suffix: "internal", - 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 && !strings.Contains(composed.LeftOut["dnsmasq"], "${machine:address}") { - t.Fatalf("a machine off the network was composed a resolver, or left out for another reason: %v", - composed.LeftOut) - } - if err != nil && !strings.Contains(err.Error(), "${machine:address}") { - t.Fatalf("a machine off the network was refused for another reason: %v", err) - } -} - -// daemonKeysDeclared is the top-level keys of the docker module's own daemon.json, read from its -// manifest: what the runtime may be given (novox/hq issue 498). A placeholder sits inside a JSON -// string there, so the content parses as it is. -func daemonKeysDeclared(t *testing.T) map[string]bool { - t.Helper() - for _, r := range catalogueManifest(t, "docker").Resources { - if r["id"] != "daemon" { - continue - } - var keys map[string]any - if err := json.Unmarshal([]byte(r["content"].(string)), &keys); err != nil { - t.Fatalf("the docker module's daemon.json is not JSON: %v", err) - } - declared := map[string]bool{} - for key := range keys { - declared[key] = true - } - return declared - } - t.Fatal("the docker module declares no daemon.json") - return nil -} diff --git a/internal/catalogue/retirement_event_test.go b/internal/catalogue/retirement_event_test.go deleted file mode 100644 index ec029784..00000000 --- a/internal/catalogue/retirement_event_test.go +++ /dev/null @@ -1,22 +0,0 @@ -package catalogue - -import ( - "slices" - "testing" -) - -// Every provider may say what becomes of a consumer the mesh stopped asking for (novox/hq ADR 0230), -// whatever its manifest lists — as it may say a consumer it keeps failing (ADR 0224) — and a module -// that receives nothing may not. -func TestEveryProviderMaySayWhatItRetires(t *testing.T) { - provider := Manifest{Module: "pg", Receives: map[string]string{"postgres-database": "/x/mesh.json"}, - Emits: []string{"database.provisioned"}} - for _, e := range []string{ProvisionerFailing, ProvisionerRecovered, ProvisionerRetirement, "database.provisioned"} { - if !slices.Contains(provider.EmitsAll(), e) { - t.Errorf("a provider may not emit %s: %v", e, provider.EmitsAll()) - } - } - if slices.Contains(Manifest{Module: "app", Emits: []string{"x"}}.EmitsAll(), ProvisionerRetirement) { - t.Error("a module that provides nothing may say what it retires") - } -} diff --git a/internal/catalogue/roster_test.go b/internal/catalogue/roster_test.go deleted file mode 100644 index f2780604..00000000 --- a/internal/catalogue/roster_test.go +++ /dev/null @@ -1,281 +0,0 @@ -package catalogue - -import ( - "strings" - "testing" -) - -// Keyed by the internal name, as the control plane hands them (issue 079). bart has no address — -// the ordinary state between adding a machine and it joining. -var threeMachines = map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2", "bart.internal": ""} - -// A module says where it wants a roster file and in what format, and is given the rendered file. -func TestAModuleIsGivenTheFileItAskedFor(t *testing.T) { - m := Manifest{Module: "resolver", Facts: map[string]RosterFile{ - "zones": {Path: "/etc/mesh/zones.conf", Template: "{{range .Machines}}address=/{{.FQDN}}/{{.Address}}\n{{end}}"}, - }} - given, err := FactsInto(m, Resolution{Node: "homer"}, threeMachines, threeMachines, nil, "") - if err != nil { - t.Fatal(err) - } - if len(given) != 1 { - t.Fatalf("expected one file, got %d", len(given)) - } - if given[0]["path"] != "/etc/mesh/zones.conf" || given[0]["type"] != "file" { - t.Fatalf("not written where it was asked for: %v", given[0]) - } - // The id is fact-, which is what a restart-on names when the roster changes. - if given[0]["id"] != "fact-zones" { - t.Fatalf("the file's id is not fact-, so a restart-on cannot find it: %v", given[0]["id"]) - } - if !strings.Contains(given[0]["content"].(string), "address=/homer.internal/10.42.0.1") { - t.Fatalf("the file does not hold the fact: %v", given[0]["content"]) - } -} - -// **A shared fact is written into a region of the machine's file, not over it** (novox/hq issue -// 128). A hosts file is the machine's — its localhost, the operator's lines, other tools' blocks — -// so the mesh owns only a marked region (`into: block`); a resolver's zones file is the mesh's -// whole, and carries no `into`. -func TestASharedFactIsWrittenIntoARegion(t *testing.T) { - roster := map[string]string{"homer.internal": "10.42.0.1"} - m := Manifest{Module: "net", Facts: map[string]RosterFile{ - "node-names": {Path: "/etc/hosts", Template: "{{range .Names}}{{.FQDN}}\n{{end}}", Shared: true}, - "node-zones": {Path: "/etc/zones", Template: "{{range .Machines}}{{.FQDN}}\n{{end}}"}, - }} - given, err := FactsInto(m, Resolution{Node: "homer"}, roster, roster, nil, "") - if err != nil { - t.Fatal(err) - } - by := map[string]map[string]any{} - for _, f := range given { - by[f["path"].(string)] = f - } - if by["/etc/hosts"]["into"] != "block" { - t.Fatalf("a shared fact is not written into a region, so the mesh writes the file whole: %v", by["/etc/hosts"]) - } - if _, has := by["/etc/zones"]["into"]; has { - t.Fatalf("an unshared fact was written into a region, so the mesh does not own its own file whole: %v", by["/etc/zones"]) - } -} - -// **The format is the module's — the mesh renders whatever template it gives.** The same roster -// through two templates is two entirely different files, and the control plane reads neither. -func TestTheFormatIsTheModulesOwn(t *testing.T) { - roster := map[string]string{"homer.internal": "10.42.0.1"} - hostsish := Manifest{Module: "a", Facts: map[string]RosterFile{ - "f": {Path: "/f", Template: "{{range .Names}}{{.Address}}\t{{.Name}}\n{{end}}"}}} - sshish := Manifest{Module: "b", Facts: map[string]RosterFile{ - "f": {Path: "/f", Template: "{{range .Names}}Host {{.Name}}\n HostName {{.FQDN}}\n{{end}}"}}} - - h, err := FactsInto(hostsish, Resolution{Node: "homer"}, roster, roster, nil, "") - if err != nil { - t.Fatal(err) - } - s, err := FactsInto(sshish, Resolution{Node: "homer"}, roster, roster, nil, "") - if err != nil { - t.Fatal(err) - } - if h[0]["content"] != "10.42.0.1\thomer\n" { - t.Fatalf("the hosts-shaped template did not render its format: %q", h[0]["content"]) - } - if s[0]["content"] != "Host homer\n HostName homer.internal\n" { - t.Fatalf("the ssh-shaped template did not render its format: %q", s[0]["content"]) - } -} - -// **A template that will not parse is refused here, not on a machine.** A daemon that starts, reads -// a file the mesh could not render, and answers nothing is a much worse way to find out. -func TestABrokenTemplateIsRefusedHere(t *testing.T) { - m := Manifest{Module: "resolver", Facts: map[string]RosterFile{ - "zones": {Path: "/etc/zones", Template: "{{range .Machines}}oops"}}} - _, err := FactsInto(m, Resolution{}, nil, nil, nil, "") - if err == nil { - t.Fatal("a template that does not parse was accepted, so the machine gets an empty file") - } - if !strings.Contains(err.Error(), "resolver") || !strings.Contains(err.Error(), "zones") { - t.Fatalf("the refusal does not say whose template, or which: %v", err) - } -} - -// A template reading something the mesh does not compute is refused, not rendered empty. The roster -// is a closed shape; asking it for the weather fails where the manifest is. -func TestATemplateReadingWhatTheMeshDoesNotHaveIsRefused(t *testing.T) { - m := Manifest{Module: "resolver", Facts: map[string]RosterFile{ - "zones": {Path: "/etc/zones", Template: "{{.Weather}}"}}} - if _, err := FactsInto(m, Resolution{}, nil, nil, nil, ""); err == nil { - t.Fatal("a template read a field nobody computes and rendered anyway, silently") - } -} - -// A relative path is refused, or a module decides where the mesh writes on a machine. -func TestAFactMustBeAskedForAtAnAbsolutePath(t *testing.T) { - m := Manifest{Module: "resolver", Facts: map[string]RosterFile{ - "hosts": {Path: "etc/hosts", Template: "x"}}} - if _, err := FactsInto(m, Resolution{}, nil, nil, nil, ""); err == nil { - t.Fatal("a relative path was accepted") - } -} - -// A machine the mesh has a record for and cannot place is left out of the roster. -// -// **Not an oversight — the alternative is worse.** A name written with no address resolves to -// nothing, and a connection to that hangs. Leaving it out fails at once and says the name is -// unknown, which is a thing somebody can act on. -func TestAMachineWithNoAddressIsNotInTheRoster(t *testing.T) { - m := Manifest{Module: "a", Facts: map[string]RosterFile{ - "f": {Path: "/f", Template: "{{range .Machines}}{{.Name}}\n{{end}}"}}} - given, err := FactsInto(m, Resolution{Node: "homer"}, threeMachines, threeMachines, nil, "") - if err != nil { - t.Fatal(err) - } - if strings.Contains(given[0]["content"].(string), "bart") { - t.Fatalf("a machine with no address was in the roster, so its name resolves to nothing:\n%s", given[0]["content"]) - } -} - -// **The names the control plane hands a resolution are already internal names** — `homer.internal`, -// the same map every container gets as its hosts. A roster entry's FQDN is that name, not it with -// the suffix appended a second time; either key gives the same entries. -func TestNamesAreNotSuffixedTwice(t *testing.T) { - internal := map[string]string{"homer.internal": "10.42.0.1"} - bare := map[string]string{"homer": "10.42.0.1"} - tmpl := RosterFile{Path: "/f", Template: "{{range .Machines}}{{.FQDN}} {{.Name}}\n{{end}}"} - - fromInternal, err := FactsInto(Manifest{Module: "a", Facts: map[string]RosterFile{"f": tmpl}}, Resolution{Node: "homer"}, internal, internal, nil, "") - if err != nil { - t.Fatal(err) - } - fromBare, err := FactsInto(Manifest{Module: "a", Facts: map[string]RosterFile{"f": tmpl}}, Resolution{Node: "homer"}, bare, bare, nil, "") - if err != nil { - t.Fatal(err) - } - if fromInternal[0]["content"] != fromBare[0]["content"] { - t.Fatalf("the roster differs by how the names were keyed:\n%q\n%q", fromInternal[0]["content"], fromBare[0]["content"]) - } - got := fromInternal[0]["content"].(string) - if strings.Contains(got, "internal.internal") || !strings.Contains(got, "homer.internal homer") { - t.Fatalf("the entry carries a doubled suffix or the wrong bare name:\n%s", got) - } -} - -// The suffix the control plane composed the names with is the one a template sees — an operator who -// chose another does not get `.internal`. `.Suffix` is the bare form, and FQDNs carry it. -func TestTheSuffixIsCarriedAsComposed(t *testing.T) { - names := map[string]string{"homer.lan": "10.42.0.1"} - m := Manifest{Module: "a", Facts: map[string]RosterFile{ - "f": {Path: "/f", Template: "local=/{{.Suffix}}/\n{{range .Machines}}{{.FQDN}}\n{{end}}"}}} - given, err := FactsInto(m, Resolution{Node: "homer"}, names, names, nil, "lan") - if err != nil { - t.Fatal(err) - } - got := given[0]["content"].(string) - if !strings.Contains(got, "local=/lan/") || strings.Contains(got, "internal") { - t.Fatalf("the operator's suffix was not carried, so its names would be wrong:\n%s", got) - } - if !strings.Contains(got, "homer.lan") { - t.Fatalf("the FQDN does not carry the operator's suffix:\n%s", got) - } -} - -// novox/hq 04-ISSUES/111: a template is given both sets and chooses. `.Names` is every name the mesh -// serves — the machines and the names it was told to route; `.Machines` is only the machines. A -// container's hosts wants every name so a routed name resolves to the machine serving it; a resolver -// told the suffix is its own wants only the machines, or a routed name written there with the suffix -// is a name nobody will ever ask for, standing beside the machines and looking as real. -func TestATemplateChoosesMachinesOrEveryName(t *testing.T) { - machines := map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2"} - every := map[string]string{ - "homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2", - "drive.example.test": "10.42.0.1", "git.example.test": "10.42.0.2", - } - m := Manifest{Module: "resolver", Facts: map[string]RosterFile{ - "zones": {Path: "/etc/zones", Template: "{{range .Machines}}{{.FQDN}}\n{{end}}"}, - "hosts": {Path: "/etc/hosts", Template: "{{range .Names}}{{.FQDN}}\n{{end}}"}, - }} - given, err := FactsInto(m, Resolution{Node: "homer"}, every, machines, nil, "") - if err != nil { - t.Fatal(err) - } - by := map[string]string{} - for _, f := range given { - by[f["path"].(string)] = f["content"].(string) - } - - zones := by["/etc/zones"] - for _, served := range []string{"drive.example.test", "git.example.test"} { - if strings.Contains(zones, served) { - t.Fatalf("a template over .Machines saw %q, a name the mesh serves rather than a machine:\n%s", served, zones) - } - } - if !strings.Contains(zones, "homer.internal") { - t.Fatalf("a template over .Machines did not see the machines:\n%s", zones) - } - - hosts := by["/etc/hosts"] - for _, name := range []string{"homer.internal", "drive.example.test", "git.example.test"} { - if !strings.Contains(hosts, name) { - t.Fatalf("a template over .Names did not see %q, so a container would not resolve it:\n%s", name, hosts) - } - } -} - -// A home fact is placed under the operator account's home and chowned to it, and its template sees -// each node's account (novox/hq to-be 29) — the ssh-client config is the case. -func TestAHomeFactIsPlacedUnderTheAccountsHomeAndOwnedByIt(t *testing.T) { - names := map[string]string{"homer.internal": "10.42.0.1", "marge.internal": "10.42.0.2"} - accounts := map[string]string{"homer": "jo", "marge": "jo"} - m := Manifest{Module: "ssh-client", Facts: map[string]RosterFile{ - "ssh-config": {Path: ".ssh/config.d/mesh", Home: true, - Template: "{{range .Names}}Host {{.Name}}\n HostName {{.FQDN}}\n User {{.Account}}\n{{end}}"}}} - given, err := FactsInto(m, Resolution{Node: "homer", Account: "jo"}, names, names, accounts, "") - if err != nil { - t.Fatal(err) - } - f := given[0] - if f["path"] != "/home/jo/.ssh/config.d/mesh" { - t.Fatalf("the home fact was not placed under the account's home: %v", f["path"]) - } - if f["owner"] != "jo" { - t.Fatalf("the home fact is not owned by the account: %v", f["owner"]) - } - if !strings.Contains(f["content"].(string), "Host marge\n HostName marge.internal\n User jo") { - t.Fatalf("the config does not name the peer's account:\n%s", f["content"]) - } -} - -// A machine with no operator account gets no home fact — it cannot be placed, so it is left out -// rather than written to nowhere. -func TestAHomeFactIsSkippedWhereThereIsNoAccount(t *testing.T) { - names := map[string]string{"homer.internal": "10.42.0.1"} - m := Manifest{Module: "ssh-client", Facts: map[string]RosterFile{ - "ssh-config": {Path: ".ssh/config", Home: true, Template: "x"}}} - given, err := FactsInto(m, Resolution{Node: "homer"}, names, names, nil, "") - if err != nil { - t.Fatal(err) - } - if len(given) != 0 { - t.Fatalf("a home fact was placed on a machine with no operator account: %v", given) - } -} - -// A routed name is already a full name under a public domain and has no mesh form. Appending the -// suffix to it made `git.example.tld.internal` — carried by every machine's hosts file, served by -// nothing, and refused by the proxy at the handshake (novox/hq issue 157). It is published as -// itself, and only a machine has a bare name beside its full one. -func TestARoutedNameIsPublishedAsItselfAndNotSuffixed(t *testing.T) { - names := map[string]string{"homer.internal": "10.42.0.1", "git.example.tld": "10.42.0.1"} - tmpl := RosterFile{Path: "/f", Template: "{{range .Names}}{{.FQDN}} {{.Name}}\n{{end}}"} - out, err := FactsInto(Manifest{Module: "a", Facts: map[string]RosterFile{"f": tmpl}}, - Resolution{Node: "homer"}, names, map[string]string{"homer.internal": "10.42.0.1"}, nil, "internal") - if err != nil { - t.Fatal(err) - } - got := out[0]["content"].(string) - if strings.Contains(got, "tld.internal") { - t.Fatalf("the routed name was given a suffixed alias that nothing serves:\n%s", got) - } - if !strings.Contains(got, "git.example.tld git.example.tld\n") || !strings.Contains(got, "homer.internal homer\n") { - t.Fatalf("the roster does not carry the routed name as itself beside the machine's two forms:\n%s", got) - } -} diff --git a/internal/catalogue/routing_test.go b/internal/catalogue/routing_test.go deleted file mode 100644 index bb6a115f..00000000 --- a/internal/catalogue/routing_test.go +++ /dev/null @@ -1,256 +0,0 @@ -package catalogue - -import ( - "strings" - "testing" -) - -// novox/hq ADR 0066 — public routing is name-agnostic. -// -// A route contribution carries a label (the subdomain the operator chose); the node carries its -// public domain; the mesh composes