7906 lines
323 KiB
Go
7906 lines
323 KiB
Go
// The judge's rules, moved from mesh-sdk go/catalogue at 25a2cec (novox/hq ADR 0578 §4, issue 1585): every manifest
|
||
// problem function, the strict parser and its tables. Generated by moving each judge-only declaration whole, a method
|
||
// on a catalogue type becoming a function taking it first (manifestJailProblems(m) for m.jailProblems()); the helpers
|
||
// composition keeps too are copied at the end, so mesh-sdk keeps its own. Change them here; mesh-sdk carries none.
|
||
|
||
package judge
|
||
|
||
import (
|
||
bytes "bytes"
|
||
json "encoding/json"
|
||
errors "errors"
|
||
fmt "fmt"
|
||
"git.novox.be/novox/mesh-contracts/go/seats/meshbroker"
|
||
catalogue "git.novox.be/novox/mesh-sdk/go/catalogue"
|
||
math "math"
|
||
net "net"
|
||
url "net/url"
|
||
path "path"
|
||
filepath "path/filepath"
|
||
regexp "regexp"
|
||
slices "slices"
|
||
sort "sort"
|
||
strconv "strconv"
|
||
strings "strings"
|
||
template "text/template"
|
||
time "time"
|
||
unicode "unicode"
|
||
utf8 "unicode/utf8"
|
||
)
|
||
|
||
// ---- moved from catalogue ----
|
||
|
||
// 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 catalogue.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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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 ""
|
||
}
|
||
|
||
// 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 catalogue.Artifact) []string {
|
||
if a.Paths == nil {
|
||
return nil
|
||
}
|
||
switch {
|
||
case a.Kind == catalogue.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 != catalogue.ArtifactBundle && a.Kind != catalogue.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
|
||
}
|
||
|
||
// spokenSystems is the list as a refusal says it, so a reader is one edit from right.
|
||
func spokenSystems() string {
|
||
return strings.Join(systems, ", ")
|
||
}
|
||
|
||
// 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 catalogue.Manifest) []string {
|
||
if m.Build == nil || catalogue.IsToolRunner(m) {
|
||
return nil
|
||
}
|
||
named := runByAResource(m)
|
||
var problems []string
|
||
for _, a := range m.Build.Artifacts {
|
||
if a.Kind != catalogue.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
|
||
}
|
||
|
||
// 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 catalogue.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 != catalogue.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
|
||
}
|
||
|
||
// seriesForm is a package series, `<major>.<minor>` (design 32).
|
||
var seriesForm = regexp.MustCompile(`^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$`)
|
||
|
||
// seriesProblems are an artifact's refusals of the words of design 32's versions (DLV-I13; ADR 0606 §4, ADR 0608 §5): a
|
||
// package series well formed and only on the four kinds the builder numbers, a package in Go or TypeScript, and the
|
||
// contracts marker only on a package.
|
||
func seriesProblems(module string, a catalogue.Artifact) []string {
|
||
var problems []string
|
||
if a.Series != "" {
|
||
if !seriesForm.MatchString(a.Series) {
|
||
problems = append(problems, fmt.Sprintf("%s: %q names the package series %q, which is no <major>.<minor> such as 0.1 (design 32)",
|
||
module, a.Name, a.Series))
|
||
}
|
||
switch a.Kind {
|
||
case catalogue.ArtifactPackage, catalogue.ArtifactImage, catalogue.ArtifactBundle, catalogue.ArtifactArchive:
|
||
default:
|
||
problems = append(problems, fmt.Sprintf("%s: %q is a %q and names a package series; only a package, an image, a bundle "+
|
||
"or an archive is numbered (design 32)", module, a.Name, a.Kind))
|
||
}
|
||
}
|
||
if a.Kind == catalogue.ArtifactPackage && a.Language != "" && a.Language != "go" && a.Language != "typescript" {
|
||
problems = append(problems, fmt.Sprintf("%s: %q is a package in %q; a mesh package is go or typescript (design 32)",
|
||
module, a.Name, a.Language))
|
||
}
|
||
if a.GeneratedFrom != "" && a.Kind != catalogue.ArtifactPackage {
|
||
problems = append(problems, fmt.Sprintf("%s: %q is a %q and names generated-from, the contracts marker, which only a "+
|
||
"package carries (design 26)", module, a.Name, a.Kind))
|
||
}
|
||
return problems
|
||
}
|
||
|
||
// contractsProblems refuses a contracts module that claims a seat, naming each seat (DLV-I16; ADR 0608 §5, design 26
|
||
// *Contracts modules*): a module whose package carries the contracts marker, generated-from, holds contracts only.
|
||
func contractsProblems(m catalogue.Manifest) []string {
|
||
if m.Build == nil || len(m.Claims) == 0 {
|
||
return nil
|
||
}
|
||
marked := ""
|
||
for _, a := range m.Build.Artifacts {
|
||
if a.GeneratedFrom != "" {
|
||
marked = a.Name
|
||
break
|
||
}
|
||
}
|
||
if marked == "" {
|
||
return nil
|
||
}
|
||
var problems []string
|
||
for _, c := range m.Claims {
|
||
problems = append(problems, fmt.Sprintf("%s: its package %q carries generated-from, so it is a contracts module, which "+
|
||
"holds contracts only and claims no seat; it claims %s (design 26)", m.Module, marked, c.Name))
|
||
}
|
||
return problems
|
||
}
|
||
|
||
// checkBuild is the manifest's own account of what it builds.
|
||
func buildProblems(b *catalogue.Build, 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 catalogue.ArtifactImage, catalogue.ArtifactArchive, catalogue.ArtifactUpstream, catalogue.ArtifactBundle, catalogue.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, catalogue.ArtifactImage, catalogue.ArtifactArchive, catalogue.ArtifactUpstream,
|
||
catalogue.ArtifactBundle+", "+catalogue.ArtifactPackage))
|
||
}
|
||
problems = append(problems, seriesProblems(module, a)...)
|
||
// **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 != catalogue.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 != catalogue.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 == catalogue.ArtifactBundle || a.Kind == catalogue.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 := catalogue.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. A package is published as its source (a Go module, an npm package; ADR 0606 §1), never as a binary,
|
||
// so it names no system whatever its language.
|
||
if compiled := compilesToABinary(a.Language) && a.Kind != catalogue.ArtifactPackage; 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 != catalogue.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 != catalogue.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 == catalogue.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
|
||
}
|
||
|
||
var bundleEnvWords = map[string]bool{
|
||
catalogue.RuntimeToolModules: true, catalogue.RuntimeBrokerFile: true, catalogue.RuntimeOperatorAccount: true,
|
||
catalogue.RuntimeOperatorHome: true, catalogue.RuntimeToolEnv: true, catalogue.RuntimeAgentAccount: true, catalogue.RuntimeAgentHome: true,
|
||
}
|
||
|
||
var reservedSuffixes = []string{".internal", ".local", ".lan", ".home", ".corp", ".localhost", ".home.arpa",
|
||
".test", ".invalid", ".example", ".intranet", ".private"}
|
||
|
||
var sha256Hex = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||
|
||
var systems = []string{"alpine", "android", "arch"}
|
||
|
||
// 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 catalogue.Manifest) []string {
|
||
var problems []string
|
||
for _, r := range m.Resources {
|
||
b, has, read := catalogue.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" || !catalogue.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
|
||
}
|
||
|
||
// readOnlyStateProblems refuses a state declared read-only that no process's bus state names: the module's own account
|
||
// and the tool runner carrying it read it and publish nothing on it, so only such a process writes it.
|
||
func readOnlyStateProblems(m catalogue.Manifest) []string {
|
||
named := map[string]bool{}
|
||
for _, a := range m.BusAccounts() {
|
||
if a.Tools {
|
||
continue
|
||
}
|
||
for _, s := range a.Bus.State {
|
||
named[s] = true
|
||
}
|
||
}
|
||
var problems []string
|
||
for _, s := range m.State {
|
||
if s.ReadOnly && !named[s.Name] {
|
||
problems = append(problems, fmt.Sprintf("%s declares its state %s read-only and no process's bus state names it: "+
|
||
"only such a process writes a read-only state, so nothing would", m.Module, s.Name))
|
||
}
|
||
}
|
||
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 == catalogue.EnvironmentCollection && k == "name" {
|
||
return variableName.MatchString(v)
|
||
}
|
||
return attributeKey.MatchString(k) && attributeValue.MatchString(v)
|
||
}
|
||
|
||
// collectionProblems is what is wrong with a module's collections and items, from the manifest alone.
|
||
func collectionProblems(m catalogue.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[itemKey(it)]:
|
||
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, itemKey(it)))
|
||
case it.Name == "" && slices.ContainsFunc(m.Items, func(o catalogue.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[itemKey(it)] = true
|
||
problems = append(problems, itemAttributeProblems(m.Module, where, it)...)
|
||
if it.Collection == catalogue.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/<name>\" (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
|
||
}
|
||
|
||
// 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 catalogue.Item) []string {
|
||
var problems []string
|
||
for _, k := range sortedKeys(it.Attributes) {
|
||
if !slices.Contains([]string{"kind", "name", "at", catalogue.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=<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"] != catalogue.PathAtStart && it.Attributes["at"] != catalogue.PathAtEnd) {
|
||
problems = append(problems, fmt.Sprintf("%s: a PATH entry is at=%s or at=%s and has no name", where,
|
||
catalogue.PathAtStart, catalogue.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
|
||
}
|
||
|
||
// 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 catalogue.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 == catalogue.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 *catalogue.PlaceField
|
||
for i := range r.Shape.Places {
|
||
if r.Shape.Places[i].Field == k {
|
||
place = &r.Shape.Places[i]
|
||
}
|
||
}
|
||
switch {
|
||
case k == catalogue.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(catalogue.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(catalogue.KnownCapabilities, ", ")))
|
||
}
|
||
return problems
|
||
}
|
||
|
||
// 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 catalogue.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:" + itemKey(it),
|
||
"type": "file", "content": it.Content})...)
|
||
}
|
||
|
||
var collectionName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*(:[a-z0-9][a-z0-9-]*)*$`)
|
||
|
||
var itemName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||
|
||
// 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 catalogue.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 catalogue.ConfigNumber, catalogue.ConfigString, catalogue.ConfigBoolean:
|
||
case catalogue.ConfigObject, catalogue.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, catalogue.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, catalogue.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, catalogue.ConfigField, key))
|
||
}
|
||
}
|
||
problems = append(problems, configTakesProblems(m)...)
|
||
problems = append(problems, reflectsProblems(m)...)
|
||
return problems
|
||
}
|
||
|
||
// MixedFormProblems is a manifest using both forms, said with the words of each (ADR 0348 §1).
|
||
func MixedFormProblems(m catalogue.Manifest) []string {
|
||
if config, settings := m.FormsWritten(); len(config) == 0 || len(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:<key>} throughout (novox/hq ADR 0348 §1)",
|
||
m.Module, strings.Join(first(m.FormsWritten()), ", "), strings.Join(second(m.FormsWritten()), ", "),
|
||
catalogue.ConfigField, catalogue.TakesConfigField)}
|
||
}
|
||
|
||
// 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 catalogue.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:<key>} and "+
|
||
"takes-config, never under %s (novox/hq ADR 0348 §1, §4)", m.Module, key, catalogue.SettingsField, catalogue.SettingsField))
|
||
}
|
||
}
|
||
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 catalogue.Manifest) map[string]bool {
|
||
placed := settingKeysUsedBy(m)
|
||
for _, r := range m.Resources {
|
||
for _, k := range takenKeyList(r) {
|
||
placed[k] = true
|
||
}
|
||
}
|
||
return placed
|
||
}
|
||
|
||
// configShaped is whether a declaration says anything of the config form's.
|
||
func configShaped(d catalogue.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
|
||
}
|
||
|
||
// 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 catalogue.Manifest) []string {
|
||
var problems, silent []string
|
||
naming := false
|
||
for _, r := range m.Resources {
|
||
if how, _ := r["merge"].(string); how == "" {
|
||
continue
|
||
}
|
||
if _, said := r[catalogue.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,
|
||
catalogue.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, catalogue.TakesConfigField, catalogue.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, catalogue.TakesConfigField))
|
||
}
|
||
}
|
||
return problems
|
||
}
|
||
|
||
// 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 catalogue.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
|
||
}
|
||
|
||
// inConfigWords is a refusal said of a module in the config form in its words: `${config:<key>}` and
|
||
// takes-config, where the rules shared with the settings form say `${setting:<key>}` 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, catalogue.TakesSettingsField, catalogue.TakesConfigField)
|
||
}
|
||
|
||
// 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
|
||
// `<module>.<id>`.
|
||
func reflectsProblems(m catalogue.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 <module>.<id> (novox/hq ADR 0348 §6)",
|
||
m.Module, r["id"], id, field, m.Module))
|
||
}
|
||
}
|
||
}
|
||
return problems
|
||
}
|
||
|
||
var meshMadeIDs = []string{catalogue.ComposedJailsID(), catalogue.FilteringID(), catalogue.CertificateID(), catalogue.AuthorityID(), catalogue.BusUsersID()}
|
||
|
||
var meshMadePrefixes = []string{catalogue.NeedID(""), catalogue.SecretID(""), strings.TrimSuffix(catalogue.GrantID("", ""), "-"), catalogue.BoundID(""), catalogue.ReceivedID(""), catalogue.AccessID("/")}
|
||
|
||
const dnsLabelLimit = 63
|
||
|
||
// 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 catalogue.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
|
||
}
|
||
|
||
func boundWords(b catalogue.IdentityBound) string {
|
||
if !b.Bounded() {
|
||
return "nothing"
|
||
}
|
||
return fmt.Sprintf("%d", b.Max)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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 catalogue.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 := catalogue.SeatNamed(c.Seat); known && s.Name == catalogue.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 != catalogue.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
|
||
}
|
||
|
||
// coversDirectory is whether a data item names the directory, a path within it, or a directory it
|
||
// is placed beneath.
|
||
func coversDirectory(m catalogue.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
|
||
}
|
||
|
||
func providesName(m catalogue.Manifest, provision string) bool {
|
||
for _, o := range m.Provides {
|
||
if o.Name == provision {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// writtenDirectories are the module's directories a container mounts whole and may write: a volume
|
||
// `${dir:<id>}:<target>` 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 catalogue.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
|
||
}
|
||
|
||
// 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 manifestDataProblems(m catalogue.Manifest) []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]catalogue.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:<id>} or ${access:<id>}, "+
|
||
"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 catalogue.ClassIrreplaceable, catalogue.ClassValuable, catalogue.ClassRebuildable, catalogue.ClassCache:
|
||
case catalogue.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 == catalogue.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 == catalogue.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 == catalogue.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 "", catalogue.MeasureWalk, catalogue.MeasureDataset, catalogue.MeasureShallow:
|
||
default:
|
||
say("%s's measure %q is walk, dataset or shallow", label, it.Measure)
|
||
}
|
||
if _, err := catalogue.ParseDataDuration(it.Within); err != nil {
|
||
say("%s's within: %v", label, err)
|
||
}
|
||
if _, err := catalogue.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 != "" && !catalogue.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 == catalogue.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 catalogue.ClassIrreplaceable, catalogue.ClassValuable, catalogue.ClassRebuildable, catalogue.ClassCache, catalogue.ClassNone:
|
||
default:
|
||
say("its consumers' %s is class %q; one of irreplaceable, valuable, rebuildable, cache or none", provision, c.Class)
|
||
}
|
||
switch {
|
||
case c.Class == catalogue.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 == catalogue.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 catalogue.ClassIrreplaceable, catalogue.ClassValuable, catalogue.ClassRebuildable, catalogue.ClassCache:
|
||
default:
|
||
say("what it keeps with %s is class %q; one of irreplaceable, valuable, rebuildable or cache", provision, k.Class)
|
||
}
|
||
}
|
||
return problems
|
||
}
|
||
|
||
var dataID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||
|
||
var dataPathRef = regexp.MustCompile(`^\$\{(dir|access):([a-z0-9][a-z0-9-]*)\}(/[^\s$]*)?$`)
|
||
|
||
// 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 `<seat>.<event>` hears the seat's subject (SeatSays), so the holder says it there, with stdio.SeatPublish
|
||
// on `mesh.seat.<seat>.event.<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 catalogue.Manifest) []string {
|
||
var problems []string
|
||
for _, c := range m.Claims {
|
||
seat, known := catalogue.SeatNamed(c.Name)
|
||
if !known || len(seat.Emits) == 0 {
|
||
continue
|
||
}
|
||
for _, e := range m.Emits {
|
||
if catalogue.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
|
||
}
|
||
|
||
// 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))
|
||
}
|
||
|
||
// 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 manifestUnknownDirRefs(m catalogue.Manifest) []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
|
||
}
|
||
|
||
// 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 manifestContributionPlaceholderProblems(m catalogue.Manifest) []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
|
||
}
|
||
|
||
// environmentProblems is what is wrong with this module's environment contribution, from the
|
||
// manifest alone.
|
||
func manifestEnvironmentProblems(m catalogue.Manifest) []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 != catalogue.PathAtStart && p.At != catalogue.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, catalogue.PathAtStart, catalogue.PathAtEnd))
|
||
}
|
||
}
|
||
return problems
|
||
}
|
||
|
||
// 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 manifestShellProblems(m catalogue.Manifest) []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
|
||
}
|
||
|
||
var variableName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||
|
||
// 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 catalogue.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, catalogue.TheRest))
|
||
continue
|
||
}
|
||
if err := consumePattern(c); err != nil {
|
||
problems = append(problems, fmt.Sprintf("%s consumes %q: %v", m.Module, c, err))
|
||
}
|
||
}
|
||
return problems
|
||
}
|
||
|
||
// 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 == catalogue.TheRest:
|
||
if i != len(parts)-1 {
|
||
return fmt.Errorf("%q stands for the rest of a name, so nothing may follow it", catalogue.TheRest)
|
||
}
|
||
case part == catalogue.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] != catalogue.TheRest {
|
||
return fmt.Errorf(
|
||
"%q names an emitter and no event. Write <emitter>.<event>, or %q for every event",
|
||
pattern, catalogue.TheRest)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// 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 := catalogue.SeatNamed(first); isASeat {
|
||
return true
|
||
}
|
||
if _, isASeat := catalogue.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 == catalogue.OneName || part == catalogue.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
|
||
}
|
||
|
||
// 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+".")
|
||
}
|
||
|
||
// 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 catalogue.Manifest) []string {
|
||
if !m.ClaimsSeat(catalogue.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, catalogue.FilesSeat, m.RunsAs)}
|
||
}
|
||
|
||
// 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 catalogue.Manifest) []string {
|
||
var problems []string
|
||
check := func(where string, values map[string]any) {
|
||
name, ok := values[catalogue.RouteEndpoint].(string)
|
||
if !ok || strings.TrimSpace(name) == "" {
|
||
return
|
||
}
|
||
if _, found := catalogue.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
|
||
}
|
||
|
||
// 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 catalogue.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/<name>\"` (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 == catalogue.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
|
||
}
|
||
|
||
func healthKindsWords() string {
|
||
return "a check is runtime (the image's own, adopted by name), http, tcp, exec, unit or tool"
|
||
}
|
||
|
||
// 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 catalogue.Manifest) []string {
|
||
var problems []string
|
||
endpoints := map[string]catalogue.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 := catalogue.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 := catalogue.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 !catalogue.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 catalogue.HealthRuntime, catalogue.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 catalogue.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 catalogue.HealthHTTP, catalogue.HealthTCP, catalogue.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 catalogue.HealthHTTP, catalogue.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 != catalogue.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 == catalogue.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 == catalogue.HealthExec) {
|
||
if h.Kind == catalogue.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 == catalogue.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 catalogue.Health) []string {
|
||
var problems []string
|
||
if h.Interval < catalogue.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, catalogue.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 < catalogue.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, catalogue.HealthLooksFloor))
|
||
}
|
||
if h.Grace < 0 {
|
||
problems = append(problems, fmt.Sprintf("%s has a grace of %s", where, h.Grace))
|
||
}
|
||
if h.Looks >= catalogue.HealthLooksFloor && h.Interval >= catalogue.HealthIntervalFloor {
|
||
if took := h.Grace + time.Duration(h.Looks)*h.Interval; took > catalogue.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, catalogue.HealthWithin))
|
||
}
|
||
}
|
||
return problems
|
||
}
|
||
|
||
func namedEndpointsWords(endpoints map[string]catalogue.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, ", ")
|
||
}
|
||
|
||
// 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 catalogue.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 := catalogue.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 := catalogue.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 catalogue.CheckIdentityWithin(machine, source, tightest) == nil {
|
||
continue
|
||
}
|
||
got := catalogue.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(catalogue.IdentityPrefix)-longestMachine-1))
|
||
}
|
||
}
|
||
return problems
|
||
}
|
||
|
||
// 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 catalogue.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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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')
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// 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[catalogue.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 == catalogue.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)
|
||
}
|
||
}
|
||
}
|
||
|
||
var address = regexp.MustCompile(`(?:[0-9]{1,3}\.){3}[0-9]{1,3}`)
|
||
|
||
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)`)
|
||
|
||
var prose = map[string]bool{"why": true, "description": true}
|
||
|
||
var upstreamVersion = regexp.MustCompile(`^build\.artifacts\[[0-9]+\]\.version$`)
|
||
|
||
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,
|
||
}
|
||
|
||
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,
|
||
}
|
||
|
||
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,
|
||
}
|
||
|
||
// 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 catalogue.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 < catalogue.LogLeastMiB || l.MaxMiB > catalogue.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, catalogue.LogLeastMiB, catalogue.LogMostMiB))
|
||
}
|
||
}
|
||
return problems
|
||
}
|
||
|
||
// 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 catalogue.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 != catalogue.CredentialFormPair && c.Form != catalogue.CredentialFormNone:
|
||
problems = append(problems, fmt.Sprintf("%s provides %q with the form %q, not %q or %q", m.Module, o.Name,
|
||
c.Form, catalogue.CredentialFormPair, catalogue.CredentialFormNone))
|
||
}
|
||
}
|
||
return problems
|
||
}
|
||
|
||
func ParseManifest(raw []byte) (catalogue.Manifest, error) {
|
||
return parseManifest(raw, catalogue.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 catalogue.PredefinedList) (catalogue.Manifest, error) {
|
||
return parseManifest(raw, predefined)
|
||
}
|
||
|
||
// 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 catalogue.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
|
||
}
|
||
|
||
// hasSchedule is whether a resource declares a cadence, as a string.
|
||
func hasSchedule(r map[string]any) bool {
|
||
s, _ := r["schedule"].(string)
|
||
return s != ""
|
||
}
|
||
|
||
// 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 catalogue.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 <module>.<tool>, or "+
|
||
"* for every tool on the mesh (novox/hq ADR 0152)", m.Module, t))
|
||
}
|
||
}
|
||
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 catalogue.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)}
|
||
}
|
||
|
||
// 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 catalogue.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 > catalogue.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, catalogue.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
|
||
}
|
||
|
||
// unknownFieldText is how the lenient decoder words the first field its shape does not have, and readWithout the list of
|
||
// every field it read the manifest without (catalogue.Manifest.SetAside).
|
||
var (
|
||
unknownFieldText = regexp.MustCompile(`json: unknown field "([^"]+)"`)
|
||
readWithout = regexp.MustCompile(`\(read without ([^)]*)\)`)
|
||
)
|
||
|
||
// UnknownFields are the fields of a manifest the judge does not know, as the lenient decoder sets them aside (catalogue.Decode,
|
||
// each once, the path it names them with), or none: a manifest this judge reads whole has none, and a field that used to
|
||
// mean something (renamed) is a known mistake, not a field a later judge may know, so a manifest with one has none
|
||
// either.
|
||
func UnknownFields(raw []byte) []string {
|
||
m, err := catalogue.Decode(raw)
|
||
aside := m.SetAside()
|
||
if err != nil || aside == "" {
|
||
return nil
|
||
}
|
||
first := unknownFieldText.FindStringSubmatch(aside)
|
||
if first == nil {
|
||
return nil // set aside for another reason: the judge's refusal as it is
|
||
}
|
||
fields := []string{first[1]}
|
||
if list := readWithout.FindStringSubmatch(aside); list != nil {
|
||
fields = fields[:0]
|
||
for _, f := range strings.Split(list[1], ", ") {
|
||
if f = strings.TrimSpace(f); f != "" && !slices.Contains(fields, f) {
|
||
fields = append(fields, f)
|
||
}
|
||
}
|
||
}
|
||
for _, f := range fields {
|
||
if _, was := renamed[f[strings.LastIndex(f, ".")+1:]]; was {
|
||
return nil
|
||
}
|
||
}
|
||
return fields
|
||
}
|
||
|
||
func parseManifest(raw []byte, predefined catalogue.PredefinedList) (catalogue.Manifest, error) {
|
||
var m catalogue.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.SetAside() != "" {
|
||
// Kept aside by UnmarshalJSON for the stored catalogue's sake; registration refuses it.
|
||
err = errors.New(m.SetAside())
|
||
}
|
||
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 catalogue.Manifest{}, fmt.Errorf(
|
||
"this manifest says %q, which is now called %q: %w", was, is, err)
|
||
}
|
||
}
|
||
return catalogue.Manifest{}, fmt.Errorf("this is not a module manifest: %w", err)
|
||
}
|
||
m.SetPredefined(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 and ask what it declares** (novox/hq ADR 0357): the node's runtime carries
|
||
// the bundle, is granted the events the module emits under the module's name, as for any module it carries,
|
||
// hears what it consumes and asks every tool and seat verb on the bundle's behalf (mesh-sdk go/buscompose, the
|
||
// runtime's invoke of `*`) — the bus's account is not who publishes or asks. So emits, invokes, consumes and uses
|
||
// are refused only for a bus module with no bundle to say, hear or ask them (issue 774: nats's bundle records its
|
||
// hand act).
|
||
//
|
||
// **And its keeper has a process account of its own** (novox/hq ADR 0626 §1, §3, changing ADR 0235 §8): exactly
|
||
// one process declares `bus`, the keeper, whose declaration alone may name `keeps` (the bus's create and update
|
||
// subjects under modules' names), and names no subject on which a tool or a verb is answered: neither
|
||
// `*`, nor one of the module's own tools, nor a verb of a seat it holds (its seat's acting verbs reach the keeper
|
||
// through its state, ADR 0626 §4). The bus state the foundation keeps on the old sections (ADR 0595 §10, ADR 0619)
|
||
// is the keeper's state and the bus's own event stream, so a bus module of the foundation may declare it.
|
||
if _, account := m.OwnSecrets["broker"]; account && m.ClaimsSeat(meshbroker.Seat) {
|
||
var said []string
|
||
bundled := manifestShipsABundle(m)
|
||
foundation := slices.Contains(Foundation, m.Module)
|
||
for _, f := range []struct {
|
||
name string
|
||
n int
|
||
ok bool
|
||
}{{"emits", len(m.EmitsAll()), bundled}, {"invokes", len(m.Invokes), bundled},
|
||
{"consumes", len(m.Consumes), bundled}, {"uses", len(m.Uses), bundled},
|
||
{"state", len(m.State), foundation}, {"logs", len(m.Logs), foundation}, {"reads", len(m.Reads), false},
|
||
{"streams", len(m.Streams), foundation}} {
|
||
if f.n > 0 && !f.ok {
|
||
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, meshbroker.Seat, strings.Join(said, ", ")))
|
||
}
|
||
problems = append(problems, keeperBusProblems(m)...)
|
||
} else {
|
||
for _, r := range m.Resources {
|
||
if fmt.Sprint(r["type"]) == "process" && busKeeps(r) {
|
||
problems = append(problems, fmt.Sprintf("%s's process %v's bus keeps the bus's entries; only the keeper "+
|
||
"of the module holding %s with its own bus account may (novox/hq ADR 0626 §1, §3)", m.Module, r["id"],
|
||
meshbroker.Seat))
|
||
}
|
||
}
|
||
}
|
||
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 != catalogue.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, catalogue.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-<provision>` (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, catalogue.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, catalogue.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(catalogue.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 == catalogue.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 != catalogue.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, catalogue.IssuedBySoftware, catalogue.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\": \"<its 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 != catalogue.ScopeNode && s != catalogue.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, catalogue.ScopeNode, catalogue.ScopeMesh))
|
||
}
|
||
switch {
|
||
case offer.Reach == "":
|
||
case offer.Reach != catalogue.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, catalogue.ReachMachine))
|
||
case offer.At() != catalogue.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)...)
|
||
// And the object buckets it owns (novox/hq ADR 0592 §5, §9).
|
||
problems = append(problems, ObjectsProblems(m)...)
|
||
problems = append(problems, WorkQueueStreamProblems(m)...)
|
||
// And the bus state it asks for in `requires`, by template (busstate.go, novox/hq ADR 0595; issue 1966 D1).
|
||
problems = append(problems, BusStateProblems(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 catalogue.ScopeNode, catalogue.ScopeSite, catalogue.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, buildProblems(m.Build, m.Module)...)
|
||
problems = append(problems, contractsProblems(m)...)
|
||
// 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)...)
|
||
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))
|
||
}
|
||
// **A store holder that builds is not refused by its manifest** (the operator, 2026-11-04, issue 1966; this rule
|
||
// came from novox/hq 04-ISSUES/029). A rule here refused a module that provides `artifact-store` and also builds an
|
||
// artifact: building publishes to the store, so on a fresh mesh with no store yet such a build would wait for the
|
||
// module it belongs to. The registry seats' holder builds the program it runs in front of the store (design 51),
|
||
// so it publishes through itself; that circle is a fact of a mesh's first start, not of a manifest. It is caught where
|
||
// a fresh mesh starts, by the genesis fix of issue 1152, and no rule refuses the manifest for it.
|
||
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 catalogue.FromMesh, catalogue.FromEverywhere, catalogue.FromMachine, catalogue.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, catalogue.FromMesh, catalogue.FromEverywhere, catalogue.FromMachine, catalogue.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, catalogue.FromMesh, catalogue.FromEverywhere, catalogue.FromMachine, catalogue.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 catalogue.IsFamily(name) {
|
||
if !memberRest.MatchString(strings.TrimSuffix(catalogue.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 != catalogue.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, catalogue.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 && !catalogue.IsFamily(other) {
|
||
if rest := strings.TrimPrefix(other, catalogue.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 \"<prefix>-*\"", 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 != catalogue.TakenAtStart && own.Taken != catalogue.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, catalogue.TakenAtStart, catalogue.TakenApplied))
|
||
}
|
||
if own.IssuedBy != "" && own.IssuedBy != catalogue.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, catalogue.IssuedOutside))
|
||
}
|
||
problems = append(problems, ownSecretPromptProblems(m, name, own.Prompt, catalogue.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, catalogue.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:<name>}` 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 catalogue.AccessRead, catalogue.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, catalogue.AccessRead, catalogue.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, manifestUndeclaredMounts(m)...)
|
||
problems = append(problems, manifestUnknownDirRefs(m)...)
|
||
problems = append(problems, manifestUnknownAccessRefs(m)...)
|
||
problems = append(problems, manifestResolvedPathProblems(m)...)
|
||
problems = append(problems, manifestJailProblems(m)...)
|
||
// 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, manifestEnvironmentProblems(m)...)
|
||
problems = append(problems, manifestShellProblems(m)...)
|
||
problems = append(problems, manifestContributionPlaceholderProblems(m)...)
|
||
problems = append(problems, manifestSeatContributionProblems(m)...)
|
||
problems = append(problems, manifestPlacementProblems(m)...)
|
||
problems = append(problems, manifestDataProblems(m)...)
|
||
problems = append(problems, manifestUpgradeProblems(m)...)
|
||
problems = append(problems, manifestUnhealthyProblems(m)...)
|
||
|
||
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)...)
|
||
// A read-only state is written by a process naming it in its bus state alone (review round 2): one no process names
|
||
// would be written by nobody.
|
||
problems = append(problems, readOnlyStateProblems(m)...)
|
||
if m.DeclaresConfig() {
|
||
// Said in the words it was written in: the rules shared with the settings form say ${setting:<key>}
|
||
// 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 catalogue.Manifest{}, fmt.Errorf("this manifest cannot be used:\n - %s",
|
||
strings.Join(problems, "\n - "))
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
// 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 catalogue.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) > catalogue.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, catalogue.MaxReplaced))
|
||
case seen[c]:
|
||
problems = append(problems, fmt.Sprintf("%s: %s replaces %q twice", m.Module, tool, c))
|
||
}
|
||
seen[c] = true
|
||
}
|
||
}
|
||
return problems
|
||
}
|
||
|
||
// 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 catalogue.Manifest) []string {
|
||
if len(m.Waits) == 0 {
|
||
return nil
|
||
}
|
||
if !catalogue.IsToolRunner(m) {
|
||
return []string{fmt.Sprintf("%s says what a session waits on; only the tool runner's module (it says "+
|
||
"\"tool-runner\": true) declares waits (novox/hq ADR 0367 §2)", m.Module)}
|
||
}
|
||
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 <emitter>.<event>, 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
|
||
}
|
||
|
||
// 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 catalogue.Manifest, r map[string]any, scheduled bool) []string {
|
||
raw, present := r[catalogue.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, catalogue.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, catalogue.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, catalogue.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, catalogue.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, catalogue.WhileStopped, r["id"], id))
|
||
}
|
||
}
|
||
return problems
|
||
}
|
||
|
||
// 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 `<HOST>` 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 manifestJailProblems(m catalogue.Manifest) []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, "<HOST>"); n > 1 {
|
||
problems = append(problems, fmt.Sprintf("%s's jail %s names <HOST> %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 <HOST> once",
|
||
m.Module, strconv.Quote(j.Name), n))
|
||
}
|
||
}
|
||
}
|
||
return problems
|
||
}
|
||
|
||
// shipsABundle says the module builds a tools bundle, which the node's runtime carries and speaks for.
|
||
func manifestShipsABundle(m catalogue.Manifest) 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 == catalogue.ArtifactBundle {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// undeclaredMounts is every bind-mount source no declaration covers — see the check above.
|
||
func manifestUndeclaredMounts(m catalogue.Manifest) []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
|
||
}
|
||
|
||
var endpointName = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
||
|
||
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"},
|
||
}
|
||
|
||
var name = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*$`)
|
||
|
||
var promptPlaceholder = regexp.MustCompile(`\{[^{}]*\}`)
|
||
|
||
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 toolName = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
|
||
|
||
var waitToken = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
|
||
|
||
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",
|
||
}
|
||
|
||
// 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 catalogue.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, catalogue.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[catalogue.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, catalogue.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"], catalogue.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, catalogue.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:<value>} — 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, catalogue.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, catalogue.OnlyWhenBound, provision))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return problems
|
||
}
|
||
|
||
// 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 catalogue.Manifest, requirement string) []string {
|
||
names := []string{requirement}
|
||
for local := range m.SecretsMany[requirement] {
|
||
names = append(names, local)
|
||
}
|
||
sort.Strings(names)
|
||
return names
|
||
}
|
||
|
||
// 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 manifestResolvedPathProblems(m catalogue.Manifest) []string {
|
||
dirs := dirsFor(m, catalogue.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
|
||
}
|
||
|
||
// 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 manifestUnknownAccessRefs(m catalogue.Manifest) []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
|
||
}
|
||
|
||
var engineTrees = []string{"/var/lib/mesh-host", "/usr/lib/nox-mesh-host"}
|
||
|
||
var forbiddenBelow = []string{"/proc", "/sys", "/dev", "/boot", "/root", "/var/spool", "/opt", "/var/lib/docker",
|
||
"/var/lib/containers", "/var/lib/containerd"}
|
||
|
||
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"}
|
||
|
||
// 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) (catalogue.Manifest, error) {
|
||
list := catalogue.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) catalogue.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 := catalogue.PredefinedList{}
|
||
if json.Unmarshal(raw, &m) != nil {
|
||
return list
|
||
}
|
||
for _, d := range m.Config {
|
||
if d.Predefined != "" {
|
||
list[d.Predefined] = catalogue.PredefinedKey{Type: d.Type, Description: d.Description, Trusted: d.Trusted}
|
||
}
|
||
}
|
||
return list
|
||
}
|
||
|
||
const meshStateRoot = defaultDataRoot + "/" + placeMesh
|
||
|
||
// 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 catalogue.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 "+
|
||
"<module>.<tool>, or a role's seat:<seat>.<verb> (novox/hq ADR 0436 §1)", m.Module, id, t))
|
||
}
|
||
}
|
||
}
|
||
return problems
|
||
}
|
||
|
||
func filepathDir(p string) string {
|
||
if i := strings.LastIndex(p, "/"); i > 0 {
|
||
return p[:i]
|
||
}
|
||
return "/"
|
||
}
|
||
|
||
// invokeNameValid says a module or process's invoked entry names a tool: <module>.<tool>, a role's
|
||
// seat:<seat>.<verb>, 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)
|
||
}
|
||
|
||
// 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 catalogue.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
|
||
}
|
||
|
||
// 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 catalogue.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)}
|
||
}
|
||
|
||
// busKeeps says the process resource r's bus declares `keeps` (novox/hq ADR 0626 §1): read from the resource as
|
||
// written rather than through catalogue.ProcessBus, so any value but false or absent counts and a refusal is never dodged
|
||
// by its spelling.
|
||
func busKeeps(r map[string]any) bool {
|
||
b, _ := r["bus"].(map[string]any)
|
||
v, present := b["keeps"]
|
||
return present && v != false
|
||
}
|
||
|
||
// keeperBusProblems is what is wrong with the bus module's process accounts (novox/hq ADR 0626 §1, §3): a second
|
||
// process declaring `bus` beside its keeper, or a keeper's bus naming a subject on which a tool or a verb is answered:
|
||
// `*`, one of the module's own tools, or a verb of a seat the module holds.
|
||
func keeperBusProblems(m catalogue.Manifest) []string {
|
||
var problems, keepers []string
|
||
held := map[string]bool{}
|
||
for _, c := range m.Claims {
|
||
held[c.Name] = true
|
||
}
|
||
for _, r := range m.Resources {
|
||
if fmt.Sprint(r["type"]) != "process" {
|
||
continue
|
||
}
|
||
b, present, err := processBusOf(r)
|
||
if !present || err != nil {
|
||
continue
|
||
}
|
||
id := fmt.Sprint(r["id"])
|
||
keepers = append(keepers, id)
|
||
for _, t := range b.Invokes {
|
||
seat, verb, isSeat := strings.Cut(strings.TrimPrefix(t, "seat:"), ".")
|
||
own := !strings.HasPrefix(t, "seat:") && seat == m.Module && slices.Contains(m.Tools, verb)
|
||
if t == "*" || own || (strings.HasPrefix(t, "seat:") && isSeat && held[seat]) {
|
||
problems = append(problems, fmt.Sprintf("%s's keeper %s's bus invokes %q, a subject on which a tool or "+
|
||
"a verb is answered; the keeper is granted none (novox/hq ADR 0626 §1, §3, ADR 0436 §3)", m.Module, id, t))
|
||
}
|
||
}
|
||
}
|
||
if len(keepers) > 1 {
|
||
problems = append(problems, fmt.Sprintf("%s holds %s and its processes %s declare bus; only its keeper has a "+
|
||
"process account of its own (novox/hq ADR 0626 §1, §3)", m.Module, meshbroker.Seat, strings.Join(keepers, ", ")))
|
||
}
|
||
return problems
|
||
}
|
||
|
||
// 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 catalogue.Manifest) []string {
|
||
var problems []string
|
||
var artifacts []catalogue.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 != catalogue.ArtifactBundle || catalogue.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 b.Keeps {
|
||
problems = append(problems, fmt.Sprintf("%s's tools bundle %s's bus keeps the bus's entries; only the keeper, the "+
|
||
"process of the module holding %s with its own bus account, may (novox/hq ADR 0626 §1, §3)", m.Module, a.Name,
|
||
meshbroker.Seat))
|
||
}
|
||
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
|
||
}
|
||
|
||
// toolsSecretProblems refuses a tools bundle's bus account secret the runtime's account could not read: it is declared
|
||
// as an own secret `broker-<binary>`, at `${dir:<id>}/broker-<binary>` 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 catalogue.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
|
||
}
|
||
|
||
// BusRemovals are what a bus account may remove, the closed set of novox/hq ADR 0564 Limits 4 as ADR 0590 clause 6
|
||
// changes it: the node-engine's acts and the ended controller's leftovers.
|
||
var BusRemovals = []string{catalogue.RemovesEngineActs, catalogue.RemovesControllerLeftovers}
|
||
|
||
// 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 catalogue.Manifest) []string {
|
||
if !m.ClaimsSeat(catalogue.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, catalogue.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, catalogue.ReadsSeat, m.RunsAs, groups)}
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// seatContributionProblems is what is wrong with this module's contributions, from the manifest
|
||
// alone (novox/hq ADR 0212 §2).
|
||
func manifestSeatContributionProblems(m catalogue.Manifest) []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(catalogue.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(catalogue.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
|
||
}
|
||
|
||
// combinations is every assignment of a value to each placing field.
|
||
func combinations(places []catalogue.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 comboKey(combo map[string]string) string {
|
||
var parts []string
|
||
for _, k := range sortedKeys(combo) {
|
||
parts = append(parts, k+"="+combo[k])
|
||
}
|
||
return strings.Join(parts, ",")
|
||
}
|
||
|
||
func covers(where, combo map[string]string) bool {
|
||
for field, value := range where {
|
||
if combo[field] != value {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// 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 catalogue.Manifest, i int, r catalogue.Receivable, c catalogue.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 := catalogue.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
|
||
}
|
||
|
||
// placesKind is whether any of a holder's files names the kind's placeholder.
|
||
func placesKind(m catalogue.Manifest, s catalogue.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 := catalogue.SeatNamed(seat); ok && cs.Name == s.Name && k == kind {
|
||
return true
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// shapedProblems is what is wrong with one contribution of a kind received as data.
|
||
func shapedProblems(module string, i int, r catalogue.Receivable, c catalogue.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
|
||
}
|
||
|
||
// 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 manifestPlacementProblems(m catalogue.Manifest) []string {
|
||
var problems []string
|
||
for _, c := range m.Claims {
|
||
s, known := catalogue.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 := catalogue.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
|
||
}
|
||
|
||
var capabilityName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||
|
||
// invokesOf is everything a module says it invokes: its own and each process's bus, once each.
|
||
func invokesOf(m catalogue.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)
|
||
}
|
||
|
||
// seatInvokeProblems are the `seat:<seat>.<verb>` 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 catalogue.Shelf, declared map[string]catalogue.SeatDeclaration) []string {
|
||
claimed := map[string]bool{}
|
||
for _, m := range shelf {
|
||
for _, c := range m.Claims {
|
||
claimed[c.Name] = true
|
||
if s, ok := catalogue.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 := catalogue.SeatInvoke(invoke)
|
||
if !ok {
|
||
continue
|
||
}
|
||
var verbs []catalogue.Verb
|
||
held := claimed[seat]
|
||
if s, isMesh := catalogue.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, "+catalogue.UndeclaredSeat, module, invoke, seat))
|
||
continue
|
||
}
|
||
if !slices.ContainsFunc(verbs, func(v catalogue.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, "+catalogue.UnheldSeat, module, invoke, seat))
|
||
}
|
||
}
|
||
}
|
||
return problems
|
||
}
|
||
|
||
// 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 []catalogue.Seat) []string {
|
||
var problems []string
|
||
owners := map[string][]string{} // path → the seats naming it without stepping back
|
||
stepping := map[string][]catalogue.SeatPath{} // seat → the paths it names stepping back
|
||
byName := map[string]catalogue.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 != catalogue.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
|
||
}
|
||
|
||
// 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) []catalogue.SeatPath {
|
||
out := make([]catalogue.SeatPath, 0, len(paths))
|
||
for _, p := range paths {
|
||
out = append(out, catalogue.SeatPath{Path: p})
|
||
}
|
||
return out
|
||
}
|
||
|
||
// 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 catalogue.Manifest, s catalogue.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
|
||
}
|
||
|
||
func namesWithoutSteppingBack(s catalogue.Seat, p string) bool {
|
||
for _, sp := range s.Paths {
|
||
if sp.Path == p && sp.StepsBackFor == "" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// 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 != catalogue.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 ""
|
||
}
|
||
|
||
// 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 catalogue.Manifest) []string {
|
||
var problems []string
|
||
|
||
defined := map[string]catalogue.SeatDeclaration{}
|
||
for _, d := range m.DefinesSeats {
|
||
if _, isSystem := catalogue.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 := catalogue.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 := catalogue.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
|
||
}
|
||
|
||
// 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-")
|
||
}
|
||
|
||
func seatNames() string {
|
||
names := make([]string, 0)
|
||
for _, s := range catalogue.Seats() {
|
||
names = append(names, s.Name)
|
||
}
|
||
sort.Strings(names)
|
||
return strings.Join(names, ", ")
|
||
}
|
||
|
||
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)",
|
||
}
|
||
|
||
const meshSeatPrefix = "mesh-"
|
||
|
||
const retiredLoginShell = "login-shell"
|
||
|
||
func CatalogueProblems(shelf catalogue.Shelf) []string {
|
||
var problems []string
|
||
// A stream name has one owner (novox/hq ADR 0363).
|
||
problems = append(problems, StreamNameProblems(shelf)...)
|
||
// An object bucket's name has one owner, and another module reads it only through its owner's grant (ADR 0592 §5, §9).
|
||
problems = append(problems, ObjectsCatalogueProblems(shelf)...)
|
||
// Bus state in `requires`: a name on the bus has one owner, and the new form has no readers (ADR 0595, ADR 0596 §2).
|
||
problems = append(problems, BusStateCatalogueProblems(shelf)...)
|
||
// A manifest's packages name a known package in a well-formed series (design 32, DLV-I14): Between, which is handed the
|
||
// known names.
|
||
// 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]catalogue.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 := catalogue.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 := catalogue.SeatNamed(seat); isMesh {
|
||
return s.Scope == catalogue.ScopeNode
|
||
}
|
||
if s, ok := declared[seat]; ok {
|
||
return s.At() == catalogue.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, "+catalogue.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, "+catalogue.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, "+catalogue.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, _ := catalogue.SeatNamed(c.Name)
|
||
if err := catalogue.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 := catalogue.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
|
||
}
|
||
|
||
// 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 catalogue.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
|
||
}
|
||
|
||
// 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 catalogue.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 !catalogue.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
|
||
}
|
||
|
||
// 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 catalogue.Manifest, s catalogue.SeatDeclaration) []string {
|
||
var problems []string
|
||
if len(s.Needs) == 0 {
|
||
return problems
|
||
}
|
||
if s.At() != catalogue.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
|
||
}
|
||
|
||
// declaredSeatProblems is what one manifest can be judged on alone.
|
||
func declaredSeatProblems(m catalogue.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, catalogue.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 := catalogue.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 catalogue.ScopeNode, catalogue.ScopeSite, catalogue.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 seatDeclarationVerbs(s) {
|
||
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
|
||
}
|
||
|
||
// 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 catalogue.Claim, s catalogue.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
|
||
}
|
||
|
||
// 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 catalogue.Shelf) []string {
|
||
out := make([]string, 0, len(shelf))
|
||
for k := range shelf {
|
||
out = append(out, k)
|
||
}
|
||
sort.Strings(out)
|
||
return out
|
||
}
|
||
|
||
// trafficProblems is what one declaration of the rules of ADR 0259 §3 can be judged on alone.
|
||
func trafficProblems(m catalogue.Manifest, s catalogue.SeatDeclaration) []string {
|
||
var problems []string
|
||
if s.Kinded && !catalogue.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
|
||
}
|
||
|
||
// 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 catalogue.Manifest, c catalogue.Claim, s catalogue.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
|
||
}
|
||
|
||
// verbs is everything the protocol names, for the checks that do not care which half.
|
||
func seatDeclarationVerbs(s catalogue.SeatDeclaration) []string {
|
||
out := append([]string{}, s.Accepts...)
|
||
out = append(out, s.Emits...)
|
||
return append(out, catalogue.VerbNames(s.Serves)...)
|
||
}
|
||
|
||
var accountName = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,30}$`)
|
||
|
||
var maxLength = regexp.MustCompile(`^max-length:[1-9][0-9]{0,6}$`)
|
||
|
||
// 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 catalogue.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
|
||
}
|
||
|
||
// 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 catalogue.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, ", "))}
|
||
}
|
||
|
||
// ownNameKeys is every name a served value of m may say for one requirement it contributes to:
|
||
// `name` and `internal-name`, and `name-<local>` and `internal-name-<local>` for each local name it
|
||
// contributes under.
|
||
func ownNameKeys(m catalogue.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
|
||
}
|
||
|
||
func quoted(values []string) []string {
|
||
out := make([]string, len(values))
|
||
for i, v := range values {
|
||
out[i] = fmt.Sprintf("%q", v)
|
||
}
|
||
return out
|
||
}
|
||
|
||
var ownNameFacts = []string{"name", "internal-name"}
|
||
|
||
// SettingProblems is every way a definition's setting defaults are wrong, in its own words.
|
||
func SettingProblems(m catalogue.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 != catalogue.KindPreference {
|
||
say("kind is %q, and only a %q has a default (novox/hq ADR 0262)", d.Kind, catalogue.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
|
||
}
|
||
|
||
// 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 ""
|
||
}
|
||
|
||
func sortedSettingKeys(in map[string]catalogue.SettingDeclaration) []string {
|
||
keys := make([]string, 0, len(in))
|
||
for k := range in {
|
||
keys = append(keys, k)
|
||
}
|
||
sort.Strings(keys)
|
||
return keys
|
||
}
|
||
|
||
var aboutAnAmount = map[string]bool{
|
||
"max": true, "min": true, "num": true, "count": true, "show": true, "hide": true, "enable": true,
|
||
"disable": true,
|
||
}
|
||
|
||
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,
|
||
}
|
||
|
||
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,
|
||
}
|
||
|
||
// 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 catalogue.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
|
||
}
|
||
|
||
// 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 catalogue.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
|
||
}
|
||
|
||
// 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 catalogue.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
|
||
}
|
||
|
||
var groupSide = regexp.MustCompile(`^(?:\$\{setting:[a-z0-9][a-z0-9_.-]*\}|[0-9]+)$`)
|
||
|
||
var ownerSide = `(?:\$\{setting:[a-z0-9][a-z0-9_.-]*\}|[0-9]+)`
|
||
|
||
var ownerWithSetting = regexp.MustCompile(`^` + ownerSide + `(?::` + ownerSide + `)?$`)
|
||
|
||
var unitInstance = regexp.MustCompile(`^[A-Za-z0-9:_.-]+@\$\{setting:[a-z0-9][a-z0-9_.-]*\}\.[a-z]+$`)
|
||
|
||
// 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 catalogue.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 catalogue.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[catalogue.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, catalogue.TakesSettingsField))
|
||
}
|
||
return problems
|
||
}
|
||
|
||
// 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 catalogue.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 > catalogue.StateMostHistory {
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s keeps %d values of %q; a key keeps 0 or 1 to %d (0 is taken as 1)", m.Module, s.History, s.Name, catalogue.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 := catalogue.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
|
||
}
|
||
|
||
// **A module keeps its data in the bus's predefined forms** (the lead and the operator, 2026-10-29; the operator's rule of
|
||
// 2026-10-22, "data storage is done using our predefined nats bus options"): a state, a log or an object store, which the
|
||
// runtime reads and writes for its tools and other modules read by a declared `reads`. A stream of its own is outside
|
||
// them and refused, naming it. streamsKeptUntilMoved are the streams declared before this check, each allowed by name
|
||
// until its module moves it; the list only shrinks. The judge's own exception, kept here and no longer in mesh-sdk,
|
||
// which names no module (issue 774, the operator 2026-11-03).
|
||
var streamsKeptUntilMoved = map[string][]string{
|
||
"brother": {"device-asks"},
|
||
"mesh-build-go": {"toolchains-go"},
|
||
"mesh-build-node": {"toolchains-node"},
|
||
"mesh-mail": {"mail", "mail-sends"},
|
||
"only-office": {"convert-asks"},
|
||
"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 catalogue.Manifest) []string {
|
||
var out []string
|
||
for _, s := range m.Streams {
|
||
// A stream that keeps its history is one of the bus's predefined forms (novox/hq ADR 0592 §6).
|
||
if !s.KeepsHistory() && !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
|
||
}
|
||
|
||
// 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 catalogue.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 == catalogue.OneName || emitter == catalogue.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
|
||
}
|
||
|
||
// StreamNameProblems are the rules about stream names no single manifest can be judged against: two
|
||
// modules declaring one name.
|
||
func StreamNameProblems(shelf catalogue.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
|
||
}
|
||
|
||
// StreamProblems is what is wrong with a manifest's streams, judged on the one manifest.
|
||
func StreamProblems(m catalogue.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 := catalogue.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(catalogue.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 < catalogue.StreamLeastDays || s.MaxAgeDays > catalogue.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, catalogue.StreamLeastDays, catalogue.StreamMostDays))
|
||
}
|
||
if s.MaxMiB != 0 && (s.MaxMiB < catalogue.StreamLeastMiB || s.MaxMiB > catalogue.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, catalogue.StreamLeastMiB, catalogue.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 "", catalogue.StreamRetentionWorkQueue:
|
||
case catalogue.StreamRetentionHistory:
|
||
// A stream that keeps its history (novox/hq ADR 0592 §6) keeps every message with no maximum age and
|
||
// refuses a new one when full: an age is meaningless on it, and refused.
|
||
if s.MaxAgeDays != 0 {
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s's stream %q keeps its history and declares max-age-days %d; a stream that keeps its history keeps "+
|
||
"every message with no maximum age (novox/hq ADR 0592 §6)", m.Module, s.Name, s.MaxAgeDays))
|
||
}
|
||
default:
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s's stream %q declares retention %q; a stream's retention is %q, %q or nothing (novox/hq ADR 0436 §6, "+
|
||
"ADR 0592 §6)", m.Module, s.Name, s.Retention, catalogue.StreamRetentionWorkQueue, catalogue.StreamRetentionHistory))
|
||
}
|
||
switch {
|
||
case s.Retention == catalogue.StreamRetentionWorkQueue &&
|
||
(s.MaxDeliveries < catalogue.StreamLeastDeliveries || s.MaxDeliveries > catalogue.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, catalogue.StreamLeastDeliveries,
|
||
catalogue.StreamMostDeliveries))
|
||
case s.Retention != catalogue.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, catalogue.StreamRetentionWorkQueue))
|
||
}
|
||
switch {
|
||
case s.Duplicates != 0 && s.Retention != catalogue.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, catalogue.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 == catalogue.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, catalogue.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, catalogue.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
|
||
}
|
||
|
||
// 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 catalogue.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 == catalogue.OneName || emitter == catalogue.TheRest || !known || emitter == module {
|
||
continue
|
||
}
|
||
hearing, _ := provider.StreamsHearing(rest)
|
||
for _, s := range hearing {
|
||
if s.Retention != catalogue.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
|
||
}
|
||
|
||
// 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 catalogue.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 != catalogue.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
|
||
}
|
||
|
||
// 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 == catalogue.TheRest:
|
||
if i != len(parts)-1 {
|
||
return fmt.Errorf("%q stands for the rest of a name, so nothing may follow it", catalogue.TheRest)
|
||
}
|
||
case part == catalogue.OneName:
|
||
case key && part == catalogue.GrantKey:
|
||
case !eventName.MatchString(part):
|
||
return fmt.Errorf("%q is not a usable name: lower-case letters, digits and dashes", part)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// 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 catalogue.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
|
||
}
|
||
|
||
// 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 catalogue.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+"/<name>")
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// 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 catalogue.Manifest) []string {
|
||
var out []string
|
||
for _, r := range m.Resources {
|
||
v, said := r[catalogue.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"], catalogue.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"], catalogue.TrustedField, v))
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// unhealthyProblems refuses an `unhealthy` without a why, and any severity but urgent or warning (novox/hq ADR
|
||
// 0355 §2).
|
||
func manifestUnhealthyProblems(m catalogue.Manifest) []string {
|
||
if m.Unhealthy == nil {
|
||
return nil
|
||
}
|
||
var problems []string
|
||
switch m.Unhealthy.Severity {
|
||
case catalogue.UnhealthyUrgent, catalogue.UnhealthyWarning:
|
||
default:
|
||
problems = append(problems, fmt.Sprintf("unhealthy is %q or %q, not %q", catalogue.UnhealthyUrgent, catalogue.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
|
||
}
|
||
|
||
func manifestUpgradeProblems(m catalogue.Manifest) []string {
|
||
if m.Upgrade == nil {
|
||
return nil
|
||
}
|
||
switch m.Upgrade.Policy {
|
||
case catalogue.PolicyRoll:
|
||
case catalogue.PolicyTogether, catalogue.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", catalogue.PolicyRoll, catalogue.PolicyTogether, catalogue.PolicyRecord,
|
||
m.Upgrade.Policy)}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// zoneProblems is what is wrong with a module's zone declaration on its own, before any node.
|
||
func zoneProblems(m catalogue.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 !manifestHasListen(m, 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 manifestHasListen(m catalogue.Manifest, name string) bool {
|
||
if name == "" {
|
||
return false
|
||
}
|
||
for _, l := range m.Listens {
|
||
if l.Name == name {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// ---- copied from catalogue: helpers composition keeps too ----
|
||
|
||
// sortedKeys is map iteration made repeatable, which everything written to a machine needs.
|
||
func sortedKeys[V any](m map[string]V) []string {
|
||
out := make([]string, 0, len(m))
|
||
for k := range m {
|
||
out = append(out, k)
|
||
}
|
||
sort.Strings(out)
|
||
return out
|
||
}
|
||
|
||
var ofPort = regexp.MustCompile(`\$\{port:([0-9]+)\}`)
|
||
|
||
var dirRef = regexp.MustCompile(`\$\{dir:([a-z0-9][a-z0-9-]*)\}`)
|
||
|
||
// runByAResource is the artifacts one of a module's own resources names — a process that runs it,
|
||
// a step, an archive that unpacks it — by name.
|
||
func runByAResource(m catalogue.Manifest) map[string]bool {
|
||
named := map[string]bool{}
|
||
for _, r := range m.Resources {
|
||
if a, ok := r["artifact"].(string); ok && a != "" {
|
||
named[a] = true
|
||
}
|
||
}
|
||
return named
|
||
}
|
||
|
||
// compilesToABinary is whether this language's bundle is a binary for one operating system rather
|
||
// than code that runs wherever its interpreter does.
|
||
//
|
||
// **Asked of the language, not of the artifact.** A module says what it is written in; what that
|
||
// implies is the mesh's to know, exactly as the compiler is (novox/hq ADR 0142). Asking the artifact
|
||
// would let two artifacts in one language disagree about whether they are portable.
|
||
func compilesToABinary(language string) bool {
|
||
switch strings.ToLower(strings.TrimSpace(language)) {
|
||
case "go":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
var attributeKey = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
||
|
||
var attributeValue = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`)
|
||
|
||
// oldCollection is an old namespace's collection by its name: its comment, or why no item may be contributed to it.
|
||
// ok is false for a name that is not an old namespace's.
|
||
//
|
||
// forItem is whether an item is to be contributed to it: an item may be contributed to a kind received as data,
|
||
// which a collector may place as a collection only where its shape says its pieces are text (design 57 L3).
|
||
func oldCollection(name string, forItem bool) (comment string, old bool, wrong error) {
|
||
namespace, key, _ := strings.Cut(name, ":")
|
||
if !slices.Contains(oldCollectionNamespaces, namespace) {
|
||
return "", false, nil
|
||
}
|
||
switch namespace {
|
||
case "shell":
|
||
target, slot, two := strings.Cut(key, ":")
|
||
if !two || !oneOf(contributionTargets(), target) || !oneOf(knownSlots, slot) {
|
||
return "", true, fmt.Errorf("%s is no slot: shell code is shell:<shell>:<slot>, the shell one of %s, "+
|
||
"the session's file one of %s or the power moment one of %s, and the slot one of %s", name,
|
||
strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", "), strings.Join(powerMoments, ", "),
|
||
strings.Join(knownSlots, ", "))
|
||
}
|
||
// shellCode's line before each module's code.
|
||
return "#", true, nil
|
||
case "environment":
|
||
if key == "" {
|
||
// The collection itself, which an item is contributed to; a placeholder names a rendering.
|
||
return "#", true, nil
|
||
}
|
||
if key != catalogue.EnvironmentPOSIX && key != catalogue.EnvironmentSystemd {
|
||
return "", true, fmt.Errorf("%s is no format: the environment is environment:%s or environment:%s",
|
||
name, catalogue.EnvironmentPOSIX, catalogue.EnvironmentSystemd)
|
||
}
|
||
return "#", true, nil
|
||
}
|
||
seat, kind, _ := strings.Cut(key, ":")
|
||
s, r, ok := receivable(seat, kind)
|
||
switch {
|
||
case !ok && s.Name == "":
|
||
return "", true, fmt.Errorf("%s names the seat %q, which the mesh does not define", name, seat)
|
||
case !ok:
|
||
return "", true, fmt.Errorf("%s: %s; %s", name, s.Name, kindsOf(s))
|
||
case r.Shape != nil && (forItem || collectableAsText(r)):
|
||
return r.Comment, true, nil
|
||
case r.Shape != nil:
|
||
return "", true, fmt.Errorf("%s is received by %s as data in its own shape, which no item of text is "+
|
||
"(novox/hq ADR 0255)", name, s.Name)
|
||
case r.Dirs:
|
||
return "", true, fmt.Errorf("%s names its contributors' directories, which the holder reads as paths "+
|
||
"the controller fills; it is not yet a collection (design 57 L1)", name)
|
||
case s.Name != canonicalSeat(seat):
|
||
return "", true, fmt.Errorf("%s names the seat by another name; a collection names it %s", name, s.Name)
|
||
}
|
||
return r.Comment, true, nil
|
||
}
|
||
|
||
var ofCollected = regexp.MustCompile(`\$\{collected:([^}]*)\}`)
|
||
|
||
// splitSelector is a placeholder's name without its selector, and the selector: `widgets:bar=top,place=left` is
|
||
// `widgets` and `bar=top,place=left`. A collection's name holds no `=`, so the selector begins at the last colon
|
||
// before the first one.
|
||
func splitSelector(name string) (collection, selector string) {
|
||
eq := strings.Index(name, "=")
|
||
if eq < 0 {
|
||
return name, ""
|
||
}
|
||
colon := strings.LastIndex(name[:eq], ":")
|
||
if colon < 0 {
|
||
return name, ""
|
||
}
|
||
return name[:colon], name[colon+1:]
|
||
}
|
||
|
||
func itemKey(it catalogue.Item) string { return catalogue.ItemKey(it.Collection, it.Name) }
|
||
|
||
// itemTemplateProblem is what is wrong with an item's template path, or "".
|
||
func itemTemplateProblem(m catalogue.Manifest, name string) string {
|
||
dir := m.TemplatesDir()
|
||
if strings.HasPrefix(name, "/") || path.Clean(name) != name || !strings.HasPrefix(name, dir+"/") ||
|
||
strings.Contains(name, "\\") {
|
||
return fmt.Sprintf("the template %q is not a file under %s/ of its own repository: a template is %q, a "+
|
||
"plain relative path, and the build packs only %s/", name, dir, dir+"/<name>", dir)
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// receivable is what a seat receives of a kind, with the seat's canonical name; false when the seat
|
||
// is unknown or does not receive it.
|
||
func receivable(seat, kind string) (catalogue.Seat, catalogue.Receivable, bool) {
|
||
s, known := catalogue.SeatNamed(seat)
|
||
if !known {
|
||
return catalogue.Seat{}, catalogue.Receivable{}, false
|
||
}
|
||
for _, r := range s.Receives {
|
||
if r.Kind == kind {
|
||
return s, r, true
|
||
}
|
||
}
|
||
return s, catalogue.Receivable{}, false
|
||
}
|
||
|
||
func placeFields(r catalogue.Receivable) string {
|
||
var names []string
|
||
for _, p := range r.Shape.Places {
|
||
names = append(names, p.Field)
|
||
}
|
||
return strings.Join(names, ", ")
|
||
}
|
||
|
||
func oneOf(list []string, s string) bool {
|
||
for _, x := range list {
|
||
if x == s {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
var templatePlaceholder = regexp.MustCompile(`\$\{([a-z][a-z-]*):([^}\s$]+)\}`)
|
||
|
||
// collectionOfPlaceholder is the collection a placeholder's namespace and key name, and whether they name one.
|
||
func collectionOfPlaceholder(namespace, key string) (string, bool) {
|
||
key, _ = splitSelector(key)
|
||
switch {
|
||
case namespace == catalogue.CollectedNamespace && strings.HasPrefix(key, catalogue.EnvironmentCollection+":"):
|
||
// The environment is one collection, and the rest of the name picks the rendering (issue 795).
|
||
return catalogue.EnvironmentCollection, true
|
||
case namespace == catalogue.CollectedNamespace:
|
||
return key, true
|
||
case namespace == "environment":
|
||
return catalogue.EnvironmentCollection, true
|
||
case slices.Contains(oldCollectionNamespaces, namespace):
|
||
return namespace + ":" + key, true
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
// unconsumedPlaceholders is every placeholder in one resource of a definition that no pass fills
|
||
// where it stands, each named with the module, the resource, the field and the token. The same
|
||
// function at the catalogue check and at composition, over the resource as the manifest wrote it.
|
||
func unconsumedPlaceholders(module string, r map[string]any) []string {
|
||
kind := fmt.Sprint(r["type"])
|
||
var problems []string
|
||
var sweep func(top, field string, v any)
|
||
sweep = func(top, field string, v any) {
|
||
switch v := v.(type) {
|
||
case string:
|
||
rest := v
|
||
for _, f := range fillers {
|
||
for _, p := range f.patterns {
|
||
if !fillerReads(f, kind, top) {
|
||
for _, token := range p.FindAllString(rest, -1) {
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s's resource %v holds %s in its %s, and ${%s:…} is not filled in this field: "+
|
||
"it would reach the machine as that text (novox/hq issue 231)%s",
|
||
module, r["id"], token, field, f.namespace, whereFilled(f)))
|
||
}
|
||
}
|
||
rest = p.ReplaceAllString(rest, "")
|
||
}
|
||
}
|
||
for _, token := range ofKnownNamespace.FindAllString(rest, -1) {
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s's resource %v holds %s in its %s, which is no placeholder the mesh fills: a "+
|
||
"misspelt key, or a namespace in the wrong case, would reach the machine as that "+
|
||
"text (novox/hq issue 231)", module, r["id"], token, field))
|
||
}
|
||
rest = ofKnownNamespace.ReplaceAllString(rest, "")
|
||
for _, token := range namespaceShaped.FindAllString(rest, -1) {
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s's resource %v holds %s in its %s, and no pass of the mesh fills it: a misspelt "+
|
||
"placeholder would reach the machine as that text (novox/hq issue 231). The mesh "+
|
||
"fills ${%s:…}; the shell's own syntax belongs in the module's shell code (ADR 0204)",
|
||
module, r["id"], token, field, strings.Join(namespacesOf(fillers), ":…}, ${")))
|
||
}
|
||
case []any:
|
||
for i, e := range v {
|
||
sweep(top, fmt.Sprintf("%s[%d]", field, i), e)
|
||
}
|
||
case map[string]any:
|
||
for _, k := range sortedKeys(v) {
|
||
sweep(top, field+"."+k, v[k])
|
||
}
|
||
}
|
||
}
|
||
for _, k := range sortedKeys(r) {
|
||
sweep(k, k, r[k])
|
||
}
|
||
return problems
|
||
}
|
||
|
||
// renamedTaken is every value a mergeable file takes under a name of its own, with that name (issue 751).
|
||
func renamedTaken(m catalogue.Manifest) map[string]string {
|
||
out := map[string]string{}
|
||
for _, r := range m.Resources {
|
||
if how, _ := r["merge"].(string); how == "" {
|
||
continue
|
||
}
|
||
for _, e := range takenEntries(r[catalogue.TakesSettingsField]) {
|
||
if e.renamed {
|
||
out[e.key] = e.as
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
var settingRef = regexp.MustCompile(`\$\{setting:([a-z0-9][a-z0-9_.-]*)\}`)
|
||
|
||
var meshWords = map[string]bool{
|
||
catalogue.PortsSetting: true, catalogue.ExposeSetting: true, catalogue.ReachSetting: true, catalogue.EndpointsSetting: true,
|
||
catalogue.PlacesSetting: true, catalogue.AccessesSetting: true, catalogue.NetworksSetting: true,
|
||
}
|
||
|
||
var accountFacts = map[string]bool{"puid": true, "pgid": true}
|
||
|
||
// validationProblems is what is wrong with a declaration's validation, and its default against it.
|
||
func validationProblems(d catalogue.SettingDeclaration) []string {
|
||
var problems []string
|
||
isNumber := d.Type == catalogue.ConfigNumber
|
||
if len(d.Enum) > 0 && d.Type != catalogue.ConfigNumber && d.Type != catalogue.ConfigString {
|
||
problems = append(problems, fmt.Sprintf("an enum is for a number or a string, and this is a %s", d.Type))
|
||
}
|
||
if (d.Minimum != nil || d.Maximum != nil) && !isNumber {
|
||
problems = append(problems, fmt.Sprintf("a minimum or maximum is for a number, and this is a %s", d.Type))
|
||
}
|
||
if d.Minimum != nil && d.Maximum != nil && *d.Minimum > *d.Maximum {
|
||
problems = append(problems, fmt.Sprintf("its minimum %v is above its maximum %v", *d.Minimum, *d.Maximum))
|
||
}
|
||
var pattern *regexp.Regexp
|
||
if d.Pattern != "" {
|
||
if d.Type != catalogue.ConfigString {
|
||
problems = append(problems, fmt.Sprintf("a pattern is for a string, and this is a %s", d.Type))
|
||
}
|
||
var err error
|
||
if pattern, err = regexp.Compile(`^(?:` + d.Pattern + `)$`); err != nil {
|
||
problems = append(problems, fmt.Sprintf("its pattern %q is not a regular expression: %v", d.Pattern, err))
|
||
}
|
||
}
|
||
for _, v := range d.Enum {
|
||
if !ofType(v, d.Type) {
|
||
problems = append(problems, fmt.Sprintf("its enum holds %v, which is not a %s", v, d.Type))
|
||
}
|
||
}
|
||
if d.Default == nil {
|
||
return problems
|
||
}
|
||
if !ofType(d.Default, d.Type) {
|
||
if d.Type != "" {
|
||
problems = append(problems, fmt.Sprintf("its default %v is not a %s", d.Default, d.Type))
|
||
}
|
||
return problems
|
||
}
|
||
if len(d.Enum) > 0 && !slices.ContainsFunc(d.Enum, func(v any) bool { return v == d.Default }) {
|
||
problems = append(problems, fmt.Sprintf("its default %v is not one of %v", d.Default, d.Enum))
|
||
}
|
||
if n, ok := d.Default.(float64); ok {
|
||
if d.Minimum != nil && n < *d.Minimum {
|
||
problems = append(problems, fmt.Sprintf("its default %v is below its minimum %v", n, *d.Minimum))
|
||
}
|
||
if d.Maximum != nil && n > *d.Maximum {
|
||
problems = append(problems, fmt.Sprintf("its default %v is above its maximum %v", n, *d.Maximum))
|
||
}
|
||
}
|
||
if s, ok := d.Default.(string); ok && pattern != nil && !pattern.MatchString(s) {
|
||
problems = append(problems, fmt.Sprintf("its default %q does not match its pattern %q", s, d.Pattern))
|
||
}
|
||
return problems
|
||
}
|
||
|
||
// settingKeysUsedBy is every key a module's files, units, directory owners, contributions and served
|
||
// facts ask for, so a setting that lands in one is not called stray.
|
||
func settingKeysUsedBy(m catalogue.Manifest) map[string]bool {
|
||
used := settingKeysAskedOutsideOwners(m)
|
||
for _, r := range m.Resources {
|
||
if fmt.Sprint(r["type"]) == "directory" {
|
||
if owner, ok := r["owner"].(string); ok {
|
||
for _, k := range settingsUsed(owner) {
|
||
used[k] = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return used
|
||
}
|
||
|
||
// takenKeyList is the keys a resource takes, in order: the config keys, never the file's names for them.
|
||
func takenKeyList(resource map[string]any) []string {
|
||
var keys []string
|
||
for _, e := range takenEntries(resource[catalogue.TakesSettingsField]) {
|
||
keys = append(keys, e.key)
|
||
}
|
||
return keys
|
||
}
|
||
|
||
func stringsOf(v any) []string {
|
||
raw, ok := v.([]any)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
var out []string
|
||
for _, item := range raw {
|
||
if s, ok := item.(string); ok {
|
||
out = append(out, s)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func sortedServes(serves map[string]map[string]any) []string {
|
||
out := make([]string, 0, len(serves))
|
||
for k := range serves {
|
||
out = append(out, k)
|
||
}
|
||
sort.Strings(out)
|
||
return out
|
||
}
|
||
|
||
func sortedAnyKeys(values map[string]any) []string {
|
||
out := make([]string, 0, len(values))
|
||
for k := range values {
|
||
out = append(out, k)
|
||
}
|
||
sort.Strings(out)
|
||
return out
|
||
}
|
||
|
||
// consumerInto replaces every `${consumer:…}` in one value.
|
||
//
|
||
// **A fact or an alphabet the mesh does not have is refused, not left standing.** Written through,
|
||
// the literal `${consumer:as}` would reach a configuration file and be read as a bucket name,
|
||
// failing somewhere that names neither the module nor the mesh — the same reasoning `${bound:…}`
|
||
// is refused by (boundInto).
|
||
func consumerInto(value, as string) (string, error) {
|
||
var failed error
|
||
out := consumerFact.ReplaceAllStringFunc(value, func(match string) string {
|
||
parts := consumerFact.FindStringSubmatch(match)
|
||
fact, alphabet := parts[1], parts[2]
|
||
if fact != "as" {
|
||
if failed == nil {
|
||
failed = fmt.Errorf(
|
||
"says %s, and the mesh states %s about a consumer", match, orNothing(consumerFacts))
|
||
}
|
||
return match
|
||
}
|
||
switch alphabet {
|
||
case "":
|
||
return as
|
||
case "dns":
|
||
return asDNSLabel(as)
|
||
default:
|
||
if failed == nil {
|
||
failed = fmt.Errorf(
|
||
"says %s, and the mesh writes an identity as %s", match, orNothing(consumerAlphabets))
|
||
}
|
||
return match
|
||
}
|
||
})
|
||
if failed != nil {
|
||
return "", failed
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
var classRank = map[string]int{catalogue.ClassNone: 0, catalogue.ClassCache: 1, catalogue.ClassRebuildable: 2, catalogue.ClassValuable: 3, catalogue.ClassIrreplaceable: 4}
|
||
|
||
// keepsByClass is whether a class keeps something a binding must not move away from.
|
||
func keepsByClass(class string) bool {
|
||
return class == catalogue.ClassIrreplaceable || class == catalogue.ClassValuable || class == catalogue.ClassRebuildable
|
||
}
|
||
|
||
var volumeMount = regexp.MustCompile(`^(\$\{(?:dir|access):[a-z0-9][a-z0-9-]*\}[^:]*|[^:]+):([^:]+)(?::(.*))?$`)
|
||
|
||
var dirPlain = regexp.MustCompile(`^\$\{dir:([a-z0-9][a-z0-9-]*)\}$`)
|
||
|
||
// 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
|
||
out := dirRef.ReplaceAllStringFunc(s, func(ref string) string {
|
||
id := dirRef.FindStringSubmatch(ref)[1]
|
||
path, has := dirs[id]
|
||
if !has {
|
||
missing = fmt.Errorf(
|
||
"%s says ${dir:%s}, and %s declares no directory %q. It declares %s",
|
||
module, id, module, id, orNothing(namesOfDirs(dirs)))
|
||
return ref
|
||
}
|
||
return path
|
||
})
|
||
return out, missing
|
||
}
|
||
|
||
const placeOwn = "."
|
||
|
||
const placeMesh = "mesh"
|
||
|
||
// 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
|
||
// authorises the bus's user list: a module that does not hold the account's environment writing it
|
||
// would be a second writer of a file there is one of, and a module that does not hold the login
|
||
// shell writing every module's shell code would be a second shell.
|
||
func placeholderProblems(m catalogue.Manifest, r map[string]any) []string {
|
||
var problems []string
|
||
for _, field := range sortedKeys(r) {
|
||
s, ok := r[field].(string)
|
||
if !ok {
|
||
continue
|
||
}
|
||
env := ofEnvironment.FindAllStringSubmatch(s, -1)
|
||
code := ofShell.FindAllStringSubmatch(s, -1)
|
||
if len(env)+len(code) == 0 {
|
||
continue
|
||
}
|
||
if field != "content" {
|
||
// Placed only where a file's bytes are, which is where every one of them is meant to go:
|
||
// a path or an owner holding several lines of shell is nothing the host could act on.
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s's resource %v names %s in its %s; the environment and the shell's code are placed "+
|
||
"only in a file's content", m.Module, r["id"], placeholderOf(env, code), field))
|
||
continue
|
||
}
|
||
for _, e := range env {
|
||
if e[1] != catalogue.EnvironmentPOSIX && e[1] != catalogue.EnvironmentSystemd {
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s's resource %v names %s; the environment is ${environment:%s} or ${environment:%s}",
|
||
m.Module, r["id"], e[0], catalogue.EnvironmentPOSIX, catalogue.EnvironmentSystemd))
|
||
}
|
||
}
|
||
if len(env) > 0 && !m.ClaimsSeat(catalogue.EnvironmentSeat) {
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s's resource %v names %s and %s does not claim %s; the account's environment is "+
|
||
"written by that seat's holder alone (novox/hq ADR 0203)",
|
||
m.Module, r["id"], env[0][0], m.Module, catalogue.EnvironmentSeat))
|
||
}
|
||
// Each placeholder judged by its own target: a shell's code is the login shell's holder's to
|
||
// place (ADR 0204), the session's files the display server's (ADR 0208 §4) — and a holder of
|
||
// one placing the other's would be a second writer of a file there is one of.
|
||
refusedFor := map[string]bool{}
|
||
for _, c := range code {
|
||
target, slot, two := strings.Cut(c[1], ":")
|
||
if !two || !oneOf(contributionTargets(), target) || !oneOf(knownSlots, slot) {
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s's resource %v names %s; shell code is ${shell:<shell>:<slot>}, the shell one of "+
|
||
"%s, the session's file one of %s or the power moment one of %s, and the slot one of %s",
|
||
m.Module, r["id"], c[0], strings.Join(knownShells, ", "), strings.Join(sessionFiles, ", "),
|
||
strings.Join(powerMoments, ", "), strings.Join(knownSlots, ", ")))
|
||
continue
|
||
}
|
||
seat := placerOf(target)
|
||
if m.ClaimsSeat(seat) || refusedFor[seat] {
|
||
continue
|
||
}
|
||
refusedFor[seat] = true
|
||
if seat == catalogue.PowerSeat {
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s's resource %v names %s and %s does not claim %s; every module's code for a power "+
|
||
"moment is placed by the power seat's holder alone (novox/hq ADR 0211)",
|
||
m.Module, r["id"], c[0], m.Module, seat))
|
||
continue
|
||
}
|
||
if seat == catalogue.DisplayServerSeat {
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s's resource %v names %s and %s does not claim %s; every module's %s is placed by "+
|
||
"the display server's holder alone (novox/hq ADR 0208)",
|
||
m.Module, r["id"], c[0], m.Module, seat, target))
|
||
continue
|
||
}
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s's resource %v names %s and %s does not claim %s; every module's shell code is "+
|
||
"placed by the login shell's holder alone (novox/hq ADR 0204)",
|
||
m.Module, r["id"], c[0], m.Module, seat))
|
||
}
|
||
}
|
||
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).
|
||
func seatPlaceholderProblems(m catalogue.Manifest, r map[string]any) []string {
|
||
var problems []string
|
||
for _, field := range sortedKeys(r) {
|
||
v, ok := r[field].(string)
|
||
if !ok {
|
||
continue
|
||
}
|
||
found := ofContribution.FindAllStringSubmatch(v, -1)
|
||
if len(found) == 0 {
|
||
continue
|
||
}
|
||
if field != "content" {
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s's resource %v names %s in its %s; contributions are placed only in a file's content",
|
||
m.Module, r["id"], found[0][0], field))
|
||
continue
|
||
}
|
||
for _, f := range found {
|
||
seat, rest, two := strings.Cut(f[1], ":")
|
||
// A kind received as data may be narrowed by its shape's placing fields (novox/hq ADR 0255).
|
||
kind, where, _ := strings.Cut(rest, ":")
|
||
s, rcv, ok := receivable(seat, kind)
|
||
if two && ok {
|
||
if _, err := placeholderWhere(rcv, where); err != nil {
|
||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], err))
|
||
}
|
||
}
|
||
if !two || !ok {
|
||
detail := "the mesh defines no seat " + fmt.Sprintf("%q", seat)
|
||
if s.Name != "" {
|
||
detail = s.Name + ": " + kindsOf(s)
|
||
}
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s's resource %v names %s; a contribution is ${contribution:<seat>:<kind>} (%s)",
|
||
m.Module, r["id"], f[0], detail))
|
||
continue
|
||
}
|
||
if !m.ClaimsSeat(s.Name) {
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s's resource %v names %s and %s does not claim %s; every module's contributions to a "+
|
||
"seat are placed by its holder alone (novox/hq ADR 0212)",
|
||
m.Module, r["id"], f[0], m.Module, s.Name))
|
||
}
|
||
}
|
||
}
|
||
return problems
|
||
}
|
||
|
||
// collectedPlaceholderProblems is what is wrong with one resource's ${collected:…}: placed only in a file's content,
|
||
// and naming a collection the module declares it collects.
|
||
func collectedPlaceholderProblems(m catalogue.Manifest, r map[string]any) []string {
|
||
var problems []string
|
||
for _, field := range sortedKeys(r) {
|
||
v, ok := r[field].(string)
|
||
if !ok {
|
||
continue
|
||
}
|
||
for _, f := range ofCollected.FindAllStringSubmatch(v, -1) {
|
||
base, selector := splitSelector(f[1])
|
||
if _, err := selectorOf(selector); err != nil {
|
||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], err))
|
||
continue
|
||
}
|
||
switch {
|
||
case field != "content":
|
||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s in its %s; a collection is placed "+
|
||
"only in a file's content", m.Module, r["id"], f[0], field))
|
||
case isOld(base):
|
||
if _, _, wrong := oldCollection(base, false); wrong != nil {
|
||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s: %v", m.Module, r["id"], f[0], wrong))
|
||
}
|
||
case !slices.ContainsFunc(m.Collections, func(c catalogue.Collection) bool { return c.Name == base }):
|
||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s, and %s does not collect %q: "+
|
||
"declare it under collections (novox/hq ADR 0349 §13)", m.Module, r["id"], f[0], m.Module, base))
|
||
}
|
||
}
|
||
}
|
||
return problems
|
||
}
|
||
|
||
// consumersPlaceholderProblems is what is wrong with one resource's ${consumers:…}: placed only in a file's content,
|
||
// for a provision the module provides, in a file only the provider reads and the client writes back.
|
||
func consumersPlaceholderProblems(m catalogue.Manifest, r map[string]any) []string {
|
||
var problems []string
|
||
for _, field := range sortedKeys(r) {
|
||
v, ok := r[field].(string)
|
||
if !ok {
|
||
continue
|
||
}
|
||
for _, f := range ofConsumers.FindAllStringSubmatch(v, -1) {
|
||
switch {
|
||
case field != "content" || fmt.Sprint(r["type"]) != "file":
|
||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s in its %s; a provider's consumers "+
|
||
"are placed only in a file's content (novox/hq ADR 0373)", m.Module, r["id"], f[0], field))
|
||
continue
|
||
case !slices.ContainsFunc(m.Provides, func(o catalogue.Offer) bool { return o.Name == f[1] }):
|
||
problems = append(problems, fmt.Sprintf("%s's resource %v names %s, and %s does not provide %q: only a "+
|
||
"provider's template holds its consumers' credentials (novox/hq ADR 0373)", m.Module, r["id"], f[0],
|
||
m.Module, f[1]))
|
||
}
|
||
if mode := fmt.Sprint(r["mode"]); mode != "0600" && mode != "0400" {
|
||
problems = append(problems, fmt.Sprintf("%s's resource %v holds its consumers' credentials (%s) with "+
|
||
"mode %q: the provider alone reads it, 0600 or 0400 (novox/hq ADR 0373)", m.Module, r["id"], f[0],
|
||
r["mode"]))
|
||
}
|
||
if owner := ownershipOf(r); owner != catalogue.RuntimeOwned {
|
||
problems = append(problems, fmt.Sprintf("%s's resource %v holds its consumers' credentials (%s) and is "+
|
||
"%s at runtime: it is owned, written back whenever it differs (novox/hq ADR 0373)", m.Module,
|
||
r["id"], f[0], owner))
|
||
}
|
||
}
|
||
}
|
||
return problems
|
||
}
|
||
|
||
// literalProblem is why a value cannot be written, unquoted by either reader, as the same string in
|
||
// both formats — or nothing. A `$` would expand differently in each; a quote or a backslash is
|
||
// quoting in one and a character in the other; a line break ends the line in both.
|
||
func literalProblem(v string) string {
|
||
switch {
|
||
case strings.ContainsAny(v, `'"`):
|
||
return "holds a quote"
|
||
case strings.Contains(v, `\`):
|
||
return "holds a backslash"
|
||
case strings.ContainsAny(v, "\n\r"):
|
||
return "holds a line break"
|
||
case strings.ContainsRune(v, 0):
|
||
return "holds a NUL"
|
||
case strings.Contains(ofMachine.ReplaceAllString(v, ""), "$"):
|
||
return "holds a $ that is not one of the machine's ${machine:…} facts"
|
||
}
|
||
return ""
|
||
}
|
||
|
||
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)"
|
||
|
||
var ofMachine = regexp.MustCompile(`\$\{machine:([a-z0-9][a-z0-9_-]*)\}`)
|
||
|
||
// contributionTargets is every name a contribution's `for` may take.
|
||
func contributionTargets() []string {
|
||
out := append(append([]string(nil), knownShells...), sessionFiles...)
|
||
return append(out, powerMoments...)
|
||
}
|
||
|
||
var knownShells = []string{"zsh", "bash", "fish"}
|
||
|
||
var sessionFiles = []string{"xinitrc", "xresources"}
|
||
|
||
var powerMoments = []string{"after-boot", "before-sleep", "after-wake", "before-shutdown", "on-mains", "on-battery"}
|
||
|
||
var knownSlots = []string{"first", "normal", "last"}
|
||
|
||
var eventName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||
|
||
var stateName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||
|
||
// preparationTarget is the resource a module's preparation runs before: its own workload.
|
||
//
|
||
// The first container carrying an artifact this module built, and not itself a step — that is the
|
||
// thing that runs the module's code, and therefore the thing whose state must be ready. Empty when
|
||
// the module prepares nothing, or when nothing it declares could run its code.
|
||
//
|
||
// **A module with two own workloads gates the first of them.** Five modules in the catalogue declare
|
||
// more than one container of their own, none of them preparing anything today. If one ever does and
|
||
// its second workload shares the state, the gate is in front of the first — stated here because the
|
||
// alternative is a field asking an author to restate what the mesh can see.
|
||
func preparationTarget(m catalogue.Manifest) string {
|
||
if !m.Prepares {
|
||
return ""
|
||
}
|
||
for _, r := range m.Resources {
|
||
// A container, or a process the host runs from a bundle the module built (novox/hq issue
|
||
// 213): the same program in the same context, hosted as a unit rather than a container.
|
||
kind := fmt.Sprint(r["type"])
|
||
if (kind != "container" && kind != "process") || !ownArtifact(r, m.Module) {
|
||
continue
|
||
}
|
||
if once, _ := r["run-once"].(bool); once {
|
||
continue
|
||
}
|
||
if r["schedule"] != nil {
|
||
continue
|
||
}
|
||
return fmt.Sprint(r["id"])
|
||
}
|
||
return ""
|
||
}
|
||
|
||
var memberRest = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||
|
||
var accessRef = regexp.MustCompile(`\$\{access:([a-z0-9][a-z0-9-]*)\}`)
|
||
|
||
// optionalRequiresList is the stored form's list: the optional requirements, sorted.
|
||
func optionalRequiresList(m catalogue.Manifest) []string {
|
||
var out []string
|
||
for name, optional := range m.OptionalRequires {
|
||
if optional {
|
||
out = append(out, name)
|
||
}
|
||
}
|
||
sort.Strings(out)
|
||
return out
|
||
}
|
||
|
||
var bound = regexp.MustCompile(`\$\{bound:([a-z0-9][a-z0-9.-]*[a-z0-9]):([a-z0-9][a-z0-9_-]*)(?:\|default:([^}\n]+))?\}`)
|
||
|
||
const engineModule = "mesh-host"
|
||
|
||
// dirsFor is every placed directory of a module, id → the path it resolves to on this node.
|
||
//
|
||
// A pathless directory saying `"place": "."` is the assignment's own root, <root>/<module>; one
|
||
// saying `"place": "mesh"` is the mesh's directory for the module, <root>/mesh/<module>; one
|
||
// saying neither is <root>/<module>/<id>. At most one of each place makes sense; nothing enforces
|
||
// one, because two ids resolving to one path is a mistake the module's own files make visible
|
||
// immediately.
|
||
//
|
||
// A stated path may itself begin with a placed reference — `${dir:mesh-state}/state` — and is
|
||
// filled after the directories it can name are resolved; one level, because a directory beneath
|
||
// a placed one is the whole of what an adopted layout needs (issue 174's `state` and `out`).
|
||
func dirsFor(m catalogue.Manifest, with catalogue.Rendering) map[string]string {
|
||
dirs := map[string]string{}
|
||
var beneath []map[string]any
|
||
// The assignment's placement wins over both (novox/hq issue 153). Refused elsewhere when
|
||
// malformed; here an invalid setting simply places nothing.
|
||
placed, _ := catalogue.Places(m, with.Settings[m.Module])
|
||
for _, r := range m.Resources {
|
||
if fmt.Sprint(r["type"]) != "directory" {
|
||
continue
|
||
}
|
||
id := fmt.Sprint(r["id"])
|
||
if p, said := placed[id]; said {
|
||
dirs[id] = p.Path
|
||
continue
|
||
}
|
||
if path, stated := r["path"].(string); stated && path != "" {
|
||
if strings.HasPrefix(path, "${dir:") {
|
||
beneath = append(beneath, r)
|
||
continue
|
||
}
|
||
dirs[id] = strings.TrimRight(path, "/")
|
||
continue
|
||
}
|
||
switch place, _ := r["place"].(string); place {
|
||
case placeOwn:
|
||
dirs[id] = dataRoot(with) + "/" + m.Module
|
||
case placeMesh:
|
||
dirs[id] = dataRoot(with) + "/mesh/" + m.Module
|
||
default:
|
||
dirs[id] = dataRoot(with) + "/" + m.Module + "/" + id
|
||
}
|
||
}
|
||
for _, r := range beneath {
|
||
path := strings.TrimRight(r["path"].(string), "/")
|
||
// A reference to no directory is left as written and refused where the resource is
|
||
// placed (dirInto), with the message that names what exists.
|
||
filled, _ := dirFill(path, dirs, m.Module)
|
||
dirs[fmt.Sprint(r["id"])] = filled
|
||
}
|
||
return dirs
|
||
}
|
||
|
||
// accessFill resolves every ${access:…} in one string, or refuses a reference naming no access.
|
||
func accessFill(s string, accesses map[string]string, module string) (string, error) {
|
||
var missing error
|
||
out := accessRef.ReplaceAllStringFunc(s, func(ref string) string {
|
||
id := accessRef.FindStringSubmatch(ref)[1]
|
||
path, has := accesses[id]
|
||
if !has {
|
||
missing = fmt.Errorf(
|
||
"%s says ${access:%s}, and %s declares no access %q. It declares %s",
|
||
module, id, module, id, orNothing(namesOfAccessIDs(accesses)))
|
||
return ref
|
||
}
|
||
return path
|
||
})
|
||
return out, missing
|
||
}
|
||
|
||
const defaultDataRoot = "/var/lib"
|
||
|
||
// processBusOf is the bus a process resource declares, and whether it declared one at all.
|
||
func processBusOf(r map[string]any) (bus catalogue.ProcessBus, present bool, err error) {
|
||
raw, present := r["bus"]
|
||
if !present {
|
||
return catalogue.ProcessBus{}, false, nil
|
||
}
|
||
body, err := json.Marshal(raw)
|
||
if err != nil {
|
||
return catalogue.ProcessBus{}, true, err
|
||
}
|
||
if err := json.Unmarshal(body, &bus); err != nil {
|
||
return catalogue.ProcessBus{}, true, err
|
||
}
|
||
return bus, true, nil
|
||
}
|
||
|
||
// kindsOf names a seat's receivable kinds for a refusal.
|
||
func kindsOf(s catalogue.Seat) string {
|
||
if len(s.Receives) == 0 {
|
||
return "it receives no contributions"
|
||
}
|
||
var kinds []string
|
||
for _, r := range s.Receives {
|
||
kinds = append(kinds, r.Kind)
|
||
}
|
||
return "it receives " + strings.Join(kinds, ", ")
|
||
}
|
||
|
||
var stateKeyName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||
|
||
var ofContribution = regexp.MustCompile(`\$\{contribution:([^}]*)\}`)
|
||
|
||
// wholeIn is v as a whole number within [lo, hi]; JSON gives every number as a float.
|
||
func wholeIn(v any, lo, hi int) (int, bool) {
|
||
f, ok := v.(float64)
|
||
if !ok {
|
||
if i, isInt := v.(int); isInt {
|
||
f, ok = float64(i), true
|
||
}
|
||
}
|
||
if !ok || f != math.Trunc(f) || f < float64(lo) || f > float64(hi) {
|
||
return 0, false
|
||
}
|
||
return int(f), true
|
||
}
|
||
|
||
// holderTemplate is the holder's template for a kind, parsed.
|
||
func holderTemplate(holder catalogue.Manifest, seat catalogue.Seat, kind string) (*template.Template, error) {
|
||
for _, c := range holder.Claims {
|
||
if s, known := catalogue.SeatNamed(c.Name); !known || s.Name != seat.Name {
|
||
continue
|
||
}
|
||
text, has := c.Renders[kind]
|
||
if !has || strings.TrimSpace(text) == "" {
|
||
break
|
||
}
|
||
t, err := template.New(seat.Name + ":" + kind).Funcs(renderFuncs).Parse(text)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("%s's template for %s:%s does not parse: %v", holder.Module, seat.Name, kind, err)
|
||
}
|
||
return t, nil
|
||
}
|
||
return nil, fmt.Errorf("%s places %s:%s, which is data, and its claim gives no template for it in `renders` "+
|
||
"(novox/hq ADR 0255)", holder.Module, seat.Name, kind)
|
||
}
|
||
|
||
// renderPiece is one piece of data in the holder's grammar. The template sees the piece's fields,
|
||
// `module`, the contributor, and `machine`, the node it is composed for. A value the piece does not have reads as nothing in a `with` or an `if`;
|
||
// printed bare it would write `<no value>` into the tool's file, which is refused here instead.
|
||
func renderPiece(t *template.Template, module, machine string, data map[string]any) (string, error) {
|
||
view := map[string]any{"module": module, "machine": machine}
|
||
for k, v := range data {
|
||
view[k] = v
|
||
}
|
||
var b bytes.Buffer
|
||
if err := t.Execute(&b, view); err != nil {
|
||
return "", fmt.Errorf("rendering %s's piece: %v", module, err)
|
||
}
|
||
out := b.String()
|
||
if strings.Contains(out, "<no value>") {
|
||
return "", fmt.Errorf("rendering %s's piece printed a value it does not have: %q", module, out)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// placeholderWhere parses the narrowing part of a placeholder, `bar=bottom,place=status`, against the
|
||
// kind's shape. Empty narrows nothing.
|
||
func placeholderWhere(r catalogue.Receivable, where string) (map[string]string, error) {
|
||
out := map[string]string{}
|
||
if where == "" {
|
||
return out, nil
|
||
}
|
||
if r.Shape == nil {
|
||
return nil, fmt.Errorf("%s is text, which a placeholder does not narrow", r.Kind)
|
||
}
|
||
for _, pair := range strings.Split(where, ",") {
|
||
field, value, ok := strings.Cut(pair, "=")
|
||
var p *catalogue.PlaceField
|
||
for i := range r.Shape.Places {
|
||
if r.Shape.Places[i].Field == field {
|
||
p = &r.Shape.Places[i]
|
||
}
|
||
}
|
||
switch {
|
||
case !ok || p == nil:
|
||
return nil, fmt.Errorf("%q narrows by %s, and %s is placed by %s", where, field, r.Kind, placeFields(r))
|
||
case !oneOf(p.Values, value):
|
||
return nil, fmt.Errorf("%q says %s=%s; %s is one of %s", where, field, value, field, strings.Join(p.Values, ", "))
|
||
case out[field] != "":
|
||
return nil, fmt.Errorf("%q names %s twice", where, field)
|
||
}
|
||
out[field] = value
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// pathSeats is which seats name each path: the mesh's own, and then those modules declare, in the order
|
||
// given. A module's seat naming a path a seat already names loses it, and the refusal says so (novox/hq
|
||
// ADR 0287 — as for a seat's name, the second declaration loses).
|
||
func pathSeats(order []string, shelf catalogue.Shelf, declaredBy map[string]string) (map[string][]string, []string) {
|
||
named := map[string][]string{}
|
||
for _, s := range catalogue.Seats() {
|
||
for _, p := range s.Paths {
|
||
named[p.Path] = append(named[p.Path], s.Name)
|
||
}
|
||
}
|
||
var problems []string
|
||
for _, module := range order {
|
||
for _, s := range shelf[module].DefinesSeats {
|
||
if declaredBy != nil && declaredBy[s.Name] != module {
|
||
continue // a second declaration of the seat's name, refused for that already
|
||
}
|
||
for _, p := range s.Paths {
|
||
if others := named[p]; len(others) > 0 {
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s declares the seat %s naming the path %q, which %s already names; no two seats "+
|
||
"name one path (novox/hq ADR 0287)", module, s.Name, p, strings.Join(others, " and ")))
|
||
continue
|
||
}
|
||
named[p] = append(named[p], s.Name)
|
||
}
|
||
}
|
||
}
|
||
return named, problems
|
||
}
|
||
|
||
// missingPaths is each path a seat names that a module does not declare, in the seat's order.
|
||
func missingPaths(m catalogue.Manifest, paths []catalogue.SeatPath) []string {
|
||
if len(paths) == 0 {
|
||
return nil
|
||
}
|
||
has := declaredPaths(m)
|
||
var missing []string
|
||
for _, p := range paths {
|
||
if !has[p.Path] {
|
||
missing = append(missing, p.Path)
|
||
}
|
||
}
|
||
return missing
|
||
}
|
||
|
||
// unclaimedSeatPaths is each path a module declares that a seat names while it claims none of the seats
|
||
// naming it (novox/hq ADR 0287): only a claimant declares a seat's path, and a module claiming any one of
|
||
// them may.
|
||
func unclaimedSeatPaths(m catalogue.Manifest, named map[string][]string) []string {
|
||
claims := map[string]bool{}
|
||
for _, c := range m.Claims {
|
||
claims[canonicalSeat(c.Name)] = true
|
||
}
|
||
var problems []string
|
||
declared := declaredPaths(m)
|
||
for _, p := range sortedKeys(declared) {
|
||
seats := named[p]
|
||
if len(seats) == 0 {
|
||
continue
|
||
}
|
||
claimsOne := false
|
||
for _, s := range seats {
|
||
claimsOne = claimsOne || claims[s]
|
||
}
|
||
if !claimsOne {
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s declares the path %q, which the seat %s names, and claims none of them; only a holder of "+
|
||
"a seat naming a path declares it (novox/hq ADR 0287)", m.Module, p, strings.Join(seats, " and ")))
|
||
}
|
||
}
|
||
return problems
|
||
}
|
||
|
||
// requirementsContributedTo is every requirement m contributes to, sorted.
|
||
func requirementsContributedTo(m catalogue.Manifest) []string {
|
||
seen := map[string]bool{}
|
||
for to := range m.Contributes {
|
||
seen[to] = true
|
||
}
|
||
for to := range m.ContributesMany {
|
||
seen[to] = true
|
||
}
|
||
out := make([]string, 0, len(seen))
|
||
for to := range seen {
|
||
out = append(out, to)
|
||
}
|
||
sort.Strings(out)
|
||
return out
|
||
}
|
||
|
||
func orNothing(names []string) string {
|
||
if len(names) == 0 {
|
||
return "nothing"
|
||
}
|
||
return join(names)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// settingsUsed is every key a file's content asks for, once each, in order of first use.
|
||
func settingsUsed(content string) []string {
|
||
var keys []string
|
||
seen := map[string]bool{}
|
||
for _, m := range settingRef.FindAllStringSubmatch(content, -1) {
|
||
if !seen[m[1]] {
|
||
seen[m[1]] = true
|
||
keys = append(keys, m[1])
|
||
}
|
||
}
|
||
return keys
|
||
}
|
||
|
||
// settingKeysAskedOutsideOwners is every key a module's files, units, items, contributions and served facts
|
||
// ask for: what a directory's owner may follow (OwnerSettingProblems).
|
||
func settingKeysAskedOutsideOwners(m catalogue.Manifest) map[string]bool {
|
||
used := map[string]bool{}
|
||
note := func(s string) {
|
||
for _, k := range settingsUsed(s) {
|
||
used[k] = true
|
||
}
|
||
}
|
||
for _, r := range m.Resources {
|
||
switch fmt.Sprint(r["type"]) {
|
||
case "file":
|
||
if content, ok := r["content"].(string); ok {
|
||
note(content)
|
||
}
|
||
case "service":
|
||
if unit, ok := r["unit"].(string); ok {
|
||
note(unit)
|
||
}
|
||
case "container":
|
||
for _, g := range groupsOf(r) {
|
||
if s, ok := g.(string); ok {
|
||
note(s)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// An item's text, filled from its template (WithTemplatesFrom), places a key as a file's does (issue 1658).
|
||
for _, it := range m.Items {
|
||
note(it.Content)
|
||
}
|
||
inValues := func(values map[string]any) {
|
||
for _, v := range values {
|
||
if s, ok := v.(string); ok {
|
||
note(s)
|
||
}
|
||
}
|
||
}
|
||
for _, values := range m.Contributes {
|
||
inValues(values)
|
||
}
|
||
for _, locals := range m.ContributesMany {
|
||
for _, values := range locals {
|
||
inValues(values)
|
||
}
|
||
}
|
||
for _, values := range m.Serves {
|
||
inValues(values)
|
||
}
|
||
return used
|
||
}
|
||
|
||
// 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 catalogue.Manifest) []string {
|
||
var problems []string
|
||
var naming, silent []string
|
||
for _, r := range m.Resources {
|
||
id := fmt.Sprint(r["id"])
|
||
how, _ := r["merge"].(string)
|
||
raw, said := r[catalogue.TakesSettingsField]
|
||
if how == "" {
|
||
if said {
|
||
problems = append(problems, fmt.Sprintf("%s's %s says %s and does not merge: only a mergeable "+
|
||
"file takes settings by key (novox/hq ADR 0326)", m.Module, id, catalogue.TakesSettingsField))
|
||
}
|
||
continue
|
||
}
|
||
if !said {
|
||
silent = append(silent, id)
|
||
continue
|
||
}
|
||
naming = append(naming, id)
|
||
list, isList := raw.([]any)
|
||
if !isList {
|
||
problems = append(problems, fmt.Sprintf("%s's %s: %s is a list of setting keys, and this is %T",
|
||
m.Module, id, catalogue.TakesSettingsField, raw))
|
||
continue
|
||
}
|
||
protected := map[string]bool{}
|
||
for _, k := range stringsOf(r["protected"]) {
|
||
protected[k] = true
|
||
}
|
||
seen, landed := map[string]bool{}, map[string]bool{}
|
||
for _, item := range list {
|
||
if _, isObject := item.(map[string]any); isObject {
|
||
e, shaped := asTakenEntry(item)
|
||
switch {
|
||
case !m.DeclaresConfig():
|
||
problems = append(problems, fmt.Sprintf("%s's %s: %v in %s lays a value under another name, "+
|
||
"which only the config form says: write the key alone (novox/hq issue 751)",
|
||
m.Module, id, item, catalogue.TakesSettingsField))
|
||
continue
|
||
case !shaped:
|
||
problems = append(problems, fmt.Sprintf("%s's %s: %v in %s is a key alone, or an object "+
|
||
"saying exactly %q and %q: the config key and the file's name for it (novox/hq issue 751)",
|
||
m.Module, id, item, catalogue.TakesSettingsField, takenKeyField, takenAsField))
|
||
continue
|
||
case !settingRef.MatchString("${setting:" + e.key + "}"):
|
||
problems = append(problems, fmt.Sprintf("%s's %s: %q in %s is not a usable key: a config "+
|
||
"key is lower-case letters, digits, dots, dashes and underscores; the file's own spelling "+
|
||
"is its %q", m.Module, id, e.key, catalogue.TakesSettingsField, takenAsField))
|
||
continue
|
||
case !usableKey(e.as):
|
||
problems = append(problems, fmt.Sprintf("%s's %s: %q, the name %s takes %q as, is not a "+
|
||
"usable name in a file", m.Module, id, e.as, catalogue.TakesSettingsField, e.key))
|
||
continue
|
||
}
|
||
problems = append(problems, takenEntryProblems(m.Module, id, e, seen, landed, protected)...)
|
||
continue
|
||
}
|
||
key, isString := item.(string)
|
||
if !isString || !usableKey(key) {
|
||
problems = append(problems, fmt.Sprintf("%s's %s: %v in %s is not a usable setting key",
|
||
m.Module, id, item, catalogue.TakesSettingsField))
|
||
continue
|
||
}
|
||
problems = append(problems, takenEntryProblems(m.Module, id, takenEntry{key: key, as: key}, seen,
|
||
landed, protected)...)
|
||
}
|
||
}
|
||
if len(naming) > 0 {
|
||
for _, id := range silent {
|
||
problems = append(problems, fmt.Sprintf("%s's %s merges and names no %s while %s does: once one "+
|
||
"mergeable file of a module names the keys it takes, every one does, an empty list for none "+
|
||
"(novox/hq ADR 0326)", m.Module, id, catalogue.TakesSettingsField, strings.Join(naming, ", ")))
|
||
}
|
||
}
|
||
return problems
|
||
}
|
||
|
||
// patternsOverlap says some event both patterns name.
|
||
func patternsOverlap(a, b string) bool {
|
||
x, y := strings.Split(a, "."), strings.Split(b, ".")
|
||
for i := 0; ; i++ {
|
||
switch {
|
||
case i == len(x) && i == len(y):
|
||
return true
|
||
case i == len(x) || i == len(y):
|
||
return false
|
||
case x[i] == catalogue.TheRest || y[i] == catalogue.TheRest:
|
||
return true
|
||
case x[i] == catalogue.OneName || y[i] == catalogue.OneName || x[i] == y[i]:
|
||
continue
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
}
|
||
|
||
// templateOf is the template a resource names, whether it names one, and what is wrong with it ("" when nothing):
|
||
// dir is the module's templates directory.
|
||
func templateOf(module, dir string, r map[string]any) (string, bool, string) {
|
||
raw, has := r[catalogue.TemplateField]
|
||
if !has {
|
||
return "", false, ""
|
||
}
|
||
id := fmt.Sprint(r["id"])
|
||
if t := fmt.Sprint(r["type"]); t != "file" {
|
||
return "", true, fmt.Sprintf("%s: %s is a %s and names a template; only a file takes its text from one "+
|
||
"(novox/hq ADR 0349 §2)", module, id, t)
|
||
}
|
||
name, ok := raw.(string)
|
||
if !ok || strings.TrimSpace(name) == "" {
|
||
return "", true, fmt.Sprintf("%s: %s names a template that is not a path: %v; a template is %q",
|
||
module, id, raw, dir+"/<name>")
|
||
}
|
||
if _, both := r["content"]; both {
|
||
return name, true, fmt.Sprintf("%s: %s names the template %q and says its content too; a file's text is "+
|
||
"in one place, the template or the content, never both (novox/hq ADR 0349 §2)", module, id, name)
|
||
}
|
||
if strings.HasPrefix(name, "/") || path.Clean(name) != name || !strings.HasPrefix(name, dir+"/") ||
|
||
strings.Contains(name, "\\") {
|
||
return name, true, fmt.Sprintf("%s: %s names the template %q, which is not a file under %s/ of its own "+
|
||
"repository: a template is %q, a plain relative path, and the build packs only %s/",
|
||
module, id, name, dir, dir+"/<name>", dir)
|
||
}
|
||
return name, true, ""
|
||
}
|
||
|
||
func templatesArtifacts(m catalogue.Manifest) []string {
|
||
if m.Build == nil {
|
||
return nil
|
||
}
|
||
var found []string
|
||
for _, a := range m.Build.Artifacts {
|
||
if a.Kind == catalogue.ArtifactArchive && a.URL == "" && cleanFrom(a.From) == m.TemplatesDir() {
|
||
found = append(found, a.Name)
|
||
}
|
||
}
|
||
return found
|
||
}
|
||
|
||
var oldCollectionNamespaces = []string{"shell", "environment", "contribution"}
|
||
|
||
// collectableAsText is whether a collector may name a kind received as data as a collection.
|
||
func collectableAsText(r catalogue.Receivable) bool { return r.Shape != nil && r.Shape.Text }
|
||
|
||
// canonicalSeat is the seat a claimed name refers to, by its current name: itself for a name the mesh
|
||
// does not know (a module's own seat), its seat's name for a former one.
|
||
func canonicalSeat(name string) string {
|
||
if s, known := catalogue.SeatNamed(name); known {
|
||
return s.Name
|
||
}
|
||
return name
|
||
}
|
||
|
||
var fillers = []filler{
|
||
{namespace: "setting", patterns: []*regexp.Regexp{settingRef}, fields: map[string][]string{"file": {"content"}, "service": {"unit"}, "directory": {"owner"}, "container": {"groups"}}},
|
||
{namespace: "dir", patterns: []*regexp.Regexp{dirRef}, fields: map[string][]string{"*": {"path", "content", "volumes", "env", "env-file"}}},
|
||
{namespace: "access", patterns: []*regexp.Regexp{accessRef}, fields: map[string][]string{"*": {"path", "content", "volumes", "env", "env-file"}}},
|
||
{namespace: "secret", patterns: []*regexp.Regexp{placeholder}, fields: map[string][]string{"file": {"content"}},
|
||
why: "a secret is never filled into an environment variable or any other field: put it in a file the " +
|
||
"module declares and mount that (novox/hq ADR 0086)"},
|
||
{namespace: "bound", patterns: []*regexp.Regexp{bound}, fields: map[string][]string{"file": {"content"}}},
|
||
{namespace: "port", patterns: []*regexp.Regexp{ofPort}, fields: map[string][]string{"file": {"content"}, "container": {"env"}, "process": {"env"}}},
|
||
{namespace: "seat", patterns: []*regexp.Regexp{ofSeat, ofSeatReach}, fields: map[string][]string{"file": {"content"}, "container": {"env"}, "process": {"env"}}},
|
||
{namespace: "machine", patterns: []*regexp.Regexp{ofMachine}, fields: map[string][]string{"*": {"path", "owner", "content", "name", "user", "root", "home"}}},
|
||
// Placed in a file's content by their holders, and judged there by their own rules
|
||
// (placeholderProblems, seatPlaceholderProblems).
|
||
{namespace: "environment", patterns: []*regexp.Regexp{ofEnvironment}, fields: map[string][]string{"*": {"content"}}},
|
||
{namespace: "shell", patterns: []*regexp.Regexp{ofShell}, fields: map[string][]string{"*": {"content"}}},
|
||
{namespace: "contribution", patterns: []*regexp.Regexp{ofContribution}, fields: map[string][]string{"*": {"content"}}},
|
||
// What the machine's modules back up, placed by the backup holder's file (backup_placement.go, issue 794).
|
||
{namespace: catalogue.BackupNamespace, patterns: []*regexp.Regexp{ofBackup}, fields: map[string][]string{"*": {"content"}}},
|
||
// A collection, placed by its collector (collections.go): filled by the provisioner's client.
|
||
{namespace: catalogue.CollectedNamespace, patterns: []*regexp.Regexp{ofCollected}, fields: map[string][]string{"*": {"content"}}},
|
||
// A provider's consumers' credentials (consumers.go): filled by the provisioner's client.
|
||
{namespace: catalogue.ConsumersNamespace, patterns: []*regexp.Regexp{ofConsumers}, fields: map[string][]string{"file": {"content"}}},
|
||
// Filled in what a provider serves (consumer_into_serves.go), never in a resource.
|
||
{namespace: "consumer", patterns: []*regexp.Regexp{consumerFact}},
|
||
}
|
||
|
||
// reads is whether this pass fills a field of a resource of this type.
|
||
func fillerReads(f filler, kind, field string) bool {
|
||
return oneOf(f.fields[kind], field) || oneOf(f.fields["*"], field)
|
||
}
|
||
|
||
// whereFilled says where a namespace's pass does fill, for a refusal of one written elsewhere.
|
||
func whereFilled(f filler) string {
|
||
if f.why != "" {
|
||
return ". " + strings.ToUpper(f.why[:1]) + f.why[1:]
|
||
}
|
||
if len(f.fields) == 0 {
|
||
return "; it is filled only in what a provider serves"
|
||
}
|
||
var where []string
|
||
for _, kind := range sortedKeys(f.fields) {
|
||
of := "a " + kind + "'s"
|
||
if kind == "*" {
|
||
of = "any resource's"
|
||
}
|
||
where = append(where, of+" "+strings.Join(f.fields[kind], ", "))
|
||
}
|
||
return "; it is filled in " + strings.Join(where, "; ")
|
||
}
|
||
|
||
var ofKnownNamespace = regexp.MustCompile(`(?im)\$\{(?:` + strings.Join(namespacesOf(fillers), "|") +
|
||
`):(?:[^-=+?}\n][^}\n]*\}?|\}|$)`)
|
||
|
||
var namespaceShaped = regexp.MustCompile(`\$\{[a-z][a-z0-9_-]*:[A-Za-z0-9][^\s{}$]*\}`)
|
||
|
||
func namespacesOf(fs []filler) []string {
|
||
out := make([]string, len(fs))
|
||
for i, f := range fs {
|
||
out[i] = f.namespace
|
||
}
|
||
return out
|
||
}
|
||
|
||
// takenEntries is the entries of a takes-settings list in their order, a usable entry only: a string is a key
|
||
// under its own name, an object with exactly key and as a key under another (TakenSettingsProblems says the rest).
|
||
func takenEntries(raw any) []takenEntry {
|
||
list, _ := raw.([]any)
|
||
var out []takenEntry
|
||
for _, item := range list {
|
||
if e, ok := asTakenEntry(item); ok {
|
||
out = append(out, e)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ofType is whether a value read from JSON is of a config type.
|
||
func ofType(v any, kind string) bool {
|
||
switch v.(type) {
|
||
case float64:
|
||
return kind == catalogue.ConfigNumber
|
||
case string:
|
||
return kind == catalogue.ConfigString
|
||
case bool:
|
||
return kind == catalogue.ConfigBoolean
|
||
case map[string]any:
|
||
return kind == catalogue.ConfigObject
|
||
case []any:
|
||
return kind == catalogue.ConfigList
|
||
}
|
||
return false
|
||
}
|
||
|
||
var consumerFact = regexp.MustCompile(`\$\{consumer:([a-z][a-z0-9-]*)(?::([a-z][a-z0-9-]*))?\}`)
|
||
|
||
var consumerFacts = []string{"as"}
|
||
|
||
// asDNSLabel writes a minted identity as a DNS label.
|
||
//
|
||
// The mesh's identities are already lower-case letters, digits and `_` (ConsumerIdentity), and
|
||
// already inside the bound of the provision serving them: a provider that serves one as a DNS label
|
||
// bounds it at 63 or less (CheckServes, novox/hq ADR 0225), and one that serves it at all without
|
||
// saying is held to twenty (IdentityBoundOf). So
|
||
// this is the separator and nothing else — no lower-casing of what is already lower case, no
|
||
// truncation to a limit the identity is already inside, no padding of a name that is already long
|
||
// enough. Each of those would be the mesh guessing at a rule it has not been given.
|
||
func asDNSLabel(as string) string {
|
||
return strings.ReplaceAll(as, "_", "-")
|
||
}
|
||
|
||
var consumerAlphabets = []string{"dns"}
|
||
|
||
func namesOfDirs(dirs map[string]string) []string {
|
||
var names []string
|
||
for id := range dirs {
|
||
names = append(names, fmt.Sprintf("%q", id))
|
||
}
|
||
sort.Strings(names)
|
||
return names
|
||
}
|
||
|
||
var ofEnvironment = regexp.MustCompile(`\$\{environment:([^}]*)\}`)
|
||
|
||
var ofShell = regexp.MustCompile(`\$\{shell:([^}]*)\}`)
|
||
|
||
func placeholderOf(env, code [][]string) string {
|
||
if len(env) > 0 {
|
||
return env[0][0]
|
||
}
|
||
return code[0][0]
|
||
}
|
||
|
||
// placerOf is the seat whose holder places a contribution for this target (novox/hq ADR 0204,
|
||
// ADR 0208 §4).
|
||
func placerOf(target string) string {
|
||
if oneOf(sessionFiles, target) {
|
||
return catalogue.DisplayServerSeat
|
||
}
|
||
if oneOf(powerMoments, target) {
|
||
return catalogue.PowerSeat
|
||
}
|
||
return catalogue.LoginShellSeat
|
||
}
|
||
|
||
// selectorOf is a selector's pairs, or why it is none.
|
||
func selectorOf(selector string) (map[string]string, error) {
|
||
out := map[string]string{}
|
||
if selector == "" {
|
||
return out, nil
|
||
}
|
||
for _, pair := range strings.Split(selector, ",") {
|
||
k, v, ok := strings.Cut(pair, "=")
|
||
switch {
|
||
case !ok || !attributeKey.MatchString(k) || !attributeValue.MatchString(v):
|
||
return nil, fmt.Errorf("%q in %q is not <attribute>=<value>", pair, selector)
|
||
case out[k] != "":
|
||
return nil, fmt.Errorf("%q names %s twice", selector, k)
|
||
}
|
||
out[k] = v
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// isOld is whether a collection's name is an old namespace's.
|
||
func isOld(name string) bool {
|
||
_, old, _ := oldCollection(name, true)
|
||
return old
|
||
}
|
||
|
||
var ofConsumers = regexp.MustCompile(`\$\{consumers:([^}]*)\}`)
|
||
|
||
// ownershipOf is who owns a file at runtime, by its form, as the node-engine maps the old forms (mesh-host
|
||
// declaration.File.Ownership): written into is managed, created once is a seed, any other is owned.
|
||
func ownershipOf(r map[string]any) string {
|
||
switch {
|
||
case r["into"] != nil && fmt.Sprint(r["into"]) != "":
|
||
return catalogue.RuntimeManaged
|
||
case r["create-once"] == true:
|
||
return catalogue.RuntimeSeed
|
||
}
|
||
return catalogue.RuntimeOwned
|
||
}
|
||
|
||
// ownArtifact is whether a resource runs something this module built, in either spelling a manifest
|
||
// may be in: naming the artifact, before a build resolved it, or carrying the reference a build
|
||
// recorded — this mesh's own store, under this module's name.
|
||
func ownArtifact(resource map[string]any, module string) bool {
|
||
if named, _ := resource["artifact"].(string); named != "" {
|
||
return true
|
||
}
|
||
image, _ := resource["image"].(string)
|
||
// A process or an archive carries what was built as its source (novox/hq issue 213).
|
||
source, _ := resource["source"].(string)
|
||
return strings.HasPrefix(image, catalogue.ArtifactStoreScheme+module+"/") ||
|
||
strings.HasPrefix(source, catalogue.ArtifactStoreScheme+module+"/")
|
||
}
|
||
|
||
// dataRoot is the root this node keeps placed directories under.
|
||
func dataRoot(with catalogue.Rendering) string {
|
||
if root := strings.TrimRight(strings.TrimSpace(with.DataRoot), "/"); root != "" {
|
||
return root
|
||
}
|
||
return defaultDataRoot
|
||
}
|
||
|
||
func namesOfAccessIDs(accesses map[string]string) []string {
|
||
var names []string
|
||
for id := range accesses {
|
||
names = append(names, fmt.Sprintf("%q", id))
|
||
}
|
||
sort.Strings(names)
|
||
return names
|
||
}
|
||
|
||
var renderFuncs = template.FuncMap{"quote": quote}
|
||
|
||
// 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.
|
||
//
|
||
// **Cleaned, because the machine writes the cleaned path**: /etc//hosts, /etc/./hosts, /etc/../etc/hosts and
|
||
// /etc/hosts/ are all /etc/hosts to it, and a seat's path is clean by its own rule, so a declaration compared
|
||
// as written would pass for a path nobody named.
|
||
func declaredPaths(m catalogue.Manifest) map[string]bool {
|
||
out := map[string]bool{}
|
||
if m.OwnsPaths != nil {
|
||
// Carried: what its manifest declares, never what runs of it (issue 1126, seed-8: f1-circuits, snake and txt-game
|
||
// on ace owned their /var/lib paths on the node and none on the controller).
|
||
for _, p := range m.OwnsPaths {
|
||
out[p] = true
|
||
}
|
||
return out
|
||
}
|
||
for _, r := range m.Resources {
|
||
if p, ok := r["path"].(string); ok && strings.HasPrefix(p, "/") {
|
||
out[path.Clean(p)] = true
|
||
}
|
||
}
|
||
for _, f := range m.Facts {
|
||
if !f.Home && strings.HasPrefix(f.Path, "/") {
|
||
out[path.Clean(f.Path)] = true
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func join(names []string) string {
|
||
switch len(names) {
|
||
case 1:
|
||
return names[0]
|
||
case 2:
|
||
return names[0] + " or " + names[1]
|
||
}
|
||
out := ""
|
||
for i, n := range names[:len(names)-1] {
|
||
if i > 0 {
|
||
out += ", "
|
||
}
|
||
out += n
|
||
}
|
||
return out + " or " + names[len(names)-1]
|
||
}
|
||
|
||
// asTakenEntry reads one item of a takes-settings list, and whether it is shaped as an entry at all.
|
||
func asTakenEntry(item any) (takenEntry, bool) {
|
||
switch v := item.(type) {
|
||
case string:
|
||
return takenEntry{key: v, as: v}, true
|
||
case map[string]any:
|
||
key, keyOK := v[takenKeyField].(string)
|
||
as, asOK := v[takenAsField].(string)
|
||
if !keyOK || !asOK || len(v) != 2 {
|
||
return takenEntry{}, false
|
||
}
|
||
return takenEntry{key: key, as: as, renamed: true}, true
|
||
}
|
||
return takenEntry{}, false
|
||
}
|
||
|
||
const takenKeyField = "key"
|
||
|
||
const takenAsField = "as"
|
||
|
||
// usableKey is a key a file can be said to take: not empty, and no space, line end or control
|
||
// character. Not the lower-case rule of a `${setting:<key>}`: a file's own keys are its software's
|
||
// spelling, such as `timeZone`.
|
||
func usableKey(key string) bool {
|
||
if key == "" {
|
||
return false
|
||
}
|
||
for _, r := range key {
|
||
if unicode.IsSpace(r) || unicode.IsControl(r) || !unicode.IsPrint(r) {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// takenEntryProblems is what is wrong with one usable entry of a file's takes-settings, against those before it:
|
||
// a key taken twice, or two values laid under one name in the file; a word the mesh reads itself, as the key or as
|
||
// the file's name; a name the file keeps as protected. seen and landed are the keys and the names so far, added to.
|
||
func takenEntryProblems(module, id string, e takenEntry, seen, landed, protected map[string]bool) []string {
|
||
var problems []string
|
||
switch {
|
||
case seen[e.key]:
|
||
problems = append(problems, fmt.Sprintf("%s's %s names %q twice in %s", module, id, e.key, catalogue.TakesSettingsField))
|
||
case landed[e.as]:
|
||
problems = append(problems, fmt.Sprintf("%s's %s names %q twice in %s, as a key or as the name a key is "+
|
||
"laid under: one name in a file holds one value", module, id, e.as, catalogue.TakesSettingsField))
|
||
case meshWords[e.key] || meshWords[e.as]:
|
||
word := e.key
|
||
if !meshWords[word] {
|
||
word = e.as
|
||
}
|
||
problems = append(problems, fmt.Sprintf("%s's %s names %q in %s, and the mesh reads %q itself "+
|
||
"(novox/hq ADR 0164 §14)", module, id, word, catalogue.TakesSettingsField, word))
|
||
case protected[e.as]:
|
||
problems = append(problems, fmt.Sprintf("%s's %s names %q in %s and keeps it as protected: a "+
|
||
"key is the file's or the setting's, never both", module, id, e.as, catalogue.TakesSettingsField))
|
||
}
|
||
seen[e.key], landed[e.as] = true, true
|
||
return problems
|
||
}
|
||
|
||
type takenEntry struct {
|
||
key, as string
|
||
renamed bool
|
||
}
|
||
|
||
// cleanFrom is an artifact's directory as one relative path: "./files/" is "files".
|
||
func cleanFrom(from string) string {
|
||
c := path.Clean(strings.TrimSpace(from))
|
||
return strings.TrimPrefix(c, "./")
|
||
}
|
||
|
||
type filler struct {
|
||
namespace string
|
||
patterns []*regexp.Regexp
|
||
fields map[string][]string
|
||
// why, when set, is the rule that keeps the namespace out of every other field, said with a
|
||
// refusal of one written there.
|
||
why string
|
||
}
|
||
|
||
var placeholder = regexp.MustCompile(`\$\{secret:([a-z0-9][a-z0-9-]*)\}`)
|
||
|
||
var ofSeat = regexp.MustCompile(`\$\{seat:([a-z0-9][a-z0-9-]*):([0-9]+)\}`)
|
||
|
||
var ofSeatReach = regexp.MustCompile(`\$\{seat:([a-z0-9][a-z0-9-]*):reach\}`)
|
||
|
||
var ofBackup = regexp.MustCompile(`\$\{backup:([^}]*)\}`)
|
||
|
||
func quote(v any) (string, error) {
|
||
b, err := json.Marshal(fmt.Sprint(v))
|
||
return strings.ReplaceAll(string(b), "\x7f", `\u007f`), err
|
||
}
|
||
|
||
// schema is a JSON schema for an object of string properties, which is every argument the verbs
|
||
// above take. Kept small on purpose: a schema an agent cannot read is a tool it cannot call. The
|
||
// switches are the properties that are "true" or "false" and nothing else, said in the schema as an
|
||
// enum so a caller sees it and the verb can refuse any other word rather than read it as false.
|
||
//
|
||
// **The schema is the whole of what a verb takes** (novox/hq issue 244): an argument it does not
|
||
// name is refused when the verb is called, never passed over.
|
||
func schema(properties map[string]string, required []string, switches ...string) map[string]any {
|
||
isSwitch := map[string]bool{}
|
||
for _, s := range switches {
|
||
if _, declared := properties[s]; !declared {
|
||
panic("a switch that is not a property: " + s)
|
||
}
|
||
isSwitch[s] = true
|
||
}
|
||
props := map[string]any{}
|
||
for name, description := range properties {
|
||
p := map[string]any{"type": "string", "description": description}
|
||
if isSwitch[name] {
|
||
p["enum"] = []string{"true", "false"}
|
||
}
|
||
props[name] = p
|
||
}
|
||
out := map[string]any{"type": "object", "properties": props}
|
||
if len(required) > 0 {
|
||
out["required"] = required
|
||
}
|
||
return out
|
||
}
|
||
|
||
func vaultIntake(first map[string]string) map[string]string {
|
||
out := map[string]string{"call": "the second step: the call the first step answered",
|
||
"sealed": "the second step: the value sealed to the call's key — never the value"}
|
||
for k, v := range first {
|
||
out[k] = v
|
||
}
|
||
return out
|
||
}
|
||
|
||
// vaultIntake is a two-step vault verb's arguments: what its first step names, then the call and what was sealed.
|
||
// withHolders adds to `issued` the provision's holders from that provider (issue 749): a provider's tool may answer a
|
||
// value for each, which the vault keeps as that holder's own and hands to it alone.
|
||
func withHolders(input map[string]any) map[string]any {
|
||
props, _ := input["properties"].(map[string]any)
|
||
props["holders"] = map[string]any{"type": "array",
|
||
"description": "the provision's holders from that provider, each {node, module}: its tool may answer a value " +
|
||
"for each, kept as that holder's own and handed to it alone",
|
||
"items": map[string]any{"type": "object", "properties": map[string]any{
|
||
"node": map[string]any{"type": "string", "description": "the holder's machine"},
|
||
"module": map[string]any{"type": "string", "description": "the consuming module"}}}}
|
||
return input
|
||
}
|
||
|
||
// configInput is the schema of mesh-config's verb: a value of the key's own type (a number for a number, never its
|
||
// text), and a limit that is a number; every other argument a string.
|
||
func configInput() map[string]any {
|
||
in := schema(map[string]string{"action": "list (when not given), set, clear or history",
|
||
"key": "the config key", "value": "for set: the value, of the key's type",
|
||
"module": "a module: its own value, with node", "node": "a node",
|
||
"why": "for set and clear: why", "by": "who gave the value at the controller's terminal",
|
||
"limit": "for history: at most this many, newest"}, nil)
|
||
props := in["properties"].(map[string]any)
|
||
props["value"] = map[string]any{"description": "for set: the value, of the key's type: a number, a string, " +
|
||
"a boolean, or for a trusted key an object or a list"}
|
||
props["limit"] = map[string]any{"type": "number", "description": "for history: at most this many, newest"}
|
||
return in
|
||
}
|
||
|
||
const healthDecision = "novox/hq ADR 0564"
|
||
|
||
const controlRecordsDecision = "novox/hq ADR 0565"
|
||
|
||
// listed makes the named properties of a schema lists of text: a caller gives them as a JSON array (or, as
|
||
// any argument, one text separated by commas).
|
||
func listed(in map[string]any, names ...string) map[string]any {
|
||
props, _ := in["properties"].(map[string]any)
|
||
for _, n := range names {
|
||
p, _ := props[n].(map[string]any)
|
||
if p == nil {
|
||
panic("a list that is not a property: " + n)
|
||
}
|
||
props[n] = map[string]any{"type": "array", "items": map[string]any{"type": "string"},
|
||
"description": p["description"]}
|
||
}
|
||
return in
|
||
}
|
||
|
||
const filesAsAccount = "as the node's operator account, through sudo -n where its rights do not reach; a place a " +
|
||
"secret is kept, or a known credential file, is refused by name"
|
||
|
||
const overrideWord = "a reason, to change a path a module owns anyway (optional)"
|
||
|
||
// withEnum narrows one string property of a schema to the values it may take, so a caller is told
|
||
// the choices by the schema rather than by a refusal.
|
||
func withEnum(s map[string]any, property string, values ...string) map[string]any {
|
||
props := s["properties"].(map[string]any)
|
||
p := props[property].(map[string]any)
|
||
p["enum"] = values
|
||
return s
|
||
}
|
||
|
||
// barBlockShape is the shape of a block (novox/hq ADR 0255): which bar, where on it, in what order,
|
||
// what it shows, and that kind's options.
|
||
//
|
||
// - bar: top or bottom;
|
||
// - place: resources (beside the processor, memory and disks) or status (beside the sound, before the
|
||
// clock) — the two places every holder keeps for contributed blocks;
|
||
// - order: 0–99 within the place, 50 when absent;
|
||
// - shows: battery or command; options: what that kind takes.
|
||
func barBlockShape() *catalogue.Shape {
|
||
return &catalogue.Shape{
|
||
Text: true,
|
||
Places: []catalogue.PlaceField{{Field: "bar", Values: []string{"bottom", "top"}},
|
||
{Field: "place", Values: []string{"resources", "status"}}},
|
||
Order: "order",
|
||
Check: barBlockProblems,
|
||
Reads: barBlockReads,
|
||
Examples: []map[string]any{
|
||
{"bar": "bottom", "place": "status", "shows": "battery"},
|
||
{"bar": "bottom", "place": "status", "shows": "battery", "options": map[string]any{"device": "BAT0"}},
|
||
{"bar": "bottom", "place": "status", "shows": "command",
|
||
"options": map[string]any{"command": "echo \"a line\"", "interval": float64(5)}},
|
||
{"bar": "bottom", "place": "status", "shows": "state", "options": map[string]any{"state": "power.draw"}},
|
||
{"bar": "bottom", "place": "status", "shows": "state",
|
||
"options": map[string]any{"state": "power.draw", "key": "a-machine"}},
|
||
},
|
||
}
|
||
}
|
||
|
||
func barBlockProblems(data map[string]any) []string {
|
||
var problems []string
|
||
for field := range data {
|
||
if !oneOf([]string{"bar", "place", "order", "shows", "options"}, field) {
|
||
problems = append(problems, fmt.Sprintf("a block has no field %q; it has bar, place, order, shows and options", field))
|
||
}
|
||
}
|
||
shows, _ := data["shows"].(string)
|
||
takes, known := barShows[shows]
|
||
if !known {
|
||
return append(problems, fmt.Sprintf("a block shows %v; it shows one of %s", data["shows"],
|
||
strings.Join(sortedKeys(barShows), ", ")))
|
||
}
|
||
options := map[string]any{}
|
||
if raw, has := data["options"]; has {
|
||
o, ok := raw.(map[string]any)
|
||
if !ok {
|
||
return append(problems, "a block's options are an object")
|
||
}
|
||
options = o
|
||
}
|
||
for name, v := range options {
|
||
opt, ok := takes[name]
|
||
if !ok {
|
||
problems = append(problems, fmt.Sprintf("a %s block has no option %q; it takes %s", shows, name,
|
||
orNothing(sortedKeys(takes))))
|
||
continue
|
||
}
|
||
switch opt.kind {
|
||
case "text":
|
||
if t, isText := v.(string); !isText || strings.TrimSpace(t) == "" {
|
||
problems = append(problems, fmt.Sprintf("a %s block's %s is text", shows, name))
|
||
}
|
||
case "seconds":
|
||
if _, whole := wholeIn(v, 1, 86400); !whole {
|
||
problems = append(problems, fmt.Sprintf("a %s block's %s is a whole number of seconds from 1 to 86400", shows, name))
|
||
}
|
||
}
|
||
}
|
||
for _, name := range sortedKeys(takes) {
|
||
if _, has := options[name]; takes[name].required && !has {
|
||
problems = append(problems, fmt.Sprintf("a %s block needs the option %s", shows, name))
|
||
}
|
||
}
|
||
return problems
|
||
}
|
||
|
||
// barBlockReads is the state and key a block shows, if any; an empty key is the machine's own.
|
||
func barBlockReads(data map[string]any) []catalogue.StateRead {
|
||
if data["shows"] != "state" {
|
||
return nil
|
||
}
|
||
options, _ := data["options"].(map[string]any)
|
||
s, ok := options["state"].(string)
|
||
if !ok || s == "" {
|
||
return nil
|
||
}
|
||
key, _ := options["key"].(string)
|
||
return []catalogue.StateRead{{Read: s, Key: key}}
|
||
}
|
||
|
||
var barShows = map[string]map[string]barOption{
|
||
// The machine's battery: its charge, and how long it lasts or takes to fill.
|
||
"battery": {"device": {kind: "text"}},
|
||
// One line a command prints, run every interval seconds: what the bar cannot know itself.
|
||
"command": {"command": {kind: "text", required: true}, "interval": {kind: "seconds", required: true}},
|
||
// A value the contributor keeps on the bus (novox/hq ADR 0255): its state `<module>.<name>` and the
|
||
// key, this machine's name when absent. The value is an object whose `text` is what is shown. The
|
||
// holder is granted to read it and shows it however its bar shows a changing value; no file and
|
||
// no command passes between the two modules.
|
||
"state": {"state": {kind: "text", required: true}, "key": {kind: "text"}},
|
||
}
|
||
|
||
type barOption struct {
|
||
kind string // text, or seconds (a whole number from 1 to 86400)
|
||
required bool
|
||
}
|
||
|
||
func first(a, _ []string) []string { return a }
|
||
func second(_, b []string) []string { return b }
|
||
|
||
// reservedStreamPrefixes are the starts of the stream names the mesh derives, which no module's stream may take (moved
|
||
// from mesh-sdk's catalogue, ReservedStreamPrefixes: a rule's table is the judge's, novox/hq ADR 0578 §4).
|
||
var reservedStreamPrefixes = []string{"SEAT_", "LOG_", "KV_", "OBJ_", "DEAD_"}
|
||
|
||
// ObjectsProblems is what is wrong with a manifest's own object buckets, judged on the one manifest (novox/hq ADR 0592
|
||
// §5, §9, §25): each named locally as a state is, once, never a name its own state takes, its name on the bus taking
|
||
// none of the prefixes the mesh derives names with, its size between catalogue.ObjectsLeastMiB and ObjectsMostMiB (no
|
||
// default), and a grant only by a trusted config value of the module.
|
||
func ObjectsProblems(m catalogue.Manifest) []string {
|
||
if len(m.Objects) == 0 {
|
||
return nil
|
||
}
|
||
var problems []string
|
||
if !stateName.MatchString(m.Module) {
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s keeps an object bucket, and a module's name is part of its buckets' names, which take one plain name — "+
|
||
"no dot (novox/hq ADR 0592 §5)", m.Module))
|
||
}
|
||
seen := map[string]bool{}
|
||
states := map[string]bool{}
|
||
for _, st := range m.State {
|
||
states[st.Name] = true
|
||
}
|
||
terminal := catalogue.TerminalKeys(m)
|
||
for _, o := range m.Objects {
|
||
switch {
|
||
case !stateName.MatchString(o.Name):
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s keeps object bucket %q: an object bucket is named locally — lower-case letters, digits and hyphens, "+
|
||
"no dot and no underscore; the mesh derives the bucket (novox/hq ADR 0592 §5)", m.Module, o.Name))
|
||
case seen[o.Name]:
|
||
problems = append(problems, fmt.Sprintf("%s keeps object bucket %q twice: one bucket per name (novox/hq ADR 0592 §5)", m.Module, o.Name))
|
||
case states[o.Name]:
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s keeps both a state and an object bucket %q: a read names one of them, so each name is one (novox/hq "+
|
||
"ADR 0592 §9)", m.Module, o.Name))
|
||
default:
|
||
for _, prefix := range reservedStreamPrefixes {
|
||
if strings.HasPrefix(strings.ToUpper(o.Bucket(m.Module)), prefix) {
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s keeps object bucket %q, whose name on the bus %s starts with %s, which the mesh derives names "+
|
||
"with (novox/hq ADR 0592 §5)", m.Module, o.Name, o.Bucket(m.Module), prefix))
|
||
}
|
||
}
|
||
}
|
||
seen[o.Name] = true
|
||
if o.MaxMiB < catalogue.ObjectsLeastMiB || o.MaxMiB > catalogue.ObjectsMostMiB {
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s caps object bucket %q at %d MiB; an object bucket says its size, between %d and %d MiB (novox/hq ADR "+
|
||
"0592 §5, §25)", m.Module, o.Name, o.MaxMiB, catalogue.ObjectsLeastMiB, catalogue.ObjectsMostMiB))
|
||
}
|
||
if g := o.Grant; g != nil && !slices.Contains(terminal, g.Config) {
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s's object bucket %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 reads is the "+
|
||
"operator's (novox/hq ADR 0348 §8, ADR 0592 §9)", m.Module, o.Name, g.Config))
|
||
}
|
||
}
|
||
return problems
|
||
}
|
||
|
||
// ObjectsCatalogueProblems are the rules of object buckets no single manifest can be judged against (novox/hq ADR 0592
|
||
// §5, §9): a bucket's name on the bus has one owner, never another module's stream or bucket; and a module that reads
|
||
// another's bucket (`reads` naming it) reads it only through the owner's grant, a bucket with none being read by its
|
||
// owner alone. Which modules the grant's value names is the operator's, set at the controller's terminal, and checked
|
||
// where the read is granted, not here.
|
||
func ObjectsCatalogueProblems(shelf catalogue.Shelf) []string {
|
||
var problems []string
|
||
owner := map[string]string{} // a name on the bus, upper-cased, to the module declaring it
|
||
for _, module := range shelfOrder(shelf) {
|
||
for _, s := range shelf[module].Streams {
|
||
if _, taken := owner[strings.ToUpper(s.BusName())]; !taken {
|
||
owner[strings.ToUpper(s.BusName())] = module
|
||
}
|
||
}
|
||
}
|
||
for _, module := range shelfOrder(shelf) {
|
||
for _, o := range shelf[module].Objects {
|
||
name := strings.ToUpper(o.Bucket(module))
|
||
if first, taken := owner[name]; taken && first != module {
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s keeps object bucket %s, a name %s already takes on the bus; an object bucket's name has one owner "+
|
||
"(novox/hq ADR 0592 §5)", module, o.Bucket(module), first))
|
||
continue
|
||
}
|
||
owner[name] = module
|
||
}
|
||
}
|
||
for _, module := range shelfOrder(shelf) {
|
||
for _, r := range shelf[module].Reads {
|
||
from, local, err := catalogue.ReadState(r)
|
||
if err != nil {
|
||
continue // StateProblems says why
|
||
}
|
||
o, ok := shelf[from].ObjectsNamed(local)
|
||
if !ok || from == module || o.Grant != nil {
|
||
continue
|
||
}
|
||
problems = append(problems, fmt.Sprintf(
|
||
"%s reads %s's object bucket %q, which grants no reader: an object bucket is read by another module only "+
|
||
"through its owner's grant, a trusted config value naming the reader (novox/hq ADR 0592 §9)", module, from, local))
|
||
}
|
||
}
|
||
return problems
|
||
}
|