mesh-delivery is over its budget (810 unit, 73 integration on main), so this branch adds none net: its new cases are subtests, the three work-queue process tests and the consumes test become one, the history stream bounds join the stream values table, and the go/-only mesh-sdk merge check joins the one new integration test. buildrun's 20 unit and 1 integration tests moved in from mesh-sdk with it. Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
6449 lines
320 KiB
Go
6449 lines
320 KiB
Go
// The judge's tests, moved from mesh-sdk go/catalogue at 25a2cec with the rules they test (novox/hq ADR 0578 §4, issue
|
|
// 1585); those reading the catalogue read the copy captured in testdata/beside (internal/beside).
|
|
|
|
package judge
|
|
|
|
import (
|
|
json "encoding/json"
|
|
fmt "fmt"
|
|
catalogue "git.novox.be/novox/mesh-sdk/go/catalogue"
|
|
ast "go/ast"
|
|
parser "go/parser"
|
|
token "go/token"
|
|
beside "mesh-delivery-indexer/internal/beside"
|
|
os "os"
|
|
filepath "path/filepath"
|
|
reflect "reflect"
|
|
regexp "regexp"
|
|
slices "slices"
|
|
strconv "strconv"
|
|
strings "strings"
|
|
sync "sync"
|
|
testing "testing"
|
|
)
|
|
|
|
// ---- moved from catalogue ----
|
|
|
|
// A backup contribution written by hand still names its module's own directories; one it does not
|
|
// declare is refused where it is written rather than reaching the holder as the literal text. (The
|
|
// catalogue check refuses a hand-written backup line at all since ADR 0233; parsing still reads one,
|
|
// because a module on the shelf was written before.)
|
|
func TestABackupNamingAnUndeclaredDirectoryIsRefused(t *testing.T) {
|
|
_, err := ParseManifest([]byte(`{"module":"pg","version":"1",
|
|
"contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:nowhere}"}]}`))
|
|
if err == nil || !strings.Contains(err.Error(), "nowhere") {
|
|
t.Fatalf("a backup of an undeclared directory was accepted: %v", err)
|
|
}
|
|
}
|
|
|
|
// **A language that compiles to a binary must say which system.**
|
|
//
|
|
// A binary is pinned to one operating system at link time, so a host refuses to touch a machine it
|
|
// was not built for. An artifact that says nothing would be compiled for whatever the build machine
|
|
// happened to be — which reads as portable and is not, and is the fault this check exists for.
|
|
func TestABinaryMustSayWhichSystemItIsFor(t *testing.T) {
|
|
got := buildProblemsOf(t, bundleFor("go", ""))
|
|
if !strings.Contains(got, "says no system") {
|
|
t.Fatalf("a compiled bundle with no system was accepted:\n%s", got)
|
|
}
|
|
// And the refusal names what it could have said, so a reader is one edit from right.
|
|
for _, system := range []string{"alpine", "android", "arch"} {
|
|
if !strings.Contains(got, system) {
|
|
t.Fatalf("the refusal does not name %q as a choice:\n%s", system, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestABinaryThatNamesASystemIsAccepted(t *testing.T) {
|
|
if got := buildProblemsOf(t, bundleFor("go", "arch")); got != "" {
|
|
t.Fatalf("a compiled bundle naming a system was refused:\n%s", got)
|
|
}
|
|
}
|
|
|
|
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 TestABuildContextOnASeatNamesNoForge(t *testing.T) {
|
|
m := catalogue.Manifest{Module: "packager", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{
|
|
{Name: "server", Kind: "image", From: "Dockerfile",
|
|
Context: &catalogue.ArtifactContext{Seat: "git", Repository: "org/controller", Ref: "main"}},
|
|
}}}
|
|
if got := InstallationProblems(m); len(got) != 0 {
|
|
t.Fatalf("a context on a seat was reported: %v", got)
|
|
}
|
|
m.Build.Artifacts[0].Context = &catalogue.ArtifactContext{Repository: "https://git.mesh-one.be/org/controller.git"}
|
|
if got := InstallationProblems(m); len(got) != 1 {
|
|
t.Fatalf("a context by URL was not reported: %v", got)
|
|
}
|
|
}
|
|
|
|
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 != catalogue.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)
|
|
}
|
|
}
|
|
|
|
// 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 := catalogue.Rendering{ArtifactStore: "anchor.internal:5101",
|
|
Needed: map[string]map[string]string{
|
|
catalogue.RuntimeModule: {"broker": "sealed-credential"},
|
|
"dash": {"token": "sealed-token"},
|
|
}}
|
|
dash := catalogue.Manifest{Module: "dash", Version: "1", Tools: []string{"status"},
|
|
Listens: []catalogue.Listening{{Name: "web", Port: 3000, Protocol: "tcp"}},
|
|
OwnSecrets: catalogue.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: &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "tools", Kind: catalogue.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 := buildProblems(dash.Build, dash.Module); len(problems) > 0 {
|
|
t.Fatalf("a bundle's words written with ${dir:…} and ${port:…} were refused: %v", problems)
|
|
}
|
|
dash, err := dash.Resolve([]catalogue.Built{{Name: "tools", Kind: catalogue.ArtifactBundle,
|
|
Reference: catalogue.ArtifactStoreScheme + "dash/tools/blobs/" + bundleDigest, Digest: bundleDigest}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
other := aToolsModule(t, "nftables", "tools/index.js")
|
|
|
|
out, err := catalogue.Resolution{Node: "anchor", Account: "ops", Modules: []catalogue.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, catalogue.RuntimeModule+"."+catalogue.RuntimeProcessID())
|
|
env := process["env"].(map[string]string)
|
|
var given map[string]map[string]string
|
|
if err := json.Unmarshal([]byte(env[catalogue.RuntimeToolEnv]), &given); err != nil {
|
|
t.Fatalf("the runtime's %s is not JSON: %q", catalogue.RuntimeToolEnv, env[catalogue.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." + catalogue.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." + catalogue.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 := catalogue.Resolution{Node: "anchor", Modules: []catalogue.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([]catalogue.Bundle(nil), dash.Bundles...)
|
|
changed.Bundles[0].Env = map[string]string{"DASH_ADMIN": "somebody-else"}
|
|
out2, err := catalogue.Resolution{Node: "anchor", Account: "ops", Modules: []catalogue.Manifest{changed, theRuntime(t)}}.Declaration(with)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if fmt.Sprint(fileNamed(out2, catalogue.RuntimeModule+"."+catalogue.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")
|
|
}
|
|
})
|
|
}
|
|
|
|
// 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() catalogue.Manifest {
|
|
return catalogue.Manifest{Module: "baserow", Version: "1", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{
|
|
{Name: "tools", Kind: catalogue.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 = catalogue.RuntimeModule
|
|
for name, m := range map[string]catalogue.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)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestABundlesWordsAreRefusedWhenTheyAreNotPathsOrConstants(t *testing.T) {
|
|
m := catalogue.Manifest{Module: "dash", Version: "1", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{
|
|
{Name: "tools", Kind: catalogue.ArtifactBundle, Language: "typescript", Entrypoints: []string{"tools/index.js"},
|
|
Env: map[string]string{"DASH_TOKEN": "${secret:token}", catalogue.RuntimeBrokerFile: "/x", "DASH_PEER": "${bound:db:url}"}},
|
|
{Name: "runtime", Kind: catalogue.ArtifactImage, From: "Dockerfile", Env: map[string]string{"X": "y"}},
|
|
}}}
|
|
said := strings.Join(buildProblems(m.Build, 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 ` + catalogue.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)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestABusyIsHeldToItsShape(t *testing.T) {
|
|
if _, err := ParseManifest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"})); err != nil {
|
|
t.Fatalf("a well-formed busy was refused: %v", err)
|
|
}
|
|
if _, err := ParseManifest(busyManifest(t, "agent", map[string]any{"command": "x", "timeout": "30s"})); err != nil {
|
|
t.Fatalf("a busy with its timeout was refused: %v", err)
|
|
}
|
|
for name, c := range map[string]struct {
|
|
on string
|
|
busy any
|
|
says string
|
|
}{
|
|
"text": {"agent", "mesh-builder busy", "it is an object"},
|
|
"no command": {"agent", map[string]any{"timeout": "5s"}, "names no command"},
|
|
"two lines": {"agent", map[string]any{"command": "a\nb"}, "one line"},
|
|
"a long timeout": {"agent", map[string]any{"command": "x", "timeout": "5m"}, "at most 1m0s"},
|
|
"an unknown key": {"agent", map[string]any{"command": "x", "every": "1m"}, `says "every"`},
|
|
"on a step": {"seed", map[string]any{"command": "x"}, "not a container that stays up"},
|
|
"on a service": {"daemon", map[string]any{"command": "x"}, "not a container that stays up"},
|
|
} {
|
|
_, err := ParseManifest(busyManifest(t, c.on, c.busy))
|
|
if err == nil || !strings.Contains(err.Error(), c.says) {
|
|
t.Errorf("%s: want a refusal saying %q, got %v", name, c.says, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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([]catalogue.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, catalogue.Shelf{"telegram": m}); strings.Contains(got, "does not serve") {
|
|
t.Fatalf("a claim naming the seat's verb 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 = []catalogue.Claim{{Name: "telegram-sender", Scope: catalogue.ScopeNode}}
|
|
got := problemsFor(t, catalogue.Shelf{"telegram": m})
|
|
if !strings.Contains(got, "scope") {
|
|
t.Fatalf("a claim at the wrong scope was accepted: %s", got)
|
|
}
|
|
}
|
|
|
|
// 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 := catalogue.Manifest{Module: "someone", DefinesSeats: []catalogue.SeatDeclaration{
|
|
{Name: "the-anything", Scope: catalogue.ScopeNode, Accepts: []string{"work"}},
|
|
}}
|
|
if problems := CatalogueProblems(catalogue.Shelf{claimant.Module: claimant, "someone": declarer}); len(problems) != 0 {
|
|
t.Fatalf("a claim on a declared seat was refused: %v", problems)
|
|
}
|
|
// The second of a kind (issue 1952; the lead, 2026-10-29): a holder that lists its seat's event among its own emits
|
|
// would say it where no consumer hears it, so the manifest is refused, naming the seat's subject; the seat's
|
|
// holder saying nothing of its own is accepted.
|
|
holder := func(emits string) []byte {
|
|
return []byte(`{"module":"mesh-self-check","version":"1","emits":` + emits + `,"claims":[{"name":"mesh-self-check","scope":"mesh","serves":["doctor","probes"]}]}`)
|
|
}
|
|
if _, err := ParseManifest(holder(`["checked"]`)); err == nil || !strings.Contains(err.Error(), "mesh.seat.mesh-self-check.event.checked") {
|
|
t.Errorf("a seat's event listed as the module's own: %v", err)
|
|
}
|
|
if _, err := ParseManifest(holder(`[]`)); err != nil {
|
|
t.Errorf("a holder saying its seat's event on the seat was refused: %v", err)
|
|
}
|
|
}
|
|
|
|
// **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(catalogue.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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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 := catalogue.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]catalogue.RosterFile{"name": {Path: "/etc/hostname", Template: "{{.Node}}\n"}}
|
|
if err := catalogue.CanHold(rendered, seat); err != nil {
|
|
t.Errorf("a holder declaring a path as a file the mesh renders was refused: %v", err)
|
|
}
|
|
err := catalogue.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]catalogue.RosterFile{"name": {Path: "/etc/hostname", Home: true, Template: "x"}}
|
|
if err := catalogue.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(catalogue.Shelf{"names": hostnameHolder(hosts)}), "; "); !strings.Contains(got,
|
|
"does not declare /etc/hostname") {
|
|
t.Errorf("registration let a holder without /etc/hostname through: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestACompiledBundleStillMustSayItsSystem(t *testing.T) {
|
|
b := &catalogue.Build{Artifacts: []catalogue.Artifact{{
|
|
Name: "host", Kind: catalogue.ArtifactBundle, Language: "go", From: "cmd/mesh-host",
|
|
}}}
|
|
if len(buildProblems(b, "mesh-host")) == 0 {
|
|
t.Fatal("a compiled bundle with no system was accepted")
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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 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 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 TestADeclaredStoreParsesAndPassesTheCheck(t *testing.T) {
|
|
m := mustParse(t, declaredStore)
|
|
if problems := DataProblems(catalogue.Shelf{"pg": m}); len(problems) > 0 {
|
|
t.Fatalf("a store declaring its data was refused: %v", problems)
|
|
}
|
|
if !m.KeepsConsumerData("postgres-database") {
|
|
t.Fatal("an irreplaceable consumer class does not keep the consumer's data")
|
|
}
|
|
if it, _ := m.DataItem("store"); !it.BackedUp() || it.BackupWithin() != catalogue.DefaultBackupWithin || it.ActiveWithin().Hours() != 24 {
|
|
t.Fatalf("the store item reads %+v", it)
|
|
}
|
|
if it, _ := m.DataItem("dumps"); it.BackedUp() {
|
|
t.Fatal("a rebuildable item that says none is backed up")
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A definition names no installation (novox/hq ADR 0112, ADR 0155). What the mesh acts on is judged;
|
|
// prose is not; the world's registries and resolvers are the world's; a declared exception is a
|
|
// reason a reader sees.
|
|
func TestADefinitionNamingAnInstallationIsNamedBack(t *testing.T) {
|
|
m := catalogue.Manifest{Module: "idp", Resources: []map[string]any{
|
|
{"id": "server", "type": "container", "image": "quay.io/keycloak/keycloak@sha256:aa",
|
|
"env": map[string]any{"KC_HOSTNAME": "https://login.mesh-one.be"}},
|
|
{"id": "env", "type": "file", "content": "REAL_IP_FROM=192.168.1.0/24,127.0.0.0/8,203.0.113.7,51.15.22.9\n"},
|
|
}, Listens: []catalogue.Listening{{Port: 8080, From: catalogue.FromMesh, Why: "the login page; login.mesh-one.be is a route grant"}}}
|
|
got := strings.Join(InstallationProblems(m), "\n")
|
|
for _, want := range []string{
|
|
"idp names login.mesh-one.be at resources[0].env.KC_HOSTNAME",
|
|
"idp names the public address 51.15.22.9 at resources[1].content",
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("missing %q in:\n%s", want, got)
|
|
}
|
|
}
|
|
for _, mustNot := range []string{"quay.io", "why", "203.0.113.7", "192.168.1.0", "127.0.0.0"} {
|
|
if strings.Contains(got, mustNot) {
|
|
t.Errorf("%q was reported and should not be:\n%s", mustNot, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func 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(catalogue.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 TestADirectoryBeneathAPlacedOneIsPlacedWithIt(t *testing.T) {
|
|
t.Run("anEnvironmentItemIsTypedAndNamesWhatItNeeds", anEnvironmentItemIsTypedAndNamesWhatItNeeds)
|
|
t.Run("theMeshsDirectoryForAModuleIsAPlace", theMeshsDirectoryForAModuleIsAPlace)
|
|
t.Run("anIdentityServedAsADNSLabelIsBoundedToOne", anIdentityServedAsADNSLabelIsBoundedToOne)
|
|
// An adopted layout keeps a subdirectory the predecessor made under the mesh's directory
|
|
// (issue 174: a forge's runtime state, a manager's output). Stated as beneath the placed one,
|
|
// it moves with it — a node's root moves both, and the definition names no host path.
|
|
m := catalogue.Manifest{Module: "gitea", Resources: []map[string]any{
|
|
{"id": "mesh-state", "type": "directory", "place": "mesh"},
|
|
{"id": "runtime-state", "type": "directory", "path": "${dir:mesh-state}/state"},
|
|
{"id": "server", "type": "container", "image": "x@sha256:aa",
|
|
"volumes": []any{"${dir:runtime-state}:/data"}},
|
|
}}
|
|
if got := manifestUnknownDirRefs(m); len(got) != 0 {
|
|
t.Fatalf("a path beneath a placed directory is well formed; got %v", got)
|
|
}
|
|
dirs := dirsFor(m, catalogue.Rendering{DataRoot: "/srv"})
|
|
if dirs["runtime-state"] != "/srv/mesh/gitea/state" {
|
|
t.Fatalf("the subdirectory follows the placed one; got %v", dirs)
|
|
}
|
|
|
|
}
|
|
|
|
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 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)
|
|
}
|
|
}
|
|
|
|
// 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 := catalogue.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(catalogue.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 := catalogue.Manifest{Module: "agent", Resources: []map[string]any{
|
|
{"id": "settings", "type": "file", "path": "/a", "merge": catalogue.MergeJSON, "content": `{"managed_settings": {}}`},
|
|
{"id": "blank", "type": "file", "path": "/b", "merge": catalogue.MergeJSON, "content": `{}`}}}
|
|
if got := strings.Join(catalogue.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(catalogue.Manifest{Module: "power", Resources: []map[string]any{bad}}); len(problems) == 0 {
|
|
t.Errorf("%v was taken", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAGoBundleMayNameItsCommand(t *testing.T) {
|
|
b := &catalogue.Build{Artifacts: []catalogue.Artifact{{
|
|
Name: "host-arch", Kind: catalogue.ArtifactBundle, Language: "go", System: "arch",
|
|
From: "cmd/mesh-host",
|
|
}}}
|
|
if p := buildProblems(b, "mesh-host"); len(p) != 0 {
|
|
t.Fatalf("a go bundle naming its command was refused: %v", 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 := catalogue.Rendering{ArtifactStore: "anchor.internal:5101",
|
|
Needed: map[string]map[string]string{catalogue.RuntimeModule: {"broker": "sealed-credential"}}}
|
|
lamp := catalogue.Manifest{Module: "lamp", Version: "1", Tools: []string{"on"},
|
|
Build: &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "tools", Kind: catalogue.ArtifactBundle, Language: "go",
|
|
System: "arch", From: "cmd/lamp-tools"}}}}
|
|
if p := buildProblems(lamp.Build, "lamp"); len(p) != 0 {
|
|
t.Fatalf("a Go tools bundle was refused: %v", p)
|
|
}
|
|
lamp, err := lamp.Resolve([]catalogue.Built{{Name: "tools", Kind: catalogue.ArtifactBundle,
|
|
Reference: catalogue.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 := catalogue.Resolution{Node: "anchor", Account: "ops", Modules: []catalogue.Manifest{lamp, theRuntime(t)}}.Declaration(with)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if fileNamed(out, "lamp."+catalogue.BundleID("tools")) == nil {
|
|
t.Errorf("the Go bundle is not delivered: %v", ids(out))
|
|
}
|
|
env := fileNamed(out, catalogue.RuntimeModule+"."+catalogue.RuntimeProcessID())["env"].(map[string]string)
|
|
if env[catalogue.RuntimeToolModules] != "lamp="+catalogue.BundlePath("lamp", "tools")+"/lamp-tools" {
|
|
t.Errorf("the runtime is told %q, want the binary", env[catalogue.RuntimeToolModules])
|
|
}
|
|
|
|
// An artifact may say it explicitly; naming anything but the binary is refused.
|
|
said := catalogue.Manifest{Module: "lamp", Version: "1", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "tools",
|
|
Kind: catalogue.ArtifactBundle, Language: "go", System: "arch", Binary: "lamp", Loads: []string{"lamp"}}}}}
|
|
if p := buildProblems(said.Build, "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 := buildProblems(said.Build, "lamp"); len(p) == 0 {
|
|
t.Error("a Go bundle loading a file it does not contain was admitted")
|
|
}
|
|
}
|
|
|
|
func TestAGuardedPortMustBeAPort(t *testing.T) {
|
|
if _, err := ParseManifest([]byte(`{"module":"postgres","guards":[5432]}`)); err != nil {
|
|
t.Fatalf("guards is refused: %v", err)
|
|
}
|
|
if _, err := ParseManifest([]byte(`{"module":"postgres","guards":[0]}`)); err == nil {
|
|
t.Fatal("guarding port 0 was accepted")
|
|
}
|
|
}
|
|
|
|
// Each entry names the credential form the provider's offer declares (issue 768), from the provider's manifest, so
|
|
// the vault makes a first value in that form for a provider it holds no row of: one of its own secrets shared by
|
|
// every consumer, a login per consumer when the offer says no credential, issued by its software, or given by a
|
|
// person (the offer's own secret issued outside).
|
|
func TestAHandedModulesPlanNamesTheCredentialFormOfItsProvidersOffer(t *testing.T) {
|
|
storeOffering := func(credential string) catalogue.Manifest {
|
|
raw := `{"module":"store","version":"1","provides":[{"name":"postgres-database","scope":"mesh"` +
|
|
credential + `}],"serves":{"postgres-database":{"port":5432}}`
|
|
if strings.Contains(credential, `"own"`) {
|
|
raw += `,"own-secrets":{"admin":{"path":"/var/lib/store/admin","taken":"at-start"},` +
|
|
`"token":{"path":"/var/lib/store/token","taken":"at-start","issued-by":"outside"}}`
|
|
}
|
|
return mustParse(t, raw+`}`)
|
|
}
|
|
for credential, want := range map[string]string{
|
|
// No default (the lead, 2026-10-29): an offer that says nothing has no form, and the catalogue refuses it.
|
|
``: "",
|
|
`,"credential":{"form":"pair"}`: catalogue.CredentialFormPair,
|
|
`,"credential":{"form":"none"}`: catalogue.CredentialFormNone,
|
|
`,"credential":{"own":"admin"}`: catalogue.CredentialFormShared,
|
|
`,"credential":{"own":"token"}`: catalogue.CredentialFormGiven,
|
|
`,"credential":{"issued-by":"outside"}`: catalogue.CredentialFormGiven,
|
|
`,"credential":{"issued-by":"software","tool":"store_issued_token"}`: catalogue.CredentialFormIssued,
|
|
} {
|
|
node := workstation()
|
|
node.At = "workstation.internal"
|
|
consumer := notifierConsumer(t, true, false)
|
|
r, err := catalogue.Resolve(shelf(consumer, storeOffering(credential)), []string{consumer.Module}, node, twoStores("anchor"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r.Account = "op"
|
|
c, _ := composeWith(t, r, handingTo("store:5000"))
|
|
got := c.Plans["notify"].Provisions
|
|
if len(got) != 1 || got[0].CredentialForm != want {
|
|
t.Errorf("an offer with %q: the plan names %+v, want the form %q", credential, got, want)
|
|
}
|
|
// Every form a plan names is in the one list each reader checks (issue 774); an undeclared one is not.
|
|
if catalogue.IsCredentialForm(want) != (want != "") {
|
|
t.Errorf("the form %q of an offer with %q: in CredentialForms %v", want, credential, catalogue.IsCredentialForm(want))
|
|
}
|
|
}
|
|
if forms := strings.Join(catalogue.CredentialForms(), ","); forms != "shared,pair,issued,given,none" || catalogue.IsCredentialForm("maybe") {
|
|
t.Errorf("the credential forms are %s, and maybe is one: %v", forms, catalogue.IsCredentialForm("maybe"))
|
|
}
|
|
if got := (catalogue.Manifest{Module: "x"}).CredentialFormOf("postgres-database"); got != "" {
|
|
t.Errorf("a module offering nothing has the credential form %q", got)
|
|
}
|
|
// The catalogue refuses an offer saying no form, and a form beside an own secret or not pair or none.
|
|
for credential, refused := range map[string]string{``: "says no credential form", `,"credential":{}`: "says no credential form",
|
|
`,"credential":{"form":"pair","own":"admin"}`: "beside an own secret", `,"credential":{"form":"maybe"}`: `not "pair" or "none"`,
|
|
`,"credential":{"form":"none"}`: ""} {
|
|
raw := `{"module":"store","version":"1","provides":[{"name":"postgres-database","scope":"mesh"` + credential + `}]}`
|
|
var m catalogue.Manifest
|
|
_ = json.Unmarshal([]byte(raw), &m)
|
|
problems := strings.Join(CredentialFormProblems(m), "; ")
|
|
if (refused == "") != (problems == "") || !strings.Contains(problems, refused) {
|
|
t.Errorf("%q: %q, want %q", credential, problems, refused)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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, catalogue.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)
|
|
}
|
|
}
|
|
|
|
// issue 900, ADR 0394 §2: a holder of node-files runs as the tool runner's account, so it declares no runs-as.
|
|
func TestAHolderOfNodeFilesDeclaringRunsAsIsRefused(t *testing.T) {
|
|
p := FilesHolderProblems(catalogue.Manifest{Module: "files", RunsAs: "mesh-files", Claims: []catalogue.Claim{{Name: catalogue.FilesSeat}}})
|
|
if len(p) != 1 || !strings.Contains(p[0], "0394") || !strings.Contains(p[0], "runs-as") {
|
|
t.Errorf("a holder declaring runs-as was not refused: %v", p)
|
|
}
|
|
if p := FilesHolderProblems(catalogue.Manifest{Module: "files", Claims: []catalogue.Claim{{Name: catalogue.FilesSeat}}}); len(p) != 0 {
|
|
t.Errorf("a holder declaring none was refused: %v", p)
|
|
}
|
|
if p := FilesHolderProblems(catalogue.Manifest{Module: "other", RunsAs: "x"}); len(p) != 0 {
|
|
t.Errorf("a non-holder was judged: %v", p)
|
|
}
|
|
}
|
|
|
|
// 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(catalogue.Manifest{Module: "reads", RunsAs: "mesh-reads",
|
|
Claims: []catalogue.Claim{{Name: catalogue.ReadsSeat}}}); len(p) != 0 {
|
|
t.Errorf("a holder running as its own account was refused: %v", p)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A jail with no name, no pattern, or a name another jail of the same module already took is refused.
|
|
func TestAJailIsNamedOnceAndSaysWhatAFailureLooksLike(t *testing.T) {
|
|
m := catalogue.Manifest{Module: "mailu", Jails: []catalogue.Jail{
|
|
{Name: "", Failregex: "^.*rip=<HOST>$"},
|
|
{Name: "mailu-front", Failregex: " "},
|
|
{Name: "mailu-front", Failregex: "^.*rip=<HOST>$"},
|
|
}}
|
|
problems := strings.Join(manifestJailProblems(m), "\n")
|
|
for _, want := range []string{"a jail with no name", "says nothing a failed attempt looks like", "two jails called"} {
|
|
if !strings.Contains(problems, want) {
|
|
t.Errorf("missing %q in:\n%s", want, problems)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A jail whose pattern names <HOST> twice is refused by name: fail2ban would read it as a duplicate
|
|
// capture group and refuse its whole configuration, leaving the machine with no bans at all — which
|
|
// is what happened on the control node the day this was built (novox/hq ADR 0179).
|
|
func TestAJailsPatternNamesTheHostOncePerShape(t *testing.T) {
|
|
twice := catalogue.Manifest{Module: "route-proxy", Jails: []catalogue.Jail{{
|
|
Name: "route-proxy",
|
|
Failregex: `^.*(?:refused for <HOST>:\d+|asked from <HOST>:\d+)$`,
|
|
Jail: "backend = systemd",
|
|
}}}
|
|
problems := manifestJailProblems(twice)
|
|
if len(problems) != 1 || !strings.Contains(problems[0], "names <HOST> 2 times") {
|
|
t.Fatalf("a pattern naming <HOST> twice was not refused by name: %v", problems)
|
|
}
|
|
if !strings.Contains(problems[0], "no bans at all") {
|
|
t.Errorf("the refusal does not say what it costs: %q", problems[0])
|
|
}
|
|
|
|
// The same two shapes, one pattern each, is how it is written — and is accepted.
|
|
split := catalogue.Manifest{Module: "route-proxy", Jails: []catalogue.Jail{{
|
|
Name: "route-proxy",
|
|
Failregex: "^.*refused for <HOST>:\\d+$\n ^.*asked from <HOST>:\\d+$",
|
|
Jail: "backend = systemd",
|
|
}}}
|
|
if problems := manifestJailProblems(split); len(problems) != 0 {
|
|
t.Fatalf("one <HOST> per pattern was refused: %v", problems)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAKindedBenchNeedsAKindAndNoOtherSeatTakesOne(t *testing.T) {
|
|
got := problemsFor(t, catalogue.Shelf{"messenger": router(), "nameless": aChannel("nameless", "")})
|
|
if !strings.Contains(got, "claims the kinded bench channel and names no kind") {
|
|
t.Fatalf("a claim without a kind stood: %s", got)
|
|
}
|
|
odd := catalogue.Manifest{Module: "odd", Claims: []catalogue.Claim{{Name: "operator-channel", Scope: catalogue.ScopeMesh, Kind: "telegram"}}}
|
|
got = problemsFor(t, catalogue.Shelf{"messenger": router(), "odd": odd})
|
|
if !strings.Contains(got, "only a kinded bench takes a kind") {
|
|
t.Fatalf("a kind on a seat that is not kinded stood: %s", got)
|
|
}
|
|
dotted := problemsFor(t, catalogue.Shelf{"messenger": router(), "dotted": aChannel("dotted", "a.b")})
|
|
if !strings.Contains(dotted, "not a usable name") {
|
|
t.Fatalf("a kind that would widen a subject stood: %s", dotted)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The catalogue check refuses a log outside the caps, with a name that is not one plain token, with a
|
|
// field the mesh does not know, or kept by a module whose own name is not one plain token.
|
|
func TestALogIsNamedLocallyAndCapped(t *testing.T) {
|
|
for _, c := range []struct{ manifest, says string }{
|
|
{`{"module":"a","version":"1","logs":["mesh.changes"]}`, `keeps log "mesh.changes": a log is named locally`},
|
|
{`{"module":"a","version":"1","logs":["my_changes"]}`, `keeps log "my_changes"`},
|
|
{`{"module":"a","version":"1","logs":["Changes"]}`, `keeps log "Changes"`},
|
|
{`{"module":"a","version":"1","logs":["c","c"]}`, `keeps log "c" twice`},
|
|
{`{"module":"a","version":"1","logs":[{"name":"c","max-mib":8193}]}`, `between 1 and 8192 MiB`},
|
|
{`{"module":"a","version":"1","logs":[{"name":"c","max-mib":-1}]}`, `between 1 and 8192 MiB`},
|
|
{`{"module":"a","version":"1","logs":[{"name":"c","max-mib":0}]}`, `max-mib is between 1 and 8192, not 0`},
|
|
{`{"module":"a","version":"1","logs":[{"name":"c","stream":"LOG_x"}]}`, `{name, max-mib, max-age-days}`},
|
|
{`{"module":"a","version":"1","logs":[{"name":"c","max-age-days":0}]}`, `max-age-days is between 1 and 3650, not 0`},
|
|
{`{"module":"a.b","version":"1","logs":["c"]}`, `no dot`},
|
|
} {
|
|
_, err := ParseManifest([]byte(c.manifest))
|
|
if err == nil {
|
|
t.Errorf("%s was accepted", c.manifest)
|
|
continue
|
|
}
|
|
if !strings.Contains(err.Error(), c.says) {
|
|
t.Errorf("%s refused for the wrong reason: %v", c.manifest, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// What ADR 0203 §2 allows a contribution to say, refused at parse when it says anything else.
|
|
func TestAMalformedEnvironmentIsRefusedAtParse(t *testing.T) {
|
|
for _, c := range []struct{ environment, want string }{
|
|
{`{"variables":{"1X":"a"}}`, `tool sets the variable "1X", which is not a name a shell accepts`},
|
|
{`{"variables":{"MY-VAR":"a"}}`, `tool sets the variable "MY-VAR", which is not a name a shell accepts`},
|
|
{`{"variables":{"PATH":"/bin"}}`, `tool sets PATH as a variable; a module adds an entry under environment.path`},
|
|
{`{"variables":{"A":"$HOME/x"}}`, `tool sets A to "$HOME/x", which holds a $ that is not one of the machine's ${machine:…} facts`},
|
|
{`{"variables":{"A":"${HOME}/x"}}`, `tool sets A to "${HOME}/x", which holds a $`},
|
|
{`{"variables":{"A":"it's"}}`, `tool sets A to "it's", which holds a quote`},
|
|
{`{"variables":{"A":"say \"hi\""}}`, `which holds a quote`},
|
|
{`{"variables":{"A":"a\\b"}}`, `which holds a backslash`},
|
|
{`{"variables":{"A":"a\nb"}}`, `which holds a line break`},
|
|
{`{"variables":{"A":"a\u0000b"}}`, `which holds a NUL`},
|
|
{`{"path":[{"entry":"","at":"start"}]}`, `tool's PATH entry 1 names no directory`},
|
|
{`{"path":[{"entry":"/a:/b","at":"start"}]}`, `tool puts "/a:/b" on PATH, which holds a colon`},
|
|
{`{"path":[{"entry":"$HOME/bin","at":"start"}]}`, `tool puts "$HOME/bin" on PATH, which holds a $`},
|
|
{`{"path":[{"entry":"/a","at":"middle"}]}`, `tool puts "/a" on PATH at "middle"; an entry goes at "start" or "end"`},
|
|
{`{"path":[{"entry":"/a"}]}`, `tool puts "/a" on PATH at ""`},
|
|
{`{"path":[{"entry":"/a","at":"start"},{"entry":"/a","at":"end"}]}`, `tool puts "/a" on PATH twice`},
|
|
{`{"variables":{"A":"x"},"paths":[]}`, `unknown field "paths"`},
|
|
} {
|
|
_, err := ParseManifest([]byte(`{"module":"tool","environment":` + c.environment + `}`))
|
|
if err == nil || !strings.Contains(err.Error(), c.want) {
|
|
t.Errorf("%s: want %q, got %v", c.environment, c.want, err)
|
|
}
|
|
}
|
|
// What is allowed: a literal, and the machine's own facts.
|
|
if _, err := ParseManifest([]byte(`{"module":"tool","environment":{` +
|
|
`"variables":{"GOPATH":"${machine:account-home}/go","DISABLE_X":"1","ANSWER":"a b+c=d"},` +
|
|
`"path":[{"entry":"${machine:account-home}/go/bin","at":"start"},{"entry":"/opt/x","at":"end"}]}}`)); err != nil {
|
|
t.Fatalf("a well-formed environment was refused: %v", err)
|
|
}
|
|
}
|
|
|
|
func 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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 := catalogue.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 manifest key nothing reads is worse than a key that is refused: five HAL manifests carried
|
|
// `scope:`, which read as a restriction and restricted nothing (novox/hq 04-ISSUES/003). The host's
|
|
// declaration parser has always refused unknown keys; manifests never did.
|
|
func TestAManifestKeyNothingReadsIsRefused(t *testing.T) {
|
|
_, err := ParseManifest([]byte(`{"module":"web","scope":"mesh"}`))
|
|
if err == nil {
|
|
t.Fatal("a manifest with a key nothing reads was accepted, which is how scope: survived")
|
|
}
|
|
if !strings.Contains(err.Error(), "scope") {
|
|
t.Fatalf("refused without naming the key, which leaves the author guessing: %v", err)
|
|
}
|
|
}
|
|
|
|
// `local-network` is a source a manifest may name (§1, changing ADR 0045 §3), and an unknown source is
|
|
// still refused.
|
|
func TestAManifestMayListenFromTheLocalNetwork(t *testing.T) {
|
|
if _, err := ParseManifest([]byte(`{"module":"mosquitto","version":"1",
|
|
"listens":[{"port":1883,"from":"local-network"}]}`)); err != nil {
|
|
t.Fatalf("a listen from the local network is refused: %v", err)
|
|
}
|
|
_, err := ParseManifest([]byte(`{"module":"mosquitto","version":"1",
|
|
"listens":[{"port":1883,"from":"lan"}]}`))
|
|
if err == nil || !strings.Contains(err.Error(), `from "lan"`) {
|
|
t.Fatalf("a source the mesh cannot render is not refused: %v", err)
|
|
}
|
|
}
|
|
|
|
// A module uses one form or the other, never both.
|
|
func TestAManifestMixingTheConfigAndSettingsFormsIsRefused(t *testing.T) {
|
|
m := inConfigForm()
|
|
resourceOf(m, "configuration")["content"] = "font = ${config:font-size}\nwidth = ${setting:width}\nfrom = ${config:sender}\n"
|
|
refused(t, m, "mixes", "${setting:", "config")
|
|
|
|
m = inConfigForm()
|
|
delete(resourceOf(m, "rules"), "takes-config")
|
|
resourceOf(m, "rules")["takes-settings"] = []any{"position", "theme"}
|
|
refused(t, m, "mixes", "takes-settings")
|
|
|
|
m = inConfigForm()
|
|
m["settings"] = map[string]any{"colour": map[string]any{"kind": "preference", "default": "red", "why": "red"}}
|
|
refused(t, m, "mixes", "settings")
|
|
}
|
|
|
|
func TestAManifestProvidingAmqpIsRefused(t *testing.T) {
|
|
raw := []byte(`{"module":"old-broker","version":"1","provides":[{"name":"amqp","scope":"mesh"}]}`)
|
|
_, err := ParseManifest(raw)
|
|
if err == nil || !strings.Contains(err.Error(), `provides "amqp", which is not a provision`) {
|
|
t.Fatalf("a module providing amqp was not refused, or not for the reason: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAManifestRequiringAmqpIsRefused(t *testing.T) {
|
|
raw := []byte(`{"module":"forwarder","version":"1","requires":["amqp"]}`)
|
|
_, err := ParseManifest(raw)
|
|
if err == nil || !strings.Contains(err.Error(), `requires "amqp", which is not a provision`) {
|
|
t.Fatalf("a module requiring amqp was not refused, or not for the reason: %v", err)
|
|
}
|
|
}
|
|
|
|
// A manifest written against the old name is told what the field became.
|
|
//
|
|
// `needs` and `secrets` were both name-to-path and differed only in whose secret it was, so
|
|
// reaching for the wrong one parsed cleanly and failed somewhere else entirely. Refusing the old
|
|
// name with "unknown field" would be correct and unhelpful: whoever wrote it knew what they meant,
|
|
// and the mesh knows what it is called now.
|
|
func TestAManifestUsingTheOldNameIsToldTheNewOne(t *testing.T) {
|
|
_, err := ParseManifest([]byte(
|
|
`{"module":"postgres","version":"1","needs":{"superuser":"/var/lib/superuser"}}`))
|
|
if err == nil {
|
|
t.Fatal("a manifest using the old name was accepted, so two fields now mean one thing")
|
|
}
|
|
for _, want := range []string{"needs", "own-secrets"} {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Fatalf("the refusal does not mention %q: %v", want, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A manifest in the config form registers with the group a config value fills, and the value is placed (a container's
|
|
// groups count as a place for it); a placeholder in any other shape is refused where the manifest is read.
|
|
func TestAManifestWhoseContainerTakesItsGroupFromConfigRegisters(t *testing.T) {
|
|
m := map[string]any{
|
|
"module": "n8n", "version": "1",
|
|
"config": map[string]any{"media-gid": map[string]any{"type": "number", "minimum": 1, "required": true,
|
|
"description": "the group that owns the media library on this machine"}},
|
|
"resources": []any{aContainerWithGroups("${config:media-gid}")},
|
|
}
|
|
if _, err := parseRaw(t, m); err != nil {
|
|
t.Fatalf("refused: %v", err)
|
|
}
|
|
for _, shape := range []string{"x${config:media-gid}", "${config:media-gid}:1", "${config:other}"} {
|
|
m["resources"] = []any{aContainerWithGroups(shape)}
|
|
if _, err := parseRaw(t, m); err == nil {
|
|
t.Errorf("groups %q registered", shape)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 := catalogue.Manifest{Module: "notes", Unhealthy: &catalogue.UnhealthyPolicy{Severity: catalogue.UnhealthyWarning, Why: "notes wait"}}
|
|
if warning.UnhealthyIsUrgent() || len(manifestUnhealthyProblems(warning)) != 0 {
|
|
t.Errorf("a warning said with why reads as urgent or is refused: %v", manifestUnhealthyProblems(warning))
|
|
}
|
|
}
|
|
|
|
func TestAMapReferenceToNoDirectoryRefusesAtTheManifest(t *testing.T) {
|
|
m := catalogue.Manifest{
|
|
Module: "photos",
|
|
Resources: []map[string]any{{"id": "state", "type": "directory", "place": "."}},
|
|
Binds: map[string]string{"route": "${dir:stat}/route.json"},
|
|
}
|
|
problems := manifestUnknownDirRefs(m)
|
|
if len(problems) != 1 || !strings.Contains(problems[0], `${dir:stat}`) {
|
|
t.Fatalf("a map naming no directory is a manifest problem; got %v", problems)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// 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 := catalogue.Manifest{Module: "impostor", DefinesSeats: []catalogue.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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The whole point: a module contributes a capability without the mesh being changed.
|
|
func TestAModuleDeclaresItsOwnSeatAndHoldsIt(t *testing.T) {
|
|
shop := catalogue.Manifest{Module: "shop", Uses: []string{"telegram-sender"}}
|
|
if got := problemsFor(t, catalogue.Shelf{"telegram": telegram(), "shop": shop}); got != "" {
|
|
t.Fatalf("a declared seat and its caller were refused: %s", got)
|
|
}
|
|
}
|
|
|
|
// 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(catalogue.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)
|
|
}
|
|
}
|
|
|
|
func TestAModuleIsEitherWrittenOrComputedNotBoth(t *testing.T) {
|
|
// Otherwise nobody could say where a given file on a machine came from.
|
|
_, err := ParseManifest([]byte(`{"module":"mesh-network","version":"1",
|
|
"computed":"mesh-network",
|
|
"resources":[{"id":"a","type":"package","package":"wireguard-tools"}]}`))
|
|
if err == nil {
|
|
t.Fatal("a module that both ships files and has them computed was accepted")
|
|
}
|
|
if !strings.Contains(err.Error(), "one or the other") {
|
|
t.Fatalf("unhelpful refusal: %v", err)
|
|
}
|
|
}
|
|
|
|
// A module may not declare an action, and it is refused where it was written.
|
|
//
|
|
// The host refuses one arriving over the link, which is the bound the whole security argument
|
|
// rests on (novox/hq ADR 0005) and is enforced in the right place. But a module's resources reach
|
|
// a machine *over* the link, so a manifest carrying an action used to be accepted here, stored,
|
|
// resolved and pushed — and then refused on the machine, in a log, with nothing tying it back to
|
|
// the manifest. Enforced at the far end only is enforced and not usable.
|
|
func TestAModuleMayNotDeclareAnAction(t *testing.T) {
|
|
_, err := ParseManifest([]byte(`{"module":"probe","version":"1","resources":[
|
|
{"id":"migrate","type":"action","command":["/bin/true"],"verify":["/bin/true"]}]}`))
|
|
if err == nil {
|
|
t.Fatal("a manifest declaring an action was accepted, and the machine would refuse it")
|
|
}
|
|
if !strings.Contains(err.Error(), "may not") || !strings.Contains(err.Error(), "0005") {
|
|
t.Errorf("the refusal does not say what rule it is: %v", err)
|
|
}
|
|
// And it says what to do instead, because "you may not" without an alternative is where a
|
|
// module author stops.
|
|
if !strings.Contains(err.Error(), "ships a program") {
|
|
t.Errorf("the refusal names no alternative: %v", err)
|
|
}
|
|
}
|
|
|
|
// A provision names what the consumer is coupled to (novox/hq ADR 0027).
|
|
//
|
|
// The fault this defends against does not look like a fault: resolution succeeds, the module
|
|
// deploys, and the first query fails somewhere else entirely. Every earlier test had exactly one
|
|
// provider of each name, so no mismatch was expressible and none was caught.
|
|
func TestAModuleMayNotProvideAGenericDatabase(t *testing.T) {
|
|
for _, hidden := range []string{"database", "db", "sql", "sql-database"} {
|
|
_, err := ParseManifest([]byte(`{"module":"postgres","version":"1",
|
|
"provides":[{"name":"` + hidden + `","scope":"mesh"}]}`))
|
|
if err == nil {
|
|
t.Fatalf("providing %q was accepted; a requirement for it matches any engine", hidden)
|
|
}
|
|
for _, want := range []string{hidden, "postgres-database", "first query"} {
|
|
if !strings.Contains(err.Error(), want) {
|
|
t.Errorf("refusing %q did not mention %q, so nobody learns what to write:\n%v",
|
|
hidden, want, err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAModuleNamedAfterADomainIsNamedBack(t *testing.T) {
|
|
got := InstallationProblems(catalogue.Manifest{Module: "mesh-one.be"})
|
|
if len(got) != 1 || !strings.Contains(got[0], "named after mesh-one.be") {
|
|
t.Fatalf("got %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)
|
|
}
|
|
}
|
|
|
|
// 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", catalogue.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 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 := catalogue.Manifest{Module: "a", Version: "1", DefinesSeats: []catalogue.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 := catalogue.Manifest{Module: "a", Version: "1", DefinesSeats: []catalogue.SeatDeclaration{
|
|
{Name: "a-seat", Scope: catalogue.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 := catalogue.Manifest{Module: "b", Version: "1", DefinesSeats: []catalogue.SeatDeclaration{
|
|
{Name: "b-seat", Scope: catalogue.ScopeNode, Paths: []string{"/etc/hosts"}}}}
|
|
if got := strings.Join(CatalogueProblems(catalogue.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 := catalogue.Manifest{Module: "c", Version: "1",
|
|
DefinesSeats: []catalogue.SeatDeclaration{{Name: "c-seat", Scope: catalogue.ScopeNode, Paths: []string{"/etc/c.conf"}}},
|
|
Claims: []catalogue.Claim{{Name: "c-seat", Scope: catalogue.ScopeNode}}}
|
|
if got := strings.Join(CatalogueProblems(catalogue.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(catalogue.Shelf{"c": own}); len(got) != 0 {
|
|
t.Errorf("a holder declaring its own seat's path was refused: %v", got)
|
|
}
|
|
other := catalogue.Manifest{Module: "d", Version: "1", Resources: []map[string]any{
|
|
{"id": "conf", "type": "file", "path": "/etc/c.conf", "content": "x"}}}
|
|
if got := strings.Join(CatalogueProblems(catalogue.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)
|
|
}
|
|
}
|
|
|
|
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) catalogue.Manifest {
|
|
m := twoFiles(app, sidecar)
|
|
m.Resources[2]["content"] = "WORKERS=${setting:workers}\n"
|
|
m.Settings = map[string]catalogue.SettingDeclaration{"workers": {Kind: catalogue.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": catalogue.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 := catalogue.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)
|
|
}
|
|
}
|
|
|
|
// 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 := catalogue.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")
|
|
}
|
|
}
|
|
|
|
func TestAMountNothingDeclaresIsRefused(t *testing.T) {
|
|
_, err := ParseManifest(manifestMounting("/services/store/data", ""))
|
|
if err == nil {
|
|
t.Fatal("a container mounting a path no resource declares was accepted; the runtime " +
|
|
"would create it as root and the module's owner and mode would never apply")
|
|
}
|
|
if !strings.Contains(err.Error(), "/services/store/data") {
|
|
t.Fatalf("refused without naming the path, which leaves the author guessing: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAMovedModulesMarkIsJudged(t *testing.T) {
|
|
for name, raw := range map[string]string{
|
|
"marked moved and naming no template": `{"module":"bare","version":"1","moved":true,
|
|
"resources":[{"id":"conf","type":"file","path":"/etc/bare","content":"x"}]}`,
|
|
"handed written in a repository": `{"module":"bare","version":"1","moved":true,
|
|
"handed":{"source":"s","digest":"d","templates":{}},
|
|
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
|
"resources":[{"id":"conf","type":"file","path":"/etc/bare","template":"files/bare"}]}`,
|
|
"handed and not moved": `{"module":"bare","version":"1",
|
|
"handed":{"source":"s","digest":"d","templates":{}},
|
|
"resources":[{"id":"conf","type":"file","path":"/etc/bare","content":"x"}]}`,
|
|
} {
|
|
if _, err := ParseManifest([]byte(raw)); err == nil {
|
|
t.Errorf("%s: accepted", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A manifest states an access's extent, the way a listening port states its source. An absolute
|
|
// path and a mode of read or read-write; anything else is refused rather than acted on wrongly.
|
|
func TestAnAccessIsValidated(t *testing.T) {
|
|
good := []byte(`{"module":"m","accesses":[{"path":"/services/media","mode":"read-write"}]}`)
|
|
if _, err := ParseManifest(good); err != nil {
|
|
t.Fatalf("a valid access was refused: %v", err)
|
|
}
|
|
|
|
// Mode is optional and narrows to read — the safe default, because the danger with an access
|
|
// is being given more than was meant, not less.
|
|
bare := []byte(`{"module":"m","accesses":[{"path":"/services/media"}]}`)
|
|
m, err := ParseManifest(bare)
|
|
if err != nil {
|
|
t.Fatalf("an access with no mode was refused: %v", err)
|
|
}
|
|
if m.Accesses[0].At() != catalogue.AccessRead {
|
|
t.Errorf("an access with no mode is %q, want %q", m.Accesses[0].At(), catalogue.AccessRead)
|
|
}
|
|
|
|
relative := []byte(`{"module":"m","accesses":[{"path":"services/media","mode":"read"}]}`)
|
|
if _, err := ParseManifest(relative); err == nil {
|
|
t.Error("a relative access path was accepted")
|
|
}
|
|
|
|
wrong := []byte(`{"module":"m","accesses":[{"path":"/services/media","mode":"append"}]}`)
|
|
if _, err := ParseManifest(wrong); err == nil {
|
|
t.Error("an access with an unknown mode was accepted")
|
|
}
|
|
}
|
|
|
|
func TestANameMeantOnPurposeIsDeclaredWithItsReason(t *testing.T) {
|
|
// The federation's public directory in a homeserver's config: the world's, said so, and a name
|
|
// the map does not cover is still reported.
|
|
m := catalogue.Manifest{Module: "homeserver", Resources: []map[string]any{
|
|
{"id": "conf", "type": "file", "content": "trusted_key_servers: matrix.org\nwell_known: https://mesh-one.be\n",
|
|
catalogue.NamesOnPurpose: map[string]any{"matrix.org": "the federation's public key server, the world's"}},
|
|
}}
|
|
got := InstallationProblems(m)
|
|
if len(got) != 1 || !strings.Contains(got[0], "mesh-one.be") {
|
|
t.Fatalf("got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestAnArtifactBuiltFromOutsideItsRepositoryIsRefused(t *testing.T) {
|
|
// A build reads its own repository and nothing else. A path leaving it would make what gets
|
|
// built depend on whatever happens to be on the machine doing the building.
|
|
for _, from := range []string{"/etc/passwd", "../elsewhere"} {
|
|
_, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
|
|
{"name":"x","kind":"archive","from":"` + from + `"}]}}`))
|
|
if err == nil {
|
|
t.Fatalf("%q was accepted as a build input", from)
|
|
}
|
|
if !strings.Contains(err.Error(), "outside its own repository") {
|
|
t.Fatalf("refused for the wrong reason: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// An artifact names the paths it is built from with `paths` (novox/hq ADR 0499 clause 1): a package, or a bundle in a
|
|
// language other than Go, may; a Go program's artifact, an archive, an image and an upstream artifact may not, and the
|
|
// refusal names the artifact and its kind. Each path stays inside the module's directory: no `..`, no absolute path,
|
|
// no pattern.
|
|
func TestAnArtifactNamesThePathsItIsBuiltFrom(t *testing.T) {
|
|
m, err := ParseManifest([]byte(`{"module":"mesh-sdk","version":"1","slug":"sdk","build":{"artifacts":[
|
|
{"name":"lib","kind":"package","language":"typescript",
|
|
"paths":["src","test","package.json","package-lock.json","tsconfig.json"]}]},"resources":[]}`))
|
|
if err != nil {
|
|
t.Fatalf("a package naming its paths was refused: %v", err)
|
|
}
|
|
if got := strings.Join(m.Build.Artifacts[0].Paths, ","); got != "src,test,package.json,package-lock.json,tsconfig.json" {
|
|
t.Fatalf("the paths were not kept as written: %q", got)
|
|
}
|
|
if _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
|
|
{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["index.js"],"loads":["index.js"],"paths":["src","package.json"]}]}}`)); err != nil {
|
|
t.Fatalf("a TypeScript bundle naming its paths was refused: %v", err)
|
|
}
|
|
|
|
for _, c := range []struct{ artifact, says string }{
|
|
{`{"name":"x","kind":"bundle","language":"go","system":"linux-amd64","paths":["cmd"]}`, `"x" is a Go program`},
|
|
{`{"name":"x","kind":"archive","from":"files","paths":["files"]}`, `"x" is a "archive"`},
|
|
{`{"name":"x","kind":"image","from":"Dockerfile","paths":["src"]}`, `"x" is a "image"`},
|
|
{`{"name":"x","kind":"upstream","from":"nats:2.10","paths":["src"]}`, `"x" is a "upstream"`},
|
|
{`{"name":"x","kind":"package","language":"typescript","paths":["../other"]}`, "leaves the module's directory"},
|
|
{`{"name":"x","kind":"package","language":"typescript","paths":["src/../../x"]}`, "leaves the module's directory"},
|
|
{`{"name":"x","kind":"package","language":"typescript","paths":["/etc"]}`, "leaves the module's directory"},
|
|
{`{"name":"x","kind":"package","language":"typescript","paths":["src/*.ts"]}`, "is a pattern"},
|
|
{`{"name":"x","kind":"package","language":"typescript","paths":["src/[ab]"]}`, "is a pattern"},
|
|
{`{"name":"x","kind":"package","language":"typescript","paths":[""]}`, "names an empty path"},
|
|
{`{"name":"x","kind":"package","language":"typescript","paths":["."]}`, "names the module's whole directory"},
|
|
{`{"name":"x","kind":"package","language":"typescript","paths":[]}`, "names no path"},
|
|
} {
|
|
_, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[` + c.artifact + `]}}`))
|
|
if err == nil || !strings.Contains(err.Error(), c.says) {
|
|
t.Errorf("%s: %v, wanted a refusal saying %q", c.artifact, err, c.says)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAnArtifactOfAnUnknownKindIsRefused(t *testing.T) {
|
|
_, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
|
|
{"name":"x","kind":"binary","from":"main.go"}]}}`))
|
|
if err == nil {
|
|
t.Fatal("an artifact of an unknown kind was accepted")
|
|
}
|
|
}
|
|
|
|
func TestANeedIsAnAbsolutePath(t *testing.T) {
|
|
_, err := ParseManifest([]byte(`{"module":"postgres","version":"1",
|
|
"own-secrets":{"superuser":"superuser.txt"}}`))
|
|
if err == nil {
|
|
t.Fatal("a relative path was accepted")
|
|
}
|
|
if !strings.Contains(err.Error(), "absolute path") {
|
|
t.Fatalf("unhelpful refusal: %v", err)
|
|
}
|
|
}
|
|
|
|
// 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 := catalogue.Manifest{Module: "mail", DefinesSeats: []catalogue.SeatDeclaration{{
|
|
Name: "mail-notifier", Scope: catalogue.ScopeNode, Needs: []string{"desk-beeper"}}}}
|
|
|
|
// Nobody declares desk-beeper at all.
|
|
got := problemsFor(t, catalogue.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 := catalogue.Manifest{Module: "dunst", DefinesSeats: []catalogue.SeatDeclaration{{Name: "desk-beeper", Scope: catalogue.ScopeMesh}}}
|
|
got = problemsFor(t, catalogue.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 := catalogue.Manifest{Module: "dunst", DefinesSeats: []catalogue.SeatDeclaration{{Name: "desk-beeper", Scope: catalogue.ScopeNode}}}
|
|
if got := problemsFor(t, catalogue.Shelf{"mail": mail, "dunst": dunst}); got != "" {
|
|
t.Fatalf("needing a node seat another module declares was refused: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestAnEmptyContributionIsRefused(t *testing.T) {
|
|
// Either a mistake or a requirement written the long way round, and both are better said.
|
|
_, err := ParseManifest([]byte(`{"module":"board","version":"1",
|
|
"contributes":{"reverse-proxy":{}}}`))
|
|
if err == nil {
|
|
t.Fatal("a module contributed nothing and was accepted")
|
|
}
|
|
if !strings.Contains(err.Error(), "require it") {
|
|
t.Fatalf("the refusal does not say what to do instead: %v", err)
|
|
}
|
|
}
|
|
|
|
// A name that is not a name is refused where it is written: it ends up in something a person types.
|
|
func TestAnEndpointNameIsHeldToItsShape(t *testing.T) {
|
|
for _, wrong := range []string{"Web", "web port", "3000", "-web", "web_surface"} {
|
|
m := catalogue.Manifest{Module: "odd", Listens: []catalogue.Listening{{Name: wrong, Port: 80, From: catalogue.FromMesh}}}
|
|
if got := strings.Join(endpointNameProblems(m), "\n"); !strings.Contains(got, "a name is lowercase") {
|
|
t.Fatalf("%q was accepted as an endpoint name:\n%s", wrong, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func anEnvironmentItemIsTypedAndNamesWhatItNeeds(t *testing.T) {
|
|
item := func(attrs map[string]string) []string {
|
|
return environmentItemProblems("x", catalogue.Item{Collection: "environment", Attributes: attrs})
|
|
}
|
|
for _, ok := range []map[string]string{
|
|
{"kind": "variable", "name": "GTK_THEME"},
|
|
{"kind": "path", "at": "end", "order": "10"},
|
|
} {
|
|
if p := item(ok); len(p) != 0 {
|
|
t.Errorf("%v is refused: %v", ok, p)
|
|
}
|
|
}
|
|
for _, bad := range []map[string]string{
|
|
nil, {"kind": "other"}, {"kind": "variable"}, {"kind": "variable", "name": "PATH"},
|
|
{"kind": "path"}, {"kind": "path", "at": "middle"}, {"kind": "path", "at": "start", "name": "X"},
|
|
{"kind": "variable", "name": "A", "color": "red"},
|
|
} {
|
|
if p := item(bad); len(p) == 0 {
|
|
t.Errorf("%v is taken", bad)
|
|
}
|
|
}
|
|
if !attributeFits("environment", "name", "GTK_THEME") || attributeFits("shell:zsh:normal", "name", "GTK_THEME") ||
|
|
attributeFits("environment", "kind", "Variable") {
|
|
t.Error("an upper-case name is the environment's name alone")
|
|
}
|
|
|
|
}
|
|
|
|
// An identity served as a DNS label is never truncated, so the offer's bound must keep it in one.
|
|
func anIdentityServedAsADNSLabelIsBoundedToOne(t *testing.T) {
|
|
serves := map[string]map[string]any{"bucket": {"name": "${consumer:as:dns}"}}
|
|
wide := catalogue.Manifest{Module: "wide", Serves: serves, Provides: []catalogue.Offer{{Name: "bucket", Scope: catalogue.ScopeMesh,
|
|
Identity: &catalogue.OfferIdentity{Max: 255, In: "a client id"}}}}
|
|
if got := CheckServes(wide); len(got) != 1 || !strings.Contains(got[0], "a label keeps 63") {
|
|
t.Fatalf("a 255-character bound on a DNS label passed: %q", got)
|
|
}
|
|
unsaid := catalogue.Manifest{Module: "unsaid", Serves: serves, Provides: []catalogue.Offer{{Name: "bucket", Scope: catalogue.ScopeMesh}}}
|
|
if got := CheckServes(unsaid); len(got) != 0 {
|
|
t.Fatalf("the default bound (20) on a DNS label was refused: %q", got)
|
|
}
|
|
}
|
|
|
|
func TestAnImageFromAnInstallationsRegistryNeedsAReason(t *testing.T) {
|
|
bare := catalogue.Manifest{Module: "site", Resources: []map[string]any{
|
|
{"id": "server", "type": "container", "image": "registry.mesh-one.be/org/site@sha256:cc"},
|
|
}}
|
|
if got := InstallationProblems(bare); len(got) != 1 || !strings.Contains(got[0], "registry.mesh-one.be") {
|
|
t.Fatalf("an image on an installation's registry was not named: %v", got)
|
|
}
|
|
excepted := catalogue.Manifest{Module: "site", Resources: []map[string]any{
|
|
{"id": "server", "type": "container", "image": "registry.mesh-one.be/org/site@sha256:cc",
|
|
catalogue.NamesOnPurpose: map[string]any{"registry.mesh-one.be": "built outside the mesh until the site's repository is a build source here"}},
|
|
}}
|
|
if got := InstallationProblems(excepted); len(got) != 0 {
|
|
t.Fatalf("a declared exception was still reported: %v", got)
|
|
}
|
|
}
|
|
|
|
func TestAnInterpretedBundleStillMayNotNameASource(t *testing.T) {
|
|
b := &catalogue.Build{Artifacts: []catalogue.Artifact{{
|
|
Name: "tools", Kind: catalogue.ArtifactBundle, Language: "typescript", From: "src",
|
|
}}}
|
|
p := buildProblems(b, "something")
|
|
if len(p) == 0 {
|
|
t.Fatal("an interpreted bundle naming what it is built from was accepted")
|
|
}
|
|
}
|
|
|
|
// And an ordinary unknown key is still refused as one, rather than being guessed at.
|
|
func TestAnInventedKeyIsNotTreatedAsARename(t *testing.T) {
|
|
_, err := ParseManifest([]byte(`{"module":"postgres","version":"1","nonsense":{}}`))
|
|
if err == nil {
|
|
t.Fatal("an invented key was accepted")
|
|
}
|
|
if strings.Contains(err.Error(), "is now called") {
|
|
t.Fatalf("an invented key was reported as a rename: %v", err)
|
|
}
|
|
}
|
|
|
|
// An entry that names a module and no tool is refused at parse, in the manifest's words, rather than
|
|
// at the composition of the bus's user list where it would stop the file for everybody.
|
|
func TestAnInvokeThatNamesNoToolIsRefusedAtParse(t *testing.T) {
|
|
_, err := ParseManifest([]byte(`{"module":"mesh-console","version":"1","invokes":["shop"]}`))
|
|
if err == nil {
|
|
t.Fatal("an invoke naming no tool was accepted")
|
|
}
|
|
if !strings.Contains(err.Error(), `invokes "shop", which does not name a tool`) {
|
|
t.Fatalf("refused for the wrong reason: %v", err)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A provision's credential may be one only a person outside the mesh can give (novox/hq ADR 0337 §1, issue 164):
|
|
// the offer says `issued-by: outside` and names neither an own secret nor a tool, and the parser refuses it beside
|
|
// either. An offer that says `software` keeps ADR 0305's shape.
|
|
func TestAnOfferMaySayAPersonOutsideTheMeshIssuesItsCredential(t *testing.T) {
|
|
m, err := ParseManifest([]byte(`{"module":"tv","version":"1",
|
|
"provides":[{"name":"tv-api","scope":"mesh","credential":{"issued-by":"outside"}}],
|
|
"serves":{"tv-api":{"port":8989}}}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !m.IssuedOutsideOf("tv-api") {
|
|
t.Fatal("the offer's credential issued outside was not read")
|
|
}
|
|
if m.IssuedOutsideOf("other") {
|
|
t.Fatal("a provision the module does not offer reads as issued outside")
|
|
}
|
|
if _, issued := m.IssuedCredentialOf("tv-api"); issued {
|
|
t.Fatal("a credential issued outside was read as issued by the software")
|
|
}
|
|
if _, shared := m.SharedCredentialOf("tv-api"); shared {
|
|
t.Fatal("a credential issued outside was read as an own secret shared")
|
|
}
|
|
if who, offered := catalogue.OffersIssuedOutside(map[string]catalogue.Manifest{"tv": m}, "tv-api"); !offered || who != "tv" {
|
|
t.Fatalf("the catalogue does not say tv offers tv-api issued outside: %q %v", who, offered)
|
|
}
|
|
|
|
for want, raw := range map[string]string{
|
|
"names its own secret": `{"module":"d","version":"1","own-secrets":{"key":{"path":"/p","taken":"at-start"}},
|
|
"provides":[{"name":"d-api","credential":{"issued-by":"outside","own":"key"}}]}`,
|
|
"names the tool": `{"module":"d","version":"1",
|
|
"provides":[{"name":"d-api","credential":{"issued-by":"outside","tool":"d_key"}}]}`,
|
|
"the issuers an offer may name": `{"module":"d","version":"1",
|
|
"provides":[{"name":"d-api","credential":{"issued-by":"a-person"}}]}`,
|
|
} {
|
|
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
|
|
t.Errorf("expected a refusal saying %q, got %v", want, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A provision's credential may be issued by its provider's software (novox/hq ADR 0305, issue 526): the offer
|
|
// names the tool that answers it and no own secret, and every other shape is refused at parse.
|
|
func TestAnOfferMaySayItsSoftwareIssuesTheCredential(t *testing.T) {
|
|
m, err := ParseManifest([]byte(`{"module":"media","version":"1",
|
|
"provides":[{"name":"media-api","scope":"mesh","credential":{"issued-by":"software","tool":"media_issued_credential"}}],
|
|
"serves":{"media-api":{"port":32400}}}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if tool, issued := m.IssuedCredentialOf("media-api"); !issued || tool != "media_issued_credential" {
|
|
t.Fatalf("the offer's issued credential was not read: %q %v", tool, issued)
|
|
}
|
|
if _, shared := m.SharedCredentialOf("media-api"); shared {
|
|
t.Fatal("an issued credential was read as an own secret shared")
|
|
}
|
|
if _, issued := m.IssuedCredentialOf("other"); issued {
|
|
t.Fatal("a provision the module does not offer reads as issued")
|
|
}
|
|
|
|
for want, raw := range map[string]string{
|
|
"the issuers an offer may name": `{"module":"d","version":"1",
|
|
"provides":[{"name":"d-api","credential":{"issued-by":"vendor","tool":"d_token"}}]}`,
|
|
"names no tool that answers it": `{"module":"d","version":"1",
|
|
"provides":[{"name":"d-api","credential":{"issued-by":"software"}}]}`,
|
|
"names its own secret": `{"module":"d","version":"1","own-secrets":{"token":{"path":"/p","taken":"at-start"}},
|
|
"provides":[{"name":"d-api","credential":{"issued-by":"software","tool":"d_token","own":"token"}}]}`,
|
|
"the issuers an offer may name ": `{"module":"d","version":"1",
|
|
"provides":[{"name":"d-api","credential":{"tool":"d_token"}}]}`,
|
|
} {
|
|
want = strings.TrimSpace(want)
|
|
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
|
|
t.Errorf("expected a refusal saying %q, got %v", want, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The field reads as written and writes back the same, and refuses what says nothing.
|
|
func TestAnOffersIdentityIsParsedStrictly(t *testing.T) {
|
|
for _, raw := range []string{
|
|
`{"name":"s3-bucket","scope":"mesh","identity":{"max":20,"in":"an S3 access key"}}`,
|
|
`{"name":"wildcard-resolution","scope":"mesh","identity":false}`,
|
|
`{"name":"redis-cache","scope":"mesh","identity":{"in":"a Redis ACL user"}}`,
|
|
} {
|
|
var o catalogue.Offer
|
|
if err := json.Unmarshal([]byte(raw), &o); err != nil {
|
|
t.Fatalf("%s: %v", raw, err)
|
|
}
|
|
back, err := json.Marshal(o)
|
|
if err != nil || string(back) != raw {
|
|
t.Errorf("did not round-trip:\n%s\n%s (%v)", raw, back, err)
|
|
}
|
|
}
|
|
for _, raw := range []string{
|
|
`{"name":"x","identity":true}`,
|
|
`{"name":"x","identity":{"max":20,"in":"y","most":3}}`,
|
|
} {
|
|
var o catalogue.Offer
|
|
if err := json.Unmarshal([]byte(raw), &o); err == nil {
|
|
t.Errorf("accepted %s", raw)
|
|
}
|
|
}
|
|
bad := catalogue.Manifest{Module: "bad", Version: "1", Provides: []catalogue.Offer{
|
|
{Name: "unsaid", Scope: catalogue.ScopeMesh, Identity: &catalogue.OfferIdentity{Max: 20}},
|
|
{Name: "tiny", Scope: catalogue.ScopeMesh, Identity: &catalogue.OfferIdentity{Max: 4, In: "nothing usable"}},
|
|
}}
|
|
raw, err := json.Marshal(bad)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = ParseManifest(raw)
|
|
if err == nil || !strings.Contains(err.Error(), "without saying what keeps them") ||
|
|
!strings.Contains(err.Error(), "the shortest the mesh makes") {
|
|
t.Fatalf("a bound with no `in`, or too short for any identity, was accepted: %v", err)
|
|
}
|
|
}
|
|
|
|
// A value named after the operator's own (a url, a user, an address) is `required` where nothing falls back when
|
|
// it is unset; where a mergeable file takes it and no placeholder asks for it, the file's own content (or the
|
|
// module's code reading the file) falls back, so it keeps a default and stays optional, as it is today: the
|
|
// operator's decision on issue 664's second question (issue 735).
|
|
func TestAnOperatorNamedValueAFileFallsBackOnMayHaveADefault(t *testing.T) {
|
|
m := inConfigForm()
|
|
m["config"].(map[string]any)["url"] = map[string]any{"type": "string",
|
|
"description": "the address the notifier posts to, a URL; empty for the one the file's software picks",
|
|
"default": ""}
|
|
resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", "url"}
|
|
if _, err := parseRaw(t, m); err != nil {
|
|
t.Fatalf("a taken value named after the operator's, with a default, was refused: %v", err)
|
|
}
|
|
|
|
// A placeholder asking for it has no fallback but the default, which would put a value in the definition
|
|
// where the operator's belongs (ADR 0112).
|
|
asked := inConfigForm()
|
|
asked["config"].(map[string]any)["url"] = map[string]any{"type": "string", "description": "a URL", "default": ""}
|
|
resourceOf(asked, "rules")["takes-config"] = []any{"position", "theme", "url"}
|
|
resourceOf(asked, "configuration")["content"] = "font = Inter ${config:font-size}\nwidth = ${config:width}\n" +
|
|
"from = ${config:sender}\npost = ${config:url}\n"
|
|
refused(t, asked, `"url"`, `a key naming "url" is the operator's value`)
|
|
|
|
// Read by the module's code alone, nothing in the definition says it falls back.
|
|
runtime := inConfigForm()
|
|
runtime["config"].(map[string]any)["url"] = map[string]any{"type": "string", "description": "a URL",
|
|
"default": "", "runtime": true}
|
|
refused(t, runtime, `"url"`, `a key naming "url" is the operator's value`)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// 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]catalogue.SettingDeclaration{"workers": {Kind: catalogue.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 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 catalogue.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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 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 := catalogue.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(catalogue.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 := catalogue.CanHold(holder, hostnameSeat(t)); err != nil {
|
|
t.Errorf("a holder declaring %s was refused: %v", spelled, 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)
|
|
}
|
|
}
|
|
|
|
func TestAnUnhealthyWithoutWhyIsRefused(t *testing.T) {
|
|
for _, why := range []string{"", " "} {
|
|
if problems := manifestUnhealthyProblems((catalogue.Manifest{Module: "mail", Unhealthy: &catalogue.UnhealthyPolicy{Severity: catalogue.UnhealthyUrgent, Why: why}})); 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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// A key nobody renders is refused, not left in the file as a literal.
|
|
func TestAnUnknownPlaceholderKeyIsRefused(t *testing.T) {
|
|
for _, c := range []struct{ content, want string }{
|
|
{"${environment:foo}", "names ${environment:foo}; the environment is ${environment:posix} or ${environment:systemd}"},
|
|
{"${shell:zsh:middle}", "names ${shell:zsh:middle}; shell code is ${shell:<shell>:<slot>}"},
|
|
{"${shell:tcsh:first}", "names ${shell:tcsh:first}; shell code is ${shell:<shell>:<slot>}"},
|
|
{"${shell:zsh}", "names ${shell:zsh}; shell code is ${shell:<shell>:<slot>}"},
|
|
} {
|
|
raw := `{"module":"holder","claims":[{"name":"node-environment","scope":"node"},{"name":"node-login-shell","scope":"node"}],` +
|
|
`"resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` + c.content + `"}]}`
|
|
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) {
|
|
t.Errorf("%s was accepted: %v", c.content, err)
|
|
}
|
|
}
|
|
// And outside a file's content, where nothing could be placed.
|
|
raw := `{"module":"holder","claims":[{"name":"node-environment","scope":"node"}],` +
|
|
`"resources":[{"id":"rc","type":"file","path":"/etc/${environment:posix}","content":"x"}]}`
|
|
if _, err := ParseManifest([]byte(raw)); err == nil ||
|
|
!strings.Contains(err.Error(), "names ${environment:posix} in its path; the environment and the shell's code are placed only in a file's content") {
|
|
t.Errorf("a placeholder in a path was accepted: %v", err)
|
|
}
|
|
}
|
|
|
|
// **Every endpoint in the catalogue is unnamed today, and must stay valid.** The word ships one
|
|
// release before anything uses it.
|
|
func TestAnUnnamedEndpointIsStillValid(t *testing.T) {
|
|
m := catalogue.Manifest{Module: "ordinary", Listens: []catalogue.Listening{{Port: 443, From: catalogue.FromEverywhere}}}
|
|
if got := endpointNameProblems(m); len(got) != 0 {
|
|
t.Fatalf("an unnamed endpoint was refused: %v", got)
|
|
}
|
|
if got := RouteProblems(m); len(got) != 0 {
|
|
t.Fatalf("a module with no route was refused: %v", got)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 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 TestAPackageParsesLikeABundleAndNeedsALanguage(t *testing.T) {
|
|
// The SDK's shape: a package built from the module's own directory, naming a language.
|
|
m, err := ParseManifest([]byte(`{"module":"mesh-sdk","version":"1","slug":"sdk",
|
|
"build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript"}]},
|
|
"resources":[]}`))
|
|
if err != nil {
|
|
t.Fatalf("the SDK's package manifest did not parse: %v", err)
|
|
}
|
|
if m.Build.Artifacts[0].Kind != catalogue.ArtifactPackage {
|
|
t.Fatalf("expected a package artifact, got %q", m.Build.Artifacts[0].Kind)
|
|
}
|
|
|
|
// A package that names what it is built from is refused, exactly as a bundle is: it is built
|
|
// from the module's own directory.
|
|
if _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
|
|
{"name":"lib","kind":"package","language":"typescript","from":"Dockerfile"}]}}`)); err == nil {
|
|
t.Fatal("a package naming a source was accepted")
|
|
}
|
|
// A package with no language cannot choose a toolchain.
|
|
if _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
|
|
{"name":"lib","kind":"package"}]}}`)); err == nil {
|
|
t.Fatal("a package with no language was accepted")
|
|
}
|
|
}
|
|
|
|
func 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)
|
|
}
|
|
}
|
|
|
|
// ADR 0203 §5 and ADR 0204 §3: a placeholder outside the seat's holder is refused — by the parser,
|
|
// which is what the catalogue check and registration run, and again at composition, in the same words.
|
|
func TestAPlaceholderOutsideTheHolderIsRefused(t *testing.T) {
|
|
for _, c := range []struct{ content, want string }{
|
|
{"${environment:posix}", "toolchain's resource rc names ${environment:posix} and toolchain does not claim node-environment"},
|
|
{"${shell:zsh:normal}", "toolchain's resource rc names ${shell:zsh:normal} and toolchain does not claim node-login-shell"},
|
|
} {
|
|
raw := `{"module":"toolchain","resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` + c.content + `"}]}`
|
|
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) {
|
|
t.Errorf("the catalogue check accepted %s outside its holder: %v", c.content, err)
|
|
}
|
|
m := catalogue.Manifest{Module: "toolchain", Resources: []map[string]any{
|
|
{"id": "rc", "type": "file", "path": "/etc/rc", "content": c.content}}}
|
|
r := catalogue.Resolution{Node: "workstation", Account: "op", Modules: []catalogue.Manifest{m}}
|
|
if _, err := r.Declaration(catalogue.Rendering{}); err == nil || !strings.Contains(err.Error(), c.want) {
|
|
t.Errorf("composition accepted %s outside its holder: %v", c.content, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// **And a language that runs anywhere must not name one.** A system that decides nothing reads as
|
|
// though it did, which is the same fault as a restriction that restricts nothing (novox/hq ADR 0045).
|
|
func TestAPortableBundleMayNotNameASystem(t *testing.T) {
|
|
got := buildProblemsOf(t, bundleFor("typescript", "arch"))
|
|
if !strings.Contains(got, "runs anywhere") {
|
|
t.Fatalf("a portable bundle was allowed to name a system:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestAPortableBundleNamingNoSystemIsAccepted(t *testing.T) {
|
|
if got := buildProblemsOf(t, bundleFor("typescript", "")); got != "" {
|
|
t.Fatalf("an ordinary bundle was refused:\n%s", got)
|
|
}
|
|
}
|
|
|
|
// A rule with no source is open, and must say so rather than appear to restrict something.
|
|
func TestAPortWithNoSourceIsRefused(t *testing.T) {
|
|
_, err := ParseManifest([]byte(`{"module":"web","listens":[{"port":443}]}`))
|
|
if err == nil {
|
|
t.Fatal("a port that never said who may reach it was accepted; it reads as a restriction")
|
|
}
|
|
if !strings.Contains(err.Error(), "from where") {
|
|
t.Fatalf("refused without saying what is missing: %v", err)
|
|
}
|
|
}
|
|
|
|
// 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/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)
|
|
}
|
|
|
|
// 1952 S5 (novox/hq ADR 0564 Limits 4; the lead, 2026-10-29): a tools bundle declares its own bus account, read as a
|
|
// process's, named after its binary and kept through resolution; a removal is from the closed set, alone, and the
|
|
// bus module's (mesh-broker) alone.
|
|
toolsIn := func(claims, bus, mode, where string) string {
|
|
return `{"module":"nats","version":"1","claims":` + claims + `,"own-secrets":{"broker":"${dir:mesh-state}/broker",` +
|
|
`"broker-nats-tools":"${dir:tools-secret}/broker-nats-tools"},"resources":[{"id":"mesh-state","type":"directory",` +
|
|
`"mode":"0700","place":"mesh","unknown":"program"},{"id":"tools-secret","type":"directory","mode":"` + mode +
|
|
`","path":"` + where + `","unknown":"program"}],"build":{"artifacts":[{"name":"tools","kind":"bundle",` +
|
|
`"language":"go","system":"arch","from":"cmd/nats-tools","binary":"nats-tools","loads":["nats-tools"],"bus":` + bus + `}]}}`
|
|
}
|
|
tools := func(claims, bus string) string { return toolsIn(claims, bus, "0711", "/var/lib/mesh/nats-tools") }
|
|
broker := `[{"name":"mesh-broker","scope":"mesh"}]`
|
|
for _, c := range []struct {
|
|
name, manifest, refused string
|
|
}{
|
|
{name: "the bus module's tools removing", manifest: tools(broker, `{"removes":["mesh-host_acts","controller-leftovers"]}`)},
|
|
{name: "a removal outside the set", manifest: tools(broker, `{"removes":["EVENTS.a_m"]}`), refused: "not one of"},
|
|
{name: "a removal beside a grant", manifest: tools(broker, `{"removes":["mesh-host_acts"],"invokes":["nats.nats_streams"]}`), refused: "with nothing else"},
|
|
{name: "another module's tools removing", manifest: tools(`[]`, `{"removes":["mesh-host_acts"]}`), refused: "only the bus module"},
|
|
{name: "a tools bundle consuming a queue", manifest: tools(broker, `{"queues":["x"]}`), refused: "holds no consumer"},
|
|
{name: "a process removing", manifest: mailSenderProcessManifest(t, `{"removes":["mesh-host_acts"]}`), refused: "only a tools bundle"},
|
|
// The runtime's account reads the account's secret: it lies where others may pass (the host agent, 2026-10-29).
|
|
{name: "a tools secret the runtime cannot reach", manifest: toolsIn(broker, `{"removes":["mesh-host_acts"]}`, "0700",
|
|
"/var/lib/mesh/nats-tools"), refused: "cannot reach"},
|
|
// Its parents too: under the module's mesh place, /var/lib/mesh/nats is 0700 (the lead, read on novox 2026-10-29).
|
|
{name: "a tools secret beneath the module's mesh place", manifest: toolsIn(broker, `{"removes":["mesh-host_acts"]}`, "0711",
|
|
"${dir:mesh-state}/tools"), refused: "cannot reach"},
|
|
} {
|
|
m, err := ParseManifest([]byte(c.manifest))
|
|
if c.refused != "" {
|
|
if err == nil || !strings.Contains(err.Error(), c.refused) {
|
|
t.Errorf("%s: %v, want refused saying %q", c.name, err, c.refused)
|
|
}
|
|
continue
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", c.name, err)
|
|
}
|
|
accounts := m.BusAccounts()
|
|
if len(accounts) != 1 || accounts[0].ID != "nats-tools" || !accounts[0].Tools ||
|
|
!strSliceEqual(accounts[0].Bus.Removes, []string{catalogue.RemovesEngineActs, catalogue.RemovesControllerLeftovers}) {
|
|
t.Errorf("%s: the accounts are %+v", c.name, accounts)
|
|
}
|
|
// Resolved, the build goes and its bundles keep the bus (build.go).
|
|
resolved := catalogue.Manifest{Module: "nats", Bundles: []catalogue.Bundle{{Name: "tools", Binary: "nats-tools", Bus: m.Build.Artifacts[0].Bus}}}
|
|
if got := resolved.BusAccounts(); len(got) != 1 || got[0].ID != "nats-tools" {
|
|
t.Errorf("%s: resolved, the accounts are %+v", c.name, got)
|
|
}
|
|
}
|
|
if got := m.BusAccounts(); len(got) != 1 || got[0].ID != "send" || got[0].Tools {
|
|
t.Errorf("the process's account is %+v", got)
|
|
}
|
|
}
|
|
|
|
// {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)
|
|
}
|
|
}
|
|
|
|
// What `module check` and the merge gate judge: the provider read from its tree, its text inline. Judged by the same
|
|
// rules, a provider naming a provision it does not provide is refused there too, and one that does composes.
|
|
func TestAProviderReadFromItsTreeIsJudgedByTheSameRules(t *testing.T) {
|
|
read := func(text string) (catalogue.Manifest, error) {
|
|
dir := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(dir, "files"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(dir, "files", "consumers.json"), []byte(text), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m, err := ParseManifest([]byte(handedProvider))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return WithTemplatesFrom(m, dir)
|
|
}
|
|
filled, err := read(consumersText)
|
|
if err != nil {
|
|
t.Fatalf("a provider read from its tree is refused: %v", err)
|
|
}
|
|
if _, err := notifierOn(t, filled).Compose(handingTo("store:5000")); err != nil {
|
|
t.Fatalf("a provider read from its tree does not compose where it would be handed: %v", err)
|
|
}
|
|
if _, err := read("${consumers:cache}"); err == nil || !strings.Contains(err.Error(), "does not provide") {
|
|
t.Fatalf("a provider read from its tree naming a provision it does not provide: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAReferenceToNoDirectoryRefusesAtTheManifest(t *testing.T) {
|
|
m := placedModule()
|
|
m.Resources[2]["volumes"] = []any{"${dir:date}:/data"} // a typo, the likely shape
|
|
problems := manifestUnknownDirRefs(m)
|
|
if len(problems) != 1 || !strings.Contains(problems[0], `${dir:date}`) {
|
|
t.Fatalf("a reference naming no directory is a manifest problem; got %v", problems)
|
|
}
|
|
if got := manifestUnknownDirRefs(placedModule()); len(got) != 0 {
|
|
t.Fatalf("a correct definition has none; got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestARenamedEntryIsRefusedWhereItCouldNotHold(t *testing.T) {
|
|
entry := func(m map[string]any, e any) map[string]any {
|
|
resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", e}
|
|
return m
|
|
}
|
|
refused(t, entry(renaming(), map[string]any{"key": "url"}), "notifier's rules", `"key" and "as"`)
|
|
refused(t, entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER_URL", "when": "set"}), `"key" and "as"`)
|
|
refused(t, entry(renaming(), map[string]any{"key": "URL", "as": "NOTIFIER_URL"}), "not a usable key")
|
|
refused(t, entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER URL"}), "not a usable name")
|
|
refused(t, entry(renaming(), map[string]any{"key": "url", "as": "endpoints"}), `"endpoints"`, "the mesh reads")
|
|
refused(t, entry(renaming(), map[string]any{"key": "url", "as": "theme"}), `"theme"`, "twice")
|
|
refused(t, entry(renaming(), map[string]any{"key": "theme", "as": "THEME"}), `"theme"`, "twice")
|
|
refused(t, entry(renaming(), map[string]any{"key": "nothing", "as": "NOTHING"}), `"nothing"`, "declares no such value")
|
|
|
|
kept := entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER_URL"})
|
|
resourceOf(kept, "rules")["protected"] = []any{"NOTIFIER_URL"}
|
|
refused(t, kept, `"NOTIFIER_URL"`, "protected")
|
|
|
|
// Laid only when set, so the code's own fallback holds: never required.
|
|
required := renaming()
|
|
declaration(required, "url")["required"] = true
|
|
delete(declaration(required, "url"), "default")
|
|
refused(t, required, `"url"`, "only when it is set", "never required")
|
|
}
|
|
|
|
func TestARenamedEntryIsTheConfigFormsOnly(t *testing.T) {
|
|
m := map[string]any{
|
|
"module": "notifier", "version": "1",
|
|
"resources": []any{
|
|
map[string]any{"id": "rules", "type": "file", "path": "/x/rules.json", "merge": "json", "content": "{}",
|
|
"takes-settings": []any{map[string]any{"key": "url", "as": "NOTIFIER_URL"}}},
|
|
},
|
|
}
|
|
refused(t, m, "notifier's rules", "config form")
|
|
}
|
|
|
|
// Tonight's case (issue 263): networkmanager, no slug, requiring the mesh's resolver provision on a
|
|
// machine with a six-character name. Under ADR 0049's one bound it was refused, and its provider's
|
|
// whole machine with it; the resolver keeps no name, so it is not refused at all.
|
|
func TestARequirementOnAKeylessProvisionComposesWithALongName(t *testing.T) {
|
|
shelf := catalogue.Shelf{
|
|
"resolver": {Module: "resolver", Provides: []catalogue.Offer{{Name: "wildcard-resolution", Scope: catalogue.ScopeMesh}}},
|
|
"networkmanager": {Module: "networkmanager", Requires: []string{"wildcard-resolution"}},
|
|
}
|
|
if catalogue.CheckIdentity("laptop", "networkmanager") == nil {
|
|
t.Fatal("the regression is not reproduced: mesh_laptop_networkmanager fits the old global bound")
|
|
}
|
|
if got := IdentityProblems(shelf, 6); len(got) != 0 {
|
|
t.Fatalf("a requirement on a keyless provision was refused for its length: %q", got)
|
|
}
|
|
r := catalogue.Resolution{Node: "laptop", Modules: []catalogue.Manifest{shelf["networkmanager"]},
|
|
Needs: []catalogue.Needed{{Name: "wildcard-resolution", From: "anchor", For: "networkmanager",
|
|
Identity: shelf["resolver"].IdentityBoundOf("wildcard-resolution")}}}
|
|
if got := r.Overflowing(); len(got) != 0 {
|
|
t.Fatalf("a keyless requirement is reported as overflowing: %+v", got)
|
|
}
|
|
}
|
|
|
|
// 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([]catalogue.Built{
|
|
{Name: "code", Kind: catalogue.ArtifactBundle, Reference: "http://store.example:5100/v2/sample/code/blobs/" + digest, Digest: digest},
|
|
{Name: "files", Kind: catalogue.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, catalogue.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"], catalogue.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)
|
|
}
|
|
}
|
|
|
|
// A route naming an endpoint the module does not declare reaches nothing, and is refused where it is
|
|
// written rather than resolving to no port and serving nothing.
|
|
func TestARouteNamingAnEndpointTheModuleLacksIsRefused(t *testing.T) {
|
|
m := aMediaServer()
|
|
m.Contributes["route"][catalogue.RouteEndpoint] = "absent"
|
|
got := strings.Join(RouteProblems(m), "\n")
|
|
if !strings.Contains(got, "does not declare") {
|
|
t.Fatalf("a route naming an absent endpoint was accepted:\n%s", got)
|
|
}
|
|
}
|
|
|
|
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 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// A seat needing itself names nothing (novox/hq ADR 0378 §8).
|
|
func TestASeatCannotNeedItself(t *testing.T) {
|
|
m := catalogue.Manifest{Module: "mail", DefinesSeats: []catalogue.SeatDeclaration{{
|
|
Name: "mail-notifier", Scope: catalogue.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)
|
|
}
|
|
}
|
|
|
|
// **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(catalogue.Shelf{m.Module: m}), "; ")
|
|
if !strings.Contains(got, "mesh seat") {
|
|
t.Fatalf("the refusal does not say which scope the seat is: %q", got)
|
|
}
|
|
}
|
|
|
|
// 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 := catalogue.Manifest{Module: "a", Version: "1", DefinesSeats: []catalogue.SeatDeclaration{
|
|
{Name: "a-seat", Scope: catalogue.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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 := catalogue.Manifest{Module: "vague", DefinesSeats: []catalogue.SeatDeclaration{{Name: "something", Scope: catalogue.ScopeNode}}}
|
|
if got := strings.Join(declaredSeatProblems(m), "; "); got != "" {
|
|
t.Fatalf("a marker seat was refused: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestASecondClaimOfOneKindIsRefused(t *testing.T) {
|
|
got := problemsFor(t, catalogue.Shelf{"messenger": router(), "telegram": aChannel("telegram", "telegram"),
|
|
"telegram-two": aChannel("telegram-two", "telegram")})
|
|
if !strings.Contains(got, `of kind "telegram", which telegram already claims`) {
|
|
t.Fatalf("a second holder of one kind stood: %s", got)
|
|
}
|
|
}
|
|
|
|
func 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)
|
|
}
|
|
}
|
|
|
|
// A fact or an alphabet the mesh does not have is refused where the definition is, not where a
|
|
// consumer happens to be resolved — and the refusal says what may be said instead.
|
|
func TestAServedValueNamingSomethingTheMeshDoesNotHaveIsRefused(t *testing.T) {
|
|
for _, c := range []struct{ value, says string }{
|
|
{"${consumer:node}", "as"},
|
|
{"${consumer:as:punycode}", "dns"},
|
|
} {
|
|
m := store()
|
|
m.Serves["s3-bucket"]["bucket"] = c.value
|
|
raw, err := json.Marshal(m)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = ParseManifest(raw)
|
|
if err == nil {
|
|
t.Fatalf("%s was accepted", c.value)
|
|
}
|
|
if !strings.Contains(err.Error(), c.value) {
|
|
t.Errorf("the refusal of %s does not quote it: %v", c.value, err)
|
|
}
|
|
if !strings.Contains(err.Error(), c.says) {
|
|
t.Errorf("the refusal of %s does not say what may be said (%q): %v", c.value, c.says, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 := catalogue.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 TestASiteScopedProvisionIsRefused(t *testing.T) {
|
|
// Meaningful for a claim — one DHCP server per segment — and not yet meaningful for a
|
|
// provision, because nothing knows how to reach "the one at my site".
|
|
_, err := ParseManifest([]byte(
|
|
`{"module":"dns","version":"1","provides":[{"name":"resolver","scope":"site"}]}`))
|
|
if err == nil {
|
|
t.Fatal("a site-scoped provision was accepted")
|
|
}
|
|
if !strings.Contains(err.Error(), "site") {
|
|
t.Fatalf("unhelpful refusal: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAskingForTheRuleSetWithNowhereToPutItIsRefused(t *testing.T) {
|
|
_, err := ParseManifest([]byte(`{"module":"firewall","filtering":{"into":""}}`))
|
|
if err == nil {
|
|
t.Fatal("a module asked for the rule set and named no path; it would receive nothing")
|
|
}
|
|
}
|
|
|
|
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 := catalogue.Manifest{Module: "flows", Resources: []map[string]any{{"id": "settings", "type": "file",
|
|
"path": "/x", "merge": catalogue.MergeJSON, "content": "{}", catalogue.TakesSettingsField: []any{"timeZone"}}}}
|
|
if problems := TakenSettingsProblems(m); len(problems) != 0 {
|
|
t.Errorf("timeZone was refused: %v", problems)
|
|
}
|
|
}
|
|
|
|
func TestASourceNobodyDefinedIsRefused(t *testing.T) {
|
|
_, err := ParseManifest([]byte(`{"module":"web","listens":[{"port":443,"from":"lan"}]}`))
|
|
if err == nil {
|
|
t.Fatal("a source the mesh cannot render was accepted, and would be silently dropped")
|
|
}
|
|
}
|
|
|
|
// 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 stored manifest with a key this reader does not know, at the top or inside any block, is decoded with the key set
|
|
// aside and named, and its module is composed, never left out (novox/hq ADR 0578 §6, which changes ADR 0262); the judge
|
|
// refuses it. One case per block whose decoder wraps the decoder's words in its own.
|
|
func TestAStoredManifestWithAnUnknownKeyIsSetAsideAndTheJudgeRefusesIt(t *testing.T) {
|
|
t.Run("it still provides what it provides", aModuleWithAFieldSetAsideStillProvidesWhatItProvides)
|
|
t.Run("it is composed whole", aModuleWithAFieldSetAsideIsComposedWhole)
|
|
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}]}]}`,
|
|
} {
|
|
m, err := catalogue.Decode([]byte(raw))
|
|
if 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.SetAside(), `"a-field-from-later"`) || m.ReadNoFurther() {
|
|
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 := catalogue.Resolution{Node: "laptop", Modules: []catalogue.Manifest{m, notifier()}}.LeftOut(nil, false)
|
|
if len(left) != 0 {
|
|
t.Errorf("%s: a module was left out for a field set aside: %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 catalogue.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 catalogue.Manifest
|
|
if err := json.Unmarshal([]byte(`{"module": "bad", "state": [{"name": 3}]}`), &bad); err == nil {
|
|
t.Fatal("a malformed stored manifest was read")
|
|
}
|
|
}
|
|
|
|
// 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 system the mesh does not build for is refused where it is written. These are the host's own
|
|
// names, not a compiler's: the difference between two of them is a C library rather than a kernel,
|
|
// so a value that looks like an operating system to a toolchain is still wrong here.
|
|
func TestASystemTheMeshDoesNotBuildForIsRefused(t *testing.T) {
|
|
for _, wrong := range []string{"linux", "debian", "darwin"} {
|
|
got := buildProblemsOf(t, bundleFor("go", wrong))
|
|
if !strings.Contains(got, "and a system is") {
|
|
t.Fatalf("%q was accepted as a system:\n%s", wrong, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 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 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 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// novox/hq ADR 0259 §8: a module saying warrants, or speaking for a kind that proves its sender, runs as an
|
|
// account of its own — never carried by the machine's runtime, which runs as the operator's account.
|
|
func TestATrustedHolderMustRunAsAnAccountOfItsOwn(t *testing.T) {
|
|
r := router()
|
|
r.RunsAs = ""
|
|
if got := problemsFor(t, catalogue.Shelf{"messenger": r}); !strings.Contains(got, "messenger must run as an account of its own") {
|
|
t.Errorf("a router on the machine's runtime stood: %s", got)
|
|
}
|
|
tg := aChannel("telegram", "telegram")
|
|
tg.Claims[0].Capabilities = []string{"choice", "verified-sender"}
|
|
if got := problemsFor(t, catalogue.Shelf{"messenger": router(), "telegram": tg}); !strings.Contains(got, "telegram must run as an account of its own") {
|
|
t.Errorf("a verified channel on the machine's runtime stood: %s", got)
|
|
}
|
|
desk := aChannel("desk-channel", "desktop")
|
|
desk.Claims[0].Capabilities = []string{"choice"}
|
|
if got := problemsFor(t, catalogue.Shelf{"messenger": router(), "desk-channel": desk}); got != "" {
|
|
t.Errorf("a channel proving nothing was held to it: %s", got)
|
|
}
|
|
// A kind that is `private` shows a link's code, which links an account as the operator: its holder is
|
|
// trusted with it, so it runs as its own account too (the confirmation review of 2026-10-09).
|
|
private := aChannel("desk-channel", "desktop")
|
|
private.Claims[0].Capabilities = []string{"choice", "private"}
|
|
if got := problemsFor(t, catalogue.Shelf{"messenger": router(), "desk-channel": private}); !strings.Contains(got, "desk-channel must run as an account of its own") {
|
|
t.Errorf("a private channel on the machine's runtime stood: %s", got)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 1178/AC2: a work-queue stream is read by exactly one process of its module and by no other module's consumes: one
|
|
// process naming it is accepted; none, two, or another module's consumes is refused, naming it.
|
|
func TestAWorkQueueStreamIsReadByExactlyOneProcessOfItsModule(t *testing.T) {
|
|
if got := WorkQueueStreamProblems(mailSenderManifestWithQueue("mail-sends")); len(got) != 0 {
|
|
t.Fatalf("a work-queue stream one process names was refused: %v", got)
|
|
}
|
|
if got := strings.Join(WorkQueueStreamProblems(mailSenderManifestWithQueue("")), "; "); !strings.Contains(got, "mail-sends") ||
|
|
!strings.Contains(got, "no process names it") {
|
|
t.Errorf("an unnamed work-queue stream: %q", got)
|
|
}
|
|
two := mailSenderManifestWithQueue("mail-sends")
|
|
two.Resources = append(two.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"}}})
|
|
if got := strings.Join(WorkQueueStreamProblems(two), "; "); !strings.Contains(got, "mail-sends") || !strings.Contains(got, "2 processes name") {
|
|
t.Errorf("a work-queue stream two processes name: %q", got)
|
|
}
|
|
reader := catalogue.Manifest{Module: "reader", Version: "1", Consumes: []string{"mesh-mail.mail.send.approved"}}
|
|
got := strings.Join(WorkQueueConsumedElsewhereProblems(catalogue.Shelf{"mesh-mail": mailSenderManifestWithQueue("mail-sends"), "reader": reader}), "; ")
|
|
if !strings.Contains(got, "reader") || !strings.Contains(got, "MAIL_SENDS") {
|
|
t.Errorf("another module's consumes of a work-queue stream: %q", got)
|
|
}
|
|
}
|
|
|
|
// 1177/AC2, ADR 0592 §6: a stream whose work-queue or history values are out of range, contradictory, or carries a grant
|
|
// where it may not 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"},
|
|
// ADR 0592 §6: a stream that keeps its history has no maximum age, deliveries or duplicate window.
|
|
"a history stream with a maximum age": {`[{"name":"mail-sends","events":["mail.send.approved"],` +
|
|
`"retention":"history","max-age-days":30}]`, "no maximum age"},
|
|
"a history stream with max-deliveries": {`[{"name":"mail-sends","events":["mail.send.approved"],` +
|
|
`"retention":"history","max-deliveries":3}]`, "without retention"},
|
|
"a history stream with duplicates": {`[{"name":"mail-sends","events":["mail.send.approved"],` +
|
|
`"retention":"history","duplicates":2}]`, "without retention"},
|
|
"a history stream over its bound": {`[{"name":"mail-sends","events":["mail.send.approved"],` +
|
|
`"retention":"history","max-mib":8193}]`, "between"},
|
|
} {
|
|
_, 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
func TestBindingWhatYouDoNotRequireIsRefused(t *testing.T) {
|
|
_, err := ParseManifest([]byte(`{"module":"app","version":"1",
|
|
"binds":{"postgres-database":"/etc/app/db.json"}}`))
|
|
if err == nil {
|
|
t.Fatal("a module was told about something it never asked for")
|
|
}
|
|
if !strings.Contains(err.Error(), "does not require") {
|
|
t.Fatalf("unhelpful refusal: %v", err)
|
|
}
|
|
}
|
|
|
|
// A channel's capabilities come from the fixed vocabulary, and only a kinded claim carries any.
|
|
func TestCapabilitiesAreTheVocabularysAndOnlyOnAKindedClaim(t *testing.T) {
|
|
good := aChannel("telegram", "telegram")
|
|
good.Claims[0].Capabilities = []string{"deliver", "choice", "verified-sender", "max-length:4096"}
|
|
good.RunsAs = "telegram"
|
|
if got := problemsFor(t, catalogue.Shelf{"messenger": router(), "telegram": good}); got != "" {
|
|
t.Fatalf("the vocabulary was refused: %s", got)
|
|
}
|
|
bad := aChannel("telegram", "telegram")
|
|
bad.Claims[0].Capabilities = []string{"trusted", "max-length:lots"}
|
|
got := problemsFor(t, catalogue.Shelf{"messenger": router(), "telegram": bad})
|
|
for _, w := range []string{`"trusted"`, `"max-length:lots"`} {
|
|
if !strings.Contains(got, w+", which channel-capabilities/1 does not have") {
|
|
t.Errorf("%s was not refused: %s", w, got)
|
|
}
|
|
}
|
|
odd := catalogue.Manifest{Module: "odd", Claims: []catalogue.Claim{{Name: "operator-channel", Scope: catalogue.ScopeMesh, Capabilities: []string{"deliver"}}}}
|
|
if got := problemsFor(t, catalogue.Shelf{"messenger": router(), "odd": odd}); !strings.Contains(got, "only a kinded bench's claim carries them") {
|
|
t.Errorf("capabilities on a seat that is not kinded stood: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestCodeForAPowerMomentDependsOnThePowerSeat(t *testing.T) {
|
|
for _, moment := range powerMoments {
|
|
m := mod("laptop", nil, nil, nil)
|
|
m.Shell = []catalogue.ShellCode{{For: moment, Slot: "normal", Code: "true"}}
|
|
if got := catalogue.DependsOn(nil, m); !reflect.DeepEqual(got, []string{catalogue.PowerSeat}) {
|
|
t.Errorf("code for %s depends on %v, want %s", moment, got, catalogue.PowerSeat)
|
|
}
|
|
if p := manifestShellProblems(m); len(p) != 0 {
|
|
t.Errorf("code for %s is refused: %v", moment, p)
|
|
}
|
|
}
|
|
m := mod("laptop", nil, nil, nil)
|
|
m.Shell = []catalogue.ShellCode{{For: "after-lunch", Slot: "normal", Code: "true"}}
|
|
if p := manifestShellProblems(m); len(p) == 0 {
|
|
t.Error("code for a moment that does not exist was accepted")
|
|
}
|
|
if s, ok := catalogue.SeatNamed(catalogue.PowerSeat); !ok || s.Scope != catalogue.ScopeNode {
|
|
t.Errorf("%s is not a node seat of the mesh's own: %+v %v", catalogue.PowerSeat, s, ok)
|
|
}
|
|
}
|
|
|
|
func TestCollectionsAndItemsAreJudged(t *testing.T) {
|
|
for name, c := range map[string]struct{ raw, says string }{
|
|
"an item of a module not moved": {`{"module":"m","version":"1",
|
|
"items":[{"collection":"shell:zsh:normal","template":"files/x"}],
|
|
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "is marked moved"},
|
|
"two items to one collection": {`{"module":"m","version":"1","moved":true,
|
|
"items":[{"collection":"a","template":"files/x"},{"collection":"a","template":"files/y"}],
|
|
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "contributes to a twice"},
|
|
"an item to no slot": {`{"module":"m","version":"1","moved":true,
|
|
"items":[{"collection":"shell:zsh:middle","template":"files/x"}],
|
|
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "is no slot"},
|
|
"an item outside the templates": {`{"module":"m","version":"1","moved":true,
|
|
"items":[{"collection":"a","template":"x"}],
|
|
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "is not a file under files/"},
|
|
"an item naming a secret": {`{"module":"m","version":"1",
|
|
"items":[{"collection":"a","content":"t=${secret:token}"}]}`, "where no secret is"},
|
|
"a collection not declared": {`{"module":"m","version":"1",
|
|
"resources":[{"id":"f","type":"file","path":"/f","content":"${collected:a}"}]}`, "does not collect"},
|
|
"an old namespace declared": {`{"module":"m","version":"1","collections":[{"name":"shell:zsh:normal"}],
|
|
"resources":[{"id":"f","type":"file","path":"/f","content":"x"}]}`, "read from its placeholder"},
|
|
"a collection placed nowhere": {`{"module":"m","version":"1","collections":[{"name":"a"}],
|
|
"resources":[{"id":"f","type":"file","path":"/f","content":"x"}]}`, "no file of its places it"},
|
|
"a collection in a path": {`{"module":"m","version":"1","collections":[{"name":"a"}],
|
|
"resources":[{"id":"f","type":"file","path":"/${collected:a}","content":"${collected:a}"}]}`,
|
|
"only in a file's content"},
|
|
} {
|
|
_, err := ParseManifest([]byte(c.raw))
|
|
if err == nil || !strings.Contains(err.Error(), c.says) {
|
|
t.Errorf("%s: %v, want it to say %q", name, err, c.says)
|
|
}
|
|
}
|
|
if _, err := ParseManifest([]byte(collectorZsh)); err != nil {
|
|
t.Errorf("a collector: %v", err)
|
|
}
|
|
}
|
|
|
|
// Rule 3: a default failing its validation.
|
|
func TestConfigRefusesADefaultFailingItsValidation(t *testing.T) {
|
|
for _, c := range []struct {
|
|
key string
|
|
value any
|
|
said string
|
|
}{
|
|
{"font-size", 4, "below its minimum"},
|
|
{"font-size", 41, "above its maximum"},
|
|
{"font-size", "10", "not a number"},
|
|
{"position", "bottom", "not one of"},
|
|
{"theme", "Dark", "does not match"},
|
|
} {
|
|
m := inConfigForm()
|
|
declaration(m, c.key)["default"] = c.value
|
|
refused(t, m, `"`+c.key+`"`, c.said)
|
|
}
|
|
}
|
|
|
|
// Rule 10: a moved module's mergeable file not naming its keys under takes-config.
|
|
func TestConfigRefusesAMergeableFileNotNamingItsKeys(t *testing.T) {
|
|
m := inConfigForm()
|
|
delete(resourceOf(m, "rules"), "takes-config")
|
|
// position and theme are then placed nowhere; say they are read at runtime so only the file's rule speaks.
|
|
declaration(m, "position")["runtime"] = true
|
|
declaration(m, "theme")["runtime"] = true
|
|
refused(t, m, "rules", "names no takes-config")
|
|
}
|
|
|
|
// Rule 11: a mesh word, puid or pgid in takes-config.
|
|
func TestConfigRefusesAMeshWordOrAnAccountFactInTakesConfig(t *testing.T) {
|
|
for _, word := range []string{"ports", "expose", "reach", "endpoints", "places", "accesses", "networks", "puid", "pgid"} {
|
|
m := inConfigForm()
|
|
resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", word}
|
|
refused(t, m, `"`+word+`"`, "takes-config")
|
|
}
|
|
}
|
|
|
|
// Rule 4: an object or a list not trusted.
|
|
func TestConfigRefusesAnObjectOrListNotTrusted(t *testing.T) {
|
|
for _, kind := range []string{"object", "list"} {
|
|
m := inConfigForm()
|
|
def := any(map[string]any{"a": 1})
|
|
if kind == "list" {
|
|
def = []any{"a"}
|
|
}
|
|
m["config"].(map[string]any)["rules"] = map[string]any{"type": kind, "description": "the rules, as " + kind,
|
|
"default": def, "runtime": true}
|
|
refused(t, m, `"rules"`, "is "+kind+" and not trusted")
|
|
m["config"].(map[string]any)["rules"].(map[string]any)["trusted"] = true
|
|
if _, err := parseRaw(t, m); err != nil {
|
|
t.Fatalf("a trusted %s is refused: %v", kind, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Rule 6: an untrusted value falling back to a trusted predefined key.
|
|
func TestConfigRefusesAnUntrustedValueFallingBackToATrustedKey(t *testing.T) {
|
|
withPredefined(t, nil)
|
|
m := inConfigForm()
|
|
declaration(m, "theme")["predefined"] = "theme"
|
|
refused(t, m, `"theme"`, "is not present")
|
|
|
|
withPredefined(t, map[string]catalogue.PredefinedKey{"theme": {Type: "string", Trusted: true}})
|
|
refused(t, m, `"theme"`, "trusted", "is not")
|
|
declaration(m, "theme")["trusted"] = true
|
|
if _, err := parseRaw(t, m); err != nil {
|
|
t.Fatalf("a trusted value falling back to a trusted key is refused: %v", err)
|
|
}
|
|
}
|
|
|
|
// Rule 8: a placeholder naming an undeclared value.
|
|
func TestConfigRefusesAPlaceholderNamingAnUndeclaredValue(t *testing.T) {
|
|
m := inConfigForm()
|
|
resourceOf(m, "configuration")["content"] = "font = ${config:font-size}\nwidth = ${config:width}\n" +
|
|
"from = ${config:sender}\ncolour = ${config:colour}\n"
|
|
refused(t, m, "${config:colour}", "declares no such value")
|
|
}
|
|
|
|
// Rule 5: a predefined key not on the list, or of another type. Where no list is present, a value naming one is
|
|
// refused saying so.
|
|
func TestConfigRefusesAPredefinedKeyNotOnTheListOrOfAnotherType(t *testing.T) {
|
|
withPredefined(t, nil)
|
|
m := inConfigForm()
|
|
declaration(m, "font-size")["predefined"] = "font-size"
|
|
refused(t, m, `"font-size"`, "the list of predefined keys", "is not present")
|
|
|
|
withPredefined(t, map[string]catalogue.PredefinedKey{"font-size": {Type: "number"}, "accent": {Type: "string"}})
|
|
if _, err := parseRaw(t, m); err != nil {
|
|
t.Fatalf("a value falling back to a listed key of its type is refused: %v", err)
|
|
}
|
|
m = inConfigForm()
|
|
declaration(m, "font-size")["predefined"] = "font-scale"
|
|
refused(t, m, `"font-size"`, `"font-scale"`, "not on the list")
|
|
m = inConfigForm()
|
|
declaration(m, "font-size")["predefined"] = "accent"
|
|
refused(t, m, `"font-size"`, `"accent"`, "is a string")
|
|
}
|
|
|
|
// Rule 9: a reload-on or restart-on naming a file the module does not declare.
|
|
func TestConfigRefusesAReloadOrRestartOnNamingAnUndeclaredFile(t *testing.T) {
|
|
m := inConfigForm()
|
|
resourceOf(m, "notifier")["reload-on"] = []any{"configuration", "dunstrc"}
|
|
refused(t, m, "reload-on", `"dunstrc"`, "declares no such")
|
|
m = inConfigForm()
|
|
resourceOf(m, "notifier")["restart-on"] = []any{"rule"}
|
|
refused(t, m, "restart-on", `"rule"`)
|
|
// Another module's file, and one the mesh makes for the module, are not the module's to declare.
|
|
m = inConfigForm()
|
|
resourceOf(m, "notifier")["restart-on"] = []any{"rules", "resolver.fact-zones", "secret-mail", "composed-jails"}
|
|
if _, err := parseRaw(t, m); err != nil {
|
|
t.Fatalf("refused: %v", err)
|
|
}
|
|
}
|
|
|
|
// A key a mergeable file takes is a config value like every other: one not declared under config is refused by
|
|
// name (ADR 0348 §4, review of mesh-controller#320).
|
|
func TestConfigRefusesATakesConfigKeyNotDeclared(t *testing.T) {
|
|
m := inConfigForm()
|
|
resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", "urgency"}
|
|
refused(t, m, `"urgency"`, "takes-config", "declares no such value under config")
|
|
}
|
|
|
|
// Rule 7: a value neither placed in a template nor read at runtime.
|
|
func TestConfigRefusesAValueNeitherPlacedNorReadAtRuntime(t *testing.T) {
|
|
m := inConfigForm()
|
|
delete(declaration(m, "history"), "runtime")
|
|
refused(t, m, `"history"`, "${config:history}", `"runtime": true`)
|
|
// Taken by a mergeable file is placed.
|
|
m = inConfigForm()
|
|
if _, err := parseRaw(t, m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// Rule 2: a value with neither a default nor required.
|
|
func TestConfigRefusesAValueWithNeitherDefaultNorRequired(t *testing.T) {
|
|
m := inConfigForm()
|
|
delete(declaration(m, "width"), "default")
|
|
refused(t, m, `"width"`, "neither a default nor required")
|
|
m = inConfigForm()
|
|
declaration(m, "sender")["default"] = "noreply@example.org"
|
|
refused(t, m, `"sender"`, "both a default and required")
|
|
}
|
|
|
|
// Rule 1: a value without type or description.
|
|
func TestConfigRefusesAValueWithoutTypeOrDescription(t *testing.T) {
|
|
m := inConfigForm()
|
|
delete(declaration(m, "width"), "type")
|
|
refused(t, m, `notifier's config value "width"`, "no type")
|
|
m = inConfigForm()
|
|
declaration(m, "width")["type"] = "colour"
|
|
refused(t, m, `"width"`, `type "colour"`)
|
|
m = inConfigForm()
|
|
delete(declaration(m, "width"), "description")
|
|
refused(t, m, `"width"`, "no description")
|
|
}
|
|
|
|
// 1658/AC2: a declared value placed nowhere, with an item that does not say it, is still refused.
|
|
func TestConfigStillRefusesAValueNoItemPlaces(t *testing.T) {
|
|
m := map[string]any{
|
|
"module": "m", "version": "1",
|
|
"config": map[string]any{
|
|
"k": map[string]any{"type": "string", "description": "a word nothing says", "default": "x"},
|
|
},
|
|
"items": []any{map[string]any{"collection": "a", "content": "k = fixed\n"}},
|
|
}
|
|
refused(t, m, `"k"`, "placed nowhere")
|
|
}
|
|
|
|
// Consumer data said to live in a provision the module requires is protected only as well as that
|
|
// provision's provider protects its consumers' data.
|
|
func TestConsumerDataInARequiredProvisionIsJudgedAgainstItsProvider(t *testing.T) {
|
|
idp := mustParse(t, `{"module":"idp","version":"1","requires":["postgres-database"],
|
|
"provides":[{"name":"oidc-client","scope":"mesh"}],"grants":{"oidc-client":"${dir:g}"},
|
|
"resources":[{"id":"g","type":"directory","mode":"0700"}],
|
|
"data":{"consumers":{"oidc-client":{"class":"irreplaceable","in":"postgres-database"}}}}`)
|
|
cachy := mustParse(t, strings.Replace(declaredStore, `"consumers":{"postgres-database":{"class":"irreplaceable","in":"store"}}`,
|
|
`"consumers":{"postgres-database":{"class":"cache"}}`, 1))
|
|
if p := strings.Join(DataProblems(catalogue.Shelf{"idp": idp, "pg": cachy}), "\n"); !strings.Contains(p, "not protected as irreplaceable") {
|
|
t.Fatalf("irreplaceable data kept in a cache passed: %s", p)
|
|
}
|
|
if p := DataProblems(catalogue.Shelf{"idp": idp, "pg": mustParse(t, declaredStore)}); len(p) > 0 {
|
|
t.Fatalf("refused against a provider that keeps its consumers' data: %v", p)
|
|
}
|
|
}
|
|
|
|
// The settings form keeps its own declaration; the config form's has no kind or why.
|
|
func TestEachFormKeepsItsOwnDeclaration(t *testing.T) {
|
|
m := inConfigForm()
|
|
declaration(m, "width")["why"] = "fits a title"
|
|
refused(t, m, `"width"`, "why")
|
|
|
|
err := parsed(t, catalogue.Manifest{Module: "notifier",
|
|
Settings: map[string]catalogue.SettingDeclaration{"width": {Kind: catalogue.KindPreference, Default: float64(250),
|
|
Why: "fits a title", Type: "number"}},
|
|
Resources: []map[string]any{{"id": "c", "type": "file", "path": "/x", "content": "${setting:width}"}}})
|
|
if err == nil || !strings.Contains(err.Error(), "under config") {
|
|
t.Fatalf("a typed value under settings: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestEveryCatalogueIdentityFitsWhatItRequires is ADR 0225's check over the real catalogue: every
|
|
// module's identity, on the longest machine name, fits the bound of every provision it wants. An
|
|
// overflow fails here, in the pull request that introduces it, rather than on the provider's machine
|
|
// the first time a real machine's name meets the module's (issue 263).
|
|
func TestEveryCatalogueIdentityFitsWhatItRequires(t *testing.T) {
|
|
_, found := catalogueManifests(t)
|
|
shelf := catalogue.Shelf{}
|
|
for _, r := range found {
|
|
m, err := r.manifest, r.err
|
|
if err != nil {
|
|
t.Fatalf("%s: %v", r.path, err)
|
|
}
|
|
shelf[m.Module] = m
|
|
}
|
|
for _, p := range IdentityProblems(shelf, catalogue.DefaultLongestMachine) {
|
|
t.Error(p)
|
|
}
|
|
// The night it was found: the resolver provision bounds nothing, and the object store still 20.
|
|
if dns, ok := shelf["dnsmasq"]; ok {
|
|
if b := dns.IdentityBoundOf("wildcard-resolution"); b.Bounded() {
|
|
t.Errorf("the resolver provision bounds its consumers' identities: %+v", b)
|
|
}
|
|
}
|
|
if store, ok := shelf["minio"]; ok {
|
|
if b := store.IdentityBoundOf("s3-bucket"); b.Max != 20 {
|
|
t.Errorf("the object store's access key is not bounded at 20: %+v", b)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestEveryCatalogueModuleDeclaresItsData is ADR 0233's check over the real catalogue (it replaced ADR
|
|
// 0214's store list): every provider that grants says what it keeps for its consumers, nothing writes a
|
|
// backup line by hand, every directory a container writes is declared, and every irreplaceable item is
|
|
// backed up — so a store added is a store backed up, without a list of stores to keep in step.
|
|
func TestEveryCatalogueModuleDeclaresItsData(t *testing.T) {
|
|
_, found := catalogueManifests(t)
|
|
shelf := catalogue.Shelf{}
|
|
granting := 0
|
|
for _, r := range found {
|
|
m, err := r.manifest, r.err
|
|
if err != nil {
|
|
continue // TestEveryCatalogueManifestParses says why
|
|
}
|
|
if len(m.Grants) > 0 {
|
|
granting++
|
|
}
|
|
shelf[m.Module] = m
|
|
}
|
|
for _, problem := range DataProblems(shelf) {
|
|
t.Error(problem)
|
|
}
|
|
if granting == 0 {
|
|
t.Fatal("no module in the catalogue grants a provision, so this proved nothing")
|
|
}
|
|
}
|
|
|
|
// **Every module's event names are what design 29 says, across the whole catalogue.**
|
|
//
|
|
// The rule above holds by construction and turned out to be weaker than it reads: a manifest holds
|
|
// no subject, and every manifest in the catalogue still held the old bus's routing key, which
|
|
// derives into a namespace nobody owns (novox/hq 04-ISSUES/127). Nothing failed — the services
|
|
// started and none of them reacted. This is the check that was missing.
|
|
func TestEveryManifestsEventNamesAreLocal(t *testing.T) {
|
|
manifests := theCatalogue(t)
|
|
|
|
var problems []string
|
|
for _, m := range manifests {
|
|
problems = append(problems, EventProblems(m)...)
|
|
}
|
|
if len(problems) > 0 {
|
|
t.Fatalf("the catalogue holds %d event name(s) the mesh would derive wrongly:\n %s",
|
|
len(problems), strings.Join(problems, "\n "))
|
|
}
|
|
}
|
|
|
|
// **Across the whole catalogue**: every log is named locally and capped within the mesh's caps.
|
|
func TestEveryManifestsLogsAreLocalAndCapped(t *testing.T) {
|
|
var problems []string
|
|
for _, m := range theCatalogue(t) {
|
|
problems = append(problems, LogProblems(m)...)
|
|
}
|
|
if len(problems) > 0 {
|
|
t.Fatalf("the catalogue's logs are not what ADR 0297 says:\n %s", strings.Join(problems, "\n "))
|
|
}
|
|
}
|
|
|
|
// **Across the whole catalogue**: every state name is local and every read names a module's state.
|
|
func TestEveryManifestsStateIsLocalAndEveryReadIsKept(t *testing.T) {
|
|
manifests := theCatalogue(t)
|
|
var problems []string
|
|
for _, m := range manifests {
|
|
problems = append(problems, StateProblems(m)...)
|
|
}
|
|
if len(problems) > 0 {
|
|
t.Fatalf("the catalogue's state is not what ADR 0201 says:\n %s", strings.Join(problems, "\n "))
|
|
}
|
|
}
|
|
|
|
func TestEveryOutOfBoundsHealthIsRefusedByName(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
on string
|
|
health map[string]any
|
|
says string
|
|
}{
|
|
{"no kind", "server", map[string]any{"endpoint": "web"}, "with no kind"},
|
|
{"an unknown kind", "server", map[string]any{"kind": "ping"}, `of kind "ping"`},
|
|
{"a port", "server", map[string]any{"kind": "tcp", "port": 8080}, "names a port"},
|
|
{"an address", "server", map[string]any{"kind": "http", "endpoint": "web", "address": "127.0.0.1"}, "names a address"},
|
|
{"a url", "server", map[string]any{"kind": "http", "url": "http://localhost:8080/"}, "names a url"},
|
|
{"an unknown key", "server", map[string]any{"kind": "tcp", "endpoint": "web", "retries": 3}, `"retries"`},
|
|
{"no endpoint", "server", map[string]any{"kind": "http"}, "names no endpoint"},
|
|
{"an undeclared endpoint", "server", map[string]any{"kind": "tcp", "endpoint": "admin"}, "does not declare"},
|
|
{"a udp endpoint", "server", map[string]any{"kind": "tcp", "endpoint": "beacon"}, "a check connects over tcp"},
|
|
{"an interval under the floor", "server", map[string]any{"kind": "tcp", "endpoint": "web", "interval": "5s", "timeout": "1s"}, "no more often than every 10s"},
|
|
{"a timeout of the interval", "server", map[string]any{"kind": "tcp", "endpoint": "web", "interval": "10s", "timeout": "10s"}, "under its interval"},
|
|
{"one failing look", "server", map[string]any{"kind": "tcp", "endpoint": "web", "looks": 1}, "at least 2"},
|
|
{"a grace and looks past five minutes", "server", map[string]any{"kind": "tcp", "endpoint": "web", "grace": "4m", "looks": 3, "interval": "30s"}, "at most 5m0s"},
|
|
{"a duration that is not one", "server", map[string]any{"kind": "tcp", "endpoint": "web", "interval": "often"}, "is a duration"},
|
|
{"looks that are not a number", "server", map[string]any{"kind": "tcp", "endpoint": "web", "looks": "three"}, "whole number"},
|
|
{"a path without a slash", "server", map[string]any{"kind": "http", "endpoint": "web", "path": "health"}, "starts with /"},
|
|
{"a status that is not one", "server", map[string]any{"kind": "http", "endpoint": "web", "status": 700}, "is not one"},
|
|
{"a scheme that is not one", "server", map[string]any{"kind": "http", "endpoint": "web", "scheme": "ftp"}, "http or https"},
|
|
{"a status on a tcp check", "server", map[string]any{"kind": "tcp", "endpoint": "web", "status": 200}, "only an http check has"},
|
|
{"an exec with no command", "server", map[string]any{"kind": "exec"}, "says no command"},
|
|
{"a command on an http check", "server", map[string]any{"kind": "http", "endpoint": "web", "command": "true"}, "only an exec check runs"},
|
|
{"a runtime check on a service", "daemon", map[string]any{"kind": "runtime"}, "only a container has"},
|
|
{"a unit check on a container", "server", map[string]any{"kind": "unit"}, "a service's or a process's"},
|
|
{"a tool the module does not serve", "server", map[string]any{"kind": "tool", "tool": "app_admin"}, "does not serve"},
|
|
{"a tool check alone", "server", map[string]any{"kind": "tool", "tool": "app_status"}, "judges itself only by its own tool"},
|
|
{"needs not required", "server", map[string]any{"kind": "tcp", "endpoint": "web", "needs": "redis"}, "does not require"},
|
|
{"health on a step", "seed", map[string]any{"kind": "runtime"}, "does not stay up"},
|
|
{"health on a schedule", "sweep", map[string]any{"kind": "runtime"}, "does not stay up"},
|
|
{"health that is not an object", "server", nil, "is an object"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
raw := healthManifest(t, c.on, c.health)
|
|
if c.health == nil {
|
|
raw = []byte(strings.Replace(string(raw), `"name":"app-server"`, `"name":"app-server","health":"tcp"`, 1))
|
|
}
|
|
_, err := ParseManifest(raw)
|
|
if err == nil {
|
|
t.Fatalf("%s was accepted", c.name)
|
|
}
|
|
if !strings.Contains(err.Error(), c.says) {
|
|
t.Errorf("refused, but not for saying %q: %v", c.says, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// **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 := catalogue.Manifest{Module: "feed", Streams: []catalogue.StreamDeclaration{{Name: "items", Events: []string{"item.**"}}}}
|
|
carried := catalogue.Manifest{Module: "reader", Consumes: []string{"mesh-mail.mail.*.message.added"}}
|
|
got := RestrictedHearingProblems(catalogue.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(catalogue.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(catalogue.Shelf{"mesh-mail": provider, "reader": own}); len(got) != 0 {
|
|
t.Fatalf("a module of its own account was refused: %v", got)
|
|
}
|
|
for _, m := range []catalogue.Manifest{
|
|
{Module: "events", Consumes: []string{"mesh-mail.account.added"}},
|
|
{Module: "items", Consumes: []string{"feed.item.**"}},
|
|
{Module: "audit", Consumes: []string{"**", "*.mail.**"}},
|
|
} {
|
|
if got := RestrictedHearingProblems(catalogue.Shelf{"mesh-mail": provider, "feed": feed, m.Module: m}); len(got) != 0 {
|
|
t.Errorf("%s was refused: %v", m.Module, got)
|
|
}
|
|
}
|
|
if got := RestrictedHearingProblems(catalogue.Shelf{"reader": carried}); len(got) != 0 {
|
|
t.Fatalf("judged without its provider: %v", got)
|
|
}
|
|
}
|
|
|
|
// 1354/AC3: a contribution placed only where the machine reports the user namespaces capability (the name issue 1308 has
|
|
// the node-engine report) is accepted, and a misspelt name is still refused with today's words. The manifests are the
|
|
// shape of TestIfACapabilityNamesOneTheNodeEngineDetectsOnAnOfferedKindOnly (mesh-control 6c2a8d62
|
|
// internal/catalogue/seat_data_test.go:105), which stays in mesh-controller with the tests that read its other packages.
|
|
func TestIfACapabilityMayNameUserNamespaces(t *testing.T) {
|
|
const piece = `{"module":"power","contributions":[{"seat":"node-bar","kind":"block","if-capability":%q,"data":{"bar":"top","place":"status","shows":"battery"}}]}`
|
|
if _, err := ParseManifest([]byte(strings.Replace(piece, "%q", `"user-namespaces"`, 1))); err != nil {
|
|
t.Fatalf("if-capability user-namespaces was refused: %v", err)
|
|
}
|
|
for name, want := range map[string]string{
|
|
"user-namespace": "which no machine reports",
|
|
"userns": "which no machine reports",
|
|
"user_namespaces": "is not a capability's name",
|
|
} {
|
|
_, err := ParseManifest([]byte(strings.Replace(piece, "%q", `"`+name+`"`, 1)))
|
|
if err == nil || !strings.Contains(err.Error(), want) {
|
|
t.Errorf("%s: accepted, or refused without today's words %q: %v", name, want, err)
|
|
}
|
|
}
|
|
if !oneOf(catalogue.KnownCapabilities, "user-namespaces") {
|
|
t.Error("user-namespaces is not a known capability")
|
|
}
|
|
for _, name := range []string{"battery", "power-meter", "seat", "container-runtime"} {
|
|
if !oneOf(catalogue.KnownCapabilities, name) {
|
|
t.Errorf("%s is no longer a known capability", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
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(catalogue.KnownCapabilities, name) {
|
|
t.Errorf("%s is not a known capability", name)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Data a consumer keeps with a provider as irreplaceable must be protected there: the check refuses a
|
|
// provider that keeps its consumers' data as a cache, or in an item with no protection.
|
|
func TestIrreplaceableDataKeptWithAProviderMustBeProtectedThere(t *testing.T) {
|
|
photos := mustParse(t, `{"module":"photos","version":"1","requires":["s3-bucket"],
|
|
"data":{"kept-by":{"s3-bucket":{"class":"irreplaceable"}}}}`)
|
|
store := func(backup string) catalogue.Manifest {
|
|
return mustParse(t, `{"module":"objects","version":"1","provides":[{"name":"s3-bucket","scope":"mesh"}],
|
|
"grants":{"s3-bucket":"${dir:g}"},
|
|
"data":{"own":[{"id":"data","path":"${dir:data}","class":"valuable","backup":"`+backup+`"}],
|
|
"consumers":{"s3-bucket":{"class":"valuable","in":"data"}}},
|
|
"resources":[{"id":"g","type":"directory","mode":"0700"},{"id":"data","type":"directory","mode":"0700"}]}`)
|
|
}
|
|
if p := DataProblems(catalogue.Shelf{"photos": photos, "objects": store("copy")}); len(p) > 0 {
|
|
t.Fatalf("a provider backing its consumers' data up was refused: %v", p)
|
|
}
|
|
if p := strings.Join(DataProblems(catalogue.Shelf{"photos": photos, "objects": store("none")}), "\n"); !strings.Contains(p, "neither backed up") {
|
|
t.Fatalf("irreplaceable photos in an unprotected store passed: %s", p)
|
|
}
|
|
}
|
|
|
|
func TestItemNamesAttributesAndCapabilitiesAreJudged(t *testing.T) {
|
|
item := func(extra string) string {
|
|
return `{"module":"p","moved":true,"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},` +
|
|
`"items":[` + extra + `]}`
|
|
}
|
|
for items, want := range map[string]string{
|
|
`{"collection":"c","name":"a","template":"files/a"},{"collection":"c","template":"files/b"}`: "has no name",
|
|
`{"collection":"c","name":"a","template":"files/a"},{"collection":"c","name":"a","template":"files/b"}`: "twice",
|
|
`{"collection":"c","name":"Big","template":"files/a"}`: "is not a name",
|
|
`{"collection":"c","attributes":{"order":"100"},"template":"files/a"}`: "0 to 99",
|
|
`{"collection":"c","attributes":{"bar":"a b"},"template":"files/a"}`: "is not a value",
|
|
`{"collection":"c","if-capability":"batery","template":"files/a"}`: "no machine reports",
|
|
`{"collection":"contribution:node-bar:block","attributes":{"bar":"left"},"template":"files/a"}`: "bar=left",
|
|
`{"collection":"contribution:node-bar:block","attributes":{"colour":"red"},"template":"files/a"}`: `"colour" is none of them`,
|
|
`{"collection":"c","name":"a","template":"files/a","colour":"red"}`: "colour",
|
|
} {
|
|
_, err := ParseManifest([]byte(item(items)))
|
|
if err == nil || !strings.Contains(err.Error(), want) {
|
|
t.Errorf("%s: %v, want %q", items, err, want)
|
|
}
|
|
}
|
|
ok := item(`{"collection":"contribution:node-bar:block","name":"a","attributes":{"bar":"top","place":"status","order":"5"},"if-capability":"battery","template":"files/a"}`)
|
|
if _, err := ParseManifest([]byte(ok)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestKeepsMustBeAnAbsolutePath(t *testing.T) {
|
|
_, err := ParseManifest([]byte(`{"module":"mesh-vault","version":"1","keeps":"root"}`))
|
|
if err == nil || !strings.Contains(err.Error(), "keeps") {
|
|
t.Fatalf("a relative keeps path was not refused: %v", err)
|
|
}
|
|
if _, err := ParseManifest([]byte(`{"module":"mesh-vault","version":"1","keeps":"/var/lib/mesh-vault/root"}`)); err != nil {
|
|
t.Fatalf("an absolute keeps path was refused: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMalformedShellCodeIsRefusedAtParse(t *testing.T) {
|
|
for _, c := range []struct{ shell, want string }{
|
|
{`[{"for":"tcsh","slot":"normal","code":"x"}]`, `tool's shell code 1 is for "tcsh"; the shells are zsh, bash, fish`},
|
|
{`[{"for":"zsh","slot":"middle","code":"x"}]`, `tool's shell code 1 goes in the slot "middle"; the slots are first, normal, last`},
|
|
{`[{"for":"zsh","slot":"last","code":"x"},{"for":"zsh","slot":"last","code":" \n"}]`, `tool's shell code 2 has no code`},
|
|
{`[{"for":"zsh","slot":"last","code":"x","order":1}]`, `unknown field "order"`},
|
|
} {
|
|
_, err := ParseManifest([]byte(`{"module":"tool","shell":` + c.shell + `}`))
|
|
if err == nil || !strings.Contains(err.Error(), c.want) {
|
|
t.Errorf("%s: want %q, got %v", c.shell, c.want, err)
|
|
}
|
|
}
|
|
// The code itself is never judged: a shell's own `${…}` is not the mesh's.
|
|
if _, err := ParseManifest([]byte(`{"module":"tool","shell":[{"for":"zsh","slot":"first",` +
|
|
`"code":"source \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\""}]}`)); err != nil {
|
|
t.Fatalf("shell code was judged as if it were the mesh's: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestNoCatalogueManifestNamesAnInstallation is ADR 0112's check, run over the real catalogue: no
|
|
// definition names a domain or a public address the mesh acts on, and every value that must for now
|
|
// carries its reason (novox/hq ADR 0155, issue 134). The list it prints is the one that shrinks.
|
|
func TestNoCatalogueManifestNamesAnInstallation(t *testing.T) {
|
|
_, found := catalogueManifests(t)
|
|
var named []string
|
|
for _, r := range found {
|
|
m, err := r.manifest, r.err
|
|
if err != nil {
|
|
t.Errorf("%s: %v", r.path, err)
|
|
continue
|
|
}
|
|
named = append(named, InstallationProblems(m)...)
|
|
}
|
|
if len(named) > 0 {
|
|
t.Fatalf("%d value(s) name an installation:\n %s", len(named), strings.Join(named, "\n "))
|
|
}
|
|
}
|
|
|
|
// 1887/A9a: the catalogue defines no seat node-build-agent, and a manifest claiming it is refused naming its
|
|
// replacement; the build modules' seats accept the three kinds in their pull order and serve current, pause, resume.
|
|
func TestNodeBuildAgentIsRetiredForTheBuildSeats(t *testing.T) {
|
|
for _, gone := range []string{"node-build-agent", "mesh-build-machine"} {
|
|
if _, ok := catalogue.SeatNamed(gone); ok {
|
|
t.Errorf("the mesh still defines %s", gone)
|
|
}
|
|
}
|
|
_, err := ParseManifest([]byte(`{"module":"build-agent","version":"1","claims":[{"name":"node-build-agent","scope":"node"}]}`))
|
|
if err == nil || !strings.Contains(err.Error(), `"node-build-agent", which is retired`) || !strings.Contains(err.Error(), "node-build-go") {
|
|
t.Fatalf("the claim is not refused naming its replacement: %v", err)
|
|
}
|
|
// And over a catalogue whose store still holds no row for it, a use of it is refused the same way.
|
|
user := catalogue.Manifest{Module: "watcher", Uses: []string{"node-build-agent"}}
|
|
if joined := strings.Join(CatalogueProblems(catalogue.Shelf{user.Module: user}), "; "); !strings.Contains(joined, "which is retired: its work is node-build-go") {
|
|
t.Fatalf("a use is not refused naming the replacement: %s", joined)
|
|
}
|
|
for _, name := range []string{"node-build-go", "node-build-node", "node-build-image"} {
|
|
s, ok := catalogue.SeatNamed(name)
|
|
if !ok || s.Scope != catalogue.ScopeNode || !s.PulledByKind || strings.Join(s.Accepts, ",") != "delivery,check,recheck" {
|
|
t.Fatalf("%s = %+v", name, s)
|
|
}
|
|
if !s.Yields {
|
|
t.Errorf("%s does not yield (ADR 0553)", name)
|
|
}
|
|
var verbs []string
|
|
for _, v := range s.Serves {
|
|
verbs = append(verbs, v.Name)
|
|
}
|
|
if strings.Join(verbs, ",") != "current,pause,resume" {
|
|
t.Errorf("%s serves %v", name, verbs)
|
|
}
|
|
}
|
|
// The build seats are the seats that yield, and no other (ADR 0553 §1).
|
|
var yielding []string
|
|
for _, s := range catalogue.Seats() {
|
|
if s.Yields {
|
|
yielding = append(yielding, s.Name)
|
|
}
|
|
}
|
|
if strings.Join(yielding, ",") != "node-build-go,node-build-node,node-build-image" {
|
|
t.Errorf("the seats that yield are %v", yielding)
|
|
}
|
|
}
|
|
|
|
// §2: a module may claim one of them, and may not declare it as its own.
|
|
func TestNoModuleMayDeclareAGraphicalSessionSeat(t *testing.T) {
|
|
raw := `{"module":"xorg","seats":[{"name":"node-display-server","scope":"node"}]}`
|
|
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "mesh's own namespace") {
|
|
t.Fatalf("a module declared node-display-server as its own: %v", err)
|
|
}
|
|
}
|
|
|
|
// ADR 0204 §1: the login shell is the mesh's, so no module declares it — neither under the mesh's
|
|
// name nor under the name a module gave it before.
|
|
func TestNoModuleMayDeclareTheLoginShell(t *testing.T) {
|
|
for _, n := range []string{"login-shell", "node-login-shell", "node-environment"} {
|
|
raw := `{"module":"zsh","seats":[{"name":"` + n + `","scope":"node","serves":["execute"]}],"tools":["execute"]}`
|
|
_, err := ParseManifest([]byte(raw))
|
|
if err == nil {
|
|
t.Errorf("a module declaring %q was accepted", n)
|
|
}
|
|
}
|
|
got := strings.Join(declaredSeatProblems(catalogue.Manifest{Module: "zsh",
|
|
DefinesSeats: []catalogue.SeatDeclaration{{Name: "login-shell", Scope: catalogue.ScopeNode}}}), "; ")
|
|
if !strings.Contains(got, `zsh declares a seat named "login-shell"; the login shell is the mesh's own seat node-login-shell`) {
|
|
t.Fatalf("declaring login-shell was not refused by name: %q", got)
|
|
}
|
|
// And a shell module claiming the mesh's seat, serving execute, is what the seat is for.
|
|
m, err := ParseManifest([]byte(`{"module":"zsh","tools":["execute"],` +
|
|
`"claims":[{"name":"node-login-shell","scope":"node"}]}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := catalogue.CanHold(m, catalogue.Seat{Name: catalogue.LoginShellSeat, Scope: catalogue.ScopeNode, Serves: loginShellVerbs()}); err != nil {
|
|
t.Fatalf("a shell module claiming the seat cannot hold it: %v", err)
|
|
}
|
|
}
|
|
|
|
// One component, one artifact per system: the shape the mesh's own binaries are declared in, and the
|
|
// reason the target is the artifact's rather than the recipe's.
|
|
func TestOneArtifactPerSystemIsAccepted(t *testing.T) {
|
|
m := catalogue.Manifest{Module: "the-host", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{
|
|
{Name: "arch", Kind: catalogue.ArtifactBundle, Language: "go", System: "arch"},
|
|
{Name: "alpine", Kind: catalogue.ArtifactBundle, Language: "go", System: "alpine"},
|
|
{Name: "android", Kind: catalogue.ArtifactBundle, Language: "go", System: "android"},
|
|
}}}
|
|
if got := buildProblemsOf(t, m); got != "" {
|
|
t.Fatalf("one artifact per system was refused:\n%s", got)
|
|
}
|
|
}
|
|
|
|
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 := catalogue.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)
|
|
}
|
|
}
|
|
|
|
// 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 := catalogue.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(catalogue.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 := catalogue.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(catalogue.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 := catalogue.Manifest{Module: "reader", Version: "1", Accesses: []catalogue.Access{{ID: "hosts", Path: "/etc/hosts"}}}
|
|
if got := CatalogueProblems(catalogue.Shelf{"reader": reader}); len(got) != 0 {
|
|
t.Errorf("reading /etc/hosts was refused: %v", got)
|
|
}
|
|
uplink := catalogue.Manifest{Module: "uplink", Version: "1", Claims: []catalogue.Claim{{Name: "node-uplink", Scope: catalogue.ScopeNode}},
|
|
Facts: map[string]catalogue.RosterFile{"resolvers": {Path: catalogue.ResolverFile, Template: "nameserver 10.42.0.1\n"}}}
|
|
resolver := localResolver()
|
|
resolver.Claims[0].Serves = []string{"routes", "route", "unroute"}
|
|
if got := CatalogueProblems(catalogue.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)
|
|
}
|
|
}
|
|
|
|
// An image names the Go package its recipe compiles (novox/hq ADR 0267): within the tree it is built in,
|
|
// and only an image says one.
|
|
func TestOnlyAnImageNamesThePackageItCompilesWithinItsTree(t *testing.T) {
|
|
m, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
|
|
{"name":"x","kind":"image","from":"Dockerfile","compiles":"cmd/x"}]}}`))
|
|
if err != nil || m.Build.Artifacts[0].Compiles != "cmd/x" {
|
|
t.Fatalf("an image naming its package was refused or lost it: %v", err)
|
|
}
|
|
for _, c := range []struct{ artifact, says string }{
|
|
{`{"name":"x","kind":"archive","from":"files","compiles":"cmd/x"}`, "Only an image"},
|
|
{`{"name":"x","kind":"image","from":"Dockerfile","compiles":"../x"}`, "leaves the tree"},
|
|
{`{"name":"x","kind":"image","from":"Dockerfile","compiles":"/x"}`, "leaves the tree"},
|
|
{`{"name":"x","kind":"image","from":"Dockerfile","compiles":"a/../../x"}`, "leaves the tree"},
|
|
} {
|
|
_, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[` + c.artifact + `]}}`))
|
|
if err == nil || !strings.Contains(err.Error(), c.says) {
|
|
t.Errorf("%s: %v, wanted a refusal saying %q", c.artifact, err, c.says)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 := catalogue.Manifest{Module: "mail", DefinesSeats: []catalogue.SeatDeclaration{{
|
|
Name: "mail-notifier", Scope: catalogue.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)
|
|
}
|
|
}
|
|
|
|
func TestOnlyChannelAndIntakeAreKinded(t *testing.T) {
|
|
m := catalogue.Manifest{Module: "x", DefinesSeats: []catalogue.SeatDeclaration{{Name: "pager", Kinded: true, Accepts: []string{"page"}}}}
|
|
if got := strings.Join(declaredSeatProblems(m), "; "); !strings.Contains(got, "only channel and intake are kinded") {
|
|
t.Fatalf("another kinded bench was declared: %s", got)
|
|
}
|
|
}
|
|
|
|
// The indexer and the builder hold no published list of predefined keys; a manifest whose config values fall back to
|
|
// one (claude-code, dunst, xterm at mesh-catalog 835bf22b) is still read for its build's and fingerprint's inputs
|
|
// (issue 1751).
|
|
func TestParseManifestForInputsReadsAValueFallingBackToAPredefinedKey(t *testing.T) { // issue 1751
|
|
was := catalogue.ReplacePublished(nil) // the indexer's and the builder's case: no published list
|
|
defer catalogue.ReplacePublished(was)
|
|
for _, module := range []string{"claude-code", "dunst", "xterm"} {
|
|
raw, err := os.ReadFile("testdata/catalogue-835bf22b-" + module + ".module.json")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := ParseManifest(raw); err == nil {
|
|
t.Fatalf("%s: ParseManifest reads it with no list; the test proves nothing", module)
|
|
}
|
|
if m, err := ParseManifestForInputs(raw); err != nil || m.Module != module {
|
|
t.Errorf("%s: %v", module, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPlaceIsValidatedAtTheManifest(t *testing.T) {
|
|
both := catalogue.Manifest{Module: "x", Resources: []map[string]any{
|
|
{"id": "d", "type": "directory", "place": ".", "path": "/somewhere"},
|
|
}}
|
|
if got := manifestUnknownDirRefs(both); len(got) != 1 || !strings.Contains(got[0], "both path and place") {
|
|
t.Fatalf("path beside place refuses; got %v", got)
|
|
}
|
|
elsewhere := catalogue.Manifest{Module: "x", Resources: []map[string]any{
|
|
{"id": "f", "type": "file", "place": ".", "path": "/somewhere", "content": ""},
|
|
}}
|
|
if got := manifestUnknownDirRefs(elsewhere); len(got) != 1 || !strings.Contains(got[0], "not a directory") {
|
|
t.Fatalf("place on a file refuses; got %v", got)
|
|
}
|
|
wrong := catalogue.Manifest{Module: "x", Resources: []map[string]any{
|
|
{"id": "d", "type": "directory", "place": "sub/dir"},
|
|
}}
|
|
if got := manifestUnknownDirRefs(wrong); len(got) != 1 || !strings.Contains(got[0], `the places are "."`) {
|
|
t.Fatalf("a place that is neither root refuses; got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestReceivingWhatYouDoNotProvideIsRefused(t *testing.T) {
|
|
// It would create a file nobody ever writes to, on a machine where nothing asked for it.
|
|
_, err := ParseManifest([]byte(`{"module":"traefik","version":"1",
|
|
"receives":{"reverse-proxy":"/etc/traefik/mesh.json"}}`))
|
|
if err == nil {
|
|
t.Fatal("a module received contributions to something it does not provide")
|
|
}
|
|
if !strings.Contains(err.Error(), "does not provide") {
|
|
t.Fatalf("unhelpful refusal: %v", err)
|
|
}
|
|
}
|
|
|
|
// 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 := catalogue.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(catalogue.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 := catalogue.SeatNamed("node-hostname")
|
|
if !ok {
|
|
t.Fatal("no node-hostname seat")
|
|
}
|
|
holder := catalogue.Manifest{Module: "names", Version: "1", Claims: []catalogue.Claim{{Name: "node-hostname", Scope: catalogue.ScopeNode,
|
|
Serves: []string{"entries", "add", "remove"}}}}
|
|
err := catalogue.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)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunsAsIsAnAccountOfTheModulesOwn(t *testing.T) {
|
|
ok := catalogue.Manifest{Module: "telegram", RunsAs: "telegram", SecretsOwner: "telegram",
|
|
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/telegram/broker"}},
|
|
Resources: []map[string]any{{"id": "account", "type": "user", "name": "telegram"}}}
|
|
if got := RunsAsProblems(ok); len(got) != 0 {
|
|
t.Fatalf("a sound runs-as was refused: %v", got)
|
|
}
|
|
for want, change := range map[string]func(*catalogue.Manifest){
|
|
"never root": func(m *catalogue.Manifest) { m.RunsAs, m.SecretsOwner = "root", "root" },
|
|
"not an account name": func(m *catalogue.Manifest) { m.RunsAs = "${machine:account}" },
|
|
"which it does not make": func(m *catalogue.Manifest) { m.Resources = nil },
|
|
"declares no own secret": func(m *catalogue.Manifest) { m.OwnSecrets = nil },
|
|
"they are the account's own": func(m *catalogue.Manifest) { m.SecretsOwner = "" },
|
|
} {
|
|
m := ok
|
|
m.Resources = append([]map[string]any(nil), ok.Resources...)
|
|
m.OwnSecrets = catalogue.OwnSecrets{"broker": {Path: "/x"}}
|
|
change(&m)
|
|
if got := strings.Join(RunsAsProblems(m), "; "); !strings.Contains(got, want) {
|
|
t.Errorf("want %q, got %q", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 TestServingWhatYouDoNotProvideIsRefused(t *testing.T) {
|
|
_, err := ParseManifest([]byte(`{"module":"app","version":"1",
|
|
"serves":{"postgres-database":{"port":5432}}}`))
|
|
if err == nil {
|
|
t.Fatal("a module served something it does not provide")
|
|
}
|
|
if !strings.Contains(err.Error(), "does not provide") {
|
|
t.Fatalf("unhelpful refusal: %v", err)
|
|
}
|
|
}
|
|
|
|
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",
|
|
catalogue.TakesSettingsField: []any{"a"}},
|
|
"does not merge"},
|
|
"not a list": {
|
|
map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": catalogue.MergeJSON, "content": "{}",
|
|
catalogue.TakesSettingsField: "server"},
|
|
"is a list of setting keys"},
|
|
"a mesh word": {
|
|
map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": catalogue.MergeJSON, "content": "{}",
|
|
catalogue.TakesSettingsField: []any{"endpoints"}},
|
|
"the mesh reads"},
|
|
"a protected key": {
|
|
map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": catalogue.MergeJSON, "content": "{}",
|
|
"protected": []any{"secret"}, catalogue.TakesSettingsField: []any{"secret"}},
|
|
"keeps it as protected"},
|
|
"named twice": {
|
|
map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": catalogue.MergeJSON, "content": "{}",
|
|
catalogue.TakesSettingsField: []any{"a", "a"}},
|
|
"twice"},
|
|
"not a key": {
|
|
map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": catalogue.MergeJSON, "content": "{}",
|
|
catalogue.TakesSettingsField: []any{"two words"}},
|
|
"not a usable setting key"},
|
|
}
|
|
for name, c := range cases {
|
|
problems := TakenSettingsProblems(catalogue.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",
|
|
catalogue.TakesSettingsField: []any{"a"}}
|
|
if _, err := catalogue.ApplySettings(plain, nil); err == nil {
|
|
t.Error("a file that does not merge and names keys was settled")
|
|
}
|
|
}
|
|
|
|
// The module that provides the artifact store may not be delivered through it.
|
|
//
|
|
// Building publishes to the store and the builder will not start without one, so a module that
|
|
// provides the store and also builds something asks the mesh to put an artifact into the thing
|
|
// that artifact is needed to create. On a mesh new enough to have no registry, that is a build
|
|
// that never returns (novox/hq 04-ISSUES/029).
|
|
func TestTheArtifactStoreCannotBeDeliveredThroughItself(t *testing.T) {
|
|
_, err := ParseManifest([]byte(`{"module":"registry","version":"1",` +
|
|
`"provides":[{"name":"artifact-store","scope":"mesh"}],` +
|
|
`"build":{"artifacts":[{"name":"registry","kind":"upstream","from":"registry:2"}]},` +
|
|
`"resources":[{"id":"store","type":"container","name":"mesh-registry",` +
|
|
`"artifact":"registry","ports":["5000:5000"]}]}`))
|
|
if err == nil {
|
|
t.Fatal("a registry module that builds its own image was accepted; the build has " +
|
|
"nowhere to publish until the module it belongs to is already running")
|
|
}
|
|
if !strings.Contains(err.Error(), "artifact-store") {
|
|
t.Fatalf("refused without naming the provision the cycle turns on: %v", err)
|
|
}
|
|
}
|
|
|
|
// The module holding mesh-broker is the bus, and its account is granted the bus's snapshot API and
|
|
// nothing else (novox/hq ADR 0235). Anything it declared to say or hear on the bus would be granted
|
|
// nothing, so it is refused at the parser rather than silently dropped.
|
|
func TestTheBussAccountSaysNothingOnTheBus(t *testing.T) {
|
|
raw := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],
|
|
"claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"},
|
|
"emits":["something.happened"]}`)
|
|
_, err := ParseManifest(raw)
|
|
if err == nil || !strings.Contains(err.Error(), "granted the bus's snapshot API and nothing else") {
|
|
t.Fatalf("a bus module declaring what it emits was not refused, or not for the reason: %v", err)
|
|
}
|
|
// Nor what it invokes, with no bundle for the runtime to ask on its behalf.
|
|
unbundledInvoke := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],
|
|
"claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"},
|
|
"invokes":["seat:mesh-hand-acts.hand-act"]}`)
|
|
if _, err := ParseManifest(unbundledInvoke); err == nil || !strings.Contains(err.Error(), "its invokes would be granted nothing") {
|
|
t.Fatalf("a bus module with no bundle declaring what it invokes was not refused, or not for the reason: %v", err)
|
|
}
|
|
|
|
// Its tools bundle asks what the module invokes (novox/hq ADR 0357; issue 774): the bundle asks through the
|
|
// node's runtime, which is granted every tool and seat verb (mesh-sdk go/buscompose), so a bus module that
|
|
// ships one may declare them. The shape of mesh-catalog's modules/nats/module.json at fc5b849d, cut to the
|
|
// parts the rule reads, was refused and held every bus user list from 2026-11-02 01:35.
|
|
nats := []byte(`{"module":"nats","version":"1","provides":[{"name":"mesh-bus","scope":"mesh","credential":{"form":"none"}}],
|
|
"claims":[{"name":"mesh-broker","scope":"mesh"}],
|
|
"own-secrets":{"broker":"/run/broker","broker-nats-tools":"${dir:tools-secret}/broker-nats-tools"},
|
|
"resources":[{"id":"tools-secret","type":"directory","path":"/var/lib/mesh/nats-tools","mode":"0711","unknown":"program"}],
|
|
"emits":["consumer-removed","bucket-keys-removed","stream-removed","bucket-expiry-set"],
|
|
"invokes":["seat:mesh-hand-acts.hand-act"],"consumes":[],
|
|
"tools":["nats_server","nats_bucket_expiry"],
|
|
"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/nats-tools",
|
|
"binary":"nats-tools","loads":["nats-tools"],"bus":{"removes":["controller-leftovers"]}}]}}`)
|
|
nm, err := ParseManifest(nats)
|
|
if err != nil {
|
|
t.Fatalf("the bus module whose tools bundle invokes a seat verb was refused: %v", err)
|
|
}
|
|
nbuilt, err := nm.Resolve([]catalogue.Built{{Name: "tools", Kind: catalogue.ArtifactBundle, Reference: "store/tools:1", Digest: "sha256:1"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if raw, err = json.Marshal(nbuilt); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := ParseManifestForInputs(raw); err != nil {
|
|
t.Fatalf("the built manifest of the bus module whose tools bundle invokes a seat verb was refused: %v", err)
|
|
}
|
|
|
|
// Its tools bundle says what it emits (novox/hq ADR 0357): the node's runtime carries the bundle and is
|
|
// granted the module's events, so a bus module that ships one may declare them.
|
|
bundled := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],
|
|
"claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"},
|
|
"emits":["bus.slow-consumer"],"tools":["bus_streams"],
|
|
"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/t","binary":"t"}]}}`)
|
|
m, err := ParseManifest(bundled)
|
|
if err != nil {
|
|
t.Fatalf("a bus module whose tools bundle declares what it emits was refused: %v", err)
|
|
}
|
|
// And so does its built manifest, its bundle in Bundles and its build read away (issue 774: nats's built manifest
|
|
// was refused, and with it every plan of the control-node).
|
|
built, err := m.Resolve([]catalogue.Built{{Name: "tools", Kind: catalogue.ArtifactBundle, Reference: "store/tools:1", Digest: "sha256:1"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, err = json.Marshal(built)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := ParseManifestForInputs(raw); err != nil {
|
|
t.Fatalf("the built manifest of a bus module whose tools bundle declares what it emits was refused: %v", err)
|
|
}
|
|
|
|
quiet := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],
|
|
"claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"},
|
|
"tools":["bus_streams"]}`)
|
|
m, err = ParseManifest(quiet)
|
|
if err != nil {
|
|
t.Fatalf("a bus module with an account and tools was refused: %v", err)
|
|
}
|
|
if !m.ClaimsSeat(catalogue.BrokerSeat) {
|
|
t.Fatal("it does not read as holding the broker seat")
|
|
}
|
|
}
|
|
|
|
// The catalogue check: a granting provider says what it keeps, says it in one place, writes no backup
|
|
// line by hand, and every directory a container writes is declared (novox/hq ADR 0233).
|
|
func TestTheCatalogueCheckAsksEveryModuleForItsData(t *testing.T) {
|
|
unsaid := mustParse(t, `{"module":"q","version":"1","provides":[{"name":"queue","scope":"mesh"}],
|
|
"grants":{"queue":"${dir:g}"},"resources":[{"id":"g","type":"directory","mode":"0700"}]}`)
|
|
onTheOffer := mustParse(t, `{"module":"r","version":"1","provides":[{"name":"resolver","scope":"mesh","keeps-consumer-data":false}]}`)
|
|
byHand := mustParse(t, `{"module":"h","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"}],
|
|
"contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:d}"}]}`)
|
|
writes := mustParse(t, `{"module":"w","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"},
|
|
{"id":"c","type":"directory","mode":"0700"},
|
|
{"id":"app","type":"container","image":"x@sha256:aa","volumes":["${dir:d}:/data","${dir:c}:/etc/app:ro","${dir:d}/f:/f"]}]}`)
|
|
problems := strings.Join(DataProblems(catalogue.Shelf{"q": unsaid, "r": onTheOffer, "h": byHand, "w": writes}), "\n")
|
|
for _, want := range []string{
|
|
"q grants queue and does not say what it keeps",
|
|
"r says keeps-consumer-data on its offer",
|
|
"h's contribution 1 is a backup line written by hand",
|
|
`w mounts its directory "d" into a container to be written`,
|
|
} {
|
|
if !strings.Contains(problems, want) {
|
|
t.Errorf("the check does not say %q:\n%s", want, problems)
|
|
}
|
|
}
|
|
if strings.Contains(problems, `"c"`) {
|
|
t.Errorf("a read-only mount was taken for written data:\n%s", problems)
|
|
}
|
|
// The same module declaring the directory, as a cache, passes.
|
|
declared := mustParse(t, `{"module":"w","version":"1","data":{"own":[{"id":"d","path":"${dir:d}","class":"cache"}]},
|
|
"resources":[{"id":"d","type":"directory","mode":"0700"},
|
|
{"id":"app","type":"container","image":"x@sha256:aa","volumes":["${dir:d}:/data"]}]}`)
|
|
if p := DataProblems(catalogue.Shelf{"w": declared}); len(p) > 0 {
|
|
t.Fatalf("a declared directory is still refused: %v", p)
|
|
}
|
|
}
|
|
|
|
// Refused before merge: the catalogue check judges each module's identity, on the longest machine
|
|
// name, against the bound of every provision it wants — and names the module and the slug to set.
|
|
func TestTheCatalogueCheckRefusesAnIdentityThatOverflowsWhatItRequires(t *testing.T) {
|
|
shelf := catalogue.Shelf{
|
|
"objects": {Module: "objects", Provides: []catalogue.Offer{{Name: "s3-bucket", Scope: catalogue.ScopeMesh,
|
|
Identity: &catalogue.OfferIdentity{Max: 20, In: "an S3 access key"}}},
|
|
Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}},
|
|
"photoalbum": {Module: "photoalbum", Requires: []string{"s3-bucket"}},
|
|
"files": {Module: "files", Requires: []string{"s3-bucket"}},
|
|
}
|
|
problems := IdentityProblems(shelf, 6)
|
|
if len(problems) != 1 || !strings.Contains(problems[0], "photoalbum wants s3-bucket") ||
|
|
!strings.Contains(problems[0], `"mesh_nnnnnn_photoalbum", 22`) ||
|
|
!strings.Contains(problems[0], "`slug` of at most 8 characters") {
|
|
t.Fatalf("one overflow, named with its remedy, was expected: %q", problems)
|
|
}
|
|
// A longer machine name refuses more: the check is about the mesh's machines, not one.
|
|
if got := IdentityProblems(shelf, 10); len(got) != 2 {
|
|
t.Fatalf("on a 10-character name both overflow (mesh_nnnnnnnnnn_files is 21): %q", got)
|
|
}
|
|
// And a slug is the remedy it names.
|
|
album := shelf["photoalbum"]
|
|
album.Slug = "album"
|
|
shelf["photoalbum"] = album
|
|
if got := IdentityProblems(shelf, 6); len(got) != 0 {
|
|
t.Fatalf("a slug that fits is still refused: %q", got)
|
|
}
|
|
}
|
|
|
|
// 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 := catalogue.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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The catalogue pass no longer judges one module's reads against another's manifest (novox/hq ADR 0495 clauses 1 and 5,
|
|
// issue 1790): what an owner keeps is what it announces on the bus, and each module's own announcement test holds that.
|
|
func TestTheCataloguePassJudgesNoReadAgainstTheOwnersManifest(t *testing.T) {
|
|
shelf := catalogue.Shelf{
|
|
"licence-manager": {Module: "licence-manager", State: []catalogue.StateDeclaration{{Name: "bindings"}}},
|
|
"claude-code": {Module: "claude-code", Reads: []string{"licence-manager.tokens"}},
|
|
}
|
|
for _, p := range CatalogueProblems(shelf) {
|
|
if strings.Contains(p, "keeps no state") {
|
|
t.Fatalf("the catalogue pass judged a read against the owner's manifest: %s", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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, _ := catalogue.SeatNamed(catalogue.BarSeat)
|
|
if holder.Moved {
|
|
placed := "${collected:contribution:" + catalogue.BarSeat + ":" + catalogue.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, catalogue.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, catalogue.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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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(catalogue.DefaultSeats()); len(p) != 0 {
|
|
t.Fatalf("the compiled set breaks ADR 0287: %v", p)
|
|
}
|
|
for _, c := range []struct {
|
|
why string
|
|
set []catalogue.Seat
|
|
want string
|
|
}{
|
|
{"a mesh seat naming a path", []catalogue.Seat{{Name: "mesh-x", Scope: catalogue.ScopeMesh, Paths: []catalogue.SeatPath{{Path: "/etc/x"}}}},
|
|
"only a node seat names paths"},
|
|
{"a placeholder", []catalogue.Seat{{Name: "node-x", Scope: catalogue.ScopeNode, Paths: []catalogue.SeatPath{{Path: "/etc/${setting:x}"}}}},
|
|
"placeholder"},
|
|
{"a relative path", []catalogue.Seat{{Name: "node-x", Scope: catalogue.ScopeNode, Paths: []catalogue.SeatPath{{Path: "etc/x"}}}},
|
|
"not a clean absolute path"},
|
|
{"two owners", []catalogue.Seat{
|
|
{Name: "node-a", Scope: catalogue.ScopeNode, Paths: []catalogue.SeatPath{{Path: "/etc/x"}}},
|
|
{Name: "node-b", Scope: catalogue.ScopeNode, Paths: []catalogue.SeatPath{{Path: "/etc/x"}}}}, "no two seats name one path"},
|
|
{"a cycle", []catalogue.Seat{
|
|
{Name: "node-a", Scope: catalogue.ScopeNode, Paths: []catalogue.SeatPath{{Path: "/etc/x", StepsBackFor: "node-b"}}},
|
|
{Name: "node-b", Scope: catalogue.ScopeNode, Paths: []catalogue.SeatPath{{Path: "/etc/x", StepsBackFor: "node-a"}}}},
|
|
"does not own that path itself"},
|
|
{"stepping back for nobody", []catalogue.Seat{
|
|
{Name: "node-a", Scope: catalogue.ScopeNode, Paths: []catalogue.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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Every rule of the section itself, refused at parse in the words of the module.
|
|
func TestTheDataSectionIsRefusedWhereItIsWrong(t *testing.T) {
|
|
for _, c := range []struct{ name, data, want string }{
|
|
{"a class it does not know", `{"own":[{"id":"a","path":"${dir:d}","class":"precious"}]}`, "not one the mesh protects by"},
|
|
{"an access it does not have", `{"own":[{"id":"a","path":"${access:media}","class":"irreplaceable","redundancy":"an array"}]}`, "declares no access"},
|
|
{"irreplaceable and protected by nothing", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","backup":"none"}]}`, "protected one way or the other"},
|
|
{"a cache on redundancy", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","redundancy":"an array"}]}`, "a cache is not protected"},
|
|
{"kept with something not required", `{"kept-by":{"s3-bucket":{"class":"irreplaceable"}}}`, "does not require"},
|
|
{"a measure it does not know", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","measure":"du"}]}`, "walk, dataset or shallow"},
|
|
{"a cache expecting writes", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","active":"1d"}]}`, "only irreplaceable and valuable"},
|
|
{"a machine path", `{"own":[{"id":"a","path":"/srv/a","class":"cache"}]}`, "names no machine path"},
|
|
{"a directory it does not declare", `{"own":[{"id":"a","path":"${dir:nowhere}","class":"cache"}]}`, "declares no directory"},
|
|
{"a cache backed up", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","backup":"copy"}]}`, "disposable"},
|
|
{"a dump into nothing", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","backup":{"dump":"x","into":"b"}}]}`, "not one of the module's data items"},
|
|
{"a dump into itself", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","backup":{"dump":"x","into":"a"}}]}`, "into itself"},
|
|
{"a bad duration", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","within":"soon"}]}`, "not a duration"},
|
|
{"the same id twice", `{"own":[{"id":"a","path":"${dir:d}","class":"cache"},{"id":"a","path":"${dir:d}/x","class":"cache"}]}`, "declared twice"},
|
|
{"own data of class none", `{"own":[{"id":"a","path":"${dir:d}","class":"none"}]}`, "only a provision"},
|
|
{"consumers of something not provided", `{"consumers":{"s3-bucket":{"class":"cache"}}}`, "does not provide"},
|
|
{"consumer data that lives nowhere", `{"consumers":{"q":{"class":"irreplaceable"}}}`, "says nowhere it lives"},
|
|
{"consumer data in an item less precious", `{"own":[{"id":"a","path":"${dir:d}","class":"rebuildable"}],"consumers":{"q":{"class":"irreplaceable","in":"a"}}}`, "which is only rebuildable"},
|
|
{"consumer data in something unknown", `{"consumers":{"q":{"class":"rebuildable","in":"elsewhere"}}}`, "neither one of its data items"},
|
|
} {
|
|
raw := `{"module":"m","version":"1","provides":["q"],"resources":[{"id":"d","type":"directory","mode":"0700"}],"data":` + c.data + `}`
|
|
_, err := ParseManifest([]byte(raw))
|
|
if err == nil || !strings.Contains(err.Error(), c.want) {
|
|
t.Errorf("%s: %v, want %q", c.name, err, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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"] = catalogue.SettingDeclaration{Kind: catalogue.KindPreference, Default: "relay.acme.be", Why: "as at relay.acme.be"}
|
|
m.Settings["description"] = catalogue.SettingDeclaration{Kind: catalogue.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)
|
|
}
|
|
}
|
|
|
|
func TestTheMachinesReachIsAProvisionsOnlyReachAndIsNodeScoped(t *testing.T) {
|
|
for _, c := range []struct{ provides, want string }{
|
|
{`{"name":"x11-display","reach":"internal"}`, `with reach "internal"; a provision's reach is "machine" or nothing`},
|
|
{`{"name":"x11-display","scope":"mesh","reach":"machine"}`, `at scope "mesh" with the machine's reach`},
|
|
} {
|
|
_, err := ParseManifest([]byte(`{"module":"xorg","provides":[` + c.provides + `]}`))
|
|
if err == nil || !strings.Contains(err.Error(), c.want) {
|
|
t.Errorf("%s: want %q, got %v", c.provides, c.want, err)
|
|
}
|
|
}
|
|
m, err := ParseManifest([]byte(`{"module":"xorg","provides":[{"name":"x11-display","reach":"machine"}]}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !m.Provides[0].MachineReach() {
|
|
t.Fatal("the reach was not read")
|
|
}
|
|
back, _ := json.Marshal(m.Provides[0])
|
|
if string(back) != `{"name":"x11-display","reach":"machine"}` {
|
|
t.Errorf("written back as %s", back)
|
|
}
|
|
}
|
|
|
|
func theMeshsDirectoryForAModuleIsAPlace(t *testing.T) {
|
|
// novox/hq issue 174. What the mesh writes for a module — its bus credential, its bindings —
|
|
// is the mesh's plumbing under <root>/mesh/<module>, and the definition names it by id.
|
|
m := catalogue.Manifest{Module: "umami",
|
|
Resources: []map[string]any{
|
|
{"id": "mesh-state", "type": "directory", "place": "mesh"},
|
|
{"id": "state", "type": "directory", "place": "."},
|
|
{"id": "server", "type": "container", "image": "x@sha256:aa",
|
|
"volumes": []any{"${dir:mesh-state}/broker:/run/secrets/broker:ro"}},
|
|
},
|
|
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "${dir:mesh-state}/broker"}},
|
|
Binds: map[string]string{"route": "${dir:state}/route.json"},
|
|
}
|
|
if got := manifestUnknownDirRefs(m); len(got) != 0 {
|
|
t.Fatalf("place %q is a place; got %v", "mesh", got)
|
|
}
|
|
dirs := dirsFor(m, catalogue.Rendering{})
|
|
if dirs["mesh-state"] != "/var/lib/mesh/umami" || dirs["state"] != "/var/lib/umami" {
|
|
t.Fatalf("the mesh's directory sits beside the module's, not in it; got %v", dirs)
|
|
}
|
|
dirs = dirsFor(m, catalogue.Rendering{DataRoot: "/srv"})
|
|
if dirs["mesh-state"] != "/srv/mesh/umami" {
|
|
t.Fatalf("a node's root moves the mesh's files with the module's; got %v", dirs)
|
|
}
|
|
|
|
}
|
|
|
|
// 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 := catalogue.Manifest{Module: "nats",
|
|
Provides: []catalogue.Offer{{Name: "mesh-bus", Scope: catalogue.ScopeMesh, Credential: &catalogue.OfferCredential{Form: catalogue.CredentialFormNone}}},
|
|
Claims: []catalogue.Claim{{Name: "mesh-broker", Scope: catalogue.ScopeMesh}}}
|
|
if got := problemsFor(t, catalogue.Shelf{"nats": m}); got != "" {
|
|
t.Fatalf("a mesh seat was refused by the derived check: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestTheModuleCheckRefusesAnOptionalPlaceholderThatCouldRenderEmpty(t *testing.T) {
|
|
refused := map[string]string{
|
|
"a placeholder without a default": `{"id":"ds","type":"file","path":"/a","mode":"0644",
|
|
"content":"url: ${bound:influxdb-api:at}"}`,
|
|
"a secret outside an only-when-bound file": `{"id":"s","type":"file","path":"/a","mode":"0400",
|
|
"content":"${secret:influxdb-api}"}`,
|
|
"only-when-bound on a hard requirement": `{"id":"r","type":"file","path":"/a","mode":"0644",
|
|
"content":"x","only-when-bound":"route"}`,
|
|
"only-when-bound on something not required": `{"id":"r","type":"file","path":"/a","mode":"0644",
|
|
"content":"x","only-when-bound":"mqtt-topic"}`,
|
|
"only-when-bound on a directory": `{"id":"d","type":"directory","mode":"0700",
|
|
"only-when-bound":"influxdb-api"}`,
|
|
"a default on a hard requirement": `{"id":"r","type":"file","path":"/a","mode":"0644",
|
|
"content":"${bound:route:name|default:x}"}`,
|
|
}
|
|
for what, resource := range refused {
|
|
if _, err := ParseManifest([]byte(optionalModule(resource))); err == nil {
|
|
t.Errorf("%s passed the module check", what)
|
|
} else if what == "a placeholder without a default" && !strings.Contains(err.Error(), "|default:") {
|
|
t.Errorf("the refusal does not say how to write it: %v", err)
|
|
}
|
|
}
|
|
accepted := map[string]string{
|
|
"a placeholder with a default": `{"id":"ds","type":"file","path":"/a","mode":"0644",
|
|
"content":"url: ${bound:influxdb-api:at|default:none}"}`,
|
|
"a placeholder and a secret inside an only-when-bound file": `{"id":"ds","type":"file","path":"/a",
|
|
"mode":"0400","content":"url: ${bound:influxdb-api:at}\npassword: ${secret:influxdb-api}",
|
|
"only-when-bound":"influxdb-api"}`,
|
|
}
|
|
for what, resource := range accepted {
|
|
if _, err := ParseManifest([]byte(optionalModule(resource))); err != nil {
|
|
t.Errorf("%s was refused: %v", what, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTheNewRulesAreHeldToWhatTheSeatSays(t *testing.T) {
|
|
m := catalogue.Manifest{Module: "x", DefinesSeats: []catalogue.SeatDeclaration{{Name: "thing", Accepts: []string{"do"},
|
|
ByCaller: []string{"undo"}, Proofs: []string{"code"}, Records: []string{"nothing"}}}}
|
|
got := strings.Join(declaredSeatProblems(m), "; ")
|
|
for _, want := range []string{"names thing.undo by its caller, which the seat neither accepts nor emits",
|
|
"declares proofs on thing, which is not kinded", `read its records "nothing", which it keeps no state of`} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("not refused: %q in %s", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// **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 := catalogue.Seats()
|
|
t.Cleanup(func() { catalogue.UseSeats(was) })
|
|
|
|
// A store whose bus seat delivers something this module does provide.
|
|
catalogue.UseSeats([]catalogue.Seat{{Name: "mesh-broker", Scope: catalogue.ScopeMesh, Delivers: "mesh-bus", Decision: "test"}})
|
|
raw := []byte(`{"module":"a-bus","version":"1",` +
|
|
`"provides":[{"name":"mesh-bus","scope":"mesh","credential":{"form":"none"}}],` +
|
|
`"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(catalogue.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.
|
|
catalogue.UseSeats([]catalogue.Seat{{Name: "mesh-broker", Scope: catalogue.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(catalogue.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)
|
|
}
|
|
}
|
|
|
|
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 catalogue.SettingDeclaration
|
|
refuse string
|
|
}{
|
|
{"no kind", "font-size", catalogue.SettingDeclaration{Default: float64(10), Why: "x"}, `only a "preference" has a default`},
|
|
{"another kind", "font-size", catalogue.SettingDeclaration{Kind: "operator", Default: float64(10), Why: "x"}, `only a "preference"`},
|
|
{"no default", "font-size", catalogue.SettingDeclaration{Kind: catalogue.KindPreference, Why: "x"}, "no default"},
|
|
{"an empty default", "font-size", catalogue.SettingDeclaration{Kind: catalogue.KindPreference, Default: " ", Why: "x"}, "an empty default"},
|
|
{"an object", "font-size", catalogue.SettingDeclaration{Kind: catalogue.KindPreference, Default: map[string]any{"a": 1.0}, Why: "x"}, "a string, a number or a boolean"},
|
|
{"no why", "font-size", catalogue.SettingDeclaration{Kind: catalogue.KindPreference, Default: float64(10)}, "no why"},
|
|
{"the operator's", "mail-domain", catalogue.SettingDeclaration{Kind: catalogue.KindPreference, Default: "example.tld", Why: "x"}, "is the operator's value"},
|
|
{"a secret", "api-token", catalogue.SettingDeclaration{Kind: catalogue.KindPreference, Default: "x", Why: "x"}, "is the operator's value"},
|
|
{"the mesh's word", catalogue.PortsSetting, catalogue.SettingDeclaration{Kind: catalogue.KindPreference, Default: float64(1), Why: "x"}, "the mesh reads"},
|
|
{"read by nothing", "height", catalogue.SettingDeclaration{Kind: catalogue.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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A refusal that reorders itself between runs is a refusal nobody can diff.
|
|
func TestTheProblemsAreStable(t *testing.T) {
|
|
shelf := catalogue.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)
|
|
}
|
|
}
|
|
|
|
// 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 := catalogue.Manifest{Module: "reads", RunsAs: "mesh-reads", Claims: []catalogue.Claim{{Name: catalogue.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)
|
|
}
|
|
}
|
|
|
|
// The controller refuses a definition's resolved path by the node-engine's own rules, no more (novox/hq issue
|
|
// 496): the same lists, read here from mesh-host's internal/apply/placement_guard.go — in a merge check the clone
|
|
// beside it at the commit the mesh runs, elsewhere the copy captured in testdata/beside. When the engine's lists
|
|
// move, this fails until the controller's move with them, so the gate never refuses what the engine applies nor
|
|
// passes what it refuses by path alone.
|
|
func TestTheResolvedPathRulesAreTheNodeEnginesOwn(t *testing.T) {
|
|
file := filepath.Join(beside.Dir(t, "mesh-host"), "internal", "apply", "placement_guard.go")
|
|
parsed, err := parser.ParseFile(token.NewFileSet(), file, nil, 0)
|
|
if err != nil {
|
|
t.Fatalf("the node-engine's guard does not parse: %v", err)
|
|
}
|
|
lists := map[string][]string{}
|
|
consts := map[string]string{}
|
|
ast.Inspect(parsed, func(n ast.Node) bool {
|
|
spec, ok := n.(*ast.ValueSpec)
|
|
if !ok {
|
|
return true
|
|
}
|
|
for i, name := range spec.Names {
|
|
if i >= len(spec.Values) {
|
|
continue
|
|
}
|
|
switch v := spec.Values[i].(type) {
|
|
case *ast.CompositeLit:
|
|
for _, elt := range v.Elts {
|
|
if lit, ok := elt.(*ast.BasicLit); ok && lit.Kind == token.STRING {
|
|
s, _ := strconv.Unquote(lit.Value)
|
|
lists[name.Name] = append(lists[name.Name], s)
|
|
}
|
|
}
|
|
case *ast.BasicLit:
|
|
if v.Kind == token.STRING {
|
|
consts[name.Name], _ = strconv.Unquote(v.Value)
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
for name, ours := range map[string][]string{
|
|
"protectedRoots": protectedRoots, "forbiddenBelow": forbiddenBelow, "engineTrees": engineTrees,
|
|
} {
|
|
theirs := lists[name]
|
|
if len(theirs) == 0 {
|
|
t.Errorf("the node-engine's guard names no %s any more; read it and say where its rule went", name)
|
|
continue
|
|
}
|
|
a, b := slices.Clone(ours), slices.Clone(theirs)
|
|
slices.Sort(a)
|
|
slices.Sort(b)
|
|
if !slices.Equal(a, b) {
|
|
t.Errorf("%s differs from the node-engine's:\n controller %v\n node-engine %v", name, a, b)
|
|
}
|
|
}
|
|
if consts["engineModule"] != engineModule {
|
|
t.Errorf("the node-engine's own module is %q there and %q here", consts["engineModule"], engineModule)
|
|
}
|
|
}
|
|
|
|
func TestTheRuntimeIsToldTheAgentAccount(t *testing.T) {
|
|
with := catalogue.Rendering{ArtifactStore: "anchor.internal:5101",
|
|
Needed: map[string]map[string]string{catalogue.RuntimeModule: {"broker": "sealed-credential"}}}
|
|
envOf := func(r catalogue.Resolution) map[string]string {
|
|
t.Helper()
|
|
r.Node, r.Modules = "anchor", []catalogue.Manifest{aToolsModule(t, "nftables", "tools/index.js"), theRuntime(t)}
|
|
out, err := r.Declaration(with)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
process := fileNamed(out, catalogue.RuntimeModule+"."+catalogue.RuntimeProcessID())
|
|
if process == nil {
|
|
t.Fatal("no runtime process was composed")
|
|
}
|
|
return process["env"].(map[string]string)
|
|
}
|
|
env := envOf(catalogue.Resolution{Account: "ops", AgentAccount: "agent"})
|
|
if env[catalogue.RuntimeAgentAccount] != "agent" || env[catalogue.RuntimeAgentHome] != "/home/agent" || env[catalogue.RuntimeOperatorAccount] != "ops" {
|
|
t.Errorf("the runtime is not told whom agents run as: %v", env)
|
|
}
|
|
env = envOf(catalogue.Resolution{Account: "ops"})
|
|
if env[catalogue.RuntimeAgentAccount] != "ops" || env[catalogue.RuntimeAgentHome] != "/home/ops" {
|
|
t.Errorf("with no agent account, agents run as the operator: %v", env)
|
|
}
|
|
env = envOf(catalogue.Resolution{})
|
|
if _, set := env[catalogue.RuntimeAgentAccount]; set {
|
|
t.Errorf("a machine with no account names an agent account: %v", env)
|
|
}
|
|
if problems := bundleEnvProblems("x", catalogue.Artifact{Name: "b", Kind: catalogue.ArtifactBundle, Loads: []string{"x"},
|
|
Env: map[string]string{catalogue.RuntimeAgentAccount: "me"}}); len(problems) == 0 {
|
|
t.Error("a bundle may tell the runtime whom agents run as")
|
|
}
|
|
}
|
|
|
|
// The machine's facility — the container runtime's socket — is granted by the capability that names
|
|
// it, and declaring it as the module's own directory would be a lie the host would act on. This is
|
|
// the case the first version of this check refused, and was withdrawn for: the builder.
|
|
func TestTheRuntimeSocketIsGrantedByTheCapabilityAndNotOtherwise(t *testing.T) {
|
|
granted := `{"module":"builder","capabilities":["container-runtime"],"resources":[` +
|
|
strings.Replace(aContainerMounting, "%s", "/var/run/docker.sock", 1) + `]}`
|
|
if _, err := ParseManifest([]byte(granted)); err != nil {
|
|
t.Fatalf("a module with the container-runtime capability may mount its socket: %v", err)
|
|
}
|
|
if _, err := ParseManifest(manifestMounting("/var/run/docker.sock", "")); err == nil {
|
|
t.Fatal("a module mounted the container runtime's socket without declaring the capability, and was accepted")
|
|
}
|
|
}
|
|
|
|
// 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 catalogue.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 catalogue.ScopeNode, catalogue.ScopeSite, catalogue.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-seven with mesh-placement (novox/hq ADR 0567), serving its five verbs: adopt and hand-back went with the
|
|
// controller (novox/hq ADR 0590, issue 1126).
|
|
if s, ok := catalogue.SeatNamed(catalogue.PlacementSeat); !ok || s.Scope != catalogue.ScopeMesh || !reflect.DeepEqual(catalogue.VerbNames(s.Serves),
|
|
[]string{"placement", "proposals", "propose", "withdraw", "again"}) {
|
|
t.Errorf("the placement seat is %v, %v", ok, s)
|
|
}
|
|
// The checker's seat serves the try, which replaces the shell's merge check (ADR 0547 §10, ADR 0245).
|
|
var checkerVerbs []string
|
|
for _, s := range catalogue.Seats() {
|
|
if s.Name == catalogue.CheckerSeat {
|
|
for _, v := range s.Serves {
|
|
checkerVerbs = append(checkerVerbs, v.Name)
|
|
if v.Name == "try" && strings.Join(v.Replaces, "|") != "sh merge-check.sh|docker run postgres" {
|
|
t.Errorf("try replaces %v", v.Replaces)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if strings.Join(checkerVerbs, ",") != "try,try-read,try-end" {
|
|
t.Errorf("the checker seat serves %v", checkerVerbs)
|
|
}
|
|
// The four health seats (novox/hq ADR 0564 clause 1): at mesh scope, each held once, each naming ADR 0564, with the
|
|
// verbs and events the record names; the events keep the controller's names (clause 3).
|
|
for _, c := range []struct{ seat, verbs, emits string }{
|
|
{catalogue.ConditionsSeat, "conditions,silence,resync", "condition-raised,condition-changed,condition-cleared"},
|
|
{catalogue.WatchdogsSeat, "signals,stuck,presence,dead-letters,resync", ""},
|
|
{catalogue.SelfCheckSeat, "doctor,probes", "checked"},
|
|
{catalogue.HealersSeat, "healers,heal,drill", "healer-acted"},
|
|
// And mesh-config's config; its switch went with the controller (ADR 0590 clause 3).
|
|
{"mesh-config", "config", ""},
|
|
// And ADR 0565 clause 1's two.
|
|
{catalogue.WantsSeat, "upgrade,bus", "policy-changed"},
|
|
{catalogue.HandActsSeat, "hand-act,hand-acts,warranted", "act-recorded"},
|
|
// ADR 0571 clause 2: the artifact store's sweep.
|
|
{catalogue.ArtifactSweepSeat, "artifacts,held,collect,mirrors,images", "artifacts-gone"},
|
|
} {
|
|
s, ok := catalogue.SeatNamed(c.seat)
|
|
if !ok || s.Scope != catalogue.ScopeMesh || s.Replicated || s.Delivers != "" || !strings.Contains(s.Decision, "ADR 05") ||
|
|
strings.Join(catalogue.VerbNames(s.Serves), ",") != c.verbs || strings.Join(s.Emits, ",") != c.emits {
|
|
t.Errorf("the health seat %s is %v, %+v", c.seat, ok, s)
|
|
}
|
|
}
|
|
// The node seat node-engine is the node's engine's (novox/hq ADR 0563 §1): at node scope, delivering nothing, its verbs
|
|
// (compose-candidate since ADR 0593 §7) and three events, held by no assignment, and a module's claim of it refused.
|
|
if engine, ok := catalogue.SeatNamed(catalogue.EngineSeat); !ok || engine.Scope != catalogue.ScopeNode || engine.Delivers != "" || !engine.HeldByTheEngine {
|
|
t.Errorf("node-engine is %+v, not a node seat delivering nothing that the engine holds", engine)
|
|
} else {
|
|
var verbs []string
|
|
for _, v := range engine.Serves {
|
|
verbs = append(verbs, v.Name)
|
|
}
|
|
// acts reads one act by its id too (ADR 0563 §9: the seat's tools are its command line).
|
|
if props, _ := engine.Serves[5].Input["properties"].(map[string]any); props["id"] == nil {
|
|
t.Errorf("acts takes no id: %v", engine.Serves[5].Input)
|
|
}
|
|
if strings.Join(verbs, ",") != "hand-over,access-act,judge-directories,setuid-search,root-free,acts,packages,composition,compose,compose-candidate" ||
|
|
strings.Join(engine.Emits, ",") != "asked,done,refused" {
|
|
t.Errorf("node-engine serves %v and emits %v", verbs, engine.Emits)
|
|
}
|
|
}
|
|
if _, err := ParseManifest([]byte(`{"module":"thing","version":"1","claims":[{"name":"node-engine","scope":"node"}]}`)); err == nil ||
|
|
!strings.Contains(err.Error(), "node-engine") {
|
|
t.Errorf("a module's claim of node-engine was not refused: %v", err)
|
|
}
|
|
// Forty-six with the three build seats replacing node-build-agent and the retired mesh-build-machine (novox/hq
|
|
// ADR 0525 §2, §10); 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.
|
|
// Forty-eight with mesh-delivery-checker (novox/hq ADR 0547 §10, issue 774); forty-nine with node-engine (novox/hq ADR 0563 §1);
|
|
// fifty-three with the four health seats (novox/hq ADR 0564 clause 1); fifty-five with mesh-wants and mesh-hand-acts
|
|
// (novox/hq ADR 0565 clause 1); fifty-six with mesh-artifact-sweep (novox/hq ADR 0571 clause 2);
|
|
// fifty-seven with mesh-grants (ADR 0582 clause 1); fifty-eight with mesh-enrolment (novox/hq ADR 0587 §1); fifty-six
|
|
// without the controller's seat and mesh-grants, which served its lease write alone (novox/hq ADR 0590, issue 1126).
|
|
if len(catalogue.Seats()) != 56 {
|
|
t.Errorf("the mesh defines %d seats rather than 56; the set is closed, so a change here is "+
|
|
"a decision (novox/hq ADR 0110): %s", len(catalogue.Seats()), seatNames())
|
|
}
|
|
}
|
|
|
|
// §4: xinitrc and xresources slots, placed by the display server's holder alone.
|
|
func TestTheSessionsFilesArePlacedByTheDisplayServersHolderAlone(t *testing.T) {
|
|
xorg := catalogue.Manifest{Module: "xorg", Claims: []catalogue.Claim{{Name: catalogue.DisplayServerSeat}},
|
|
Shell: []catalogue.ShellCode{{For: "xinitrc", Slot: "first", Code: "xset s off"}},
|
|
Resources: []map[string]any{
|
|
{"id": "xinitrc", "type": "file", "path": "/home/op/.xinitrc",
|
|
"content": "${shell:xinitrc:first}${shell:xinitrc:normal}${shell:xinitrc:last}"},
|
|
{"id": "xresources", "type": "file", "path": "/home/op/.Xresources",
|
|
"content": "${shell:xresources:normal}"},
|
|
}}
|
|
i3 := catalogue.Manifest{Module: "i3", Shell: []catalogue.ShellCode{{For: "xinitrc", Slot: "last", Code: "exec i3"}}}
|
|
theme := catalogue.Manifest{Module: "theme", Shell: []catalogue.ShellCode{
|
|
{For: "xresources", Slot: "normal", Code: "Xft.dpi: 96"},
|
|
{For: "zsh", Slot: "normal", Code: "not for the session"},
|
|
}}
|
|
r := catalogue.Resolution{Node: "workstation", Account: "op", Modules: []catalogue.Manifest{xorg, i3, theme}}
|
|
out, err := r.Declaration(catalogue.Rendering{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
by := map[string]any{}
|
|
for _, res := range out {
|
|
by[res["id"].(string)] = res["content"]
|
|
}
|
|
if got := by["xorg.xinitrc"]; got != "# xorg\nxset s off\n# i3\nexec i3\n" {
|
|
t.Errorf("the .xinitrc is %q", got)
|
|
}
|
|
if got := by["xorg.xresources"]; got != "# theme\nXft.dpi: 96\n" {
|
|
t.Errorf("the .Xresources is %q", got)
|
|
}
|
|
|
|
// The contributions parse; the placeholders parse only in the holder.
|
|
if _, err := ParseManifest([]byte(`{"module":"i3","shell":[{"for":"xinitrc","slot":"last","code":"exec i3"},` +
|
|
`{"for":"xresources","slot":"normal","code":"i3.font: x"}]}`)); err != nil {
|
|
t.Fatalf("a session contribution was refused: %v", err)
|
|
}
|
|
for _, c := range []struct{ claims, content, want string }{
|
|
{``, "${shell:xinitrc:normal}", "does not claim node-display-server; every module's xinitrc is placed by the display server's holder alone"},
|
|
{`{"name":"node-login-shell"}`, "${shell:xresources:normal}", "does not claim node-display-server"},
|
|
{`{"name":"node-display-server"}`, "${shell:zsh:normal}", "does not claim node-login-shell"},
|
|
{`{"name":"node-display-server"}`, "${shell:xsession:normal}", "the session's file one of xinitrc, xresources"},
|
|
} {
|
|
raw := `{"module":"holder","claims":[` + c.claims + `],"resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` +
|
|
c.content + `"}]}`
|
|
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) {
|
|
t.Errorf("%s with claims [%s]: want %q, got %v", c.content, c.claims, c.want, err)
|
|
}
|
|
}
|
|
if _, err := ParseManifest([]byte(`{"module":"xorg","claims":[{"name":"node-display-server"}],` +
|
|
`"resources":[{"id":"rc","type":"file","path":"/home/op/.xinitrc","content":"${shell:xinitrc:last}"}]}`)); err != nil {
|
|
t.Errorf("the display server's holder could not place the session's slots: %v", err)
|
|
}
|
|
}
|
|
|
|
// The stored form reads on a controller older than optional requirements (review of mesh-controller
|
|
// #281). Inventory.Catalogue stops at the first manifest it cannot read, so a long-form entry inside
|
|
// `requires` would have stopped every plan after a rollback. Stored, `requires` is plain names, as main's
|
|
// manifest type reads it, and which are optional sits beside it in a key such a controller sets aside.
|
|
func TestTheStoredFormReadsWhereRequiresIsAListOfNames(t *testing.T) {
|
|
m := mustParse(t, `{"module":"grafana","version":"1",
|
|
"requires":["route",{"name":"influxdb-api","optional":true}]}`)
|
|
raw, err := json.Marshal(m)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// main's field, exactly: `Requires []string`.
|
|
var older struct {
|
|
Requires []string `json:"requires"`
|
|
}
|
|
if err := json.Unmarshal(raw, &older); err != nil {
|
|
t.Fatalf("a controller reading requires as names cannot read the stored form: %v\n%s", err, raw)
|
|
}
|
|
if strings.Join(older.Requires, " ") != "route influxdb-api" {
|
|
t.Fatalf("it reads the requirements as %v", older.Requires)
|
|
}
|
|
var keys map[string]json.RawMessage
|
|
if err := json.Unmarshal(raw, &keys); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if string(keys[catalogue.OptionalRequiresKey]) != `["influxdb-api"]` {
|
|
t.Fatalf("which are optional is stored as %s", keys[catalogue.OptionalRequiresKey])
|
|
}
|
|
// And this controller reads the stored form back the same, leniently and strictly.
|
|
var back catalogue.Manifest
|
|
if err := json.Unmarshal(raw, &back); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
strict, err := ParseManifest(raw)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, got := range []catalogue.Manifest{back, strict} {
|
|
if !got.IsOptional("influxdb-api") || got.IsOptional("route") || len(got.Requires) != 2 {
|
|
t.Fatalf("read back as %v optional %v", got.Requires, got.OptionalRequires)
|
|
}
|
|
}
|
|
// A stored list naming something the module does not require is refused.
|
|
if _, err := ParseManifest([]byte(`{"module":"x","version":"1","requires":["route"],"optional-requires":["mqtt-topic"]}`)); err == nil {
|
|
t.Error("an optional requirement the module does not require passed the check")
|
|
}
|
|
}
|
|
|
|
// 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 := catalogue.SeatNamed("node-uplink")
|
|
if !known {
|
|
t.Fatalf("the uplink is not a seat; the seats are: %s", seatNames())
|
|
}
|
|
if seat.Scope != catalogue.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 TestTheWorldsNamesAreNotAnInstallations(t *testing.T) {
|
|
m := catalogue.Manifest{Module: "resolver", Resources: []map[string]any{
|
|
{"id": "conf", "type": "file", "content": "server=1.1.1.1\nserver=8.8.8.8\nlisten=127.0.0.55\n"},
|
|
{"id": "proxy", "type": "container", "image": "docker.io/library/traefik@sha256:bb"},
|
|
{"id": "adapter", "type": "file", "content": "{\"machine\": \"host.docker.internal\"}\n"},
|
|
{"id": "doc", "type": "file", "content": "root = https://git.example.tld/\n"},
|
|
}}
|
|
if got := InstallationProblems(m); len(got) != 0 {
|
|
t.Fatalf("the world's names were reported: %v", got)
|
|
}
|
|
}
|
|
|
|
func TestTwoArtifactsWithOneNameAreRefused(t *testing.T) {
|
|
_, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
|
|
{"name":"x","kind":"image","from":"Dockerfile"},
|
|
{"name":"x","kind":"archive","from":"files"}]}}`))
|
|
if err == nil {
|
|
t.Fatal("two artifacts with one name were accepted")
|
|
}
|
|
if !strings.Contains(err.Error(), "could mean either") {
|
|
t.Fatalf("unhelpful refusal: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTwoChannelsOfDifferentKindsHoldTheBenches(t *testing.T) {
|
|
shelf := catalogue.Shelf{"messenger": router(), "telegram": aChannel("telegram", "telegram"),
|
|
"desk-channel": aChannel("desk-channel", "desktop")}
|
|
if got := problemsFor(t, shelf); got != "" {
|
|
t.Fatalf("two kinds were refused: %s", got)
|
|
}
|
|
for _, m := range shelf {
|
|
if got := declaredSeatProblems(m); len(got) > 0 {
|
|
t.Fatalf("%s: %v", m.Module, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
// **Two endpoints called the same would make an assignment configure whichever was read last.** The
|
|
// point of a name is that it identifies one thing.
|
|
func TestTwoEndpointsWithOneNameAreRefused(t *testing.T) {
|
|
m := catalogue.Manifest{Module: "twice", Listens: []catalogue.Listening{
|
|
{Name: "web", Port: 80, From: catalogue.FromMesh},
|
|
{Name: "web", Port: 8080, From: catalogue.FromMesh},
|
|
}}
|
|
got := strings.Join(endpointNameProblems(m), "\n")
|
|
if !strings.Contains(got, "could mean either") {
|
|
t.Fatalf("two endpoints with one name were accepted:\n%s", got)
|
|
}
|
|
}
|
|
|
|
// A stream name has one owner across the catalogue (ADR 0363 §1).
|
|
func TestTwoModulesCannotDeclareOneStream(t *testing.T) {
|
|
a := catalogue.Manifest{Module: "a", Streams: []catalogue.StreamDeclaration{{Name: "mail", Events: []string{"x"}}}}
|
|
b := catalogue.Manifest{Module: "b", Streams: []catalogue.StreamDeclaration{{Name: "mail", Events: []string{"y"}}}}
|
|
got := strings.Join(CatalogueProblems(catalogue.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(catalogue.Shelf{"a": a}); len(got) != 0 {
|
|
t.Fatalf("one owner refused: %v", got)
|
|
}
|
|
// And a stream of its own is outside the bus's predefined forms (the lead, 2026-10-29): refused, naming it, unless it
|
|
// is one kept by name until its module moves it.
|
|
if !strings.Contains(got, `a keeps the stream "mail", outside the bus's predefined forms`) {
|
|
t.Fatalf("a stream of its own was not refused: %s", got)
|
|
}
|
|
if got := PredefinedFormProblems(catalogue.Manifest{Module: "mesh-mail", Streams: a.Streams}); len(got) != 0 {
|
|
t.Fatalf("a stream kept until it moves was refused: %v", got)
|
|
}
|
|
}
|
|
|
|
// A seat name meaning two protocols is the failure nobody could diagnose afterwards.
|
|
func TestTwoModulesCannotDeclareTheSameSeat(t *testing.T) {
|
|
other := catalogue.Manifest{Module: "aardvark", DefinesSeats: []catalogue.SeatDeclaration{{
|
|
Name: "telegram-sender", Scope: catalogue.ScopeMesh, Accepts: []string{"something-else"}}}}
|
|
got := problemsFor(t, catalogue.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. And a seat verb a module invokes is one a holder serves (issue 774, sweep item 1: messenger
|
|
// invoked a verb nothing answered, and Telegram held every ask silently).
|
|
func TestUsingASeatNobodyDeclaresIsRefused(t *testing.T) {
|
|
unclaimed := telegram()
|
|
unclaimed.Claims = nil
|
|
for _, row := range []struct {
|
|
name, want string
|
|
shop catalogue.Manifest
|
|
seat catalogue.Manifest
|
|
note bool // over some manifests alone it is a note (IsUndeclaredSeat)
|
|
}{
|
|
{"a misspelled seat used", `uses the seat "telegram-sendr", ` + catalogue.UndeclaredSeat,
|
|
catalogue.Manifest{Module: "shop", Uses: []string{"telegram-sendr"}}, telegram(), true},
|
|
{"a declared verb invoked of a held seat", "", catalogue.Manifest{Module: "shop", Invokes: []string{"seat:telegram-sender.status"}}, telegram(), false},
|
|
{"a verb the seat does not promise", `the seat telegram-sender serves no verb "resend"`,
|
|
catalogue.Manifest{Module: "shop", Invokes: []string{"seat:telegram-sender.resend"}}, telegram(), false},
|
|
{"a verb of a misspelled seat", `a verb of the seat "telegram-sendr", ` + catalogue.UndeclaredSeat,
|
|
catalogue.Manifest{Module: "shop", Invokes: []string{"seat:telegram-sendr.status"}}, telegram(), true},
|
|
{"a verb of a declared seat nobody claims", `a verb of the seat "telegram-sender", ` + catalogue.UnheldSeat,
|
|
catalogue.Manifest{Module: "shop", Invokes: []string{"seat:telegram-sender.status"}}, unclaimed, true},
|
|
{"a verb of a mesh seat whose holder lives in another repository", "",
|
|
catalogue.Manifest{Module: "shop", Invokes: []string{"seat:mesh-watchdogs.signals"}}, telegram(), false},
|
|
{"a verb a mesh seat does not promise", `the seat mesh-placement serves no verb "adopt"`,
|
|
catalogue.Manifest{Module: "shop", Invokes: []string{"seat:mesh-placement.adopt"}}, telegram(), false},
|
|
{"the controller's seat is gone (ADR 0590)", `a verb of the seat "mesh-controller", ` + catalogue.UndeclaredSeat,
|
|
catalogue.Manifest{Module: "shop", Invokes: []string{"seat:mesh-controller.root-free"}}, telegram(), true},
|
|
{"the engine's verb: every node's engine holds its seat", "",
|
|
catalogue.Manifest{Module: "shop", Invokes: []string{"seat:node-engine.root-free"}}, telegram(), false},
|
|
{"a verb the engine's seat does not promise", `the seat node-engine serves no verb "root-freed"`,
|
|
catalogue.Manifest{Module: "shop", Invokes: []string{"seat:node-engine.root-freed"}}, telegram(), false},
|
|
} {
|
|
got := CatalogueProblems(catalogue.Shelf{"telegram": row.seat, "shop": row.shop})
|
|
joined := strings.Join(got, "; ")
|
|
if row.want == "" && joined != "" || row.want != "" && (len(got) != 1 || !strings.Contains(joined, row.want) ||
|
|
catalogue.IsUndeclaredSeat(got[0]) != row.note) {
|
|
t.Errorf("%s: %s", row.name, joined)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWhatAHandedModuleCannotHaveFromTheVaultIsRefused(t *testing.T) {
|
|
// A template naming a credential the module requires: a binding's, which moves with its provider.
|
|
requires := strings.Replace(botMoved(), `"own-secrets":`, `"requires":["database"],"secrets":{"database":"/etc/bot/db"},"own-secrets":`, 1)
|
|
r, err := catalogue.Resolve(shelf(held(t, requires, builtBot("db = ${secret:database}\n")),
|
|
catalogue.Manifest{Module: "db", Version: "1", Provides: []catalogue.Offer{{Name: "database", Scope: catalogue.ScopeNode}}}),
|
|
[]string{"bot"}, workstation(), catalogue.World{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r.Account = "op"
|
|
if _, err := r.Compose(handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "moves with its provider") {
|
|
t.Errorf("a template naming a required credential: %v", err)
|
|
}
|
|
// A file with its text inline naming a secret, in a handed module.
|
|
inline := strings.Replace(botMoved(), `{"id":"daemon"`,
|
|
`{"id":"legacy","type":"file","path":"/etc/bot/legacy","content":"t = ${secret:token}\n"},{"id":"daemon"`, 1)
|
|
r = notifierOn(t, held(t, inline, builtBot(botText)))
|
|
if _, err := r.Compose(handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "keep it in a template") {
|
|
t.Errorf("an inline file naming a secret in a handed module: %v", err)
|
|
}
|
|
// A family and a secret issued outside the mesh are not handed by name yet: the mark is refused.
|
|
for why, raw := range map[string]string{
|
|
"a family": strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`, `"own-secrets":{"token":"${dir:state}/token","key-*":"${dir:state}/keys"}`, 1),
|
|
"issued outside": strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`, `"own-secrets":{"token":{"path":"${dir:state}/token","issued-by":"outside"}}`, 1),
|
|
} {
|
|
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "design 57 V4") {
|
|
t.Errorf("a moved module with %s: %v", why, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWhatReplacesMayNameIsRefusedNearItsAuthor(t *testing.T) {
|
|
for what, resource := range map[string]string{
|
|
"a container": `{"id":"c","type":"container","name":"c","image":"x@` + aServiceDigest + `","replaces":["old"]}`,
|
|
"a step": `{"id":"p","type":"process","name":"p","run":["./p"],"run-once":true,"replaces":["old"]}`,
|
|
"something declared": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["p"]}`,
|
|
"another module's": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["other.old"]}`,
|
|
"not a list": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":"old"}`,
|
|
} {
|
|
raw := `{"module":"m","version":"1","resources":[` + resource + `]}`
|
|
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "replace") {
|
|
t.Errorf("replaces on %s was accepted: %v", what, err)
|
|
}
|
|
}
|
|
ok := `{"module":"m","version":"1","resources":[{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["old"]}]}`
|
|
if _, err := ParseManifest([]byte(ok)); err != nil {
|
|
t.Errorf("a process replacing what its module no longer declares was refused: %v", err)
|
|
}
|
|
}
|
|
|
|
// 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 catalogue.Manifest
|
|
policy, from string
|
|
}{
|
|
{"default", catalogue.Manifest{Module: "app"}, catalogue.PolicyRoll, catalogue.FromDefault},
|
|
{"says record", catalogue.Manifest{Module: "db", Upgrade: &catalogue.UpgradePolicy{Policy: catalogue.PolicyRecord, Why: "a restart costs"}},
|
|
catalogue.PolicyRecord, catalogue.FromModule},
|
|
{"says together", catalogue.Manifest{Module: "dns", Upgrade: &catalogue.UpgradePolicy{Policy: catalogue.PolicyTogether, Why: "one zone"}},
|
|
catalogue.PolicyTogether, catalogue.FromModule},
|
|
{"irreplaceable data", catalogue.Manifest{Module: "media", Data: &catalogue.Data{Own: []catalogue.DataItem{{ID: "library", Class: catalogue.ClassIrreplaceable}}}},
|
|
catalogue.PolicyRecord, catalogue.FromData},
|
|
{"valuable data rolls", catalogue.Manifest{Module: "notes", Data: &catalogue.Data{Own: []catalogue.DataItem{{ID: "db", Class: catalogue.ClassValuable}}}},
|
|
catalogue.PolicyRoll, catalogue.FromDefault},
|
|
{"irreplaceable but says roll", catalogue.Manifest{Module: "photos", Upgrade: &catalogue.UpgradePolicy{Policy: catalogue.PolicyRoll},
|
|
Data: &catalogue.Data{Own: []catalogue.DataItem{{ID: "originals", Class: catalogue.ClassIrreplaceable}}}}, catalogue.PolicyRoll, catalogue.FromModule},
|
|
{"the bus, whatever it says", catalogue.Manifest{Module: "nats", Provides: []catalogue.Offer{{Name: "mesh-bus"}},
|
|
Upgrade: &catalogue.UpgradePolicy{Policy: catalogue.PolicyRoll}}, catalogue.PolicyRecord, catalogue.FromBus},
|
|
}
|
|
for _, c := range cases {
|
|
policy, from, _ := catalogue.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)
|
|
}
|
|
// ADR 0565 clause 7 (issue 1953 L5-S3): a person's choice wins over each source but the bus; none leaves the
|
|
// derivation as it was.
|
|
person := &catalogue.PersonPolicy{V: catalogue.PersonPolicyVersion, Module: c.m.Module, Policy: catalogue.PolicyTogether, Why: "one zone", By: "person.jochen"}
|
|
policy, from, _, by := catalogue.Upgrade(c.m, person)
|
|
wantPolicy, wantFrom, wantBy := catalogue.PolicyTogether, catalogue.FromPerson, "person.jochen"
|
|
if c.from == catalogue.FromBus {
|
|
wantPolicy, wantFrom, wantBy = c.policy, catalogue.FromBus, ""
|
|
}
|
|
if policy != wantPolicy || from != wantFrom || by != wantBy {
|
|
t.Errorf("%s with a person's choice: %s from %s by %q", c.name, policy, from, by)
|
|
}
|
|
if policy, from, _, _ := catalogue.Upgrade(c.m, nil); policy != c.policy || from != c.from {
|
|
t.Errorf("%s with no choice: %s from %s", c.name, policy, from)
|
|
}
|
|
}
|
|
for _, p := range []catalogue.PersonPolicy{{Module: "m", Policy: "sometimes", Why: "x", By: "p"}, {Module: "m", Policy: catalogue.PolicyRecord, By: "p"},
|
|
{Module: "m", Policy: catalogue.PolicyRecord, Why: "x"}} {
|
|
if p.Check() == nil {
|
|
t.Errorf("%+v was accepted", p)
|
|
}
|
|
}
|
|
// A policy other than roll says why, and an unknown one is refused.
|
|
for _, u := range []catalogue.UpgradePolicy{{Policy: catalogue.PolicyRecord}, {Policy: catalogue.PolicyTogether}, {Policy: "sometimes", Why: "x"}} {
|
|
if problems := manifestUpgradeProblems((catalogue.Manifest{Module: "m", Upgrade: &u})); len(problems) == 0 {
|
|
t.Errorf("%+v was accepted", u)
|
|
}
|
|
}
|
|
if problems := manifestUpgradeProblems((catalogue.Manifest{Module: "m", Upgrade: &catalogue.UpgradePolicy{Policy: catalogue.PolicyRecord, Why: "a restart costs"}})); 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)
|
|
}
|
|
}
|
|
|
|
// ---- copied from catalogue: helpers composition keeps too ----
|
|
|
|
func buildProblemsOf(t *testing.T, m catalogue.Manifest) string {
|
|
t.Helper()
|
|
return strings.Join(buildProblems(m.Build, m.Module), "\n")
|
|
}
|
|
|
|
// bundleFor is a manifest whose one artifact is a bundle in the given language and system.
|
|
func bundleFor(language, system string) catalogue.Manifest {
|
|
return catalogue.Manifest{Module: "a-component", Build: &catalogue.Build{Artifacts: []catalogue.Artifact{
|
|
{Name: "binary", Kind: catalogue.ArtifactBundle, Language: language, System: system},
|
|
}}}
|
|
}
|
|
|
|
// 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) catalogue.Manifest {
|
|
t.Helper()
|
|
m := catalogue.Manifest{Module: name, Version: "1", Tools: []string{"status"},
|
|
Build: &catalogue.Build{Artifacts: []catalogue.Artifact{
|
|
{Name: "tools", Kind: catalogue.ArtifactBundle, Language: "typescript", Entrypoints: entrypoints},
|
|
}}}
|
|
resolved, err := m.Resolve([]catalogue.Built{{Name: "tools", Kind: catalogue.ArtifactBundle,
|
|
Reference: catalogue.ArtifactStoreScheme + name + "/tools/blobs/" + bundleDigest, Digest: bundleDigest}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
var bundleDigest = "sha256:" + strings.Repeat("b", 64)
|
|
|
|
// 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) catalogue.Manifest {
|
|
t.Helper()
|
|
m := catalogue.Manifest{Module: catalogue.RuntimeModule, Version: "1",
|
|
OwnSecrets: catalogue.OwnSecrets{"broker": {Path: "/var/lib/mesh/" + catalogue.RuntimeModule + "/broker"}},
|
|
Build: &catalogue.Build{Artifacts: []catalogue.Artifact{{Name: "runtime", Kind: catalogue.ArtifactBundle, Language: "typescript",
|
|
Entrypoints: []string{"src/main.js"}}}}}
|
|
resolved, err := m.Resolve([]catalogue.Built{{Name: "runtime", Kind: catalogue.ArtifactBundle,
|
|
Reference: catalogue.ArtifactStoreScheme + catalogue.RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return resolved
|
|
}
|
|
|
|
func fileNamed(out []map[string]any, id string) map[string]any {
|
|
for _, r := range out {
|
|
if r["id"] == id {
|
|
return r
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ids(out []map[string]any) []string {
|
|
var names []string
|
|
for _, r := range out {
|
|
names = append(names, r["id"].(string))
|
|
}
|
|
return names
|
|
}
|
|
|
|
func busyManifest(t *testing.T, on string, busy any) []byte {
|
|
t.Helper()
|
|
extra := map[string]any{"id": "agent", "type": "container", "name": "app-agent",
|
|
"image": "registry.example/app" + healthDigest}
|
|
var m map[string]any
|
|
if err := json.Unmarshal(healthManifest(t, "", nil, extra), &m); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, r := range m["resources"].([]any) {
|
|
if r := r.(map[string]any); r["id"] == on {
|
|
r[catalogue.BusyField] = busy
|
|
}
|
|
}
|
|
out, err := json.Marshal(m)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func telegram() catalogue.Manifest {
|
|
return catalogue.Manifest{Module: "telegram", Tools: []string{"status"}, DefinesSeats: []catalogue.SeatDeclaration{{
|
|
Name: "telegram-sender", Scope: catalogue.ScopeMesh,
|
|
Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}, Serves: []catalogue.Verb{{Name: "status"}},
|
|
}}, Claims: []catalogue.Claim{{Name: "telegram-sender", Scope: catalogue.ScopeMesh}}}
|
|
}
|
|
|
|
func problemsFor(t *testing.T, shelf catalogue.Shelf) string {
|
|
t.Helper()
|
|
return strings.Join(CatalogueProblems(shelf), "; ")
|
|
}
|
|
|
|
func claimed(claims string) []byte {
|
|
return []byte(`{"module":"thing","version":"1","provides":[{"name":"npm-package-registry","scope":"mesh","credential":{"form":"none"}}],"claims":` + claims + `}`)
|
|
}
|
|
|
|
func hostnameSeat(t *testing.T) catalogue.Seat {
|
|
t.Helper()
|
|
s, ok := catalogue.SeatNamed("node-hostname")
|
|
if !ok {
|
|
t.Fatal("no node-hostname seat")
|
|
}
|
|
return s
|
|
}
|
|
|
|
func hostnameHolder(resources ...map[string]any) catalogue.Manifest {
|
|
return catalogue.Manifest{Module: "names", Version: "1", Claims: []catalogue.Claim{{Name: "node-hostname", Scope: catalogue.ScopeNode,
|
|
Serves: []string{"entries", "add", "remove"}}}, Resources: resources}
|
|
}
|
|
|
|
func mustParse(t *testing.T, raw string) catalogue.Manifest {
|
|
t.Helper()
|
|
m, err := ParseManifest([]byte(raw))
|
|
if err != nil {
|
|
t.Fatalf("refused: %v", err)
|
|
}
|
|
return m
|
|
}
|
|
|
|
const declaredStore = `{"module":"pg","version":"1",
|
|
"provides":[{"name":"postgres-database","scope":"mesh"}],
|
|
"grants":{"postgres-database":"${dir:grants}"},
|
|
"data":{
|
|
"own":[
|
|
{"id":"store","path":"${dir:store}","class":"irreplaceable","backup":{"dump":"dump-all > ${dir:dumps}/all","into":"dumps"},"active":"1d"},
|
|
{"id":"dumps","path":"${dir:dumps}","class":"rebuildable","backup":"none"}],
|
|
"consumers":{"postgres-database":{"class":"irreplaceable","in":"store"}}},
|
|
"resources":[
|
|
{"id":"grants","type":"directory","mode":"0700"},
|
|
{"id":"store","type":"directory","path":"/srv/store","mode":"0700"},
|
|
{"id":"dumps","type":"directory","path":"${dir:store}/dumps","mode":"0700"},
|
|
{"id":"server","type":"container","image":"x@sha256:aa","volumes":["${dir:store}:/var/lib/postgresql/data"]}]}`
|
|
|
|
func shallowCopy(resource map[string]any) map[string]any {
|
|
copied := map[string]any{}
|
|
for k, v := range resource {
|
|
copied[k] = v
|
|
}
|
|
return copied
|
|
}
|
|
|
|
const 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"}]}`
|
|
|
|
func workstation() catalogue.Node {
|
|
return catalogue.Node{Name: "workstation", Site: "house",
|
|
Capabilities: map[string]bool{"seat": true, "container-runtime": true}}
|
|
}
|
|
|
|
// notifierConsumer is the notifier requiring a mesh-scoped database, marked moved when moved, with its credential
|
|
// kept under two local names (ADR 0094) when locals is set.
|
|
func notifierConsumer(t *testing.T, moved, locals bool) catalogue.Manifest {
|
|
t.Helper()
|
|
raw := handedNotifier
|
|
if moved {
|
|
raw = notifierMoved()
|
|
}
|
|
add := `"requires":["postgres-database"],`
|
|
if locals {
|
|
add += `"secrets":{"postgres-database":{"hot":"/var/lib/notify/hot","cold":"/var/lib/notify/cold"}},`
|
|
}
|
|
raw = strings.Replace(raw, `"build":`, add+`"build":`, 1)
|
|
return held(t, raw, builtNotifier())
|
|
}
|
|
|
|
func shelf(ms ...catalogue.Manifest) map[string]catalogue.Manifest {
|
|
out := map[string]catalogue.Manifest{}
|
|
for _, m := range ms {
|
|
out[m.Module] = m
|
|
}
|
|
return out
|
|
}
|
|
|
|
// twoStores is a mesh where two machines each run a store providing the database, the consumer pinned to one.
|
|
func twoStores(pin string) catalogue.World {
|
|
return catalogue.World{Offered: map[string][]catalogue.Provider{"postgres-database": {
|
|
{Node: "anchor", At: "anchor.internal", Module: "store", Serves: map[string]any{"port": 5432}},
|
|
{Node: "home", At: "home.internal", Module: "store-two", Serves: map[string]any{"port": 5434}},
|
|
}}, Pinned: map[string]catalogue.Chosen{"postgres-database": {Node: pin}}}
|
|
}
|
|
|
|
func composeWith(t *testing.T, r catalogue.Resolution, with catalogue.Rendering) (catalogue.Composed, string) {
|
|
t.Helper()
|
|
c, err := r.Compose(with)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, err := json.Marshal(c.Resources)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return c, string(raw)
|
|
}
|
|
|
|
func handingTo(store string) catalogue.Rendering {
|
|
return catalogue.Rendering{Hands: true, Provisioner: "mesh-provisioner-client", ArtifactStore: store}
|
|
}
|
|
|
|
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}}`
|
|
|
|
// The router of novox/hq ADR 0259: it declares the operator's seat and the two kinded benches.
|
|
func router() catalogue.Manifest {
|
|
return catalogue.Manifest{Module: "messenger", Tools: []string{"open", "history", "notify"},
|
|
State: []catalogue.StateDeclaration{{Name: "asks"}}, RunsAs: "messenger", SecretsOwner: "messenger",
|
|
DefinesSeats: []catalogue.SeatDeclaration{
|
|
{Name: "operator-channel", Scope: catalogue.ScopeMesh, Accepts: []string{"ask", "cancel"}, Emits: []string{"decided"},
|
|
ByCaller: []string{"ask", "cancel", "decided"}, Records: []string{"asks"},
|
|
Serves: []catalogue.Verb{{Name: "open"}, {Name: "history"}, {Name: "notify"}}},
|
|
{Name: "channel", Scope: catalogue.ScopeMesh, Kinded: true, Accepts: []string{"show", "edit", "send"}},
|
|
{Name: "intake", Scope: catalogue.ScopeMesh, Kinded: true, Emits: []string{"choice", "link"}, Proofs: []string{"code"}},
|
|
},
|
|
Claims: []catalogue.Claim{{Name: "operator-channel", Scope: catalogue.ScopeMesh}},
|
|
Uses: []string{"channel"}}
|
|
}
|
|
|
|
func aChannel(module, kind string) catalogue.Manifest {
|
|
return catalogue.Manifest{Module: module, Claims: []catalogue.Claim{
|
|
{Name: "channel", Scope: catalogue.ScopeMesh, Kind: kind}, {Name: "intake", Scope: catalogue.ScopeMesh, Kind: kind}}}
|
|
}
|
|
|
|
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 familyManifest(t *testing.T, ownSecrets string) (catalogue.Manifest, error) {
|
|
t.Helper()
|
|
return ParseManifest([]byte(`{"module":"mounts","version":"1","own-secrets":{` + ownSecrets + `}}`))
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// withPredefined is the list of predefined keys the mesh published, for the length of a test.
|
|
func withPredefined(t *testing.T, keys catalogue.PredefinedList) {
|
|
t.Helper()
|
|
was := catalogue.ReplacePublished(keys)
|
|
t.Cleanup(func() { catalogue.ReplacePublished(was) })
|
|
}
|
|
|
|
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}
|
|
}`
|
|
|
|
// 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
|
|
}
|
|
|
|
// inConfigForm is a module in the config form that registers: every value typed, described, defaulted or
|
|
// required, placed in a file or read at runtime, and its mergeable file naming its keys.
|
|
func inConfigForm() map[string]any {
|
|
return map[string]any{
|
|
"module": "notifier",
|
|
"version": "1",
|
|
"config": map[string]any{
|
|
"font-size": map[string]any{"type": "number", "description": "the notification's font size, in points",
|
|
"minimum": 6, "maximum": 40, "default": 10},
|
|
"width": map[string]any{"type": "number", "description": "the notification's width, in pixels",
|
|
"default": 250},
|
|
"position": map[string]any{"type": "string", "description": "the corner it shows in, a word",
|
|
"enum": []any{"top-right", "top-left"}, "default": "top-right"},
|
|
"theme": map[string]any{"type": "string", "description": "the colour theme, a lower-case name",
|
|
"pattern": "[a-z]+", "default": "dark"},
|
|
"history": map[string]any{"type": "number", "description": "how many notifications it keeps, a count",
|
|
"default": 20, "runtime": true},
|
|
"sender": map[string]any{"type": "string", "description": "the address it mails a digest from",
|
|
"required": true},
|
|
},
|
|
"resources": []any{
|
|
map[string]any{"id": "configuration", "type": "file", "path": "/x/notifierrc",
|
|
"content": "font = Inter ${config:font-size}\nwidth = ${config:width}\nfrom = ${config:sender}\n"},
|
|
map[string]any{"id": "rules", "type": "file", "path": "/x/rules.json", "merge": "json",
|
|
"takes-config": []any{"position", "theme"}, "content": "{\"position\": \"top-right\"}\n"},
|
|
map[string]any{"id": "notifier", "type": "service", "unit": "notifier.service",
|
|
"reload-on": []any{"configuration"}, "restart-on": []any{"rules"}},
|
|
},
|
|
}
|
|
}
|
|
|
|
// declaration is one value of inConfigForm's config, to change in place.
|
|
func declaration(m map[string]any, key string) map[string]any {
|
|
return m["config"].(map[string]any)[key].(map[string]any)
|
|
}
|
|
|
|
func resourceOf(m map[string]any, id string) map[string]any {
|
|
for _, r := range m["resources"].([]any) {
|
|
if r.(map[string]any)["id"] == id {
|
|
return r.(map[string]any)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// refused is that the manifest is refused with every one of these words.
|
|
func refused(t *testing.T, m map[string]any, words ...string) {
|
|
t.Helper()
|
|
_, err := parseRaw(t, m)
|
|
if err == nil {
|
|
t.Fatalf("registered; wanted a refusal saying %q", words)
|
|
}
|
|
for _, w := range words {
|
|
if !strings.Contains(err.Error(), w) {
|
|
t.Fatalf("refused without %q:\n%v", w, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func aContainerWithGroups(groups ...any) map[string]any {
|
|
return map[string]any{"id": "server", "type": "container", "name": "n8n", "image": "x@sha256:" + strings.Repeat("a", 64),
|
|
"groups": groups}
|
|
}
|
|
|
|
func parseRaw(t *testing.T, m map[string]any) (catalogue.Manifest, error) {
|
|
t.Helper()
|
|
raw, err := json.Marshal(m)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return ParseManifest(raw)
|
|
}
|
|
|
|
const misspelledShell = "${shel:zsh:first}"
|
|
|
|
const undeclaredSetting = "${setting:Undeclared}"
|
|
|
|
const misspelledMachine = "${machnie:address}"
|
|
|
|
const shellsOwn = "${XDG_CACHE_HOME:-x}"
|
|
|
|
const shellsOperators = "${PORT:-8080} ${SHELL:-/bin/sh} ${SECRET:?unset} ${dir:-/tmp}"
|
|
|
|
// 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) catalogue.Manifest {
|
|
appFile := map[string]any{"id": "app", "type": "file", "path": "/etc/app.json", "merge": catalogue.MergeJSON,
|
|
"content": `{"server":{"port":8080},"search":{"safe":1}}`}
|
|
sideFile := map[string]any{"id": "sidecar", "type": "file", "path": "/etc/sidecar.json", "merge": catalogue.MergeJSON,
|
|
"content": `{"url":"http://127.0.0.1:8080"}`}
|
|
if app != nil {
|
|
appFile[catalogue.TakesSettingsField] = app
|
|
}
|
|
if sidecar != nil {
|
|
sideFile[catalogue.TakesSettingsField] = sidecar
|
|
}
|
|
return catalogue.Manifest{Module: "search", Resources: []map[string]any{
|
|
appFile, sideFile,
|
|
{"id": "env", "type": "file", "path": "/etc/search.env", "content": "SITE=${setting:site-name}\n"},
|
|
}}
|
|
}
|
|
|
|
func containsText(list []string, text string) bool {
|
|
for _, s := range list {
|
|
if strings.Contains(s, text) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
var digest = "sha256:" + strings.Repeat("d", 64)
|
|
|
|
func manifestMounting(from string, beside string) []byte {
|
|
res := aContainerMounting
|
|
if beside != "" {
|
|
res = beside + "," + res
|
|
}
|
|
return []byte(`{"module":"store","resources":[` + strings.Replace(res, "%s", from, 1) + `]}`)
|
|
}
|
|
|
|
func recycler() catalogue.Manifest {
|
|
return catalogue.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
|
|
}
|
|
|
|
func parsed(t *testing.T, m catalogue.Manifest) error {
|
|
t.Helper()
|
|
raw, err := json.Marshal(m)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = ParseManifest(raw)
|
|
return err
|
|
}
|
|
|
|
func upstreamManifest(artifact string) string {
|
|
return `{"module":"a","version":"1","build":{"artifacts":[` + artifact + `]}}`
|
|
}
|
|
|
|
const pinnedSum = "38dd24890a66b4cf9a45bc24e15192e00c26b929e1bb8df7974c16a4539129e2"
|
|
|
|
// 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"]}`
|
|
|
|
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
|
|
}
|
|
|
|
const handedProvider = `{"module":"db","version":"1","moved":true,
|
|
"provides":[{"name":"postgres-database","scope":"mesh","identity":{"max":63,"in":"a PostgreSQL role"}}],
|
|
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
|
"resources":[
|
|
{"id":"state","type":"directory","mode":"0700","place":"."},
|
|
{"id":"consumers","type":"file","path":"${dir:state}/consumers.json","mode":"0600","template":"files/consumers.json"}]}`
|
|
|
|
const consumersText = "${consumers:postgres-database}"
|
|
|
|
func notifierOn(t *testing.T, m catalogue.Manifest) catalogue.Resolution {
|
|
t.Helper()
|
|
r, err := catalogue.Resolve(shelf(m), []string{m.Module}, workstation(), catalogue.World{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r.Account = "op"
|
|
return r
|
|
}
|
|
|
|
func placedModule() catalogue.Manifest {
|
|
return catalogue.Manifest{
|
|
Module: "photos",
|
|
Resources: []map[string]any{
|
|
{"id": "data", "type": "directory", "mode": "0700"},
|
|
{"id": "server-env", "type": "file", "path": "${dir:data}/server.env",
|
|
"content": "STORE=${dir:data}/objects\n"},
|
|
{"id": "server", "type": "container", "name": "photos-server",
|
|
"volumes": []any{"${dir:data}:/data"},
|
|
"env": map[string]any{"DATA": "${dir:data}/objects"},
|
|
"env-file": []any{"${dir:data}/server.env"}},
|
|
},
|
|
}
|
|
}
|
|
|
|
// renaming is inConfigForm with its rules file taking `url` as NOTIFIER_URL, a value the code reading the file
|
|
// falls back on when the file lacks it.
|
|
func renaming() map[string]any {
|
|
m := inConfigForm()
|
|
m["config"].(map[string]any)["url"] = map[string]any{"type": "string",
|
|
"description": "the address the notifier posts to, a URL; unset, the notifier's own environment says it",
|
|
"default": ""}
|
|
resourceOf(m, "rules")["takes-config"] = []any{"position", "theme",
|
|
map[string]any{"key": "url", "as": "NOTIFIER_URL"}}
|
|
return m
|
|
}
|
|
|
|
// aMediaServer is the shape one port number per key cannot express: two endpoints of different kinds.
|
|
// A web surface a proxy serves under a subdomain, and a protocol port clients dial directly because
|
|
// the client expects that number.
|
|
func aMediaServer() catalogue.Manifest {
|
|
return catalogue.Manifest{
|
|
Module: "media",
|
|
Listens: []catalogue.Listening{
|
|
{Name: "web", Port: 80, From: catalogue.FromMesh, Why: "the app, behind the proxy"},
|
|
{Name: "stream", Port: 32400, From: catalogue.FromEverywhere, Fixed: true,
|
|
Why: "the client dials this number; the protocol chose it"},
|
|
},
|
|
Contributes: map[string]map[string]any{
|
|
"route": {"label": "media", catalogue.RouteEndpoint: "web"},
|
|
},
|
|
// Both endpoints are published by its container, which is what lets a machine port be given
|
|
// for either: the mesh moves a port the module publishes, never one it merely listens on.
|
|
Resources: []map[string]any{
|
|
{"id": "server", "type": "container", "name": "media",
|
|
"ports": []any{"80", "32400"}},
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
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/"}}}`
|
|
|
|
// store is an object store in the shape minio has: it serves a region and a port to everyone, and
|
|
// a bucket named for whoever is asking.
|
|
func store() catalogue.Manifest {
|
|
return catalogue.Manifest{
|
|
Module: "store", Version: "1",
|
|
Provides: catalogue.FromAnywhere("s3-bucket"),
|
|
Listens: []catalogue.Listening{{Port: 9000, Protocol: "tcp", From: catalogue.FromMesh}},
|
|
Serves: map[string]map[string]any{"s3-bucket": {
|
|
"region": "eu-west",
|
|
"bucket": "${consumer:as:dns}",
|
|
}},
|
|
Receives: map[string]string{"s3-bucket": "/var/lib/store/grants/mesh.json"},
|
|
Grants: map[string]string{"s3-bucket": "/var/lib/store/grants"},
|
|
Resources: []map[string]any{{
|
|
"id": "server", "type": "container", "name": "store", "ports": []any{"9000"},
|
|
}},
|
|
}
|
|
}
|
|
|
|
// 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 aModuleWithAFieldSetAsideStillProvidesWhatItProvides(t *testing.T) {
|
|
var m catalogue.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 why := catalogue.UnknownFieldReason(m); why != "" {
|
|
t.Fatal(why)
|
|
}
|
|
}
|
|
|
|
// A module with a field set aside is composed whole: its files are declared and its data backed up (novox/hq ADR 0578
|
|
// §6; before, ADR 0262 left it out and backed up its data alone).
|
|
func aModuleWithAFieldSetAsideIsComposedWhole(t *testing.T) {
|
|
var later catalogue.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 := catalogue.Manifest{Module: "backups", Version: "1",
|
|
Claims: []catalogue.Claim{{Name: catalogue.BackupSeat, Scope: catalogue.ScopeNode}},
|
|
Resources: []map[string]any{{"id": "list", "type": "file", "path": "/etc/backups.list", "mode": "0644",
|
|
"content": "${contribution:" + catalogue.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("left out: %v", composed.LeftOut)
|
|
}
|
|
got := byID(composed.Resources)
|
|
if _, declared := got["later.rc"]; !declared {
|
|
t.Fatal("the module's file is not 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)
|
|
}
|
|
}
|
|
|
|
func notifier() catalogue.Manifest {
|
|
return catalogue.Manifest{Module: "notifier",
|
|
Settings: map[string]catalogue.SettingDeclaration{
|
|
"font-size": {Kind: catalogue.KindPreference, Default: float64(10), Why: "readable at a scale of one"},
|
|
"width": {Kind: catalogue.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"}},
|
|
}
|
|
}
|
|
|
|
// 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 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"}]}`
|
|
|
|
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 + `"}]}`
|
|
|
|
// 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) catalogue.Manifest {
|
|
return catalogue.Manifest{
|
|
Module: "mesh-mail", Version: "1", Emits: []string{"mail.send.sent", "mail.send.failed"},
|
|
State: []catalogue.StateDeclaration{{Name: "sends"}},
|
|
Streams: []catalogue.StreamDeclaration{{Name: "mail-sends", Events: []string{"mail.send.approved"},
|
|
Retention: catalogue.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 mod(name string, provides, requires, capabilities []string, claims ...catalogue.Claim) catalogue.Manifest {
|
|
return catalogue.Manifest{Module: name, Provides: catalogue.Offers(provides...), Requires: requires,
|
|
Capabilities: capabilities, Claims: claims}
|
|
}
|
|
|
|
const collectorZsh = `{"module":"zsh","version":"1","moved":true,
|
|
"claims":[{"name":"node-login-shell","scope":"node"}],
|
|
"collections":[{"name":"widgets","comment":"#"}],
|
|
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
|
"resources":[{"id":"rc","type":"file","path":"${machine:account-home}/.zshrc","owner":"${machine:account}",
|
|
"mode":"0644","template":"files/zshrc"}]}`
|
|
|
|
// catalogueManifests is every manifest of the catalogue (catalogueRoot), read through ReadTreeManifest once
|
|
// per test binary and not once per test: the checks over the real catalogue each read and parsed every
|
|
// manifest again, and together took 1.5 s of mesh-sdk's unit bound of 2 s (issue 1756). What it returns is
|
|
// shared and read-only: a test that changes a manifest copies it first.
|
|
func catalogueManifests(t *testing.T) (string, []readManifest) {
|
|
t.Helper()
|
|
root := catalogueRoot(t)
|
|
readOnce.Do(func() {
|
|
readRoot = root
|
|
found, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, p := range found {
|
|
m, err := ReadTreeManifest(root, filepath.Dir(p))
|
|
readAll = append(readAll, readManifest{path: p, manifest: m, err: err})
|
|
}
|
|
})
|
|
if len(readAll) == 0 {
|
|
t.Fatalf("no manifests under %s", root)
|
|
}
|
|
return readRoot, readAll
|
|
}
|
|
|
|
// theCatalogue is every manifest of the catalogue internal/beside finds, parsed the way registration
|
|
// parses one.
|
|
func theCatalogue(t *testing.T) []catalogue.Manifest {
|
|
t.Helper()
|
|
root := beside.Catalogue(t)
|
|
entries, err := os.ReadDir(root)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var out []catalogue.Manifest
|
|
for _, e := range entries {
|
|
if !e.IsDir() {
|
|
continue
|
|
}
|
|
raw, err := os.ReadFile(filepath.Join(root, e.Name(), "module.json"))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
var m catalogue.Manifest
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
|
t.Fatalf("%s: %v", e.Name(), err)
|
|
}
|
|
out = append(out, m)
|
|
}
|
|
if len(out) == 0 {
|
|
t.Fatalf("no manifests under %s, so this proved nothing", root)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// healthManifest is a module of one web container, one running service and one step, with the health
|
|
// given on the container (or on the resource named by on).
|
|
func healthManifest(t *testing.T, on string, health map[string]any, more ...map[string]any) []byte {
|
|
t.Helper()
|
|
resources := []map[string]any{
|
|
{"id": "server", "type": "container", "name": "app-server", "image": "registry.example/app" + healthDigest,
|
|
"ports": []any{"8080"}},
|
|
{"id": "daemon", "type": "service", "unit": "app.service", "state": "running"},
|
|
{"id": "seed", "type": "container", "name": "app-seed", "image": "registry.example/app" + healthDigest,
|
|
"run-once": true},
|
|
{"id": "sweep", "type": "container", "name": "app-sweep", "image": "registry.example/app" + healthDigest,
|
|
"schedule": "0 3 * * *"},
|
|
}
|
|
resources = append(resources, more...)
|
|
for _, r := range resources {
|
|
if r["id"] == on && health != nil {
|
|
r["health"] = health
|
|
}
|
|
}
|
|
m := map[string]any{"module": "app", "requires": []any{"postgres-database"}, "tools": []any{"app_status"},
|
|
"listens": []any{
|
|
map[string]any{"name": "web", "port": 8080, "from": "mesh"},
|
|
map[string]any{"name": "beacon", "port": 9999, "protocol": "udp", "from": "mesh"},
|
|
},
|
|
"resources": resources}
|
|
raw, err := json.Marshal(m)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return raw
|
|
}
|
|
|
|
const mailStream = `[{"name":"mail","events":["mail.*.message.added","mail.*.message.changed",` +
|
|
`"mail.*.message.removed"],"max-age-days":7,"grant":{"config":"grants","events":"mail.{key}.**"}}]`
|
|
|
|
// localResolver is a stand-in holder: it claims the seat and renders the resolver file naming the
|
|
// machine's own address. What is under test is the controller's rule, not the catalogue's module.
|
|
func localResolver() catalogue.Manifest {
|
|
return catalogue.Manifest{Module: "local-resolver", Version: "1",
|
|
Claims: []catalogue.Claim{{Name: catalogue.ResolverSeat, Scope: catalogue.ScopeNode}},
|
|
Facts: map[string]catalogue.RosterFile{"resolvers": {Path: catalogue.ResolverFile,
|
|
Template: "# Managed by the mesh\n{{range .Machines}}{{if eq .Name $.Node}}nameserver {{.Address}}\n{{end}}{{end}}"}}}
|
|
}
|
|
|
|
func catalogueManifest(t *testing.T, module string) catalogue.Manifest {
|
|
t.Helper()
|
|
root, dir := moduleTree(t, module)
|
|
m, err := ReadTreeManifest(root, dir)
|
|
if err != nil {
|
|
t.Fatalf("%s does not parse:\n%v", module, err)
|
|
}
|
|
return m
|
|
}
|
|
|
|
func optionalModule(resources string) string {
|
|
return `{"module":"grafana","version":"1",
|
|
"requires":["route",{"name":"influxdb-api","optional":true}],
|
|
"secrets":{"influxdb-api":"/var/lib/grafana/influxdb-api"},
|
|
"resources":[` + resources + `]}`
|
|
}
|
|
|
|
const aContainerMounting = `{"id":"server","type":"container","name":"store","image":"x@sha256:` +
|
|
`0000000000000000000000000000000000000000000000000000000000000000","volumes":["%s:/inside"]}`
|
|
|
|
func botMoved() string {
|
|
return strings.Replace(handedBot, `"version":"1",`, `"version":"1","moved":true,`, 1)
|
|
}
|
|
|
|
// 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 []catalogue.Built) catalogue.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 := catalogue.HeldTemplateProblem(out); problem != "" {
|
|
t.Fatal(problem)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func builtBot(text string) []catalogue.Built {
|
|
return []catalogue.Built{{Name: "files", Kind: catalogue.ArtifactArchive,
|
|
Reference: catalogue.ArtifactStoreScheme + "bot/files/blobs/sha256:" + strings.Repeat("e", 64),
|
|
Digest: "sha256:" + strings.Repeat("e", 64), Texts: map[string]string{"bot.conf": text}}}
|
|
}
|
|
|
|
const botText = "token = ${secret:token}\nhome = ${machine:account-home}\n"
|
|
|
|
const aServiceDigest = "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
|
|
|
|
const healthDigest = "@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
|
|
const handedNotifier = `{"module":"notify","version":"1",
|
|
"config":{"width":{"type":"number","description":"How wide a notification is, in pixels.","default":250}},
|
|
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
|
"resources":[
|
|
{"id":"configuration-dir","type":"directory","path":"${machine:account-home}/.config/notify","owner":"${machine:account}","mode":"0755"},
|
|
{"id":"configuration","type":"file","path":"${machine:account-home}/.config/notify/rc","owner":"${machine:account}","mode":"0644","template":"files/rc"},
|
|
{"id":"legacy","type":"file","path":"/etc/notify.conf","content":"plain = yes\n"},
|
|
{"id":"notifier","type":"service","unit":"notify.service","scope":"user","user":"${machine:account}","reload-on":["configuration"]}]}`
|
|
|
|
func notifierMoved() string {
|
|
return strings.Replace(handedNotifier, `"version":"1",`, `"version":"1","moved":true,`, 1)
|
|
}
|
|
|
|
func builtNotifier() []catalogue.Built {
|
|
return []catalogue.Built{{Name: "files", Kind: catalogue.ArtifactArchive,
|
|
Reference: catalogue.ArtifactStoreScheme + "notify/files/blobs/sha256:" + strings.Repeat("d", 64),
|
|
Digest: "sha256:" + strings.Repeat("d", 64), Texts: map[string]string{"rc": notifierText}}}
|
|
}
|
|
|
|
// anAdoptedAnchor is the control-node's set: the store, the bus, the registry, the private network,
|
|
// a served module and the filter module.
|
|
func anAdoptedAnchor() catalogue.Resolution {
|
|
return catalogue.Resolution{Node: "anchor", Modules: []catalogue.Manifest{
|
|
{Module: "network", Computed: "overlay"},
|
|
{Module: "postgres", Guards: []int{5432},
|
|
Listens: []catalogue.Listening{{Port: 5432, From: catalogue.FromMesh}},
|
|
Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-store",
|
|
"ports": []any{"5432:5432"}}}},
|
|
{Module: "lavinmq", Guards: []int{15672},
|
|
Listens: []catalogue.Listening{{Port: 5671, From: catalogue.FromMesh}, {Port: 5672, From: catalogue.FromMesh}},
|
|
Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-broker",
|
|
"ports": []any{"5671:5671", "5672:5672", "127.0.0.1:15672:15672"}}}},
|
|
{Module: "distribution",
|
|
Listens: []catalogue.Listening{{Port: 5000, From: catalogue.FromMesh}},
|
|
Resources: []map[string]any{{"id": "store", "type": "container", "name": "registry",
|
|
"ports": []any{"5000"}}}},
|
|
{Module: "hello-web",
|
|
Listens: []catalogue.Listening{{Port: 8080, From: catalogue.FromEverywhere}},
|
|
Resources: []map[string]any{{"id": "server", "type": "container", "name": "hello-web",
|
|
"ports": []any{"8080"}}}},
|
|
{Module: "helper", Listens: []catalogue.Listening{{Port: 9000, From: catalogue.FromMachine}}},
|
|
{Module: "nftables", Filtering: &catalogue.Filtering{Into: "/etc/nftables.conf"},
|
|
Resources: []map[string]any{{"id": "load", "type": "service", "unit": "mesh-filter.service",
|
|
"state": "running", "restart-on": []any{"filtering"}}}},
|
|
}}
|
|
}
|
|
|
|
func anchorRendering(adopted bool) catalogue.Rendering {
|
|
return catalogue.Rendering{
|
|
Generators: map[string]catalogue.Generator{"overlay": hub{}},
|
|
Ports: map[string]map[int]int{"distribution": {5000: 5000}, "hello-web": {8080: 20001}},
|
|
Settings: catalogue.SettingsBy{"distribution": {{From: "node anchor",
|
|
Placement: map[string]any{catalogue.ExposeSetting: map[string]any{"5000": catalogue.FromEverywhere}}}}},
|
|
Mesh: []string{"10.42.0.1"},
|
|
Foundation: []int{5671},
|
|
// What the machine reported faces outside, which every rule in the filter is written
|
|
// around (novox/hq ADR 0140).
|
|
OutwardLinks: []string{"eth0"},
|
|
TunnelInterface: "mesh0",
|
|
Adopted: adopted,
|
|
// Genesis takes the foundation's modules.
|
|
Taken: map[string]bool{"postgres": true, "lavinmq": true},
|
|
}
|
|
}
|
|
|
|
func byID(resources []map[string]any) map[string]map[string]any {
|
|
out := map[string]map[string]any{}
|
|
for _, r := range resources {
|
|
out[r["id"].(string)] = r
|
|
}
|
|
return out
|
|
}
|
|
|
|
const mainText = `# tidy\nroot = ${dir:conf}\nquoted = \"yes\" é\n`
|
|
|
|
const extraText = `one\ntwo\n`
|
|
|
|
func queueList(q string) []any {
|
|
if q == "" {
|
|
return nil
|
|
}
|
|
out := []any{}
|
|
for _, s := range strings.Split(q, ",") {
|
|
out = append(out, s)
|
|
}
|
|
return out
|
|
}
|
|
|
|
type readManifest struct {
|
|
path string // the module.json
|
|
manifest catalogue.Manifest
|
|
err error
|
|
}
|
|
|
|
// TestEveryCatalogueManifestParses runs the real catalogue through the real gate.
|
|
//
|
|
// Not a fixture: the point is whether the manifests as written are accepted by the control plane that
|
|
// will read them, and a copy of one manifest proves nothing about the other seventy-one.
|
|
// catalogueRoot is the catalogue these checks run over: in a merge check the clone the build seat put
|
|
// beside this one, elsewhere the copy captured in testdata/beside (internal/beside). A check that only
|
|
// ran when somebody remembered a variable was a check nobody ran (novox/hq issue 134), and one that read
|
|
// whatever checkout sat beside judged the machine, not the change (novox/hq issue 432): it never skips.
|
|
func catalogueRoot(t *testing.T) string {
|
|
t.Helper()
|
|
return beside.Dir(t, "mesh-catalog")
|
|
}
|
|
|
|
var readOnce sync.Once
|
|
|
|
var readRoot string
|
|
|
|
var readAll []readManifest
|
|
|
|
// The catalogue's foundation modules as they are — in a merge check the catalogue cloned beside it,
|
|
// elsewhere the copy captured in testdata/beside (internal/beside, novox/hq issue 432) — parsed by the
|
|
// real parser (novox/hq ADR 0100):
|
|
// the store and the broker say which of their ports the mesh guards on an adopted node, and the
|
|
// filter module loads its table through a unit of its own whose stop deletes only that table.
|
|
// moduleTree is where a module is read from: the catalogue beside, with the catalogue as the root. In
|
|
// mesh-controller it first looked in the repository's own modules/<module>, where build-agent and
|
|
// route-proxy live whole (novox/hq ADR 0420 §1, issue 1019); mesh-sdk has no such directory, and the
|
|
// tests that read those two modules stay in mesh-controller (issue 1379).
|
|
func moduleTree(t *testing.T, module string) (root, dir string) {
|
|
t.Helper()
|
|
return beside.Dir(t, "mesh-catalog"), filepath.Join(beside.Catalogue(t), module)
|
|
}
|
|
|
|
const handedBot = `{"module":"bot","version":"1",
|
|
"own-secrets":{"token":"${dir:state}/token"},
|
|
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
|
|
"resources":[
|
|
{"id":"state","type":"directory","mode":"0700","place":"."},
|
|
{"id":"conf","type":"file","path":"/etc/bot/bot.conf","mode":"0640","template":"files/bot.conf"},
|
|
{"id":"daemon","type":"process","name":"bot","run":["bot"],"env":{"BOT_TOKEN_FILE":"${dir:state}/token"}}]}`
|
|
|
|
const notifierText = "width = ${config:width}\nhome = ${machine:account-home}\n"
|
|
|
|
type hub struct{}
|
|
|
|
func (hub) Resources(string) ([]map[string]any, bool, error) {
|
|
return []map[string]any{{"id": "config", "type": "file", "path": "/etc/wireguard/mesh0.conf",
|
|
"content": "[Interface]\n"}}, true, nil
|
|
}
|
|
func (hub) Listens(string) ([]catalogue.Listening, error) {
|
|
return []catalogue.Listening{{Port: 51820, Protocol: "udp", From: catalogue.FromEverywhere}}, nil
|
|
}
|