Delete the second half of internal/catalogue's tests: mesh-sdk's go/catalogue holds them (issue 1381)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A module that must know its own address — a login redirect, a canonical URL, an issuer — had it
|
||||
// written into its manifest as a literal (novox/hq 04-ISSUES/122): a domain in a definition, wrong on
|
||||
// every other machine. It is told instead, from the same composition the provider receives.
|
||||
|
||||
// selfAware contributes a labelled route, binds the requirement, and writes its own name into a file.
|
||||
func selfAware(label string) Manifest {
|
||||
m := labelled("board", label, 8080)
|
||||
m.Requires = []string{"reverse-proxy"}
|
||||
m.Binds = map[string]string{"reverse-proxy": "/var/lib/board/route.json"}
|
||||
m.Resources = []map[string]any{
|
||||
{"id": "conf", "type": "file", "path": "/var/lib/board/app.conf",
|
||||
"content": "root = https://${bound:reverse-proxy:name}/\ninternal = ${bound:reverse-proxy:internal-name}\n"},
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// servingProxy is proxy() as the catalogue's route providers are declared: the provision scoped to
|
||||
// the mesh, serving nothing a consumer must know (route-adapter, route-proxy: `"serves": {"route": {}}`).
|
||||
func servingProxy() Manifest {
|
||||
p := proxy()
|
||||
p.Provides = []Offer{{Name: "reverse-proxy", Scope: ScopeMesh}}
|
||||
p.Serves = map[string]map[string]any{"reverse-proxy": {}}
|
||||
return p
|
||||
}
|
||||
|
||||
// nodeProxy is the same provider scoped to its node, whose answer on the same machine comes from
|
||||
// `here` rather than from the mesh's needs — the other path a binding is written by.
|
||||
func nodeProxy() Manifest {
|
||||
p := proxy()
|
||||
p.Serves = map[string]map[string]any{"reverse-proxy": {"scheme": "http"}}
|
||||
return p
|
||||
}
|
||||
|
||||
// onBoth is a node with a public domain and a private-network address, so both names compose.
|
||||
func onBoth(domain string) Node {
|
||||
n := withDomain(domain)
|
||||
n.At = "anchor.internal"
|
||||
return n
|
||||
}
|
||||
|
||||
func fileAt(t *testing.T, out []map[string]any, path string) string {
|
||||
t.Helper()
|
||||
for _, r := range out {
|
||||
if r["path"] == path {
|
||||
return r["content"].(string)
|
||||
}
|
||||
}
|
||||
t.Fatalf("nothing was declared at %s", path)
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestAModuleIsToldTheNameItsProviderServes(t *testing.T) {
|
||||
got, err := Resolve(shelf(servingProxy(), selfAware("git")), []string{"traefik", "board"},
|
||||
onBoth("example.tld"), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := mustDeclare(t, got)
|
||||
served := received(t, out)[0].Values
|
||||
|
||||
var binding map[string]any
|
||||
if err := json.Unmarshal([]byte(fileAt(t, out, "/var/lib/board/route.json")), &binding); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if binding["name"] != served["name"] || binding["name"] != "git.example.tld" {
|
||||
t.Fatalf("the module was told %v, the provider serves %v", binding["name"], served["name"])
|
||||
}
|
||||
if binding["internal-name"] != served["internal-name"] || binding["internal-name"] == nil {
|
||||
t.Fatalf("internal name: module told %v, provider serves %v",
|
||||
binding["internal-name"], served["internal-name"])
|
||||
}
|
||||
|
||||
conf := fileAt(t, out, "/var/lib/board/app.conf")
|
||||
want := "root = https://git.example.tld/\ninternal = " + served["internal-name"].(string) + "\n"
|
||||
if conf != want {
|
||||
t.Fatalf("the file was rendered as\n%s\nwant\n%s", conf, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheNameAModuleIsToldFollowsTheNodesDomain(t *testing.T) {
|
||||
// The whole point: the same definition, two machines, two names — nothing edited.
|
||||
for _, domain := range []string{"example.tld", "other.example"} {
|
||||
got, err := Resolve(shelf(servingProxy(), selfAware("git")), []string{"traefik", "board"},
|
||||
onBoth(domain), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf := fileAt(t, mustDeclare(t, got), "/var/lib/board/app.conf")
|
||||
if !strings.HasPrefix(conf, "root = https://git."+domain+"/") {
|
||||
t.Fatalf("on %s the module wrote %q", domain, conf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleWithNoPublicNameIsNotToldOne(t *testing.T) {
|
||||
// No public domain on the node: nothing composed, so no `name` — and a file asking for one is
|
||||
// refused rather than rendered with a placeholder or an empty host.
|
||||
m := selfAware("git")
|
||||
m.Resources[0]["content"] = "root = https://${bound:reverse-proxy:name}/\n"
|
||||
got, err := Resolve(shelf(servingProxy(), m), []string{"traefik", "board"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := got.Declaration(Rendering{}); err == nil ||
|
||||
!strings.Contains(err.Error(), `"name"`) {
|
||||
t.Fatalf("a file asking for a name that was never composed was not refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleIsToldItsNameByANodeScopedProviderToo(t *testing.T) {
|
||||
m := selfAware("git")
|
||||
m.Resources[0]["content"] = "root = https://${bound:reverse-proxy:name}/\n"
|
||||
got, err := Resolve(shelf(nodeProxy(), m), []string{"board"},
|
||||
withDomain("example.tld"), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conf := fileAt(t, mustDeclare(t, got), "/var/lib/board/app.conf")
|
||||
if !strings.HasPrefix(conf, "root = https://git.example.tld/") {
|
||||
t.Fatalf("a same-machine, node-scoped answer did not tell the module its name: %q", conf)
|
||||
}
|
||||
}
|
||||
@@ -1,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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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", "<node> <module>"} {
|
||||
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 <module>") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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:<id>} 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)
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 - "))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 <root>/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, <root>/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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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-<name>, 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-<name>, 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)
|
||||
}
|
||||
}
|
||||
@@ -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 <label>.<public-domain> and interprets neither half. The
|
||||
// checks here are the ADR's own: the same catalogue resolves against two domains by changing one
|
||||
// node setting and nothing else, and a legacy full-name contribution passes through untouched so
|
||||
// the catalogue can migrate module by module.
|
||||
|
||||
// labelled is a module that asks to be published under a subdomain rather than a full hostname.
|
||||
func labelled(module, label string, port int) Manifest {
|
||||
return Manifest{Module: module, Version: "1",
|
||||
Contributes: map[string]map[string]any{
|
||||
"reverse-proxy": {"label": label, "port": port},
|
||||
}}
|
||||
}
|
||||
|
||||
// withDomain is a workstation that composes its routed names under one public domain.
|
||||
func withDomain(domain string) Node {
|
||||
n := workstation()
|
||||
n.PublicDomain = domain
|
||||
return n
|
||||
}
|
||||
|
||||
func TestALabelComposesWithTheNodesPublicDomain(t *testing.T) {
|
||||
got, err := Resolve(shelf(proxy(), labelled("board", "git", 8080)),
|
||||
[]string{"board"}, withDomain("example.tld"), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
given := received(t, mustDeclare(t, got))
|
||||
if len(given) != 1 {
|
||||
t.Fatalf("the proxy was told about %d of 1: %v", len(given), given)
|
||||
}
|
||||
if given[0].Values["name"] != "git.example.tld" {
|
||||
t.Fatalf("the label did not compose with the domain: %v", given[0].Values)
|
||||
}
|
||||
// The port the module gave is still there — composing the name must not drop what the proxy
|
||||
// needs to reach the workload.
|
||||
if given[0].Values["port"] != float64(8080) {
|
||||
t.Fatalf("composing the name dropped the port: %v", given[0].Values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheApexLabelComposesToTheBareDomain(t *testing.T) {
|
||||
got, err := Resolve(shelf(proxy(), labelled("board", "@", 4000)),
|
||||
[]string{"board"}, withDomain("example.tld"), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
given := received(t, mustDeclare(t, got))
|
||||
if len(given) != 1 {
|
||||
t.Fatalf("the proxy was told about %d of 1: %v", len(given), given)
|
||||
}
|
||||
// `@` is the zone-file apex: served at the bare public domain, no subdomain, no leading dot.
|
||||
if given[0].Values["name"] != "example.tld" {
|
||||
t.Fatalf("the apex label did not compose to the bare domain: %v", given[0].Values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneNodeSettingMovesTheCatalogueBetweenDomains(t *testing.T) {
|
||||
// The ADR's name-agnostic check: the same catalogue resolves against two different public
|
||||
// domains by changing one node setting and nothing else.
|
||||
one, err := Resolve(shelf(proxy(), labelled("board", "git", 8080)),
|
||||
[]string{"board"}, withDomain("example.tld"), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
two, err := Resolve(shelf(proxy(), labelled("board", "git", 8080)),
|
||||
[]string{"board"}, withDomain("other.example"), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
first := received(t, mustDeclare(t, one))[0].Values
|
||||
second := received(t, mustDeclare(t, two))[0].Values
|
||||
|
||||
if first["name"] != "git.example.tld" || second["name"] != "git.other.example" {
|
||||
t.Fatalf("the name did not track the domain: %v then %v", first["name"], second["name"])
|
||||
}
|
||||
// And nothing else moved: same label, same port. The domain is the one fact that changed.
|
||||
if first["label"] != second["label"] || first["label"] != "git" {
|
||||
t.Fatalf("the label changed with the domain: %v then %v", first["label"], second["label"])
|
||||
}
|
||||
if first["port"] != second["port"] {
|
||||
t.Fatalf("the port changed with the domain: %v then %v", first["port"], second["port"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestALegacyFullNameContributionPassesThroughUnchanged(t *testing.T) {
|
||||
// Backward compatibility: a contribution that still carries a full `name` and no `label` is
|
||||
// granted that name as-is, even on a node that has a public domain. This is what lets the
|
||||
// catalogue migrate one module at a time while the running mesh keeps working.
|
||||
legacy := Manifest{Module: "board", Version: "1",
|
||||
Contributes: map[string]map[string]any{
|
||||
"reverse-proxy": {"name": "git.pinned.example", "port": 8080},
|
||||
}}
|
||||
got, err := Resolve(shelf(proxy(), legacy), []string{"board"}, withDomain("example.tld"), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
given := received(t, mustDeclare(t, got))
|
||||
if given[0].Values["name"] != "git.pinned.example" {
|
||||
t.Fatalf("a full name was rewritten: %v", given[0].Values)
|
||||
}
|
||||
if _, grewLabel := given[0].Values["label"]; grewLabel {
|
||||
t.Fatalf("a legacy contribution grew a label: %v", given[0].Values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALabelWithNoPublicDomainComposesNothing(t *testing.T) {
|
||||
// A routed module on a node with no public domain has nothing to join its label to. It composes
|
||||
// no name — which reads downstream exactly as a route that named no host, the same as before
|
||||
// this existed — rather than an fabricated `git.` with a dangling dot.
|
||||
got, err := Resolve(shelf(proxy(), labelled("board", "git", 8080)),
|
||||
[]string{"board"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
given := received(t, mustDeclare(t, got))
|
||||
if _, named := given[0].Values["name"]; named {
|
||||
t.Fatalf("a name was composed with no domain to compose it from: %v", given[0].Values)
|
||||
}
|
||||
if given[0].Values["label"] != "git" {
|
||||
t.Fatalf("the label was lost: %v", given[0].Values)
|
||||
}
|
||||
}
|
||||
|
||||
// withPrivateAddress is a workstation on the private network, at the given internal name — the
|
||||
// same fact a route's own consumers already receive as `${bound:...:at}`.
|
||||
func withPrivateAddress(at string) Node {
|
||||
n := workstation()
|
||||
n.At = at
|
||||
return n
|
||||
}
|
||||
|
||||
func TestALabelComposesWithTheNodesPrivateAddressToo(t *testing.T) {
|
||||
// A predecessor proxy answered a route on both a public and a private-network hostname for the
|
||||
// same convenience the mesh restores here: reaching a service over the VPN without a public TLS
|
||||
// round trip. Composed independently of the public name, from the node's own `At`.
|
||||
got, err := Resolve(shelf(proxy(), labelled("board", "git", 8080)),
|
||||
[]string{"board"}, withPrivateAddress("anchor.internal"), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
given := received(t, mustDeclare(t, got))
|
||||
if given[0].Values["internal-name"] != "git.anchor.internal" {
|
||||
t.Fatalf("the label did not compose with the private address: %v", given[0].Values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThePublicAndInternalNamesComposeIndependently(t *testing.T) {
|
||||
// A node with both a public domain and a private address gets both names from one label; a
|
||||
// node with only one of the two gets only the matching one — neither composition depends on
|
||||
// the other being possible.
|
||||
both := withPrivateAddress("anchor.internal")
|
||||
both.PublicDomain = "example.tld"
|
||||
got, err := Resolve(shelf(proxy(), labelled("board", "git", 8080)),
|
||||
[]string{"board"}, both, World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
given := received(t, mustDeclare(t, got))
|
||||
if given[0].Values["name"] != "git.example.tld" {
|
||||
t.Fatalf("the public name did not compose alongside the internal one: %v", given[0].Values)
|
||||
}
|
||||
if given[0].Values["internal-name"] != "git.anchor.internal" {
|
||||
t.Fatalf("the internal name did not compose alongside the public one: %v", given[0].Values)
|
||||
}
|
||||
|
||||
publicOnly, err := Resolve(shelf(proxy(), labelled("board", "git", 8080)),
|
||||
[]string{"board"}, withDomain("example.tld"), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
givenPublicOnly := received(t, mustDeclare(t, publicOnly))
|
||||
if _, has := givenPublicOnly[0].Values["internal-name"]; has {
|
||||
t.Fatalf("an internal name was composed with no private address to compose it from: %v",
|
||||
givenPublicOnly[0].Values)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheApexLabelComposesToTheBarePrivateAddress(t *testing.T) {
|
||||
got, err := Resolve(shelf(proxy(), labelled("board", "@", 4000)),
|
||||
[]string{"board"}, withPrivateAddress("anchor.internal"), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
given := received(t, mustDeclare(t, got))
|
||||
if given[0].Values["internal-name"] != "anchor.internal" {
|
||||
t.Fatalf("the apex label did not compose to the bare private address: %v", given[0].Values)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0066 propagate, by ADR 0148's means: a granted route name is published into the
|
||||
// machine's roster mapped to the node that serves it, beside the `<node>.internal` names, and the
|
||||
// machine's resolver answers it to every container. Nothing is written into the container itself —
|
||||
// a routed name that moved would otherwise be wrong inside every container until each was recreated.
|
||||
func TestARoutedNameResolvesToTheServingNode(t *testing.T) {
|
||||
names := map[string]string{
|
||||
"anchor.internal": "10.42.0.1",
|
||||
"git.example.tld": "10.42.0.1",
|
||||
}
|
||||
got := containersOf(t, Resolution{Node: "anchor", Modules: []Manifest{{
|
||||
Module: "app",
|
||||
Resources: []map[string]any{{"id": "web", "type": "container", "name": "web",
|
||||
"image": "registry.example/web@sha256:" + strings.Repeat("a", 64)}},
|
||||
}}}, Rendering{Names: names})
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("expected one container, got %d", len(got))
|
||||
}
|
||||
if given := namesOf(got[0]); len(given) != 0 {
|
||||
t.Fatalf("the routed name was copied into the container, where it would go stale: %v", given)
|
||||
}
|
||||
var sawRoute bool
|
||||
for _, e := range entriesFrom(names, nil, "internal") {
|
||||
if e.Name == "git.example.tld" && e.Address == "10.42.0.1" {
|
||||
sawRoute = true
|
||||
}
|
||||
}
|
||||
if !sawRoute {
|
||||
t.Fatalf("the routed name is not in the roster the machine's resolver answers from")
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq issue 139, ADR 0151: `<label>.<node>.internal` is answered by every machine's resolver as
|
||||
// "anything under that node's name goes to that node", so the node in a route's internal name must
|
||||
// be the one whose proxy answers it. Composed under the consumer's own name, a route served from
|
||||
// another machine got an internal name that resolved to a machine with nothing listening, while the
|
||||
// public name — published at the serving node's address — worked.
|
||||
func TestARoutesInternalNameIsComposedUnderTheNodeThatServesIt(t *testing.T) {
|
||||
hub := proxy()
|
||||
hub.Provides = FromAnywhere("reverse-proxy")
|
||||
got, err := Resolve(shelf(hub, labelled("board", "git", 8080)), []string{"board"},
|
||||
withPrivateAddress("laptop.internal"), World{Offered: map[string][]Provider{
|
||||
"reverse-proxy": {{Node: "anchor", At: "anchor.internal", Module: "traefik"}},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Gathered the way the control plane gathers a consumer's contribution for a provider on
|
||||
// another machine.
|
||||
values, asks, err := got.ContributionsFrom("reverse-proxy", "board", nil)
|
||||
if err != nil || !asks {
|
||||
t.Fatalf("the route was not contributed: %v %v", asks, err)
|
||||
}
|
||||
if values["internal-name"] != "git.anchor.internal" {
|
||||
t.Fatalf("the internal name does not say where the request arrives: %v", values)
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A run-once container is a step the host runs to completion (novox/hq ADR 0052). The control
|
||||
// plane's part is small and exact: carry the field to the host unchanged, keep the step ahead of
|
||||
// the container it gates in author order, and refuse a malformed run-once near its cause rather
|
||||
// than on the machine. These tests defend that.
|
||||
|
||||
// indexOfID returns the position of the resource with the given (module-prefixed) id, or -1.
|
||||
func indexOfID(out []map[string]any, id string) int {
|
||||
for i, r := range out {
|
||||
if r["id"] == id {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func TestARunOnceContainerRendersBeforeTheContainerItGates(t *testing.T) {
|
||||
// The gate is declaration order, not a resolved dependency: the step is written before the
|
||||
// container that needs it, and the host applies in order and stops at a step that did not
|
||||
// complete. So the control plane must carry run-once through untouched and must not reorder the
|
||||
// two containers.
|
||||
digest := "@sha256:" + strings.Repeat("a", 64)
|
||||
r := Resolution{Node: "laptop", Modules: []Manifest{{
|
||||
Module: "mosquitto",
|
||||
Resources: []map[string]any{
|
||||
{"id": "seed", "type": "container", "name": "seed",
|
||||
"image": "registry.example/runtime" + digest, "run-once": true},
|
||||
{"id": "server", "type": "container", "name": "broker",
|
||||
"image": "registry.example/broker" + digest},
|
||||
},
|
||||
}}}
|
||||
out, err := r.Declaration(Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
seed := indexOfID(out, "mosquitto.seed")
|
||||
server := indexOfID(out, "mosquitto.server")
|
||||
if seed == -1 || server == -1 {
|
||||
t.Fatalf("a container was lost in rendering: seed=%d server=%d", seed, server)
|
||||
}
|
||||
if seed >= server {
|
||||
t.Errorf("the run-once step rendered after the container it gates (seed=%d server=%d)", seed, server)
|
||||
}
|
||||
if once, _ := out[seed]["run-once"].(bool); !once {
|
||||
t.Errorf("run-once did not reach the host declaration: %+v", out[seed])
|
||||
}
|
||||
if _, present := out[server]["run-once"]; present {
|
||||
t.Errorf("run-once leaked onto the container that is not a step: %+v", out[server])
|
||||
}
|
||||
}
|
||||
|
||||
func TestARunOnceMustBeABoolean(t *testing.T) {
|
||||
// A value that is not true or false is refused here, not sent to a machine that would then have
|
||||
// to guess what a string means.
|
||||
digest := "@sha256:" + strings.Repeat("a", 64)
|
||||
bad := []byte(`{"module":"m","resources":[
|
||||
{"id":"seed","type":"container","name":"seed","image":"registry.example/x` + digest + `","run-once":"yes"}
|
||||
]}`)
|
||||
if _, err := ParseManifest(bad); err == nil {
|
||||
t.Error("a run-once that is not a boolean was accepted")
|
||||
} else if !strings.Contains(err.Error(), "run-once") {
|
||||
t.Errorf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
|
||||
good := []byte(`{"module":"m","resources":[
|
||||
{"id":"seed","type":"container","name":"seed","image":"registry.example/x` + digest + `","run-once":true}
|
||||
]}`)
|
||||
if _, err := ParseManifest(good); err != nil {
|
||||
t.Errorf("a valid run-once container was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARunOnceStepMayNameWhatItReads(t *testing.T) {
|
||||
// For a step, restart-on means *run again* when what it reads changed: a gate that fetches a
|
||||
// provider's root names the binding file it reads, so a provider that moved is fetched again
|
||||
// (novox/hq ADR 0099). Accepted here, and the host's digest does the rest.
|
||||
digest := "@sha256:" + strings.Repeat("a", 64)
|
||||
good := []byte(`{"module":"m","resources":[
|
||||
{"id":"conf","type":"file","path":"/x","content":"y"},
|
||||
{"id":"seed","type":"container","name":"seed","image":"registry.example/x` + digest + `","run-once":true,"restart-on":["conf"]}
|
||||
]}`)
|
||||
if _, err := ParseManifest(good); err != nil {
|
||||
t.Errorf("a run-once step naming what it reads was refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The packet-filter manifest as it was the day the runtime was decided (novox/hq ADR 0175): tools,
|
||||
// served from a container built on the tool runtime's image, with NET_ADMIN so the container could
|
||||
// reach the filter. The exact pattern to-be 38 WP4 moves it off, and the one the gate refuses.
|
||||
const thePacketFilterAsItWas = `{
|
||||
"module": "nftables",
|
||||
"version": "1",
|
||||
"capabilities": ["firewall", "container-runtime"],
|
||||
"claims": [{"name": "node-packet-filter", "scope": "node", "serves": ["rules", "reload", "remove"]}],
|
||||
"filtering": {"into": "/etc/nftables.conf"},
|
||||
"resources": [
|
||||
{"id": "mesh-state", "type": "directory", "mode": "0700", "place": "mesh"},
|
||||
{"id": "package", "type": "package", "package": "nftables"},
|
||||
{"id": "unit", "type": "file", "path": "/etc/systemd/system/mesh-filter.service",
|
||||
"content": "[Unit]\nDescription=The mesh's packet filter\n[Service]\nType=oneshot\nExecStart=nft -f /etc/nftables.conf\n", "mode": "0644"},
|
||||
{"id": "load", "type": "service", "unit": "mesh-filter.service", "state": "running", "boot": "enabled",
|
||||
"restart-on": ["unit"], "reload-on": ["filtering"]},
|
||||
{"id": "runtime", "type": "container", "name": "mesh-nftables", "network": "host",
|
||||
"capabilities": ["NET_ADMIN"],
|
||||
"volumes": ["${dir:mesh-state}/broker:/run/secrets/broker:ro", "/etc/nftables.conf:/etc/nftables.conf:ro"],
|
||||
"env": {"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_FILTER_FILE": "/etc/nftables.conf"},
|
||||
"artifact": "runtime"}
|
||||
],
|
||||
"tools": ["firewall_rules"],
|
||||
"own-secrets": {"broker": "${dir:mesh-state}/broker"},
|
||||
"build": {
|
||||
"on": [
|
||||
{"arg": "BUILD_BASE", "module": "mesh-tools", "artifact": "build"},
|
||||
{"arg": "RUNTIME_BASE", "module": "mesh-tools", "artifact": "runtime"}
|
||||
],
|
||||
"artifacts": [{"name": "runtime", "kind": "image", "from": "Dockerfile"}]
|
||||
}
|
||||
}`
|
||||
|
||||
func TestAToolContainerOnTheRuntimeImageIsNamedForWhatItIs(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(thePacketFilterAsItWas))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// From the repository: the manifest says what it builds on.
|
||||
why := ToolContainerOnTheRuntime(m, nil)
|
||||
if why == "" {
|
||||
t.Fatal("the packet filter's tool container was not recognised from its build")
|
||||
}
|
||||
for _, word := range []string{"nftables", "mesh-tools", "runtime", "ADR 0175", "bundle"} {
|
||||
if !strings.Contains(why, word) {
|
||||
t.Errorf("the refusal does not say %q: %s", word, why)
|
||||
}
|
||||
}
|
||||
|
||||
// Built: the manifest carries no build, and what it stood on says the same.
|
||||
built, err := m.Resolve([]Built{{Name: "runtime", Kind: ArtifactImage,
|
||||
Reference: ArtifactStoreScheme + "nftables/runtime@" + digest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stoodOn := []string{"anchor.internal:5100/mesh-tools/build@" + digest, "anchor.internal:5100/mesh-tools/runtime@" + digest}
|
||||
if ToolContainerOnTheRuntime(built, stoodOn) == "" {
|
||||
t.Error("the packet filter's tool container was not recognised from what its build stood on")
|
||||
}
|
||||
if ToolContainerOnTheRuntime(built, nil) != "" {
|
||||
t.Error("a built manifest with no record of its base was judged to be on the runtime")
|
||||
}
|
||||
|
||||
// Each of the three alone is an ordinary module.
|
||||
bundle := m
|
||||
bundle.Resources = m.Resources[:len(m.Resources)-1]
|
||||
if ToolContainerOnTheRuntime(bundle, nil) != "" {
|
||||
t.Error("a module with tools and no container is the pattern the runtime serves, and was refused")
|
||||
}
|
||||
service := m
|
||||
service.Tools = nil
|
||||
if ToolContainerOnTheRuntime(service, nil) != "" {
|
||||
t.Error("a service built against the SDK, declaring no tools, was refused")
|
||||
}
|
||||
elsewhere := m
|
||||
elsewhere.Build = &Build{On: []BuildsOn{{Arg: "NODE_BASE", Image: "node@" + digest}},
|
||||
Artifacts: m.Build.Artifacts}
|
||||
if ToolContainerOnTheRuntime(elsewhere, nil) != "" {
|
||||
t.Error("a tool container on a public base was refused as though it were on the runtime's")
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-host/validate"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0366: a module that runs as its own account declares its runtime's health under one manifest
|
||||
// key, `runtime-health`, in the form a resource's `health` takes; the controller puts it on the process it
|
||||
// writes for that runtime, and a tool check there stands beside the node-engine's judgement that the runtime
|
||||
// is alive. A module that runs as no account of its own is refused the key.
|
||||
|
||||
// ownAccountManifest is a module of its own account with nothing long-running but its own runtime, given
|
||||
// runtime-health (or none, when health is nil), and running as no account when runsAs is false.
|
||||
func ownAccountManifest(t *testing.T, runsAs bool, health any) []byte {
|
||||
t.Helper()
|
||||
m := map[string]any{"module": "keeper", "tools": []any{"keeper_status", "keeper_health"},
|
||||
"own-secrets": map[string]any{"broker": map[string]any{"path": "/var/lib/keeper/broker"}},
|
||||
"resources": []any{map[string]any{"id": "account", "type": "user", "name": "keeper"}}}
|
||||
if runsAs {
|
||||
m["runs-as"], m["secrets-owner"] = "keeper", "keeper"
|
||||
}
|
||||
if health != nil {
|
||||
m["runtime-health"] = health
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
func TestAModuleOfItsOwnAccountDeclaresItsRuntimesHealthByItsOwnTool(t *testing.T) {
|
||||
m, err := ParseManifest(ownAccountManifest(t, true, map[string]any{"kind": "tool", "tool": "keeper_health"}))
|
||||
if err != nil {
|
||||
t.Fatalf("a tool check on its own runtime was refused: %v", err)
|
||||
}
|
||||
if got := HealthChecks(m); len(got) != 1 || got[0] != "keeper.keeper_health" {
|
||||
t.Errorf("the engine is granted %v, want keeper.keeper_health", got)
|
||||
}
|
||||
if r, has := OwnRuntimeHealth(m); !has || r["id"] != "keeper."+OwnRuntimeID() {
|
||||
t.Errorf("its runtime's health reads as %v %v", r, has)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeHealthIsRefusedWithoutAnAccountOfItsOwn(t *testing.T) {
|
||||
_, err := ParseManifest(ownAccountManifest(t, false, map[string]any{"kind": "tool", "tool": "keeper_health"}))
|
||||
if err == nil || !strings.Contains(err.Error(), "runtime-health") || !strings.Contains(err.Error(), "runs-as") {
|
||||
t.Errorf("runtime-health without runs-as: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARuntimeHealthIsHeldToTheHealthRules(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
health any
|
||||
says string
|
||||
}{
|
||||
{map[string]any{"kind": "tool", "tool": "keeper_restart"}, "does not serve"},
|
||||
{map[string]any{"kind": "tool", "tool": "keeper_health", "interval": "2s"}, "no more often"},
|
||||
{map[string]any{"kind": "exec", "command": "true"}, "only a container"},
|
||||
{map[string]any{"kind": "tool", "tool": "keeper_health", "port": 80}, "names a port"},
|
||||
{"tool", "is an object"},
|
||||
} {
|
||||
_, err := ParseManifest(ownAccountManifest(t, true, c.health))
|
||||
if err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%v: %v, want it refused saying %q", c.health, err, c.says)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The runtime-health is composed onto <module>.own-runtime, in the words the node-engine takes; to an engine
|
||||
// that does not read health it is not sent, as for every resource.
|
||||
func TestARuntimeHealthIsComposedOntoTheModulesOwnRuntime(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
keeper := aToolsModule(t, "keeper", "tools/index.js")
|
||||
keeper.Tools = []string{"keeper_health"}
|
||||
keeper.RunsAs, keeper.SecretsOwner = "keeper", "keeper"
|
||||
keeper.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/keeper/broker"}}
|
||||
keeper.RuntimeHealth = map[string]any{"kind": "tool", "tool": "keeper_health", "interval": "60s", "timeout": "10s",
|
||||
"looks": 2.0}
|
||||
for _, reads := range []bool{true, false} {
|
||||
with := Rendering{ArtifactStore: "anchor.internal:5101", ReadsHealth: reads,
|
||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed"}, "keeper": {"broker": "own"}}}
|
||||
out, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{keeper, goRuntime}}.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
own := fileNamed(out, "keeper."+OwnRuntimeID())
|
||||
if own == nil {
|
||||
t.Fatalf("no own runtime: %v", ids(out))
|
||||
}
|
||||
h, has := own[HealthField].(map[string]any)
|
||||
if !reads {
|
||||
if has {
|
||||
t.Errorf("health sent to an engine that does not read it: %v", h)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !has || h["kind"] != "tool" || h["tool"] != "keeper_health" || h["interval"] != "1m0s" ||
|
||||
h["timeout"] != "10s" || h["looks"] != 2 || h["grace"] != "1m0s" {
|
||||
t.Errorf("its own runtime's health: %v", own[HealthField])
|
||||
}
|
||||
if machine := fileNamed(out, RuntimeModule+"."+RuntimeProcessID()); machine[HealthField] != nil {
|
||||
t.Errorf("the machine's runtime was given the module's health: %v", machine[HealthField])
|
||||
}
|
||||
body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": out})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if problems := validate.Declaration(body); len(problems) > 0 {
|
||||
t.Errorf("composed into something the node-engine refuses: %v", problems)
|
||||
}
|
||||
}
|
||||
// The catalogue's own manifest is not written into.
|
||||
if _, ok := keeper.RuntimeHealth.(map[string]any)["grace"]; ok {
|
||||
t.Error("composing wrote the defaults into the catalogue's manifest")
|
||||
}
|
||||
}
|
||||
@@ -1,587 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The node's tool runtime (novox/hq ADR 0175, to-be 38): where the runtime module is assigned, a
|
||||
// machine is sent every assigned module's tools bundle as an archive, and the runtime's own process
|
||||
// loading them. Where it is not, the machine is sent exactly what it was sent before.
|
||||
|
||||
var bundleDigest = "sha256:" + strings.Repeat("b", 64)
|
||||
|
||||
// aToolsModule is a module whose tools come as a compiled bundle and nothing else — the shape every
|
||||
// module takes once its tool container goes (to-be 38 WP4).
|
||||
func aToolsModule(t *testing.T, name string, entrypoints ...string) Manifest {
|
||||
t.Helper()
|
||||
m := Manifest{Module: name, Version: "1", Tools: []string{"status"},
|
||||
Build: &Build{Artifacts: []Artifact{
|
||||
{Name: "tools", Kind: ArtifactBundle, Language: "typescript", Entrypoints: entrypoints},
|
||||
}}}
|
||||
resolved, err := m.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + name + "/tools/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
// theRuntime is the runtime module as the catalogue holds it: its own bundle, run rather than
|
||||
// loaded, and its broker secret to receive the node's credential in.
|
||||
func theRuntime(t *testing.T) Manifest {
|
||||
t.Helper()
|
||||
m := Manifest{Module: RuntimeModule, Version: "1",
|
||||
OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/mesh/" + RuntimeModule + "/broker"}},
|
||||
Build: &Build{Artifacts: []Artifact{{Name: "runtime", Kind: ArtifactBundle, Language: "typescript",
|
||||
Entrypoints: []string{"src/main.js"}}}}}
|
||||
resolved, err := m.Resolve([]Built{{Name: "runtime", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
func TestABuildsBundlesAreCarriedOnTheResolvedManifest(t *testing.T) {
|
||||
m := aToolsModule(t, "nftables", "tools/index.js")
|
||||
if len(m.Bundles) != 1 {
|
||||
t.Fatalf("the resolved manifest carries %d bundle(s), not the one the build made", len(m.Bundles))
|
||||
}
|
||||
b := m.Bundles[0]
|
||||
if b.Name != "tools" || b.Digest != bundleDigest || b.Language != "typescript" ||
|
||||
b.Source != ArtifactStoreScheme+"nftables/tools/blobs/"+bundleDigest ||
|
||||
len(b.Entrypoints) != 1 || b.Entrypoints[0] != "tools/index.js" {
|
||||
t.Errorf("the bundle is carried as %+v", b)
|
||||
}
|
||||
// A repository manifest may not write what the build derives.
|
||||
raw := `{"module":"x","version":"1","build":{"artifacts":[{"name":"t","kind":"bundle","language":"typescript"}]},` +
|
||||
`"bundles":[{"name":"t","source":"s","digest":"` + bundleDigest + `"}]}`
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "bundles") {
|
||||
t.Errorf("a manifest stating its build's output by hand was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryToolsBundleIsDeliveredWhereTheRuntimeRuns(t *testing.T) {
|
||||
store := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}}
|
||||
nftables := aToolsModule(t, "nftables", "tools/index.js")
|
||||
zsh := aToolsModule(t, "zsh", "tools/index.js", "tools/more.js")
|
||||
|
||||
t.Run("with the runtime, one archive per tools bundle", func(t *testing.T) {
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{nftables, zsh, theRuntime(t)}}
|
||||
out, err := r.Declaration(store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
archive := fileNamed(out, "nftables."+BundleID("tools"))
|
||||
if archive == nil {
|
||||
t.Fatalf("nftables' tools bundle was not delivered: %v", ids(out))
|
||||
}
|
||||
if archive["type"] != "archive" || archive["digest"] != bundleDigest ||
|
||||
archive["path"] != BundleRoot+"/nftables/tools" {
|
||||
t.Errorf("delivered as %v", archive)
|
||||
}
|
||||
if archive["source"] != "http://anchor.internal:5101/v2/nftables/tools/blobs/"+bundleDigest {
|
||||
t.Errorf("fetched from %v, not through the store as this network reaches it", archive["source"])
|
||||
}
|
||||
if fileNamed(out, "zsh."+BundleID("tools")) == nil {
|
||||
t.Errorf("zsh's tools bundle was not delivered: %v", ids(out))
|
||||
}
|
||||
// The runtime's own bundle is run, not loaded: its process delivers it, not an archive.
|
||||
if fileNamed(out, RuntimeModule+"."+BundleID("runtime")) != nil {
|
||||
t.Error("the runtime's own bundle was delivered as an archive beside its process")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("without the runtime, nothing changes", func(t *testing.T) {
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{nftables, zsh}}
|
||||
out, err := r.Declaration(store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range ids(out) {
|
||||
if strings.Contains(id, BundleID("")) {
|
||||
t.Errorf("%s was delivered to a machine running no runtime to load it", id)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func ids(out []map[string]any) []string {
|
||||
var names []string
|
||||
for _, r := range out {
|
||||
names = append(names, r["id"].(string))
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// One process per machine runs the runtime from its own bundle, told what it serves and from where,
|
||||
// where its credential is, and who the operator is — restarted when any of that changes.
|
||||
func TestTheMachineRunsOneRuntimeLoadingEveryDeliveredBundle(t *testing.T) {
|
||||
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}}
|
||||
nftables := aToolsModule(t, "nftables", "tools/index.js")
|
||||
// A bundle carrying a daemon beside its tools says which files the runtime loads.
|
||||
showcase := Manifest{Module: "showcase", Version: "1", Tools: []string{"greet"},
|
||||
Build: &Build{Artifacts: []Artifact{{Name: "code", Kind: ArtifactBundle, Language: "typescript",
|
||||
Entrypoints: []string{"daemon/index.js", "tools/index.js"}, Loads: []string{"tools/index.js"}}}}}
|
||||
showcase, err := showcase.Resolve([]Built{{Name: "code", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + "showcase/code/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{nftables, showcase, theRuntime(t)}}
|
||||
out, err := r.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
process := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())
|
||||
if process == nil {
|
||||
t.Fatalf("no runtime process was composed: %v", ids(out))
|
||||
}
|
||||
if process["type"] != "process" || process["name"] != RuntimeModule || process["digest"] != bundleDigest ||
|
||||
process["source"] != "http://anchor.internal:5101/v2/"+RuntimeModule+"/runtime/blobs/"+bundleDigest {
|
||||
t.Errorf("the runtime's process is %v", process)
|
||||
}
|
||||
if fmt.Sprint(process["run"]) != "[node src/main.js]" {
|
||||
t.Errorf("the runtime is run as %v; its bundle's one entrypoint, by its language's interpreter", process["run"])
|
||||
}
|
||||
env := process["env"].(map[string]string)
|
||||
if env[RuntimeToolModules] != "nftables="+BundleRoot+"/nftables/tools/tools/index.js,"+
|
||||
"showcase="+BundleRoot+"/showcase/code/tools/index.js" {
|
||||
t.Errorf("the runtime is told to serve %q: every loaded file, by module, and nothing a bundle runs", env[RuntimeToolModules])
|
||||
}
|
||||
if env[RuntimeBrokerFile] != "/var/lib/mesh/"+RuntimeModule+"/broker" {
|
||||
t.Errorf("the runtime reads its credential at %q, not where the module's own secret is placed", env[RuntimeBrokerFile])
|
||||
}
|
||||
if env[RuntimeOperatorAccount] != "ops" || env[RuntimeOperatorHome] != "/home/ops" || process["user"] != "ops" {
|
||||
t.Errorf("the operator is not handed to the runtime: %v as %v", env, process["user"])
|
||||
}
|
||||
// The credential the process reads belongs to the account it runs as, or it could not read it
|
||||
// (to-be 38 WP3); other modules' secrets are left as their manifests say.
|
||||
if credential := fileNamed(out, RuntimeModule+"."+NeedID("broker")); credential == nil || credential["owner"] != "ops" {
|
||||
t.Errorf("the runtime's credential is not the account's to read: %v", credential)
|
||||
}
|
||||
restarts := fmt.Sprint(process["restart-on"])
|
||||
for _, want := range []string{"nftables." + BundleID("tools"), "showcase." + BundleID("code"), RuntimeModule + "." + NeedID("broker")} {
|
||||
if !strings.Contains(restarts, want) {
|
||||
t.Errorf("the runtime is not restarted when %s changes: %s", want, restarts)
|
||||
}
|
||||
}
|
||||
// After every bundle and the credential, so both exist before it starts.
|
||||
names := ids(out)
|
||||
if names[len(names)-1] != RuntimeModule+"."+RuntimeProcessID() {
|
||||
t.Errorf("the runtime's process is not last: %v", names)
|
||||
}
|
||||
|
||||
t.Run("a machine with no account runs it as root without the operator words", func(t *testing.T) {
|
||||
out, err := Resolution{Node: "anchor", Modules: []Manifest{nftables, theRuntime(t)}}.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
process := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())
|
||||
env := process["env"].(map[string]string)
|
||||
if _, set := env[RuntimeOperatorAccount]; set {
|
||||
t.Error("an operator account was named on a machine that has none")
|
||||
}
|
||||
if _, set := process["user"]; set {
|
||||
t.Error("a user was set on a machine with no account")
|
||||
}
|
||||
if credential := fileNamed(out, RuntimeModule+"."+NeedID("broker")); credential == nil || credential["owner"] != nil {
|
||||
t.Errorf("the runtime's credential was given an owner on a machine with no account: %v", credential)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a runtime module built wrong is refused by name", func(t *testing.T) {
|
||||
two := Manifest{Module: RuntimeModule, Version: "1", OwnSecrets: OwnSecrets{"broker": {Path: "/b"}},
|
||||
Build: &Build{Artifacts: []Artifact{{Name: "runtime", Kind: ArtifactBundle, Language: "typescript",
|
||||
Entrypoints: []string{"a.js", "b.js"}}}}}
|
||||
resolved, err := two.Resolve([]Built{{Name: "runtime", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + "x/runtime/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = Resolution{Node: "anchor", Modules: []Manifest{resolved}}.Declaration(with)
|
||||
if err == nil || !strings.Contains(err.Error(), "entrypoint") {
|
||||
t.Errorf("a runtime bundle with two entrypoints was composed: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// novox/hq ADR 0192: a tools bundle says what it is given; the composer resolves it per machine as
|
||||
// a container's environment, hands it to the runtime as the module's words, and makes what the
|
||||
// words name readable by the account the runtime runs as.
|
||||
func TestABundleIsGivenItsWordsResolvedForThisMachine(t *testing.T) {
|
||||
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{
|
||||
RuntimeModule: {"broker": "sealed-credential"},
|
||||
"dash": {"token": "sealed-token"},
|
||||
}}
|
||||
dash := Manifest{Module: "dash", Version: "1", Tools: []string{"status"},
|
||||
Listens: []Listening{{Name: "web", Port: 3000, Protocol: "tcp"}},
|
||||
OwnSecrets: OwnSecrets{"token": {Path: "${dir:mesh-state}/token"}},
|
||||
Resources: []map[string]any{
|
||||
{"id": "mesh-state", "type": "directory", "mode": "0700", "place": "mesh"},
|
||||
{"id": "config", "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", "content": "{}\n"},
|
||||
{"id": "unrelated", "type": "file", "path": "/etc/dash.conf", "content": "x\n"},
|
||||
},
|
||||
Build: &Build{Artifacts: []Artifact{{Name: "tools", Kind: ArtifactBundle, Language: "typescript",
|
||||
Entrypoints: []string{"tools/index.js"},
|
||||
Env: map[string]string{
|
||||
"DASH_CONFIG_FILE": "${dir:mesh-state}/config.json",
|
||||
"DASH_TOKEN_FILE": "${dir:mesh-state}/token",
|
||||
"DASH_URL": "http://127.0.0.1:${port:3000}",
|
||||
"DASH_ADMIN": "mesh-admin",
|
||||
}}}}}
|
||||
if problems := dash.Build.problems(dash.Module); len(problems) > 0 {
|
||||
t.Fatalf("a bundle's words written with ${dir:…} and ${port:…} were refused: %v", problems)
|
||||
}
|
||||
dash, err := dash.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + "dash/tools/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other := aToolsModule(t, "nftables", "tools/index.js")
|
||||
|
||||
out, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{dash, other, theRuntime(t)}}.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := fileNamed(out, "dash.mesh-state")
|
||||
if dir == nil {
|
||||
t.Fatalf("no directory: %v", ids(out))
|
||||
}
|
||||
at := fmt.Sprint(dir["path"])
|
||||
process := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())
|
||||
env := process["env"].(map[string]string)
|
||||
var given map[string]map[string]string
|
||||
if err := json.Unmarshal([]byte(env[RuntimeToolEnv]), &given); err != nil {
|
||||
t.Fatalf("the runtime's %s is not JSON: %q", RuntimeToolEnv, env[RuntimeToolEnv])
|
||||
}
|
||||
want := map[string]string{
|
||||
"DASH_CONFIG_FILE": at + "/config.json",
|
||||
"DASH_TOKEN_FILE": at + "/token",
|
||||
"DASH_URL": "http://127.0.0.1:3000",
|
||||
"DASH_ADMIN": "mesh-admin",
|
||||
}
|
||||
if fmt.Sprint(given["dash"]) != fmt.Sprint(want) {
|
||||
t.Errorf("dash is given %v, want %v", given["dash"], want)
|
||||
}
|
||||
if _, has := given["nftables"]; has {
|
||||
t.Errorf("a module that declares no words was given some: %v", given)
|
||||
}
|
||||
// What the words name is the account's to read; nothing else of the module's is touched.
|
||||
for _, id := range []string{"dash.mesh-state", "dash.config", "dash." + NeedID("token")} {
|
||||
if r := fileNamed(out, id); r == nil || r["owner"] != "ops" {
|
||||
t.Errorf("%s is not the account's to read: %v", id, r)
|
||||
}
|
||||
}
|
||||
if r := fileNamed(out, "dash.unrelated"); r == nil || r["owner"] != nil {
|
||||
t.Errorf("a file no word names was given an owner: %v", r)
|
||||
}
|
||||
// A file a word names restarts the runtime when it changes, as it restarted the tool container.
|
||||
restarts := fmt.Sprint(process["restart-on"])
|
||||
for _, want := range []string{"dash.config", "dash." + NeedID("token")} {
|
||||
if !strings.Contains(restarts, want) {
|
||||
t.Errorf("the runtime is not restarted when %s changes: %s", want, restarts)
|
||||
}
|
||||
}
|
||||
if strings.Contains(restarts, "dash.unrelated") || strings.Contains(restarts, "dash.mesh-state") {
|
||||
t.Errorf("the runtime restarts for something no word names as a file: %s", restarts)
|
||||
}
|
||||
|
||||
t.Run("on a machine with no account the runtime is root and nothing is re-owned", func(t *testing.T) {
|
||||
out, err := Resolution{Node: "anchor", Modules: []Manifest{dash, theRuntime(t)}}.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r := fileNamed(out, "dash.config"); r["owner"] != nil {
|
||||
t.Errorf("re-owned with no account: %v", r)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a change to a module's words changes the runtime's process", func(t *testing.T) {
|
||||
changed := dash
|
||||
changed.Bundles = append([]Bundle(nil), dash.Bundles...)
|
||||
changed.Bundles[0].Env = map[string]string{"DASH_ADMIN": "somebody-else"}
|
||||
out2, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{changed, theRuntime(t)}}.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fmt.Sprint(fileNamed(out2, RuntimeModule+"."+RuntimeProcessID())["env"]) == fmt.Sprint(env) {
|
||||
t.Error("the runtime's process is the same after a module's words changed, so it would not restart")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestABundlesWordsAreRefusedWhenTheyAreNotPathsOrConstants(t *testing.T) {
|
||||
m := Manifest{Module: "dash", Version: "1", Build: &Build{Artifacts: []Artifact{
|
||||
{Name: "tools", Kind: ArtifactBundle, Language: "typescript", Entrypoints: []string{"tools/index.js"},
|
||||
Env: map[string]string{"DASH_TOKEN": "${secret:token}", RuntimeBrokerFile: "/x", "DASH_PEER": "${bound:db:url}"}},
|
||||
{Name: "runtime", Kind: ArtifactImage, From: "Dockerfile", Env: map[string]string{"X": "y"}},
|
||||
}}}
|
||||
said := strings.Join(m.Build.problems(m.Module), "\n")
|
||||
for _, want := range []string{
|
||||
`"tools" gives DASH_TOKEN the value "${secret:token}"`,
|
||||
`"tools" gives DASH_PEER the value "${bound:db:url}"`,
|
||||
`"tools" gives itself ` + RuntimeBrokerFile,
|
||||
`"runtime" is a "image" and says what it is given`,
|
||||
} {
|
||||
if !strings.Contains(said, want) {
|
||||
t.Errorf("not refused: %s\nsaid:\n%s", want, said)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0193: the runtime is told the launcher a build wrote, and the entrypoint itself for a
|
||||
// build from before launchers — so the move needs no flag day.
|
||||
func TestTheRuntimeStartsTheLauncherWhereTheBuildWroteOne(t *testing.T) {
|
||||
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}}
|
||||
launched := Manifest{Module: "dash", Version: "1", Tools: []string{"status"},
|
||||
Build: &Build{Artifacts: []Artifact{{Name: "tools", Kind: ArtifactBundle, Language: "typescript",
|
||||
Entrypoints: []string{"tools/index.js"}}}}}
|
||||
launched, err := launched.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + "dash/tools/blobs/" + bundleDigest, Digest: bundleDigest,
|
||||
Launchers: map[string]string{"tools/index.js": "tools/index.serve.mjs"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
older := aToolsModule(t, "nftables", "tools/index.js")
|
||||
out, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{launched, older, theRuntime(t)}}.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())["env"].(map[string]string)
|
||||
want := "dash=" + BundleRoot + "/dash/tools/tools/index.serve.mjs,nftables=" + BundleRoot + "/nftables/tools/tools/index.js"
|
||||
if env[RuntimeToolModules] != want {
|
||||
t.Errorf("the runtime is told %q, want %q", env[RuntimeToolModules], want)
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0193: a runtime compiled to a binary runs itself from its own unpacked bundle.
|
||||
func TestARuntimeCompiledToABinaryRunsItself(t *testing.T) {
|
||||
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}}
|
||||
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)
|
||||
}
|
||||
out, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{aToolsModule(t, "nftables", "tools/index.js"), goRuntime}}.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
process := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())
|
||||
if fmt.Sprint(process["run"]) != "[./node-tools]" {
|
||||
t.Errorf("a Go runtime is run as %v, want its own binary", process["run"])
|
||||
}
|
||||
env := process["env"].(map[string]string)
|
||||
if env[RuntimeToolModules] == "" || process["user"] != "ops" {
|
||||
t.Errorf("the Go runtime is not told what to serve or whose it is: %v %v", env, process["user"])
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq 04-ISSUES/216: a bundle nothing loads, runs or unpacks is refused at registration; saying
|
||||
// `loads`, listing `tools`, or a resource naming it admits it.
|
||||
func TestABundleNothingDeliversIsRefused(t *testing.T) {
|
||||
base := func() Manifest {
|
||||
return Manifest{Module: "baserow", Version: "1", Build: &Build{Artifacts: []Artifact{
|
||||
{Name: "tools", Kind: ArtifactBundle, Language: "typescript", Entrypoints: []string{"tools/index.js"}}}}}
|
||||
}
|
||||
if p := undeliveredBundles(base()); len(p) != 1 || !strings.Contains(p[0], "never reach a machine") {
|
||||
t.Fatalf("a bundle nothing delivers was admitted: %v", p)
|
||||
}
|
||||
loads := base()
|
||||
loads.Build.Artifacts[0].Loads = []string{"tools/index.js"}
|
||||
tools := base()
|
||||
tools.Tools = []string{"baserow_list_rows"}
|
||||
run := base()
|
||||
run.Resources = []map[string]any{{"id": "daemon", "type": "process", "artifact": "tools", "run": []any{"node", "tools/index.js"}}}
|
||||
runtime := base()
|
||||
runtime.Module = RuntimeModule
|
||||
for name, m := range map[string]Manifest{"loads": loads, "tools": tools, "a process": run, "the runtime": runtime} {
|
||||
if p := undeliveredBundles(m); len(p) != 0 {
|
||||
t.Errorf("a bundle delivered by %s was refused: %v", name, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0193: a Go tools bundle is served — its binary is what the runtime starts, delivered
|
||||
// like any tools bundle, named to the runtime where a TypeScript bundle names its launcher.
|
||||
func TestAGoToolsBundleIsServedByItsBinary(t *testing.T) {
|
||||
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}}
|
||||
lamp := Manifest{Module: "lamp", Version: "1", Tools: []string{"on"},
|
||||
Build: &Build{Artifacts: []Artifact{{Name: "tools", Kind: ArtifactBundle, Language: "go",
|
||||
System: "arch", From: "cmd/lamp-tools"}}}}
|
||||
if p := lamp.Build.problems("lamp"); len(p) != 0 {
|
||||
t.Fatalf("a Go tools bundle was refused: %v", p)
|
||||
}
|
||||
lamp, err := lamp.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + "lamp/tools/blobs/" + bundleDigest, Digest: bundleDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fmt.Sprint(lamp.Bundles[0].Loads) != "[lamp-tools]" {
|
||||
t.Fatalf("the runtime loads %v from a Go bundle, want its binary", lamp.Bundles[0].Loads)
|
||||
}
|
||||
out, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{lamp, theRuntime(t)}}.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fileNamed(out, "lamp."+BundleID("tools")) == nil {
|
||||
t.Errorf("the Go bundle is not delivered: %v", ids(out))
|
||||
}
|
||||
env := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())["env"].(map[string]string)
|
||||
if env[RuntimeToolModules] != "lamp="+BundlePath("lamp", "tools")+"/lamp-tools" {
|
||||
t.Errorf("the runtime is told %q, want the binary", env[RuntimeToolModules])
|
||||
}
|
||||
|
||||
// An artifact may say it explicitly; naming anything but the binary is refused.
|
||||
said := Manifest{Module: "lamp", Version: "1", Build: &Build{Artifacts: []Artifact{{Name: "tools",
|
||||
Kind: ArtifactBundle, Language: "go", System: "arch", Binary: "lamp", Loads: []string{"lamp"}}}}}
|
||||
if p := said.Build.problems("lamp"); len(p) != 0 {
|
||||
t.Errorf("loads naming the binary was refused: %v", p)
|
||||
}
|
||||
said.Build.Artifacts[0].Loads = []string{"tools/index.js"}
|
||||
if p := said.Build.problems("lamp"); len(p) == 0 {
|
||||
t.Error("a Go bundle loading a file it does not contain was admitted")
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0259 §8: a module of its own account is served by a runtime of its own — the machine's
|
||||
// runtime program, as that account, on that module's own credential — and never by the machine's runtime,
|
||||
// which runs as the operator's account and is given none of its words.
|
||||
func TestAModuleOfItsOwnAccountIsServedByARuntimeOfItsOwn(t *testing.T) {
|
||||
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}, "telegram": {"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)
|
||||
}
|
||||
telegram := aToolsModule(t, "telegram", "tools/index.js")
|
||||
telegram.RunsAs, telegram.SecretsOwner = "telegram", "telegram"
|
||||
telegram.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/telegram/broker"}}
|
||||
out, err := Resolution{Node: "anchor", Account: "ops",
|
||||
Modules: []Manifest{aToolsModule(t, "nftables", "tools/index.js"), telegram, goRuntime}}.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
machine := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())
|
||||
if served := machine["env"].(map[string]string)[RuntimeToolModules]; strings.Contains(served, "telegram") || !strings.Contains(served, "nftables") {
|
||||
t.Errorf("the machine's runtime serves %q", served)
|
||||
}
|
||||
own := fileNamed(out, "telegram."+OwnRuntimeID())
|
||||
if own == nil {
|
||||
t.Fatalf("telegram has no runtime of its own: %v", ids(out))
|
||||
}
|
||||
env := own["env"].(map[string]string)
|
||||
if own["user"] != "telegram" || fmt.Sprint(own["run"]) != "[./node-tools]" ||
|
||||
env[RuntimeBrokerFile] != "/var/lib/telegram/broker" ||
|
||||
env[RuntimeToolModules] != "telegram="+BundleRoot+"/telegram/tools/tools/index.js" {
|
||||
t.Errorf("its own runtime: user %v run %v env %v", own["user"], own["run"], env)
|
||||
}
|
||||
if _, told := env[RuntimeOperatorAccount]; told {
|
||||
t.Error("a runtime of a module's own account is told the operator's account")
|
||||
}
|
||||
// Without the machine's runtime to run it from, it is refused in words.
|
||||
if _, err := (Resolution{Node: "anchor", Modules: []Manifest{telegram}}).ownRuntimes(with); err == nil {
|
||||
t.Error("a module of its own account composed without a runtime program")
|
||||
}
|
||||
}
|
||||
|
||||
// The review of 2026-10-09 (L4): a module of its own account never runs as the node's operator account, nor
|
||||
// as the account agents run as there — either would hand what it holds back to the accounts it is kept from.
|
||||
func TestAModuleOfItsOwnAccountIsRefusedTheOperatorsAndTheAgentsAccount(t *testing.T) {
|
||||
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}, "telegram": {"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)
|
||||
}
|
||||
for _, account := range []string{"ops", "agent"} {
|
||||
telegram := aToolsModule(t, "telegram", "tools/index.js")
|
||||
telegram.RunsAs, telegram.SecretsOwner = account, account
|
||||
telegram.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/telegram/broker"}}
|
||||
_, err := Resolution{Node: "anchor", Account: "ops", AgentAccount: "agent",
|
||||
Modules: []Manifest{telegram, goRuntime}}.ownRuntimes(with)
|
||||
if err == nil || !strings.Contains(err.Error(), account) {
|
||||
t.Errorf("telegram running as %s was composed: %v", account, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 723: the vault runs as its own account, so what the mesh writes for it — the export it keeps, the grant
|
||||
// files it holds for its consumers, and what its words name — belongs to that account, never the operator's.
|
||||
func TestWhatAModuleOfItsOwnAccountReadsIsItsAccounts(t *testing.T) {
|
||||
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
||||
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}, "vault": {"broker": "own"}},
|
||||
Kept: &KeptExport{Export: 1, OperatorKey: "k"},
|
||||
Grants: []Grant{{Provision: "secret", Consumer: "anchor", From: "telegram",
|
||||
Values: map[string]any{"name": "telegram"}}}}
|
||||
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)
|
||||
}
|
||||
vault := aToolsModule(t, "vault", "tools/index.js")
|
||||
vault.RunsAs, vault.SecretsOwner = "vault", "vault"
|
||||
vault.OwnSecrets = OwnSecrets{"broker": {Path: "/var/lib/vault/broker"}}
|
||||
vault.Keeps = "/var/lib/vault/root"
|
||||
vault.Grants = map[string]string{"secret": "/var/lib/vault/grants"}
|
||||
vault.Receives = map[string]string{"secret": "/var/lib/vault/grants/mesh.json"}
|
||||
vault.Bundles[0].Env = map[string]string{"MESH_RECEIVES": "/var/lib/vault/grants/mesh.json"}
|
||||
out, err2 := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{vault, goRuntime}}.Declaration(with)
|
||||
if err = err2; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := 0
|
||||
for _, r := range out {
|
||||
path := fmt.Sprint(r["path"])
|
||||
if !strings.HasPrefix(path, "/var/lib/vault/") || r["type"] != "file" {
|
||||
continue
|
||||
}
|
||||
seen++
|
||||
if r["owner"] != "vault" {
|
||||
t.Errorf("%s (%v) belongs to %v, not the vault's own account", path, r["id"], r["owner"])
|
||||
}
|
||||
}
|
||||
if seen < 4 {
|
||||
t.Errorf("only %d of the vault's files were composed: %v", seen, ids(out))
|
||||
}
|
||||
// Carried by the machine's runtime instead, nothing of it changes: the operator's account reads them.
|
||||
carried := vault
|
||||
carried.RunsAs, carried.SecretsOwner = "", ""
|
||||
out, err = Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{carried, goRuntime}}.Declaration(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range out {
|
||||
if path := fmt.Sprint(r["path"]); strings.HasPrefix(path, "/var/lib/vault/") && r["owner"] == "vault" {
|
||||
t.Errorf("%s belongs to the vault's account though it runs as none", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A scheduled container is the recurring twin of run-once (novox/hq ADR 0053). The control plane's
|
||||
// part is small and exact: carry the field to the host unchanged, and refuse a malformed schedule —
|
||||
// or the contradictory pair run-once + schedule — near its author rather than on the machine. These
|
||||
// tests defend that.
|
||||
|
||||
func TestAScheduledContainerRendersWithTheCronField(t *testing.T) {
|
||||
// The resolver carries `schedule` into the rendered host declaration untouched, exactly as it
|
||||
// carries run-once — a container's keys are copied through, so the host receives the field as
|
||||
// written.
|
||||
digest := "@sha256:" + strings.Repeat("a", 64)
|
||||
r := Resolution{Node: "laptop", Modules: []Manifest{{
|
||||
Module: "kometa",
|
||||
Resources: []map[string]any{
|
||||
{"id": "sync", "type": "container", "name": "sync",
|
||||
"image": "registry.example/runtime" + digest, "schedule": "0 3 * * *"},
|
||||
},
|
||||
}}}
|
||||
out, err := r.Declaration(Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sync := indexOfID(out, "kometa.sync")
|
||||
if sync == -1 {
|
||||
t.Fatal("the scheduled container was lost in rendering")
|
||||
}
|
||||
if got, _ := out[sync]["schedule"].(string); got != "0 3 * * *" {
|
||||
t.Errorf("schedule did not reach the host declaration: %+v", out[sync])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMalformedScheduleIsRefused(t *testing.T) {
|
||||
digest := "@sha256:" + strings.Repeat("a", 64)
|
||||
bad := []byte(`{"module":"m","resources":[
|
||||
{"id":"sync","type":"container","name":"sync","image":"registry.example/x` + digest + `","schedule":"every night"}
|
||||
]}`)
|
||||
if _, err := ParseManifest(bad); err == nil {
|
||||
t.Error("a malformed schedule was accepted")
|
||||
} else if !strings.Contains(err.Error(), "cron") {
|
||||
t.Errorf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
|
||||
// A schedule out of range is refused for the same reason, near its author.
|
||||
outOfRange := []byte(`{"module":"m","resources":[
|
||||
{"id":"sync","type":"container","name":"sync","image":"registry.example/x` + digest + `","schedule":"0 25 * * *"}
|
||||
]}`)
|
||||
if _, err := ParseManifest(outOfRange); err == nil {
|
||||
t.Error("a schedule with an hour out of range was accepted")
|
||||
}
|
||||
|
||||
good := []byte(`{"module":"m","resources":[
|
||||
{"id":"sync","type":"container","name":"sync","image":"registry.example/x` + digest + `","schedule":"*/15 * * * *"}
|
||||
]}`)
|
||||
if _, err := ParseManifest(good); err != nil {
|
||||
t.Errorf("a valid scheduled container was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAScheduleThatIsNotAStringIsRefused(t *testing.T) {
|
||||
digest := "@sha256:" + strings.Repeat("a", 64)
|
||||
bad := []byte(`{"module":"m","resources":[
|
||||
{"id":"sync","type":"container","name":"sync","image":"registry.example/x` + digest + `","schedule":true}
|
||||
]}`)
|
||||
if _, err := ParseManifest(bad); err == nil {
|
||||
t.Error("a schedule that is not a string was accepted")
|
||||
} else if !strings.Contains(err.Error(), "schedule") {
|
||||
t.Errorf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAContainerCannotBeBothRunOnceAndScheduled(t *testing.T) {
|
||||
// A container runs once and gates, or on a cadence, or stays up — never two (novox/hq ADR 0053).
|
||||
digest := "@sha256:" + strings.Repeat("a", 64)
|
||||
bad := []byte(`{"module":"m","resources":[
|
||||
{"id":"sync","type":"container","name":"sync","image":"registry.example/x` + digest + `","run-once":true,"schedule":"0 3 * * *"}
|
||||
]}`)
|
||||
if _, err := ParseManifest(bad); err == nil {
|
||||
t.Error("a container that was both run-once and scheduled was accepted")
|
||||
} else if !strings.Contains(err.Error(), "both run-once and schedule") {
|
||||
t.Errorf("refused for the wrong reason: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import "testing"
|
||||
|
||||
// A mesh-wide provision whose seat is held on another machine is answered by that holder, even on a
|
||||
// machine that runs a provider of its own (novox/hq issue 258). Every machine ran its own resolver
|
||||
// while the mesh moved to one; each bound its resolver configuration to itself, with the mesh's
|
||||
// resolver held elsewhere and pinned.
|
||||
func resolverMesh() map[string]Manifest {
|
||||
return map[string]Manifest{
|
||||
"resolver": {Module: "resolver", Version: "1",
|
||||
Provides: []Offer{{Name: "wildcard-resolution", Scope: ScopeMesh}},
|
||||
Claims: []Claim{{Name: "mesh-dns-resolver", Scope: ScopeMesh}}},
|
||||
"asker": {Module: "asker", Version: "1", Requires: []string{"wildcard-resolution"}},
|
||||
}
|
||||
}
|
||||
|
||||
func resolverWorld(held string, pin *Chosen) World {
|
||||
w := World{
|
||||
Offered: map[string][]Provider{"wildcard-resolution": {
|
||||
{Node: "anchor", At: "anchor.internal", Module: "resolver"},
|
||||
{Node: "laptop", At: "laptop.internal", Module: "resolver"},
|
||||
}},
|
||||
}
|
||||
// Held is who holds it across the mesh; Holdings is the same holder on record, which lets this
|
||||
// machine's own claimant stand beside it (ADR 0131).
|
||||
w.Held = []Held{{Claim: "mesh-dns-resolver", Scope: ScopeMesh, Node: held, Module: "resolver"}}
|
||||
w.Holdings = w.Held
|
||||
if pin != nil {
|
||||
w.Pinned = map[string]Chosen{"wildcard-resolution": *pin}
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func answeredFrom(t *testing.T, world World) string {
|
||||
t.Helper()
|
||||
laptop := Node{Name: "laptop", At: "laptop.internal"}
|
||||
got, err := Resolve(resolverMesh(), []string{"resolver", "asker"}, laptop, world)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, n := range got.Needs {
|
||||
if n.Name == "wildcard-resolution" {
|
||||
return n.From
|
||||
}
|
||||
}
|
||||
t.Fatalf("no binding for wildcard-resolution: %+v", got.Needs)
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestTheSeatsHolderElsewhereAnswersBeforeThisMachinesOwnProvider(t *testing.T) {
|
||||
if from := answeredFrom(t, resolverWorld("anchor", nil)); from != "anchor" {
|
||||
t.Fatalf("bound to %s; the seat is held on anchor", from)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPinElsewhereAnswersBeforeThisMachinesOwnProvider(t *testing.T) {
|
||||
if from := answeredFrom(t, resolverWorld("laptop", &Chosen{Node: "anchor", Module: "resolver"})); from != "anchor" {
|
||||
t.Fatalf("bound to %s; this machine was pinned to anchor", from)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheHolderOnThisMachineStillAnswersHere(t *testing.T) {
|
||||
if from := answeredFrom(t, resolverWorld("laptop", nil)); from != "laptop" {
|
||||
t.Fatalf("bound to %s; the seat is held here", from)
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0212: a seat says what it receives, its holder places it, and a contribution
|
||||
// depends on the seat.
|
||||
|
||||
func hotkeysHolder() Manifest {
|
||||
return Manifest{Module: "triggerhappy", Claims: []Claim{{Name: HotkeysSeat}}, Resources: []map[string]any{
|
||||
{"id": "triggers", "type": "file", "path": "/etc/triggerhappy/triggers.d/mesh.conf",
|
||||
"content": "# the mesh's triggers\n${contribution:node-hotkeys:trigger}"},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestAContributionReachesTheHoldersFileInModuleOrderNamedByModule(t *testing.T) {
|
||||
laptop := Manifest{Module: "laptop", Contributions: []SeatContribution{
|
||||
{Seat: HotkeysSeat, Kind: "trigger", Content: "KEY_PROG1 1 play ${machine:account-home}"},
|
||||
{Seat: HotkeysSeat, Kind: "trigger", Content: "KEY_F21 1 touchpad\n"},
|
||||
}}
|
||||
another := Manifest{Module: "another", Contributions: []SeatContribution{
|
||||
{Seat: HotkeysSeat, Kind: "trigger", Content: "KEY_MUTE 1 mute ${shell:zsh:first}"},
|
||||
}}
|
||||
unrelated := Manifest{Module: "bar", Contributions: []SeatContribution{
|
||||
{Seat: DisplaySessionSeat, Kind: "config", Content: "bar { }"},
|
||||
}}
|
||||
r := Resolution{Node: "laptop", Account: "op", Modules: []Manifest{hotkeysHolder(), laptop, another, unrelated}}
|
||||
out, err := r.Declaration(Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var file map[string]any
|
||||
for _, res := range out {
|
||||
if res["id"] == "triggerhappy.triggers" {
|
||||
file = res
|
||||
}
|
||||
}
|
||||
if file == nil {
|
||||
t.Fatalf("the holder's file was not composed: %v", out)
|
||||
}
|
||||
// Module order; each module named once; text never read, so neither ${machine:…} nor ${shell:…}
|
||||
// inside a contribution is filled.
|
||||
want := "# the mesh's triggers\n" +
|
||||
"# another\nKEY_MUTE 1 mute ${shell:zsh:first}\n" +
|
||||
"# laptop\nKEY_PROG1 1 play ${machine:account-home}\nKEY_F21 1 touchpad\n"
|
||||
if got := file["content"]; got != want {
|
||||
t.Fatalf("the holder's file is\n%s\nnot\n%s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoContributionPlacesNothing(t *testing.T) {
|
||||
r := Resolution{Node: "laptop", Account: "op", Modules: []Manifest{hotkeysHolder()}}
|
||||
out, err := r.Declaration(Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, res := range out {
|
||||
if res["id"] == "triggerhappy.triggers" && res["content"] != "# the mesh's triggers\n" {
|
||||
t.Fatalf("an empty kind left %q", res["content"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAContributionToASeatThatDoesNotReceiveItIsRefused(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
`{"seat":"node-hotkeys","kind":"config","content":"x"}`: "it receives trigger",
|
||||
`{"seat":"node-nothing","kind":"trigger","content":"x"}`: "the mesh does not define",
|
||||
`{"seat":"node-hotkeys","kind":"trigger","content":" "}`: "has no content",
|
||||
`{"seat":"node-display-session","kind":"trigger","content":"x"}`: "it receives config",
|
||||
}
|
||||
for c, want := range cases {
|
||||
raw := `{"module":"laptop","contributions":[` + c + `]}`
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%s: accepted, or refused without %q: %v", c, want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAContributionPlaceholderOutsideTheHolderIsRefused(t *testing.T) {
|
||||
raw := `{"module":"laptop","resources":[{"id":"t","type":"file","path":"/etc/t","content":"${contribution:node-hotkeys:trigger}"}]}`
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "laptop does not claim node-hotkeys") {
|
||||
t.Errorf("a placeholder outside the holder was accepted: %v", err)
|
||||
}
|
||||
raw = `{"module":"triggerhappy","claims":[{"name":"node-hotkeys"}],"resources":[{"id":"t","type":"file","path":"/etc/t","content":"${contribution:node-hotkeys:keys}"}]}`
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "it receives trigger") {
|
||||
t.Errorf("a placeholder for a kind the seat does not receive was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAContributionDependsOnItsSeat(t *testing.T) {
|
||||
m := Manifest{Module: "laptop", Contributions: []SeatContribution{
|
||||
{Seat: HotkeysSeat, Kind: "trigger", Content: "x"},
|
||||
{Seat: DisplaySessionSeat, Kind: "config", Content: "y"},
|
||||
}}
|
||||
if got, want := DependsOn(nil, m), []string{DisplaySessionSeat, HotkeysSeat}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("depends on %v, want %v", got, want)
|
||||
}
|
||||
catalogue := map[string]Manifest{"laptop": m, "triggerhappy": hotkeysHolder()}
|
||||
if _, err := AssignRefusal(catalogue, "laptop", nil, []string{"laptop"}); err == nil ||
|
||||
!strings.Contains(err.Error(), HotkeysSeat) {
|
||||
t.Errorf("a contributor without the holder was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheHotkeysSeatIsTheMeshsAndReceivesTriggers(t *testing.T) {
|
||||
s, ok := SeatNamed(HotkeysSeat)
|
||||
if !ok || s.Scope != ScopeNode || len(s.Receives) != 1 || s.Receives[0].Kind != "trigger" {
|
||||
t.Fatalf("%+v %v", s, ok)
|
||||
}
|
||||
d, _ := SeatNamed(DisplaySessionSeat)
|
||||
if len(d.Receives) != 1 || d.Receives[0].Kind != "config" {
|
||||
t.Fatalf("the display session receives %+v", d.Receives)
|
||||
}
|
||||
}
|
||||
@@ -1,426 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0255: a seat may receive a kind as data in its own shape, which the holder
|
||||
// renders with its template; the holder places every combination of the shape's placing fields once;
|
||||
// a contribution may be placed only where the machine reports a capability; and an offered kind makes
|
||||
// no dependency on its seat.
|
||||
|
||||
const barTemplate = `{{if eq .shows "battery"}}[[block]]
|
||||
block = "battery"
|
||||
{{with .options}}{{with .device}}device = {{quote .}}
|
||||
{{end}}{{end}}{{else if eq .shows "command"}}[[block]]
|
||||
block = "custom"
|
||||
command = {{quote .options.command}}
|
||||
interval = {{.options.interval}}
|
||||
{{else if eq .shows "state"}}[[block]]
|
||||
block = "custom"
|
||||
command = {{quote (printf "show %s %s" .options.state (or .options.key .machine))}}
|
||||
{{end}}`
|
||||
|
||||
func barHolder() Manifest {
|
||||
place := func(bar string) string {
|
||||
return "own-first\n${contribution:node-bar:block:bar=" + bar + ",place=resources}" +
|
||||
"own-middle\n${contribution:node-bar:block:bar=" + bar + ",place=status}own-last\n"
|
||||
}
|
||||
return Manifest{Module: "a-bar", Claims: []Claim{{Name: BarSeat, Renders: map[string]string{BarKindBlock: barTemplate}}},
|
||||
Resources: []map[string]any{
|
||||
{"id": "bottom", "type": "file", "path": "/b", "content": place("bottom")},
|
||||
{"id": "top", "type": "file", "path": "/t", "content": place("top")},
|
||||
}}
|
||||
}
|
||||
|
||||
func block(data string) SeatContribution {
|
||||
var d map[string]any
|
||||
if err := json.Unmarshal([]byte(data), &d); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return SeatContribution{Seat: BarSeat, Kind: BarKindBlock, Data: d}
|
||||
}
|
||||
|
||||
func composedFile(t *testing.T, r Resolution, id string) string {
|
||||
t.Helper()
|
||||
out, err := r.Declaration(Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, res := range out {
|
||||
if res["id"] == id {
|
||||
return res["content"].(string)
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s was not composed", id)
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestABlockIsRenderedByTheHolderInItsPlaceAndOrderNamedByModule(t *testing.T) {
|
||||
power := Manifest{Module: "power", Contributions: []SeatContribution{
|
||||
block(`{"bar":"bottom","place":"status","shows":"battery"}`),
|
||||
block(`{"bar":"bottom","place":"status","order":20,"shows":"command","options":{"command":"draw \"now\"","interval":5}}`),
|
||||
}}
|
||||
other := Manifest{Module: "another", Contributions: []SeatContribution{
|
||||
block(`{"bar":"bottom","place":"status","order":70,"shows":"battery","options":{"device":"BAT1"}}`),
|
||||
block(`{"bar":"top","place":"resources","shows":"command","options":{"command":"up","interval":60}}`),
|
||||
}}
|
||||
r := Resolution{Node: "laptop", Account: "op", Modules: []Manifest{barHolder(), power, other}}
|
||||
|
||||
// Ordered by order (20, 50, 70), then module order; each piece under its module's name; the
|
||||
// command quoted so its own quotes cannot end the line.
|
||||
want := "own-first\nown-middle\n" +
|
||||
"# power\n[[block]]\nblock = \"custom\"\ncommand = \"draw \\\"now\\\"\"\ninterval = 5\n" +
|
||||
"# power\n[[block]]\nblock = \"battery\"\n" +
|
||||
"# another\n[[block]]\nblock = \"battery\"\ndevice = \"BAT1\"\n" +
|
||||
"own-last\n"
|
||||
if got := composedFile(t, r, "a-bar.bottom"); got != want {
|
||||
t.Fatalf("the bottom bar is\n%s\nnot\n%s", got, want)
|
||||
}
|
||||
want = "own-first\n# another\n[[block]]\nblock = \"custom\"\ncommand = \"up\"\ninterval = 60\nown-middle\nown-last\n"
|
||||
if got := composedFile(t, r, "a-bar.top"); got != want {
|
||||
t.Fatalf("the top bar is\n%s\nnot\n%s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAContributionIfACapabilityIsPlacedOnlyWhereTheMachineReportsIt(t *testing.T) {
|
||||
c := block(`{"bar":"bottom","place":"status","shows":"battery"}`)
|
||||
c.IfCapability = "battery"
|
||||
power := Manifest{Module: "power", Contributions: []SeatContribution{c}}
|
||||
for _, c := range []struct {
|
||||
caps map[string]bool
|
||||
has bool
|
||||
}{{map[string]bool{"battery": true}, true}, {map[string]bool{"seat": true}, false}, {nil, false}} {
|
||||
r := Resolution{Node: "n", Account: "op", Capabilities: c.caps, Modules: []Manifest{barHolder(), power}}
|
||||
if bar := composedFile(t, r, "a-bar.bottom"); strings.Contains(bar, `block = "battery"`) != c.has {
|
||||
t.Errorf("with %v: the battery placed is not %v:\n%s", c.caps, c.has, bar)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIfACapabilityNamesOneTheNodeEngineDetectsOnAnOfferedKindOnly(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
// A name nothing detects would leave the piece out on every machine.
|
||||
`{"seat":"node-bar","kind":"block","if-capability":"batery","data":{"bar":"top","place":"status","shows":"battery"}}`: "which no machine reports",
|
||||
// A kind the holder depends on is never left out for want of something.
|
||||
`{"seat":"node-hotkeys","kind":"trigger","if-capability":"battery","content":"KEY_BATTERY 1 x"}`: "only a kind the seat offers",
|
||||
`{"seat":"node-backup","kind":"backup","if-capability":"seat","content":"path /x"}`: "only a kind the seat offers",
|
||||
}
|
||||
for c, want := range cases {
|
||||
raw := `{"module":"power","contributions":[` + c + `]}`
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%s: accepted, or refused without %q: %v", c, want, err)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"battery", "power-meter", "seat"} {
|
||||
if !oneOf(KnownCapabilities, name) {
|
||||
t.Errorf("%s is not a known capability", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveCarriesTheMachinesCapabilities(t *testing.T) {
|
||||
node := workstation()
|
||||
node.Capabilities["battery"] = true
|
||||
r, err := Resolve(shelf(Manifest{Module: "a"}), []string{"a"}, node, World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !r.Capabilities["battery"] || !r.Capabilities["seat"] {
|
||||
t.Fatalf("the resolution carries %v, not the node's %v", r.Capabilities, node.Capabilities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPieceTheHolderCannotRenderIsLeftOutAndNamedNotTheMachine(t *testing.T) {
|
||||
// A shows the shape gained after the holder's template was written: built here, past the check.
|
||||
power := Manifest{Module: "power", Contributions: []SeatContribution{
|
||||
block(`{"bar":"bottom","place":"status","shows":"gpu"}`),
|
||||
block(`{"bar":"bottom","place":"status","shows":"battery"}`),
|
||||
}}
|
||||
r := Resolution{Node: "n", Account: "op", Modules: []Manifest{barHolder(), power}}
|
||||
c, err := r.Compose(Rendering{})
|
||||
if err != nil {
|
||||
t.Fatalf("one piece failed the whole machine: %v", err)
|
||||
}
|
||||
if len(c.Unplaced) != 1 || !strings.Contains(c.Unplaced[0], "power's node-bar:block") ||
|
||||
!strings.Contains(c.Unplaced[0], "shows=gpu") || !strings.Contains(c.Unplaced[0], "renders nothing") {
|
||||
t.Fatalf("unplaced: %v", c.Unplaced)
|
||||
}
|
||||
for _, res := range c.Resources {
|
||||
if res["id"] == "a-bar.bottom" && !strings.Contains(res["content"].(string), `block = "battery"`) {
|
||||
t.Fatalf("the piece beside it was lost too: %s", res["content"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A moved bar is rendered by the provisioner's client, not by the controller, so the rule that its template renders
|
||||
// every example of the shape is no longer judged here (issue 915, which also covers placementProblems not reading the
|
||||
// collected form). What the controller still owns is that the bar's files place the blocks at all: a moved bar's
|
||||
// carry the collected placement, and one that stopped placing them fails here rather than being skipped (issue 432).
|
||||
func TestTheCataloguesBarPlacesBlocksAndItsShapeCoversEveryShow(t *testing.T) {
|
||||
holder := catalogueManifest(t, "i3status-rust")
|
||||
s, _ := SeatNamed(BarSeat)
|
||||
if holder.Moved {
|
||||
placed := "${collected:contribution:" + BarSeat + ":" + BarKindBlock + ":"
|
||||
found := false
|
||||
for _, res := range holder.Resources {
|
||||
if content, _ := res["content"].(string); strings.Contains(content, placed) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("the catalogue's moved bar places no blocks: none of its files carries %s…}", placed)
|
||||
}
|
||||
} else {
|
||||
if !placesKind(holder, s, BarKindBlock) {
|
||||
// It places them since the catalogue's bar took the seat; a skip here hid a bar that stopped
|
||||
// (novox/hq issue 432).
|
||||
t.Fatal("the catalogue's bar places no blocks, so none of the shape's examples would render")
|
||||
}
|
||||
tmpl, err := holderTemplate(holder, s, BarKindBlock)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, ex := range s.Receives[0].Shape.Examples {
|
||||
out, err := renderPiece(tmpl, "example", "a-machine", ex)
|
||||
if err != nil || !strings.Contains(out, "[[block]]") {
|
||||
t.Errorf("the catalogue's bar renders %v as %q: %v", ex, out, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
for shows := range barShows {
|
||||
covered := false
|
||||
for _, ex := range s.Receives[0].Shape.Examples {
|
||||
covered = covered || ex["shows"] == shows
|
||||
}
|
||||
if !covered {
|
||||
t.Errorf("the shape has no example that shows %s, so no holder is made to render it", shows)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuoteIsATomlBasicStringOfTheSameText(t *testing.T) {
|
||||
for _, in := range []string{`plain`, `say "hi" \ there`, "tab\tnew\nline", "del\x7fend", "nul\x00 esc\x1b", "<&> é ☃"} {
|
||||
q, err := quote(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := tomlBasicString(q)
|
||||
if err != nil || got != in {
|
||||
t.Errorf("quote(%q) = %s, which TOML reads as %q: %v", in, q, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tomlBasicString reads one TOML basic string, as the TOML specification (1.0, "String") defines it:
|
||||
// any character but a quote, a backslash and the control characters U+0000–U+0008, U+000A–U+001F
|
||||
// and U+007F, which are written as escapes.
|
||||
func tomlBasicString(s string) (string, error) {
|
||||
if len(s) < 2 || s[0] != '"' || s[len(s)-1] != '"' {
|
||||
return "", fmt.Errorf("not quoted")
|
||||
}
|
||||
var b strings.Builder
|
||||
rs := []rune(s[1 : len(s)-1])
|
||||
for i := 0; i < len(rs); i++ {
|
||||
r := rs[i]
|
||||
switch {
|
||||
case r == '"':
|
||||
return "", fmt.Errorf("a bare quote at %d", i)
|
||||
case r == 0x7f || (r < 0x20 && r != '\t'):
|
||||
return "", fmt.Errorf("a bare control character %U at %d", r, i)
|
||||
case r != '\\':
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
i++
|
||||
if i >= len(rs) {
|
||||
return "", fmt.Errorf("an escape at the end")
|
||||
}
|
||||
switch rs[i] {
|
||||
case 'b':
|
||||
b.WriteRune('\b')
|
||||
case 't':
|
||||
b.WriteRune('\t')
|
||||
case 'n':
|
||||
b.WriteRune('\n')
|
||||
case 'f':
|
||||
b.WriteRune('\f')
|
||||
case 'r':
|
||||
b.WriteRune('\r')
|
||||
case '"':
|
||||
b.WriteRune('"')
|
||||
case '\\':
|
||||
b.WriteRune('\\')
|
||||
case 'u', 'U':
|
||||
n := 4
|
||||
if rs[i] == 'U' {
|
||||
n = 8
|
||||
}
|
||||
if i+n > len(rs)-1 {
|
||||
return "", fmt.Errorf("a short \\u escape")
|
||||
}
|
||||
v, err := strconv.ParseUint(string(rs[i+1:i+1+n]), 16, 32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b.WriteRune(rune(v))
|
||||
i += n
|
||||
default:
|
||||
return "", fmt.Errorf("the escape \\%c is not TOML's", rs[i])
|
||||
}
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func TestABlockOutsideTheShapeIsRefused(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
`{"seat":"node-bar","kind":"block","content":"[[block]]"}`: "has no data",
|
||||
`{"seat":"node-bar","kind":"block","content":"x","data":{"bar":"top","place":"status","shows":"battery"}}`: "has content",
|
||||
`{"seat":"node-hotkeys","kind":"trigger","data":{"a":"b"}}`: "receives trigger as text",
|
||||
`{"seat":"node-bar","kind":"block","data":{"bar":"left","place":"status","shows":"battery"}}`: `bar "left"`,
|
||||
`{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"middle","shows":"battery"}}`: `place "middle"`,
|
||||
`{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"gpu"}}`: "shows one of battery, command",
|
||||
`{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"battery","colour":"red"}}`: `no field "colour"`,
|
||||
`{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"battery","order":100}}`: "whole number from 0 to 99",
|
||||
`{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"command"}}`: "needs the option command",
|
||||
`{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"battery","options":{"x":"y"}}}`: `no option "x"`,
|
||||
`{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"command","options":{"command":"c","interval":0}}}`: "seconds",
|
||||
`{"seat":"node-bar","kind":"block","if-capability":"Has Battery","data":{"bar":"top","place":"status","shows":"battery"}}`: "not a capability",
|
||||
}
|
||||
for c, want := range cases {
|
||||
raw := `{"module":"power","contributions":[` + c + `]}`
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%s: accepted, or refused without %q: %v", c, want, err)
|
||||
}
|
||||
}
|
||||
ok := `{"module":"power","contributions":[{"seat":"node-bar","kind":"block","if-capability":"battery",` +
|
||||
`"data":{"bar":"bottom","place":"status","order":10,"shows":"command","options":{"command":"c","interval":5}}}]}`
|
||||
if _, err := ParseManifest([]byte(ok)); err != nil {
|
||||
t.Errorf("a block in the shape was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAHolderPlacesEveryCombinationOnceWithATemplateThatRendersEveryExample(t *testing.T) {
|
||||
holder := func(template string, contents ...string) string {
|
||||
var res []string
|
||||
for i, c := range contents {
|
||||
b, _ := json.Marshal(c)
|
||||
res = append(res, `{"id":"f`+string(rune('a'+i))+`","type":"file","path":"/f`+string(rune('a'+i))+`","content":`+string(b)+`}`)
|
||||
}
|
||||
tb, _ := json.Marshal(template)
|
||||
renders := ""
|
||||
if template != "" {
|
||||
renders = `,"renders":{"block":` + string(tb) + `}`
|
||||
}
|
||||
return `{"module":"a-bar","claims":[{"name":"node-bar"` + renders + `}],"resources":[` + strings.Join(res, ",") + `]}`
|
||||
}
|
||||
all := "${contribution:node-bar:block:bar=bottom,place=resources}${contribution:node-bar:block:bar=bottom,place=status}" +
|
||||
"${contribution:node-bar:block:bar=top,place=resources}${contribution:node-bar:block:bar=top,place=status}"
|
||||
cases := map[string]string{
|
||||
holder(barTemplate, "${contribution:node-bar:block:bar=bottom}"): "never places node-bar:block for bar=top,place=resources",
|
||||
holder(barTemplate, all, "${contribution:node-bar:block:place=status}"): "for bar=bottom,place=status 2 times",
|
||||
holder("", all): "gives no template",
|
||||
holder(`{{if eq .shows "battery"}}b{{end}}`, all): "renders nothing",
|
||||
holder(`{{.nothing}}`, all): "renders nothing",
|
||||
holder(`{{if}`, all): "does not parse",
|
||||
holder(barTemplate, "${contribution:node-bar:block:bar=left}"+all): "bar is one of bottom, top",
|
||||
holder(barTemplate, "${contribution:node-bar:block:colour=red}"+all): "placed by bar, place",
|
||||
holder(barTemplate, "${contribution:node-hotkeys:trigger:bar=top}"): "does not claim node-hotkeys",
|
||||
}
|
||||
for raw, want := range cases {
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%s:\naccepted, or refused without %q: %v", raw, want, err)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{holder(barTemplate, all), holder(barTemplate, "${contribution:node-bar:block}"),
|
||||
// A holder that places no block at all is older than the kind, and is not refused for it.
|
||||
holder("", "no blocks here")} {
|
||||
if _, err := ParseManifest([]byte(raw)); err != nil {
|
||||
t.Errorf("%s: refused: %v", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnOfferedBlockMakesNoDependencyOnTheBar(t *testing.T) {
|
||||
power := Manifest{Module: "power", Contributions: []SeatContribution{
|
||||
block(`{"bar":"bottom","place":"status","shows":"battery"}`),
|
||||
{Seat: HotkeysSeat, Kind: "trigger", Content: "x"},
|
||||
}}
|
||||
for _, seat := range DependsOn(nil, power) {
|
||||
if seat == BarSeat {
|
||||
t.Fatalf("a block made the power module depend on the bar: %v", DependsOn(nil, power))
|
||||
}
|
||||
}
|
||||
// And on a machine with no bar it is placed nowhere, and nothing fails.
|
||||
r := Resolution{Node: "server", Account: "op", Capabilities: map[string]bool{"battery": true},
|
||||
Modules: []Manifest{hotkeysHolder(), power}}
|
||||
if _, err := r.Declaration(Rendering{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheBarReceivesBlocksAsData(t *testing.T) {
|
||||
s, _ := SeatNamed(BarSeat)
|
||||
if len(s.Receives) != 1 || s.Receives[0].Kind != BarKindBlock || s.Receives[0].Shape == nil || !s.Receives[0].Offered {
|
||||
t.Fatalf("node-bar receives %+v", s.Receives)
|
||||
}
|
||||
for _, ex := range s.Receives[0].Shape.Examples {
|
||||
if p := barBlockProblems(ex); len(p) > 0 {
|
||||
t.Errorf("the shape's own example %v is refused: %v", ex, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAStateBlockIsRenderedForThisMachineAndTheHolderIsGrantedToReadIt(t *testing.T) {
|
||||
power := Manifest{Module: "power", State: []StateDeclaration{{Name: "draw"}}, Contributions: []SeatContribution{
|
||||
block(`{"bar":"bottom","place":"status","shows":"state","options":{"state":"power.draw"}}`),
|
||||
}}
|
||||
r := Resolution{Node: "laptop", Account: "op", Modules: []Manifest{barHolder(), power}}
|
||||
if bar := composedFile(t, r, "a-bar.bottom"); !strings.Contains(bar, `command = "show power.draw laptop"`) {
|
||||
t.Fatalf("the block does not read this machine's key:\n%s", bar)
|
||||
}
|
||||
if got := ReadsGranted(barHolder(), []Manifest{barHolder(), power}, "laptop"); len(got) != 1 ||
|
||||
got[0] != (StateRead{Read: "power.draw", Key: "laptop"}) {
|
||||
t.Fatalf("the holder is granted %v", got)
|
||||
}
|
||||
// Only the holder, and only for what the modules on its machine offer.
|
||||
if got := ReadsGranted(power, []Manifest{barHolder(), power}, "laptop"); len(got) != 0 {
|
||||
t.Fatalf("a contributor is granted %v", got)
|
||||
}
|
||||
if got := ReadsGranted(barHolder(), []Manifest{barHolder()}, "laptop"); len(got) != 0 {
|
||||
t.Fatalf("a holder alone is granted %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleOffersItsOwnStateOnly(t *testing.T) {
|
||||
block := `{"seat":"node-bar","kind":"block","data":{"bar":"bottom","place":"status","shows":"state","options":{"state":"%s"}}}`
|
||||
cases := map[string]string{
|
||||
`{"module":"power","state":["draw"],"contributions":[` + fmt.Sprintf(block, "other.draw") + `]}`: "which power does not keep",
|
||||
`{"module":"power","contributions":[` + fmt.Sprintf(block, "power.draw") + `]}`: "which power does not keep",
|
||||
`{"module":"power","state":["draw"],"contributions":[` + fmt.Sprintf(block, "draw") + `]}`: "does not name a module",
|
||||
}
|
||||
for raw, want := range cases {
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%s: accepted, or refused without %q: %v", raw, want, err)
|
||||
}
|
||||
}
|
||||
ok := `{"module":"power","state":["draw"],"contributions":[` + fmt.Sprintf(block, "power.draw") + `]}`
|
||||
if _, err := ParseManifest([]byte(ok)); err != nil {
|
||||
t.Errorf("its own state was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerMachineStateIsDeclaredAndKeptInItsLongForm(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(`{"module":"power","state":[{"name":"draw","ttl-seconds":30,"per-machine":true}]}`))
|
||||
if err != nil || len(m.State) != 1 || !m.State[0].PerMachine {
|
||||
t.Fatalf("%+v %v", m.State, err)
|
||||
}
|
||||
raw, _ := json.Marshal(StateDeclaration{Name: "draw", PerMachine: true})
|
||||
if string(raw) != `{"name":"draw","per-machine":true}` {
|
||||
t.Fatalf("%s", raw)
|
||||
}
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0207: a module depends on the node seats that apply its resources.
|
||||
|
||||
func res(kind, id string) map[string]any {
|
||||
r := map[string]any{"id": id, "type": kind}
|
||||
switch kind {
|
||||
case "service":
|
||||
r["unit"] = id + ".service"
|
||||
case "package":
|
||||
r["package"] = id
|
||||
case "container":
|
||||
r["image"] = id
|
||||
case "file":
|
||||
r["path"] = "/etc/" + id
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func withResources(m Manifest, rs ...map[string]any) Manifest {
|
||||
m.Resources = rs
|
||||
return m
|
||||
}
|
||||
|
||||
// The three holders as to-be 42 names them, each declaring what it really does: systemd's own
|
||||
// package needs the package manager, pacman's timer needs the service manager, docker's package and
|
||||
// service need both.
|
||||
func coreThree() []Manifest {
|
||||
return []Manifest{
|
||||
withResources(mod("systemd", nil, nil, nil, Claim{Name: ServiceManagerSeat}), res("package", "systemd")),
|
||||
withResources(mod("pacman", nil, nil, nil, Claim{Name: PackageManagerSeat}), res("service", "pacman-refresh")),
|
||||
withResources(mod("docker", nil, nil, nil, Claim{Name: ContainerRuntimeSeat}),
|
||||
res("package", "docker"), res("service", "docker")),
|
||||
}
|
||||
}
|
||||
|
||||
func TestADependencyIsDerivedFromEachResourceTypeThatAppliesThroughASeat(t *testing.T) {
|
||||
cases := map[string][]string{
|
||||
"service": {ServiceManagerSeat},
|
||||
"package": {PackageManagerSeat},
|
||||
"container": {ContainerRuntimeSeat},
|
||||
// The host's own acts, or the mesh's: nothing in between holds a role for them.
|
||||
"file": nil, "directory": nil, "process": nil, "archive": nil, "user": nil,
|
||||
"action": nil, "network": nil, "access": nil,
|
||||
}
|
||||
for kind, want := range cases {
|
||||
got := DependsOn(nil, withResources(mod("m", nil, nil, nil), res(kind, "x")))
|
||||
if len(got) == 0 {
|
||||
got = nil
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("a %s resource depends on %v, want %v", kind, got, want)
|
||||
}
|
||||
}
|
||||
all := DependsOn(nil, withResources(mod("m", nil, nil, nil),
|
||||
res("container", "a"), res("service", "b"), res("package", "c"), res("package", "d")))
|
||||
if want := []string{ContainerRuntimeSeat, PackageManagerSeat, ServiceManagerSeat}; !reflect.DeepEqual(all, want) {
|
||||
t.Errorf("a module of every kind depends on %v, want each seat once: %v", all, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSeatsThatApplyResourcesAreTheMeshsOwnAtNodeScope(t *testing.T) {
|
||||
for _, name := range []string{ServiceManagerSeat, PackageManagerSeat, ContainerRuntimeSeat} {
|
||||
s, ok := SeatNamed(name)
|
||||
if !ok {
|
||||
t.Fatalf("%s is not in the mesh's set", name)
|
||||
}
|
||||
if s.Scope != ScopeNode {
|
||||
t.Errorf("%s is held per %s, want per node", name, s.Scope)
|
||||
}
|
||||
}
|
||||
// No verbs yet for either new seat: ADR 0207 seeds the package manager without a protocol, and
|
||||
// the runtime's verbs wait for ADR 0166's acceptance.
|
||||
for _, name := range []string{PackageManagerSeat, ContainerRuntimeSeat} {
|
||||
if s, _ := SeatNamed(name); len(s.Serves)+len(s.Accepts)+len(s.Emits) > 0 {
|
||||
t.Errorf("%s carries a protocol; ADR 0207 seeds it with none", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodeWhoseAssignmentsHoldTheSeatsResolvesWithNothingUnheld(t *testing.T) {
|
||||
web := withResources(mod("web", nil, nil, nil), res("container", "web"), res("service", "web-timer"))
|
||||
cat := shelf(append(coreThree(), web)...)
|
||||
got, err := Resolve(cat, []string{"systemd", "pacman", "docker", "web"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Unheld) != 0 {
|
||||
t.Errorf("a node holding all three seats reports %v", got.Unheld)
|
||||
}
|
||||
if _, err := AssignRefusal(cat, "workstation", []string{"systemd", "pacman", "docker"}, []string{"web"}); err != nil {
|
||||
t.Errorf("assigning beside the three holders was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAssignmentMissingAHolderIsRefusedNamingTheSeatAndItsPossibleHolders(t *testing.T) {
|
||||
web := withResources(mod("web", nil, nil, nil), res("container", "web"), res("service", "web-timer"))
|
||||
cat := shelf(append(coreThree(), web)...)
|
||||
_, err := AssignRefusal(cat, "workstation", []string{"systemd", "pacman"}, []string{"web"})
|
||||
var refusal *Refusal
|
||||
if !errors.As(err, &refusal) {
|
||||
t.Fatalf("web assigned to a node without a container runtime was not refused: %v", err)
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{"web on workstation depends on " + ContainerRuntimeSeat, "docker", "ADR 0207"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("the refusal does not say %q:\n%s", want, msg)
|
||||
}
|
||||
}
|
||||
// What the node does hold is not named as missing.
|
||||
if strings.Contains(msg, "depends on "+ServiceManagerSeat) {
|
||||
t.Errorf("the refusal names a seat systemd already holds:\n%s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADependencyNoCatalogueModuleCanMeetIsSaidNeverRefused(t *testing.T) {
|
||||
// No runtime module in the catalogue: refusing would stop every container's assignment until one
|
||||
// is written, with no remedy to name — so it is said, with the switch on as with it off.
|
||||
web := withResources(mod("web", nil, nil, nil), res("container", "web"))
|
||||
cat := shelf(web)
|
||||
for _, on := range []bool{false, true} {
|
||||
enforceSeatDependencies = on
|
||||
said, err := AssignRefusal(cat, "workstation", nil, []string{"web"})
|
||||
if err != nil {
|
||||
t.Fatalf("switch %v: a dependency nothing could meet was refused: %v", on, err)
|
||||
}
|
||||
if len(said) != 1 || !strings.Contains(said[0], "no module in the catalogue claims it yet") {
|
||||
t.Errorf("switch %v: the assignment does not say what it depends on: %v", on, said)
|
||||
}
|
||||
if _, err := Resolve(cat, []string{"web"}, workstation(), World{}); err != nil {
|
||||
t.Errorf("switch %v: a dependency nothing could meet refused the node: %v", on, err)
|
||||
}
|
||||
}
|
||||
enforceSeatDependencies = true
|
||||
}
|
||||
|
||||
func TestTheHoldersMutualDependenceResolvesWhenAssignedTogether(t *testing.T) {
|
||||
cat := shelf(coreThree()...)
|
||||
// Alone, each needs the other.
|
||||
if _, err := AssignRefusal(cat, "workstation", nil, []string{"systemd"}); err == nil ||
|
||||
!strings.Contains(err.Error(), "pacman") {
|
||||
t.Errorf("systemd alone was not refused naming pacman: %v", err)
|
||||
}
|
||||
if _, err := AssignRefusal(cat, "workstation", nil, []string{"pacman"}); err == nil ||
|
||||
!strings.Contains(err.Error(), "systemd") {
|
||||
t.Errorf("pacman alone was not refused naming systemd: %v", err)
|
||||
}
|
||||
// Together, in one act, they meet each other — and docker meets its own seat.
|
||||
if _, err := AssignRefusal(cat, "workstation", nil, []string{"systemd", "pacman", "docker"}); err != nil {
|
||||
t.Errorf("the three holders assigned together were refused: %v", err)
|
||||
}
|
||||
got, err := Resolve(cat, []string{"systemd", "pacman"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Unheld) != 0 {
|
||||
t.Errorf("systemd and pacman together report %v", got.Unheld)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusIsToldOfAnUnmetDependencyAndTheNodeStillResolves(t *testing.T) {
|
||||
// Before the switch (the state the mesh ran in until every node held the three seats).
|
||||
enforceSeatDependencies = false
|
||||
defer func() { enforceSeatDependencies = true }()
|
||||
web := withResources(mod("web", nil, nil, nil), res("container", "web"))
|
||||
cat := shelf(append(coreThree(), web)...)
|
||||
got, err := Resolve(cat, []string{"web"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatalf("an unmet dependency refused the node before the switch: %v", err)
|
||||
}
|
||||
want := []Unheld{{Node: "workstation", Module: "web", Seat: ContainerRuntimeSeat, Holders: []string{"docker"}}}
|
||||
if !reflect.DeepEqual(got.Unheld, want) {
|
||||
t.Errorf("reported %+v, want %+v", got.Unheld, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithTheSwitchFlippedAnUnmetDependencyRefusesTheNode(t *testing.T) {
|
||||
enforceSeatDependencies = true
|
||||
defer func() { enforceSeatDependencies = true }()
|
||||
web := withResources(mod("web", nil, nil, nil), res("container", "web"))
|
||||
cat := shelf(append(coreThree(), web)...)
|
||||
_, err := Resolve(cat, []string{"web"}, workstation(), World{})
|
||||
if err == nil || !strings.Contains(err.Error(), ContainerRuntimeSeat) || !strings.Contains(err.Error(), "docker") {
|
||||
t.Fatalf("with the switch on, an unmet dependency gave %v", err)
|
||||
}
|
||||
// Never in the first pass, whose refusals take a machine off the network instead.
|
||||
if _, err := Resolve(cat, []string{"web"}, workstation(), World{Unchecked: true}); err != nil {
|
||||
t.Errorf("the first pass refused an unmet dependency: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheFoundationsDeclarationsAreNotJudged(t *testing.T) {
|
||||
// The private network, as the controller computes it: its tools' package and its service are
|
||||
// the mesh's, written per node, and so are never a module's dependency.
|
||||
network := withResources(mod("mesh-wireguard", []string{"private-network"}, nil, nil),
|
||||
res("package", "wireguard-tools"), res("service", "overlay-up"))
|
||||
network.Computed = "mesh-wireguard"
|
||||
// The host, whatever it declares.
|
||||
host := withResources(mod("mesh-host", nil, nil, nil), res("file", "launcher"), res("service", "nox-mesh-host"))
|
||||
cat := shelf(append(coreThree(), network, host)...)
|
||||
|
||||
for _, m := range []Manifest{network, host} {
|
||||
if d := DependsOn(cat, m); len(d) != 0 {
|
||||
t.Errorf("%s, the foundation's, depends on %v", m.Module, d)
|
||||
}
|
||||
}
|
||||
got, err := Resolve(cat, []string{"mesh-wireguard", "mesh-host"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Unheld) != 0 {
|
||||
t.Errorf("the foundation on a node with no holders reports %v", got.Unheld)
|
||||
}
|
||||
if _, err := AssignRefusal(cat, "workstation", nil, []string{"mesh-wireguard", "mesh-host"}); err != nil {
|
||||
t.Errorf("assigning the foundation was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A module-declared seat's needs are judged exactly as a compiled seat's once were (novox/hq ADR
|
||||
// 0220, ADR 0378 §8): "mail" declares the node seat "mail-notifier" needing node-notifier, the
|
||||
// mesh's own (desktop notifications); "notifier" claims mail-notifier; "dunst" claims node-notifier,
|
||||
// as it does in the real catalogue.
|
||||
func mailNotifierAndDunst() map[string]Manifest {
|
||||
return shelf(
|
||||
Manifest{Module: "mail", DefinesSeats: []SeatDeclaration{{
|
||||
Name: "mail-notifier", Scope: ScopeNode, Needs: []string{NotifierSeat},
|
||||
}}},
|
||||
mod("notifier", nil, nil, nil, Claim{Name: "mail-notifier"}),
|
||||
mod("dunst", nil, nil, nil, Claim{Name: NotifierSeat}),
|
||||
)
|
||||
}
|
||||
|
||||
func TestAModuleDeclaredSeatsNeedsAreJudgedLikeACompiledSeatsOnceWere(t *testing.T) {
|
||||
cat := mailNotifierAndDunst()
|
||||
_, err := AssignRefusal(cat, "workstation", nil, []string{"notifier"})
|
||||
var refusal *Refusal
|
||||
if !errors.As(err, &refusal) || !strings.Contains(err.Error(), "node-notifier") {
|
||||
t.Fatalf("notifier assigned without dunst was not refused naming node-notifier: %v", err)
|
||||
}
|
||||
if _, err := AssignRefusal(cat, "workstation", []string{"dunst"}, []string{"notifier"}); err != nil {
|
||||
t.Errorf("notifier beside dunst was refused: %v", err)
|
||||
}
|
||||
if err := UnassignRefusal(cat, "workstation", []string{"dunst", "notifier"}, []string{"dunst"}); err == nil ||
|
||||
!strings.Contains(err.Error(), "node-notifier") {
|
||||
t.Errorf("taking dunst from under notifier was not refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnassigningTheLastHolderWhileDependentsRemainIsRefused(t *testing.T) {
|
||||
sshd := withResources(mod("sshd", nil, nil, nil), res("service", "sshd"))
|
||||
cat := shelf(append(coreThree(), sshd)...)
|
||||
on := []string{"systemd", "pacman", "docker", "sshd"}
|
||||
|
||||
err := UnassignRefusal(cat, "workstation", on, []string{"systemd"})
|
||||
if err == nil {
|
||||
t.Fatal("the last service manager came off a node still running services")
|
||||
}
|
||||
for _, want := range []string{ServiceManagerSeat, "sshd", "pacman", "docker"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not name %q:\n%v", want, err)
|
||||
}
|
||||
}
|
||||
// A dependent comes off freely, and the holders with everything depending on them in one act.
|
||||
if err := UnassignRefusal(cat, "workstation", on, []string{"sshd"}); err != nil {
|
||||
t.Errorf("a dependent's unassignment was refused: %v", err)
|
||||
}
|
||||
if err := UnassignRefusal(cat, "workstation", on, on); err != nil {
|
||||
t.Errorf("unassigning everything together was refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The control plane's own addresses follow the node's ports (novox/hq 04-ISSUES/102).
|
||||
|
||||
func TestASeatPlaceholderAnswersWhereThisMachinePutTheHolder(t *testing.T) {
|
||||
control := map[string]any{
|
||||
"type": "container", "id": "server", "name": "mesh-controller",
|
||||
"env": map[string]any{
|
||||
"MESH_STORE_INVENTORY_PORT": "${seat:mesh-store:5432}",
|
||||
"MESH_BROKER_ADDRESS_PORT": "${seat:mesh-broker:5671}",
|
||||
"MESH_STORE_INVENTORY_FILE": "/run/secrets/inventory",
|
||||
},
|
||||
}
|
||||
with := Rendering{Seats: map[string]map[int]int{
|
||||
"mesh-store": {5432: 6852}, "mesh-broker": {5672: 5679, 5671: 5671},
|
||||
}}
|
||||
if err := seatInto(control, "mesh-controller", with); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env := control["env"].(map[string]any)
|
||||
for key, want := range map[string]string{
|
||||
"MESH_STORE_INVENTORY_PORT": "6852",
|
||||
"MESH_BROKER_ADDRESS_PORT": "5671",
|
||||
"MESH_STORE_INVENTORY_FILE": "/run/secrets/inventory",
|
||||
} {
|
||||
if env[key] != want {
|
||||
t.Errorf("%s = %v, want %q", key, env[key], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A seat nothing on this machine holds — or one whose holder the mesh has given no port — answers
|
||||
// with nothing, so the port genesis wrote into the connection string stands. Not the software's
|
||||
// own port: on a node given a port at genesis before the store's module exists, that would
|
||||
// override the right number with the catalogue's.
|
||||
func TestASeatTheMeshCannotPlaceAnswersWithNothing(t *testing.T) {
|
||||
control := map[string]any{
|
||||
"type": "container", "id": "server", "name": "mesh-controller",
|
||||
"env": map[string]any{"MESH_STORE_INVENTORY_PORT": "${seat:mesh-store:5432}"},
|
||||
}
|
||||
if err := seatInto(control, "mesh-controller", Rendering{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := control["env"].(map[string]any)["MESH_STORE_INVENTORY_PORT"]; got != "" {
|
||||
t.Fatalf("with nothing known, the seat answered %q", got)
|
||||
}
|
||||
file := map[string]any{"type": "file", "content": "port=${seat:mesh-store:5432}\n"}
|
||||
if err := seatInto(file, "x", Rendering{Seats: map[string]map[int]int{"mesh-store": {5433: 1}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := file["content"]; got != "port=\n" {
|
||||
t.Fatalf("a port the holder does not publish answered %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASeatPlaceholderNamingNoPortIsRefused(t *testing.T) {
|
||||
file := map[string]any{"type": "file", "content": "${seat:mesh-store:99999}"}
|
||||
if err := seatInto(file, "x", Rendering{}); err == nil {
|
||||
t.Fatal("99999 was accepted as a port")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillingASeatLeavesTheManifestAlone(t *testing.T) {
|
||||
env := map[string]any{"MESH_STORE_INVENTORY_PORT": "${seat:mesh-store:5432}"}
|
||||
manifest := map[string]any{"type": "container", "id": "server", "env": env}
|
||||
for _, at := range []int{6852, 5432} {
|
||||
copied := map[string]any{}
|
||||
for k, v := range manifest {
|
||||
copied[k] = v
|
||||
}
|
||||
with := Rendering{Seats: map[string]map[int]int{"mesh-store": {5432: at}}}
|
||||
if err := seatInto(copied, "mesh-controller", with); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if env["MESH_STORE_INVENTORY_PORT"] != "${seat:mesh-store:5432}" {
|
||||
t.Fatalf("the module's own manifest was edited: %v", env)
|
||||
}
|
||||
}
|
||||
|
||||
// **The control plane's own manifest, composed through the whole path.**
|
||||
//
|
||||
// The store was given 6852 and the broker's plain port 5679 (the control-node's migration, novox/hq
|
||||
// 04-ISSUES/102). The control plane's own connections are sealed at genesis with the ports genesis
|
||||
// wrote; what its container is told beside them is where this machine put the store and the
|
||||
// broker now, read from the node's settings exactly as every consumer's binding is.
|
||||
func TestTheControlPlanesOwnAddressesFollowTheNodesPorts(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := ParseManifest(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("the control plane's own manifest does not parse:\n%v", err)
|
||||
}
|
||||
// The manifest itself names them now; withSeatPorts is a no-op on it, and this holds it so.
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] != "process" {
|
||||
continue
|
||||
}
|
||||
env, _ := r["env"].(map[string]any)
|
||||
for key, want := range SeatPorts {
|
||||
if env[key] != want {
|
||||
t.Errorf("module.json says %s=%v, not %q", key, env[key], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
m = withSeatPorts(m)
|
||||
control, err := m.Resolve([]Built{{
|
||||
Name: "controller", Kind: ArtifactBundle,
|
||||
Reference: ArtifactStoreScheme + "mesh-controller/controller@sha256:" + strings.Repeat("c", 64),
|
||||
Digest: "sha256:" + strings.Repeat("c", 64),
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{control}}
|
||||
needed := map[string]map[string]string{"mesh-controller": {}}
|
||||
for name := range m.OwnSecrets {
|
||||
needed["mesh-controller"][name] = "sealed-" + name
|
||||
}
|
||||
out, err := r.Declaration(Rendering{
|
||||
Needed: needed,
|
||||
ArtifactStore: "anchor.internal:5100",
|
||||
Seats: map[string]map[int]int{
|
||||
"mesh-store": {5432: 6852},
|
||||
"mesh-broker": {5671: 5671, 5672: 5679, 15672: 15673},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("the control plane does not compose: %v", err)
|
||||
}
|
||||
server := fileNamed(out, "mesh-controller.controller")
|
||||
if server == nil {
|
||||
t.Fatalf("the control plane's process is not in the declaration: %v", out)
|
||||
}
|
||||
env, _ := server["env"].(map[string]any)
|
||||
for key, want := range map[string]string{
|
||||
"MESH_STORE_INVENTORY_PORT": "6852",
|
||||
"MESH_STORE_IDENTITY_PORT": "6852",
|
||||
"MESH_STORE_LICENCES_PORT": "6852",
|
||||
"MESH_BROKER_MANAGEMENT_PORT": "15673",
|
||||
"MESH_BROKER_ADDRESS_PORT": "5671",
|
||||
} {
|
||||
if env[key] != want {
|
||||
t.Errorf("the control plane is told %s=%v; the node put it on %s", key, env[key], want)
|
||||
}
|
||||
}
|
||||
if got := server["source"]; got != "anchor.internal:5100/mesh-controller/controller@sha256:"+strings.Repeat("c", 64) {
|
||||
t.Errorf("the control plane's own bundle is fetched from %v, not routed through the store", got)
|
||||
}
|
||||
|
||||
// And on a mesh where the foundation is where genesis raised it, nothing is added.
|
||||
out, err = r.Declaration(Rendering{Needed: needed, ArtifactStore: "anchor.internal:5100"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env, _ = fileNamed(out, "mesh-controller.controller")["env"].(map[string]any)
|
||||
if env["MESH_STORE_INVENTORY_PORT"] != "" {
|
||||
t.Errorf("with no settings, the control plane is told %v", env)
|
||||
}
|
||||
}
|
||||
|
||||
// SeatPorts is what the control plane's manifest says beside each sealed connection: the port
|
||||
// this machine put the seat's holder at (novox/hq 04-ISSUES/102).
|
||||
var SeatPorts = map[string]string{
|
||||
"MESH_STORE_INVENTORY_PORT": "${seat:mesh-store:5432}",
|
||||
"MESH_STORE_IDENTITY_PORT": "${seat:mesh-store:5432}",
|
||||
"MESH_STORE_LICENCES_PORT": "${seat:mesh-store:5432}",
|
||||
"MESH_BROKER_MANAGEMENT_PORT": "${seat:mesh-broker:15672}",
|
||||
"MESH_BROKER_ADDRESS_PORT": "${seat:mesh-broker:5671}",
|
||||
}
|
||||
|
||||
// withSeatPorts is the control plane's manifest with SeatPorts in its container's environment.
|
||||
//
|
||||
// **The manifest lands one commit after the code that fills it**, deliberately: a control plane
|
||||
// still running the previous build passes `${seat:…}` through unfilled, and the manifest may only
|
||||
// name the placeholder once every control plane that could compose it knows it. So the test does
|
||||
// not depend on module.json carrying these yet, and is a no-op once it does.
|
||||
func withSeatPorts(m Manifest) Manifest {
|
||||
out := m
|
||||
out.Resources = nil
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] != "container" && r["type"] != "process" {
|
||||
out.Resources = append(out.Resources, r)
|
||||
continue
|
||||
}
|
||||
copied := map[string]any{}
|
||||
for k, v := range r {
|
||||
copied[k] = v
|
||||
}
|
||||
env := map[string]any{}
|
||||
if had, ok := r["env"].(map[string]any); ok {
|
||||
for k, v := range had {
|
||||
env[k] = v
|
||||
}
|
||||
}
|
||||
for k, v := range SeatPorts {
|
||||
if _, said := env[k]; !said {
|
||||
env[k] = v
|
||||
}
|
||||
}
|
||||
copied["env"] = env
|
||||
out.Resources = append(out.Resources, copied)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Where this machine reaches a mesh seat's holder (novox/hq ADR 0222, issue 190): the container
|
||||
// runtime's module tells the runtime to trust the mesh's registry without binding the store.
|
||||
|
||||
func runtimeTrust() map[string]any {
|
||||
return map[string]any{
|
||||
"type": "file", "id": "daemon", "path": "/etc/docker/daemon.json", "into": "json",
|
||||
"content": `{"live-restore": true, "insecure-registries": ["${seat:mesh-artifact-store:reach}"]}` + "\n",
|
||||
}
|
||||
}
|
||||
|
||||
func TestASeatsReachIsWhereThisMachineReachesItsHolder(t *testing.T) {
|
||||
file := runtimeTrust()
|
||||
with := Rendering{SeatReach: map[string]string{"mesh-artifact-store": "anchor.internal:5100"}}
|
||||
if err := seatInto(file, "docker", with); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := `{"live-restore": true, "insecure-registries": ["anchor.internal:5100"]}` + "\n"
|
||||
if file["content"] != want {
|
||||
t.Fatalf("content = %q, want %q", file["content"], want)
|
||||
}
|
||||
|
||||
process := map[string]any{"type": "process", "name": "p",
|
||||
"env": map[string]any{"REGISTRY": "${seat:mesh-artifact-store:reach}"}}
|
||||
if err := seatInto(process, "x", with); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := process["env"].(map[string]any)["REGISTRY"]; got != "anchor.internal:5100" {
|
||||
t.Fatalf("an environment value was filled with %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// With no holder reachable, the runtime is not told to trust "": the member is dropped, and the
|
||||
// list with it when nothing else is in it.
|
||||
func TestAnUnansweredReachAddsNothingToAList(t *testing.T) {
|
||||
file := runtimeTrust()
|
||||
if err := seatInto(file, "docker", Rendering{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := file["content"]; got != `{"live-restore":true}`+"\n" {
|
||||
t.Fatalf("with no store reachable, the runtime's keys are %q", got)
|
||||
}
|
||||
|
||||
kept := map[string]any{"type": "file", "into": "json",
|
||||
"content": `{"insecure-registries": ["${seat:mesh-artifact-store:reach}", "192.0.2.7:5000"]}`}
|
||||
if err := seatInto(kept, "docker", Rendering{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := kept["content"]; got != `{"insecure-registries":["192.0.2.7:5000"]}`+"\n" {
|
||||
t.Fatalf("a list's other members were not kept: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAReachTheMeshDoesNotAnswerIsRefused(t *testing.T) {
|
||||
file := map[string]any{"type": "file", "content": "${seat:mesh-store:reach}"}
|
||||
err := seatInto(file, "x", Rendering{SeatReach: map[string]string{"mesh-store": "anchor.internal:5432"}})
|
||||
if err == nil || !strings.Contains(err.Error(), "mesh-artifact-store") {
|
||||
t.Fatalf("a reach the mesh does not answer was not refused by name: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Through the whole composition, as the container runtime's module declares it.
|
||||
func TestTheRuntimesTrustIsComposedFromTheSeatsReach(t *testing.T) {
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{{
|
||||
Module: "docker", Resources: []map[string]any{runtimeTrust()},
|
||||
}}}
|
||||
out, err := r.Declaration(Rendering{
|
||||
SeatReach: map[string]string{"mesh-artifact-store": "anchor.internal:5100"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := fileNamed(out, "docker.daemon")
|
||||
if file == nil {
|
||||
t.Fatalf("no file in %v", out)
|
||||
}
|
||||
if got := file["content"]; !strings.Contains(fmt.Sprint(got), `["anchor.internal:5100"]`) {
|
||||
t.Fatalf("the runtime's file says %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// **A seat held once whose holder on record is assigned nowhere passes to its only claimant** (novox/hq ADR
|
||||
// 0449 §4, issue 1317).
|
||||
|
||||
func onRecord(node, module string) []Held {
|
||||
return []Held{{Claim: "mesh-broker", Scope: ScopeMesh, Node: node, Module: module}}
|
||||
}
|
||||
|
||||
// 1317/AC1
|
||||
func TestASeatWhoseHolderIsAssignedNowhereIsPassedToItsOnlyClaimant(t *testing.T) {
|
||||
busSeatDelivering(t, "mesh-bus")
|
||||
passes, said := SeatsToPassOn(onRecord("novox", "old-broker"),
|
||||
[]Assignment{{Node: "novox", Manifest: newBroker()}})
|
||||
if len(said) != 0 || len(passes) != 1 {
|
||||
t.Fatalf("the only claimant was not given the seat: passes=%v said=%v", passes, said)
|
||||
}
|
||||
p := passes[0]
|
||||
if p.Seat.Name != "mesh-broker" || p.From.Module != "old-broker" || p.From.Node != "novox" ||
|
||||
p.To.Module != "new-broker" || p.To.Node != "novox" {
|
||||
t.Fatalf("the pass does not name the seat, the former holder and the new one: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// 1317/AC2
|
||||
func TestWithTwoClaimantsAndTheHolderGoneNothingIsPassedAndTheRefusalNamesBoth(t *testing.T) {
|
||||
busSeatDelivering(t, "mesh-bus")
|
||||
passes, said := SeatsToPassOn(onRecord("novox", "gone"), []Assignment{
|
||||
{Node: "novox", Manifest: newBroker()}, {Node: "ace", Manifest: oldBroker()}})
|
||||
if len(passes) != 0 || len(said) != 1 {
|
||||
t.Fatalf("two claimants: passes=%v said=%v", passes, said)
|
||||
}
|
||||
for _, want := range []string{"old-broker", "new-broker", "seat mesh-broker --to"} {
|
||||
if !strings.Contains(said[0], want) {
|
||||
t.Fatalf("the refusal does not say %q: %s", want, said[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1317/AC3
|
||||
func TestAHolderStillAssignedSomewhereChangesNothing(t *testing.T) {
|
||||
busSeatDelivering(t, "mesh-bus")
|
||||
// Assigned on another machine than the record names: still assigned somewhere.
|
||||
passes, said := SeatsToPassOn(onRecord("novox", "old-broker"), []Assignment{
|
||||
{Node: "ace", Manifest: oldBroker()}, {Node: "novox", Manifest: newBroker()}})
|
||||
if len(passes) != 0 || len(said) != 0 {
|
||||
t.Fatalf("a holder still assigned lost its seat: passes=%v said=%v", passes, said)
|
||||
}
|
||||
}
|
||||
|
||||
// 1317/AC4
|
||||
func TestWithNoClaimantTheRecordStays(t *testing.T) {
|
||||
busSeatDelivering(t, "mesh-bus")
|
||||
passes, said := SeatsToPassOn(onRecord("novox", "old-broker"), []Assignment{
|
||||
{Node: "novox", Manifest: Manifest{Module: "other"}}})
|
||||
if len(passes) != 0 || len(said) != 0 {
|
||||
t.Fatalf("with no claimant: passes=%v said=%v", passes, said)
|
||||
}
|
||||
// A claimant that cannot hold the seat (it does not provide what the seat delivers) is no claimant.
|
||||
bad := Manifest{Module: "no-bus", Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}}
|
||||
passes, said = SeatsToPassOn(onRecord("novox", "old-broker"), []Assignment{{Node: "novox", Manifest: bad}})
|
||||
if len(passes) != 0 || len(said) != 0 {
|
||||
t.Fatalf("a module that cannot hold the seat was passed it: passes=%v said=%v", passes, said)
|
||||
}
|
||||
}
|
||||
|
||||
// A replicated seat is never passed: its holders are added by an act (ADR 0223).
|
||||
func TestAReplicatedSeatIsNeverPassed(t *testing.T) {
|
||||
record := []Held{{Claim: "mesh-dns-resolver", Scope: ScopeMesh, Node: "novox", Module: "gone"}}
|
||||
resolver := Manifest{Module: "resolver", Provides: []Offer{{Name: "wildcard-resolution", Scope: ScopeMesh}},
|
||||
Claims: []Claim{{Name: "mesh-dns-resolver", Scope: ScopeMesh}}}
|
||||
passes, _ := SeatsToPassOn(record, []Assignment{{Node: "novox", Manifest: resolver}})
|
||||
if len(passes) != 0 {
|
||||
t.Fatalf("a replicated seat was passed: %v", passes)
|
||||
}
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0287 (issue 412): a seat names the paths its holder owns, a claim without them is
|
||||
// refused, and only a claimant declares a seat's path.
|
||||
|
||||
// The starting set, exactly: node-hostname names /etc/hosts and /etc/hostname, node-uplink the resolver
|
||||
// file stepping back for node-resolver, node-resolver the resolver file, and every other seat names none.
|
||||
func TestTheSeatsThatNamePathsAreTheStartingSet(t *testing.T) {
|
||||
want := map[string]string{
|
||||
"node-hostname": "/etc/hosts /etc/hostname",
|
||||
"node-uplink": "/etc/resolv.conf>node-resolver",
|
||||
ResolverSeat: "/etc/resolv.conf",
|
||||
}
|
||||
for _, s := range DefaultSeats() {
|
||||
var got []string
|
||||
for _, p := range s.Paths {
|
||||
if p.StepsBackFor != "" {
|
||||
got = append(got, p.Path+">"+p.StepsBackFor)
|
||||
} else {
|
||||
got = append(got, p.Path)
|
||||
}
|
||||
}
|
||||
if strings.Join(got, " ") != want[s.Name] {
|
||||
t.Errorf("%s names %v; ADR 0287 gives it %q", s.Name, got, want[s.Name])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The compiled set keeps the rules: node seats only, absolute and literal, one seat per path without
|
||||
// stepping back, and a stepping back that cannot cycle.
|
||||
func TestTheCompiledSeatPathsKeepTheRules(t *testing.T) {
|
||||
if p := SeatPathProblems(DefaultSeats()); len(p) != 0 {
|
||||
t.Fatalf("the compiled set breaks ADR 0287: %v", p)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
why string
|
||||
set []Seat
|
||||
want string
|
||||
}{
|
||||
{"a mesh seat naming a path", []Seat{{Name: "mesh-x", Scope: ScopeMesh, Paths: []SeatPath{{Path: "/etc/x"}}}},
|
||||
"only a node seat names paths"},
|
||||
{"a placeholder", []Seat{{Name: "node-x", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/${setting:x}"}}}},
|
||||
"placeholder"},
|
||||
{"a relative path", []Seat{{Name: "node-x", Scope: ScopeNode, Paths: []SeatPath{{Path: "etc/x"}}}},
|
||||
"not a clean absolute path"},
|
||||
{"two owners", []Seat{
|
||||
{Name: "node-a", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x"}}},
|
||||
{Name: "node-b", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x"}}}}, "no two seats name one path"},
|
||||
{"a cycle", []Seat{
|
||||
{Name: "node-a", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x", StepsBackFor: "node-b"}}},
|
||||
{Name: "node-b", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x", StepsBackFor: "node-a"}}}},
|
||||
"does not own that path itself"},
|
||||
{"stepping back for nobody", []Seat{
|
||||
{Name: "node-a", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x", StepsBackFor: "node-z"}}}},
|
||||
"not a node seat"},
|
||||
} {
|
||||
if got := strings.Join(SeatPathProblems(c.set), "; "); !strings.Contains(got, c.want) {
|
||||
t.Errorf("%s: %q does not say %q", c.why, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What the store's rows carry is never the paths: a row read back keeps the compiled ones.
|
||||
func TestTheSeatPathsSurviveTheStoresRows(t *testing.T) {
|
||||
before := seats
|
||||
t.Cleanup(func() { seats = before })
|
||||
UseSeats([]Seat{{Name: "node-hostname", Scope: ScopeNode}})
|
||||
s, _ := SeatNamed("node-hostname")
|
||||
if len(s.Paths) != 2 {
|
||||
t.Fatalf("node-hostname read back from its row names %v", s.Paths)
|
||||
}
|
||||
}
|
||||
|
||||
func hostnameSeat(t *testing.T) Seat {
|
||||
t.Helper()
|
||||
s, ok := SeatNamed("node-hostname")
|
||||
if !ok {
|
||||
t.Fatal("no node-hostname seat")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func hostnameHolder(resources ...map[string]any) Manifest {
|
||||
return Manifest{Module: "names", Version: "1", Claims: []Claim{{Name: "node-hostname", Scope: ScopeNode,
|
||||
Serves: []string{"entries", "add", "remove"}}}, Resources: resources}
|
||||
}
|
||||
|
||||
// A holder declares every path its seat names, as a resource or as a file the mesh renders for it, or
|
||||
// CanHold refuses it naming the seat and each missing path — the judgement registration and handover share.
|
||||
func TestAClaimWithoutTheSeatsPathsIsRefused(t *testing.T) {
|
||||
seat := hostnameSeat(t)
|
||||
hosts := map[string]any{"id": "hosts", "type": "file", "path": "/etc/hosts", "content": "x"}
|
||||
name := map[string]any{"id": "name", "type": "file", "path": "/etc/hostname", "content": "x"}
|
||||
|
||||
if err := CanHold(hostnameHolder(hosts, name), seat); err != nil {
|
||||
t.Errorf("a holder declaring both paths as resources was refused: %v", err)
|
||||
}
|
||||
rendered := hostnameHolder(hosts)
|
||||
rendered.Facts = map[string]RosterFile{"name": {Path: "/etc/hostname", Template: "{{.Node}}\n"}}
|
||||
if err := CanHold(rendered, seat); err != nil {
|
||||
t.Errorf("a holder declaring a path as a file the mesh renders was refused: %v", err)
|
||||
}
|
||||
err := CanHold(hostnameHolder(), seat)
|
||||
if err == nil {
|
||||
t.Fatal("a holder declaring neither path was accepted")
|
||||
}
|
||||
for _, want := range []string{"node-hostname", "/etc/hosts", "/etc/hostname", "ADR 0287"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not name %s: %v", want, err)
|
||||
}
|
||||
}
|
||||
// A file under the operator's home is placed per account: it is not the machine's path.
|
||||
home := hostnameHolder(hosts)
|
||||
home.Facts = map[string]RosterFile{"name": {Path: "/etc/hostname", Home: true, Template: "x"}}
|
||||
if err := CanHold(home, seat); err == nil {
|
||||
t.Error("a file in the operator's home counted as the machine's /etc/hostname")
|
||||
}
|
||||
// At registration, the same refusal.
|
||||
if got := strings.Join(CatalogueProblems(Shelf{"names": hostnameHolder(hosts)}), "; "); !strings.Contains(got,
|
||||
"does not declare /etc/hostname") {
|
||||
t.Errorf("registration let a holder without /etc/hostname through: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Only a claimant declares a seat's path: a module claiming no seat that names /etc/hosts and declaring
|
||||
// it is refused at registration naming the seat; the uplink's holder and the resolver's both declaring
|
||||
// the resolver file are each a claimant of a seat naming it. Reading a seat's path is access, and allowed.
|
||||
func TestOnlyAClaimantDeclaresASeatsPath(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, `stranger declares the path "/etc/hosts", which the seat node-hostname names`) {
|
||||
t.Errorf("a module declaring /etc/hosts without claiming node-hostname was not refused: %q", got)
|
||||
}
|
||||
// However the path is spelled (novox/hq issue 412 review).
|
||||
unclean := Manifest{Module: "unclean", Version: "1", Resources: []map[string]any{
|
||||
{"id": "hosts", "type": "file", "path": "/etc/../etc/hosts", "content": "10.0.0.1 x\n"}}}
|
||||
if got := strings.Join(CatalogueProblems(Shelf{"unclean": unclean}), "; "); !strings.Contains(got, "node-hostname") {
|
||||
t.Errorf("/etc/../etc/hosts from a module not claiming node-hostname was not refused: %q", got)
|
||||
}
|
||||
reader := Manifest{Module: "reader", Version: "1", Accesses: []Access{{ID: "hosts", Path: "/etc/hosts"}}}
|
||||
if got := CatalogueProblems(Shelf{"reader": reader}); len(got) != 0 {
|
||||
t.Errorf("reading /etc/hosts was refused: %v", got)
|
||||
}
|
||||
uplink := Manifest{Module: "uplink", Version: "1", Claims: []Claim{{Name: "node-uplink", Scope: ScopeNode}},
|
||||
Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile, Template: "nameserver 10.42.0.1\n"}}}
|
||||
resolver := localResolver()
|
||||
resolver.Claims[0].Serves = []string{"routes", "route", "unroute"}
|
||||
if got := CatalogueProblems(Shelf{"uplink": uplink, resolver.Module: resolver}); len(got) != 0 {
|
||||
t.Errorf("the uplink's holder and the resolver's declaring the resolver file were refused: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A module's own seat names paths under the same rules: a node seat only, literal, and none another seat
|
||||
// names — the second declaration loses. Its holder declares them, as a mesh seat's does.
|
||||
func TestAModulesOwnSeatNamesPathsUnderTheSameRules(t *testing.T) {
|
||||
meshScoped := Manifest{Module: "a", Version: "1", DefinesSeats: []SeatDeclaration{
|
||||
{Name: "a-seat", Paths: []string{"/etc/a"}}}}
|
||||
if got := strings.Join(declaredSeatProblems(meshScoped), "; "); !strings.Contains(got, "only a node seat names paths") {
|
||||
t.Errorf("a module's mesh seat naming a path was accepted: %q", got)
|
||||
}
|
||||
placeholder := Manifest{Module: "a", Version: "1", DefinesSeats: []SeatDeclaration{
|
||||
{Name: "a-seat", Scope: ScopeNode, Paths: []string{"${dir:state}/a"}}}}
|
||||
if got := strings.Join(declaredSeatProblems(placeholder), "; "); !strings.Contains(got, "placeholder") {
|
||||
t.Errorf("a module's seat naming a path with a placeholder was accepted: %q", got)
|
||||
}
|
||||
|
||||
taken := Manifest{Module: "b", Version: "1", DefinesSeats: []SeatDeclaration{
|
||||
{Name: "b-seat", Scope: ScopeNode, Paths: []string{"/etc/hosts"}}}}
|
||||
if got := strings.Join(CatalogueProblems(Shelf{"b": taken}), "; "); !strings.Contains(got,
|
||||
`b declares the seat b-seat naming the path "/etc/hosts", which node-hostname already names`) {
|
||||
t.Errorf("a module's seat naming /etc/hosts was accepted: %q", got)
|
||||
}
|
||||
|
||||
own := Manifest{Module: "c", Version: "1",
|
||||
DefinesSeats: []SeatDeclaration{{Name: "c-seat", Scope: ScopeNode, Paths: []string{"/etc/c.conf"}}},
|
||||
Claims: []Claim{{Name: "c-seat", Scope: ScopeNode}}}
|
||||
if got := strings.Join(CatalogueProblems(Shelf{"c": own}), "; "); !strings.Contains(got,
|
||||
"c claims c-seat but does not declare /etc/c.conf") {
|
||||
t.Errorf("a holder of a module's seat without its path was accepted: %q", got)
|
||||
}
|
||||
own.Resources = []map[string]any{{"id": "conf", "type": "file", "path": "/etc/c.conf", "content": "x"}}
|
||||
if got := CatalogueProblems(Shelf{"c": own}); len(got) != 0 {
|
||||
t.Errorf("a holder declaring its own seat's path was refused: %v", got)
|
||||
}
|
||||
other := Manifest{Module: "d", Version: "1", Resources: []map[string]any{
|
||||
{"id": "conf", "type": "file", "path": "/etc/c.conf", "content": "x"}}}
|
||||
if got := strings.Join(CatalogueProblems(Shelf{"c": own, "d": other}), "; "); !strings.Contains(got,
|
||||
`d declares the path "/etc/c.conf", which the seat c-seat names`) {
|
||||
t.Errorf("a module not claiming c-seat declared its path: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
type writesHosts struct{}
|
||||
|
||||
func (writesHosts) Resources(string) ([]map[string]any, bool, error) {
|
||||
return []map[string]any{{"id": "hosts", "type": "file", "path": "/etc/hosts", "into": "block",
|
||||
"content": "10.42.0.1 anchor\n"}}, true, nil
|
||||
}
|
||||
|
||||
// The controller is bound too: what it generates for a machine on a seat's path is refused at composition.
|
||||
func TestAGeneratedResourceOnASeatsPathIsRefused(t *testing.T) {
|
||||
r := Resolution{Node: "workstation", Modules: []Manifest{
|
||||
{Module: "mesh-names", Computed: "mesh-names"},
|
||||
}}
|
||||
_, err := r.Declaration(Rendering{Generators: map[string]Generator{"mesh-names": writesHosts{}}})
|
||||
if err == nil {
|
||||
t.Fatal("a generated resource on /etc/hosts was composed")
|
||||
}
|
||||
for _, want := range []string{"mesh-names", "/etc/hosts", "node-hostname"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("the refusal does not name %s: %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Who owns a seat's path on a machine: the seat naming it and its holder there, with the stepping back
|
||||
// applied — the resolver's holder where node-resolver is held, else the uplink's.
|
||||
func TestPathOwnersApplyTheSteppingBack(t *testing.T) {
|
||||
held := []Held{
|
||||
{Claim: "node-uplink", Scope: ScopeNode, Node: "laptop", Module: "networkmanager"},
|
||||
{Claim: ResolverSeat, Scope: ScopeNode, Node: "laptop", Module: "systemd-resolved"},
|
||||
{Claim: "node-uplink", Scope: ScopeNode, Node: "server", Module: "systemd-networkd"},
|
||||
{Claim: "node-hostname", Scope: ScopeNode, Node: "server", Module: "hostname"},
|
||||
}
|
||||
var got []string
|
||||
for _, o := range PathOwners(Seats(), held) {
|
||||
got = append(got, o.Path+" "+o.Node+" "+o.Seat+" "+o.Module)
|
||||
}
|
||||
want := []string{
|
||||
"/etc/hostname server node-hostname hostname",
|
||||
"/etc/hosts server node-hostname hostname",
|
||||
"/etc/resolv.conf laptop node-resolver systemd-resolved",
|
||||
"/etc/resolv.conf server node-uplink systemd-networkd",
|
||||
}
|
||||
if strings.Join(got, "\n") != strings.Join(want, "\n") {
|
||||
t.Errorf("the owners are\n%s\nnot\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
// The stepping back comes from the seat, and is of a rendered file only: an uplink holder declaring the
|
||||
// resolver file as a resource beside a node-resolver holder is two writers of one path, refused when the
|
||||
// machine is resolved — never composed twice — as before the seat named its paths.
|
||||
func TestAnUplinkDeclaringTheResolverFileAsAResourceIsRefusedBesideTheResolver(t *testing.T) {
|
||||
shelf := resolverShelf(t)
|
||||
uplink := shelf["systemd-networkd"]
|
||||
uplink.Facts = nil
|
||||
uplink.Resources = append(append([]map[string]any(nil), uplink.Resources...),
|
||||
map[string]any{"id": "resolvers", "type": "file", "path": ResolverFile, "content": "nameserver 10.42.0.1\n"})
|
||||
shelf["systemd-networkd"] = uplink
|
||||
shelf["local-resolver"] = localResolver()
|
||||
_, err := Resolve(shelf, []string{"dnsmasq", "systemd-networkd", "local-resolver"}, splitDNSLaptop(), World{})
|
||||
if err == nil || !strings.Contains(err.Error(), `both declare the path "/etc/resolv.conf"`) {
|
||||
t.Fatalf("an uplink declaring the resolver file as a resource beside the resolver was not refused: %v", err)
|
||||
}
|
||||
// Its rendered file steps back, by the seat's definition, and nothing else of it does.
|
||||
rendered := Manifest{Module: "uplink", Version: "1", Claims: []Claim{{Name: "node-uplink"}},
|
||||
Facts: map[string]RosterFile{"resolvers": {Path: "/etc//resolv.conf", Template: "x"},
|
||||
"other": {Path: "/etc/uplink.conf", Template: "x"}}}
|
||||
got := stepsBack(rendered, []Manifest{rendered, localResolver()})
|
||||
if len(got.Facts) != 1 || got.Facts["other"].Path == "" {
|
||||
t.Errorf("the uplink's holder renders %v beside the resolver", got.Facts)
|
||||
}
|
||||
}
|
||||
|
||||
// A path is compared as the machine writes it: cleaned. Each spelling of /etc/hosts is /etc/hosts.
|
||||
func TestAnUncleanSpellingOfASeatsPathIsTheSeatsPath(t *testing.T) {
|
||||
for _, spelled := range []string{"/etc//hosts", "/etc/./hosts", "/etc/../etc/hosts", "/etc/hosts/"} {
|
||||
stranger := Manifest{Module: "stranger", Version: "1", Resources: []map[string]any{
|
||||
{"id": "hosts", "type": "file", "path": spelled, "content": "10.0.0.1 x\n"}}}
|
||||
if got := strings.Join(CatalogueProblems(Shelf{"stranger": stranger}), "; "); !strings.Contains(got,
|
||||
`stranger declares the path "/etc/hosts", which the seat node-hostname names`) {
|
||||
t.Errorf("%s from a module not claiming node-hostname was not refused: %q", spelled, got)
|
||||
}
|
||||
holder := hostnameHolder(map[string]any{"id": "hosts", "type": "file", "path": spelled, "content": "x"},
|
||||
map[string]any{"id": "name", "type": "file", "path": "/etc/hostname", "content": "x"})
|
||||
if err := CanHold(holder, hostnameSeat(t)); err != nil {
|
||||
t.Errorf("a holder declaring %s was refused: %v", spelled, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A seat names a role's own path, never the root or a top-level directory.
|
||||
func TestASeatNamesNoRootOrTopLevelDirectory(t *testing.T) {
|
||||
for _, p := range []string{"/", "/etc"} {
|
||||
m := Manifest{Module: "a", Version: "1", DefinesSeats: []SeatDeclaration{
|
||||
{Name: "a-seat", Scope: ScopeNode, Paths: []string{p}}}}
|
||||
if got := strings.Join(declaredSeatProblems(m), "; "); !strings.Contains(got, "top-level directory") {
|
||||
t.Errorf("a module's seat naming %s was accepted: %q", p, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The catalogue as it is passes: no holder of a seat naming a path is missing one, and nobody else
|
||||
// declares one.
|
||||
func TestTheCataloguePassesTheSeatPathRules(t *testing.T) {
|
||||
shelf := Shelf{}
|
||||
for _, m := range theCatalogue(t) {
|
||||
shelf[m.Module] = m
|
||||
}
|
||||
for _, p := range CatalogueProblems(shelf) {
|
||||
if strings.Contains(p, "ADR 0287") {
|
||||
t.Errorf("the catalogue breaks ADR 0287: %s", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func problemsFor(t *testing.T, shelf Shelf) string {
|
||||
t.Helper()
|
||||
return strings.Join(CatalogueProblems(shelf), "; ")
|
||||
}
|
||||
|
||||
func telegram() Manifest {
|
||||
return Manifest{Module: "telegram", Tools: []string{"status"}, DefinesSeats: []SeatDeclaration{{
|
||||
Name: "telegram-sender", Scope: ScopeMesh,
|
||||
Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}, Serves: []Verb{{Name: "status"}},
|
||||
}}, Claims: []Claim{{Name: "telegram-sender", Scope: ScopeMesh}}}
|
||||
}
|
||||
|
||||
// The whole point: a module contributes a capability without the mesh being changed.
|
||||
func TestAModuleDeclaresItsOwnSeatAndHoldsIt(t *testing.T) {
|
||||
shop := Manifest{Module: "shop", Uses: []string{"telegram-sender"}}
|
||||
if got := problemsFor(t, Shelf{"telegram": telegram(), "shop": shop}); got != "" {
|
||||
t.Fatalf("a declared seat and its caller were refused: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The prefix is the reservation rule, so there is no list to maintain and none to drift.
|
||||
func TestAModuleCannotDeclareAMeshSeat(t *testing.T) {
|
||||
for _, n := range []string{"mesh-broker", "mesh-anything", "mesh-store"} {
|
||||
m := Manifest{Module: "impostor", DefinesSeats: []SeatDeclaration{{Name: n, Accepts: []string{"x"}}}}
|
||||
got := strings.Join(declaredSeatProblems(m), "; ")
|
||||
if !strings.Contains(got, "reserved to the mesh") {
|
||||
t.Fatalf("%q was accepted as a module's seat: %q", n, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A seat name meaning two protocols is the failure nobody could diagnose afterwards.
|
||||
func TestTwoModulesCannotDeclareTheSameSeat(t *testing.T) {
|
||||
other := Manifest{Module: "aardvark", DefinesSeats: []SeatDeclaration{{
|
||||
Name: "telegram-sender", Scope: ScopeMesh, Accepts: []string{"something-else"}}}}
|
||||
got := problemsFor(t, Shelf{"telegram": telegram(), "aardvark": other})
|
||||
if !strings.Contains(got, "already declares") {
|
||||
t.Fatalf("both declarations stood: %s", got)
|
||||
}
|
||||
// The first declarer keeps it; only the second is refused.
|
||||
if strings.Count(got, "already declares") != 1 {
|
||||
t.Fatalf("expected exactly one refusal: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Where ADR 0110's guarantee lands under a derived set: a typo is refused, not resolved to
|
||||
// nothing at runtime.
|
||||
func TestUsingASeatNobodyDeclaresIsRefused(t *testing.T) {
|
||||
shop := Manifest{Module: "shop", Uses: []string{"telegram-sendr"}}
|
||||
got := problemsFor(t, Shelf{"telegram": telegram(), "shop": shop})
|
||||
if !strings.Contains(got, "telegram-sendr") || !strings.Contains(got, "no module declares") {
|
||||
t.Fatalf("a misspelled seat was accepted: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A holder that does not answer what the seat promises is a caller's timeout, found here instead.
|
||||
func TestAHolderMustServeWhatItsSeatPromises(t *testing.T) {
|
||||
m := telegram()
|
||||
m.Tools = nil // declares the seat, serves none of it
|
||||
got := problemsFor(t, Shelf{"telegram": m})
|
||||
if !strings.Contains(got, "does not serve status") {
|
||||
t.Fatalf("a holder was accepted that answers nothing its seat promises: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The claim may say what it serves for the role instead, when the module's own tools are not the
|
||||
// seat's verbs (ADR 0160).
|
||||
func TestAClaimMayNameWhatItServesForTheSeat(t *testing.T) {
|
||||
m := telegram()
|
||||
m.Tools = []string{"telegram_send"}
|
||||
m.Claims = append([]Claim(nil), m.Claims...)
|
||||
for i := range m.Claims {
|
||||
if m.Claims[i].Name == "telegram-sender" {
|
||||
m.Claims[i].Serves = []string{"status"}
|
||||
}
|
||||
}
|
||||
if got := problemsFor(t, Shelf{"telegram": m}); strings.Contains(got, "does not serve") {
|
||||
t.Fatalf("a claim naming the seat's verb was refused: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A seat with no protocol is a marker: which module is this node's showcase, or its packet filter.
|
||||
// Most node-scoped seats are markers, so refusing one would refuse the majority of the set.
|
||||
func TestASeatWithoutAProtocolIsAMarkerNotAMistake(t *testing.T) {
|
||||
m := Manifest{Module: "vague", DefinesSeats: []SeatDeclaration{{Name: "something", Scope: ScopeNode}}}
|
||||
if got := strings.Join(declaredSeatProblems(m), "; "); got != "" {
|
||||
t.Fatalf("a marker seat was refused: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A claim at the wrong scope is a different seat than the one declared.
|
||||
func TestAClaimMustMatchTheDeclaredScope(t *testing.T) {
|
||||
m := telegram()
|
||||
m.Claims = []Claim{{Name: "telegram-sender", Scope: ScopeNode}}
|
||||
got := problemsFor(t, Shelf{"telegram": m})
|
||||
if !strings.Contains(got, "scope") {
|
||||
t.Fatalf("a claim at the wrong scope was accepted: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The mesh's own seats still work, and are not shadowed by the derived half.
|
||||
func TestTheMeshsOwnSeatsAreStillClaimable(t *testing.T) {
|
||||
// It delivers the bus, so its holder provides the bus — the rule this check now enforces.
|
||||
m := Manifest{Module: "nats",
|
||||
Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh}},
|
||||
Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}}
|
||||
if got := problemsFor(t, Shelf{"nats": m}); got != "" {
|
||||
t.Fatalf("a mesh seat was refused by the derived check: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Only a node seat may carry needs (novox/hq ADR 0378 §8): a per-mesh seat's holder is not on any
|
||||
// one node to depend on anything beside it there.
|
||||
func TestOnlyANodeSeatMayCarryNeeds(t *testing.T) {
|
||||
m := Manifest{Module: "mail", DefinesSeats: []SeatDeclaration{{
|
||||
Name: "mail-notifier", Scope: ScopeMesh, Needs: []string{"node-notifier"}}}}
|
||||
got := strings.Join(declaredSeatProblems(m), "; ")
|
||||
if !strings.Contains(got, "needing") || !strings.Contains(got, "node seat") {
|
||||
t.Fatalf("a mesh-scope seat's needs were accepted: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A seat needing itself names nothing (novox/hq ADR 0378 §8).
|
||||
func TestASeatCannotNeedItself(t *testing.T) {
|
||||
m := Manifest{Module: "mail", DefinesSeats: []SeatDeclaration{{
|
||||
Name: "mail-notifier", Scope: ScopeNode, Needs: []string{"mail-notifier"}}}}
|
||||
got := strings.Join(declaredSeatProblems(m), "; ")
|
||||
if !strings.Contains(got, "needing itself") {
|
||||
t.Fatalf("a seat needing itself was accepted: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A need naming nothing node-scope — undeclared, or declared at another scope — is the whole
|
||||
// catalogue's to say (novox/hq ADR 0378 §8).
|
||||
func TestANeedMustNameANodeSeatSomewhereInTheCatalogue(t *testing.T) {
|
||||
mail := Manifest{Module: "mail", DefinesSeats: []SeatDeclaration{{
|
||||
Name: "mail-notifier", Scope: ScopeNode, Needs: []string{"desk-beeper"}}}}
|
||||
|
||||
// Nobody declares desk-beeper at all.
|
||||
got := problemsFor(t, Shelf{"mail": mail})
|
||||
if !strings.Contains(got, "desk-beeper") || !strings.Contains(got, "no module declares") {
|
||||
t.Fatalf("needing an undeclared seat was accepted: %q", got)
|
||||
}
|
||||
|
||||
// Declared, but at mesh scope: still not a node seat to hold beside it.
|
||||
notNode := Manifest{Module: "dunst", DefinesSeats: []SeatDeclaration{{Name: "desk-beeper", Scope: ScopeMesh}}}
|
||||
got = problemsFor(t, Shelf{"mail": mail, "dunst": notNode})
|
||||
if !strings.Contains(got, "desk-beeper") || !strings.Contains(got, "not a node seat") {
|
||||
t.Fatalf("needing a mesh-scope seat was accepted: %q", got)
|
||||
}
|
||||
|
||||
// Declared at node scope: accepted.
|
||||
dunst := Manifest{Module: "dunst", DefinesSeats: []SeatDeclaration{{Name: "desk-beeper", Scope: ScopeNode}}}
|
||||
if got := problemsFor(t, Shelf{"mail": mail, "dunst": dunst}); got != "" {
|
||||
t.Fatalf("needing a node seat another module declares was refused: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A refusal that reorders itself between runs is a refusal nobody can diff.
|
||||
func TestTheProblemsAreStable(t *testing.T) {
|
||||
shelf := Shelf{"telegram": telegram(), "shop": {Module: "shop", Uses: []string{"nope"}},
|
||||
"other": {Module: "other", Uses: []string{"also-nope"}}}
|
||||
first, second := problemsFor(t, shelf), problemsFor(t, shelf)
|
||||
if first != second {
|
||||
t.Fatalf("unstable:\n%s\n%s", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
// **A build machine has no store, so it may not judge a seat.** The set is data the control plane
|
||||
// owns (novox/hq ADR 0122), and `ParseManifest` runs on the build machine too, against whatever set
|
||||
// that binary was compiled with. When the two disagreed, a valid holder of the bus seat was refused
|
||||
// mid-rollout — the compiled row said the seat delivered one provision, the store's row said
|
||||
// another, and the build failed on the copy rather than the truth. The parser judges the manifest;
|
||||
// the seat set judges the claim, where it is loaded.
|
||||
func TestTheParserDoesNotJudgeWhatOnlyTheStoreKnows(t *testing.T) {
|
||||
was := Seats()
|
||||
t.Cleanup(func() { UseSeats(was) })
|
||||
|
||||
// A store whose bus seat delivers something this module does provide.
|
||||
UseSeats([]Seat{{Name: "mesh-broker", Scope: ScopeMesh, Delivers: "mesh-bus", Decision: "test"}})
|
||||
raw := []byte(`{"module":"a-bus","version":"1",` +
|
||||
`"provides":[{"name":"mesh-bus","scope":"mesh"}],` +
|
||||
`"claims":[{"name":"mesh-broker","scope":"mesh"}]}`)
|
||||
|
||||
m, err := ParseManifest(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("the parser refused a claim only the seat set can judge: %v", err)
|
||||
}
|
||||
if got := CatalogueProblems(Shelf{m.Module: m}); len(got) != 0 {
|
||||
t.Fatalf("a holder that provides what the store says the seat delivers was refused: %v", got)
|
||||
}
|
||||
|
||||
// And with the store saying the seat delivers something else, registration is what refuses it.
|
||||
UseSeats([]Seat{{Name: "mesh-broker", Scope: ScopeMesh, Delivers: "other-bus", Decision: "test"}})
|
||||
if _, err := ParseManifest(raw); err != nil {
|
||||
t.Fatalf("the parser judged it the second time: %v", err)
|
||||
}
|
||||
got := strings.Join(CatalogueProblems(Shelf{m.Module: m}), "; ")
|
||||
if !strings.Contains(got, `does not provide "other-bus"`) {
|
||||
t.Fatalf("registration did not refuse a holder that cannot answer for the seat: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,340 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/beside"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0110: a seat is a module assignment from a closed set.
|
||||
|
||||
// The set is closed, and changing it is a decision.
|
||||
//
|
||||
// **The count is asserted, and every entry names the record that made it a seat**, so the next
|
||||
// person changing the set finds the argument rather than a number to edit — the pattern the host's
|
||||
// vocabulary test follows. If this fails because a seat was added, the fix is a record in novox/hq
|
||||
// and a row in to-be 26, not a new number here.
|
||||
func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
||||
// A seat a later record extends names both, "novox/hq ADR 0166, ADR 0207": the one that defined
|
||||
// it and the one that seeded it.
|
||||
record := regexp.MustCompile(`^novox/hq ADR \d{4}(, ADR \d{4})*$`)
|
||||
seen := map[string]bool{}
|
||||
delivered := map[string]string{}
|
||||
for _, s := range Seats() {
|
||||
if seen[s.Name] {
|
||||
t.Errorf("%s is in the set twice", s.Name)
|
||||
}
|
||||
seen[s.Name] = true
|
||||
if !record.MatchString(s.Decision) {
|
||||
t.Errorf("%s names %q as its decision; every seat names the record that made it one",
|
||||
s.Name, s.Decision)
|
||||
}
|
||||
switch s.Scope {
|
||||
case ScopeNode, ScopeSite, ScopeMesh:
|
||||
default:
|
||||
t.Errorf("%s is held per %q, which is not a scope", s.Name, s.Scope)
|
||||
}
|
||||
if s.Delivers != "" {
|
||||
// Two seats answering for one provision would put the question "which one?" back,
|
||||
// which is the question a seat exists to answer.
|
||||
if other, twice := delivered[s.Delivers]; twice {
|
||||
t.Errorf("%s and %s both deliver %q", other, s.Name, s.Delivers)
|
||||
}
|
||||
delivered[s.Delivers] = s.Name
|
||||
}
|
||||
}
|
||||
// Forty-five with node-files (novox/hq ADR 0394); forty-four again once node-reads goes.
|
||||
// Forty-four with node-provisioner (novox/hq ADR 0349 clause 3); forty-three with mesh-provisioner (novox/hq ADR 0349 clause 3); forty-two with mesh-config (novox/hq ADR 0348 §9); forty-one with node-reads (novox/hq ADR 0310); forty with node-nfs-server and node-mounts (novox/hq ADR 0263); thirty-eight with node-resolver (novox/hq ADR 0247); thirty-seven with mesh-delivery (novox/hq ADR 0239); thirty-six since node-resolver-config retired
|
||||
// into node-uplink (novox/hq ADR 0223); thirty-seven
|
||||
// since the retired node-dns-resolver went (novox/hq ADR 0220); thirty-eight with
|
||||
// node-backup (novox/hq ADR 0214); thirty-seven with node-message-bus (novox/hq ADR 0215);
|
||||
// thirty-six with mesh-dns-resolver (novox/hq ADR 0194) and node-hosts-file (ADR 0199, now
|
||||
// node-hostname); thirty-four
|
||||
// with node-hotkeys (ADR 0212); thirty-three with node-power (ADR 0211); thirty-two since the
|
||||
// graphical session's eleven (ADR 0208); twenty-one with node-package-manager and
|
||||
// node-container-runtime (ADR 0207); nineteen with node-environment and node-login-shell (ADR 0203,
|
||||
// ADR 0204); seventeen with node-build-agent (ADR 0190). One fewer once the retired
|
||||
// mesh-build-machine row goes, when no registered manifest claims it.
|
||||
if len(Seats()) != 45 {
|
||||
t.Errorf("the mesh defines %d seats rather than 45; the set is closed, so a change here is "+
|
||||
"a decision (novox/hq ADR 0110): %s", len(Seats()), seatNames())
|
||||
}
|
||||
}
|
||||
|
||||
// novox/hq ADR 0117: a machine's uplink is a seat, held per machine, and delivers nothing.
|
||||
//
|
||||
// **Nothing, because nothing may be required of it.** A holder only keeps its network manager from
|
||||
// contradicting the mesh; a requirement resolving to it would make the manager the mesh's answer
|
||||
// for something, and the manager's link is the one thing the mesh must never be able to break.
|
||||
func TestTheUplinkIsANodeSeatThatDeliversNothing(t *testing.T) {
|
||||
seat, known := SeatNamed("node-uplink")
|
||||
if !known {
|
||||
t.Fatalf("the uplink is not a seat; the seats are: %s", seatNames())
|
||||
}
|
||||
if seat.Scope != ScopeNode || seat.Delivers != "" || seat.Decision != "novox/hq ADR 0117" {
|
||||
t.Fatalf("the uplink is %+v, not a node seat delivering nothing by ADR 0117", seat)
|
||||
}
|
||||
// And a manager's module can hold it without providing anything.
|
||||
raw := []byte(`{"module":"networkmanager","version":"1","claims":[{"name":"node-uplink","scope":"node"}]}`)
|
||||
if _, err := ParseManifest(raw); err != nil {
|
||||
t.Fatalf("a network manager's module could not hold the uplink: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func claimed(claims string) []byte {
|
||||
return []byte(`{"module":"thing","version":"1","provides":[{"name":"npm-package-registry","scope":"mesh"}],"claims":` + claims + `}`)
|
||||
}
|
||||
|
||||
// **The refusal moved, it did not go** (novox/hq ADR 0118, superseding 0110). A module may now
|
||||
// declare its own seats, so whether a claimed seat exists is a fact about the *catalogue* and not
|
||||
// about the manifest in front of the parser: a claim on a seat another registered module declares
|
||||
// is perfectly good, and the parser cannot tell the two cases apart. So the parser accepts it and
|
||||
// registration refuses it — the same guarantee, at the same moment work would otherwise start,
|
||||
// from a set nobody maintains by hand.
|
||||
func TestAClaimOnASeatNobodyDeclaresIsRefusedAtRegistration(t *testing.T) {
|
||||
m, err := ParseManifest(claimed(`[{"name":"the-anything","scope":"node"}]`))
|
||||
if err != nil {
|
||||
t.Fatalf("the parser judged a claim it cannot judge alone: %v", err)
|
||||
}
|
||||
|
||||
problems := CatalogueProblems(Shelf{m.Module: m})
|
||||
if len(problems) == 0 {
|
||||
t.Fatal("a module invented a seat by claiming it, and registration allowed it")
|
||||
}
|
||||
joined := strings.Join(problems, "; ")
|
||||
if !strings.Contains(joined, "the-anything") || !strings.Contains(joined, "no module declares") {
|
||||
t.Fatalf("the refusal does not say the seat is nobody's: %v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
// And the same claim is fine once something declares that seat, which is the case the parser
|
||||
// could not have distinguished.
|
||||
func TestAClaimOnASeatAnotherModuleDeclaresIsAccepted(t *testing.T) {
|
||||
claimant, err := ParseManifest(claimed(`[{"name":"the-anything","scope":"node"}]`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
declarer := Manifest{Module: "someone", DefinesSeats: []SeatDeclaration{
|
||||
{Name: "the-anything", Scope: ScopeNode, Accepts: []string{"work"}},
|
||||
}}
|
||||
if problems := CatalogueProblems(Shelf{claimant.Module: claimant, "someone": declarer}); len(problems) != 0 {
|
||||
t.Fatalf("a claim on a declared seat was refused: %v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
// A module may define its own seat and claim it — the mesh enforces exclusivity without knowing
|
||||
// what it means (novox/hq ADR 0121). But it may not define one in the mesh's own namespace.
|
||||
func TestAModuleDefinesAndClaimsItsOwnSeat(t *testing.T) {
|
||||
ok := []byte(`{"module":"showcase","version":"1","seats":[{"name":"the-showcase","scope":"node"}],` +
|
||||
`"claims":[{"name":"the-showcase","scope":"node"}]}`)
|
||||
if _, err := ParseManifest(ok); err != nil {
|
||||
t.Fatalf("a module could not define and claim its own seat: %v", err)
|
||||
}
|
||||
// Claiming a name nobody defines is still refused — but **at registration, not here**: with
|
||||
// seats declared by modules, a claim on a seat *another* module declares is good, and the
|
||||
// parser cannot tell that from an invented name. See
|
||||
// TestAClaimOnASeatNobodyDeclaresIsRefusedAtRegistration.
|
||||
claimant, err := ParseManifest(claimed(`[{"name":"the-anything","scope":"node"}]`))
|
||||
if err != nil {
|
||||
t.Fatalf("the parser judged a claim it cannot judge alone: %v", err)
|
||||
}
|
||||
if len(CatalogueProblems(Shelf{claimant.Module: claimant})) == 0 {
|
||||
t.Fatal("a module claimed a seat nobody defines")
|
||||
}
|
||||
// A module may not carve its seat out of the mesh's own namespace.
|
||||
bad := []byte(`{"module":"x","version":"1","seats":[{"name":"node-mine","scope":"node"}],` +
|
||||
`"claims":[{"name":"node-mine","scope":"node"}]}`)
|
||||
if _, err := ParseManifest(bad); err == nil || !strings.Contains(err.Error(), "own namespace") {
|
||||
t.Fatalf("a module defined a seat in the mesh's namespace and was not refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// **At registration, not in the parser** (novox/hq ADR 0122): a seat's scope is a property of the
|
||||
// set, the set is the store's, and the parser also runs on a build machine that has no store.
|
||||
func TestASeatClaimedAtAnotherScopeIsRefused(t *testing.T) {
|
||||
m, err := ParseManifest(claimed(`[{"name":"npm-package-registry","scope":"node"}]`))
|
||||
if err != nil {
|
||||
t.Fatalf("the parser judged a scope it reads from data it may not have: %v", err)
|
||||
}
|
||||
got := strings.Join(CatalogueProblems(Shelf{m.Module: m}), "; ")
|
||||
if !strings.Contains(got, "mesh seat") {
|
||||
t.Fatalf("the refusal does not say which scope the seat is: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADeliveringSeatIsOnlyHeldByAModuleThatProvides(t *testing.T) {
|
||||
// Holding it makes the module the mesh's answer for the provision. A module that cannot answer
|
||||
// would be the answer anyway, and every consumer would be sent to it.
|
||||
// And refused at registration, where the seat set is the store's: what a seat delivers is
|
||||
// data, so a compiled copy of it may not be what refuses a build (novox/hq ADR 0122).
|
||||
raw := []byte(`{"module":"thing","version":"1","claims":[{"name":"git","scope":"mesh"}]}`)
|
||||
m, err := ParseManifest(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("the parser judged what a seat delivers: %v", err)
|
||||
}
|
||||
got := strings.Join(CatalogueProblems(Shelf{m.Module: m}), "; ")
|
||||
if !strings.Contains(got, `does not provide "git"`) {
|
||||
t.Fatalf("the refusal does not say what is missing: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAClaimThatIsMalformedIsRefusedOnceForThat(t *testing.T) {
|
||||
// Not a second time for being unknown: one mistake, one line.
|
||||
_, err := ParseManifest(claimed(`[{"name":"Not A Name","scope":"node"}]`))
|
||||
if err == nil {
|
||||
t.Fatal("a malformed claim was accepted")
|
||||
}
|
||||
if strings.Contains(err.Error(), "not a seat") {
|
||||
t.Fatalf("a malformed claim was also called unknown: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Every module in use claims a seat in the set, so closing it refuses nothing that runs.
|
||||
//
|
||||
// Read from the catalogue (internal/beside) and from this repository's own manifest, the two places a
|
||||
// manifest lives (ADR 0069). The private-network module's manifest is composed in code, and its claim
|
||||
// is checked where it is composed.
|
||||
func TestEveryManifestInUseClaimsASeatTheMeshDefines(t *testing.T) {
|
||||
paths, _ := filepath.Glob(filepath.Join(beside.Catalogue(t), "*", "module.json"))
|
||||
if len(paths) == 0 {
|
||||
t.Fatal("no manifests in the catalogue, so this proved nothing")
|
||||
}
|
||||
paths = append(paths, "../../module.json")
|
||||
var checked int
|
||||
for _, path := range paths {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Leniently, so a manifest refused for something unrelated is not reported as a seat
|
||||
// problem, and the seat check below is the only thing this test holds a module to.
|
||||
var m Manifest
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatalf("%s: %v", path, err)
|
||||
}
|
||||
for _, problem := range claimProblems(m) {
|
||||
t.Errorf("%s: %s", path, problem)
|
||||
}
|
||||
checked += len(m.Claims)
|
||||
}
|
||||
if checked == 0 {
|
||||
t.Fatal("no claims were checked, so this proved nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// The holder of a seat answers among several providers.
|
||||
|
||||
func registryShelf() map[string]Manifest {
|
||||
return shelf(
|
||||
Manifest{Module: "gitea", Version: "1", Provides: FromAnywhere("npm-package-registry"),
|
||||
Claims: []Claim{{Name: "npm-package-registry", Scope: ScopeMesh}}},
|
||||
Manifest{Module: "verdaccio", Version: "1", Provides: FromAnywhere("npm-package-registry")},
|
||||
Manifest{Module: "builder", Version: "1", Requires: []string{"npm-package-registry"}},
|
||||
)
|
||||
}
|
||||
|
||||
func twoRegistries() map[string][]Provider {
|
||||
return map[string][]Provider{"npm-package-registry": {
|
||||
{Node: "anchor", At: "anchor.internal", Module: "gitea"},
|
||||
{Node: "archive", At: "archive.internal", Module: "verdaccio"},
|
||||
}}
|
||||
}
|
||||
|
||||
func giteaHoldsTheSeat() []Held {
|
||||
return []Held{{Claim: "npm-package-registry", Scope: ScopeMesh, Node: "anchor", Module: "gitea"}}
|
||||
}
|
||||
|
||||
func TestTheSeatsHolderAnswersWhenSeveralProvide(t *testing.T) {
|
||||
// The whole point: a second registry beside the holder harms nothing, and nobody pins.
|
||||
got, err := Resolve(registryShelf(), []string{"builder"}, reachable(),
|
||||
World{Offered: twoRegistries(), Held: giteaHoldsTheSeat()})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Needs) != 1 || got.Needs[0].From != "anchor" {
|
||||
t.Fatalf("the seat's holder did not answer: %v", got.Needs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPinStillWinsOverTheSeat(t *testing.T) {
|
||||
// A consumer coupled to one provider's contents has said so, and the seat does not overrule it.
|
||||
got, err := Resolve(registryShelf(), []string{"builder"}, reachable(),
|
||||
World{Offered: twoRegistries(), Held: giteaHoldsTheSeat(),
|
||||
Pinned: map[string]Chosen{"npm-package-registry": {Node: "archive", Module: "verdaccio"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Needs) != 1 || got.Needs[0].From != "archive" {
|
||||
t.Fatalf("the pin was overruled by the seat: %v", got.Needs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithTheSeatUnheldSeveralProvidersAreStillRefused(t *testing.T) {
|
||||
// No seat held is no choice made, and ADR 0009's rule stands: never guessed.
|
||||
_, err := Resolve(registryShelf(), []string{"builder"}, reachable(),
|
||||
World{Offered: twoRegistries()})
|
||||
if err == nil {
|
||||
t.Fatal("one of two registries was picked with nobody holding the seat")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "pin") {
|
||||
t.Fatalf("the refusal does not say how to choose: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheHolderIsTheModuleNotTheMachine(t *testing.T) {
|
||||
// Two modules on one machine could provide the same thing; only the one holding the seat
|
||||
// answers. A holder matched by node alone would send consumers to whichever came first.
|
||||
providers := []Provider{
|
||||
{Node: "anchor", At: "anchor.internal", Module: "verdaccio"},
|
||||
{Node: "anchor", At: "anchor.internal", Module: "gitea"},
|
||||
}
|
||||
holder, held := HolderAmong("npm-package-registry", providers, giteaHoldsTheSeat())
|
||||
if !held || holder.Module != "gitea" {
|
||||
t.Fatalf("the holder was not told apart from a neighbour: %+v", holder)
|
||||
}
|
||||
}
|
||||
|
||||
// The working set is loaded from the store, and an empty load never erases it (novox/hq ADR 0122).
|
||||
func TestUseSeatsReplacesTheSetButNeverEmptiesIt(t *testing.T) {
|
||||
before := Seats()
|
||||
defer UseSeats(DefaultSeats()) // restore for other tests, whatever this leaves it as
|
||||
|
||||
// An empty load (store not seeded, or unreadable) leaves the compiled defaults in force.
|
||||
UseSeats(nil)
|
||||
if len(Seats()) != len(before) {
|
||||
t.Fatalf("an empty load changed the set from %d to %d seats", len(before), len(Seats()))
|
||||
}
|
||||
// A non-empty load replaces it — this is how a rename in the store reaches the lookups.
|
||||
UseSeats([]Seat{{Name: "node-firewall", Scope: ScopeNode, Decision: "novox/hq ADR 0122"}})
|
||||
if _, known := SeatNamed("node-firewall"); !known {
|
||||
t.Fatal("the loaded set did not replace the working set")
|
||||
}
|
||||
if len(Seats()) != 1 {
|
||||
t.Fatalf("the working set is %d seats, not the one that was loaded", len(Seats()))
|
||||
}
|
||||
}
|
||||
|
||||
// A former name resolves to the seat it was renamed from (novox/hq ADR 0122), so a manifest's claim
|
||||
// and a held record naming the old name break nothing after a rename.
|
||||
func TestAFormerNameResolvesAfterARename(t *testing.T) {
|
||||
defer func() { UseSeats(DefaultSeats()); UseAliases(nil) }()
|
||||
UseSeats([]Seat{{Name: "git", Scope: ScopeMesh, Delivers: "git", Decision: "novox/hq ADR 0121"}})
|
||||
UseAliases(map[string]string{"git": "git"})
|
||||
|
||||
// The old name resolves to the renamed seat.
|
||||
if s, ok := SeatNamed("git"); !ok || s.Name != "git" {
|
||||
t.Fatalf("the former name did not resolve to the renamed seat: %+v ok=%v", s, ok)
|
||||
}
|
||||
// And a holder recorded under the old name is still found for the provision the seat delivers.
|
||||
providers := []Provider{{Node: "anchor", At: "anchor.internal", Module: "gitea"}}
|
||||
held := []Held{{Claim: "git", Scope: ScopeMesh, Node: "anchor", Module: "gitea"}}
|
||||
holder, found := HolderAmong("git", providers, held)
|
||||
if !found || holder.Module != "gitea" {
|
||||
t.Fatalf("the holder recorded under the former name was not matched: %+v found=%v", holder, found)
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A secret family (novox/hq ADR 0283): one own secret per part the settings name, issued outside the mesh, each
|
||||
// given by its full name, never made by the mesh.
|
||||
|
||||
func familyManifest(t *testing.T, ownSecrets string) (Manifest, error) {
|
||||
t.Helper()
|
||||
return ParseManifest([]byte(`{"module":"mounts","version":"1","own-secrets":{` + ownSecrets + `}}`))
|
||||
}
|
||||
|
||||
func TestAFamilyIsDeclaredIssuedOutsideWithOneStar(t *testing.T) {
|
||||
m, err := familyManifest(t, `"smb-password-*":{"path":"/var/lib/mounts/smb-password-*.secret","issued-by":"outside"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("a well-formed family was refused: %v", err)
|
||||
}
|
||||
if got := m.OwnSecrets.Families(); len(got) != 1 || got[0] != "smb-password-*" {
|
||||
t.Fatalf("families: %v", got)
|
||||
}
|
||||
if len(m.OwnSecrets.Plain()) != 0 {
|
||||
t.Fatalf("a family counted as a secret declared by name: %v", m.OwnSecrets.Plain())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMalformedFamilyIsRefused(t *testing.T) {
|
||||
for name, c := range map[string]struct{ own, says string }{
|
||||
"made by the mesh": {`"smb-password-*":{"path":"/s/smb-password-*.secret","taken":"at-start"}`, "issued-by"},
|
||||
"no star in its path": {`"smb-password-*":{"path":"/s/smb-password.secret","issued-by":"outside"}`,
|
||||
"exactly one *"},
|
||||
"two stars in its path": {`"smb-password-*":{"path":"/s/*/smb-password-*.secret","issued-by":"outside"}`,
|
||||
"exactly one *"},
|
||||
"a star inside a name": {`"smb*password":{"path":"/s/x","issued-by":"outside"}`, "only ends a family"},
|
||||
"a name that is also a member": {`"smb-password-*":{"path":"/s/p-*","issued-by":"outside"},
|
||||
"smb-password-games":{"path":"/s/games","issued-by":"outside"}`, "also a member"},
|
||||
} {
|
||||
if _, err := familyManifest(t, c.own); err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%s: %v; want a refusal saying %q", name, err, c.says)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMemberIsFoundByItsFullNameAndLandsWhereTheStarIs(t *testing.T) {
|
||||
o := OwnSecrets{
|
||||
"smb-password-*": {Path: "/s/smb-password-*.secret", IssuedBy: IssuedOutside},
|
||||
"smb-password-big-*": {Path: "/big/*.secret", IssuedBy: IssuedOutside},
|
||||
"token": {Path: "/s/token", IssuedBy: IssuedOutside},
|
||||
}
|
||||
s, family, ok := o.Lookup("smb-password-games")
|
||||
if !ok || family != "smb-password-*" || s.Path != "/s/smb-password-games.secret" || s.IssuedBy != IssuedOutside {
|
||||
t.Fatalf("a member: %+v %q %v", s, family, ok)
|
||||
}
|
||||
if s, family, ok := o.Lookup("smb-password-big-one"); !ok || family != "smb-password-big-*" || s.Path != "/big/one.secret" {
|
||||
t.Fatalf("the longest family that fits: %+v %q %v", s, family, ok)
|
||||
}
|
||||
if s, family, ok := o.Lookup("token"); !ok || family != "" || s.Path != "/s/token" {
|
||||
t.Fatalf("a secret declared by name: %+v %q %v", s, family, ok)
|
||||
}
|
||||
for _, name := range []string{"smb-password-*", "smb-password-", "smb-password-../etc", "smb-password-a/b",
|
||||
"smb-password-a.b", "smb-password-Games", "smb-password--x", "other"} {
|
||||
if _, _, ok := o.Lookup(name); ok {
|
||||
t.Errorf("%q was found as a secret", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A member given is one file at its path; one not given is nothing, and the machine still composes — the mesh never
|
||||
// makes a member.
|
||||
func TestAMemberGivenIsPlacedAndOneNotGivenIsNothing(t *testing.T) {
|
||||
m, err := familyManifest(t, `"smb-password-*":{"path":"/var/lib/mounts/smb-password-*.secret","issued-by":"outside"}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := Resolution{Node: "workstation", Modules: []Manifest{m}}
|
||||
out, err := r.Declaration(Rendering{Needed: map[string]map[string]string{"mounts": {"smb-password-games": "sealed"}}})
|
||||
if err != nil {
|
||||
t.Fatalf("a module with one member given did not compose: %v", err)
|
||||
}
|
||||
var paths []string
|
||||
for _, res := range out {
|
||||
if res["sealed"] != nil {
|
||||
paths = append(paths, res["path"].(string))
|
||||
}
|
||||
}
|
||||
if len(paths) != 1 || paths[0] != "/var/lib/mounts/smb-password-games.secret" {
|
||||
t.Fatalf("placed: %v", paths)
|
||||
}
|
||||
out, err = r.Declaration(Rendering{})
|
||||
if err != nil {
|
||||
t.Fatalf("a module with no member given did not compose: %v", err)
|
||||
}
|
||||
for _, res := range out {
|
||||
if res["sealed"] != nil {
|
||||
t.Fatalf("a member nobody gave was placed: %v", res)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// What reads one of a module's own secrets is restarted when the secret changes (novox/hq issue 203,
|
||||
// issue 206): the build machine kept an hour-old credential open because its manifest restarted it
|
||||
// on its environment file alone. Composed, so a manifest need not say it; a scheduled process is
|
||||
// left alone, because the host refuses a restart-on for one and it reads the file afresh each run.
|
||||
func TestAContainerReadingAnOwnSecretIsRestartedWhenItChanges(t *testing.T) {
|
||||
m := Manifest{Module: "agent", Version: "1",
|
||||
OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/mesh/agent/broker"}},
|
||||
Resources: []map[string]any{
|
||||
{"id": "mesh-state", "type": "directory", "path": "/var/lib/mesh/agent", "mode": "0700"},
|
||||
{"id": "settings", "type": "file", "path": "/var/lib/mesh/agent/agent.env", "mode": "0600", "content": "A=1\n"},
|
||||
{"id": "server", "type": "container", "name": "agent", "network": "host",
|
||||
"image": "registry.example/agent@sha256:" + strings.Repeat("a", 64),
|
||||
"volumes": []any{"/var/lib/mesh/agent:/run/mesh:ro", "/var/lib/mesh/agent/broker:/run/mesh/broker:ro"},
|
||||
"env-file": []any{"/var/lib/mesh/agent/agent.env"},
|
||||
"restart-on": []any{"settings"}},
|
||||
{"id": "nightly", "type": "container", "name": "agent-nightly", "schedule": "0 3 * * *",
|
||||
"image": "registry.example/agent@sha256:" + strings.Repeat("a", 64),
|
||||
"volumes": []any{"/var/lib/mesh/agent/broker:/run/mesh/broker:ro"}},
|
||||
{"id": "other", "type": "container", "name": "agent-other",
|
||||
"image": "registry.example/agent@sha256:" + strings.Repeat("a", 64)},
|
||||
}}
|
||||
got, err := Resolve(shelf(m), []string{m.Module},
|
||||
Node{Name: "anchor", At: "10.0.0.1", Capabilities: map[string]bool{"container-runtime": true}}, World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := got.Declaration(Rendering{Needed: map[string]map[string]string{"agent": {"broker": "SEALED"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
by := map[string]map[string]any{}
|
||||
for _, r := range out {
|
||||
by[r["id"].(string)] = r
|
||||
}
|
||||
if want := []any{"agent.settings", "agent.needs-broker"}; !reflect.DeepEqual(by["agent.server"]["restart-on"], want) {
|
||||
t.Fatalf("the server reads the credential and is not restarted on it: %v", by["agent.server"]["restart-on"])
|
||||
}
|
||||
if _, has := by["agent.nightly"]["restart-on"]; has {
|
||||
t.Fatalf("a scheduled container was given a restart-on, which the host refuses: %v", by["agent.nightly"]["restart-on"])
|
||||
}
|
||||
if _, has := by["agent.other"]["restart-on"]; has {
|
||||
t.Fatalf("a container that reads no secret was given one to restart on: %v", by["agent.other"]["restart-on"])
|
||||
}
|
||||
}
|
||||
|
||||
// A member of a secret family is an own secret too (novox/hq issue 405, ADR 0283 decision 6): a container that
|
||||
// reads a member given is restarted when it changes, as for a secret declared by name. A member not given is no
|
||||
// file, so nothing is restarted on it.
|
||||
func TestAContainerReadingAFamilyMemberIsRestartedWhenItChanges(t *testing.T) {
|
||||
m := Manifest{Module: "mounts", Version: "1",
|
||||
OwnSecrets: OwnSecrets{"smb-password-*": {Path: "/var/lib/mesh/mounts/smb-password-*.secret", IssuedBy: IssuedOutside}},
|
||||
Resources: []map[string]any{
|
||||
{"id": "games", "type": "container", "name": "mounts-games", "network": "host",
|
||||
"image": "registry.example/mounts@sha256:" + strings.Repeat("a", 64),
|
||||
"volumes": []any{"/var/lib/mesh/mounts/smb-password-games.secret:/run/password:ro"}},
|
||||
{"id": "library", "type": "container", "name": "mounts-library", "network": "host",
|
||||
"image": "registry.example/mounts@sha256:" + strings.Repeat("a", 64),
|
||||
"volumes": []any{"/var/lib/mesh/mounts/smb-password-library.secret:/run/password:ro"}},
|
||||
}}
|
||||
got, err := Resolve(shelf(m), []string{m.Module},
|
||||
Node{Name: "anchor", At: "10.0.0.1", Capabilities: map[string]bool{"container-runtime": true}}, World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := got.Declaration(Rendering{Needed: map[string]map[string]string{"mounts": {"smb-password-games": "SEALED"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
by := map[string]map[string]any{}
|
||||
for _, r := range out {
|
||||
by[r["id"].(string)] = r
|
||||
}
|
||||
if want := []any{"mounts.needs-smb-password-games"}; !reflect.DeepEqual(by["mounts.games"]["restart-on"], want) {
|
||||
t.Fatalf("a container reading a member given is not restarted on it: %v", by["mounts.games"]["restart-on"])
|
||||
}
|
||||
if _, placed := by["mounts.needs-smb-password-games"]; !placed {
|
||||
t.Fatalf("the member given is not placed, so a restart-on names nothing: %v", out)
|
||||
}
|
||||
if _, has := by["mounts.library"]["restart-on"]; has {
|
||||
t.Fatalf("a container reading a member not given was given a restart-on naming nothing: %v", by["mounts.library"]["restart-on"])
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func aModuleWithAnEnvFileSecret(exception string) Manifest {
|
||||
container := map[string]any{
|
||||
"id": "server", "type": "container", "name": "app", "image": "app@sha256:" + strings.Repeat("a", 64),
|
||||
"env-file": []any{"/var/lib/app/server.env"},
|
||||
}
|
||||
if exception != "" {
|
||||
container[SecretsInEnvironment] = exception
|
||||
}
|
||||
return Manifest{
|
||||
Module: "app", Version: "1",
|
||||
OwnSecrets: OwnSecrets{"token": {Path: "/var/lib/app/token.secret"}},
|
||||
Resources: []map[string]any{
|
||||
{"id": "env", "type": "file", "path": "/var/lib/app/server.env", "mode": "0600",
|
||||
"content": "APP_TOKEN=${secret:token}\n"},
|
||||
container,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func declare(t *testing.T, m Manifest) ([]map[string]any, error) {
|
||||
t.Helper()
|
||||
got, err := Resolve(shelf(m), []string{m.Module},
|
||||
Node{Name: "anchor", At: "10.0.0.1", Capabilities: map[string]bool{}}, World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return got.Declaration(Rendering{Needed: map[string]map[string]string{"app": {"token": "SEALED"}}})
|
||||
}
|
||||
|
||||
// A container that reads a file carrying a secret as its environment is refused, unless it says
|
||||
// why — and then the reason stays in the catalogue and never reaches the machine.
|
||||
func TestASecretInAnEnvFileIsRefusedUnlessDeclared(t *testing.T) {
|
||||
_, err := declare(t, aModuleWithAnEnvFileSecret(""))
|
||||
if err == nil || !strings.Contains(err.Error(), "04-ISSUES/041") {
|
||||
t.Fatalf("an env-file carrying a secret was declared without a word: %v", err)
|
||||
}
|
||||
|
||||
out, err := declare(t, aModuleWithAnEnvFileSecret("the image reads its configuration from the environment only"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range out {
|
||||
if _, leaked := r[SecretsInEnvironment]; leaked {
|
||||
t.Fatalf("the catalogue's reason was sent to the machine: %v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A secret placeholder inside a container's env is refused outright: nothing fills it there.
|
||||
func TestASecretInEnvIsAlwaysRefused(t *testing.T) {
|
||||
m := aModuleWithAnEnvFileSecret("said")
|
||||
m.Resources[1]["env"] = map[string]any{"APP_TOKEN": "${secret:token}"}
|
||||
if _, err := declare(t, m); err == nil || !strings.Contains(err.Error(), "never filled into an environment variable") {
|
||||
t.Fatalf("a secret in env was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A file with no secret in it is an ordinary env-file and needs no word.
|
||||
func TestAnEnvFileWithoutASecretNeedsNothing(t *testing.T) {
|
||||
m := aModuleWithAnEnvFileSecret("")
|
||||
m.Resources[0]["content"] = "APP_MODE=production\n"
|
||||
if _, err := declare(t, m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// A module whose process is not root names who its secret files belong to, and every secret file
|
||||
// the composer writes for it carries that owner — the mounted file is readable where it is used.
|
||||
func TestSecretFilesCarryTheOwnerTheModuleNamed(t *testing.T) {
|
||||
m := aModuleWithAnEnvFileSecret("said")
|
||||
m.SecretsOwner = "65534:65534"
|
||||
out, err := declare(t, m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var seen bool
|
||||
for _, r := range out {
|
||||
if r["path"] != "/var/lib/app/token.secret" {
|
||||
continue
|
||||
}
|
||||
seen = true
|
||||
if r["owner"] != "65534:65534" {
|
||||
t.Errorf("the secret file is owned by %v", r["owner"])
|
||||
}
|
||||
}
|
||||
if !seen {
|
||||
t.Fatal("no secret file in the declaration")
|
||||
}
|
||||
m.SecretsOwner = ""
|
||||
out, _ = declare(t, m)
|
||||
for _, r := range out {
|
||||
if r["path"] == "/var/lib/app/token.secret" && r["owner"] != nil {
|
||||
t.Errorf("an owner was invented: %v", r["owner"])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Driven through Declaration, never through the helper.
|
||||
//
|
||||
// Three times in this repository a test asserted on a helper while the code that called it was
|
||||
// wrong, and each time an injected fault stayed silent because nothing on the path was under test.
|
||||
// What has to be true is that a declaration leaving the control plane carries the values, so that
|
||||
// is what these ask.
|
||||
|
||||
func fileNamed(out []map[string]any, id string) map[string]any {
|
||||
for _, r := range out {
|
||||
if r["id"] == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// A module's own credential, put into a file it wrote.
|
||||
func TestAFileGetsTheSecretItsContentAsksFor(t *testing.T) {
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{{
|
||||
Module: "gitea",
|
||||
OwnSecrets: OwnSecrets{"admin": {Path: "/var/lib/gitea/admin.env"}},
|
||||
Resources: []map[string]any{{
|
||||
"id": "conf", "type": "file", "path": "/etc/gitea/app.ini",
|
||||
"content": "[security]\nSECRET_KEY = ${secret:admin}\n",
|
||||
}},
|
||||
}}}
|
||||
out, err := r.Declaration(Rendering{
|
||||
Needed: map[string]map[string]string{"gitea": {"admin": "sealed-admin"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := fileNamed(out, "gitea.conf")
|
||||
if file == nil {
|
||||
t.Fatalf("no file in %v", out)
|
||||
}
|
||||
got, _ := file["secrets"].(map[string]any)
|
||||
if got["admin"] != "sealed-admin" {
|
||||
t.Errorf("the file was sent without its secret: %v", file)
|
||||
}
|
||||
// The hole is still a hole on the wire. The mesh does not fill it in; the host does, on the
|
||||
// machine, which is the only place both halves exist.
|
||||
if !strings.Contains(file["content"].(string), "${secret:admin}") {
|
||||
t.Errorf("the mesh substituted it itself: %v", file["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// The gap this exists for: a granted credential arrives as a bare password, and the program that
|
||||
// needs it reads KEY=value.
|
||||
func TestAGrantedCredentialCanBeShapedIntoAnEnvFile(t *testing.T) {
|
||||
r := Resolution{
|
||||
Node: "anchor",
|
||||
Modules: []Manifest{{
|
||||
Module: "keycloak",
|
||||
Requires: []string{"postgres-database"},
|
||||
Secrets: map[string]string{"postgres-database": "/var/lib/keycloak/db.secret"},
|
||||
Resources: []map[string]any{{
|
||||
"id": "dbenv", "type": "file", "path": "/var/lib/keycloak/database.env",
|
||||
"content": "KC_DB_USERNAME=keycloak\nKC_DB_PASSWORD=${secret:postgres-database}\n",
|
||||
"mode": "0600",
|
||||
}},
|
||||
}},
|
||||
Needs: []Needed{{Name: "postgres-database", For: "keycloak", From: "anchor", Sealed: "sealed-db"}},
|
||||
}
|
||||
out, err := r.Declaration(Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := fileNamed(out, "keycloak.dbenv")
|
||||
got, _ := file["secrets"].(map[string]any)
|
||||
if got["postgres-database"] != "sealed-db" {
|
||||
t.Errorf("the credential never reached the file it was needed in: %v", file)
|
||||
}
|
||||
}
|
||||
|
||||
// Two modules on one node requiring the same provision each get THEIR OWN credential in a
|
||||
// ${secret:…} file — not whichever need happened to come last. This is the placeholder-path twin of
|
||||
// the guard novox/hq 04-ISSUES/022 put on the secrets:-map path; without it, a node with baserow and
|
||||
// letta both consuming postgres gave baserow letta's password and authentication failed.
|
||||
func TestTwoConsumersOfOneProvisionEachGetTheirOwnInAPlaceholderFile(t *testing.T) {
|
||||
r := Resolution{
|
||||
Node: "anchor",
|
||||
Modules: []Manifest{
|
||||
{
|
||||
Module: "keycloak",
|
||||
Requires: []string{"postgres-database"},
|
||||
Secrets: map[string]string{"postgres-database": "/var/lib/keycloak/db.secret"},
|
||||
Resources: []map[string]any{{
|
||||
"id": "dbenv", "type": "file", "path": "/var/lib/keycloak/database.env",
|
||||
"content": "PW=${secret:postgres-database}\n", "mode": "0600",
|
||||
}},
|
||||
},
|
||||
{
|
||||
Module: "grafana",
|
||||
Requires: []string{"postgres-database"},
|
||||
Secrets: map[string]string{"postgres-database": "/var/lib/grafana/db.secret"},
|
||||
Resources: []map[string]any{{
|
||||
"id": "dbenv", "type": "file", "path": "/var/lib/grafana/database.env",
|
||||
"content": "PW=${secret:postgres-database}\n", "mode": "0600",
|
||||
}},
|
||||
},
|
||||
},
|
||||
Needs: []Needed{
|
||||
{Name: "postgres-database", For: "keycloak", From: "anchor", Sealed: "sealed-kc"},
|
||||
{Name: "postgres-database", For: "grafana", From: "anchor", Sealed: "sealed-gf"},
|
||||
},
|
||||
}
|
||||
out, err := r.Declaration(Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
kc, _ := fileNamed(out, "keycloak.dbenv")["secrets"].(map[string]any)
|
||||
if kc["postgres-database"] != "sealed-kc" {
|
||||
t.Errorf("keycloak got the wrong credential (someone else's password): %v", kc)
|
||||
}
|
||||
gf, _ := fileNamed(out, "grafana.dbenv")["secrets"].(map[string]any)
|
||||
if gf["postgres-database"] != "sealed-gf" {
|
||||
t.Errorf("grafana got the wrong credential (someone else's password): %v", gf)
|
||||
}
|
||||
}
|
||||
|
||||
// A name nothing declared is a typo, and it is refused here rather than on the machine.
|
||||
func TestAFileNamingASecretTheModuleDoesNotHaveIsRefused(t *testing.T) {
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{{
|
||||
Module: "gitea",
|
||||
OwnSecrets: OwnSecrets{"admin": {Path: "/var/lib/gitea/admin.env"}},
|
||||
Resources: []map[string]any{{
|
||||
"id": "conf", "type": "file", "path": "/etc/gitea/app.ini",
|
||||
"content": "SECRET_KEY = ${secret:adnim}\n",
|
||||
}},
|
||||
}}}
|
||||
_, err := r.Declaration(Rendering{
|
||||
Needed: map[string]map[string]string{"gitea": {"admin": "sealed-admin"}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("a file asked for a secret that does not exist and the mesh sent it anyway")
|
||||
}
|
||||
// It says what could have been meant, because the answer is nearly always one of two names.
|
||||
if !strings.Contains(err.Error(), `"admin"`) {
|
||||
t.Errorf("the refusal does not say what it could have named: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// One module may not read another's credential by guessing its name.
|
||||
func TestAFileCannotNameAnotherModulesSecret(t *testing.T) {
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{
|
||||
{Module: "postgres", OwnSecrets: OwnSecrets{"superuser": {Path: "/var/lib/postgres/su.env"}}},
|
||||
{Module: "gitea", Resources: []map[string]any{{
|
||||
"id": "conf", "type": "file", "path": "/etc/gitea/app.ini",
|
||||
"content": "PASSWORD=${secret:superuser}\n",
|
||||
}}},
|
||||
}}
|
||||
_, err := r.Declaration(Rendering{Needed: map[string]map[string]string{
|
||||
"postgres": {"superuser": "sealed-su"},
|
||||
}})
|
||||
if err == nil {
|
||||
t.Fatal("gitea read postgres's credential by naming it")
|
||||
}
|
||||
}
|
||||
|
||||
// A token inside a JSON document, where a setting put the placeholder there.
|
||||
//
|
||||
// **The case this whole shape was argued from**: a desktop client that reads its token from an
|
||||
// attribute inside a JSON file in a home directory, not from an environment variable. The module
|
||||
// ships an empty document, a setting says which attribute, and the credential never passes
|
||||
// through the mesh in the open.
|
||||
func TestASettingThatCarriesAPlaceholderIsStillFilled(t *testing.T) {
|
||||
r := Resolution{Node: "workstation", Modules: []Manifest{{
|
||||
Module: "chat",
|
||||
OwnSecrets: OwnSecrets{"api-token": {Path: "/home/operator/.config/chat/token"}},
|
||||
Resources: []map[string]any{{
|
||||
"id": "settings", "type": "file", "merge": "json",
|
||||
"path": "/home/operator/.config/chat/settings.json", "content": "{}",
|
||||
}},
|
||||
}}}
|
||||
out, err := r.Declaration(Rendering{
|
||||
Needed: map[string]map[string]string{"chat": {"api-token": "sealed-token"}},
|
||||
Settings: SettingsBy{"chat": {{From: "node", Values: map[string]any{
|
||||
"chat.atlassian.token": "${secret:api-token}",
|
||||
}}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
file := fileNamed(out, "chat.settings")
|
||||
got, _ := file["secrets"].(map[string]any)
|
||||
if got["api-token"] != "sealed-token" {
|
||||
t.Errorf("a placeholder a setting put there was never filled: %v", file)
|
||||
}
|
||||
// Filling secrets runs after settings for exactly this reason: before the merge, the content
|
||||
// is "{}" and asks for nothing at all.
|
||||
if !strings.Contains(file["content"].(string), "${secret:api-token}") {
|
||||
t.Errorf("the hole did not survive to the machine: %v", file["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// An own secret and a requirement of the same name would make ${secret:x} mean either.
|
||||
func TestANameMeaningTwoThingsIsRefused(t *testing.T) {
|
||||
r := Resolution{
|
||||
Node: "anchor",
|
||||
Modules: []Manifest{{
|
||||
Module: "thing",
|
||||
OwnSecrets: OwnSecrets{"store": {Path: "/var/lib/thing/own.env"}},
|
||||
Secrets: map[string]string{"store": "/var/lib/thing/granted.env"},
|
||||
}},
|
||||
Needs: []Needed{{Name: "store", From: "anchor", Sealed: "sealed-granted"}},
|
||||
}
|
||||
_, err := r.Declaration(Rendering{
|
||||
Needed: map[string]map[string]string{"thing": {"store": "sealed-own"}},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "rename") {
|
||||
t.Errorf("one name meant two credentials and nothing said so: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A file with no placeholder is not given a secrets map. The host refuses a secret its content
|
||||
// never asks for, so an empty map added helpfully would break every file that has none.
|
||||
func TestAFileWithNoPlaceholderIsLeftAlone(t *testing.T) {
|
||||
r := Resolution{Node: "anchor", Modules: []Manifest{{
|
||||
Module: "gitea",
|
||||
OwnSecrets: OwnSecrets{"admin": {Path: "/var/lib/gitea/admin.env"}},
|
||||
Resources: []map[string]any{{
|
||||
"id": "conf", "type": "file", "path": "/etc/gitea/app.ini", "content": "RUN_MODE=prod\n",
|
||||
}},
|
||||
}}}
|
||||
out, err := r.Declaration(Rendering{
|
||||
Needed: map[string]map[string]string{"gitea": {"admin": "sealed-admin"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, given := fileNamed(out, "gitea.conf")["secrets"]; given {
|
||||
t.Error("a file that asks for nothing was given a secrets map, which the host refuses")
|
||||
}
|
||||
}
|
||||
@@ -1,194 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// influxdb and grafana as the catalogue declares them, cut to what composes the data source on a
|
||||
// machine like ace (novox/hq issue 696): what influxdb serves for influxdb-api and its mergeable
|
||||
// runtime configuration, and grafana's binding, data source file and credential.
|
||||
const grafanaLikeTheCatalogue = `{
|
||||
"module": "grafana", "version": "1",
|
||||
"requires": [{"name": "influxdb-api", "optional": true}],
|
||||
"contributes": {"influxdb-api": {"access": "read"}},
|
||||
"binds": {"influxdb-api": "${dir:state}/influxdb.json"},
|
||||
"secrets": {"influxdb-api": "${dir:mesh-state}/influxdb-api"},
|
||||
"resources": [
|
||||
{"id": "mesh-state", "type": "directory", "mode": "0700", "place": "mesh"},
|
||||
{"id": "state", "type": "directory", "mode": "0700", "place": "."},
|
||||
{"id": "influxdb-datasources", "type": "directory", "mode": "0755", "owner": "472:0"},
|
||||
{"id": "influxdb-datasource", "type": "file", "path": "${dir:influxdb-datasources}/mesh-influxdb.yaml", "mode": "0644",
|
||||
"content": "apiVersion: 1\n# Written by the mesh from grafana's influxdb-api binding, only while it is bound (novox/hq ADR 0330):\n# without influxdb, grafana runs with no mesh data source. grafana reads it at start. Its own name and\n# uid, so a data source somebody made in the UI is never overwritten, and read-only in the UI because\n# the mesh resets it. The password is read from the file the mesh delivers, never written here.\ndatasources:\n - name: InfluxDB (mesh)\n uid: mesh-influxdb-api\n type: influxdb\n access: proxy\n url: ${bound:influxdb-api:scheme}://${bound:influxdb-api:at}:${bound:influxdb-api:port}\n user: ${bound:influxdb-api:as}\n isDefault: false\n editable: false\n jsonData:\n dbName: ${bound:influxdb-api:bucket}\n httpMode: POST\n secureJsonData:\n password: $__file{/run/secrets/influxdb/influxdb-api}\n",
|
||||
"only-when-bound": "influxdb-api"}
|
||||
]
|
||||
}`
|
||||
|
||||
// influxdbServing is influxdb as the catalogue declared it before issue 696 (literal served org and
|
||||
// bucket) or after (served from its settings, today's literals as their defaults).
|
||||
func influxdbServing(after bool) string {
|
||||
served := `"org": "mesh", "bucket": "default"`
|
||||
settings, takes := ``, ``
|
||||
if after {
|
||||
served = `"org": "${setting:org}", "bucket": "${setting:bucket}"`
|
||||
settings = `"settings": {
|
||||
"org": {"kind": "preference", "default": "mesh", "why": "the org influxdb makes at its first start"},
|
||||
"bucket": {"kind": "preference", "default": "default", "why": "the bucket influxdb makes at its first start"}},`
|
||||
takes = `, "takes-settings": ["bucket", "org", "url"], "trusted": true`
|
||||
}
|
||||
return `{
|
||||
"module": "influxdb", "version": "1",
|
||||
"provides": [{"name": "influxdb-api", "scope": "mesh", "identity": {"in": "an InfluxDB v1 authorization"}}],
|
||||
"listens": [{"name": "api", "port": 8086, "protocol": "tcp", "from": "mesh", "why": "queries and writes"}],
|
||||
` + settings + `
|
||||
"serves": {"influxdb-api": {"scheme": "http", "port": 8086, ` + served + `}},
|
||||
"resources": [
|
||||
{"id": "mesh-state", "type": "directory", "mode": "0700", "place": "mesh"},
|
||||
{"id": "runtime-config", "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600",
|
||||
"content": "{}\n", "merge": "json"` + takes + `}
|
||||
]
|
||||
}`
|
||||
}
|
||||
|
||||
// composedLikeAce is every resource of grafana and influxdb as one machine composes them, by id, with
|
||||
// influxdb's settings layer on it.
|
||||
func composedLikeAce(t *testing.T, influxdb string, layers []Layer) map[string]string {
|
||||
t.Helper()
|
||||
var ms []Manifest
|
||||
for _, raw := range []string{influxdb, grafanaLikeTheCatalogue} {
|
||||
m, err := ParseManifest([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ms = append(ms, m)
|
||||
}
|
||||
r, err := Resolve(shelf(ms...), []string{"influxdb", "grafana"}, reachable(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := r.Declaration(Rendering{Settings: SettingsBy{"influxdb": layers},
|
||||
Needed: map[string]map[string]string{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]string{}
|
||||
for _, res := range out {
|
||||
id, _ := res["id"].(string)
|
||||
if !strings.HasPrefix(id, "grafana.") && !strings.HasPrefix(id, "influxdb.") {
|
||||
continue
|
||||
}
|
||||
b, err := json.Marshal(res)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got[id] = string(b)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
// aceLikeLayer is a layer like influxdb's on ace: its own org and bucket (made up here), and the
|
||||
// endpoint block the mesh reads itself.
|
||||
func aceLikeLayer() []Layer {
|
||||
return []Layer{{From: "ace", Values: map[string]any{
|
||||
"org": "made-up-org", "bucket": "made-up-bucket",
|
||||
"endpoints": map[string]any{"api": map[string]any{"label": "influx", "port": float64(8086), "reach": "internal"}},
|
||||
}}}
|
||||
}
|
||||
|
||||
// grafana composes byte for byte as it did before issue 696, on a machine like ace and on one
|
||||
// without a layer, now that influxdb serves its org and bucket from its settings and no layer
|
||||
// replaces a served key. The golden files are grafana's resources as the integration branch's
|
||||
// controller composed them at 71362c1 (Settle still overriding), with influxdb serving the literals.
|
||||
func TestGrafanaComposesTheSameOnceInfluxdbServesItsSettings(t *testing.T) {
|
||||
for name, layers := range map[string][]Layer{"ace": aceLikeLayer(), "unlayered": nil} {
|
||||
raw, err := os.ReadFile("testdata/influxdb-served-by-setting/grafana-" + name + ".json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var want map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &want); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := composedLikeAce(t, influxdbServing(true), layers)
|
||||
seen := 0
|
||||
for id, v := range got {
|
||||
if !strings.HasPrefix(id, "grafana.") {
|
||||
continue
|
||||
}
|
||||
seen++
|
||||
w, ok := want[id]
|
||||
if !ok {
|
||||
t.Errorf("%s: %s composes and did not before", name, id)
|
||||
continue
|
||||
}
|
||||
var compact bytes.Buffer
|
||||
if err := json.Compact(&compact, w); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if compact.String() != v {
|
||||
t.Errorf("%s: %s changed:\n got %s\nwant %s", name, id, v, compact.String())
|
||||
}
|
||||
}
|
||||
if seen != len(want) {
|
||||
t.Errorf("%s: %d of grafana's %d resources compose", name, seen, len(want))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What a provider serves is its definition's, filled from settings only where it asks with
|
||||
// ${setting:<key>} (novox/hq issue 696, ADR 0348 clause 11): a layer that sets a key the provider
|
||||
// serves as a literal replaces nothing, and a key it does not serve is never added.
|
||||
func TestALayerNoLongerReplacesAServedKey(t *testing.T) {
|
||||
served, err := Settle(map[string]any{"host": "mail", "port": 25},
|
||||
[]Layer{{From: "the mesh", Values: map[string]any{"host": "post", "sitename": "Mail"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if served["host"] != "mail" {
|
||||
t.Errorf("a layer replaced the served host: %v", served)
|
||||
}
|
||||
if _, leaked := served["sitename"]; leaked {
|
||||
t.Errorf("a setting for a file reached the consumers: %v", served)
|
||||
}
|
||||
|
||||
// And a layer setting it is said to reach nothing, rather than taken for a change.
|
||||
m := Manifest{Module: "mail", Serves: map[string]map[string]any{"smtp": {"host": "mail"}}}
|
||||
if unused := UnusedSettings(m, []Layer{{From: "the mesh", Values: map[string]any{"host": "post"}}}); len(unused) != 1 {
|
||||
t.Errorf("a layer setting a literal served key was not said to reach nothing: %v", unused)
|
||||
}
|
||||
|
||||
// influxdb as it was before issue 696, serving literals, under a layer like ace's: the layer no
|
||||
// longer reaches grafana — which is why the catalogue moves influxdb first.
|
||||
got := composedLikeAce(t, influxdbServing(false), aceLikeLayer())
|
||||
for _, id := range []string{"grafana.influxdb-datasource", "grafana.bound-influxdb-api"} {
|
||||
if strings.Contains(got[id], "made-up") {
|
||||
t.Errorf("%s still carries the layer's value over a literal served key: %s", id, got[id])
|
||||
}
|
||||
}
|
||||
if !strings.Contains(got["grafana.influxdb-datasource"], `dbName: default\n`) {
|
||||
t.Errorf("the data source does not name the served literal: %s", got["grafana.influxdb-datasource"])
|
||||
}
|
||||
}
|
||||
|
||||
// influxdb's own runtime configuration takes the keys it names: org and bucket from a layer like
|
||||
// ace's, and not the endpoint block the mesh reads itself, which today's form wrote there too.
|
||||
func TestInfluxdbsConfigTakesItsOrgAndBucket(t *testing.T) {
|
||||
got := composedLikeAce(t, influxdbServing(true), aceLikeLayer())["influxdb.runtime-config"]
|
||||
var r map[string]any
|
||||
if err := json.Unmarshal([]byte(got), &r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := "{\n \"bucket\": \"made-up-bucket\",\n \"org\": \"made-up-org\"\n}\n"; r["content"] != want {
|
||||
t.Errorf("config.json: got %q, want %q", r["content"], want)
|
||||
}
|
||||
got = composedLikeAce(t, influxdbServing(true), nil)["influxdb.runtime-config"]
|
||||
if err := json.Unmarshal([]byte(got), &r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r["content"] != "{}\n" {
|
||||
t.Errorf("a default reached the mergeable file: %q", r["content"])
|
||||
}
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0346, issue 624: the forge's package registry writes every tarball at its public name,
|
||||
// and the lab, its consumer, must trust that name and no other. The name is the mesh's own composition
|
||||
// of the forge's route, which the forge's files read as ${bound:route:name-web}; a consumer can now be
|
||||
// served it.
|
||||
|
||||
const aRegistryForge = `{"module":"forge","version":"1","requires":["route"],
|
||||
"provides":[{"name":"npm-package-registry","scope":"mesh"}],
|
||||
"contributes":{"route":{"web":{"label":"git","port":3000}}},
|
||||
"serves":{"npm-package-registry":{"scheme":"http","port":3000,"npm-path":"/api/packages/novox/npm/",
|
||||
"public-root":"https://${bound:route:name-web}/api/packages/novox/npm/"}}}`
|
||||
|
||||
func aForgeManifest(t *testing.T) Manifest {
|
||||
t.Helper()
|
||||
m, err := ParseManifest([]byte(aRegistryForge))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestAProviderServesItsOwnComposedName(t *testing.T) {
|
||||
m := aForgeManifest(t)
|
||||
r := Resolution{Node: "home", PublicDomain: "example.org"}
|
||||
got, said, err := r.ServedOwnNames(m, "npm-package-registry", m.Serves["npm-package-registry"], nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["public-root"] != "https://git.example.org/api/packages/novox/npm/" || len(said) != 0 {
|
||||
t.Fatalf("the public root is the route's composed name: %v, said %v", got, said)
|
||||
}
|
||||
if got["npm-path"] != "/api/packages/novox/npm/" || got["port"] != float64(3000) {
|
||||
t.Fatalf("the rest is served as it was: %v", got)
|
||||
}
|
||||
if m.Serves["npm-package-registry"]["public-root"] != "https://${bound:route:name-web}/api/packages/novox/npm/" {
|
||||
t.Fatal("the manifest's own map was rewritten")
|
||||
}
|
||||
}
|
||||
|
||||
func TestANameTheMachineDoesNotComposeLeavesTheKeyOutAndSaysSo(t *testing.T) {
|
||||
m := aForgeManifest(t)
|
||||
r := Resolution{Node: "home"} // no public domain
|
||||
got, said, err := r.ServedOwnNames(m, "npm-package-registry", m.Serves["npm-package-registry"], nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, there := got["public-root"]; there {
|
||||
t.Fatalf("a name not composed is left out, never written as the placeholder or empty: %v", got)
|
||||
}
|
||||
if got["npm-path"] == nil {
|
||||
t.Fatalf("only the key that names it is left out: %v", got)
|
||||
}
|
||||
if len(said) != 1 || !strings.Contains(said[0], `forge serves npm-package-registry without "public-root"`) ||
|
||||
!strings.Contains(said[0], "home composes no such name") {
|
||||
t.Fatalf("said: %v", said)
|
||||
}
|
||||
if lines := (Resolution{Node: "home", Modules: []Manifest{m}}).ServedLeftOut(nil); len(lines) != 1 {
|
||||
t.Fatalf("the plan on the provider's machine says it: %v", lines)
|
||||
}
|
||||
if lines := (Resolution{Node: "home", PublicDomain: "example.org", Modules: []Manifest{m}}).ServedLeftOut(nil); len(lines) != 0 {
|
||||
t.Fatalf("nothing to say when the name is composed: %v", lines)
|
||||
}
|
||||
}
|
||||
|
||||
// On the provider's own machine the consumer is served the same name (ADR 0346 §2).
|
||||
func TestAConsumerBesideTheProviderIsServedItsName(t *testing.T) {
|
||||
m := aForgeManifest(t)
|
||||
consumer := Manifest{Module: "lab", Version: "1", Requires: []string{"npm-package-registry"},
|
||||
Binds: map[string]string{"npm-package-registry": "/var/lib/lab/package-registry.json"}}
|
||||
node := workstation()
|
||||
node.PublicDomain = "example.org"
|
||||
got, err := Resolve(shelf(minimalRouteProxy(), m, consumer), []string{"route-proxy", "forge", "lab"}, node, World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
served, answered, err := got.servedOnThisMachine("npm-package-registry", Rendering{})
|
||||
if err != nil || !answered {
|
||||
t.Fatalf("answered %v: %v", answered, err)
|
||||
}
|
||||
if served["public-root"] != "https://git.example.org/api/packages/novox/npm/" {
|
||||
t.Fatalf("served beside the provider: %v", served)
|
||||
}
|
||||
out, err := got.Declaration(Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var binding string
|
||||
for _, r := range out {
|
||||
if r["path"] == "/var/lib/lab/package-registry.json" {
|
||||
binding, _ = r["content"].(string)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(binding, `"public-root": "https://git.example.org/api/packages/novox/npm/"`) &&
|
||||
!strings.Contains(binding, `"public-root":"https://git.example.org/api/packages/novox/npm/"`) {
|
||||
t.Fatalf("the consumer's binding carries the name:\n%s", binding)
|
||||
}
|
||||
if strings.Contains(binding, "${bound:") {
|
||||
t.Fatalf("a placeholder reached the consumer:\n%s", binding)
|
||||
}
|
||||
}
|
||||
|
||||
// From another machine, the provider's served values carry what that machine composed, and a key it
|
||||
// left out is said on the consumer's need (ADR 0346 §2, §3).
|
||||
func TestAConsumerElsewhereIsToldWhatTheProviderLeftOut(t *testing.T) {
|
||||
consumer := Manifest{Module: "lab", Version: "1", Requires: []string{"npm-package-registry"},
|
||||
Binds: map[string]string{"npm-package-registry": "/var/lib/lab/package-registry.json"}}
|
||||
node := workstation()
|
||||
node.At = "workstation.internal"
|
||||
world := World{Offered: map[string][]Provider{"npm-package-registry": {{Node: "home", At: "home.internal",
|
||||
Module: "forge", Serves: map[string]any{"port": 3000},
|
||||
LeftOut: []string{`forge serves npm-package-registry without "public-root"`}}}}}
|
||||
got, err := Resolve(shelf(aForgeManifest(t), consumer), []string{"lab"}, node, world)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, n := range got.Needs {
|
||||
if n.Name == "npm-package-registry" {
|
||||
if len(n.LeftOut) != 1 {
|
||||
t.Fatalf("the need carries what was left out: %+v", n)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("no need: %+v", got.Needs)
|
||||
}
|
||||
|
||||
func TestAServedNameIsCheckedWhenTheDefinitionIsRead(t *testing.T) {
|
||||
for bad, want := range map[string]string{
|
||||
`"public-root":"https://${bound:route:name-api}/x"`: "what forge is called through route is name, internal-name, name-web, internal-name-web",
|
||||
`"public-root":"https://${bound:postgres-database:name}/x"`: `forge contributes to "route"`,
|
||||
`"public-root":"https://${bound:route:name-web|default:x}/x"`: "takes no default",
|
||||
`"public-root":"https://${bound:route:at}/x"`: "what forge is called through route",
|
||||
} {
|
||||
raw := strings.Replace(aRegistryForge, `"public-root":"https://${bound:route:name-web}/api/packages/novox/npm/"`, bad, 1)
|
||||
_, err := ParseManifest([]byte(raw))
|
||||
if err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%s: want a refusal saying %q, got %v", bad, want, err)
|
||||
}
|
||||
}
|
||||
nested := strings.Replace(aRegistryForge, `"public-root":"https://${bound:route:name-web}/api/packages/novox/npm/"`,
|
||||
`"roots":["https://${bound:route:name-web}/x"]`, 1)
|
||||
if _, err := ParseManifest([]byte(nested)); err == nil || !strings.Contains(err.Error(), "inside a list or an object") {
|
||||
t.Errorf("a nested own name is refused, not served as the placeholder: %v", err)
|
||||
}
|
||||
if _, err := ParseManifest([]byte(aRegistryForge)); err != nil {
|
||||
t.Fatalf("its own name is accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The provider's contributions file carries no own name on a consumer's entry (ADR 0346 §4): only what
|
||||
// is derived for that consumer is there (ADR 0202 §5), and the provider reads its own names in its files.
|
||||
func TestAProvidersOwnNameIsNotOnItsConsumersEntry(t *testing.T) {
|
||||
m := aForgeManifest(t)
|
||||
r := Resolution{Node: "home", PublicDomain: "example.org", Modules: []Manifest{m}}
|
||||
derived, err := r.derivedFor("npm-package-registry", "lab_home", "lab", "", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if derived != nil {
|
||||
t.Fatalf("a consumer's entry carries only what was derived for it: %v", derived)
|
||||
}
|
||||
}
|
||||
@@ -1,450 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A preference has a default in the definition; the mesh's layer, then the node's, override it; a key
|
||||
// with a default is not stray; and a value that is the operator's still has none (novox/hq ADR 0262).
|
||||
|
||||
func notifier() Manifest {
|
||||
return Manifest{Module: "notifier",
|
||||
Settings: map[string]SettingDeclaration{
|
||||
"font-size": {Kind: KindPreference, Default: float64(10), Why: "readable at a scale of one"},
|
||||
"width": {Kind: KindPreference, Default: float64(250), Why: "fits a title of forty characters"},
|
||||
},
|
||||
Resources: []map[string]any{{"id": "configuration", "type": "file", "path": "/x/notifierrc",
|
||||
"content": "font = Inter ${setting:font-size}\nwidth = ${setting:width}\n"}},
|
||||
}
|
||||
}
|
||||
|
||||
func parsed(t *testing.T, m Manifest) error {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = ParseManifest(raw)
|
||||
return err
|
||||
}
|
||||
|
||||
func TestAnUnsetPreferenceTakesItsDefault(t *testing.T) {
|
||||
m := notifier()
|
||||
for _, layers := range [][]Layer{nil, {{From: MeshWideLayer, Values: map[string]any{}}}} {
|
||||
file := map[string]any{}
|
||||
for k, v := range m.Resources[0] {
|
||||
file[k] = v
|
||||
}
|
||||
if err := settingInto(file, WithDefaults(m, layers), m.Module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if file["content"] != "font = Inter 10\nwidth = 250\n" {
|
||||
t.Fatalf("filled as %q", file["content"])
|
||||
}
|
||||
}
|
||||
if err := JudgeSettings(m, nil, false); err != nil {
|
||||
t.Fatalf("a module whose every key has a default does not compose with no layer: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheNodeOverTheMeshOverTheDefault(t *testing.T) {
|
||||
m := notifier()
|
||||
layers := []Layer{
|
||||
{From: MeshWideLayer, Values: map[string]any{"width": float64(300)}},
|
||||
{From: "laptop", Values: map[string]any{"font-size": float64(13), "width": float64(340)}},
|
||||
}
|
||||
file := map[string]any{"type": "file", "content": m.Resources[0]["content"]}
|
||||
if err := settingInto(file, WithDefaults(m, layers), m.Module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if file["content"] != "font = Inter 13\nwidth = 340\n" {
|
||||
t.Fatalf("filled as %q", file["content"])
|
||||
}
|
||||
file = map[string]any{"type": "file", "content": m.Resources[0]["content"]}
|
||||
if err := settingInto(file, WithDefaults(m, layers[:1]), m.Module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if file["content"] != "font = Inter 10\nwidth = 300\n" {
|
||||
t.Fatalf("the mesh's layer over the default filled as %q", file["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// Composed for a machine, the way a push writes it: the default reaches the file, and the node's
|
||||
// layer overrides it.
|
||||
func TestAComposedMachineGetsTheDefaultAndTheNodesValue(t *testing.T) {
|
||||
r := anAdoptedAnchor()
|
||||
r.Modules = append(r.Modules, notifier())
|
||||
with := anchorRendering(false)
|
||||
composed, err := r.Compose(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if why, left := composed.LeftOut["notifier"]; left {
|
||||
t.Fatalf("the notifier was left out: %s", why)
|
||||
}
|
||||
if c := byID(composed.Resources)["notifier.configuration"]["content"]; c != "font = Inter 10\nwidth = 250\n" {
|
||||
t.Fatalf("composed with no layer as %q", c)
|
||||
}
|
||||
with.Settings["notifier"] = []Layer{{From: "anchor", Values: map[string]any{"font-size": float64(13)}}}
|
||||
if composed, err = r.Compose(with); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := byID(composed.Resources)["notifier.configuration"]["content"]; c != "font = Inter 13\nwidth = 250\n" {
|
||||
t.Fatalf("composed with the node's font size as %q", c)
|
||||
}
|
||||
}
|
||||
|
||||
// Setting a key the module gives a default is not refused as reaching nothing: it overrides the
|
||||
// default, which is how a running module gains a setting with no gap between.
|
||||
func TestAKeyWithADefaultIsNotStray(t *testing.T) {
|
||||
m := notifier()
|
||||
m.Resources[0]["content"] = "font = Inter ${setting:font-size}\nwidth = ${setting:width}\n"
|
||||
stray := UnusedSettings(m, []Layer{{From: "laptop", Values: map[string]any{"font-size": float64(13), "colour": "red"}}})
|
||||
joined := strings.Join(stray, "; ")
|
||||
if strings.Contains(joined, `"font-size"`) || !strings.Contains(joined, `"colour"`) {
|
||||
t.Fatalf("stray: %s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
// A default fills ${setting:…} only: it never becomes a key of a mergeable file (issue 168).
|
||||
func TestADefaultIsNotMergedIntoAJSONFile(t *testing.T) {
|
||||
m := notifier()
|
||||
m.Resources = append(m.Resources, map[string]any{"id": "other", "type": "file", "path": "/x/other.json",
|
||||
"merge": MergeJSON, "content": `{"keep": 1}`})
|
||||
out, err := ApplySettings(m.Resources[1], WithDefaults(m, nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(out["content"].(string), "font-size") {
|
||||
t.Fatalf("a default reached a mergeable file: %s", out["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// A value that is the operator's has no default: no layer setting it is refused by name, as before.
|
||||
func TestAnOperatorsValueWithoutADefaultIsStillRefused(t *testing.T) {
|
||||
m := notifier()
|
||||
m.Resources[0]["content"] = "font = Inter ${setting:font-size}\nwidth = ${setting:width}\nfrom = ${setting:domain}\n"
|
||||
err := JudgeSettings(m, nil, false)
|
||||
if err == nil || !strings.Contains(err.Error(), "${setting:domain}") {
|
||||
t.Fatalf("judged %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), "font-size") {
|
||||
t.Fatalf("a default was named as set today: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheParserTakesAPreferenceAndRefusesTheRest(t *testing.T) {
|
||||
if err := parsed(t, notifier()); err != nil {
|
||||
t.Fatalf("a preference with a default was refused: %v", err)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
key string
|
||||
d SettingDeclaration
|
||||
refuse string
|
||||
}{
|
||||
{"no kind", "font-size", SettingDeclaration{Default: float64(10), Why: "x"}, `only a "preference" has a default`},
|
||||
{"another kind", "font-size", SettingDeclaration{Kind: "operator", Default: float64(10), Why: "x"}, `only a "preference"`},
|
||||
{"no default", "font-size", SettingDeclaration{Kind: KindPreference, Why: "x"}, "no default"},
|
||||
{"an empty default", "font-size", SettingDeclaration{Kind: KindPreference, Default: " ", Why: "x"}, "an empty default"},
|
||||
{"an object", "font-size", SettingDeclaration{Kind: KindPreference, Default: map[string]any{"a": 1.0}, Why: "x"}, "a string, a number or a boolean"},
|
||||
{"no why", "font-size", SettingDeclaration{Kind: KindPreference, Default: float64(10)}, "no why"},
|
||||
{"the operator's", "mail-domain", SettingDeclaration{Kind: KindPreference, Default: "example.tld", Why: "x"}, "is the operator's value"},
|
||||
{"a secret", "api-token", SettingDeclaration{Kind: KindPreference, Default: "x", Why: "x"}, "is the operator's value"},
|
||||
{"the mesh's word", PortsSetting, SettingDeclaration{Kind: KindPreference, Default: float64(1), Why: "x"}, "the mesh reads"},
|
||||
{"read by nothing", "height", SettingDeclaration{Kind: KindPreference, Default: float64(300), Why: "x"}, "reaches nothing"},
|
||||
} {
|
||||
m := notifier()
|
||||
m.Resources[0]["content"] = m.Resources[0]["content"].(string) + "x = ${setting:" + c.key + "}\n"
|
||||
if c.name == "read by nothing" {
|
||||
m.Resources[0]["content"] = "font = Inter ${setting:font-size}\nwidth = ${setting:width}\n"
|
||||
}
|
||||
m.Settings[c.key] = c.d
|
||||
err := parsed(t, m)
|
||||
if err == nil || !strings.Contains(err.Error(), c.refuse) {
|
||||
t.Errorf("%s: parsed %v, want %q", c.name, err, c.refuse)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEveryValueSaysWhereItCameFrom(t *testing.T) {
|
||||
m := notifier()
|
||||
m.Resources[0]["content"] = m.Resources[0]["content"].(string) + "x = ${setting:position}\n"
|
||||
got := Effective(m, []Layer{
|
||||
{From: MeshWideLayer, Values: map[string]any{"width": float64(300), "position": "top-right"}},
|
||||
{From: "laptop", Values: map[string]any{"font-size": float64(13)}},
|
||||
})
|
||||
want := map[string]string{"font-size": "laptop", "position": MeshWideLayer, "width": MeshWideLayer}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("effective: %+v", got)
|
||||
}
|
||||
for _, s := range got {
|
||||
if s.From != want[s.Key] {
|
||||
t.Errorf("%s from %q, want %q", s.Key, s.From, want[s.Key])
|
||||
}
|
||||
}
|
||||
if got[0].Key != "font-size" || got[0].Value != float64(13) || got[0].Default != float64(10) {
|
||||
t.Fatalf("font-size: %+v", got[0])
|
||||
}
|
||||
only := Effective(m, nil)
|
||||
if only[0].From != DefaultLayer || only[0].Value != float64(10) {
|
||||
t.Fatalf("with no layer: %+v", only)
|
||||
}
|
||||
}
|
||||
|
||||
// A node may be called `default`. Its layer is a node's like any other: it overrides the module's
|
||||
// default, it merges into a mergeable file, and it is named among what is set.
|
||||
func TestANodeCalledDefaultIsANodesLayer(t *testing.T) {
|
||||
m := notifier()
|
||||
node := []Layer{{From: DefaultLayer, Values: map[string]any{"font-size": float64(13)}}}
|
||||
file := map[string]any{"type": "file", "content": m.Resources[0]["content"]}
|
||||
if err := settingInto(file, WithDefaults(m, node), m.Module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if file["content"] != "font = Inter 13\nwidth = 250\n" {
|
||||
t.Fatalf("the node called default was dropped: %q", file["content"])
|
||||
}
|
||||
out, err := ApplySettings(map[string]any{"id": "j", "type": "file", "merge": MergeJSON, "content": `{}`}, node)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out["content"].(string), `"font-size": 13`) {
|
||||
t.Fatalf("the node called default did not merge: %s", out["content"])
|
||||
}
|
||||
if got := Effective(m, node); got[0].FromDefault || got[0].Value != float64(13) {
|
||||
t.Fatalf("the node's value read as the default: %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
// The operator's own value is told by what a key's name is about: its last word, or its last two as
|
||||
// a known compound; a plural as its singular; and never a number or a switch.
|
||||
func TestAKeyIsTheOperatorsByWhatItIsAbout(t *testing.T) {
|
||||
for _, key := range []string{"max-tokens", "show-hostname", "ghost-opacity", "users-per-page", "mailbox-size",
|
||||
"font-size", "width", "keyboard-delay", "ipv6-preferred", "client-width", "user-agent", "url-timeout",
|
||||
"site-title", "cert-renewal-days", "name", "font-name", "allow-resize", "use-gpu", "disable-sender-check",
|
||||
"max-recipients", "gateway-timeout", "sender-delay", "upstream-resolvers", "mirror-countries",
|
||||
"pool-region", "proxy-timeout", "listen-backlog", "peer-keepalive", "admin-theme", "log-file",
|
||||
"cache-path"} {
|
||||
if w := operatorsWord(key); w != "" {
|
||||
t.Errorf("%s read as the operator's (%s)", key, w)
|
||||
}
|
||||
}
|
||||
for key, word := range map[string]string{"mail-domain": "domain", "api-key": "key", "admin-password": "password",
|
||||
"oauth-client-id": "client-id", "db-dsn": "dsn", "public-ip": "ip", "puid": "puid", "dns-zone": "zone",
|
||||
"webhook": "webhook", "notify_phone": "phone", "cors.origin": "origin", "smtp-pass": "pass",
|
||||
"backup-passphrase": "passphrase", "data-owner": "owner", "fqdn": "fqdn", "tenant": "tenant", "host": "host",
|
||||
"allowed-hosts": "host", "admin-emails": "email", "tokens": "token", "hostname": "hostname",
|
||||
"apikey": "apikey", "servername": "servername", "tls-cert": "cert", "site": "site", "timezone": "timezone",
|
||||
"bearer": "bearer", "bind-ipv4": "ipv4", "listen-ipv6": "ipv6", "site-name": "site-name",
|
||||
"server-name": "server-name", "public-name": "public-name", "smtp-relay": "smtp-relay",
|
||||
"host-name": "host-name", "user-name": "user-name", "domain-name": "domain-name",
|
||||
"nameserver": "nameserver", "upstream-nameservers": "nameserver", "dns-server": "dns-server",
|
||||
"dns-servers": "dns-server", "default-gateway": "gateway", "lan-subnet": "subnet", "sender": "sender",
|
||||
"notify-recipients": "recipient", "contact": "contact", "tls-certificate": "certificate",
|
||||
"allow-from": "allow-from", "deny-from": "deny-from", "allow-hosts": "host", "use-host": "host",
|
||||
"trusted": "trusted", "allow-list": "allow-list", "ip-whitelist": "whitelist", "peer": "peer",
|
||||
"wireguard-peers": "peer", "bind": "bind", "listen": "listen", "upstream": "upstream", "http-proxy": "proxy",
|
||||
"trusted-proxies": "proxy", "admin": "admin", "notify-admin": "admin", "wake-mac": "mac",
|
||||
"password-file": "password-file", "key-file": "key-file", "token-path": "token-path",
|
||||
"secret-file": "secret-file", "cert-path": "cert-path"} {
|
||||
if w := operatorsWord(key); w != word {
|
||||
t.Errorf("%s: read %q, want %q", key, w, word)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A default is a value the mesh writes, so the installation check reads it, even under a setting
|
||||
// called `description` or `why`; a setting's own why is prose and is not read.
|
||||
func TestTheInstallationCheckReadsADefault(t *testing.T) {
|
||||
m := notifier()
|
||||
m.Settings["relay"] = SettingDeclaration{Kind: KindPreference, Default: "relay.acme.be", Why: "as at relay.acme.be"}
|
||||
m.Settings["description"] = SettingDeclaration{Kind: KindPreference, Default: "notes.acme.be", Why: "x"}
|
||||
problems := strings.Join(InstallationProblems(m), "; ")
|
||||
for _, want := range []string{"relay.acme.be at settings.relay.default", "notes.acme.be at settings.description.default"} {
|
||||
if !strings.Contains(problems, want) {
|
||||
t.Errorf("not reported: %q in %s", want, problems)
|
||||
}
|
||||
}
|
||||
if strings.Contains(problems, "settings.relay.why") {
|
||||
t.Errorf("a why was read as a value: %s", problems)
|
||||
}
|
||||
}
|
||||
|
||||
// A default fills ${setting:…} in what a module contributes and serves, and never replaces a value a
|
||||
// contribution states itself.
|
||||
func TestADefaultFillsAContributionAndAServedFactButNotALiteral(t *testing.T) {
|
||||
m := notifier()
|
||||
m.Settings["site-title"] = SettingDeclaration{Kind: KindPreference, Default: "Notes", Why: "x"}
|
||||
contribution := map[string]any{"title": "${setting:site-title}", "width": "fixed", "port": float64(8080)}
|
||||
got, err := overridden(contribution, WithDefaults(m, nil), "a contribution")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got["title"] != "Notes" || got["width"] != "fixed" {
|
||||
t.Fatalf("contribution: %v", got)
|
||||
}
|
||||
got, err = overridden(contribution, WithDefaults(m, []Layer{{From: "laptop", Values: map[string]any{"width": "wide"}}}), "a contribution")
|
||||
if err != nil || got["width"] != "wide" {
|
||||
t.Fatalf("a node's value did not override a contribution's own key: %v %v", got, err)
|
||||
}
|
||||
served, err := Settle(map[string]any{"name": "${setting:site-title}"}, WithDefaults(m, nil))
|
||||
if err != nil || served["name"] != "Notes" {
|
||||
t.Fatalf("served: %v %v", served, err)
|
||||
}
|
||||
if _, err := Settle(map[string]any{"name": "${setting:site-title}"}, nil); err == nil {
|
||||
t.Fatal("a served fact without the defaults was filled")
|
||||
}
|
||||
}
|
||||
|
||||
// A stored manifest with a key this controller does not know, at the top or inside any block, is read
|
||||
// and its module is left out of every declaration by name; the rest of the catalogue is read; the module
|
||||
// check refuses it. One case per block whose decoder wraps the decoder's words in its own.
|
||||
func TestAStoredManifestWithAnUnknownKeyIsLeftOutAndRegistrationRefusesIt(t *testing.T) {
|
||||
for where, raw := range map[string]string{
|
||||
"the top": `{"module": "later", "version": "2", "a-field-from-later": {"x": 1}}`,
|
||||
"a state": `{"module": "later", "version": "2", "state": [{"name": "s", "a-field-from-later": 1}]}`,
|
||||
"a provided name": `{"module": "later", "version": "2", "provides": [{"name": "p", "a-field-from-later": 1}]}`,
|
||||
"an offer's identity": `{"module": "later", "version": "2", "provides": [{"name": "p", "identity": {"in": "x", "a-field-from-later": 1}}]}`,
|
||||
"a backup": `{"module": "later", "version": "2", "data": {"own": [{"id": "d", "path": "${dir:d}", "class": "valuable", "backup": {"dump": "x", "into": "y", "a-field-from-later": 1}}]}}`,
|
||||
"an own secret": `{"module": "later", "version": "2", "own-secrets": {"s": {"path": "/x", "a-field-from-later": 1}}}`,
|
||||
"a seat's verb": `{"module": "later", "version": "2", "seats": [{"name": "later-seat", "serves": [{"name": "v", "a-field-from-later": 1}]}]}`,
|
||||
} {
|
||||
var m Manifest
|
||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||
t.Errorf("%s: the stored manifest was not read: %v", where, err)
|
||||
continue
|
||||
}
|
||||
if m.Module != "later" || m.Version != "2" || !strings.Contains(m.UnknownField(), `"a-field-from-later"`) {
|
||||
t.Errorf("%s: read as %q %q, unknown %q", where, m.Module, m.Version, m.UnknownField())
|
||||
continue
|
||||
}
|
||||
if where != "the top" && !strings.Contains(m.UnknownField(), ": json: unknown field") {
|
||||
t.Errorf("%s: the block's decoder did not say it: %q", where, m.UnknownField())
|
||||
}
|
||||
left := Resolution{Node: "laptop", Modules: []Manifest{m, notifier()}}.LeftOut(nil, false)
|
||||
if why := left["later"]; !strings.Contains(why, "uses a field this controller does not know") ||
|
||||
!strings.Contains(why, "a-field-from-later") {
|
||||
t.Errorf("%s: not left out by name: %v", where, left)
|
||||
}
|
||||
if _, notifierLeft := left["notifier"]; notifierLeft {
|
||||
t.Errorf("%s: another module was left out with it: %v", where, left)
|
||||
}
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "a-field-from-later") {
|
||||
t.Errorf("%s: the module check took it: %v", where, err)
|
||||
}
|
||||
}
|
||||
var known Manifest
|
||||
if err := json.Unmarshal([]byte(`{"module": "now", "state": [{"name": "s"}]}`), &known); err != nil || known.UnknownField() != "" {
|
||||
t.Fatalf("a known manifest: %v %q", err, known.UnknownField())
|
||||
}
|
||||
// A malformed manifest is still refused: only an unknown key is read past.
|
||||
var bad Manifest
|
||||
if err := json.Unmarshal([]byte(`{"module": "bad", "state": [{"name": 3}]}`), &bad); err == nil {
|
||||
t.Fatal("a malformed stored manifest was read")
|
||||
}
|
||||
}
|
||||
|
||||
// A module left out for a key this controller does not know, inside an entry, is read past that key
|
||||
// alone: it still provides what it provides, its other entries are whole, and a key of the same name
|
||||
// that another entry knows is kept (novox/hq ADR 0262).
|
||||
func TestALeftOutModuleStillProvidesWhatItProvides(t *testing.T) {
|
||||
var m Manifest
|
||||
raw := `{"module": "later", "version": "2",
|
||||
"provides": [{"name": "db", "scope": "mesh"}, {"name": "cache", "a-field-from-later": 1}],
|
||||
"state": [{"name": "s", "history": 3}],
|
||||
"data": {"own": [{"id": "d", "path": "${dir:d}", "class": "valuable", "backup": {"dump": "x", "into": "d", "class": "later"}}]},
|
||||
"resources": [{"id": "d", "type": "directory", "mode": "0700"}]}`
|
||||
if err := json.Unmarshal([]byte(raw), &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m.Provides) != 2 || m.Provides[0].Name != "db" || m.Provides[1].Name != "cache" {
|
||||
t.Fatalf("provides: %+v", m.Provides)
|
||||
}
|
||||
if len(m.State) != 1 || m.State[0].History != 3 {
|
||||
t.Fatalf("state: %+v", m.State)
|
||||
}
|
||||
if m.Data == nil || len(m.Data.Own) != 1 || m.Data.Own[0].Class != "valuable" {
|
||||
t.Fatalf("data: the item's own class was taken for the backup's unknown one: %+v", m.Data)
|
||||
}
|
||||
for _, want := range []string{"provides[1].a-field-from-later", "data.own[0].backup.class"} {
|
||||
if !strings.Contains(m.UnknownField(), want) {
|
||||
t.Errorf("unknown %q does not say it read without %s", m.UnknownField(), want)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(UnknownFieldReason(m), "left out") {
|
||||
t.Fatal(UnknownFieldReason(m))
|
||||
}
|
||||
}
|
||||
|
||||
// A left-out module's data is still copied: the backup holder on its machine keeps its lines while every
|
||||
// other thing of it is left out.
|
||||
func TestALeftOutModulesDataIsStillBackedUp(t *testing.T) {
|
||||
var later Manifest
|
||||
if err := json.Unmarshal([]byte(`{"module": "later", "version": "2", "a-field-from-later": 1,
|
||||
"resources": [{"id": "d", "type": "directory", "mode": "0700"}, {"id": "rc", "type": "file", "path": "/etc/later.conf", "content": "x\n"}],
|
||||
"data": {"own": [{"id": "d", "path": "${dir:d}", "class": "valuable"}]}}`), &later); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
holder := Manifest{Module: "backups", Version: "1",
|
||||
Claims: []Claim{{Name: BackupSeat, Scope: ScopeNode}},
|
||||
Resources: []map[string]any{{"id": "list", "type": "file", "path": "/etc/backups.list", "mode": "0644",
|
||||
"content": "${contribution:" + BackupSeat + ":backup}"}}}
|
||||
r := anAdoptedAnchor()
|
||||
r.Modules = append(r.Modules, holder, later)
|
||||
composed, err := r.Compose(anchorRendering(false))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, left := composed.LeftOut["later"]; !left {
|
||||
t.Fatalf("not left out: %v", composed.LeftOut)
|
||||
}
|
||||
got := byID(composed.Resources)
|
||||
if _, declared := got["later.rc"]; declared {
|
||||
t.Fatal("the left-out module's file is still declared")
|
||||
}
|
||||
list, _ := got["backups.list"]["content"].(string)
|
||||
if !strings.Contains(list, "# later") || !strings.Contains(list, "path /var/lib/later/d") {
|
||||
t.Fatalf("the left-out module's data is no longer backed up:\n%s", list)
|
||||
}
|
||||
}
|
||||
|
||||
// A left-out module's backup lines are best effort: a line that cannot be placed — an access nobody
|
||||
// placed, a placement setting that does not read — is said among what could not be placed, and the
|
||||
// machine is declared (novox/hq ADR 0262).
|
||||
func TestALeftOutModulesUnplaceableBackupLineCostsOnlyThatLine(t *testing.T) {
|
||||
holder := Manifest{Module: "backups", Version: "1",
|
||||
Claims: []Claim{{Name: BackupSeat, Scope: ScopeNode}},
|
||||
Resources: []map[string]any{{"id": "list", "type": "file", "path": "/etc/backups.list", "mode": "0644",
|
||||
"content": "${contribution:" + BackupSeat + ":backup}"}}}
|
||||
media := Manifest{Module: "media", Version: "1",
|
||||
Accesses: []Access{{ID: "library", Mode: "read"}},
|
||||
Data: &Data{Own: []DataItem{{ID: "library", Path: "${access:library}", Class: "valuable"}}}}
|
||||
placedBadly := Manifest{Module: "notes", Version: "1",
|
||||
Resources: []map[string]any{{"id": "d", "type": "directory", "path": "/srv/notes", "mode": "0700"}},
|
||||
Data: &Data{Own: []DataItem{{ID: "d", Path: "${dir:d}", Class: "valuable"}}}}
|
||||
r := anAdoptedAnchor()
|
||||
r.Modules = append(r.Modules, holder, media, placedBadly)
|
||||
with := anchorRendering(false)
|
||||
with.Settings["notes"] = []Layer{LayerOf("anchor", map[string]any{PlacesSetting: "not a map"})}
|
||||
composed, err := r.Compose(with)
|
||||
if err != nil {
|
||||
t.Fatalf("an unplaceable line of a left-out module failed the machine: %v", err)
|
||||
}
|
||||
for _, m := range []string{"media", "notes"} {
|
||||
if _, left := composed.LeftOut[m]; !left {
|
||||
t.Errorf("%s is not left out: %v", m, composed.LeftOut)
|
||||
}
|
||||
}
|
||||
unplaced := strings.Join(composed.Unplaced, "\n")
|
||||
for _, want := range []string{"media's backup for node-backup", "notes's backup for node-backup", "placement setting does not read"} {
|
||||
if !strings.Contains(unplaced, want) {
|
||||
t.Errorf("not said among what could not be placed: %q in\n%s", want, unplaced)
|
||||
}
|
||||
}
|
||||
list, _ := byID(composed.Resources)["backups.list"]["content"].(string)
|
||||
if strings.Contains(list, "/srv/notes") || strings.Contains(list, "${") {
|
||||
t.Fatalf("a line was placed from the definition's default or unfilled:\n%s", list)
|
||||
}
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A declared directory may be owned by the account its module runs as (novox/hq issue 279): a media
|
||||
// manager runs as the puid and pgid its assignment sets per node, and a folder it must write was owned
|
||||
// by the manifest's literal, so the app refused it on a machine whose identity was not the image's.
|
||||
|
||||
func recycler() Manifest {
|
||||
return 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"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func copied(r map[string]any) map[string]any {
|
||||
out := map[string]any{}
|
||||
for k, v := range r {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// The node's identity reaches the directory's owner, the node's layer over the mesh's, a number given
|
||||
// as a string or as a JSON number alike.
|
||||
func TestASettingOwnsADirectory(t *testing.T) {
|
||||
m := recycler()
|
||||
layers := []Layer{
|
||||
{From: "the mesh", Values: map[string]any{"puid": "1000", "pgid": "1000"}},
|
||||
{From: "this node", Values: map[string]any{"puid": float64(1001), "pgid": "2000"}},
|
||||
}
|
||||
r := copied(m.Resources[0])
|
||||
if err := settingInto(r, layers, m.Module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r["owner"] != "1001:2000" {
|
||||
t.Fatalf("owned by %q, not 1001:2000", r["owner"])
|
||||
}
|
||||
user := copied(m.Resources[0])
|
||||
user["owner"] = "${setting:puid}"
|
||||
if err := settingInto(user, layers, m.Module); err != nil || user["owner"] != "1001" {
|
||||
t.Fatalf("user alone: %v, %v", user["owner"], err)
|
||||
}
|
||||
}
|
||||
|
||||
// Composed for a machine, the way a push writes it: the directory reaches the node-engine owned by the
|
||||
// number the node's settings name, never by the placeholder.
|
||||
func TestAComposedMachineGetsTheOwnerTheSettingNames(t *testing.T) {
|
||||
r := anAdoptedAnchor()
|
||||
r.Modules = append(r.Modules, recycler())
|
||||
with := anchorRendering(false)
|
||||
with.Settings["sonarr"] = []Layer{{From: "anchor", Values: map[string]any{"puid": "1001", "pgid": "2000"}}}
|
||||
composed, err := r.Compose(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if why, left := composed.LeftOut["sonarr"]; left {
|
||||
t.Fatalf("left out: %s", why)
|
||||
}
|
||||
got := byID(composed.Resources)["sonarr.recycle-bin"]
|
||||
if got["owner"] != "1001:2000" {
|
||||
t.Fatalf("composed owned by %v, not 1001:2000", got["owner"])
|
||||
}
|
||||
}
|
||||
|
||||
// Only a whole number owns a directory: an account's name, text, a sign, a fraction or a value past the
|
||||
// kernel's ids is refused by name, and the directory is left as it was. So is a key nothing sets.
|
||||
func TestAnOwnerSettingThatIsNoNumberIsRefused(t *testing.T) {
|
||||
m := recycler()
|
||||
for _, bad := range []any{"abc", "root", "", "-1", "+5", "1000:1000", "10 00", "1000\nx", float64(1.5),
|
||||
float64(-3), "4294967295", "99999999999", true} {
|
||||
r := copied(m.Resources[0])
|
||||
err := settingInto(r, []Layer{{From: "ace", Values: map[string]any{"puid": bad, "pgid": "1000"}}}, m.Module)
|
||||
if err == nil || !strings.Contains(err.Error(), `"puid"`) || !strings.Contains(err.Error(), "whole number") {
|
||||
t.Errorf("%v: %v", bad, err)
|
||||
}
|
||||
if r["owner"] != m.Resources[0]["owner"] {
|
||||
t.Errorf("%v: the owner was changed on refusal: %v", bad, r["owner"])
|
||||
}
|
||||
}
|
||||
r := copied(m.Resources[0])
|
||||
if err := settingInto(r, nil, m.Module); err == nil || !strings.Contains(err.Error(), "${setting:puid}") {
|
||||
t.Errorf("nothing set: %v", err)
|
||||
}
|
||||
if err := JudgeSettings(m, []Layer{{From: "ace", Values: map[string]any{"puid": "media", "pgid": "1000"}}}, false); err == nil ||
|
||||
!strings.Contains(err.Error(), "whole number") {
|
||||
t.Errorf("a stored name is not refused when judged: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Where the manifest is read: an owner holding a setting is a whole placeholder or a whole number on
|
||||
// each side of the colon, and every key is one the module asks for elsewhere or declares a default for.
|
||||
func TestAnOwnerSettingIsJudgedWhereTheManifestIsRead(t *testing.T) {
|
||||
ok := []string{"${setting:puid}:${setting:pgid}", "${setting:puid}", "${setting:puid}:1000", "1000:${setting:pgid}",
|
||||
"1000:1000", "media:media"}
|
||||
bad := []string{
|
||||
"${setting:puid}x",
|
||||
"x${setting:puid}",
|
||||
"${setting:puid}:${setting:pgid}:1",
|
||||
"${setting:puid}${setting:pgid}",
|
||||
"media:${setting:pgid}",
|
||||
"${setting:puid}:",
|
||||
"${setting:other}:${setting:pgid}",
|
||||
}
|
||||
for _, owner := range append(ok, bad...) {
|
||||
m := recycler()
|
||||
m.Resources[0] = copied(m.Resources[0])
|
||||
m.Resources[0]["owner"] = owner
|
||||
err := parsed(t, m)
|
||||
refused := err != nil && strings.Contains(err.Error(), "owner")
|
||||
want := false
|
||||
for _, b := range bad {
|
||||
want = want || b == owner
|
||||
}
|
||||
if refused != want {
|
||||
t.Errorf("%s: refused %v, want %v (%v)", owner, refused, want, err)
|
||||
}
|
||||
}
|
||||
// A key with a default is declared, though nothing else asks for it.
|
||||
m := recycler()
|
||||
m.Resources = m.Resources[:1]
|
||||
m.Resources[0] = copied(m.Resources[0])
|
||||
m.Resources[0]["owner"] = "${setting:workers}"
|
||||
m.Settings = map[string]SettingDeclaration{"workers": {Kind: KindPreference, Default: float64(4), Why: "a test"}}
|
||||
if err := parsed(t, m); err != nil && strings.Contains(err.Error(), "owner") {
|
||||
t.Errorf("a declared key was refused: %v", err)
|
||||
}
|
||||
// A file's owner takes no setting: the sweep says so.
|
||||
f := recycler()
|
||||
f.Resources[1] = copied(f.Resources[1])
|
||||
f.Resources[1]["owner"] = "${setting:puid}"
|
||||
if err := parsed(t, f); err == nil || !strings.Contains(err.Error(), "not filled in this field") {
|
||||
t.Errorf("a file's owner setting passed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A key a directory's owner asks for is a destination, so setting it is not called stray.
|
||||
func TestASettingAnOwnerAsksForIsNotStray(t *testing.T) {
|
||||
m := recycler()
|
||||
m.Resources = m.Resources[:1]
|
||||
stray := strings.Join(UnusedSettings(m, []Layer{{From: "ace", Values: map[string]any{"puid": "1001", "pgid": "2000"}}}), "; ")
|
||||
if strings.Contains(stray, "puid") || strings.Contains(stray, "pgid") {
|
||||
t.Errorf("called stray: %s", stray)
|
||||
}
|
||||
}
|
||||
|
||||
// An owner is written as the number parsed: "0001" and "00" reach the node-engine as 1 and 0, never as text.
|
||||
// Uid 0 is allowed, because a key an owner asks for is the terminal's alone.
|
||||
func TestAnOwnerSettingIsWrittenAsTheNumber(t *testing.T) {
|
||||
m := recycler()
|
||||
r := copied(m.Resources[0])
|
||||
if err := settingInto(r, []Layer{{From: "ace", Values: map[string]any{"puid": "0001", "pgid": "00"}}}, m.Module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r["owner"] != "1:0" {
|
||||
t.Fatalf("owned by %q, not 1:0", r["owner"])
|
||||
}
|
||||
}
|
||||
|
||||
// Every key a directory's owner asks for is the controller's terminal's alone (novox/hq issue 279, issue 339):
|
||||
// root chowns the folder to it, so it is terminal-only whatever else asks for it — a file nothing trusts, a
|
||||
// default, or nothing at all.
|
||||
func TestAnOwnersKeyIsTheTerminals(t *testing.T) {
|
||||
m := recycler()
|
||||
m.Resources[1] = copied(m.Resources[1])
|
||||
m.Resources[1]["trusted"] = false
|
||||
m.Settings = map[string]SettingDeclaration{"pgid": {Kind: KindPreference, Default: "1000", Why: "a test"}}
|
||||
keys := strings.Join(TerminalKeys(m), ",")
|
||||
for _, k := range []string{"puid", "pgid"} {
|
||||
if !strings.Contains(","+keys+",", ","+k+",") {
|
||||
t.Errorf("%s is not the terminal's: %s", k, keys)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A key asked only in a template is not refused while the manifest still names its templates (issue 817), and
|
||||
// is judged once they are filled: WithTemplatesFrom over a tree whose template does not ask for it refuses it.
|
||||
func TestAnOwnerSettingAskedOnlyInATemplateIsJudgedOnceFilled(t *testing.T) {
|
||||
raw := `{"module":"sonarr","version":"1",
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[{"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","template":"files/identity.env"}]}`
|
||||
m, err := ParseManifest([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatalf("a templated manifest was refused: %v", err)
|
||||
}
|
||||
write := func(text string) string {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "files"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "files", "identity.env"), []byte(text), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return dir
|
||||
}
|
||||
if _, err := WithTemplatesFrom(m, write("PUID=${setting:puid}\nPGID=${setting:pgid}\n")); err != nil {
|
||||
t.Errorf("a template asking for both keys was refused: %v", err)
|
||||
}
|
||||
_, err = WithTemplatesFrom(m, write("NOTHING=1\n"))
|
||||
if err == nil || !strings.Contains(err.Error(), "asks for \"puid\" nowhere else") {
|
||||
t.Errorf("a template not asking for puid was not refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A setting may name the unit a service resource holds: a module that stops the distribution's own timer
|
||||
// for the pool the operator names cannot write the pool into its definition (novox/hq ADR 0112), and a
|
||||
// unit name with ${setting:…} left in it is a unit no machine has, so the apply fails far from its cause.
|
||||
|
||||
func scrubber() Manifest {
|
||||
return Manifest{Module: "zfs",
|
||||
Settings: map[string]SettingDeclaration{
|
||||
"scrub-cadence": {Kind: KindPreference, Default: "weekly", Why: "what the distribution's timer did"},
|
||||
},
|
||||
Resources: []map[string]any{
|
||||
{"id": "distribution-weekly", "type": "service", "unit": "zfs-scrub-weekly@${setting:scrub-pool}.timer",
|
||||
"state": "stopped", "boot": "disabled"},
|
||||
{"id": "distribution-monthly", "type": "service", "unit": "zfs-scrub-monthly@${setting:scrub-pool}.timer",
|
||||
"state": "stopped", "boot": "disabled"},
|
||||
{"id": "scrub-config", "type": "file", "path": "/etc/zfs-tools/scrub.conf",
|
||||
"content": "pool=${setting:scrub-pool}\ncadence=${setting:scrub-cadence}\n"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestASettingNamesAServicesUnit(t *testing.T) {
|
||||
m := scrubber()
|
||||
layers := WithDefaults(m, []Layer{{From: "ace", Values: map[string]any{"scrub-pool": "storage", "scrub-cadence": "monthly"}}})
|
||||
for i, want := range []string{"zfs-scrub-weekly@storage.timer", "zfs-scrub-monthly@storage.timer"} {
|
||||
r := map[string]any{}
|
||||
for k, v := range m.Resources[i] {
|
||||
r[k] = v
|
||||
}
|
||||
if err := settingInto(r, layers, m.Module); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r["unit"] != want {
|
||||
t.Errorf("composed as %q, not %q", r["unit"], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Composed for a machine, the way a push writes it: the operator's pool and cadence reach the units' names
|
||||
// and the file.
|
||||
func TestAComposedMachineGetsTheUnitTheSettingNames(t *testing.T) {
|
||||
r := anAdoptedAnchor()
|
||||
r.Modules = append(r.Modules, scrubber())
|
||||
with := anchorRendering(false)
|
||||
with.Settings["zfs"] = []Layer{{From: "anchor", Values: map[string]any{"scrub-pool": "storage", "scrub-cadence": "monthly"}}}
|
||||
composed, err := r.Compose(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if why, left := composed.LeftOut["zfs"]; left {
|
||||
t.Fatalf("left out: %s", why)
|
||||
}
|
||||
got := byID(composed.Resources)
|
||||
for id, want := range map[string]string{"zfs.distribution-weekly": "zfs-scrub-weekly@storage.timer",
|
||||
"zfs.distribution-monthly": "zfs-scrub-monthly@storage.timer"} {
|
||||
if got[id]["unit"] != want {
|
||||
t.Errorf("%s composed as %v, not %s", id, got[id]["unit"], want)
|
||||
}
|
||||
t.Logf("%s: %v", id, got[id]["unit"])
|
||||
}
|
||||
if c := got["zfs.scrub-config"]["content"]; c != "pool=storage\ncadence=monthly\n" {
|
||||
t.Errorf("the file: %q", c)
|
||||
}
|
||||
}
|
||||
|
||||
// A value that would not make a unit's name is refused by name, never written: a space, a slash, a
|
||||
// newline that would begin a directive, or a second suffix.
|
||||
func TestASettingThatMakesNoUnitNameIsRefused(t *testing.T) {
|
||||
m := scrubber()
|
||||
for _, bad := range []string{"", "stor age", "a/b", "storage\nExecStart=/bin/sh", "a@b", "$(x)", "*", `a\\x2d`} {
|
||||
r := map[string]any{}
|
||||
for k, v := range m.Resources[0] {
|
||||
r[k] = v
|
||||
}
|
||||
err := settingInto(r, []Layer{{From: "ace", Values: map[string]any{"scrub-pool": bad}}}, m.Module)
|
||||
if err == nil || !strings.Contains(err.Error(), "scrub-pool") || !strings.Contains(err.Error(), "unit") {
|
||||
t.Errorf("%q: %v", bad, err)
|
||||
}
|
||||
if r["unit"] != m.Resources[0]["unit"] {
|
||||
t.Errorf("%q: the unit was changed on refusal: %v", bad, r["unit"])
|
||||
}
|
||||
}
|
||||
r := map[string]any{}
|
||||
for k, v := range m.Resources[0] {
|
||||
r[k] = v
|
||||
}
|
||||
if err := settingInto(r, nil, m.Module); err == nil || !strings.Contains(err.Error(), "${setting:scrub-pool}") {
|
||||
t.Errorf("nothing set: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A key a unit's name asks for is a destination, so setting it is not called stray, and judging the
|
||||
// settings sees it.
|
||||
func TestASettingAUnitAsksForIsNotStrayAndIsJudged(t *testing.T) {
|
||||
m := scrubber()
|
||||
stray := strings.Join(UnusedSettings(m, []Layer{{From: "ace", Values: map[string]any{"scrub-pool": "storage"}}}), "; ")
|
||||
if strings.Contains(stray, "scrub-pool") {
|
||||
t.Errorf("called stray: %s", stray)
|
||||
}
|
||||
if err := JudgeSettings(m, nil, false); err == nil || !strings.Contains(err.Error(), "scrub-pool") {
|
||||
t.Errorf("a unit's setting nothing sets is not refused when judged: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Third review: a setting may name only a template's instance — right after the `@`, before the final
|
||||
// suffix, and the whole instance — so a value can never make the unit another unit, nor another kind.
|
||||
// Refused where the manifest is read, near its author.
|
||||
func TestASettingInAUnitNameIsOnlyATemplatesInstance(t *testing.T) {
|
||||
ok := []string{"zfs-scrub-weekly@${setting:scrub-pool}.timer", "getty@${setting:tty}.service"}
|
||||
bad := []string{
|
||||
"${setting:unit}",
|
||||
"${setting:name}.timer",
|
||||
"zfs-scrub-${setting:cadence}@storage.timer",
|
||||
"zfs-scrub@${setting:pool}-x.timer",
|
||||
"zfs-scrub@x-${setting:pool}.timer",
|
||||
"zfs-scrub@${setting:pool}.${setting:kind}",
|
||||
"zfs-scrub@${setting:pool}",
|
||||
"zfs-scrub@${setting:a}${setting:b}.timer",
|
||||
}
|
||||
for _, unit := range append(ok, bad...) {
|
||||
m := Manifest{Module: "zfs", Resources: []map[string]any{{"id": "t", "type": "service", "unit": unit, "state": "stopped"}}}
|
||||
err := parsed(t, m)
|
||||
refused := err != nil && strings.Contains(err.Error(), "instance")
|
||||
want := false
|
||||
for _, b := range bad {
|
||||
want = want || b == unit
|
||||
}
|
||||
if refused != want {
|
||||
t.Errorf("%s: refused %v, want %v (%v)", unit, refused, want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnInstanceValueMayNotLeadWithADashNorBeLong(t *testing.T) {
|
||||
m := scrubber()
|
||||
for _, bad := range []string{"-storage", "--help", strings.Repeat("a", 65)} {
|
||||
r := map[string]any{}
|
||||
for k, v := range m.Resources[0] {
|
||||
r[k] = v
|
||||
}
|
||||
err := settingInto(r, []Layer{{From: "ace", Values: map[string]any{"scrub-pool": bad}}}, m.Module)
|
||||
if err == nil || !strings.Contains(err.Error(), "scrub-pool") {
|
||||
t.Errorf("%q: %v", bad, err)
|
||||
}
|
||||
}
|
||||
r := map[string]any{}
|
||||
for k, v := range m.Resources[0] {
|
||||
r[k] = v
|
||||
}
|
||||
if err := settingInto(r, []Layer{{From: "ace", Values: map[string]any{"scrub-pool": strings.Repeat("a", 64)}}}, m.Module); err != nil {
|
||||
t.Errorf("64 characters: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// An operator's value reaches a file from the assignment's settings, the node's layer over the
|
||||
// mesh's (novox/hq ADR 0112, ADR 0155), and a value nothing set is refused by name.
|
||||
func TestASettingReachesAFileFromTheLayers(t *testing.T) {
|
||||
file := map[string]any{"id": "env", "type": "file", "path": "/x/mail.env",
|
||||
"content": "DOMAIN=${setting:domain}\nSITENAME=${setting:sitename}\nWORKERS=${setting:workers}\n"}
|
||||
layers := []Layer{
|
||||
{From: "the mesh", Values: map[string]any{"domain": "example.tld", "sitename": "Mesh", "workers": float64(4)}},
|
||||
{From: "this node", Values: map[string]any{"sitename": "This one"}},
|
||||
}
|
||||
if err := settingInto(file, layers, "mail"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if file["content"] != "DOMAIN=example.tld\nSITENAME=This one\nWORKERS=4\n" {
|
||||
t.Fatalf("filled as %q", file["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestASettingNothingSetIsRefusedByName(t *testing.T) {
|
||||
file := map[string]any{"id": "env", "type": "file", "content": "DOMAIN=${setting:domain}\n"}
|
||||
err := settingInto(file, []Layer{{From: "the mesh", Values: map[string]any{"other": "x"}}}, "mail")
|
||||
if err == nil {
|
||||
t.Fatal("a setting nothing set was written as something")
|
||||
}
|
||||
for _, want := range []string{"${setting:domain}", "settings set mail", "set today: other"} {
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Fatalf("the refusal lacks %q: %v", want, err)
|
||||
}
|
||||
}
|
||||
// Left as it was: the literal placeholder must never reach a machine.
|
||||
if file["content"] != "DOMAIN=${setting:domain}\n" {
|
||||
t.Fatalf("content was changed on refusal: %q", file["content"])
|
||||
}
|
||||
}
|
||||
|
||||
// A key a file asks for is a destination, so setting it is not called stray.
|
||||
func TestASettingAFileAsksForIsNotStray(t *testing.T) {
|
||||
m := Manifest{Module: "mail", Resources: []map[string]any{
|
||||
{"id": "env", "type": "file", "content": "DOMAIN=${setting:domain}\n"},
|
||||
}}
|
||||
layers := []Layer{{From: "the mesh", Values: map[string]any{"domain": "example.tld", "stray": "x"}}}
|
||||
unused := strings.Join(UnusedSettings(m, layers), "; ")
|
||||
if strings.Contains(unused, `"domain"`) {
|
||||
t.Fatalf("a key a file asks for was called stray: %s", unused)
|
||||
}
|
||||
if !strings.Contains(unused, `"stray"`) {
|
||||
t.Fatalf("a key nothing reads was not named: %s", unused)
|
||||
}
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// twoFiles is a module that owns two configurable files, which issue 168 said no module could:
|
||||
// every key of every layer reached both.
|
||||
func twoFiles(app, sidecar []any) Manifest {
|
||||
appFile := map[string]any{"id": "app", "type": "file", "path": "/etc/app.json", "merge": MergeJSON,
|
||||
"content": `{"server":{"port":8080},"search":{"safe":1}}`}
|
||||
sideFile := map[string]any{"id": "sidecar", "type": "file", "path": "/etc/sidecar.json", "merge": MergeJSON,
|
||||
"content": `{"url":"http://127.0.0.1:8080"}`}
|
||||
if app != nil {
|
||||
appFile[TakesSettingsField] = app
|
||||
}
|
||||
if sidecar != nil {
|
||||
sideFile[TakesSettingsField] = sidecar
|
||||
}
|
||||
return Manifest{Module: "search", Resources: []map[string]any{
|
||||
appFile, sideFile,
|
||||
{"id": "env", "type": "file", "path": "/etc/search.env", "content": "SITE=${setting:site-name}\n"},
|
||||
}}
|
||||
}
|
||||
|
||||
func settledContent(t *testing.T, resource map[string]any, layers []Layer) map[string]any {
|
||||
t.Helper()
|
||||
out, err := ApplySettings(resource, layers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, kept := out[TakesSettingsField]; kept {
|
||||
t.Errorf("%v still carries %s; the host knows no such field", out["id"], TakesSettingsField)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(out["content"].(string)), &parsed); err != nil {
|
||||
t.Fatalf("%v is not JSON after settling: %v", out["id"], err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func TestEachMergeableFileTakesOnlyTheKeysItNames(t *testing.T) {
|
||||
// novox/hq ADR 0326 §1, issue 168: two configurable files, each taking its own keys. A key for
|
||||
// the other file, for a ${setting:…} elsewhere or for the mesh itself lands in neither.
|
||||
m := twoFiles([]any{"server", "search"}, []any{"url"})
|
||||
layers := []Layer{
|
||||
{From: "the mesh", Values: map[string]any{
|
||||
"server": map[string]any{"base_url": "https://search.example.tld/"},
|
||||
"url": "http://127.0.0.1:9090", "site-name": "Search",
|
||||
"endpoints": map[string]any{"web": map[string]any{"reach": "public"}}}},
|
||||
{From: "node-a", Values: map[string]any{"search": map[string]any{"safe": 0.0}}},
|
||||
}
|
||||
|
||||
app := settledContent(t, m.Resources[0], layers)
|
||||
for _, stray := range []string{"url", "site-name", "endpoints"} {
|
||||
if _, in := app[stray]; in {
|
||||
t.Errorf("app took %q, which it does not name: %v", stray, app)
|
||||
}
|
||||
}
|
||||
server, _ := app["server"].(map[string]any)
|
||||
if server["base_url"] != "https://search.example.tld/" || server["port"] != 8080.0 {
|
||||
t.Errorf("app's server block is %v; the setting should merge into the file's own", server)
|
||||
}
|
||||
if search, _ := app["search"].(map[string]any); search["safe"] != 0.0 {
|
||||
t.Errorf("app's search block is %v; the node's layer should win", search)
|
||||
}
|
||||
|
||||
side := settledContent(t, m.Resources[1], layers)
|
||||
if side["url"] != "http://127.0.0.1:9090" {
|
||||
t.Errorf("sidecar's url is %v; it names url", side["url"])
|
||||
}
|
||||
if len(side) != 1 {
|
||||
t.Errorf("sidecar took keys it does not name: %v", side)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEmptyListTakesNoSetting(t *testing.T) {
|
||||
// ADR 0164 §12: a file that names no setting takes none.
|
||||
m := twoFiles([]any{"server"}, []any{})
|
||||
side := settledContent(t, m.Resources[1], []Layer{{From: "the mesh", Values: map[string]any{
|
||||
"server": map[string]any{"port": 1.0}, "url": "x"}}})
|
||||
if len(side) != 1 || side["url"] != "http://127.0.0.1:8080" {
|
||||
t.Errorf("a file taking none was changed: %v", side)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFileThatNamesNothingKeepsTodaysForm(t *testing.T) {
|
||||
// ADR 0326 §3, ADR 0164 §15: a module none of whose files names keys is left as it was.
|
||||
m := twoFiles(nil, nil)
|
||||
app := settledContent(t, m.Resources[0], []Layer{{From: "the mesh", Values: map[string]any{"url": "x"}}})
|
||||
if app["url"] != "x" {
|
||||
t.Errorf("a silent file should still take every key until its module names them: %v", app)
|
||||
}
|
||||
if unused := UnusedSettings(m, []Layer{{From: "the mesh", Values: map[string]any{"anything": 1}}}); len(unused) != 0 {
|
||||
t.Errorf("today's form has nothing stray, and said: %v", unused)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAKeyNoFileNamesIsStray(t *testing.T) {
|
||||
// ADR 0326 §5: once the files name their keys, a key none of them names — and nothing else
|
||||
// takes — is stray, and is said with what each file takes.
|
||||
m := twoFiles([]any{"server", "search"}, []any{"url"})
|
||||
unused := UnusedSettings(m, []Layer{{From: "the mesh", Values: map[string]any{
|
||||
"server": 1, "url": "x", "site-name": "Search", "ui": map[string]any{"theme": "dark"}}}})
|
||||
if len(unused) != 1 || !strings.Contains(unused[0], `"ui"`) {
|
||||
t.Fatalf("only ui reaches nothing; named: %v", unused)
|
||||
}
|
||||
if !strings.Contains(unused[0], "app takes server, search") || !strings.Contains(unused[0], "sidecar takes url") {
|
||||
t.Errorf("the stray key's message does not say what each file takes: %s", unused[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOneNamingFileMakesEveryMergeableFileName(t *testing.T) {
|
||||
// ADR 0326 §2: a module with one naming and one silent mergeable file is refused, naming the
|
||||
// silent one — the silent one would otherwise still take every key.
|
||||
m := twoFiles([]any{"server"}, nil)
|
||||
problems := TakenSettingsProblems(m)
|
||||
if len(problems) != 1 || !strings.Contains(problems[0], "sidecar merges and names no takes-settings") {
|
||||
t.Fatalf("problems: %v", problems)
|
||||
}
|
||||
if err := JudgeSettings(m, nil, false); err == nil || !strings.Contains(err.Error(), "sidecar") {
|
||||
t.Errorf("composing it should refuse it too, and said: %v", err)
|
||||
}
|
||||
raw, err := json.Marshal(map[string]any{"module": m.Module, "version": "1", "resources": m.Resources})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ParseManifest(raw); err == nil || !strings.Contains(err.Error(), "sidecar merges and names no") {
|
||||
t.Errorf("the module check does not say it: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTakesSettingsIsRefusedWhereItCannotMeanAnything(t *testing.T) {
|
||||
// ADR 0326 §4.
|
||||
cases := map[string]struct {
|
||||
resource map[string]any
|
||||
says string
|
||||
}{
|
||||
"on a file that does not merge": {
|
||||
map[string]any{"id": "plain", "type": "file", "path": "/x", "content": "x",
|
||||
TakesSettingsField: []any{"a"}},
|
||||
"does not merge"},
|
||||
"not a list": {
|
||||
map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": MergeJSON, "content": "{}",
|
||||
TakesSettingsField: "server"},
|
||||
"is a list of setting keys"},
|
||||
"a mesh word": {
|
||||
map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": MergeJSON, "content": "{}",
|
||||
TakesSettingsField: []any{"endpoints"}},
|
||||
"the mesh reads"},
|
||||
"a protected key": {
|
||||
map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": MergeJSON, "content": "{}",
|
||||
"protected": []any{"secret"}, TakesSettingsField: []any{"secret"}},
|
||||
"keeps it as protected"},
|
||||
"named twice": {
|
||||
map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": MergeJSON, "content": "{}",
|
||||
TakesSettingsField: []any{"a", "a"}},
|
||||
"twice"},
|
||||
"not a key": {
|
||||
map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": MergeJSON, "content": "{}",
|
||||
TakesSettingsField: []any{"two words"}},
|
||||
"not a usable setting key"},
|
||||
}
|
||||
for name, c := range cases {
|
||||
problems := TakenSettingsProblems(Manifest{Module: "m", Resources: []map[string]any{c.resource}})
|
||||
if !containsText(problems, c.says) {
|
||||
t.Errorf("%s: want a problem saying %q, got %v", name, c.says, problems)
|
||||
}
|
||||
}
|
||||
// And a file that does not merge is refused where it is settled, not handed to the host.
|
||||
plain := map[string]any{"id": "plain", "type": "file", "path": "/x", "content": "x",
|
||||
TakesSettingsField: []any{"a"}}
|
||||
if _, err := ApplySettings(plain, nil); err == nil {
|
||||
t.Error("a file that does not merge and names keys was settled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestASoftwareSpelledKeyIsUsable(t *testing.T) {
|
||||
// A file's keys are its software's: nodered's timeZone is not lower case and is still a key.
|
||||
m := Manifest{Module: "flows", Resources: []map[string]any{{"id": "settings", "type": "file",
|
||||
"path": "/x", "merge": MergeJSON, "content": "{}", TakesSettingsField: []any{"timeZone"}}}}
|
||||
if problems := TakenSettingsProblems(m); len(problems) != 0 {
|
||||
t.Errorf("timeZone was refused: %v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
func containsText(list []string, text string) bool {
|
||||
for _, s := range list {
|
||||
if strings.Contains(s, text) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestAModuleThatDeclaresASettingNamesItsFilesKeys(t *testing.T) {
|
||||
// ADR 0326 §3, ADR 0164 §15, novox/hq issue 574: only a module that declares no settings keeps
|
||||
// today's form. One that declares a preference and owns a silent mergeable file is refused where
|
||||
// a definition is written — and read and composed where a stored one is read again, so a build kept
|
||||
// from before the rule is still sent and still put back.
|
||||
declaring := func(app, sidecar []any) Manifest {
|
||||
m := twoFiles(app, sidecar)
|
||||
m.Resources[2]["content"] = "WORKERS=${setting:workers}\n"
|
||||
m.Settings = map[string]SettingDeclaration{"workers": {Kind: KindPreference, Default: 4.0, Why: "a number"}}
|
||||
return m
|
||||
}
|
||||
m := declaring(nil, nil)
|
||||
problems := WrittenSettingsProblems(m)
|
||||
if len(problems) != 2 || !containsText(problems, "mergeable app names no takes-settings") ||
|
||||
!containsText(problems, "mergeable sidecar names no takes-settings") {
|
||||
t.Errorf("where it is written: %v", problems)
|
||||
}
|
||||
if problems := TakenSettingsProblems(m); len(problems) != 0 {
|
||||
t.Errorf("what a stored definition is read by refuses it: %v", problems)
|
||||
}
|
||||
raw, err := json.Marshal(map[string]any{"module": m.Module, "version": "1", "resources": m.Resources,
|
||||
"settings": map[string]any{"workers": map[string]any{"kind": KindPreference, "default": 4, "why": "a number"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
read, err := ParseManifest(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("a stored build from before the rule is not read again: %v", err)
|
||||
}
|
||||
if err := JudgeSettings(read, nil, false); err != nil {
|
||||
t.Errorf("a stored definition is composed as before, and was refused: %v", err)
|
||||
}
|
||||
|
||||
if problems := WrittenSettingsProblems(declaring([]any{"server"}, []any{})); len(problems) != 0 {
|
||||
t.Errorf("every mergeable file names its keys, and was refused: %v", problems)
|
||||
}
|
||||
if problems := WrittenSettingsProblems(twoFiles(nil, nil)); len(problems) != 0 {
|
||||
t.Errorf("a module declaring no setting was refused: %v", problems)
|
||||
}
|
||||
// One naming file and one silent: §2 names the silent one, and §3 does not name it again.
|
||||
mixed := declaring([]any{"server"}, nil)
|
||||
if written, taken := WrittenSettingsProblems(mixed), TakenSettingsProblems(mixed); len(written) != 0 || len(taken) != 1 {
|
||||
t.Errorf("the silent file is reported %d times by §3 and %d by §2; want once, by §2: %v %v",
|
||||
len(written), len(taken), written, taken)
|
||||
}
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A setting is judged where it is stored, and an impossible one costs a module, not a machine
|
||||
// (novox/hq ADR 0163, rule 6): the one judgement, used by SetSettings before storing and by
|
||||
// Compose when a definition has moved under a stored setting.
|
||||
func TestASettingThatCannotComposeIsRefusedByNameAndLeavesOnlyItsModuleOut(t *testing.T) {
|
||||
web := Manifest{Module: "hello-web",
|
||||
Listens: []Listening{{Port: 8080, From: FromEverywhere}},
|
||||
Resources: []map[string]any{{"id": "server", "type": "container", "name": "hello-web",
|
||||
"ports": []any{"8080"}}}}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
layer map[string]any
|
||||
refuse string
|
||||
}{
|
||||
{"a port the module does not publish", map[string]any{PortsSetting: map[string]any{"9999": 10000}},
|
||||
"hello-web gives port 9999 a machine port, and no container of its publishes 9999"},
|
||||
{"a mesh-wide port", map[string]any{PortsSetting: map[string]any{"8080": 10000}},
|
||||
"a port is a fact about one machine"},
|
||||
} {
|
||||
from := "anchor"
|
||||
if c.name == "a mesh-wide port" {
|
||||
from = MeshWideLayer
|
||||
}
|
||||
err := JudgeSettings(web, []Layer{LayerOf(from, c.layer)}, true)
|
||||
if err == nil || !strings.Contains(err.Error(), c.refuse) {
|
||||
t.Errorf("%s: judged %v, want %q", c.name, err, c.refuse)
|
||||
}
|
||||
}
|
||||
if err := JudgeSettings(web, []Layer{{From: "anchor", Placement: map[string]any{PortsSetting: map[string]any{"8080": 10000}}}}, true); err != nil {
|
||||
t.Fatalf("a port the module publishes was refused: %v", err)
|
||||
}
|
||||
|
||||
// Composed, a module whose stored setting no longer works is left out by name; the rest of
|
||||
// the machine is declared.
|
||||
r := anAdoptedAnchor()
|
||||
with := anchorRendering(false)
|
||||
with.Settings = SettingsBy{"hello-web": {{From: "anchor", Placement: map[string]any{PortsSetting: map[string]any{"9999": 10000}}}}}
|
||||
composed, err := r.Compose(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
why, left := composed.LeftOut["hello-web"]
|
||||
if !left || !strings.Contains(why, "no container of its publishes 9999") {
|
||||
t.Fatalf("hello-web is not left out by name: %v", composed.LeftOut)
|
||||
}
|
||||
if len(composed.LeftOut) != 1 {
|
||||
t.Fatalf("more than hello-web is left out: %v", composed.LeftOut)
|
||||
}
|
||||
got := byID(composed.Resources)
|
||||
if _, declared := got["hello-web.server"]; declared {
|
||||
t.Fatal("the left-out module's container is still declared")
|
||||
}
|
||||
if _, declared := got["distribution.store"]; !declared {
|
||||
t.Fatal("the rest of the machine was not declared")
|
||||
}
|
||||
if left := r.LeftOut(with.Settings, false); len(left) != 1 || left["hello-web"] == "" {
|
||||
t.Fatalf("the judgement a plan reads differs from what compose did: %v", left)
|
||||
}
|
||||
}
|
||||
|
||||
// A taken container keeps a found network by a per-machine setting (novox/hq ADR 0163, rule 4):
|
||||
// on an adopted machine only, for a container the module declares, and it reaches the container's
|
||||
// declaration as the networks it also joins.
|
||||
func TestAKeptNetworkReachesTheContainerOnAnAdoptedMachineOnly(t *testing.T) {
|
||||
r := anAdoptedAnchor()
|
||||
keep := SettingsBy{"hello-web": {LayerOf("anchor",
|
||||
map[string]any{NetworksSetting: map[string]any{"server": []any{"predecessor_default"}}})}}
|
||||
|
||||
with := anchorRendering(true)
|
||||
with.Settings = keep
|
||||
composed, err := r.Compose(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(composed.LeftOut) != 0 {
|
||||
t.Fatalf("a kept network left a module out: %v", composed.LeftOut)
|
||||
}
|
||||
server := byID(composed.Resources)["hello-web.server"]
|
||||
networks, _ := server["networks"].([]any)
|
||||
if len(networks) != 1 || networks[0] != "predecessor_default" {
|
||||
t.Fatalf("the container does not join the kept network: %v", server)
|
||||
}
|
||||
if _, has := byID(composed.Resources)["distribution.store"]["networks"]; has {
|
||||
t.Fatal("another container joins a network nobody kept for it")
|
||||
}
|
||||
|
||||
// Converged, the setting reaches nothing it was for, and the module is left out saying so.
|
||||
with = anchorRendering(false)
|
||||
with.Settings = keep
|
||||
composed, err = r.Compose(with)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if why := composed.LeftOut["hello-web"]; !strings.Contains(why, "anchor is converged") {
|
||||
t.Fatalf("a kept network on a converged machine: %v", composed.LeftOut)
|
||||
}
|
||||
|
||||
web := r.Modules[4]
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
layer Layer
|
||||
want string
|
||||
}{
|
||||
{"mesh-wide", LayerOf(MeshWideLayer, map[string]any{NetworksSetting: map[string]any{"server": []any{"x"}}}),
|
||||
"a found network is a fact about one machine"},
|
||||
{"an unknown container", LayerOf("anchor", map[string]any{NetworksSetting: map[string]any{"db": []any{"x"}}}),
|
||||
`names the container "db", which it does not declare`},
|
||||
{"not a list", LayerOf("anchor", map[string]any{NetworksSetting: map[string]any{"server": "x"}}),
|
||||
"is a list of network names"},
|
||||
{"not a network name", LayerOf("anchor", map[string]any{NetworksSetting: map[string]any{"server": []any{"a/b"}}}),
|
||||
"which is not a network name"},
|
||||
} {
|
||||
_, err := KeptNetworks(web, []Layer{c.layer}, true)
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: %v, want %q", c.name, err, c.want)
|
||||
}
|
||||
}
|
||||
if kept, err := KeptNetworks(web, nil, false); err != nil || kept != nil {
|
||||
t.Fatalf("no setting: %v %v", kept, err)
|
||||
}
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func file(content string, protected ...string) map[string]any {
|
||||
r := map[string]any{"id": "conf", "type": "file", "path": "/etc/thing.json",
|
||||
"merge": MergeJSON, "content": content}
|
||||
if len(protected) > 0 {
|
||||
var as []any
|
||||
for _, p := range protected {
|
||||
as = append(as, p)
|
||||
}
|
||||
r["protected"] = as
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func merged(t *testing.T, resource map[string]any, layers ...Layer) map[string]any {
|
||||
t.Helper()
|
||||
out, err := ApplySettings(resource, layers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var parsed map[string]any
|
||||
if err := json.Unmarshal([]byte(out["content"].(string)), &parsed); err != nil {
|
||||
t.Fatalf("the merged file is not JSON: %v", err)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func TestASettingBeatsTheModulesDefault(t *testing.T) {
|
||||
// The whole rule. A setting is a statement about that key made deliberately; the default was
|
||||
// only ever what to do in the absence of one. So there is nothing to resolve.
|
||||
got := merged(t, file(`{"port":8080,"log":"info"}`),
|
||||
Layer{From: "node", Values: map[string]any{"port": 9090.0}})
|
||||
|
||||
if got["port"] != 9090.0 {
|
||||
t.Errorf("port is %v; the setting should have won", got["port"])
|
||||
}
|
||||
if got["log"] != "info" {
|
||||
t.Errorf("log is %v; a key nobody set should keep the module's value", got["log"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpstreamKeepsTheKeysNobodySet(t *testing.T) {
|
||||
// The point of merging rather than replacing: the module can change its half freely and only
|
||||
// the keys somebody actually cares about are pinned.
|
||||
got := merged(t, file(`{"port":8080,"log":"info","workers":4}`),
|
||||
Layer{From: "node", Values: map[string]any{"log": "debug"}})
|
||||
|
||||
if got["port"] != 8080.0 || got["workers"] != 4.0 {
|
||||
t.Errorf("the module's other keys did not survive: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheNodeBeatsTheMesh(t *testing.T) {
|
||||
// Two layers, in order. A node that differs is expressed by differing, rather than by
|
||||
// repeating everything the rest of the mesh already says.
|
||||
got := merged(t, file(`{"log":"info"}`),
|
||||
Layer{From: "mesh", Values: map[string]any{"log": "warn", "workers": 8.0}},
|
||||
Layer{From: "node", Values: map[string]any{"log": "debug"}})
|
||||
|
||||
if got["log"] != "debug" {
|
||||
t.Errorf("log is %v; the node's setting should have won", got["log"])
|
||||
}
|
||||
if got["workers"] != 8.0 {
|
||||
t.Errorf("workers is %v; a mesh-wide setting the node did not touch should stand", got["workers"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNestedBlocksMergeRatherThanReplace(t *testing.T) {
|
||||
// Setting one field of a block must not delete its siblings, or every setting would have to
|
||||
// restate the whole block and would then pin all of it against upstream.
|
||||
got := merged(t, file(`{"http":{"gzip":"off","timeout":30,"port":80}}`),
|
||||
Layer{From: "node", Values: map[string]any{
|
||||
"http": map[string]any{"gzip": "on"}}})
|
||||
|
||||
block := got["http"].(map[string]any)
|
||||
if block["gzip"] != "on" {
|
||||
t.Errorf("gzip is %v", block["gzip"])
|
||||
}
|
||||
if block["timeout"] != 30.0 || block["port"] != 80.0 {
|
||||
t.Errorf("the block's other fields were lost: %v", block)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAListIsReplacedWholeNotMerged(t *testing.T) {
|
||||
// A list that merged element-wise could neither be shortened nor reordered, and there is no
|
||||
// correct guess about which element is "the same one".
|
||||
got := merged(t, file(`{"hosts":["a","b","c"]}`),
|
||||
Layer{From: "node", Values: map[string]any{"hosts": []any{"x"}}})
|
||||
|
||||
hosts := got["hosts"].([]any)
|
||||
if len(hosts) != 1 || hosts[0] != "x" {
|
||||
t.Errorf("hosts is %v; a list is replaced whole", hosts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProtectedKeyIsRefusedNotIgnored(t *testing.T) {
|
||||
// A setting quietly dropped is somebody believing they changed something. Refusing says so
|
||||
// while they are looking at it.
|
||||
_, err := ApplySettings(file(`{"socket":"/run/thing.sock","log":"info"}`, "socket"),
|
||||
[]Layer{{From: "node", Values: map[string]any{"socket": "/tmp/mine.sock"}}})
|
||||
if err == nil {
|
||||
t.Fatal("a protected key was overridden")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "socket") || !strings.Contains(err.Error(), "not settable") {
|
||||
t.Errorf("the refusal does not say which key or why: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsAroundAProtectedKeyStillApply(t *testing.T) {
|
||||
got := merged(t, file(`{"socket":"/run/thing.sock","log":"info"}`, "socket"),
|
||||
Layer{From: "node", Values: map[string]any{"log": "debug"}})
|
||||
if got["log"] != "debug" {
|
||||
t.Errorf("log is %v", got["log"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSameSettingsAlwaysProduceTheSameBytes(t *testing.T) {
|
||||
// A file whose lines move for no reason makes every reconcile look like a change, and a
|
||||
// service reflecting it would restart for ever.
|
||||
//
|
||||
// Note what this defends: Go's JSON encoder sorts map keys, so the stability comes from the
|
||||
// standard library and this passes with the merging removed. It is worth keeping as the thing
|
||||
// that would catch a move to an encoder that does not sort — but it is not evidence about the
|
||||
// code below it, and it was checked.
|
||||
resource := file(`{"b":2,"a":1,"c":3}`)
|
||||
layer := Layer{From: "node", Values: map[string]any{"z": 26.0, "a": 100.0}}
|
||||
|
||||
first, err := ApplySettings(resource, []Layer{layer})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 5; i++ {
|
||||
again, err := ApplySettings(resource, []Layer{layer})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again["content"] != first["content"] {
|
||||
t.Fatal("the same settings produced different bytes")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFileThatDoesNotMergeIsLeftAlone(t *testing.T) {
|
||||
plain := map[string]any{"id": "conf", "type": "file", "path": "/etc/thing",
|
||||
"content": "not structured at all\n"}
|
||||
out, err := ApplySettings(plain, []Layer{{From: "node", Values: map[string]any{"x": 1}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out["content"] != "not structured at all\n" {
|
||||
t.Errorf("a file with no merge rule was changed: %v", out["content"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsThatReachNothingAreNamed(t *testing.T) {
|
||||
// Somebody who misspells a module, or sets a key on one with nothing mergeable, has changed
|
||||
// nothing — and would otherwise find out by the machine not behaving differently, which is
|
||||
// the slowest way there is.
|
||||
m := Manifest{Module: "thing", Resources: []map[string]any{
|
||||
{"id": "conf", "type": "file", "path": "/etc/thing", "content": "plain"},
|
||||
}}
|
||||
unused := UnusedSettings(m, []Layer{{From: "node", Values: map[string]any{"port": 1}}})
|
||||
if len(unused) != 1 || !strings.Contains(unused[0], "no file that merges it") {
|
||||
t.Errorf("settings that reached nothing were not named: %v", unused)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASettingLandsOnlyWhereSomethingDeclaresIt(t *testing.T) {
|
||||
// novox/hq 04-ISSUES/173. A module that contributes a route and serves a provision takes a
|
||||
// setting for a key either declares, and a setting for a key neither declares is stray — it
|
||||
// would not reach the route or the served fact, so it must be said rather than dropped.
|
||||
m := Manifest{Module: "mail",
|
||||
Contributes: map[string]map[string]any{"reverse-proxy": {"host": "mail", "port": 8080}},
|
||||
Serves: map[string]map[string]any{"smtp": {"host": "mail", "port": 25}},
|
||||
Resources: []map[string]any{
|
||||
{"id": "env", "type": "file", "path": "/etc/mail.env", "content": "SITE=${setting:sitename}\n"},
|
||||
}}
|
||||
layers := []Layer{{From: "the mesh", Values: map[string]any{
|
||||
"host": "post", "sitename": "Mail", "website": "https://www.example.tld"}}}
|
||||
unused := UnusedSettings(m, layers)
|
||||
if len(unused) != 1 || !strings.Contains(unused[0], `"website"`) {
|
||||
t.Errorf("only website reaches nothing; named: %v", unused)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContentThatIsNotJSONIsRefusedWhereSomebodyIsLooking(t *testing.T) {
|
||||
// Rather than on the machine, at apply time, as a file the program cannot read.
|
||||
_, err := ApplySettings(file(`this is not json`), nil)
|
||||
if err == nil {
|
||||
t.Fatal("a module claiming to merge as JSON shipped something else and was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAServedValueMayBeTheOperators(t *testing.T) {
|
||||
// A mail provider serves its domain and an identity provider its issuer; neither is the
|
||||
// definition's to state (ADR 0155). Filled from the layers, refused by name when unset.
|
||||
served, err := Settle(map[string]any{"port": 587, "domain": "${setting:domain}"},
|
||||
[]Layer{{From: "the mesh", Values: map[string]any{"domain": "example.tld"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if served["domain"] != "example.tld" {
|
||||
t.Errorf("the operator's value did not fill the served key: %v", served)
|
||||
}
|
||||
_, err = Settle(map[string]any{"domain": "${setting:domain}"}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), `"domain"`) {
|
||||
t.Errorf("a served value nothing sets must be refused by name; got %v", err)
|
||||
}
|
||||
m := Manifest{Module: "mail", Serves: map[string]map[string]any{"smtp": {"domain": "${setting:domain}"}}}
|
||||
if unused := UnusedSettings(m, []Layer{{From: "the mesh", Values: map[string]any{"domain": "x"}}}); len(unused) != 0 {
|
||||
t.Errorf("a setting a served fact asks for is not stray: %v", unused)
|
||||
}
|
||||
}
|
||||
|
||||
// No layer replaces a value the vault fills, at any depth (novox/hq issue 610): searxng's server.secret_key
|
||||
// sits under `server`, a key its file takes, and a layer reaching it was let through by a top-level-only check.
|
||||
func TestNoLayerReplacesAVaultSecretAtAnyDepth(t *testing.T) {
|
||||
file := map[string]any{"id": "settings", "type": "file", "merge": MergeJSON,
|
||||
"content": `{"server": {"secret_key": "${secret:secret}", "port": 8080}, "ui": {"theme": "simple"}, "token": "${secret:token}"}`}
|
||||
layer := func(values map[string]any) []Layer { return []Layer{{From: "anchor", Values: values}} }
|
||||
|
||||
merged, err := ApplySettings(file, layer(map[string]any{"server": map[string]any{"port": float64(9000)}, "ui": map[string]any{"theme": "dark"}}))
|
||||
if err != nil {
|
||||
t.Fatalf("a key beside the secret was refused: %v", err)
|
||||
}
|
||||
if c := merged["content"].(string); !strings.Contains(c, `"secret_key": "${secret:secret}"`) || !strings.Contains(c, `"port": 9000`) {
|
||||
t.Fatalf("the secret or the setting did not land as meant:\n%s", c)
|
||||
}
|
||||
for name, values := range map[string]map[string]any{
|
||||
"the nested secret": {"server": map[string]any{"secret_key": "chosen"}},
|
||||
"the whole map holding it": {"server": "elsewhere"},
|
||||
"a top-level secret": {"token": "chosen"},
|
||||
"the secret, emptied": {"server": map[string]any{"secret_key": ""}},
|
||||
} {
|
||||
if _, err := ApplySettings(file, layer(values)); err == nil || !strings.Contains(err.Error(), "vault") {
|
||||
t.Errorf("%s: %v, want a refusal naming the vault", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A module may answer one requirement more than once, the sibling of ADR 0094 for `contributes`
|
||||
// rather than `secrets`: an object store's data API and its console are two different public
|
||||
// names, not one. `contributes` maps a requirement to several sets of values under local names,
|
||||
// each reaching the provider as its own entry — the same "several from one" shape ADR 0094 gave
|
||||
// `secrets`, applied to the other half of an edge.
|
||||
|
||||
const twoRoutes = `{"module":"minio","version":"1","requires":["route"],
|
||||
"contributes":{"route":{"api":{"label":"files-api","port":9000},"console":{"label":"files","port":9001}}}}`
|
||||
|
||||
func TestContributesReadsBothShapesAndWritesThemBack(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(twoRoutes))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
locals := m.ContributesMany["route"]
|
||||
if len(locals) != 2 || locals["api"]["label"] != "files-api" || locals["console"]["port"] != float64(9001) {
|
||||
t.Fatalf("two contributions under local names: %+v", locals)
|
||||
}
|
||||
plain, err := ParseManifest([]byte(`{"module":"board","version":"1","requires":["route"],
|
||||
"contributes":{"route":{"label":"board","port":8080}}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := plain.Contributes["route"]; got["label"] != "board" || len(plain.ContributesMany) != 0 {
|
||||
t.Fatalf("the plain shape is one contribution with no local names: %+v / %+v", got, plain.ContributesMany)
|
||||
}
|
||||
// Written back in the shape it was read, so a built manifest keeps its local names.
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := ParseManifest(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("what was written does not read: %v\n%s", err, raw)
|
||||
}
|
||||
if len(again.ContributesMany["route"]) != 2 {
|
||||
t.Fatalf("the local names did not survive a round trip:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAContributionLocalNameMustBeUsable(t *testing.T) {
|
||||
for _, bad := range []string{
|
||||
// Not a usable name.
|
||||
`{"module":"minio","version":"1","requires":["route"],
|
||||
"contributes":{"route":{"Not OK":{"label":"files","port":9000}}}}`,
|
||||
// A local contribution with nothing in it.
|
||||
`{"module":"minio","version":"1","requires":["route"],
|
||||
"contributes":{"route":{"api":{}}}}`,
|
||||
} {
|
||||
if _, err := ParseManifest([]byte(bad)); err == nil {
|
||||
t.Errorf("accepted:\n%s", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func minimalRouteProxy() Manifest {
|
||||
return Manifest{Module: "route-proxy", Version: "1",
|
||||
Provides: FromAnywhere("route"),
|
||||
Receives: map[string]string{"route": "/var/lib/route-proxy/routes/mesh.json"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleWithTwoRoutesGivesTheProviderTwoContributions(t *testing.T) {
|
||||
minio, err := ParseManifest([]byte(twoRoutes))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := Resolve(shelf(minimalRouteProxy(), minio), []string{"route-proxy", "minio"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := got.Declaration(Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var given []Contribution
|
||||
for _, r := range out {
|
||||
if r["path"] != "/var/lib/route-proxy/routes/mesh.json" {
|
||||
continue
|
||||
}
|
||||
var parsed struct {
|
||||
Given []Contribution `json:"given"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(r["content"].(string)), &parsed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
given = parsed.Given
|
||||
}
|
||||
if len(given) != 2 {
|
||||
t.Fatalf("two named routes from one module are two contributions: %+v", given)
|
||||
}
|
||||
byPort := map[float64]string{}
|
||||
for _, g := range given {
|
||||
if g.From != "minio" {
|
||||
t.Fatalf("both contributions are minio's: %+v", g)
|
||||
}
|
||||
port, _ := g.Values["port"].(float64)
|
||||
label, _ := g.Values["label"].(string)
|
||||
byPort[port] = label
|
||||
}
|
||||
if byPort[9000] != "files-api" || byPort[9001] != "files" {
|
||||
t.Fatalf("the two routes did not both survive: %+v", given)
|
||||
}
|
||||
}
|
||||
|
||||
// A module with the ordinary, single-contribution shape resolves exactly as it did before —
|
||||
// ContributesMany being empty must change nothing about it.
|
||||
func TestASingleRouteStillResolvesTheOrdinaryWay(t *testing.T) {
|
||||
got, err := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
given := received(t, mustDeclare(t, got))
|
||||
if len(given) != 1 || given[0].From != "board" {
|
||||
t.Fatalf("the plain shape regressed: %+v", given)
|
||||
}
|
||||
}
|
||||
|
||||
// ContributionsFrom is what mints the ONE pair credential a requiring module is granted
|
||||
// (cmd/mesh-controller/plan.go's grantsFor) — a separate path from Declaration()'s raw file, and
|
||||
// the one the two-routes test above never exercised. Where a module contributes several times,
|
||||
// there is no single "the" value: settling to whichever sorts first would both misrepresent the
|
||||
// grant and collide with that same contribution's own entry from contributions(), which is
|
||||
// exactly the duplicate a live plan against minio surfaced (files-api appearing once with a
|
||||
// credential, once without, while files got neither).
|
||||
func TestContributionsFromHasNoSingleValueWhenAModuleContributesSeveralTimes(t *testing.T) {
|
||||
minio, err := ParseManifest([]byte(twoRoutes))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := Resolve(shelf(minimalRouteProxy(), minio), []string{"route-proxy", "minio"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
values, asks, err := got.ContributionsFrom("route", "minio", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !asks {
|
||||
t.Fatal("minio still requires route, so it still asks")
|
||||
}
|
||||
if len(values) != 0 {
|
||||
t.Fatalf("no single value represents two contributions, got %+v", values)
|
||||
}
|
||||
}
|
||||
|
||||
// The ordinary, single-contribution case is unchanged: exactly one match still settles to it.
|
||||
func TestContributionsFromReturnsTheOneValueForAnOrdinaryContribution(t *testing.T) {
|
||||
got, err := Resolve(shelf(proxy(), published("board", "board", 8080)), []string{"board"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
values, asks, err := got.ContributionsFrom("reverse-proxy", "board", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !asks || values["host"] != "board" {
|
||||
t.Fatalf("the ordinary single contribution should still settle to its own value: %+v", values)
|
||||
}
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A module may need several values from one provider that gives one per pair (novox/hq
|
||||
// 04-ISSUES/069, ADR 0094): `secrets` maps a requirement to several files under local names, and
|
||||
// each local name is a pair credential of its own — its own need, its own file, its own holder.
|
||||
|
||||
const twoSecrets = `{"module":"ca","version":"1","requires":["secret"],
|
||||
"secrets":{"secret":{"root-key":"/var/lib/ca/root.key","root-pass":"/var/lib/ca/root.pass"}},
|
||||
"resources":[{"id":"state","type":"directory","path":"/var/lib/ca","mode":"0700"}]}`
|
||||
|
||||
func TestSecretsReadBothShapesAndWriteThemBack(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(twoSecrets))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files := m.SecretFiles("secret")
|
||||
if len(files) != 2 || files[0].Local != "root-key" || files[1].Path != "/var/lib/ca/root.pass" {
|
||||
t.Fatalf("two files under local names, in order: %+v", files)
|
||||
}
|
||||
plain, err := ParseManifest([]byte(`{"module":"app","version":"1","requires":["secret"],"secrets":{"secret":"/var/lib/app/secret"}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := plain.SecretFiles("secret"); len(got) != 1 || got[0].Local != "" || got[0].Path != "/var/lib/app/secret" {
|
||||
t.Fatalf("the plain shape is one file with no local name: %+v", got)
|
||||
}
|
||||
// Written back in the shape it was read, so a built manifest keeps its local names.
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := ParseManifest(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("what was written does not read: %v\n%s", err, raw)
|
||||
}
|
||||
if len(again.SecretFiles("secret")) != 2 {
|
||||
t.Fatalf("the local names did not survive a round trip:\n%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestALocalNameMayNotCollideWithWhatTheModuleAlreadyCallsSomething(t *testing.T) {
|
||||
for _, bad := range []string{
|
||||
// One of the module's own secrets.
|
||||
`{"module":"ca","version":"1","requires":["secret"],"own-secrets":{"root-key":"/var/lib/ca/own"},
|
||||
"secrets":{"secret":{"root-key":"/var/lib/ca/root.key"}}}`,
|
||||
// Something it requires.
|
||||
`{"module":"ca","version":"1","requires":["secret","postgres-database"],
|
||||
"secrets":{"secret":{"postgres-database":"/var/lib/ca/x"}}}`,
|
||||
// Not a usable name.
|
||||
`{"module":"ca","version":"1","requires":["secret"],"secrets":{"secret":{"Root Key":"/var/lib/ca/x"}}}`,
|
||||
// A relative path.
|
||||
`{"module":"ca","version":"1","requires":["secret"],"secrets":{"secret":{"root-key":"root.key"}}}`,
|
||||
} {
|
||||
if _, err := ParseManifest([]byte(bad)); err == nil {
|
||||
t.Errorf("accepted:\n%s", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func vaultAndCA() map[string]Manifest {
|
||||
ca, _ := ParseManifest([]byte(twoSecrets))
|
||||
vault := Manifest{Module: "mesh-vault", Version: "1", Provides: FromAnywhere("secret"),
|
||||
Grants: map[string]string{"secret": "/var/lib/vault/grants"},
|
||||
Receives: map[string]string{"secret": "/var/lib/vault/grants/mesh.json"}}
|
||||
// A second consumer of the same provision that keeps ONE file, mentioned before the one that
|
||||
// keeps two: the lab found the expansion done on the first module to mention the provision,
|
||||
// and the second consumer given one credential and no file.
|
||||
cache := Manifest{Module: "cache", Version: "1", Requires: []string{"secret"},
|
||||
Secrets: map[string]string{"secret": "/var/lib/cache/secret"}}
|
||||
return shelf(vault, cache, ca)
|
||||
}
|
||||
|
||||
func TestEachLocalNameIsANeedAFileAndAHolderOfItsOwn(t *testing.T) {
|
||||
got, err := Resolve(vaultAndCA(), []string{"mesh-vault", "cache", "ca"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var locals, cacheLocals []string
|
||||
for _, n := range got.Needs {
|
||||
if n.Name == "secret" && n.For == "ca" {
|
||||
locals = append(locals, n.Local)
|
||||
}
|
||||
if n.Name == "secret" && n.For == "cache" {
|
||||
cacheLocals = append(cacheLocals, n.Local)
|
||||
}
|
||||
}
|
||||
if strings.Join(locals, ",") != "root-key,root-pass" {
|
||||
t.Fatalf("two secrets from one provider are two needs: %v", got.Needs)
|
||||
}
|
||||
if len(cacheLocals) != 1 || cacheLocals[0] != "" {
|
||||
t.Fatalf("the one-file consumer keeps one need with no local name: %v", got.Needs)
|
||||
}
|
||||
for i := range got.Needs {
|
||||
got.Needs[i].Sealed = "sealed-" + got.Needs[i].Local
|
||||
}
|
||||
out, err := got.Declaration(Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := map[string]string{}
|
||||
for _, r := range out {
|
||||
if r["type"] == "file" && strings.HasPrefix(r["path"].(string), "/var/lib/ca/root.") {
|
||||
seen[r["id"].(string)] = r["sealed"].(string)
|
||||
}
|
||||
}
|
||||
if seen["ca."+SecretID("root-key")] != "sealed-root-key" || seen["ca."+SecretID("root-pass")] != "sealed-root-pass" {
|
||||
t.Fatalf("each local name is its own file with its own credential: %v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProviderSeesEachLocalNameAsAHolderOfItsOwn(t *testing.T) {
|
||||
r := Resolution{Modules: []Manifest{vaultAndCA()["mesh-vault"]}}
|
||||
got, err := r.contributions(SettingsBy{}, []Grant{
|
||||
{Provision: "secret", Consumer: "workstation", From: "ca", Local: "root-key", Sealed: "x"},
|
||||
{Provision: "secret", Consumer: "workstation", From: "ca", Local: "root-pass", Sealed: "y"},
|
||||
}, map[string]string{"secret": "/var/lib/vault/grants"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
given := got["secret"]
|
||||
if len(given) != 2 {
|
||||
t.Fatalf("two holders: %+v", given)
|
||||
}
|
||||
if given[0].As != "mesh_workstation_ca_root_key" && given[0].As != "mesh_workstation_ca_root-key" {
|
||||
t.Fatalf("the holder is the consumer's identity with the local name after it: %q", given[0].As)
|
||||
}
|
||||
if given[0].Secret == given[1].Secret {
|
||||
t.Fatalf("two holders share one file on the provider: %q", given[0].Secret)
|
||||
}
|
||||
}
|
||||
|
||||
// And on the provider's machine, two files with two ids — the lab's first run had the declaration
|
||||
// refused for two resources with one identity.
|
||||
func TestAProviderKeepsOneFilePerHolder(t *testing.T) {
|
||||
got, err := Resolve(vaultAndCA(), []string{"mesh-vault", "cache", "ca"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := got.Declaration(Rendering{Grants: []Grant{
|
||||
{Provision: "secret", Consumer: "workstation", From: "ca", Local: "root-key", Sealed: "x"},
|
||||
{Provision: "secret", Consumer: "workstation", From: "ca", Local: "root-pass", Sealed: "y"},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := map[string]string{}
|
||||
for _, r := range out {
|
||||
if id, _ := r["id"].(string); strings.Contains(id, "grant-secret") {
|
||||
ids[id] = r["path"].(string)
|
||||
}
|
||||
}
|
||||
if len(ids) != 2 {
|
||||
t.Fatalf("two holders are two grant files: %v", ids)
|
||||
}
|
||||
}
|
||||
|
||||
// One file under a local name is still a local name (review C1): the need carries it, the file
|
||||
// is written, and ${secret:<name>} is filled.
|
||||
func TestOneLocalNameIsStillALocalName(t *testing.T) {
|
||||
only, _ := ParseManifest([]byte(`{"module":"one","version":"1","requires":["secret"],
|
||||
"secrets":{"secret":{"only":"/var/lib/one/only"}}}`))
|
||||
vault := vaultAndCA()["mesh-vault"]
|
||||
got, err := Resolve(shelf(vault, only), []string{"mesh-vault", "one"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var found *Needed
|
||||
for i, n := range got.Needs {
|
||||
if n.For == "one" && n.Name == "secret" {
|
||||
found = &got.Needs[i]
|
||||
}
|
||||
}
|
||||
if found == nil || found.Local != "only" {
|
||||
t.Fatalf("the one named file did not become a need under its name: %v", got.Needs)
|
||||
}
|
||||
found.Sealed = "sealed-only"
|
||||
out, err := got.Declaration(Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var written bool
|
||||
for _, r := range out {
|
||||
if r["path"] == "/var/lib/one/only" && r["sealed"] == "sealed-only" {
|
||||
written = true
|
||||
}
|
||||
}
|
||||
if !written {
|
||||
t.Fatal("the file under the one local name was not written")
|
||||
}
|
||||
}
|
||||
|
||||
// A local name names one credential: two requirements may not share it (review C2).
|
||||
func TestALocalNameIsUniqueAcrossRequirements(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"x","version":"1","requires":["secret","postgres-database"],
|
||||
"secrets":{"secret":{"x":"/var/lib/x/a"},"postgres-database":{"x":"/var/lib/x/b"}}}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "both under") {
|
||||
t.Fatalf("two requirements under one local name were accepted: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// One account, several modules: the shell's module sets its shell, the container runtime's adds it
|
||||
// to a group. Groups are only ever added by the host, so they are contributed; a shell or a home is
|
||||
// one value, owned by one module per node.
|
||||
|
||||
func userResource(fields map[string]any) map[string]any {
|
||||
r := map[string]any{"id": "operator", "type": "user", "name": "op"}
|
||||
for k, v := range fields {
|
||||
r[k] = v
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func TestAShellAndAGroupOnOneAccountFromTwoModulesResolve(t *testing.T) {
|
||||
zsh := Manifest{Module: "zsh", Resources: []map[string]any{userResource(map[string]any{"shell": "/usr/bin/zsh"})}}
|
||||
docker := Manifest{Module: "docker", Resources: []map[string]any{userResource(map[string]any{"groups": []any{"docker"}})}}
|
||||
other := Manifest{Module: "media", Resources: []map[string]any{userResource(map[string]any{"groups": []any{"video"}})}}
|
||||
if problems := checkResources([]Manifest{zsh, docker, other}); len(problems) != 0 {
|
||||
t.Fatalf("a shell and two modules' groups on one account were refused: %v", problems)
|
||||
}
|
||||
if _, err := Resolve(shelf(zsh, docker, other), []string{"zsh", "docker", "media"}, workstation(), World{}); err != nil {
|
||||
t.Fatalf("the three did not resolve together: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTwoModulesSettingOneAccountsShellOrHomeAreRefused(t *testing.T) {
|
||||
for _, field := range []string{"shell", "home"} {
|
||||
a := Manifest{Module: "zsh", Resources: []map[string]any{userResource(map[string]any{field: "/one"})}}
|
||||
b := Manifest{Module: "fish", Resources: []map[string]any{userResource(map[string]any{field: "/two", "groups": []any{"x"}})}}
|
||||
problems := checkResources([]Manifest{a, b})
|
||||
want := `zsh and fish both set the ` + field + ` of the user "op"`
|
||||
if len(problems) != 1 || !strings.Contains(problems[0], want) {
|
||||
t.Errorf("two modules setting %s gave %v, want %q", field, problems, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// An account a module declares only to put in a group is applied where it is written, after the package
|
||||
// that makes the group (novox/hq ADR 0252); one that says how it logs in still goes first (issue 213).
|
||||
func TestAnAccountOnlyGivenGroupsKeepsItsWrittenPlace(t *testing.T) {
|
||||
compose := func(raw string) []map[string]any {
|
||||
t.Helper()
|
||||
m, err := ParseManifest([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := Resolution{Node: "workstation", Modules: []Manifest{m}}.Declaration(Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
out := compose(`{"module": "lights", "version": "1", "resources": [
|
||||
{"id": "daemon", "type": "package", "package": "lights-daemon"},
|
||||
{"id": "account", "type": "user", "name": "op", "groups": ["lights"]}
|
||||
]}`)
|
||||
if pkg, acct := indexOf(out, "lights.daemon"), indexOf(out, "lights.account"); pkg < 0 || acct < pkg {
|
||||
t.Fatalf("the account (%d) is not after the package that makes its group (%d): %v", acct, pkg, out)
|
||||
}
|
||||
out = compose(`{"module": "shell", "version": "1", "resources": [
|
||||
{"id": "package", "type": "package", "package": "zsh"},
|
||||
{"id": "login", "type": "user", "name": "op", "shell": "/usr/bin/zsh", "groups": ["wheel"]}
|
||||
]}`)
|
||||
if pkg, acct := indexOf(out, "shell.package"), indexOf(out, "shell.login"); acct < 0 || acct > pkg {
|
||||
t.Fatalf("an account with a shell is no longer first (%d, package %d): %v", acct, pkg, out)
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A provider with one credential shares it (novox/hq ADR 0158): the offer names the own secret, the
|
||||
// secret says how it is taken, and a consumer's need carries the name so the plan mints its copy
|
||||
// from the provider's value.
|
||||
func TestAnOfferMayNameAnOwnSecretAsItsCredential(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(`{"module":"downloader","version":"1",
|
||||
"own-secrets":{"password":{"path":"/var/lib/mesh/downloader/password","taken":"at-start"}},
|
||||
"provides":[{"name":"downloader-api","credential":{"own":"password"}}],
|
||||
"serves":{"downloader-api":{"port":8080,"username":"admin"}}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if own, shared := m.SharedCredentialOf("downloader-api"); !shared || own != "password" {
|
||||
t.Fatalf("the offer's credential was not read: %v %v", own, shared)
|
||||
}
|
||||
if got := m.ProvisionsSharing("password"); len(got) != 1 || got[0] != "downloader-api" {
|
||||
t.Fatalf("the provisions sharing the secret: %v", got)
|
||||
}
|
||||
|
||||
for want, raw := range map[string]string{
|
||||
"declares no such secret": `{"module":"d","version":"1","provides":[{"name":"d-api","credential":{"own":"password"}}]}`,
|
||||
"must say how the module takes it": `{"module":"d","version":"1","own-secrets":{"password":"/p"},
|
||||
"provides":[{"name":"d-api","credential":{"own":"password"}}]}`,
|
||||
"names no own secret": `{"module":"d","version":"1","provides":[{"name":"d-api","credential":{"own":""}}]}`,
|
||||
} {
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("expected a refusal saying %q, got %v", want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sharingShelf() map[string]Manifest {
|
||||
return shelf(
|
||||
Manifest{Module: "downloader", Version: "1",
|
||||
Provides: []Offer{{Name: "downloader-api", Scope: ScopeMesh, Credential: &OfferCredential{Own: "password"}}},
|
||||
OwnSecrets: OwnSecrets{"password": {Path: "/var/lib/mesh/downloader/password", Taken: TakenAtStart}},
|
||||
Serves: map[string]map[string]any{"downloader-api": {"port": 8080, "username": "admin"}}},
|
||||
Manifest{Module: "manager", Version: "1", Requires: []string{"downloader-api"}},
|
||||
)
|
||||
}
|
||||
|
||||
func TestAConsumersNeedCarriesTheSharedSecretsName(t *testing.T) {
|
||||
// On the same machine.
|
||||
together, err := Resolve(sharingShelf(), []string{"downloader", "manager"}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, n := range together.Needs {
|
||||
if n.Name == "downloader-api" && n.For == "manager" {
|
||||
found = true
|
||||
if n.SharedOwn != "password" {
|
||||
t.Fatalf("the need on one machine does not name the shared secret: %+v", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("the manager's need was not resolved: %+v", together.Needs)
|
||||
}
|
||||
// Across machines, the provider known by its module.
|
||||
apart, err := Resolve(sharingShelf(), []string{"manager"}, onBoth("example.tld"), World{
|
||||
Offered: map[string][]Provider{"downloader-api": {{Node: "home-server", At: "home-server.internal",
|
||||
Module: "downloader", Serves: map[string]any{"port": 8080}}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, n := range apart.Needs {
|
||||
if n.Name == "downloader-api" && n.SharedOwn != "password" {
|
||||
t.Fatalf("the need across machines does not name the shared secret: %+v", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Defends novox/hq ADR 0263: a machine's shares and the shares a machine mounts are two node seats, each
|
||||
// with its verbs required of every holder, each delivering nothing — `nfs-share` is provided at the mesh's
|
||||
// scope by the holder of node-nfs-server, and a node seat cannot answer for a provision at the mesh's.
|
||||
|
||||
func TestTheSharesAndMountsAreNodeSeatsWithTheirVerbs(t *testing.T) {
|
||||
for seat, want := range map[string]string{
|
||||
NFSServerSeat: "exports clients test reload adopt",
|
||||
MountsSeat: "list test mount unmount adopt",
|
||||
} {
|
||||
s, ok := SeatNamed(seat)
|
||||
if !ok {
|
||||
t.Fatalf("%s is not in the mesh's set", seat)
|
||||
}
|
||||
if s.Scope != ScopeNode || s.Decision != "novox/hq ADR 0263" || s.Delivers != "" || s.Replicated {
|
||||
t.Errorf("%s is %+v; a node seat under ADR 0263 that delivers nothing", seat, s)
|
||||
}
|
||||
var got []string
|
||||
for _, v := range s.Serves {
|
||||
got = append(got, v.Name)
|
||||
if v.Optional {
|
||||
t.Errorf("%s.%s is optional; its first holder serves it", seat, v.Name)
|
||||
}
|
||||
if v.Description == "" || v.Input["type"] != "object" || len(v.Replaces) == 0 {
|
||||
t.Errorf("%s.%s has no description, no object schema or says it replaces nothing", seat, v.Name)
|
||||
}
|
||||
}
|
||||
if strings.Join(got, " ") != want {
|
||||
t.Errorf("%s serves %v, not %s", seat, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Both acts that take over what a person wrote by hand are dry runs unless confirmed, and name the path.
|
||||
func TestTheAdoptVerbsAreDryRunsUnlessConfirmed(t *testing.T) {
|
||||
for seat, key := range map[string]string{NFSServerSeat: "path", MountsSeat: "mountpoint"} {
|
||||
s, _ := SeatNamed(seat)
|
||||
for _, v := range s.Serves {
|
||||
if v.Name != "adopt" {
|
||||
continue
|
||||
}
|
||||
props, _ := v.Input["properties"].(map[string]any)
|
||||
confirm, _ := props["confirm"].(map[string]any)
|
||||
if confirm == nil || confirm["enum"] == nil {
|
||||
t.Errorf("%s.adopt has no confirm switch: %v", seat, v.Input)
|
||||
}
|
||||
if req, _ := v.Input["required"].([]string); len(req) != 1 || req[0] != key {
|
||||
t.Errorf("%s.adopt requires %v, not %q alone", seat, v.Input["required"], key)
|
||||
}
|
||||
if !strings.Contains(v.Description, "Without confirm, says what it would do and changes nothing") {
|
||||
t.Errorf("%s.adopt does not say a call without confirm changes nothing: %s", seat, v.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A holder claiming the seat at a node with every verb holds it; one naming a verb the seat does not
|
||||
// promise, or leaving one out, is refused — as the catalogue's nfs-server and mounts modules claim them.
|
||||
func TestAHolderOfTheShareSeatsServesEveryVerbAndNothingElse(t *testing.T) {
|
||||
cases := []struct {
|
||||
seat, module string
|
||||
verbs []string
|
||||
}{
|
||||
{NFSServerSeat, "nfs-server", []string{"exports", "clients", "test", "reload", "adopt"}},
|
||||
{MountsSeat, "mounts", []string{"list", "test", "mount", "unmount", "adopt"}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
seat, _ := SeatNamed(c.seat)
|
||||
holder := Manifest{Module: c.module, Version: "1",
|
||||
Claims: []Claim{{Name: c.seat, Scope: ScopeNode, Serves: c.verbs}}}
|
||||
if c.seat == NFSServerSeat {
|
||||
holder.Provides = []Offer{{Name: "nfs-share", Scope: ScopeMesh}}
|
||||
}
|
||||
if err := CanHold(holder, seat); err != nil {
|
||||
t.Errorf("%s serving every verb is refused: %v", c.module, err)
|
||||
}
|
||||
typo := holder
|
||||
typo.Claims = []Claim{{Name: c.seat, Scope: ScopeNode, Serves: append(append([]string{}, c.verbs...), "export")}}
|
||||
if err := CanHold(typo, seat); err == nil || !strings.Contains(err.Error(), "does not promise") {
|
||||
t.Errorf("%s naming a verb the seat does not promise was accepted: %v", c.module, err)
|
||||
}
|
||||
short := holder
|
||||
short.Claims = []Claim{{Name: c.seat, Scope: ScopeNode, Serves: c.verbs[:len(c.verbs)-1]}}
|
||||
if err := CanHold(short, seat); err == nil || !strings.Contains(err.Error(), "adopt") {
|
||||
t.Errorf("%s leaving adopt out was accepted: %v", c.module, err)
|
||||
}
|
||||
mesh := holder
|
||||
mesh.Claims = []Claim{{Name: c.seat, Scope: ScopeMesh, Serves: c.verbs}}
|
||||
if err := CanHold(mesh, seat); err == nil {
|
||||
t.Errorf("%s claiming a node seat at the mesh's scope was accepted", c.module)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What each verb replaces is what an agent would type over ssh to read or change a share by hand.
|
||||
func TestTheShareVerbsSayWhatTheyReplace(t *testing.T) {
|
||||
want := map[string]string{
|
||||
NFSServerSeat + ".exports": "exportfs -v",
|
||||
NFSServerSeat + ".adopt": "zfs set sharenfs=off",
|
||||
MountsSeat + ".list": "cat /etc/fstab",
|
||||
MountsSeat + ".adopt": "sed -i /etc/fstab",
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// The confirm switch is the seat's string "true", as every verb's switch is, and its description says so:
|
||||
// a holder handed the boolean reading of "true to change it" would treat the string as a dry run.
|
||||
func TestTheConfirmSwitchIsTheStringTrue(t *testing.T) {
|
||||
for _, seat := range []string{NFSServerSeat, MountsSeat} {
|
||||
s, _ := SeatNamed(seat)
|
||||
for _, v := range s.Serves {
|
||||
props, _ := v.Input["properties"].(map[string]any)
|
||||
confirm, _ := props["confirm"].(map[string]any)
|
||||
if confirm == nil {
|
||||
continue
|
||||
}
|
||||
if confirm["type"] != "string" {
|
||||
t.Errorf("%s.%s confirm is %v, not the string switch", seat, v.Name, confirm["type"])
|
||||
}
|
||||
if d, _ := confirm["description"].(string); !strings.Contains(d, `"true"`) {
|
||||
t.Errorf("%s.%s confirm does not name the string \"true\": %q", seat, v.Name, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reload has the kernel read the export files; the module's process writes its file. The description
|
||||
// must not promise a write it does not do.
|
||||
func TestReloadSaysWhatItDoes(t *testing.T) {
|
||||
s, _ := SeatNamed(NFSServerSeat)
|
||||
for _, v := range s.Serves {
|
||||
if v.Name == "reload" && (strings.Contains(v.Description, "Write this machine's export file") ||
|
||||
!strings.Contains(v.Description, "exportfs")) {
|
||||
t.Errorf("reload's description promises a write or does not name exportfs: %s", v.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A share is exported to each node the server grants it to and that asks for it, at that node's own
|
||||
// address (novox/hq ADR 0263 rule 5) — never to the private network's whole range. The verbs that
|
||||
// describe the export say so, and do not promise the range.
|
||||
func TestTheExportVerbsDescribePerNodeAddresses(t *testing.T) {
|
||||
s, _ := SeatNamed(NFSServerSeat)
|
||||
for _, v := range s.Serves {
|
||||
if v.Name != "exports" && v.Name != "test" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(v.Description, "private network's range") {
|
||||
t.Errorf("%s still describes the export as the private network's range: %s", v.Name, v.Description)
|
||||
}
|
||||
if !strings.Contains(v.Description, "address") {
|
||||
t.Errorf("%s does not say the export is to each node's address: %s", v.Name, v.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A short-form port may name its protocol — "3478/udp" — and the mesh assigns the number exactly
|
||||
// as it does for "3478": the protocol rides along on the outside. Read as one token, the suffix made
|
||||
// the entry "not a port" and the runtime published it on a random machine port (found on ace: unifi's
|
||||
// STUN and discovery, while every TCP pin beside them held).
|
||||
func TestAShortFormPortKeepsItsProtocolAndGetsItsMachinePort(t *testing.T) {
|
||||
container := map[string]any{"type": "container", "ports": []any{"3478/udp", "8443", "10001/udp"}}
|
||||
with := Rendering{
|
||||
Given: map[string]map[int]int{"unifi": {3478: 3478}},
|
||||
Ports: map[string]map[int]int{"unifi": {8443: 20010, 10001: 20011}},
|
||||
}
|
||||
publishedOn(container, "unifi", with)
|
||||
got := fmt.Sprint(container["ports"])
|
||||
want := "[3478:3478/udp 20010:8443 20011:10001/udp]"
|
||||
if got != want {
|
||||
t.Fatalf("published %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/beside"
|
||||
)
|
||||
|
||||
// **The showcase module is parsed by the real parser, in the real test suite.**
|
||||
//
|
||||
// A module that exercises every capability is only worth having if something checks it still does.
|
||||
// Written as a test rather than a script so it runs whenever anything about manifests changes —
|
||||
// which is exactly when a module using all of it would quietly stop being valid.
|
||||
func TestTheShowcaseModuleIsAValidManifest(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join(beside.Catalogue(t), "showcase", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := ParseManifest(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("the module that exercises everything does not parse:\n%v", err)
|
||||
}
|
||||
|
||||
// Every resource kind a MODULE may use, actually in it.
|
||||
//
|
||||
// Two of the host's eleven are deliberately absent, and the reasons are worth keeping:
|
||||
//
|
||||
// - `action` is refused to modules outright. The link may not carry a command to run (ADR
|
||||
// 0005), so a module that needs something done ships a program that reads what the mesh
|
||||
// delivered and reconciles — which is what a run-once `process` is.
|
||||
// - `service` puts an EXISTING unit into a state and deliberately installs none, which is
|
||||
// right for software that ships its own unit. A module whose code the mesh built has no
|
||||
// such unit until the mesh writes one, and that is a `process`.
|
||||
kinds := map[string]bool{}
|
||||
for _, r := range m.Resources {
|
||||
kind, _ := r["type"].(string)
|
||||
kinds[kind] = true
|
||||
}
|
||||
for _, want := range []string{
|
||||
"access", "archive", "container", "directory", "file", "network", "package",
|
||||
"process", "user",
|
||||
} {
|
||||
if !kinds[want] {
|
||||
t.Errorf("showcase no longer exercises %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
// And all three ways a module's own code can run, which is the thing most easily lost.
|
||||
var stays, once, scheduled bool
|
||||
for _, r := range m.Resources {
|
||||
if kind, _ := r["type"].(string); kind != "process" {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case r["run-once"] == true:
|
||||
once = true
|
||||
case r["schedule"] != nil:
|
||||
scheduled = true
|
||||
default:
|
||||
stays = true
|
||||
}
|
||||
}
|
||||
if !stays || !once || !scheduled {
|
||||
t.Errorf("showcase does not exercise all three process modes: stays=%v once=%v scheduled=%v",
|
||||
stays, once, scheduled)
|
||||
}
|
||||
|
||||
// And the artifact kinds, including the one that compiles.
|
||||
var bundle, archive, upstream bool
|
||||
for _, a := range m.Build.Artifacts {
|
||||
switch a.Kind {
|
||||
case ArtifactBundle:
|
||||
bundle = true
|
||||
case ArtifactArchive:
|
||||
archive = true
|
||||
case ArtifactUpstream:
|
||||
upstream = true
|
||||
}
|
||||
}
|
||||
if !bundle || !archive || !upstream {
|
||||
t.Errorf("showcase does not exercise every artifact kind: bundle=%v archive=%v upstream=%v",
|
||||
bundle, archive, upstream)
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/novox/mesh-controller/internal/beside"
|
||||
)
|
||||
|
||||
// boundKeys are every key the files of a module ask of ${bound:forge-account:<key>}, read from the module's own
|
||||
// tree on the shelf, so the test offers what ssh-client asks however often it asks one more (issue 985).
|
||||
func boundKeys(t *testing.T, module, provision string) map[string]bool {
|
||||
t.Helper()
|
||||
ask := regexp.MustCompile(`\$\{bound:` + regexp.QuoteMeta(provision) + `:([A-Za-z0-9_.-]+)\}`)
|
||||
keys := map[string]bool{}
|
||||
root := filepath.Join(beside.Catalogue(t), module)
|
||||
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || strings.HasSuffix(path, "_test.go") {
|
||||
return err
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, m := range ask.FindAllStringSubmatch(string(body), -1) {
|
||||
keys[m[1]] = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("reading what %s asks of %s: %v", module, provision, err)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// The ssh-client module, composed as a machine receives it (novox/hq to-be 29, research 027/03): a
|
||||
// region at the START of the operator's ~/.ssh/config that includes ~/.ssh/config.d/* — first,
|
||||
// because ssh takes the first value it finds for each option — and the mesh's Host blocks as the
|
||||
// whole of ~/.ssh/config.d/00-mesh, every other node with its account. ~/.ssh and ~/.ssh/config.d
|
||||
// are created 0700 and owned by the account; the operator's own config below the region is kept.
|
||||
func TestSSHClientOwnsTheOperatorsSSHConfig(t *testing.T) {
|
||||
shelf := shelf(catalogueManifest(t, "ssh-client"), catalogueManifest(t, "gitea"))
|
||||
// ssh-client requires forge-account since mesh-catalog #340 (novox/hq issue 955), answered by the forge's
|
||||
// holder on another node; this test is about the operator's config, so the forge is offered from there
|
||||
// (issue 985).
|
||||
// Every key ssh-client's files ask is offered, read from them (boundKeys); a few have a realistic value.
|
||||
serves := map[string]any{}
|
||||
for key := range boundKeys(t, "ssh-client", "forge-account") {
|
||||
serves[key] = "sample-" + key
|
||||
}
|
||||
for key, value := range map[string]any{"ssh_host": "marge.internal", "ssh_port": 222,
|
||||
"known_host": "[marge.internal]:222 ssh-ed25519 AAAA", "ssh_base": "ssh://git@marge.internal:222/",
|
||||
"https_base": "https://git.example.org/"} {
|
||||
serves[key] = value
|
||||
}
|
||||
forge := World{Offered: map[string][]Provider{
|
||||
"forge-account": {{Node: "marge", At: "marge.internal", Module: "gitea", Serves: serves}}}}
|
||||
got, err := Resolve(shelf, []string{"ssh-client"},
|
||||
Node{Name: "homer", At: "homer.internal", Account: "jo", AccountHome: "/home/jo"}, forge)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := map[string]string{"homer.internal": "10.10.0.1", "marge.internal": "10.10.0.2",
|
||||
"bart.internal": "10.10.0.3"}
|
||||
out, err := got.Declaration(Rendering{
|
||||
Names: names, Machines: names,
|
||||
Accounts: map[string]string{"homer": "jo", "marge": "jo", "bart": "op"},
|
||||
Suffix: "internal",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
by := map[string]map[string]any{}
|
||||
for _, r := range out {
|
||||
by[r["id"].(string)] = r
|
||||
}
|
||||
|
||||
for id, path := range map[string]string{
|
||||
"ssh-client.ssh-dir": "/home/jo/.ssh", "ssh-client.config-d": "/home/jo/.ssh/config.d"} {
|
||||
dir := by[id]
|
||||
if dir == nil || dir["type"] != "directory" || dir["path"] != path || dir["owner"] != "jo" || dir["mode"] != "0700" {
|
||||
t.Fatalf("%s is not created 0700 owned by the account: %v", path, dir)
|
||||
}
|
||||
}
|
||||
|
||||
cfg := by["ssh-client.config"]
|
||||
if cfg == nil || cfg["path"] != "/home/jo/.ssh/config" || cfg["owner"] != "jo" ||
|
||||
cfg["into"] != "block" || cfg["at"] != "start" {
|
||||
t.Fatalf("the mesh's region is not the first thing in the operator's ~/.ssh/config: %v", cfg)
|
||||
}
|
||||
if body := cfg["content"].(string); !strings.Contains(body, "\nInclude ~/.ssh/config.d/*\n") || strings.Contains(body, "\nHost ") {
|
||||
t.Fatalf("the region does not just include config.d:\n%s", body)
|
||||
}
|
||||
|
||||
var hosts map[string]any
|
||||
for _, r := range out {
|
||||
if r["path"] == "/home/jo/.ssh/config.d/00-mesh" {
|
||||
hosts = r
|
||||
}
|
||||
}
|
||||
if hosts == nil || hosts["type"] != "file" || hosts["into"] != nil {
|
||||
t.Fatalf("the mesh's hosts are not the whole of ~/.ssh/config.d/00-mesh: %v", hosts)
|
||||
}
|
||||
body := hosts["content"].(string)
|
||||
for _, want := range []string{"Host marge marge.internal", "Host bart bart.internal", "User jo", "User op"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("00-mesh does not say %q — every other node with its account:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "Host homer ") {
|
||||
t.Fatalf("00-mesh names the machine itself, not only its peers:\n%s", body)
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A module declares the state it keeps and the state it reads (novox/hq ADR 0201), a bucket by its
|
||||
// bare name or with the owner's options.
|
||||
func TestAManifestMaySayWhatStateItKeepsAndReads(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(`{"module":"claude-code","version":"1",` +
|
||||
`"state":["servers",{"name":"seen","history":5,"ttl-seconds":3600}],` +
|
||||
`"reads":["licence-manager.bindings"]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m.State) != 2 || m.State[0].Name != "servers" || m.State[1].History != 5 || m.State[1].TTLSeconds != 3600 {
|
||||
t.Fatalf("state not read: %+v", m.State)
|
||||
}
|
||||
if len(m.Reads) != 1 || m.Reads[0] != "licence-manager.bindings" {
|
||||
t.Fatalf("reads not read: %v", m.Reads)
|
||||
}
|
||||
// Written back as it came in: the short form where nothing else is said.
|
||||
out, _ := json.Marshal(m.State)
|
||||
if string(out) != `["servers",{"name":"seen","history":5,"ttl-seconds":3600}]` {
|
||||
t.Fatalf("written back as %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A name the bus could not hold, or that would let two modules derive one bucket, is refused at
|
||||
// registration in the manifest's words.
|
||||
func TestAStateNameIsLocalAndOneToken(t *testing.T) {
|
||||
for _, c := range []struct{ manifest, says string }{
|
||||
{`{"module":"a","version":"1","state":["mesh.servers"]}`, `keeps state "mesh.servers": a state is named locally`},
|
||||
{`{"module":"a","version":"1","state":["my_servers"]}`, `keeps state "my_servers"`},
|
||||
{`{"module":"a","version":"1","state":["s","s"]}`, `keeps state "s" twice`},
|
||||
{`{"module":"a","version":"1","state":[{"name":"s","history":65}]}`, `a key keeps between 1 and 64`},
|
||||
{`{"module":"a.b","version":"1","state":["s"]}`, `no dot`},
|
||||
{`{"module":"a","version":"1","reads":["bindings"]}`, `a read is <module>.<name>`},
|
||||
{`{"module":"a","version":"1","reads":["a.s"]}`, `which is its own state`},
|
||||
{`{"module":"a","version":"1","state":[{"name":"s","shared":true}]}`, `{name, history, ttl-seconds, per-machine, key-ttl}`},
|
||||
} {
|
||||
_, err := ParseManifest([]byte(c.manifest))
|
||||
if err == nil {
|
||||
t.Errorf("%s was accepted", c.manifest)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%s refused for the wrong reason: %v", c.manifest, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A read whose owner is present must name a state that owner keeps; an absent owner says nothing,
|
||||
// because a module may be installed before the one whose state it reads.
|
||||
func TestAReadNamesStateItsOwnerKeeps(t *testing.T) {
|
||||
owner := Manifest{Module: "licence-manager", State: []StateDeclaration{{Name: "bindings"}}}
|
||||
good := Manifest{Module: "claude-code", Reads: []string{"licence-manager.bindings", "absent.anything"}}
|
||||
bad := Manifest{Module: "other", Reads: []string{"licence-manager.tokens"}}
|
||||
if p := StateReadsNothingDeclares([]Manifest{owner, good}); len(p) != 0 {
|
||||
t.Fatalf("a read of declared state was refused: %v", p)
|
||||
}
|
||||
p := StateReadsNothingDeclares([]Manifest{owner, bad})
|
||||
if len(p) != 1 || !strings.Contains(p[0], `licence-manager keeps no state called "tokens"`) {
|
||||
t.Fatalf("a read of state nobody keeps was not named: %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// **The controller's record mirrors are kept state** (design 54 §5, design 57 F3): a module may read
|
||||
// `mesh-controller.assignments` and `mesh-controller.declared-config` although the controller's manifest
|
||||
// declares no `state`; a read of anything else of the controller's is still named.
|
||||
func TestAReadOfTheControllersMirrorsIsAReadOfKeptState(t *testing.T) {
|
||||
controller := Manifest{Module: ControllerSeatName}
|
||||
reader := Manifest{Module: "mesh-config", Reads: []string{"mesh-controller.assignments", "mesh-controller.declared-config"}}
|
||||
if p := StateReadsNothingDeclares([]Manifest{controller, reader}); len(p) != 0 {
|
||||
t.Fatalf("a read of the controller's mirrors was refused: %v", p)
|
||||
}
|
||||
other := Manifest{Module: "other", Reads: []string{"mesh-controller.calls"}}
|
||||
if p := StateReadsNothingDeclares([]Manifest{controller, other}); len(p) != 1 {
|
||||
t.Fatalf("a read of the controller's own calls was not named: %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// **Across the whole catalogue**: every state name is local, and every read whose owner is present
|
||||
// names state that owner keeps.
|
||||
func TestEveryManifestsStateIsLocalAndEveryReadIsKept(t *testing.T) {
|
||||
manifests := theCatalogue(t)
|
||||
var problems []string
|
||||
for _, m := range manifests {
|
||||
problems = append(problems, StateProblems(m)...)
|
||||
}
|
||||
problems = append(problems, StateReadsNothingDeclares(manifests)...)
|
||||
if len(problems) > 0 {
|
||||
t.Fatalf("the catalogue's state is not what ADR 0201 says:\n %s", strings.Join(problems, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// `module check` says it too: the cross-catalogue pass names a read nothing on the shelf keeps.
|
||||
func TestTheCataloguePassNamesAReadItsOwnerDoesNotKeep(t *testing.T) {
|
||||
shelf := Shelf{
|
||||
"licence-manager": {Module: "licence-manager", State: []StateDeclaration{{Name: "bindings"}}},
|
||||
"claude-code": {Module: "claude-code", Reads: []string{"licence-manager.tokens"}},
|
||||
}
|
||||
problems := CatalogueProblems(shelf)
|
||||
if len(problems) != 1 || !strings.Contains(problems[0], `keeps no state called "tokens"`) {
|
||||
t.Fatalf("the catalogue pass said %v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
// 1154/AC1: a state entry may say key-ttl; it is read, written back, and a bare name or an entry without it reads as before.
|
||||
func TestAStateMayHaveKeysThatExpireOneByOne(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(`{"module":"mesh-delivery-watchman","version":"1",` +
|
||||
`"state":[{"name":"pulse","key-ttl":true},"stuck",{"name":"seen","history":5}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !m.State[0].KeyTTL || m.State[1].KeyTTL || m.State[2].KeyTTL {
|
||||
t.Fatalf("key-ttl not read as said: %+v", m.State)
|
||||
}
|
||||
out, _ := json.Marshal(m.State)
|
||||
if string(out) != `[{"name":"pulse","key-ttl":true},"stuck",{"name":"seen","history":5}]` {
|
||||
t.Fatalf("written back as %s", out)
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A module stream may declare retention: work-queue (novox/hq ADR 0436 §6): a message is removed once a
|
||||
// consumer acknowledges it, the bus refusing a new one rather than dropping what it holds. The fixture is
|
||||
// mesh-mail's future `mail-sends` stream (ADR 0436 §7, §8), copied from the module.json shape at
|
||||
// /home/jochen/projects/novox/mesh-catalog/modules/mesh-mail/module.json and extended with retention,
|
||||
// max-deliveries and duplicates as the ADR's clause 6 describes.
|
||||
const mailSendsStream = `[{"name":"mail-sends","events":["mail.send.approved"],"max-age-days":1,"max-mib":64,` +
|
||||
`"retention":"work-queue","max-deliveries":5,"duplicates":24}]`
|
||||
|
||||
// 1177/AC1: a well-formed work-queue stream is accepted and read back whole. Named by one process in
|
||||
// bus.queues (novox/hq ADR 0436 §6, checked by 1178's WorkQueueStreamProblems), so this stays accepted once
|
||||
// that rule exists.
|
||||
func TestAWorkQueueStreamIsDeclaredAndAccepted(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","streams":` + mailSendsStream +
|
||||
`,"resources":[{"id":"account","type":"user","name":"mesh-mail-send"},` +
|
||||
`{"id":"send","type":"process","run":["./mesh-mail","send"],"user":"mesh-mail-send",` +
|
||||
`"bus":{"queues":["mail-sends"]}}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("a well-formed work-queue stream was refused: %v", err)
|
||||
}
|
||||
s := m.Streams[0]
|
||||
if s.Retention != StreamRetentionWorkQueue || s.MaxDeliveries != 5 || s.Duplicates != 24 ||
|
||||
s.Days() != 1 || s.Cap() != 64 {
|
||||
t.Fatalf("the stream read as %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// 1177/AC2: a stream whose work-queue values are out of range, contradictory, or carries a grant is refused
|
||||
// naming the stream and the rule.
|
||||
func TestAWorkQueueStreamsValuesAreBounded(t *testing.T) {
|
||||
for name, c := range map[string]struct{ streams, says string }{
|
||||
"max-deliveries 0": {`[{"name":"mail-sends","events":["mail.send.approved"],"retention":"work-queue",` +
|
||||
`"max-deliveries":0,"duplicates":24}]`, "between 1 and 10"},
|
||||
"max-deliveries 11": {`[{"name":"mail-sends","events":["mail.send.approved"],"retention":"work-queue",` +
|
||||
`"max-deliveries":11,"duplicates":24}]`, "between 1 and 10"},
|
||||
"duplicates longer than its maximum age": {`[{"name":"mail-sends","events":["mail.send.approved"],` +
|
||||
`"max-age-days":1,"retention":"work-queue","max-deliveries":5,"duplicates":25}]`, "longer than its maximum age"},
|
||||
"an unknown retention": {`[{"name":"mail-sends","events":["mail.send.approved"],"retention":"sometimes"}]`,
|
||||
"retention"},
|
||||
"max-deliveries without retention work-queue": {`[{"name":"mail-sends","events":["mail.send.approved"],` +
|
||||
`"max-deliveries":5}]`, "without retention"},
|
||||
"duplicates without retention work-queue": {`[{"name":"mail-sends","events":["mail.send.approved"],` +
|
||||
`"duplicates":24}]`, "without retention"},
|
||||
"a work-queue stream carrying a grant": {`[{"name":"mail-sends","events":["mail.send.approved"],` +
|
||||
`"retention":"work-queue","max-deliveries":5,` +
|
||||
`"grant":{"config":"grants","events":"mail-sends.{key}.**"}}]`, "carrying a grant"},
|
||||
} {
|
||||
_, err := ParseManifest([]byte(mailManifest(t, c.streams, "")))
|
||||
if err == nil || !strings.Contains(err.Error(), "mail-sends") || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%s: %v; want a refusal naming the stream and %q", name, err, c.says)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1177/AC3: a stream with no retention composes as main does — limits retention, dropping its oldest — proven
|
||||
// at the broker, where the bus's retention policy is decided (internal/broker/modstreams_test.go).
|
||||
func TestAStreamWithNoRetentionReadsAsLimits(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(mailManifest(t, mailStream, "")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := m.Streams[0].Retention; got != "" {
|
||||
t.Fatalf("a stream declaring no retention read %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mailManifest is a module owning a restricted stream, as mesh-mail declares it (novox/hq ADR 0363), with
|
||||
// the trusted mergeable file that takes its grants setting; extra is spliced in at the top.
|
||||
func mailManifest(t *testing.T, streams, extra string) string {
|
||||
t.Helper()
|
||||
return `{"module":"mesh-mail","version":"1",` + extra + `"streams":` + streams + `,` +
|
||||
`"resources":[{"id":"state","type":"directory","mode":"0700","place":"."},` +
|
||||
`{"id":"grants","trusted":true,"type":"file","path":"${dir:state}/grants.json","mode":"0600",` +
|
||||
`"merge":"json","takes-settings":["grants"],"content":"{\"grants\": {}}"}]}`
|
||||
}
|
||||
|
||||
const mailStream = `[{"name":"mail","events":["mail.*.message.added","mail.*.message.changed",` +
|
||||
`"mail.*.message.removed"],"max-age-days":7,"grant":{"config":"grants","events":"mail.{key}.**"}}]`
|
||||
|
||||
// A module declares a stream of its own (ADR 0363 §1): its events with a variable name (§3), its age and
|
||||
// size with defaults, and a grant by a trusted setting of its own (§4).
|
||||
func TestAModuleDeclaresAStreamOfItsOwn(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(mailManifest(t, mailStream, "")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m.Streams) != 1 {
|
||||
t.Fatalf("streams not read: %+v", m.Streams)
|
||||
}
|
||||
s := m.Streams[0]
|
||||
if s.BusName() != "MAIL" || s.Days() != 7 || s.Cap() != StreamDefaultMiB || s.Grant == nil ||
|
||||
s.Grant.Config != "grants" || s.Grant.Granted("mesh") != "mail.mesh.**" {
|
||||
t.Fatalf("stream read as %+v", s)
|
||||
}
|
||||
if !s.Carries("mail.work.message.added") || s.Carries("mail.work.body") || !s.Carries("mail.*.message.removed") {
|
||||
t.Fatal("the stream's patterns do not say what it carries")
|
||||
}
|
||||
plain, err := ParseManifest([]byte(`{"module":"feed","version":"1","streams":[{"name":"feed-items","events":["item.**"]}]}`))
|
||||
if err != nil {
|
||||
t.Fatalf("a stream without a grant was refused: %v", err)
|
||||
}
|
||||
if got := plain.Streams[0]; got.BusName() != "FEED_ITEMS" || got.Days() != StreamDefaultDays || got.Grant != nil {
|
||||
t.Fatalf("defaults read as %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Registration refuses a stream the mesh could not hold or would leave ambiguous (ADR 0363 §1, §2, §4).
|
||||
func TestAStreamIsNamedBoundedAndGrantedByATrustedConfigValue(t *testing.T) {
|
||||
grants := `"grant":{"config":"grants","events":"mail.{key}.**"}`
|
||||
for _, c := range []struct{ manifest, says string }{
|
||||
{mailManifest(t, `[{"name":"Mail","events":["mail.**"]}]`, ""), `a stream is named locally`},
|
||||
{mailManifest(t, `[{"name":"mail.x","events":["mail.**"]}]`, ""), `a stream is named locally`},
|
||||
{mailManifest(t, `[{"name":"events","events":["mail.**"]}]`, ""), `the mesh's own stream EVENTS`},
|
||||
{mailManifest(t, `[{"name":"log-x","events":["mail.**"]}]`, ""), `starts with LOG_`},
|
||||
{mailManifest(t, `[{"name":"mail","events":["mail.**"]},{"name":"mail","events":["x.**"]}]`, ""), `twice`},
|
||||
{mailManifest(t, `[{"name":"mail","events":[]}]`, ""), `with no events`},
|
||||
{mailManifest(t, `[{"name":"mail","events":["mail.**.x"]}]`, ""), `nothing may follow it`},
|
||||
{mailManifest(t, `[{"name":"mail","events":["mail.**"],"max-age-days":31}]`, ""), `between 1 and 30`},
|
||||
{mailManifest(t, `[{"name":"mail","events":["mail.**"],"max-mib":9000}]`, ""), `between 1 and 8192`},
|
||||
{mailManifest(t, `[{"name":"mail","events":["mail.**"],"stream":"MAIL"}]`, ""), `{name, events`},
|
||||
{mailManifest(t, `[{"name":"mail","events":["mail.**"]}]`, `"emits":["mail.mesh.message.added"],`),
|
||||
`which its stream "mail" carries`},
|
||||
{mailManifest(t, `[{"name":"mail","events":["mail.**"]},{"name":"more","events":["mail.x.*"]}]`, ""),
|
||||
`both carry`},
|
||||
{mailManifest(t, `[{"name":"mail","events":["mail.**"],"grant":{"config":"tags","events":"mail.{key}.**"}}]`, ""),
|
||||
`is not a trusted config value`},
|
||||
{mailManifest(t, `[{"name":"mail","events":["mail.**"],"grant":{"config":"grants","events":"mail.**"}}]`, ""),
|
||||
`exactly once`},
|
||||
{mailManifest(t, `[{"name":"mail","events":["mail.**"],"grant":{"config":"grants","events":"{key}.{key}"}}]`, ""),
|
||||
`exactly once`},
|
||||
{mailManifest(t, `[{"name":"mail","events":["mail.**"],`+grants+`,"why":1}]`, ""), `{name, events`},
|
||||
{`{"module":"a.b","version":"1","streams":[{"name":"x","events":["x"]}]}`, `no dot`},
|
||||
} {
|
||||
_, err := ParseManifest([]byte(c.manifest))
|
||||
if err == nil {
|
||||
t.Errorf("%s was accepted", c.manifest)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%s refused for the wrong reason: %v", c.manifest, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A stream name has one owner across the catalogue (ADR 0363 §1).
|
||||
func TestTwoModulesCannotDeclareOneStream(t *testing.T) {
|
||||
a := Manifest{Module: "a", Streams: []StreamDeclaration{{Name: "mail", Events: []string{"x"}}}}
|
||||
b := Manifest{Module: "b", Streams: []StreamDeclaration{{Name: "mail", Events: []string{"y"}}}}
|
||||
got := strings.Join(CatalogueProblems(Shelf{"a": a, "b": b}), "; ")
|
||||
if !strings.Contains(got, "b declares the stream MAIL, which a already declares") {
|
||||
t.Fatalf("a second owner of MAIL was not refused: %s", got)
|
||||
}
|
||||
if got := StreamNameProblems(Shelf{"a": a}); len(got) != 0 {
|
||||
t.Fatalf("one owner refused: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The setting's value names who hears each key: a module wherever it runs, or on one machine (ADR 0363 §4).
|
||||
func TestARestrictingSettingNamesAModuleOrAModuleOnAMachine(t *testing.T) {
|
||||
var value any
|
||||
if err := json.Unmarshal([]byte(`{"mesh":["reader","person.jochen"],"work":["ace.reader"],"home":["other"],`+
|
||||
`"Bad.Key":["reader"]}`), &value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := GrantedKeys(value, "reader", "ace"); !slices.Equal(got, []string{"mesh", "work"}) {
|
||||
t.Fatalf("on ace reader is granted %v", got)
|
||||
}
|
||||
if got := GrantedKeys(value, "reader", "shanks"); !slices.Equal(got, []string{"mesh"}) {
|
||||
t.Fatalf("on shanks reader is granted %v", got)
|
||||
}
|
||||
if got := GrantedKeys(`{"mesh":["reader"]}`, "reader", "ace"); !slices.Equal(got, []string{"mesh"}) {
|
||||
t.Fatalf("a value held as its JSON text grants %v", got)
|
||||
}
|
||||
if got := GrantedKeys([]any{"reader"}, "reader", "ace"); len(got) != 0 {
|
||||
t.Fatalf("a value of another shape granted %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A consumer hears a restricted stream only where its consumes asks and the setting grants it, and only
|
||||
// by naming the emitter (ADR 0363 §5); what a stream carries whole leaves EVENTS.
|
||||
func TestAConsumerHearsOnlyWhatItAsksAndIsGranted(t *testing.T) {
|
||||
provider, err := ParseManifest([]byte(mailManifest(t, mailStream, `"emits":["account.added"],`)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
shelf := map[string]Manifest{"mesh-mail": provider}
|
||||
var value any
|
||||
_ = json.Unmarshal([]byte(`{"mesh":["reader"],"work":["shanks.reader"]}`), &value)
|
||||
values := func(module, setting string) []any {
|
||||
if module == "mesh-mail" && setting == "grants" {
|
||||
return []any{value}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
reader := Manifest{Module: "reader", Consumes: []string{"mesh-mail.mail.**", "mesh-mail.account.added", "billing.paid"}}
|
||||
|
||||
parts, touches, onEvents := Hearing(reader, shelf, "ace", values)
|
||||
if !touches || len(parts) != 1 || parts[0].Stream.BusName() != "MAIL" {
|
||||
t.Fatalf("heard %+v", parts)
|
||||
}
|
||||
want := []string{"mail.mesh.message.added", "mail.mesh.message.changed", "mail.mesh.message.removed"}
|
||||
if !slices.Equal(parts[0].Patterns, want) {
|
||||
t.Fatalf("on ace reader hears %v, want %v", parts[0].Patterns, want)
|
||||
}
|
||||
if !slices.Equal(onEvents, []string{"mesh-mail.account.added", "billing.paid"}) {
|
||||
t.Fatalf("left on EVENTS: %v", onEvents)
|
||||
}
|
||||
parts, _, _ = Hearing(reader, shelf, "shanks", values)
|
||||
if len(parts) != 1 || len(parts[0].Patterns) != 6 {
|
||||
t.Fatalf("on shanks reader hears %+v", parts)
|
||||
}
|
||||
|
||||
narrow := Manifest{Module: "reader", Consumes: []string{"mesh-mail.mail.*.message.added"}}
|
||||
parts, _, _ = Hearing(narrow, shelf, "ace", values)
|
||||
if len(parts) != 1 || !slices.Equal(parts[0].Patterns, []string{"mail.mesh.message.added"}) {
|
||||
t.Fatalf("a narrower consumes heard %+v", parts)
|
||||
}
|
||||
|
||||
stranger := Manifest{Module: "stranger", Consumes: []string{"mesh-mail.mail.**"}}
|
||||
parts, touches, onEvents = Hearing(stranger, shelf, "ace", values)
|
||||
if len(parts) != 0 || !touches || len(onEvents) != 0 {
|
||||
t.Fatalf("a module not granted heard %+v (touches %v, on EVENTS %v)", parts, touches, onEvents)
|
||||
}
|
||||
everything := Manifest{Module: "audit", Consumes: []string{"**", "*.mail.**"}}
|
||||
parts, touches, _ = Hearing(everything, shelf, "ace", values)
|
||||
if len(parts) != 0 || touches {
|
||||
t.Fatalf("a wildcard emitter reached a module's stream: %+v", parts)
|
||||
}
|
||||
parts, _, _ = Hearing(reader, shelf, "ace", func(string, string) []any { return nil })
|
||||
if len(parts) != 0 {
|
||||
t.Fatalf("with no setting reader heard %+v", parts)
|
||||
}
|
||||
}
|
||||
|
||||
// A stream without a grant is heard by whoever asks, within its patterns.
|
||||
func TestAnUnrestrictedStreamIsHeardByWhoeverAsks(t *testing.T) {
|
||||
feed := Manifest{Module: "feed", Streams: []StreamDeclaration{{Name: "items", Events: []string{"item.*.added", "item.*.gone"}}}}
|
||||
reader := Manifest{Module: "reader", Consumes: []string{"feed.item.**", "feed.item.x.added"}}
|
||||
parts, touches, onEvents := Hearing(reader, map[string]Manifest{"feed": feed}, "ace", nil)
|
||||
if !touches || len(onEvents) != 0 || len(parts) != 1 ||
|
||||
!slices.Equal(parts[0].Patterns, []string{"item.*.added", "item.*.gone"}) {
|
||||
t.Fatalf("heard %+v, on EVENTS %v", parts, onEvents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNarrowingTwoPatterns(t *testing.T) {
|
||||
for _, c := range []struct{ a, b, want string }{
|
||||
{"mail.**", "mail.*.message.added", "mail.*.message.added"},
|
||||
{"mail.*.message.added", "mail.mesh.**", "mail.mesh.message.added"},
|
||||
{"a.*", "*.b", "a.b"},
|
||||
{"**", "a.**", "a.**"},
|
||||
{"a.b", "a.c", ""},
|
||||
{"a", "a.**", ""},
|
||||
} {
|
||||
got, ok := narrow(c.a, c.b)
|
||||
if (c.want == "") == ok || got != c.want {
|
||||
t.Errorf("narrow(%s, %s) = %q, %v; want %q", c.a, c.b, got, ok, c.want)
|
||||
}
|
||||
}
|
||||
if got := uncovered([]string{"a.**", "a.b", "a.b", "c"}); !slices.Equal(got, []string{"a.**", "c"}) {
|
||||
t.Fatalf("uncovered: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// **A module that hears a restricted stream runs as an account of its own** (ADR 0376 §2): registration refuses a
|
||||
// `consumes` naming a module and asking for events its restricted stream carries, unless the manifest declares
|
||||
// `runs-as`; a stream without a grant, an event the provider says on EVENTS, and a wildcard emitter stay as they
|
||||
// were, and a consumer whose provider is not on the shelf is judged once it is.
|
||||
func TestHearingARestrictedStreamNeedsAnAccountOfItsOwn(t *testing.T) {
|
||||
provider, err := ParseManifest([]byte(mailManifest(t, mailStream, `"emits":["account.added"],`)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
feed := Manifest{Module: "feed", Streams: []StreamDeclaration{{Name: "items", Events: []string{"item.**"}}}}
|
||||
carried := Manifest{Module: "reader", Consumes: []string{"mesh-mail.mail.*.message.added"}}
|
||||
got := RestrictedHearingProblems(Shelf{"mesh-mail": provider, "reader": carried})
|
||||
if len(got) != 1 || !strings.Contains(got[0], "reader consumes mesh-mail.mail.*.message.added") ||
|
||||
!strings.Contains(got[0], "MAIL") || !strings.Contains(got[0], "runs-as") || !strings.Contains(got[0], "ADR 0376") {
|
||||
t.Fatalf("a carried module hearing MAIL was not refused as it should be: %v", got)
|
||||
}
|
||||
if all := strings.Join(CatalogueProblems(Shelf{"mesh-mail": provider, "reader": carried}), "; "); !strings.Contains(all, got[0]) {
|
||||
t.Fatalf("the catalogue's check does not refuse it: %s", all)
|
||||
}
|
||||
own := carried
|
||||
own.RunsAs = "reader"
|
||||
if got := RestrictedHearingProblems(Shelf{"mesh-mail": provider, "reader": own}); len(got) != 0 {
|
||||
t.Fatalf("a module of its own account was refused: %v", got)
|
||||
}
|
||||
for _, m := range []Manifest{
|
||||
{Module: "events", Consumes: []string{"mesh-mail.account.added"}},
|
||||
{Module: "items", Consumes: []string{"feed.item.**"}},
|
||||
{Module: "audit", Consumes: []string{"**", "*.mail.**"}},
|
||||
} {
|
||||
if got := RestrictedHearingProblems(Shelf{"mesh-mail": provider, "feed": feed, m.Module: m}); len(got) != 0 {
|
||||
t.Errorf("%s was refused: %v", m.Module, got)
|
||||
}
|
||||
}
|
||||
if got := RestrictedHearingProblems(Shelf{"reader": carried}); len(got) != 0 {
|
||||
t.Fatalf("judged without its provider: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A file resource may name a template file in its module's repository (novox/hq ADR 0349 §2, design 57 package
|
||||
// T1, issue 654): the build packs files/ into an archive, and resolving the build reads each template's text back
|
||||
// into content, so the mesh holds, composes and sends exactly what it would for the same text inline.
|
||||
|
||||
// The two forms of one module: its text inline, and its text in files/.
|
||||
const (
|
||||
inlineTwin = `{"module":"tidy","version":"1",
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[
|
||||
{"id":"conf","type":"directory","path":"${dir:conf}"},
|
||||
{"id":"main","type":"file","path":"${dir:conf}/tidy.conf","mode":"0644","content":"` + mainText + `"},
|
||||
{"id":"extra","type":"file","path":"${dir:conf}/extra.conf","content":"` + extraText + `"}]}`
|
||||
templatedTwin = `{"module":"tidy","version":"1",
|
||||
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
||||
"resources":[
|
||||
{"id":"conf","type":"directory","path":"${dir:conf}"},
|
||||
{"id":"main","type":"file","path":"${dir:conf}/tidy.conf","mode":"0644","template":"files/tidy.conf"},
|
||||
{"id":"extra","type":"file","path":"${dir:conf}/extra.conf","template":"files/deeper/extra.conf"}]}`
|
||||
mainText = `# tidy\nroot = ${dir:conf}\nquoted = \"yes\" é\n`
|
||||
extraText = `one\ntwo\n`
|
||||
)
|
||||
|
||||
// templateTexts is what a build reads back from the packed files/ archive: by path inside it.
|
||||
func templateTexts(t *testing.T) map[string]string {
|
||||
t.Helper()
|
||||
var m struct{ Main, Extra string }
|
||||
if err := json.Unmarshal([]byte(`{"Main":"`+mainText+`","Extra":"`+extraText+`"}`), &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return map[string]string{"tidy.conf": m.Main, "deeper/extra.conf": m.Extra, "unused.conf": "not named"}
|
||||
}
|
||||
|
||||
func builtFiles(texts map[string]string) []Built {
|
||||
return []Built{{Name: "files", Kind: ArtifactArchive,
|
||||
Reference: ArtifactStoreScheme + "tidy/files/blobs/sha256:" + strings.Repeat("c", 64),
|
||||
Digest: "sha256:" + strings.Repeat("c", 64), Texts: texts}}
|
||||
}
|
||||
|
||||
// held is a manifest as registration holds it: resolved with what was built, then read again strictly.
|
||||
func held(t *testing.T, raw string, built []Built) Manifest {
|
||||
t.Helper()
|
||||
m, err := ParseManifest([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, err := m.Resolve(built)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := json.Marshal(resolved)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := ParseManifest(again)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if problem := HeldTemplateProblem(out); problem != "" {
|
||||
t.Fatal(problem)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func composed(t *testing.T, m Manifest) string {
|
||||
t.Helper()
|
||||
got, err := Resolve(shelf(m), []string{m.Module}, workstation(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := json.Marshal(mustDeclare(t, got))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func TestATemplatedModuleComposesByteIdenticallyToItsInlineTwin(t *testing.T) {
|
||||
inline := composed(t, held(t, inlineTwin, builtFiles(nil)))
|
||||
templated := composed(t, held(t, templatedTwin, builtFiles(templateTexts(t))))
|
||||
if inline != templated {
|
||||
t.Fatalf("the two forms compose differently:\ninline: %s\ntemplated: %s", inline, templated)
|
||||
}
|
||||
if !strings.Contains(templated, "quoted = \\\"yes\\\"") || strings.Contains(templated, `"template"`) {
|
||||
t.Fatalf("the composition does not carry the template's text as content: %s", templated)
|
||||
}
|
||||
}
|
||||
|
||||
// What `module check`, the merge gate and `module add` judge: the text read from the tree beside the manifest.
|
||||
func TestATemplatedModuleReadFromItsTreeComposesByteIdenticallyToItsInlineTwin(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for name, text := range templateTexts(t) {
|
||||
full := filepath.Join(dir, "files", filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(text), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
m, err := ParseManifest([]byte(templatedTwin))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
filled, err := WithTemplatesFrom(m, dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filled.Templated() {
|
||||
t.Fatal("a manifest read from its tree still names a template")
|
||||
}
|
||||
resolved, err := filled.Resolve(builtFiles(nil))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := composed(t, resolved), composed(t, held(t, inlineTwin, builtFiles(nil))); got != want {
|
||||
t.Fatalf("read from the tree, the module composes differently:\ninline: %s\ntemplated: %s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFileNamingBothATemplateAndContentIsRefused(t *testing.T) {
|
||||
raw := strings.Replace(templatedTwin, `"template":"files/tidy.conf"`,
|
||||
`"template":"files/tidy.conf","content":"x"`, 1)
|
||||
_, err := ParseManifest([]byte(raw))
|
||||
if err == nil || !strings.Contains(err.Error(), "main") || !strings.Contains(err.Error(), "never both") {
|
||||
t.Fatalf("a file with both a template and content was not refused by name: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATemplateOutsideFilesIsRefusedByName(t *testing.T) {
|
||||
for _, bad := range []string{"../tidy.conf", "/etc/tidy.conf", "files", "files/", "files/../tidy.conf",
|
||||
"files/./tidy.conf", "other/tidy.conf", "tidy.conf", "files//tidy.conf", `files\\tidy.conf`} {
|
||||
raw := strings.Replace(templatedTwin, `"files/tidy.conf"`, `"`+bad+`"`, 1)
|
||||
_, err := ParseManifest([]byte(raw))
|
||||
if err == nil || !strings.Contains(err.Error(), "main") || !strings.Contains(err.Error(), "not a file under files/") {
|
||||
t.Errorf("the template %q was not refused by name: %v", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestATemplateOnAResourceThatIsNotAFileIsRefused(t *testing.T) {
|
||||
raw := strings.Replace(templatedTwin, `{"id":"conf","type":"directory","path":"${dir:conf}"}`,
|
||||
`{"id":"conf","type":"directory","path":"${dir:conf}","template":"files/tidy.conf"}`, 1)
|
||||
_, err := ParseManifest([]byte(raw))
|
||||
if err == nil || !strings.Contains(err.Error(), "conf is a directory and names a template") {
|
||||
t.Fatalf("a directory naming a template was not refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATemplatedModuleWhoseBuildPacksNoFilesIsRefused(t *testing.T) {
|
||||
for _, build := range []string{
|
||||
``,
|
||||
`"build":{"artifacts":[{"name":"look","kind":"archive","from":"theme"}]},`,
|
||||
`"build":{"artifacts":[{"name":"a","kind":"archive","from":"files"},{"name":"b","kind":"archive","from":"./files/"}]},`,
|
||||
} {
|
||||
raw := strings.Replace(templatedTwin,
|
||||
`"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},`, build, 1)
|
||||
_, err := ParseManifest([]byte(raw))
|
||||
if err == nil || !strings.Contains(err.Error(), "files/") {
|
||||
t.Errorf("a templated module with the build %q was not refused: %v", build, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMissingTemplateIsRefusedByName(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(templatedTwin))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// In the tree beside the manifest.
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "files"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "files", "tidy.conf"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = WithTemplatesFrom(m, dir)
|
||||
if err == nil || !strings.Contains(err.Error(), "extra") || !strings.Contains(err.Error(), "files/deeper/extra.conf") {
|
||||
t.Fatalf("a template missing from the tree was not refused by name: %v", err)
|
||||
}
|
||||
// And in what the build read back from its archive.
|
||||
texts := templateTexts(t)
|
||||
delete(texts, "deeper/extra.conf")
|
||||
_, err = m.Resolve(builtFiles(texts))
|
||||
if err == nil || !strings.Contains(err.Error(), "extra") || !strings.Contains(err.Error(), "files/deeper/extra.conf") {
|
||||
t.Fatalf("a template missing from the archive was not refused by name: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATemplateThatIsASymlinkIsRefused(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(templatedTwin))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "files", "deeper"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "elsewhere"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "files", "tidy.conf"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(dir, "elsewhere"), filepath.Join(dir, "files", "deeper", "extra.conf")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := WithTemplatesFrom(m, dir); err == nil || !strings.Contains(err.Error(), "not a plain file") {
|
||||
t.Fatalf("a template that is a symlink was not refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAResolveWithoutTheArchivesTextIsRefused(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(templatedTwin))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := m.Resolve(builtFiles(nil)); err == nil || !strings.Contains(err.Error(), "files") {
|
||||
t.Fatalf("a templated module resolved with no text read back from its archive: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATemplateThatIsNotUTF8IsRefused(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(templatedTwin))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
texts := templateTexts(t)
|
||||
texts["tidy.conf"] = "\xff\xfe"
|
||||
if _, err := m.Resolve(builtFiles(texts)); err == nil || !strings.Contains(err.Error(), "UTF-8") {
|
||||
t.Fatalf("a template that is not UTF-8 was resolved: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// What registration refuses: a manifest to be held that still names a template — one resolved by a builder that
|
||||
// predates templates would otherwise compose files with no text.
|
||||
func TestAManifestStillNamingATemplateIsNotHeld(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(templatedTwin))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if problem := HeldTemplateProblem(m); !strings.Contains(problem, "main") || !strings.Contains(problem, "files/tidy.conf") {
|
||||
t.Fatalf("a manifest naming a template would be held: %q", problem)
|
||||
}
|
||||
if problem := HeldTemplateProblem(held(t, inlineTwin, builtFiles(nil))); problem != "" {
|
||||
t.Fatalf("an inline manifest is refused: %s", problem)
|
||||
}
|
||||
}
|
||||
|
||||
// A symlinked files/, or a symlinked directory under it, would read a file outside the tree: refused by name,
|
||||
// as is any symlink or special file under files/ that no template names, which the build's packing refuses.
|
||||
func TestASymlinkAnywhereUnderFilesIsRefusedByName(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(templatedTwin))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(outside, "deeper"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"tidy.conf", "deeper/extra.conf", "secret"} {
|
||||
if err := os.WriteFile(filepath.Join(outside, filepath.FromSlash(name)), []byte("outside"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
write := func(t *testing.T, dir, name string) {
|
||||
t.Helper()
|
||||
full := filepath.Join(dir, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte("inside"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
link := func(t *testing.T, target, at string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(at), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(target, at); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
make func(t *testing.T, dir string)
|
||||
names string
|
||||
}{
|
||||
{"files/ itself", func(t *testing.T, dir string) {
|
||||
link(t, outside, filepath.Join(dir, "files"))
|
||||
}, "files/ is a symlink"},
|
||||
{"a directory under files/", func(t *testing.T, dir string) {
|
||||
write(t, dir, "files/tidy.conf")
|
||||
link(t, filepath.Join(outside, "deeper"), filepath.Join(dir, "files", "deeper"))
|
||||
}, "files/deeper is a symlink"},
|
||||
{"a file no template names", func(t *testing.T, dir string) {
|
||||
write(t, dir, "files/tidy.conf")
|
||||
write(t, dir, "files/deeper/extra.conf")
|
||||
link(t, filepath.Join(outside, "secret"), filepath.Join(dir, "files", "unnamed"))
|
||||
}, "files/unnamed is a symlink"},
|
||||
} {
|
||||
dir := t.TempDir()
|
||||
c.make(t, dir)
|
||||
_, err := WithTemplatesFrom(m, dir)
|
||||
if err == nil || !strings.Contains(err.Error(), c.names) {
|
||||
t.Errorf("a symlinked %s was not refused by name: %v", c.name, err)
|
||||
}
|
||||
if err != nil && strings.Contains(err.Error(), "outside") {
|
||||
t.Errorf("a symlinked %s was read: %v", c.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A module may name the directory its templates are kept in (`"templates": "<dir>"`, default files/; issue 731): a
|
||||
// module whose files/ is already an archive unpacked onto the machine keeps its templates in a directory of their
|
||||
// own, packed into an archive no resource names, so its unpacked files/ is exactly what it was.
|
||||
|
||||
// The two forms of one module whose files/ is unpacked onto the machine: its text inline, and in templates/.
|
||||
const (
|
||||
unpackingInline = `{"module":"tidy","version":"1",
|
||||
"build":{"artifacts":[{"name":"scripts","kind":"archive","from":"files"}]},
|
||||
"resources":[
|
||||
{"id":"scripts","type":"archive","path":"/usr/local/lib/tidy","artifact":"scripts"},
|
||||
{"id":"conf","type":"directory","path":"${dir:conf}"},
|
||||
{"id":"main","type":"file","path":"/etc/tidy.conf","mode":"0644","content":"` + mainText + `"},
|
||||
{"id":"extra","type":"file","path":"/etc/extra.conf","content":"` + extraText + `"}]}`
|
||||
unpackingTemplated = `{"module":"tidy","version":"1",
|
||||
"templates":"templates",
|
||||
"build":{"artifacts":[{"name":"scripts","kind":"archive","from":"files"},
|
||||
{"name":"templates","kind":"archive","from":"templates"}]},
|
||||
"resources":[
|
||||
{"id":"scripts","type":"archive","path":"/usr/local/lib/tidy","artifact":"scripts"},
|
||||
{"id":"conf","type":"directory","path":"${dir:conf}"},
|
||||
{"id":"main","type":"file","path":"/etc/tidy.conf","mode":"0644","template":"templates/tidy.conf"},
|
||||
{"id":"extra","type":"file","path":"/etc/extra.conf","template":"templates/deeper/extra.conf"}]}`
|
||||
)
|
||||
|
||||
func builtUnpacking(templates map[string]string) []Built {
|
||||
built := []Built{{Name: "scripts", Kind: ArtifactArchive,
|
||||
Reference: "https://store.invalid/scripts.tar.gz",
|
||||
Digest: "sha256:" + strings.Repeat("a", 64)}}
|
||||
if templates != nil {
|
||||
built = append(built, Built{Name: "templates", Kind: ArtifactArchive,
|
||||
Reference: ArtifactStoreScheme + "tidy/templates/blobs/sha256:" + strings.Repeat("b", 64),
|
||||
Digest: "sha256:" + strings.Repeat("b", 64), Texts: templates})
|
||||
}
|
||||
return built
|
||||
}
|
||||
|
||||
func TestAModuleWithItsOwnTemplatesDirectoryComposesByteIdenticallyToItsInlineTwin(t *testing.T) {
|
||||
inline := composed(t, held(t, unpackingInline, builtUnpacking(nil)))
|
||||
templated := composed(t, held(t, unpackingTemplated, builtUnpacking(templateTexts(t))))
|
||||
if inline != templated {
|
||||
t.Fatalf("the two forms compose differently:\ninline: %s\ntemplated: %s", inline, templated)
|
||||
}
|
||||
// The unpacked archive is the files/ one, by its digest; the templates archive reaches no machine.
|
||||
if !strings.Contains(templated, strings.Repeat("a", 64)) || strings.Contains(templated, strings.Repeat("b", 64)) {
|
||||
t.Fatalf("the composition does not unpack files/ alone: %s", templated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheManifestTheMeshHoldsNamesNoTemplatesDirectory(t *testing.T) {
|
||||
m := held(t, unpackingTemplated, builtUnpacking(templateTexts(t)))
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), `"templates"`) {
|
||||
t.Fatalf("the held manifest still names its templates directory: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModuleWithItsOwnTemplatesDirectoryIsReadFromItsTree(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for name, text := range templateTexts(t) {
|
||||
full := filepath.Join(dir, "templates", filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(text), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// files/ holds what the machine unpacks, a symlink among it judged by nobody here: it is not the templates.
|
||||
if err := os.MkdirAll(filepath.Join(dir, "files", "bin"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "files", "bin", "tidy"), []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := ParseManifest([]byte(unpackingTemplated))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
filled, err := WithTemplatesFrom(m, dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, err := filled.Resolve(builtUnpacking(map[string]string{}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := composed(t, resolved), composed(t, held(t, unpackingInline, builtUnpacking(nil))); got != want {
|
||||
t.Fatalf("read from the tree, the module composes differently:\ninline: %s\ntemplated: %s", want, got)
|
||||
}
|
||||
|
||||
// A symlink under the templates directory is refused by its path, as under files/.
|
||||
if err := os.Symlink("/etc/hostname", filepath.Join(dir, "templates", "elsewhere")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := WithTemplatesFrom(m, dir); err == nil || !strings.Contains(err.Error(), "templates/elsewhere is a symlink") {
|
||||
t.Fatalf("a symlink under templates/ was not refused by its path: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATemplateOutsideTheNamedTemplatesDirectoryIsRefused(t *testing.T) {
|
||||
raw := strings.Replace(unpackingTemplated, `"template":"templates/tidy.conf"`, `"template":"files/tidy.conf"`, 1)
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "files/tidy.conf") ||
|
||||
!strings.Contains(err.Error(), "templates/") {
|
||||
t.Fatalf("a template outside the named templates directory was not refused by name: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATemplatesDirectoryNoArchiveIsBuiltFromIsRefused(t *testing.T) {
|
||||
raw := strings.Replace(unpackingTemplated, `,
|
||||
{"name":"templates","kind":"archive","from":"templates"}`, "", 1)
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "no archive from templates/") {
|
||||
t.Fatalf("a module whose build packs no archive from its templates directory was not refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATemplatesDirectoryOnAModuleNamingNoTemplateIsRefused(t *testing.T) {
|
||||
raw := strings.Replace(unpackingInline, `"version":"1",`, `"version":"1","templates":"templates",`, 1)
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "names no template") {
|
||||
t.Fatalf("a templates directory read by nothing was not refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestATemplatesDirectoryThatIsNotAPlainRelativeDirectoryIsRefused(t *testing.T) {
|
||||
for _, dir := range []string{"/etc", "../other", "..", "templates/../files", "./templates", ".", "a\\b", "templates/"} {
|
||||
raw := strings.Replace(unpackingTemplated, `"templates":"templates"`, `"templates":`+jsonString(dir), 1)
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "templates directory") {
|
||||
t.Errorf("the templates directory %q was not refused: %v", dir, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestATemplatesDirectoryNamedFilesIsTheDefault(t *testing.T) {
|
||||
raw := strings.Replace(templatedTwin, `"version":"1",`, `"version":"1","templates":"files",`, 1)
|
||||
if got, want := composed(t, held(t, raw, builtFiles(templateTexts(t)))),
|
||||
composed(t, held(t, inlineTwin, builtFiles(nil))); got != want {
|
||||
t.Fatalf("naming files/ as the templates directory composes differently:\n%s\n%s", got, want)
|
||||
}
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// No placement and no access at the machine's own system or the mesh's state, however it is spelled (novox/hq
|
||||
// issue 339); a module's own place elsewhere is taken.
|
||||
func TestAPlacementOrAnAccessAtTheMachinesOwnIsRefused(t *testing.T) {
|
||||
m := Manifest{Module: "notes", Resources: []map[string]any{{"id": "data", "type": "directory"}},
|
||||
Accesses: []Access{{ID: "media"}}}
|
||||
for _, path := range []string{"/", "/etc", "/etc/", "/etc/sudoers.d", "/usr/bin", "/root", "/var", "/var/lib",
|
||||
"/home", "/home/", "/run", "/run/user/1000", "/var/lib/mesh/x", "/var/lib/mesh-host", "/var/lib/mesh-host/identity",
|
||||
"/srv/../etc", "//etc", "/proc/1", "/sys", "/dev", "/boot/efi", "/bin", "/sbin", "/lib", "/lib64"} {
|
||||
layers := []Layer{LayerOf("laptop", map[string]any{PlacesSetting: map[string]any{"data": path}})}
|
||||
if _, err := Places(m, layers); err == nil || !strings.Contains(err.Error(), "issue 339") {
|
||||
t.Errorf("a place at %s: %v", path, err)
|
||||
}
|
||||
layers = []Layer{LayerOf("laptop", map[string]any{AccessesSetting: map[string]any{"media": path}})}
|
||||
if _, err := AccessPlaces(m, layers); err == nil || !strings.Contains(err.Error(), "issue 339") {
|
||||
t.Errorf("an access at %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/srv/notes", "/mnt/plex/data", "/storage/media", "/services/media/movies",
|
||||
"/var/lib/notes/data", "/home/restic/repo", "/var/lib/mesh-store"} {
|
||||
layers := []Layer{LayerOf("laptop", map[string]any{PlacesSetting: map[string]any{"data": path}})}
|
||||
if got, err := Places(m, layers); err != nil || got["data"].Path != path {
|
||||
t.Errorf("a place at %s: %v %v", path, got, err)
|
||||
}
|
||||
layers = []Layer{LayerOf("laptop", map[string]any{AccessesSetting: map[string]any{"media": path}})}
|
||||
if got, err := AccessPlaces(m, layers); err != nil || got["media"] != path {
|
||||
t.Errorf("an access at %s: %v %v", path, got, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A line break, a carriage return or a NUL in any string of any setting is refused, at any depth, keys too —
|
||||
// where a layer is judged, which is both where it is kept and where it is composed (novox/hq issue 339).
|
||||
func TestASettingHoldsOneLine(t *testing.T) {
|
||||
m := Manifest{Module: "mailu"}
|
||||
for _, v := range []any{"a\nDEBUG=1", "a\rb", "a\x00b", []any{"ok", "x\ny"},
|
||||
map[string]any{"k": []any{"ok", map[string]any{"deep": "x\ny"}}}, map[string]any{"k\nx": "v"}} {
|
||||
if err := JudgeSettings(m, []Layer{{From: "home", Values: map[string]any{"v": v}}}, false); err == nil ||
|
||||
!strings.Contains(err.Error(), "line break") {
|
||||
t.Errorf("%q: %v", v, err)
|
||||
}
|
||||
}
|
||||
if err := JudgeSettings(m, []Layer{{From: "home", Values: map[string]any{"v\nPATH": "x"}}}, false); err == nil {
|
||||
t.Error("a line break in a key was taken")
|
||||
}
|
||||
if err := JudgeSettings(m, []Layer{{From: "home", Values: map[string]any{"v": "one line", "n": 3.0,
|
||||
"l": []any{"a", "b"}}}}, false); err != nil {
|
||||
t.Errorf("one line each: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Lines are allowed in one setting only: step-ca's `root`, as certificates that encoding/pem decodes and
|
||||
// x509.ParseCertificate parses (novox/hq issue 339). Any other module or key, another label, or a block that is
|
||||
// not a certificate is refused like any other line break.
|
||||
func TestOnlyTheAuthoritysRootMayHoldLines(t *testing.T) {
|
||||
ca := Manifest{Module: "step-ca"}
|
||||
judge := func(m Manifest, key, v string) error {
|
||||
return JudgeSettings(m, []Layer{{From: "anchor", Values: map[string]any{key: v}}}, false)
|
||||
}
|
||||
for _, ok := range []string{servedRoot, servedRoot + servedRoot, strings.TrimSuffix(servedRoot, "\n")} {
|
||||
if err := judge(ca, "root", ok); err != nil {
|
||||
t.Errorf("the authority's root: %v", err)
|
||||
}
|
||||
}
|
||||
body := strings.TrimSuffix(strings.TrimPrefix(servedRoot, "-----BEGIN CERTIFICATE-----\n"), "-----END CERTIFICATE-----\n")
|
||||
for name, bad := range map[string]string{
|
||||
"a line after it": servedRoot + "PATH=/tmp\n",
|
||||
"a line before it": "PATH=\n" + servedRoot,
|
||||
"another label": "-----BEGIN PRIVATE KEY-----\n" + body + "-----END PRIVATE KEY-----\n",
|
||||
"headers": "-----BEGIN CERTIFICATE-----\nProc-Type: 4,ENCRYPTED\n\n" + body + "-----END CERTIFICATE-----\n",
|
||||
"base64 that is no cert": "-----BEGIN CERTIFICATE-----\nMIIBeDCCAR2gAwIBAgIQfake000000000000000000000000\n-----END CERTIFICATE-----\n",
|
||||
"carriage returns": strings.ReplaceAll(servedRoot, "\n", "\r\n"),
|
||||
} {
|
||||
if err := judge(ca, "root", bad); err == nil {
|
||||
t.Errorf("%s was taken", name)
|
||||
}
|
||||
}
|
||||
if err := judge(ca, "other", servedRoot); err == nil {
|
||||
t.Error("a certificate in another key of the authority was taken")
|
||||
}
|
||||
if err := judge(Manifest{Module: "mailu"}, "root", servedRoot); err == nil {
|
||||
t.Error("a certificate in another module's setting was taken")
|
||||
}
|
||||
if err := JudgeSettings(ca, []Layer{{From: "anchor", Values: map[string]any{"root": []any{servedRoot}}}}, false); err == nil {
|
||||
t.Error("a certificate below the top of the setting was taken")
|
||||
}
|
||||
}
|
||||
|
||||
// Every character a reader takes as the end of a line is refused, not only \n and \r: vertical tab, form feed,
|
||||
// NEL and the Unicode line and paragraph separators (novox/hq issue 339).
|
||||
func TestEveryLineEndIsRefused(t *testing.T) {
|
||||
m := Manifest{Module: "mailu"}
|
||||
for _, v := range []string{"a\vb", "a\fb", "a\u0085b", "a\u2028b", "a\u2029b"} {
|
||||
if err := JudgeSettings(m, []Layer{{From: "home", Values: map[string]any{"v": v}}}, false); err == nil ||
|
||||
!strings.Contains(err.Error(), "line break") {
|
||||
t.Errorf("%q: %v", v, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The review's additions: the spool, the container runtimes' data, /opt, and any home's .ssh.
|
||||
func TestTheRuntimesDataAndAnyHomesSSHAreTheMachinesOwn(t *testing.T) {
|
||||
m := Manifest{Module: "notes", Resources: []map[string]any{{"id": "data", "type": "directory"}},
|
||||
Accesses: []Access{{ID: "media"}}}
|
||||
for _, path := range []string{"/var/spool", "/var/spool/cron", "/var/lib/docker", "/var/lib/docker/volumes",
|
||||
"/var/lib/containers/storage", "/var/lib/containerd", "/var/lib/containerd/io.containerd.snapshotter.v1", "/opt", "/opt/app", "/home/alice/.ssh", "/home/alice/.ssh/keys",
|
||||
"/srv/backup/.ssh", "/root/.ssh"} {
|
||||
layers := []Layer{LayerOf("laptop", map[string]any{PlacesSetting: map[string]any{"data": path}})}
|
||||
if _, err := Places(m, layers); err == nil || !strings.Contains(err.Error(), "issue 339") {
|
||||
t.Errorf("a place at %s: %v", path, err)
|
||||
}
|
||||
layers = []Layer{LayerOf("laptop", map[string]any{AccessesSetting: map[string]any{"media": path}})}
|
||||
if _, err := AccessPlaces(m, layers); err == nil || !strings.Contains(err.Error(), "issue 339") {
|
||||
t.Errorf("an access at %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/var/lib/dockerish", "/home/alice/ssh", "/home/alice/.sshd-notes", "/storage/media"} {
|
||||
layers := []Layer{LayerOf("laptop", map[string]any{PlacesSetting: map[string]any{"data": path}})}
|
||||
if _, err := Places(m, layers); err != nil {
|
||||
t.Errorf("a place at %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What a provider serves its consumers is the terminal's (novox/hq issue 339): any key under its `serves`, and any
|
||||
// setting a served value asks for, is set at the terminal alone — a verb that could change a port could point every
|
||||
// consumer at a listener of the caller's own. So is any setting a file marked `trusted` asks for.
|
||||
func TestTerminalKeysAreDerived(t *testing.T) {
|
||||
postgres := Manifest{Module: "postgres", Serves: map[string]map[string]any{"postgres-database": {"port": 5432.0}}}
|
||||
keycloak := Manifest{Module: "keycloak", Serves: map[string]map[string]any{"oidc-client": {
|
||||
"issuer": "${setting:issuer}", "token-path": "/protocol/openid-connect/token"}}}
|
||||
power := Manifest{Module: "power", Resources: []map[string]any{
|
||||
{"id": "logind", "type": "file", "path": "/etc/systemd/logind.conf.d/power.conf", "trusted": true,
|
||||
"content": "HandleLidSwitch=${setting:handle-lid-switch}\n"},
|
||||
{"id": "note", "type": "file", "path": "/var/lib/power/note", "trusted": false, "content": "${setting:greeting}\n"},
|
||||
// Unmarked counts as trusted (fail closed): only `"trusted": false` lets a verb change what a file asks for.
|
||||
{"id": "unmarked", "type": "file", "path": "/etc/power/unmarked", "content": "${setting:unmarked}\n"}}}
|
||||
agent := Manifest{Module: "claude-code", Resources: []map[string]any{{"id": "settings", "type": "file",
|
||||
"path": "/var/lib/agent/settings.json", "merge": MergeJSON,
|
||||
"content": "{\n \"role\": \"\",\n \"mcp_servers\": {},\n \"managed_settings\": {}\n}\n"}}}
|
||||
for _, c := range []struct {
|
||||
m Manifest
|
||||
want string
|
||||
}{
|
||||
{postgres, "places,accesses,port"},
|
||||
{keycloak, "places,accesses,issuer,token-path"},
|
||||
{power, "places,accesses,handle-lid-switch,unmarked"},
|
||||
{Manifest{Module: "plain"}, "places,accesses"},
|
||||
// A mergeable file asks for every key its own content names (novox/hq issue 340): the agent's module keeps
|
||||
// what every Claude Code session on a node obeys in one.
|
||||
{agent, "places,accesses,managed_settings,mcp_servers,role"},
|
||||
{Manifest{Module: "notifier", Resources: []map[string]any{{"id": "look", "type": "file", "path": "/var/lib/n/look.json",
|
||||
"merge": MergeJSON, "trusted": false, "content": `{"font": 13}`}}}, "places,accesses"},
|
||||
{Manifest{Module: "empty", Resources: []map[string]any{{"id": "config", "type": "file", "path": "/var/lib/e/c.json",
|
||||
"merge": MergeJSON, "content": `{}`}}}, "places,accesses"},
|
||||
} {
|
||||
if got := strings.Join(TerminalKeys(c.m), ","); got != c.want {
|
||||
t.Errorf("%s: %s; want %s", c.m.Module, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A module whose mergeable files name the keys they take (ADR 0326) has no whole layer at the terminal: a
|
||||
// trusted file's named keys are terminal keys, and a file nothing trusts leaves its keys to the verb (novox/hq
|
||||
// ADR 0329 §5, issue 586: betterbird's tags beside its trusted accounts, identities, calendars, address books
|
||||
// and filters). A module whose files name no keys keeps the whole layer at the terminal (issue 340).
|
||||
func TestOnlyANamedKeyOfATrustedFileIsTheTerminals(t *testing.T) {
|
||||
betterbird := Manifest{Module: "betterbird", Resources: []map[string]any{
|
||||
{"id": "prefs", "type": "file", "path": "/home/p/prefs.json", "merge": MergeJSON, "trusted": true,
|
||||
TakesSettingsField: []any{"prefs"}, "content": `{"prefs": {}}`},
|
||||
{"id": "accounts", "type": "file", "path": "/home/p/accounts.json", "merge": MergeJSON, "trusted": true,
|
||||
TakesSettingsField: []any{"accounts"}, "content": `{"accounts": {}}`},
|
||||
{"id": "configuration", "type": "file", "path": "/home/p/configuration.json", "merge": MergeJSON,
|
||||
TakesSettingsField: []any{"identities", "calendars", "address-books", "filters"}, "content": `{}`},
|
||||
{"id": "tags", "type": "file", "path": "/home/p/tags.json", "merge": MergeJSON, "trusted": false,
|
||||
TakesSettingsField: []any{"tags"}, "content": `{"tags": {}}`}}}
|
||||
if got := TrustedMergeable(betterbird); len(got) != 0 {
|
||||
t.Fatalf("files that name their keys put no whole layer at the terminal: %v", got)
|
||||
}
|
||||
if got := strings.Join(TerminalKeys(betterbird), ","); got != "places,accesses,accounts,address-books,calendars,filters,identities,prefs" {
|
||||
t.Fatalf("the named keys of the trusted files, unmarked counting as trusted, and not tags: %s", got)
|
||||
}
|
||||
silent := Manifest{Module: "keycloak", Resources: []map[string]any{{"id": "runtime", "type": "file",
|
||||
"path": "/var/lib/kc/runtime.json", "merge": MergeJSON, "content": `{}`}}}
|
||||
if got := TrustedMergeable(silent); len(got) != 1 {
|
||||
t.Fatalf("a file that names no keys still takes any key, so its module's layer stays the terminal's: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A file that asks for a setting says whether what it asks is trusted (novox/hq issue 339): `trusted` is a
|
||||
// boolean, on a file alone, and a file asking for a setting without it is named.
|
||||
func TestAFileSaysWhetherItsSettingsAreTrusted(t *testing.T) {
|
||||
m := Manifest{Module: "power", Resources: []map[string]any{
|
||||
{"id": "said", "type": "file", "path": "/etc/a", "trusted": true, "content": "${setting:a}"},
|
||||
{"id": "unsaid", "type": "file", "path": "/etc/b", "content": "${setting:b}"},
|
||||
{"id": "no-setting", "type": "file", "path": "/etc/c", "content": "plain"}}}
|
||||
if got := strings.Join(UnsaidTrust(m), ","); got != "unsaid" {
|
||||
t.Errorf("unsaid: %s; want unsaid", got)
|
||||
}
|
||||
// A mergeable file that names keys asks for them, so it is named too; one that names none asks for nothing.
|
||||
merged := Manifest{Module: "agent", Resources: []map[string]any{
|
||||
{"id": "settings", "type": "file", "path": "/a", "merge": MergeJSON, "content": `{"managed_settings": {}}`},
|
||||
{"id": "blank", "type": "file", "path": "/b", "merge": MergeJSON, "content": `{}`}}}
|
||||
if got := strings.Join(UnsaidTrust(merged), ","); got != "settings" {
|
||||
t.Errorf("unsaid: %s; want settings", got)
|
||||
}
|
||||
for _, bad := range []map[string]any{
|
||||
{"id": "x", "type": "file", "path": "/etc/x", "trusted": "yes", "content": "${setting:a}"},
|
||||
{"id": "y", "type": "directory", "trusted": true},
|
||||
} {
|
||||
if problems := TrustProblems(Manifest{Module: "power", Resources: []map[string]any{bad}}); len(problems) == 0 {
|
||||
t.Errorf("%v was taken", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The bus is never public (novox/hq ADR 0169). Its port is what the bus module declares, the mesh,
|
||||
// and the control plane adds no opening of its own: a machine joins through the tunnel, so the
|
||||
// broker's host is filtered like any other. Before this, the broker port was a foundation port and
|
||||
// rendered from anywhere beside its from-the-mesh rule.
|
||||
func TestTheBusPortIsReachedFromTheMeshAlone(t *testing.T) {
|
||||
rules := []Rule{{Port: 4222, Protocol: "tcp", From: FromMesh, Because: []string{"nats"},
|
||||
Why: []string{"the mesh bus"}}}
|
||||
out := AsNftables(rules, []string{"10.10.0.1", "10.10.0.2"}, true, nil, []string{"eth0"}, "mesh0")
|
||||
|
||||
if !regexp.MustCompile(`ip saddr \{ 10\.10\.0\.1, 10\.10\.0\.2 \} tcp dport 4222 accept`).MatchString(out) {
|
||||
t.Fatalf("the bus is not reachable from the mesh:\n%s", out)
|
||||
}
|
||||
if regexp.MustCompile(`(?m)^\s*tcp dport 4222 accept`).MatchString(out) {
|
||||
t.Fatalf("the bus is reachable from anywhere:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A node runs several modules that all want one database (novox/hq 04-ISSUES/022).
|
||||
//
|
||||
// **The ordinary arrangement, and it could not be planned at all.** The walk that resolves a node
|
||||
// is a work-list over names, so a requirement three modules shared was visited once and produced
|
||||
// one need — carrying whichever module mentioned it first. Everything downstream inherited that:
|
||||
// one credential, keyed by machine, named after a machine by the provisioner.
|
||||
//
|
||||
// The symptom had two halves and only one was loud. The provider refused, naming the modules and
|
||||
// saying they would share one credential, which reads as a decision. The consumer did not: it
|
||||
// resolved cleanly, wrote one module's credential file, and left the other two absent — a service
|
||||
// that starts and cannot authenticate, with nothing anywhere saying why. That is the shape of 021
|
||||
// again, on a different axis.
|
||||
|
||||
func threeConsumers() map[string]Manifest {
|
||||
return shelf(
|
||||
Manifest{Module: "postgres", Version: "1", Provides: FromAnywhere("postgres-database")},
|
||||
Manifest{Module: "gitea", Version: "1", Requires: []string{"postgres-database"},
|
||||
Contributes: map[string]map[string]any{"postgres-database": {"name": "gitea"}},
|
||||
Secrets: map[string]string{"postgres-database": "/var/lib/gitea/db.secret"}},
|
||||
Manifest{Module: "keycloak", Version: "1", Requires: []string{"postgres-database"},
|
||||
Contributes: map[string]map[string]any{"postgres-database": {"name": "keycloak"}},
|
||||
Secrets: map[string]string{"postgres-database": "/var/lib/keycloak/db.secret"}},
|
||||
Manifest{Module: "umami", Version: "1", Requires: []string{"postgres-database"},
|
||||
Contributes: map[string]map[string]any{"postgres-database": {"name": "umami"}},
|
||||
Secrets: map[string]string{"postgres-database": "/var/lib/umami/db.secret"}},
|
||||
)
|
||||
}
|
||||
|
||||
func TestEveryConsumerOnANodeGetsItsOwnCredential(t *testing.T) {
|
||||
got, err := Resolve(threeConsumers(), []string{"gitea", "keycloak", "umami"},
|
||||
reachable(), World{Offered: onNetwork("anchor")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.Needs) != 3 {
|
||||
t.Fatalf("three modules want a database and the node has %d need(s): %v",
|
||||
len(got.Needs), got.Needs)
|
||||
}
|
||||
for _, want := range []string{"gitea", "keycloak", "umami"} {
|
||||
var found bool
|
||||
for _, n := range got.Needs {
|
||||
if n.For == want {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("%s wants a database and no credential is made for it", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Each consumer's own credential reaches its own file. Matching on the provision alone, every
|
||||
// consumer took whichever need was last — a module handed somebody else's password, which is a
|
||||
// valid credential and therefore fails in a way that looks like a configuration error.
|
||||
func TestEachConsumerGetsItsOwnCredentialAndNotAnothersFile(t *testing.T) {
|
||||
got, err := Resolve(threeConsumers(), []string{"gitea", "keycloak", "umami"},
|
||||
reachable(), World{Offered: onNetwork("anchor")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := range got.Needs {
|
||||
got.Needs[i].Sealed = "sealed-for-" + got.Needs[i].For
|
||||
}
|
||||
out, err := got.Declaration(Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"gitea", "keycloak", "umami"} {
|
||||
var seen bool
|
||||
for _, r := range out {
|
||||
if r["path"] != "/var/lib/"+want+"/db.secret" {
|
||||
continue
|
||||
}
|
||||
seen = true
|
||||
if r["sealed"] != "sealed-for-"+want {
|
||||
t.Errorf("%s was given %v, which belongs to something else", want, r["sealed"])
|
||||
}
|
||||
}
|
||||
if !seen {
|
||||
t.Errorf("%s resolved and its credential file was never written", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The provider is told about all three, separately, and names each grant after the module.
|
||||
func TestAProviderIsToldAboutEveryConsumerOnOneMachine(t *testing.T) {
|
||||
provider, err := Resolve(shelf(Manifest{
|
||||
Module: "postgres", Version: "1", Provides: FromAnywhere("postgres-database"),
|
||||
Grants: map[string]string{"postgres-database": "/var/lib/postgres/grants"},
|
||||
Receives: map[string]string{"postgres-database": "/var/lib/postgres/grants/mesh.json"},
|
||||
}), []string{"postgres"}, reachable(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var grants []Grant
|
||||
for _, who := range []string{"gitea", "keycloak", "umami"} {
|
||||
grants = append(grants, Grant{
|
||||
Provision: "postgres-database", Consumer: "anchor", From: who,
|
||||
Values: map[string]any{"name": who}, Sealed: "sealed-for-" + who})
|
||||
}
|
||||
out, err := provider.Declaration(Rendering{Grants: grants})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, who := range []string{"gitea", "keycloak", "umami"} {
|
||||
path := "/var/lib/postgres/grants/anchor." + who + ".secret"
|
||||
var found bool
|
||||
for _, r := range out {
|
||||
if r["path"] == path {
|
||||
found = true
|
||||
if r["sealed"] != "sealed-for-"+who {
|
||||
t.Errorf("%s's grant holds %v", who, r["sealed"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("the provider was never told to create a login for %s", who)
|
||||
}
|
||||
}
|
||||
|
||||
// And all three appear in the readable manifest, so the provisioner sees three consumers
|
||||
// where there are three. Named after one machine, they were one path and the last won.
|
||||
for _, r := range out {
|
||||
if r["path"] != "/var/lib/postgres/grants/mesh.json" {
|
||||
continue
|
||||
}
|
||||
body := r["content"].(string)
|
||||
for _, who := range []string{"gitea", "keycloak", "umami"} {
|
||||
if !strings.Contains(body, `"`+who+`"`) {
|
||||
t.Errorf("the provider's manifest never mentions %s: %s", who, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The mesh has two resolvers (novox/hq ADR 0223): `mesh-dns-resolver` is replicated, held on the
|
||||
// anchor and on the home server, each answering the same names; every machine's resolver file lists
|
||||
// every holder — its own first when it is one — and no public resolver. ADR 0196 listed the mesh's
|
||||
// resolver then a public one, and musl asks both at once and takes the first reply: from the home
|
||||
// server the public "no such name" for the anchor's mesh name won, every time, in every Alpine build.
|
||||
// The file is written by the module holding the machine's uplink (ADR 0223 part 2).
|
||||
|
||||
// resolverMachines is the anchor and the home server holding the resolver, and a laptop holding nothing.
|
||||
var resolverMachines = map[string]string{
|
||||
"anchor.internal": "10.42.0.1", "laptop.internal": "10.42.0.2", "home.internal": "10.42.0.3"}
|
||||
|
||||
// bothResolvers is the two holders on record, as `seat mesh-dns-resolver --add` leaves them.
|
||||
var bothResolvers = []Held{
|
||||
{Claim: "mesh-dns-resolver", Scope: ScopeMesh, Node: "anchor", Module: "dnsmasq"},
|
||||
{Claim: "mesh-dns-resolver", Scope: ScopeMesh, Node: "home", Module: "dnsmasq"},
|
||||
}
|
||||
|
||||
// uplinks is every module in the catalogue holding node-uplink, and so writing the machine's resolver
|
||||
// file (novox/hq ADR 0223 part 2): the program that would otherwise rewrite it is the one that writes it.
|
||||
// dhcpcd's left the catalogue with ADR 0226, held by no machine.
|
||||
var uplinks = []string{"networkmanager", "systemd-networkd"}
|
||||
|
||||
// managing is a machine as each uplink module needs it: able to install, run a service and run the
|
||||
// manager that module is for.
|
||||
func managing(node string) Node {
|
||||
caps := map[string]bool{"package-manager": true, "service-manager": true}
|
||||
for _, u := range uplinks {
|
||||
caps["uplink-"+u] = true
|
||||
}
|
||||
return Node{Name: node, At: node + ".internal", Capabilities: caps}
|
||||
}
|
||||
|
||||
// twoResolverShelf is the resolver, the uplink modules that write what a machine asks, and a stand-in
|
||||
// answering `mesh-addressing`.
|
||||
func twoResolverShelf(t *testing.T) map[string]Manifest {
|
||||
t.Helper()
|
||||
out := map[string]Manifest{
|
||||
"net": {Module: "net", Version: "1", Provides: []Offer{{Name: "mesh-addressing"}}},
|
||||
"dnsmasq": catalogueManifest(t, "dnsmasq"),
|
||||
}
|
||||
for _, u := range uplinks {
|
||||
out[u] = catalogueManifest(t, u)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// worldWithout is the rest of the mesh as a plan for one machine sees it: every other holder's claim
|
||||
// and offer, and both holders on record.
|
||||
func worldWithout(node string) World {
|
||||
w := World{Holdings: bothResolvers, Offered: map[string][]Provider{}}
|
||||
for _, h := range bothResolvers {
|
||||
if h.Node == node {
|
||||
continue
|
||||
}
|
||||
w.Held = append(w.Held, h)
|
||||
w.Offered["wildcard-resolution"] = append(w.Offered["wildcard-resolution"],
|
||||
Provider{Node: h.Node, At: h.Node + ".internal", Module: h.Module})
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// resolvConfOn resolves and composes one machine and answers with the nameservers its resolver file
|
||||
// lists, in order, and the file. The file is the uplink's — `uplink` is one of the assigned modules —
|
||||
// and nothing else on the machine declares that path.
|
||||
func resolvConfOn(t *testing.T, node, uplink string, assigned []string) ([]string, string) {
|
||||
t.Helper()
|
||||
got, err := Resolve(twoResolverShelf(t), assigned, managing(node), worldWithout(node))
|
||||
if err != nil {
|
||||
t.Fatalf("%s with %s does not resolve with two resolvers on record: %v", node, uplink, err)
|
||||
}
|
||||
out, err := got.Declaration(Rendering{
|
||||
Names: resolverMachines, Machines: resolverMachines, Suffix: "internal",
|
||||
Holders: map[string]map[string]string{"mesh-dns-resolver": {
|
||||
"anchor.internal": "10.42.0.1", "home.internal": "10.42.0.3"}},
|
||||
Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("%s with %s does not compose: %v", node, uplink, err)
|
||||
}
|
||||
var file map[string]any
|
||||
for _, r := range out {
|
||||
if r["path"] != "/etc/resolv.conf" {
|
||||
continue
|
||||
}
|
||||
if file != nil {
|
||||
t.Fatalf("%s declares /etc/resolv.conf twice: %v and %v", node, file["id"], r["id"])
|
||||
}
|
||||
file = r
|
||||
}
|
||||
if file == nil || file["id"] != uplink+".fact-resolvers" {
|
||||
t.Fatalf("%s's resolver file is not %s's: %v", node, uplink, file)
|
||||
}
|
||||
content, _ := file["content"].(string)
|
||||
var servers []string
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
if strings.HasPrefix(line, "nameserver ") {
|
||||
servers = append(servers, strings.TrimPrefix(line, "nameserver "))
|
||||
}
|
||||
}
|
||||
return servers, content
|
||||
}
|
||||
|
||||
// Per uplink module: each writes a resolver file listing both holders, the machine's own first on a
|
||||
// holder, and only the holders on a machine that is none.
|
||||
func TestEveryUplinkListsBothResolversItsOwnFirst(t *testing.T) {
|
||||
for _, uplink := range uplinks {
|
||||
for node, want := range map[string][]string{
|
||||
"anchor": {"10.42.0.1", "10.42.0.3"},
|
||||
"home": {"10.42.0.3", "10.42.0.1"},
|
||||
"laptop": {"10.42.0.1", "10.42.0.3"},
|
||||
} {
|
||||
assigned := []string{uplink}
|
||||
if node != "laptop" {
|
||||
assigned = append(assigned, "dnsmasq")
|
||||
}
|
||||
servers, content := resolvConfOn(t, node, uplink, assigned)
|
||||
if strings.Join(servers, " ") != strings.Join(want, " ") {
|
||||
t.Errorf("%s on %s lists %v; want %v\n%s", uplink, node, servers, want, content)
|
||||
}
|
||||
for _, public := range []string{"1.1.1.1", "8.8.8.8", "9.9.9.9"} {
|
||||
if strings.Contains(content, public) {
|
||||
t.Errorf("%s on %s lists a public resolver beside the mesh's (ADR 0223):\n%s", uplink, node, content)
|
||||
}
|
||||
}
|
||||
if !strings.HasSuffix(content, "\noptions timeout:1 attempts:2 edns0\n") {
|
||||
t.Errorf("%s on %s does not end with two short attempts:\n%s", uplink, node, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One file, whichever manager the machine runs: the three modules carry the same template, so a
|
||||
// machine changing its manager changes nothing in what it asks, and a fix made to one is made to all.
|
||||
func TestEveryUplinkWritesTheSameResolverFile(t *testing.T) {
|
||||
var first, firstOf string
|
||||
for _, uplink := range uplinks {
|
||||
fact, ok := catalogueManifest(t, uplink).Facts["resolvers"]
|
||||
if !ok || fact.Path != "/etc/resolv.conf" || fact.Shared || fact.Home {
|
||||
t.Fatalf("%s does not write the machine's resolver file whole: %+v", uplink, fact)
|
||||
}
|
||||
if first == "" {
|
||||
first, firstOf = fact.Template, uplink
|
||||
continue
|
||||
}
|
||||
if fact.Template != first {
|
||||
t.Errorf("%s's resolver file differs from %s's; the three are kept identical", uplink, firstOf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The requirement stays with what writes the file (ADR 0223 part 1): a machine is refused when nothing
|
||||
// in the mesh resolves, rather than given a file listing nothing.
|
||||
func TestEveryUplinkRequiresTheMeshsResolver(t *testing.T) {
|
||||
for _, uplink := range uplinks {
|
||||
found := false
|
||||
for _, r := range catalogueManifest(t, uplink).Requires {
|
||||
found = found || r == "wildcard-resolution"
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("%s writes the resolver file and does not require wildcard-resolution", uplink)
|
||||
}
|
||||
}
|
||||
_, err := Resolve(twoResolverShelf(t), []string{"networkmanager"}, managing("laptop"), World{})
|
||||
if err == nil || !strings.Contains(err.Error(), "wildcard-resolution") {
|
||||
t.Errorf("an uplink was composed on a mesh with no resolver: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A holder answers its own requirement, even though the other holder sorts first (issue 258 kept).
|
||||
func TestAHolderAnswersItsOwnRequirement(t *testing.T) {
|
||||
got, err := Resolve(twoResolverShelf(t), []string{"dnsmasq", "networkmanager"},
|
||||
managing("home"), worldWithout("home"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, n := range got.Needs {
|
||||
if n.Name == "wildcard-resolution" && n.From != "home" {
|
||||
t.Errorf("the home server's uplink is bound to %s; it holds the seat itself", n.From)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A seat held once is still held once: a second claimant on another machine is refused while
|
||||
// nothing is on record, and a store recording two holders of it is refused, naming the seat.
|
||||
func TestASingleHolderMeshSeatStillRefusesASecondHolder(t *testing.T) {
|
||||
store := shelf(mod("postgres", nil, nil, nil, Claim{Name: "mesh-store", Scope: ScopeMesh}))
|
||||
other := Held{Claim: "mesh-store", Scope: ScopeMesh, Node: "anchor", Module: "postgres"}
|
||||
if _, err := Resolve(store, []string{"postgres"}, workstation(), World{Held: []Held{other}}); err == nil ||
|
||||
!strings.Contains(err.Error(), "one per mesh") {
|
||||
t.Errorf("a second claimant of a seat held once was not refused: %v", err)
|
||||
}
|
||||
here := Held{Claim: "mesh-store", Scope: ScopeMesh, Node: workstation().Name, Module: "postgres"}
|
||||
_, err := Resolve(store, []string{"postgres"}, workstation(),
|
||||
World{Held: []Held{other}, Holdings: []Held{other, here}})
|
||||
if err == nil || !strings.Contains(err.Error(), "on record as held by 2") {
|
||||
t.Errorf("two holders on record for a seat held once were not refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Replicated is not "whoever is assigned": two claimants with nothing on record are refused, as for
|
||||
// any mesh seat, and each holder is added by an act.
|
||||
func TestTwoUnrecordedClaimantsOfTheReplicatedSeatAreRefused(t *testing.T) {
|
||||
w := worldWithout("home")
|
||||
w.Holdings = nil
|
||||
_, err := Resolve(twoResolverShelf(t), []string{"dnsmasq"}, Node{Name: "home", At: "home.internal"}, w)
|
||||
if err == nil || !strings.Contains(err.Error(), "seat mesh-dns-resolver --to") {
|
||||
t.Errorf("a second resolver with nothing on record was not refused, naming the handover: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Only the mesh's resolver is replicated: a seat being replicated is a decision, recorded.
|
||||
func TestOnlyTheResolverIsReplicated(t *testing.T) {
|
||||
for _, s := range Seats() {
|
||||
if s.Replicated != (s.Name == "mesh-dns-resolver") {
|
||||
t.Errorf("%s replicated = %v; only mesh-dns-resolver is (ADR 0223)", s.Name, s.Replicated)
|
||||
}
|
||||
}
|
||||
UseSeats([]Seat{{Name: "mesh-dns-resolver", Scope: ScopeMesh, Delivers: "wildcard-resolution"}})
|
||||
defer UseSeats(DefaultSeats())
|
||||
if s, _ := SeatNamed("mesh-dns-resolver"); !s.Replicated {
|
||||
t.Error("loading the set from the store, which has no column for it, lost the resolver's replication")
|
||||
}
|
||||
}
|
||||
|
||||
// Every consumer is bound to the same holder whatever order the mesh resolved its machines in.
|
||||
func TestTheFirstHolderIsTheFirstProvider(t *testing.T) {
|
||||
providers := []Provider{{Node: "anchor", Module: "dnsmasq"}, {Node: "home", Module: "dnsmasq"}}
|
||||
for _, held := range [][]Held{bothResolvers, {bothResolvers[1], bothResolvers[0]}} {
|
||||
if p, ok := HolderAmong("wildcard-resolution", providers, held); !ok || p.Node != "anchor" {
|
||||
t.Errorf("held in order %v answered %v", held, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One host record per machine, beside its wildcard (novox/hq issue 262): a name with a host record
|
||||
// says it exists and has no IPv6 address, where the wildcard alone said there is no such name, and
|
||||
// musl reads that as final.
|
||||
func TestTheResolverHasOneHostRecordPerMachine(t *testing.T) {
|
||||
got, err := Resolve(twoResolverShelf(t), []string{"dnsmasq"}, Node{Name: "anchor", At: "anchor.internal"},
|
||||
World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := got.Declaration(Rendering{Names: resolverMachines, Machines: resolverMachines, Suffix: "internal",
|
||||
Needed: map[string]map[string]string{"dnsmasq": {"broker": "sealed"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, _ := byID(out)["dnsmasq.fact-node-zones"]["content"].(string)
|
||||
records := map[string]int{}
|
||||
for _, line := range strings.Split(content, "\n") {
|
||||
if strings.HasPrefix(line, "host-record=") {
|
||||
records[strings.TrimPrefix(line, "host-record=")]++
|
||||
}
|
||||
}
|
||||
for name, at := range resolverMachines {
|
||||
if records[name+","+at] != 1 {
|
||||
t.Errorf("%s has %d host records at %s, and has one:\n%s", name, records[name+","+at], at, content)
|
||||
}
|
||||
}
|
||||
if len(records) != len(resolverMachines) {
|
||||
t.Errorf("%d host records for %d machines:\n%s", len(records), len(resolverMachines), content)
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq issue 231 — a misspelled placeholder is written out as text.
|
||||
//
|
||||
// The four placeholders of the issue, in one file: two misspelled namespaces, a setting key no
|
||||
// definition could declare, and the shell's own syntax. The first three are refused by name, at the
|
||||
// catalogue check and at composition; the fourth reaches the file as written.
|
||||
const (
|
||||
misspelledShell = "${shel:zsh:first}"
|
||||
undeclaredSetting = "${setting:Undeclared}"
|
||||
misspelledMachine = "${machnie:address}"
|
||||
shellsOwn = "${XDG_CACHE_HOME:-x}"
|
||||
// The shell's operators after a word the mesh also uses, in any case: the shell's, and passed.
|
||||
shellsOperators = "${PORT:-8080} ${SHELL:-/bin/sh} ${SECRET:?unset} ${dir:-/tmp}"
|
||||
)
|
||||
|
||||
// The catalogue check — the strict parse registration runs too — refuses each by name, with the
|
||||
// module and the field it stands in, and says nothing about the shell's own syntax.
|
||||
func TestAMisspelledPlaceholderIsRefusedAtTheCheck(t *testing.T) {
|
||||
raw := `{"module":"speller","version":"1","resources":[{"id":"rc","type":"file","path":"/etc/speller.rc",` +
|
||||
`"content":"a=` + misspelledShell + `\nb=` + undeclaredSetting + `\nc=` + misspelledMachine +
|
||||
`\nd=` + shellsOwn + `\n"}]}`
|
||||
_, err := ParseManifest([]byte(raw))
|
||||
if err == nil {
|
||||
t.Fatal("a file holding three placeholders no pass consumes was accepted")
|
||||
}
|
||||
for _, token := range []string{misspelledShell, undeclaredSetting, misspelledMachine} {
|
||||
if !strings.Contains(err.Error(), "speller's resource rc holds "+token+" in its content") {
|
||||
t.Errorf("the refusal does not name %s with its module and field: %v", token, err)
|
||||
}
|
||||
}
|
||||
if strings.Contains(err.Error(), "XDG_CACHE_HOME") {
|
||||
t.Errorf("the shell's own syntax was refused: %v", err)
|
||||
}
|
||||
|
||||
// A namespace the mesh knows, misspelt in any way its own pattern does not take, and the same
|
||||
// namespace in a field its pass does not read: refused at the check as at composition.
|
||||
for _, c := range []struct{ resource, token, field string }{
|
||||
{`{"id":"rc","type":"file","path":"/etc/rc","content":"${Machine:address}"}`, "${Machine:address}", "content"},
|
||||
{`{"id":"rc","type":"file","path":"/etc/rc","content":"${machine:.address}"}`, "${machine:.address}", "content"},
|
||||
{`{"id":"rc","type":"file","path":"/etc/rc","content":"${machine: address}"}`, "${machine: address}", "content"},
|
||||
{`{"id":"rc","type":"file","path":"/etc/rc","content":"${Dir:x}"}`, "${Dir:x}", "content"},
|
||||
{`{"id":"rc","type":"file","path":"/etc/rc","content":"a=${machine:address\nb=1"}`, "${machine:address", "content"},
|
||||
{`{"id":"rc","type":"process","name":"speller","env":{"NAME":"${machine:name}"}}`, "${machine:name}", "env.NAME"},
|
||||
} {
|
||||
raw := `{"module":"speller","version":"1","resources":[` + c.resource + `]}`
|
||||
_, err := ParseManifest([]byte(raw))
|
||||
if err == nil || !strings.Contains(err.Error(), "speller's resource rc holds "+c.token+" in its "+c.field) {
|
||||
t.Errorf("%s in its %s was accepted at the check, or not named: %v", c.token, c.field, err)
|
||||
}
|
||||
}
|
||||
|
||||
// And the shell's syntax alone, beside placeholders every pass knows, is accepted — as is the
|
||||
// shape a lower-case shell variable takes with an operator after its colon.
|
||||
raw = `{"module":"speller","version":"1","resources":[{"id":"rc","type":"file","path":"${machine:account-home}/.rc",` +
|
||||
`"content":"` + shellsOwn + ` ${(%):-%n} ${1:-.} ${count:-} ${trial:+ on trial} ${machine:address} ` +
|
||||
shellsOperators + `\n"},` +
|
||||
`{"id":"server","type":"container","name":"server","env":{"PORT":"${PORT:-80}"}}]}`
|
||||
if _, err := ParseManifest([]byte(raw)); err != nil {
|
||||
t.Fatalf("the shell's own syntax was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Composition refuses the same placeholders in the same words — a manifest the store already holds
|
||||
// was never parsed by this binary — and a namespace the mesh knows, written in a field its pass does
|
||||
// not read, is refused there too, because it would reach the machine as the same literal text.
|
||||
func TestAMisspelledPlaceholderIsRefusedAtComposition(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
field string
|
||||
r map[string]any
|
||||
token string
|
||||
}{
|
||||
{"content", map[string]any{"id": "rc", "type": "file", "path": "/etc/speller.rc", "content": "a=" + misspelledShell + "\n"}, misspelledShell},
|
||||
{"content", map[string]any{"id": "rc", "type": "file", "path": "/etc/speller.rc", "content": "b=" + undeclaredSetting + "\n"}, undeclaredSetting},
|
||||
{"content", map[string]any{"id": "rc", "type": "file", "path": "/etc/speller.rc", "content": "c=" + misspelledMachine + "\n"}, misspelledMachine},
|
||||
{"env.NAME", map[string]any{"id": "rc", "type": "process", "name": "speller", "env": map[string]any{"NAME": "${machine:name}"}}, "${machine:name}"},
|
||||
{"content", map[string]any{"id": "rc", "type": "file", "path": "/etc/speller.rc", "content": "${Machine:address}"}, "${Machine:address}"},
|
||||
{"content", map[string]any{"id": "rc", "type": "file", "path": "/etc/speller.rc", "content": "${machine:.address}"}, "${machine:.address}"},
|
||||
{"content", map[string]any{"id": "rc", "type": "file", "path": "/etc/speller.rc", "content": "${machine: address}"}, "${machine: address}"},
|
||||
{"content", map[string]any{"id": "rc", "type": "file", "path": "/etc/speller.rc", "content": "${Dir:x}"}, "${Dir:x}"},
|
||||
{"content", map[string]any{"id": "rc", "type": "file", "path": "/etc/speller.rc", "content": "a=${machine:address\nb=1"}, "${machine:address"},
|
||||
} {
|
||||
m := Manifest{Module: "speller", Version: "1", Resources: []map[string]any{c.r}}
|
||||
r := Resolution{Node: "workstation", Account: "op", Modules: []Manifest{m}}
|
||||
_, err := r.Declaration(Rendering{})
|
||||
if err == nil || !strings.Contains(err.Error(), "speller's resource rc holds "+c.token+" in its "+c.field) {
|
||||
t.Errorf("%s in its %s was composed rather than refused by name: %v", c.token, c.field, err)
|
||||
}
|
||||
}
|
||||
|
||||
m := Manifest{Module: "speller", Version: "1", Resources: []map[string]any{
|
||||
{"id": "rc", "type": "file", "path": "/etc/speller.rc", "content": "d=" + shellsOwn + " " + shellsOperators + "\n"},
|
||||
{"id": "server", "type": "process", "name": "server", "env": map[string]any{"PORT": "${PORT:-80}"}},
|
||||
}}
|
||||
out, err := Resolution{Node: "workstation", Account: "op", Modules: []Manifest{m}}.Declaration(Rendering{})
|
||||
if err != nil {
|
||||
t.Fatalf("the shell's own syntax was refused: %v", err)
|
||||
}
|
||||
if got := contentOf(t, out, "speller.rc"); got != "d="+shellsOwn+" "+shellsOperators+"\n" {
|
||||
t.Fatalf("the shell's own syntax did not pass through as written: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// contentOf is the content of the composed resource with this id, failing when it was not composed.
|
||||
func contentOf(t *testing.T, out []map[string]any, id string) string {
|
||||
t.Helper()
|
||||
for _, res := range out {
|
||||
if res["id"] == id {
|
||||
return plainly(res["content"])
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s was not composed: %v", id, out)
|
||||
return ""
|
||||
}
|
||||
|
||||
// What a value puts into a file is its software's text, not the definition's, and is never swept
|
||||
// (review of mesh-controller #211): an operator's setting holding `${labels:instance}` filled through
|
||||
// ${setting:…}, and a JSON setting `${level:upper}` merged into a mergeable file, reach the machine as
|
||||
// set — software that templates its own configuration (Log4j's `${env:…}`, Spring's `${timeout:30}`)
|
||||
// is configured exactly this way.
|
||||
func TestAValueHoldingAPlaceholderShapeComposesUnchanged(t *testing.T) {
|
||||
m := Manifest{Module: "templater", Version: "1", Resources: []map[string]any{
|
||||
{"id": "conf", "type": "file", "path": "/etc/templater.conf", "content": "template=${setting:template}\n"},
|
||||
{"id": "json", "type": "file", "path": "/etc/templater.json", "merge": MergeJSON, "content": `{"fmt":"plain"}`},
|
||||
}}
|
||||
settings := SettingsBy{"templater": {{From: "the operator", Values: map[string]any{
|
||||
"template": "${labels:instance}", "fmt": "${level:upper}",
|
||||
}}}}
|
||||
out, err := Resolution{Node: "workstation", Account: "op", Modules: []Manifest{m}}.Declaration(Rendering{Settings: settings})
|
||||
if err != nil {
|
||||
t.Fatalf("a value holding a placeholder's shape was refused as the definition's: %v", err)
|
||||
}
|
||||
if got := contentOf(t, out, "templater.conf"); got != "template=${labels:instance}\n" {
|
||||
t.Errorf("the setting did not reach the file as set: %q", got)
|
||||
}
|
||||
if got := contentOf(t, out, "templater.json"); !strings.Contains(got, `${level:upper}`) {
|
||||
t.Errorf("the merged setting did not reach the file as set: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Contributed shell code is the shell's, and no pass reads it (novox/hq ADR 0204): zsh's own
|
||||
// `${path:t}` is namespace-shaped, and reaches the holder's file untouched.
|
||||
func TestContributedShellCodeIsNotSwept(t *testing.T) {
|
||||
modules := []Manifest{zshHolder(), {Module: "modifier", Shell: []ShellCode{
|
||||
{For: "zsh", Slot: "normal", Code: "echo ${path:t} ${shel:zsh:first}"},
|
||||
}}}
|
||||
out, err := Resolution{Node: "workstation", Account: "op", Modules: modules}.Declaration(Rendering{})
|
||||
if err != nil {
|
||||
t.Fatalf("contributed shell code was swept: %v", err)
|
||||
}
|
||||
if got := contentOf(t, out, "zsh.zshrc"); !strings.Contains(got, "echo ${path:t} ${shel:zsh:first}") {
|
||||
t.Fatalf("the contributed code did not reach the holder's file as written: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A module may declare its unhealthy urgent, with why; the module check refuses it without why and with any
|
||||
// severity but urgent or warning (novox/hq ADR 0355).
|
||||
|
||||
func TestAnUnhealthyDeclaredUrgentIsUrgent(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(`{"module":"mail","version":"1",` +
|
||||
`"unhealthy":{"severity":"urgent","why":"mail is refused or stuck for everyone with a mailbox"}}`))
|
||||
if err != nil {
|
||||
t.Fatalf("a manifest declaring its unhealthy urgent with why was refused: %v", err)
|
||||
}
|
||||
if !m.UnhealthyIsUrgent() {
|
||||
t.Errorf("a manifest declaring its unhealthy urgent reads as not urgent: %+v", m.Unhealthy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAManifestWithoutUnhealthyIsNotUrgent(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(`{"module":"notes","version":"1"}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m.UnhealthyIsUrgent() {
|
||||
t.Errorf("a manifest saying nothing of its unhealthy reads as urgent")
|
||||
}
|
||||
warning := Manifest{Module: "notes", Unhealthy: &UnhealthyPolicy{Severity: UnhealthyWarning, Why: "notes wait"}}
|
||||
if warning.UnhealthyIsUrgent() || len(warning.unhealthyProblems()) != 0 {
|
||||
t.Errorf("a warning said with why reads as urgent or is refused: %v", warning.unhealthyProblems())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnhealthyWithoutWhyIsRefused(t *testing.T) {
|
||||
for _, why := range []string{"", " "} {
|
||||
if problems := (Manifest{Module: "mail", Unhealthy: &UnhealthyPolicy{Severity: UnhealthyUrgent, Why: why}}).unhealthyProblems(); len(problems) == 0 {
|
||||
t.Errorf("urgent with why %q was accepted", why)
|
||||
}
|
||||
}
|
||||
if _, err := ParseManifest([]byte(`{"module":"mail","version":"1","unhealthy":{"severity":"urgent"}}`)); err == nil ||
|
||||
!strings.Contains(err.Error(), "unhealthy says why") {
|
||||
t.Errorf("a manifest declaring its unhealthy urgent without why parsed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnhealthySeverityOtherThanUrgentOrWarningIsRefused(t *testing.T) {
|
||||
if _, err := ParseManifest([]byte(`{"module":"mail","version":"1","unhealthy":{"severity":"loud","why":"mail stops"}}`)); err == nil ||
|
||||
!strings.Contains(err.Error(), `not "loud"`) {
|
||||
t.Errorf("a manifest declaring its unhealthy loud parsed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A module rolls out by default; it records where it says so with why, where it keeps irreplaceable
|
||||
// data, and always where it is the bus (novox/hq ADR 0236).
|
||||
func TestWhereAModulesUpgradePolicyComesFrom(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
m Manifest
|
||||
policy, from string
|
||||
}{
|
||||
{"default", Manifest{Module: "app"}, PolicyRoll, FromDefault},
|
||||
{"says record", Manifest{Module: "db", Upgrade: &UpgradePolicy{Policy: PolicyRecord, Why: "a restart costs"}},
|
||||
PolicyRecord, FromModule},
|
||||
{"says together", Manifest{Module: "dns", Upgrade: &UpgradePolicy{Policy: PolicyTogether, Why: "one zone"}},
|
||||
PolicyTogether, FromModule},
|
||||
{"irreplaceable data", Manifest{Module: "media", Data: &Data{Own: []DataItem{{ID: "library", Class: ClassIrreplaceable}}}},
|
||||
PolicyRecord, FromData},
|
||||
{"valuable data rolls", Manifest{Module: "notes", Data: &Data{Own: []DataItem{{ID: "db", Class: ClassValuable}}}},
|
||||
PolicyRoll, FromDefault},
|
||||
{"irreplaceable but says roll", Manifest{Module: "photos", Upgrade: &UpgradePolicy{Policy: PolicyRoll},
|
||||
Data: &Data{Own: []DataItem{{ID: "originals", Class: ClassIrreplaceable}}}}, PolicyRoll, FromModule},
|
||||
{"the bus, whatever it says", Manifest{Module: "nats", Provides: []Offer{{Name: "mesh-bus"}},
|
||||
Upgrade: &UpgradePolicy{Policy: PolicyRoll}}, PolicyRecord, FromBus},
|
||||
}
|
||||
for _, c := range cases {
|
||||
policy, from, _ := DerivedUpgrade(c.m)
|
||||
if policy != c.policy || from != c.from {
|
||||
t.Errorf("%s: %s from %s, want %s from %s", c.name, policy, from, c.policy, c.from)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A policy other than roll says why, and an unknown one is refused.
|
||||
func TestAnUpgradePolicySaysWhy(t *testing.T) {
|
||||
for _, u := range []UpgradePolicy{{Policy: PolicyRecord}, {Policy: PolicyTogether}, {Policy: "sometimes", Why: "x"}} {
|
||||
if problems := (Manifest{Module: "m", Upgrade: &u}).upgradeProblems(); len(problems) == 0 {
|
||||
t.Errorf("%+v was accepted", u)
|
||||
}
|
||||
}
|
||||
if problems := (Manifest{Module: "m", Upgrade: &UpgradePolicy{Policy: PolicyRecord, Why: "a restart costs"}}).upgradeProblems(); len(problems) != 0 {
|
||||
t.Errorf("a record with why was refused: %v", problems)
|
||||
}
|
||||
if _, err := ParseManifest([]byte(`{"module":"m","upgrade":{"policy":"record"}}`)); err == nil ||
|
||||
!strings.Contains(err.Error(), "says why") {
|
||||
t.Errorf("a manifest recording without why parsed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The node-uplink seat's verbs (novox/hq ADR 0241): promised and routed, optional — not yet a condition of
|
||||
// holding — so the holders of today still hold it, a holder may name them, and nothing it does not promise;
|
||||
// and still optional once the store's row is read back (optionalAsCompiled).
|
||||
|
||||
func uplinkSeat(t *testing.T) Seat {
|
||||
t.Helper()
|
||||
for _, s := range Seats() {
|
||||
if s.Name == "node-uplink" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
t.Fatal("no node-uplink seat")
|
||||
return Seat{}
|
||||
}
|
||||
|
||||
func TestTheUplinkSeatPromisesItsVerbsAndRequiresNoneYet(t *testing.T) {
|
||||
seat := uplinkSeat(t)
|
||||
var names []string
|
||||
for _, v := range seat.Serves {
|
||||
names = append(names, v.Name)
|
||||
if !v.Optional {
|
||||
t.Errorf("%s is required already", v.Name)
|
||||
}
|
||||
}
|
||||
if !slices.Equal(names, []string{"resolvers", "links"}) {
|
||||
t.Fatalf("node-uplink promises %v", names)
|
||||
}
|
||||
// The resolver file is the seat's path (novox/hq ADR 0287), which every holder of today renders.
|
||||
today := Manifest{Module: "networkmanager", Version: "1", Claims: []Claim{{Name: "node-uplink", Scope: ScopeNode}},
|
||||
Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile, Template: "x"}}}
|
||||
if err := CanHold(today, seat); err != nil {
|
||||
t.Fatalf("a holder that serves no verb yet is refused: %v", err)
|
||||
}
|
||||
serving := today
|
||||
serving.Claims = []Claim{{Name: "node-uplink", Scope: ScopeNode, Serves: []string{"resolvers", "links"}}}
|
||||
if err := CanHold(serving, seat); err != nil {
|
||||
t.Fatalf("a holder serving both is refused: %v", err)
|
||||
}
|
||||
typo := today
|
||||
typo.Claims = []Claim{{Name: "node-uplink", Scope: ScopeNode, Serves: []string{"resolver"}}}
|
||||
if err := CanHold(typo, seat); err == nil || !strings.Contains(err.Error(), "does not promise") {
|
||||
t.Fatalf("a verb the seat does not promise was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheUplinkVerbsReadBackFromTheRowStayOptional(t *testing.T) {
|
||||
before := seats
|
||||
t.Cleanup(func() { seats = before })
|
||||
// The row as seeding stores it: both verbs, the mark not a column.
|
||||
UseSeats([]Seat{{Name: "node-uplink", Scope: ScopeNode, Serves: []Verb{{Name: "resolvers"}, {Name: "links"}}}})
|
||||
seat := uplinkSeat(t)
|
||||
holder := Manifest{Module: "systemd-networkd", Version: "1", Claims: []Claim{{Name: "node-uplink", Scope: ScopeNode}},
|
||||
Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile, Template: "x"}}}
|
||||
if err := CanHold(holder, seat); err != nil {
|
||||
t.Fatalf("read back from the row, the verbs are required: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// An archive pinned by URL and sha256 (novox/hq ADR 0316 §2, §6): the vendor's file, mirrored by the build
|
||||
// seat, never a copy in the repository.
|
||||
|
||||
const pinnedSum = "38dd24890a66b4cf9a45bc24e15192e00c26b929e1bb8df7974c16a4539129e2"
|
||||
|
||||
func upstreamManifest(artifact string) string {
|
||||
return `{"module":"a","version":"1","build":{"artifacts":[` + artifact + `]}}`
|
||||
}
|
||||
|
||||
func TestAnArchivePinnedByURLAndSha256IsAccepted(t *testing.T) {
|
||||
for _, a := range []string{
|
||||
`{"name":"archive","kind":"archive","url":"https://vendor.example.org/release-1.tar.xz","sha256":"` + pinnedSum +
|
||||
`","version":"1.0","within":"release"}`,
|
||||
`{"name":"addon","kind":"archive","url":"https://vendor.example.org/addon.xpi","sha256":"` + pinnedSum +
|
||||
`","version":"1.5.6.1","file":"addon.xpi"}`,
|
||||
} {
|
||||
if _, err := ParseManifest([]byte(upstreamManifest(a))); err != nil {
|
||||
t.Errorf("%s was refused: %v", a, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUpstreamFileThatIsNotPinnedIsRefused(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
// No digest: what the address serves changes under the mesh.
|
||||
`{"name":"x","kind":"archive","url":"https://vendor.invalid/r.tar.xz","version":"1"}`: "without a pinned sha256",
|
||||
`{"name":"x","kind":"archive","url":"https://vendor.invalid/r.tar.xz","sha256":"ABC","version":"1"}`: "without a pinned sha256",
|
||||
// No version: a person reading the pin cannot tell which release.
|
||||
`{"name":"x","kind":"archive","url":"https://vendor.invalid/r.tar.xz","sha256":"` + pinnedSum + `"}`: "says no version",
|
||||
// Not https.
|
||||
`{"name":"x","kind":"archive","url":"http://vendor.invalid/r.tar.xz","sha256":"` + pinnedSum + `","version":"1"}`: "over https",
|
||||
// Both a directory of the repository and a URL.
|
||||
`{"name":"x","kind":"archive","from":"files","url":"https://vendor.invalid/r.tar.xz","sha256":"` + pinnedSum +
|
||||
`","version":"1"}`: "one or the other",
|
||||
// A pin without a URL.
|
||||
`{"name":"x","kind":"archive","from":"files","sha256":"` + pinnedSum + `"}`: "no url",
|
||||
// Only an archive is mirrored from a file.
|
||||
`{"name":"x","kind":"image","from":"Dockerfile","url":"https://vendor.invalid/r.tar.xz","sha256":"` + pinnedSum +
|
||||
`","version":"1"}`: "only an archive",
|
||||
// A file name that is a path, a directory within that leaves the archive, and both at once.
|
||||
`{"name":"x","kind":"archive","url":"https://vendor.invalid/a.xpi","sha256":"` + pinnedSum +
|
||||
`","version":"1","file":"../a.xpi"}`: "not a plain relative file name",
|
||||
`{"name":"x","kind":"archive","url":"https://vendor.invalid/r.tar.xz","sha256":"` + pinnedSum +
|
||||
`","version":"1","within":"../up"}`: "not a plain relative directory",
|
||||
`{"name":"x","kind":"archive","url":"https://vendor.invalid/r.tar.xz","sha256":"` + pinnedSum +
|
||||
`","version":"1","within":"r","file":"r.tar.xz"}`: "a file is kept whole",
|
||||
}
|
||||
for artifact, want := range cases {
|
||||
_, err := ParseManifest([]byte(upstreamManifest(artifact)))
|
||||
if err == nil {
|
||||
t.Errorf("%s was accepted", artifact)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%s was refused for another reason than %q: %v", artifact, want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUpstreamFilesVendorIsNamedOnPurposeAndAnInstallationsIsRefused(t *testing.T) {
|
||||
// The vendor's host is judged like any name, and passes through names-on-purpose; a version of four dotted
|
||||
// numbers is not an address, and a name in it is still judged.
|
||||
m, err := ParseManifest([]byte(upstreamManifest(`{"name":"owl","kind":"archive","url":"https://vendor.be/owl/owl.xpi","sha256":"` +
|
||||
pinnedSum + `","version":"1.5.6.1","file":"owl.xpi","names-on-purpose":{"vendor.be":"Owl's vendor publishes it there"}}`)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if problems := InstallationProblems(m); len(problems) != 0 {
|
||||
t.Fatalf("the declared vendor and the version were taken for an installation: %v", problems)
|
||||
}
|
||||
m.Build.Artifacts[0].NamesOnPurpose = nil
|
||||
if problems := InstallationProblems(m); len(problems) != 1 || !strings.Contains(problems[0], "vendor.be") {
|
||||
t.Fatalf("an undeclared vendor host is judged: %v", problems)
|
||||
}
|
||||
m.Build.Artifacts[0].Version = "git.novox.be 10.0.0.1"
|
||||
if problems := InstallationProblems(m); len(problems) != 2 {
|
||||
t.Fatalf("a name in the version is judged: %v", problems)
|
||||
}
|
||||
m.Build.Artifacts[0].URL, m.Build.Artifacts[0].Version = "https://mail.novox.be/owl.xpi", "1"
|
||||
if problems := InstallationProblems(m); len(problems) != 1 || !strings.Contains(problems[0], "mail.novox.be") {
|
||||
t.Fatalf("an installation's host in the url is judged: %v", problems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUpstreamFileFromAnAddressOrAReservedNameIsRefused(t *testing.T) {
|
||||
for _, u := range []string{"https://git.novox.internal/r.tar.xz", "https://10.1.2.3/r.tar.xz",
|
||||
"https://203.0.113.9/r.tar.xz", "https://[2001:db8::1]/r.tar.xz", "https://nas.lan/r.tar.xz", "https://vendor/r.tar.xz"} {
|
||||
_, err := ParseManifest([]byte(upstreamManifest(`{"name":"x","kind":"archive","url":"` + u + `","sha256":"` +
|
||||
pinnedSum + `","version":"1"}`)))
|
||||
if err == nil || !strings.Contains(err.Error(), "published under a public name") {
|
||||
t.Errorf("%s: %v", u, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import "testing"
|
||||
|
||||
// The vault's provision is one the controller itself dereferences — every minted credential is
|
||||
// sealed with it — so it is delivered by a seat of the mesh's own, and a second provider is a second
|
||||
// claimant refused by name rather than a candidate for a pin (novox/hq ADR 0161, issue 106).
|
||||
func TestTheVaultsSeatDeliversSecret(t *testing.T) {
|
||||
seat, known := SeatNamed("mesh-vault")
|
||||
if !known {
|
||||
t.Fatal("mesh-vault is not in the mesh's own set")
|
||||
}
|
||||
if seat.Scope != ScopeMesh || seat.Delivers != "secret" {
|
||||
t.Fatalf("mesh-vault is %s-scoped and delivers %q; one per mesh, delivering secret", seat.Scope, seat.Delivers)
|
||||
}
|
||||
vault := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}},
|
||||
Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh}}}
|
||||
if err := CanHold(vault, seat); err != nil {
|
||||
t.Fatalf("the vault, claiming its seat and providing secret, was refused: %v", err)
|
||||
}
|
||||
another := Manifest{Module: "other-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}}}
|
||||
if err := CanHold(another, seat); err == nil {
|
||||
t.Fatal("a provider of secret that does not claim the seat was allowed to hold it")
|
||||
}
|
||||
}
|
||||
|
||||
// The vault answers `get` on request and reply (novox/hq ADR 0349 §11, issue 674): the seat promises the
|
||||
// verb, so a holder serving it is accepted — and, optional while the vault that runs is the TypeScript one,
|
||||
// so is a holder that does not yet.
|
||||
func TestTheVaultsSeatPromisesGet(t *testing.T) {
|
||||
seat, _ := SeatNamed("mesh-vault")
|
||||
var get *Verb
|
||||
for i := range seat.Serves {
|
||||
if seat.Serves[i].Name == "get" {
|
||||
get = &seat.Serves[i]
|
||||
}
|
||||
}
|
||||
if get == nil {
|
||||
t.Fatal("the vault's seat does not promise get")
|
||||
}
|
||||
for _, arg := range []string{"name", "public_key"} {
|
||||
if props, _ := get.Input["properties"].(map[string]any); props[arg] == nil {
|
||||
t.Errorf("get takes no %s", arg)
|
||||
}
|
||||
}
|
||||
serving := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}},
|
||||
Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh, Serves: []string{"get"}}}}
|
||||
if err := CanHold(serving, seat); err != nil {
|
||||
t.Fatalf("the vault serving get was refused: %v", err)
|
||||
}
|
||||
notYet := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}},
|
||||
Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh}}}
|
||||
if err := CanHold(notYet, seat); err != nil {
|
||||
t.Fatalf("the vault that does not serve get yet was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The vault takes values in and rotates them (novox/hq ADR 0349 clauses 8 to 10, issue 675): the seat promises
|
||||
// accept, secret-ask, issued and rotate, each with the arguments of its steps; a holder serving them is accepted,
|
||||
// and, optional while the vault that runs does not, so is one that does not yet.
|
||||
func TestTheVaultsSeatPromisesItsIntakeVerbs(t *testing.T) {
|
||||
seat, _ := SeatNamed("mesh-vault")
|
||||
want := map[string][]string{
|
||||
"accept": {"node", "module", "name", "provider", "provision", "by", "call", "sealed"},
|
||||
"secret-ask": {"node", "module", "name", "desk", "asked_by", "outcome", "call", "sealed"},
|
||||
"issued": {"provision", "provider", "module", "call", "sealed", "holders"},
|
||||
"rotate": {"node", "module", "name", "provider", "provision", "by"},
|
||||
}
|
||||
for name, args := range want {
|
||||
var verb *Verb
|
||||
for i := range seat.Serves {
|
||||
if seat.Serves[i].Name == name {
|
||||
verb = &seat.Serves[i]
|
||||
}
|
||||
}
|
||||
if verb == nil {
|
||||
t.Errorf("the vault's seat does not promise %s", name)
|
||||
continue
|
||||
}
|
||||
if !verb.Optional {
|
||||
t.Errorf("%s is not optional while the vault that runs does not serve it", name)
|
||||
}
|
||||
props, _ := verb.Input["properties"].(map[string]any)
|
||||
for _, arg := range args {
|
||||
if props[arg] == nil {
|
||||
t.Errorf("%s takes no %s", name, arg)
|
||||
}
|
||||
}
|
||||
if props["value"] != nil {
|
||||
t.Errorf("%s takes a value in the clear", name)
|
||||
}
|
||||
}
|
||||
// The holders `issued` names (issue 749) are a list of {node, module}.
|
||||
for _, verb := range seat.Serves {
|
||||
if verb.Name != "issued" {
|
||||
continue
|
||||
}
|
||||
props, _ := verb.Input["properties"].(map[string]any)
|
||||
holders, _ := props["holders"].(map[string]any)
|
||||
items, _ := holders["items"].(map[string]any)
|
||||
each, _ := items["properties"].(map[string]any)
|
||||
if holders["type"] != "array" || each["node"] == nil || each["module"] == nil {
|
||||
t.Errorf("issued's holders are %v", holders)
|
||||
}
|
||||
}
|
||||
serving := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}},
|
||||
Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh,
|
||||
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate"}}}}
|
||||
if err := CanHold(serving, seat); err != nil {
|
||||
t.Fatalf("the vault serving its verbs was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The move of the controller's secrets into the vault (design 57's V3, issue 676): the seat promises move-in, which a
|
||||
// machine's node-engine asks in two steps with the row, its origin and generation, then the value sealed to the
|
||||
// call's key and a key to read back to; and move-asks, the controller's record of asks copied as it is. Optional
|
||||
// while the vault that runs does not serve them; never a value in the clear.
|
||||
func TestTheVaultsSeatPromisesTheMove(t *testing.T) {
|
||||
seat, _ := SeatNamed("mesh-vault")
|
||||
want := map[string][]string{
|
||||
"move-in": {"node", "module", "name", "provider", "provision", "consumer", "consumer_module", "local", "origin",
|
||||
"generation", "made_at", "call", "sealed", "read_back_to"},
|
||||
"move-asks": {"asks"},
|
||||
}
|
||||
for name, args := range want {
|
||||
var verb *Verb
|
||||
for i := range seat.Serves {
|
||||
if seat.Serves[i].Name == name {
|
||||
verb = &seat.Serves[i]
|
||||
}
|
||||
}
|
||||
if verb == nil {
|
||||
t.Errorf("the vault's seat does not promise %s", name)
|
||||
continue
|
||||
}
|
||||
if !verb.Optional {
|
||||
t.Errorf("%s is not optional while the vault that runs does not serve it", name)
|
||||
}
|
||||
props, _ := verb.Input["properties"].(map[string]any)
|
||||
for _, arg := range args {
|
||||
if props[arg] == nil {
|
||||
t.Errorf("%s takes no %s", name, arg)
|
||||
}
|
||||
}
|
||||
if props["value"] != nil {
|
||||
t.Errorf("%s takes a value in the clear", name)
|
||||
}
|
||||
}
|
||||
serving := Manifest{Module: "mesh-vault", Provides: []Offer{{Name: "secret", Scope: ScopeMesh}},
|
||||
Claims: []Claim{{Name: "mesh-vault", Scope: ScopeMesh,
|
||||
Serves: []string{"get", "accept", "secret-ask", "issued", "rotate", "move-in", "move-asks"}}}}
|
||||
if err := CanHold(serving, seat); err != nil {
|
||||
t.Fatalf("the vault serving the move was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 786: the vault's seat promises node-key, optional while the vault that runs does not serve it.
|
||||
func TestTheVaultsSeatPromisesNodeKey(t *testing.T) {
|
||||
seat, _ := SeatNamed("mesh-vault")
|
||||
for _, v := range seat.Serves {
|
||||
if v.Name != "node-key" {
|
||||
continue
|
||||
}
|
||||
props, _ := v.Input["properties"].(map[string]any)
|
||||
if !v.Optional || props["key"] == nil {
|
||||
t.Errorf("node-key: optional %v, key %v", v.Optional, props["key"])
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Error("the vault's seat does not promise node-key")
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A served verb is written as a bare name or in full, and both read into one type (novox/hq ADR 0132).
|
||||
func TestAServedVerbIsANameOrADefinition(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(`{"module":"till","version":"1","tools":["price","refund"],` +
|
||||
`"seats":[{"name":"shop-till","serves":["price",{"name":"refund","description":"give it back",` +
|
||||
`"input":{"type":"object","properties":{"order":{"type":"string"}}}}]}],` +
|
||||
`"claims":[{"name":"shop-till","scope":"mesh"}]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := m.DefinesSeats[0].Serves
|
||||
if len(got) != 2 || got[0].Name != "price" || got[1].Name != "refund" || got[1].Description != "give it back" {
|
||||
t.Fatalf("verbs not read: %+v", got)
|
||||
}
|
||||
if got[1].Input["type"] != "object" {
|
||||
t.Fatalf("the schema did not travel with the verb: %+v", got[1].Input)
|
||||
}
|
||||
}
|
||||
|
||||
// A misspelt key inside a verb's definition is refused, like one anywhere else in the manifest.
|
||||
func TestAVerbWithAnUnknownKeyIsRefused(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"till","version":"1",` +
|
||||
`"seats":[{"name":"shop-till","serves":[{"name":"price","descripton":"typo"}]}]}`))
|
||||
if err == nil || !strings.Contains(err.Error(), "descripton") {
|
||||
t.Fatalf("a verb with a misspelt key was accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Holding a mesh seat that serves verbs requires serving them, and the refusal names the verbs.
|
||||
func TestHoldingAMeshSeatRequiresServingItsVerbs(t *testing.T) {
|
||||
was := Seats()
|
||||
t.Cleanup(func() { UseSeats(was) })
|
||||
UseSeats([]Seat{{Name: "mesh-controller", Scope: ScopeMesh, Decision: "test",
|
||||
Serves: []Verb{{Name: "status"}, {Name: "push"}}}})
|
||||
seat, _ := SeatNamed("mesh-controller")
|
||||
|
||||
partial := Manifest{Module: "a-controller", Tools: []string{"status"},
|
||||
Claims: []Claim{{Name: "mesh-controller", Scope: ScopeMesh}}}
|
||||
err := CanHold(partial, seat)
|
||||
if err == nil || !strings.Contains(err.Error(), "does not serve push") {
|
||||
t.Fatalf("a holder missing a verb was not refused by name: %v", err)
|
||||
}
|
||||
whole := Manifest{Module: "a-controller", Tools: []string{"status", "push"},
|
||||
Claims: []Claim{{Name: "mesh-controller", Scope: ScopeMesh}}}
|
||||
if err := CanHold(whole, seat); err != nil {
|
||||
t.Fatalf("a holder serving every verb was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The mesh's own verbs are declared in full: an agent cannot call a name without a schema.
|
||||
func TestEveryControllerVerbIsDescribedWithASchema(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for _, v := range ControllerVerbs {
|
||||
if v.Description == "" || v.Input == nil || v.Input["type"] != "object" {
|
||||
t.Errorf("%s: no description or no object schema", v.Name)
|
||||
}
|
||||
if seen[v.Name] {
|
||||
t.Errorf("%s declared twice", v.Name)
|
||||
}
|
||||
seen[v.Name] = true
|
||||
}
|
||||
seat, _ := SeatNamed(ControllerSeatName)
|
||||
if len(seat.Serves) != len(ControllerVerbs) {
|
||||
t.Fatalf("the compiled mesh-controller seat serves %d verbs, the table has %d", len(seat.Serves), len(ControllerVerbs))
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A component is unpacked into a directory named for its version, so it can read its own version from
|
||||
// its path (novox/hq ADR 0141, 0142). Until this, nothing could compose that path: an archive named a
|
||||
// fixed one and nothing interpolated the build into it, so nothing could ask for
|
||||
// `…/versions/<version>/` and every machine took a hand-placed fallback (04-ISSUES/142).
|
||||
|
||||
const aDigest = "sha256:ad62528c47c7b4a71cf814473f5de52a061348ce9521f707b0171a10fa6b247f"
|
||||
|
||||
func TestAnArchivePathCanNameTheBuildsOwnVersion(t *testing.T) {
|
||||
m := Manifest{
|
||||
Module: "mesh-host",
|
||||
Build: &Build{Artifacts: []Artifact{{Name: "host-arch", Kind: ArtifactBundle, Language: "go", System: "arch"}}},
|
||||
Resources: []map[string]any{{
|
||||
"id": "next", "type": "archive", "artifact": "host-arch",
|
||||
"path": "/usr/lib/nox-mesh-host/versions/${version}",
|
||||
}},
|
||||
}
|
||||
got, err := m.Resolve([]Built{{Name: "host-arch", Kind: ArtifactBundle,
|
||||
Reference: "artifact-store://mesh-host/host-arch", Digest: aDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path, _ := got.Resources[0]["path"].(string)
|
||||
if strings.Contains(path, "${version}") {
|
||||
t.Fatalf("the version was not resolved: %q", path)
|
||||
}
|
||||
if path != "/usr/lib/nox-mesh-host/versions/ad62528c47c7" {
|
||||
t.Fatalf("the path resolved to %q", path)
|
||||
}
|
||||
// The artifact key goes, as it does for every resolved resource: it is a build-time word and the
|
||||
// host has never heard of it.
|
||||
if _, still := got.Resources[0]["artifact"]; still {
|
||||
t.Fatal("the artifact key survived resolution")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheVersionIsTheDigestSoAnUnchangedBuildKeepsItsPath(t *testing.T) {
|
||||
// The alternative is the commit, and two builds of one commit are meant to be the same bytes —
|
||||
// every toolchain here is -trimpath for that reason. A commit-named path would move for an
|
||||
// identical binary and recreate everything reading it.
|
||||
first := versionOf(aDigest)
|
||||
again := versionOf(aDigest)
|
||||
if first != again || first == "" {
|
||||
t.Fatalf("the same bytes produced %q and %q", first, again)
|
||||
}
|
||||
if other := versionOf("sha256:" + strings.Repeat("b", 64)); other == first {
|
||||
t.Fatal("different bytes produced the same version")
|
||||
}
|
||||
// A path is read by people and quoted by shells.
|
||||
if strings.ContainsAny(first, ":/ ") {
|
||||
t.Fatalf("the version is not safe in a path: %q", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnImageIsRefusedAVersionedPlace(t *testing.T) {
|
||||
// An image is not unpacked, so it has no directory to be named for its version. Left as literal
|
||||
// text it would reach a machine and be created as a directory called ${version}.
|
||||
m := Manifest{
|
||||
Module: "something",
|
||||
Build: &Build{Artifacts: []Artifact{{Name: "server", Kind: ArtifactImage, From: "Dockerfile"}}},
|
||||
Resources: []map[string]any{{
|
||||
"id": "where", "type": "directory", "artifact": "server",
|
||||
"path": "/var/lib/something/${version}",
|
||||
}},
|
||||
}
|
||||
_, err := m.Resolve([]Built{{Name: "server", Kind: ArtifactImage, Reference: "registry/x@" + aDigest}})
|
||||
if err == nil {
|
||||
t.Fatal("an image was given a versioned place")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "not unpacked") {
|
||||
t.Fatalf("the refusal does not say why: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAResourceWithoutAVersionReferenceIsUntouched(t *testing.T) {
|
||||
m := Manifest{
|
||||
Module: "mesh-host",
|
||||
Build: &Build{Artifacts: []Artifact{{Name: "host-arch", Kind: ArtifactBundle, Language: "go", System: "arch"}}},
|
||||
Resources: []map[string]any{{
|
||||
"id": "next", "type": "archive", "artifact": "host-arch", "path": "/usr/lib/fixed",
|
||||
}},
|
||||
}
|
||||
got, err := m.Resolve([]Built{{Name: "host-arch", Kind: ArtifactBundle,
|
||||
Reference: "artifact-store://mesh-host/host-arch", Digest: aDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if path, _ := got.Resources[0]["path"].(string); path != "/usr/lib/fixed" {
|
||||
t.Fatalf("a path naming no version became %q", path)
|
||||
}
|
||||
}
|
||||
|
||||
// A process's env can name the build's own version too (novox/hq issue 352): the controller is told which
|
||||
// build it is, and records what that build reads of the store's schema under it.
|
||||
func TestAProcessEnvCanNameTheBuildsOwnVersion(t *testing.T) {
|
||||
m := Manifest{
|
||||
Module: "mesh-controller",
|
||||
Build: &Build{Artifacts: []Artifact{{Name: "controller", Kind: ArtifactBundle, Language: "go", System: "arch"}}},
|
||||
Resources: []map[string]any{{
|
||||
"id": "controller", "type": "process", "artifact": "controller", "run": []any{"./mesh-controller", "serve"},
|
||||
"env": map[string]any{"MESH_CONTROLLER_VERSION": "${version}", "OTHER": "kept"},
|
||||
}},
|
||||
}
|
||||
got, err := m.Resolve([]Built{{Name: "controller", Kind: ArtifactBundle,
|
||||
Reference: "artifact-store://mesh-controller/controller", Digest: aDigest}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
env, _ := got.Resources[0]["env"].(map[string]any)
|
||||
if env["MESH_CONTROLLER_VERSION"] != "ad62528c47c7" || env["OTHER"] != "kept" {
|
||||
t.Fatalf("the env resolved to %v", env)
|
||||
}
|
||||
if run, _ := got.Resources[0]["run"].([]any); len(run) != 2 {
|
||||
t.Fatalf("the run was changed: %v", run)
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// novox/hq ADR 0367 §2: the tool runner's module names, one by one, the event families a session may wait on.
|
||||
func TestTheToolRunnerMayNameTheEventsASessionWaitsOn(t *testing.T) {
|
||||
m, err := ParseManifest([]byte(`{"module":"node-tools","version":"1",` +
|
||||
`"waits":["mesh-knowledge.reviewed","operator-channel.decided.claude-code"]}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m.Waits) != 2 || m.Waits[1] != "operator-channel.decided.claude-code" {
|
||||
t.Fatalf("waits not read: %v", m.Waits)
|
||||
}
|
||||
}
|
||||
|
||||
// A family is named one by one, never by a wildcard, and only by the tool runner's module (ADR 0367 §2, §6):
|
||||
// each refusal is said in the manifest's words, at parse, rather than at the composition of the bus's users.
|
||||
func TestAWaitIsRefusedUnlessItNamesOneFamilyOfTheToolRunner(t *testing.T) {
|
||||
for manifest, want := range map[string]string{
|
||||
`{"module":"shop","version":"1","waits":["mesh-knowledge.reviewed"]}`: "only node-tools",
|
||||
`{"module":"node-tools","version":"1","waits":["mesh-knowledge.*"]}`: "never a wildcard",
|
||||
`{"module":"node-tools","version":"1","waits":["**"]}`: "never a wildcard",
|
||||
`{"module":"node-tools","version":"1","waits":["mesh-knowledge.>"]}`: "never a wildcard",
|
||||
`{"module":"node-tools","version":"1","waits":["*.reviewed"]}`: "never a wildcard",
|
||||
`{"module":"node-tools","version":"1","waits":["reviewed"]}`: "does not name an emitter and an event",
|
||||
`{"module":"node-tools","version":"1","waits":["mesh-knowledge..reviewed"]}`: "does not name an emitter and an event",
|
||||
`{"module":"node-tools","version":"1","waits":["Mesh-Knowledge.reviewed x"]}`: "does not name an emitter and an event",
|
||||
`{"module":"node-tools","version":"1","waits":["a.b","a.b"]}`: "twice",
|
||||
} {
|
||||
_, err := ParseManifest([]byte(manifest))
|
||||
if err == nil {
|
||||
t.Errorf("accepted: %s", manifest)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%s refused for the wrong reason (want %q): %v", manifest, want, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A scheduled step may hold its module's own containers still while it runs (novox/hq ADR 0189).
|
||||
//
|
||||
// The host judges what it receives — whether each id is a container on that machine. What the
|
||||
// definition is the only place to see is judged here, near whoever wrote it.
|
||||
|
||||
func aStoreManifest(step map[string]any) []byte {
|
||||
m := map[string]any{
|
||||
"module": "distribution", "version": "1",
|
||||
"resources": []any{
|
||||
map[string]any{"id": "store", "type": "container", "name": "mesh-registry",
|
||||
"image": "registry@sha256:" + strings.Repeat("a", 64)},
|
||||
step,
|
||||
},
|
||||
}
|
||||
raw, _ := json.Marshal(m)
|
||||
return raw
|
||||
}
|
||||
|
||||
func TestAMaintenanceWindowOnItsOwnModulesContainerIsAccepted(t *testing.T) {
|
||||
raw := aStoreManifest(map[string]any{
|
||||
"id": "collect", "type": "container", "name": "mesh-registry-collect",
|
||||
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
||||
"schedule": "30 3 * * *", "while-stopped": []any{"store"},
|
||||
})
|
||||
if _, err := ParseManifest(raw); err != nil {
|
||||
t.Fatalf("a step holding its own module's container still was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMaintenanceWindowIsRefusedWhereTheDefinitionShowsItCannotMean(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
step map[string]any
|
||||
says string
|
||||
}{
|
||||
{
|
||||
"on a step with no schedule",
|
||||
map[string]any{"id": "collect", "type": "container", "name": "c",
|
||||
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
||||
"while-stopped": []any{"store"}},
|
||||
"gates what is declared after it",
|
||||
},
|
||||
{
|
||||
"on a run-once step, which already has order",
|
||||
map[string]any{"id": "collect", "type": "container", "name": "c",
|
||||
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
||||
"run-once": true, "while-stopped": []any{"store"}},
|
||||
"A maintenance window is for a recurring step",
|
||||
},
|
||||
{
|
||||
"naming a container this module does not declare",
|
||||
map[string]any{"id": "collect", "type": "container", "name": "c",
|
||||
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
||||
"schedule": "30 3 * * *", "while-stopped": []any{"the-broker"}},
|
||||
"could quiesce a neighbour could stop the mesh",
|
||||
},
|
||||
{
|
||||
"naming itself",
|
||||
map[string]any{"id": "collect", "type": "container", "name": "c",
|
||||
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
||||
"schedule": "30 3 * * *", "while-stopped": []any{"collect"}},
|
||||
"naming itself",
|
||||
},
|
||||
{
|
||||
"written as something that is not a list",
|
||||
map[string]any{"id": "collect", "type": "container", "name": "c",
|
||||
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
||||
"schedule": "30 3 * * *", "while-stopped": "store"},
|
||||
"a list of this module's container ids",
|
||||
},
|
||||
} {
|
||||
_, err := ParseManifest(aStoreManifest(c.step))
|
||||
if err == nil {
|
||||
t.Errorf("%s was accepted", c.name)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%s: the refusal does not say %q:\n%v", c.name, c.says, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A composed declaration names the step's held containers the way the machine knows them.
|
||||
//
|
||||
// **The gap that let a bug through to the control node.** The manifest says `while-stopped:
|
||||
// ["store"]`, because a module names its own resources locally; the declaration a machine
|
||||
// receives calls that container `distribution.store`, because every resource is composed under
|
||||
// its module. `restart-on` and `reload-on` are rewritten for exactly this reason, and
|
||||
// `while-stopped` was not — so the host found no container by that id and refused the whole
|
||||
// declaration, every push, until it was fixed.
|
||||
//
|
||||
// It passed every test on both sides: the controller's tests read manifests, the host's read
|
||||
// hand-written declarations with bare ids. Only composing one and judging the result catches it.
|
||||
func TestAComposedWindowNamesTheContainerAsTheMachineKnowsIt(t *testing.T) {
|
||||
store := Manifest{
|
||||
Module: "distribution", Version: "1",
|
||||
Provides: FromAnywhere("artifact-store"),
|
||||
Listens: []Listening{{Port: 5000, Protocol: "tcp", From: FromMesh}},
|
||||
Serves: map[string]map[string]any{"artifact-store": {"port": 5000}},
|
||||
Resources: []map[string]any{
|
||||
{"id": "store", "type": "container", "name": "mesh-registry",
|
||||
"image": "registry@sha256:" + strings.Repeat("a", 64), "ports": []any{"5000"}},
|
||||
{"id": "collect", "type": "container", "name": "mesh-registry-collect",
|
||||
"image": "registry@sha256:" + strings.Repeat("a", 64),
|
||||
"schedule": "30 3 * * *", WhileStopped: []any{"store"}},
|
||||
},
|
||||
}
|
||||
r, err := Resolve(shelf(store), []string{"distribution"}, reachable(), World{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := r.Declaration(Rendering{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
collect := fileNamed(out, "distribution.collect")
|
||||
if collect == nil {
|
||||
for _, res := range out {
|
||||
if res["id"] == "distribution.collect" {
|
||||
collect = res
|
||||
}
|
||||
}
|
||||
}
|
||||
if collect == nil {
|
||||
t.Fatalf("the step was not composed at all: %v", out)
|
||||
}
|
||||
held, _ := collect[WhileStopped].([]any)
|
||||
if len(held) != 1 {
|
||||
t.Fatalf("the composed step holds %v still; want one container", collect[WhileStopped])
|
||||
}
|
||||
if got := fmt.Sprint(held[0]); got != "distribution.store" {
|
||||
t.Fatalf("the composed step says it holds %q still, and the machine's container is "+
|
||||
"called %q — the host refuses a declaration naming a container it does not have, "+
|
||||
"whole, so the machine would take nothing at all", got, "distribution.store")
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A work-queue stream is read by exactly one process of its own module (novox/hq ADR 0436 §6): named once
|
||||
// in a process's bus.queues, and never asked for by another module's consumes.
|
||||
|
||||
// mailSenderManifestWithQueue is the mesh-mail fixture (ADR 0436 §7, §8) with its process's bus.queues set
|
||||
// as given, so each 1178/AC2 case can vary just that.
|
||||
func mailSenderManifestWithQueue(queues string) Manifest {
|
||||
return Manifest{
|
||||
Module: "mesh-mail", Version: "1", Emits: []string{"mail.send.sent", "mail.send.failed"},
|
||||
State: []StateDeclaration{{Name: "sends"}},
|
||||
Streams: []StreamDeclaration{{Name: "mail-sends", Events: []string{"mail.send.approved"},
|
||||
Retention: StreamRetentionWorkQueue, MaxDeliveries: 5}},
|
||||
Resources: []map[string]any{
|
||||
{"id": "account", "type": "user", "name": "mesh-mail-send"},
|
||||
{"id": "send", "type": "process", "run": []any{"./mesh-mail", "send"}, "user": "mesh-mail-send",
|
||||
"bus": map[string]any{"emits": []any{"mail.send.sent", "mail.send.failed"}, "queues": queueList(queues)}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func queueList(q string) []any {
|
||||
if q == "" {
|
||||
return nil
|
||||
}
|
||||
out := []any{}
|
||||
for _, s := range strings.Split(q, ",") {
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// 1178/AC2 (named): a work-queue stream no process names is refused.
|
||||
func TestAWorkQueueStreamNoProcessNamesIsRefused(t *testing.T) {
|
||||
m := mailSenderManifestWithQueue("")
|
||||
got := strings.Join(WorkQueueStreamProblems(m), "; ")
|
||||
if !strings.Contains(got, "mail-sends") || !strings.Contains(got, "no process names it") {
|
||||
t.Fatalf("an unnamed work-queue stream: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1178/AC2 (named twice): a work-queue stream two processes name is refused.
|
||||
func TestAWorkQueueStreamTwoProcessesNameIsRefused(t *testing.T) {
|
||||
m := mailSenderManifestWithQueue("mail-sends")
|
||||
m.Resources = append(m.Resources, map[string]any{"id": "resend", "type": "process",
|
||||
"run": []any{"./mesh-mail", "send"}, "user": "mesh-mail-send",
|
||||
"bus": map[string]any{"emits": []any{"mail.send.sent"}, "queues": []any{"mail-sends"}}})
|
||||
got := strings.Join(WorkQueueStreamProblems(m), "; ")
|
||||
if !strings.Contains(got, "mail-sends") || !strings.Contains(got, "2 processes name") {
|
||||
t.Fatalf("a work-queue stream two processes name: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1178/AC2 (named once): a work-queue stream exactly one process names is accepted.
|
||||
func TestAWorkQueueStreamOneProcessNamesIsAccepted(t *testing.T) {
|
||||
m := mailSenderManifestWithQueue("mail-sends")
|
||||
if got := WorkQueueStreamProblems(m); len(got) != 0 {
|
||||
t.Fatalf("a work-queue stream one process names was refused: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 1178/AC2 (another module's consumes): a work-queue stream another module's consumes asks for is refused.
|
||||
func TestAWorkQueueStreamAnotherModulesConsumesIsRefused(t *testing.T) {
|
||||
sender := mailSenderManifestWithQueue("mail-sends")
|
||||
reader := Manifest{Module: "reader", Version: "1", Consumes: []string{"mesh-mail.mail.send.approved"}}
|
||||
got := strings.Join(WorkQueueConsumedElsewhereProblems(Shelf{"mesh-mail": sender, "reader": reader}), "; ")
|
||||
if !strings.Contains(got, "reader") || !strings.Contains(got, "MAIL_SENDS") {
|
||||
t.Fatalf("another module's consumes of a work-queue stream: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package catalogue
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A zone names the listen that answers it, or there is nothing to forward to (novox/hq ADR 0199).
|
||||
func TestAZoneMustNameOneOfTheModulesListens(t *testing.T) {
|
||||
_, err := ParseManifest([]byte(`{"module":"lab","version":"1",
|
||||
"listens":[{"name":"dns","port":5353,"protocol":"udp","from":"mesh","why":"the lab's names"}],
|
||||
"zone":{"name":"${setting:zone}","listen":"web"}}`))
|
||||
if err == nil || !strings.Contains(err.Error(), `has no listen of that name`) {
|
||||
t.Fatalf("a zone answered by a listen the module does not have was accepted: %v", err)
|
||||
}
|
||||
if _, err := ParseManifest([]byte(`{"module":"lab","version":"1",
|
||||
"listens":[{"name":"dns","port":5353,"protocol":"udp","from":"mesh","why":"the lab's names"}],
|
||||
"zone":{"name":"${setting:zone}","listen":"dns"}}`)); err != nil {
|
||||
t.Fatalf("a well-formed zone was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// The zone is the operator's (a setting) and the port is where this machine publishes the listen —
|
||||
// neither is the definition's to state.
|
||||
func TestAZoneIsPlacedFromTheNodesSettingAndPublishedPort(t *testing.T) {
|
||||
m := Manifest{Module: "lab", Zone: &Zone{Name: "${setting:zone}", Listen: "dns"},
|
||||
Listens: []Listening{{Name: "dns", Port: 5353, From: FromMesh}}}
|
||||
z, err := ZoneOn(m, []Layer{{From: "node", Values: map[string]any{"zone": "Incus."}}},
|
||||
map[int]int{5353: 15353}, "workstation", "10.77.0.3")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if z.Zone != "incus" || z.Address != "10.77.0.3" || z.Port != 15353 || z.Node != "workstation" {
|
||||
t.Fatalf("the zone was placed as %+v", *z)
|
||||
}
|
||||
if _, err := ZoneOn(m, nil, nil, "workstation", "10.77.0.3"); err == nil {
|
||||
t.Fatal("a zone nobody named was placed")
|
||||
}
|
||||
}
|
||||
|
||||
// One module answers a zone, and none may shadow the mesh's names or a public domain.
|
||||
func TestTheMeshRefusesAZoneTwiceOrOneThatShadows(t *testing.T) {
|
||||
one := ZoneAt{Zone: "incus", Node: "workstation", Module: "lab", Address: "10.77.0.3", Port: 53}
|
||||
if p := ZonesProblems([]ZoneAt{one}, "internal", []string{"example.tld"}); len(p) != 0 {
|
||||
t.Fatalf("one ordinary zone was refused: %v", p)
|
||||
}
|
||||
twice := one
|
||||
twice.Node, twice.Module = "laptop", "other"
|
||||
cases := map[string][]ZoneAt{
|
||||
"declared by": {one, twice},
|
||||
"mesh's own suffix": {{Zone: "lab.internal", Node: "a", Module: "m"}},
|
||||
"public domain": {{Zone: "dev.example.tld", Node: "a", Module: "m"}},
|
||||
}
|
||||
for want, zones := range cases {
|
||||
p := strings.Join(ZonesProblems(zones, "internal", []string{"example.tld"}), "\n")
|
||||
if !strings.Contains(p, want) {
|
||||
t.Errorf("not refused for %q: %q", want, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The resolver's template sees every zone with where it is answered, in zone order.
|
||||
func TestTheResolversTemplateRangesTheZones(t *testing.T) {
|
||||
m := Manifest{Module: "dnsmasq", Facts: map[string]RosterFile{"zones": {
|
||||
Path: "/etc/mesh-resolver/zones.conf",
|
||||
Template: "{{range .Zones}}server=/{{.Zone}}/{{.Address}}#{{.Port}}\n{{end}}",
|
||||
}}}
|
||||
out, err := FactsWithZonesInto(m, Resolution{Node: "anchor"}, nil, nil, nil, "", []ZoneAt{
|
||||
{Zone: "zeta", Address: "10.77.0.2", Port: 53},
|
||||
{Zone: "incus", Address: "10.77.0.3", Port: 15353},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := out[0]["content"]; got != "server=/incus/10.77.0.3#15353\nserver=/zeta/10.77.0.2#53\n" {
|
||||
t.Fatalf("the resolver was told %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user