diff --git a/go/catalogue/a_service_process_test.go b/go/catalogue/a_service_process_test.go index 1e25276..023d03d 100644 --- a/go/catalogue/a_service_process_test.go +++ b/go/catalogue/a_service_process_test.go @@ -2,7 +2,6 @@ package catalogue import ( "encoding/json" - "strings" "testing" ) @@ -31,7 +30,7 @@ func aServiceModule(t *testing.T) Manifest { "build": {"artifacts": [{"name": "code", "kind": "bundle", "language": "go", "system": "arch", "from": "cmd/svc", "binary": "svc"}]} }` - m, err := ParseManifest([]byte(raw)) + m, err := decodeForTest([]byte(raw)) if err != nil { t.Fatalf("the service's manifest is refused: %v", err) } @@ -145,22 +144,3 @@ func TestWhatAProcessReplacesIsNamedAsTheHostRecordedIt(t *testing.T) { t.Fatalf("the process replaces %s", got) } } - -func TestWhatReplacesMayNameIsRefusedNearItsAuthor(t *testing.T) { - for what, resource := range map[string]string{ - "a container": `{"id":"c","type":"container","name":"c","image":"x@` + aServiceDigest + `","replaces":["old"]}`, - "a step": `{"id":"p","type":"process","name":"p","run":["./p"],"run-once":true,"replaces":["old"]}`, - "something declared": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["p"]}`, - "another module's": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["other.old"]}`, - "not a list": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":"old"}`, - } { - raw := `{"module":"m","version":"1","resources":[` + resource + `]}` - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "replace") { - t.Errorf("replaces on %s was accepted: %v", what, err) - } - } - ok := `{"module":"m","version":"1","resources":[{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["old"]}]}` - if _, err := ParseManifest([]byte(ok)); err != nil { - t.Errorf("a process replacing what its module no longer declares was refused: %v", err) - } -} diff --git a/go/catalogue/accesses_test.go b/go/catalogue/accesses_test.go index b95250a..0ebd1bf 100644 --- a/go/catalogue/accesses_test.go +++ b/go/catalogue/accesses_test.go @@ -76,33 +76,3 @@ func TestAModuleMayNotOwnWhatAnotherAccesses(t *testing.T) { t.Errorf("the refusal does not explain the ownership conflict: %v", err) } } - -// A manifest states an access's extent, the way a listening port states its source. An absolute -// path and a mode of read or read-write; anything else is refused rather than acted on wrongly. -func TestAnAccessIsValidated(t *testing.T) { - good := []byte(`{"module":"m","accesses":[{"path":"/services/media","mode":"read-write"}]}`) - if _, err := ParseManifest(good); err != nil { - t.Fatalf("a valid access was refused: %v", err) - } - - // Mode is optional and narrows to read — the safe default, because the danger with an access - // is being given more than was meant, not less. - bare := []byte(`{"module":"m","accesses":[{"path":"/services/media"}]}`) - m, err := ParseManifest(bare) - if err != nil { - t.Fatalf("an access with no mode was refused: %v", err) - } - if m.Accesses[0].At() != AccessRead { - t.Errorf("an access with no mode is %q, want %q", m.Accesses[0].At(), AccessRead) - } - - relative := []byte(`{"module":"m","accesses":[{"path":"services/media","mode":"read"}]}`) - if _, err := ParseManifest(relative); err == nil { - t.Error("a relative access path was accepted") - } - - wrong := []byte(`{"module":"m","accesses":[{"path":"/services/media","mode":"append"}]}`) - if _, err := ParseManifest(wrong); err == nil { - t.Error("an access with an unknown mode was accepted") - } -} diff --git a/go/catalogue/adoption_test.go b/go/catalogue/adoption_test.go index 8630fa4..a812651 100644 --- a/go/catalogue/adoption_test.go +++ b/go/catalogue/adoption_test.go @@ -258,15 +258,6 @@ WantedBy=multi-user.target } } -func TestAGuardedPortMustBeAPort(t *testing.T) { - if _, err := ParseManifest([]byte(`{"module":"postgres","guards":[5432]}`)); err != nil { - t.Fatalf("guards is refused: %v", err) - } - if _, err := ParseManifest([]byte(`{"module":"postgres","guards":[0]}`)); err == nil { - t.Fatal("guarding port 0 was accepted") - } -} - func keys[V any](m map[string]V) []string { return sortedKeys(m) } diff --git a/go/catalogue/agent_account_test.go b/go/catalogue/agent_account_test.go index 90efb04..204e866 100644 --- a/go/catalogue/agent_account_test.go +++ b/go/catalogue/agent_account_test.go @@ -43,7 +43,7 @@ const agentModule = `{"module": "agent", "version": "1", "resources": [ ]}` func TestTheAgentAccountIsDeclaredNeverRootOnlyToAnEngineThatJudgesIt(t *testing.T) { - m, err := ParseManifest([]byte(agentModule)) + m, err := decodeForTest([]byte(agentModule)) if err != nil { t.Fatal(err) } @@ -83,37 +83,3 @@ func TestTheAgentAccountIsDeclaredNeverRootOnlyToAnEngineThatJudgesIt(t *testing t.Errorf("with no agent account, the agent's directory is the operator's: %v", home) } } - -func TestTheRuntimeIsToldTheAgentAccount(t *testing.T) { - with := Rendering{ArtifactStore: "anchor.internal:5101", - Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}} - envOf := func(r Resolution) map[string]string { - t.Helper() - r.Node, r.Modules = "anchor", []Manifest{aToolsModule(t, "nftables", "tools/index.js"), theRuntime(t)} - out, err := r.Declaration(with) - if err != nil { - t.Fatal(err) - } - process := fileNamed(out, RuntimeModule+"."+RuntimeProcessID()) - if process == nil { - t.Fatal("no runtime process was composed") - } - return process["env"].(map[string]string) - } - env := envOf(Resolution{Account: "ops", AgentAccount: "agent"}) - if env[RuntimeAgentAccount] != "agent" || env[RuntimeAgentHome] != "/home/agent" || env[RuntimeOperatorAccount] != "ops" { - t.Errorf("the runtime is not told whom agents run as: %v", env) - } - env = envOf(Resolution{Account: "ops"}) - if env[RuntimeAgentAccount] != "ops" || env[RuntimeAgentHome] != "/home/ops" { - t.Errorf("with no agent account, agents run as the operator: %v", env) - } - env = envOf(Resolution{}) - if _, set := env[RuntimeAgentAccount]; set { - t.Errorf("a machine with no account names an agent account: %v", env) - } - if problems := bundleEnvProblems("x", Artifact{Name: "b", Kind: ArtifactBundle, Loads: []string{"x"}, - Env: map[string]string{RuntimeAgentAccount: "me"}}); len(problems) == 0 { - t.Error("a bundle may tell the runtime whom agents run as") - } -} diff --git a/go/catalogue/amqp_is_not_a_provision_test.go b/go/catalogue/amqp_is_not_a_provision_test.go index 11d1ad5..adbeb2d 100644 --- a/go/catalogue/amqp_is_not_a_provision_test.go +++ b/go/catalogue/amqp_is_not_a_provision_test.go @@ -14,22 +14,6 @@ import ( // the old wire protocol asks for the one server being retired, so both directions are refused at the // parser — this is judged from the manifest alone, no store needed. -func TestAManifestProvidingAmqpIsRefused(t *testing.T) { - raw := []byte(`{"module":"old-broker","version":"1","provides":[{"name":"amqp","scope":"mesh"}]}`) - _, err := ParseManifest(raw) - if err == nil || !strings.Contains(err.Error(), `provides "amqp", which is not a provision`) { - t.Fatalf("a module providing amqp was not refused, or not for the reason: %v", err) - } -} - -func TestAManifestRequiringAmqpIsRefused(t *testing.T) { - raw := []byte(`{"module":"forwarder","version":"1","requires":["amqp"]}`) - _, err := ParseManifest(raw) - if err == nil || !strings.Contains(err.Error(), `requires "amqp", which is not a provision`) { - t.Fatalf("a module requiring amqp was not refused, or not for the reason: %v", err) - } -} - // And the catalogue (internal/beside) names it nowhere — the three modules that did are removed under // design 28 task 5.4, not converted. func TestNoCatalogueManifestNamesAmqp(t *testing.T) { diff --git a/go/catalogue/backup_test.go b/go/catalogue/backup_test.go index 4450ac2..f45093f 100644 --- a/go/catalogue/backup_test.go +++ b/go/catalogue/backup_test.go @@ -5,28 +5,16 @@ import ( "testing" ) -// A backup contribution written by hand still names its module's own directories; one it does not -// declare is refused where it is written rather than reaching the holder as the literal text. (The -// catalogue check refuses a hand-written backup line at all since ADR 0233; parsing still reads one, -// because a module on the shelf was written before.) -func TestABackupNamingAnUndeclaredDirectoryIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"pg","version":"1", - "contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:nowhere}"}]}`)) - if err == nil || !strings.Contains(err.Error(), "nowhere") { - t.Fatalf("a backup of an undeclared directory was accepted: %v", err) - } -} - // A module on the shelf from before ADR 0233, which writes its backup lines by hand and declares no // data, is still backed up: its lines are placed as written, each module's under a comment naming it. func TestHandWrittenBackupLinesOfAnOlderModuleArePlaced(t *testing.T) { - pg, err := ParseManifest([]byte(`{"module":"pg","version":"1", + pg, err := decodeForTest([]byte(`{"module":"pg","version":"1", "resources":[{"id":"dumps","type":"directory","path":"/srv/pg/dumps","mode":"0700"}], "contributions":[{"seat":"node-backup","kind":"backup","content":"run pg-dump-all\npath ${dir:dumps}"}]}`)) if err != nil { t.Fatal(err) } - mail, err := ParseManifest([]byte(`{"module":"mail","version":"1", + mail, err := decodeForTest([]byte(`{"module":"mail","version":"1", "resources":[{"id":"spool","type":"directory","mode":"0700"}], "contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:spool}"}]}`)) if err != nil { diff --git a/go/catalogue/bootstrap_cycle_test.go b/go/catalogue/bootstrap_cycle_test.go index 9cbbd67..fb26088 100644 --- a/go/catalogue/bootstrap_cycle_test.go +++ b/go/catalogue/bootstrap_cycle_test.go @@ -1,34 +1,12 @@ package catalogue import ( - "strings" "testing" ) -// The module that provides the artifact store may not be delivered through it. -// -// Building publishes to the store and the builder will not start without one, so a module that -// provides the store and also builds something asks the mesh to put an artifact into the thing -// that artifact is needed to create. On a mesh new enough to have no registry, that is a build -// that never returns (novox/hq 04-ISSUES/029). -func TestTheArtifactStoreCannotBeDeliveredThroughItself(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"registry","version":"1",` + - `"provides":[{"name":"artifact-store","scope":"mesh"}],` + - `"build":{"artifacts":[{"name":"registry","kind":"upstream","from":"registry:2"}]},` + - `"resources":[{"id":"store","type":"container","name":"mesh-registry",` + - `"artifact":"registry","ports":["5000:5000"]}]}`)) - if err == nil { - t.Fatal("a registry module that builds its own image was accepted; the build has " + - "nowhere to publish until the module it belongs to is already running") - } - if !strings.Contains(err.Error(), "artifact-store") { - t.Fatalf("refused without naming the provision the cycle turns on: %v", err) - } -} - // Naming the image directly is the way out, and must stay accepted. func TestAnArtifactStoreThatNamesItsImageIsAccepted(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"registry","version":"1",` + + _, err := decodeForTest([]byte(`{"module":"registry","version":"1",` + `"provides":[{"name":"artifact-store","scope":"mesh"}],` + `"resources":[{"id":"store","type":"container","name":"mesh-registry",` + `"image":"registry@sha256:` + @@ -41,7 +19,7 @@ func TestAnArtifactStoreThatNamesItsImageIsAccepted(t *testing.T) { // And an ordinary module still builds whatever it likes. func TestAModuleThatDoesNotProvideTheStoreStillBuilds(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"forge","version":"1",` + + _, err := decodeForTest([]byte(`{"module":"forge","version":"1",` + `"build":{"artifacts":[{"name":"forge","kind":"upstream","from":"gitea/gitea:1.22"}]},` + `"resources":[{"id":"run","type":"container","name":"forge","artifact":"forge"}]}`)) if err != nil { diff --git a/go/catalogue/brokered_test.go b/go/catalogue/brokered_test.go index c379f71..0ff4800 100644 --- a/go/catalogue/brokered_test.go +++ b/go/catalogue/brokered_test.go @@ -174,7 +174,7 @@ func TestALocalRequirementIsStillAnsweredLocally(t *testing.T) { func TestTheShortFormStillMeansHere(t *testing.T) { // `"provides": ["shell"]` must keep meaning what it meant, or every existing manifest // silently changes meaning. - m, err := ParseManifest([]byte(`{"module":"zsh","version":"1","provides":["shell"]}`)) + m, err := decodeForTest([]byte(`{"module":"zsh","version":"1","provides":["shell"]}`)) if err != nil { t.Fatal(err) } @@ -183,19 +183,6 @@ func TestTheShortFormStillMeansHere(t *testing.T) { } } -func TestASiteScopedProvisionIsRefused(t *testing.T) { - // Meaningful for a claim — one DHCP server per segment — and not yet meaningful for a - // provision, because nothing knows how to reach "the one at my site". - _, err := ParseManifest([]byte( - `{"module":"dns","version":"1","provides":[{"name":"resolver","scope":"site"}]}`)) - if err == nil { - t.Fatal("a site-scoped provision was accepted") - } - if !strings.Contains(err.Error(), "site") { - t.Fatalf("unhelpful refusal: %v", err) - } -} - // Being told, which is the difference between knowing and being able to. func boundShelf() map[string]Manifest { @@ -308,25 +295,3 @@ func TestBindingSomethingAnsweredHereWritesNothing(t *testing.T) { } } } - -func TestBindingWhatYouDoNotRequireIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"app","version":"1", - "binds":{"postgres-database":"/etc/app/db.json"}}`)) - if err == nil { - t.Fatal("a module was told about something it never asked for") - } - if !strings.Contains(err.Error(), "does not require") { - t.Fatalf("unhelpful refusal: %v", err) - } -} - -func TestServingWhatYouDoNotProvideIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"app","version":"1", - "serves":{"postgres-database":{"port":5432}}}`)) - if err == nil { - t.Fatal("a module served something it does not provide") - } - if !strings.Contains(err.Error(), "does not provide") { - t.Fatalf("unhelpful refusal: %v", err) - } -} diff --git a/go/catalogue/bucket_shares_test.go b/go/catalogue/bucket_shares_test.go index 87345f4..c35b297 100644 --- a/go/catalogue/bucket_shares_test.go +++ b/go/catalogue/bucket_shares_test.go @@ -88,7 +88,7 @@ func TestAMalformedShareIsRefused(t *testing.T) { "other require": `{"name":"route","shares":[{"to":"a","methods":["get"]}]}`, } for name, entry := range cases { - _, err := ParseManifest([]byte(`{"module":"files","version":"1","requires":[` + entry + `]}`)) + _, err := decodeForTest([]byte(`{"module":"files","version":"1","requires":[` + entry + `]}`)) if err == nil { t.Errorf("%s: accepted", name) continue @@ -98,7 +98,7 @@ func TestAMalformedShareIsRefused(t *testing.T) { } } // The stored form is held to the same rules. - _, err := ParseManifest([]byte(`{"module":"files","version":"1","requires":["s3-bucket"], + _, err := decodeForTest([]byte(`{"module":"files","version":"1","requires":["s3-bucket"], "requires-shares":{"s3-bucket":[{"to":"a","methods":[]}]}}`)) if err == nil || !strings.Contains(err.Error(), "ADR 0468 §8") { t.Errorf("the stored form was accepted: %v", err) @@ -169,7 +169,7 @@ func TestChangingOnlyTheSharesDoesNotRecreateTheConsumer(t *testing.T) { // 1591/AC5: a consumer whose contributes to s3-bucket names `shares` itself is refused. func TestAConsumerCannotContributeSharesItself(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"files","version":"1", + _, err := decodeForTest([]byte(`{"module":"files","version":"1", "contributes":{"s3-bucket":{"shares":"x"}}, "requires":[{"name":"s3-bucket","shares":[{"to":"a","methods":["get"]}]}]}`)) if err == nil || !strings.Contains(err.Error(), "ADR 0468 §8") { diff --git a/go/catalogue/build.go b/go/catalogue/build.go index 7c2a4b6..9dbce70 100644 --- a/go/catalogue/build.go +++ b/go/catalogue/build.go @@ -2,10 +2,7 @@ package catalogue import ( "fmt" - "net" - "net/url" "path" - "regexp" "sort" "strings" ) @@ -237,152 +234,6 @@ func withVersion(value any, version string) any { return value } -// checkBuild is the manifest's own account of what it builds. -func (b *Build) problems(module string) []string { - if b == nil { - return nil - } - var problems []string - seen := map[string]bool{} - for _, a := range b.Artifacts { - if a.Name == "" { - problems = append(problems, module+" builds an artifact with no name") - continue - } - if seen[a.Name] { - problems = append(problems, fmt.Sprintf( - "%s builds two artifacts called %q, and a resource naming it could mean either", - module, a.Name)) - } - seen[a.Name] = true - switch a.Kind { - case ArtifactImage, ArtifactArchive, ArtifactUpstream, ArtifactBundle, ArtifactPackage: - default: - problems = append(problems, fmt.Sprintf( - "%s: %q is a %q, and an artifact is %q, %q, %q or %q", - module, a.Name, a.Kind, ArtifactImage, ArtifactArchive, ArtifactUpstream, - ArtifactBundle+", "+ArtifactPackage)) - } - // **A bundle is built from the module itself, so it says a language instead.** Everything - // else names what it is built from: a Dockerfile, a directory, somebody else's reference. - // A bundle's source is the module's own directory by definition, and what it needs to say - // is which compiler — because the mesh chooses that, and cannot choose for a module that - // has not said. - if len(a.External) > 0 && (a.Kind != ArtifactBundle || a.Language != "typescript") { - problems = append(problems, fmt.Sprintf( - "%s: %q names packages it keeps external, and only a TypeScript bundle is bundled into "+ - "one file with some kept out (novox/hq ADR 0193)", module, a.Name)) - } - if len(a.Env) > 0 && a.Kind != ArtifactBundle { - problems = append(problems, fmt.Sprintf( - "%s: %q is a %q and says what it is given (env). Only a bundle the node's runtime "+ - "serves is given words (novox/hq ADR 0192); a container says its own environment", - module, a.Name, a.Kind)) - } - if a.Kind == ArtifactBundle || a.Kind == ArtifactPackage { - // **Except for a language that compiles to a binary, where it names which one** - // (novox/hq 04-ISSUES/142). A bundle in an interpreted language is the module's own - // directory compiled whole, and naming a source would be describing its own build. A - // repository written in a compiled language holds several commands — the host and its - // bootstrap live in one, and the mesh needs the host — and "the module's own directory" - // is then not a package at all. So the compiled case may say which package, and says - // the module root by saying nothing. - if a.From != "" && !compilesToABinary(a.Language) { - problems = append(problems, fmt.Sprintf( - "%s: %q is a bundle and names what it is built from (%q). A bundle is built "+ - "from the module's own directory; what it says is the language", - module, a.Name, a.From)) - } - if strings.TrimSpace(a.Language) == "" { - problems = append(problems, fmt.Sprintf( - "%s: %q is a bundle and says no language, so nothing can choose a compiler "+ - "for it", module, a.Name)) - } - problems = append(problems, bundleEnvProblems(module, a)...) - // What the runtime loads is among what was compiled (ADR 0175): a name here that is - // not an entrypoint is a file the bundle does not contain, and the runtime would - // fail to import it on every machine rather than here. - for _, load := range a.Loads { - found := false - for _, e := range a.Entrypoints { - found = found || e == load - } - // A bundle compiled to a binary is one executable: the runtime loads that or nothing - // (novox/hq ADR 0193). - if bin := BinaryOf(a); bin != "" { - found = load == bin - } - if !found { - problems = append(problems, fmt.Sprintf( - "%s: %q says the runtime loads %q, which is not among its entrypoints — "+ - "what is loaded is compiled, so it is named there too", module, a.Name, load)) - } - } - // **A system, for a language that compiles to a binary** (novox/hq ADR 0142). A binary - // is pinned to one operating system at link time so a host refuses to touch a machine - // it was not built for (novox/hq ADR 0005); an artifact that says nothing would be - // compiled for whatever the build machine happened to be, which reads as portable and - // is not. - if compiled := compilesToABinary(a.Language); compiled && strings.TrimSpace(a.System) == "" { - problems = append(problems, fmt.Sprintf( - "%s: %q is compiled to a binary and says no system, so it would be built for "+ - "whatever the build machine happens to be. Declare one artifact per "+ - "system: %s", module, a.Name, spokenSystems())) - } else if !compiled && strings.TrimSpace(a.System) != "" { - problems = append(problems, fmt.Sprintf( - "%s: %q names the system %q and is written in %q, which compiles to code that "+ - "runs anywhere — a system that decides nothing reads as though it did", - module, a.Name, a.System, a.Language)) - } else if compiled && !knownSystem(a.System) { - problems = append(problems, fmt.Sprintf( - "%s: %q is built for %q, and a system is %s", - module, a.Name, a.System, spokenSystems())) - } - } else { - if a.From == "" && a.URL == "" { - problems = append(problems, fmt.Sprintf( - "%s: %q says nothing about what it is built from", module, a.Name)) - } - if a.Language != "" { - problems = append(problems, fmt.Sprintf( - "%s: %q is a %q and names a language. Only a bundle is compiled by the mesh; "+ - "everything else brings its own recipe", module, a.Name, a.Kind)) - } - } - if a.Compiles != "" { - if a.Kind != ArtifactImage { - problems = append(problems, fmt.Sprintf( - "%s: %q is a %q and names a Go package it compiles. Only an image's recipe is "+ - "told which; a bundle names its package in from (novox/hq ADR 0267)", module, a.Name, a.Kind)) - } else if c := strings.TrimSpace(a.Compiles); strings.HasPrefix(c, "/") || c == ".." || - strings.HasPrefix(c, "../") || strings.Contains(c, "/../") || strings.HasSuffix(c, "/..") { - problems = append(problems, fmt.Sprintf( - "%s: %q compiles %q, which leaves the tree it is built in", module, a.Name, a.Compiles)) - } - } - // An upstream image is named, not read from the repository, so the path rule does not - // apply to it — and applying it anyway would refuse every reference with a registry host - // in it. - if a.Kind != ArtifactUpstream && - (strings.HasPrefix(a.From, "/") || strings.Contains(a.From, "..")) { - // 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 building it. - problems = append(problems, fmt.Sprintf( - "%s: %q is built from %q, which is outside its own repository", - module, a.Name, a.From)) - } - if a.Kind == ArtifactUpstream && !strings.Contains(a.From, ":") { - // Without a tag or digest, what gets mirrored is whatever `latest` means today, and - // a module pinned to that is not pinned. - problems = append(problems, fmt.Sprintf( - "%s: %q mirrors %q, which names no tag or digest", module, a.Name, a.From)) - } - problems = append(problems, upstreamFileProblems(module, a)...) - problems = append(problems, pathsProblems(module, a)...) - } - return problems -} - // NamedPaths are the paths a module's fingerprint takes its tree from (novox/hq ADR 0499 clause 2): the union of the // paths its artifacts name, cleaned and sorted, and every true only when the module builds something and every artifact // names paths; otherwise none, and the module keeps its directory's tree. @@ -410,122 +261,6 @@ func (m Manifest) NamedPaths() (paths []string, every bool) { return paths, true } -// pathsProblems is what is wrong with an artifact's `paths` (novox/hq ADR 0499 clause 1): named on a kind whose build -// source is derived or named otherwise, or a path that leaves the module's directory, is its whole directory, or is a -// pattern. -func pathsProblems(module string, a Artifact) []string { - if a.Paths == nil { - return nil - } - switch { - case a.Kind == ArtifactBundle && a.Language == "go": - return []string{fmt.Sprintf("%s: %q is a Go program and names paths: its build source is derived from the "+ - "package it compiles (novox/hq ADR 0267 clause 1, ADR 0499 clause 1)", module, a.Name)} - case a.Kind != ArtifactBundle && a.Kind != ArtifactPackage: - return []string{fmt.Sprintf("%s: %q is a %q and names paths; only a package or a bundle in a language other "+ - "than Go names the paths it is built from (novox/hq ADR 0499 clause 1)", module, a.Name, a.Kind)} - case len(a.Paths) == 0: - return []string{fmt.Sprintf("%s: %q names no path in paths; leave the word out to build from the module's "+ - "directory", module, a.Name)} - } - var problems []string - for _, p := range a.Paths { - clean := path.Clean(p) - switch { - case strings.TrimSpace(p) == "": - problems = append(problems, fmt.Sprintf("%s: %q names an empty path", module, a.Name)) - case strings.HasPrefix(p, "/") || clean == ".." || strings.HasPrefix(clean, "../"): - problems = append(problems, fmt.Sprintf("%s: %q names the path %q, which leaves the module's directory", - module, a.Name, p)) - case clean == ".": - problems = append(problems, fmt.Sprintf("%s: %q names the module's whole directory (%q) as a path; leave "+ - "the word out for that", module, a.Name, p)) - case strings.ContainsAny(p, "*?[]{}\\"): - problems = append(problems, fmt.Sprintf("%s: %q names %q, which is a pattern; paths names files and "+ - "directories", module, a.Name, p)) - } - } - return problems -} - -// reservedSuffixes are names no public vendor publishes under: an installation's own and documentation's. -var reservedSuffixes = []string{".internal", ".local", ".lan", ".home", ".corp", ".localhost", ".home.arpa", - ".test", ".invalid", ".example", ".intranet", ".private"} - -// notAPublicHost says why a host is not a public vendor's name, or "". -func notAPublicHost(host string) string { - h := strings.ToLower(strings.TrimSuffix(host, ".")) - if net.ParseIP(h) != nil { - return "an address rather than a name" - } - if !strings.Contains(h, ".") { - return "a name with no domain" - } - for _, s := range reservedSuffixes { - if strings.HasSuffix(h, s) { - return "a name under " + s + ", which is nobody's public domain" - } - } - return "" -} - -// sha256Hex is a pinned sha256 as a manifest writes it: 64 lower-case hexadecimal characters. -var sha256Hex = regexp.MustCompile(`^[0-9a-f]{64}$`) - -// upstreamFileProblems is what is wrong with an archive pinned by URL (novox/hq ADR 0316 §2, §6): it is an -// archive, fetched over https, pinned by a sha256 and a version, and it names no directory of the repository -// besides; a file name or a directory within is a plain path that stays where it is put. -func upstreamFileProblems(module string, a Artifact) []string { - pinned := a.URL != "" || a.SHA256 != "" || a.Version != "" || a.File != "" || a.Within != "" - if !pinned { - return nil - } - var problems []string - say := func(format string, args ...any) { - problems = append(problems, fmt.Sprintf("%s: %q ", module, a.Name)+fmt.Sprintf(format, args...)) - } - if a.Kind != ArtifactArchive { - say("is a %q and names an upstream file (url, sha256, version, file, within): only an archive is "+ - "mirrored from one (novox/hq ADR 0316)", a.Kind) - return problems - } - if a.URL == "" { - say("names a sha256, a version, a file or a directory within, and no url to fetch it from") - return problems - } - if a.From != "" { - say("is built from %q and fetched from %s: an archive is one or the other", a.From, a.URL) - } - if u, err := url.Parse(a.URL); err != nil || u.Scheme != "https" || u.Host == "" { - say("is fetched from %q, and an upstream file is fetched over https from a named host", a.URL) - } else if why := notAPublicHost(u.Hostname()); why != "" { - say("is fetched from %s, %s: an upstream file is somebody else's release, published under a public name", a.URL, why) - } - if !sha256Hex.MatchString(a.SHA256) { - say("is fetched from %s without a pinned sha256 (64 lower-case hexadecimal characters): what that "+ - "address serves changes under the mesh, and only the digest says it is what was reviewed", a.URL) - } - if strings.TrimSpace(a.Version) == "" { - say("pins %s and says no version, so a person reading the pin cannot tell which release it is", a.URL) - } - if a.File != "" && a.Within != "" { - say("names a file (%q) and a directory within (%q): a file is kept whole, a directory is taken out "+ - "of an archive", a.File, a.Within) - } - for field, p := range map[string]string{"file": a.File, "within": a.Within} { - if p == "" { - continue - } - clean := path.Clean(p) - if clean != p || strings.HasPrefix(p, "/") || clean == "." || clean == ".." || strings.HasPrefix(clean, "../") || - (field == "file" && strings.Contains(p, "/")) { - say("names %s %q, which is not a plain relative %s", field, p, map[string]string{ - "file": "file name", "within": "directory"}[field]) - } - } - return problems -} - // oneOrOther keeps the message readable for one artifact and for several, because a message that // says "neither" about one thing reads as a bug in the message. func oneOrOther(n int) string { @@ -535,30 +270,6 @@ func oneOrOther(n int) string { return "them" } -// Systems the mesh builds binaries for, which is the set a host may be pinned to (novox/hq ADR 0005). -// -// **A closed list, and the host's own, not the compiler's.** These are not the values a Go toolchain -// would call an operating system — the difference between two of them is a C library, not a kernel. -// They are what a machine reports itself to be and what a host is linked to refuse, so the list that -// matters is the one the host understands. -var systems = []string{"alpine", "android", "arch"} - -// knownSystem is whether the mesh builds for it. -func knownSystem(system string) bool { - want := strings.ToLower(strings.TrimSpace(system)) - for _, s := range systems { - if s == want { - return true - } - } - return false -} - -// spokenSystems is the list as a refusal says it, so a reader is one edit from right. -func spokenSystems() string { - return strings.Join(systems, ", ") -} - // compilesToABinary is whether this language's bundle is a binary for one operating system rather // than code that runs wherever its interpreter does. // @@ -599,42 +310,6 @@ func versionOf(digest string) string { return hex } -// bundleEnvWords are the words the runtime sets for itself; a bundle that named one would be -// telling the runtime what it is, which is the mesh's to say (novox/hq ADR 0192). -var bundleEnvWords = map[string]bool{ - RuntimeToolModules: true, RuntimeBrokerFile: true, RuntimeOperatorAccount: true, - RuntimeOperatorHome: true, RuntimeToolEnv: true, RuntimeAgentAccount: true, RuntimeAgentHome: true, -} - -// bundleEnvProblems says what is wrong with what a bundle says it is given (novox/hq ADR 0192): -// a value is a path or a constant written with the references a container's environment may use -// for a place or a port, and never a secret's content or another module's binding — a secret -// reaches a tool as a file whose path is named. -func bundleEnvProblems(module string, a Artifact) []string { - if len(a.Env) == 0 { - return nil - } - var problems []string - for _, word := range sortedKeys(a.Env) { - value := a.Env[word] - if bundleEnvWords[word] { - problems = append(problems, fmt.Sprintf( - "%s: %q gives itself %s, which the node's runtime sets for itself; a bundle is "+ - "given its own words beside the runtime's, never in place of them (novox/hq ADR 0192)", - module, a.Name, word)) - } - rest := ofPort.ReplaceAllString(dirRef.ReplaceAllString(value, ""), "") - if strings.Contains(rest, "${") { - problems = append(problems, fmt.Sprintf( - "%s: %q gives %s the value %q. A bundle's word is a path or a constant, written with "+ - "${dir:…} and ${port:…} only; a secret reaches a tool as a file the mesh places, "+ - "named by its path, never as its content (novox/hq ADR 0192)", - module, a.Name, word, value)) - } - } - return problems -} - func copyWords(in map[string]string) map[string]string { if len(in) == 0 { return nil @@ -675,31 +350,6 @@ func runByAResource(m Manifest) map[string]bool { return named } -// undeliveredBundles says which of a module's bundles nothing would ever put on a machine (novox/hq -// 04-ISSUES/216). A bundle reaches a machine three ways: the node's runtime serves it (it says -// `loads`, or its module declares `tools`), a resource names it (a process, a step, an archive), or -// it is the runtime itself. One reached by none of them was built, recorded and pushed as success, -// and was simply absent — seven modules' tools went missing that way on 2026-10-03. Refused here, -// naming the field that would deliver it. -func undeliveredBundles(m Manifest) []string { - if m.Build == nil || m.Module == RuntimeModule { - return nil - } - named := runByAResource(m) - var problems []string - for _, a := range m.Build.Artifacts { - if a.Kind != ArtifactBundle || named[a.Name] || len(a.Loads) > 0 || len(m.Tools) > 0 { - continue - } - problems = append(problems, fmt.Sprintf( - "%s: the bundle %q would be built and never reach a machine: nothing loads it, runs it or "+ - "unpacks it. A tools bundle says `loads` (the entrypoints the node's runtime serves) or its "+ - "module lists its `tools`; a daemon or a step is a resource naming it (novox/hq 04-ISSUES/216)", - m.Module, a.Name)) - } - return problems -} - // BundleLoads is what the runtime loads of the bundle artifact a of m (run: the bundles one of m's own resources runs, // runByAResource): what the artifact said, else every entrypoint of a module that declares tools, else nothing; a // bundle compiled to a binary is served by its binary. The rule a build's resolution keeps on Bundle.Loads, and what a diff --git a/go/catalogue/build_system_test.go b/go/catalogue/build_system_test.go index e1a97dd..99cefec 100644 --- a/go/catalogue/build_system_test.go +++ b/go/catalogue/build_system_test.go @@ -1,82 +1,8 @@ package catalogue -import ( - "strings" - "testing" -) - // bundleFor is a manifest whose one artifact is a bundle in the given language and system. func bundleFor(language, system string) Manifest { return Manifest{Module: "a-component", Build: &Build{Artifacts: []Artifact{ {Name: "binary", Kind: ArtifactBundle, Language: language, System: system}, }}} } - -func problemsOf(t *testing.T, m Manifest) string { - t.Helper() - return strings.Join(m.Build.problems(m.Module), "\n") -} - -// **A language that compiles to a binary must say which system.** -// -// A binary is pinned to one operating system at link time, so a host refuses to touch a machine it -// was not built for. An artifact that says nothing would be compiled for whatever the build machine -// happened to be — which reads as portable and is not, and is the fault this check exists for. -func TestABinaryMustSayWhichSystemItIsFor(t *testing.T) { - got := problemsOf(t, bundleFor("go", "")) - if !strings.Contains(got, "says no system") { - t.Fatalf("a compiled bundle with no system was accepted:\n%s", got) - } - // And the refusal names what it could have said, so a reader is one edit from right. - for _, system := range []string{"alpine", "android", "arch"} { - if !strings.Contains(got, system) { - t.Fatalf("the refusal does not name %q as a choice:\n%s", system, got) - } - } -} - -func TestABinaryThatNamesASystemIsAccepted(t *testing.T) { - if got := problemsOf(t, bundleFor("go", "arch")); got != "" { - t.Fatalf("a compiled bundle naming a system was refused:\n%s", got) - } -} - -// A system the mesh does not build for is refused where it is written. These are the host's own -// names, not a compiler's: the difference between two of them is a C library rather than a kernel, -// so a value that looks like an operating system to a toolchain is still wrong here. -func TestASystemTheMeshDoesNotBuildForIsRefused(t *testing.T) { - for _, wrong := range []string{"linux", "debian", "darwin"} { - got := problemsOf(t, bundleFor("go", wrong)) - if !strings.Contains(got, "and a system is") { - t.Fatalf("%q was accepted as a system:\n%s", wrong, got) - } - } -} - -// **And a language that runs anywhere must not name one.** A system that decides nothing reads as -// though it did, which is the same fault as a restriction that restricts nothing (novox/hq ADR 0045). -func TestAPortableBundleMayNotNameASystem(t *testing.T) { - got := problemsOf(t, bundleFor("typescript", "arch")) - if !strings.Contains(got, "runs anywhere") { - t.Fatalf("a portable bundle was allowed to name a system:\n%s", got) - } -} - -func TestAPortableBundleNamingNoSystemIsAccepted(t *testing.T) { - if got := problemsOf(t, bundleFor("typescript", "")); got != "" { - t.Fatalf("an ordinary bundle was refused:\n%s", got) - } -} - -// One component, one artifact per system: the shape the mesh's own binaries are declared in, and the -// reason the target is the artifact's rather than the recipe's. -func TestOneArtifactPerSystemIsAccepted(t *testing.T) { - m := Manifest{Module: "the-host", Build: &Build{Artifacts: []Artifact{ - {Name: "arch", Kind: ArtifactBundle, Language: "go", System: "arch"}, - {Name: "alpine", Kind: ArtifactBundle, Language: "go", System: "alpine"}, - {Name: "android", Kind: ArtifactBundle, Language: "go", System: "android"}, - }}} - if got := problemsOf(t, m); got != "" { - t.Fatalf("one artifact per system was refused:\n%s", got) - } -} diff --git a/go/catalogue/build_test.go b/go/catalogue/build_test.go index c17c0eb..aa9be4c 100644 --- a/go/catalogue/build_test.go +++ b/go/catalogue/build_test.go @@ -108,68 +108,8 @@ func TestAModuleThatBuildsNothingIsOrdinary(t *testing.T) { } } -func TestTwoArtifactsWithOneNameAreRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ - {"name":"x","kind":"image","from":"Dockerfile"}, - {"name":"x","kind":"archive","from":"files"}]}}`)) - if err == nil { - t.Fatal("two artifacts with one name were accepted") - } - if !strings.Contains(err.Error(), "could mean either") { - t.Fatalf("unhelpful refusal: %v", err) - } -} - -func TestAnArtifactBuiltFromOutsideItsRepositoryIsRefused(t *testing.T) { - // A build reads its own repository and nothing else. A path leaving it would make what gets - // built depend on whatever happens to be on the machine doing the building. - for _, from := range []string{"/etc/passwd", "../elsewhere"} { - _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ - {"name":"x","kind":"archive","from":"` + from + `"}]}}`)) - if err == nil { - t.Fatalf("%q was accepted as a build input", from) - } - if !strings.Contains(err.Error(), "outside its own repository") { - t.Fatalf("refused for the wrong reason: %v", err) - } - } -} - -func TestAnArtifactOfAnUnknownKindIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ - {"name":"x","kind":"binary","from":"main.go"}]}}`)) - if err == nil { - t.Fatal("an artifact of an unknown kind was accepted") - } -} - -func TestAPackageParsesLikeABundleAndNeedsALanguage(t *testing.T) { - // The SDK's shape: a package built from the module's own directory, naming a language. - m, err := ParseManifest([]byte(`{"module":"mesh-sdk","version":"1","slug":"sdk", - "build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript"}]}, - "resources":[]}`)) - if err != nil { - t.Fatalf("the SDK's package manifest did not parse: %v", err) - } - if m.Build.Artifacts[0].Kind != ArtifactPackage { - t.Fatalf("expected a package artifact, got %q", m.Build.Artifacts[0].Kind) - } - - // A package that names what it is built from is refused, exactly as a bundle is: it is built - // from the module's own directory. - if _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ - {"name":"lib","kind":"package","language":"typescript","from":"Dockerfile"}]}}`)); err == nil { - t.Fatal("a package naming a source was accepted") - } - // A package with no language cannot choose a toolchain. - if _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ - {"name":"lib","kind":"package"}]}}`)); err == nil { - t.Fatal("a package with no language was accepted") - } -} - func TestAResourceNamingAPackageIsRefused(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"a","version":"1","slug":"a", + m, err := decodeForTest([]byte(`{"module":"a","version":"1","slug":"a", "build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript"}]}, "resources":[{"id":"svc","type":"container","name":"a","artifact":"lib"}]}`)) if err != nil { @@ -180,24 +120,3 @@ func TestAResourceNamingAPackageIsRefused(t *testing.T) { t.Fatal("a resource backed by a package was accepted; a package is not a resource") } } - -// An image names the Go package its recipe compiles (novox/hq ADR 0267): within the tree it is built in, -// and only an image says one. -func TestOnlyAnImageNamesThePackageItCompilesWithinItsTree(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[ - {"name":"x","kind":"image","from":"Dockerfile","compiles":"cmd/x"}]}}`)) - if err != nil || m.Build.Artifacts[0].Compiles != "cmd/x" { - t.Fatalf("an image naming its package was refused or lost it: %v", err) - } - for _, c := range []struct{ artifact, says string }{ - {`{"name":"x","kind":"archive","from":"files","compiles":"cmd/x"}`, "Only an image"}, - {`{"name":"x","kind":"image","from":"Dockerfile","compiles":"../x"}`, "leaves the tree"}, - {`{"name":"x","kind":"image","from":"Dockerfile","compiles":"/x"}`, "leaves the tree"}, - {`{"name":"x","kind":"image","from":"Dockerfile","compiles":"a/../../x"}`, "leaves the tree"}, - } { - _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[` + c.artifact + `]}}`)) - if err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: %v, wanted a refusal saying %q", c.artifact, err, c.says) - } - } -} diff --git a/go/catalogue/bus_snapshot_test.go b/go/catalogue/bus_snapshot_test.go index 5f8edb3..69bd737 100644 --- a/go/catalogue/bus_snapshot_test.go +++ b/go/catalogue/bus_snapshot_test.go @@ -1,7 +1,6 @@ package catalogue import ( - "encoding/json" "os" "path/filepath" "strings" @@ -10,54 +9,6 @@ import ( "git.novox.be/novox/mesh-sdk/go/internal/beside" ) -// The module holding mesh-broker is the bus, and its account is granted the bus's snapshot API and -// nothing else (novox/hq ADR 0235). Anything it declared to say or hear on the bus would be granted -// nothing, so it is refused at the parser rather than silently dropped. -func TestTheBussAccountSaysNothingOnTheBus(t *testing.T) { - raw := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}], - "claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"}, - "emits":["something.happened"]}`) - _, err := ParseManifest(raw) - if err == nil || !strings.Contains(err.Error(), "granted the bus's snapshot API and nothing else") { - t.Fatalf("a bus module declaring what it emits was not refused, or not for the reason: %v", err) - } - - // Its tools bundle says what it emits (novox/hq ADR 0357): the node's runtime carries the bundle and is - // granted the module's events, so a bus module that ships one may declare them. - bundled := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}], - "claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"}, - "emits":["bus.slow-consumer"],"tools":["bus_streams"], - "build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/t","binary":"t"}]}}`) - 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([]Built{{Name: "tools", Kind: 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(BrokerSeat) { - t.Fatal("it does not read as holding the broker seat") - } -} - // The catalogue's bus protects its streams by the snapshot, not as live files: its streams' item is a // dump into its snapshots, it has the account the dump runs as, and the dump runs the snapshot // program in the bus's own container. @@ -66,7 +17,7 @@ func TestTheCataloguesBusIsBackedUpBySnapshot(t *testing.T) { if err != nil { t.Fatal(err) } - m, err := ParseManifest(raw) + m, err := decodeForTest(raw) if err != nil { t.Fatal(err) } diff --git a/go/catalogue/busy.go b/go/catalogue/busy.go index 18cbbf0..bf56b3a 100644 --- a/go/catalogue/busy.go +++ b/go/catalogue/busy.go @@ -80,34 +80,6 @@ func ReadBusy(r map[string]any) (Busy, bool, []string) { return b, true, problems } -// busyProblems is everything wrong with a manifest's `busy` fields, in the manifest's words: a command on -// one line, a timeout within its bounds, and only on a container that stays up. -func busyProblems(m Manifest) []string { - var problems []string - for _, r := range m.Resources { - b, has, read := ReadBusy(r) - if !has { - continue - } - where := m.Module + ": " + fmt.Sprint(r["id"]) - for _, p := range read { - problems = append(problems, m.Module+": "+p) - } - if fmt.Sprint(r["type"]) != "container" || !LongRunning(r) { - problems = append(problems, fmt.Sprintf("%s declares busy and is not a container that stays up: busy is "+ - "asked of a running container before it is replaced (ADR 0335)", where)) - } - if b.Command == "" { - problems = append(problems, where+"'s busy names no command: one run inside the container, exiting 0 "+ - "while it is busy") - } - if strings.ContainsAny(b.Command, "\n\r") { - problems = append(problems, where+"'s busy command is one line") - } - } - return problems -} - // busyInto composes a container's `busy` into the node-engine's words, or takes it away: for an engine that // reads it, the command and its timeout written out; for one that does not, the field is not sent, and the // container is replaced at once, as before. diff --git a/go/catalogue/busy_test.go b/go/catalogue/busy_test.go index 6ffca3d..42ca6d4 100644 --- a/go/catalogue/busy_test.go +++ b/go/catalogue/busy_test.go @@ -2,7 +2,6 @@ package catalogue import ( "encoding/json" - "strings" "testing" "git.novox.be/novox/mesh-sdk/go/validate" @@ -32,35 +31,8 @@ func busyManifest(t *testing.T, on string, busy any) []byte { return out } -func TestABusyIsHeldToItsShape(t *testing.T) { - if _, err := ParseManifest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"})); err != nil { - t.Fatalf("a well-formed busy was refused: %v", err) - } - if _, err := ParseManifest(busyManifest(t, "agent", map[string]any{"command": "x", "timeout": "30s"})); err != nil { - t.Fatalf("a busy with its timeout was refused: %v", err) - } - for name, c := range map[string]struct { - on string - busy any - says string - }{ - "text": {"agent", "mesh-builder busy", "it is an object"}, - "no command": {"agent", map[string]any{"timeout": "5s"}, "names no command"}, - "two lines": {"agent", map[string]any{"command": "a\nb"}, "one line"}, - "a long timeout": {"agent", map[string]any{"command": "x", "timeout": "5m"}, "at most 1m0s"}, - "an unknown key": {"agent", map[string]any{"command": "x", "every": "1m"}, `says "every"`}, - "on a step": {"seed", map[string]any{"command": "x"}, "not a container that stays up"}, - "on a service": {"daemon", map[string]any{"command": "x"}, "not a container that stays up"}, - } { - _, err := ParseManifest(busyManifest(t, c.on, c.busy)) - if err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: want a refusal saying %q, got %v", name, c.says, err) - } - } -} - func TestABusyIsSentOnlyToAnEngineThatReadsIt(t *testing.T) { - m, err := ParseManifest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"})) + m, err := decodeForTest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"})) if err != nil { t.Fatal(err) } diff --git a/go/catalogue/catalogue_check_test.go b/go/catalogue/catalogue_check_test.go index ffc7cf8..b2f8e60 100644 --- a/go/catalogue/catalogue_check_test.go +++ b/go/catalogue/catalogue_check_test.go @@ -2,7 +2,6 @@ package catalogue import ( "path/filepath" - "strings" "testing" "git.novox.be/novox/mesh-sdk/go/internal/beside" @@ -45,78 +44,3 @@ func TestEveryCatalogueManifestParses(t *testing.T) { t.Fatal("no endpoint in the catalogue is named, so this proved nothing") } } - -// TestNoCatalogueManifestNamesAnInstallation is ADR 0112's check, run over the real catalogue: no -// definition names a domain or a public address the mesh acts on, and every value that must for now -// carries its reason (novox/hq ADR 0155, issue 134). The list it prints is the one that shrinks. -func TestNoCatalogueManifestNamesAnInstallation(t *testing.T) { - _, 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 ")) - } -} - -// 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 := 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") - } -} - -// 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 := 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, 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) - } - } -} diff --git a/go/catalogue/collections.go b/go/catalogue/collections.go index c2a6562..3dffb3a 100644 --- a/go/catalogue/collections.go +++ b/go/catalogue/collections.go @@ -113,12 +113,7 @@ var oldCollectionNamespaces = []string{"shell", "environment", "contribution"} var ( // ofCollected is where a collector places a collection. Loose inside the braces, so a misspelt name is refused. - ofCollected = regexp.MustCompile(`\$\{collected:([^}]*)\}`) - // collectionName is words joined by colons, so an old namespace's placeholder names its collection as it reads. - collectionName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*(:[a-z0-9][a-z0-9-]*)*$`) - // itemName, attributeKey and attributeValue are the words an item's name and attributes are made of; none holds - // a colon, a comma or an equals sign, which tell a selector from a collection's name. - itemName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + ofCollected = regexp.MustCompile(`\$\{collected:([^}]*)\}`) attributeKey = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) attributeValue = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`) ) @@ -242,190 +237,6 @@ func placerOfOld(name string) string { return canonicalSeat(seat) } -// collectionProblems is what is wrong with a module's collections and items, from the manifest alone. -func collectionProblems(m Manifest) []string { - var problems []string - declared := map[string]bool{} - for i, c := range m.Collections { - switch { - case !collectionName.MatchString(c.Name): - problems = append(problems, fmt.Sprintf("%s's collection %d is %q, which is not a collection's name: "+ - "words of lower-case letters, digits and hyphens, joined by colons", m.Module, i+1, c.Name)) - case declared[c.Name]: - problems = append(problems, fmt.Sprintf("%s collects %s twice", m.Module, c.Name)) - } - if _, old, _ := oldCollection(c.Name, false); old { - problems = append(problems, fmt.Sprintf("%s collects %s, which is read from its placeholder: a module "+ - "places it as ${%s} where it holds the seat that places it, and declares nothing", m.Module, c.Name, - c.Name)) - } - if strings.ContainsAny(c.Comment, "\n\r") { - problems = append(problems, fmt.Sprintf("%s's collection %s has a comment holding a line break", - m.Module, c.Name)) - } - declared[c.Name] = true - } - // A collection declared and placed nowhere is a word read by nothing; judged where every file's text is here. - if !m.Templated() { - named := map[string]bool{} - for _, r := range m.Resources { - if text, ok := r["content"].(string); ok { - for _, f := range ofCollected.FindAllStringSubmatch(text, -1) { - base, _ := splitSelector(f[1]) - named[base] = true - } - } - } - for _, c := range m.Collections { - if !named[c.Name] && collectionName.MatchString(c.Name) { - problems = append(problems, fmt.Sprintf("%s collects %s and no file of its places it as "+ - "${collected:%s}", m.Module, c.Name, c.Name)) - } - } - } - contributed := map[string]bool{} - for i, it := range m.Items { - where := fmt.Sprintf("%s's item %d", m.Module, i+1) - if !collectionName.MatchString(it.Collection) { - problems = append(problems, fmt.Sprintf("%s is to %q, which is not a collection's name", where, - it.Collection)) - } else if _, old, wrong := oldCollection(it.Collection, true); old && wrong != nil { - problems = append(problems, fmt.Sprintf("%s: %v", where, wrong)) - } - switch { - case it.Name != "" && !itemName.MatchString(it.Name): - problems = append(problems, fmt.Sprintf("%s: the name %q is not a name: lower-case letters, digits and "+ - "hyphens", where, it.Name)) - case contributed[it.key()]: - problems = append(problems, fmt.Sprintf("%s contributes to %s twice; a module contributes one item to "+ - "a collection, or several each with a name of its own", m.Module, it.key())) - case it.Name == "" && slices.ContainsFunc(m.Items, func(o Item) bool { - return o.Collection == it.Collection && o.Name != "" - }): - problems = append(problems, fmt.Sprintf("%s contributes several items to %s and %s has no name; each "+ - "of several items has a name of its own", m.Module, it.Collection, where)) - } - contributed[it.key()] = true - problems = append(problems, itemAttributeProblems(m.Module, where, it)...) - if it.Collection == EnvironmentCollection { - problems = append(problems, environmentItemProblems(where, it)...) - } - switch { - case it.Template == "" && it.Content == "": - problems = append(problems, fmt.Sprintf("%s names no template: an item is a template of the module's "+ - "own, \"template\": \"%s/\" (novox/hq ADR 0349 §13)", where, m.TemplatesDir())) - case it.Template != "" && it.Content != "": - problems = append(problems, fmt.Sprintf("%s names its template and says its content too", where)) - case it.Template != "": - if wrong := itemTemplateProblem(m, it.Template); wrong != "" { - problems = append(problems, fmt.Sprintf("%s: %s", where, wrong)) - } - // **Filled by the provisioner's client alone**: the controller renders no item. - if !m.Moved { - problems = append(problems, fmt.Sprintf("%s: an item is filled by the provisioner's client of the "+ - "node its contributor is placed on, so a module contributing one is marked moved "+ - "(\"moved\": true; novox/hq ADR 0349 §13, §17)", where)) - } - } - if it.Content != "" { - problems = append(problems, itemTextProblems(m.Module, it)...) - } - } - return problems -} - -// itemAttributeProblems is what is wrong with an item's attributes and capability. The attributes of an item of a kind -// received as data are the fields its shape places by (and `order`), each with a value the shape takes. -func itemAttributeProblems(module, where string, it Item) []string { - var problems []string - for _, k := range sortedKeys(it.Attributes) { - v := it.Attributes[k] - switch { - case !attributeKey.MatchString(k): - problems = append(problems, fmt.Sprintf("%s: the attribute %q is not a name: lower-case letters, digits "+ - "and hyphens", where, k)) - continue - case !attributeFits(it.Collection, k, v): - problems = append(problems, fmt.Sprintf("%s: %s=%q is not a value: lower-case letters, digits, hyphens, "+ - "dots and underscores (an environment variable's name, letters, digits and underscores)", where, k, v)) - continue - } - if n, err := strconv.Atoi(v); k == OrderAttribute && (err != nil || n < 0 || n > 99) { - problems = append(problems, fmt.Sprintf("%s: order=%q is not a whole number from 0 to 99", where, v)) - } - } - if namespace, key, _ := strings.Cut(it.Collection, ":"); namespace == "contribution" { - seat, kind, _ := strings.Cut(key, ":") - if _, r, ok := receivable(seat, kind); ok && r.Shape != nil { - for _, k := range sortedKeys(it.Attributes) { - var place *PlaceField - for i := range r.Shape.Places { - if r.Shape.Places[i].Field == k { - place = &r.Shape.Places[i] - } - } - switch { - case k == OrderAttribute: - case place == nil: - problems = append(problems, fmt.Sprintf("%s: %s is placed by %s, and %q is none of them", where, - it.Collection, placeFields(r), k)) - case !oneOf(place.Values, it.Attributes[k]): - problems = append(problems, fmt.Sprintf("%s: %s=%s; %s is one of %s", where, k, it.Attributes[k], k, - strings.Join(place.Values, ", "))) - } - } - } - } - switch { - case it.IfCapability == "": - case !capabilityName.MatchString(it.IfCapability): - problems = append(problems, fmt.Sprintf("%s: if-capability %q is not a capability's name", where, it.IfCapability)) - case !oneOf(KnownCapabilities, it.IfCapability): - problems = append(problems, fmt.Sprintf("%s: if-capability %q is one no machine reports; the node-engine "+ - "detects %s", where, it.IfCapability, strings.Join(KnownCapabilities, ", "))) - } - return problems -} - -// AttributeFits is whether an item's attribute is of the shape its collection allows: words of lower case, except -// the environment's `name`, which is a variable's name (design 57 L3, issue 795). The same rule is mesh-catalog's -// provisioner client's, and the inventory's. -func attributeFits(collection, k, v string) bool { - if collection == EnvironmentCollection && k == "name" { - return variableName.MatchString(v) - } - return attributeKey.MatchString(k) && attributeValue.MatchString(v) -} - -// environmentItemProblems is what is wrong with an item of the environment collection: typed by `kind`, a variable -// with its `name`, a PATH entry `at` the start or the end, and no attribute besides `order`. -func environmentItemProblems(where string, it Item) []string { - var problems []string - for _, k := range sortedKeys(it.Attributes) { - if !slices.Contains([]string{"kind", "name", "at", OrderAttribute}, k) { - problems = append(problems, fmt.Sprintf("%s: %s is typed by kind, name and at, and %q is none of them", - where, it.Collection, k)) - } - } - switch kind := it.Attributes["kind"]; kind { - case "variable": - if it.Attributes["name"] == "" || it.Attributes["at"] != "" { - problems = append(problems, fmt.Sprintf("%s: a variable names itself with name= and has no at", where)) - } else if it.Attributes["name"] == "PATH" { - problems = append(problems, fmt.Sprintf("%s: PATH is not a variable; a PATH entry is kind=path", where)) - } - case "path": - if it.Attributes["name"] != "" || (it.Attributes["at"] != PathAtStart && it.Attributes["at"] != PathAtEnd) { - problems = append(problems, fmt.Sprintf("%s: a PATH entry is at=%s or at=%s and has no name", where, - PathAtStart, PathAtEnd)) - } - default: - problems = append(problems, fmt.Sprintf("%s: an item of %s is kind=variable or kind=path, not %q", where, - it.Collection, kind)) - } - return problems -} - // itemTemplateProblem is what is wrong with an item's template path, or "". func itemTemplateProblem(m Manifest, name string) string { dir := m.TemplatesDir() @@ -437,26 +248,6 @@ func itemTemplateProblem(m Manifest, name string) string { return "" } -// itemTextProblems is what an item's text may not name: a secret or a bound value, since an item is kept in a -// bucket and no secret is (ADR 0349 clause 7), and a collection, since an item is not a collector. And every -// placeholder no pass fills, as in a file's content. -func itemTextProblems(module string, it Item) []string { - var problems []string - for _, f := range templatePlaceholder.FindAllStringSubmatch(it.Content, -1) { - namespace, key := f[1], f[2] - switch _, collection := collectionOfPlaceholder(namespace, key); { - case namespace == "secret" || namespace == "bound": - problems = append(problems, fmt.Sprintf("%s's item of %s names %s: an item is kept in a bucket, where "+ - "no secret is (novox/hq ADR 0349 §7)", module, it.Collection, f[0])) - case collection: - problems = append(problems, fmt.Sprintf("%s's item of %s names %s: an item is not a collector", - module, it.Collection, f[0])) - } - } - return append(problems, unconsumedPlaceholders(module, map[string]any{"id": "item:" + it.key(), - "type": "file", "content": it.Content})...) -} - // isOld is whether a collection's name is an old namespace's. func isOld(name string) bool { _, old, _ := oldCollection(name, true) diff --git a/go/catalogue/collections_l3_test.go b/go/catalogue/collections_l3_test.go index 4c687a4..6935def 100644 --- a/go/catalogue/collections_l3_test.go +++ b/go/catalogue/collections_l3_test.go @@ -69,7 +69,7 @@ func TestASelectorIsRefusedWhenMalformedOrNamingACollectionNotCollected(t *testi raw := strings.Replace(barCollector, `"template":"files/bars"`, `"content":"`+placeholder+`"`, 1) raw = strings.Replace(raw, `"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},`, "", 1) raw = strings.Replace(raw, `"moved":true,`, "", 1) - m, err := ParseManifest([]byte(raw)) + m, err := decodeForTest([]byte(raw)) if err == nil { _, err = (Resolution{Node: "n", Modules: []Manifest{m}}).Declaration(Rendering{}) } @@ -82,33 +82,6 @@ func TestASelectorIsRefusedWhenMalformedOrNamingACollectionNotCollected(t *testi } } -func TestItemNamesAttributesAndCapabilitiesAreJudged(t *testing.T) { - item := func(extra string) string { - return `{"module":"p","moved":true,"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},` + - `"items":[` + extra + `]}` - } - for items, want := range map[string]string{ - `{"collection":"c","name":"a","template":"files/a"},{"collection":"c","template":"files/b"}`: "has no name", - `{"collection":"c","name":"a","template":"files/a"},{"collection":"c","name":"a","template":"files/b"}`: "twice", - `{"collection":"c","name":"Big","template":"files/a"}`: "is not a name", - `{"collection":"c","attributes":{"order":"100"},"template":"files/a"}`: "0 to 99", - `{"collection":"c","attributes":{"bar":"a b"},"template":"files/a"}`: "is not a value", - `{"collection":"c","if-capability":"batery","template":"files/a"}`: "no machine reports", - `{"collection":"contribution:node-bar:block","attributes":{"bar":"left"},"template":"files/a"}`: "bar=left", - `{"collection":"contribution:node-bar:block","attributes":{"colour":"red"},"template":"files/a"}`: `"colour" is none of them`, - `{"collection":"c","name":"a","template":"files/a","colour":"red"}`: "colour", - } { - _, err := ParseManifest([]byte(item(items))) - if err == nil || !strings.Contains(err.Error(), want) { - t.Errorf("%s: %v, want %q", items, err, want) - } - } - ok := item(`{"collection":"contribution:node-bar:block","name":"a","attributes":{"bar":"top","place":"status","order":"5"},"if-capability":"battery","template":"files/a"}`) - if _, err := ParseManifest([]byte(ok)); err != nil { - t.Fatal(err) - } -} - // A moved contributor's items, selected by the old placeholder's pair, render in the old order (order, module, item // name) and with the same lines as the contributions they replace. func TestASelectedMovedItemRendersIntoTheOldBarPlaceholderAsTheContributionDid(t *testing.T) { diff --git a/go/catalogue/collections_test.go b/go/catalogue/collections_test.go index c9aaf1b..f28c48c 100644 --- a/go/catalogue/collections_test.go +++ b/go/catalogue/collections_test.go @@ -37,7 +37,7 @@ func collectionsOn(t *testing.T) Resolution { t.Helper() zsh := held(t, collectorZsh, builtWith("zsh", map[string]string{"zshrc": zshrcText})) prompt := held(t, contributorP10k, builtWith("prompt", map[string]string{"zsh": contributorText})) - aliases, err := ParseManifest([]byte(oldFormAliases)) + aliases, err := decodeForTest([]byte(oldFormAliases)) if err != nil { t.Fatal(err) } @@ -141,39 +141,3 @@ func TestAMovedContributorsItemRendersAsItsOldFormDid(t *testing.T) { } } } - -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) - } -} diff --git a/go/catalogue/computed_test.go b/go/catalogue/computed_test.go index ccdd198..3b46312 100644 --- a/go/catalogue/computed_test.go +++ b/go/catalogue/computed_test.go @@ -104,19 +104,6 @@ func TestAGeneratorThisControlPlaneDoesNotHaveIsRefused(t *testing.T) { } } -func TestAModuleIsEitherWrittenOrComputedNotBoth(t *testing.T) { - // Otherwise nobody could say where a given file on a machine came from. - _, err := ParseManifest([]byte(`{"module":"mesh-network","version":"1", - "computed":"mesh-network", - "resources":[{"id":"a","type":"package","package":"wireguard-tools"}]}`)) - if err == nil { - t.Fatal("a module that both ships files and has them computed was accepted") - } - if !strings.Contains(err.Error(), "one or the other") { - t.Fatalf("unhelpful refusal: %v", err) - } -} - func TestSettingsApplyToAComputedModuleToo(t *testing.T) { // Being computed is about where the files come from, not about whether they are configurable. // A line drawn there would be arbitrary and nobody could predict it. diff --git a/go/catalogue/config.go b/go/catalogue/config.go index 0a54370..4d7c1fd 100644 --- a/go/catalogue/config.go +++ b/go/catalogue/config.go @@ -5,7 +5,6 @@ import ( "fmt" "regexp" "slices" - "strings" ) // A module declares its config (novox/hq ADR 0348 §1, §4, §5, §6, §8, §11; design 57 package K1, issue 660). @@ -123,26 +122,6 @@ func toConfigForm(raw []byte) ([]byte, error) { return raw, nil } -// inConfigWords is a refusal said of a module in the config form in its words: `${config:}` and -// takes-config, where the rules shared with the settings form say `${setting:}` and takes-settings. -func inConfigWords(problem string) string { - problem = settingRef.ReplaceAllStringFunc(problem, func(ref string) string { - return "${config:" + settingRef.FindStringSubmatch(ref)[1] + "}" - }) - return strings.ReplaceAll(problem, TakesSettingsField, TakesConfigField) -} - -// MixedFormProblems is a manifest using both forms, said with the words of each (ADR 0348 §1). -func MixedFormProblems(m Manifest) []string { - if len(m.forms.config) == 0 || len(m.forms.settings) == 0 { - return nil - } - return []string{fmt.Sprintf("%s mixes the config form (%s) with the settings form (%s): a module uses one form "+ - "or the other, never both; write %s, %s and ${config:} throughout (novox/hq ADR 0348 §1)", - m.Module, strings.Join(m.forms.config, ", "), strings.Join(m.forms.settings, ", "), - ConfigField, TakesConfigField)} -} - // configDeclaration is the config form's declaration as written: nothing of the settings form's, and nothing // unsaid. type configDeclaration struct { @@ -171,166 +150,6 @@ func (d SettingDeclaration) MarshalJSON() ([]byte, error) { Default: d.Default, Required: d.Required, Runtime: d.Runtime}) } -// configShaped is whether a declaration says anything of the config form's. -func configShaped(d SettingDeclaration) bool { - return d.Type != "" || d.Description != "" || len(d.Enum) > 0 || d.Minimum != nil || d.Maximum != nil || - d.Pattern != "" || d.Trusted || d.Predefined != "" || d.Required || d.Runtime -} - -// TypedSettingProblems is a declaration under `settings` that says what only `config` says: a value with a type, -// a description and its validation is declared in the config form (ADR 0348 §1, §4). -func TypedSettingProblems(m Manifest) []string { - var problems []string - for _, key := range sortedSettingKeys(m.Settings) { - if configShaped(m.Settings[key]) { - problems = append(problems, fmt.Sprintf("%s's setting %q says its type, description or validation "+ - "under %s: a value declared with those is declared under config, with ${config:} and "+ - "takes-config, never under %s (novox/hq ADR 0348 §1, §4)", m.Module, key, SettingsField, SettingsField)) - } - } - return problems -} - -// ConfigProblems is every way a module in the config form declares its config wrongly, each said by name with -// what to do (ADR 0348 How it is checked, design 57 package K1). -func ConfigProblems(m Manifest) []string { - if !m.DeclaresConfig() { - return nil - } - var problems []string - placed := configPlaced(m) - fallsBack := fileFallsBack(m) - renamed := renamedTaken(m) - for _, key := range sortedSettingKeys(m.Settings) { - d := m.Settings[key] - say := func(format string, args ...any) { - problems = append(problems, fmt.Sprintf("%s's config value %q: ", m.Module, key)+fmt.Sprintf(format, args...)) - } - if !settingRef.MatchString("${setting:" + key + "}") { - say("not a usable key: lower-case letters, digits, dots, dashes and underscores") - continue - } - if meshWords[key] || accountFacts[key] { - say("%q is placement, which the mesh decides, never a config value (novox/hq ADR 0348 §11)", key) - continue - } - if d.Kind != "" || d.Why != "" { - say("kind and why are the settings form's; under config a value says its type and its description " + - "(novox/hq ADR 0348 §1, §4)") - } - switch d.Type { - case ConfigNumber, ConfigString, ConfigBoolean: - case ConfigObject, ConfigList: - if !d.Trusted { - say("is %s and not trusted: an object or a list decides more than one value can say, so it is "+ - "trusted, \"trusted\": true, or split into numbers, strings and booleans (novox/hq ADR 0348 §4)", d.Type) - } - case "": - say("no type: say \"type\": number, string or boolean, or object or list when trusted (novox/hq ADR 0348 §4)") - default: - say("type %q is none of number, string, boolean, object and list (novox/hq ADR 0348 §4)", d.Type) - } - if strings.TrimSpace(d.Description) == "" { - say("no description: say in a sentence what the value is, including its unit (novox/hq ADR 0348 §4)") - } - switch { - case d.Required && d.Default != nil: - say("both a default and required: a required value is the operator's own and has no default; give " + - "one or the other (novox/hq ADR 0348 §4)") - case !d.Required && d.Default == nil: - say("neither a default nor required: give a default, or say \"required\": true for a value that is " + - "the operator's own (novox/hq ADR 0348 §4)") - } - if as := renamed[key]; as != "" && d.Required { - say("is laid into its file as %q, and only when it is set, so the file and its code fall back when "+ - "it is not: it is never required; give it the file's default, the empty value of its type "+ - "(novox/hq issue 751)", as) - } - for _, wrong := range validationProblems(d) { - say("%s", wrong) - } - if word := operatorsWord(key); word != "" && d.Default != nil && !fallsBack[key] { - say("a key naming %q is the operator's value: declare it \"required\": true, with no default, "+ - "unless a mergeable file takes it and no placeholder asks for it, where the file falls back "+ - "(novox/hq ADR 0348 §4, ADR 0112, issue 735)", word) - } - if d.Predefined != "" { - p, listed := m.predefined[d.Predefined] - switch { - case m.predefined == nil: - say("falls back to the predefined key %q, and the list of predefined keys (%s at the root of "+ - "the catalogue's repository) is not present where it is judged: a value falls back only to a "+ - "key on that list; judge it in the catalogue, or leave \"predefined\" out (novox/hq ADR 0348 §7)", - d.Predefined, PredefinedFile) - case !listed: - say("falls back to %q, which is not on the list of predefined keys: name a key on it, or leave "+ - "\"predefined\" out (novox/hq ADR 0348 §7)", d.Predefined) - default: - if p.Type != d.Type { - say("falls back to the predefined key %q, which is a %s, and this value is a %s: a value falls "+ - "back only to a key of its own type (novox/hq ADR 0348 §4)", d.Predefined, p.Type, d.Type) - } - if p.Trusted && !d.Trusted { - say("falls back to the trusted predefined key %q and is not trusted itself: a value that falls "+ - "back to a trusted key is trusted, \"trusted\": true (novox/hq ADR 0348 §8)", d.Predefined) - } - } - } - // A template's text is not here yet. Resolve fills it from the build, WithTemplatesFrom from the tree; neither - // judges again itself, but WithTemplatesFrom parses its result again, and a resolved build is parsed again - // where it is registered, and this is judged then. - if !placed[key] && !d.Runtime && !m.Templated() { - say("placed nowhere: no file says ${config:%s} or takes it under %s, and it is not marked "+ - "\"runtime\": true for a value the module's code reads itself (novox/hq ADR 0348 §5)", key, TakesConfigField) - } - } - used := settingKeysUsedBy(m) - for _, key := range sortedKeys(used) { - if _, declared := m.Settings[key]; !declared { - problems = append(problems, fmt.Sprintf("%s says ${config:%s} and declares no such value under %s: "+ - "declare %q with its type, description and a default or required (novox/hq ADR 0348 §4, §5)", - m.Module, key, ConfigField, key)) - } - } - problems = append(problems, configTakesProblems(m)...) - problems = append(problems, reflectsProblems(m)...) - return problems -} - -// configPlaced is every value a file, a unit, a directory's owner, a contribution or a served fact asks for, and -// every value a mergeable file takes. -func configPlaced(m Manifest) map[string]bool { - placed := settingKeysUsedBy(m) - for _, r := range m.Resources { - for _, k := range takenKeyList(r) { - placed[k] = true - } - } - return placed -} - -// fileFallsBack is every value a mergeable file takes and no placeholder asks for. Unset, such a value is simply -// not laid over the file: the file's own content, or the module's code reading the file, falls back. So one named -// after the operator's value (a url, a user, an address) is not `required`, and its default is the file's: its -// value in the content, else the empty value of its type, never laid over the file (the operator's decision on -// issue 664, issue 735). A placeholder has no such fallback: its default would be filled in where the operator's -// value belongs (ADR 0112). -func fileFallsBack(m Manifest) map[string]bool { - asked := settingKeysUsedBy(m) - out := map[string]bool{} - for _, r := range m.Resources { - if how, _ := r["merge"].(string); how == "" { - continue - } - for _, k := range takenKeyList(r) { - if !asked[k] { - out[k] = true - } - } - } - return out -} - // renamedTaken is every value a mergeable file takes under a name of its own, with that name (issue 751). func renamedTaken(m Manifest) map[string]string { out := map[string]string{} @@ -417,88 +236,3 @@ func ofType(v any, kind string) bool { } return false } - -// configTakesProblems is what ADR 0348 §11 adds for a module in the config form: an account fact in -// takes-config (the mesh's own words are refused for both forms by TakenSettingsProblems), and a mergeable file -// naming no keys where none of the module's does (where one does, TakenSettingsProblems names every one that -// does not). -func configTakesProblems(m Manifest) []string { - var problems, silent []string - naming := false - for _, r := range m.Resources { - if how, _ := r["merge"].(string); how == "" { - continue - } - if _, said := r[TakesSettingsField]; !said { - silent = append(silent, fmt.Sprint(r["id"])) - continue - } - naming = true - for _, k := range takenKeyList(r) { - _, declared := m.Settings[k] - switch { - case accountFacts[k]: - problems = append(problems, fmt.Sprintf("%s's %v names %q in %s, and %q is an account fact the "+ - "mesh places, never a config value (novox/hq ADR 0348 §11)", m.Module, r["id"], k, - TakesConfigField, k)) - case meshWords[k]: - // Refused for both forms by TakenSettingsProblems, in its words. - case !declared: - problems = append(problems, fmt.Sprintf("%s's %v names %q in %s and declares no such value under "+ - "%s: a value a file takes is declared like every other, with its type, description and a "+ - "default or required, or the config verb could never set it (novox/hq ADR 0348 §4, §11)", - m.Module, r["id"], k, TakesConfigField, ConfigField)) - } - } - } - if !naming { - for _, id := range silent { - problems = append(problems, fmt.Sprintf("%s's mergeable %s names no %s: a module that declares config "+ - "names the keys each mergeable file takes, an empty list for none (novox/hq ADR 0348 §11)", - m.Module, id, TakesConfigField)) - } - } - return problems -} - -// meshMadeIDs are the resources the mesh composes into a module's own set, which its `restart-on` may name -// without declaring them; meshMadePrefixes are those named after what they are for. -var ( - meshMadeIDs = []string{ComposedJailsID(), FilteringID(), CertificateID(), AuthorityID(), BusUsersID()} - meshMadePrefixes = []string{NeedID(""), SecretID(""), strings.TrimSuffix(GrantID("", ""), "-"), BoundID(""), ReceivedID(""), AccessID("/")} -) - -// reflectsProblems is a `reload-on` or `restart-on` naming a file the module does not declare (ADR 0348 §6): not -// one of its own resources, not one the mesh composes for it, and not another module's, which is named -// `.`. -func reflectsProblems(m Manifest) []string { - own := map[string]bool{} - for _, r := range m.Resources { - own[fmt.Sprint(r["id"])] = true - } - meshMade := func(id string) bool { - if slices.Contains(meshMadeIDs, id) { - return true - } - for _, p := range meshMadePrefixes { - if strings.HasPrefix(id, p) && len(id) > len(p) { - return true - } - } - return false - } - var problems []string - for _, r := range m.Resources { - for _, field := range []string{"reload-on", "restart-on"} { - for _, id := range stringsOf(r[field]) { - if strings.Contains(id, ".") || own[id] || meshMade(id) { - continue - } - problems = append(problems, fmt.Sprintf("%s's %v names %q in its %s, and %s declares no such "+ - "file: name a file of its own, or another module's as . (novox/hq ADR 0348 §6)", - m.Module, r["id"], id, field, m.Module)) - } - } - } - return problems -} diff --git a/go/catalogue/config_rename_test.go b/go/catalogue/config_rename_test.go index 35ed21a..f97ec6d 100644 --- a/go/catalogue/config_rename_test.go +++ b/go/catalogue/config_rename_test.go @@ -143,43 +143,7 @@ func TestARenamedEntryIsWrittenBackAsItWasSaid(t *testing.T) { if !strings.Contains(string(raw), `"takes-config":["position","theme",{"as":"NOTIFIER_URL","key":"url"}]`) { t.Fatalf("written back as %s", raw) } - if _, err := ParseManifest(raw); err != nil { + if _, err := decodeForTest(raw); err != nil { t.Fatalf("read again: %v", err) } } - -func TestARenamedEntryIsRefusedWhereItCouldNotHold(t *testing.T) { - entry := func(m map[string]any, e any) map[string]any { - resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", e} - return m - } - refused(t, entry(renaming(), map[string]any{"key": "url"}), "notifier's rules", `"key" and "as"`) - refused(t, entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER_URL", "when": "set"}), `"key" and "as"`) - refused(t, entry(renaming(), map[string]any{"key": "URL", "as": "NOTIFIER_URL"}), "not a usable key") - refused(t, entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER URL"}), "not a usable name") - refused(t, entry(renaming(), map[string]any{"key": "url", "as": "endpoints"}), `"endpoints"`, "the mesh reads") - refused(t, entry(renaming(), map[string]any{"key": "url", "as": "theme"}), `"theme"`, "twice") - refused(t, entry(renaming(), map[string]any{"key": "theme", "as": "THEME"}), `"theme"`, "twice") - refused(t, entry(renaming(), map[string]any{"key": "nothing", "as": "NOTHING"}), `"nothing"`, "declares no such value") - - kept := entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER_URL"}) - resourceOf(kept, "rules")["protected"] = []any{"NOTIFIER_URL"} - refused(t, kept, `"NOTIFIER_URL"`, "protected") - - // Laid only when set, so the code's own fallback holds: never required. - required := renaming() - declaration(required, "url")["required"] = true - delete(declaration(required, "url"), "default") - refused(t, required, `"url"`, "only when it is set", "never required") -} - -func TestARenamedEntryIsTheConfigFormsOnly(t *testing.T) { - m := map[string]any{ - "module": "notifier", "version": "1", - "resources": []any{ - map[string]any{"id": "rules", "type": "file", "path": "/x/rules.json", "merge": "json", "content": "{}", - "takes-settings": []any{map[string]any{"key": "url", "as": "NOTIFIER_URL"}}}, - }, - } - refused(t, m, "notifier's rules", "config form") -} diff --git a/go/catalogue/config_test.go b/go/catalogue/config_test.go index 852d340..47efa8f 100644 --- a/go/catalogue/config_test.go +++ b/go/catalogue/config_test.go @@ -63,7 +63,7 @@ func parseRaw(t *testing.T, m map[string]any) (Manifest, error) { if err != nil { t.Fatal(err) } - return ParseManifest(raw) + return decodeForTest(raw) } // refused is that the manifest is refused with every one of these words. @@ -97,66 +97,6 @@ func TestAModuleInTheConfigFormRegisters(t *testing.T) { } } -// Rule 1: a value without type or description. -func TestConfigRefusesAValueWithoutTypeOrDescription(t *testing.T) { - m := inConfigForm() - delete(declaration(m, "width"), "type") - refused(t, m, `notifier's config value "width"`, "no type") - m = inConfigForm() - declaration(m, "width")["type"] = "colour" - refused(t, m, `"width"`, `type "colour"`) - m = inConfigForm() - delete(declaration(m, "width"), "description") - refused(t, m, `"width"`, "no description") -} - -// Rule 2: a value with neither a default nor required. -func TestConfigRefusesAValueWithNeitherDefaultNorRequired(t *testing.T) { - m := inConfigForm() - delete(declaration(m, "width"), "default") - refused(t, m, `"width"`, "neither a default nor required") - m = inConfigForm() - declaration(m, "sender")["default"] = "noreply@example.org" - refused(t, m, `"sender"`, "both a default and required") -} - -// Rule 3: a default failing its validation. -func TestConfigRefusesADefaultFailingItsValidation(t *testing.T) { - for _, c := range []struct { - key string - value any - said string - }{ - {"font-size", 4, "below its minimum"}, - {"font-size", 41, "above its maximum"}, - {"font-size", "10", "not a number"}, - {"position", "bottom", "not one of"}, - {"theme", "Dark", "does not match"}, - } { - m := inConfigForm() - declaration(m, c.key)["default"] = c.value - refused(t, m, `"`+c.key+`"`, c.said) - } -} - -// Rule 4: an object or a list not trusted. -func TestConfigRefusesAnObjectOrListNotTrusted(t *testing.T) { - for _, kind := range []string{"object", "list"} { - m := inConfigForm() - def := any(map[string]any{"a": 1}) - if kind == "list" { - def = []any{"a"} - } - m["config"].(map[string]any)["rules"] = map[string]any{"type": kind, "description": "the rules, as " + kind, - "default": def, "runtime": true} - refused(t, m, `"rules"`, "is "+kind+" and not trusted") - m["config"].(map[string]any)["rules"].(map[string]any)["trusted"] = true - if _, err := parseRaw(t, m); err != nil { - t.Fatalf("a trusted %s is refused: %v", kind, err) - } - } -} - // withPredefined is the list of predefined keys the mesh published, for the length of a test. func withPredefined(t *testing.T, keys PredefinedList) { t.Helper() @@ -164,127 +104,6 @@ func withPredefined(t *testing.T, keys PredefinedList) { t.Cleanup(func() { ReplacePublished(was) }) } -// Rule 5: a predefined key not on the list, or of another type. Where no list is present, a value naming one is -// refused saying so. -func TestConfigRefusesAPredefinedKeyNotOnTheListOrOfAnotherType(t *testing.T) { - withPredefined(t, nil) - m := inConfigForm() - declaration(m, "font-size")["predefined"] = "font-size" - refused(t, m, `"font-size"`, "the list of predefined keys", "is not present") - - withPredefined(t, map[string]PredefinedKey{"font-size": {Type: "number"}, "accent": {Type: "string"}}) - if _, err := parseRaw(t, m); err != nil { - t.Fatalf("a value falling back to a listed key of its type is refused: %v", err) - } - m = inConfigForm() - declaration(m, "font-size")["predefined"] = "font-scale" - refused(t, m, `"font-size"`, `"font-scale"`, "not on the list") - m = inConfigForm() - declaration(m, "font-size")["predefined"] = "accent" - refused(t, m, `"font-size"`, `"accent"`, "is a string") -} - -// Rule 6: an untrusted value falling back to a trusted predefined key. -func TestConfigRefusesAnUntrustedValueFallingBackToATrustedKey(t *testing.T) { - withPredefined(t, nil) - m := inConfigForm() - declaration(m, "theme")["predefined"] = "theme" - refused(t, m, `"theme"`, "is not present") - - withPredefined(t, map[string]PredefinedKey{"theme": {Type: "string", Trusted: true}}) - refused(t, m, `"theme"`, "trusted", "is not") - declaration(m, "theme")["trusted"] = true - if _, err := parseRaw(t, m); err != nil { - t.Fatalf("a trusted value falling back to a trusted key is refused: %v", err) - } -} - -// Rule 7: a value neither placed in a template nor read at runtime. -func TestConfigRefusesAValueNeitherPlacedNorReadAtRuntime(t *testing.T) { - m := inConfigForm() - delete(declaration(m, "history"), "runtime") - refused(t, m, `"history"`, "${config:history}", `"runtime": true`) - // Taken by a mergeable file is placed. - m = inConfigForm() - if _, err := parseRaw(t, m); err != nil { - t.Fatal(err) - } -} - -// Rule 8: a placeholder naming an undeclared value. -func TestConfigRefusesAPlaceholderNamingAnUndeclaredValue(t *testing.T) { - m := inConfigForm() - resourceOf(m, "configuration")["content"] = "font = ${config:font-size}\nwidth = ${config:width}\n" + - "from = ${config:sender}\ncolour = ${config:colour}\n" - refused(t, m, "${config:colour}", "declares no such value") -} - -// Rule 9: a reload-on or restart-on naming a file the module does not declare. -func TestConfigRefusesAReloadOrRestartOnNamingAnUndeclaredFile(t *testing.T) { - m := inConfigForm() - resourceOf(m, "notifier")["reload-on"] = []any{"configuration", "dunstrc"} - refused(t, m, "reload-on", `"dunstrc"`, "declares no such") - m = inConfigForm() - resourceOf(m, "notifier")["restart-on"] = []any{"rule"} - refused(t, m, "restart-on", `"rule"`) - // Another module's file, and one the mesh makes for the module, are not the module's to declare. - m = inConfigForm() - resourceOf(m, "notifier")["restart-on"] = []any{"rules", "resolver.fact-zones", "secret-mail", "composed-jails"} - if _, err := parseRaw(t, m); err != nil { - t.Fatalf("refused: %v", err) - } -} - -// Rule 10: a moved module's mergeable file not naming its keys under takes-config. -func TestConfigRefusesAMergeableFileNotNamingItsKeys(t *testing.T) { - m := inConfigForm() - delete(resourceOf(m, "rules"), "takes-config") - // position and theme are then placed nowhere; say they are read at runtime so only the file's rule speaks. - declaration(m, "position")["runtime"] = true - declaration(m, "theme")["runtime"] = true - refused(t, m, "rules", "names no takes-config") -} - -// Rule 11: a mesh word, puid or pgid in takes-config. -func TestConfigRefusesAMeshWordOrAnAccountFactInTakesConfig(t *testing.T) { - for _, word := range []string{"ports", "expose", "reach", "endpoints", "places", "accesses", "networks", "puid", "pgid"} { - m := inConfigForm() - resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", word} - refused(t, m, `"`+word+`"`, "takes-config") - } -} - -// A module uses one form or the other, never both. -func TestAManifestMixingTheConfigAndSettingsFormsIsRefused(t *testing.T) { - m := inConfigForm() - resourceOf(m, "configuration")["content"] = "font = ${config:font-size}\nwidth = ${setting:width}\nfrom = ${config:sender}\n" - refused(t, m, "mixes", "${setting:", "config") - - m = inConfigForm() - delete(resourceOf(m, "rules"), "takes-config") - resourceOf(m, "rules")["takes-settings"] = []any{"position", "theme"} - refused(t, m, "mixes", "takes-settings") - - m = inConfigForm() - m["settings"] = map[string]any{"colour": map[string]any{"kind": "preference", "default": "red", "why": "red"}} - refused(t, m, "mixes", "settings") -} - -// The settings form keeps its own declaration; the config form's has no kind or why. -func TestEachFormKeepsItsOwnDeclaration(t *testing.T) { - m := inConfigForm() - declaration(m, "width")["why"] = "fits a title" - refused(t, m, `"width"`, "why") - - err := parsed(t, Manifest{Module: "notifier", - Settings: map[string]SettingDeclaration{"width": {Kind: KindPreference, Default: float64(250), - Why: "fits a title", Type: "number"}}, - Resources: []map[string]any{{"id": "c", "type": "file", "path": "/x", "content": "${setting:width}"}}}) - if err == nil || !strings.Contains(err.Error(), "under config") { - t.Fatalf("a typed value under settings: %v", err) - } -} - // What the mesh holds is what was written: a module in the config form is written back in it, and read again // the same. func TestAConfigManifestIsWrittenBackInItsOwnForm(t *testing.T) { @@ -306,7 +125,7 @@ func TestAConfigManifestIsWrittenBackInItsOwnForm(t *testing.T) { t.Fatalf("written back with %s: %s", unsaid, raw) } } - again, err := ParseManifest(raw) + again, err := decodeForTest(raw) if err != nil { t.Fatal(err) } @@ -365,7 +184,7 @@ const configDunst = `{"module": "dunst", "version": "1", func TestAModuleRewrittenFromSettingsToConfigComposesByteIdentically(t *testing.T) { composed := func(manifest string, layers []Layer) []byte { t.Helper() - m, err := ParseManifest([]byte(manifest)) + m, err := decodeForTest([]byte(manifest)) if err != nil { t.Fatal(err) } @@ -404,14 +223,6 @@ func TestAModuleRewrittenFromSettingsToConfigComposesByteIdentically(t *testing. } } -// A key a mergeable file takes is a config value like every other: one not declared under config is refused by -// name (ADR 0348 §4, review of mesh-controller#320). -func TestConfigRefusesATakesConfigKeyNotDeclared(t *testing.T) { - m := inConfigForm() - resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", "urgency"} - refused(t, m, `"urgency"`, "takes-config", "declares no such value under config") -} - // A required value nothing sets is refused at composition in the config form's words: ${config:} and the // config verb, never `settings set` (review of mesh-controller#320). The settings form's words are unchanged. func TestAnUnsetRequiredValueIsRefusedInTheConfigFormsWords(t *testing.T) { @@ -466,36 +277,6 @@ func TestAnUnsetRequiredValueIsRefusedInTheConfigFormsWords(t *testing.T) { } } -// 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`) -} - // 1658/AC1: a value whose only placement is ${config:key} in an item's text is placed. func TestConfigCountsAnItemsTextAsAPlacement(t *testing.T) { m := map[string]any{ @@ -509,15 +290,3 @@ func TestConfigCountsAnItemsTextAsAPlacement(t *testing.T) { t.Fatalf("a value placed only in an item's text is refused: %v", err) } } - -// 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") -} diff --git a/go/catalogue/consumer_into_serves.go b/go/catalogue/consumer_into_serves.go index e5d4791..441a77d 100644 --- a/go/catalogue/consumer_into_serves.go +++ b/go/catalogue/consumer_into_serves.go @@ -124,55 +124,6 @@ func asDNSLabel(as string) string { return strings.ReplaceAll(as, "_", "-") } -// CheckServes refuses a `serves` block that names a consumer fact or an alphabet the mesh does not -// have, when the definition is parsed rather than when a consumer is resolved. -// -// A provision nobody consumes yet still has its rule read: a definition that would be refused the -// first time somebody required it is a definition that is wrong now. -func CheckServes(m Manifest) []string { - var problems []string - for _, provision := range sortedServes(m.Serves) { - for _, key := range sortedAnyKeys(m.Serves[provision]) { - text, ok := m.Serves[provision][key].(string) - if !ok { - // A provider's own name is filled only in a value of its own (novox/hq ADR 0346): one - // nested in a list or an object would be served as the placeholder, unsaid. - problems = append(problems, nestedOwnNames(m, provision, key, m.Serves[provision][key])...) - continue - } - // A probe identity, because what is checked is the shape of the statement and not - // what any consumer is called. - if _, err := consumerInto(text, "mesh_node_module"); err != nil { - problems = append(problems, fmt.Sprintf( - "%s serves %s, and the value it serves as %q %s", m.Module, provision, key, err)) - } - // And the provider's own names it may serve (novox/hq ADR 0346). - problems = append(problems, checkServedOwnNames(m, provision, key, text)...) - // A label longer than DNS keeps is not truncated here (asDNSLabel), so the offer's own - // bound has to keep the identity inside one (ADR 0225). - if strings.Contains(text, "${consumer:as:dns}") { - if b := m.IdentityBoundOf(provision); !b.Bounded() || b.Max > dnsLabelLimit { - problems = append(problems, fmt.Sprintf( - "%s serves %s's consumers their identity as a DNS label in %q, and bounds "+ - "that identity at %s: a label keeps %d — state an `identity` of at most %d", - m.Module, provision, key, boundWords(b), dnsLabelLimit, dnsLabelLimit)) - } - } - } - } - return problems -} - -// dnsLabelLimit is the longest DNS label (RFC 1035 §2.3.4). -const dnsLabelLimit = 63 - -func boundWords(b IdentityBound) string { - if !b.Bounded() { - return "nothing" - } - return fmt.Sprintf("%d", b.Max) -} - func sortedServes(serves map[string]map[string]any) []string { out := make([]string, 0, len(serves)) for k := range serves { diff --git a/go/catalogue/consumes_start_test.go b/go/catalogue/consumes_start_test.go index 3374792..fc3ed1b 100644 --- a/go/catalogue/consumes_start_test.go +++ b/go/catalogue/consumes_start_test.go @@ -10,7 +10,7 @@ import ( func TestAConsumesEntryAskingWhereItStartsIsRefused(t *testing.T) { // 1279/AC4 for _, key := range []string{"from", "start", "deliver"} { raw := `{"module":"audit","version":"1","consumes":[{"event":"shop.order.placed","` + key + `":"beginning"}]}` - _, err := ParseManifest([]byte(raw)) + _, err := decodeForTest([]byte(raw)) if err == nil { t.Fatalf("a consumes entry with %q was accepted", key) } @@ -18,7 +18,7 @@ func TestAConsumesEntryAskingWhereItStartsIsRefused(t *testing.T) { // 1279/AC4 t.Errorf("the refusal for %q does not name the key and the field: %v", key, err) } } - if _, err := ParseManifest([]byte(`{"module":"audit","version":"1","consumes":["shop.order.placed"]}`)); err != nil { + if _, err := decodeForTest([]byte(`{"module":"audit","version":"1","consumes":["shop.order.placed"]}`)); err != nil { t.Errorf("a plain consumes entry was refused: %v", err) } } diff --git a/go/catalogue/contributes_test.go b/go/catalogue/contributes_test.go index beb6ab6..19db70d 100644 --- a/go/catalogue/contributes_test.go +++ b/go/catalogue/contributes_test.go @@ -218,30 +218,6 @@ func TestASettingReachesAContributionOnlyWhereItDeclaresTheKey(t *testing.T) { } } -func TestReceivingWhatYouDoNotProvideIsRefused(t *testing.T) { - // It would create a file nobody ever writes to, on a machine where nothing asked for it. - _, err := ParseManifest([]byte(`{"module":"traefik","version":"1", - "receives":{"reverse-proxy":"/etc/traefik/mesh.json"}}`)) - if err == nil { - t.Fatal("a module received contributions to something it does not provide") - } - if !strings.Contains(err.Error(), "does not provide") { - t.Fatalf("unhelpful refusal: %v", err) - } -} - -func TestAnEmptyContributionIsRefused(t *testing.T) { - // Either a mistake or a requirement written the long way round, and both are better said. - _, err := ParseManifest([]byte(`{"module":"board","version":"1", - "contributes":{"reverse-proxy":{}}}`)) - if err == nil { - t.Fatal("a module contributed nothing and was accepted") - } - if !strings.Contains(err.Error(), "require it") { - t.Fatalf("the refusal does not say what to do instead: %v", err) - } -} - // A provision answered from anywhere in the mesh has consumers on other machines, and the // provider has to know who they are. Contributions were node-local until this, which meant the // one case that most needed them was the one they did not reach. diff --git a/go/catalogue/contribution_dependencies_test.go b/go/catalogue/contribution_dependencies_test.go index 91e9bc0..dc8cafd 100644 --- a/go/catalogue/contribution_dependencies_test.go +++ b/go/catalogue/contribution_dependencies_test.go @@ -113,24 +113,3 @@ func TestAPackageDeclaredPresentAndAbsentIsRefusedNamingBoth(t *testing.T) { t.Errorf("an unrelated assignment is refused for a contradiction already there: %v", err) } } - -func TestCodeForAPowerMomentDependsOnThePowerSeat(t *testing.T) { - for _, moment := range powerMoments { - m := mod("laptop", nil, nil, nil) - m.Shell = []ShellCode{{For: moment, Slot: "normal", Code: "true"}} - if got := DependsOn(nil, m); !reflect.DeepEqual(got, []string{PowerSeat}) { - t.Errorf("code for %s depends on %v, want %s", moment, got, PowerSeat) - } - if p := m.shellProblems(); len(p) != 0 { - t.Errorf("code for %s is refused: %v", moment, p) - } - } - m := mod("laptop", nil, nil, nil) - m.Shell = []ShellCode{{For: "after-lunch", Slot: "normal", Code: "true"}} - if p := m.shellProblems(); len(p) == 0 { - t.Error("code for a moment that does not exist was accepted") - } - if s, ok := SeatNamed(PowerSeat); !ok || s.Scope != ScopeNode { - t.Errorf("%s is not a node seat of the mesh's own: %+v %v", PowerSeat, s, ok) - } -} diff --git a/go/catalogue/core_credentials_test.go b/go/catalogue/core_credentials_test.go index 1c123f3..7e09a7d 100644 --- a/go/catalogue/core_credentials_test.go +++ b/go/catalogue/core_credentials_test.go @@ -33,7 +33,7 @@ const provisionerClient = `{"module":"mesh-provisioner-client","version":"1", func clientOn(t *testing.T) Resolution { t.Helper() - m, err := ParseManifest([]byte(provisionerClient)) + m, err := decodeForTest([]byte(provisionerClient)) if err != nil { t.Fatal(err) } diff --git a/go/catalogue/cron.go b/go/catalogue/cron.go index fbb6d29..9fa7ce1 100644 --- a/go/catalogue/cron.go +++ b/go/catalogue/cron.go @@ -13,80 +13,3 @@ package catalogue // The ordinary five fields — minute, hour, day-of-month, month, day-of-week — with `*`, lists // (`,`), ranges (`-`) and steps (`/`). No names (jan, mon): a numeric cron is what the host // evaluates, and refusing a name here is refusing it near whoever wrote it. - -import ( - "fmt" - "strconv" - "strings" -) - -// validateCron reports why a string is not a five-field cron expression, or nil if it is one. -func validateCron(expr string) error { - fields := strings.Fields(expr) - if len(fields) != 5 { - return fmt.Errorf( - "a schedule is a five-field cron expression (minute hour day-of-month month "+ - "day-of-week), and this has %d field(s)", len(fields)) - } - bounds := []struct { - name string - min, max int - }{ - {"minute", 0, 59}, - {"hour", 0, 23}, - {"day-of-month", 1, 31}, - {"month", 1, 12}, - {"day-of-week", 0, 7}, // 0 and 7 are both Sunday, the convention every cron keeps - } - for i, b := range bounds { - if err := validateCronField(fields[i], b.min, b.max); err != nil { - return fmt.Errorf("%s field: %w", b.name, err) - } - } - return nil -} - -// validateCronField checks one field's elements are within range and well-formed. -func validateCronField(spec string, min, max int) error { - if spec == "" { - return fmt.Errorf("is empty") - } - for _, part := range strings.Split(spec, ",") { - if part == "" { - return fmt.Errorf("%q has an empty element between commas", spec) - } - - rangePart := part - if slash := strings.IndexByte(part, '/'); slash >= 0 { - rangePart = part[:slash] - n, err := strconv.Atoi(part[slash+1:]) - if err != nil || n < 1 { - return fmt.Errorf("step in %q is not a positive number", part) - } - } - - switch { - case rangePart == "*": - // Any value; nothing to bound. - case strings.IndexByte(rangePart, '-') >= 0: - dash := strings.IndexByte(rangePart, '-') - lo, errLo := strconv.Atoi(rangePart[:dash]) - hi, errHi := strconv.Atoi(rangePart[dash+1:]) - if errLo != nil || errHi != nil { - return fmt.Errorf("range %q is not two numbers", rangePart) - } - if lo < min || hi > max || lo > hi { - return fmt.Errorf("range %q is outside the allowed %d-%d", part, min, max) - } - default: - v, err := strconv.Atoi(rangePart) - if err != nil { - return fmt.Errorf("%q is not a number", rangePart) - } - if v < min || v > max { - return fmt.Errorf("%q is outside the allowed range %d-%d", part, min, max) - } - } - } - return nil -} diff --git a/go/catalogue/data.go b/go/catalogue/data.go index 5a43203..86ddf79 100644 --- a/go/catalogue/data.go +++ b/go/catalogue/data.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "regexp" - "sort" "strconv" "strings" "time" @@ -305,182 +304,6 @@ func keepsByClass(class string) bool { return class == ClassIrreplaceable || class == ClassValuable || class == ClassRebuildable } -// dataID is what an item's id may be: it is a token in a condition's key and a word on a line. -var dataID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) - -// dataPathRef is an item's path: one of the module's directories or accesses, and optionally a path -// beneath it. -var dataPathRef = regexp.MustCompile(`^\$\{(dir|access):([a-z0-9][a-z0-9-]*)\}(/[^\s$]*)?$`) - -// dataProblems is what is wrong with the `data` section itself, from the manifest alone: judged at -// registration as every other per-manifest problem is. Whether a module declares what it should is -// the catalogue check's (DataProblems), because a module already running was written before it. -func (m Manifest) dataProblems() []string { - if m.Data == nil { - return nil - } - var problems []string - say := func(format string, args ...any) { - problems = append(problems, fmt.Sprintf("%s's data: ", m.Module)+fmt.Sprintf(format, args...)) - } - dirs := map[string]bool{} - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) == "directory" { - dirs[fmt.Sprint(r["id"])] = true - } - } - accesses := map[string]bool{} - for _, a := range m.Accesses { - if a.ID != "" { - accesses[a.ID] = true - } - } - ids := map[string]DataItem{} - paths := map[string]string{} - for i, it := range m.Data.Own { - label := it.ID - if label == "" { - label = fmt.Sprintf("item %d", i+1) - } - if !dataID.MatchString(it.ID) { - say("%s has no usable id: lower-case letters, digits and dashes", label) - } else if _, twice := ids[it.ID]; twice { - say("%s is declared twice", it.ID) - } - ids[it.ID] = it - ref := dataPathRef.FindStringSubmatch(it.Path) - switch { - case ref == nil: - say("%s's path %q is not one of the module's directories or accesses: ${dir:} or ${access:}, "+ - "or a path beneath one (a definition names no machine path, novox/hq ADR 0112)", label, it.Path) - case ref[1] == "dir" && !dirs[ref[2]]: - say("%s's path names ${dir:%s}, and %s declares no directory %q", label, ref[2], m.Module, ref[2]) - case ref[1] == "access" && !accesses[ref[2]]: - say("%s's path names ${access:%s}, and %s declares no access %q", label, ref[2], m.Module, ref[2]) - case strings.Contains(it.Path, ".."): - say("%s's path %q climbs out of its directory", label, it.Path) - } - if other, twice := paths[it.Path]; twice && it.Path != "" { - say("%s and %s name the same path %s", other, label, it.Path) - } - paths[it.Path] = label - switch it.Class { - case ClassIrreplaceable, ClassValuable, ClassRebuildable, ClassCache: - case ClassNone: - say("%s is class none, which only a provision keeping nothing of its consumers' is; own data "+ - "that is disposable is cache", label) - default: - say("%s's class %q is not one the mesh protects by: irreplaceable, valuable, rebuildable or cache", - label, it.Class) - } - if it.Class == ClassIrreplaceable && !it.BackedUp() && strings.TrimSpace(it.Redundancy) == "" { - say("%s is irreplaceable and is neither backed up nor said to be protected by redundancy; the only "+ - "copy of something is protected one way or the other (novox/hq ADR 0233) — copy it, dump it into "+ - "another item, or say `redundancy` with why that is enough", label) - } - if it.Class == ClassCache && it.Backup != nil && !it.Backup.None { - say("%s is a cache and asks to be backed up; a cache is disposable, or it is not a cache", label) - } - if it.Class == ClassCache && it.Redundancy != "" { - say("%s is a cache and says it is protected by redundancy; a cache is not protected", label) - } - switch it.Measure { - case "", MeasureWalk, MeasureDataset, MeasureShallow: - default: - say("%s's measure %q is walk, dataset or shallow", label, it.Measure) - } - if _, err := ParseDataDuration(it.Within); err != nil { - say("%s's within: %v", label, err) - } - if _, err := ParseDataDuration(it.Active); err != nil { - say("%s's active: %v", label, err) - } - if it.Within != "" && !it.BackedUp() { - say("%s states within, and is not backed up", label) - } - if it.Active != "" && !Watched(it.Class) { - say("%s is %s and expects writes; only irreplaceable and valuable data is watched", label, it.Class) - } - } - // Dumps go into an item of the same module, declared, and not into themselves. - for _, it := range m.Data.Own { - if it.Backup == nil || it.Backup.Copy || it.Backup.None { - continue - } - switch into, ok := ids[it.Backup.Into]; { - case strings.TrimSpace(it.Backup.Dump) == "": - say("%s's backup names no dump command", it.ID) - case it.Backup.Into == "": - say("%s's dump says no item it writes into", it.ID) - case !ok: - say("%s's dump writes into %q, which is not one of the module's data items", it.ID, it.Backup.Into) - case into.ID == it.ID: - say("%s's dump writes into itself; a dump is copied from where it lands", it.ID) - case into.Class == ClassCache: - say("%s's dump writes into %s, a cache; what is copied must not be disposable", it.ID, into.ID) - } - if it.Backup.Dump != "" { - declared := map[string]string{} - for d := range dirs { - declared[d] = "" - } - if _, err := dirFill(it.Backup.Dump, declared, m.Module); err != nil { - say("%s's dump: %v", it.ID, err) - } - } - } - provided := map[string]bool{} - for _, o := range m.Provides { - provided[o.Name] = true - } - wants := map[string]bool{} - for _, w := range m.Wants() { - wants[w] = true - } - for _, provision := range sortedKeys(m.Data.Consumers) { - c := m.Data.Consumers[provision] - if !provided[provision] { - say("says what it keeps for the consumers of %q, which it does not provide", provision) - } - switch c.Class { - case ClassIrreplaceable, ClassValuable, ClassRebuildable, ClassCache, ClassNone: - default: - say("its consumers' %s is class %q; one of irreplaceable, valuable, rebuildable, cache or none", provision, c.Class) - } - switch { - case c.Class == ClassNone && c.In != "": - say("its consumers' %s keeps nothing and says where it is kept (%s)", provision, c.In) - case keepsByClass(c.Class) && c.In == "": - say("its consumers' %s is %s and says nowhere it lives: `in` names one of the module's items, or a "+ - "provision it requires", provision, c.Class) - case c.In != "": - if own, ok := ids[c.In]; ok { - if c.Class == ClassIrreplaceable && !own.BackedUp() && own.Redundancy == "" { - say("its consumers' %s is irreplaceable and lives in %s, which is not protected", provision, c.In) - } - if classRank[own.Class] < classRank[c.Class] { - say("its consumers' %s is %s and lives in %s, which is only %s", provision, c.Class, c.In, own.Class) - } - } else if !wants[c.In] { - say("its consumers' %s lives in %q, which is neither one of its data items nor a provision it "+ - "requires", provision, c.In) - } - } - } - for _, provision := range sortedKeys(m.Data.KeptBy) { - k := m.Data.KeptBy[provision] - if !wants[provision] { - say("says it keeps data with the provider of %q, which it does not require", provision) - } - switch k.Class { - case ClassIrreplaceable, ClassValuable, ClassRebuildable, ClassCache: - default: - say("what it keeps with %s is class %q; one of irreplaceable, valuable, rebuildable or cache", provision, k.Class) - } - } - return problems -} - // KeepsConsumerData is whether this module, providing a provision, keeps what each consumer writes // there (novox/hq ADR 0232, ADR 0233): whether a consumer bound to it is bound to its data. // @@ -596,196 +419,9 @@ func (m Manifest) allContributions() []SeatContribution { // --- the catalogue check ------------------------------------------------------------------------ -// DataProblems is what the catalogue check refuses about the data a module declares (novox/hq ADR -// 0233), judged over the manifests given together: -// -// - a provider that grants a provision says what it keeps for that provision's consumers; -// - nobody says it on the offer any more (`keeps-consumer-data`): the data section is the one place; -// - nobody writes a backup line by hand: it is derived from the data section; -// - a directory a container writes, mounted whole, is a data item of some class; -// - consumer data said to live in a required provision lives where that provision's provider keeps -// its consumers' data, as preciously, when the provider is given; -// - data a consumer keeps with a provider as irreplaceable is protected there, when the provider is -// given. -// -// **The check's, not registration's**, as ADR 0214's backup rule was: a module already on the shelf -// was written before the rule, and refusing it there would refuse the very providers whose data the -// rule protects. Each module meets it where it is written. -func DataProblems(shelf Shelf) []string { - var problems []string - for _, name := range shelfOrder(shelf) { - m := shelf[name] - for _, provision := range sortedKeys(m.Grants) { - if _, said := m.ConsumerDataOf(provision); !said { - problems = append(problems, fmt.Sprintf( - "%s grants %s and does not say what it keeps for its consumers: data.consumers.%s with a "+ - "class — irreplaceable, valuable, rebuildable, cache, or none (novox/hq ADR 0233)", name, provision, provision)) - } - } - for _, o := range m.Provides { - if o.KeepsConsumerData != nil { - problems = append(problems, fmt.Sprintf( - "%s says keeps-consumer-data on its offer of %s; it is said once, in data.consumers.%s, with a "+ - "class (novox/hq ADR 0233)", name, o.Name, o.Name)) - } - } - for i, c := range m.Contributions { - if s, known := SeatNamed(c.Seat); known && s.Name == BackupSeat { - problems = append(problems, fmt.Sprintf( - "%s's contribution %d is a backup line written by hand; backups are derived from the data "+ - "section — declare the data, with its class and how it is protected (novox/hq ADR 0233)", name, i+1)) - } - } - for _, dir := range writtenDirectories(m) { - if !coversDirectory(m, dir) { - problems = append(problems, fmt.Sprintf( - "%s mounts its directory %q into a container to be written, and declares no data in it: "+ - "data.own with a class — cache if it is disposable (novox/hq ADR 0233)", name, dir)) - } - } - if m.Data == nil { - continue - } - for _, provision := range sortedKeys(m.Data.Consumers) { - c := m.Data.Consumers[provision] - if c.In == "" { - continue - } - if _, own := m.DataItem(c.In); own { - continue - } - for _, pname := range shelfOrder(shelf) { - p := shelf[pname] - pc, said := p.ConsumerDataOf(c.In) - if !said || !providesName(p, c.In) { - continue - } - if classRank[pc.Class] < classRank[c.Class] { - problems = append(problems, fmt.Sprintf( - "%s keeps its consumers' %s, %s, in %s — and %s keeps its consumers' %s as %s, "+ - "so it is not protected as %s", name, provision, c.Class, c.In, pname, c.In, pc.Class, c.Class)) - } - } - } - for _, provision := range sortedKeys(m.Data.KeptBy) { - k := m.Data.KeptBy[provision] - if k.Class != ClassIrreplaceable { - continue - } - for _, pname := range shelfOrder(shelf) { - p := shelf[pname] - pc, said := p.ConsumerDataOf(provision) - if !said || !providesName(p, provision) { - continue - } - if !keepsByClass(pc.Class) { - problems = append(problems, fmt.Sprintf( - "%s keeps irreplaceable data with %s, and %s keeps its consumers' %s as %s — nothing of it is "+ - "protected there", name, provision, pname, provision, pc.Class)) - continue - } - if in, own := p.DataItem(pc.In); own && !in.BackedUp() && in.Redundancy == "" { - problems = append(problems, fmt.Sprintf( - "%s keeps irreplaceable data with %s, and %s keeps it in %s, which is neither backed up nor "+ - "on declared redundancy", name, provision, pname, pc.In)) - } - } - } - } - return problems -} - -func providesName(m Manifest, provision string) bool { - for _, o := range m.Provides { - if o.Name == provision { - return true - } - } - return false -} - -// writtenDirectories are the module's directories a container mounts whole and may write: a volume -// `${dir:}:` without `:ro`, or a directory stated by an absolute path mounted the same -// way. A file beneath a directory, or a read-only mount, is configuration the mesh wrote. -func writtenDirectories(m Manifest) []string { - absolute := map[string]string{} - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) != "directory" { - continue - } - if p, ok := r["path"].(string); ok && strings.HasPrefix(p, "/") { - absolute[strings.TrimRight(p, "/")] = fmt.Sprint(r["id"]) - } - } - seen := map[string]bool{} - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) != "container" { - continue - } - vols, _ := r["volumes"].([]any) - for _, v := range vols { - s, _ := v.(string) - mount := volumeMount.FindStringSubmatch(s) - if mount == nil || volumeReadOnly(mount[3]) { - continue - } - src := strings.TrimRight(mount[1], "/") - if ref := dirPlain.FindStringSubmatch(src); ref != nil { - seen[ref[1]] = true - } else if id, ok := absolute[src]; ok { - seen[id] = true - } - } - } - out := make([]string, 0, len(seen)) - for id := range seen { - out = append(out, id) - } - sort.Strings(out) - return out -} - // volumeMount is a container's volume, `:[:]`, its source possibly a // `${dir:}` — whose own colon is not the separator. var volumeMount = regexp.MustCompile(`^(\$\{(?:dir|access):[a-z0-9][a-z0-9-]*\}[^:]*|[^:]+):([^:]+)(?::(.*))?$`) -// volumeReadOnly is whether a volume's options mount it read-only. -func volumeReadOnly(options string) bool { - for _, o := range strings.Split(options, ",") { - if strings.TrimSpace(o) == "ro" { - return true - } - } - return false -} - // dirPlain is a whole directory, `${dir:}` and nothing after it. var dirPlain = regexp.MustCompile(`^\$\{dir:([a-z0-9][a-z0-9-]*)\}$`) - -// coversDirectory is whether a data item names the directory, a path within it, or a directory it -// is placed beneath. -func coversDirectory(m Manifest, dir string) bool { - parents := map[string]string{} - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) != "directory" { - continue - } - if p, ok := r["path"].(string); ok { - if ref := dirRef.FindStringSubmatch(p); ref != nil && strings.HasPrefix(p, "${dir:") { - parents[fmt.Sprint(r["id"])] = ref[1] - } - } - } - for _, it := range m.DataItems() { - ref := dataPathRef.FindStringSubmatch(it.Path) - if ref == nil || ref[1] != "dir" { - continue - } - for d, hops := dir, 0; d != "" && hops < 4; d, hops = parents[d], hops+1 { - if ref[2] == d { - return true - } - } - } - return false -} diff --git a/go/catalogue/data_test.go b/go/catalogue/data_test.go index a013e9c..a3e98d7 100644 --- a/go/catalogue/data_test.go +++ b/go/catalogue/data_test.go @@ -2,7 +2,6 @@ package catalogue import ( "slices" - "strings" "testing" ) @@ -24,111 +23,13 @@ const declaredStore = `{"module":"pg","version":"1", func mustParse(t *testing.T, raw string) Manifest { t.Helper() - m, err := ParseManifest([]byte(raw)) + m, err := decodeForTest([]byte(raw)) if err != nil { t.Fatalf("refused: %v", err) } return m } -func TestADeclaredStoreParsesAndPassesTheCheck(t *testing.T) { - m := mustParse(t, declaredStore) - if problems := DataProblems(Shelf{"pg": m}); len(problems) > 0 { - t.Fatalf("a store declaring its data was refused: %v", problems) - } - if !m.KeepsConsumerData("postgres-database") { - t.Fatal("an irreplaceable consumer class does not keep the consumer's data") - } - if it, _ := m.DataItem("store"); !it.BackedUp() || it.BackupWithin() != DefaultBackupWithin || it.ActiveWithin().Hours() != 24 { - t.Fatalf("the store item reads %+v", it) - } - if it, _ := m.DataItem("dumps"); it.BackedUp() { - t.Fatal("a rebuildable item that says none is backed up") - } -} - -// Every rule of the section itself, refused at parse in the words of the module. -func TestTheDataSectionIsRefusedWhereItIsWrong(t *testing.T) { - for _, c := range []struct{ name, data, want string }{ - {"a class it does not know", `{"own":[{"id":"a","path":"${dir:d}","class":"precious"}]}`, "not one the mesh protects by"}, - {"an access it does not have", `{"own":[{"id":"a","path":"${access:media}","class":"irreplaceable","redundancy":"an array"}]}`, "declares no access"}, - {"irreplaceable and protected by nothing", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","backup":"none"}]}`, "protected one way or the other"}, - {"a cache on redundancy", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","redundancy":"an array"}]}`, "a cache is not protected"}, - {"kept with something not required", `{"kept-by":{"s3-bucket":{"class":"irreplaceable"}}}`, "does not require"}, - {"a measure it does not know", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","measure":"du"}]}`, "walk, dataset or shallow"}, - {"a cache expecting writes", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","active":"1d"}]}`, "only irreplaceable and valuable"}, - {"a machine path", `{"own":[{"id":"a","path":"/srv/a","class":"cache"}]}`, "names no machine path"}, - {"a directory it does not declare", `{"own":[{"id":"a","path":"${dir:nowhere}","class":"cache"}]}`, "declares no directory"}, - {"a cache backed up", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","backup":"copy"}]}`, "disposable"}, - {"a dump into nothing", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","backup":{"dump":"x","into":"b"}}]}`, "not one of the module's data items"}, - {"a dump into itself", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","backup":{"dump":"x","into":"a"}}]}`, "into itself"}, - {"a bad duration", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","within":"soon"}]}`, "not a duration"}, - {"the same id twice", `{"own":[{"id":"a","path":"${dir:d}","class":"cache"},{"id":"a","path":"${dir:d}/x","class":"cache"}]}`, "declared twice"}, - {"own data of class none", `{"own":[{"id":"a","path":"${dir:d}","class":"none"}]}`, "only a provision"}, - {"consumers of something not provided", `{"consumers":{"s3-bucket":{"class":"cache"}}}`, "does not provide"}, - {"consumer data that lives nowhere", `{"consumers":{"q":{"class":"irreplaceable"}}}`, "says nowhere it lives"}, - {"consumer data in an item less precious", `{"own":[{"id":"a","path":"${dir:d}","class":"rebuildable"}],"consumers":{"q":{"class":"irreplaceable","in":"a"}}}`, "which is only rebuildable"}, - {"consumer data in something unknown", `{"consumers":{"q":{"class":"rebuildable","in":"elsewhere"}}}`, "neither one of its data items"}, - } { - raw := `{"module":"m","version":"1","provides":["q"],"resources":[{"id":"d","type":"directory","mode":"0700"}],"data":` + c.data + `}` - _, err := ParseManifest([]byte(raw)) - if err == nil || !strings.Contains(err.Error(), c.want) { - t.Errorf("%s: %v, want %q", c.name, err, c.want) - } - } -} - -// The catalogue check: a granting provider says what it keeps, says it in one place, writes no backup -// line by hand, and every directory a container writes is declared (novox/hq ADR 0233). -func TestTheCatalogueCheckAsksEveryModuleForItsData(t *testing.T) { - unsaid := mustParse(t, `{"module":"q","version":"1","provides":[{"name":"queue","scope":"mesh"}], - "grants":{"queue":"${dir:g}"},"resources":[{"id":"g","type":"directory","mode":"0700"}]}`) - onTheOffer := mustParse(t, `{"module":"r","version":"1","provides":[{"name":"resolver","scope":"mesh","keeps-consumer-data":false}]}`) - byHand := mustParse(t, `{"module":"h","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"}], - "contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:d}"}]}`) - writes := mustParse(t, `{"module":"w","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"}, - {"id":"c","type":"directory","mode":"0700"}, - {"id":"app","type":"container","image":"x@sha256:aa","volumes":["${dir:d}:/data","${dir:c}:/etc/app:ro","${dir:d}/f:/f"]}]}`) - problems := strings.Join(DataProblems(Shelf{"q": unsaid, "r": onTheOffer, "h": byHand, "w": writes}), "\n") - for _, want := range []string{ - "q grants queue and does not say what it keeps", - "r says keeps-consumer-data on its offer", - "h's contribution 1 is a backup line written by hand", - `w mounts its directory "d" into a container to be written`, - } { - if !strings.Contains(problems, want) { - t.Errorf("the check does not say %q:\n%s", want, problems) - } - } - if strings.Contains(problems, `"c"`) { - t.Errorf("a read-only mount was taken for written data:\n%s", problems) - } - // The same module declaring the directory, as a cache, passes. - declared := mustParse(t, `{"module":"w","version":"1","data":{"own":[{"id":"d","path":"${dir:d}","class":"cache"}]}, - "resources":[{"id":"d","type":"directory","mode":"0700"}, - {"id":"app","type":"container","image":"x@sha256:aa","volumes":["${dir:d}:/data"]}]}`) - if p := DataProblems(Shelf{"w": declared}); len(p) > 0 { - t.Fatalf("a declared directory is still refused: %v", p) - } -} - -// Consumer data said to live in a provision the module requires is protected only as well as that -// provision's provider protects its consumers' data. -func TestConsumerDataInARequiredProvisionIsJudgedAgainstItsProvider(t *testing.T) { - idp := mustParse(t, `{"module":"idp","version":"1","requires":["postgres-database"], - "provides":[{"name":"oidc-client","scope":"mesh"}],"grants":{"oidc-client":"${dir:g}"}, - "resources":[{"id":"g","type":"directory","mode":"0700"}], - "data":{"consumers":{"oidc-client":{"class":"irreplaceable","in":"postgres-database"}}}}`) - cachy := mustParse(t, strings.Replace(declaredStore, `"consumers":{"postgres-database":{"class":"irreplaceable","in":"store"}}`, - `"consumers":{"postgres-database":{"class":"cache"}}`, 1)) - if p := strings.Join(DataProblems(Shelf{"idp": idp, "pg": cachy}), "\n"); !strings.Contains(p, "not protected as irreplaceable") { - t.Fatalf("irreplaceable data kept in a cache passed: %s", p) - } - if p := DataProblems(Shelf{"idp": idp, "pg": mustParse(t, declaredStore)}); len(p) > 0 { - t.Fatalf("refused against a provider that keeps its consumers' data: %v", p) - } -} - // What a binding is sticky by follows the class (ADR 0232, generalised by 0233): a cache's consumers // move freely, a store's do not; an older definition saying nothing still follows its grant. func TestKeepingConsumerDataFollowsTheClass(t *testing.T) { @@ -215,23 +116,3 @@ func TestTheBackupHoldersLinesAreDerivedFromTheData(t *testing.T) { t.Fatalf("an operator's path reads %+v", it) } } - -// Data a consumer keeps with a provider as irreplaceable must be protected there: the check refuses a -// provider that keeps its consumers' data as a cache, or in an item with no protection. -func TestIrreplaceableDataKeptWithAProviderMustBeProtectedThere(t *testing.T) { - photos := mustParse(t, `{"module":"photos","version":"1","requires":["s3-bucket"], - "data":{"kept-by":{"s3-bucket":{"class":"irreplaceable"}}}}`) - store := func(backup string) Manifest { - return mustParse(t, `{"module":"objects","version":"1","provides":[{"name":"s3-bucket","scope":"mesh"}], - "grants":{"s3-bucket":"${dir:g}"}, - "data":{"own":[{"id":"data","path":"${dir:data}","class":"valuable","backup":"`+backup+`"}], - "consumers":{"s3-bucket":{"class":"valuable","in":"data"}}}, - "resources":[{"id":"g","type":"directory","mode":"0700"},{"id":"data","type":"directory","mode":"0700"}]}`) - } - if p := DataProblems(Shelf{"photos": photos, "objects": store("copy")}); len(p) > 0 { - t.Fatalf("a provider backing its consumers' data up was refused: %v", p) - } - if p := strings.Join(DataProblems(Shelf{"photos": photos, "objects": store("none")}), "\n"); !strings.Contains(p, "neither backed up") { - t.Fatalf("irreplaceable photos in an unprotected store passed: %s", p) - } -} diff --git a/go/catalogue/decode_for_test_test.go b/go/catalogue/decode_for_test_test.go new file mode 100644 index 0000000..fa3d9c0 --- /dev/null +++ b/go/catalogue/decode_for_test_test.go @@ -0,0 +1,12 @@ +package catalogue + +// decodeForTest builds a manifest for a test as every reader reads one (Decode); the judge's own tests are in +// mesh-delivery's judge (novox/hq ADR 0578 §4). +func decodeForTest(raw []byte) (Manifest, error) { return Decode(raw) } + +// decodeWithForTest is decodeForTest keeping the list of predefined keys it was read with. +func decodeWithForTest(raw []byte, list PredefinedList) (Manifest, error) { + m, err := Decode(raw) + m.SetPredefined(list) + return m, err +} diff --git a/go/catalogue/delivery_seat.go b/go/catalogue/delivery_seat.go index 5cab2ce..f1d2d24 100644 --- a/go/catalogue/delivery_seat.go +++ b/go/catalogue/delivery_seat.go @@ -1,7 +1,5 @@ package catalogue -import "fmt" - // The delivery's owner (novox/hq ADR 0239, to-be 47): one holder for the mesh, the module mesh-delivery, // which owns a delivery — one commit in one repository, from its pull request's head to every machine — // and a delivery group, and asks the controller for every act. A seat of the mesh's own, so the role has @@ -107,29 +105,6 @@ func SeatSays(emits []string, event string) bool { return false } -// seatEventProblems refuses a module that lists among its own `emits` an event a seat it claims says (the lead, -// 2026-10-29, issue 1952, the second of a kind: the self-check's `checked` and mesh-hand-acts' `act-recorded`). A -// consumer of `.` hears the seat's subject (SeatSays), so the holder says it there, with stdio.SeatPublish -// on `mesh.seat..event.`; said through stdio.Emit as the module's own, it lands where nobody listens. Caught -// here, when the manifest is read at the build, not when nothing is heard on the machines. -func seatEventProblems(m Manifest) []string { - var problems []string - for _, c := range m.Claims { - seat, known := SeatNamed(c.Name) - if !known || len(seat.Emits) == 0 { - continue - } - for _, e := range m.Emits { - if SeatSays(seat.Emits, e) { - problems = append(problems, fmt.Sprintf("%s lists %q among its own emits, and the seat %s it claims says it: "+ - "say it on the seat with stdio.SeatPublish(\"mesh.seat.%s.event.%s\", ...) and leave it out of emits, or "+ - "no consumer of %s.%s hears it", m.Module, e, seat.Name, seat.Name, e, seat.Name, e)) - } - } - } - return problems -} - // topicMatches is whether an event's name matches a declared pattern, `*` one dot-separated segment and // `>` or `**` the rest. func topicMatches(pattern, event string) bool { diff --git a/go/catalogue/derived_for_consumer_test.go b/go/catalogue/derived_for_consumer_test.go index 8d5e973..7ae5a5b 100644 --- a/go/catalogue/derived_for_consumer_test.go +++ b/go/catalogue/derived_for_consumer_test.go @@ -193,32 +193,6 @@ func TestASettingComposesWithADerivedValue(t *testing.T) { } } -// A fact or an alphabet the mesh does not have is refused where the definition is, not where a -// consumer happens to be resolved — and the refusal says what may be said instead. -func TestAServedValueNamingSomethingTheMeshDoesNotHaveIsRefused(t *testing.T) { - for _, c := range []struct{ value, says string }{ - {"${consumer:node}", "as"}, - {"${consumer:as:punycode}", "dns"}, - } { - m := store() - m.Serves["s3-bucket"]["bucket"] = c.value - raw, err := json.Marshal(m) - if err != nil { - t.Fatal(err) - } - _, err = ParseManifest(raw) - if err == nil { - t.Fatalf("%s was accepted", c.value) - } - if !strings.Contains(err.Error(), c.value) { - t.Errorf("the refusal of %s does not quote it: %v", c.value, err) - } - if !strings.Contains(err.Error(), c.says) { - t.Errorf("the refusal of %s does not say what may be said (%q): %v", c.value, c.says, err) - } - } -} - // `dns` is checked against an identity the mesh actually mints, not an invented string. func TestTheDNSAlphabetIsTheMintedIdentityWithItsSeparatorRewritten(t *testing.T) { as := ConsumerIdentity("anchor", IdentitySource("ncloud", "nextcloud")) diff --git a/go/catalogue/dir_into.go b/go/catalogue/dir_into.go index dd3be2f..e9b3f92 100644 --- a/go/catalogue/dir_into.go +++ b/go/catalogue/dir_into.go @@ -106,14 +106,6 @@ func dirsFor(m Manifest, with Rendering) map[string]string { return dirs } -// placedOrAbsolute says a path is usable where the mesh needs one: absolute already, or -// beginning with a placed reference — resolution makes it absolute before anything reads it. -// (unknownDirRefs is what checks the reference names a real directory.) -func placedOrAbsolute(path string) bool { - return strings.HasPrefix(path, "/") || - (strings.HasPrefix(path, "${dir:") && dirRef.MatchString(path)) -} - // dirFill resolves every ${dir:…} in one string, or refuses a reference naming no directory. func dirFill(s string, dirs map[string]string, module string) (string, error) { var missing error @@ -260,109 +252,6 @@ func dirInto(resource map[string]any, dirs map[string]string, module string) err return nil } -// unknownDirRefs is every ${dir:…} in the definition that names no directory the definition -// declares — refused where the author is, not at composition on some later day (the same -// near-versus-far reasoning as the host's strict parse). -func (m Manifest) unknownDirRefs() []string { - declared := map[string]bool{} - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) == "directory" { - declared[fmt.Sprint(r["id"])] = true - } - } - referenced := func(s string) []string { - var ids []string - for _, match := range dirRef.FindAllStringSubmatch(s, -1) { - ids = append(ids, match[1]) - } - return ids - } - var problems []string - for _, r := range m.Resources { - place, said := r["place"].(string) - if !said { - continue - } - if fmt.Sprint(r["type"]) != "directory" { - problems = append(problems, fmt.Sprintf( - "%s says place on %v, which is not a directory — only a directory is placed", - m.Module, r["id"])) - continue - } - if path, stated := r["path"].(string); stated && path != "" { - problems = append(problems, fmt.Sprintf( - "%s states both path and place on %v — a stated path IS the placement", - m.Module, r["id"])) - } - if place != placeOwn && place != placeMesh { - problems = append(problems, fmt.Sprintf( - "%s says place %q on %v, and the places are %q — the assignment's own root — and "+ - "%q — where the mesh keeps what it writes for the module", - m.Module, place, r["id"], placeOwn, placeMesh)) - } - } - seen := map[string]bool{} - refuse := func(id string, where any) { - if declared[id] || seen[id] { - return - } - seen[id] = true - problems = append(problems, fmt.Sprintf( - "%s says ${dir:%s} in %v, and declares no directory %q — a reference the mesh "+ - "cannot place would reach the machine as a literal path", - m.Module, id, where, id)) - } - for _, r := range m.Resources { - for _, field := range []string{"path", "content"} { - if s, ok := r[field].(string); ok { - for _, id := range referenced(s) { - refuse(id, r["id"]) - } - } - } - for _, field := range []string{"volumes", "env-file"} { - if list, ok := r[field].([]any); ok { - for _, v := range list { - if s, ok := v.(string); ok { - for _, id := range referenced(s) { - refuse(id, r["id"]) - } - } - } - } - } - if env, ok := r["env"].(map[string]any); ok { - for _, v := range env { - if s, ok := v.(string); ok { - for _, id := range referenced(s) { - refuse(id, r["id"]) - } - } - } - } - } - maps := map[string]map[string]string{ - "receives": m.Receives, "binds": m.Binds, "secrets": m.Secrets, - "own-secrets": m.OwnSecrets.Paths(), "grants": m.Grants, - } - for field, entries := range maps { - for _, value := range entries { - for _, id := range referenced(value) { - refuse(id, field) - } - } - } - for to, locals := range m.SecretsMany { - for _, value := range locals { - for _, id := range referenced(value) { - refuse(id, "secrets."+to) - } - } - } - sort.Strings(problems) - return problems -} - func namesOfDirs(dirs map[string]string) []string { var names []string for id := range dirs { diff --git a/go/catalogue/dir_into_test.go b/go/catalogue/dir_into_test.go index ee7a96e..8c7a3a5 100644 --- a/go/catalogue/dir_into_test.go +++ b/go/catalogue/dir_into_test.go @@ -110,18 +110,6 @@ func TestFillingForOneNodeLeaksIntoNoOther(t *testing.T) { } } -func TestAReferenceToNoDirectoryRefusesAtTheManifest(t *testing.T) { - m := placedModule() - m.Resources[2]["volumes"] = []any{"${dir:date}:/data"} // a typo, the likely shape - problems := m.unknownDirRefs() - if len(problems) != 1 || !strings.Contains(problems[0], `${dir:date}`) { - t.Fatalf("a reference naming no directory is a manifest problem; got %v", problems) - } - if got := placedModule().unknownDirRefs(); len(got) != 0 { - t.Fatalf("a correct definition has none; got %v", got) - } -} - func TestAReferenceToNoDirectoryRefusesAtCompositionToo(t *testing.T) { m := placedModule() container := shallowCopy(m.Resources[2]) @@ -188,39 +176,6 @@ func TestTheManifestsMapsArePlaced(t *testing.T) { } } -func TestAMapReferenceToNoDirectoryRefusesAtTheManifest(t *testing.T) { - m := Manifest{ - Module: "photos", - Resources: []map[string]any{{"id": "state", "type": "directory", "place": "."}}, - Binds: map[string]string{"route": "${dir:stat}/route.json"}, - } - problems := m.unknownDirRefs() - if len(problems) != 1 || !strings.Contains(problems[0], `${dir:stat}`) { - t.Fatalf("a map naming no directory is a manifest problem; got %v", problems) - } -} - -func TestPlaceIsValidatedAtTheManifest(t *testing.T) { - both := Manifest{Module: "x", Resources: []map[string]any{ - {"id": "d", "type": "directory", "place": ".", "path": "/somewhere"}, - }} - if got := both.unknownDirRefs(); len(got) != 1 || !strings.Contains(got[0], "both path and place") { - t.Fatalf("path beside place refuses; got %v", got) - } - elsewhere := Manifest{Module: "x", Resources: []map[string]any{ - {"id": "f", "type": "file", "place": ".", "path": "/somewhere", "content": ""}, - }} - if got := elsewhere.unknownDirRefs(); len(got) != 1 || !strings.Contains(got[0], "not a directory") { - t.Fatalf("place on a file refuses; got %v", got) - } - wrong := Manifest{Module: "x", Resources: []map[string]any{ - {"id": "d", "type": "directory", "place": "sub/dir"}, - }} - if got := wrong.unknownDirRefs(); len(got) != 1 || !strings.Contains(got[0], `the places are "."`) { - t.Fatalf("a place that is neither root refuses; got %v", got) - } -} - func TestTheMeshsDirectoryForAModuleIsAPlace(t *testing.T) { // novox/hq issue 174. What the mesh writes for a module — its bus credential, its bindings — // is the mesh's plumbing under /mesh/, and the definition names it by id. @@ -234,9 +189,6 @@ func TestTheMeshsDirectoryForAModuleIsAPlace(t *testing.T) { OwnSecrets: OwnSecrets{"broker": {Path: "${dir:mesh-state}/broker"}}, Binds: map[string]string{"route": "${dir:state}/route.json"}, } - if got := m.unknownDirRefs(); len(got) != 0 { - t.Fatalf("place %q is a place; got %v", "mesh", got) - } dirs := dirsFor(m, Rendering{}) if dirs["mesh-state"] != "/var/lib/mesh/umami" || dirs["state"] != "/var/lib/umami" { t.Fatalf("the mesh's directory sits beside the module's, not in it; got %v", dirs) @@ -301,9 +253,6 @@ func TestADirectoryBeneathAPlacedOneIsPlacedWithIt(t *testing.T) { {"id": "server", "type": "container", "image": "x@sha256:aa", "volumes": []any{"${dir:runtime-state}:/data"}}, }} - if got := m.unknownDirRefs(); len(got) != 0 { - t.Fatalf("a path beneath a placed directory is well formed; got %v", got) - } dirs := dirsFor(m, Rendering{DataRoot: "/srv"}) if dirs["runtime-state"] != "/srv/mesh/gitea/state" { t.Fatalf("the subdirectory follows the placed one; got %v", dirs) diff --git a/go/catalogue/endpoint_test.go b/go/catalogue/endpoint_test.go index d05f040..4df27f8 100644 --- a/go/catalogue/endpoint_test.go +++ b/go/catalogue/endpoint_test.go @@ -1,7 +1,6 @@ package catalogue import ( - "strings" "testing" ) @@ -100,52 +99,6 @@ func TestTwoEndpointsOfDifferentShapesAreConfiguredSeparately(t *testing.T) { } } -// A route naming an endpoint the module does not declare reaches nothing, and is refused where it is -// written rather than resolving to no port and serving nothing. -func TestARouteNamingAnEndpointTheModuleLacksIsRefused(t *testing.T) { - m := aMediaServer() - m.Contributes["route"][RouteEndpoint] = "absent" - got := strings.Join(RouteProblems(m), "\n") - if !strings.Contains(got, "does not declare") { - t.Fatalf("a route naming an absent endpoint was accepted:\n%s", got) - } -} - -// **Two endpoints called the same would make an assignment configure whichever was read last.** The -// point of a name is that it identifies one thing. -func TestTwoEndpointsWithOneNameAreRefused(t *testing.T) { - m := Manifest{Module: "twice", Listens: []Listening{ - {Name: "web", Port: 80, From: FromMesh}, - {Name: "web", Port: 8080, From: FromMesh}, - }} - got := strings.Join(endpointNameProblems(m), "\n") - if !strings.Contains(got, "could mean either") { - t.Fatalf("two endpoints with one name were accepted:\n%s", got) - } -} - -// A name that is not a name is refused where it is written: it ends up in something a person types. -func TestAnEndpointNameIsHeldToItsShape(t *testing.T) { - for _, wrong := range []string{"Web", "web port", "3000", "-web", "web_surface"} { - m := Manifest{Module: "odd", Listens: []Listening{{Name: wrong, Port: 80, From: FromMesh}}} - if got := strings.Join(endpointNameProblems(m), "\n"); !strings.Contains(got, "a name is lowercase") { - t.Fatalf("%q was accepted as an endpoint name:\n%s", wrong, got) - } - } -} - -// **Every endpoint in the catalogue is unnamed today, and must stay valid.** The word ships one -// release before anything uses it. -func TestAnUnnamedEndpointIsStillValid(t *testing.T) { - m := Manifest{Module: "ordinary", Listens: []Listening{{Port: 443, From: FromEverywhere}}} - if got := endpointNameProblems(m); len(got) != 0 { - t.Fatalf("an unnamed endpoint was refused: %v", got) - } - if got := RouteProblems(m); len(got) != 0 { - t.Fatalf("a module with no route was refused: %v", got) - } -} - // **A route that names an endpoint still carries that endpoint's port.** // // Everything downstream reads the port: the provider is told where to reach the consumer, and the diff --git a/go/catalogue/engine_guard_test.go b/go/catalogue/engine_guard_test.go index 2988c9e..1b52013 100644 --- a/go/catalogue/engine_guard_test.go +++ b/go/catalogue/engine_guard_test.go @@ -1,71 +1 @@ package catalogue - -import ( - "go/ast" - "go/parser" - "go/token" - "path/filepath" - "slices" - "strconv" - "testing" - - "git.novox.be/novox/mesh-sdk/go/internal/beside" -) - -// The controller refuses a definition's resolved path by the node-engine's own rules, no more (novox/hq issue -// 496): the same lists, read here from mesh-host's internal/apply/placement_guard.go — in a merge check the clone -// beside it at the commit the mesh runs, elsewhere the copy captured in testdata/beside. When the engine's lists -// move, this fails until the controller's move with them, so the gate never refuses what the engine applies nor -// passes what it refuses by path alone. -func TestTheResolvedPathRulesAreTheNodeEnginesOwn(t *testing.T) { - file := filepath.Join(beside.Dir(t, "mesh-host"), "internal", "apply", "placement_guard.go") - parsed, err := parser.ParseFile(token.NewFileSet(), file, nil, 0) - if err != nil { - t.Fatalf("the node-engine's guard does not parse: %v", err) - } - lists := map[string][]string{} - consts := map[string]string{} - ast.Inspect(parsed, func(n ast.Node) bool { - spec, ok := n.(*ast.ValueSpec) - if !ok { - return true - } - for i, name := range spec.Names { - if i >= len(spec.Values) { - continue - } - switch v := spec.Values[i].(type) { - case *ast.CompositeLit: - for _, elt := range v.Elts { - if lit, ok := elt.(*ast.BasicLit); ok && lit.Kind == token.STRING { - s, _ := strconv.Unquote(lit.Value) - lists[name.Name] = append(lists[name.Name], s) - } - } - case *ast.BasicLit: - if v.Kind == token.STRING { - consts[name.Name], _ = strconv.Unquote(v.Value) - } - } - } - return true - }) - for name, ours := range map[string][]string{ - "protectedRoots": protectedRoots, "forbiddenBelow": forbiddenBelow, "engineTrees": engineTrees, - } { - theirs := lists[name] - if len(theirs) == 0 { - t.Errorf("the node-engine's guard names no %s any more; read it and say where its rule went", name) - continue - } - a, b := slices.Clone(ours), slices.Clone(theirs) - slices.Sort(a) - slices.Sort(b) - if !slices.Equal(a, b) { - t.Errorf("%s differs from the node-engine's:\n controller %v\n node-engine %v", name, a, b) - } - } - if consts["engineModule"] != engineModule { - t.Errorf("the node-engine's own module is %q there and %q here", consts["engineModule"], engineModule) - } -} diff --git a/go/catalogue/environment_into.go b/go/catalogue/environment_into.go index c2faff4..a638f94 100644 --- a/go/catalogue/environment_into.go +++ b/go/catalogue/environment_into.go @@ -114,64 +114,6 @@ var ( ofContributed = regexp.MustCompile(`\$\{(shell|contribution|backup):([^}]*)\}`) ) -// variableName is a POSIX shell variable name, which is also what environment.d accepts. -var variableName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) - -// environmentProblems is what is wrong with this module's environment contribution, from the -// manifest alone. -func (m Manifest) environmentProblems() []string { - if m.Environment == nil { - return nil - } - var problems []string - for _, n := range sortedKeys(m.Environment.Variables) { - switch { - case !variableName.MatchString(n): - problems = append(problems, fmt.Sprintf( - "%s sets the variable %q, which is not a name a shell accepts: a letter or an "+ - "underscore, then letters, digits and underscores", m.Module, n)) - continue - case n == "PATH": - // PATH is the one variable every module shares, so no module may set it whole: a second - // setter would replace the first's entries, and the account's own PATH with them. - problems = append(problems, fmt.Sprintf( - "%s sets PATH as a variable; a module adds an entry under environment.path, at the "+ - "start or the end, and PATH is composed from every module's (novox/hq ADR 0203)", m.Module)) - continue - } - if why := literalProblem(m.Environment.Variables[n]); why != "" { - problems = append(problems, fmt.Sprintf( - "%s sets %s to %q, which %s — %s", m.Module, n, m.Environment.Variables[n], why, literalRule)) - } - } - seen := map[string]bool{} - for i, p := range m.Environment.Path { - switch { - case p.Entry == "": - problems = append(problems, fmt.Sprintf("%s's PATH entry %d names no directory", m.Module, i+1)) - case strings.Contains(ofMachine.ReplaceAllString(p.Entry, ""), ":"): - // A colon is PATH's own separator, so an entry holding one is two entries, and the - // check that it is already present would look for the wrong thing. - problems = append(problems, fmt.Sprintf( - "%s puts %q on PATH, which holds a colon, PATH's own separator", m.Module, p.Entry)) - case seen[p.Entry]: - problems = append(problems, fmt.Sprintf("%s puts %q on PATH twice", m.Module, p.Entry)) - default: - if why := literalProblem(p.Entry); why != "" { - problems = append(problems, fmt.Sprintf( - "%s puts %q on PATH, which %s — %s", m.Module, p.Entry, why, literalRule)) - } - } - seen[p.Entry] = true - if p.At != PathAtStart && p.At != PathAtEnd { - problems = append(problems, fmt.Sprintf( - "%s puts %q on PATH at %q; an entry goes at %q or %q of the account's PATH", - m.Module, p.Entry, p.At, PathAtStart, PathAtEnd)) - } - } - return problems -} - // literalRule is why a value must be literal, said with every refusal of one. const literalRule = "a value is literal, so a POSIX shell and the service manager read it alike, and " + "names the machine only through the mesh's own ${machine:…} facts (novox/hq ADR 0203)" @@ -195,46 +137,6 @@ func literalProblem(v string) string { return "" } -// shellProblems is what is wrong with this module's shell code, from the manifest alone. The code -// itself is not judged: it is the shell's syntax, which the controller does not read. -func (m Manifest) shellProblems() []string { - var problems []string - for i, c := range m.Shell { - if !oneOf(contributionTargets(), c.For) { - problems = append(problems, fmt.Sprintf( - "%s's shell code %d is for %q; the shells are %s, the session's files %s, and the power "+ - "moments %s", m.Module, i+1, c.For, strings.Join(knownShells, ", "), - strings.Join(sessionFiles, ", "), strings.Join(powerMoments, ", "))) - } - if !oneOf(knownSlots, c.Slot) { - problems = append(problems, fmt.Sprintf( - "%s's shell code %d goes in the slot %q; the slots are %s", m.Module, i+1, c.Slot, - strings.Join(knownSlots, ", "))) - } - if strings.TrimSpace(c.Code) == "" { - problems = append(problems, fmt.Sprintf("%s's shell code %d has no code", m.Module, i+1)) - } - } - return problems -} - -// contributionPlaceholderProblems is every place this module's resources name the environment or -// the shell's code and may not — judged from the manifest, so the catalogue check refuses it before -// a mesh does, and again at composition in the same words. -func (m Manifest) contributionPlaceholderProblems() []string { - var problems []string - for _, r := range m.Resources { - problems = append(problems, placeholderProblems(m, r)...) - problems = append(problems, seatPlaceholderProblems(m, r)...) - problems = append(problems, collectedPlaceholderProblems(m, r)...) - problems = append(problems, consumersPlaceholderProblems(m, r)...) - // And every placeholder no pass fills where it stands: a misspelt namespace, a key its - // namespace cannot take, or a field its pass does not read (novox/hq issue 231). - problems = append(problems, unconsumedPlaceholders(m.Module, r)...) - } - return problems -} - // placeholderProblems is what is wrong with one resource's ${environment:…} and ${shell:…}. // // **The seat authorises it, not the placeholder** (novox/hq ADR 0203 §5, ADR 0204 §3), as the seat diff --git a/go/catalogue/environment_into_test.go b/go/catalogue/environment_into_test.go index ca112e9..1df412e 100644 --- a/go/catalogue/environment_into_test.go +++ b/go/catalogue/environment_into_test.go @@ -276,102 +276,6 @@ func TestTheEnvironmentHolderPlacesBothRenderings(t *testing.T) { } } -// ADR 0203 §5 and ADR 0204 §3: a placeholder outside the seat's holder is refused — by the parser, -// which is what the catalogue check and registration run, and again at composition, in the same words. -func TestAPlaceholderOutsideTheHolderIsRefused(t *testing.T) { - for _, c := range []struct{ content, want string }{ - {"${environment:posix}", "toolchain's resource rc names ${environment:posix} and toolchain does not claim node-environment"}, - {"${shell:zsh:normal}", "toolchain's resource rc names ${shell:zsh:normal} and toolchain does not claim node-login-shell"}, - } { - raw := `{"module":"toolchain","resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` + c.content + `"}]}` - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) { - t.Errorf("the catalogue check accepted %s outside its holder: %v", c.content, err) - } - m := Manifest{Module: "toolchain", Resources: []map[string]any{ - {"id": "rc", "type": "file", "path": "/etc/rc", "content": c.content}}} - r := Resolution{Node: "workstation", Account: "op", Modules: []Manifest{m}} - if _, err := r.Declaration(Rendering{}); err == nil || !strings.Contains(err.Error(), c.want) { - t.Errorf("composition accepted %s outside its holder: %v", c.content, err) - } - } -} - -// A key nobody renders is refused, not left in the file as a literal. -func TestAnUnknownPlaceholderKeyIsRefused(t *testing.T) { - for _, c := range []struct{ content, want string }{ - {"${environment:foo}", "names ${environment:foo}; the environment is ${environment:posix} or ${environment:systemd}"}, - {"${shell:zsh:middle}", "names ${shell:zsh:middle}; shell code is ${shell::}"}, - {"${shell:tcsh:first}", "names ${shell:tcsh:first}; shell code is ${shell::}"}, - {"${shell:zsh}", "names ${shell:zsh}; shell code is ${shell::}"}, - } { - raw := `{"module":"holder","claims":[{"name":"node-environment","scope":"node"},{"name":"node-login-shell","scope":"node"}],` + - `"resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` + c.content + `"}]}` - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) { - t.Errorf("%s was accepted: %v", c.content, err) - } - } - // And outside a file's content, where nothing could be placed. - raw := `{"module":"holder","claims":[{"name":"node-environment","scope":"node"}],` + - `"resources":[{"id":"rc","type":"file","path":"/etc/${environment:posix}","content":"x"}]}` - if _, err := ParseManifest([]byte(raw)); err == nil || - !strings.Contains(err.Error(), "names ${environment:posix} in its path; the environment and the shell's code are placed only in a file's content") { - t.Errorf("a placeholder in a path was accepted: %v", err) - } -} - -// What ADR 0203 §2 allows a contribution to say, refused at parse when it says anything else. -func TestAMalformedEnvironmentIsRefusedAtParse(t *testing.T) { - for _, c := range []struct{ environment, want string }{ - {`{"variables":{"1X":"a"}}`, `tool sets the variable "1X", which is not a name a shell accepts`}, - {`{"variables":{"MY-VAR":"a"}}`, `tool sets the variable "MY-VAR", which is not a name a shell accepts`}, - {`{"variables":{"PATH":"/bin"}}`, `tool sets PATH as a variable; a module adds an entry under environment.path`}, - {`{"variables":{"A":"$HOME/x"}}`, `tool sets A to "$HOME/x", which holds a $ that is not one of the machine's ${machine:…} facts`}, - {`{"variables":{"A":"${HOME}/x"}}`, `tool sets A to "${HOME}/x", which holds a $`}, - {`{"variables":{"A":"it's"}}`, `tool sets A to "it's", which holds a quote`}, - {`{"variables":{"A":"say \"hi\""}}`, `which holds a quote`}, - {`{"variables":{"A":"a\\b"}}`, `which holds a backslash`}, - {`{"variables":{"A":"a\nb"}}`, `which holds a line break`}, - {`{"variables":{"A":"a\u0000b"}}`, `which holds a NUL`}, - {`{"path":[{"entry":"","at":"start"}]}`, `tool's PATH entry 1 names no directory`}, - {`{"path":[{"entry":"/a:/b","at":"start"}]}`, `tool puts "/a:/b" on PATH, which holds a colon`}, - {`{"path":[{"entry":"$HOME/bin","at":"start"}]}`, `tool puts "$HOME/bin" on PATH, which holds a $`}, - {`{"path":[{"entry":"/a","at":"middle"}]}`, `tool puts "/a" on PATH at "middle"; an entry goes at "start" or "end"`}, - {`{"path":[{"entry":"/a"}]}`, `tool puts "/a" on PATH at ""`}, - {`{"path":[{"entry":"/a","at":"start"},{"entry":"/a","at":"end"}]}`, `tool puts "/a" on PATH twice`}, - {`{"variables":{"A":"x"},"paths":[]}`, `unknown field "paths"`}, - } { - _, err := ParseManifest([]byte(`{"module":"tool","environment":` + c.environment + `}`)) - if err == nil || !strings.Contains(err.Error(), c.want) { - t.Errorf("%s: want %q, got %v", c.environment, c.want, err) - } - } - // What is allowed: a literal, and the machine's own facts. - if _, err := ParseManifest([]byte(`{"module":"tool","environment":{` + - `"variables":{"GOPATH":"${machine:account-home}/go","DISABLE_X":"1","ANSWER":"a b+c=d"},` + - `"path":[{"entry":"${machine:account-home}/go/bin","at":"start"},{"entry":"/opt/x","at":"end"}]}}`)); err != nil { - t.Fatalf("a well-formed environment was refused: %v", err) - } -} - -func TestMalformedShellCodeIsRefusedAtParse(t *testing.T) { - for _, c := range []struct{ shell, want string }{ - {`[{"for":"tcsh","slot":"normal","code":"x"}]`, `tool's shell code 1 is for "tcsh"; the shells are zsh, bash, fish`}, - {`[{"for":"zsh","slot":"middle","code":"x"}]`, `tool's shell code 1 goes in the slot "middle"; the slots are first, normal, last`}, - {`[{"for":"zsh","slot":"last","code":"x"},{"for":"zsh","slot":"last","code":" \n"}]`, `tool's shell code 2 has no code`}, - {`[{"for":"zsh","slot":"last","code":"x","order":1}]`, `unknown field "order"`}, - } { - _, err := ParseManifest([]byte(`{"module":"tool","shell":` + c.shell + `}`)) - if err == nil || !strings.Contains(err.Error(), c.want) { - t.Errorf("%s: want %q, got %v", c.shell, c.want, err) - } - } - // The code itself is never judged: a shell's own `${…}` is not the mesh's. - if _, err := ParseManifest([]byte(`{"module":"tool","shell":[{"for":"zsh","slot":"first",` + - `"code":"source \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\""}]}`)); err != nil { - t.Fatalf("shell code was judged as if it were the mesh's: %v", err) - } -} - // ADR 0203 §1 and ADR 0204 §1: both seats are the mesh's own, held once per machine; the login // shell's contract is `execute`, described and with a schema an agent can call. func TestTheSeatTableCarriesTheEnvironmentAndTheLoginShell(t *testing.T) { @@ -396,29 +300,3 @@ func TestTheSeatTableCarriesTheEnvironmentAndTheLoginShell(t *testing.T) { t.Fatalf("execute takes no timeout: %v", props) } } - -// ADR 0204 §1: the login shell is the mesh's, so no module declares it — neither under the mesh's -// name nor under the name a module gave it before. -func TestNoModuleMayDeclareTheLoginShell(t *testing.T) { - for _, n := range []string{"login-shell", "node-login-shell", "node-environment"} { - raw := `{"module":"zsh","seats":[{"name":"` + n + `","scope":"node","serves":["execute"]}],"tools":["execute"]}` - _, err := ParseManifest([]byte(raw)) - if err == nil { - t.Errorf("a module declaring %q was accepted", n) - } - } - got := strings.Join(declaredSeatProblems(Manifest{Module: "zsh", - DefinesSeats: []SeatDeclaration{{Name: "login-shell", Scope: ScopeNode}}}), "; ") - if !strings.Contains(got, `zsh declares a seat named "login-shell"; the login shell is the mesh's own seat node-login-shell`) { - t.Fatalf("declaring login-shell was not refused by name: %q", got) - } - // And a shell module claiming the mesh's seat, serving execute, is what the seat is for. - m, err := ParseManifest([]byte(`{"module":"zsh","tools":["execute"],` + - `"claims":[{"name":"node-login-shell","scope":"node"}]}`)) - if err != nil { - t.Fatal(err) - } - if err := CanHold(m, Seat{Name: LoginShellSeat, Scope: ScopeNode, Serves: loginShellVerbs()}); err != nil { - t.Fatalf("a shell module claiming the seat cannot hold it: %v", err) - } -} diff --git a/go/catalogue/environment_items_test.go b/go/catalogue/environment_items_test.go index cd4c93b..ede8942 100644 --- a/go/catalogue/environment_items_test.go +++ b/go/catalogue/environment_items_test.go @@ -129,31 +129,7 @@ func TestTheEnvironmentOfTheCatalogueIsTheSameHandedOrNot(t *testing.T) { } } -func TestAnEnvironmentItemIsTypedAndNamesWhatItNeeds(t *testing.T) { - item := func(attrs map[string]string) []string { - return environmentItemProblems("x", Item{Collection: "environment", Attributes: attrs}) - } - for _, ok := range []map[string]string{ - {"kind": "variable", "name": "GTK_THEME"}, - {"kind": "path", "at": "end", "order": "10"}, - } { - if p := item(ok); len(p) != 0 { - t.Errorf("%v is refused: %v", ok, p) - } - } - for _, bad := range []map[string]string{ - nil, {"kind": "other"}, {"kind": "variable"}, {"kind": "variable", "name": "PATH"}, - {"kind": "path"}, {"kind": "path", "at": "middle"}, {"kind": "path", "at": "start", "name": "X"}, - {"kind": "variable", "name": "A", "color": "red"}, - } { - if p := item(bad); len(p) == 0 { - t.Errorf("%v is taken", bad) - } - } - if !attributeFits("environment", "name", "GTK_THEME") || attributeFits("shell:zsh:normal", "name", "GTK_THEME") || - attributeFits("environment", "kind", "Variable") { - t.Error("an upper-case name is the environment's name alone") - } +func TestTwoModulesSettingOneVariableAreRefused(t *testing.T) { // A module that sets a variable twice, in two modules, is refused as the old form refused it. a := Manifest{Module: "a", Items: []Item{{Collection: "environment", Content: "1\n", Attributes: map[string]string{"kind": "variable", "name": "X"}}}} diff --git a/go/catalogue/events.go b/go/catalogue/events.go index ce8b9dd..684cae5 100644 --- a/go/catalogue/events.go +++ b/go/catalogue/events.go @@ -1,10 +1,8 @@ package catalogue import ( - "fmt" "regexp" "slices" - "strings" ) // What a module may call an event, and what a consumer may ask for. @@ -36,131 +34,6 @@ const ( TheRest = "**" ) -// EventProblems is what is wrong with a manifest's events. -// -// Refused at registration, because the alternative is a module that installs, starts, connects and -// reacts to nothing — and every log line says it is fine. -func EventProblems(m Manifest) []string { - var problems []string - - for _, e := range m.Emits { - if was, stale := staleEventForm(e, m.Module); stale { - problems = append(problems, fmt.Sprintf( - "%s emits %q, which is the old bus's routing key. An event is named locally now, so "+ - "write %q — the mesh derives the subject (novox/hq design 29 §1)", - m.Module, was, strings.TrimPrefix(was, "module."+m.Module+"."))) - continue - } - if strings.HasPrefix(e, "module.") { - problems = append(problems, fmt.Sprintf( - "%s emits %q: `module.` is reserved, because it is how the old bus spelled a "+ - "routing key and an event named that way derives into a namespace nobody owns", - m.Module, e)) - continue - } - if err := localName(e); err != nil { - problems = append(problems, fmt.Sprintf("%s emits %q: %v", m.Module, e, err)) - continue - } - // **Its own name, never another's.** The bus enforces that a namespace belongs to the module - // it is named for, so an event named for somebody else cannot be published at all. If the - // event is about a role rather than about this module, it belongs on the seat: a name that - // is stable across whoever fills it (04-ISSUES/127). - if first, _, split := strings.Cut(e, "."); split && isAModuleNameOtherThan(first, m.Module) { - problems = append(problems, fmt.Sprintf( - "%s emits %q, which reads as another module's event. A module publishes under its "+ - "own name only. If this is about a role rather than about %s, declare it on that "+ - "seat, where the name survives the holder changing", - m.Module, e, m.Module)) - } - } - - for _, c := range m.Consumes { - if strings.HasPrefix(c, "module.") { - problems = append(problems, fmt.Sprintf( - "%s consumes %q, which is the old bus's pattern. A consumed event names its emitter "+ - "and the event: write %q", m.Module, c, strings.TrimPrefix(c, "module."))) - continue - } - if c == "#" { - problems = append(problems, fmt.Sprintf( - "%s consumes %q, which is the old bus's wildcard for everything. Write %q", - m.Module, c, TheRest)) - continue - } - if err := consumePattern(c); err != nil { - problems = append(problems, fmt.Sprintf("%s consumes %q: %v", m.Module, c, err)) - } - } - return problems -} - -// staleEventForm says an emitted name is this module's own old routing key, and what it was. -func staleEventForm(event, module string) (string, bool) { - return event, module != "" && strings.HasPrefix(event, "module."+module+".") -} - -// isAModuleNameOtherThan says a first token names some module of this mesh that is not this one. -// -// Only the mesh's own seats and the catalogue could answer this properly, and neither is reachable -// from a parser given one manifest. So this catches the case that actually happened — a name that -// is a *provision* the mesh defines, which is where "another module's event" comes from in practice -// — and the whole-catalogue check catches the rest. -func isAModuleNameOtherThan(first, module string) bool { - if first == module || first == "" { - return false - } - if _, isASeat := SeatNamed(first); isASeat { - return true - } - if _, isASeat := SeatDelivering(first); isASeat { - return true - } - return false -} - -// localName checks one event name: dot-separated names, no wildcards, nothing else. -func localName(event string) error { - if event == "" { - return fmt.Errorf("an event needs a name") - } - for _, part := range strings.Split(event, ".") { - if part == OneName || part == TheRest { - return fmt.Errorf("an emitted event names one event, so it carries no wildcard") - } - if !eventName.MatchString(part) { - return fmt.Errorf("%q is not a usable name: lower-case letters, digits and dashes", part) - } - } - return nil -} - -// consumePattern checks a consumed pattern: the emitter, then the event, with wildcards. -func consumePattern(pattern string) error { - if pattern == "" { - return fmt.Errorf("a consumed event needs an emitter and an event") - } - parts := strings.Split(pattern, ".") - for i, part := range parts { - switch { - case part == TheRest: - if i != len(parts)-1 { - return fmt.Errorf("%q stands for the rest of a name, so nothing may follow it", TheRest) - } - case part == OneName: - case !eventName.MatchString(part): - return fmt.Errorf("%q is not a usable name: lower-case letters, digits and dashes", part) - } - } - // `**` alone is every event from every module, which the audit logger wants and says plainly. - if len(parts) == 1 && parts[0] != TheRest { - return fmt.Errorf( - "%q names an emitter and no event. Write ., or %q for every event", - pattern, TheRest) - } - return nil -} - // The events a provider says about its consumers (novox/hq ADR 0224): a consumer it has failed // without one success for minutes, and that consumer succeeding again or being withdrawn. The // controller follows them from every module and `status` names a consumer failing until it recovers. diff --git a/go/catalogue/files.go b/go/catalogue/files.go index 81b7839..f7c0a5a 100644 --- a/go/catalogue/files.go +++ b/go/catalogue/files.go @@ -98,16 +98,6 @@ func filesVerbs() []Verb { ) } -// FilesHolderProblems is what a holder of node-files is held to (novox/hq ADR 0394 §2): it runs as the tool -// runner's account, the node's operator account, so it declares no runs-as. -func FilesHolderProblems(m Manifest) []string { - if !m.ClaimsSeat(FilesSeat) || m.RunsAs == "" { - return nil - } - return []string{fmt.Sprintf("%s claims %s and declares runs-as %q: its holder runs as the tool runner's account, "+ - "the node's operator account (novox/hq ADR 0394 §2)", m.Module, FilesSeat, m.RunsAs)} -} - // ownedPaths is every path a module owns on this node, as the holder is told (novox/hq ADR 0394 §5): the // paths every seat declares (ADR 0287) with the module holding the seat here, and the paths and files each // module's manifest places (declaredPaths). A seat's path wins where both name one. diff --git a/go/catalogue/files_test.go b/go/catalogue/files_test.go index 92de928..9ce0a52 100644 --- a/go/catalogue/files_test.go +++ b/go/catalogue/files_test.go @@ -46,20 +46,6 @@ func TestNodeFilesIsANodeSeatWithEveryVerbOfADR0394(t *testing.T) { } } -// issue 900, ADR 0394 §2: a holder of node-files runs as the tool runner's account, so it declares no runs-as. -func TestAHolderOfNodeFilesDeclaringRunsAsIsRefused(t *testing.T) { - p := FilesHolderProblems(Manifest{Module: "files", RunsAs: "mesh-files", Claims: []Claim{{Name: FilesSeat}}}) - if len(p) != 1 || !strings.Contains(p[0], "0394") || !strings.Contains(p[0], "runs-as") { - t.Errorf("a holder declaring runs-as was not refused: %v", p) - } - if p := FilesHolderProblems(Manifest{Module: "files", Claims: []Claim{{Name: FilesSeat}}}); len(p) != 0 { - t.Errorf("a holder declaring none was refused: %v", p) - } - if p := FilesHolderProblems(Manifest{Module: "other", RunsAs: "x"}); len(p) != 0 { - t.Errorf("a non-holder was judged: %v", p) - } -} - // issue 900, ADR 0394 §4: the holder is told the secret places and the paths modules own on its node. func TestTheHolderOfNodeFilesIsToldSecretPlacesAndOwnedPaths(t *testing.T) { files := Manifest{Module: "files", Version: "1", Claims: []Claim{{Name: FilesSeat}}, diff --git a/go/catalogue/filtering.go b/go/catalogue/filtering.go index 46ffd0d..3a14f3f 100644 --- a/go/catalogue/filtering.go +++ b/go/catalogue/filtering.go @@ -1000,32 +1000,6 @@ func Reaches(m Manifest, layers []Layer) (map[int]string, error) { // route that names the endpoint says what it means, and the mesh looks the port up. const RouteEndpoint = "endpoint" -// RouteProblems holds a module's route contributions to naming an endpoint it actually has. -// -// 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 — the fault this repository names most -// often, a declaration that reads as though it did something. -func RouteProblems(m Manifest) []string { - var problems []string - check := func(where string, values map[string]any) { - name, ok := values[RouteEndpoint].(string) - if !ok || strings.TrimSpace(name) == "" { - return - } - if _, found := EndpointPort(m, name); !found { - problems = append(problems, fmt.Sprintf( - "%s routes %s to the endpoint %q, which it does not declare", m.Module, where, name)) - } - } - if values, ok := m.Contributes["route"]; ok { - check("a name", values) - } - for local, values := range m.ContributesMany["route"] { - check(local, values) - } - return problems -} - // EndpointsSetting is the settings key that configures a module's endpoints by name, per node // (novox/hq ADR 0138): // diff --git a/go/catalogue/filtering_test.go b/go/catalogue/filtering_test.go index 11cb514..8bf1e00 100644 --- a/go/catalogue/filtering_test.go +++ b/go/catalogue/filtering_test.go @@ -7,22 +7,9 @@ import ( "testing" ) -// A manifest key nothing reads is worse than a key that is refused: five HAL manifests carried -// `scope:`, which read as a restriction and restricted nothing (novox/hq 04-ISSUES/003). The host's -// declaration parser has always refused unknown keys; manifests never did. -func TestAManifestKeyNothingReadsIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"web","scope":"mesh"}`)) - if err == nil { - t.Fatal("a manifest with a key nothing reads was accepted, which is how scope: survived") - } - if !strings.Contains(err.Error(), "scope") { - t.Fatalf("refused without naming the key, which leaves the author guessing: %v", err) - } -} - // The same discipline must not refuse a manifest that is merely ordinary. func TestAnOrdinaryManifestIsStillAccepted(t *testing.T) { - m, err := ParseManifest([]byte( + m, err := decodeForTest([]byte( `{"module":"web","listens":[{"port":443,"from":"anywhere","why":"the site"}]}`)) if err != nil { t.Fatalf("an ordinary manifest was refused: %v", err) @@ -32,24 +19,6 @@ func TestAnOrdinaryManifestIsStillAccepted(t *testing.T) { } } -// A rule with no source is open, and must say so rather than appear to restrict something. -func TestAPortWithNoSourceIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"web","listens":[{"port":443}]}`)) - if err == nil { - t.Fatal("a port that never said who may reach it was accepted; it reads as a restriction") - } - if !strings.Contains(err.Error(), "from where") { - t.Fatalf("refused without saying what is missing: %v", err) - } -} - -func TestASourceNobodyDefinedIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"web","listens":[{"port":443,"from":"lan"}]}`)) - if err == nil { - t.Fatal("a source the mesh cannot render was accepted, and would be silently dropped") - } -} - // The derivation is over the whole node, not one module. func TestTheRuleSetIsEveryAssignedModulesPorts(t *testing.T) { r := Resolution{Modules: []Manifest{ @@ -357,13 +326,6 @@ func TestTheModuleAskingForTheRuleSetGetsEveryModulesPorts(t *testing.T) { } } -func TestAskingForTheRuleSetWithNowhereToPutItIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"firewall","filtering":{"into":""}}`)) - if err == nil { - t.Fatal("a module asked for the rule set and named no path; it would receive nothing") - } -} - // nftables matches the two address families separately, and one set holding both is a syntax // error — so the file fails to load, the service reports a fault, and the machine filters nothing. func TestAMeshOnBothAddressFamiliesRendersBoth(t *testing.T) { diff --git a/go/catalogue/go_bundle_test.go b/go/catalogue/go_bundle_test.go index eb6cd2b..4cf1196 100644 --- a/go/catalogue/go_bundle_test.go +++ b/go/catalogue/go_bundle_test.go @@ -1,37 +1,6 @@ package catalogue -import "testing" - // A bundle is the module's own directory compiled whole, and naming a source would be describing its // own build. That holds for an interpreted language and cannot hold for a compiled one: a repository // written in Go carries several commands — the host and its bootstrap live in one — and "the module's // own directory" is then not a package at all (novox/hq 04-ISSUES/142). - -func TestAGoBundleMayNameItsCommand(t *testing.T) { - b := &Build{Artifacts: []Artifact{{ - Name: "host-arch", Kind: ArtifactBundle, Language: "go", System: "arch", - From: "cmd/mesh-host", - }}} - if p := b.problems("mesh-host"); len(p) != 0 { - t.Fatalf("a go bundle naming its command was refused: %v", p) - } -} - -func TestAnInterpretedBundleStillMayNotNameASource(t *testing.T) { - b := &Build{Artifacts: []Artifact{{ - Name: "tools", Kind: ArtifactBundle, Language: "typescript", From: "src", - }}} - p := b.problems("something") - if len(p) == 0 { - t.Fatal("an interpreted bundle naming what it is built from was accepted") - } -} - -func TestACompiledBundleStillMustSayItsSystem(t *testing.T) { - b := &Build{Artifacts: []Artifact{{ - Name: "host", Kind: ArtifactBundle, Language: "go", From: "cmd/mesh-host", - }}} - if len(b.problems("mesh-host")) == 0 { - t.Fatal("a compiled bundle with no system was accepted") - } -} diff --git a/go/catalogue/graphical_session_test.go b/go/catalogue/graphical_session_test.go index f412e24..6524634 100644 --- a/go/catalogue/graphical_session_test.go +++ b/go/catalogue/graphical_session_test.go @@ -61,14 +61,6 @@ func TestTheGraphicalSessionsSeatsAreTheMeshsOwnWithTheirVerbs(t *testing.T) { } } -// §2: a module may claim one of them, and may not declare it as its own. -func TestNoModuleMayDeclareAGraphicalSessionSeat(t *testing.T) { - raw := `{"module":"xorg","seats":[{"name":"node-display-server","scope":"node"}]}` - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "mesh's own namespace") { - t.Fatalf("a module declared node-display-server as its own: %v", err) - } -} - func displayServer(name, display string, claims ...string) Manifest { m := Manifest{Module: name, Provides: []Offer{{Name: display, Reach: ReachMachine}}} for _, c := range claims { @@ -126,29 +118,6 @@ func TestAMachineReachRequirementIsNotPulledInNorAnsweredFromAnotherNode(t *test } } -func TestTheMachinesReachIsAProvisionsOnlyReachAndIsNodeScoped(t *testing.T) { - for _, c := range []struct{ provides, want string }{ - {`{"name":"x11-display","reach":"internal"}`, `with reach "internal"; a provision's reach is "machine" or nothing`}, - {`{"name":"x11-display","scope":"mesh","reach":"machine"}`, `at scope "mesh" with the machine's reach`}, - } { - _, err := ParseManifest([]byte(`{"module":"xorg","provides":[` + c.provides + `]}`)) - if err == nil || !strings.Contains(err.Error(), c.want) { - t.Errorf("%s: want %q, got %v", c.provides, c.want, err) - } - } - m, err := ParseManifest([]byte(`{"module":"xorg","provides":[{"name":"x11-display","reach":"machine"}]}`)) - if err != nil { - t.Fatal(err) - } - if !m.Provides[0].MachineReach() { - t.Fatal("the reach was not read") - } - back, _ := json.Marshal(m.Provides[0]) - if string(back) != `{"name":"x11-display","reach":"machine"}` { - t.Errorf("written back as %s", back) - } -} - func TestACatalogueDisagreeingAboutAProvisionsReachIsRefused(t *testing.T) { cat := shelf(windowManager(), displayServer("xorg", "x11-display"), Manifest{Module: "fake-x", Provides: Offers("x11-display")}) @@ -157,57 +126,3 @@ func TestACatalogueDisagreeingAboutAProvisionsReachIsRefused(t *testing.T) { t.Fatalf("a provision with and without the machine's reach gave %v", err) } } - -// §4: xinitrc and xresources slots, placed by the display server's holder alone. -func TestTheSessionsFilesArePlacedByTheDisplayServersHolderAlone(t *testing.T) { - xorg := Manifest{Module: "xorg", Claims: []Claim{{Name: DisplayServerSeat}}, - Shell: []ShellCode{{For: "xinitrc", Slot: "first", Code: "xset s off"}}, - Resources: []map[string]any{ - {"id": "xinitrc", "type": "file", "path": "/home/op/.xinitrc", - "content": "${shell:xinitrc:first}${shell:xinitrc:normal}${shell:xinitrc:last}"}, - {"id": "xresources", "type": "file", "path": "/home/op/.Xresources", - "content": "${shell:xresources:normal}"}, - }} - i3 := Manifest{Module: "i3", Shell: []ShellCode{{For: "xinitrc", Slot: "last", Code: "exec i3"}}} - theme := Manifest{Module: "theme", Shell: []ShellCode{ - {For: "xresources", Slot: "normal", Code: "Xft.dpi: 96"}, - {For: "zsh", Slot: "normal", Code: "not for the session"}, - }} - r := Resolution{Node: "workstation", Account: "op", Modules: []Manifest{xorg, i3, theme}} - out, err := r.Declaration(Rendering{}) - if err != nil { - t.Fatal(err) - } - by := map[string]any{} - for _, res := range out { - by[res["id"].(string)] = res["content"] - } - if got := by["xorg.xinitrc"]; got != "# xorg\nxset s off\n# i3\nexec i3\n" { - t.Errorf("the .xinitrc is %q", got) - } - if got := by["xorg.xresources"]; got != "# theme\nXft.dpi: 96\n" { - t.Errorf("the .Xresources is %q", got) - } - - // The contributions parse; the placeholders parse only in the holder. - if _, err := ParseManifest([]byte(`{"module":"i3","shell":[{"for":"xinitrc","slot":"last","code":"exec i3"},` + - `{"for":"xresources","slot":"normal","code":"i3.font: x"}]}`)); err != nil { - t.Fatalf("a session contribution was refused: %v", err) - } - for _, c := range []struct{ claims, content, want string }{ - {``, "${shell:xinitrc:normal}", "does not claim node-display-server; every module's xinitrc is placed by the display server's holder alone"}, - {`{"name":"node-login-shell"}`, "${shell:xresources:normal}", "does not claim node-display-server"}, - {`{"name":"node-display-server"}`, "${shell:zsh:normal}", "does not claim node-login-shell"}, - {`{"name":"node-display-server"}`, "${shell:xsession:normal}", "the session's file one of xinitrc, xresources"}, - } { - raw := `{"module":"holder","claims":[` + c.claims + `],"resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` + - c.content + `"}]}` - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) { - t.Errorf("%s with claims [%s]: want %q, got %v", c.content, c.claims, c.want, err) - } - } - if _, err := ParseManifest([]byte(`{"module":"xorg","claims":[{"name":"node-display-server"}],` + - `"resources":[{"id":"rc","type":"file","path":"/home/op/.xinitrc","content":"${shell:xinitrc:last}"}]}`)); err != nil { - t.Errorf("the display server's holder could not place the session's slots: %v", err) - } -} diff --git a/go/catalogue/groups_test.go b/go/catalogue/groups_test.go index b12276d..e3a5484 100644 --- a/go/catalogue/groups_test.go +++ b/go/catalogue/groups_test.go @@ -58,26 +58,6 @@ func TestAGroupThatIsNotAWholeNumberAboveZeroIsRefused(t *testing.T) { } } -// 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) - } - } -} - // 1636/AC3 composed for a machine, the way a push writes it: n8n's group is the number the machine's config value // says, written as a number, and the module is left out by name when the machine says none. func TestAComposedMachineGetsTheGroupTheConfigValueNames(t *testing.T) { diff --git a/go/catalogue/handed.go b/go/catalogue/handed.go index 7d692b4..54b1b46 100644 --- a/go/catalogue/handed.go +++ b/go/catalogue/handed.go @@ -305,43 +305,6 @@ func takesOf(m Manifest, id string) string { return takes } -// movedProblems is what is wrong with a module's mark: a moved module must name a template (as written) or carry -// what its build kept (as held), and Handed is the build's word, never a repository's. -func movedProblems(m Manifest) []string { - switch { - case m.Handed != nil && !m.Moved: - return []string{fmt.Sprintf("%s carries what a moved module's build keeps (handed) and is not marked moved", - m.Module)} - case m.Handed != nil && m.Build != nil: - return []string{fmt.Sprintf("%s says handed in its repository: that is what its build keeps, never "+ - "written by hand (novox/hq ADR 0349 §2)", m.Module)} - case m.Moved && m.Handed == nil && !m.Templated(): - return []string{fmt.Sprintf("%s is marked moved and names no template: a moved module's files are filled "+ - "from template files of its own, `\"template\": \"%s/\"` (novox/hq ADR 0349 §2)", m.Module, - m.TemplatesDir())} - } - if !m.Moved { - return nil - } - // **A moved module's secrets are the vault's, handed to its machine's client** (design 57 V4): a secret the - // mesh makes, by name. A family's members and a value issued outside the mesh are given by a person and may be - // absent, which the client cannot tell from one the vault does not hold yet; they move with a package of their - // own. - var problems []string - if fams := m.OwnSecrets.Families(); len(fams) > 0 { - problems = append(problems, fmt.Sprintf("%s is marked moved and keeps a secret family (%s): a moved module's "+ - "secrets are handed by the vault by name, and a family's members are not yet (design 57 V4)", m.Module, - strings.Join(fams, ", "))) - } - for _, name := range sortedKeys(m.OwnSecrets.Plain()) { - if m.OwnSecrets[name].IssuedBy == IssuedOutside { - problems = append(problems, fmt.Sprintf("%s is marked moved and its secret %s is issued outside the mesh: "+ - "a value that may not be given is not yet handed by the vault (design 57 V4)", m.Module, name)) - } - } - return problems -} - // handedBy is what a moved module's build keeps, from its templates archive as built, or nil for a module not // moved. func handedBy(m Manifest, archive Built) *Handed { diff --git a/go/catalogue/handed_consumers_test.go b/go/catalogue/handed_consumers_test.go index abef0b9..ae8ce1e 100644 --- a/go/catalogue/handed_consumers_test.go +++ b/go/catalogue/handed_consumers_test.go @@ -48,7 +48,7 @@ func TestWhatAConsumersPlaceholderMayNotBeIsRefused(t *testing.T) { "a file the program keeps": {strings.Replace(handedProvider, `"mode":"0600","template"`, `"mode":"0600","create-once":true,"template"`, 1), consumersText, "owned"}, } { - m, err := ParseManifest([]byte(c.raw)) + m, err := decodeForTest([]byte(c.raw)) if err != nil { t.Fatalf("%s: %v", why, err) } diff --git a/go/catalogue/handed_providers_test.go b/go/catalogue/handed_providers_test.go index daee6d3..50592b6 100644 --- a/go/catalogue/handed_providers_test.go +++ b/go/catalogue/handed_providers_test.go @@ -116,68 +116,6 @@ func TestAHandedModulesIssuedCredentialIsItsProvisionsOneEntry(t *testing.T) { } } -// Each entry names the credential form the provider's offer declares (issue 768), from the provider's manifest, so -// the vault makes a first value in that form for a provider it holds no row of: one of its own secrets shared by -// every consumer, a login per consumer when the offer says no credential, issued by its software, or given by a -// person (the offer's own secret issued outside). -func TestAHandedModulesPlanNamesTheCredentialFormOfItsProvidersOffer(t *testing.T) { - storeOffering := func(credential string) Manifest { - raw := `{"module":"store","version":"1","provides":[{"name":"postgres-database","scope":"mesh"` + - credential + `}],"serves":{"postgres-database":{"port":5432}}` - if strings.Contains(credential, `"own"`) { - raw += `,"own-secrets":{"admin":{"path":"/var/lib/store/admin","taken":"at-start"},` + - `"token":{"path":"/var/lib/store/token","taken":"at-start","issued-by":"outside"}}` - } - return mustParse(t, raw+`}`) - } - for credential, want := range map[string]string{ - // No default (the lead, 2026-10-29): an offer that says nothing has no form, and the catalogue refuses it. - ``: "", - `,"credential":{"form":"pair"}`: CredentialFormPair, - `,"credential":{"form":"none"}`: CredentialFormNone, - `,"credential":{"own":"admin"}`: CredentialFormShared, - `,"credential":{"own":"token"}`: CredentialFormGiven, - `,"credential":{"issued-by":"outside"}`: CredentialFormGiven, - `,"credential":{"issued-by":"software","tool":"store_issued_token"}`: CredentialFormIssued, - } { - node := workstation() - node.At = "workstation.internal" - consumer := notifierConsumer(t, true, false) - r, err := Resolve(shelf(consumer, storeOffering(credential)), []string{consumer.Module}, node, twoStores("anchor")) - if err != nil { - t.Fatal(err) - } - r.Account = "op" - c, _ := composeWith(t, r, handingTo("store:5000")) - got := c.Plans["notify"].Provisions - if len(got) != 1 || got[0].CredentialForm != want { - t.Errorf("an offer with %q: the plan names %+v, want the form %q", credential, got, want) - } - // Every form a plan names is in the one list each reader checks (issue 774); an undeclared one is not. - if IsCredentialForm(want) != (want != "") { - t.Errorf("the form %q of an offer with %q: in CredentialForms %v", want, credential, IsCredentialForm(want)) - } - } - if forms := strings.Join(CredentialForms(), ","); forms != "shared,pair,issued,given,none" || IsCredentialForm("maybe") { - t.Errorf("the credential forms are %s, and maybe is one: %v", forms, IsCredentialForm("maybe")) - } - if got := (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 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 consumer's plan entry carries what its manifest contributes to that provision (issue 776), so the provider's // helper learns a database's name from the bus; a consumer contributing nothing has the entry it had. func TestAHandedModulesPlanEntryCarriesWhatItContributesToItsProvider(t *testing.T) { @@ -258,7 +196,7 @@ func TestAnOfferAnsweredByTheHelperIsReadAndHandsNoFile(t *testing.T) { if err != nil || !strings.Contains(string(raw), `"answered-by":"helper"`) { t.Fatalf("written back as %s: %v", raw, err) } - if _, err := ParseManifest([]byte(strings.Replace(with, `"helper"`, `"harness"`, 1))); err == nil { + if _, err := decodeForTest([]byte(strings.Replace(with, `"helper"`, `"harness"`, 1))); err == nil { t.Fatal("an unknown answerer was accepted") } c1, before := composeWith(t, notifierOn(t, held(t, without, builtNotifier())), handingTo("store:5000")) diff --git a/go/catalogue/handed_pulled_in_test.go b/go/catalogue/handed_pulled_in_test.go index cfec741..188cbba 100644 --- a/go/catalogue/handed_pulled_in_test.go +++ b/go/catalogue/handed_pulled_in_test.go @@ -22,7 +22,7 @@ func providing(raw string) string { // onWorkstation resolves m on the workstation either assigned beside desk, or pulled in by desk alone. func onWorkstation(t *testing.T, m Manifest, assigned bool) Resolution { t.Helper() - desk, err := ParseManifest([]byte(pulledInBy)) + desk, err := decodeForTest([]byte(pulledInBy)) if err != nil { t.Fatal(err) } diff --git a/go/catalogue/handed_secrets_test.go b/go/catalogue/handed_secrets_test.go index a8a8760..794a312 100644 --- a/go/catalogue/handed_secrets_test.go +++ b/go/catalogue/handed_secrets_test.go @@ -123,37 +123,6 @@ func TestAMovedModulesSecretsAreSealedAsBeforeWhereItIsNotHanded(t *testing.T) { } } -func TestWhatAHandedModuleCannotHaveFromTheVaultIsRefused(t *testing.T) { - // A template naming a credential the module requires: a binding's, which moves with its provider. - requires := strings.Replace(botMoved(), `"own-secrets":`, `"requires":["database"],"secrets":{"database":"/etc/bot/db"},"own-secrets":`, 1) - r, err := Resolve(shelf(held(t, requires, builtBot("db = ${secret:database}\n")), - Manifest{Module: "db", Version: "1", Provides: []Offer{{Name: "database", Scope: ScopeNode}}}), - []string{"bot"}, workstation(), World{}) - if err != nil { - t.Fatal(err) - } - r.Account = "op" - if _, err := r.Compose(handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "moves with its provider") { - t.Errorf("a template naming a required credential: %v", err) - } - // A file with its text inline naming a secret, in a handed module. - inline := strings.Replace(botMoved(), `{"id":"daemon"`, - `{"id":"legacy","type":"file","path":"/etc/bot/legacy","content":"t = ${secret:token}\n"},{"id":"daemon"`, 1) - r = notifierOn(t, held(t, inline, builtBot(botText))) - if _, err := r.Compose(handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "keep it in a template") { - t.Errorf("an inline file naming a secret in a handed module: %v", err) - } - // A family and a secret issued outside the mesh are not handed by name yet: the mark is refused. - for why, raw := range map[string]string{ - "a family": strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`, `"own-secrets":{"token":"${dir:state}/token","key-*":"${dir:state}/keys"}`, 1), - "issued outside": strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`, `"own-secrets":{"token":{"path":"${dir:state}/token","issued-by":"outside"}}`, 1), - } { - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "design 57 V4") { - t.Errorf("a moved module with %s: %v", why, err) - } - } -} - // A credential of what a handed module requires that the vault keeps for it alone (issue 757): the forge's read // token for one agent, issued by the forge's software per holder. Where the module is handed, its file is declared // placed and without a value, and the plan names it with its provision and provider; anywhere else, and for a diff --git a/go/catalogue/handed_test.go b/go/catalogue/handed_test.go index dbe3bec..8ed924e 100644 --- a/go/catalogue/handed_test.go +++ b/go/catalogue/handed_test.go @@ -141,24 +141,6 @@ func TestAMovedModulesTemplatedFileIsHandedToTheProvisioner(t *testing.T) { } } -func TestAMovedModulesMarkIsJudged(t *testing.T) { - for name, raw := range map[string]string{ - "marked moved and naming no template": `{"module":"bare","version":"1","moved":true, - "resources":[{"id":"conf","type":"file","path":"/etc/bare","content":"x"}]}`, - "handed written in a repository": `{"module":"bare","version":"1","moved":true, - "handed":{"source":"s","digest":"d","templates":{}}, - "build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}, - "resources":[{"id":"conf","type":"file","path":"/etc/bare","template":"files/bare"}]}`, - "handed and not moved": `{"module":"bare","version":"1", - "handed":{"source":"s","digest":"d","templates":{}}, - "resources":[{"id":"conf","type":"file","path":"/etc/bare","content":"x"}]}`, - } { - if _, err := ParseManifest([]byte(raw)); err == nil { - t.Errorf("%s: accepted", name) - } - } -} - // What `module check` and the merge gate judge: a moved module read from its tree, its text inline. It is still // marked moved, and composes as before, since nothing of its build's archive is there to hand. func TestAMovedModuleReadFromItsTreeIsJudgedAsItComposesUnhanded(t *testing.T) { @@ -169,7 +151,7 @@ func TestAMovedModuleReadFromItsTreeIsJudgedAsItComposesUnhanded(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "files", "rc"), []byte(notifierText), 0o644); err != nil { t.Fatal(err) } - m, err := ParseManifest([]byte(notifierMoved())) + m, err := decodeForTest([]byte(notifierMoved())) if err != nil { t.Fatal(err) } diff --git a/go/catalogue/health.go b/go/catalogue/health.go index 1ff2d0d..058c9a8 100644 --- a/go/catalogue/health.go +++ b/go/catalogue/health.go @@ -174,207 +174,6 @@ func ReadHealth(r map[string]any) (Health, bool, []string) { return h, true, problems } -// healthProblems is everything wrong with a manifest's `health` fields, in the manifest's words (to-be -// 48 §8): a field on something that does not stay up, a kind its resource cannot have, an endpoint the -// module does not declare, a port or an address, a timing outside its bounds, a tool the module does not -// serve, and a tool check with no check of another kind beside it on the module. -func healthProblems(m Manifest) []string { - var problems []string - endpoints := map[string]Listening{} - for _, l := range m.Listens { - if n := strings.TrimSpace(l.Name); n != "" { - endpoints[n] = l - } - } - tools := map[string]bool{} - for _, t := range m.Tools { - tools[t] = true - } - wants := map[string]bool{} - for _, w := range m.Wants() { - wants[w] = true - } - var toolChecks []string - otherKinds := 0 - resources := m.Resources - // A module's own runtime is judged as the process the controller writes for it (novox/hq ADR 0366 §1), - // and only a module that runs as its own account has one. - own, hasOwn := OwnRuntimeHealth(m) - if hasOwn { - if m.RunsAs == "" { - problems = append(problems, fmt.Sprintf("%s declares runtime-health and runs as no account of its own "+ - "(runs-as): only a module served by a runtime of its own has that runtime's health to declare "+ - "(novox/hq ADR 0366 §1)", m.Module)) - hasOwn = false - } else { - resources = append(append([]map[string]any(nil), m.Resources...), own) - } - } - for i, r := range resources { - h, has, read := ReadHealth(r) - if !has { - continue - } - ownRuntime := hasOwn && i == len(resources)-1 - id, typ := fmt.Sprint(r["id"]), fmt.Sprint(r["type"]) - where := m.Module + ": " + id - if ownRuntime { - where = m.Module + ": its runtime-health" - } - for _, p := range read { - problems = append(problems, m.Module+": "+p) - } - if !LongRunning(r) { - problems = append(problems, fmt.Sprintf("%s declares health and does not stay up: a step, anything on a "+ - "schedule and a service not stated running are judged by their step and their schedule (ADR 0240 rule 1)", where)) - continue - } - switch h.Kind { - case HealthRuntime, HealthExec: - if typ != "container" { - problems = append(problems, fmt.Sprintf("%s is a %s and its health is %q, which only a container has: "+ - "the runtime runs it inside the container", where, typ, h.Kind)) - } - case HealthUnit: - if typ == "container" { - problems = append(problems, fmt.Sprintf("%s is a container and its health is %q, which is a service's "+ - "or a process's own readiness", where, h.Kind)) - } - case HealthHTTP, HealthTCP, HealthTool: - case "": - problems = append(problems, fmt.Sprintf("%s declares health with no kind: %s", where, healthKindsWords())) - default: - problems = append(problems, fmt.Sprintf("%s declares health of kind %q: %s", where, h.Kind, healthKindsWords())) - } - switch h.Kind { - case HealthHTTP, HealthTCP: - l, declared := endpoints[h.Endpoint] - switch { - case h.Endpoint == "": - problems = append(problems, fmt.Sprintf("%s's %s check names no endpoint: it names one the module "+ - "declares under listens, by its name", where, h.Kind)) - case !declared: - problems = append(problems, fmt.Sprintf("%s's %s check names the endpoint %q, which %s does not declare "+ - "under listens (%s)", where, h.Kind, h.Endpoint, m.Module, namedEndpointsWords(endpoints))) - case l.At() != "tcp": - problems = append(problems, fmt.Sprintf("%s's %s check names %q, which is %s: a check connects over tcp", - where, h.Kind, h.Endpoint, l.At())) - } - default: - if h.Endpoint != "" { - problems = append(problems, fmt.Sprintf("%s's %s check names an endpoint, which only an http or tcp check "+ - "looks at", where, h.Kind)) - } - } - if h.Kind != HealthHTTP && (h.Path != "" || h.Status != 0 || h.Body != "" || h.Scheme != "") { - problems = append(problems, fmt.Sprintf("%s's %s check says a path, a status, a body or a scheme, which "+ - "only an http check has", where, h.Kind)) - } - if h.Kind == HealthHTTP { - if h.Path != "" && !strings.HasPrefix(h.Path, "/") { - problems = append(problems, fmt.Sprintf("%s's http check asks %q; a path starts with /", where, h.Path)) - } - if h.Status != 0 && (h.Status < 100 || h.Status > 599) { - problems = append(problems, fmt.Sprintf("%s's http check expects status %d, which is not one", where, h.Status)) - } - if h.Scheme != "" && h.Scheme != "http" && h.Scheme != "https" { - problems = append(problems, fmt.Sprintf("%s's http check is over %q; it is http or https", where, h.Scheme)) - } - } - if (h.Command != "") != (h.Kind == HealthExec) { - if h.Kind == HealthExec { - problems = append(problems, fmt.Sprintf("%s's exec check says no command", where)) - } else { - problems = append(problems, fmt.Sprintf("%s's %s check says a command, which only an exec check runs", - where, h.Kind)) - } - } - if h.Kind == HealthTool { - switch { - case h.Tool == "": - problems = append(problems, fmt.Sprintf("%s's tool check names no tool", where)) - case !tools[h.Tool]: - problems = append(problems, fmt.Sprintf("%s's tool check asks %q, which %s does not serve (its tools: %s)", - where, h.Tool, m.Module, orNoneWords(m.Tools))) - } - // Its own runtime being alive, judged by the node-engine, is the check the module does not run - // itself that this tool check stands beside (novox/hq ADR 0366 §2). - if !ownRuntime { - toolChecks = append(toolChecks, id) - } - } else { - if h.Tool != "" { - problems = append(problems, fmt.Sprintf("%s's %s check names a tool, which only a tool check asks", where, h.Kind)) - } - if h.Kind != "" { - otherKinds++ - } - } - if h.Needs != "" && !wants[h.Needs] { - problems = append(problems, fmt.Sprintf("%s's check needs %q, which %s does not require: a check names "+ - "the provision it exercises, among those the module requires", where, h.Needs, m.Module)) - } - problems = append(problems, healthTimingProblems(where, h)...) - } - if len(toolChecks) > 0 && otherKinds == 0 { - problems = append(problems, fmt.Sprintf("%s judges itself only by its own tool (%s): a tool check is for "+ - "function no endpoint shows, and only beside a check of another kind the module does not run itself "+ - "(ADR 0227 rule 8, ADR 0240 rule 2)", m.Module, strings.Join(toolChecks, ", "))) - } - return problems -} - -// healthTimingProblems holds a check's timing to its bounds. -func healthTimingProblems(where string, h Health) []string { - var problems []string - if h.Interval < HealthIntervalFloor { - problems = append(problems, fmt.Sprintf("%s looks every %s; a check looks no more often than every %s — "+ - "the mesh is a guest on the machine (ADR 0240 rule 2)", where, h.Interval, HealthIntervalFloor)) - } - if h.Timeout <= 0 || h.Timeout >= h.Interval { - problems = append(problems, fmt.Sprintf("%s gives a look %s, which must be more than nothing and under its "+ - "interval of %s", where, h.Timeout, h.Interval)) - } - if h.Looks < HealthLooksFloor { - problems = append(problems, fmt.Sprintf("%s is unhealthy after %d failing look(s); it is at least %d — one "+ - "look can be wrong (issue 277)", where, h.Looks, HealthLooksFloor)) - } - if h.Grace < 0 { - problems = append(problems, fmt.Sprintf("%s has a grace of %s", where, h.Grace)) - } - if h.Looks >= HealthLooksFloor && h.Interval >= HealthIntervalFloor { - if took := h.Grace + time.Duration(h.Looks)*h.Interval; took > HealthWithin { - problems = append(problems, fmt.Sprintf("%s is said unhealthy at the earliest %s after it starts (a grace "+ - "of %s and %d looks every %s); it is at most %s, so a resource broken from its start is said within "+ - "the gate's bound", where, took, h.Grace, h.Looks, h.Interval, HealthWithin)) - } - } - return problems -} - -func healthKindsWords() string { - return "a check is runtime (the image's own, adopted by name), http, tcp, exec, unit or tool" -} - -func namedEndpointsWords(endpoints map[string]Listening) string { - if len(endpoints) == 0 { - return "it names none" - } - names := make([]string, 0, len(endpoints)) - for n := range endpoints { - names = append(names, n) - } - sort.Strings(names) - return "it names " + strings.Join(names, ", ") -} - -func orNoneWords(names []string) string { - if len(names) == 0 { - return "none" - } - return strings.Join(names, ", ") -} - // Undeclared is every long-running resource of the manifest without `health`, by id (ADR 0240 rule 8): // what the catalogue's count counts. func Undeclared(m Manifest) []string { diff --git a/go/catalogue/health_test.go b/go/catalogue/health_test.go index ef3ae04..3b50ec9 100644 --- a/go/catalogue/health_test.go +++ b/go/catalogue/health_test.go @@ -53,78 +53,24 @@ func TestAWellFormedHealthIsAccepted(t *testing.T) { {"kind": "runtime"}, {"kind": "exec", "command": "pg_isready -q", "grace": "4m", "looks": 2, "interval": "30s"}, } { - if _, err := ParseManifest(healthManifest(t, "server", h)); err != nil { + if _, err := decodeForTest(healthManifest(t, "server", h)); err != nil { t.Errorf("%v was refused: %v", h, err) } } - if _, err := ParseManifest(healthManifest(t, "daemon", map[string]any{"kind": "unit"})); err != nil { + if _, err := decodeForTest(healthManifest(t, "daemon", map[string]any{"kind": "unit"})); err != nil { t.Errorf("a service's own readiness was refused: %v", err) } // A tool beside a check of another kind on the module. raw := healthManifest(t, "server", map[string]any{"kind": "http", "endpoint": "web"}, map[string]any{"id": "admin", "type": "service", "unit": "app-admin.service", "state": "running", "health": map[string]any{"kind": "tool", "tool": "app_status"}}) - if _, err := ParseManifest(raw); err != nil { + if _, err := decodeForTest(raw); err != nil { t.Errorf("a tool check beside an http check was refused: %v", err) } } -func TestEveryOutOfBoundsHealthIsRefusedByName(t *testing.T) { - cases := []struct { - name string - on string - health map[string]any - says string - }{ - {"no kind", "server", map[string]any{"endpoint": "web"}, "with no kind"}, - {"an unknown kind", "server", map[string]any{"kind": "ping"}, `of kind "ping"`}, - {"a port", "server", map[string]any{"kind": "tcp", "port": 8080}, "names a port"}, - {"an address", "server", map[string]any{"kind": "http", "endpoint": "web", "address": "127.0.0.1"}, "names a address"}, - {"a url", "server", map[string]any{"kind": "http", "url": "http://localhost:8080/"}, "names a url"}, - {"an unknown key", "server", map[string]any{"kind": "tcp", "endpoint": "web", "retries": 3}, `"retries"`}, - {"no endpoint", "server", map[string]any{"kind": "http"}, "names no endpoint"}, - {"an undeclared endpoint", "server", map[string]any{"kind": "tcp", "endpoint": "admin"}, "does not declare"}, - {"a udp endpoint", "server", map[string]any{"kind": "tcp", "endpoint": "beacon"}, "a check connects over tcp"}, - {"an interval under the floor", "server", map[string]any{"kind": "tcp", "endpoint": "web", "interval": "5s", "timeout": "1s"}, "no more often than every 10s"}, - {"a timeout of the interval", "server", map[string]any{"kind": "tcp", "endpoint": "web", "interval": "10s", "timeout": "10s"}, "under its interval"}, - {"one failing look", "server", map[string]any{"kind": "tcp", "endpoint": "web", "looks": 1}, "at least 2"}, - {"a grace and looks past five minutes", "server", map[string]any{"kind": "tcp", "endpoint": "web", "grace": "4m", "looks": 3, "interval": "30s"}, "at most 5m0s"}, - {"a duration that is not one", "server", map[string]any{"kind": "tcp", "endpoint": "web", "interval": "often"}, "is a duration"}, - {"looks that are not a number", "server", map[string]any{"kind": "tcp", "endpoint": "web", "looks": "three"}, "whole number"}, - {"a path without a slash", "server", map[string]any{"kind": "http", "endpoint": "web", "path": "health"}, "starts with /"}, - {"a status that is not one", "server", map[string]any{"kind": "http", "endpoint": "web", "status": 700}, "is not one"}, - {"a scheme that is not one", "server", map[string]any{"kind": "http", "endpoint": "web", "scheme": "ftp"}, "http or https"}, - {"a status on a tcp check", "server", map[string]any{"kind": "tcp", "endpoint": "web", "status": 200}, "only an http check has"}, - {"an exec with no command", "server", map[string]any{"kind": "exec"}, "says no command"}, - {"a command on an http check", "server", map[string]any{"kind": "http", "endpoint": "web", "command": "true"}, "only an exec check runs"}, - {"a runtime check on a service", "daemon", map[string]any{"kind": "runtime"}, "only a container has"}, - {"a unit check on a container", "server", map[string]any{"kind": "unit"}, "a service's or a process's"}, - {"a tool the module does not serve", "server", map[string]any{"kind": "tool", "tool": "app_admin"}, "does not serve"}, - {"a tool check alone", "server", map[string]any{"kind": "tool", "tool": "app_status"}, "judges itself only by its own tool"}, - {"needs not required", "server", map[string]any{"kind": "tcp", "endpoint": "web", "needs": "redis"}, "does not require"}, - {"health on a step", "seed", map[string]any{"kind": "runtime"}, "does not stay up"}, - {"health on a schedule", "sweep", map[string]any{"kind": "runtime"}, "does not stay up"}, - {"health that is not an object", "server", nil, "is an object"}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - raw := healthManifest(t, c.on, c.health) - if c.health == nil { - raw = []byte(strings.Replace(string(raw), `"name":"app-server"`, `"name":"app-server","health":"tcp"`, 1)) - } - _, err := ParseManifest(raw) - if err == nil { - t.Fatalf("%s was accepted", c.name) - } - if !strings.Contains(err.Error(), c.says) { - t.Errorf("refused, but not for saying %q: %v", c.says, err) - } - }) - } -} - func TestHealthIsComposedAsThePortThisMachineGaveTheEndpoint(t *testing.T) { - m, err := ParseManifest(healthManifest(t, "server", map[string]any{"kind": "http", "endpoint": "web", "path": "/healthz", + m, err := decodeForTest(healthManifest(t, "server", map[string]any{"kind": "http", "endpoint": "web", "path": "/healthz", "needs": "postgres-database"})) if err != nil { t.Fatal(err) @@ -163,7 +109,7 @@ func TestHealthIsComposedAsThePortThisMachineGaveTheEndpoint(t *testing.T) { } func TestTheUndeclaredAreTheLongRunningWithoutHealth(t *testing.T) { - m, err := ParseManifest(healthManifest(t, "server", map[string]any{"kind": "runtime"})) + m, err := decodeForTest(healthManifest(t, "server", map[string]any{"kind": "runtime"})) if err != nil { t.Fatal(err) } @@ -176,7 +122,7 @@ func TestATooledHealthIsGrantedToTheEngine(t *testing.T) { raw := healthManifest(t, "server", map[string]any{"kind": "http", "endpoint": "web"}, map[string]any{"id": "admin", "type": "service", "unit": "app-admin.service", "state": "running", "health": map[string]any{"kind": "tool", "tool": "app_status"}}) - m, err := ParseManifest(raw) + m, err := decodeForTest(raw) if err != nil { t.Fatal(err) } @@ -194,7 +140,7 @@ func TestEveryComposedHealthIsOneTheNodeEngineTakes(t *testing.T) { {"kind": "runtime"}, {"kind": "exec", "command": "pg_isready -q"}, } { - m, err := ParseManifest(healthManifest(t, "server", h, + m, err := decodeForTest(healthManifest(t, "server", h, map[string]any{"id": "admin", "type": "service", "unit": "app-admin.service", "state": "running", "health": map[string]any{"kind": "tool", "tool": "app_status"}})) if err != nil { diff --git a/go/catalogue/identity.go b/go/catalogue/identity.go index 0400735..376f3df 100644 --- a/go/catalogue/identity.go +++ b/go/catalogue/identity.go @@ -174,47 +174,3 @@ func (r Resolution) Overflowing() []Overflow { // passes on every machine that mesh has. `module check --longest-machine-name` says another mesh's; // a mesh that names a longer machine raises this in the same change (ADR 0225). const DefaultLongestMachine = 6 - -// IdentityProblems is every module whose identity would overflow a provision it wants, on a machine -// whose name is `longestMachine` characters — judged before merge, over the catalogue alone, so the -// pull request that introduces an overflow is the one refused (novox/hq ADR 0225, issue 263). A -// provision no module in the shelf offers is not judged: its bound is not known here. -func IdentityProblems(shelf Shelf, longestMachine int) []string { - offeredBy := map[string][]string{} - for _, name := range shelfOrder(shelf) { - for _, o := range shelf[name].Offers() { - offeredBy[o] = append(offeredBy[o], name) - } - } - machine := strings.Repeat("n", longestMachine) - var problems []string - for _, name := range shelfOrder(shelf) { - m := shelf[name] - source := IdentitySource(m.Slug, m.Module) - for _, want := range m.Wants() { - // The tightest bound among the modules offering it: whichever one answers on a given - // machine, the identity has to fit it. - tightest, by := IdentityBound{}, "" - for _, provider := range offeredBy[want] { - if provider == name { - continue // a module answering its own requirement is not its own consumer - } - b := shelf[provider].IdentityBoundOf(want) - if b.Bounded() && (!tightest.Bounded() || b.Max < tightest.Max) { - tightest, by = b, provider - } - } - if CheckIdentityWithin(machine, source, tightest) == nil { - continue - } - got := ConsumerIdentity(machine, source) - problems = append(problems, fmt.Sprintf( - "%s wants %s, and %s keeps its consumers' identities in %s of at most %d characters: "+ - "on a machine with a %d-character name it is identified as %q, %d — give %s a "+ - "`slug` of at most %d characters", - name, want, by, tightest.In, tightest.Max, longestMachine, got, len(got), name, - tightest.Max-len(IdentityPrefix)-longestMachine-1)) - } - } - return problems -} diff --git a/go/catalogue/identity_bound_test.go b/go/catalogue/identity_bound_test.go index f7f27c0..7dee560 100644 --- a/go/catalogue/identity_bound_test.go +++ b/go/catalogue/identity_bound_test.go @@ -1,7 +1,6 @@ package catalogue import ( - "encoding/json" "strings" "testing" ) @@ -61,97 +60,6 @@ func TestAnUnstatedBoundFollowsWhatTheProviderIsTold(t *testing.T) { } } -// The field reads as written and writes back the same, and refuses what says nothing. -func TestAnOffersIdentityIsParsedStrictly(t *testing.T) { - for _, raw := range []string{ - `{"name":"s3-bucket","scope":"mesh","identity":{"max":20,"in":"an S3 access key"}}`, - `{"name":"wildcard-resolution","scope":"mesh","identity":false}`, - `{"name":"redis-cache","scope":"mesh","identity":{"in":"a Redis ACL user"}}`, - } { - var o Offer - if err := json.Unmarshal([]byte(raw), &o); err != nil { - t.Fatalf("%s: %v", raw, err) - } - back, err := json.Marshal(o) - if err != nil || string(back) != raw { - t.Errorf("did not round-trip:\n%s\n%s (%v)", raw, back, err) - } - } - for _, raw := range []string{ - `{"name":"x","identity":true}`, - `{"name":"x","identity":{"max":20,"in":"y","most":3}}`, - } { - var o Offer - if err := json.Unmarshal([]byte(raw), &o); err == nil { - t.Errorf("accepted %s", raw) - } - } - bad := Manifest{Module: "bad", Version: "1", Provides: []Offer{ - {Name: "unsaid", Scope: ScopeMesh, Identity: &OfferIdentity{Max: 20}}, - {Name: "tiny", Scope: ScopeMesh, Identity: &OfferIdentity{Max: 4, In: "nothing usable"}}, - }} - raw, err := json.Marshal(bad) - if err != nil { - t.Fatal(err) - } - _, err = ParseManifest(raw) - if err == nil || !strings.Contains(err.Error(), "without saying what keeps them") || - !strings.Contains(err.Error(), "the shortest the mesh makes") { - t.Fatalf("a bound with no `in`, or too short for any identity, was accepted: %v", err) - } -} - -// Refused before merge: the catalogue check judges each module's identity, on the longest machine -// name, against the bound of every provision it wants — and names the module and the slug to set. -func TestTheCatalogueCheckRefusesAnIdentityThatOverflowsWhatItRequires(t *testing.T) { - shelf := Shelf{ - "objects": {Module: "objects", Provides: []Offer{{Name: "s3-bucket", Scope: ScopeMesh, - Identity: &OfferIdentity{Max: 20, In: "an S3 access key"}}}, - Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}}, - "photoalbum": {Module: "photoalbum", Requires: []string{"s3-bucket"}}, - "files": {Module: "files", Requires: []string{"s3-bucket"}}, - } - problems := IdentityProblems(shelf, 6) - if len(problems) != 1 || !strings.Contains(problems[0], "photoalbum wants s3-bucket") || - !strings.Contains(problems[0], `"mesh_nnnnnn_photoalbum", 22`) || - !strings.Contains(problems[0], "`slug` of at most 8 characters") { - t.Fatalf("one overflow, named with its remedy, was expected: %q", problems) - } - // A longer machine name refuses more: the check is about the mesh's machines, not one. - if got := IdentityProblems(shelf, 10); len(got) != 2 { - t.Fatalf("on a 10-character name both overflow (mesh_nnnnnnnnnn_files is 21): %q", got) - } - // And a slug is the remedy it names. - album := shelf["photoalbum"] - album.Slug = "album" - shelf["photoalbum"] = album - if got := IdentityProblems(shelf, 6); len(got) != 0 { - t.Fatalf("a slug that fits is still refused: %q", got) - } -} - -// Tonight's case (issue 263): networkmanager, no slug, requiring the mesh's resolver provision on a -// machine with a six-character name. Under ADR 0049's one bound it was refused, and its provider's -// whole machine with it; the resolver keeps no name, so it is not refused at all. -func TestARequirementOnAKeylessProvisionComposesWithALongName(t *testing.T) { - shelf := Shelf{ - "resolver": {Module: "resolver", Provides: []Offer{{Name: "wildcard-resolution", Scope: ScopeMesh}}}, - "networkmanager": {Module: "networkmanager", Requires: []string{"wildcard-resolution"}}, - } - if CheckIdentity("laptop", "networkmanager") == nil { - t.Fatal("the regression is not reproduced: mesh_laptop_networkmanager fits the old global bound") - } - if got := IdentityProblems(shelf, 6); len(got) != 0 { - t.Fatalf("a requirement on a keyless provision was refused for its length: %q", got) - } - r := Resolution{Node: "laptop", Modules: []Manifest{shelf["networkmanager"]}, - Needs: []Needed{{Name: "wildcard-resolution", From: "anchor", For: "networkmanager", - Identity: shelf["resolver"].IdentityBoundOf("wildcard-resolution")}}} - if got := r.Overflowing(); len(got) != 0 { - t.Fatalf("a keyless requirement is reported as overflowing: %+v", got) - } -} - // The consumer's side of the same judgement the provider's composition makes: what `status` says. func TestAnOverflowingRequirementIsNamedFromTheConsumersSide(t *testing.T) { bound := IdentityBound{Max: 20, In: "an S3 access key"} @@ -174,17 +82,3 @@ func TestAnOverflowingRequirementIsNamedFromTheConsumersSide(t *testing.T) { t.Fatalf("the overflow does not say what keeps it or the remedy: %s", said) } } - -// An identity served as a DNS label is never truncated, so the offer's bound must keep it in one. -func TestAnIdentityServedAsADNSLabelIsBoundedToOne(t *testing.T) { - serves := map[string]map[string]any{"bucket": {"name": "${consumer:as:dns}"}} - wide := Manifest{Module: "wide", Serves: serves, Provides: []Offer{{Name: "bucket", Scope: ScopeMesh, - Identity: &OfferIdentity{Max: 255, In: "a client id"}}}} - if got := CheckServes(wide); len(got) != 1 || !strings.Contains(got[0], "a label keeps 63") { - t.Fatalf("a 255-character bound on a DNS label passed: %q", got) - } - unsaid := Manifest{Module: "unsaid", Serves: serves, Provides: []Offer{{Name: "bucket", Scope: ScopeMesh}}} - if got := CheckServes(unsaid); len(got) != 0 { - t.Fatalf("the default bound (20) on a DNS label was refused: %q", got) - } -} diff --git a/go/catalogue/installation.go b/go/catalogue/installation.go index b257806..2a00b7c 100644 --- a/go/catalogue/installation.go +++ b/go/catalogue/installation.go @@ -1,14 +1,5 @@ package catalogue -import ( - "encoding/json" - "fmt" - "net" - "regexp" - "sort" - "strings" -) - // A definition names no installation (novox/hq ADR 0112, ADR 0155, issues 122 and 134). // // A module definition holds what is true of the module everywhere; what is particular to one mesh — @@ -34,203 +25,3 @@ import ( // NamesOnPurpose is the catalogue-level word a resource carries for the names it means to name: // each name mapped to its reason. The host never sees it. const NamesOnPurpose = "names-on-purpose" - -// prose is every key whose value the mesh never reads. -var prose = map[string]bool{"why": true, "description": true} - -// Registries the world runs, which an image may name because an image reference must say where it -// is pulled from. Anything else in an image reference is a registry of some installation. -var worldsRegistries = map[string]bool{ - "docker.io": true, "registry-1.docker.io": true, "index.docker.io": true, "ghcr.io": true, - "quay.io": true, "gcr.io": true, "registry.k8s.io": true, "k8s.gcr.io": true, - "mcr.microsoft.com": true, "lscr.io": true, "public.ecr.aws": true, "registry.gitlab.com": true, - "codeberg.org": true, "cgr.dev": true, -} - -// Services the world runs that a definition may name as a policy default, the way it may name a -// public resolver: the public certificate authorities' ACME directories. Anything else a served -// fact or a file names is somebody's installation. -var worldsServices = map[string]bool{ - "acme-v02.api.letsencrypt.org": true, "acme-staging-v02.api.letsencrypt.org": true, - "api.buypass.com": true, "api.test4.buypass.no": true, "dv.acme-v02.api.pki.goog": true, - "acme.zerossl.com": true, -} - -// Resolvers the world runs, which a machine's resolver may forward to as a policy default. -var worldsResolvers = map[string]bool{ - "1.1.1.1": true, "1.0.0.1": true, "8.8.8.8": true, "8.8.4.4": true, "9.9.9.9": true, - "149.112.112.112": true, "208.67.222.222": true, "208.67.220.220": true, -} - -// hostname is a dotted name whose last label is a top-level domain a real installation would have. -// Not every dotted token: `module.json`, `index.html` and `docker.sock` are dotted and name nothing. -// Boundaries are checked by hand rather than in the pattern, because two names one character apart -// — `a.example.tld,b.example.tld` — would otherwise share the delimiter and the second would be lost. -var hostname = regexp.MustCompile( - `(?i)(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+` + - `(?:be|nl|de|fr|uk|eu|com|net|org|io|dev|app|cloud|site|online|me|co|ch|at|lu|` + - `internal|example|tld|test|invalid)`) - -// upstreamVersion is where an artifact pinned by URL says which release it is (Artifact.Version). -var upstreamVersion = regexp.MustCompile(`^build\.artifacts\[[0-9]+\]\.version$`) - -// address is a dotted quad. -var address = regexp.MustCompile(`(?:[0-9]{1,3}\.){3}[0-9]{1,3}`) - -// isName is whether a byte may be part of a name; a match bordered by one is a longer token. -func isName(b byte) bool { - return b == '.' || b == '-' || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') -} - -// standalone are the matches of re in value that are whole tokens, not parts of a longer one. -func standalone(re *regexp.Regexp, value string) []string { - var out []string - for _, span := range re.FindAllStringIndex(value, -1) { - if span[0] > 0 && isName(value[span[0]-1]) { - continue - } - if span[1] < len(value) && isName(value[span[1]]) { - continue - } - out = append(out, value[span[0]:span[1]]) - } - return out -} - -// InstallationProblems is every value of a definition that names an installation, in the -// definition's own words: where it is, and what it names. -func InstallationProblems(m Manifest) []string { - raw, err := json.Marshal(m) - if err != nil { - return []string{fmt.Sprintf("%s could not be read back: %v", m.Module, err)} - } - var tree any - if err := json.Unmarshal(raw, &tree); err != nil { - return []string{fmt.Sprintf("%s could not be read back: %v", m.Module, err)} - } - var problems []string - // The module's own name is a value too: a module named after the domain it serves is a - // definition that can only be installed there (issue 134). - for _, name := range namesIn(m.Module) { - problems = append(problems, fmt.Sprintf( - "%s is named after %s, and a module is named for what it is, not for where it runs", m.Module, name)) - } - walk(tree, "", nil, func(at string, value string, meant map[string]bool, isImage bool) { - for _, name := range namesIn(value) { - if (isImage && worldsRegistries[strings.ToLower(name)]) || meant[name] { - continue - } - problems = append(problems, fmt.Sprintf("%s names %s at %s", m.Module, name, at)) - } - for _, ip := range addressesIn(value) { - // An upstream pin's version may have four dotted numbers (Owl 1.5.6.1), which is no address; a name - // in it is still judged above. Its URL is judged whole: the vendor's host goes through the artifact's - // names-on-purpose like any name a definition means (novox/hq ADR 0316). - if meant[ip] || upstreamVersion.MatchString(at) { - continue - } - problems = append(problems, fmt.Sprintf("%s names the public address %s at %s", m.Module, ip, at)) - } - }) - sort.Strings(problems) - return problems -} - -// walk visits every string in the tree with its path, the names the enclosing resource means to -// name (with a reason), and whether it is an image reference. -func walk(node any, at string, meant map[string]bool, visit func(at, value string, meant map[string]bool, isImage bool)) { - switch v := node.(type) { - case map[string]any: - if declared, has := v[NamesOnPurpose].(map[string]any); has { - widened := map[string]bool{} - for name := range meant { - widened[name] = true - } - for name, reason := range declared { - if r, ok := reason.(string); ok && strings.TrimSpace(r) != "" { - widened[strings.ToLower(name)] = true - } - } - meant = widened - } - keys := make([]string, 0, len(v)) - for k := range v { - keys = append(keys, k) - } - sort.Strings(keys) - for _, k := range keys { - // Prose is a string a person reads. A key that is called `why` or `description` and holds - // anything else — a setting of that name, whose default the mesh writes — is walked like any - // other (novox/hq ADR 0262). - if _, isString := v[k].(string); (prose[k] && isString) || k == NamesOnPurpose || (at == "" && k == "module") { - continue - } - child := at + "." + k - if at == "" { - child = k - } - if s, isString := v[k].(string); isString { - visit(child, s, meant, k == "image") - continue - } - walk(v[k], child, meant, visit) - } - case []any: - for i, item := range v { - child := fmt.Sprintf("%s[%d]", at, i) - if s, isString := item.(string); isString { - visit(child, s, meant, false) - continue - } - walk(item, child, meant, visit) - } - } -} - -// namesIn is every hostname in a value that could belong to an installation. -func namesIn(value string) []string { - var out []string - for _, found := range standalone(hostname, value) { - name := strings.ToLower(found) - switch { - case strings.HasSuffix(name, ".docker.internal"): - // The container runtime's alias for its own host: every machine running it has one. - case worldsServices[name]: - // A public authority named as a policy default, true of any mesh that wants it. - case name == "example.tld", strings.HasSuffix(name, ".example.tld"), - name == "example.com", name == "example.net", name == "example.org", - strings.HasSuffix(name, ".example.com"), strings.HasSuffix(name, ".example.net"), - strings.HasSuffix(name, ".example.org"), strings.HasSuffix(name, ".example"), - strings.HasSuffix(name, ".test"), strings.HasSuffix(name, ".invalid"): - // Documentation names, which is what a definition's own example should use. - default: - out = append(out, name) - } - } - return out -} - -// addressesIn is every public address in a value: not a private range, loopback, link-local, the -// unspecified address, a documentation range, or a resolver the world runs. -func addressesIn(value string) []string { - var out []string - for _, found := range standalone(address, value) { - ip := net.ParseIP(found) - if ip == nil || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() || - ip.IsUnspecified() || ip.IsMulticast() || worldsResolvers[found] || documentation(ip) { - continue - } - out = append(out, found) - } - return out -} - -func documentation(ip net.IP) bool { - for _, cidr := range []string{"192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24", "100.64.0.0/10"} { - _, block, _ := net.ParseCIDR(cidr) - if block.Contains(ip) { - return true - } - } - return false -} diff --git a/go/catalogue/installation_test.go b/go/catalogue/installation_test.go index 563ed72..1b52013 100644 --- a/go/catalogue/installation_test.go +++ b/go/catalogue/installation_test.go @@ -1,93 +1 @@ package catalogue - -import ( - "strings" - "testing" -) - -// A definition names no installation (novox/hq ADR 0112, ADR 0155). What the mesh acts on is judged; -// prose is not; the world's registries and resolvers are the world's; a declared exception is a -// reason a reader sees. -func TestADefinitionNamingAnInstallationIsNamedBack(t *testing.T) { - m := Manifest{Module: "idp", Resources: []map[string]any{ - {"id": "server", "type": "container", "image": "quay.io/keycloak/keycloak@sha256:aa", - "env": map[string]any{"KC_HOSTNAME": "https://login.mesh-one.be"}}, - {"id": "env", "type": "file", "content": "REAL_IP_FROM=192.168.1.0/24,127.0.0.0/8,203.0.113.7,51.15.22.9\n"}, - }, Listens: []Listening{{Port: 8080, From: FromMesh, Why: "the login page; login.mesh-one.be is a route grant"}}} - got := strings.Join(InstallationProblems(m), "\n") - for _, want := range []string{ - "idp names login.mesh-one.be at resources[0].env.KC_HOSTNAME", - "idp names the public address 51.15.22.9 at resources[1].content", - } { - if !strings.Contains(got, want) { - t.Errorf("missing %q in:\n%s", want, got) - } - } - for _, mustNot := range []string{"quay.io", "why", "203.0.113.7", "192.168.1.0", "127.0.0.0"} { - if strings.Contains(got, mustNot) { - t.Errorf("%q was reported and should not be:\n%s", mustNot, got) - } - } -} - -func TestTheWorldsNamesAreNotAnInstallations(t *testing.T) { - m := Manifest{Module: "resolver", Resources: []map[string]any{ - {"id": "conf", "type": "file", "content": "server=1.1.1.1\nserver=8.8.8.8\nlisten=127.0.0.55\n"}, - {"id": "proxy", "type": "container", "image": "docker.io/library/traefik@sha256:bb"}, - {"id": "adapter", "type": "file", "content": "{\"machine\": \"host.docker.internal\"}\n"}, - {"id": "doc", "type": "file", "content": "root = https://git.example.tld/\n"}, - }} - if got := InstallationProblems(m); len(got) != 0 { - t.Fatalf("the world's names were reported: %v", got) - } -} - -func TestANameMeantOnPurposeIsDeclaredWithItsReason(t *testing.T) { - // The federation's public directory in a homeserver's config: the world's, said so, and a name - // the map does not cover is still reported. - m := Manifest{Module: "homeserver", Resources: []map[string]any{ - {"id": "conf", "type": "file", "content": "trusted_key_servers: matrix.org\nwell_known: https://mesh-one.be\n", - NamesOnPurpose: map[string]any{"matrix.org": "the federation's public key server, the world's"}}, - }} - got := InstallationProblems(m) - if len(got) != 1 || !strings.Contains(got[0], "mesh-one.be") { - t.Fatalf("got %v", got) - } -} - -func TestAnImageFromAnInstallationsRegistryNeedsAReason(t *testing.T) { - bare := Manifest{Module: "site", Resources: []map[string]any{ - {"id": "server", "type": "container", "image": "registry.mesh-one.be/org/site@sha256:cc"}, - }} - if got := InstallationProblems(bare); len(got) != 1 || !strings.Contains(got[0], "registry.mesh-one.be") { - t.Fatalf("an image on an installation's registry was not named: %v", got) - } - excepted := Manifest{Module: "site", Resources: []map[string]any{ - {"id": "server", "type": "container", "image": "registry.mesh-one.be/org/site@sha256:cc", - NamesOnPurpose: map[string]any{"registry.mesh-one.be": "built outside the mesh until the site's repository is a build source here"}}, - }} - if got := InstallationProblems(excepted); len(got) != 0 { - t.Fatalf("a declared exception was still reported: %v", got) - } -} - -func TestAModuleNamedAfterADomainIsNamedBack(t *testing.T) { - got := InstallationProblems(Manifest{Module: "mesh-one.be"}) - if len(got) != 1 || !strings.Contains(got[0], "named after mesh-one.be") { - t.Fatalf("got %v", got) - } -} - -func TestABuildContextOnASeatNamesNoForge(t *testing.T) { - m := Manifest{Module: "packager", Build: &Build{Artifacts: []Artifact{ - {Name: "server", Kind: "image", From: "Dockerfile", - Context: &ArtifactContext{Seat: "git", Repository: "org/controller", Ref: "main"}}, - }}} - if got := InstallationProblems(m); len(got) != 0 { - t.Fatalf("a context on a seat was reported: %v", got) - } - m.Build.Artifacts[0].Context = &ArtifactContext{Repository: "https://git.mesh-one.be/org/controller.git"} - if got := InstallationProblems(m); len(got) != 1 { - t.Fatalf("a context by URL was not reported: %v", got) - } -} diff --git a/go/catalogue/invokes_test.go b/go/catalogue/invokes_test.go index 7612ad2..c43bf47 100644 --- a/go/catalogue/invokes_test.go +++ b/go/catalogue/invokes_test.go @@ -1,14 +1,13 @@ package catalogue import ( - "strings" "testing" ) // A manifest may say which tools its module calls (novox/hq ADR 0152), and the parser accepts the // two shapes the grant has: a named tool, and every tool. func TestAManifestMaySayWhatItInvokes(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"mesh-console","version":"1",` + + m, err := decodeForTest([]byte(`{"module":"mesh-console","version":"1",` + `"invokes":["mesh-catalog.catalog_modules","*"]}`)) if err != nil { t.Fatal(err) @@ -17,15 +16,3 @@ func TestAManifestMaySayWhatItInvokes(t *testing.T) { t.Fatalf("invokes not read: %v", m.Invokes) } } - -// An entry that names a module and no tool is refused at parse, in the manifest's words, rather than -// at the composition of the bus's user list where it would stop the file for everybody. -func TestAnInvokeThatNamesNoToolIsRefusedAtParse(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"mesh-console","version":"1","invokes":["shop"]}`)) - if err == nil { - t.Fatal("an invoke naming no tool was accepted") - } - if !strings.Contains(err.Error(), `invokes "shop", which does not name a tool`) { - t.Fatalf("refused for the wrong reason: %v", err) - } -} diff --git a/go/catalogue/issued_credential_test.go b/go/catalogue/issued_credential_test.go index 88b235d..1b52013 100644 --- a/go/catalogue/issued_credential_test.go +++ b/go/catalogue/issued_credential_test.go @@ -1,42 +1 @@ package catalogue - -import ( - "strings" - "testing" -) - -// A provision's credential may be issued by its provider's software (novox/hq ADR 0305, issue 526): the offer -// names the tool that answers it and no own secret, and every other shape is refused at parse. -func TestAnOfferMaySayItsSoftwareIssuesTheCredential(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"media","version":"1", - "provides":[{"name":"media-api","scope":"mesh","credential":{"issued-by":"software","tool":"media_issued_credential"}}], - "serves":{"media-api":{"port":32400}}}`)) - if err != nil { - t.Fatal(err) - } - if tool, issued := m.IssuedCredentialOf("media-api"); !issued || tool != "media_issued_credential" { - t.Fatalf("the offer's issued credential was not read: %q %v", tool, issued) - } - if _, shared := m.SharedCredentialOf("media-api"); shared { - t.Fatal("an issued credential was read as an own secret shared") - } - if _, issued := m.IssuedCredentialOf("other"); issued { - t.Fatal("a provision the module does not offer reads as issued") - } - - for want, raw := range map[string]string{ - "the issuers an offer may name": `{"module":"d","version":"1", - "provides":[{"name":"d-api","credential":{"issued-by":"vendor","tool":"d_token"}}]}`, - "names no tool that answers it": `{"module":"d","version":"1", - "provides":[{"name":"d-api","credential":{"issued-by":"software"}}]}`, - "names its own secret": `{"module":"d","version":"1","own-secrets":{"token":{"path":"/p","taken":"at-start"}}, - "provides":[{"name":"d-api","credential":{"issued-by":"software","tool":"d_token","own":"token"}}]}`, - "the issuers an offer may name ": `{"module":"d","version":"1", - "provides":[{"name":"d-api","credential":{"tool":"d_token"}}]}`, - } { - want = strings.TrimSpace(want) - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { - t.Errorf("expected a refusal saying %q, got %v", want, err) - } - } -} diff --git a/go/catalogue/issued_outside_test.go b/go/catalogue/issued_outside_test.go index 54e5d29..1b52013 100644 --- a/go/catalogue/issued_outside_test.go +++ b/go/catalogue/issued_outside_test.go @@ -1,46 +1 @@ package catalogue - -import ( - "strings" - "testing" -) - -// A provision's credential may be one only a person outside the mesh can give (novox/hq ADR 0337 §1, issue 164): -// the offer says `issued-by: outside` and names neither an own secret nor a tool, and the parser refuses it beside -// either. An offer that says `software` keeps ADR 0305's shape. -func TestAnOfferMaySayAPersonOutsideTheMeshIssuesItsCredential(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"tv","version":"1", - "provides":[{"name":"tv-api","scope":"mesh","credential":{"issued-by":"outside"}}], - "serves":{"tv-api":{"port":8989}}}`)) - if err != nil { - t.Fatal(err) - } - if !m.IssuedOutsideOf("tv-api") { - t.Fatal("the offer's credential issued outside was not read") - } - if m.IssuedOutsideOf("other") { - t.Fatal("a provision the module does not offer reads as issued outside") - } - if _, issued := m.IssuedCredentialOf("tv-api"); issued { - t.Fatal("a credential issued outside was read as issued by the software") - } - if _, shared := m.SharedCredentialOf("tv-api"); shared { - t.Fatal("a credential issued outside was read as an own secret shared") - } - if who, offered := OffersIssuedOutside(map[string]Manifest{"tv": m}, "tv-api"); !offered || who != "tv" { - t.Fatalf("the catalogue does not say tv offers tv-api issued outside: %q %v", who, offered) - } - - for want, raw := range map[string]string{ - "names its own secret": `{"module":"d","version":"1","own-secrets":{"key":{"path":"/p","taken":"at-start"}}, - "provides":[{"name":"d-api","credential":{"issued-by":"outside","own":"key"}}]}`, - "names the tool": `{"module":"d","version":"1", - "provides":[{"name":"d-api","credential":{"issued-by":"outside","tool":"d_key"}}]}`, - "the issuers an offer may name": `{"module":"d","version":"1", - "provides":[{"name":"d-api","credential":{"issued-by":"a-person"}}]}`, - } { - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { - t.Errorf("expected a refusal saying %q, got %v", want, err) - } - } -} diff --git a/go/catalogue/jail_problems_test.go b/go/catalogue/jail_problems_test.go index 800d36a..1b52013 100644 --- a/go/catalogue/jail_problems_test.go +++ b/go/catalogue/jail_problems_test.go @@ -1,49 +1 @@ package catalogue - -import ( - "strings" - "testing" -) - -// A jail whose pattern names twice is refused by name: fail2ban would read it as a duplicate -// capture group and refuse its whole configuration, leaving the machine with no bans at all — which -// is what happened on the control node the day this was built (novox/hq ADR 0179). -func TestAJailsPatternNamesTheHostOncePerShape(t *testing.T) { - twice := Manifest{Module: "route-proxy", Jails: []Jail{{ - Name: "route-proxy", - Failregex: `^.*(?:refused for :\d+|asked from :\d+)$`, - Jail: "backend = systemd", - }}} - problems := twice.jailProblems() - if len(problems) != 1 || !strings.Contains(problems[0], "names 2 times") { - t.Fatalf("a pattern naming twice was not refused by name: %v", problems) - } - if !strings.Contains(problems[0], "no bans at all") { - t.Errorf("the refusal does not say what it costs: %q", problems[0]) - } - - // The same two shapes, one pattern each, is how it is written — and is accepted. - split := Manifest{Module: "route-proxy", Jails: []Jail{{ - Name: "route-proxy", - Failregex: "^.*refused for :\\d+$\n ^.*asked from :\\d+$", - Jail: "backend = systemd", - }}} - if problems := split.jailProblems(); len(problems) != 0 { - t.Fatalf("one per pattern was refused: %v", problems) - } -} - -// A jail with no name, no pattern, or a name another jail of the same module already took is refused. -func TestAJailIsNamedOnceAndSaysWhatAFailureLooksLike(t *testing.T) { - m := Manifest{Module: "mailu", Jails: []Jail{ - {Name: "", Failregex: "^.*rip=$"}, - {Name: "mailu-front", Failregex: " "}, - {Name: "mailu-front", Failregex: "^.*rip=$"}, - }} - problems := strings.Join(m.jailProblems(), "\n") - for _, want := range []string{"a jail with no name", "says nothing a failed attempt looks like", "two jails called"} { - if !strings.Contains(problems, want) { - t.Errorf("missing %q in:\n%s", want, problems) - } - } -} diff --git a/go/catalogue/kept_test.go b/go/catalogue/kept_test.go index f60f8c3..1d18167 100644 --- a/go/catalogue/kept_test.go +++ b/go/catalogue/kept_test.go @@ -56,13 +56,3 @@ func TestOnlyAModuleThatKeepsGetsTheExport(t *testing.T) { } } } - -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) - } -} diff --git a/go/catalogue/kinded_test.go b/go/catalogue/kinded_test.go index 896a87f..3afbdfa 100644 --- a/go/catalogue/kinded_test.go +++ b/go/catalogue/kinded_test.go @@ -1,7 +1,6 @@ package catalogue import ( - "strings" "testing" ) @@ -25,62 +24,6 @@ func aChannel(module, kind string) Manifest { {Name: "channel", Scope: ScopeMesh, Kind: kind}, {Name: "intake", Scope: ScopeMesh, Kind: kind}}} } -func TestTwoChannelsOfDifferentKindsHoldTheBenches(t *testing.T) { - shelf := Shelf{"messenger": router(), "telegram": aChannel("telegram", "telegram"), - "desk-channel": aChannel("desk-channel", "desktop")} - if got := problemsFor(t, shelf); got != "" { - t.Fatalf("two kinds were refused: %s", got) - } - for _, m := range shelf { - if got := declaredSeatProblems(m); len(got) > 0 { - t.Fatalf("%s: %v", m.Module, got) - } - } -} - -func TestASecondClaimOfOneKindIsRefused(t *testing.T) { - got := problemsFor(t, Shelf{"messenger": router(), "telegram": aChannel("telegram", "telegram"), - "telegram-two": aChannel("telegram-two", "telegram")}) - if !strings.Contains(got, `of kind "telegram", which telegram already claims`) { - t.Fatalf("a second holder of one kind stood: %s", got) - } -} - -func TestAKindedBenchNeedsAKindAndNoOtherSeatTakesOne(t *testing.T) { - got := problemsFor(t, Shelf{"messenger": router(), "nameless": aChannel("nameless", "")}) - if !strings.Contains(got, "claims the kinded bench channel and names no kind") { - t.Fatalf("a claim without a kind stood: %s", got) - } - odd := Manifest{Module: "odd", Claims: []Claim{{Name: "operator-channel", Scope: ScopeMesh, Kind: "telegram"}}} - got = problemsFor(t, Shelf{"messenger": router(), "odd": odd}) - if !strings.Contains(got, "only a kinded bench takes a kind") { - t.Fatalf("a kind on a seat that is not kinded stood: %s", got) - } - dotted := problemsFor(t, Shelf{"messenger": router(), "dotted": aChannel("dotted", "a.b")}) - if !strings.Contains(dotted, "not a usable name") { - t.Fatalf("a kind that would widen a subject stood: %s", dotted) - } -} - -func TestOnlyChannelAndIntakeAreKinded(t *testing.T) { - m := Manifest{Module: "x", DefinesSeats: []SeatDeclaration{{Name: "pager", Kinded: true, Accepts: []string{"page"}}}} - if got := strings.Join(declaredSeatProblems(m), "; "); !strings.Contains(got, "only channel and intake are kinded") { - t.Fatalf("another kinded bench was declared: %s", got) - } -} - -func TestTheNewRulesAreHeldToWhatTheSeatSays(t *testing.T) { - m := Manifest{Module: "x", DefinesSeats: []SeatDeclaration{{Name: "thing", Accepts: []string{"do"}, - ByCaller: []string{"undo"}, Proofs: []string{"code"}, Records: []string{"nothing"}}}} - got := strings.Join(declaredSeatProblems(m), "; ") - for _, want := range []string{"names thing.undo by its caller, which the seat neither accepts nor emits", - "declares proofs on thing, which is not kinded", `read its records "nothing", which it keeps no state of`} { - if !strings.Contains(got, want) { - t.Errorf("not refused: %q in %s", want, got) - } - } -} - // Two kinds on one machine are two holders, and one kind on two machines is a second claimant. func TestEachKindIsItsOwnHolderWhenResolved(t *testing.T) { modules := []Manifest{aChannel("telegram", "telegram"), aChannel("desk-channel", "desktop")} @@ -93,76 +36,3 @@ func TestEachKindIsItsOwnHolderWhenResolved(t *testing.T) { t.Fatal("one kind held on two machines was not refused") } } - -// A channel's capabilities come from the fixed vocabulary, and only a kinded claim carries any. -func TestCapabilitiesAreTheVocabularysAndOnlyOnAKindedClaim(t *testing.T) { - good := aChannel("telegram", "telegram") - good.Claims[0].Capabilities = []string{"deliver", "choice", "verified-sender", "max-length:4096"} - good.RunsAs = "telegram" - if got := problemsFor(t, Shelf{"messenger": router(), "telegram": good}); got != "" { - t.Fatalf("the vocabulary was refused: %s", got) - } - bad := aChannel("telegram", "telegram") - bad.Claims[0].Capabilities = []string{"trusted", "max-length:lots"} - got := problemsFor(t, Shelf{"messenger": router(), "telegram": bad}) - for _, w := range []string{`"trusted"`, `"max-length:lots"`} { - if !strings.Contains(got, w+", which channel-capabilities/1 does not have") { - t.Errorf("%s was not refused: %s", w, got) - } - } - odd := Manifest{Module: "odd", Claims: []Claim{{Name: "operator-channel", Scope: ScopeMesh, Capabilities: []string{"deliver"}}}} - if got := problemsFor(t, Shelf{"messenger": router(), "odd": odd}); !strings.Contains(got, "only a kinded bench's claim carries them") { - t.Errorf("capabilities on a seat that is not kinded stood: %s", got) - } -} - -// novox/hq ADR 0259 §8: a module saying warrants, or speaking for a kind that proves its sender, runs as an -// account of its own — never carried by the machine's runtime, which runs as the operator's account. -func TestATrustedHolderMustRunAsAnAccountOfItsOwn(t *testing.T) { - r := router() - r.RunsAs = "" - if got := problemsFor(t, Shelf{"messenger": r}); !strings.Contains(got, "messenger must run as an account of its own") { - t.Errorf("a router on the machine's runtime stood: %s", got) - } - tg := aChannel("telegram", "telegram") - tg.Claims[0].Capabilities = []string{"choice", "verified-sender"} - if got := problemsFor(t, Shelf{"messenger": router(), "telegram": tg}); !strings.Contains(got, "telegram must run as an account of its own") { - t.Errorf("a verified channel on the machine's runtime stood: %s", got) - } - desk := aChannel("desk-channel", "desktop") - desk.Claims[0].Capabilities = []string{"choice"} - if got := problemsFor(t, Shelf{"messenger": router(), "desk-channel": desk}); got != "" { - t.Errorf("a channel proving nothing was held to it: %s", got) - } - // A kind that is `private` shows a link's code, which links an account as the operator: its holder is - // trusted with it, so it runs as its own account too (the confirmation review of 2026-10-09). - private := aChannel("desk-channel", "desktop") - private.Claims[0].Capabilities = []string{"choice", "private"} - if got := problemsFor(t, Shelf{"messenger": router(), "desk-channel": private}); !strings.Contains(got, "desk-channel must run as an account of its own") { - t.Errorf("a private channel on the machine's runtime stood: %s", got) - } -} - -func TestRunsAsIsAnAccountOfTheModulesOwn(t *testing.T) { - ok := Manifest{Module: "telegram", RunsAs: "telegram", SecretsOwner: "telegram", - OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/telegram/broker"}}, - Resources: []map[string]any{{"id": "account", "type": "user", "name": "telegram"}}} - if got := RunsAsProblems(ok); len(got) != 0 { - t.Fatalf("a sound runs-as was refused: %v", got) - } - for want, change := range map[string]func(*Manifest){ - "never root": func(m *Manifest) { m.RunsAs, m.SecretsOwner = "root", "root" }, - "not an account name": func(m *Manifest) { m.RunsAs = "${machine:account}" }, - "which it does not make": func(m *Manifest) { m.Resources = nil }, - "declares no own secret": func(m *Manifest) { m.OwnSecrets = nil }, - "they are the account's own": func(m *Manifest) { m.SecretsOwner = "" }, - } { - m := ok - m.Resources = append([]map[string]any(nil), ok.Resources...) - m.OwnSecrets = OwnSecrets{"broker": {Path: "/x"}} - change(&m) - if got := strings.Join(RunsAsProblems(m), "; "); !strings.Contains(got, want) { - t.Errorf("want %q, got %q", want, got) - } - } -} diff --git a/go/catalogue/localnetwork_test.go b/go/catalogue/localnetwork_test.go index 4a0cd6f..f6a2e19 100644 --- a/go/catalogue/localnetwork_test.go +++ b/go/catalogue/localnetwork_test.go @@ -118,20 +118,6 @@ func TestNoLocalNetworkIsSaidAndNotWidened(t *testing.T) { } } -// `local-network` is a source a manifest may name (§1, changing ADR 0045 §3), and an unknown source is -// still refused. -func TestAManifestMayListenFromTheLocalNetwork(t *testing.T) { - if _, err := ParseManifest([]byte(`{"module":"mosquitto","version":"1", - "listens":[{"port":1883,"from":"local-network"}]}`)); err != nil { - t.Fatalf("a listen from the local network is refused: %v", err) - } - _, err := ParseManifest([]byte(`{"module":"mosquitto","version":"1", - "listens":[{"port":1883,"from":"lan"}]}`)) - if err == nil || !strings.Contains(err.Error(), `from "lan"`) { - t.Fatalf("a source the mesh cannot render is not refused: %v", err) - } -} - // **A fourth reach** (§4, changing ADR 0138 §4): `internal` with the local networks added to what the // filter opens — the internal name, not the public one. func TestTheLocalNetworkReachIsInternalWithTheLocalNetworkAdded(t *testing.T) { diff --git a/go/catalogue/logs.go b/go/catalogue/logs.go index d20aa1c..ec93acd 100644 --- a/go/catalogue/logs.go +++ b/go/catalogue/logs.go @@ -87,34 +87,3 @@ func (l LogDeclaration) MarshalJSON() ([]byte, error) { type plain LogDeclaration return json.Marshal(plain(l)) } - -// LogProblems is what is wrong with a manifest's logs. -// -// Refused at registration, for a bucket's reason: a stream name the bus cannot hold, or a cap the -// mesh would not grant, is a module that installs, starts, and is refused on its first append. -func LogProblems(m Manifest) []string { - var problems []string - if len(m.Logs) > 0 && !stateName.MatchString(m.Module) { - problems = append(problems, fmt.Sprintf( - "%s keeps a log, and a module's name is part of its logs' names, which take one plain "+ - "name — no dot (novox/hq ADR 0297)", m.Module)) - } - seen := map[string]bool{} - for _, l := range m.Logs { - switch { - case !stateName.MatchString(l.Name): - problems = append(problems, fmt.Sprintf( - "%s keeps log %q: a log is named locally — lower-case letters, digits and hyphens, "+ - "no dot and no underscore; the mesh derives the stream (novox/hq ADR 0297)", m.Module, l.Name)) - case seen[l.Name]: - problems = append(problems, fmt.Sprintf("%s keeps log %q twice", m.Module, l.Name)) - } - seen[l.Name] = true - if l.MaxMiB != 0 && (l.MaxMiB < LogLeastMiB || l.MaxMiB > LogMostMiB) { - problems = append(problems, fmt.Sprintf( - "%s caps log %q at %d MiB; a log holds between %d and %d MiB (novox/hq ADR 0297)", - m.Module, l.Name, l.MaxMiB, LogLeastMiB, LogMostMiB)) - } - } - return problems -} diff --git a/go/catalogue/logs_test.go b/go/catalogue/logs_test.go index aeea053..381c8b0 100644 --- a/go/catalogue/logs_test.go +++ b/go/catalogue/logs_test.go @@ -2,14 +2,13 @@ package catalogue import ( "encoding/json" - "strings" "testing" ) // A module declares the logs it keeps (novox/hq ADR 0297 §1): a log by its bare name, or with its cap // in MiB; the default cap where none is said. func TestAManifestMaySayWhatLogsItKeeps(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"mesh-issues","version":"1",` + + m, err := decodeForTest([]byte(`{"module":"mesh-issues","version":"1",` + `"logs":["changes",{"name":"moves","max-mib":2048}]}`)) if err != nil { t.Fatal(err) @@ -25,7 +24,7 @@ func TestAManifestMaySayWhatLogsItKeeps(t *testing.T) { t.Fatalf("written back as %s", out) } // 1953 S-age (ADR 0565 §10): a log may keep its entries for an age, written back with it. - aged, err := ParseManifest([]byte(`{"module":"mesh-hand-acts","version":"1","logs":[{"name":"acts","max-age-days":400}]}`)) + aged, err := decodeForTest([]byte(`{"module":"mesh-hand-acts","version":"1","logs":[{"name":"acts","max-age-days":400}]}`)) if err != nil || aged.Logs[0].MaxAgeDays != 400 { t.Fatalf("an aged log read as %+v, %v", aged.Logs, err) } @@ -33,45 +32,8 @@ func TestAManifestMaySayWhatLogsItKeeps(t *testing.T) { t.Fatalf("an aged log written back as %s", out) } for _, edge := range []string{`{"name":"a","max-mib":1}`, `{"name":"a","max-mib":8192}`, `{"name":"a","max-age-days":3650}`} { - if _, err := ParseManifest([]byte(`{"module":"a","version":"1","logs":[` + edge + `]}`)); err != nil { + if _, err := decodeForTest([]byte(`{"module":"a","version":"1","logs":[` + edge + `]}`)); err != nil { t.Errorf("%s is inside the caps and was refused: %v", edge, err) } } } - -// The catalogue check refuses a log outside the caps, with a name that is not one plain token, with a -// field the mesh does not know, or kept by a module whose own name is not one plain token. -func TestALogIsNamedLocallyAndCapped(t *testing.T) { - for _, c := range []struct{ manifest, says string }{ - {`{"module":"a","version":"1","logs":["mesh.changes"]}`, `keeps log "mesh.changes": a log is named locally`}, - {`{"module":"a","version":"1","logs":["my_changes"]}`, `keeps log "my_changes"`}, - {`{"module":"a","version":"1","logs":["Changes"]}`, `keeps log "Changes"`}, - {`{"module":"a","version":"1","logs":["c","c"]}`, `keeps log "c" twice`}, - {`{"module":"a","version":"1","logs":[{"name":"c","max-mib":8193}]}`, `between 1 and 8192 MiB`}, - {`{"module":"a","version":"1","logs":[{"name":"c","max-mib":-1}]}`, `between 1 and 8192 MiB`}, - {`{"module":"a","version":"1","logs":[{"name":"c","max-mib":0}]}`, `max-mib is between 1 and 8192, not 0`}, - {`{"module":"a","version":"1","logs":[{"name":"c","stream":"LOG_x"}]}`, `{name, max-mib, 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) - } - } -} - -// **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 ")) - } -} diff --git a/go/catalogue/manifest.go b/go/catalogue/manifest.go index c475a59..568be11 100644 --- a/go/catalogue/manifest.go +++ b/go/catalogue/manifest.go @@ -15,7 +15,6 @@ import ( "sort" "strconv" "strings" - "unicode/utf8" ) // Scopes a claim can have. @@ -29,27 +28,6 @@ const ( ScopeMesh = "mesh" ) -// name is what a module, a provision or a claim may be called. -// -// Constrained because these become resource identities, permission patterns and error messages, -// and a name that is valid in one and not the others is a fault found late. -// renamed is what a field used to be called, and what it is now. -// -// Kept rather than dropped once the rename is done: a manifest written against the old name is -// refused either way, and the difference is whether whoever wrote it has to go and find out why. -var renamed = map[string]string{ - // `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. - "needs": "own-secrets", -} - -var name = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*$`) - -// toolName is what a module calls one of its tools: the sdk's tools are `catalog_modules` and -// `gitea_list_repos`, so an underscore is ordinary here and a dot is not — the dot is what separates -// the module from the tool in `.`, and a tool name carrying one would be two grants. -var toolName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) - // Claim is a singular resource a module takes over. type Claim struct { Name string `json:"name"` @@ -409,28 +387,6 @@ func (m Manifest) CredentialFormOf(provision string) string { return "" } -// CredentialFormProblems refuses an offer that does not say its credential's form (the lead, 2026-10-29): an own -// secret, an issuer, or `form` pair or none, and `form` only where neither of the others is said. -func CredentialFormProblems(m Manifest) []string { - var problems []string - for _, o := range m.Provides { - c := o.Credential - switch { - case c == nil || (c.Own == "" && c.IssuedBy == "" && c.Form == ""): - problems = append(problems, fmt.Sprintf("%s provides %q and says no credential form: say `credential` with "+ - "`form` pair (a login of each consumer's own) or none (no credential), or the own secret or issuer it is", - m.Module, o.Name)) - case c.Form != "" && (c.Own != "" || c.IssuedBy != ""): - problems = append(problems, fmt.Sprintf("%s provides %q with `form` beside an own secret or an issuer; `form` "+ - "says only pair or none", m.Module, o.Name)) - case c.Form != "" && c.Form != CredentialFormPair && c.Form != CredentialFormNone: - problems = append(problems, fmt.Sprintf("%s provides %q with the form %q, not %q or %q", m.Module, o.Name, - c.Form, CredentialFormPair, CredentialFormNone)) - } - } - return problems -} - // ProvisionsSharing is every provision of this module whose credential is the named own secret. func (m Manifest) ProvisionsSharing(own string) []string { var out []string @@ -1866,838 +1822,6 @@ func SecretLocal(to, local string) string { // the JSON decoder words it, or "" when it had none (novox/hq ADR 0262). func (m Manifest) UnknownField() string { return m.unknown } -func ParseManifest(raw []byte) (Manifest, error) { - return parseManifest(raw, PublishedPredefined()) -} - -// ParseManifestWith is ParseManifest judging every value that falls back to a predefined key against the list -// given — the one read from the tree the manifest is in (predefined.go, novox/hq ADR 0348 §7) — where -// ParseManifest judges against the list the mesh published. Nil is no list: a value naming a key is refused. -func ParseManifestWith(raw []byte, predefined PredefinedList) (Manifest, error) { - return parseManifest(raw, predefined) -} - -func parseManifest(raw []byte, predefined PredefinedList) (Manifest, error) { - var m Manifest - // Strictly. **An unknown key is refused**, which is the discipline the host's declaration - // parser has and manifests lacked (novox/hq 04-ISSUES/003): a `scope:` key survived in five - // manifests, read by nothing, making them appear to restrict a port and restrict nothing. - // - // "An unenforced rule is indistinguishable from a wrong one, and costs more, because people - // believe it" — and a silently-accepted key is worse than unenforced, because a reviewer - // checking whether something is restricted will find that it is, and be wrong. - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.DisallowUnknownFields() - err := decoder.Decode(&m) - if err == nil && m.unknown != "" { - // Kept aside by UnmarshalJSON for the stored catalogue's sake; registration refuses it. - err = errors.New(m.unknown) - } - if err != nil { - // A key that used to mean something says what it became. Refusing a renamed field with - // "unknown field" is correct and unhelpful: whoever wrote it knew what they meant, and - // the mesh knows what it is called now. - for was, is := range renamed { - if strings.Contains(err.Error(), `"`+was+`"`) { - return Manifest{}, fmt.Errorf( - "this manifest says %q, which is now called %q: %w", was, is, err) - } - } - return Manifest{}, fmt.Errorf("this is not a module manifest: %w", err) - } - m.predefined = predefined - - var problems []string - if !name.MatchString(m.Module) { - problems = append(problems, fmt.Sprintf( - "%q is not a usable module name: lower-case letters, digits, dashes and dots", m.Module)) - } - // A slug is a short identifier the mesh derives a login from (novox/hq ADR 0049). The same - // charset as a name; its length is judged against the bound of each provision it wants on the - // longest machine name by the catalogue check (IdentityProblems, ADR 0225), and against the - // machine it is on when its provider grants it — a slug that is fine on one machine's short name - // can overflow another's. - if m.Slug != "" && !name.MatchString(m.Slug) { - problems = append(problems, fmt.Sprintf( - "%q is not a usable slug: lower-case letters, digits, dashes and dots", m.Slug)) - } - // **`amqp` is not a provision, and not a requirement** (novox/hq ADR 0131). A module that wants - // messaging wants the mesh's bus — it emits and consumes through the sdk, which the mesh hands the - // bus with the module's own credential — and the bus is whatever holds `mesh-broker`, spoken in - // whatever that holder speaks. Naming the old wire protocol asks for a specific server, and the - // only one that could answer is the one being retired. Refused here so the word cannot come back - // through a manifest. - for _, offer := range m.Provides { - if offer.Name == "amqp" { - problems = append(problems, fmt.Sprintf( - "%s provides %q, which is not a provision: the mesh's bus is whatever holds "+ - "mesh-broker, and a module provides mesh-bus to be it (novox/hq ADR 0131)", - m.Module, offer.Name)) - } - } - // **The bus's own account copies the bus and says nothing on it** (novox/hq ADR 0235). The module - // holding mesh-broker is granted the snapshot API and nothing else, so anything it declared to say - // or hear on the bus would be granted nothing — refused here rather than silently dropped. - // - // **But its tools bundle may say what it declares** (novox/hq ADR 0357): the node's runtime carries - // the bundle and is granted the events the module emits, under the module's name, as for any module it - // carries — the bus's account is not who publishes them. So emits are refused only for a bus module - // with no bundle to say them. - if _, account := m.OwnSecrets["broker"]; account && m.ClaimsSeat(BrokerSeat) { - var said []string - if len(m.EmitsAll()) > 0 && !m.shipsABundle() { - said = append(said, "emits") - } - for _, f := range []struct { - name string - n int - }{{"consumes", len(m.Consumes)}, {"uses", len(m.Uses)}, {"invokes", len(m.Invokes)}, - {"state", len(m.State)}, {"logs", len(m.Logs)}, {"reads", len(m.Reads)}, - {"streams", len(m.Streams)}} { - if f.n > 0 { - said = append(said, f.name) - } - } - if len(said) > 0 { - problems = append(problems, fmt.Sprintf( - "%s holds %s and declares a bus account, which is granted the bus's snapshot API and nothing "+ - "else (novox/hq ADR 0235); its %s would be granted nothing", m.Module, BrokerSeat, strings.Join(said, ", "))) - } - } - for _, r := range m.Requires { - if r == "amqp" { - problems = append(problems, fmt.Sprintf( - "%s requires %q, which is not a provision: a module reaches the mesh's bus through "+ - "the sdk, and depends on the mesh-broker seat, not on a protocol (novox/hq ADR 0131)", - m.Module, r)) - } - } - if m.Instances != "" && m.Instances != InstancesInterchangeable { - problems = append(problems, fmt.Sprintf( - "%s says its instances are %q; the one word is %q, for a module that is the same on every machine", - m.Module, m.Instances, InstancesInterchangeable)) - } - for _, offer := range m.Provides { - p := offer.Name - if !name.MatchString(p) { - problems = append(problems, fmt.Sprintf("%q is not a usable name to provide", p)) - } - // A provision's credential is a provision of its own, `secret-` (novox/hq ADR 0570 clause 6, the - // lead 2026-10-29): no module provides a name of that form, so the two keys never meet. - if strings.HasPrefix(p, CredentialProvisionPrefix) { - problems = append(problems, fmt.Sprintf("%s provides %q; a name starting %q is the mesh's for a provision's "+ - "credential, kept by the vault (novox/hq ADR 0570)", m.Module, p, CredentialProvisionPrefix)) - } - // A bound says what keeps the name, so the refusal it causes can say it (ADR 0225); and it - // leaves room for the shortest identity the mesh makes, or it would refuse every consumer. - if id := offer.Identity; id != nil && !id.None { - if strings.TrimSpace(id.In) == "" { - problems = append(problems, fmt.Sprintf( - "%s bounds the identities of %s's consumers without saying what keeps them: `in`", - m.Module, p)) - } - if shortest := len(IdentityPrefix) + 3; id.Max < 0 || id.Max > 0 && id.Max < shortest { - problems = append(problems, fmt.Sprintf( - "%s bounds %s's consumers' identities at %d characters, and the shortest the mesh "+ - "makes is %d", m.Module, p, id.Max, shortest)) - } - } - if c := offer.Credential; c != nil && c.IssuedBy == IssuedOutside { - // Only a person can give it (novox/hq ADR 0337 §1, issue 164): the software holds a value the mesh can - // neither make nor fetch, so the offer names nothing that would answer it — no own secret, no tool. - switch { - case c.Own != "": - problems = append(problems, fmt.Sprintf( - "%s provides %q with a credential issued outside the mesh and names its own secret %q too; a "+ - "person gives the value with `secret accept`, so the offer names no own secret (ADR 0337)", - m.Module, p, c.Own)) - case c.Tool != "": - problems = append(problems, fmt.Sprintf( - "%s provides %q with a credential issued outside the mesh and names the tool %q; only a "+ - "person gives it, so the offer names no tool (ADR 0337)", m.Module, p, c.Tool)) - } - } else if c := offer.Credential; c != nil && (c.IssuedBy != "" || c.Tool != "") { - // The software issues it (novox/hq ADR 0305): named by the tool that answers it, never by an own - // secret, which would be a second copy for the mesh to keep in step with the software's. - switch { - case c.IssuedBy != IssuedBySoftware: - problems = append(problems, fmt.Sprintf( - "%s provides %q with a credential issued by %q; the issuers an offer may name are %q and %q "+ - "(ADR 0305, ADR 0337)", m.Module, p, c.IssuedBy, IssuedBySoftware, IssuedOutside)) - case c.Own != "": - problems = append(problems, fmt.Sprintf( - "%s provides %q with a credential its software issues and names its own secret %q too; the "+ - "software's value is the one, so the offer names no own secret (ADR 0305)", m.Module, p, c.Own)) - case strings.TrimSpace(c.Tool) == "": - problems = append(problems, fmt.Sprintf( - "%s provides %q with a credential its software issues and names no tool that answers it: "+ - "\"tool\": \"\" (ADR 0305)", m.Module, p)) - } - } else if offer.Credential != nil { - own, declared := m.OwnSecrets[offer.Credential.Own] - switch { - case offer.Credential.Own == "" && offer.Credential.Form != "": - // The form alone, pair or none (CredentialFormProblems judges it). - case offer.Credential.Own == "": - problems = append(problems, fmt.Sprintf( - "%s provides %q with a credential that names no own secret", m.Module, p)) - case !declared: - problems = append(problems, fmt.Sprintf( - "%s provides %q with its own secret %q as the credential, and declares no such secret", - m.Module, p, offer.Credential.Own)) - case own.Taken == "": - problems = append(problems, fmt.Sprintf( - "%s provides %q with its own secret %q as the credential every consumer receives, so "+ - "the secret must say how the module takes it: \"taken\": \"at-start\" or \"applied\" (ADR 0158)", - m.Module, p, offer.Credential.Own)) - } - } - if instead, generic := engineGeneric[p]; generic { - // A consumer is written against an engine, not a role (novox/hq ADR 0027). Providing - // the role means a requirement for it matches any engine, resolves as satisfied, and - // fails on the first query — with nothing pointing back at the match. - problems = append(problems, fmt.Sprintf( - "%s provides %q, which hides which engine it is: a requirement for %q would match "+ - "any of them and fail on the first query. Name the engine — %s", - m.Module, p, p, instead)) - } - if s := offer.At(); s != ScopeNode && s != ScopeMesh { - // Site scope is meaningful for a claim — one DHCP server per segment — and is not - // yet meaningful for a provision, because nothing knows how to reach "the one at my - // site". Refused rather than silently treated as mesh-wide. - problems = append(problems, fmt.Sprintf( - "%s provides %q at scope %q; a provision is %q or %q", - m.Module, p, s, ScopeNode, ScopeMesh)) - } - switch { - case offer.Reach == "": - case offer.Reach != ReachMachine: - // The one reach a provision has (novox/hq ADR 0208): a provision reached over the private - // network is mesh scope, and the world reaches nothing but a name. - problems = append(problems, fmt.Sprintf( - "%s provides %q with reach %q; a provision's reach is %q or nothing", - m.Module, p, offer.Reach, ReachMachine)) - case offer.At() != ScopeNode: - problems = append(problems, fmt.Sprintf( - "%s provides %q at scope %q with the machine's reach; a provision usable only on its own "+ - "machine is node-scoped (novox/hq ADR 0208)", m.Module, p, offer.At())) - } - if p == m.Module { - // Harmless and worth saying: a module always provides its own name, so writing it - // suggests the author expected it not to. - problems = append(problems, fmt.Sprintf( - "%s provides its own name already; listing it says nothing", m.Module)) - } - } - for _, r := range m.Requires { - if !name.MatchString(r) { - problems = append(problems, fmt.Sprintf("%q is not a usable name to require", r)) - } - if r == m.Module { - problems = append(problems, fmt.Sprintf("%s requires itself", m.Module)) - } - } - // What it may call an event, and what it may ask to hear (events.go). Checked here because a - // module whose event names are wrong installs, starts, connects and reacts to nothing, with - // every log line saying it is fine (novox/hq 04-ISSUES/127). - problems = append(problems, EventProblems(m)...) - // And what it may call its state, and whose it may read (state.go, novox/hq ADR 0201). - problems = append(problems, StateProblems(m)...) - // And what it may call its logs, and how large it may ask them to grow (logs.go, novox/hq ADR 0297). - problems = append(problems, LogProblems(m)...) - // And the streams it owns, and who may hear them (streams.go, novox/hq ADR 0363). - problems = append(problems, StreamProblems(m)...) - problems = append(problems, WorkQueueStreamProblems(m)...) - // And the defaults it gives its settings (setting_defaults.go, novox/hq ADR 0262), or, written in the - // config form, the whole declaration of each value (config.go, novox/hq ADR 0348). - if m.DeclaresConfig() { - problems = append(problems, ConfigProblems(m)...) - } else { - problems = append(problems, SettingProblems(m)...) - problems = append(problems, TypedSettingProblems(m)...) - } - // And what it requires optionally, and the files it delivers only while bound (novox/hq ADR 0330). - problems = append(problems, OptionalProblems(m)...) - // And which setting keys each mergeable file takes (setting_takes.go, novox/hq ADR 0326). - problems = append(problems, TakenSettingsProblems(m)...) - wellFormed := true - for _, c := range m.Claims { - if !name.MatchString(c.Name) { - problems = append(problems, fmt.Sprintf("%q is not a usable claim name", c.Name)) - wellFormed = false - } - switch c.At() { - case ScopeNode, ScopeSite, ScopeMesh: - default: - problems = append(problems, fmt.Sprintf( - "%s claims %s at scope %q; a claim is held per node, per site or per mesh", - m.Module, c.Name, c.Scope)) - wellFormed = false - } - } - // Against the seats the mesh defines (novox/hq ADR 0110), once every claim is at least a name - // and a scope — a malformed claim is refused for that, not a second time for being unknown. - if wellFormed { - problems = append(problems, claimProblems(m)...) - } - // What one manifest can be judged on: a declaration's shape, its scope, and the reserved - // prefix. Whether a seat anybody names exists, and whether a holder answers for it, are - // facts about the catalogue and are checked at registration (CatalogueProblems). - problems = append(problems, declaredSeatProblems(m)...) - problems = append(problems, RunsAsProblems(m)...) - problems = append(problems, ProcessBusProblems(m)...) - problems = append(problems, seatEventProblems(m)...) - problems = append(problems, ReadsHolderProblems(m)...) - problems = append(problems, FilesHolderProblems(m)...) - if m.Computed != "" && len(m.Resources) > 0 { - // One or the other. A module that both ships files and has them computed would leave - // nobody able to say where a given file came from. - problems = append(problems, fmt.Sprintf( - "%s has resources of its own and says they are computed by %q; it is one or the other", - m.Module, m.Computed)) - } - for to, values := range m.Contributes { - if !name.MatchString(to) { - problems = append(problems, fmt.Sprintf("%q is not a usable name to contribute to", to)) - } - if len(values) == 0 { - // An empty contribution is either a mistake or a requirement written the long way - // round, and both are better said plainly. - problems = append(problems, fmt.Sprintf( - "%s contributes nothing to %q; if it only needs one, require it", m.Module, to)) - } - } - for to, locals := range m.ContributesMany { - if !name.MatchString(to) { - problems = append(problems, fmt.Sprintf("%q is not a usable name to contribute to", to)) - } - if len(locals) == 0 { - problems = append(problems, fmt.Sprintf( - "%s contributes nothing to %q; if it only needs one, require it", m.Module, to)) - } - for local, values := range locals { - if !name.MatchString(local) { - problems = append(problems, fmt.Sprintf( - "%s contributes to %q under %q, which is not a usable name", m.Module, to, local)) - } - if len(values) == 0 { - problems = append(problems, fmt.Sprintf( - "%s contributes nothing to %q under %q", m.Module, to, local)) - } - } - } - problems = append(problems, m.Build.problems(m.Module)...) - // A file's text in a template file of the module's repository (novox/hq ADR 0349 §2): only a file, never beside - // content, only under files/, and packed by the module's build. - problems = append(problems, templateProblems(m)...) - problems = append(problems, movedProblems(m)...) - problems = append(problems, collectionProblems(m)...) - problems = append(problems, undeliveredBundles(m)...) - // **What provides the artifact store cannot be delivered through it** (novox/hq 04-ISSUES/029). - // - // 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. - // - // It is the question the foundation record asks of every candidate — can it grant itself the - // thing it provides? The store cannot create its own database, the broker cannot create its - // own virtual host, and a registry cannot grant itself a repository. Such a module names its - // image, exactly as the bundle names the three a first node starts from. - // - // Refused here because the alternative is a build that never returns, on a mesh new enough - // that nobody is watching it yet. - if m.Build != nil && len(m.Bundles) > 0 { - // The output of a build, written beside the build that produces it (ADR 0175). A resource - // naming a digest beside an `artifact` would be the same mistake, and is caught the same way: - // what the mesh derives, a repository does not state. - problems = append(problems, fmt.Sprintf( - "%s writes `bundles` beside its build. The mesh derives that from what the build "+ - "produced; a manifest states `build.artifacts` and nothing about what came out", - m.Module)) - } - if m.Build != nil && len(m.Build.Artifacts) > 0 { - for _, o := range m.Offers() { - if o != ArtifactStoreProvision { - continue - } - problems = append(problems, fmt.Sprintf( - "%s provides %q and also builds %d artifact(s), which cannot both be true: "+ - "building publishes to the artifact store, so this asks the mesh to put an "+ - "artifact into the thing that artifact is needed to create. A module that "+ - "provides the store names its image instead. One that wants more beside it "+ - "— an interface, a tool server — is a second module, mirrored in the "+ - "ordinary way once this one is running", - m.Module, ArtifactStoreProvision, len(m.Build.Artifacts))) - } - } - for to := range m.Serves { - var offered bool - for _, o := range m.Offers() { - if o == to { - offered = true - } - } - if !offered { - problems = append(problems, fmt.Sprintf( - "%s serves %q to whoever requires it, and does not provide it", m.Module, to)) - } - } - // A served value may be derived for the consumer it is served to (novox/hq ADR 0202). Read - // here, where the definition is, rather than when somebody first requires it: a rule that - // would be refused at the first consumer is wrong from the moment it is written. - problems = append(problems, CheckServes(m)...) - for to, where := range m.Binds { - if !placedOrAbsolute(where) { - problems = append(problems, fmt.Sprintf( - "%s binds %q at %q, which is neither an absolute path nor a placed one", m.Module, to, where)) - } - var wanted bool - for _, w := range m.Wants() { - if w == to { - wanted = true - } - } - if !wanted { - // Being told about something you never asked for would write a file describing a - // machine this one has no business talking to. - problems = append(problems, fmt.Sprintf( - "%s binds %q and does not require it", m.Module, to)) - } - } - if f := m.Filtering; f != nil && strings.TrimSpace(f.Into) == "" { - problems = append(problems, fmt.Sprintf( - "%s asks for the computed rule set and does not say where to put it", m.Module)) - } - for _, l := range m.Listens { - if l.Port < 1 || l.Port > 65535 { - problems = append(problems, fmt.Sprintf( - "%s listens on port %d, which is not a port", m.Module, l.Port)) - } - switch l.From { - case FromMesh, FromEverywhere, FromMachine, FromLocalNetwork: - case "": - // The fault this field exists to prevent. A rule with no source is open, and a - // manifest that omitted it would read as a restriction and be none. - problems = append(problems, fmt.Sprintf( - "%s listens on %d and does not say from where; it is %q, %q, %q or %q", - m.Module, l.Port, FromMesh, FromEverywhere, FromMachine, FromLocalNetwork)) - default: - problems = append(problems, fmt.Sprintf( - "%s listens on %d from %q; it is %q, %q, %q or %q", - m.Module, l.Port, l.From, FromMesh, FromEverywhere, FromMachine, FromLocalNetwork)) - } - if p := l.At(); p != "tcp" && p != "udp" { - problems = append(problems, fmt.Sprintf( - "%s listens on %d over %q, which is tcp or udp", m.Module, l.Port, p)) - } - } - problems = append(problems, invokeProblems(m)...) - problems = append(problems, waitProblems(m)...) - problems = append(problems, replacesProblems(m)...) - problems = append(problems, UnitSettingProblems(m)...) - problems = append(problems, OwnerSettingProblems(m)...) - problems = append(problems, GroupSettingProblems(m)...) - problems = append(problems, endpointNameProblems(m)...) - problems = append(problems, RouteProblems(m)...) - for _, port := range m.Guards { - if port < 1 || port > 65535 { - problems = append(problems, fmt.Sprintf( - "%s guards port %d, which is not a port", m.Module, port)) - } - } - if c := m.Certificate; c != nil { - if !strings.HasPrefix(c.Into, "/") { - problems = append(problems, fmt.Sprintf( - "%s wants its certificate at %q, which is not an absolute path", m.Module, c.Into)) - } - if c.Authority != "" && !strings.HasPrefix(c.Authority, "/") { - problems = append(problems, fmt.Sprintf( - "%s wants the authority at %q, which is not an absolute path", - m.Module, c.Authority)) - } - } - // **A module may not declare an action, and this is where it is said** (novox/hq ADR 0005). - // - // The host already refuses one, correctly and for the right reason: the link may not carry a - // command to run, and that bound is what limits a compromised control plane to shapes it - // cannot turn into arbitrary code. But a module's resources reach a machine over the link, so - // a manifest carrying an action was accepted here, stored, resolved, planned and pushed — and - // refused on the machine, in the host's log, with nothing connecting it back to the manifest - // that caused it. - // - // That is the same failure as the network shape earlier today: the refusal was right, arrived - // far from its cause, and nobody was reading the log. A rule enforced only at the far end is - // enforced; it is just not usable. - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) != "action" { - continue - } - problems = append(problems, fmt.Sprintf( - "%s declares %v, which is an action, and a module may not: the link may not carry a "+ - "command to run (novox/hq ADR 0005). A module that needs something done ships a "+ - "program that reads what the mesh delivered and reconciles", - m.Module, r["id"])) - } - // **What a process replaces is something the module no longer declares** (novox/hq issue 213). - // The host keeps it running until the process is, then removes it: so it is named by the id the - // module used to give it, it is never a resource the module still declares — that would be - // applied and removed by one declaration — and only a process that stays up has anything to - // hand over to. Said here, near the author, as the host would refuse it far away. - ids := map[string]bool{} - for _, r := range m.Resources { - ids[fmt.Sprint(r["id"])] = true - } - for _, r := range m.Resources { - raw, present := r["replaces"] - if !present { - continue - } - if fmt.Sprint(r["type"]) != "process" { - problems = append(problems, fmt.Sprintf( - "%s: %v says what it replaces, and only a process does", m.Module, r["id"])) - continue - } - if once, _ := r["run-once"].(bool); once || r["schedule"] != nil { - problems = append(problems, fmt.Sprintf( - "%s: %v replaces something and runs once or on a schedule — only a process that stays "+ - "up is there a moment later to hand over to", m.Module, r["id"])) - } - list, ok := raw.([]any) - if !ok { - problems = append(problems, fmt.Sprintf( - "%s: %v replaces %v; replaces is a list of the ids this module no longer declares", - m.Module, r["id"], raw)) - continue - } - for _, item := range list { - id, ok := item.(string) - switch { - case !ok || strings.TrimSpace(id) == "": - problems = append(problems, fmt.Sprintf( - "%s: %v replaces %v, which is not an id", m.Module, r["id"], item)) - case strings.Contains(id, "."): - problems = append(problems, fmt.Sprintf( - "%s: %v replaces %q; a process replaces only a resource of its own module, named "+ - "by its own id", m.Module, r["id"], id)) - case ids[id]: - problems = append(problems, fmt.Sprintf( - "%s: %v replaces %q, which this module still declares", m.Module, r["id"], id)) - } - } - } - // **A module that prepares its state must have code the mesh can run** (novox/hq ADR 0135). The - // preparation is the module's own program in its preparation mode, so it is derived from the - // resource that runs that program — and a module declaring none has asked for something the mesh - // cannot compose. Said here, where the manifest is read, rather than by a declaration that - // quietly prepares nothing. - if m.Prepares && preparationTarget(m) == "" { - problems = append(problems, fmt.Sprintf( - "%s says it prepares its state, and declares no container or process running an artifact it built — "+ - "the preparation is this module's own program, so there has to be one for the mesh to "+ - "run it in", m.Module)) - } - // **A run-once container is a step the host runs to completion** (novox/hq ADR 0052). It is a - // boolean modifier on the container shape — the host runs the container, requires it to exit 0, - // and starts whatever the declaration places after it only once it has. A value that is not a - // boolean is refused here rather than only on the machine, for the same near-versus-far reason - // the action ban above records. A run-once step may name what it reads under restart-on: for a - // step the word means *run again* when one of those changed, which is how a fact fetched from a - // provider is fetched again when the provider moved (novox/hq ADR 0099). - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) != "container" { - continue - } - var runOnce bool - if raw, present := r["run-once"]; present { - once, ok := raw.(bool) - if !ok { - problems = append(problems, fmt.Sprintf( - "%s declares run-once on %v as a %T; run-once is true or false", - m.Module, r["id"], raw)) - } else { - runOnce = once - } - } - // **A scheduled container runs on a recurring cadence** (novox/hq ADR 0053), the recurring - // twin of run-once. The control plane carries the field to the host unchanged (the resolver - // copies every key of a resource, so `schedule` reaches the rendered declaration on its own); - // what belongs here is refusing, near its author, a value the host would only refuse far away. - // Three things: a value that is not a string, a string that is not a valid cron, and the pair - // run-once + schedule — a container runs once, or on a cadence, or stays up, never two. - if raw, present := r["schedule"]; present { - cron, ok := raw.(string) - switch { - case !ok: - problems = append(problems, fmt.Sprintf( - "%s declares schedule on %v as a %T; a schedule is a five-field cron string", - m.Module, r["id"], raw)) - default: - if err := validateCron(cron); err != nil { - problems = append(problems, fmt.Sprintf( - "%s declares schedule %q on %v, which is not a valid cron: %v", - m.Module, cron, r["id"], err)) - } - if runOnce { - problems = append(problems, fmt.Sprintf( - "%s declares %v as both run-once and schedule; a container runs once and "+ - "gates, or on a cadence, or stays up — never two (novox/hq ADR 0053)", - m.Module, r["id"])) - } - } - } - // **A scheduled step may hold this module's own containers still while it runs** - // (novox/hq ADR 0189). What the host judges is the declaration it receives — whether each - // id is a container placed on that machine; what belongs here is what only the definition - // shows: that the ids are this module's, that they are containers, and that the step is - // scheduled. A module naming a neighbour's container would be a module that can stop the - // mesh, and the manifest is where that is visible. - problems = append(problems, whileStoppedProblems(m, r, hasSchedule(r))...) - } - for name, own := range m.OwnSecrets { - // **A family is issued outside the mesh, and lands one file per member** (novox/hq ADR 0283): the mesh - // never makes a member, so a family the mesh may make would be one nothing ever fills. - if IsFamily(name) { - if !memberRest.MatchString(strings.TrimSuffix(FamilyPrefix(name), "-")) { - problems = append(problems, fmt.Sprintf( - "%s declares the secret family %q, whose prefix is not a name", m.Module, name)) - } - if own.IssuedBy != IssuedOutside { - problems = append(problems, fmt.Sprintf( - "%s declares the secret family %q without \"issued-by\": %q; the mesh never makes a "+ - "member of a family, so only a party outside the mesh can fill one (novox/hq ADR 0283)", - m.Module, name, IssuedOutside)) - } - if strings.Count(own.Path, "*") != 1 { - problems = append(problems, fmt.Sprintf( - "%s keeps the secret family %q at %q, which does not hold exactly one *: each member lands "+ - "where the * is replaced by its name (novox/hq ADR 0283)", m.Module, name, own.Path)) - } - for other := range m.OwnSecrets { - if other != name && !IsFamily(other) { - if rest := strings.TrimPrefix(other, FamilyPrefix(name)); rest != other && memberRest.MatchString(rest) { - problems = append(problems, fmt.Sprintf( - "%s declares the secret %q, which is also a member of its family %q — a name names one", - m.Module, other, name)) - } - } - } - } else if strings.Contains(name, "*") { - problems = append(problems, fmt.Sprintf( - "%s declares the secret %q: a * only ends a family's name, as \"-*\"", m.Module, name)) - } - if !placedOrAbsolute(own.Path) { - problems = append(problems, fmt.Sprintf( - "%s needs %q at %q, which is neither an absolute path nor a placed one", m.Module, name, own.Path)) - } - if name == "" { - problems = append(problems, m.Module+" needs a secret with no name") - } - if own.Taken != "" && own.Taken != TakenAtStart && own.Taken != TakenApplied { - problems = append(problems, fmt.Sprintf( - "%s says its secret %q is taken %q; a secret is taken %q (read when the module starts) "+ - "or %q (applied by the module's own code to a backend that takes it once)", - m.Module, name, own.Taken, TakenAtStart, TakenApplied)) - } - if own.IssuedBy != "" && own.IssuedBy != IssuedOutside { - problems = append(problems, fmt.Sprintf( - "%s says its secret %q is issued by %q; a secret says %q when a party outside the mesh "+ - "issues it — a vendor's key, a bot's token, a licence — and says nothing when the mesh "+ - "may make it (novox/hq ADR 0228)", - m.Module, name, own.IssuedBy, IssuedOutside)) - } - problems = append(problems, ownSecretPromptProblems(m, name, own.Prompt, IsFamily(name))...) - problems = append(problems, ownSecretOwnerProblems(m, name, own.Owner)...) - } - localOf := map[string]string{} - for _, to := range m.SecretRequirements() { - if _, plain := m.Secrets[to]; plain { - if _, also := m.SecretsMany[to]; also { - problems = append(problems, fmt.Sprintf( - "%s keeps the credential for %q both as one file and as several", m.Module, to)) - } - } - for _, f := range m.SecretFiles(to) { - if !placedOrAbsolute(f.Path) { - problems = append(problems, fmt.Sprintf( - "%s keeps the credential for %q at %q, which is neither an absolute path nor a placed one", - m.Module, SecretLocal(to, f.Local), f.Path)) - } - if f.Local != "" && !name.MatchString(f.Local) { - problems = append(problems, fmt.Sprintf( - "%s keeps a credential for %q under %q, which is not a usable name", - m.Module, to, f.Local)) - } - // A local name is what `${secret:}` says, so it may not be another requirement's - // name, another requirement's local name, or one of the module's own secrets — the - // file would hold the wrong credential while every check passed. - if f.Local != "" { - if other, taken := localOf[f.Local]; taken && other != to { - problems = append(problems, fmt.Sprintf( - "%s keeps credentials for %q and %q both under %q — a local name names one", - m.Module, other, to, f.Local)) - } - localOf[f.Local] = to - if _, own := m.OwnSecrets[f.Local]; own { - problems = append(problems, fmt.Sprintf( - "%s keeps a credential for %q under %q, which is also one of its own secrets", - m.Module, to, f.Local)) - } - for _, w := range m.Wants() { - if w == f.Local { - problems = append(problems, fmt.Sprintf( - "%s keeps a credential for %q under %q, which is also something it requires", - m.Module, to, f.Local)) - } - } - } - } - if len(m.SecretsMany[to]) == 0 && m.Secrets[to] == "" { - if _, many := m.SecretsMany[to]; many { - problems = append(problems, fmt.Sprintf( - "%s keeps the credential for %q as several files and names none", m.Module, to)) - } - } - var wanted bool - for _, w := range m.Wants() { - if w == to { - wanted = true - } - } - if !wanted { - problems = append(problems, fmt.Sprintf( - "%s wants the credential for %q and does not require it", m.Module, to)) - } - } - for to, where := range m.Grants { - if !placedOrAbsolute(where) { - problems = append(problems, fmt.Sprintf( - "%s grants %q into %q, which is neither an absolute path nor a placed one", m.Module, to, where)) - } - var offered bool - for _, o := range m.Offers() { - if o == to { - offered = true - } - } - if !offered { - problems = append(problems, fmt.Sprintf( - "%s grants %q to its consumers and does not provide it", m.Module, to)) - } - } - if m.Keeps != "" && !strings.HasPrefix(m.Keeps, "/") { - problems = append(problems, fmt.Sprintf( - "%s keeps the operator-sealed secrets at %q, which is not an absolute path", m.Module, m.Keeps)) - } - for to, where := range m.Receives { - if !name.MatchString(to) { - problems = append(problems, fmt.Sprintf("%q is not a usable name to receive", to)) - } - if !placedOrAbsolute(where) { - problems = append(problems, fmt.Sprintf( - "%s receives %q at %q, which is neither an absolute path nor a placed one", m.Module, to, where)) - } - var offered bool - for _, o := range m.Offers() { - if o == to { - offered = true - } - } - if !offered { - // Receiving contributions to something you do not provide would create a file nobody - // ever writes to, on a machine where nothing asked for it. - problems = append(problems, fmt.Sprintf( - "%s receives contributions to %q and does not provide it", m.Module, to)) - } - } - for _, a := range m.Accesses { - if a.ID == "" && a.Path == "" { - problems = append(problems, fmt.Sprintf( - "%s declares an access with neither an id nor a path — an id, which the assignment places", - m.Module)) - } - if a.ID != "" && !accessRef.MatchString("${access:"+a.ID+"}") { - problems = append(problems, fmt.Sprintf( - "%s accesses %q; an access id is lowercase letters, digits and dashes", m.Module, a.ID)) - } - if a.Path != "" && !strings.HasPrefix(a.Path, "/") { - problems = append(problems, fmt.Sprintf( - "%s accesses %q, which is not an absolute path", m.Module, a.Path)) - } - switch a.At() { - case AccessRead, AccessReadWrite: - default: - problems = append(problems, fmt.Sprintf( - "%s accesses %q at mode %q; an access is %q or %q", - m.Module, a.Path, a.Mode, AccessRead, AccessReadWrite)) - } - } - - // **A container may not mount a path the module never declared** (novox/hq 04-ISSUES/026, - // ADR 0091). A bind mount whose source does not exist is created by the container runtime, as - // root, with whatever mode it picks — so `owner` and `mode` never reach the directory holding - // the module's data, and the rule that keeps data when a module goes away (ADR 0030) does not - // cover it, because the mesh has never heard of it. - // - // Three things declare a path, and they are the three kinds of thing a path can be: - // - the module's own: a directory or file resource, or where a secret, a grant or a - // contribution lands — created and owned by the mesh for this module; - // - the operator's: an `accesses` entry (ADR 0051) — pre-existing, shared, granted for use; - // - the machine's: a facility a declared capability grants, such as the container runtime's - // socket. It exists, the machine owns it, and declaring it as the module's own directory - // would be a lie the host would act on — which is why the first version of this check was - // withdrawn: it refused the builder. - // - // Checked here rather than on the machine because the machine cannot tell the difference: by - // the time it sees the mount it is being asked to create the directory, which it can do. - problems = append(problems, m.undeclaredMounts()...) - problems = append(problems, m.unknownDirRefs()...) - problems = append(problems, m.unknownAccessRefs()...) - problems = append(problems, m.resolvedPathProblems()...) - problems = append(problems, m.jailProblems()...) - // What a module adds to the account's environment and to the login shell, and the holder's - // placeholders for them (novox/hq ADR 0203, ADR 0204) — here, so the catalogue check refuses - // them in the words registration does. - problems = append(problems, m.environmentProblems()...) - problems = append(problems, m.shellProblems()...) - problems = append(problems, m.contributionPlaceholderProblems()...) - problems = append(problems, m.seatContributionProblems()...) - problems = append(problems, m.placementProblems()...) - problems = append(problems, m.dataProblems()...) - problems = append(problems, m.upgradeProblems()...) - problems = append(problems, m.unhealthyProblems()...) - - for i, r := range m.Resources { - id, _ := r["id"].(string) - if id == "" { - problems = append(problems, fmt.Sprintf("resource %d has no id", i)) - } - if _, ok := r["type"].(string); !ok { - problems = append(problems, fmt.Sprintf("resource %q has no type", id)) - } - } - - problems = append(problems, zoneProblems(m)...) - // How each long-running resource is ready (novox/hq ADR 0240 rule 2): said near its author, in the - // words the node-engine would refuse it in far away. - problems = append(problems, healthProblems(m)...) - // And whether a container is busy (novox/hq ADR 0335), likewise. - problems = append(problems, busyProblems(m)...) - if m.DeclaresConfig() { - // Said in the words it was written in: the rules shared with the settings form say ${setting:} - // and takes-settings. - for i, p := range problems { - problems[i] = inConfigWords(p) - } - problems = append(problems, MixedFormProblems(m)...) - } - if len(problems) > 0 { - sort.Strings(problems) - return Manifest{}, fmt.Errorf("this manifest cannot be used:\n - %s", - strings.Join(problems, "\n - ")) - } - return m, nil -} - // Offers is everything this module can satisfy: its own name, and what it provides. func (m Manifest) Offers() []string { out := []string{m.Module} @@ -2776,173 +1900,6 @@ func (m Manifest) MachineSide(port int) (at int, mayAssign bool) { return port, false } -// facilitiesOf is what each capability lets a container mount: paths the machine owns and a module -// is granted the use of by declaring the capability, never by declaring them as its own. -var facilitiesOf = map[string][]string{ - // Both spellings: /var/run is a link to /run on every machine the mesh runs on. - "container-runtime": {"/var/run/docker.sock", "/run/docker.sock"}, - // The virtualisation daemon's socket, for the lab (novox/hq ADR 0172): it raises machines there. - "virtualisation": {"/var/lib/incus/unix.socket"}, -} - -// jailProblems is every jail this module declares that the machine's intrusion prevention would -// refuse (novox/hq ADR 0179). -// -// **Because one bad pattern stops every jail, not its own.** fail2ban expands `` into a named -// capture group, so a pattern naming it twice is a duplicate group name, and the daemon refuses the -// whole configuration and exits — the machine keeps no bans at all, for any jail, including the one -// watching its ssh. Caught live on the control node the day this was built, where a proxy's pattern -// matched two shapes of refusal in one line. A pattern matches one shape; several shapes are several -// patterns, one per line, as fail2ban's own filters are written. -func (m Manifest) jailProblems() []string { - var problems []string - seen := map[string]bool{} - for _, j := range m.Jails { - switch { - case strings.TrimSpace(j.Name) == "": - problems = append(problems, m.Module+" declares a jail with no name") - case seen[j.Name]: - problems = append(problems, m.Module+" declares two jails called "+strconv.Quote(j.Name)) - } - seen[j.Name] = true - if strings.TrimSpace(j.Failregex) == "" { - problems = append(problems, m.Module+"'s jail "+strconv.Quote(j.Name)+" says nothing a failed attempt looks like") - } - for _, line := range strings.Split(j.Failregex, "\n") { - if strings.TrimSpace(line) == "" { - continue - } - if n := strings.Count(line, ""); n > 1 { - problems = append(problems, fmt.Sprintf("%s's jail %s names %d times in one pattern; "+ - "fail2ban reads it as one capture group and refuses the whole configuration, so the machine "+ - "keeps no bans at all — write one pattern per shape, each naming once", - m.Module, strconv.Quote(j.Name), n)) - } - } - } - return problems -} - -// undeclaredMounts is every bind-mount source no declaration covers — see the check above. -func (m Manifest) undeclaredMounts() []string { - declared := map[string]bool{} - claim := func(p string) { - if strings.HasPrefix(p, "/") { - declared[strings.TrimRight(p, "/")] = true - } - } - for _, r := range m.Resources { - switch fmt.Sprint(r["type"]) { - case "directory", "file": - claim(fmt.Sprint(r["path"])) - } - } - for _, own := range m.OwnSecrets { - claim(own.Path) - } - for _, to := range m.SecretRequirements() { - for _, f := range m.SecretFiles(to) { - claim(f.Path) - } - } - for _, where := range m.Receives { - claim(where) - } - for _, where := range m.Grants { - claim(where) - } - for _, where := range m.Binds { - claim(where) - } - for _, a := range m.Accesses { - claim(a.Path) - } - for _, c := range m.Capabilities { - for _, p := range facilitiesOf[c] { - claim(p) - } - } - // The file a filter module's rule set is written to is declared by `filtering.into`: the mesh - // writes it, the module loads it, and the module's runtime may read it back to reload the - // mesh's own table (novox/hq ADR 0170). - if m.Filtering != nil { - claim(m.Filtering.Into) - } - // Under a declared directory is declared: a module that says where its data lives has said so - // for what it puts inside. - covers := func(path string) bool { - for at := path; strings.HasPrefix(at, "/"); { - if declared[at] { - return true - } - cut := strings.LastIndex(at, "/") - if cut <= 0 { - return false - } - at = at[:cut] - } - return false - } - var problems []string - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) != "container" { - continue - } - mounts, _ := r["volumes"].([]any) - for _, v := range mounts { - from, _, _ := strings.Cut(fmt.Sprint(v), ":") - if !strings.HasPrefix(from, "/") || covers(strings.TrimRight(from, "/")) { - continue - } - problems = append(problems, fmt.Sprintf( - "%s mounts %q into %v, and nothing in %s declares it. A bind mount the module did "+ - "not declare is created by the container runtime as root, so the module's own "+ - "owner and mode do not reach the directory that holds its data, and the rule "+ - "that keeps data when a module goes away (novox/hq ADR 0030) does not cover it. "+ - "Declare it: a directory resource if it is the module's, an `accesses` entry if "+ - "it is the operator's, or the capability that grants it if it is the machine's", - m.Module, from, r["id"], m.Module)) - } - } - return problems -} - -// endpointName is what an endpoint may be called: lowercase letters, digits and dashes, starting -// with a letter. The same shape a label has, because both end up in something a person types. -var endpointName = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) - -// endpointNameProblems holds a module's endpoint names to being usable as references (novox/hq ADR -// 0138). -// -// **Unique, because the point of a name is that it identifies one thing.** Two endpoints called the -// same would make an assignment that configures one silently configure whichever the mesh read last -// — the shape of fault this repository keeps finding, where a declaration appears to say something -// and says something else. -func endpointNameProblems(m Manifest) []string { - var problems []string - seen := map[string]int{} - for _, l := range m.Listens { - name := strings.TrimSpace(l.Name) - if name == "" { - continue - } - if !endpointName.MatchString(name) { - problems = append(problems, fmt.Sprintf( - "%s calls the endpoint on port %d %q; a name is lowercase letters, digits and "+ - "dashes, starting with a letter", m.Module, l.Port, l.Name)) - continue - } - if before, already := seen[name]; already { - problems = append(problems, fmt.Sprintf( - "%s calls both port %d and port %d %q, so anything naming that endpoint could mean "+ - "either", m.Module, before, l.Port, name)) - continue - } - seen[name] = l.Port - } - return problems -} - // EndpointPort is the port of the endpoint a module calls this, and whether it has one. func EndpointPort(m Manifest, name string) (int, bool) { want := strings.TrimSpace(name) @@ -2961,107 +1918,6 @@ func EndpointPort(m Manifest, name string) (int, bool) { // make it this command, never a script. const MaxReplaced = 80 -// replacesProblems judges what a module says its tools replace (novox/hq ADR 0245): each key a tool it -// declares, each entry a command line of one line, short, and said once. -func replacesProblems(m Manifest) []string { - var problems []string - tools := map[string]bool{} - for _, t := range m.Tools { - tools[t] = true - } - keys := make([]string, 0, len(m.Replaces)) - for k := range m.Replaces { - keys = append(keys, k) - } - sort.Strings(keys) - for _, tool := range keys { - if !tools[tool] { - problems = append(problems, fmt.Sprintf("%s says what %q replaces, and declares no such tool: "+ - "a module says it for a tool in its `tools`; a seat's verb says it in the seat's definition "+ - "(novox/hq ADR 0245)", m.Module, tool)) - continue - } - if len(m.Replaces[tool]) == 0 { - problems = append(problems, fmt.Sprintf("%s says %s replaces nothing: leave it out", m.Module, tool)) - } - seen := map[string]bool{} - for _, c := range m.Replaces[tool] { - c = strings.TrimSpace(c) - switch { - case c == "": - problems = append(problems, fmt.Sprintf("%s: %s replaces an empty command", m.Module, tool)) - case strings.ContainsAny(c, "\n\r"): - problems = append(problems, fmt.Sprintf("%s: %s replaces %q, which is more than one line", m.Module, tool, c)) - case len(c) > MaxReplaced: - problems = append(problems, fmt.Sprintf("%s: %s replaces %q, longer than %d characters: "+ - "the command's name and the words that make it this command", m.Module, tool, c, MaxReplaced)) - case seen[c]: - problems = append(problems, fmt.Sprintf("%s: %s replaces %q twice", m.Module, tool, c)) - } - seen[c] = true - } - } - return problems -} - -// invokeProblems judges what a module says it calls (novox/hq ADR 0152). -// -// Refused here, in the manifest's words, rather than at the next composition of the bus's user -// list — where a bad entry would stop the whole file being written for everybody, as a person's -// malformed grant would have (operator.go). An entry that names a module and no tool is the one -// mistake worth naming: `shop` reads like a grant to a module's tools and would be a grant to nothing. -func invokeProblems(m Manifest) []string { - var problems []string - for _, t := range m.Invokes { - if !invokeNameValid(t) { - problems = append(problems, fmt.Sprintf( - "%s invokes %q, which does not name a tool: a module invokes ., or "+ - "* for every tool on the mesh (novox/hq ADR 0152)", m.Module, t)) - } - } - return problems -} - -// waitToken is one name of an event family's subject: never a wildcard, never a dot, never empty. -var waitToken = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`) - -// waitProblems judges the event families a module says a session may wait on (novox/hq ADR 0367 §2, §6): -// the tool runner's alone, each named whole — emitter and event, with no wildcard — and each once. Refused -// here, in the manifest's words, rather than at the composition of the bus's users. -func waitProblems(m Manifest) []string { - if len(m.Waits) == 0 { - return nil - } - if m.Module != RuntimeModule { - return []string{fmt.Sprintf("%s says what a session waits on; only %s, the tool runner's module, "+ - "declares waits (novox/hq ADR 0367 §2)", m.Module, RuntimeModule)} - } - var problems []string - seen := map[string]bool{} - for _, w := range m.Waits { - if strings.ContainsAny(w, "*>") { - problems = append(problems, fmt.Sprintf("%s waits on %q: a family is named one by one, never a "+ - "wildcard (novox/hq ADR 0367 §2)", m.Module, w)) - continue - } - tokens := strings.Split(w, ".") - whole := len(tokens) >= 2 && name.MatchString(tokens[0]) - for _, t := range tokens[1:] { - whole = whole && waitToken.MatchString(t) - } - if !whole { - problems = append(problems, fmt.Sprintf("%s waits on %q, which does not name an emitter and an "+ - "event: a family is ., as consumes names one", m.Module, w)) - continue - } - if seen[w] { - problems = append(problems, fmt.Sprintf("%s waits on %q twice", m.Module, w)) - } - seen[w] = true - } - return problems -} - // How a module takes one of its own secrets (ADR 0114): read from the file when it starts, or // applied by its own code to a backend that takes it once. const ( @@ -3103,74 +1959,6 @@ func (s OwnSecret) MeshMayMake() bool { // desk's prompt reads at a glance. const PromptMax = 120 -// promptPlaceholder is a `{word}` token in a prompt's line; the only one a prompt may hold is `{member}`, a -// family's member suffix (novox/hq issue 789). -var promptPlaceholder = regexp.MustCompile(`\{[^{}]*\}`) - -// ownSecretPromptProblems is what is wrong with an own secret's declared prompt, in the module check's words: -// empty is fine (no prompt is declared, and the desk asks by name alone as it does today); otherwise one line, -// at most PromptMax characters — counting what a person reads, not its bytes — holding only the `{member}` -// placeholder, and only where the secret is a family: a single secret's prompt has no member to fill, so -// `{member}` there would stay in the prompt literally (novox/hq issue 789). -func ownSecretPromptProblems(m Manifest, name, prompt string, family bool) []string { - if prompt == "" { - return nil - } - var problems []string - if strings.ContainsAny(prompt, "\n\r") { - problems = append(problems, fmt.Sprintf( - "%s names its secret %q with a prompt that holds more than one line; a prompt is one line (novox/hq issue 789)", - m.Module, name)) - } - if n := utf8.RuneCountInString(prompt); n > PromptMax { - problems = append(problems, fmt.Sprintf( - "%s names its secret %q with a prompt %d characters long; a prompt is at most %d characters", - m.Module, name, n, PromptMax)) - } - for _, ph := range promptPlaceholder.FindAllString(prompt, -1) { - if ph != "{member}" { - problems = append(problems, fmt.Sprintf( - "%s names its secret %q with a prompt that holds %s; a prompt holds only the {member} placeholder, "+ - "filled with a family's member suffix (novox/hq issue 789)", - m.Module, name, ph)) - } else if !family { - problems = append(problems, fmt.Sprintf( - "%s names its secret %q with a prompt that holds {member}; {member} is replaced only for a "+ - "family's member (novox/hq issue 789)", - m.Module, name)) - } - } - return problems -} - -// ownSecretOwnerProblems is what is wrong with an own secret's declared owner (novox/hq ADR 0436 §1): naming -// an account the module makes — a `user` resource of that name — never root, which a secret file owned by it -// would make readable by anything, and never the operator account's placeholder (`${machine:account}`), which -// the mesh fills per machine and a manifest cannot say safely (novox/hq ADR 0436, settled by the lead). -func ownSecretOwnerProblems(m Manifest, name, owner string) []string { - if owner == "" { - return nil - } - if owner == "root" { - return []string{fmt.Sprintf( - "%s names %q as the owner of its secret %q; an own secret's owner is an account the module "+ - "makes, never root (novox/hq ADR 0436 §1)", m.Module, owner, name)} - } - if owner == "${machine:account}" { - return []string{fmt.Sprintf( - "%s names %q as the owner of its secret %q; an own secret's owner is an account the module "+ - "makes, never the operator account (novox/hq ADR 0436 §1)", m.Module, owner, name)} - } - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) == "user" && fmt.Sprint(r["name"]) == owner { - return nil - } - } - return []string{fmt.Sprintf( - "%s names %q as the owner of its secret %q, which it does not make: a user resource named %q "+ - "(novox/hq ADR 0436 §1)", m.Module, owner, name, owner)} -} - // OwnSecrets is a module's own secrets by name. On the wire each is a path, or an object naming // the path and how it is taken; written back the way it was read, so a manifest the mesh holds // keeps its bytes. @@ -3311,85 +2099,3 @@ const InstancesInterchangeable = "interchangeable" // WhileStopped is the resource key naming the containers a scheduled step holds still while it // runs (novox/hq ADR 0189). Carried to the host unchanged, like `schedule`. const WhileStopped = "while-stopped" - -// hasSchedule is whether a resource declares a cadence, as a string. -func hasSchedule(r map[string]any) bool { - s, _ := r["schedule"].(string) - return s != "" -} - -// whileStoppedProblems judges one container's maintenance window against its own definition -// (novox/hq ADR 0189). -// -// Three things the manifest is the only place to see: that the step is scheduled (a one-time -// offline job says *before* rather than *instead of* — at apply the host already has a window, -// because the declaration is applied in order and a run-once step gates what follows); that every -// id it names is **this module's own** container; and that it does not name itself. -// -// The host checks the fourth — that the container is actually placed on that machine — because -// that is a fact about the declaration and not about the definition. -func whileStoppedProblems(m Manifest, r map[string]any, scheduled bool) []string { - raw, present := r[WhileStopped] - if !present { - return nil - } - ids, ok := raw.([]any) - if !ok { - return []string{fmt.Sprintf( - "%s declares %s on %v as a %T; it is a list of this module's container ids", - m.Module, WhileStopped, r["id"], raw)} - } - var problems []string - if len(ids) > 0 && !scheduled { - problems = append(problems, fmt.Sprintf( - "%s declares %s on %v, which has no schedule. A maintenance window is for a recurring "+ - "step: at apply the mesh already has one, because a run-once step gates what is "+ - "declared after it (novox/hq ADR 0189)", m.Module, WhileStopped, r["id"])) - } - containers := map[string]bool{} - for _, own := range m.Resources { - if fmt.Sprint(own["type"]) == "container" { - containers[fmt.Sprint(own["id"])] = true - } - } - for _, each := range ids { - id, ok := each.(string) - if !ok { - problems = append(problems, fmt.Sprintf( - "%s declares %s on %v naming a %T; each entry is a container's id", - m.Module, WhileStopped, r["id"], each)) - continue - } - if id == fmt.Sprint(r["id"]) { - problems = append(problems, fmt.Sprintf( - "%s declares %s on %v naming itself", m.Module, WhileStopped, r["id"])) - continue - } - if !containers[id] { - problems = append(problems, fmt.Sprintf( - "%s declares %s on %v naming %q, which is not a container this module declares. "+ - "A step may hold still its own module's containers and nobody else's — one "+ - "that could quiesce a neighbour could stop the mesh", - m.Module, WhileStopped, r["id"], id)) - } - } - return problems -} - -// shipsABundle says the module builds a tools bundle, which the node's runtime carries and speaks for. -func (m Manifest) shipsABundle() bool { - // A built manifest says its bundles in Bundles, its build read away (Resolve; issue 774: nats's built manifest was - // refused for the emits its tools bundle says). - if len(m.Bundles) > 0 { - return true - } - if m.Build == nil { - return false - } - for _, a := range m.Build.Artifacts { - if a.Kind == ArtifactBundle { - return true - } - } - return false -} diff --git a/go/catalogue/mounts_test.go b/go/catalogue/mounts_test.go index 1e0a3df..ded7c18 100644 --- a/go/catalogue/mounts_test.go +++ b/go/catalogue/mounts_test.go @@ -22,20 +22,9 @@ func manifestMounting(from string, beside string) []byte { return []byte(`{"module":"store","resources":[` + strings.Replace(res, "%s", from, 1) + `]}`) } -func TestAMountNothingDeclaresIsRefused(t *testing.T) { - _, err := ParseManifest(manifestMounting("/services/store/data", "")) - if err == nil { - t.Fatal("a container mounting a path no resource declares was accepted; the runtime " + - "would create it as root and the module's owner and mode would never apply") - } - if !strings.Contains(err.Error(), "/services/store/data") { - t.Fatalf("refused without naming the path, which leaves the author guessing: %v", err) - } -} - // Declaring it is enough — including declaring the directory above it. func TestAMountUnderADeclaredDirectoryIsAccepted(t *testing.T) { - _, err := ParseManifest(manifestMounting("/services/store/data", + _, err := decodeForTest(manifestMounting("/services/store/data", `{"id":"state","type":"directory","path":"/services/store","mode":"0700"}`)) if err != nil { t.Fatalf("a module that said where its data lives was refused anyway: %v", err) @@ -45,27 +34,13 @@ func TestAMountUnderADeclaredDirectoryIsAccepted(t *testing.T) { // The operator's path, granted for use (ADR 0051), is declared by `accesses`, not by a directory the // module would then own. func TestAMountOfAnAccessedPathIsAccepted(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"store","accesses":[{"path":"/services/media/movies","mode":"read"}],` + + _, err := decodeForTest([]byte(`{"module":"store","accesses":[{"path":"/services/media/movies","mode":"read"}],` + `"resources":[` + strings.Replace(aContainerMounting, "%s", "/services/media/movies", 1) + `]}`)) if err != nil { t.Fatalf("a mount of a path the module declares it accesses was refused: %v", err) } } -// The machine's facility — the container runtime's socket — is granted by the capability that names -// it, and declaring it as the module's own directory would be a lie the host would act on. This is -// the case the first version of this check refused, and was withdrawn for: the builder. -func TestTheRuntimeSocketIsGrantedByTheCapabilityAndNotOtherwise(t *testing.T) { - granted := `{"module":"builder","capabilities":["container-runtime"],"resources":[` + - strings.Replace(aContainerMounting, "%s", "/var/run/docker.sock", 1) + `]}` - if _, err := ParseManifest([]byte(granted)); err != nil { - t.Fatalf("a module with the container-runtime capability may mount its socket: %v", err) - } - if _, err := ParseManifest(manifestMounting("/var/run/docker.sock", "")); err == nil { - t.Fatal("a module mounted the container runtime's socket without declaring the capability, and was accepted") - } -} - // **Every manifest in the catalogue (internal/beside) passes**, so the rule is not one the catalogue // is already breaking. func TestEveryCatalogueManifestDeclaresWhatItMounts(t *testing.T) { @@ -79,7 +54,7 @@ func TestEveryCatalogueManifestDeclaresWhatItMounts(t *testing.T) { // Where a bound fact lands is the mesh's file too, and a container may mount it directly. func TestAMountOfABoundFactIsAccepted(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"store","requires":["model-access"],` + + _, err := decodeForTest([]byte(`{"module":"store","requires":["model-access"],` + `"binds":{"model-access":"/var/lib/store/model.json"},"resources":[` + strings.Replace(aContainerMounting, "%s", "/var/lib/store/model.json", 1) + `]}`)) if err != nil { @@ -90,7 +65,7 @@ func TestAMountOfABoundFactIsAccepted(t *testing.T) { // The file a filter module's rule set is written to is declared by `filtering.into` (novox/hq ADR // 0169): the module's runtime mounts it to reload the mesh's own table, and nothing else declares it. func TestAMountOfTheFilterFileIsDeclaredByFilteringInto(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"nftables","filtering":{"into":"/etc/nftables.conf"},` + + _, err := decodeForTest([]byte(`{"module":"nftables","filtering":{"into":"/etc/nftables.conf"},` + `"resources":[` + strings.Replace(aContainerMounting, "%s", "/etc/nftables.conf", 1) + `]}`)) if err != nil { t.Fatalf("a filter module mounting its own filter file was refused: %v", err) diff --git a/go/catalogue/naming.go b/go/catalogue/naming.go index 7198cd8..1b52013 100644 --- a/go/catalogue/naming.go +++ b/go/catalogue/naming.go @@ -1,17 +1 @@ package catalogue - -// Provision names that say a role where the consumer is coupled to an implementation. -// -// **A consumer's code is written against PostgreSQL or against SQL Server, never against a -// database** (novox/hq ADR 0027). A requirement naming the role matches any engine, resolves as -// satisfied, deploys, and fails on the first query — with nothing connecting the error back to a -// match made elsewhere by something that thought it had done its job. -// -// Refused rather than documented, because the previous naming *was* the documented convention. -// A rule states how it is checked (novox/hq 00-META/how-we-build.md §5). -var engineGeneric = map[string]string{ - "database": "postgres-database, mssql-database, mariadb-database", - "db": "postgres-database, mssql-database, mariadb-database", - "sql": "postgres-database, mssql-database, mariadb-database", - "sql-database": "postgres-database, mssql-database, mariadb-database", -} diff --git a/go/catalogue/naming_test.go b/go/catalogue/naming_test.go index c168ea4..b455f31 100644 --- a/go/catalogue/naming_test.go +++ b/go/catalogue/naming_test.go @@ -1,36 +1,14 @@ package catalogue import ( - "strings" "testing" ) -// A provision names what the consumer is coupled to (novox/hq ADR 0027). -// -// The fault this defends against does not look like a fault: resolution succeeds, the module -// deploys, and the first query fails somewhere else entirely. Every earlier test had exactly one -// provider of each name, so no mismatch was expressible and none was caught. -func TestAModuleMayNotProvideAGenericDatabase(t *testing.T) { - for _, hidden := range []string{"database", "db", "sql", "sql-database"} { - _, err := ParseManifest([]byte(`{"module":"postgres","version":"1", - "provides":[{"name":"` + hidden + `","scope":"mesh"}]}`)) - if err == nil { - t.Fatalf("providing %q was accepted; a requirement for it matches any engine", hidden) - } - for _, want := range []string{hidden, "postgres-database", "first query"} { - if !strings.Contains(err.Error(), want) { - t.Errorf("refusing %q did not mention %q, so nobody learns what to write:\n%v", - hidden, want, err) - } - } - } -} - // The rule is about coupling, not about specificity everywhere. Naming `route` after a particular // proxy would be the same error in the other direction. func TestARoleNameIsFineWhereTheConsumerCannotTellTheDifference(t *testing.T) { for _, role := range []string{"route", "resolver", "artifact-store", "postgres-database"} { - if _, err := ParseManifest([]byte(`{"module":"m","version":"1", + if _, err := decodeForTest([]byte(`{"module":"m","version":"1", "provides":[{"name":"` + role + `","scope":"mesh"}]}`)); err != nil { t.Errorf("providing %q was refused, and it names what a consumer actually gets: %v", role, err) @@ -38,32 +16,9 @@ func TestARoleNameIsFineWhereTheConsumerCannotTellTheDifference(t *testing.T) { } } -// A module may not declare an action, and it is refused where it was written. -// -// The host refuses one arriving over the link, which is the bound the whole security argument -// rests on (novox/hq ADR 0005) and is enforced in the right place. But a module's resources reach -// a machine *over* the link, so a manifest carrying an action used to be accepted here, stored, -// resolved and pushed — and then refused on the machine, in a log, with nothing tying it back to -// the manifest. Enforced at the far end only is enforced and not usable. -func TestAModuleMayNotDeclareAnAction(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"probe","version":"1","resources":[ - {"id":"migrate","type":"action","command":["/bin/true"],"verify":["/bin/true"]}]}`)) - if err == nil { - t.Fatal("a manifest declaring an action was accepted, and the machine would refuse it") - } - if !strings.Contains(err.Error(), "may not") || !strings.Contains(err.Error(), "0005") { - t.Errorf("the refusal does not say what rule it is: %v", err) - } - // And it says what to do instead, because "you may not" without an alternative is where a - // module author stops. - if !strings.Contains(err.Error(), "ships a program") { - t.Errorf("the refusal names no alternative: %v", err) - } -} - // Every other shape a module may declare still passes, so the check above bounds one thing. func TestTheOtherShapesAreStillAccepted(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"probe","version":"1","resources":[ + _, err := decodeForTest([]byte(`{"module":"probe","version":"1","resources":[ {"id":"d","type":"directory","path":"/var/lib/probe","mode":"0700"}, {"id":"f","type":"file","path":"/var/lib/probe/x","content":"x\n"}, {"id":"n","type":"network","name":"probe"}, diff --git a/go/catalogue/needs_test.go b/go/catalogue/needs_test.go index 8a176a1..3a00dea 100644 --- a/go/catalogue/needs_test.go +++ b/go/catalogue/needs_test.go @@ -60,17 +60,6 @@ func TestADeclaredNeedThatWasNotMadeIsRefused(t *testing.T) { } } -func TestANeedIsAnAbsolutePath(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"postgres","version":"1", - "own-secrets":{"superuser":"superuser.txt"}}`)) - if err == nil { - t.Fatal("a relative path was accepted") - } - if !strings.Contains(err.Error(), "absolute path") { - t.Fatalf("unhelpful refusal: %v", err) - } -} - func TestAModuleMayNeedSeveralThings(t *testing.T) { // A password and a token, say. Telling them apart is the module's business, not the mesh's. m := needy() @@ -92,33 +81,3 @@ func TestAModuleMayNeedSeveralThings(t *testing.T) { t.Fatalf("two needs did not land as two secrets: %v", seen) } } - -// A manifest written against the old name is told what the field became. -// -// `needs` and `secrets` were both name-to-path and differed only in whose secret it was, so -// reaching for the wrong one parsed cleanly and failed somewhere else entirely. Refusing the old -// name with "unknown field" would be correct and unhelpful: whoever wrote it knew what they meant, -// and the mesh knows what it is called now. -func TestAManifestUsingTheOldNameIsToldTheNewOne(t *testing.T) { - _, err := ParseManifest([]byte( - `{"module":"postgres","version":"1","needs":{"superuser":"/var/lib/superuser"}}`)) - if err == nil { - t.Fatal("a manifest using the old name was accepted, so two fields now mean one thing") - } - for _, want := range []string{"needs", "own-secrets"} { - if !strings.Contains(err.Error(), want) { - t.Fatalf("the refusal does not mention %q: %v", want, err) - } - } -} - -// And an ordinary unknown key is still refused as one, rather than being guessed at. -func TestAnInventedKeyIsNotTreatedAsARename(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"postgres","version":"1","nonsense":{}}`)) - if err == nil { - t.Fatal("an invented key was accepted") - } - if strings.Contains(err.Error(), "is now called") { - t.Fatalf("an invented key was reported as a rename: %v", err) - } -} diff --git a/go/catalogue/no_subjects_test.go b/go/catalogue/no_subjects_test.go index a247296..2d493a1 100644 --- a/go/catalogue/no_subjects_test.go +++ b/go/catalogue/no_subjects_test.go @@ -74,25 +74,6 @@ func TestNoManifestContainsASubject(t *testing.T) { t.Logf("%d manifests hold no subject", checked) } -// **Every module's event names are what design 29 says, across the whole catalogue.** -// -// The rule above holds by construction and turned out to be weaker than it reads: a manifest holds -// no subject, and every manifest in the catalogue still held the old bus's routing key, which -// derives into a namespace nobody owns (novox/hq 04-ISSUES/127). Nothing failed — the services -// started and none of them reacted. This is the check that was missing. -func TestEveryManifestsEventNamesAreLocal(t *testing.T) { - manifests := theCatalogue(t) - - var problems []string - for _, m := range manifests { - problems = append(problems, EventProblems(m)...) - } - if len(problems) > 0 { - t.Fatalf("the catalogue holds %d event name(s) the mesh would derive wrongly:\n %s", - len(problems), strings.Join(problems, "\n ")) - } -} - // theCatalogue is every manifest of the catalogue internal/beside finds, parsed the way registration // parses one. func theCatalogue(t *testing.T) []Manifest { diff --git a/go/catalogue/optional_requirements.go b/go/catalogue/optional_requirements.go index 921b1d7..ab40521 100644 --- a/go/catalogue/optional_requirements.go +++ b/go/catalogue/optional_requirements.go @@ -193,102 +193,3 @@ func withoutAbsent(resources []map[string]any, absent map[string]bool) []map[str } return out } - -// secretNamesOf is every `${secret:…}` name that carries a credential of this requirement: its own -// name, and each local name it keeps several under (ADR 0094). -func secretNamesOf(m Manifest, requirement string) []string { - names := []string{requirement} - for local := range m.SecretsMany[requirement] { - names = append(names, local) - } - sort.Strings(names) - return names -} - -// OptionalProblems is what the module check refuses of a manifest's optional requirements and its -// only-when-bound resources (ADR 0330 §4–§6). -func OptionalProblems(m Manifest) []string { - var problems []string - required := map[string]bool{} - for _, r := range m.Requires { - if required[r] && m.IsOptional(r) { - problems = append(problems, fmt.Sprintf("%s requires %q twice; say it once, optional or not", m.Module, r)) - } - required[r] = true - } - for _, r := range optionalRequiresList(m) { - if !required[r] { - problems = append(problems, fmt.Sprintf( - "%s says %q is optional in %s and does not require it", m.Module, r, OptionalRequiresKey)) - } - } - // A contribution is to whoever answers the requirement, so it goes with it: contributing to one - // that is optional is allowed, and composed only while it is bound. - for _, r := range m.Resources { - id := fmt.Sprint(r["id"]) - when, marked := r[OnlyWhenBound] - on := "" - if marked { - name, ok := when.(string) - switch { - case !ok || name == "": - problems = append(problems, fmt.Sprintf( - "%s's resource %s says %s %v; it names one requirement the module requires optionally", - m.Module, id, OnlyWhenBound, when)) - continue - case fmt.Sprint(r["type"]) != "file": - problems = append(problems, fmt.Sprintf( - "%s's resource %s is a %v and says %s: only a file is left out while a requirement is "+ - "absent (novox/hq ADR 0330 §5)", m.Module, id, r["type"], OnlyWhenBound)) - continue - case !required[name]: - problems = append(problems, fmt.Sprintf( - "%s's resource %s is delivered only when %q is bound, and %s does not require it", - m.Module, id, name, m.Module)) - continue - case !m.IsOptional(name): - problems = append(problems, fmt.Sprintf( - "%s's resource %s is delivered only when %q is bound, which is a hard requirement and "+ - "always bound: mark the requirement optional, or drop %s", m.Module, id, name, OnlyWhenBound)) - continue - } - on = name - } - if fmt.Sprint(r["type"]) != "file" { - continue - } - content, _ := r["content"].(string) - for _, use := range bound.FindAllStringSubmatch(content, -1) { - provision, key, hasDefault := use[1], use[2], use[3] != "" - if !m.IsOptional(provision) { - if hasDefault { - problems = append(problems, fmt.Sprintf( - "%s's resource %s gives ${bound:%s:%s} a default, and %q is a hard requirement, always "+ - "bound: a default it never renders only misleads a reader", m.Module, id, provision, key, provision)) - } - continue - } - if !hasDefault && on != provision { - problems = append(problems, fmt.Sprintf( - "%s's resource %s says ${bound:%s:%s}, and %q is optional: while it is absent this would "+ - "be empty. Name its value then — ${bound:%s:%s|default:} — or deliver the file "+ - "only while it is bound, with \"%s\": %q (novox/hq ADR 0330 §4)", - m.Module, id, provision, key, provision, provision, key, OnlyWhenBound, provision)) - } - } - for _, provision := range m.Requires { - if !m.IsOptional(provision) || on == provision { - continue - } - for _, name := range secretNamesOf(m, provision) { - if strings.Contains(content, "${secret:"+name+"}") { - problems = append(problems, fmt.Sprintf( - "%s's resource %s says ${secret:%s}, the credential of %q, which is optional and not "+ - "delivered while absent: deliver the file only while it is bound, with \"%s\": %q "+ - "(novox/hq ADR 0330 §6)", m.Module, id, name, provision, OnlyWhenBound, provision)) - } - } - } - } - return problems -} diff --git a/go/catalogue/optional_requirements_test.go b/go/catalogue/optional_requirements_test.go index 0b6f808..2e92003 100644 --- a/go/catalogue/optional_requirements_test.go +++ b/go/catalogue/optional_requirements_test.go @@ -54,60 +54,12 @@ func TestARequirementIsHardUnlessMarkedOptional(t *testing.T) { } } -// The stored form reads on a controller older than optional requirements (review of mesh-controller -// #281). Inventory.Catalogue stops at the first manifest it cannot read, so a long-form entry inside -// `requires` would have stopped every plan after a rollback. Stored, `requires` is plain names, as main's -// manifest type reads it, and which are optional sits beside it in a key such a controller sets aside. -func TestTheStoredFormReadsWhereRequiresIsAListOfNames(t *testing.T) { - m := mustParse(t, `{"module":"grafana","version":"1", - "requires":["route",{"name":"influxdb-api","optional":true}]}`) - raw, err := json.Marshal(m) - if err != nil { - t.Fatal(err) - } - // main's field, exactly: `Requires []string`. - var older struct { - Requires []string `json:"requires"` - } - if err := json.Unmarshal(raw, &older); err != nil { - t.Fatalf("a controller reading requires as names cannot read the stored form: %v\n%s", err, raw) - } - if strings.Join(older.Requires, " ") != "route influxdb-api" { - t.Fatalf("it reads the requirements as %v", older.Requires) - } - var keys map[string]json.RawMessage - if err := json.Unmarshal(raw, &keys); err != nil { - t.Fatal(err) - } - if string(keys[OptionalRequiresKey]) != `["influxdb-api"]` { - t.Fatalf("which are optional is stored as %s", keys[OptionalRequiresKey]) - } - // And this controller reads the stored form back the same, leniently and strictly. - var back Manifest - if err := json.Unmarshal(raw, &back); err != nil { - t.Fatal(err) - } - strict, err := ParseManifest(raw) - if err != nil { - t.Fatal(err) - } - for _, got := range []Manifest{back, strict} { - if !got.IsOptional("influxdb-api") || got.IsOptional("route") || len(got.Requires) != 2 { - t.Fatalf("read back as %v optional %v", got.Requires, got.OptionalRequires) - } - } - // A stored list naming something the module does not require is refused. - if _, err := ParseManifest([]byte(`{"module":"x","version":"1","requires":["route"],"optional-requires":["mqtt-topic"]}`)); err == nil { - t.Error("an optional requirement the module does not require passed the check") - } -} - func TestARequirementsLongFormRefusesWhatItDoesNotKnow(t *testing.T) { for _, raw := range []string{ `{"module":"x","version":"1","requires":[{"name":"influxdb-api","optinal":true}]}`, `{"module":"x","version":"1","requires":[{"optional":true}]}`, } { - if _, err := ParseManifest([]byte(raw)); err == nil { + if _, err := decodeForTest([]byte(raw)); err == nil { t.Errorf("accepted %s", raw) } } @@ -202,42 +154,6 @@ func optionalModule(resources string) string { "resources":[` + resources + `]}` } -func TestTheModuleCheckRefusesAnOptionalPlaceholderThatCouldRenderEmpty(t *testing.T) { - refused := map[string]string{ - "a placeholder without a default": `{"id":"ds","type":"file","path":"/a","mode":"0644", - "content":"url: ${bound:influxdb-api:at}"}`, - "a secret outside an only-when-bound file": `{"id":"s","type":"file","path":"/a","mode":"0400", - "content":"${secret:influxdb-api}"}`, - "only-when-bound on a hard requirement": `{"id":"r","type":"file","path":"/a","mode":"0644", - "content":"x","only-when-bound":"route"}`, - "only-when-bound on something not required": `{"id":"r","type":"file","path":"/a","mode":"0644", - "content":"x","only-when-bound":"mqtt-topic"}`, - "only-when-bound on a directory": `{"id":"d","type":"directory","mode":"0700", - "only-when-bound":"influxdb-api"}`, - "a default on a hard requirement": `{"id":"r","type":"file","path":"/a","mode":"0644", - "content":"${bound:route:name|default:x}"}`, - } - for what, resource := range refused { - if _, err := ParseManifest([]byte(optionalModule(resource))); err == nil { - t.Errorf("%s passed the module check", what) - } else if what == "a placeholder without a default" && !strings.Contains(err.Error(), "|default:") { - t.Errorf("the refusal does not say how to write it: %v", err) - } - } - accepted := map[string]string{ - "a placeholder with a default": `{"id":"ds","type":"file","path":"/a","mode":"0644", - "content":"url: ${bound:influxdb-api:at|default:none}"}`, - "a placeholder and a secret inside an only-when-bound file": `{"id":"ds","type":"file","path":"/a", - "mode":"0400","content":"url: ${bound:influxdb-api:at}\npassword: ${secret:influxdb-api}", - "only-when-bound":"influxdb-api"}`, - } - for what, resource := range accepted { - if _, err := ParseManifest([]byte(optionalModule(resource))); err != nil { - t.Errorf("%s was refused: %v", what, err) - } - } -} - func absentGrafana(t *testing.T, absent bool) Resolution { t.Helper() m := mustParse(t, optionalModule(` diff --git a/go/catalogue/outside_not_given_test.go b/go/catalogue/outside_not_given_test.go index a63e3d8..2c5ead0 100644 --- a/go/catalogue/outside_not_given_test.go +++ b/go/catalogue/outside_not_given_test.go @@ -11,7 +11,7 @@ import ( func outsideManifest(t *testing.T) Manifest { t.Helper() - m, err := ParseManifest([]byte(`{"module":"assistant","version":"1", + m, err := decodeForTest([]byte(`{"module":"assistant","version":"1", "own-secrets":{ "password":{"path":"/var/lib/assistant/password.secret","taken":"at-start"}, "vendor-key":{"path":"/var/lib/assistant/vendor-key.secret","taken":"at-start","issued-by":"outside"}}, diff --git a/go/catalogue/own_manifest_test.go b/go/catalogue/own_manifest_test.go index f1f7196..6f669b6 100644 --- a/go/catalogue/own_manifest_test.go +++ b/go/catalogue/own_manifest_test.go @@ -14,7 +14,7 @@ func TestOwnManifestUsesNoWordTheRunningControllerRefuses(t *testing.T) { if err != nil { t.Fatal(err) } - m, err := ParseManifest(raw) + m, err := decodeForTest(raw) if err != nil { t.Fatalf("mesh-sdk's module.json does not parse: %v", err) } diff --git a/go/catalogue/own_secret_owner_test.go b/go/catalogue/own_secret_owner_test.go index fba354a..57a948c 100644 --- a/go/catalogue/own_secret_owner_test.go +++ b/go/catalogue/own_secret_owner_test.go @@ -1,7 +1,6 @@ package catalogue import ( - "strings" "testing" ) @@ -20,7 +19,7 @@ func mailSenderManifest(t *testing.T, own string) Manifest { ownSecrets += `,"owner":"` + own + `"` } ownSecrets += `}` - m, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","secrets-owner":"mesh-mail","own-secrets":{` + + m, err := decodeForTest([]byte(`{"module":"mesh-mail","version":"1","secrets-owner":"mesh-mail","own-secrets":{` + ownSecrets + `},"resources":[{"id":"account","type":"user","name":"mesh-mail-send"}]}`)) if err != nil { t.Fatalf("a manifest with own=%q was refused: %v", own, err) @@ -73,23 +72,6 @@ func TestAnOwnSecretsOwnerIsGivenToItsFiles(t *testing.T) { } } -// 1170/AC2: an owner naming an account the module does not make, root, or the operator account's placeholder is -// refused, naming the secret and the account. -func TestAnOwnSecretsOwnerIsRefusedWhenItIsNotAnAccountTheModuleMakes(t *testing.T) { - for name, c := range map[string]struct{ owner, says string }{ - "root": {"root", "root"}, - "the operator account": {"${machine:account}", "operator account"}, - "an account not declared": {"not-made", "not-made"}, - } { - _, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{` + - `"mail-credential-*":{"path":"/var/lib/mesh-mail/mail-credential-*.secret","issued-by":"outside","owner":"` + - c.owner + `"}},"resources":[{"id":"account","type":"user","name":"mesh-mail-send"}]}`)) - if err == nil || !strings.Contains(err.Error(), "mail-credential-*") || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: %v; want a refusal naming the secret and %q", name, err, c.says) - } - } -} - // 1170/AC3: a manifest with no owner on any own secret composes exactly as it did before this clause existed: // every secret file belongs to the module's secrets-owner (or nothing, when that is absent too). func TestNoOwnerOnAnySecretComposesAsMainDoes(t *testing.T) { diff --git a/go/catalogue/own_secret_prompt_test.go b/go/catalogue/own_secret_prompt_test.go index f0d6575..ef278e8 100644 --- a/go/catalogue/own_secret_prompt_test.go +++ b/go/catalogue/own_secret_prompt_test.go @@ -10,7 +10,7 @@ import ( // its bare name (novox/hq issue 789): reviewed in the module's pull request, and the controller validates it in // the module check. func TestAnOwnSecretsPromptParsesAndIsWrittenBack(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{ + m, err := decodeForTest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{ "mail-credential-*":{"path":"/var/lib/mesh-mail/mail-credential-*.secret","issued-by":"outside", "prompt":"the password of the mail account {member}"}, "smtp-relay-key":{"path":"/var/lib/mesh-mail/smtp-relay-key","taken":"at-start", @@ -41,7 +41,7 @@ func TestAnOwnSecretsPromptParsesAndIsWrittenBack(t *testing.T) { // No prompt declared is fine: the desk asks by name alone, as it does today. func TestAnOwnSecretWithNoPromptIsFine(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"telegram","version":"1","own-secrets":{ + m, err := decodeForTest([]byte(`{"module":"telegram","version":"1","own-secrets":{ "telegram-token":{"path":"/var/lib/telegram/telegram-token","issued-by":"outside"}}}`)) if err != nil { t.Fatal(err) @@ -65,7 +65,7 @@ func TestAPromptOfMultiByteCharactersIsCountedByCharacter(t *testing.T) { if n := len(prompt); n <= 120 { t.Fatalf("the test prompt is %d bytes, not over 120 as intended", n) } - _, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{ + _, err := decodeForTest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{ "smtp-relay-key":{"path":"/var/lib/mesh-mail/smtp-relay-key","taken":"at-start", "prompt":` + jsonString(prompt) + `}}}`)) if err != nil { @@ -73,34 +73,6 @@ func TestAPromptOfMultiByteCharactersIsCountedByCharacter(t *testing.T) { } } -// {member} is replaced only for a family's member: a single secret's prompt may not hold it (novox/hq issue 789). -func TestAPromptHoldingMemberOnASingleSecretIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{ - "smtp-relay-key":{"path":"/var/lib/mesh-mail/smtp-relay-key","taken":"at-start", - "prompt":"For {member}"}}}`)) - if err == nil || !strings.Contains(err.Error(), "{member} is replaced only for a family's member") { - t.Fatalf("a single secret's prompt holding {member}: %v; want a refusal saying so", err) - } -} - -// A prompt that is not one short line naming only {member} is refused by the module check (novox/hq issue 789). -func TestAMalformedPromptIsRefused(t *testing.T) { - for name, c := range map[string]struct{ prompt, says string }{ - "a newline": {"the password of\nthe mail account {member}", "one line"}, - "over 120 characters": {"the password of the mail account {member}, which this mesh needs to log in to " + - "send and receive mail for the operator's account on this machine today", "120 characters"}, - "a placeholder other than {member}": {"the password of the mail account {name}", "{member}"}, - "two placeholders, one unknown": {"the {kind} secret of the mail account {member}", "{member}"}, - } { - _, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","own-secrets":{ - "mail-credential-*":{"path":"/var/lib/mesh-mail/mail-credential-*.secret","issued-by":"outside", - "prompt":` + jsonString(c.prompt) + `}}}`)) - if err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: %v; want a refusal saying %q", name, err, c.says) - } - } -} - // jsonString quotes a Go string the way encoding/json would, for building a literal into a test's manifest JSON. func jsonString(s string) string { out, err := json.Marshal(s) diff --git a/go/catalogue/own_secret_taken_test.go b/go/catalogue/own_secret_taken_test.go index 4ad016c..d6d28c0 100644 --- a/go/catalogue/own_secret_taken_test.go +++ b/go/catalogue/own_secret_taken_test.go @@ -10,7 +10,7 @@ import ( // nothing of it, an object says `at-start` or `applied`, and the bytes the mesh holds are the bytes // it was given either way. func TestAnOwnSecretSaysHowItIsTaken(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"idp","version":"1","own-secrets":{ + m, err := decodeForTest([]byte(`{"module":"idp","version":"1","own-secrets":{ "broker":"/var/lib/mesh/idp/broker", "admin":{"path":"/var/lib/idp/admin.secret","taken":"applied"}, "session":{"path":"/var/lib/idp/session.secret","taken":"at-start"}}}`)) @@ -39,55 +39,3 @@ func TestAnOwnSecretSaysHowItIsTaken(t *testing.T) { t.Fatalf("a path alone is written back as a path: %s", out) } } - -func TestAnOwnSecretTakenSomeOtherWayIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"idp","version":"1","own-secrets":{ - "admin":{"path":"/var/lib/idp/admin.secret","taken":"sometimes"}}}`)) - if err == nil || !strings.Contains(err.Error(), `taken "sometimes"`) { - t.Fatalf("an unknown word for how a secret is taken was accepted: %v", err) - } - _, err = ParseManifest([]byte(`{"module":"idp","version":"1","own-secrets":{ - "admin":{"path":"/var/lib/idp/admin.secret","rotate":"yes"}}}`)) - if err == nil { - t.Fatal("an unknown field on an own secret was accepted") - } -} - -// A secret an outside party issues says so (novox/hq ADR 0228), is written back as it was read, and -// is the one at-start secret the mesh may not make; any other word for who issued it is refused. -func TestAnOwnSecretSaysWhenAnOutsidePartyIssuesIt(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"letta","version":"1","own-secrets":{ - "server-password":{"path":"/var/lib/letta/server-password","taken":"at-start"}, - "openai-api-key":{"path":"/var/lib/letta/openai-api-key","taken":"at-start","issued-by":"outside"}, - "telegram-token":{"path":"/var/lib/letta/telegram-token","issued-by":"outside"}, - "logflare":{"path":"/var/lib/letta/logflare","taken":"applied"}, - "broker":"/var/lib/mesh/letta/broker"}}`)) - if err != nil { - t.Fatal(err) - } - for name, may := range map[string]bool{"server-password": true, "openai-api-key": false, - "telegram-token": false, "logflare": false, "broker": false} { - if got := m.OwnSecrets[name].MeshMayMake(); got != may { - t.Errorf("%s: the mesh may make it = %v, want %v", name, got, may) - } - } - out, err := json.Marshal(m.OwnSecrets) - if err != nil { - t.Fatal(err) - } - var again OwnSecrets - if err := json.Unmarshal(out, &again); err != nil { - t.Fatal(err) - } - if again["openai-api-key"] != m.OwnSecrets["openai-api-key"] || again["telegram-token"] != m.OwnSecrets["telegram-token"] { - t.Fatalf("the round trip lost who issued it: %s", out) - } - if strings.Contains(string(out), `"taken":""`) { - t.Fatalf("a secret that says only who issued it gained an empty taken: %s", out) - } - _, err = ParseManifest([]byte(`{"module":"letta","version":"1","own-secrets":{ - "openai-api-key":{"path":"/var/lib/letta/openai-api-key","issued-by":"openai"}}}`)) - if err == nil || !strings.Contains(err.Error(), `issued by "openai"`) { - t.Fatalf("an unknown word for who issued a secret was accepted: %v", err) - } -} diff --git a/go/catalogue/paths_test.go b/go/catalogue/paths_test.go index 33b9227..dee61d7 100644 --- a/go/catalogue/paths_test.go +++ b/go/catalogue/paths_test.go @@ -5,46 +5,6 @@ import ( "testing" ) -// 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) - } - } -} - // The paths a module's tree is taken from are the union its artifacts name, cleaned and sorted, and only when every // artifact names some (novox/hq ADR 0499 clause 2); a module with an artifact that names none, or with none at all, // keeps its directory's tree. diff --git a/go/catalogue/placed_paths_test.go b/go/catalogue/placed_paths_test.go index 779c151..da32bf1 100644 --- a/go/catalogue/placed_paths_test.go +++ b/go/catalogue/placed_paths_test.go @@ -39,12 +39,12 @@ func TestPlacedDirectoriesKeepTheirPaths(t *testing.T) { if string(old) == string(now) { continue } - m, err := ParseManifest(now) + m, err := decodeForTest(now) if err != nil { t.Errorf("%s: %v", module, err) continue } - earlier, err := ParseManifest(old) + earlier, err := decodeForTest(old) if err != nil { t.Errorf("%s before: %v", module, err) continue diff --git a/go/catalogue/placement.go b/go/catalogue/placement.go index a7c522a..c9f7137 100644 --- a/go/catalogue/placement.go +++ b/go/catalogue/placement.go @@ -169,105 +169,9 @@ func systemPath(path string) string { return "" } -// Where no directory or file a module's definition resolves to may be (novox/hq issue 496). -// -// mesh-catalog #205 gave docker's `state` directory `"place": "."`, which resolves to /docker: with the -// default root, /var/lib/docker, every container's filesystem. systemPath judged only the `places` and `accesses` -// settings, so the default layout and a definition's own paths reached the merge gate unjudged; it composed every -// machine and passed, and only the node-engine refused the directory, at apply, failing the walk. -// -// **Judged where a definition is judged, never where a machine is composed.** resolvedPathProblems runs in -// ParseManifest, which every route a definition takes into the mesh passes: `module check`, registration of a -// build (the builder's and the registry verbs'), and the merge gate's reading of the changed repository. A -// refusal there stops one definition before it reaches any machine. Composition reads registered manifests -// without ParseManifest, and judges nothing of this: refusing there would fail the whole machine's declaration and -// freeze every module on it for one module's path, where the node-engine fails only that resource. -// -// **The node-engine's rules, no more** (mesh-host's internal/apply/placement_guard.go, with files judged as -// directories are after novox/hq issue 495, rule 6). A path is refused when it is one of protectedRoots or holds -// one, when it is at or below a tree in forbiddenBelow, or at or below one of engineTrees and its module is not -// the node-engine's. What the engine judges with what only the machine knows stays the engine's: where the -// runtimes really keep their data, links, the accounts' homes, a directory's owner below /etc. The lists are the -// engine's own words, and a test (engine_guard_test.go) holds them equal to mesh-host's beside this repository. -// systemPath stays the stricter rule for a setting: a setting is an operator's word about one machine, and the -// trees it lists (/etc, /usr, /run, the mesh's own) are where the mesh's own modules write by design. -var ( - protectedRoots = []string{"/", "/bin", "/boot", "/dev", "/etc", "/home", "/lib", "/lib32", "/lib64", - "/media", "/mnt", "/opt", "/proc", "/root", "/run", "/sbin", "/srv", "/sys", "/tmp", "/usr", "/usr/bin", - "/usr/lib", "/usr/lib64", "/usr/local", "/usr/local/bin", "/usr/local/lib", "/usr/local/sbin", "/usr/sbin", - "/usr/share", "/var", "/var/cache", "/var/lib", "/var/lib/mesh", "/var/log", "/var/run", "/var/tmp", - "/var/spool"} - forbiddenBelow = []string{"/proc", "/sys", "/dev", "/boot", "/root", "/var/spool", "/opt", "/var/lib/docker", - "/var/lib/containers", "/var/lib/containerd"} - engineTrees = []string{"/var/lib/mesh-host", "/usr/lib/nox-mesh-host"} -) - // engineModule is the node-engine's own module, the one that places in engineTrees. const engineModule = "mesh-host" -// enginePath says why the node-engine refuses a directory or file at path for module, or "". -func enginePath(path, module string) string { - path = filepath.Clean(path) - if !filepath.IsAbs(path) { - return "" - } - atOrBelow := func(tree string) bool { return path == tree || strings.HasPrefix(path, tree+"/") } - for _, root := range protectedRoots { - if path == root || path == "/" || strings.HasPrefix(root, path+"/") { - return root + " is one of the machine's own directories, and owning it is owning everything in it" - } - } - for _, tree := range forbiddenBelow { - if atOrBelow(tree) { - return "nothing is placed in " + tree - } - } - if module != engineModule { - for _, tree := range engineTrees { - if atOrBelow(tree) { - return tree + " is the node-engine's own, placed in by its own module alone" - } - } - } - return "" -} - -// resolvedPathProblems is every directory and file of the definition whose path, resolved as a node with the -// default root resolves it, the node-engine would refuse (novox/hq issue 496). A path still holding a placeholder -// only a machine fills (a setting, an access the definition gives no default) is the engine's to judge. -func (m Manifest) resolvedPathProblems() []string { - dirs := dirsFor(m, Rendering{}) - accesses := map[string]string{} - for _, a := range m.Accesses { - if a.ID != "" && a.Path != "" { - accesses[a.ID] = a.Path - } - } - var problems []string - for _, r := range m.Resources { - kind := fmt.Sprint(r["type"]) - if kind != "directory" && kind != "file" { - continue - } - id := fmt.Sprint(r["id"]) - path, _ := r["path"].(string) - if kind == "directory" && path == "" { - path = dirs[id] - } - path, _ = dirFill(path, dirs, m.Module) - path, _ = accessFill(path, accesses, m.Module) - if path == "" || strings.Contains(path, "${") { - continue - } - if why := enginePath(path, m.Module); why != "" { - problems = append(problems, fmt.Sprintf("%s's %s %q resolves to %s, which the node-engine refuses: %s. "+ - "A module's directories and files are judged when its definition is, so the merge gate refuses it "+ - "before any machine does (novox/hq issue 496)", m.Module, kind, id, filepath.Clean(path), why)) - } - } - return problems -} - // accessRef is how a module names one of its accesses: ${access:}. var accessRef = regexp.MustCompile(`\$\{access:([a-z0-9][a-z0-9-]*)\}`) @@ -534,57 +438,6 @@ func ownerInto(resource map[string]any, placed map[string]Placement) { } } -// unknownAccessRefs is every ${access:…} in the definition that names no access the definition -// declares by id — refused where the author is, as unknownDirRefs does for directories. -func (m Manifest) unknownAccessRefs() []string { - declared := map[string]bool{} - for _, a := range m.Accesses { - if a.ID != "" { - declared[a.ID] = true - } - } - seen := map[string]bool{} - var problems []string - refuse := func(s string, where any) { - for _, match := range accessRef.FindAllStringSubmatch(s, -1) { - id := match[1] - if declared[id] || seen[id] { - continue - } - seen[id] = true - problems = append(problems, fmt.Sprintf( - "%s says ${access:%s} in %v, and declares no access %q — a reference the mesh "+ - "cannot place would reach the machine as a literal path", - m.Module, id, where, id)) - } - } - for _, r := range m.Resources { - for _, field := range []string{"path", "content"} { - if s, ok := r[field].(string); ok { - refuse(s, r["id"]) - } - } - for _, field := range []string{"volumes", "env-file"} { - if list, ok := r[field].([]any); ok { - for _, v := range list { - if s, ok := v.(string); ok { - refuse(s, r["id"]) - } - } - } - } - if env, ok := r["env"].(map[string]any); ok { - for _, v := range env { - if s, ok := v.(string); ok { - refuse(s, r["id"]) - } - } - } - } - sort.Strings(problems) - return problems -} - func directoriesOf(m Manifest) map[string]string { dirs := map[string]string{} for _, r := range m.Resources { diff --git a/go/catalogue/placement_test.go b/go/catalogue/placement_test.go index 1b19d70..ac86f13 100644 --- a/go/catalogue/placement_test.go +++ b/go/catalogue/placement_test.go @@ -161,25 +161,6 @@ func TestADefinitionsPathIsTheDefaultTheAssignmentReplaces(t *testing.T) { } } -// A reference to an access the definition does not declare is refused where the author is. -func TestAnUnknownAccessReferenceIsRefusedAtParse(t *testing.T) { - _, err := ParseManifest([]byte(`{ - "module": "arr", "version": "1", - "accesses": [{"id": "series", "mode": "read-write"}], - "resources": [ - {"id": "state", "type": "directory", "place": ".", "mode": "0700"}, - {"id": "server", "type": "container", "name": "arr", "image": "arr:1", - "volumes": ["${access:movies}:/movies"]} - ]}`)) - if err == nil || !strings.Contains(err.Error(), "${access:movies}") { - t.Fatalf("a reference to an undeclared access was accepted: %v", err) - } - _, err = ParseManifest([]byte(`{"module": "arr", "version": "1", "accesses": [{"mode": "read"}]}`)) - if err == nil || !strings.Contains(err.Error(), "neither an id nor a path") { - t.Fatalf("an access with no id and no path was accepted: %v", err) - } -} - // **Placement is read from the placement, never from a layer's values** (novox/hq ADR 0348 clause 11, issue // 651). A `places` or `accesses` among the config values places nothing; the same in Placement does. A // mergeable file of today's form still takes every key of the layer as it is said, placement too, so it diff --git a/go/catalogue/predefined_inputs.go b/go/catalogue/predefined_inputs.go index 1552cf9..1b52013 100644 --- a/go/catalogue/predefined_inputs.go +++ b/go/catalogue/predefined_inputs.go @@ -1,41 +1 @@ package catalogue - -import "encoding/json" - -// ParseManifestForInputs reads a manifest for the inputs of its build and its fingerprint: the indexer's and the -// builder's read (novox/hq issue 1751). It is never a judge of config values. A value falling back to a predefined key -// (ADR 0348 §7) is judged against the list the mesh published when this process holds one (PublishedPredefined); -// where it holds none, as in the indexer and the builder, each key a value names is taken as the value declares it (its -// type, trusted when it is), since the list is read where a manifest is judged: the merge gate from the tree's root, -// the controller from what it published. No input of a fingerprint or a build comes from the list. Everything else -// ParseManifest refuses is refused here too. -func ParseManifestForInputs(raw []byte) (Manifest, error) { - list := PublishedPredefined() - if list == nil { - list = fallbacksOf(raw) - } - return parseManifest(raw, list) -} - -// fallbacksOf is the list of the predefined keys a manifest's config values fall back to, each as the value declares -// it. A manifest that is not JSON gives an empty list, and the parser says what is wrong with it. -func fallbacksOf(raw []byte) PredefinedList { - var m struct { - Config map[string]struct { - Type string `json:"type"` - Description string `json:"description"` - Predefined string `json:"predefined"` - Trusted bool `json:"trusted"` - } `json:"config"` - } - list := PredefinedList{} - if json.Unmarshal(raw, &m) != nil { - return list - } - for _, d := range m.Config { - if d.Predefined != "" { - list[d.Predefined] = PredefinedKey{Type: d.Type, Description: d.Description, Trusted: d.Trusted} - } - } - return list -} diff --git a/go/catalogue/predefined_inputs_test.go b/go/catalogue/predefined_inputs_test.go index 40b5c5e..d5141be 100644 --- a/go/catalogue/predefined_inputs_test.go +++ b/go/catalogue/predefined_inputs_test.go @@ -6,26 +6,6 @@ import ( "testing" ) -// 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 := ReplacePublished(nil) // the indexer's and the builder's case: no published list - defer 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 TestParseManifestForInputsStillRefusesWhatTheParserRefuses(t *testing.T) { // issue 1751 was := ReplacePublished(nil) defer ReplacePublished(was) @@ -34,7 +14,7 @@ func TestParseManifestForInputsStillRefusesWhatTheParserRefuses(t *testing.T) { if bad == string(raw) { t.Fatal("the fixture no longer names font-size; the test proves nothing") } - if _, err := ParseManifestForInputs([]byte(bad)); err == nil { + if _, err := decodeForTest([]byte(bad)); err == nil { t.Error("a manifest the parser refuses was read") } } diff --git a/go/catalogue/predefined_test.go b/go/catalogue/predefined_test.go index 015a8e5..396e4b4 100644 --- a/go/catalogue/predefined_test.go +++ b/go/catalogue/predefined_test.go @@ -74,37 +74,6 @@ func fallingBackTo(t *testing.T, key string) []byte { return raw } -// A manifest naming font-size registers against the list; one naming a key not on it, or of another type, is -// refused — judged against the list given, whatever the mesh published. -func TestAManifestIsJudgedAgainstTheListOfItsTree(t *testing.T) { - withPredefined(t, nil) - list, err := ParsePredefined([]byte(theFirstKeys)) - if err != nil { - t.Fatal(err) - } - if _, err := ParseManifestWith(fallingBackTo(t, "font-size"), list); err != nil { - t.Fatalf("a value falling back to font-size is refused: %v", err) - } - if _, err := ParseManifestWith(fallingBackTo(t, "font-scale"), list); err == nil || - !strings.Contains(err.Error(), `"font-scale", which is not on the list`) { - t.Fatalf("a key not on the list: %v", err) - } - m := inConfigForm() - declaration(m, "theme")["predefined"] = "font-size" - raw, _ := json.Marshal(m) - if _, err := ParseManifestWith(raw, list); err == nil || !strings.Contains(err.Error(), "which is a number") { - t.Fatalf("a key of another type: %v", err) - } - // Where no tree is read, the published list judges: none published refuses. - if _, err := ParseManifest(fallingBackTo(t, "font-size")); err == nil || !strings.Contains(err.Error(), "is not present") { - t.Fatalf("judged against no list: %v", err) - } - withPredefined(t, list) - if _, err := ParseManifest(fallingBackTo(t, "font-size")); err != nil { - t.Fatalf("judged against the published list: %v", err) - } -} - // A template's text filled from the tree keeps the list the manifest was judged against: read again, it is not // judged against the published one. func TestTemplatesFilledKeepTheListTheManifestWasJudgedAgainst(t *testing.T) { @@ -126,7 +95,7 @@ func TestTemplatesFilledKeepTheListTheManifestWasJudgedAgainst(t *testing.T) { declaration(m, "width")["runtime"] = true declaration(m, "sender")["runtime"] = true raw, _ := json.Marshal(m) - parsed, err := ParseManifestWith(raw, list) + parsed, err := decodeWithForTest(raw, list) if err != nil { t.Fatal(err) } diff --git a/go/catalogue/prepares_test.go b/go/catalogue/prepares_test.go index eaae43b..fd7fa3c 100644 --- a/go/catalogue/prepares_test.go +++ b/go/catalogue/prepares_test.go @@ -1,7 +1,6 @@ package catalogue import ( - "encoding/json" "fmt" "strings" "testing" @@ -119,23 +118,3 @@ func TestAModuleThatPreparesNothingGetsNoStep(t *testing.T) { } } } - -// A module whose own code the mesh cannot find has nothing to ask, and saying so where the manifest -// is read beats a declaration that quietly prepares nothing. -func TestAModuleThatPreparesAndRunsNoneOfItsOwnCodeIsRefused(t *testing.T) { - m := Manifest{ - Module: "gitea", - Prepares: true, - Resources: []map[string]any{ - // Only the upstream server it packages: nothing here runs gitea's own code. - {"id": "server", "type": "container", "name": "mesh-gitea-server", "image": "gitea/gitea@" + digest}, - }, - } - raw, err := json.Marshal(m) - if err != nil { - t.Fatal(err) - } - if _, err := ParseManifest(raw); err == nil { - t.Fatal("a module that prepares its state with nothing of its own to run was accepted") - } -} diff --git a/go/catalogue/process_bus.go b/go/catalogue/process_bus.go index cdfa936..347e244 100644 --- a/go/catalogue/process_bus.go +++ b/go/catalogue/process_bus.go @@ -4,8 +4,6 @@ import ( "bytes" "encoding/json" "fmt" - "slices" - "strings" ) // ProcessBusField is the key a process resource declares its bus on. Controller-side only: never composed @@ -46,9 +44,6 @@ const ( RemovesAssignmentsStream = "ASSIGNMENTS" ) -// BusRemovals is the closed set of what a bus account may remove. -var BusRemovals = []string{RemovesControllerDeadLetterConsumer, RemovesEngineActs, RemovesAssignmentsStream} - // BusAccount is one account of a module's own beside its `broker`: a process's (ADR 0436) or a tools bundle's (ADR 0564 // Limits 4), by its id (the process resource's, or the bundle's binary), with what its bus says. type BusAccount struct { @@ -99,124 +94,6 @@ func ToolsBusSecret(m Manifest, name string) bool { return false } -// toolsSecretProblems refuses a tools bundle's bus account secret the runtime's account could not read: it is declared -// as an own secret `broker-`, at `${dir:}/broker-` for a directory resource every parent of which the -// runtime's account may pass (passableDir); the file itself is 0600 and the runtime account's. -func toolsSecretProblems(m Manifest) []string { - var problems []string - for _, a := range m.BusAccounts() { - if !a.Tools { - continue - } - name := "broker-" + a.ID - own, declared := m.OwnSecrets.Plain()[name] - if !declared { - problems = append(problems, fmt.Sprintf("%s's tools bundle %s declares bus and no own secret %s to hold its "+ - "account in (novox/hq ADR 0564 Limits 4)", m.Module, a.ID, name)) - continue - } - dir, _, _ := strings.Cut(strings.TrimPrefix(own.Path, "${dir:"), "}") - if !strings.HasPrefix(own.Path, "${dir:"+dir+"}/") || !passableDir(m, dir, 0) { - problems = append(problems, fmt.Sprintf("%s's own secret %s is at %s, which the node's tool runtime cannot "+ - "reach: a tools bundle's account lies in a directory resource the runtime's account may pass, every parent "+ - "included, such as {\"id\":\"tools-secret\",\"type\":\"directory\",\"path\":\"%s/%s-tools\",\"mode\":\"0711\"} "+ - "with the secret at ${dir:tools-secret}/%s; never under the module's own or mesh place, which are its "+ - "own (novox/hq ADR 0564 Limits 4)", m.Module, name, own.Path, meshStateRoot, m.Module, name)) - } - } - return problems -} - -// meshStateRoot is the mesh's directory of every module's mesh place on a node of the default data root: root's, mode -// 0755, so the runtime's account passes it, while each module's own place beneath it is 0700. -const meshStateRoot = defaultDataRoot + "/" + placeMesh - -// passableDir says the runtime's account may pass the directory resource id and every parent the mesh makes for it: its -// own mode lets others pass (the last digit odd), and it is either stated directly under meshStateRoot or beneath a -// placed directory that is passable in turn. A pathless directory resolves under the module's own root or its mesh -// place, which are the module's own, so it never is. -func passableDir(m Manifest, id string, depth int) bool { - if depth > 4 { - return false - } - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) != "directory" || fmt.Sprint(r["id"]) != id { - continue - } - mode, _ := r["mode"].(string) - if mode == "" || !strings.ContainsAny(mode[len(mode)-1:], "1357") { - return false - } - path, _ := r["path"].(string) - switch { - case strings.HasPrefix(path, "${dir:"): - parent, _, _ := strings.Cut(strings.TrimPrefix(path, "${dir:"), "}") - return passableDir(m, parent, depth+1) - case strings.HasPrefix(path, "/"): - return filepathDir(strings.TrimRight(path, "/")) == meshStateRoot - } - return false - } - return false -} - -func filepathDir(p string) string { - if i := strings.LastIndex(p, "/"); i > 0 { - return p[:i] - } - return "/" -} - -// toolsBusProblems is what is wrong with a tools bundle's bus (ADR 0564 Limits 4): a bus on anything but a bundle -// compiled to a binary the runtime loads, a removal outside the closed set, or a removal beside any other grant or by a -// module that does not hold mesh-broker. Every other field is judged as a process's. -func toolsBusProblems(m Manifest) []string { - var problems []string - var artifacts []Artifact - if m.Build != nil { - artifacts = m.Build.Artifacts - } - claimsBroker := false - for _, c := range m.Claims { - claimsBroker = claimsBroker || c.Name == "mesh-broker" - } - for _, a := range artifacts { - if a.Bus == nil { - continue - } - if a.Kind != ArtifactBundle || BinaryOf(a) == "" { - problems = append(problems, fmt.Sprintf("%s's artifact %s declares bus, and only a tools bundle compiled to a "+ - "binary has an account of its own (novox/hq ADR 0564 Limits 4)", m.Module, a.Name)) - continue - } - b := *a.Bus - if len(b.Queues) > 0 || b.Asks { - problems = append(problems, fmt.Sprintf("%s's tools bundle %s's bus consumes a queue or reads asks; a tools "+ - "bundle runs per call and holds no consumer (novox/hq ADR 0564 Limits 4)", m.Module, a.Name)) - } - for _, r := range b.Removes { - if !slices.Contains(BusRemovals, r) { - problems = append(problems, fmt.Sprintf("%s's tools bundle %s's bus removes %q, which is not one of %s "+ - "(novox/hq ADR 0564 Limits 4)", m.Module, a.Name, r, strings.Join(BusRemovals, ", "))) - } - } - if len(b.Removes) > 0 && (!claimsBroker || len(b.Emits)+len(b.State)+len(b.Invokes) > 0) { - problems = append(problems, fmt.Sprintf("%s's tools bundle %s's bus removes from the bus; only the bus "+ - "module's (the holder of mesh-broker) may, and with nothing else (novox/hq ADR 0564 Limits 4)", m.Module, a.Name)) - } - } - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) != "process" { - continue - } - if b, present, err := processBusOf(r); present && err == nil && len(b.Removes) > 0 { - problems = append(problems, fmt.Sprintf("%s's process %v's bus removes from the bus; only a tools bundle's "+ - "may (novox/hq ADR 0564 Limits 4)", m.Module, r["id"])) - } - } - return problems -} - // UnmarshalJSON reads a process's bus strictly: an unknown key — serves, a seat to hold or claim, or // anything else that would answer a tool call or a seat's verb — is refused here, by name (novox/hq ADR // 0436 §3). @@ -268,108 +145,3 @@ func (m Manifest) ProcessBuses() map[string]ProcessBus { } return out } - -// ProcessBusProblems is what is wrong with a manifest's processes' bus declarations (novox/hq ADR 0436 §1, -// §3): the account rule is modelled on RunsAsProblems (seats_declared.go:522-550), and an invoked tool or -// seat verb's shape on invokeProblems. -func ProcessBusProblems(m Manifest) []string { - problems := append(toolsBusProblems(m), toolsSecretProblems(m)...) - streams := map[string]bool{} - for _, s := range m.Streams { - streams[s.Name] = true - } - state := map[string]bool{} - for _, s := range m.State { - state[s.Name] = true - } - emits := map[string]bool{} - for _, e := range m.EmitsAll() { - emits[e] = true - } - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) != "process" { - continue - } - id := fmt.Sprint(r["id"]) - if _, present := r["bus"]; !present { - continue - } - b, _, err := processBusOf(r) - if err != nil { - problems = append(problems, fmt.Sprintf("%s's process %s declares bus: %v", m.Module, id, err)) - continue - } - user, _ := r["user"].(string) - problems = append(problems, processAccountProblems(m, id, user)...) - for _, e := range b.Emits { - if !emits[e] { - problems = append(problems, fmt.Sprintf( - "%s's process %s's bus emits %q, which %s does not declare as its own (novox/hq ADR 0436 §1)", - m.Module, id, e, m.Module)) - } - } - for _, q := range b.Queues { - if !streams[q] { - problems = append(problems, fmt.Sprintf( - "%s's process %s's bus queues %q, which %s does not declare as its own stream (novox/hq "+ - "ADR 0436 §1)", m.Module, id, q, m.Module)) - } - } - for _, s := range b.State { - if !state[s] { - problems = append(problems, fmt.Sprintf( - "%s's process %s's bus reads and writes state %q, which %s does not declare as its own "+ - "(novox/hq ADR 0436 §1)", m.Module, id, s, m.Module)) - } - } - for _, t := range b.Invokes { - if !invokeNameValid(t) { - problems = append(problems, fmt.Sprintf( - "%s's process %s's bus invokes %q, which does not name a tool: a process invokes "+ - "., or a role's seat:. (novox/hq ADR 0436 §1)", m.Module, id, t)) - } - } - } - return problems -} - -// processAccountProblems is what is wrong with one process's bus account (novox/hq ADR 0436 §1, §2): an -// account the module makes — a `user` resource of that name — never root and never the operator account, -// which a manifest cannot say safely (a node with no account has nothing to resolve it to, novox/hq ADR -// 0436, settled by the lead). -func processAccountProblems(m Manifest, processID, user string) []string { - switch { - case user == "": - return []string{fmt.Sprintf( - "%s's process %s declares bus and names no account; a process account is a user resource the "+ - "module makes, never root and never the operator account (novox/hq ADR 0436 §1, §2)", - m.Module, processID)} - case user == "root": - return []string{fmt.Sprintf( - "%s's process %s runs as root; a process account is the module's own, never root (novox/hq ADR "+ - "0436 §1, §2)", m.Module, processID)} - case user == "${machine:account}": - return []string{fmt.Sprintf( - "%s's process %s runs as the operator account; a process account is the module's own, never the "+ - "operator's (novox/hq ADR 0436 §1, §2)", m.Module, processID)} - } - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) == "user" && fmt.Sprint(r["name"]) == user { - return nil - } - } - return []string{fmt.Sprintf( - "%s's process %s runs as %s, which it does not make: a user resource named %s (novox/hq ADR 0436 §1, "+ - "§2)", m.Module, processID, user, user)} -} - -// invokeNameValid says a module or process's invoked entry names a tool: ., a role's -// seat:., or * for every tool on the mesh (novox/hq ADR 0152, ADR 0436 §1). -func invokeNameValid(t string) bool { - if t == "*" { - return true - } - t = strings.TrimPrefix(t, "seat:") - first, second, named := strings.Cut(t, ".") - return named && name.MatchString(first) && toolName.MatchString(second) -} diff --git a/go/catalogue/process_bus_test.go b/go/catalogue/process_bus_test.go index 3c1977c..e3a2f33 100644 --- a/go/catalogue/process_bus_test.go +++ b/go/catalogue/process_bus_test.go @@ -1,7 +1,6 @@ package catalogue import ( - "strings" "testing" ) @@ -30,84 +29,6 @@ func mailSenderProcessManifest(t *testing.T, bus string) string { const mailSenderBus = `{"emits":["mail.send.sent","mail.send.failed"],"queues":["mail-sends"],` + `"state":["sends"],"asks":true,"invokes":["seat:operator-channel.tell"]}` -// 1179/AC1: a well-formed bus on a process whose account the module makes is accepted, and the catalogue -// gives it whole to the grant composition (ProcessBuses, which 1180 reads). -func TestAProcessDeclaresItsBusAndIsAccepted(t *testing.T) { - m, err := ParseManifest([]byte(mailSenderProcessManifest(t, mailSenderBus))) - if err != nil { - t.Fatalf("a well-formed process bus was refused: %v", err) - } - buses := m.ProcessBuses() - b, ok := buses["send"] - if !ok { - t.Fatalf("the process's bus was not given to the grant composition: %+v", buses) - } - if !strSliceEqual(b.Emits, []string{"mail.send.sent", "mail.send.failed"}) || - !strSliceEqual(b.Queues, []string{"mail-sends"}) || !strSliceEqual(b.State, []string{"sends"}) || - !b.Asks || !strSliceEqual(b.Invokes, []string{"seat:operator-channel.tell"}) { - t.Fatalf("the bus read as %+v", b) - } - - // 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":["DEAD_LETTER_NOTICES.controller","mesh-host_acts","ASSIGNMENTS"]}`)}, - {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{RemovesControllerDeadLetterConsumer, RemovesEngineActs, RemovesAssignmentsStream}) { - t.Errorf("%s: the accounts are %+v", c.name, accounts) - } - // Its secret's file is the runtime account's, as the runtime's own credential is; the module's other secrets stay - // its own. - if (Resolution{Account: "ops"}).ownSecretOwner(m, "broker-nats-tools") != "ops" || - (Resolution{Account: "ops"}).ownSecretOwner(m, "broker") != "" || (Resolution{}).ownSecretOwner(m, "broker-nats-tools") != "" { - t.Errorf("%s: the tools secret's owner is %q", c.name, (Resolution{Account: "ops"}).ownSecretOwner(m, "broker-nats-tools")) - } - // Resolved, the build goes and its bundles keep the bus (build.go). - resolved := Manifest{Module: "nats", Bundles: []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) - } -} - func strSliceEqual(a, b []string) bool { if len(a) != len(b) { return false @@ -120,72 +41,14 @@ func strSliceEqual(a, b []string) bool { return true } -// 1179/AC2: a bus on a process with no user, user root, or the operator account is refused naming the -// process and the account rule. -func TestAProcessBusAccountMustBeTheModulesOwn(t *testing.T) { - for name, c := range map[string]struct{ user, says string }{ - "no user": {"", "send"}, - "user root": {"root", "root"}, - "the operator account": {"${machine:account}", "operator"}, - } { - raw := `{"module":"mesh-mail","version":"1","emits":["mail.send.sent"],"state":["sends"],` + - `"streams":[{"name":"mail-sends","events":["mail.send.approved"],"retention":"work-queue",` + - `"max-deliveries":5}],"resources":[{"id":"account","type":"user","name":"mesh-mail-send"},` + - `{"id":"send","type":"process","run":["./mesh-mail","send"]` - if c.user != "" { - raw += `,"user":"` + c.user + `"` - } - raw += `,"bus":{"emits":["mail.send.sent"]}}]}` - _, err := ParseManifest([]byte(raw)) - if err == nil || !strings.Contains(err.Error(), "send") || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: %v; want a refusal naming the process and %q", name, err, c.says) - } - } -} - -// 1179/AC3: a bus naming a stream, state or event the module does not declare as its own is refused naming -// it. -func TestAProcessBusNamesOnlyTheModulesOwn(t *testing.T) { - for name, c := range map[string]struct{ bus, says string }{ - "another module's event": {`{"emits":["billing.paid"]}`, "billing.paid"}, - "another module's queue": {`{"queues":["other-queue"]}`, "other-queue"}, - "another module's state": {`{"state":["other-state"]}`, "other-state"}, - } { - _, err := ParseManifest([]byte(mailSenderProcessManifest(t, c.bus))) - if err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: %v; want a refusal naming %q", name, err, c.says) - } - } -} - -// 1179/AC4: a bus declaring serves, holds, claims or a seat is refused naming the key; a bus with invokes of -// a tool or a seat verb is accepted. -func TestAProcessBusNeverServesOrHoldsASeat(t *testing.T) { - for name, c := range map[string]struct{ bus, says string }{ - "serves": {`{"emits":["mail.send.sent"],"serves":["send"]}`, "serves"}, - "holds": {`{"emits":["mail.send.sent"],"holds":["mail-sender"]}`, "holds"}, - "claims": {`{"emits":["mail.send.sent"],"claims":["mail-sender"]}`, "claims"}, - "seat": {`{"emits":["mail.send.sent"],"seat":"mail-sender"}`, "seat"}, - } { - _, err := ParseManifest([]byte(mailSenderProcessManifest(t, c.bus))) - if err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: %v; want a refusal naming %q", name, err, c.says) - } - } - accepted := `{"emits":["mail.send.sent","mail.send.failed"],"invokes":["seat:operator-channel.tell","mail.status"]}` - if _, err := ParseManifest([]byte(mailSenderProcessManifest(t, accepted))); err != nil { - t.Fatalf("a bus invoking a tool and a seat verb was refused: %v", err) - } -} - // 1179/AC5: a process declaring bus is composed with no bus key on the wire, and is otherwise byte for byte // what main composes for the same process without bus. func TestAProcessesBusNeverReachesTheWire(t *testing.T) { - withBus, err := ParseManifest([]byte(mailSenderProcessManifest(t, mailSenderBus))) + withBus, err := decodeForTest([]byte(mailSenderProcessManifest(t, mailSenderBus))) if err != nil { t.Fatal(err) } - withoutBus, err := ParseManifest([]byte(mailSenderProcessManifest(t, ""))) + withoutBus, err := decodeForTest([]byte(mailSenderProcessManifest(t, ""))) if err != nil { t.Fatal(err) } diff --git a/go/catalogue/provided_test.go b/go/catalogue/provided_test.go index f09c0e9..e011047 100644 --- a/go/catalogue/provided_test.go +++ b/go/catalogue/provided_test.go @@ -25,7 +25,7 @@ func provided(t *testing.T) map[string]catalogue.Manifest { if err != nil { t.Fatal(err) } - m, err := catalogue.ParseManifest(b) + m, err := catalogue.Decode(b) if err != nil { t.Fatalf("a manifest this control plane ships is not valid: %v", err) } diff --git a/go/catalogue/public_issuer_test.go b/go/catalogue/public_issuer_test.go index ec8e992..afa71a3 100644 --- a/go/catalogue/public_issuer_test.go +++ b/go/catalogue/public_issuer_test.go @@ -27,7 +27,7 @@ func TestTheRetiredNetworkingModulesAreNotInTheCatalogue(t *testing.T) { if err != nil { continue } - m, err := ParseManifest(raw) + m, err := decodeForTest(raw) if err != nil { continue // judged by the catalogue's own tests } diff --git a/go/catalogue/published_ports_test.go b/go/catalogue/published_ports_test.go index 48036d2..8c3c70b 100644 --- a/go/catalogue/published_ports_test.go +++ b/go/catalogue/published_ports_test.go @@ -38,7 +38,7 @@ func TestEveryPublishedPortIsOneItsModuleDeclares(t *testing.T) { if err != nil { continue } - m, err := ParseManifest(raw) + m, err := decodeForTest(raw) if err != nil { // Whether every manifest parses is TestEveryCatalogueManifestParses's question. continue diff --git a/go/catalogue/reads.go b/go/catalogue/reads.go index 93686a2..224e464 100644 --- a/go/catalogue/reads.go +++ b/go/catalogue/reads.go @@ -87,34 +87,6 @@ func readsVerbs() []Verb { } } -// ReadsHolderProblems is what a holder of node-reads is held to (novox/hq ADR 0310 §3): it reads as an -// account of its own, so the node's file modes bound what it sees. Carried by the machine's runtime it would -// read as the operator's account, which sees the operator's keys and the agent's credentials. -func ReadsHolderProblems(m Manifest) []string { - if !m.ClaimsSeat(ReadsSeat) { - return nil - } - if m.RunsAs == "" { - return []string{fmt.Sprintf("%s claims %s and must run as an account of its own (runs-as): its reads are "+ - "bounded by that account's file modes, and the machine's runtime runs as the operator's account "+ - "(novox/hq ADR 0310 §3)", m.Module, ReadsSeat)} - } - // In no group beside its own: a group is a way to a secret (docker's is root's), and the account's file modes - // are what bound its reads. - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) != "user" || fmt.Sprint(r["name"]) != m.RunsAs { - continue - } - if groups, said := r["groups"]; said { - if list, ok := groups.([]any); !ok || len(list) > 0 { - return []string{fmt.Sprintf("%s claims %s, and its account %s is put in groups %v: the account that "+ - "reads is in no group beside its own (novox/hq ADR 0310 §3)", m.Module, ReadsSeat, m.RunsAs, groups)} - } - } - } - return nil -} - // secretPlaces is every place on this node a secret is kept, as the placed manifests say (novox/hq ADR 0310 // §5): the directory of every module's own secret, every credential file a module is given and every grant // directory a provider is handed its consumers' credentials in, the place a module keeps the operator-sealed diff --git a/go/catalogue/reads_test.go b/go/catalogue/reads_test.go index 69f314f..0b38aba 100644 --- a/go/catalogue/reads_test.go +++ b/go/catalogue/reads_test.go @@ -36,20 +36,6 @@ func TestNodeReadsIsANodeSeatWhoseVerbsSayWhatTheyReplace(t *testing.T) { } } -// novox/hq ADR 0310 §3: a holder of node-reads reads as an account of its own; one the machine's runtime would -// carry — as the operator's account — is refused at parse. -func TestAHolderOfNodeReadsWithoutAnAccountOfItsOwnIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module": "reads", "version": "1", - "claims": [{"name": "node-reads", "scope": "node"}]}`)) - if err == nil || !strings.Contains(err.Error(), "runs-as") || !strings.Contains(err.Error(), "0310") { - t.Errorf("a holder of node-reads without runs-as was not refused for it: %v", err) - } - if p := ReadsHolderProblems(Manifest{Module: "reads", RunsAs: "mesh-reads", - Claims: []Claim{{Name: ReadsSeat}}}); len(p) != 0 { - t.Errorf("a holder running as its own account was refused: %v", p) - } -} - // novox/hq ADR 0310 §5: the holder is told, by the composition, where its node keeps a secret — the directory // of every module's own secret, every credential file and grant directory, the vault's directories and the // place the operator-sealed secrets are kept — and no other module is told. @@ -122,16 +108,3 @@ func TestASecretPlaceThatCannotBeResolvedFailsTheComposition(t *testing.T) { t.Error("a relative place was dropped") } } - -// ADR 0310 §3: the account that reads is in no group beside its own; one put in a group is refused. -func TestTheReadsAccountIsInNoGroup(t *testing.T) { - m := Manifest{Module: "reads", RunsAs: "mesh-reads", Claims: []Claim{{Name: ReadsSeat}}, - Resources: []map[string]any{{"id": "account", "type": "user", "name": "mesh-reads", "groups": []any{"docker"}}}} - if p := ReadsHolderProblems(m); len(p) != 1 || !strings.Contains(p[0], "docker") { - t.Errorf("an account in docker was not refused: %v", p) - } - m.Resources[0]["groups"] = []any{} - if p := ReadsHolderProblems(m); len(p) != 0 { - t.Errorf("an account in no group was refused: %v", p) - } -} diff --git a/go/catalogue/replaces_test.go b/go/catalogue/replaces_test.go index 4b34320..5815795 100644 --- a/go/catalogue/replaces_test.go +++ b/go/catalogue/replaces_test.go @@ -53,7 +53,7 @@ func TestEveryReplacedCommandIsOneShortLine(t *testing.T) { // A verb's definition in a manifest may say what it replaces, like the mesh's own. func TestAVerbDefinitionCarriesWhatItReplaces(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"till","version":"1","tools":["refund"],` + + m, err := decodeForTest([]byte(`{"module":"till","version":"1","tools":["refund"],` + `"seats":[{"name":"shop-till","serves":[{"name":"refund","replaces":["psql -c refund"]}]}],` + `"claims":[{"name":"shop-till","scope":"mesh"}]}`)) if err != nil { @@ -64,34 +64,6 @@ func TestAVerbDefinitionCarriesWhatItReplaces(t *testing.T) { } } -// A module says what its own tools replace, and only for a tool it declares. -func TestAModuleSaysWhatItsOwnToolsReplace(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"docker","version":"1","tools":["docker_logs","docker_list"],` + - `"replaces":{"docker_logs":["docker logs"],"docker_list":["docker ps"]}}`)) - if err != nil { - t.Fatal(err) - } - if m.Replaces["docker_logs"][0] != "docker logs" || m.Replaces["docker_list"][0] != "docker ps" { - t.Fatalf("replaces not read: %v", m.Replaces) - } - for _, c := range []struct{ manifest, says string }{ - {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_lgos":["docker logs"]}}`, - "declares no such tool"}, - {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":[]}}`, "replaces nothing"}, - {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":[" "]}}`, "empty command"}, - {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":["docker logs\nrm -rf /"]}}`, - "more than one line"}, - {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":["docker logs","docker logs"]}}`, - "twice"}, - {`{"module":"docker","version":"1","tools":["docker_logs"],"replaces":{"docker_logs":["` + strings.Repeat("x", MaxReplaced+1) + `"]}}`, - "longer than"}, - } { - if _, err := ParseManifest([]byte(c.manifest)); err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: want a refusal saying %q, got %v", c.manifest, c.says, err) - } - } -} - // A seat row the store seeded before `replaces` existed carries none; the working set takes the compiled // one, so the `tools` answer says it on a mesh whose rows exist. func TestASeededRowTakesWhatItsVerbsReplaceFromTheCompiledSeat(t *testing.T) { diff --git a/go/catalogue/replay166_test.go b/go/catalogue/replay166_test.go index 7611666..78e10d1 100644 --- a/go/catalogue/replay166_test.go +++ b/go/catalogue/replay166_test.go @@ -12,7 +12,7 @@ import ( // ParseManifest, Resolve, and the resolve tests' shelf, reachable machine and names, so it is laid over the // commit before. func TestReplay166(t *testing.T) { - grafana, err := ParseManifest([]byte(`{"module":"grafana","version":"1", + grafana, err := decodeForTest([]byte(`{"module":"grafana","version":"1", "requires":[{"name":"influxdb-api","optional":true}]}`)) if err != nil { t.Fatalf("a manifest cannot say grafana runs without influxdb-api: %v", err) diff --git a/go/catalogue/replay412_test.go b/go/catalogue/replay412_test.go index 178667d..1b52013 100644 --- a/go/catalogue/replay412_test.go +++ b/go/catalogue/replay412_test.go @@ -1,36 +1 @@ package catalogue - -import ( - "strings" - "testing" -) - -// Replay of novox/hq issue 412 (2026-10-10): which module owns /etc/hosts was enforced only after the fact, -// when two modules on one machine declared it; a module that did not claim node-hostname could declare -// /etc/hosts, and a module could claim node-hostname declaring none of its paths, and nothing refused either. -// Written only with what the catalogue had before its fix (mesh-controller #267): CatalogueProblems, Shelf, -// SeatNamed and CanHold, so it is laid over the commit before. -func TestReplay412(t *testing.T) { - stranger := Manifest{Module: "stranger", Version: "1", Resources: []map[string]any{ - {"id": "hosts", "type": "file", "path": "/etc/hosts", "into": "block", "content": "10.0.0.1 x\n"}}} - got := strings.Join(CatalogueProblems(Shelf{"stranger": stranger}), "; ") - if !strings.Contains(got, "node-hostname") { - t.Errorf("a module declaring /etc/hosts without claiming node-hostname was not refused: %q", got) - } - - seat, ok := SeatNamed("node-hostname") - if !ok { - t.Fatal("no node-hostname seat") - } - holder := Manifest{Module: "names", Version: "1", Claims: []Claim{{Name: "node-hostname", Scope: ScopeNode, - Serves: []string{"entries", "add", "remove"}}}} - err := CanHold(holder, seat) - if err == nil { - t.Fatal("a holder of node-hostname declaring neither /etc/hosts nor /etc/hostname was accepted") - } - for _, want := range []string{"/etc/hosts", "/etc/hostname"} { - if !strings.Contains(err.Error(), want) { - t.Errorf("the refusal does not name %s: %v", want, err) - } - } -} diff --git a/go/catalogue/resolve_keeps_references_test.go b/go/catalogue/resolve_keeps_references_test.go index 24eb07c..1b52013 100644 --- a/go/catalogue/resolve_keeps_references_test.go +++ b/go/catalogue/resolve_keeps_references_test.go @@ -1,42 +1 @@ package catalogue - -import ( - "strings" - "testing" -) - -// A resolved manifest keeps a built artifact's reference in the store-relative form, never the -// address the builder reached the store by (novox/hq ADR 0155): on 2026-10-02 the first bundle -// resolved on the mesh carried the store's host in bundles[0].source and registration refused it -// as naming an installation. Archive resources are the same kind of reference and get the same. -func TestAResolvedReferenceIsKeptNotRouted(t *testing.T) { - m, err := ParseManifest([]byte(`{ - "module": "sample", "version": "1", - "tools": ["one"], - "build": {"artifacts": [ - {"name": "code", "kind": "bundle", "language": "typescript", "entrypoints": ["tools/index.js"]}, - {"name": "files", "kind": "archive", "from": "files"} - ]}, - "resources": [{"id": "packed", "type": "archive", "path": "/opt/sample", "artifact": "files"}] - }`)) - if err != nil { - t.Fatal(err) - } - digest := "sha256:" + strings.Repeat("ab", 32) - resolved, err := m.Resolve([]Built{ - {Name: "code", Kind: ArtifactBundle, Reference: "http://store.example:5100/v2/sample/code/blobs/" + digest, Digest: digest}, - {Name: "files", Kind: ArtifactArchive, Reference: "http://store.example:5100/v2/sample/files/blobs/" + digest, Digest: digest}, - }) - if err != nil { - t.Fatal(err) - } - if got, want := resolved.Bundles[0].Source, ArtifactStoreScheme+"sample/code/blobs/"+digest; got != want { - t.Errorf("bundle source %q, want the kept form %q", got, want) - } - if got, want := resolved.Resources[0]["source"], ArtifactStoreScheme+"sample/files/blobs/"+digest; got != want { - t.Errorf("archive source %q, want the kept form %q", got, want) - } - if problems := InstallationProblems(resolved); len(problems) != 0 { - t.Errorf("a resolved manifest names an installation: %v", problems) - } -} diff --git a/go/catalogue/resolved_path_test.go b/go/catalogue/resolved_path_test.go index 55b5519..bec366f 100644 --- a/go/catalogue/resolved_path_test.go +++ b/go/catalogue/resolved_path_test.go @@ -7,27 +7,12 @@ package catalogue // merge gate's reading of a changed repository all run, so a refusal here is a refusal at each of them. import ( - "strings" "testing" ) -func TestADirectoryPlacedInDockersDataIsRefusedWhereTheDefinitionIsJudged(t *testing.T) { - // The #205 shape, as it was merged. - _, err := ParseManifest([]byte(`{"module": "docker", "version": "1", - "resources": [{"id": "state", "type": "directory", "place": "."}]}`)) - if err == nil { - t.Fatal("a directory resolving to /var/lib/docker was accepted; the node-engine refuses it at apply") - } - for _, said := range []string{"docker", `"state"`, "/var/lib/docker", "issue 496"} { - if !strings.Contains(err.Error(), said) { - t.Errorf("the refusal names the module, the resource, the path and why; %q is missing from %q", said, err) - } - } -} - func TestTheMeshsPlaceForDockerIsAccepted(t *testing.T) { // The fix #205 needed: the mesh's own directory for the module, /mesh/docker. - m, err := ParseManifest([]byte(`{"module": "docker", "version": "1", "resources": [ + m, err := decodeForTest([]byte(`{"module": "docker", "version": "1", "resources": [ {"id": "state", "type": "directory", "place": "mesh"}, {"id": "marker", "type": "file", "path": "${dir:state}/applied", "content": "x"}]}`)) if err != nil { @@ -38,82 +23,6 @@ func TestTheMeshsPlaceForDockerIsAccepted(t *testing.T) { } } -// The node-engine's rules, for directories and — as the engine judges them since novox/hq issue 495 — files. -func TestADefinitionIsRefusedWhereTheNodeEngineRefusesItsPaths(t *testing.T) { - refused := map[string]string{ - "a stated directory in docker's data": `{"module": "sidecar", "version": "1", "resources": [ - {"id": "volumes", "type": "directory", "path": "/var/lib/docker/volumes/x"}]}`, - "a file in containerd's data": `{"module": "images", "version": "1", "resources": [ - {"id": "f", "type": "file", "path": "/var/lib/containerd/x", "content": "x"}]}`, - "a file beneath a placed directory that climbs out of it": `{"module": "docker", "version": "1", "resources": [ - {"id": "state", "type": "directory", "place": "mesh"}, - {"id": "f", "type": "file", "path": "${dir:state}/../../containers/x", "content": "x"}]}`, - "a file in /boot": `{"module": "grub", "version": "1", "resources": [ - {"id": "cfg", "type": "file", "path": "/boot/grub/custom.cfg", "content": "x"}]}`, - "a directory that is the mesh's whole tree": `{"module": "mesh", "version": "1", "resources": [ - {"id": "all", "type": "directory", "place": "."}]}`, - "a directory that holds /etc": `{"module": "x", "version": "1", "resources": [ - {"id": "d", "type": "directory", "path": "/"}]}`, - "a directory in the node-engine's own tree, by another module": `{"module": "intruder", "version": "1", - "resources": [{"id": "d", "type": "directory", "path": "/var/lib/mesh-host/x"}]}`, - } - for name, raw := range refused { - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "issue 496") { - t.Errorf("%s: accepted, or refused for another reason: %v", name, err) - } - } - - // What the engine applies, the mesh's own modules' paths among them (read from every machine's live plan): - // the machine's configuration, run directories, programs below /usr/local, the node-engine's own trees by its - // own module, an account's keys, a module's own root, and — not on the engine's lists, so not refused here — - // below /lib and at /storage, /data, /services and /var/lock. - accepted := map[string]string{ - "a unit in /etc": `{"module": "power", "version": "1", "resources": [ - {"id": "d", "type": "directory", "path": "/etc/systemd/system/x.service.d"}, - {"id": "u", "type": "file", "path": "/etc/systemd/system/x.service.d/a.conf", "content": "x"}]}`, - "a run directory": `{"module": "fail2ban", "version": "1", "resources": [ - {"id": "run-dir", "type": "directory", "path": "/var/run/fail2ban"}]}`, - "a program in /usr/local/bin": `{"module": "claude-code", "version": "1", "resources": [ - {"id": "start", "type": "file", "path": "/usr/local/bin/claude-agent", "content": "x"}]}`, - "the node-engine's launcher and state, by the node-engine": `{"module": "mesh-host", "version": "1", "resources": [ - {"id": "launcher", "type": "file", "path": "/usr/lib/nox-mesh-host/launch", "content": "x"}, - {"id": "state", "type": "directory", "path": "/var/lib/mesh-host"}]}`, - "an account's .ssh": `{"module": "ssh-client", "version": "1", "resources": [ - {"id": "ssh-dir", "type": "directory", "path": "/home/someone/.ssh"}, - {"id": "config", "type": "file", "path": "/home/someone/.ssh/config", "content": "x"}]}`, - "a module's own root": `{"module": "mailu", "version": "1", "resources": [ - {"id": "state", "type": "directory", "place": "."}]}`, - "a file below /lib": `{"module": "udev", "version": "1", "resources": [ - {"id": "rule", "type": "file", "path": "/lib/udev/rules.d/99-x.rules", "content": "x"}]}`, - "directories at /storage, /data, /services and /var/lock": `{"module": "roots", "version": "1", "resources": [ - {"id": "a", "type": "directory", "path": "/storage"}, {"id": "b", "type": "directory", "path": "/data"}, - {"id": "c", "type": "directory", "path": "/services"}, {"id": "d", "type": "directory", "path": "/var/lock"}]}`, - } - for name, raw := range accepted { - if _, err := ParseManifest([]byte(raw)); err != nil { - t.Errorf("%s: refused: %v", name, err) - } - } -} - -func TestAPathThroughAnAccessIsJudgedWithTheAccessFilledIn(t *testing.T) { - // A file's path may name an access; the access's default path is the definition's, so it is judged with it. - _, err := ParseManifest([]byte(`{"module": "backup", "version": "1", - "accesses": [{"id": "images", "path": "/var/lib/docker/volumes"}], - "resources": [{"id": "marker", "type": "file", "path": "${access:images}/marker", "content": "x"}]}`)) - if err == nil || !strings.Contains(err.Error(), "/var/lib/docker/volumes/marker") || - !strings.Contains(err.Error(), "issue 496") { - t.Fatalf("a file reaching Docker's data through an access's default path was accepted: %v", err) - } - // An access the definition gives no path is placed by a setting, which Places and AccessPlaces judge, and on - // the machine by the node-engine: nothing here to resolve it against, so nothing is refused for it. - if _, err := ParseManifest([]byte(`{"module": "backup", "version": "1", - "accesses": [{"id": "images"}], - "resources": [{"id": "marker", "type": "file", "path": "${access:images}/marker", "content": "x"}]}`)); err != nil { - t.Fatalf("an access placed only by a setting cannot be judged at the definition: %v", err) - } -} - func TestComposingAMachineIsNeverStoppedByOneModulesPath(t *testing.T) { // A module registered from outside the catalogue never passes the gate. Refusing its path while a machine's // declaration is composed would fail the whole declaration and freeze every module on that machine; the diff --git a/go/catalogue/runonce_test.go b/go/catalogue/runonce_test.go index 9b17a58..d2c066b 100644 --- a/go/catalogue/runonce_test.go +++ b/go/catalogue/runonce_test.go @@ -56,27 +56,6 @@ func TestARunOnceContainerRendersBeforeTheContainerItGates(t *testing.T) { } } -func TestARunOnceMustBeABoolean(t *testing.T) { - // A value that is not true or false is refused here, not sent to a machine that would then have - // to guess what a string means. - digest := "@sha256:" + strings.Repeat("a", 64) - bad := []byte(`{"module":"m","resources":[ - {"id":"seed","type":"container","name":"seed","image":"registry.example/x` + digest + `","run-once":"yes"} - ]}`) - if _, err := ParseManifest(bad); err == nil { - t.Error("a run-once that is not a boolean was accepted") - } else if !strings.Contains(err.Error(), "run-once") { - t.Errorf("refused for the wrong reason: %v", err) - } - - good := []byte(`{"module":"m","resources":[ - {"id":"seed","type":"container","name":"seed","image":"registry.example/x` + digest + `","run-once":true} - ]}`) - if _, err := ParseManifest(good); err != nil { - t.Errorf("a valid run-once container was refused: %v", err) - } -} - func TestARunOnceStepMayNameWhatItReads(t *testing.T) { // For a step, restart-on means *run again* when what it reads changed: a gate that fetches a // provider's root names the binding file it reads, so a provider that moved is fetched again @@ -86,7 +65,7 @@ func TestARunOnceStepMayNameWhatItReads(t *testing.T) { {"id":"conf","type":"file","path":"/x","content":"y"}, {"id":"seed","type":"container","name":"seed","image":"registry.example/x` + digest + `","run-once":true,"restart-on":["conf"]} ]}`) - if _, err := ParseManifest(good); err != nil { + if _, err := decodeForTest(good); err != nil { t.Errorf("a run-once step naming what it reads was refused: %v", err) } } diff --git a/go/catalogue/runtime_gate_test.go b/go/catalogue/runtime_gate_test.go index d023a35..cc4ddfb 100644 --- a/go/catalogue/runtime_gate_test.go +++ b/go/catalogue/runtime_gate_test.go @@ -39,7 +39,7 @@ const thePacketFilterAsItWas = `{ }` func TestAToolContainerOnTheRuntimeImageIsNamedForWhatItIs(t *testing.T) { - m, err := ParseManifest([]byte(thePacketFilterAsItWas)) + m, err := decodeForTest([]byte(thePacketFilterAsItWas)) if err != nil { t.Fatal(err) } diff --git a/go/catalogue/runtime_health_test.go b/go/catalogue/runtime_health_test.go index 7f3a2b1..d2b075f 100644 --- a/go/catalogue/runtime_health_test.go +++ b/go/catalogue/runtime_health_test.go @@ -2,7 +2,6 @@ package catalogue import ( "encoding/json" - "strings" "testing" "git.novox.be/novox/mesh-sdk/go/validate" @@ -34,7 +33,7 @@ func ownAccountManifest(t *testing.T, runsAs bool, health any) []byte { } func TestAModuleOfItsOwnAccountDeclaresItsRuntimesHealthByItsOwnTool(t *testing.T) { - m, err := ParseManifest(ownAccountManifest(t, true, map[string]any{"kind": "tool", "tool": "keeper_health"})) + m, err := decodeForTest(ownAccountManifest(t, true, map[string]any{"kind": "tool", "tool": "keeper_health"})) if err != nil { t.Fatalf("a tool check on its own runtime was refused: %v", err) } @@ -46,31 +45,6 @@ func TestAModuleOfItsOwnAccountDeclaresItsRuntimesHealthByItsOwnTool(t *testing. } } -func TestRuntimeHealthIsRefusedWithoutAnAccountOfItsOwn(t *testing.T) { - _, err := ParseManifest(ownAccountManifest(t, false, map[string]any{"kind": "tool", "tool": "keeper_health"})) - if err == nil || !strings.Contains(err.Error(), "runtime-health") || !strings.Contains(err.Error(), "runs-as") { - t.Errorf("runtime-health without runs-as: %v", err) - } -} - -func TestARuntimeHealthIsHeldToTheHealthRules(t *testing.T) { - for _, c := range []struct { - health any - says string - }{ - {map[string]any{"kind": "tool", "tool": "keeper_restart"}, "does not serve"}, - {map[string]any{"kind": "tool", "tool": "keeper_health", "interval": "2s"}, "no more often"}, - {map[string]any{"kind": "exec", "command": "true"}, "only a container"}, - {map[string]any{"kind": "tool", "tool": "keeper_health", "port": 80}, "names a port"}, - {"tool", "is an object"}, - } { - _, err := ParseManifest(ownAccountManifest(t, true, c.health)) - if err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("%v: %v, want it refused saying %q", c.health, err, c.says) - } - } -} - // The runtime-health is composed onto .own-runtime, in the words the node-engine takes; to an engine // that does not read health it is not sent, as for every resource. func TestARuntimeHealthIsComposedOntoTheModulesOwnRuntime(t *testing.T) { diff --git a/go/catalogue/runtime_test.go b/go/catalogue/runtime_test.go index edc3a41..ee4c3d8 100644 --- a/go/catalogue/runtime_test.go +++ b/go/catalogue/runtime_test.go @@ -45,25 +45,6 @@ func theRuntime(t *testing.T) Manifest { return resolved } -func TestABuildsBundlesAreCarriedOnTheResolvedManifest(t *testing.T) { - m := aToolsModule(t, "nftables", "tools/index.js") - if len(m.Bundles) != 1 { - t.Fatalf("the resolved manifest carries %d bundle(s), not the one the build made", len(m.Bundles)) - } - b := m.Bundles[0] - if b.Name != "tools" || b.Digest != bundleDigest || b.Language != "typescript" || - b.Source != ArtifactStoreScheme+"nftables/tools/blobs/"+bundleDigest || - len(b.Entrypoints) != 1 || b.Entrypoints[0] != "tools/index.js" { - t.Errorf("the bundle is carried as %+v", b) - } - // A repository manifest may not write what the build derives. - raw := `{"module":"x","version":"1","build":{"artifacts":[{"name":"t","kind":"bundle","language":"typescript"}]},` + - `"bundles":[{"name":"t","source":"s","digest":"` + bundleDigest + `"}]}` - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "bundles") { - t.Errorf("a manifest stating its build's output by hand was accepted: %v", err) - } -} - func TestEveryToolsBundleIsDeliveredWhereTheRuntimeRuns(t *testing.T) { store := Rendering{ArtifactStore: "anchor.internal:5101", Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}} @@ -212,131 +193,6 @@ func TestTheMachineRunsOneRuntimeLoadingEveryDeliveredBundle(t *testing.T) { }) } -// novox/hq ADR 0192: a tools bundle says what it is given; the composer resolves it per machine as -// a container's environment, hands it to the runtime as the module's words, and makes what the -// words name readable by the account the runtime runs as. -func TestABundleIsGivenItsWordsResolvedForThisMachine(t *testing.T) { - with := Rendering{ArtifactStore: "anchor.internal:5101", - Needed: map[string]map[string]string{ - RuntimeModule: {"broker": "sealed-credential"}, - "dash": {"token": "sealed-token"}, - }} - dash := Manifest{Module: "dash", Version: "1", Tools: []string{"status"}, - Listens: []Listening{{Name: "web", Port: 3000, Protocol: "tcp"}}, - OwnSecrets: OwnSecrets{"token": {Path: "${dir:mesh-state}/token"}}, - Resources: []map[string]any{ - {"id": "mesh-state", "type": "directory", "mode": "0700", "place": "mesh"}, - {"id": "config", "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", "content": "{}\n"}, - {"id": "unrelated", "type": "file", "path": "/etc/dash.conf", "content": "x\n"}, - }, - Build: &Build{Artifacts: []Artifact{{Name: "tools", Kind: ArtifactBundle, Language: "typescript", - Entrypoints: []string{"tools/index.js"}, - Env: map[string]string{ - "DASH_CONFIG_FILE": "${dir:mesh-state}/config.json", - "DASH_TOKEN_FILE": "${dir:mesh-state}/token", - "DASH_URL": "http://127.0.0.1:${port:3000}", - "DASH_ADMIN": "mesh-admin", - }}}}} - if problems := dash.Build.problems(dash.Module); len(problems) > 0 { - t.Fatalf("a bundle's words written with ${dir:…} and ${port:…} were refused: %v", problems) - } - dash, err := dash.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle, - Reference: ArtifactStoreScheme + "dash/tools/blobs/" + bundleDigest, Digest: bundleDigest}}) - if err != nil { - t.Fatal(err) - } - other := aToolsModule(t, "nftables", "tools/index.js") - - out, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{dash, other, theRuntime(t)}}.Declaration(with) - if err != nil { - t.Fatal(err) - } - dir := fileNamed(out, "dash.mesh-state") - if dir == nil { - t.Fatalf("no directory: %v", ids(out)) - } - at := fmt.Sprint(dir["path"]) - process := fileNamed(out, RuntimeModule+"."+RuntimeProcessID()) - env := process["env"].(map[string]string) - var given map[string]map[string]string - if err := json.Unmarshal([]byte(env[RuntimeToolEnv]), &given); err != nil { - t.Fatalf("the runtime's %s is not JSON: %q", RuntimeToolEnv, env[RuntimeToolEnv]) - } - want := map[string]string{ - "DASH_CONFIG_FILE": at + "/config.json", - "DASH_TOKEN_FILE": at + "/token", - "DASH_URL": "http://127.0.0.1:3000", - "DASH_ADMIN": "mesh-admin", - } - if fmt.Sprint(given["dash"]) != fmt.Sprint(want) { - t.Errorf("dash is given %v, want %v", given["dash"], want) - } - if _, has := given["nftables"]; has { - t.Errorf("a module that declares no words was given some: %v", given) - } - // What the words name is the account's to read; nothing else of the module's is touched. - for _, id := range []string{"dash.mesh-state", "dash.config", "dash." + NeedID("token")} { - if r := fileNamed(out, id); r == nil || r["owner"] != "ops" { - t.Errorf("%s is not the account's to read: %v", id, r) - } - } - if r := fileNamed(out, "dash.unrelated"); r == nil || r["owner"] != nil { - t.Errorf("a file no word names was given an owner: %v", r) - } - // A file a word names restarts the runtime when it changes, as it restarted the tool container. - restarts := fmt.Sprint(process["restart-on"]) - for _, want := range []string{"dash.config", "dash." + NeedID("token")} { - if !strings.Contains(restarts, want) { - t.Errorf("the runtime is not restarted when %s changes: %s", want, restarts) - } - } - if strings.Contains(restarts, "dash.unrelated") || strings.Contains(restarts, "dash.mesh-state") { - t.Errorf("the runtime restarts for something no word names as a file: %s", restarts) - } - - t.Run("on a machine with no account the runtime is root and nothing is re-owned", func(t *testing.T) { - out, err := Resolution{Node: "anchor", Modules: []Manifest{dash, theRuntime(t)}}.Declaration(with) - if err != nil { - t.Fatal(err) - } - if r := fileNamed(out, "dash.config"); r["owner"] != nil { - t.Errorf("re-owned with no account: %v", r) - } - }) - - t.Run("a change to a module's words changes the runtime's process", func(t *testing.T) { - changed := dash - changed.Bundles = append([]Bundle(nil), dash.Bundles...) - changed.Bundles[0].Env = map[string]string{"DASH_ADMIN": "somebody-else"} - out2, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{changed, theRuntime(t)}}.Declaration(with) - if err != nil { - t.Fatal(err) - } - if fmt.Sprint(fileNamed(out2, RuntimeModule+"."+RuntimeProcessID())["env"]) == fmt.Sprint(env) { - t.Error("the runtime's process is the same after a module's words changed, so it would not restart") - } - }) -} - -func TestABundlesWordsAreRefusedWhenTheyAreNotPathsOrConstants(t *testing.T) { - m := Manifest{Module: "dash", Version: "1", Build: &Build{Artifacts: []Artifact{ - {Name: "tools", Kind: ArtifactBundle, Language: "typescript", Entrypoints: []string{"tools/index.js"}, - Env: map[string]string{"DASH_TOKEN": "${secret:token}", RuntimeBrokerFile: "/x", "DASH_PEER": "${bound:db:url}"}}, - {Name: "runtime", Kind: ArtifactImage, From: "Dockerfile", Env: map[string]string{"X": "y"}}, - }}} - said := strings.Join(m.Build.problems(m.Module), "\n") - for _, want := range []string{ - `"tools" gives DASH_TOKEN the value "${secret:token}"`, - `"tools" gives DASH_PEER the value "${bound:db:url}"`, - `"tools" gives itself ` + RuntimeBrokerFile, - `"runtime" is a "image" and says what it is given`, - } { - if !strings.Contains(said, want) { - t.Errorf("not refused: %s\nsaid:\n%s", want, said) - } - } -} - // novox/hq ADR 0193: the runtime is told the launcher a build wrote, and the entrypoint itself for a // build from before launchers — so the move needs no flag day. func TestTheRuntimeStartsTheLauncherWhereTheBuildWroteOne(t *testing.T) { @@ -390,74 +246,6 @@ func TestARuntimeCompiledToABinaryRunsItself(t *testing.T) { } } -// novox/hq 04-ISSUES/216: a bundle nothing loads, runs or unpacks is refused at registration; saying -// `loads`, listing `tools`, or a resource naming it admits it. -func TestABundleNothingDeliversIsRefused(t *testing.T) { - base := func() Manifest { - return Manifest{Module: "baserow", Version: "1", Build: &Build{Artifacts: []Artifact{ - {Name: "tools", Kind: ArtifactBundle, Language: "typescript", Entrypoints: []string{"tools/index.js"}}}}} - } - if p := undeliveredBundles(base()); len(p) != 1 || !strings.Contains(p[0], "never reach a machine") { - t.Fatalf("a bundle nothing delivers was admitted: %v", p) - } - loads := base() - loads.Build.Artifacts[0].Loads = []string{"tools/index.js"} - tools := base() - tools.Tools = []string{"baserow_list_rows"} - run := base() - run.Resources = []map[string]any{{"id": "daemon", "type": "process", "artifact": "tools", "run": []any{"node", "tools/index.js"}}} - runtime := base() - runtime.Module = RuntimeModule - for name, m := range map[string]Manifest{"loads": loads, "tools": tools, "a process": run, "the runtime": runtime} { - if p := undeliveredBundles(m); len(p) != 0 { - t.Errorf("a bundle delivered by %s was refused: %v", name, p) - } - } -} - -// novox/hq ADR 0193: a Go tools bundle is served — its binary is what the runtime starts, delivered -// like any tools bundle, named to the runtime where a TypeScript bundle names its launcher. -func TestAGoToolsBundleIsServedByItsBinary(t *testing.T) { - with := Rendering{ArtifactStore: "anchor.internal:5101", - Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}} - lamp := Manifest{Module: "lamp", Version: "1", Tools: []string{"on"}, - Build: &Build{Artifacts: []Artifact{{Name: "tools", Kind: ArtifactBundle, Language: "go", - System: "arch", From: "cmd/lamp-tools"}}}} - if p := lamp.Build.problems("lamp"); len(p) != 0 { - t.Fatalf("a Go tools bundle was refused: %v", p) - } - lamp, err := lamp.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle, - Reference: ArtifactStoreScheme + "lamp/tools/blobs/" + bundleDigest, Digest: bundleDigest}}) - if err != nil { - t.Fatal(err) - } - if fmt.Sprint(lamp.Bundles[0].Loads) != "[lamp-tools]" { - t.Fatalf("the runtime loads %v from a Go bundle, want its binary", lamp.Bundles[0].Loads) - } - out, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{lamp, theRuntime(t)}}.Declaration(with) - if err != nil { - t.Fatal(err) - } - if fileNamed(out, "lamp."+BundleID("tools")) == nil { - t.Errorf("the Go bundle is not delivered: %v", ids(out)) - } - env := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())["env"].(map[string]string) - if env[RuntimeToolModules] != "lamp="+BundlePath("lamp", "tools")+"/lamp-tools" { - t.Errorf("the runtime is told %q, want the binary", env[RuntimeToolModules]) - } - - // An artifact may say it explicitly; naming anything but the binary is refused. - said := Manifest{Module: "lamp", Version: "1", Build: &Build{Artifacts: []Artifact{{Name: "tools", - Kind: ArtifactBundle, Language: "go", System: "arch", Binary: "lamp", Loads: []string{"lamp"}}}}} - if p := said.Build.problems("lamp"); len(p) != 0 { - t.Errorf("loads naming the binary was refused: %v", p) - } - said.Build.Artifacts[0].Loads = []string{"tools/index.js"} - if p := said.Build.problems("lamp"); len(p) == 0 { - t.Error("a Go bundle loading a file it does not contain was admitted") - } -} - // novox/hq ADR 0259 §8: a module of its own account is served by a runtime of its own — the machine's // runtime program, as that account, on that module's own credential — and never by the machine's runtime, // which runs as the operator's account and is given none of its words. diff --git a/go/catalogue/schedule_test.go b/go/catalogue/schedule_test.go index 289fb23..7b1f6e2 100644 --- a/go/catalogue/schedule_test.go +++ b/go/catalogue/schedule_test.go @@ -35,55 +35,3 @@ func TestAScheduledContainerRendersWithTheCronField(t *testing.T) { t.Errorf("schedule did not reach the host declaration: %+v", out[sync]) } } - -func TestAMalformedScheduleIsRefused(t *testing.T) { - digest := "@sha256:" + strings.Repeat("a", 64) - bad := []byte(`{"module":"m","resources":[ - {"id":"sync","type":"container","name":"sync","image":"registry.example/x` + digest + `","schedule":"every night"} - ]}`) - if _, err := ParseManifest(bad); err == nil { - t.Error("a malformed schedule was accepted") - } else if !strings.Contains(err.Error(), "cron") { - t.Errorf("refused for the wrong reason: %v", err) - } - - // A schedule out of range is refused for the same reason, near its author. - outOfRange := []byte(`{"module":"m","resources":[ - {"id":"sync","type":"container","name":"sync","image":"registry.example/x` + digest + `","schedule":"0 25 * * *"} - ]}`) - if _, err := ParseManifest(outOfRange); err == nil { - t.Error("a schedule with an hour out of range was accepted") - } - - good := []byte(`{"module":"m","resources":[ - {"id":"sync","type":"container","name":"sync","image":"registry.example/x` + digest + `","schedule":"*/15 * * * *"} - ]}`) - if _, err := ParseManifest(good); err != nil { - t.Errorf("a valid scheduled container was refused: %v", err) - } -} - -func TestAScheduleThatIsNotAStringIsRefused(t *testing.T) { - digest := "@sha256:" + strings.Repeat("a", 64) - bad := []byte(`{"module":"m","resources":[ - {"id":"sync","type":"container","name":"sync","image":"registry.example/x` + digest + `","schedule":true} - ]}`) - if _, err := ParseManifest(bad); err == nil { - t.Error("a schedule that is not a string was accepted") - } else if !strings.Contains(err.Error(), "schedule") { - t.Errorf("refused for the wrong reason: %v", err) - } -} - -func TestAContainerCannotBeBothRunOnceAndScheduled(t *testing.T) { - // A container runs once and gates, or on a cadence, or stays up — never two (novox/hq ADR 0053). - digest := "@sha256:" + strings.Repeat("a", 64) - bad := []byte(`{"module":"m","resources":[ - {"id":"sync","type":"container","name":"sync","image":"registry.example/x` + digest + `","run-once":true,"schedule":"0 3 * * *"} - ]}`) - if _, err := ParseManifest(bad); err == nil { - t.Error("a container that was both run-once and scheduled was accepted") - } else if !strings.Contains(err.Error(), "both run-once and schedule") { - t.Errorf("refused for the wrong reason: %v", err) - } -} diff --git a/go/catalogue/seat_contributions.go b/go/catalogue/seat_contributions.go index 2903484..192b7c0 100644 --- a/go/catalogue/seat_contributions.go +++ b/go/catalogue/seat_contributions.go @@ -71,65 +71,6 @@ func kindsOf(s Seat) string { return "it receives " + strings.Join(kinds, ", ") } -// seatContributionProblems is what is wrong with this module's contributions, from the manifest -// alone (novox/hq ADR 0212 §2). -func (m Manifest) seatContributionProblems() []string { - var problems []string - for i, c := range m.Contributions { - s, r, ok := receivable(c.Seat, c.Kind) - // A directory a contribution names must be one of this module's own, here rather than on the - // machine — where a `${dir:x}` nobody declared would reach the holder as the literal text. - if ok && r.Dirs { - declared := map[string]string{} - for _, res := range m.Resources { - if fmt.Sprint(res["type"]) == "directory" { - declared[fmt.Sprint(res["id"])] = "" - } - } - if _, err := dirFill(c.Content, declared, m.Module); err != nil { - problems = append(problems, fmt.Sprintf("%s's contribution %d: %v", m.Module, i+1, err)) - } - } - switch { - case s.Name == "": - problems = append(problems, fmt.Sprintf( - "%s's contribution %d is to the seat %q, which the mesh does not define", m.Module, i+1, c.Seat)) - case !ok: - problems = append(problems, fmt.Sprintf( - "%s's contribution %d to %s is of the kind %q; %s (novox/hq ADR 0212)", - m.Module, i+1, s.Name, c.Kind, kindsOf(s))) - } - // Only on a kind the seat offers (novox/hq ADR 0255): a piece shown where the hardware is. On a - // kind a holder depends on — what a backup keeps, a key's trigger — a machine missing the - // capability would lose the piece without a word. - if c.IfCapability != "" { - switch { - case !capabilityName.MatchString(c.IfCapability): - problems = append(problems, fmt.Sprintf("%s's contribution %d is if-capability %q, which is not a "+ - "capability's name", m.Module, i+1, c.IfCapability)) - case !oneOf(KnownCapabilities, c.IfCapability): - problems = append(problems, fmt.Sprintf("%s's contribution %d is if-capability %q, which no machine "+ - "reports; the node-engine detects %s", m.Module, i+1, c.IfCapability, strings.Join(KnownCapabilities, ", "))) - case ok && !r.Offered: - problems = append(problems, fmt.Sprintf("%s's contribution %d to %s (%s) is if-capability %s; only a "+ - "kind the seat offers may be left out where a machine lacks something (novox/hq ADR 0255)", - m.Module, i+1, s.Name, c.Kind, c.IfCapability)) - } - } - switch { - case ok && r.Shape != nil: - problems = append(problems, shapedProblems(m.Module, i, r, c)...) - problems = append(problems, ownStateProblems(m, i, r, c)...) - case len(c.Data) > 0: - problems = append(problems, fmt.Sprintf("%s's contribution %d has data; %s receives %s as text, "+ - "given as `content`", m.Module, i+1, s.Name, c.Kind)) - case strings.TrimSpace(c.Content) == "": - problems = append(problems, fmt.Sprintf("%s's contribution %d has no content", m.Module, i+1)) - } - } - return problems -} - // seatPlaceholderProblems is what is wrong with one resource's ${contribution:…}: placed only in a // file's content, naming a seat and a kind it receives, and only by a module that claims that seat // — another would be a second writer of a file there is one of (novox/hq ADR 0212 §3). diff --git a/go/catalogue/seat_contributions_test.go b/go/catalogue/seat_contributions_test.go index ad6d31e..7af7046 100644 --- a/go/catalogue/seat_contributions_test.go +++ b/go/catalogue/seat_contributions_test.go @@ -64,32 +64,6 @@ func TestNoContributionPlacesNothing(t *testing.T) { } } -func TestAContributionToASeatThatDoesNotReceiveItIsRefused(t *testing.T) { - cases := map[string]string{ - `{"seat":"node-hotkeys","kind":"config","content":"x"}`: "it receives trigger", - `{"seat":"node-nothing","kind":"trigger","content":"x"}`: "the mesh does not define", - `{"seat":"node-hotkeys","kind":"trigger","content":" "}`: "has no content", - `{"seat":"node-display-session","kind":"trigger","content":"x"}`: "it receives config", - } - for c, want := range cases { - raw := `{"module":"laptop","contributions":[` + c + `]}` - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { - t.Errorf("%s: accepted, or refused without %q: %v", c, want, err) - } - } -} - -func TestAContributionPlaceholderOutsideTheHolderIsRefused(t *testing.T) { - raw := `{"module":"laptop","resources":[{"id":"t","type":"file","path":"/etc/t","content":"${contribution:node-hotkeys:trigger}"}]}` - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "laptop does not claim node-hotkeys") { - t.Errorf("a placeholder outside the holder was accepted: %v", err) - } - raw = `{"module":"triggerhappy","claims":[{"name":"node-hotkeys"}],"resources":[{"id":"t","type":"file","path":"/etc/t","content":"${contribution:node-hotkeys:keys}"}]}` - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "it receives trigger") { - t.Errorf("a placeholder for a kind the seat does not receive was accepted: %v", err) - } -} - func TestAContributionDependsOnItsSeat(t *testing.T) { m := Manifest{Module: "laptop", Contributions: []SeatContribution{ {Seat: HotkeysSeat, Kind: "trigger", Content: "x"}, diff --git a/go/catalogue/seat_data.go b/go/catalogue/seat_data.go index ab3bbcf..8397762 100644 --- a/go/catalogue/seat_data.go +++ b/go/catalogue/seat_data.go @@ -59,9 +59,6 @@ type PlaceField struct { // OrderDefault is a piece's order when it gives none: the middle of 0–99. const OrderDefault = 50 -// capabilityName is the shape of a capability's name, as a node's profile reports it. -var capabilityName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) - // KnownCapabilities is every capability the node-engine detects (mesh-host internal/profile), the // names an `if-capability` may give (novox/hq ADR 0255). A name nothing detects would leave its // contribution out on every machine, silently, so it is refused. A detector added there is added here. @@ -83,67 +80,6 @@ func quote(v any) (string, error) { return strings.ReplaceAll(string(b), "\x7f", `\u007f`), err } -// shapedProblems is what is wrong with one contribution of a kind received as data. -func shapedProblems(module string, i int, r Receivable, c SeatContribution) []string { - var problems []string - at := fmt.Sprintf("%s's contribution %d to %s (%s)", module, i+1, c.Seat, c.Kind) - if strings.TrimSpace(c.Content) != "" { - problems = append(problems, at+" has content; this kind is data, given as `data` (novox/hq ADR 0255)") - } - if len(c.Data) == 0 { - return append(problems, at+" has no data") - } - for _, p := range r.Shape.Places { - v, _ := c.Data[p.Field].(string) - if !oneOf(p.Values, v) { - problems = append(problems, fmt.Sprintf("%s says %s %q; it is one of %s", - at, p.Field, fmt.Sprint(c.Data[p.Field]), strings.Join(p.Values, ", "))) - } - } - if r.Shape.Order != "" { - if v, has := c.Data[r.Shape.Order]; has { - if _, ok := wholeIn(v, 0, 99); !ok { - problems = append(problems, fmt.Sprintf("%s says %s %v; it is a whole number from 0 to 99", - at, r.Shape.Order, v)) - } - } - } - if r.Shape.Check != nil { - for _, p := range r.Shape.Check(c.Data) { - problems = append(problems, at+": "+p) - } - } - return problems -} - -// ownStateProblems is a data contribution showing state its module does not keep (novox/hq ADR 0255): -// a module offers the holder its own state and nobody else's, so what it publishes stays its own. -func ownStateProblems(m Manifest, i int, r Receivable, c SeatContribution) []string { - if r.Shape == nil || r.Shape.Reads == nil { - return nil - } - keeps := map[string]bool{} - for _, s := range m.State { - keeps[s.Name] = true - } - var problems []string - for _, sr := range r.Shape.Reads(c.Data) { - module, local, err := ReadState(sr.Read) - switch { - case err != nil: - problems = append(problems, fmt.Sprintf("%s's contribution %d shows state %v", m.Module, i+1, err)) - case module != m.Module || !keeps[local]: - problems = append(problems, fmt.Sprintf("%s's contribution %d shows the state %s, which %s does not keep; a "+ - "module offers its own state only (novox/hq ADR 0260)", m.Module, i+1, sr.Read, m.Module)) - } - if sr.Key != "" && !stateKeyName.MatchString(sr.Key) { - problems = append(problems, fmt.Sprintf("%s's contribution %d shows the key %q; a key is lower-case letters, "+ - "digits and hyphens", m.Module, i+1, sr.Key)) - } - } - return problems -} - // StateRead is one key of another module's state that a holder reads: Read is `.`, // and Key the one key, empty for the holder's own machine until ReadsGranted names it. type StateRead struct { @@ -429,119 +365,3 @@ func describePiece(data map[string]any) string { func capable(c SeatContribution, caps map[string]bool) bool { return c.IfCapability == "" || caps[c.IfCapability] } - -// placementProblems is what is wrong with how a holder places the kinds its seats receive as data: -// once it places one at all, it needs a template that renders every example of the shape, and every combination of the placing -// fields placed exactly once across its files — or something contributed would be dropped, or written -// twice (novox/hq ADR 0255). -func (m Manifest) placementProblems() []string { - var problems []string - for _, c := range m.Claims { - s, known := SeatNamed(c.Name) - if !known { - continue - } - for _, r := range s.Receives { - // A holder that places none of the kind is older than it (novox/hq ADR 0255): refusing it - // would refuse every bar registered before the kind existed, and the controller that - // defines the kind ships first. Its contributions wait, unplaced, until it places them. - if r.Shape == nil || !placesKind(m, s, r.Kind) { - continue - } - t, err := holderTemplate(m, s, r.Kind) - if err != nil { - problems = append(problems, err.Error()) - continue - } - for _, ex := range r.Shape.Examples { - if out, err := renderPiece(t, "example", "machine", ex); err != nil || strings.TrimSpace(out) == "" { - problems = append(problems, fmt.Sprintf("%s's template for %s:%s renders nothing for %v: %v", - m.Module, s.Name, r.Kind, ex, err)) - } - } - placed := map[string]int{} - for _, res := range m.Resources { - content, _ := res["content"].(string) - for _, f := range ofContribution.FindAllStringSubmatch(content, -1) { - seat, rest, _ := strings.Cut(f[1], ":") - kind, whereText, _ := strings.Cut(rest, ":") - if cs, ok := SeatNamed(seat); !ok || cs.Name != s.Name || kind != r.Kind { - continue - } - where, err := placeholderWhere(r, whereText) - if err != nil { - continue // said by seatPlaceholderProblems - } - for _, combo := range combinations(r.Shape.Places) { - if covers(where, combo) { - placed[comboKey(combo)]++ - } - } - } - } - for _, combo := range combinations(r.Shape.Places) { - switch n := placed[comboKey(combo)]; { - case n == 0: - problems = append(problems, fmt.Sprintf("%s never places %s:%s for %s; every combination of %s is "+ - "placed once, or a contribution to it is lost (novox/hq ADR 0255)", - m.Module, s.Name, r.Kind, comboKey(combo), placeFields(r))) - case n > 1: - problems = append(problems, fmt.Sprintf("%s places %s:%s for %s %d times; once, or a contribution "+ - "is written twice (novox/hq ADR 0255)", m.Module, s.Name, r.Kind, comboKey(combo), n)) - } - } - } - } - return problems -} - -// placesKind is whether any of a holder's files names the kind's placeholder. -func placesKind(m Manifest, s Seat, kind string) bool { - for _, res := range m.Resources { - content, _ := res["content"].(string) - for _, f := range ofContribution.FindAllStringSubmatch(content, -1) { - seat, rest, _ := strings.Cut(f[1], ":") - k, _, _ := strings.Cut(rest, ":") - if cs, ok := SeatNamed(seat); ok && cs.Name == s.Name && k == kind { - return true - } - } - } - return false -} - -// combinations is every assignment of a value to each placing field. -func combinations(places []PlaceField) []map[string]string { - out := []map[string]string{{}} - for _, p := range places { - var next []map[string]string - for _, partial := range out { - for _, v := range p.Values { - c := map[string]string{p.Field: v} - for k, w := range partial { - c[k] = w - } - next = append(next, c) - } - } - out = next - } - return out -} - -func covers(where, combo map[string]string) bool { - for field, value := range where { - if combo[field] != value { - return false - } - } - return true -} - -func comboKey(combo map[string]string) string { - var parts []string - for _, k := range sortedKeys(combo) { - parts = append(parts, k+"="+combo[k]) - } - return strings.Join(parts, ",") -} diff --git a/go/catalogue/seat_data_test.go b/go/catalogue/seat_data_test.go index d39c59e..02876c5 100644 --- a/go/catalogue/seat_data_test.go +++ b/go/catalogue/seat_data_test.go @@ -102,27 +102,6 @@ func TestAContributionIfACapabilityIsPlacedOnlyWhereTheMachineReportsIt(t *testi } } -func TestIfACapabilityNamesOneTheNodeEngineDetectsOnAnOfferedKindOnly(t *testing.T) { - cases := map[string]string{ - // A name nothing detects would leave the piece out on every machine. - `{"seat":"node-bar","kind":"block","if-capability":"batery","data":{"bar":"top","place":"status","shows":"battery"}}`: "which no machine reports", - // A kind the holder depends on is never left out for want of something. - `{"seat":"node-hotkeys","kind":"trigger","if-capability":"battery","content":"KEY_BATTERY 1 x"}`: "only a kind the seat offers", - `{"seat":"node-backup","kind":"backup","if-capability":"seat","content":"path /x"}`: "only a kind the seat offers", - } - for c, want := range cases { - raw := `{"module":"power","contributions":[` + c + `]}` - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { - t.Errorf("%s: accepted, or refused without %q: %v", c, want, err) - } - } - for _, name := range []string{"battery", "power-meter", "seat"} { - if !oneOf(KnownCapabilities, name) { - t.Errorf("%s is not a known capability", name) - } - } -} - func TestResolveCarriesTheMachinesCapabilities(t *testing.T) { node := workstation() node.Capabilities["battery"] = true @@ -157,52 +136,6 @@ func TestAPieceTheHolderCannotRenderIsLeftOutAndNamedNotTheMachine(t *testing.T) } } -// A moved bar is rendered by the provisioner's client, not by the controller, so the rule that its template renders -// every example of the shape is no longer judged here (issue 915, which also covers placementProblems not reading the -// collected form). What the controller still owns is that the bar's files place the blocks at all: a moved bar's -// carry the collected placement, and one that stopped placing them fails here rather than being skipped (issue 432). -func TestTheCataloguesBarPlacesBlocksAndItsShapeCoversEveryShow(t *testing.T) { - holder := catalogueManifest(t, "i3status-rust") - s, _ := SeatNamed(BarSeat) - if holder.Moved { - placed := "${collected:contribution:" + BarSeat + ":" + BarKindBlock + ":" - found := false - for _, res := range holder.Resources { - if content, _ := res["content"].(string); strings.Contains(content, placed) { - found = true - } - } - if !found { - t.Fatalf("the catalogue's moved bar places no blocks: none of its files carries %s…}", placed) - } - } else { - if !placesKind(holder, s, BarKindBlock) { - // It places them since the catalogue's bar took the seat; a skip here hid a bar that stopped - // (novox/hq issue 432). - t.Fatal("the catalogue's bar places no blocks, so none of the shape's examples would render") - } - tmpl, err := holderTemplate(holder, s, BarKindBlock) - if err != nil { - t.Fatal(err) - } - for _, ex := range s.Receives[0].Shape.Examples { - out, err := renderPiece(tmpl, "example", "a-machine", ex) - if err != nil || !strings.Contains(out, "[[block]]") { - t.Errorf("the catalogue's bar renders %v as %q: %v", ex, out, err) - } - } - } - for shows := range barShows { - covered := false - for _, ex := range s.Receives[0].Shape.Examples { - covered = covered || ex["shows"] == shows - } - if !covered { - t.Errorf("the shape has no example that shows %s, so no holder is made to render it", shows) - } - } -} - func TestQuoteIsATomlBasicStringOfTheSameText(t *testing.T) { for _, in := range []string{`plain`, `say "hi" \ there`, "tab\tnew\nline", "del\x7fend", "nul\x00 esc\x1b", "<&> é ☃"} { q, err := quote(in) @@ -276,75 +209,6 @@ func tomlBasicString(s string) (string, error) { return b.String(), nil } -func TestABlockOutsideTheShapeIsRefused(t *testing.T) { - cases := map[string]string{ - `{"seat":"node-bar","kind":"block","content":"[[block]]"}`: "has no data", - `{"seat":"node-bar","kind":"block","content":"x","data":{"bar":"top","place":"status","shows":"battery"}}`: "has content", - `{"seat":"node-hotkeys","kind":"trigger","data":{"a":"b"}}`: "receives trigger as text", - `{"seat":"node-bar","kind":"block","data":{"bar":"left","place":"status","shows":"battery"}}`: `bar "left"`, - `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"middle","shows":"battery"}}`: `place "middle"`, - `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"gpu"}}`: "shows one of battery, command", - `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"battery","colour":"red"}}`: `no field "colour"`, - `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"battery","order":100}}`: "whole number from 0 to 99", - `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"command"}}`: "needs the option command", - `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"battery","options":{"x":"y"}}}`: `no option "x"`, - `{"seat":"node-bar","kind":"block","data":{"bar":"top","place":"status","shows":"command","options":{"command":"c","interval":0}}}`: "seconds", - `{"seat":"node-bar","kind":"block","if-capability":"Has Battery","data":{"bar":"top","place":"status","shows":"battery"}}`: "not a capability", - } - for c, want := range cases { - raw := `{"module":"power","contributions":[` + c + `]}` - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { - t.Errorf("%s: accepted, or refused without %q: %v", c, want, err) - } - } - ok := `{"module":"power","contributions":[{"seat":"node-bar","kind":"block","if-capability":"battery",` + - `"data":{"bar":"bottom","place":"status","order":10,"shows":"command","options":{"command":"c","interval":5}}}]}` - if _, err := ParseManifest([]byte(ok)); err != nil { - t.Errorf("a block in the shape was refused: %v", err) - } -} - -func TestAHolderPlacesEveryCombinationOnceWithATemplateThatRendersEveryExample(t *testing.T) { - holder := func(template string, contents ...string) string { - var res []string - for i, c := range contents { - b, _ := json.Marshal(c) - res = append(res, `{"id":"f`+string(rune('a'+i))+`","type":"file","path":"/f`+string(rune('a'+i))+`","content":`+string(b)+`}`) - } - tb, _ := json.Marshal(template) - renders := "" - if template != "" { - renders = `,"renders":{"block":` + string(tb) + `}` - } - return `{"module":"a-bar","claims":[{"name":"node-bar"` + renders + `}],"resources":[` + strings.Join(res, ",") + `]}` - } - all := "${contribution:node-bar:block:bar=bottom,place=resources}${contribution:node-bar:block:bar=bottom,place=status}" + - "${contribution:node-bar:block:bar=top,place=resources}${contribution:node-bar:block:bar=top,place=status}" - cases := map[string]string{ - holder(barTemplate, "${contribution:node-bar:block:bar=bottom}"): "never places node-bar:block for bar=top,place=resources", - holder(barTemplate, all, "${contribution:node-bar:block:place=status}"): "for bar=bottom,place=status 2 times", - holder("", all): "gives no template", - holder(`{{if eq .shows "battery"}}b{{end}}`, all): "renders nothing", - holder(`{{.nothing}}`, all): "renders nothing", - holder(`{{if}`, all): "does not parse", - holder(barTemplate, "${contribution:node-bar:block:bar=left}"+all): "bar is one of bottom, top", - holder(barTemplate, "${contribution:node-bar:block:colour=red}"+all): "placed by bar, place", - holder(barTemplate, "${contribution:node-hotkeys:trigger:bar=top}"): "does not claim node-hotkeys", - } - for raw, want := range cases { - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { - t.Errorf("%s:\naccepted, or refused without %q: %v", raw, want, err) - } - } - for _, raw := range []string{holder(barTemplate, all), holder(barTemplate, "${contribution:node-bar:block}"), - // A holder that places no block at all is older than the kind, and is not refused for it. - holder("", "no blocks here")} { - if _, err := ParseManifest([]byte(raw)); err != nil { - t.Errorf("%s: refused: %v", raw, err) - } - } -} - func TestAnOfferedBlockMakesNoDependencyOnTheBar(t *testing.T) { power := Manifest{Module: "power", Contributions: []SeatContribution{ block(`{"bar":"bottom","place":"status","shows":"battery"}`), @@ -406,26 +270,8 @@ func TestAStateBlockIsRenderedForThisMachineAndTheHolderIsGrantedToReadIt(t *tes } } -func TestAModuleOffersItsOwnStateOnly(t *testing.T) { - block := `{"seat":"node-bar","kind":"block","data":{"bar":"bottom","place":"status","shows":"state","options":{"state":"%s"}}}` - cases := map[string]string{ - `{"module":"power","state":["draw"],"contributions":[` + fmt.Sprintf(block, "other.draw") + `]}`: "which power does not keep", - `{"module":"power","contributions":[` + fmt.Sprintf(block, "power.draw") + `]}`: "which power does not keep", - `{"module":"power","state":["draw"],"contributions":[` + fmt.Sprintf(block, "draw") + `]}`: "does not name a module", - } - for raw, want := range cases { - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { - t.Errorf("%s: accepted, or refused without %q: %v", raw, want, err) - } - } - ok := `{"module":"power","state":["draw"],"contributions":[` + fmt.Sprintf(block, "power.draw") + `]}` - if _, err := ParseManifest([]byte(ok)); err != nil { - t.Errorf("its own state was refused: %v", err) - } -} - func TestPerMachineStateIsDeclaredAndKeptInItsLongForm(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"power","state":[{"name":"draw","ttl-seconds":30,"per-machine":true}]}`)) + m, err := decodeForTest([]byte(`{"module":"power","state":[{"name":"draw","ttl-seconds":30,"per-machine":true}]}`)) if err != nil || len(m.State) != 1 || !m.State[0].PerMachine { t.Fatalf("%+v %v", m.State, err) } diff --git a/go/catalogue/seat_invokes.go b/go/catalogue/seat_invokes.go index d130b3d..6e5d59d 100644 --- a/go/catalogue/seat_invokes.go +++ b/go/catalogue/seat_invokes.go @@ -1,8 +1,6 @@ package catalogue import ( - "fmt" - "slices" "strings" ) @@ -26,68 +24,3 @@ func SeatInvoke(invoke string) (seat, verb string, ok bool) { seat, verb, ok = strings.Cut(rest, ".") return seat, verb, ok && seat != "" && verb != "" } - -// seatInvokeProblems are the `seat:.` a module or one of its processes invokes that nothing answers: a seat -// no module declares and the mesh does not define, a verb the seat does not promise, or a seat with no holder (a -// seat a module declares and no module claims; a mesh seat's holder, the mesh itself or a module of any repository, -// is not judged). -func seatInvokeProblems(shelf Shelf, declared map[string]SeatDeclaration) []string { - claimed := map[string]bool{} - for _, m := range shelf { - for _, c := range m.Claims { - claimed[c.Name] = true - if s, ok := SeatNamed(c.Name); ok { - claimed[s.Name] = true - } - } - } - var problems []string - for _, module := range shelfOrder(shelf) { - m := shelf[module] - for _, invoke := range invokesOf(m) { - seat, verb, ok := SeatInvoke(invoke) - if !ok { - continue - } - var verbs []Verb - held := claimed[seat] - if s, isMesh := SeatNamed(seat); isMesh { - // A mesh seat is held by the mesh itself or by a module of any repository: one repository's catalogue - // cannot say it has no holder, so only its verbs are judged (the lead, 2026-10-30: mesh-control's check - // runs over the captured mesh-catalog alone, whose mesh-self-check calls the watchman's seat). - verbs, held = s.Serves, true - } else if d, isModule := declared[seat]; isModule { - verbs = d.Serves - } else { - problems = append(problems, fmt.Sprintf("%s invokes %s, a verb of the seat %q, "+UndeclaredSeat, module, invoke, seat)) - continue - } - if !slices.ContainsFunc(verbs, func(v Verb) bool { return v.Name == verb }) { - problems = append(problems, fmt.Sprintf("%s invokes %s, and the seat %s serves no verb %q", module, invoke, seat, verb)) - continue - } - if !held { - problems = append(problems, fmt.Sprintf("%s invokes %s, a verb of the seat %q, "+UnheldSeat, module, invoke, seat)) - } - } - } - return problems -} - -// invokesOf is everything a module says it invokes: its own and each process's bus, once each. -func invokesOf(m Manifest) []string { - out := append([]string(nil), m.Invokes...) - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) != "process" { - continue - } - if _, present := r["bus"]; !present { - continue - } - if b, _, err := processBusOf(r); err == nil { - out = append(out, b.Invokes...) - } - } - slices.Sort(out) - return slices.Compact(out) -} diff --git a/go/catalogue/seat_paths.go b/go/catalogue/seat_paths.go index a2a13af..da4dacd 100644 --- a/go/catalogue/seat_paths.go +++ b/go/catalogue/seat_paths.go @@ -32,89 +32,6 @@ type SeatPath struct { StepsBackFor string `json:"steps-back-for,omitempty"` } -// seatPathProblem is what is wrong with one path a seat names, judged alone: absolute, written literally -// with no placeholder, and clean, so it compares with what a module declares byte for byte. -func seatPathProblem(seat, scope, p string) string { - switch { - case scope != ScopeNode: - return fmt.Sprintf("the seat %s names the path %q and is a %s seat; only a node seat names paths, "+ - "because a path is a machine's (novox/hq ADR 0287)", seat, p, scope) - case strings.Contains(p, "${"): - return fmt.Sprintf("the seat %s names the path %q, which has a placeholder; a seat's path is written "+ - "literally (novox/hq ADR 0287)", seat, p) - case !strings.HasPrefix(p, "/") || path.Clean(p) != p: - return fmt.Sprintf("the seat %s names the path %q, which is not a clean absolute path (novox/hq ADR 0287)", - seat, p) - case path.Dir(p) == "/": - // The root or a top-level directory is every role's, never one role's own (ADR 0287). - return fmt.Sprintf("the seat %s names the path %q, the root or a top-level directory, which no one "+ - "role owns (novox/hq ADR 0287)", seat, p) - } - return "" -} - -// SeatPathProblems is what is wrong with the paths a set of the mesh's own seats names (novox/hq ADR -// 0287): only a node seat names one, each absolute and literal; every path has exactly one seat naming it -// without stepping back; and a seat steps back only for another node seat naming the same path without -// stepping back, so the stepping back cannot cycle. -func SeatPathProblems(set []Seat) []string { - var problems []string - owners := map[string][]string{} // path → the seats naming it without stepping back - stepping := map[string][]SeatPath{} // seat → the paths it names stepping back - byName := map[string]Seat{} - for _, s := range set { - byName[s.Name] = s - for _, p := range s.Paths { - if why := seatPathProblem(s.Name, s.Scope, p.Path); why != "" { - problems = append(problems, why) - } - if p.StepsBackFor == "" { - owners[p.Path] = append(owners[p.Path], s.Name) - } else { - stepping[s.Name] = append(stepping[s.Name], p) - } - } - } - for _, s := range set { - for _, p := range stepping[s.Name] { - other, known := byName[p.StepsBackFor] - switch { - case !known || other.Scope != ScopeNode: - problems = append(problems, fmt.Sprintf("the seat %s steps back from %q for %q, which is not a "+ - "node seat of the mesh's (novox/hq ADR 0287)", s.Name, p.Path, p.StepsBackFor)) - case !namesWithoutSteppingBack(other, p.Path): - problems = append(problems, fmt.Sprintf("the seat %s steps back from %q for %s, which does not "+ - "own that path itself; a seat steps back only for the one that owns it (novox/hq ADR 0287)", - s.Name, p.Path, other.Name)) - } - } - } - for _, p := range sortedKeys(owners) { - if len(owners[p]) > 1 { - problems = append(problems, fmt.Sprintf("the path %q is named by %s, each without stepping back; "+ - "no two seats name one path (novox/hq ADR 0287)", p, strings.Join(owners[p], " and "))) - } - } - for _, s := range set { - for _, p := range stepping[s.Name] { - if len(owners[p.Path]) == 0 { - problems = append(problems, fmt.Sprintf("the path %q has no seat naming it without stepping back "+ - "(novox/hq ADR 0287)", p.Path)) - } - } - } - return problems -} - -func namesWithoutSteppingBack(s Seat, p string) bool { - for _, sp := range s.Paths { - if sp.Path == p && sp.StepsBackFor == "" { - return true - } - } - return false -} - // declaredPaths is every machine path a module declares it owns: a resource's literal path, and a file // the mesh renders for it outside the operator's home. An access is not here — reading is not owning. // @@ -226,28 +143,6 @@ func unclaimedSeatPaths(m Manifest, named map[string][]string) []string { return problems } -// declaredSeatPathProblems is what one module's own seat declaration can be judged on alone: a path -// only on a node seat, absolute, literal and clean. -func declaredSeatPathProblems(m Manifest, s SeatDeclaration) []string { - var problems []string - for _, p := range s.Paths { - if why := seatPathProblem(s.Name, s.At(), p); why != "" { - problems = append(problems, m.Module+": "+why) - } - } - return problems -} - -// asSeatPaths is a module's own seat's paths in the shape the mesh's seats name theirs. A module's seat -// never steps back: only the mesh's own seats do, each by a record. -func asSeatPaths(paths []string) []SeatPath { - out := make([]SeatPath, 0, len(paths)) - for _, p := range paths { - out = append(out, SeatPath{Path: p}) - } - return out -} - // PathOwner is the module that owns one seat's path on one machine (novox/hq ADR 0287): the seat naming // the path, and that seat's holder there, with the stepping back applied. type PathOwner struct { diff --git a/go/catalogue/seat_paths_test.go b/go/catalogue/seat_paths_test.go index a6f5df5..44faa65 100644 --- a/go/catalogue/seat_paths_test.go +++ b/go/catalogue/seat_paths_test.go @@ -31,40 +31,6 @@ func TestTheSeatsThatNamePathsAreTheStartingSet(t *testing.T) { } } -// The compiled set keeps the rules: node seats only, absolute and literal, one seat per path without -// stepping back, and a stepping back that cannot cycle. -func TestTheCompiledSeatPathsKeepTheRules(t *testing.T) { - if p := SeatPathProblems(DefaultSeats()); len(p) != 0 { - t.Fatalf("the compiled set breaks ADR 0287: %v", p) - } - for _, c := range []struct { - why string - set []Seat - want string - }{ - {"a mesh seat naming a path", []Seat{{Name: "mesh-x", Scope: ScopeMesh, Paths: []SeatPath{{Path: "/etc/x"}}}}, - "only a node seat names paths"}, - {"a placeholder", []Seat{{Name: "node-x", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/${setting:x}"}}}}, - "placeholder"}, - {"a relative path", []Seat{{Name: "node-x", Scope: ScopeNode, Paths: []SeatPath{{Path: "etc/x"}}}}, - "not a clean absolute path"}, - {"two owners", []Seat{ - {Name: "node-a", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x"}}}, - {Name: "node-b", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x"}}}}, "no two seats name one path"}, - {"a cycle", []Seat{ - {Name: "node-a", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x", StepsBackFor: "node-b"}}}, - {Name: "node-b", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x", StepsBackFor: "node-a"}}}}, - "does not own that path itself"}, - {"stepping back for nobody", []Seat{ - {Name: "node-a", Scope: ScopeNode, Paths: []SeatPath{{Path: "/etc/x", StepsBackFor: "node-z"}}}}, - "not a node seat"}, - } { - if got := strings.Join(SeatPathProblems(c.set), "; "); !strings.Contains(got, c.want) { - t.Errorf("%s: %q does not say %q", c.why, got, c.want) - } - } -} - // What the store's rows carry is never the paths: a row read back keeps the compiled ones. func TestTheSeatPathsSurviveTheStoresRows(t *testing.T) { before := seats @@ -90,112 +56,6 @@ func hostnameHolder(resources ...map[string]any) Manifest { Serves: []string{"entries", "add", "remove"}}}, Resources: resources} } -// A holder declares every path its seat names, as a resource or as a file the mesh renders for it, or -// CanHold refuses it naming the seat and each missing path — the judgement registration and handover share. -func TestAClaimWithoutTheSeatsPathsIsRefused(t *testing.T) { - seat := hostnameSeat(t) - hosts := map[string]any{"id": "hosts", "type": "file", "path": "/etc/hosts", "content": "x"} - name := map[string]any{"id": "name", "type": "file", "path": "/etc/hostname", "content": "x"} - - if err := CanHold(hostnameHolder(hosts, name), seat); err != nil { - t.Errorf("a holder declaring both paths as resources was refused: %v", err) - } - rendered := hostnameHolder(hosts) - rendered.Facts = map[string]RosterFile{"name": {Path: "/etc/hostname", Template: "{{.Node}}\n"}} - if err := CanHold(rendered, seat); err != nil { - t.Errorf("a holder declaring a path as a file the mesh renders was refused: %v", err) - } - err := CanHold(hostnameHolder(), seat) - if err == nil { - t.Fatal("a holder declaring neither path was accepted") - } - for _, want := range []string{"node-hostname", "/etc/hosts", "/etc/hostname", "ADR 0287"} { - if !strings.Contains(err.Error(), want) { - t.Errorf("the refusal does not name %s: %v", want, err) - } - } - // A file under the operator's home is placed per account: it is not the machine's path. - home := hostnameHolder(hosts) - home.Facts = map[string]RosterFile{"name": {Path: "/etc/hostname", Home: true, Template: "x"}} - if err := CanHold(home, seat); err == nil { - t.Error("a file in the operator's home counted as the machine's /etc/hostname") - } - // At registration, the same refusal. - if got := strings.Join(CatalogueProblems(Shelf{"names": hostnameHolder(hosts)}), "; "); !strings.Contains(got, - "does not declare /etc/hostname") { - t.Errorf("registration let a holder without /etc/hostname through: %q", got) - } -} - -// Only a claimant declares a seat's path: a module claiming no seat that names /etc/hosts and declaring -// it is refused at registration naming the seat; the uplink's holder and the resolver's both declaring -// the resolver file are each a claimant of a seat naming it. Reading a seat's path is access, and allowed. -func TestOnlyAClaimantDeclaresASeatsPath(t *testing.T) { - stranger := Manifest{Module: "stranger", Version: "1", Resources: []map[string]any{ - {"id": "hosts", "type": "file", "path": "/etc/hosts", "into": "block", "content": "10.0.0.1 x\n"}}} - got := strings.Join(CatalogueProblems(Shelf{"stranger": stranger}), "; ") - if !strings.Contains(got, `stranger declares the path "/etc/hosts", which the seat node-hostname names`) { - t.Errorf("a module declaring /etc/hosts without claiming node-hostname was not refused: %q", got) - } - // However the path is spelled (novox/hq issue 412 review). - unclean := Manifest{Module: "unclean", Version: "1", Resources: []map[string]any{ - {"id": "hosts", "type": "file", "path": "/etc/../etc/hosts", "content": "10.0.0.1 x\n"}}} - if got := strings.Join(CatalogueProblems(Shelf{"unclean": unclean}), "; "); !strings.Contains(got, "node-hostname") { - t.Errorf("/etc/../etc/hosts from a module not claiming node-hostname was not refused: %q", got) - } - reader := Manifest{Module: "reader", Version: "1", Accesses: []Access{{ID: "hosts", Path: "/etc/hosts"}}} - if got := CatalogueProblems(Shelf{"reader": reader}); len(got) != 0 { - t.Errorf("reading /etc/hosts was refused: %v", got) - } - uplink := Manifest{Module: "uplink", Version: "1", Claims: []Claim{{Name: "node-uplink", Scope: ScopeNode}}, - Facts: map[string]RosterFile{"resolvers": {Path: ResolverFile, Template: "nameserver 10.42.0.1\n"}}} - resolver := localResolver() - resolver.Claims[0].Serves = []string{"routes", "route", "unroute"} - if got := CatalogueProblems(Shelf{"uplink": uplink, resolver.Module: resolver}); len(got) != 0 { - t.Errorf("the uplink's holder and the resolver's declaring the resolver file were refused: %v", got) - } -} - -// A module's own seat names paths under the same rules: a node seat only, literal, and none another seat -// names — the second declaration loses. Its holder declares them, as a mesh seat's does. -func TestAModulesOwnSeatNamesPathsUnderTheSameRules(t *testing.T) { - meshScoped := Manifest{Module: "a", Version: "1", DefinesSeats: []SeatDeclaration{ - {Name: "a-seat", Paths: []string{"/etc/a"}}}} - if got := strings.Join(declaredSeatProblems(meshScoped), "; "); !strings.Contains(got, "only a node seat names paths") { - t.Errorf("a module's mesh seat naming a path was accepted: %q", got) - } - placeholder := Manifest{Module: "a", Version: "1", DefinesSeats: []SeatDeclaration{ - {Name: "a-seat", Scope: ScopeNode, Paths: []string{"${dir:state}/a"}}}} - if got := strings.Join(declaredSeatProblems(placeholder), "; "); !strings.Contains(got, "placeholder") { - t.Errorf("a module's seat naming a path with a placeholder was accepted: %q", got) - } - - taken := Manifest{Module: "b", Version: "1", DefinesSeats: []SeatDeclaration{ - {Name: "b-seat", Scope: ScopeNode, Paths: []string{"/etc/hosts"}}}} - if got := strings.Join(CatalogueProblems(Shelf{"b": taken}), "; "); !strings.Contains(got, - `b declares the seat b-seat naming the path "/etc/hosts", which node-hostname already names`) { - t.Errorf("a module's seat naming /etc/hosts was accepted: %q", got) - } - - own := Manifest{Module: "c", Version: "1", - DefinesSeats: []SeatDeclaration{{Name: "c-seat", Scope: ScopeNode, Paths: []string{"/etc/c.conf"}}}, - Claims: []Claim{{Name: "c-seat", Scope: ScopeNode}}} - if got := strings.Join(CatalogueProblems(Shelf{"c": own}), "; "); !strings.Contains(got, - "c claims c-seat but does not declare /etc/c.conf") { - t.Errorf("a holder of a module's seat without its path was accepted: %q", got) - } - own.Resources = []map[string]any{{"id": "conf", "type": "file", "path": "/etc/c.conf", "content": "x"}} - if got := CatalogueProblems(Shelf{"c": own}); len(got) != 0 { - t.Errorf("a holder declaring its own seat's path was refused: %v", got) - } - other := Manifest{Module: "d", Version: "1", Resources: []map[string]any{ - {"id": "conf", "type": "file", "path": "/etc/c.conf", "content": "x"}}} - if got := strings.Join(CatalogueProblems(Shelf{"c": own, "d": other}), "; "); !strings.Contains(got, - `d declares the path "/etc/c.conf", which the seat c-seat names`) { - t.Errorf("a module not claiming c-seat declared its path: %q", got) - } -} - type writesHosts struct{} func (writesHosts) Resources(string) ([]map[string]any, bool, error) { @@ -267,45 +127,3 @@ func TestAnUplinkDeclaringTheResolverFileAsAResourceIsRefusedBesideTheResolver(t t.Errorf("the uplink's holder renders %v beside the resolver", got.Facts) } } - -// A path is compared as the machine writes it: cleaned. Each spelling of /etc/hosts is /etc/hosts. -func TestAnUncleanSpellingOfASeatsPathIsTheSeatsPath(t *testing.T) { - for _, spelled := range []string{"/etc//hosts", "/etc/./hosts", "/etc/../etc/hosts", "/etc/hosts/"} { - stranger := Manifest{Module: "stranger", Version: "1", Resources: []map[string]any{ - {"id": "hosts", "type": "file", "path": spelled, "content": "10.0.0.1 x\n"}}} - if got := strings.Join(CatalogueProblems(Shelf{"stranger": stranger}), "; "); !strings.Contains(got, - `stranger declares the path "/etc/hosts", which the seat node-hostname names`) { - t.Errorf("%s from a module not claiming node-hostname was not refused: %q", spelled, got) - } - holder := hostnameHolder(map[string]any{"id": "hosts", "type": "file", "path": spelled, "content": "x"}, - map[string]any{"id": "name", "type": "file", "path": "/etc/hostname", "content": "x"}) - if err := CanHold(holder, hostnameSeat(t)); err != nil { - t.Errorf("a holder declaring %s was refused: %v", spelled, err) - } - } -} - -// A seat names a role's own path, never the root or a top-level directory. -func TestASeatNamesNoRootOrTopLevelDirectory(t *testing.T) { - for _, p := range []string{"/", "/etc"} { - m := Manifest{Module: "a", Version: "1", DefinesSeats: []SeatDeclaration{ - {Name: "a-seat", Scope: ScopeNode, Paths: []string{p}}}} - if got := strings.Join(declaredSeatProblems(m), "; "); !strings.Contains(got, "top-level directory") { - t.Errorf("a module's seat naming %s was accepted: %q", p, got) - } - } -} - -// The catalogue as it is passes: no holder of a seat naming a path is missing one, and nobody else -// declares one. -func TestTheCataloguePassesTheSeatPathRules(t *testing.T) { - shelf := Shelf{} - for _, m := range theCatalogue(t) { - shelf[m.Module] = m - } - for _, p := range CatalogueProblems(shelf) { - if strings.Contains(p, "ADR 0287") { - t.Errorf("the catalogue breaks ADR 0287: %s", p) - } - } -} diff --git a/go/catalogue/seats.go b/go/catalogue/seats.go index e8e68b0..bef2fc7 100644 --- a/go/catalogue/seats.go +++ b/go/catalogue/seats.go @@ -2,7 +2,6 @@ package catalogue import ( "fmt" - "sort" "strings" ) @@ -493,16 +492,6 @@ var defaultSeats = append([]Seat{ // The graphical session's roles (novox/hq ADR 0208), last because they are a workstation's. graphicalSessionSeats()...) -// A system seat name is the control plane's namespace: `mesh-*` for a mesh-wide role, `node-*` for -// a per-node one (novox/hq ADR 0121). A claim to a system name the mesh does not define is refused; -// any other name is a module's own to define and claim. Some of the mesh's own seats predate this -// convention and are not yet renamed (git, npm-package-registry, the-private-network) — those are -// in the set, so they resolve by name, not by prefix. the-artifact-store was renamed on 2026-09-30 -// (novox/hq ADR 0156) and resolves through the alias table on a mesh that knew it. -func isSystemSeatName(name string) bool { - return strings.HasPrefix(name, "mesh-") || strings.HasPrefix(name, "node-") -} - // seats is the working set the lookups read. It starts as the compiled defaults and is replaced by // what the control plane loaded from its store (novox/hq ADR 0122), so a change to the set is a // change to data, not to this code. @@ -652,79 +641,6 @@ func SeatDelivering(provision string) (Seat, bool) { return Seat{}, false } -// claimProblems is what is wrong with a manifest's claims and the seats it defines. -// -// A claim is one of three things (novox/hq ADR 0121): a **system seat** the control plane defines — -// checked for scope and, if it delivers a provision, that the claimant provides it; a **system name -// the mesh does not define** (`mesh-*`/`node-*`) — refused, because that namespace is the control -// plane's; or a **module-defined seat** — valid only when this manifest also declares it, since a -// module may coordinate its own instances through a seat of its own but may not invent one by -// claiming it. A module's own seat declaration may not sit in the system namespace or shadow a -// system seat. -func claimProblems(m Manifest) []string { - var problems []string - - defined := map[string]SeatDeclaration{} - for _, d := range m.DefinesSeats { - if _, isSystem := SeatNamed(d.Name); isSystem || isSystemSeatName(d.Name) { - problems = append(problems, fmt.Sprintf( - "%s defines a seat %q in the mesh's own namespace; a module's seat is named outside "+ - "mesh-*/node-* (novox/hq ADR 0121)", m.Module, d.Name)) - continue - } - defined[d.Name] = d - } - - for _, c := range m.Claims { - if s, known := SeatNamed(c.Name); known && s.HeldByTheEngine { - // The node's engine holds it on every node, and no module may (novox/hq ADR 0563 §1): a module answering - // root-free or a hand-over would be an agent answering it. - problems = append(problems, fmt.Sprintf("%s claims %s, which every node's engine holds itself and no "+ - "module may (novox/hq ADR 0563 §1)", m.Module, c.Name)) - continue - } - if _, known := SeatNamed(c.Name); known { - // **A seat's scope and what it delivers are not judged here** (novox/hq ADR 0122). - // This function runs wherever a manifest is parsed, and one of those places is the - // build machine, which has no store: there, `SeatNamed` answers from the set the - // binary shipped with, so a build would be refused for disagreeing with a compiled - // copy of data the control plane owns. Exactly that happened — a holder of the bus - // seat was refused for not providing what a stale compiled row said the seat - // delivered, while the store's own row said otherwise. - // - // Both checks moved to CatalogueProblems, which only ever runs in the control plane, - // after UseSeats has replaced the set with the store's. - continue - } - if replaced, retired := RetiredSeats[c.Name]; retired { - problems = append(problems, fmt.Sprintf("%s claims the seat %q, which is retired: its work is %s", m.Module, c.Name, replaced)) - continue - } - if isSystemSeatName(c.Name) { - problems = append(problems, fmt.Sprintf( - "%s claims %q, which is a seat in the mesh's own namespace (mesh-*/node-*) that it "+ - "does not define (novox/hq ADR 0121) — the seats are: %s", m.Module, c.Name, seatNames())) - continue - } - d, ours := defined[c.Name] - if !ours { - // **A claim on a seat this manifest does not declare is not the parser's to judge.** - // A module may hold a seat another module declared — that is why ADR 0126 has callers - // name the seat and not its provider, so an implementation can be replaced without - // touching a caller. Whether the seat exists is a fact about the whole catalogue, so - // the refusal is at registration, where every declaration is in view - // (`CatalogueProblems`: "which no module declares and the mesh does not define"). - continue - } - if c.At() != d.At() { - problems = append(problems, fmt.Sprintf( - "%s claims its own seat %s at scope %q, having declared it at %q", - m.Module, c.Name, c.At(), d.At())) - } - } - return problems -} - // CanHold is why a module could not hold a seat, or nothing: its definition must claim the seat at // the seat's scope, and provide what the seat delivers, if it delivers anything. The seat is the // store's row, so this is judged only where the store's set is loaded — at registration and in the @@ -783,15 +699,6 @@ func providesAt(m Manifest, provision, scope string) bool { return false } -func seatNames() string { - names := make([]string, 0, len(seats)) - for _, s := range seats { - names = append(names, s.Name) - } - sort.Strings(names) - return strings.Join(names, ", ") -} - // HolderAmong is which of several providers of a provision holds the seat that delivers it. // // Found by the (node, module) pair, because a provider is identified by both (novox/hq to-be 23): @@ -984,13 +891,6 @@ func loginShellVerbs() []Verb { // delivery.AskKinds). func buildAskKinds() []string { return []string{"delivery", "check", "recheck"} } -// RetiredSeats are seats the mesh no longer defines, each with what replaced it: a claim or a use of one is refused -// naming its replacement, not as a seat nobody declares. -var RetiredSeats = map[string]string{ - "node-build-agent": "node-build-go, node-build-node and node-build-image, held by the build modules (novox/hq ADR 0525 §10)", - "mesh-build-machine": "node-build-go, node-build-node and node-build-image, held by the build modules (novox/hq ADR 0190, ADR 0525 §10)", -} - // buildSeatVerbs are what a build module answers on its own node for its seat (ADR 0525 §10). func buildSeatVerbs(language string) []Verb { return []Verb{ diff --git a/go/catalogue/seats_declared.go b/go/catalogue/seats_declared.go index 94f0ffa..11278aa 100644 --- a/go/catalogue/seats_declared.go +++ b/go/catalogue/seats_declared.go @@ -2,8 +2,6 @@ package catalogue import ( "fmt" - "regexp" - "sort" "strings" ) @@ -21,15 +19,6 @@ import ( // satisfies the protocol are facts about the *catalogue* — so they are checked at registration, // by CatalogueProblems, which is the last moment the mesh can still say no. -// meshSeatPrefix is reserved to the mesh. The prefix *is* the reservation rule: no list of -// reserved names to maintain, no way for the mesh's own namespace to be colonised by a manifest, -// and nothing to keep in step when a mesh seat is added. -const meshSeatPrefix = "mesh-" - -// retiredLoginShell is the one name outside the prefix a module may not declare: the login shell's, -// from when a module declared it (novox/hq ADR 0176), before it became the mesh's (ADR 0204). -const retiredLoginShell = "login-shell" - // A SeatDeclaration is a role a module offers on the bus: what may be sent to it, what it says, // and what it answers. A caller declares that it uses the *seat*, never the module, so the // implementation can be replaced under it. @@ -103,162 +92,6 @@ func (s SeatDeclaration) At() string { return s.Scope } -// verbs is everything the protocol names, for the checks that do not care which half. -func (s SeatDeclaration) verbs() []string { - out := append([]string{}, s.Accepts...) - out = append(out, s.Emits...) - return append(out, VerbNames(s.Serves)...) -} - -// declaredSeatProblems is what one manifest can be judged on alone. -func declaredSeatProblems(m Manifest) []string { - var problems []string - seen := map[string]bool{} - - for _, s := range m.DefinesSeats { - switch { - case s.Name == "": - problems = append(problems, fmt.Sprintf("%s declares a seat with no name", m.Module)) - continue - case !name.MatchString(s.Name): - problems = append(problems, fmt.Sprintf( - "%s declares a seat named %q, which is not a usable name", m.Module, s.Name)) - continue - case strings.HasPrefix(s.Name, meshSeatPrefix): - // The mesh's own code dereferences its seats by name — the resolver *is* the thing - // that finds the store — so the prefix is not a convention, it is a namespace. - problems = append(problems, fmt.Sprintf( - "%s declares a seat named %q; %q is reserved to the mesh, which defines its own "+ - "seats (novox/hq ADR 0118)", m.Module, s.Name, meshSeatPrefix+"*")) - continue - } - if s.Name == retiredLoginShell { - // The name ADR 0176 gave the login shell when the zsh module declared it. The seat is - // the mesh's now, so a module declaring the old name would be a second login shell - // beside it, with a protocol of its own (novox/hq ADR 0204). - problems = append(problems, fmt.Sprintf( - "%s declares a seat named %q; the login shell is the mesh's own seat %s, which a shell "+ - "module claims and none declares (novox/hq ADR 0204)", m.Module, s.Name, LoginShellSeat)) - continue - } - if seen[s.Name] { - problems = append(problems, fmt.Sprintf( - "%s declares the seat %q twice", m.Module, s.Name)) - continue - } - seen[s.Name] = true - - if _, isMesh := SeatNamed(s.Name); isMesh { - problems = append(problems, fmt.Sprintf( - "%s declares %q, which is a seat the mesh already defines", m.Module, s.Name)) - } - switch s.At() { - case ScopeNode, ScopeSite, ScopeMesh: - default: - problems = append(problems, fmt.Sprintf( - "%s declares seat %s at scope %q; a seat is held per node, per site or per mesh", - m.Module, s.Name, s.Scope)) - } - // A seat with an empty protocol is allowed, and is the mesh saying what a machine is: - // which module is this node's packet filter, or its showcase. Design 26 calls it a seat - // that delivers nothing, and that is most of the node-scoped ones. ADR 0126's "a declared - // seat carries a protocol" governs what a holder must satisfy, not that every seat offers - // something — a marker seat's protocol is satisfied by holding it. Nothing can reach this - // state by accident: a mistyped field name is refused by the parser above, so an empty - // protocol was written as one. - for _, v := range s.verbs() { - if !name.MatchString(v) { - problems = append(problems, fmt.Sprintf( - "%s declares %s.%s, which is not a usable verb", m.Module, s.Name, v)) - } - } - problems = append(problems, trafficProblems(m, s)...) - problems = append(problems, declaredSeatPathProblems(m, s)...) - problems = append(problems, declaredSeatNeedsProblems(m, s)...) - } - - for _, u := range m.Uses { - if !name.MatchString(u) { - problems = append(problems, fmt.Sprintf("%s uses %q, which is not a usable seat name", m.Module, u)) - } - } - return problems -} - -// trafficProblems is what one declaration of the rules of ADR 0259 §3 can be judged on alone. -func trafficProblems(m Manifest, s SeatDeclaration) []string { - var problems []string - if s.Kinded && !KindedBenches[s.Name] { - problems = append(problems, fmt.Sprintf( - "%s declares %s as a kinded bench; only channel and intake are kinded, and another is a "+ - "decision, recorded (novox/hq ADR 0234)", m.Module, s.Name)) - } - if s.Kinded && len(s.ByCaller) > 0 { - problems = append(problems, fmt.Sprintf( - "%s declares %s kinded and names verbs by their caller; a kinded bench's subjects carry the kind", - m.Module, s.Name)) - } - for _, v := range s.ByCaller { - inAccepts, inEmits := false, false - for _, a := range s.Accepts { - inAccepts = inAccepts || a == v - } - for _, e := range s.Emits { - inEmits = inEmits || e == v - } - if !inAccepts && !inEmits { - problems = append(problems, fmt.Sprintf( - "%s names %s.%s by its caller, which the seat neither accepts nor emits", m.Module, s.Name, v)) - } - } - for _, v := range s.Proofs { - if !name.MatchString(v) || strings.Contains(v, ".") { - problems = append(problems, fmt.Sprintf("%s declares the proof %s.%s, which is not a usable verb", - m.Module, s.Name, v)) - } - } - if len(s.Proofs) > 0 && !s.Kinded { - problems = append(problems, fmt.Sprintf( - "%s declares proofs on %s, which is not kinded; a proof is asked by a holder of a kind", - m.Module, s.Name)) - } - for _, r := range s.Records { - kept := false - for _, st := range m.State { - kept = kept || st.Name == r - } - if !kept { - problems = append(problems, fmt.Sprintf( - "%s says %s's users read its records %q, which it keeps no state of", m.Module, s.Name, r)) - } - } - return problems -} - -// declaredSeatNeedsProblems is what one declaration's needs can be judged on alone (novox/hq ADR -// 0378 §8): only a node seat is ever held beside another on the same node, so only a node seat may -// carry needs; and a seat needing itself names nothing that was not already true of holding it. -// Whether each named need itself exists as a node seat somewhere in the catalogue is CatalogueProblems' -// to say, once every manifest is known. -func declaredSeatNeedsProblems(m Manifest, s SeatDeclaration) []string { - var problems []string - if len(s.Needs) == 0 { - return problems - } - if s.At() != ScopeNode { - problems = append(problems, fmt.Sprintf( - "%s declares %s needing %s, and only a node seat needs another held beside it "+ - "(novox/hq ADR 0378 §8)", m.Module, s.Name, strings.Join(s.Needs, ", "))) - } - for _, need := range s.Needs { - if need == s.Name { - problems = append(problems, fmt.Sprintf( - "%s declares %s needing itself (novox/hq ADR 0378 §8)", m.Module, s.Name)) - } - } - return problems -} - // A Shelf is every manifest the mesh has registered, by module name. type Shelf map[string]Manifest @@ -276,176 +109,6 @@ func IsUndeclaredSeat(problem string) bool { return strings.HasSuffix(problem, UndeclaredSeat) || strings.HasSuffix(problem, UnheldSeat) } -func CatalogueProblems(shelf Shelf) []string { - var problems []string - // A stream name has one owner (novox/hq ADR 0363). - problems = append(problems, StreamNameProblems(shelf)...) - // A module that hears a restricted stream runs as an account of its own (novox/hq ADR 0376 §2). - problems = append(problems, RestrictedHearingProblems(shelf)...) - // A work-queue stream is never asked for by another module's consumes (novox/hq ADR 0436 §6). - problems = append(problems, WorkQueueConsumedElsewhereProblems(shelf)...) - // Every offer says its credential's form, never left to a default (the lead, 2026-10-29, for novox/hq ADR 0570). - for _, module := range shelfOrder(shelf) { - problems = append(problems, CredentialFormProblems(shelf[module])...) - // And its data in the bus's predefined forms, a stream of its own refused (the lead, 2026-10-29). - problems = append(problems, PredefinedFormProblems(shelf[module])...) - } - - // Who declares what, and who declared it first. - declaredBy := map[string]string{} - declared := map[string]SeatDeclaration{} - for _, module := range shelfOrder(shelf) { - for _, s := range shelf[module].DefinesSeats { - if s.Name == "" { - continue - } - if first, taken := declaredBy[s.Name]; taken { - // The second loses. A seat name meaning two different protocols is the failure - // nobody could diagnose afterwards — a caller would bind to whichever happened - // to register first, and the symptom would appear in the other module. - problems = append(problems, fmt.Sprintf( - "%s declares the seat %q, which %s already declares; a seat name means one "+ - "protocol", module, s.Name, first)) - continue - } - declaredBy[s.Name] = module - declared[s.Name] = s - } - } - - exists := func(seat string) bool { - if _, isMesh := SeatNamed(seat); isMesh { - return true - } - _, ok := declaredBy[seat] - return ok - } - - // atNode says whether a seat, compiled or declared, is a node seat — judged here because only - // the whole catalogue knows a declared one's scope (novox/hq ADR 0378 §8). - atNode := func(seat string) bool { - if s, isMesh := SeatNamed(seat); isMesh { - return s.Scope == ScopeNode - } - if s, ok := declared[seat]; ok { - return s.At() == ScopeNode - } - return false - } - - // A module-declared seat's needs name node seats that hold beside it (novox/hq ADR 0378 §8): - // whether one exists at all, compiled or declared by any module, only the whole catalogue can - // say — a single manifest's own problems (declaredSeatProblems) judge its needs no further. - for _, module := range shelfOrder(shelf) { - for _, s := range shelf[module].DefinesSeats { - for _, need := range s.Needs { - if !exists(need) { - problems = append(problems, fmt.Sprintf( - "%s declares %s needing %s, "+UndeclaredSeat, module, s.Name, need)) - } else if !atNode(need) { - problems = append(problems, fmt.Sprintf( - "%s declares %s needing %s, which is not a node seat (novox/hq ADR 0378 §8)", - module, s.Name, need)) - } - } - } - } - - // A seat verb a module invokes is one some holder serves (issue 774). - problems = append(problems, seatInvokeProblems(shelf, declared)...) - - // Which seats name each path (novox/hq ADR 0287): a module's seat naming one another seat names loses. - named, pathProblems := pathSeats(shelfOrder(shelf), shelf, declaredBy) - problems = append(problems, pathProblems...) - - // Who claims each kind of a kinded bench, so a second claim of one kind is refused (ADR 0234 §2). - kindsTaken := map[string]string{} - - for _, module := range shelfOrder(shelf) { - m := shelf[module] - for _, c := range m.Claims { - if c.Kind != "" { - if _, isModuleSeat := declared[c.Name]; !isModuleSeat { - problems = append(problems, fmt.Sprintf( - "%s claims %s of kind %q, and only a kinded bench takes a kind", module, c.Name, c.Kind)) - } - } - } - - // A `uses` naming nothing is where ADR 0110's guarantee lands under a derived set: the - // same refusal, at the same moment, from a set nobody maintains by hand. - for _, u := range m.Uses { - if replaced, retired := RetiredSeats[u]; retired && !exists(u) { - problems = append(problems, fmt.Sprintf("%s uses the seat %q, which is retired: its work is %s", module, u, replaced)) - continue - } - if !exists(u) { - problems = append(problems, fmt.Sprintf( - "%s uses the seat %q, "+UndeclaredSeat, - module, u)) - } - } - - for _, c := range m.Claims { - if replaced, retired := RetiredSeats[c.Name]; retired && !exists(c.Name) { - problems = append(problems, fmt.Sprintf("%s claims the seat %q, which is retired: its work is %s", module, c.Name, replaced)) - continue - } - if !exists(c.Name) { - problems = append(problems, fmt.Sprintf( - "%s claims the seat %q, "+UndeclaredSeat, - module, c.Name)) - continue - } - s, isModuleSeat := declared[c.Name] - if !isModuleSeat { - // **A mesh seat is judged here and nowhere else** (novox/hq ADR 0122): the set is - // the store's, and this is the only place that runs with the store's set loaded. - // The parser cannot do it — it also runs on the build machine, against whatever - // set that binary was compiled with. - seat, _ := SeatNamed(c.Name) - if err := CanHold(m, seat); err != nil { - problems = append(problems, err.Error()) - } - continue - } - problems = append(problems, kindProblems(module, c, s, kindsTaken)...) - if c.At() != s.At() { - problems = append(problems, fmt.Sprintf( - "%s claims %s at scope %q, and %s declares it at %s", - module, c.Name, c.At(), declaredBy[c.Name], s.At())) - } - // A holder that does not answer what the seat promises is a caller's timeout, found - // at assignment instead. - if missing := unserved(m, c, s); len(missing) > 0 { - problems = append(problems, fmt.Sprintf( - "%s claims %s but does not serve %s, which that seat's protocol promises", - module, c.Name, strings.Join(missing, ", "))) - } - // And owns what the seat's holder owns, as a holder of the mesh's own seats does (ADR 0287). - if missing := missingPaths(m, asSeatPaths(s.Paths)); len(missing) > 0 { - problems = append(problems, fmt.Sprintf( - "%s claims %s but does not declare %s, which that seat's holder owns (novox/hq ADR 0287)", - module, c.Name, strings.Join(missing, ", "))) - } - } - // Only a claimant declares a seat's path (novox/hq ADR 0287). - problems = append(problems, unclaimedSeatPaths(m, named)...) - } - // **A trusted holder runs as its own account** (novox/hq ADR 0259 §8): a module saying warrants, or - // speaking for a kind that proves its sender, is never carried by a machine's runtime. - for _, module := range shelfOrder(shelf) { - m := shelf[module] - if why := TrustedHolding(m, declared); why != "" && m.RunsAs == "" { - problems = append(problems, fmt.Sprintf( - "%s must run as an account of its own (runs-as): %s, and the machine's runtime runs as the "+ - "operator's account, which every agent runs as (novox/hq ADR 0259)", module, why)) - } - } - sort.Strings(problems) - return problems -} - // ChannelCapabilities is the fixed vocabulary `channel-capabilities/1` (novox/hq ADR 0234 §2): a word // outside it is refused. `max-length:` takes a number. var ChannelCapabilities = map[string]bool{ @@ -455,119 +118,6 @@ var ChannelCapabilities = map[string]bool{ "verified-sender": true, "exact-render": true, "code-factor": true, "key-factor": true, } -var maxLength = regexp.MustCompile(`^max-length:[1-9][0-9]{0,6}$`) - -// capabilityProblems are the words of a claim outside the vocabulary, and capabilities on a claim of a -// seat that is not kinded. -func capabilityProblems(module string, c Claim, kinded bool) []string { - if len(c.Capabilities) == 0 { - return nil - } - if !kinded { - return []string{fmt.Sprintf("%s claims %s with capabilities, and only a kinded bench's claim carries them", - module, c.Name)} - } - var problems []string - for _, w := range c.Capabilities { - if !ChannelCapabilities[w] && !maxLength.MatchString(w) { - problems = append(problems, fmt.Sprintf( - "%s claims %s with the capability %q, which channel-capabilities/1 does not have", module, c.Name, w)) - } - } - return problems -} - -// kindProblems is a claim judged against a declared seat's kind: a kinded bench takes one claim per kind, -// a usable name; any other seat takes none. -func kindProblems(module string, c Claim, s SeatDeclaration, taken map[string]string) []string { - if problems := capabilityProblems(module, c, s.Kinded); len(problems) > 0 { - return problems - } - switch { - case !s.Kinded && c.Kind != "": - return []string{fmt.Sprintf("%s claims %s of kind %q, and only a kinded bench takes a kind", - module, c.Name, c.Kind)} - case !s.Kinded: - return nil - case c.Kind == "": - return []string{fmt.Sprintf("%s claims the kinded bench %s and names no kind", module, c.Name)} - case !name.MatchString(c.Kind) || strings.Contains(c.Kind, "."): - return []string{fmt.Sprintf("%s claims %s of kind %q, which is not a usable name", module, c.Name, c.Kind)} - } - key := c.Name + "/" + c.Kind - if first, ok := taken[key]; ok && first != module { - return []string{fmt.Sprintf("%s claims %s of kind %q, which %s already claims; a kind has one holder", - module, c.Name, c.Kind, first)} - } - taken[key] = module - return nil -} - -// unserved is what a seat's protocol promises and the claimant does not answer. Only the tools -// are checked: `accepts` and `emits` are wired by the runtime from the declaration, while a tool -// is code the module either has or has not written — under the claim's serves, or among its own. -func unserved(m Manifest, c Claim, s SeatDeclaration) []string { - has := map[string]bool{} - for _, t := range c.ServesFor(m) { - has[t] = true - } - var missing []string - for _, t := range s.Serves { - if !has[t.Name] { - missing = append(missing, t.Name) - } - } - return missing -} - -// shelfOrder is the catalogue in a stable order, so two runs report the same problems in the same -// sequence — a refusal that reorders itself is a refusal nobody can diff. -func shelfOrder(shelf Shelf) []string { - out := make([]string, 0, len(shelf)) - for k := range shelf { - out = append(out, k) - } - sort.Strings(out) - return out -} - -var accountName = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,30}$`) - -// RunsAsProblems is what one manifest's `runs-as` is held to (novox/hq ADR 0259 §8): an account of the -// module's own making — a `user` resource of that name — that owns its secrets, with a bus account of its own, -// and that is neither root nor the operator's. -func RunsAsProblems(m Manifest) []string { - if m.RunsAs == "" { - return nil - } - var problems []string - say := func(format string, a ...any) { problems = append(problems, fmt.Sprintf(format, a...)) } - switch { - case !accountName.MatchString(m.RunsAs): - say("%s runs as %q, which is not an account name of the module's own", m.Module, m.RunsAs) - return problems - case m.RunsAs == "root": - say("%s runs as root; a module of its own account runs as an account it makes, never root", m.Module) - } - made := false - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) == "user" && fmt.Sprint(r["name"]) == m.RunsAs { - made = true - } - } - if !made { - say("%s runs as %s, which it does not make: a user resource named %s", m.Module, m.RunsAs, m.RunsAs) - } - if _, has := m.OwnSecrets["broker"]; !has { - say("%s runs as its own account and declares no own secret broker: its runtime reaches the bus on an "+ - "account of its own", m.Module) - } - if m.SecretsOwner != m.RunsAs { - say("%s runs as %s, and its secrets belong to %q: they are the account's own", m.Module, m.RunsAs, m.SecretsOwner) - } - return problems -} - // TrustedHolding is why a module must run as its own account (novox/hq ADR 0259 §8), or "": it holds a seat // whose events it says to one caller each (a warrant), or speaks for a kind of a kinded bench that proves // its sender, or is private (a link's code is shown there). None may be carried by the machine's runtime, which diff --git a/go/catalogue/seats_declared_test.go b/go/catalogue/seats_declared_test.go index 41dfabc..33766d7 100644 --- a/go/catalogue/seats_declared_test.go +++ b/go/catalogue/seats_declared_test.go @@ -1,240 +1,8 @@ package catalogue -import ( - "strings" - "testing" -) - -func problemsFor(t *testing.T, shelf Shelf) string { - t.Helper() - return strings.Join(CatalogueProblems(shelf), "; ") -} - func telegram() Manifest { return Manifest{Module: "telegram", Tools: []string{"status"}, DefinesSeats: []SeatDeclaration{{ Name: "telegram-sender", Scope: ScopeMesh, Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}, Serves: []Verb{{Name: "status"}}, }}, Claims: []Claim{{Name: "telegram-sender", Scope: ScopeMesh}}} } - -// The whole point: a module contributes a capability without the mesh being changed. -func TestAModuleDeclaresItsOwnSeatAndHoldsIt(t *testing.T) { - shop := Manifest{Module: "shop", Uses: []string{"telegram-sender"}} - if got := problemsFor(t, Shelf{"telegram": telegram(), "shop": shop}); got != "" { - t.Fatalf("a declared seat and its caller were refused: %s", got) - } -} - -// The prefix is the reservation rule, so there is no list to maintain and none to drift. -func TestAModuleCannotDeclareAMeshSeat(t *testing.T) { - for _, n := range []string{"mesh-broker", "mesh-anything", "mesh-store"} { - m := Manifest{Module: "impostor", DefinesSeats: []SeatDeclaration{{Name: n, Accepts: []string{"x"}}}} - got := strings.Join(declaredSeatProblems(m), "; ") - if !strings.Contains(got, "reserved to the mesh") { - t.Fatalf("%q was accepted as a module's seat: %q", n, got) - } - } -} - -// A seat name meaning two protocols is the failure nobody could diagnose afterwards. -func TestTwoModulesCannotDeclareTheSameSeat(t *testing.T) { - other := Manifest{Module: "aardvark", DefinesSeats: []SeatDeclaration{{ - Name: "telegram-sender", Scope: ScopeMesh, Accepts: []string{"something-else"}}}} - got := problemsFor(t, Shelf{"telegram": telegram(), "aardvark": other}) - if !strings.Contains(got, "already declares") { - t.Fatalf("both declarations stood: %s", got) - } - // The first declarer keeps it; only the second is refused. - if strings.Count(got, "already declares") != 1 { - t.Fatalf("expected exactly one refusal: %s", got) - } -} - -// Where ADR 0110's guarantee lands under a derived set: a typo is refused, not resolved to -// nothing at runtime. 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 Manifest - seat Manifest - note bool // over some manifests alone it is a note (IsUndeclaredSeat) - }{ - {"a misspelled seat used", `uses the seat "telegram-sendr", ` + UndeclaredSeat, - Manifest{Module: "shop", Uses: []string{"telegram-sendr"}}, telegram(), true}, - {"a declared verb invoked of a held seat", "", 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"`, - Manifest{Module: "shop", Invokes: []string{"seat:telegram-sender.resend"}}, telegram(), false}, - {"a verb of a misspelled seat", `a verb of the seat "telegram-sendr", ` + UndeclaredSeat, - 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", ` + UnheldSeat, - Manifest{Module: "shop", Invokes: []string{"seat:telegram-sender.status"}}, unclaimed, true}, - {"a verb of a mesh seat whose holder lives in another repository", "", - Manifest{Module: "shop", Invokes: []string{"seat:mesh-watchdogs.signals"}}, telegram(), false}, - {"a verb a mesh seat does not promise", `the seat mesh-controller serves no verb "root-freed"`, - Manifest{Module: "shop", Invokes: []string{"seat:mesh-controller.root-freed"}}, telegram(), false}, - {"the controller's verb: the controller answers its seat", "", - Manifest{Module: "shop", Invokes: []string{"seat:mesh-controller.root-free"}}, telegram(), false}, - {"the engine's verb: every node's engine holds its seat", "", - 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"`, - Manifest{Module: "shop", Invokes: []string{"seat:node-engine.root-freed"}}, telegram(), false}, - } { - got := CatalogueProblems(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) || - IsUndeclaredSeat(got[0]) != row.note) { - t.Errorf("%s: %s", row.name, joined) - } - } -} - -// A holder that does not answer what the seat promises is a caller's timeout, found here instead. -func TestAHolderMustServeWhatItsSeatPromises(t *testing.T) { - m := telegram() - m.Tools = nil // declares the seat, serves none of it - got := problemsFor(t, Shelf{"telegram": m}) - if !strings.Contains(got, "does not serve status") { - t.Fatalf("a holder was accepted that answers nothing its seat promises: %s", got) - } -} - -// The claim may say what it serves for the role instead, when the module's own tools are not the -// seat's verbs (ADR 0160). -func TestAClaimMayNameWhatItServesForTheSeat(t *testing.T) { - m := telegram() - m.Tools = []string{"telegram_send"} - m.Claims = append([]Claim(nil), m.Claims...) - for i := range m.Claims { - if m.Claims[i].Name == "telegram-sender" { - m.Claims[i].Serves = []string{"status"} - } - } - if got := problemsFor(t, Shelf{"telegram": m}); strings.Contains(got, "does not serve") { - t.Fatalf("a claim naming the seat's verb was refused: %s", got) - } -} - -// A seat with no protocol is a marker: which module is this node's showcase, or its packet filter. -// Most node-scoped seats are markers, so refusing one would refuse the majority of the set. -func TestASeatWithoutAProtocolIsAMarkerNotAMistake(t *testing.T) { - m := Manifest{Module: "vague", DefinesSeats: []SeatDeclaration{{Name: "something", Scope: ScopeNode}}} - if got := strings.Join(declaredSeatProblems(m), "; "); got != "" { - t.Fatalf("a marker seat was refused: %s", got) - } -} - -// A claim at the wrong scope is a different seat than the one declared. -func TestAClaimMustMatchTheDeclaredScope(t *testing.T) { - m := telegram() - m.Claims = []Claim{{Name: "telegram-sender", Scope: ScopeNode}} - got := problemsFor(t, Shelf{"telegram": m}) - if !strings.Contains(got, "scope") { - t.Fatalf("a claim at the wrong scope was accepted: %s", got) - } -} - -// The mesh's own seats still work, and are not shadowed by the derived half. -func TestTheMeshsOwnSeatsAreStillClaimable(t *testing.T) { - // It delivers the bus, so its holder provides the bus — the rule this check now enforces. - m := Manifest{Module: "nats", - Provides: []Offer{{Name: "mesh-bus", Scope: ScopeMesh, Credential: &OfferCredential{Form: CredentialFormNone}}}, - Claims: []Claim{{Name: "mesh-broker", Scope: ScopeMesh}}} - if got := problemsFor(t, Shelf{"nats": m}); got != "" { - t.Fatalf("a mesh seat was refused by the derived check: %s", got) - } -} - -// Only a node seat may carry needs (novox/hq ADR 0378 §8): a per-mesh seat's holder is not on any -// one node to depend on anything beside it there. -func TestOnlyANodeSeatMayCarryNeeds(t *testing.T) { - m := Manifest{Module: "mail", DefinesSeats: []SeatDeclaration{{ - Name: "mail-notifier", Scope: ScopeMesh, Needs: []string{"node-notifier"}}}} - got := strings.Join(declaredSeatProblems(m), "; ") - if !strings.Contains(got, "needing") || !strings.Contains(got, "node seat") { - t.Fatalf("a mesh-scope seat's needs were accepted: %q", got) - } -} - -// A seat needing itself names nothing (novox/hq ADR 0378 §8). -func TestASeatCannotNeedItself(t *testing.T) { - m := Manifest{Module: "mail", DefinesSeats: []SeatDeclaration{{ - Name: "mail-notifier", Scope: ScopeNode, Needs: []string{"mail-notifier"}}}} - got := strings.Join(declaredSeatProblems(m), "; ") - if !strings.Contains(got, "needing itself") { - t.Fatalf("a seat needing itself was accepted: %q", got) - } -} - -// A need naming nothing node-scope — undeclared, or declared at another scope — is the whole -// catalogue's to say (novox/hq ADR 0378 §8). -func TestANeedMustNameANodeSeatSomewhereInTheCatalogue(t *testing.T) { - mail := Manifest{Module: "mail", DefinesSeats: []SeatDeclaration{{ - Name: "mail-notifier", Scope: ScopeNode, Needs: []string{"desk-beeper"}}}} - - // Nobody declares desk-beeper at all. - got := problemsFor(t, Shelf{"mail": mail}) - if !strings.Contains(got, "desk-beeper") || !strings.Contains(got, "no module declares") { - t.Fatalf("needing an undeclared seat was accepted: %q", got) - } - - // Declared, but at mesh scope: still not a node seat to hold beside it. - notNode := Manifest{Module: "dunst", DefinesSeats: []SeatDeclaration{{Name: "desk-beeper", Scope: ScopeMesh}}} - got = problemsFor(t, Shelf{"mail": mail, "dunst": notNode}) - if !strings.Contains(got, "desk-beeper") || !strings.Contains(got, "not a node seat") { - t.Fatalf("needing a mesh-scope seat was accepted: %q", got) - } - - // Declared at node scope: accepted. - dunst := Manifest{Module: "dunst", DefinesSeats: []SeatDeclaration{{Name: "desk-beeper", Scope: ScopeNode}}} - if got := problemsFor(t, Shelf{"mail": mail, "dunst": dunst}); got != "" { - t.Fatalf("needing a node seat another module declares was refused: %q", got) - } -} - -// A refusal that reorders itself between runs is a refusal nobody can diff. -func TestTheProblemsAreStable(t *testing.T) { - shelf := Shelf{"telegram": telegram(), "shop": {Module: "shop", Uses: []string{"nope"}}, - "other": {Module: "other", Uses: []string{"also-nope"}}} - first, second := problemsFor(t, shelf), problemsFor(t, shelf) - if first != second { - t.Fatalf("unstable:\n%s\n%s", first, second) - } -} - -// **A build machine has no store, so it may not judge a seat.** The set is data the control plane -// owns (novox/hq ADR 0122), and `ParseManifest` runs on the build machine too, against whatever set -// that binary was compiled with. When the two disagreed, a valid holder of the bus seat was refused -// mid-rollout — the compiled row said the seat delivered one provision, the store's row said -// another, and the build failed on the copy rather than the truth. The parser judges the manifest; -// the seat set judges the claim, where it is loaded. -func TestTheParserDoesNotJudgeWhatOnlyTheStoreKnows(t *testing.T) { - was := Seats() - t.Cleanup(func() { UseSeats(was) }) - - // A store whose bus seat delivers something this module does provide. - UseSeats([]Seat{{Name: "mesh-broker", Scope: ScopeMesh, Delivers: "mesh-bus", Decision: "test"}}) - raw := []byte(`{"module":"a-bus","version":"1",` + - `"provides":[{"name":"mesh-bus","scope":"mesh","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(Shelf{m.Module: m}); len(got) != 0 { - t.Fatalf("a holder that provides what the store says the seat delivers was refused: %v", got) - } - - // And with the store saying the seat delivers something else, registration is what refuses it. - UseSeats([]Seat{{Name: "mesh-broker", Scope: ScopeMesh, Delivers: "other-bus", Decision: "test"}}) - if _, err := ParseManifest(raw); err != nil { - t.Fatalf("the parser judged it the second time: %v", err) - } - got := strings.Join(CatalogueProblems(Shelf{m.Module: m}), "; ") - if !strings.Contains(got, `does not provide "other-bus"`) { - t.Fatalf("registration did not refuse a holder that cannot answer for the seat: %q", got) - } -} diff --git a/go/catalogue/seats_test.go b/go/catalogue/seats_test.go index 1a58ab1..653aaa3 100644 --- a/go/catalogue/seats_test.go +++ b/go/catalogue/seats_test.go @@ -1,274 +1,16 @@ package catalogue import ( - "reflect" - "regexp" "strings" "testing" ) // Defends novox/hq ADR 0110: a seat is a module assignment from a closed set. -// The set is closed, and changing it is a decision. -// -// **The count is asserted, and every entry names the record that made it a seat**, so the next -// person changing the set finds the argument rather than a number to edit — the pattern the host's -// vocabulary test follows. If this fails because a seat was added, the fix is a record in novox/hq -// and a row in to-be 26, not a new number here. -func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) { - // A seat a later record extends names both, "novox/hq ADR 0166, ADR 0207": the one that defined - // it and the one that seeded it. - record := regexp.MustCompile(`^novox/hq ADR \d{4}(, ADR \d{4})*$`) - seen := map[string]bool{} - delivered := map[string]string{} - for _, s := range Seats() { - if seen[s.Name] { - t.Errorf("%s is in the set twice", s.Name) - } - seen[s.Name] = true - if !record.MatchString(s.Decision) { - t.Errorf("%s names %q as its decision; every seat names the record that made it one", - s.Name, s.Decision) - } - switch s.Scope { - case ScopeNode, ScopeSite, ScopeMesh: - default: - t.Errorf("%s is held per %q, which is not a scope", s.Name, s.Scope) - } - if s.Delivers != "" { - // Two seats answering for one provision would put the question "which one?" back, - // which is the question a seat exists to answer. - if other, twice := delivered[s.Delivers]; twice { - t.Errorf("%s and %s both deliver %q", other, s.Name, s.Delivers) - } - delivered[s.Delivers] = s.Name - } - } - // Forty-seven with mesh-placement (novox/hq ADR 0567), serving its seven verbs. - if s, ok := SeatNamed(PlacementSeat); !ok || s.Scope != ScopeMesh || !reflect.DeepEqual(VerbNames(s.Serves), - []string{"placement", "proposals", "propose", "withdraw", "again", "adopt", "hand-back"}) { - 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 Seats() { - if s.Name == 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 }{ - {ConditionsSeat, "conditions,silence,compare,take-over,hand-back,resync", "condition-raised,condition-changed,condition-cleared"}, - {WatchdogsSeat, "signals,stuck,presence,dead-letters,resync", ""}, - {SelfCheckSeat, "doctor,probes", "checked"}, - {HealersSeat, "healers,heal,drill", "healer-acted"}, - // And mesh-config's switch (ADR 0565 §4), beside its config. - {"mesh-config", "config,compare,take-over,hand-back", ""}, - // And ADR 0565 clause 1's two. - {WantsSeat, "upgrade,bus", "policy-changed"}, - {HandActsSeat, "hand-act,hand-acts,warranted", "act-recorded"}, - // ADR 0571 clause 2: the artifact store's sweep. - {ArtifactSweepSeat, "artifacts,held,collect,mirrors,images", "artifacts-gone"}, - } { - s, ok := SeatNamed(c.seat) - if !ok || s.Scope != ScopeMesh || s.Replicated || s.Delivers != "" || !strings.Contains(s.Decision, "ADR 05") || - strings.Join(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 six - // verbs and three events, held by no assignment, and a module's claim of it refused. - if engine, ok := SeatNamed(EngineSeat); !ok || engine.Scope != 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" || - 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). - if len(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(Seats()), seatNames()) - } -} - -// novox/hq ADR 0117: a machine's uplink is a seat, held per machine, and delivers nothing. -// -// **Nothing, because nothing may be required of it.** A holder only keeps its network manager from -// contradicting the mesh; a requirement resolving to it would make the manager the mesh's answer -// for something, and the manager's link is the one thing the mesh must never be able to break. -func TestTheUplinkIsANodeSeatThatDeliversNothing(t *testing.T) { - seat, known := SeatNamed("node-uplink") - if !known { - t.Fatalf("the uplink is not a seat; the seats are: %s", seatNames()) - } - if seat.Scope != ScopeNode || seat.Delivers != "" || seat.Decision != "novox/hq ADR 0117" { - t.Fatalf("the uplink is %+v, not a node seat delivering nothing by ADR 0117", seat) - } - // And a manager's module can hold it without providing anything. - raw := []byte(`{"module":"networkmanager","version":"1","claims":[{"name":"node-uplink","scope":"node"}]}`) - if _, err := ParseManifest(raw); err != nil { - t.Fatalf("a network manager's module could not hold the uplink: %v", err) - } -} - func claimed(claims string) []byte { return []byte(`{"module":"thing","version":"1","provides":[{"name":"npm-package-registry","scope":"mesh","credential":{"form":"none"}}],"claims":` + claims + `}`) } -// **The refusal moved, it did not go** (novox/hq ADR 0118, superseding 0110). A module may now -// declare its own seats, so whether a claimed seat exists is a fact about the *catalogue* and not -// about the manifest in front of the parser: a claim on a seat another registered module declares -// is perfectly good, and the parser cannot tell the two cases apart. So the parser accepts it and -// registration refuses it — the same guarantee, at the same moment work would otherwise start, -// from a set nobody maintains by hand. -func TestAClaimOnASeatNobodyDeclaresIsRefusedAtRegistration(t *testing.T) { - m, err := ParseManifest(claimed(`[{"name":"the-anything","scope":"node"}]`)) - if err != nil { - t.Fatalf("the parser judged a claim it cannot judge alone: %v", err) - } - - problems := CatalogueProblems(Shelf{m.Module: m}) - if len(problems) == 0 { - t.Fatal("a module invented a seat by claiming it, and registration allowed it") - } - joined := strings.Join(problems, "; ") - if !strings.Contains(joined, "the-anything") || !strings.Contains(joined, "no module declares") { - t.Fatalf("the refusal does not say the seat is nobody's: %v", problems) - } -} - -// And the same claim is fine once something declares that seat, which is the case the parser -// could not have distinguished. -func TestAClaimOnASeatAnotherModuleDeclaresIsAccepted(t *testing.T) { - claimant, err := ParseManifest(claimed(`[{"name":"the-anything","scope":"node"}]`)) - if err != nil { - t.Fatal(err) - } - declarer := Manifest{Module: "someone", DefinesSeats: []SeatDeclaration{ - {Name: "the-anything", Scope: ScopeNode, Accepts: []string{"work"}}, - }} - if problems := CatalogueProblems(Shelf{claimant.Module: claimant, "someone": declarer}); len(problems) != 0 { - t.Fatalf("a claim on a declared seat was refused: %v", problems) - } - // 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) - } -} - -// A module may define its own seat and claim it — the mesh enforces exclusivity without knowing -// what it means (novox/hq ADR 0121). But it may not define one in the mesh's own namespace. -func TestAModuleDefinesAndClaimsItsOwnSeat(t *testing.T) { - ok := []byte(`{"module":"showcase","version":"1","seats":[{"name":"the-showcase","scope":"node"}],` + - `"claims":[{"name":"the-showcase","scope":"node"}]}`) - if _, err := ParseManifest(ok); err != nil { - t.Fatalf("a module could not define and claim its own seat: %v", err) - } - // Claiming a name nobody defines is still refused — but **at registration, not here**: with - // seats declared by modules, a claim on a seat *another* module declares is good, and the - // parser cannot tell that from an invented name. See - // TestAClaimOnASeatNobodyDeclaresIsRefusedAtRegistration. - claimant, err := ParseManifest(claimed(`[{"name":"the-anything","scope":"node"}]`)) - if err != nil { - t.Fatalf("the parser judged a claim it cannot judge alone: %v", err) - } - if len(CatalogueProblems(Shelf{claimant.Module: claimant})) == 0 { - t.Fatal("a module claimed a seat nobody defines") - } - // A module may not carve its seat out of the mesh's own namespace. - bad := []byte(`{"module":"x","version":"1","seats":[{"name":"node-mine","scope":"node"}],` + - `"claims":[{"name":"node-mine","scope":"node"}]}`) - if _, err := ParseManifest(bad); err == nil || !strings.Contains(err.Error(), "own namespace") { - t.Fatalf("a module defined a seat in the mesh's namespace and was not refused: %v", err) - } -} - -// **At registration, not in the parser** (novox/hq ADR 0122): a seat's scope is a property of the -// set, the set is the store's, and the parser also runs on a build machine that has no store. -func TestASeatClaimedAtAnotherScopeIsRefused(t *testing.T) { - m, err := ParseManifest(claimed(`[{"name":"npm-package-registry","scope":"node"}]`)) - if err != nil { - t.Fatalf("the parser judged a scope it reads from data it may not have: %v", err) - } - got := strings.Join(CatalogueProblems(Shelf{m.Module: m}), "; ") - if !strings.Contains(got, "mesh seat") { - t.Fatalf("the refusal does not say which scope the seat is: %q", got) - } -} - -func TestADeliveringSeatIsOnlyHeldByAModuleThatProvides(t *testing.T) { - // Holding it makes the module the mesh's answer for the provision. A module that cannot answer - // would be the answer anyway, and every consumer would be sent to it. - // And refused at registration, where the seat set is the store's: what a seat delivers is - // data, so a compiled copy of it may not be what refuses a build (novox/hq ADR 0122). - raw := []byte(`{"module":"thing","version":"1","claims":[{"name":"git","scope":"mesh"}]}`) - m, err := ParseManifest(raw) - if err != nil { - t.Fatalf("the parser judged what a seat delivers: %v", err) - } - got := strings.Join(CatalogueProblems(Shelf{m.Module: m}), "; ") - if !strings.Contains(got, `does not provide "git"`) { - t.Fatalf("the refusal does not say what is missing: %q", got) - } -} - -func TestAClaimThatIsMalformedIsRefusedOnceForThat(t *testing.T) { - // Not a second time for being unknown: one mistake, one line. - _, err := ParseManifest(claimed(`[{"name":"Not A Name","scope":"node"}]`)) - if err == nil { - t.Fatal("a malformed claim was accepted") - } - if strings.Contains(err.Error(), "not a seat") { - t.Fatalf("a malformed claim was also called unknown: %v", err) - } -} - // The holder of a seat answers among several providers. func registryShelf() map[string]Manifest { @@ -380,48 +122,3 @@ func TestAFormerNameResolvesAfterARename(t *testing.T) { t.Fatalf("the holder recorded under the former name was not matched: %+v found=%v", holder, found) } } - -// 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 := 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 := Manifest{Module: "watcher", Uses: []string{"node-build-agent"}} - if joined := strings.Join(CatalogueProblems(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 := SeatNamed(name) - if !ok || s.Scope != 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 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) - } -} diff --git a/go/catalogue/secret_family_test.go b/go/catalogue/secret_family_test.go index 506456d..95531ca 100644 --- a/go/catalogue/secret_family_test.go +++ b/go/catalogue/secret_family_test.go @@ -1,7 +1,6 @@ package catalogue import ( - "strings" "testing" ) @@ -10,7 +9,7 @@ import ( func familyManifest(t *testing.T, ownSecrets string) (Manifest, error) { t.Helper() - return ParseManifest([]byte(`{"module":"mounts","version":"1","own-secrets":{` + ownSecrets + `}}`)) + return decodeForTest([]byte(`{"module":"mounts","version":"1","own-secrets":{` + ownSecrets + `}}`)) } func TestAFamilyIsDeclaredIssuedOutsideWithOneStar(t *testing.T) { @@ -26,23 +25,6 @@ func TestAFamilyIsDeclaredIssuedOutsideWithOneStar(t *testing.T) { } } -func TestAMalformedFamilyIsRefused(t *testing.T) { - for name, c := range map[string]struct{ own, says string }{ - "made by the mesh": {`"smb-password-*":{"path":"/s/smb-password-*.secret","taken":"at-start"}`, "issued-by"}, - "no star in its path": {`"smb-password-*":{"path":"/s/smb-password.secret","issued-by":"outside"}`, - "exactly one *"}, - "two stars in its path": {`"smb-password-*":{"path":"/s/*/smb-password-*.secret","issued-by":"outside"}`, - "exactly one *"}, - "a star inside a name": {`"smb*password":{"path":"/s/x","issued-by":"outside"}`, "only ends a family"}, - "a name that is also a member": {`"smb-password-*":{"path":"/s/p-*","issued-by":"outside"}, - "smb-password-games":{"path":"/s/games","issued-by":"outside"}`, "also a member"}, - } { - if _, err := familyManifest(t, c.own); err == nil || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: %v; want a refusal saying %q", name, err, c.says) - } - } -} - func TestAMemberIsFoundByItsFullNameAndLandsWhereTheStarIs(t *testing.T) { o := OwnSecrets{ "smb-password-*": {Path: "/s/smb-password-*.secret", IssuedBy: IssuedOutside}, diff --git a/go/catalogue/served_by_setting_test.go b/go/catalogue/served_by_setting_test.go index 681038d..91c16b7 100644 --- a/go/catalogue/served_by_setting_test.go +++ b/go/catalogue/served_by_setting_test.go @@ -59,7 +59,7 @@ func composedLikeAce(t *testing.T, influxdb string, layers []Layer) map[string]s t.Helper() var ms []Manifest for _, raw := range []string{influxdb, grafanaLikeTheCatalogue} { - m, err := ParseManifest([]byte(raw)) + m, err := decodeForTest([]byte(raw)) if err != nil { t.Fatal(err) } diff --git a/go/catalogue/served_own_names.go b/go/catalogue/served_own_names.go index ca231c8..9599542 100644 --- a/go/catalogue/served_own_names.go +++ b/go/catalogue/served_own_names.go @@ -3,7 +3,6 @@ package catalogue import ( "fmt" "sort" - "strings" ) // A provider may tell its consumers its own composed names (novox/hq ADR 0346, issue 624). @@ -22,10 +21,6 @@ import ( // consumer told nothing — a refused provider machine takes every provision it offers down with it. The // plan says which key was left out, and why, on the provider's machine and under each consumer's need. -// ownNameFacts are what a served value may name of the provider's own composition: the names, as its -// own files read them (withOwnNames). -var ownNameFacts = []string{"name", "internal-name"} - // requirementsContributedTo is every requirement m contributes to, sorted. func requirementsContributedTo(m Manifest) []string { seen := map[string]bool{} @@ -43,19 +38,6 @@ func requirementsContributedTo(m Manifest) []string { return out } -// ownNameKeys is every name a served value of m may say for one requirement it contributes to: -// `name` and `internal-name`, and `name-` and `internal-name-` for each local name it -// contributes under. -func ownNameKeys(m Manifest, to string) []string { - keys := append([]string{}, ownNameFacts...) - for _, local := range sortedKeys(m.ContributesMany[to]) { - for _, fact := range ownNameFacts { - keys = append(keys, fact+"-"+local) - } - } - return keys -} - // ServedOwnNames fills the provider's own composed names into what it serves one provision's // consumers, from this resolution's composition of the provider's machine (ADR 0346). // @@ -138,65 +120,3 @@ func (r Resolution) ServedLeftOut(settings SettingsBy) []string { } return said } - -// checkServedOwnNames refuses a served `${bound:…}` that names a requirement the module does not -// contribute to, a fact other than its own names, or a local name it does not contribute under; and -// one with a default, since a served value is left out rather than given one (ADR 0346 §3, §4). -func checkServedOwnNames(m Manifest, provision, key, text string) []string { - var problems []string - contributes := requirementsContributedTo(m) - for _, parts := range bound.FindAllStringSubmatch(text, -1) { - to, fact, fallback := parts[1], parts[2], parts[3] - says := fmt.Sprintf("%s serves %s, and the value it serves as %q says %s", m.Module, provision, key, parts[0]) - switch { - case fallback != "": - problems = append(problems, says+": a served name takes no default; one the machine does not "+ - "compose leaves the key out (novox/hq ADR 0346)") - case !oneOf(contributes, to): - problems = append(problems, fmt.Sprintf( - "%s, and %s contributes to %s: a served value names only the provider's own names "+ - "(novox/hq ADR 0346)", says, m.Module, orNothing(quoted(contributes)))) - case !oneOf(ownNameKeys(m, to), fact): - problems = append(problems, fmt.Sprintf("%s, and what %s is called through %s is %s (novox/hq ADR 0346)", - says, m.Module, to, strings.Join(ownNameKeys(m, to), ", "))) - } - } - return problems -} - -func quoted(values []string) []string { - out := make([]string, len(values)) - for i, v := range values { - out[i] = fmt.Sprintf("%q", v) - } - return out -} - -// nestedOwnNames refuses a `${bound:…}` inside a served list or object: only a string value of its own is -// filled (ServedOwnNames), so a nested one would reach the consumer as the placeholder (ADR 0346 §5). -func nestedOwnNames(m Manifest, provision, key string, value any) []string { - var found []string - var walk func(v any) - walk = func(v any) { - switch x := v.(type) { - case string: - found = append(found, bound.FindAllString(x, -1)...) - case []any: - for _, e := range x { - walk(e) - } - case map[string]any: - for _, k := range sortedAnyKeys(x) { - walk(x[k]) - } - } - } - walk(value) - if len(found) == 0 { - return nil - } - return []string{fmt.Sprintf( - "%s serves %s, and the value it serves as %q holds %s inside a list or an object: a provider's own "+ - "name is filled only in a string value of its own (novox/hq ADR 0346)", - m.Module, provision, key, strings.Join(found, ", "))} -} diff --git a/go/catalogue/served_own_names_test.go b/go/catalogue/served_own_names_test.go index db00dcc..292947c 100644 --- a/go/catalogue/served_own_names_test.go +++ b/go/catalogue/served_own_names_test.go @@ -18,7 +18,7 @@ const aRegistryForge = `{"module":"forge","version":"1","requires":["route"], func aForgeManifest(t *testing.T) Manifest { t.Helper() - m, err := ParseManifest([]byte(aRegistryForge)) + m, err := decodeForTest([]byte(aRegistryForge)) if err != nil { t.Fatal(err) } @@ -130,29 +130,6 @@ func TestAConsumerElsewhereIsToldWhatTheProviderLeftOut(t *testing.T) { t.Fatalf("no need: %+v", got.Needs) } -func TestAServedNameIsCheckedWhenTheDefinitionIsRead(t *testing.T) { - for bad, want := range map[string]string{ - `"public-root":"https://${bound:route:name-api}/x"`: "what forge is called through route is name, internal-name, name-web, internal-name-web", - `"public-root":"https://${bound:postgres-database:name}/x"`: `forge contributes to "route"`, - `"public-root":"https://${bound:route:name-web|default:x}/x"`: "takes no default", - `"public-root":"https://${bound:route:at}/x"`: "what forge is called through route", - } { - raw := strings.Replace(aRegistryForge, `"public-root":"https://${bound:route:name-web}/api/packages/novox/npm/"`, bad, 1) - _, err := ParseManifest([]byte(raw)) - if err == nil || !strings.Contains(err.Error(), want) { - t.Errorf("%s: want a refusal saying %q, got %v", bad, want, err) - } - } - nested := strings.Replace(aRegistryForge, `"public-root":"https://${bound:route:name-web}/api/packages/novox/npm/"`, - `"roots":["https://${bound:route:name-web}/x"]`, 1) - if _, err := ParseManifest([]byte(nested)); err == nil || !strings.Contains(err.Error(), "inside a list or an object") { - t.Errorf("a nested own name is refused, not served as the placeholder: %v", err) - } - if _, err := ParseManifest([]byte(aRegistryForge)); err != nil { - t.Fatalf("its own name is accepted: %v", err) - } -} - // The provider's contributions file carries no own name on a consumer's entry (ADR 0346 §4): only what // is derived for that consumer is there (ADR 0202 §5), and the provider reads its own names in its files. func TestAProvidersOwnNameIsNotOnItsConsumersEntry(t *testing.T) { diff --git a/go/catalogue/setting_defaults.go b/go/catalogue/setting_defaults.go index 353b406..8f8ad4e 100644 --- a/go/catalogue/setting_defaults.go +++ b/go/catalogue/setting_defaults.go @@ -1,9 +1,7 @@ package catalogue import ( - "fmt" "sort" - "strings" ) // A preference has a default; the operator's own value has none (novox/hq ADR 0262, extending ADR @@ -68,122 +66,6 @@ var meshWords = map[string]bool{ PlacesSetting: true, AccessesSetting: true, NetworksSetting: true, } -// operatorsOwn are the words that, as what a key's name is about, say its value is the operator's and -// never a preference: a default for one would be the literal ADR 0112 removed from definitions. -var operatorsOwn = map[string]bool{ - "domain": true, "host": true, "hostname": true, "servername": true, "fqdn": true, "zone": true, - "realm": true, "tenant": true, "site": true, "timezone": true, - "issuer": true, "url": true, "uri": true, "webhook": true, "origin": true, "dsn": true, - "ip": true, "ipv4": true, "ipv6": true, - "email": true, "mail": true, "phone": true, "address": true, - "identity": true, "login": true, "user": true, "username": true, "account": true, "owner": true, - "uid": true, "gid": true, "puid": true, "pgid": true, - "password": true, "pass": true, "passwd": true, "passphrase": true, "secret": true, "token": true, - "key": true, "apikey": true, "bearer": true, "cert": true, "certificate": true, "credential": true, - "nameserver": true, "gateway": true, "subnet": true, "sender": true, "recipient": true, "contact": true, - "trusted": true, "whitelist": true, "peer": true, "bind": true, "listen": true, "upstream": true, - "proxy": true, "admin": true, "mac": true, -} - -// operatorsCompounds are names of two words that are the operator's though neither word alone says so -// at the end of a key: a client's identifier, and a name the world knows a site or server by. -var operatorsCompounds = map[string]bool{ - "client-id": true, "site-name": true, "server-name": true, "public-name": true, "smtp-relay": true, - "host-name": true, "user-name": true, "domain-name": true, "dns-server": true, - // Whom a rule lets in or keeps out: a list of addresses or networks. - "allow-from": true, "deny-from": true, "allow-list": true, -} - -// aboutAnAmount are first words that make a key about how many or whether, never about whom: -// `max-tokens` is a number, `show-hostname` a switch. Not `allow` or `use`: `allow-from` and -// `use-host` name whom. -var aboutAnAmount = map[string]bool{ - "max": true, "min": true, "num": true, "count": true, "show": true, "hide": true, "enable": true, - "disable": true, -} - -// operatorsWord is what in a key's name says its value is the operator's, or "". A key is about its -// last word — `url-timeout` is a timeout, `user-agent` an agent, `mail-domain` a domain — or its last two -// as one of operatorsCompounds. A plural is read as its singular. -func operatorsWord(key string) string { - words := strings.FieldsFunc(key, func(r rune) bool { return r == '-' || r == '_' || r == '.' }) - if len(words) == 0 || (len(words) > 1 && aboutAnAmount[words[0]]) { - return "" - } - for i, w := range words { - switch { - case operatorsOwn[w]: - case strings.HasSuffix(w, "ies") && operatorsOwn[strings.TrimSuffix(w, "ies")+"y"]: - words[i] = strings.TrimSuffix(w, "ies") + "y" - case strings.HasSuffix(w, "s") && operatorsOwn[strings.TrimSuffix(w, "s")]: - words[i] = strings.TrimSuffix(w, "s") - } - } - if n := len(words); n > 1 { - // Where a secret or an identity is kept is the operator's too: `password-file`, `token-path`. - if (words[n-1] == "file" || words[n-1] == "path") && operatorsOwn[words[n-2]] { - return words[n-2] + "-" + words[n-1] - } - for _, last := range []string{words[n-1], strings.TrimSuffix(words[n-1], "s")} { - if pair := words[n-2] + "-" + last; operatorsCompounds[pair] { - return pair - } - } - } - if last := words[len(words)-1]; operatorsOwn[last] { - return last - } - return "" -} - -// SettingProblems is every way a definition's setting defaults are wrong, in its own words. -func SettingProblems(m Manifest) []string { - if len(m.Settings) == 0 { - return nil - } - used := settingKeysUsedBy(m) - var problems []string - for _, key := range sortedSettingKeys(m.Settings) { - d := m.Settings[key] - say := func(format string, args ...any) { - problems = append(problems, fmt.Sprintf("%s's setting %q: ", m.Module, key)+fmt.Sprintf(format, args...)) - } - if !settingRef.MatchString("${setting:" + key + "}") { - say("not a usable key: lower-case letters, digits, dots, dashes and underscores") - continue - } - if meshWords[key] { - say("the mesh reads %q itself, and a module gives it no default", key) - continue - } - if d.Kind != KindPreference { - say("kind is %q, and only a %q has a default (novox/hq ADR 0262)", d.Kind, KindPreference) - } - if word := operatorsWord(key); word != "" { - say("a key naming %q is the operator's value, and has no default — it is the "+ - "assignment's, never the definition's (novox/hq ADR 0112, ADR 0262)", word) - } - switch v := d.Default.(type) { - case string: - if strings.TrimSpace(v) == "" { - say("an empty default is no default; give the value, or declare nothing") - } - case float64, bool: - case nil: - say("no default: a key without one is the operator's, and is not declared here") - default: - say("a default is a string, a number or a boolean, and this is %T", d.Default) - } - if strings.TrimSpace(d.Why) == "" { - say("no why: say in one sentence why this default") - } - if !used[key] { - say("nothing asks for ${setting:%s}, so the default reaches nothing", key) - } - } - return problems -} - // Defaults is the layer a module's own defaults make, or nothing when it gives none. func Defaults(m Manifest) (Layer, bool) { if len(m.Settings) == 0 { @@ -250,12 +132,3 @@ func Effective(m Manifest, layers []Layer) []SettingSource { sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key }) return out } - -func sortedSettingKeys(in map[string]SettingDeclaration) []string { - keys := make([]string, 0, len(in)) - for k := range in { - keys = append(keys, k) - } - sort.Strings(keys) - return keys -} diff --git a/go/catalogue/setting_defaults_test.go b/go/catalogue/setting_defaults_test.go index a1f013d..7b08f4b 100644 --- a/go/catalogue/setting_defaults_test.go +++ b/go/catalogue/setting_defaults_test.go @@ -26,7 +26,7 @@ func parsed(t *testing.T, m Manifest) error { if err != nil { t.Fatal(err) } - _, err = ParseManifest(raw) + _, err = decodeForTest(raw) return err } @@ -135,40 +135,6 @@ func TestAnOperatorsValueWithoutADefaultIsStillRefused(t *testing.T) { } } -func TestTheParserTakesAPreferenceAndRefusesTheRest(t *testing.T) { - if err := parsed(t, notifier()); err != nil { - t.Fatalf("a preference with a default was refused: %v", err) - } - for _, c := range []struct { - name string - key string - d SettingDeclaration - refuse string - }{ - {"no kind", "font-size", SettingDeclaration{Default: float64(10), Why: "x"}, `only a "preference" has a default`}, - {"another kind", "font-size", SettingDeclaration{Kind: "operator", Default: float64(10), Why: "x"}, `only a "preference"`}, - {"no default", "font-size", SettingDeclaration{Kind: KindPreference, Why: "x"}, "no default"}, - {"an empty default", "font-size", SettingDeclaration{Kind: KindPreference, Default: " ", Why: "x"}, "an empty default"}, - {"an object", "font-size", SettingDeclaration{Kind: KindPreference, Default: map[string]any{"a": 1.0}, Why: "x"}, "a string, a number or a boolean"}, - {"no why", "font-size", SettingDeclaration{Kind: KindPreference, Default: float64(10)}, "no why"}, - {"the operator's", "mail-domain", SettingDeclaration{Kind: KindPreference, Default: "example.tld", Why: "x"}, "is the operator's value"}, - {"a secret", "api-token", SettingDeclaration{Kind: KindPreference, Default: "x", Why: "x"}, "is the operator's value"}, - {"the mesh's word", PortsSetting, SettingDeclaration{Kind: KindPreference, Default: float64(1), Why: "x"}, "the mesh reads"}, - {"read by nothing", "height", SettingDeclaration{Kind: KindPreference, Default: float64(300), Why: "x"}, "reaches nothing"}, - } { - m := notifier() - m.Resources[0]["content"] = m.Resources[0]["content"].(string) + "x = ${setting:" + c.key + "}\n" - if c.name == "read by nothing" { - m.Resources[0]["content"] = "font = Inter ${setting:font-size}\nwidth = ${setting:width}\n" - } - m.Settings[c.key] = c.d - err := parsed(t, m) - if err == nil || !strings.Contains(err.Error(), c.refuse) { - t.Errorf("%s: parsed %v, want %q", c.name, err, c.refuse) - } - } -} - func TestEveryValueSaysWhereItCameFrom(t *testing.T) { m := notifier() m.Resources[0]["content"] = m.Resources[0]["content"].(string) + "x = ${setting:position}\n" @@ -218,60 +184,6 @@ func TestANodeCalledDefaultIsANodesLayer(t *testing.T) { } } -// The operator's own value is told by what a key's name is about: its last word, or its last two as -// a known compound; a plural as its singular; and never a number or a switch. -func TestAKeyIsTheOperatorsByWhatItIsAbout(t *testing.T) { - for _, key := range []string{"max-tokens", "show-hostname", "ghost-opacity", "users-per-page", "mailbox-size", - "font-size", "width", "keyboard-delay", "ipv6-preferred", "client-width", "user-agent", "url-timeout", - "site-title", "cert-renewal-days", "name", "font-name", "allow-resize", "use-gpu", "disable-sender-check", - "max-recipients", "gateway-timeout", "sender-delay", "upstream-resolvers", "mirror-countries", - "pool-region", "proxy-timeout", "listen-backlog", "peer-keepalive", "admin-theme", "log-file", - "cache-path"} { - if w := operatorsWord(key); w != "" { - t.Errorf("%s read as the operator's (%s)", key, w) - } - } - for key, word := range map[string]string{"mail-domain": "domain", "api-key": "key", "admin-password": "password", - "oauth-client-id": "client-id", "db-dsn": "dsn", "public-ip": "ip", "puid": "puid", "dns-zone": "zone", - "webhook": "webhook", "notify_phone": "phone", "cors.origin": "origin", "smtp-pass": "pass", - "backup-passphrase": "passphrase", "data-owner": "owner", "fqdn": "fqdn", "tenant": "tenant", "host": "host", - "allowed-hosts": "host", "admin-emails": "email", "tokens": "token", "hostname": "hostname", - "apikey": "apikey", "servername": "servername", "tls-cert": "cert", "site": "site", "timezone": "timezone", - "bearer": "bearer", "bind-ipv4": "ipv4", "listen-ipv6": "ipv6", "site-name": "site-name", - "server-name": "server-name", "public-name": "public-name", "smtp-relay": "smtp-relay", - "host-name": "host-name", "user-name": "user-name", "domain-name": "domain-name", - "nameserver": "nameserver", "upstream-nameservers": "nameserver", "dns-server": "dns-server", - "dns-servers": "dns-server", "default-gateway": "gateway", "lan-subnet": "subnet", "sender": "sender", - "notify-recipients": "recipient", "contact": "contact", "tls-certificate": "certificate", - "allow-from": "allow-from", "deny-from": "deny-from", "allow-hosts": "host", "use-host": "host", - "trusted": "trusted", "allow-list": "allow-list", "ip-whitelist": "whitelist", "peer": "peer", - "wireguard-peers": "peer", "bind": "bind", "listen": "listen", "upstream": "upstream", "http-proxy": "proxy", - "trusted-proxies": "proxy", "admin": "admin", "notify-admin": "admin", "wake-mac": "mac", - "password-file": "password-file", "key-file": "key-file", "token-path": "token-path", - "secret-file": "secret-file", "cert-path": "cert-path"} { - if w := operatorsWord(key); w != word { - t.Errorf("%s: read %q, want %q", key, w, word) - } - } -} - -// A default is a value the mesh writes, so the installation check reads it, even under a setting -// called `description` or `why`; a setting's own why is prose and is not read. -func TestTheInstallationCheckReadsADefault(t *testing.T) { - m := notifier() - m.Settings["relay"] = SettingDeclaration{Kind: KindPreference, Default: "relay.acme.be", Why: "as at relay.acme.be"} - m.Settings["description"] = SettingDeclaration{Kind: KindPreference, Default: "notes.acme.be", Why: "x"} - problems := strings.Join(InstallationProblems(m), "; ") - for _, want := range []string{"relay.acme.be at settings.relay.default", "notes.acme.be at settings.description.default"} { - if !strings.Contains(problems, want) { - t.Errorf("not reported: %q in %s", want, problems) - } - } - if strings.Contains(problems, "settings.relay.why") { - t.Errorf("a why was read as a value: %s", problems) - } -} - // A default fills ${setting:…} in what a module contributes and serves, and never replaces a value a // contribution states itself. func TestADefaultFillsAContributionAndAServedFactButNotALiteral(t *testing.T) { @@ -298,52 +210,6 @@ func TestADefaultFillsAContributionAndAServedFactButNotALiteral(t *testing.T) { } } -// 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 := 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 := Resolution{Node: "laptop", Modules: []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 Manifest - if err := json.Unmarshal([]byte(`{"module": "now", "state": [{"name": "s"}]}`), &known); err != nil || known.UnknownField() != "" { - t.Fatalf("a known manifest: %v %q", err, known.UnknownField()) - } - // A malformed manifest is still refused: only an unknown key is read past. - var bad Manifest - if err := json.Unmarshal([]byte(`{"module": "bad", "state": [{"name": 3}]}`), &bad); err == nil { - t.Fatal("a malformed stored manifest was read") - } -} - // A module left out for a key this controller does not know, inside an entry, is read past that key // alone: it still provides what it provides, its other entries are whole, and a key of the same name // that another entry knows is kept (novox/hq ADR 0262). diff --git a/go/catalogue/setting_in_owner_test.go b/go/catalogue/setting_in_owner_test.go index 00affaa..f441f64 100644 --- a/go/catalogue/setting_in_owner_test.go +++ b/go/catalogue/setting_in_owner_test.go @@ -95,52 +95,6 @@ func TestAnOwnerSettingThatIsNoNumberIsRefused(t *testing.T) { } } -// Where the manifest is read: an owner holding a setting is a whole placeholder or a whole number on -// each side of the colon, and every key is one the module asks for elsewhere or declares a default for. -func TestAnOwnerSettingIsJudgedWhereTheManifestIsRead(t *testing.T) { - ok := []string{"${setting:puid}:${setting:pgid}", "${setting:puid}", "${setting:puid}:1000", "1000:${setting:pgid}", - "1000:1000", "media:media"} - bad := []string{ - "${setting:puid}x", - "x${setting:puid}", - "${setting:puid}:${setting:pgid}:1", - "${setting:puid}${setting:pgid}", - "media:${setting:pgid}", - "${setting:puid}:", - "${setting:other}:${setting:pgid}", - } - for _, owner := range append(ok, bad...) { - m := recycler() - m.Resources[0] = copied(m.Resources[0]) - m.Resources[0]["owner"] = owner - err := parsed(t, m) - refused := err != nil && strings.Contains(err.Error(), "owner") - want := false - for _, b := range bad { - want = want || b == owner - } - if refused != want { - t.Errorf("%s: refused %v, want %v (%v)", owner, refused, want, err) - } - } - // A key with a default is declared, though nothing else asks for it. - m := recycler() - m.Resources = m.Resources[:1] - m.Resources[0] = copied(m.Resources[0]) - m.Resources[0]["owner"] = "${setting:workers}" - m.Settings = map[string]SettingDeclaration{"workers": {Kind: KindPreference, Default: float64(4), Why: "a test"}} - if err := parsed(t, m); err != nil && strings.Contains(err.Error(), "owner") { - t.Errorf("a declared key was refused: %v", err) - } - // A file's owner takes no setting: the sweep says so. - f := recycler() - f.Resources[1] = copied(f.Resources[1]) - f.Resources[1]["owner"] = "${setting:puid}" - if err := parsed(t, f); err == nil || !strings.Contains(err.Error(), "not filled in this field") { - t.Errorf("a file's owner setting passed: %v", err) - } -} - // A key a directory's owner asks for is a destination, so setting it is not called stray. func TestASettingAnOwnerAsksForIsNotStray(t *testing.T) { m := recycler() diff --git a/go/catalogue/setting_in_unit_test.go b/go/catalogue/setting_in_unit_test.go index 77197b7..31ba7af 100644 --- a/go/catalogue/setting_in_unit_test.go +++ b/go/catalogue/setting_in_unit_test.go @@ -108,35 +108,6 @@ func TestASettingAUnitAsksForIsNotStrayAndIsJudged(t *testing.T) { } } -// Third review: a setting may name only a template's instance — right after the `@`, before the final -// suffix, and the whole instance — so a value can never make the unit another unit, nor another kind. -// Refused where the manifest is read, near its author. -func TestASettingInAUnitNameIsOnlyATemplatesInstance(t *testing.T) { - ok := []string{"zfs-scrub-weekly@${setting:scrub-pool}.timer", "getty@${setting:tty}.service"} - bad := []string{ - "${setting:unit}", - "${setting:name}.timer", - "zfs-scrub-${setting:cadence}@storage.timer", - "zfs-scrub@${setting:pool}-x.timer", - "zfs-scrub@x-${setting:pool}.timer", - "zfs-scrub@${setting:pool}.${setting:kind}", - "zfs-scrub@${setting:pool}", - "zfs-scrub@${setting:a}${setting:b}.timer", - } - for _, unit := range append(ok, bad...) { - m := Manifest{Module: "zfs", Resources: []map[string]any{{"id": "t", "type": "service", "unit": unit, "state": "stopped"}}} - err := parsed(t, m) - refused := err != nil && strings.Contains(err.Error(), "instance") - want := false - for _, b := range bad { - want = want || b == unit - } - if refused != want { - t.Errorf("%s: refused %v, want %v (%v)", unit, refused, want, err) - } - } -} - func TestAnInstanceValueMayNotLeadWithADashNorBeLong(t *testing.T) { m := scrubber() for _, bad := range []string{"-storage", "--help", strings.Repeat("a", 65)} { diff --git a/go/catalogue/setting_into.go b/go/catalogue/setting_into.go index 3ced407..0ec1d33 100644 --- a/go/catalogue/setting_into.go +++ b/go/catalogue/setting_into.go @@ -119,32 +119,6 @@ func settingInto(resource map[string]any, layers []Layer, module string) error { // a newline that would begin a directive in the unit file the name ends up in — is refused, never written. var unitPart = regexp.MustCompile(`^[A-Za-z0-9:_.][A-Za-z0-9:_.-]{0,63}$`) -// unitInstance is the one place a setting may stand in a unit's name: the whole instance of a template, -// after its `@` and before its suffix — `zfs-scrub-weekly@${setting:scrub-pool}.timer` — so a value can -// make the unit another instance of the same template and nothing else (third review of mesh-catalog #147). -var unitInstance = regexp.MustCompile(`^[A-Za-z0-9:_.-]+@\$\{setting:[a-z0-9][a-z0-9_.-]*\}\.[a-z]+$`) - -// UnitSettingProblems refuses, where a manifest is read, a service whose unit name carries a setting -// anywhere but as a template's whole instance. -func UnitSettingProblems(m Manifest) []string { - var problems []string - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) != "service" { - continue - } - unit, _ := r["unit"].(string) - if len(settingsUsed(unit)) == 0 && !strings.Contains(unit, "${setting:") { - continue - } - if !unitInstance.MatchString(unit) { - problems = append(problems, fmt.Sprintf("%s: the service %v's unit %q carries a setting outside a template's "+ - "instance; a setting may stand only as the whole instance, after the @ and before the suffix "+ - "(name@${setting:key}.timer)", m.Module, r["id"], unit)) - } - } - return problems -} - // settingIntoUnit fills ${setting:…} in a service resource's unit name: a module that holds the // distribution's timer for the pool the operator names cannot write the pool into its definition // (novox/hq ADR 0112). Refused, with the key and why, when nothing sets it or the value would not make a @@ -194,12 +168,6 @@ func settingIntoUnit(resource map[string]any, layers []Layer, module string) err // contribution or a served fact — or declares a default for, so the owner follows the identity its // process is told it runs as, never a key that reaches nothing else. -// ownerSide is one side of an owner a setting may fill: a whole placeholder or a whole number. -var ownerSide = `(?:\$\{setting:[a-z0-9][a-z0-9_.-]*\}|[0-9]+)` - -// ownerWithSetting is the only shape an owner holding a setting may have: user, or user:group. -var ownerWithSetting = regexp.MustCompile(`^` + ownerSide + `(?::` + ownerSide + `)?$`) - // wholeID is a value as a uid or a gid, and whether it is one: a whole number below the kernel's "no id" // (2^32-1). Uid 0 is one: a key an owner asks for is the controller's terminal's alone (TerminalKeys). func wholeID(v string) (uint64, bool) { @@ -210,49 +178,6 @@ func wholeID(v string) (uint64, bool) { return n, err == nil && n < 1<<32-1 } -// OwnerSettingProblems refuses, where a manifest is read, a directory whose owner carries a setting -// in any shape but a whole placeholder or number on each side of the colon, or names a key the module -// asks for nowhere else and declares no default for. -func OwnerSettingProblems(m Manifest) []string { - var problems []string - asked := settingKeysAskedOutsideOwners(m) - for k := range m.Settings { - asked[k] = true - } - for _, r := range m.Resources { - owner, _ := r["owner"].(string) - if !strings.Contains(owner, "${setting:") { - continue - } - if fmt.Sprint(r["type"]) != "directory" { - // Said by the placeholder sweep: only a directory's owner takes a setting. - continue - } - if !ownerWithSetting.MatchString(owner) { - problems = append(problems, fmt.Sprintf("%s: the directory %v's owner %q carries a setting in a shape "+ - "the mesh does not fill; an owner names the account by number only, each side of the colon a whole "+ - "${setting:key} or a whole number (${setting:puid}:${setting:pgid}) (novox/hq issue 279)", - m.Module, r["id"], owner)) - continue - } - // A template's text is not here yet, so what the module asks for elsewhere cannot be known: WithTemplatesFrom - // parses its result again with every template filled, and this half is judged then (as config.go skips its - // "placed nowhere" rule while m.Templated()). - if m.Templated() { - continue - } - for _, key := range settingsUsed(owner) { - if !asked[key] { - problems = append(problems, fmt.Sprintf("%s: the directory %v's owner says ${setting:%s}, and the "+ - "module asks for %q nowhere else and declares no default for it: an owner follows the identity "+ - "the module's process is told it runs as (its identity file's ${setting:%s}), never a key of its "+ - "own (novox/hq issue 279)", m.Module, r["id"], key, key, key)) - } - } - } - return problems -} - // settingIntoOwner fills ${setting:…} in a directory's owner, refusing — with the key and why, the // resource left as it was — a key nothing sets and a value that is not a whole number. func settingIntoOwner(resource map[string]any, layers []Layer, module string) error { @@ -386,51 +311,12 @@ func settingKeysAskedOutsideOwners(m Manifest) map[string]bool { // element of `groups` is a whole placeholder or a whole number; a placeholder is filled with the number the // config value holds, and the list reaches the node-engine as numbers. -// groupSide is one element of a container's groups: a whole placeholder or a whole number. -var groupSide = regexp.MustCompile(`^(?:\$\{setting:[a-z0-9][a-z0-9_.-]*\}|[0-9]+)$`) - // groupsOf is a container resource's groups as the manifest wrote them (text or numbers), none when it names none. func groupsOf(r map[string]any) []any { groups, _ := r["groups"].([]any) return groups } -// GroupSettingProblems refuses, where a manifest is read, a container whose groups hold anything but whole -// placeholders and whole numbers, or a placeholder whose key the module neither declares nor asks for elsewhere. -func GroupSettingProblems(m Manifest) []string { - var problems []string - for _, r := range m.Resources { - if fmt.Sprint(r["type"]) != "container" { - continue - } - for _, g := range groupsOf(r) { - s, isText := g.(string) - if !isText { - continue // a number: the declaration judges its value - } - if !groupSide.MatchString(s) { - problems = append(problems, fmt.Sprintf("%s: the container %v's group %q is not a whole "+ - "${config:key} or a whole number: a supplementary group is a number, and nothing else is "+ - "written into it (novox/hq ADR 0480 clause 7)", m.Module, r["id"], s)) - continue - } - if m.Templated() { - continue - } - for _, key := range settingsUsed(s) { - if _, declared := m.Settings[key]; !declared && !m.DeclaresConfig() { - continue // the settings form judges its own keys - } - if _, declared := m.Settings[key]; !declared { - problems = append(problems, fmt.Sprintf("%s: the container %v's group says ${config:%s}, and the module "+ - "declares no such value (novox/hq ADR 0348 §4)", m.Module, r["id"], key)) - } - } - } - } - return problems -} - // settingIntoGroups fills ${setting:…} in a container's groups, refusing — with the key and why, the resource left as // it was — a key nothing sets and a value that is not a whole number above zero. A number is kept; the list that // results is numbers alone. diff --git a/go/catalogue/setting_takes.go b/go/catalogue/setting_takes.go index 89d8f53..e62ec41 100644 --- a/go/catalogue/setting_takes.go +++ b/go/catalogue/setting_takes.go @@ -153,48 +153,6 @@ func namesItsKeys(m Manifest) bool { return merging > 0 } -// TakenSettingsProblems is every way a definition's takes-settings is wrong (ADR 0326 §2 and §4), in -// its own words: on a file that does not merge, not a list of keys, a key named twice, one the mesh -// reads itself, one the file keeps for itself, or one mergeable file naming its keys beside another -// that names none. -func TakenSettingsProblems(m Manifest) []string { - return takenSettingsProblems(m) -} - -// WrittenSettingsProblems is ADR 0326 §3, judged only where a definition is written — the module -// check, `module add`, a build taken in, the builder and the merge gate's registration — and never -// where a stored one is read again (ParseManifest, a build's manifest at a commit, a module put back): -// a module that declares a setting names the keys of every mergeable file it owns (novox/hq issue -// 574, ADR 0164 §11 and §15). A build stored before the rule, such as betterbird's before its accounts -// file named its keys, must still compose and still be put back; only its next change is held to it. -// -// Said only when none of the module's mergeable files names its keys: when one does, §2 already -// names every file that does not, and a file is not reported twice. -func WrittenSettingsProblems(m Manifest) []string { - if len(m.Settings) == 0 || m.DeclaresConfig() { - // A module in the config form is held to this where it is read, in its own words (ConfigProblems). - return nil - } - var silent []string - for _, r := range m.Resources { - how, _ := r["merge"].(string) - if how == "" { - continue - } - if _, said := r[TakesSettingsField]; said { - return nil - } - silent = append(silent, fmt.Sprint(r["id"])) - } - var problems []string - for _, id := range silent { - problems = append(problems, fmt.Sprintf("%s declares settings, and its mergeable %s names no %s: "+ - "a module that declares a setting names the keys each mergeable file takes, an empty list "+ - "for none (novox/hq ADR 0326 §3, ADR 0164 §11)", m.Module, id, TakesSettingsField)) - } - return problems -} - // takenSettingsProblems is every rule of ADR 0326 §2 and §4, judged where a definition is checked and // where a stored one is composed. func takenSettingsProblems(m Manifest) []string { diff --git a/go/catalogue/setting_takes_test.go b/go/catalogue/setting_takes_test.go index 3deb6a6..c392906 100644 --- a/go/catalogue/setting_takes_test.go +++ b/go/catalogue/setting_takes_test.go @@ -112,80 +112,6 @@ func TestAKeyNoFileNamesIsStray(t *testing.T) { } } -func TestOneNamingFileMakesEveryMergeableFileName(t *testing.T) { - // ADR 0326 §2: a module with one naming and one silent mergeable file is refused, naming the - // silent one — the silent one would otherwise still take every key. - m := twoFiles([]any{"server"}, nil) - problems := TakenSettingsProblems(m) - if len(problems) != 1 || !strings.Contains(problems[0], "sidecar merges and names no takes-settings") { - t.Fatalf("problems: %v", problems) - } - if err := JudgeSettings(m, nil, false); err == nil || !strings.Contains(err.Error(), "sidecar") { - t.Errorf("composing it should refuse it too, and said: %v", err) - } - raw, err := json.Marshal(map[string]any{"module": m.Module, "version": "1", "resources": m.Resources}) - if err != nil { - t.Fatal(err) - } - if _, err := ParseManifest(raw); err == nil || !strings.Contains(err.Error(), "sidecar merges and names no") { - t.Errorf("the module check does not say it: %v", err) - } -} - -func TestTakesSettingsIsRefusedWhereItCannotMeanAnything(t *testing.T) { - // ADR 0326 §4. - cases := map[string]struct { - resource map[string]any - says string - }{ - "on a file that does not merge": { - map[string]any{"id": "plain", "type": "file", "path": "/x", "content": "x", - TakesSettingsField: []any{"a"}}, - "does not merge"}, - "not a list": { - map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": MergeJSON, "content": "{}", - TakesSettingsField: "server"}, - "is a list of setting keys"}, - "a mesh word": { - map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": MergeJSON, "content": "{}", - TakesSettingsField: []any{"endpoints"}}, - "the mesh reads"}, - "a protected key": { - map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": MergeJSON, "content": "{}", - "protected": []any{"secret"}, TakesSettingsField: []any{"secret"}}, - "keeps it as protected"}, - "named twice": { - map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": MergeJSON, "content": "{}", - TakesSettingsField: []any{"a", "a"}}, - "twice"}, - "not a key": { - map[string]any{"id": "conf", "type": "file", "path": "/x", "merge": MergeJSON, "content": "{}", - TakesSettingsField: []any{"two words"}}, - "not a usable setting key"}, - } - for name, c := range cases { - problems := TakenSettingsProblems(Manifest{Module: "m", Resources: []map[string]any{c.resource}}) - if !containsText(problems, c.says) { - t.Errorf("%s: want a problem saying %q, got %v", name, c.says, problems) - } - } - // And a file that does not merge is refused where it is settled, not handed to the host. - plain := map[string]any{"id": "plain", "type": "file", "path": "/x", "content": "x", - TakesSettingsField: []any{"a"}} - if _, err := ApplySettings(plain, nil); err == nil { - t.Error("a file that does not merge and names keys was settled") - } -} - -func TestASoftwareSpelledKeyIsUsable(t *testing.T) { - // A file's keys are its software's: nodered's timeZone is not lower case and is still a key. - m := Manifest{Module: "flows", Resources: []map[string]any{{"id": "settings", "type": "file", - "path": "/x", "merge": MergeJSON, "content": "{}", TakesSettingsField: []any{"timeZone"}}}} - if problems := TakenSettingsProblems(m); len(problems) != 0 { - t.Errorf("timeZone was refused: %v", problems) - } -} - func containsText(list []string, text string) bool { for _, s := range list { if strings.Contains(s, text) { @@ -194,50 +120,3 @@ func containsText(list []string, text string) bool { } return false } - -func TestAModuleThatDeclaresASettingNamesItsFilesKeys(t *testing.T) { - // ADR 0326 §3, ADR 0164 §15, novox/hq issue 574: only a module that declares no settings keeps - // today's form. One that declares a preference and owns a silent mergeable file is refused where - // a definition is written — and read and composed where a stored one is read again, so a build kept - // from before the rule is still sent and still put back. - declaring := func(app, sidecar []any) Manifest { - m := twoFiles(app, sidecar) - m.Resources[2]["content"] = "WORKERS=${setting:workers}\n" - m.Settings = map[string]SettingDeclaration{"workers": {Kind: KindPreference, Default: 4.0, Why: "a number"}} - return m - } - m := declaring(nil, nil) - problems := WrittenSettingsProblems(m) - if len(problems) != 2 || !containsText(problems, "mergeable app names no takes-settings") || - !containsText(problems, "mergeable sidecar names no takes-settings") { - t.Errorf("where it is written: %v", problems) - } - if problems := TakenSettingsProblems(m); len(problems) != 0 { - t.Errorf("what a stored definition is read by refuses it: %v", problems) - } - raw, err := json.Marshal(map[string]any{"module": m.Module, "version": "1", "resources": m.Resources, - "settings": map[string]any{"workers": map[string]any{"kind": KindPreference, "default": 4, "why": "a number"}}}) - if err != nil { - t.Fatal(err) - } - read, err := ParseManifest(raw) - if err != nil { - t.Fatalf("a stored build from before the rule is not read again: %v", err) - } - if err := JudgeSettings(read, nil, false); err != nil { - t.Errorf("a stored definition is composed as before, and was refused: %v", err) - } - - if problems := WrittenSettingsProblems(declaring([]any{"server"}, []any{})); len(problems) != 0 { - t.Errorf("every mergeable file names its keys, and was refused: %v", problems) - } - if problems := WrittenSettingsProblems(twoFiles(nil, nil)); len(problems) != 0 { - t.Errorf("a module declaring no setting was refused: %v", problems) - } - // One naming file and one silent: §2 names the silent one, and §3 does not name it again. - mixed := declaring([]any{"server"}, nil) - if written, taken := WrittenSettingsProblems(mixed), TakenSettingsProblems(mixed); len(written) != 0 || len(taken) != 1 { - t.Errorf("the silent file is reported %d times by §3 and %d by §2; want once, by §2: %v %v", - len(written), len(taken), written, taken) - } -} diff --git a/go/catalogue/several_contributions_test.go b/go/catalogue/several_contributions_test.go index b0fe881..1884ef2 100644 --- a/go/catalogue/several_contributions_test.go +++ b/go/catalogue/several_contributions_test.go @@ -15,7 +15,7 @@ const twoRoutes = `{"module":"minio","version":"1","requires":["route"], "contributes":{"route":{"api":{"label":"files-api","port":9000},"console":{"label":"files","port":9001}}}}` func TestContributesReadsBothShapesAndWritesThemBack(t *testing.T) { - m, err := ParseManifest([]byte(twoRoutes)) + m, err := decodeForTest([]byte(twoRoutes)) if err != nil { t.Fatal(err) } @@ -23,7 +23,7 @@ func TestContributesReadsBothShapesAndWritesThemBack(t *testing.T) { if len(locals) != 2 || locals["api"]["label"] != "files-api" || locals["console"]["port"] != float64(9001) { t.Fatalf("two contributions under local names: %+v", locals) } - plain, err := ParseManifest([]byte(`{"module":"board","version":"1","requires":["route"], + plain, err := decodeForTest([]byte(`{"module":"board","version":"1","requires":["route"], "contributes":{"route":{"label":"board","port":8080}}}`)) if err != nil { t.Fatal(err) @@ -36,7 +36,7 @@ func TestContributesReadsBothShapesAndWritesThemBack(t *testing.T) { if err != nil { t.Fatal(err) } - again, err := ParseManifest(raw) + again, err := decodeForTest(raw) if err != nil { t.Fatalf("what was written does not read: %v\n%s", err, raw) } @@ -45,21 +45,6 @@ func TestContributesReadsBothShapesAndWritesThemBack(t *testing.T) { } } -func TestAContributionLocalNameMustBeUsable(t *testing.T) { - for _, bad := range []string{ - // Not a usable name. - `{"module":"minio","version":"1","requires":["route"], - "contributes":{"route":{"Not OK":{"label":"files","port":9000}}}}`, - // A local contribution with nothing in it. - `{"module":"minio","version":"1","requires":["route"], - "contributes":{"route":{"api":{}}}}`, - } { - if _, err := ParseManifest([]byte(bad)); err == nil { - t.Errorf("accepted:\n%s", bad) - } - } -} - func minimalRouteProxy() Manifest { return Manifest{Module: "route-proxy", Version: "1", Provides: FromAnywhere("route"), @@ -68,7 +53,7 @@ func minimalRouteProxy() Manifest { } func TestAModuleWithTwoRoutesGivesTheProviderTwoContributions(t *testing.T) { - minio, err := ParseManifest([]byte(twoRoutes)) + minio, err := decodeForTest([]byte(twoRoutes)) if err != nil { t.Fatal(err) } @@ -131,7 +116,7 @@ func TestASingleRouteStillResolvesTheOrdinaryWay(t *testing.T) { // exactly the duplicate a live plan against minio surfaced (files-api appearing once with a // credential, once without, while files got neither). func TestContributionsFromHasNoSingleValueWhenAModuleContributesSeveralTimes(t *testing.T) { - minio, err := ParseManifest([]byte(twoRoutes)) + minio, err := decodeForTest([]byte(twoRoutes)) if err != nil { t.Fatal(err) } diff --git a/go/catalogue/several_secrets_test.go b/go/catalogue/several_secrets_test.go index e074898..35c2c7e 100644 --- a/go/catalogue/several_secrets_test.go +++ b/go/catalogue/several_secrets_test.go @@ -15,7 +15,7 @@ const twoSecrets = `{"module":"ca","version":"1","requires":["secret"], "resources":[{"id":"state","type":"directory","path":"/var/lib/ca","mode":"0700"}]}` func TestSecretsReadBothShapesAndWriteThemBack(t *testing.T) { - m, err := ParseManifest([]byte(twoSecrets)) + m, err := decodeForTest([]byte(twoSecrets)) if err != nil { t.Fatal(err) } @@ -23,7 +23,7 @@ func TestSecretsReadBothShapesAndWriteThemBack(t *testing.T) { if len(files) != 2 || files[0].Local != "root-key" || files[1].Path != "/var/lib/ca/root.pass" { t.Fatalf("two files under local names, in order: %+v", files) } - plain, err := ParseManifest([]byte(`{"module":"app","version":"1","requires":["secret"],"secrets":{"secret":"/var/lib/app/secret"}}`)) + plain, err := decodeForTest([]byte(`{"module":"app","version":"1","requires":["secret"],"secrets":{"secret":"/var/lib/app/secret"}}`)) if err != nil { t.Fatal(err) } @@ -35,7 +35,7 @@ func TestSecretsReadBothShapesAndWriteThemBack(t *testing.T) { if err != nil { t.Fatal(err) } - again, err := ParseManifest(raw) + again, err := decodeForTest(raw) if err != nil { t.Fatalf("what was written does not read: %v\n%s", err, raw) } @@ -44,27 +44,8 @@ func TestSecretsReadBothShapesAndWriteThemBack(t *testing.T) { } } -func TestALocalNameMayNotCollideWithWhatTheModuleAlreadyCallsSomething(t *testing.T) { - for _, bad := range []string{ - // One of the module's own secrets. - `{"module":"ca","version":"1","requires":["secret"],"own-secrets":{"root-key":"/var/lib/ca/own"}, - "secrets":{"secret":{"root-key":"/var/lib/ca/root.key"}}}`, - // Something it requires. - `{"module":"ca","version":"1","requires":["secret","postgres-database"], - "secrets":{"secret":{"postgres-database":"/var/lib/ca/x"}}}`, - // Not a usable name. - `{"module":"ca","version":"1","requires":["secret"],"secrets":{"secret":{"Root Key":"/var/lib/ca/x"}}}`, - // A relative path. - `{"module":"ca","version":"1","requires":["secret"],"secrets":{"secret":{"root-key":"root.key"}}}`, - } { - if _, err := ParseManifest([]byte(bad)); err == nil { - t.Errorf("accepted:\n%s", bad) - } - } -} - func vaultAndCA() map[string]Manifest { - ca, _ := ParseManifest([]byte(twoSecrets)) + ca, _ := decodeForTest([]byte(twoSecrets)) vault := Manifest{Module: "mesh-vault", Version: "1", Provides: FromAnywhere("secret"), Grants: map[string]string{"secret": "/var/lib/vault/grants"}, Receives: map[string]string{"secret": "/var/lib/vault/grants/mesh.json"}} @@ -163,7 +144,7 @@ func TestAProviderKeepsOneFilePerHolder(t *testing.T) { // One file under a local name is still a local name (review C1): the need carries it, the file // is written, and ${secret:} is filled. func TestOneLocalNameIsStillALocalName(t *testing.T) { - only, _ := ParseManifest([]byte(`{"module":"one","version":"1","requires":["secret"], + only, _ := decodeForTest([]byte(`{"module":"one","version":"1","requires":["secret"], "secrets":{"secret":{"only":"/var/lib/one/only"}}}`)) vault := vaultAndCA()["mesh-vault"] got, err := Resolve(shelf(vault, only), []string{"mesh-vault", "one"}, workstation(), World{}) @@ -194,12 +175,3 @@ func TestOneLocalNameIsStillALocalName(t *testing.T) { t.Fatal("the file under the one local name was not written") } } - -// A local name names one credential: two requirements may not share it (review C2). -func TestALocalNameIsUniqueAcrossRequirements(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"x","version":"1","requires":["secret","postgres-database"], - "secrets":{"secret":{"x":"/var/lib/x/a"},"postgres-database":{"x":"/var/lib/x/b"}}}`)) - if err == nil || !strings.Contains(err.Error(), "both under") { - t.Fatalf("two requirements under one local name were accepted: %v", err) - } -} diff --git a/go/catalogue/shared_account_test.go b/go/catalogue/shared_account_test.go index aab013d..a968576 100644 --- a/go/catalogue/shared_account_test.go +++ b/go/catalogue/shared_account_test.go @@ -46,7 +46,7 @@ func TestTwoModulesSettingOneAccountsShellOrHomeAreRefused(t *testing.T) { func TestAnAccountOnlyGivenGroupsKeepsItsWrittenPlace(t *testing.T) { compose := func(raw string) []map[string]any { t.Helper() - m, err := ParseManifest([]byte(raw)) + m, err := decodeForTest([]byte(raw)) if err != nil { t.Fatal(err) } diff --git a/go/catalogue/shared_credential_test.go b/go/catalogue/shared_credential_test.go index 1965fd9..5a5d18e 100644 --- a/go/catalogue/shared_credential_test.go +++ b/go/catalogue/shared_credential_test.go @@ -1,40 +1,9 @@ package catalogue import ( - "strings" "testing" ) -// A provider with one credential shares it (novox/hq ADR 0158): the offer names the own secret, the -// secret says how it is taken, and a consumer's need carries the name so the plan mints its copy -// from the provider's value. -func TestAnOfferMayNameAnOwnSecretAsItsCredential(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"downloader","version":"1", - "own-secrets":{"password":{"path":"/var/lib/mesh/downloader/password","taken":"at-start"}}, - "provides":[{"name":"downloader-api","credential":{"own":"password"}}], - "serves":{"downloader-api":{"port":8080,"username":"admin"}}}`)) - if err != nil { - t.Fatal(err) - } - if own, shared := m.SharedCredentialOf("downloader-api"); !shared || own != "password" { - t.Fatalf("the offer's credential was not read: %v %v", own, shared) - } - if got := m.ProvisionsSharing("password"); len(got) != 1 || got[0] != "downloader-api" { - t.Fatalf("the provisions sharing the secret: %v", got) - } - - for want, raw := range map[string]string{ - "declares no such secret": `{"module":"d","version":"1","provides":[{"name":"d-api","credential":{"own":"password"}}]}`, - "must say how the module takes it": `{"module":"d","version":"1","own-secrets":{"password":"/p"}, - "provides":[{"name":"d-api","credential":{"own":"password"}}]}`, - "names no own secret": `{"module":"d","version":"1","provides":[{"name":"d-api","credential":{"own":""}}]}`, - } { - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) { - t.Errorf("expected a refusal saying %q, got %v", want, err) - } - } -} - func sharingShelf() map[string]Manifest { return shelf( Manifest{Module: "downloader", Version: "1", diff --git a/go/catalogue/showcase_manifest_test.go b/go/catalogue/showcase_manifest_test.go index 1ba866c..f210b68 100644 --- a/go/catalogue/showcase_manifest_test.go +++ b/go/catalogue/showcase_manifest_test.go @@ -18,7 +18,7 @@ func TestTheShowcaseModuleIsAValidManifest(t *testing.T) { if err != nil { t.Fatal(err) } - m, err := ParseManifest(raw) + m, err := decodeForTest(raw) if err != nil { t.Fatalf("the module that exercises everything does not parse:\n%v", err) } diff --git a/go/catalogue/state.go b/go/catalogue/state.go index 9229d3d..390dd5c 100644 --- a/go/catalogue/state.go +++ b/go/catalogue/state.go @@ -82,47 +82,3 @@ func ReadState(read string) (module, local string, err error) { } return module, local, nil } - -// StateProblems is what is wrong with a manifest's state and reads. -// -// Refused at registration, because a bucket name the bus cannot hold is a module that installs, -// starts, and is refused on its first write with a reason about a bucket nobody named. -func StateProblems(m Manifest) []string { - var problems []string - if len(m.State) > 0 && !stateName.MatchString(m.Module) { - problems = append(problems, fmt.Sprintf( - "%s keeps state, and a module's name is part of its buckets' names, which take one plain "+ - "name — no dot (novox/hq ADR 0201)", m.Module)) - } - seen := map[string]bool{} - for _, s := range m.State { - switch { - case !stateName.MatchString(s.Name): - problems = append(problems, fmt.Sprintf( - "%s keeps state %q: a state is named locally — lower-case letters, digits and hyphens, "+ - "no dot and no underscore; the mesh derives the bucket (novox/hq ADR 0201)", m.Module, s.Name)) - case seen[s.Name]: - problems = append(problems, fmt.Sprintf("%s keeps state %q twice", m.Module, s.Name)) - } - seen[s.Name] = true - if s.History < 0 || s.History > StateMostHistory { - problems = append(problems, fmt.Sprintf( - "%s keeps %d values of %q; a key keeps between 1 and %d", m.Module, s.History, s.Name, StateMostHistory)) - } - if s.TTLSeconds < 0 { - problems = append(problems, fmt.Sprintf("%s gives %q a negative lifetime", m.Module, s.Name)) - } - } - for _, r := range m.Reads { - module, _, err := ReadState(r) - if err != nil { - problems = append(problems, fmt.Sprintf("%s reads %v", m.Module, err)) - continue - } - if module == m.Module { - problems = append(problems, fmt.Sprintf( - "%s reads %q, which is its own state: a module reads and writes what it keeps already", m.Module, r)) - } - } - return problems -} diff --git a/go/catalogue/state_test.go b/go/catalogue/state_test.go index a30ee17..cf6cb7e 100644 --- a/go/catalogue/state_test.go +++ b/go/catalogue/state_test.go @@ -2,14 +2,13 @@ package catalogue import ( "encoding/json" - "strings" "testing" ) // A module declares the state it keeps and the state it reads (novox/hq ADR 0201), a bucket by its // bare name or with the owner's options. func TestAManifestMaySayWhatStateItKeepsAndReads(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"claude-code","version":"1",` + + m, err := decodeForTest([]byte(`{"module":"claude-code","version":"1",` + `"state":["servers",{"name":"seen","history":5,"ttl-seconds":3600}],` + `"reads":["licence-manager.bindings"]}`)) if err != nil { @@ -28,59 +27,9 @@ func TestAManifestMaySayWhatStateItKeepsAndReads(t *testing.T) { } } -// 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":"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) - } - } -} - -// **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 ")) - } -} - -// 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 := Shelf{ - "licence-manager": {Module: "licence-manager", State: []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) - } - } -} - // 1154/AC1: a state entry may say key-ttl; it is read, written back, and a bare name or an entry without it reads as before. func TestAStateMayHaveKeysThatExpireOneByOne(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"mesh-delivery-watchman","version":"1",` + + m, err := decodeForTest([]byte(`{"module":"mesh-delivery-watchman","version":"1",` + `"state":[{"name":"pulse","key-ttl":true},"stuck",{"name":"seen","history":5}]}`)) if err != nil { t.Fatal(err) diff --git a/go/catalogue/stream_work_queue_test.go b/go/catalogue/stream_work_queue_test.go index 9f8ccf6..d266c96 100644 --- a/go/catalogue/stream_work_queue_test.go +++ b/go/catalogue/stream_work_queue_test.go @@ -1,7 +1,6 @@ package catalogue import ( - "strings" "testing" ) @@ -17,7 +16,7 @@ const mailSendsStream = `[{"name":"mail-sends","events":["mail.send.approved"]," // bus.queues (novox/hq ADR 0436 §6, checked by 1178's WorkQueueStreamProblems), so this stays accepted once // that rule exists. func TestAWorkQueueStreamIsDeclaredAndAccepted(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"mesh-mail","version":"1","streams":` + mailSendsStream + + m, err := decodeForTest([]byte(`{"module":"mesh-mail","version":"1","streams":` + mailSendsStream + `,"resources":[{"id":"account","type":"user","name":"mesh-mail-send"},` + `{"id":"send","type":"process","run":["./mesh-mail","send"],"user":"mesh-mail-send",` + `"bus":{"queues":["mail-sends"]}}]}`)) @@ -31,37 +30,10 @@ func TestAWorkQueueStreamIsDeclaredAndAccepted(t *testing.T) { } } -// 1177/AC2: a stream whose work-queue values are out of range, contradictory, or carries a grant is refused -// naming the stream and the rule. -func TestAWorkQueueStreamsValuesAreBounded(t *testing.T) { - for name, c := range map[string]struct{ streams, says string }{ - "max-deliveries 0": {`[{"name":"mail-sends","events":["mail.send.approved"],"retention":"work-queue",` + - `"max-deliveries":0,"duplicates":24}]`, "between 1 and 10"}, - "max-deliveries 11": {`[{"name":"mail-sends","events":["mail.send.approved"],"retention":"work-queue",` + - `"max-deliveries":11,"duplicates":24}]`, "between 1 and 10"}, - "duplicates longer than its maximum age": {`[{"name":"mail-sends","events":["mail.send.approved"],` + - `"max-age-days":1,"retention":"work-queue","max-deliveries":5,"duplicates":25}]`, "longer than its maximum age"}, - "an unknown retention": {`[{"name":"mail-sends","events":["mail.send.approved"],"retention":"sometimes"}]`, - "retention"}, - "max-deliveries without retention work-queue": {`[{"name":"mail-sends","events":["mail.send.approved"],` + - `"max-deliveries":5}]`, "without retention"}, - "duplicates without retention work-queue": {`[{"name":"mail-sends","events":["mail.send.approved"],` + - `"duplicates":24}]`, "without retention"}, - "a work-queue stream carrying a grant": {`[{"name":"mail-sends","events":["mail.send.approved"],` + - `"retention":"work-queue","max-deliveries":5,` + - `"grant":{"config":"grants","events":"mail-sends.{key}.**"}}]`, "carrying a grant"}, - } { - _, err := ParseManifest([]byte(mailManifest(t, c.streams, ""))) - if err == nil || !strings.Contains(err.Error(), "mail-sends") || !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: %v; want a refusal naming the stream and %q", name, err, c.says) - } - } -} - // 1177/AC3: a stream with no retention composes as main does — limits retention, dropping its oldest — proven // at the broker, where the bus's retention policy is decided (internal/broker/modstreams_test.go). func TestAStreamWithNoRetentionReadsAsLimits(t *testing.T) { - m, err := ParseManifest([]byte(mailManifest(t, mailStream, ""))) + m, err := decodeForTest([]byte(mailManifest(t, mailStream, ""))) if err != nil { t.Fatal(err) } diff --git a/go/catalogue/streams.go b/go/catalogue/streams.go index 40a4ff4..2aa8c9b 100644 --- a/go/catalogue/streams.go +++ b/go/catalogue/streams.go @@ -143,155 +143,6 @@ func (s StreamDeclaration) Carries(pattern string) bool { return false } -// StreamProblems is what is wrong with a manifest's streams, judged on the one manifest. -func StreamProblems(m Manifest) []string { - if len(m.Streams) == 0 { - return nil - } - var problems []string - if !stateName.MatchString(m.Module) { - problems = append(problems, fmt.Sprintf( - "%s declares a stream, and a module's name is part of its stream's subjects, which take one plain "+ - "name — no dot (novox/hq ADR 0363)", m.Module)) - } - seen := map[string]bool{} - terminal := TerminalKeys(m) - for i, s := range m.Streams { - switch { - case !stateName.MatchString(s.Name): - problems = append(problems, fmt.Sprintf( - "%s declares stream %q: a stream is named locally — lower-case letters, digits and hyphens "+ - "(novox/hq ADR 0363)", m.Module, s.Name)) - case seen[s.BusName()]: - problems = append(problems, fmt.Sprintf("%s declares stream %q twice", m.Module, s.Name)) - case slices.Contains(MeshStreamNames, s.BusName()): - problems = append(problems, fmt.Sprintf( - "%s declares stream %q, which is the mesh's own stream %s (novox/hq ADR 0363)", m.Module, s.Name, s.BusName())) - default: - for _, prefix := range ReservedStreamPrefixes { - if strings.HasPrefix(s.BusName(), prefix) { - problems = append(problems, fmt.Sprintf( - "%s declares stream %q, whose name on the bus starts with %s, which the mesh derives names "+ - "with (novox/hq ADR 0363)", m.Module, s.Name, prefix)) - } - } - } - seen[s.BusName()] = true - if s.MaxAgeDays != 0 && (s.MaxAgeDays < StreamLeastDays || s.MaxAgeDays > StreamMostDays) { - problems = append(problems, fmt.Sprintf("%s keeps stream %q %d days; a stream keeps between %d and %d "+ - "(novox/hq ADR 0363)", m.Module, s.Name, s.MaxAgeDays, StreamLeastDays, StreamMostDays)) - } - if s.MaxMiB != 0 && (s.MaxMiB < StreamLeastMiB || s.MaxMiB > StreamMostMiB) { - problems = append(problems, fmt.Sprintf("%s caps stream %q at %d MiB; a stream holds between %d and %d "+ - "(novox/hq ADR 0363)", m.Module, s.Name, s.MaxMiB, StreamLeastMiB, StreamMostMiB)) - } - // A stream may declare retention: work-queue (novox/hq ADR 0436 §6): a message removed once a - // consumer acknowledges it, the bus refusing a new one when full rather than dropping its oldest, with - // max-deliveries (1 to 10) and a duplicate window (duplicates, hours, at most its maximum age) -- both - // meaningless, and refused, without it. A work-queue stream is read by its one consumer, never granted. - switch s.Retention { - case "", StreamRetentionWorkQueue: - default: - problems = append(problems, fmt.Sprintf( - "%s's stream %q declares retention %q; a stream's retention is %q or nothing (novox/hq ADR 0436 §6)", - m.Module, s.Name, s.Retention, StreamRetentionWorkQueue)) - } - switch { - case s.Retention == StreamRetentionWorkQueue && - (s.MaxDeliveries < StreamLeastDeliveries || s.MaxDeliveries > StreamMostDeliveries): - problems = append(problems, fmt.Sprintf( - "%s's stream %q declares max-deliveries %d; a work-queue stream's max-deliveries is between %d "+ - "and %d (novox/hq ADR 0436 §6)", m.Module, s.Name, s.MaxDeliveries, StreamLeastDeliveries, - StreamMostDeliveries)) - case s.Retention != StreamRetentionWorkQueue && s.MaxDeliveries != 0: - problems = append(problems, fmt.Sprintf( - "%s's stream %q declares max-deliveries %d without retention %q (novox/hq ADR 0436 §6)", - m.Module, s.Name, s.MaxDeliveries, StreamRetentionWorkQueue)) - } - switch { - case s.Duplicates != 0 && s.Retention != StreamRetentionWorkQueue: - problems = append(problems, fmt.Sprintf( - "%s's stream %q declares duplicates %d hours without retention %q (novox/hq ADR 0436 §6)", - m.Module, s.Name, s.Duplicates, StreamRetentionWorkQueue)) - case s.Duplicates != 0 && s.Duplicates > s.Days()*24: - problems = append(problems, fmt.Sprintf( - "%s's stream %q keeps duplicates %d hours, longer than its maximum age of %d days (novox/hq "+ - "ADR 0436 §6)", m.Module, s.Name, s.Duplicates, s.Days())) - } - if s.Retention == StreamRetentionWorkQueue && s.Grant != nil { - problems = append(problems, fmt.Sprintf( - "%s's stream %q declares retention %q carrying a grant; a work-queue stream is read by its "+ - "module's one consumer, never granted (novox/hq ADR 0436 §6)", m.Module, s.Name, s.Retention)) - } - if len(s.Events) == 0 { - problems = append(problems, fmt.Sprintf("%s declares stream %q with no events", m.Module, s.Name)) - } - for _, e := range s.Events { - if err := eventPattern(e, false); err != nil { - problems = append(problems, fmt.Sprintf("%s's stream %q carries %q: %v", m.Module, s.Name, e, err)) - } - } - // **An event goes to one place** (ADR 0363 §2): an emitted event a stream carries, or two streams - // carrying one event, would leave the module's runtime to choose. - for _, e := range m.EmitsAll() { - if s.Carries(e) { - problems = append(problems, fmt.Sprintf( - "%s emits %q, which its stream %q carries: an event goes to EVENTS or to the module's stream, "+ - "so name it in one place (novox/hq ADR 0363)", m.Module, e, s.Name)) - } - } - for _, other := range m.Streams[i+1:] { - for _, a := range s.Events { - for _, b := range other.Events { - if patternsOverlap(a, b) { - problems = append(problems, fmt.Sprintf( - "%s's streams %q and %q both carry %q and %q: an event goes to one stream (novox/hq "+ - "ADR 0363)", m.Module, s.Name, other.Name, a, b)) - } - } - } - } - if g := s.Grant; g != nil { - if !slices.Contains(terminal, g.Config) { - problems = append(problems, fmt.Sprintf( - "%s's stream %q is granted by %q, which is not a trusted config value of the module, set only at "+ - "the controller's terminal or on the operator's Approve: a grant of what a module hears is the "+ - "operator's (novox/hq ADR 0348 §8, ADR 0363)", m.Module, s.Name, g.Config)) - } - if strings.Count(g.Events, GrantKey) != 1 { - problems = append(problems, fmt.Sprintf( - "%s's stream %q grants %q, which must hold %s exactly once, as a name of its own (novox/hq ADR 0363)", - m.Module, s.Name, g.Events, GrantKey)) - } else if err := eventPattern(g.Events, true); err != nil { - problems = append(problems, fmt.Sprintf("%s's stream %q grants %q: %v", m.Module, s.Name, g.Events, err)) - } - } - } - return problems -} - -// eventPattern checks one local event pattern: names, `*` for one, `**` last for the rest; with key, -// GrantKey is a name too. -func eventPattern(pattern string, key bool) error { - if pattern == "" { - return fmt.Errorf("a pattern needs a name") - } - parts := strings.Split(pattern, ".") - for i, part := range parts { - switch { - case part == TheRest: - if i != len(parts)-1 { - return fmt.Errorf("%q stands for the rest of a name, so nothing may follow it", TheRest) - } - case part == OneName: - case key && part == GrantKey: - case !eventName.MatchString(part): - return fmt.Errorf("%q is not a usable name: lower-case letters, digits and dashes", part) - } - } - return nil -} - // StreamsHearing is every stream of a module that carries some of a consumed pattern's events, and // whether one of them carries all of them. func (m Manifest) StreamsHearing(pattern string) (hearing []StreamDeclaration, whole bool) { @@ -351,119 +202,6 @@ func patternsOverlap(a, b string) bool { } } -// WorkQueueStreamProblems refuses a work-queue stream (novox/hq ADR 0436 §6) that is not read by exactly -// one process of its own module: named by none, it queues forever unconsumed; named by two, each would take -// half of what the other needed, silently. -func WorkQueueStreamProblems(m Manifest) []string { - var problems []string - named := map[string]int{} - for _, b := range m.ProcessBuses() { - for _, q := range b.Queues { - named[q]++ - } - } - for _, s := range m.Streams { - if s.Retention != StreamRetentionWorkQueue { - continue - } - switch named[s.Name] { - case 0: - problems = append(problems, fmt.Sprintf( - "%s's work-queue stream %q: no process names it in bus.queues; a work-queue stream is read by "+ - "exactly one process (novox/hq ADR 0436 §6)", m.Module, s.Name)) - case 1: - default: - problems = append(problems, fmt.Sprintf( - "%s's work-queue stream %q: %d processes name it in bus.queues; a work-queue stream is read by "+ - "exactly one (novox/hq ADR 0436 §6)", m.Module, s.Name, named[s.Name])) - } - } - return problems -} - -// WorkQueueConsumedElsewhereProblems refuses another module's `consumes` naming a work-queue stream's events -// (novox/hq ADR 0436 §6): read by its module's one process alone, never broadcast to a consumer that only -// subscribes. Modelled on RestrictedHearingProblems. -func WorkQueueConsumedElsewhereProblems(shelf Shelf) []string { - var problems []string - for _, module := range shelfOrder(shelf) { - m := shelf[module] - for _, c := range m.Consumes { - emitter, rest, named := strings.Cut(c, ".") - provider, known := shelf[emitter] - if !named || emitter == OneName || emitter == TheRest || !known || emitter == module { - continue - } - hearing, _ := provider.StreamsHearing(rest) - for _, s := range hearing { - if s.Retention != StreamRetentionWorkQueue { - continue - } - problems = append(problems, fmt.Sprintf( - "%s consumes %s, which %s's work-queue stream %s carries; a work-queue stream is read by "+ - "its module's one process alone, never by another module's consumes (novox/hq ADR 0436 "+ - "§6)", module, c, emitter, s.BusName())) - } - } - } - return problems -} - -// StreamNameProblems are the rules about stream names no single manifest can be judged against: two -// modules declaring one name. -func StreamNameProblems(shelf Shelf) []string { - var problems []string - declaredBy := map[string]string{} - for _, module := range shelfOrder(shelf) { - for _, s := range shelf[module].Streams { - name := s.BusName() - if first, taken := declaredBy[name]; taken && first != module { - problems = append(problems, fmt.Sprintf( - "%s declares the stream %s, which %s already declares; a stream has one owner (novox/hq ADR 0363)", - module, name, first)) - continue - } - declaredBy[name] = module - } - } - return problems -} - -// RestrictedHearingProblems refuses a module that would hear a restricted stream without an account of its own -// (novox/hq ADR 0376 §2): a `consumes` naming a module literally and asking for events a restricted stream of -// that module carries, by a manifest that declares no `runs-as`. A module the machine's tool runner carries -// reaches the bus as the tool runner's account, which every agent session there shares, so the controller would -// compose nothing for it. Judged only where the provider is on the shelf, as a consumer may be installed before -// its emitter; a wildcard emitter never reaches a module's stream (ADR 0363 §5) and is not judged. -func RestrictedHearingProblems(shelf Shelf) []string { - var problems []string - for _, module := range shelfOrder(shelf) { - m := shelf[module] - if m.RunsAs != "" { - continue - } - for _, c := range m.Consumes { - emitter, rest, named := strings.Cut(c, ".") - provider, known := shelf[emitter] - if !named || emitter == OneName || emitter == TheRest || !known { - continue - } - hearing, _ := provider.StreamsHearing(rest) - for _, s := range hearing { - if s.Grant == nil { - continue - } - problems = append(problems, fmt.Sprintf( - "%s consumes %s, which %s's restricted stream %s carries, so %s must run as an account of its "+ - "own (runs-as): the machine's runtime runs as the operator's account, which every agent "+ - "runs as, and never hears a restricted stream (novox/hq ADR 0376)", - module, c, emitter, s.BusName(), module)) - } - } - } - return problems -} - // GrantedKeys reads a restricting config value (novox/hq ADR 0363 §4): each key whose list names the // module wherever it runs, or the module on this machine as `.`. A value of another shape // grants nothing; a key that is not one usable name grants nothing. @@ -646,15 +384,3 @@ var StreamsKeptUntilMoved = map[string][]string{ "mesh-delivery-checker": {"checks"}, "mesh-delivery-forge-bridge": {"forge"}, } - -// PredefinedFormProblems refuses each stream a module declares that StreamsKeptUntilMoved does not name for it. -func PredefinedFormProblems(m Manifest) []string { - var out []string - for _, s := range m.Streams { - if !slices.Contains(StreamsKeptUntilMoved[m.Module], s.Name) { - out = append(out, fmt.Sprintf("%s keeps the stream %q, outside the bus's predefined forms: keep it as a state, "+ - "a log or an object store, which the runtime reads for the module's tools and others read by `reads`", m.Module, s.Name)) - } - } - return out -} diff --git a/go/catalogue/streams_test.go b/go/catalogue/streams_test.go index cb02362..6a5cd52 100644 --- a/go/catalogue/streams_test.go +++ b/go/catalogue/streams_test.go @@ -3,7 +3,6 @@ package catalogue import ( "encoding/json" "slices" - "strings" "testing" ) @@ -23,7 +22,7 @@ const mailStream = `[{"name":"mail","events":["mail.*.message.added","mail.*.mes // A module declares a stream of its own (ADR 0363 §1): its events with a variable name (§3), its age and // size with defaults, and a grant by a trusted setting of its own (§4). func TestAModuleDeclaresAStreamOfItsOwn(t *testing.T) { - m, err := ParseManifest([]byte(mailManifest(t, mailStream, ""))) + m, err := decodeForTest([]byte(mailManifest(t, mailStream, ""))) if err != nil { t.Fatal(err) } @@ -38,7 +37,7 @@ func TestAModuleDeclaresAStreamOfItsOwn(t *testing.T) { if !s.Carries("mail.work.message.added") || s.Carries("mail.work.body") || !s.Carries("mail.*.message.removed") { t.Fatal("the stream's patterns do not say what it carries") } - plain, err := ParseManifest([]byte(`{"module":"feed","version":"1","streams":[{"name":"feed-items","events":["item.**"]}]}`)) + plain, err := decodeForTest([]byte(`{"module":"feed","version":"1","streams":[{"name":"feed-items","events":["item.**"]}]}`)) if err != nil { t.Fatalf("a stream without a grant was refused: %v", err) } @@ -47,65 +46,6 @@ func TestAModuleDeclaresAStreamOfItsOwn(t *testing.T) { } } -// Registration refuses a stream the mesh could not hold or would leave ambiguous (ADR 0363 §1, §2, §4). -func TestAStreamIsNamedBoundedAndGrantedByATrustedConfigValue(t *testing.T) { - grants := `"grant":{"config":"grants","events":"mail.{key}.**"}` - for _, c := range []struct{ manifest, says string }{ - {mailManifest(t, `[{"name":"Mail","events":["mail.**"]}]`, ""), `a stream is named locally`}, - {mailManifest(t, `[{"name":"mail.x","events":["mail.**"]}]`, ""), `a stream is named locally`}, - {mailManifest(t, `[{"name":"events","events":["mail.**"]}]`, ""), `the mesh's own stream EVENTS`}, - {mailManifest(t, `[{"name":"log-x","events":["mail.**"]}]`, ""), `starts with LOG_`}, - {mailManifest(t, `[{"name":"mail","events":["mail.**"]},{"name":"mail","events":["x.**"]}]`, ""), `twice`}, - {mailManifest(t, `[{"name":"mail","events":[]}]`, ""), `with no events`}, - {mailManifest(t, `[{"name":"mail","events":["mail.**.x"]}]`, ""), `nothing may follow it`}, - {mailManifest(t, `[{"name":"mail","events":["mail.**"],"max-age-days":31}]`, ""), `between 1 and 30`}, - {mailManifest(t, `[{"name":"mail","events":["mail.**"],"max-mib":9000}]`, ""), `between 1 and 8192`}, - {mailManifest(t, `[{"name":"mail","events":["mail.**"],"stream":"MAIL"}]`, ""), `{name, events`}, - {mailManifest(t, `[{"name":"mail","events":["mail.**"]}]`, `"emits":["mail.mesh.message.added"],`), - `which its stream "mail" carries`}, - {mailManifest(t, `[{"name":"mail","events":["mail.**"]},{"name":"more","events":["mail.x.*"]}]`, ""), - `both carry`}, - {mailManifest(t, `[{"name":"mail","events":["mail.**"],"grant":{"config":"tags","events":"mail.{key}.**"}}]`, ""), - `is not a trusted config value`}, - {mailManifest(t, `[{"name":"mail","events":["mail.**"],"grant":{"config":"grants","events":"mail.**"}}]`, ""), - `exactly once`}, - {mailManifest(t, `[{"name":"mail","events":["mail.**"],"grant":{"config":"grants","events":"{key}.{key}"}}]`, ""), - `exactly once`}, - {mailManifest(t, `[{"name":"mail","events":["mail.**"],`+grants+`,"why":1}]`, ""), `{name, events`}, - {`{"module":"a.b","version":"1","streams":[{"name":"x","events":["x"]}]}`, `no dot`}, - } { - _, err := ParseManifest([]byte(c.manifest)) - if err == nil { - t.Errorf("%s was accepted", c.manifest) - continue - } - if !strings.Contains(err.Error(), c.says) { - t.Errorf("%s refused for the wrong reason: %v", c.manifest, err) - } - } -} - -// A stream name has one owner across the catalogue (ADR 0363 §1). -func TestTwoModulesCannotDeclareOneStream(t *testing.T) { - a := Manifest{Module: "a", Streams: []StreamDeclaration{{Name: "mail", Events: []string{"x"}}}} - b := Manifest{Module: "b", Streams: []StreamDeclaration{{Name: "mail", Events: []string{"y"}}}} - got := strings.Join(CatalogueProblems(Shelf{"a": a, "b": b}), "; ") - if !strings.Contains(got, "b declares the stream MAIL, which a already declares") { - t.Fatalf("a second owner of MAIL was not refused: %s", got) - } - if got := StreamNameProblems(Shelf{"a": a}); len(got) != 0 { - t.Fatalf("one owner refused: %v", got) - } - // 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(Manifest{Module: "mesh-mail", Streams: a.Streams}); len(got) != 0 { - t.Fatalf("a stream kept until it moves was refused: %v", got) - } -} - // The setting's value names who hears each key: a module wherever it runs, or on one machine (ADR 0363 §4). func TestARestrictingSettingNamesAModuleOrAModuleOnAMachine(t *testing.T) { var value any @@ -130,7 +70,7 @@ func TestARestrictingSettingNamesAModuleOrAModuleOnAMachine(t *testing.T) { // A consumer hears a restricted stream only where its consumes asks and the setting grants it, and only // by naming the emitter (ADR 0363 §5); what a stream carries whole leaves EVENTS. func TestAConsumerHearsOnlyWhatItAsksAndIsGranted(t *testing.T) { - provider, err := ParseManifest([]byte(mailManifest(t, mailStream, `"emits":["account.added"],`))) + provider, err := decodeForTest([]byte(mailManifest(t, mailStream, `"emits":["account.added"],`))) if err != nil { t.Fatal(err) } @@ -212,41 +152,3 @@ func TestNarrowingTwoPatterns(t *testing.T) { t.Fatalf("uncovered: %v", got) } } - -// **A module that hears a restricted stream runs as an account of its own** (ADR 0376 §2): registration refuses a -// `consumes` naming a module and asking for events its restricted stream carries, unless the manifest declares -// `runs-as`; a stream without a grant, an event the provider says on EVENTS, and a wildcard emitter stay as they -// were, and a consumer whose provider is not on the shelf is judged once it is. -func TestHearingARestrictedStreamNeedsAnAccountOfItsOwn(t *testing.T) { - provider, err := ParseManifest([]byte(mailManifest(t, mailStream, `"emits":["account.added"],`))) - if err != nil { - t.Fatal(err) - } - feed := Manifest{Module: "feed", Streams: []StreamDeclaration{{Name: "items", Events: []string{"item.**"}}}} - carried := Manifest{Module: "reader", Consumes: []string{"mesh-mail.mail.*.message.added"}} - got := RestrictedHearingProblems(Shelf{"mesh-mail": provider, "reader": carried}) - if len(got) != 1 || !strings.Contains(got[0], "reader consumes mesh-mail.mail.*.message.added") || - !strings.Contains(got[0], "MAIL") || !strings.Contains(got[0], "runs-as") || !strings.Contains(got[0], "ADR 0376") { - t.Fatalf("a carried module hearing MAIL was not refused as it should be: %v", got) - } - if all := strings.Join(CatalogueProblems(Shelf{"mesh-mail": provider, "reader": carried}), "; "); !strings.Contains(all, got[0]) { - t.Fatalf("the catalogue's check does not refuse it: %s", all) - } - own := carried - own.RunsAs = "reader" - if got := RestrictedHearingProblems(Shelf{"mesh-mail": provider, "reader": own}); len(got) != 0 { - t.Fatalf("a module of its own account was refused: %v", got) - } - for _, m := range []Manifest{ - {Module: "events", Consumes: []string{"mesh-mail.account.added"}}, - {Module: "items", Consumes: []string{"feed.item.**"}}, - {Module: "audit", Consumes: []string{"**", "*.mail.**"}}, - } { - if got := RestrictedHearingProblems(Shelf{"mesh-mail": provider, "feed": feed, m.Module: m}); len(got) != 0 { - t.Errorf("%s was refused: %v", m.Module, got) - } - } - if got := RestrictedHearingProblems(Shelf{"reader": carried}); len(got) != 0 { - t.Fatalf("judged without its provider: %v", got) - } -} diff --git a/go/catalogue/template.go b/go/catalogue/template.go index 455afd8..a1d06ac 100644 --- a/go/catalogue/template.go +++ b/go/catalogue/template.go @@ -8,7 +8,6 @@ import ( "os" "path" "path/filepath" - "sort" "strings" "unicode/utf8" ) @@ -50,26 +49,6 @@ func (m Manifest) TemplatesDir() string { return DefaultTemplatesDir } -// templatesDirProblem is what is wrong with the templates directory a manifest names, or "": one directory of the -// module's own, a plain relative path inside it — never absolute, never leaving it, never the module's directory -// itself — and named only by a module that names a template, since a word read by nothing is refused. -func templatesDirProblem(m Manifest, templated bool) string { - d := m.Templates - if d == "" { - return "" - } - if strings.HasPrefix(d, "/") || strings.Contains(d, "\\") || path.Clean(d) != d || d == "." || d == ".." || - strings.HasPrefix(d, "../") { - return fmt.Sprintf("%s names the templates directory %q, which is not a directory of its own: a plain "+ - "relative path inside the module's directory, such as \"templates\" (issue 731)", m.Module, d) - } - if !templated { - return fmt.Sprintf("%s names the templates directory %q and names no template: a file resource names its "+ - "template as %q, or the manifest says no templates directory", m.Module, d, d+"/") - } - return "" -} - // Templated is whether any of the manifest's resources, or any item it contributes (collections.go), names a // template. func (m Manifest) Templated() bool { @@ -146,39 +125,6 @@ func templateOf(module, dir string, r map[string]any) (string, bool, string) { return name, true, "" } -// templateProblems is what is wrong with the manifest's templates, as written: a template on something not a file, -// beside content, or outside the module's templates directory; a templates directory that is not one, or that no -// template is in; or a module naming templates whose build packs no archive from that directory. Whether each -// template exists is judged where a tree is (ReadTemplates) or the archive's text is (Resolve). -func templateProblems(m Manifest) []string { - var problems []string - templated := m.Templated() - if wrong := templatesDirProblem(m, templated); wrong != "" { - return append(problems, wrong) - } - dir := m.TemplatesDir() - for _, r := range m.Resources { - if _, _, wrong := templateOf(m.Module, dir, r); wrong != "" { - problems = append(problems, wrong) - } - } - if !templated { - return problems - } - switch found := templatesArtifacts(m); len(found) { - case 0: - problems = append(problems, fmt.Sprintf("%s names templates and its build packs no archive from %s/: "+ - "declare an artifact {\"name\": %q, \"kind\": \"archive\", \"from\": %q} (novox/hq ADR 0349 §2)", - m.Module, dir, path.Base(dir), dir)) - case 1: - default: - sort.Strings(found) - problems = append(problems, fmt.Sprintf("%s packs %s/ into %s; its templates are read from one archive", - m.Module, dir, strings.Join(found, " and "))) - } - return problems -} - // fillTemplates is resources with each template's text as its content and the template key dropped: text says the text // of a template by its path ("/"), and whether it has one. A template it has no text for, or // text that is not UTF-8, is refused by name. Every resource is copied; the manifest's own are left as they were. diff --git a/go/catalogue/template_test.go b/go/catalogue/template_test.go index a439acb..93698e1 100644 --- a/go/catalogue/template_test.go +++ b/go/catalogue/template_test.go @@ -49,7 +49,7 @@ func builtFiles(texts map[string]string) []Built { // held is a manifest as registration holds it: resolved with what was built, then read again strictly. func held(t *testing.T, raw string, built []Built) Manifest { t.Helper() - m, err := ParseManifest([]byte(raw)) + m, err := decodeForTest([]byte(raw)) if err != nil { t.Fatal(err) } @@ -61,7 +61,7 @@ func held(t *testing.T, raw string, built []Built) Manifest { if err != nil { t.Fatal(err) } - out, err := ParseManifest(again) + out, err := decodeForTest(again) if err != nil { t.Fatal(err) } @@ -107,7 +107,7 @@ func TestATemplatedModuleReadFromItsTreeComposesByteIdenticallyToItsInlineTwin(t t.Fatal(err) } } - m, err := ParseManifest([]byte(templatedTwin)) + m, err := decodeForTest([]byte(templatedTwin)) if err != nil { t.Fatal(err) } @@ -127,52 +127,8 @@ func TestATemplatedModuleReadFromItsTreeComposesByteIdenticallyToItsInlineTwin(t } } -func TestAFileNamingBothATemplateAndContentIsRefused(t *testing.T) { - raw := strings.Replace(templatedTwin, `"template":"files/tidy.conf"`, - `"template":"files/tidy.conf","content":"x"`, 1) - _, err := ParseManifest([]byte(raw)) - if err == nil || !strings.Contains(err.Error(), "main") || !strings.Contains(err.Error(), "never both") { - t.Fatalf("a file with both a template and content was not refused by name: %v", err) - } -} - -func TestATemplateOutsideFilesIsRefusedByName(t *testing.T) { - for _, bad := range []string{"../tidy.conf", "/etc/tidy.conf", "files", "files/", "files/../tidy.conf", - "files/./tidy.conf", "other/tidy.conf", "tidy.conf", "files//tidy.conf", `files\\tidy.conf`} { - raw := strings.Replace(templatedTwin, `"files/tidy.conf"`, `"`+bad+`"`, 1) - _, err := ParseManifest([]byte(raw)) - if err == nil || !strings.Contains(err.Error(), "main") || !strings.Contains(err.Error(), "not a file under files/") { - t.Errorf("the template %q was not refused by name: %v", bad, err) - } - } -} - -func TestATemplateOnAResourceThatIsNotAFileIsRefused(t *testing.T) { - raw := strings.Replace(templatedTwin, `{"id":"conf","type":"directory","path":"${dir:conf}"}`, - `{"id":"conf","type":"directory","path":"${dir:conf}","template":"files/tidy.conf"}`, 1) - _, err := ParseManifest([]byte(raw)) - if err == nil || !strings.Contains(err.Error(), "conf is a directory and names a template") { - t.Fatalf("a directory naming a template was not refused: %v", err) - } -} - -func TestATemplatedModuleWhoseBuildPacksNoFilesIsRefused(t *testing.T) { - for _, build := range []string{ - ``, - `"build":{"artifacts":[{"name":"look","kind":"archive","from":"theme"}]},`, - `"build":{"artifacts":[{"name":"a","kind":"archive","from":"files"},{"name":"b","kind":"archive","from":"./files/"}]},`, - } { - raw := strings.Replace(templatedTwin, - `"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},`, build, 1) - _, err := ParseManifest([]byte(raw)) - if err == nil || !strings.Contains(err.Error(), "files/") { - t.Errorf("a templated module with the build %q was not refused: %v", build, err) - } - } -} - func TestAMissingTemplateIsRefusedByName(t *testing.T) { - m, err := ParseManifest([]byte(templatedTwin)) + m, err := decodeForTest([]byte(templatedTwin)) if err != nil { t.Fatal(err) } @@ -198,7 +154,7 @@ func TestAMissingTemplateIsRefusedByName(t *testing.T) { } func TestATemplateThatIsASymlinkIsRefused(t *testing.T) { - m, err := ParseManifest([]byte(templatedTwin)) + m, err := decodeForTest([]byte(templatedTwin)) if err != nil { t.Fatal(err) } @@ -221,7 +177,7 @@ func TestATemplateThatIsASymlinkIsRefused(t *testing.T) { } func TestAResolveWithoutTheArchivesTextIsRefused(t *testing.T) { - m, err := ParseManifest([]byte(templatedTwin)) + m, err := decodeForTest([]byte(templatedTwin)) if err != nil { t.Fatal(err) } @@ -231,7 +187,7 @@ func TestAResolveWithoutTheArchivesTextIsRefused(t *testing.T) { } func TestATemplateThatIsNotUTF8IsRefused(t *testing.T) { - m, err := ParseManifest([]byte(templatedTwin)) + m, err := decodeForTest([]byte(templatedTwin)) if err != nil { t.Fatal(err) } @@ -245,7 +201,7 @@ func TestATemplateThatIsNotUTF8IsRefused(t *testing.T) { // What registration refuses: a manifest to be held that still names a template — one resolved by a builder that // predates templates would otherwise compose files with no text. func TestAManifestStillNamingATemplateIsNotHeld(t *testing.T) { - m, err := ParseManifest([]byte(templatedTwin)) + m, err := decodeForTest([]byte(templatedTwin)) if err != nil { t.Fatal(err) } @@ -260,7 +216,7 @@ func TestAManifestStillNamingATemplateIsNotHeld(t *testing.T) { // A symlinked files/, or a symlinked directory under it, would read a file outside the tree: refused by name, // as is any symlink or special file under files/ that no template names, which the build's packing refuses. func TestASymlinkAnywhereUnderFilesIsRefusedByName(t *testing.T) { - m, err := ParseManifest([]byte(templatedTwin)) + m, err := decodeForTest([]byte(templatedTwin)) if err != nil { t.Fatal(err) } diff --git a/go/catalogue/templates_dir_test.go b/go/catalogue/templates_dir_test.go index ad740b0..ef79862 100644 --- a/go/catalogue/templates_dir_test.go +++ b/go/catalogue/templates_dir_test.go @@ -85,7 +85,7 @@ func TestAModuleWithItsOwnTemplatesDirectoryIsReadFromItsTree(t *testing.T) { if err := os.WriteFile(filepath.Join(dir, "files", "bin", "tidy"), []byte("#!/bin/sh\n"), 0o755); err != nil { t.Fatal(err) } - m, err := ParseManifest([]byte(unpackingTemplated)) + m, err := decodeForTest([]byte(unpackingTemplated)) if err != nil { t.Fatal(err) } @@ -110,38 +110,6 @@ func TestAModuleWithItsOwnTemplatesDirectoryIsReadFromItsTree(t *testing.T) { } } -func TestATemplateOutsideTheNamedTemplatesDirectoryIsRefused(t *testing.T) { - raw := strings.Replace(unpackingTemplated, `"template":"templates/tidy.conf"`, `"template":"files/tidy.conf"`, 1) - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "files/tidy.conf") || - !strings.Contains(err.Error(), "templates/") { - t.Fatalf("a template outside the named templates directory was not refused by name: %v", err) - } -} - -func TestATemplatesDirectoryNoArchiveIsBuiltFromIsRefused(t *testing.T) { - raw := strings.Replace(unpackingTemplated, `, - {"name":"templates","kind":"archive","from":"templates"}`, "", 1) - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "no archive from templates/") { - t.Fatalf("a module whose build packs no archive from its templates directory was not refused: %v", err) - } -} - -func TestATemplatesDirectoryOnAModuleNamingNoTemplateIsRefused(t *testing.T) { - raw := strings.Replace(unpackingInline, `"version":"1",`, `"version":"1","templates":"templates",`, 1) - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "names no template") { - t.Fatalf("a templates directory read by nothing was not refused: %v", err) - } -} - -func TestATemplatesDirectoryThatIsNotAPlainRelativeDirectoryIsRefused(t *testing.T) { - for _, dir := range []string{"/etc", "../other", "..", "templates/../files", "./templates", ".", "a\\b", "templates/"} { - raw := strings.Replace(unpackingTemplated, `"templates":"templates"`, `"templates":`+jsonString(dir), 1) - if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "templates directory") { - t.Errorf("the templates directory %q was not refused: %v", dir, err) - } - } -} - func TestATemplatesDirectoryNamedFilesIsTheDefault(t *testing.T) { raw := strings.Replace(templatedTwin, `"version":"1",`, `"version":"1","templates":"files",`, 1) if got, want := composed(t, held(t, raw, builtFiles(templateTexts(t)))), diff --git a/go/catalogue/terminal_keys.go b/go/catalogue/terminal_keys.go index b80e89e..2d8811b 100644 --- a/go/catalogue/terminal_keys.go +++ b/go/catalogue/terminal_keys.go @@ -164,25 +164,3 @@ func asksFor(r map[string]any) []string { sort.Strings(asked) return asked } - -// TrustProblems are the ways a manifest states `trusted` wrongly: anything but true or false, or on anything but -// a file. Refused at registration and by `module check`. -func TrustProblems(m Manifest) []string { - var out []string - for _, r := range m.Resources { - v, said := r[TrustedField] - if !said { - continue - } - if fmt.Sprint(r["type"]) != "file" { - out = append(out, fmt.Sprintf("%s: %v is a %v and says %q; only a file says whether the settings it "+ - "asks for are trusted (novox/hq issue 339)", m.Module, r["id"], r["type"], TrustedField)) - continue - } - if _, ok := v.(bool); !ok { - out = append(out, fmt.Sprintf("%s: %v says %q as %v; it is true or false (novox/hq issue 339)", - m.Module, r["id"], TrustedField, v)) - } - } - return out -} diff --git a/go/catalogue/terminal_settings_test.go b/go/catalogue/terminal_settings_test.go index d822a89..a7ce7a6 100644 --- a/go/catalogue/terminal_settings_test.go +++ b/go/catalogue/terminal_settings_test.go @@ -192,30 +192,3 @@ func TestOnlyANamedKeyOfATrustedFileIsTheTerminals(t *testing.T) { t.Fatalf("a file that names no keys still takes any key, so its module's layer stays the terminal's: %v", got) } } - -// A file that asks for a setting says whether what it asks is trusted (novox/hq issue 339): `trusted` is a -// boolean, on a file alone, and a file asking for a setting without it is named. -func TestAFileSaysWhetherItsSettingsAreTrusted(t *testing.T) { - m := Manifest{Module: "power", Resources: []map[string]any{ - {"id": "said", "type": "file", "path": "/etc/a", "trusted": true, "content": "${setting:a}"}, - {"id": "unsaid", "type": "file", "path": "/etc/b", "content": "${setting:b}"}, - {"id": "no-setting", "type": "file", "path": "/etc/c", "content": "plain"}}} - if got := strings.Join(UnsaidTrust(m), ","); got != "unsaid" { - t.Errorf("unsaid: %s; want unsaid", got) - } - // A mergeable file that names keys asks for them, so it is named too; one that names none asks for nothing. - merged := Manifest{Module: "agent", Resources: []map[string]any{ - {"id": "settings", "type": "file", "path": "/a", "merge": MergeJSON, "content": `{"managed_settings": {}}`}, - {"id": "blank", "type": "file", "path": "/b", "merge": MergeJSON, "content": `{}`}}} - if got := strings.Join(UnsaidTrust(merged), ","); got != "settings" { - t.Errorf("unsaid: %s; want settings", got) - } - for _, bad := range []map[string]any{ - {"id": "x", "type": "file", "path": "/etc/x", "trusted": "yes", "content": "${setting:a}"}, - {"id": "y", "type": "directory", "trusted": true}, - } { - if problems := TrustProblems(Manifest{Module: "power", Resources: []map[string]any{bad}}); len(problems) == 0 { - t.Errorf("%v was taken", bad) - } - } -} diff --git a/go/catalogue/unconsumed_placeholder_test.go b/go/catalogue/unconsumed_placeholder_test.go index c82df10..bd9b09f 100644 --- a/go/catalogue/unconsumed_placeholder_test.go +++ b/go/catalogue/unconsumed_placeholder_test.go @@ -19,53 +19,6 @@ const ( shellsOperators = "${PORT:-8080} ${SHELL:-/bin/sh} ${SECRET:?unset} ${dir:-/tmp}" ) -// The catalogue check — the strict parse registration runs too — refuses each by name, with the -// module and the field it stands in, and says nothing about the shell's own syntax. -func TestAMisspelledPlaceholderIsRefusedAtTheCheck(t *testing.T) { - raw := `{"module":"speller","version":"1","resources":[{"id":"rc","type":"file","path":"/etc/speller.rc",` + - `"content":"a=` + misspelledShell + `\nb=` + undeclaredSetting + `\nc=` + misspelledMachine + - `\nd=` + shellsOwn + `\n"}]}` - _, err := ParseManifest([]byte(raw)) - if err == nil { - t.Fatal("a file holding three placeholders no pass consumes was accepted") - } - for _, token := range []string{misspelledShell, undeclaredSetting, misspelledMachine} { - if !strings.Contains(err.Error(), "speller's resource rc holds "+token+" in its content") { - t.Errorf("the refusal does not name %s with its module and field: %v", token, err) - } - } - if strings.Contains(err.Error(), "XDG_CACHE_HOME") { - t.Errorf("the shell's own syntax was refused: %v", err) - } - - // A namespace the mesh knows, misspelt in any way its own pattern does not take, and the same - // namespace in a field its pass does not read: refused at the check as at composition. - for _, c := range []struct{ resource, token, field string }{ - {`{"id":"rc","type":"file","path":"/etc/rc","content":"${Machine:address}"}`, "${Machine:address}", "content"}, - {`{"id":"rc","type":"file","path":"/etc/rc","content":"${machine:.address}"}`, "${machine:.address}", "content"}, - {`{"id":"rc","type":"file","path":"/etc/rc","content":"${machine: address}"}`, "${machine: address}", "content"}, - {`{"id":"rc","type":"file","path":"/etc/rc","content":"${Dir:x}"}`, "${Dir:x}", "content"}, - {`{"id":"rc","type":"file","path":"/etc/rc","content":"a=${machine:address\nb=1"}`, "${machine:address", "content"}, - {`{"id":"rc","type":"process","name":"speller","env":{"NAME":"${machine:name}"}}`, "${machine:name}", "env.NAME"}, - } { - raw := `{"module":"speller","version":"1","resources":[` + c.resource + `]}` - _, err := ParseManifest([]byte(raw)) - if err == nil || !strings.Contains(err.Error(), "speller's resource rc holds "+c.token+" in its "+c.field) { - t.Errorf("%s in its %s was accepted at the check, or not named: %v", c.token, c.field, err) - } - } - - // And the shell's syntax alone, beside placeholders every pass knows, is accepted — as is the - // shape a lower-case shell variable takes with an operator after its colon. - raw = `{"module":"speller","version":"1","resources":[{"id":"rc","type":"file","path":"${machine:account-home}/.rc",` + - `"content":"` + shellsOwn + ` ${(%):-%n} ${1:-.} ${count:-} ${trial:+ on trial} ${machine:address} ` + - shellsOperators + `\n"},` + - `{"id":"server","type":"container","name":"server","env":{"PORT":"${PORT:-80}"}}]}` - if _, err := ParseManifest([]byte(raw)); err != nil { - t.Fatalf("the shell's own syntax was refused: %v", err) - } -} - // Composition refuses the same placeholders in the same words — a manifest the store already holds // was never parsed by this binary — and a namespace the mesh knows, written in a field its pass does // not read, is refused there too, because it would reach the machine as the same literal text. diff --git a/go/catalogue/unhealthy.go b/go/catalogue/unhealthy.go index b6e5583..f4fd74a 100644 --- a/go/catalogue/unhealthy.go +++ b/go/catalogue/unhealthy.go @@ -1,10 +1,5 @@ package catalogue -import ( - "fmt" - "strings" -) - // How loud a module's unhealthy condition is (novox/hq ADR 0355, extending ADR 0240). // // **A module may declare its unhealthy urgent.** A module whose failure costs people something they rely on @@ -29,26 +24,6 @@ type UnhealthyPolicy struct { Why string `json:"why,omitempty"` } -// unhealthyProblems refuses an `unhealthy` without a why, and any severity but urgent or warning (novox/hq ADR -// 0355 §2). -func (m Manifest) unhealthyProblems() []string { - if m.Unhealthy == nil { - return nil - } - var problems []string - switch m.Unhealthy.Severity { - case UnhealthyUrgent, UnhealthyWarning: - default: - problems = append(problems, fmt.Sprintf("unhealthy is %q or %q, not %q", UnhealthyUrgent, UnhealthyWarning, - m.Unhealthy.Severity)) - } - if strings.TrimSpace(m.Unhealthy.Why) == "" { - problems = append(problems, "unhealthy says why: a module that declares how loud its failure is names "+ - "what people lose while it is broken") - } - return problems -} - // UnhealthyIsUrgent is whether the module declares its unhealthy condition urgent (novox/hq ADR 0355 §1). func (m Manifest) UnhealthyIsUrgent() bool { return m.Unhealthy != nil && m.Unhealthy.Severity == UnhealthyUrgent diff --git a/go/catalogue/unhealthy_test.go b/go/catalogue/unhealthy_test.go index c6c9b88..c0853fa 100644 --- a/go/catalogue/unhealthy_test.go +++ b/go/catalogue/unhealthy_test.go @@ -1,7 +1,6 @@ package catalogue import ( - "strings" "testing" ) @@ -9,7 +8,7 @@ import ( // severity but urgent or warning (novox/hq ADR 0355). func TestAnUnhealthyDeclaredUrgentIsUrgent(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"mail","version":"1",` + + m, err := decodeForTest([]byte(`{"module":"mail","version":"1",` + `"unhealthy":{"severity":"urgent","why":"mail is refused or stuck for everyone with a mailbox"}}`)) if err != nil { t.Fatalf("a manifest declaring its unhealthy urgent with why was refused: %v", err) @@ -18,36 +17,3 @@ func TestAnUnhealthyDeclaredUrgentIsUrgent(t *testing.T) { t.Errorf("a manifest declaring its unhealthy urgent reads as not urgent: %+v", m.Unhealthy) } } - -func TestAManifestWithoutUnhealthyIsNotUrgent(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"notes","version":"1"}`)) - if err != nil { - t.Fatal(err) - } - if m.UnhealthyIsUrgent() { - t.Errorf("a manifest saying nothing of its unhealthy reads as urgent") - } - warning := Manifest{Module: "notes", Unhealthy: &UnhealthyPolicy{Severity: UnhealthyWarning, Why: "notes wait"}} - if warning.UnhealthyIsUrgent() || len(warning.unhealthyProblems()) != 0 { - t.Errorf("a warning said with why reads as urgent or is refused: %v", warning.unhealthyProblems()) - } -} - -func TestAnUnhealthyWithoutWhyIsRefused(t *testing.T) { - for _, why := range []string{"", " "} { - if problems := (Manifest{Module: "mail", Unhealthy: &UnhealthyPolicy{Severity: UnhealthyUrgent, Why: why}}).unhealthyProblems(); len(problems) == 0 { - t.Errorf("urgent with why %q was accepted", why) - } - } - if _, err := ParseManifest([]byte(`{"module":"mail","version":"1","unhealthy":{"severity":"urgent"}}`)); err == nil || - !strings.Contains(err.Error(), "unhealthy says why") { - t.Errorf("a manifest declaring its unhealthy urgent without why parsed: %v", err) - } -} - -func TestAnUnhealthySeverityOtherThanUrgentOrWarningIsRefused(t *testing.T) { - if _, err := ParseManifest([]byte(`{"module":"mail","version":"1","unhealthy":{"severity":"loud","why":"mail stops"}}`)); err == nil || - !strings.Contains(err.Error(), `not "loud"`) { - t.Errorf("a manifest declaring its unhealthy loud parsed: %v", err) - } -} diff --git a/go/catalogue/upgrade.go b/go/catalogue/upgrade.go index d498f56..037e6cc 100644 --- a/go/catalogue/upgrade.go +++ b/go/catalogue/upgrade.go @@ -35,24 +35,6 @@ type UpgradePolicy struct { Why string `json:"why,omitempty"` } -func (m Manifest) upgradeProblems() []string { - if m.Upgrade == nil { - return nil - } - switch m.Upgrade.Policy { - case PolicyRoll: - case PolicyTogether, PolicyRecord: - if strings.TrimSpace(m.Upgrade.Why) == "" { - return []string{fmt.Sprintf("upgrade %q says why: a module that does not roll out one machine first "+ - "names the reason a person reads", m.Upgrade.Policy)} - } - default: - return []string{fmt.Sprintf("upgrade is %q, %q or %q, not %q", PolicyRoll, PolicyTogether, PolicyRecord, - m.Upgrade.Policy)} - } - return nil -} - // Where a policy came from, as `upgrade` says it. const ( FromPerson = "person" diff --git a/go/catalogue/upgrade_test.go b/go/catalogue/upgrade_test.go index e007897..1b52013 100644 --- a/go/catalogue/upgrade_test.go +++ b/go/catalogue/upgrade_test.go @@ -1,69 +1 @@ package catalogue - -import ( - "strings" - "testing" -) - -// A module rolls out by default; it records where it says so with why, where it keeps irreplaceable -// data, and always where it is the bus (novox/hq ADR 0236). -func TestWhereAModulesUpgradePolicyComesFrom(t *testing.T) { - cases := []struct { - name string - m Manifest - policy, from string - }{ - {"default", Manifest{Module: "app"}, PolicyRoll, FromDefault}, - {"says record", Manifest{Module: "db", Upgrade: &UpgradePolicy{Policy: PolicyRecord, Why: "a restart costs"}}, - PolicyRecord, FromModule}, - {"says together", Manifest{Module: "dns", Upgrade: &UpgradePolicy{Policy: PolicyTogether, Why: "one zone"}}, - PolicyTogether, FromModule}, - {"irreplaceable data", Manifest{Module: "media", Data: &Data{Own: []DataItem{{ID: "library", Class: ClassIrreplaceable}}}}, - PolicyRecord, FromData}, - {"valuable data rolls", Manifest{Module: "notes", Data: &Data{Own: []DataItem{{ID: "db", Class: ClassValuable}}}}, - PolicyRoll, FromDefault}, - {"irreplaceable but says roll", Manifest{Module: "photos", Upgrade: &UpgradePolicy{Policy: PolicyRoll}, - Data: &Data{Own: []DataItem{{ID: "originals", Class: ClassIrreplaceable}}}}, PolicyRoll, FromModule}, - {"the bus, whatever it says", Manifest{Module: "nats", Provides: []Offer{{Name: "mesh-bus"}}, - Upgrade: &UpgradePolicy{Policy: PolicyRoll}}, PolicyRecord, FromBus}, - } - for _, c := range cases { - policy, from, _ := DerivedUpgrade(c.m) - if policy != c.policy || from != c.from { - t.Errorf("%s: %s from %s, want %s from %s", c.name, policy, from, c.policy, c.from) - } - // 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 := &PersonPolicy{V: PersonPolicyVersion, Module: c.m.Module, Policy: PolicyTogether, Why: "one zone", By: "person.jochen"} - policy, from, _, by := Upgrade(c.m, person) - wantPolicy, wantFrom, wantBy := PolicyTogether, FromPerson, "person.jochen" - if c.from == FromBus { - wantPolicy, wantFrom, wantBy = c.policy, 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, _, _ := 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 []PersonPolicy{{Module: "m", Policy: "sometimes", Why: "x", By: "p"}, {Module: "m", Policy: PolicyRecord, By: "p"}, - {Module: "m", Policy: 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 []UpgradePolicy{{Policy: PolicyRecord}, {Policy: PolicyTogether}, {Policy: "sometimes", Why: "x"}} { - if problems := (Manifest{Module: "m", Upgrade: &u}).upgradeProblems(); len(problems) == 0 { - t.Errorf("%+v was accepted", u) - } - } - if problems := (Manifest{Module: "m", Upgrade: &UpgradePolicy{Policy: PolicyRecord, Why: "a restart costs"}}).upgradeProblems(); len(problems) != 0 { - t.Errorf("a record with why was refused: %v", problems) - } - if _, err := ParseManifest([]byte(`{"module":"m","upgrade":{"policy":"record"}}`)); err == nil || - !strings.Contains(err.Error(), "says why") { - t.Errorf("a manifest recording without why parsed: %v", err) - } -} diff --git a/go/catalogue/upstream_file_test.go b/go/catalogue/upstream_file_test.go index ca3016d..204d492 100644 --- a/go/catalogue/upstream_file_test.go +++ b/go/catalogue/upstream_file_test.go @@ -1,7 +1,6 @@ package catalogue import ( - "strings" "testing" ) @@ -21,81 +20,8 @@ func TestAnArchivePinnedByURLAndSha256IsAccepted(t *testing.T) { `{"name":"addon","kind":"archive","url":"https://vendor.example.org/addon.xpi","sha256":"` + pinnedSum + `","version":"1.5.6.1","file":"addon.xpi"}`, } { - if _, err := ParseManifest([]byte(upstreamManifest(a))); err != nil { + if _, err := decodeForTest([]byte(upstreamManifest(a))); err != nil { t.Errorf("%s was refused: %v", a, err) } } } - -func TestAnUpstreamFileThatIsNotPinnedIsRefused(t *testing.T) { - cases := map[string]string{ - // No digest: what the address serves changes under the mesh. - `{"name":"x","kind":"archive","url":"https://vendor.invalid/r.tar.xz","version":"1"}`: "without a pinned sha256", - `{"name":"x","kind":"archive","url":"https://vendor.invalid/r.tar.xz","sha256":"ABC","version":"1"}`: "without a pinned sha256", - // No version: a person reading the pin cannot tell which release. - `{"name":"x","kind":"archive","url":"https://vendor.invalid/r.tar.xz","sha256":"` + pinnedSum + `"}`: "says no version", - // Not https. - `{"name":"x","kind":"archive","url":"http://vendor.invalid/r.tar.xz","sha256":"` + pinnedSum + `","version":"1"}`: "over https", - // Both a directory of the repository and a URL. - `{"name":"x","kind":"archive","from":"files","url":"https://vendor.invalid/r.tar.xz","sha256":"` + pinnedSum + - `","version":"1"}`: "one or the other", - // A pin without a URL. - `{"name":"x","kind":"archive","from":"files","sha256":"` + pinnedSum + `"}`: "no url", - // Only an archive is mirrored from a file. - `{"name":"x","kind":"image","from":"Dockerfile","url":"https://vendor.invalid/r.tar.xz","sha256":"` + pinnedSum + - `","version":"1"}`: "only an archive", - // A file name that is a path, a directory within that leaves the archive, and both at once. - `{"name":"x","kind":"archive","url":"https://vendor.invalid/a.xpi","sha256":"` + pinnedSum + - `","version":"1","file":"../a.xpi"}`: "not a plain relative file name", - `{"name":"x","kind":"archive","url":"https://vendor.invalid/r.tar.xz","sha256":"` + pinnedSum + - `","version":"1","within":"../up"}`: "not a plain relative directory", - `{"name":"x","kind":"archive","url":"https://vendor.invalid/r.tar.xz","sha256":"` + pinnedSum + - `","version":"1","within":"r","file":"r.tar.xz"}`: "a file is kept whole", - } - for artifact, want := range cases { - _, err := ParseManifest([]byte(upstreamManifest(artifact))) - if err == nil { - t.Errorf("%s was accepted", artifact) - continue - } - if !strings.Contains(err.Error(), want) { - t.Errorf("%s was refused for another reason than %q: %v", artifact, want, err) - } - } -} - -func TestAnUpstreamFilesVendorIsNamedOnPurposeAndAnInstallationsIsRefused(t *testing.T) { - // The vendor's host is judged like any name, and passes through names-on-purpose; a version of four dotted - // numbers is not an address, and a name in it is still judged. - m, err := ParseManifest([]byte(upstreamManifest(`{"name":"owl","kind":"archive","url":"https://vendor.be/owl/owl.xpi","sha256":"` + - pinnedSum + `","version":"1.5.6.1","file":"owl.xpi","names-on-purpose":{"vendor.be":"Owl's vendor publishes it there"}}`))) - if err != nil { - t.Fatal(err) - } - if problems := InstallationProblems(m); len(problems) != 0 { - t.Fatalf("the declared vendor and the version were taken for an installation: %v", problems) - } - m.Build.Artifacts[0].NamesOnPurpose = nil - if problems := InstallationProblems(m); len(problems) != 1 || !strings.Contains(problems[0], "vendor.be") { - t.Fatalf("an undeclared vendor host is judged: %v", problems) - } - m.Build.Artifacts[0].Version = "git.novox.be 10.0.0.1" - if problems := InstallationProblems(m); len(problems) != 2 { - t.Fatalf("a name in the version is judged: %v", problems) - } - m.Build.Artifacts[0].URL, m.Build.Artifacts[0].Version = "https://mail.novox.be/owl.xpi", "1" - if problems := InstallationProblems(m); len(problems) != 1 || !strings.Contains(problems[0], "mail.novox.be") { - t.Fatalf("an installation's host in the url is judged: %v", problems) - } -} - -func TestAnUpstreamFileFromAnAddressOrAReservedNameIsRefused(t *testing.T) { - for _, u := range []string{"https://git.novox.internal/r.tar.xz", "https://10.1.2.3/r.tar.xz", - "https://203.0.113.9/r.tar.xz", "https://[2001:db8::1]/r.tar.xz", "https://nas.lan/r.tar.xz", "https://vendor/r.tar.xz"} { - _, err := ParseManifest([]byte(upstreamManifest(`{"name":"x","kind":"archive","url":"` + u + `","sha256":"` + - pinnedSum + `","version":"1"}`))) - if err == nil || !strings.Contains(err.Error(), "published under a public name") { - t.Errorf("%s: %v", u, err) - } - } -} diff --git a/go/catalogue/user_namespaces_test.go b/go/catalogue/user_namespaces_test.go index fa595e3..1b52013 100644 --- a/go/catalogue/user_namespaces_test.go +++ b/go/catalogue/user_namespaces_test.go @@ -1,35 +1 @@ package catalogue - -import ( - "strings" - "testing" -) - -// 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(KnownCapabilities, "user-namespaces") { - t.Error("user-namespaces is not a known capability") - } - for _, name := range []string{"battery", "power-meter", "seat", "container-runtime"} { - if !oneOf(KnownCapabilities, name) { - t.Errorf("%s is no longer a known capability", name) - } - } -} diff --git a/go/catalogue/verbs_test.go b/go/catalogue/verbs_test.go index cda55ca..bf90983 100644 --- a/go/catalogue/verbs_test.go +++ b/go/catalogue/verbs_test.go @@ -7,7 +7,7 @@ import ( // A served verb is written as a bare name or in full, and both read into one type (novox/hq ADR 0132). func TestAServedVerbIsANameOrADefinition(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"till","version":"1","tools":["price","refund"],` + + m, err := decodeForTest([]byte(`{"module":"till","version":"1","tools":["price","refund"],` + `"seats":[{"name":"shop-till","serves":["price",{"name":"refund","description":"give it back",` + `"input":{"type":"object","properties":{"order":{"type":"string"}}}}]}],` + `"claims":[{"name":"shop-till","scope":"mesh"}]}`)) @@ -23,15 +23,6 @@ func TestAServedVerbIsANameOrADefinition(t *testing.T) { } } -// A misspelt key inside a verb's definition is refused, like one anywhere else in the manifest. -func TestAVerbWithAnUnknownKeyIsRefused(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"till","version":"1",` + - `"seats":[{"name":"shop-till","serves":[{"name":"price","descripton":"typo"}]}]}`)) - if err == nil || !strings.Contains(err.Error(), "descripton") { - t.Fatalf("a verb with a misspelt key was accepted: %v", err) - } -} - // Holding a mesh seat that serves verbs requires serving them, and the refusal names the verbs. func TestHoldingAMeshSeatRequiresServingItsVerbs(t *testing.T) { was := Seats() diff --git a/go/catalogue/waits_test.go b/go/catalogue/waits_test.go index adf2080..8cf7f8d 100644 --- a/go/catalogue/waits_test.go +++ b/go/catalogue/waits_test.go @@ -1,13 +1,12 @@ package catalogue import ( - "strings" "testing" ) // novox/hq ADR 0367 §2: the tool runner's module names, one by one, the event families a session may wait on. func TestTheToolRunnerMayNameTheEventsASessionWaitsOn(t *testing.T) { - m, err := ParseManifest([]byte(`{"module":"node-tools","version":"1",` + + m, err := decodeForTest([]byte(`{"module":"node-tools","version":"1",` + `"waits":["mesh-knowledge.reviewed","operator-channel.decided.claude-code"]}`)) if err != nil { t.Fatal(err) @@ -16,28 +15,3 @@ func TestTheToolRunnerMayNameTheEventsASessionWaitsOn(t *testing.T) { t.Fatalf("waits not read: %v", m.Waits) } } - -// A family is named one by one, never by a wildcard, and only by the tool runner's module (ADR 0367 §2, §6): -// each refusal is said in the manifest's words, at parse, rather than at the composition of the bus's users. -func TestAWaitIsRefusedUnlessItNamesOneFamilyOfTheToolRunner(t *testing.T) { - for manifest, want := range map[string]string{ - `{"module":"shop","version":"1","waits":["mesh-knowledge.reviewed"]}`: "only node-tools", - `{"module":"node-tools","version":"1","waits":["mesh-knowledge.*"]}`: "never a wildcard", - `{"module":"node-tools","version":"1","waits":["**"]}`: "never a wildcard", - `{"module":"node-tools","version":"1","waits":["mesh-knowledge.>"]}`: "never a wildcard", - `{"module":"node-tools","version":"1","waits":["*.reviewed"]}`: "never a wildcard", - `{"module":"node-tools","version":"1","waits":["reviewed"]}`: "does not name an emitter and an event", - `{"module":"node-tools","version":"1","waits":["mesh-knowledge..reviewed"]}`: "does not name an emitter and an event", - `{"module":"node-tools","version":"1","waits":["Mesh-Knowledge.reviewed x"]}`: "does not name an emitter and an event", - `{"module":"node-tools","version":"1","waits":["a.b","a.b"]}`: "twice", - } { - _, err := ParseManifest([]byte(manifest)) - if err == nil { - t.Errorf("accepted: %s", manifest) - continue - } - if !strings.Contains(err.Error(), want) { - t.Errorf("%s refused for the wrong reason (want %q): %v", manifest, want, err) - } - } -} diff --git a/go/catalogue/while_stopped_test.go b/go/catalogue/while_stopped_test.go index bec7a05..8c47837 100644 --- a/go/catalogue/while_stopped_test.go +++ b/go/catalogue/while_stopped_test.go @@ -31,64 +31,11 @@ func TestAMaintenanceWindowOnItsOwnModulesContainerIsAccepted(t *testing.T) { "image": "registry@sha256:" + strings.Repeat("a", 64), "schedule": "30 3 * * *", "while-stopped": []any{"store"}, }) - if _, err := ParseManifest(raw); err != nil { + if _, err := decodeForTest(raw); err != nil { t.Fatalf("a step holding its own module's container still was refused: %v", err) } } -func TestAMaintenanceWindowIsRefusedWhereTheDefinitionShowsItCannotMean(t *testing.T) { - for _, c := range []struct { - name string - step map[string]any - says string - }{ - { - "on a step with no schedule", - map[string]any{"id": "collect", "type": "container", "name": "c", - "image": "registry@sha256:" + strings.Repeat("a", 64), - "while-stopped": []any{"store"}}, - "gates what is declared after it", - }, - { - "on a run-once step, which already has order", - map[string]any{"id": "collect", "type": "container", "name": "c", - "image": "registry@sha256:" + strings.Repeat("a", 64), - "run-once": true, "while-stopped": []any{"store"}}, - "A maintenance window is for a recurring step", - }, - { - "naming a container this module does not declare", - map[string]any{"id": "collect", "type": "container", "name": "c", - "image": "registry@sha256:" + strings.Repeat("a", 64), - "schedule": "30 3 * * *", "while-stopped": []any{"the-broker"}}, - "could quiesce a neighbour could stop the mesh", - }, - { - "naming itself", - map[string]any{"id": "collect", "type": "container", "name": "c", - "image": "registry@sha256:" + strings.Repeat("a", 64), - "schedule": "30 3 * * *", "while-stopped": []any{"collect"}}, - "naming itself", - }, - { - "written as something that is not a list", - map[string]any{"id": "collect", "type": "container", "name": "c", - "image": "registry@sha256:" + strings.Repeat("a", 64), - "schedule": "30 3 * * *", "while-stopped": "store"}, - "a list of this module's container ids", - }, - } { - _, err := ParseManifest(aStoreManifest(c.step)) - if err == nil { - t.Errorf("%s was accepted", c.name) - continue - } - if !strings.Contains(err.Error(), c.says) { - t.Errorf("%s: the refusal does not say %q:\n%v", c.name, c.says, err) - } - } -} - // A composed declaration names the step's held containers the way the machine knows them. // // **The gap that let a bug through to the control node.** The manifest says `while-stopped: diff --git a/go/catalogue/work_queue_consumer_test.go b/go/catalogue/work_queue_consumer_test.go index 4f98b6f..4ba4f9c 100644 --- a/go/catalogue/work_queue_consumer_test.go +++ b/go/catalogue/work_queue_consumer_test.go @@ -2,7 +2,6 @@ package catalogue import ( "strings" - "testing" ) // A work-queue stream is read by exactly one process of its own module (novox/hq ADR 0436 §6): named once @@ -34,42 +33,3 @@ func queueList(q string) []any { } return out } - -// 1178/AC2 (named): a work-queue stream no process names is refused. -func TestAWorkQueueStreamNoProcessNamesIsRefused(t *testing.T) { - m := mailSenderManifestWithQueue("") - got := strings.Join(WorkQueueStreamProblems(m), "; ") - if !strings.Contains(got, "mail-sends") || !strings.Contains(got, "no process names it") { - t.Fatalf("an unnamed work-queue stream: %q", got) - } -} - -// 1178/AC2 (named twice): a work-queue stream two processes name is refused. -func TestAWorkQueueStreamTwoProcessesNameIsRefused(t *testing.T) { - m := mailSenderManifestWithQueue("mail-sends") - m.Resources = append(m.Resources, map[string]any{"id": "resend", "type": "process", - "run": []any{"./mesh-mail", "send"}, "user": "mesh-mail-send", - "bus": map[string]any{"emits": []any{"mail.send.sent"}, "queues": []any{"mail-sends"}}}) - got := strings.Join(WorkQueueStreamProblems(m), "; ") - if !strings.Contains(got, "mail-sends") || !strings.Contains(got, "2 processes name") { - t.Fatalf("a work-queue stream two processes name: %q", got) - } -} - -// 1178/AC2 (named once): a work-queue stream exactly one process names is accepted. -func TestAWorkQueueStreamOneProcessNamesIsAccepted(t *testing.T) { - m := mailSenderManifestWithQueue("mail-sends") - if got := WorkQueueStreamProblems(m); len(got) != 0 { - t.Fatalf("a work-queue stream one process names was refused: %v", got) - } -} - -// 1178/AC2 (another module's consumes): a work-queue stream another module's consumes asks for is refused. -func TestAWorkQueueStreamAnotherModulesConsumesIsRefused(t *testing.T) { - sender := mailSenderManifestWithQueue("mail-sends") - reader := Manifest{Module: "reader", Version: "1", Consumes: []string{"mesh-mail.mail.send.approved"}} - got := strings.Join(WorkQueueConsumedElsewhereProblems(Shelf{"mesh-mail": sender, "reader": reader}), "; ") - if !strings.Contains(got, "reader") || !strings.Contains(got, "MAIL_SENDS") { - t.Fatalf("another module's consumes of a work-queue stream: %q", got) - } -} diff --git a/go/catalogue/zones.go b/go/catalogue/zones.go index a3a938b..7c4b4a2 100644 --- a/go/catalogue/zones.go +++ b/go/catalogue/zones.go @@ -33,35 +33,6 @@ type ZoneAt struct { Port int } -// zoneProblems is what is wrong with a module's zone declaration on its own, before any node. -func zoneProblems(m Manifest) []string { - if m.Zone == nil { - return nil - } - var problems []string - if strings.TrimSpace(m.Zone.Name) == "" { - problems = append(problems, fmt.Sprintf("%s declares a zone with no name", m.Module)) - } - if !m.hasListen(m.Zone.Listen) { - problems = append(problems, fmt.Sprintf( - "%s declares zone %q answered by listen %q, and has no listen of that name", - m.Module, m.Zone.Name, m.Zone.Listen)) - } - return problems -} - -func (m Manifest) hasListen(name string) bool { - if name == "" { - return false - } - for _, l := range m.Listens { - if l.Name == name { - return true - } - } - return false -} - // ZoneOn is one module's zone as one node places it: the name settled from the node's settings, the // port its answering listen is published on there. Nothing when the module declares no zone. func ZoneOn(m Manifest, layers []Layer, published map[int]int, node, address string) (*ZoneAt, error) { diff --git a/go/catalogue/zones_test.go b/go/catalogue/zones_test.go index 515c0cb..e3a1181 100644 --- a/go/catalogue/zones_test.go +++ b/go/catalogue/zones_test.go @@ -5,21 +5,6 @@ import ( "testing" ) -// A zone names the listen that answers it, or there is nothing to forward to (novox/hq ADR 0199). -func TestAZoneMustNameOneOfTheModulesListens(t *testing.T) { - _, err := ParseManifest([]byte(`{"module":"lab","version":"1", - "listens":[{"name":"dns","port":5353,"protocol":"udp","from":"mesh","why":"the lab's names"}], - "zone":{"name":"${setting:zone}","listen":"web"}}`)) - if err == nil || !strings.Contains(err.Error(), `has no listen of that name`) { - t.Fatalf("a zone answered by a listen the module does not have was accepted: %v", err) - } - if _, err := ParseManifest([]byte(`{"module":"lab","version":"1", - "listens":[{"name":"dns","port":5353,"protocol":"udp","from":"mesh","why":"the lab's names"}], - "zone":{"name":"${setting:zone}","listen":"dns"}}`)); err != nil { - t.Fatalf("a well-formed zone was refused: %v", err) - } -} - // The zone is the operator's (a setting) and the port is where this machine publishes the listen — // neither is the definition's to state. func TestAZoneIsPlacedFromTheNodesSettingAndPublishedPort(t *testing.T) {